/* ============================================================
   Lumen — design system
   Dark-first, luminous, immersive. Chrome recedes; light leads.
   ============================================================ */

:root {
  /* surface */
  --bg-0: #000000;
  --bg-1: #0b0b14;
  --ink: #edecf5;
  --ink-dim: #a6a4bd;
  --ink-faint: #6f6d87;

  /* glass chrome */
  --glass: rgba(16, 16, 26, 0.55);
  --glass-strong: rgba(14, 14, 24, 0.82);
  --hair: rgba(255, 255, 255, 0.08);
  --hair-2: rgba(255, 255, 255, 0.14);

  /* luminous UI accent (independent of art palette) */
  --accent: #9d8bff;
  --accent-soft: rgba(157, 139, 255, 0.16);

  /* motion */
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);

  /* safe areas */
  --sat: env(safe-area-inset-top, 0px);
  --sab: env(safe-area-inset-bottom, 0px);
  --sal: env(safe-area-inset-left, 0px);
  --sar: env(safe-area-inset-right, 0px);

  --dock-radius: 22px;

  /* HOW FAR THE BOTTOM CLUSTER SITS OFF THE GLASS.
     On a notched iPhone --sab is 34px, so the old "+ 18px" put the dock 52px up from the
     physical bottom. The home indicator only occupies the bottom ~21px, which left about
     31px of nothing under a dock that is supposed to read as docked. + 8px puts it at 42px,
     roughly a finger's width clear of the indicator, which is where a floating control
     surface belongs. The max() floor is for the phones and desktops that report no inset at
     all, where 8px alone would look glued to the edge. */
  --dock-lift: max(12px, calc(var(--sab) + 8px));
  /* Height of the dock, so the quick row can park directly on top of it. app.js measures the
     real box and overwrites this on load. The literal here is only the fallback for the case
     where that JS is gone, and it is deliberately the measured value at time of writing. */
  --dock-h: 76px;
  --quick-gap: 14px;

  font-synthesis: none;
  -webkit-tap-highlight-color: transparent;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

html, body {
  height: 100%;
  width: 100%;
  overflow: hidden;
  background: var(--bg-0);
  color: var(--ink);
  font-family: "Manrope", ui-sans-serif, system-ui, -apple-system, sans-serif;
  overscroll-behavior: none;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

/* ---------- canvas stack ---------- */
.stage { position: fixed; inset: 0; }
.layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.input-layer { touch-action: none; cursor: crosshair; z-index: 5; }
/* Paper palettes lay pigment instead of light, so the art and dust layers have to darken the
   sheet under them rather than sit on top of it. The engine adds .paper when one is picked. */
.layer.paper { mix-blend-mode: multiply; }

/* The cross-eye 3D pair. Hidden until the engine turns it on (inline display, engine-owned).
   While the pair is up the real breath overlay is hidden — its canvas is composited INTO the
   halves instead, so the greeting ring appears inside the 3D scene rather than flat on top.
   The pair itself never blends: it is an opaque, finished picture. */
#stereo3d { display: none; mix-blend-mode: normal; }
body.stereo3d #breath { visibility: hidden; }

/* The depth dial under the 3D toggle. Native range input, tinted to the app. */
.depth-row { margin: 2px 0 14px; }
.depth-row .row-sub { margin-bottom: 6px; }
.depth-row input[type="range"] {
  width: 100%;
  min-width: 0;
  accent-color: var(--accent);
  touch-action: pan-x;   /* the sheet scrolls vertically; a horizontal thumb must not fight it */
}

/* Bloom, two radii.
   filter: blur() must live on the ELEMENT, not on the 2D context: ctx.filter is unsupported in
   Safari through 17 and off by default in 18 and later, including every iPhone.
   translateZ(0) keeps the blur on the GPU (without it Safari falls back to the CPU).
   Never animate the radius, that re-renders the filter every frame. Opacity is free to animate,
   so the engine breathes the bloom by opacity alone.

   THE RADIUS IS THE WHOLE GAME. CSS blur(v) is a Gaussian with stdDeviation v, so blur(5px)
   convolved with a 2px-wide mark keeps only about 2/(5*2.5) = 16% of its peak. That is why the
   old single 5px pass could never make a specular and could only ever make haze: it was wide
   enough to erase exactly the thing it was supposed to intensify. 2.5px keeps roughly a third
   of a thin core, which is a highlight. The 20px pass is not trying to keep a core at all — it
   is integrating whole clusters of marks into the air around them, which is why it is fed from
   a CUBED copy and does nothing at all around a lone filament.
   The opacities here are only what the layers look like before the first frame: the engine
   writes an inline opacity every frame so the bloom can breathe. An inline style beats a
   media query, so the reduced-transparency case is honoured in _drawGlow, not here. */
#glow {
  filter: blur(2.5px);
  mix-blend-mode: screen;
  opacity: 0.62;
  transform: translateZ(0);
  will-change: opacity;
  pointer-events: none;
}
#glowWide {
  filter: blur(20px);
  mix-blend-mode: screen;
  opacity: 0.62;
  transform: translateZ(0);
  will-change: opacity;
  pointer-events: none;
}
/* Ink on a sheet does not glow. */
#glow.paper, #glowWide.paper { display: none; }

/* Film grain. A tiled SVG fractal-noise texture, generated by the browser, so it costs no
   bytes and no per-frame JS. Smooth dark gradients on an 8-bit canvas band into visible
   rings, and dither is the standard fix: the noise breaks the banding up into texture.
   It also stops the piece reading as flat digital plastic.
   The animation nudges the tile around instead of regenerating noise, so it stays cheap. */
#grain {
  position: absolute;
  inset: -60px;                       /* overscan, so the drift never exposes an edge */
  pointer-events: none;
  z-index: 4;
  opacity: 0.13;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain-drift 700ms steps(1) infinite;
  will-change: transform;
  /* The grain has to stop before the frame does. Measured on a Midnight Aurora corner: the
     ground on its own renders (0.3, 0.7, 1.0) and the grain over it renders (2.5, 3.6, 4.8),
     so the dither was most of what was left between the piece and true black. Overlay is
     proportional below mid grey (2 * base * noise) and in theory cannot lift a black pixel at
     all, but it is composited in 8 bits and each pass rounds. Dither is only worth anything
     where there is a gradient to band, and past about 80% of the radius the ground is already
     flat zero, so mask the noise away out there and let the black be black. */
  -webkit-mask-image: radial-gradient(75% 55% at 50% 46%, #000 0%, #000 55%, rgba(0,0,0,0) 100%);
  mask-image: radial-gradient(75% 55% at 50% 46%, #000 0%, #000 55%, rgba(0,0,0,0) 100%);
}
@keyframes grain-drift {
  0%   { transform: translate(0, 0); }
  25%  { transform: translate(-13px, 7px); }
  50%  { transform: translate(9px, -11px); }
  75%  { transform: translate(-6px, -5px); }
  100% { transform: translate(0, 0); }
}
/* On paper the grain reads as tooth in the sheet, so it multiplies instead of overlaying —
   and tooth runs edge to edge, so the dark-palette mask comes off. There is no black to
   protect on a sheet, only a surface to give texture to. */
body.paper #grain {
  mix-blend-mode: multiply;
  opacity: 0.09;
  -webkit-mask-image: none;
  mask-image: none;
}
@media (prefers-reduced-motion: reduce) { #grain { animation: none; } }

/* ---------- top bar ---------- */
.topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: calc(var(--sat) + 16px) calc(var(--sar) + 20px) 12px calc(var(--sal) + 22px);
  pointer-events: none;
  transition: opacity 0.6s var(--ease);
  background: linear-gradient(to bottom, rgba(4,4,9,0.55), rgba(4,4,9,0));
}
.wordmark {
  font-family: "Syne", sans-serif;
  font-weight: 800;
  font-size: 20px;
  letter-spacing: 0.02em;
  color: var(--ink);
  opacity: 0.72;
  text-shadow: 0 0 18px rgba(157,139,255,0.25);
  /* the topbar is click-through; the name alone takes taps again, because triple-tapping it
     toggles the frame meter (see setupMeter in app.js). A tap here no longer draws, which is
     a corner the mandala barely reaches anyway. */
  pointer-events: auto;
}
.mode-label {
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-dim);
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--glass);
  border: 1px solid var(--hair);
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
}

/* ---------- bottom dock ---------- */
.dock {
  position: fixed;
  z-index: 20;
  left: 50%;
  transform: translateX(-50%);
  bottom: var(--dock-lift);
  display: flex;
  align-items: stretch;
  gap: 2px;
  padding: 8px;
  border-radius: var(--dock-radius);
  background: var(--glass-strong);
  border: 1px solid var(--hair);
  backdrop-filter: blur(22px) saturate(150%);
  -webkit-backdrop-filter: blur(22px) saturate(150%);
  box-shadow: 0 18px 50px -12px rgba(0,0,0,0.7), inset 0 1px 0 rgba(255,255,255,0.06);
  transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
  max-width: calc(100vw - 24px);
}
.dock-btn {
  appearance: none;
  border: 0;
  background: transparent;
  color: var(--ink-dim);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 8px 12px;
  min-width: 60px;
  border-radius: 15px;
  cursor: pointer;
  transition: color 0.25s var(--ease), background 0.25s var(--ease), transform 0.15s var(--ease);
  font-family: inherit;
}
.dock-btn:hover { color: var(--ink); }
.dock-btn:active { transform: scale(0.94); }
.dock-btn.active { color: var(--ink); background: var(--accent-soft); }
.dock-btn .ico { width: 24px; height: 24px; }
.dock-label {
  font-size: 10.5px;
  letter-spacing: 0.05em;
  font-weight: 500;
  white-space: nowrap;
}
.swatch {
  width: 24px; height: 24px;
  border-radius: 8px;
  background: linear-gradient(135deg, #34e7c3, #7f9cf5, #b794f4);
  box-shadow: 0 0 14px rgba(127,156,245,0.5), inset 0 0 0 1px rgba(255,255,255,0.25);
}

/* ---------- quick actions ----------
   ON A PHONE THIS IS A ROW SITTING ON THE DOCK, NOT A COLUMN CLIMBING THE RIGHT EDGE.

   It used to be four 46px circles stacked at right:16, bottom: sab + 116. Measured on a real
   390x844 viewport with the iPhone insets applied, that column ran from y480 to y694: it
   started 58px BELOW the vertical centre and covered the whole lower-right of a mandala that
   is drawn about the middle of the screen. The art is the product, and this put four opaque
   blobs on it. On the paper palettes, where the buttons are dark glass on a pale sheet, they
   read as spilled ink.

   Worse, the bottom of that column (undo, y682-728) overlapped the top-right corner of the
   dock (y716). Two floating clusters colliding in the same corner is what makes the bottom of
   this app read as crowded and shoved upward, which is what the owner was pointing at.

   As a row on top of the dock the same four controls take 46px of a band that was already
   chrome instead of 214px of the picture, the collision cannot happen because the row's
   position is derived from the dock's own measured height, and the whole of the art from the
   top bar down to the dock is one clean field.

   Centred rather than right-aligned on purpose: the dock is centred, the mandala is centred,
   and a rail hanging off one edge reads as an accident next to those. Reach is not lost. The
   row spans x82 to x308 at y660, comfortably inside a right thumb's arc, and undo stays the
   right-most button, which is the easiest one of the four to hit. */
.quick {
  position: fixed;
  z-index: 20;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(var(--dock-lift) + var(--dock-h) + var(--quick-gap));
  display: flex;
  flex-direction: row;
  gap: var(--quick-gap);
  transition: opacity 0.5s var(--ease);
}
/* Save — the one control on this rail that MAKES something. It borrows the app's existing
   "this is lit" treatment (the accent hairline and halo of .quick-btn.active) rather than
   inventing a louder colour, so it reads as the call to action without shouting over the art.
   It carries a word because an icon alone is what got missed the first time.

   THE ACCENT IS A LAYER OVER DARK GLASS, not a fill on its own. Tried as a bare accent-soft
   wash first, and on the three Paper palettes that is a pale lavender tint over a near-white
   sheet with white type on it: invisible. The rest of the chrome already solved this by
   staying dark glass over paper (see the paper-mode block near the bottom of this file), so
   this sits on the same glass and lets the accent be the light on top of it. */
.save-btn {
  appearance: none;
  display: flex; align-items: center; gap: 7px;
  height: 46px; padding: 0 17px 0 14px;
  border-radius: 999px;
  border: 1px solid rgba(157,139,255,0.5);
  background: linear-gradient(180deg, rgba(157,139,255,0.34), rgba(157,139,255,0.17)), var(--glass);
  color: #fff;
  font-family: inherit;
  font-size: 13.5px; font-weight: 600; letter-spacing: 0.02em;
  white-space: nowrap;
  cursor: pointer;
  backdrop-filter: blur(16px) saturate(140%);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  box-shadow: 0 0 22px rgba(157,139,255,0.35), inset 0 1px 0 rgba(255,255,255,0.1);
  transition: transform 0.15s var(--ease), box-shadow 0.25s var(--ease), background 0.25s var(--ease);
}
.save-btn .ico { width: 20px; height: 20px; }
.save-btn:hover { background: linear-gradient(180deg, rgba(157,139,255,0.44), rgba(157,139,255,0.26)), var(--glass); }
.save-btn:active {
  transform: scale(0.93);
  box-shadow: 0 0 10px rgba(157,139,255,0.22), inset 0 1px 0 rgba(255,255,255,0.1);
}
.quick-btn {
  appearance: none;
  width: 46px; height: 46px;
  border-radius: 50%;
  border: 1px solid var(--hair);
  background: var(--glass);
  color: var(--ink-dim);
  display: grid;
  place-items: center;
  cursor: pointer;
  backdrop-filter: blur(16px) saturate(140%);
  -webkit-backdrop-filter: blur(16px) saturate(140%);
  transition: color 0.25s var(--ease), background 0.25s var(--ease), transform 0.15s var(--ease), box-shadow 0.25s var(--ease);
}
.quick-btn .ico { width: 22px; height: 22px; }
.quick-btn:hover { color: var(--ink); }
.quick-btn:active { transform: scale(0.9); }
.quick-btn.active {
  color: #fff;
  background: var(--accent-soft);
  border-color: rgba(157,139,255,0.5);
  box-shadow: 0 0 22px rgba(157,139,255,0.35);
}

/* dim the chrome while drawing */
body.drawing .topbar,
body.drawing .dock,
body.drawing .quick { opacity: 0.18; }

/* fully idle: fade chrome gently */
body.idle .topbar { opacity: 0.4; }

/* ---------- immersive ("zen") ----------
   In Auto the app is drawing for you, so every control on screen is nothing but competition
   for the art. The chrome leaves and one tap anywhere brings it back, which is the contract
   every video player has already taught people.

   SLOW OUT, QUICK IN. The exit is a mood and can take its time; the return is the answer to
   a tap and has to feel instant, or the tap reads as ignored. body.immersive means the
   feature is engaged, so it carries the quick transition. body.zen means the chrome is
   currently gone, and it overrides with the slow one. Adding .zen therefore fades out over
   1.2s and removing it comes back in 0.3s, off one pair of rules.

   The breath cue is deliberately NOT in this list. Breathing is the whole point of the mode.

   These rules have to sit AFTER .drawing and .idle: those dim the same three elements and
   would otherwise win on source order at equal specificity. */
body.immersive .topbar,
body.immersive .dock,
body.immersive .quick {
  transition: opacity 0.3s var(--ease-out), transform 0.3s var(--ease-out);
}
body.immersive.zen .topbar,
body.immersive.zen .dock,
body.immersive.zen .quick {
  opacity: 0;
  pointer-events: none;
  transition: opacity 1.2s var(--ease), transform 1.2s var(--ease);
}
/* A little travel with the fade so it reads as leaving rather than as a bulb going out.
   The dock already carries translateX(-50%) to centre itself, so its zen transform has to
   restate it or the dock slides off to the left on the way out. */
body.immersive.zen .topbar { transform: translateY(-8px); }
body.immersive.zen .dock { transform: translateX(-50%) translateY(14px); }
/* translateX(-50%) FIRST, always. .quick is centred with translateX(-50%) on a phone, and a
   bare translateX here replaces that wholesale instead of adding to it, which slid the whole
   row 147px off the right edge of the screen in zen and made it visibly fly back in from
   off-screen every single time the chrome returned. It merged with zero git conflicts, which
   is how a rule like this gets through: nothing is textually wrong with either half. */
body.immersive.zen .quick { transform: translateX(-50%) translateX(12px); }
@media (min-width: 768px) {
  /* From 768px up .quick is a right-hand column with no centring transform to preserve. */
  body.immersive.zen .quick { transform: translateX(12px); }
}

/* The one control left on a bare screen. Low contrast enough to ignore while you breathe,
   present enough that the screen never looks dead. It fades in as the dock fades out and
   sits inside the dock's old footprint, so the two read as one thing settling. */
.zen-exit {
  position: fixed;
  z-index: 21;
  left: 50%;
  bottom: calc(var(--sab) + 26px);
  transform: translateX(-50%);
  appearance: none;
  cursor: pointer;
  font-family: inherit;
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.4);
  padding: 10px 20px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.09);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  opacity: 0;
  visibility: hidden;
  /* Slow in, QUICK out, which is the opposite of the chrome and for a reason. The pill sits
     inside the dock's own footprint so the two crossfade as one object. Measured at 500ms
     after a tap: on a symmetric 0.9s the pill was still at 0.13 with the dock already back at
     1, so for half a second the words EXIT AUTO were printed across the live dock. 0.25s out
     against the dock's 0.3s in is a clean crossfade instead.
     transform is left OUT of both fades on purpose: it is what the press uses, and a pressed
     state that takes a second to answer does not feel like a button at all. */
  transition: opacity 0.25s var(--ease-out), visibility 0.25s var(--ease-out),
              color 0.25s var(--ease), transform 0.14s var(--ease);
}
body.immersive.zen .zen-exit {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.9s var(--ease), visibility 0.9s var(--ease),
              color 0.25s var(--ease), transform 0.14s var(--ease);
}
.zen-exit:active { transform: translateX(-50%) scale(0.94); color: rgba(255, 255, 255, 0.75); }
/* the timelapse takes the whole screen for itself and ships its own Done button */
body.tl-on .zen-exit { opacity: 0; visibility: hidden; pointer-events: none; }

/* ---------- breath cue ---------- */
.breath-cue {
  position: fixed;
  z-index: 15;
  left: 0; right: 0;
  top: 50%;
  transform: translateY(-50%);
  text-align: center;
  font-family: "Syne", sans-serif;
  font-weight: 600;
  font-size: clamp(20px, 6vw, 30px);
  letter-spacing: 0.16em;
  color: rgba(255,255,255,0.85);
  text-transform: lowercase;
  opacity: 0;
  transition: opacity 1.1s var(--ease);
  pointer-events: none;
  text-shadow: 0 0 30px rgba(255,255,255,0.3);
}
.breath-cue.show { opacity: 0.9; }

/* ---------- sheet ---------- */
.scrim {
  position: fixed; inset: 0; z-index: 40;
  background: rgba(3,3,8,0.5);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
  opacity: 0;
  transition: opacity 0.35s var(--ease);
}
.scrim.show { opacity: 1; }
.sheet {
  position: fixed;
  z-index: 41;
  left: 50%;
  bottom: 0;
  transform: translate(-50%, 100%);
  width: min(520px, 100vw);
  padding: 10px 22px calc(var(--sab) + 26px);
  background: var(--glass-strong);
  border: 1px solid var(--hair);
  border-bottom: 0;
  border-radius: 26px 26px 0 0;
  backdrop-filter: blur(30px) saturate(150%);
  -webkit-backdrop-filter: blur(30px) saturate(150%);
  box-shadow: 0 -20px 60px -20px rgba(0,0,0,0.8);
  transition: transform 0.42s var(--ease-out);
  /* never taller than the screen: a full sheet used to run off the top with nothing to scroll,
     so on a phone the first controls were unreachable */
  max-height: 88vh;
  max-height: 88dvh;   /* follows the real visible height as a phone's address bar comes and goes */
  display: flex;
  flex-direction: column;
}
.sheet.show { transform: translate(-50%, 0); }

/* ---------- the way out of a sheet ----------
   Tapping the scrim always worked and nothing on screen ever said so. On a phone the sheet
   covers most of the reachable screen, so "tap the part you cannot see" is not an answer.
   Three exits now: the X, drag the grip down, or the scrim. Quiet enough that the art still
   leads — ink-dim on a hairline, no fill until you press it. */
.sheet-grip {
  display: block;
  width: 100%;
  /* The bar is 4px but the GRAB is 40px tall and full width, which is what a thumb needs.
     The negative top margin eats the sheet's own 10px of top padding so the target starts at
     the very edge of the sheet, where a thumb actually lands, and the bar still draws in
     exactly the same place it always did. Height for free. */
  margin-top: -10px;
  padding: 21px 0 15px;
  cursor: grab;
  touch-action: none;
}
.sheet-grip-bar {
  display: block;
  width: 42px; height: 4px; border-radius: 999px;
  background: var(--hair-2);
  margin: 0 auto;
  transition: background 0.25s var(--ease), transform 0.2s var(--ease);
}
.sheet-grip:active .sheet-grip-bar,
.sheet-grip.grabbing .sheet-grip-bar {
  background: rgba(255, 255, 255, 0.34);
  transform: scaleX(1.2);
}
.sheet-grip.grabbing { cursor: grabbing; }

.sheet-head {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  margin-bottom: 16px;
}
.sheet-title {
  font-family: "Syne", sans-serif;
  font-weight: 700;
  font-size: 17px;
  letter-spacing: 0.02em;
  color: var(--ink);
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sheet-nav {
  appearance: none;
  flex: 0 0 auto;
  min-width: 44px; min-height: 44px;
  border-radius: 999px;
  border: 1px solid var(--hair);
  background: rgba(255, 255, 255, 0.045);
  color: var(--ink-dim);
  font-family: inherit; font-size: 13.5px; font-weight: 600;
  display: inline-flex; align-items: center; justify-content: center; gap: 3px;
  cursor: pointer;
  transition: color 0.25s var(--ease), background 0.25s var(--ease),
              border-color 0.25s var(--ease), transform 0.15s var(--ease);
}
/* display on .sheet-nav would otherwise beat the hidden attribute and the Back button would
   sit there on every root sheet, pointing at nothing */
.sheet-nav[hidden] { display: none; }
.sheet-nav .ico { width: 20px; height: 20px; }
.sheet-nav:hover { color: var(--ink); background: rgba(255, 255, 255, 0.09); }
.sheet-nav:active {
  transform: scale(0.92);
  color: var(--ink);
  background: var(--accent-soft);
  border-color: rgba(157, 139, 255, 0.45);
}
.sheet-back { padding: 0 15px 0 9px; letter-spacing: 0.01em; }
/* the grip and title stay put, only the controls scroll */
.sheet-body {
  display: flex; flex-direction: column; gap: 18px;
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overscroll-behavior: contain;
  /* A SETTINGS SHEET MUST NEVER SCROLL SIDEWAYS. The grid above is fixed properly at its cause,
     but this is the invariant: nothing in a sheet is ever meant to be off to the right, so if
     something overflows again it gets clipped rather than turning the whole panel into a
     carousel with the labels sliding off the edge. */
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: thin;
  /* room so a card's focus ring and :active scale are not clipped by the scroll edge */
  padding: 2px 2px 4px;
  margin: -2px -2px -4px;
}

/* option grid (modes / palettes) */
.opt-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.opt {
  appearance: none;
  text-align: left;
  border: 1px solid var(--hair);
  background: rgba(255,255,255,0.03);
  color: var(--ink);
  border-radius: 16px;
  padding: 14px 15px;
  cursor: pointer;
  font-family: inherit;
  transition: border-color 0.25s var(--ease), background 0.25s var(--ease), transform 0.15s var(--ease);
  display: flex; flex-direction: column; gap: 6px;
}
.opt:active { transform: scale(0.97); }
.opt.active { border-color: rgba(157,139,255,0.55); background: var(--accent-soft); }
.opt-name { font-weight: 600; font-size: 14.5px; }
.opt-desc { font-size: 12px; color: var(--ink-dim); line-height: 1.4; }

/* palette option */
.pal-opt { position: relative; overflow: hidden; }
.pal-bar { display: flex; height: 26px; border-radius: 8px; overflow: hidden; }
.pal-bar > span { flex: 1; border-radius: 2px; }
/* an odd card left alone on the last row runs full width rather than sitting half-empty.
   Scoped to the palette grids: the Canvas, Stars and frequency grids are pinned to three
   columns inline, so their odd last card is already flush. */
@media (max-width: 767px) {
  .pal-grid > .opt:last-child:nth-child(odd) { grid-column: 1 / -1; }
}

/* segmented / stepper */
.row { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.row-label { font-size: 14px; color: var(--ink); font-weight: 500; }
.row-sub { font-size: 12px; color: var(--ink-dim); margin-top: 2px; }
.stepper { display: flex; align-items: center; gap: 4px; background: rgba(255,255,255,0.05); border-radius: 12px; padding: 4px; }
.stepper button {
  width: 38px; height: 38px; border-radius: 9px; border: 0;
  background: transparent; color: var(--ink); font-size: 20px; cursor: pointer;
  transition: background 0.2s var(--ease);
}
.stepper button:hover { background: rgba(255,255,255,0.08); }
.stepper .val { min-width: 46px; text-align: center; font-weight: 600; font-variant-numeric: tabular-nums; }

/* toggle */
/* flex: none, because a switch is a fixed-size control and every .row is a flex container:
   without it the label column's text was quietly CRUSHING every toggle in the sheet (styled
   52px, measured 36-40px, and 17px on the longest row — the knob no longer fit its own pill).
   The label side wraps; the control never gives an inch. */
.tgl { position: relative; flex: none; width: 52px; height: 30px; border-radius: 999px; background: rgba(255,255,255,0.12); border: 0; cursor: pointer; transition: background 0.3s var(--ease); }
.tgl::after { content: ""; position: absolute; top: 3px; left: 3px; width: 24px; height: 24px; border-radius: 50%; background: #fff; transition: transform 0.3s var(--ease); box-shadow: 0 2px 6px rgba(0,0,0,0.4); }
.tgl.on { background: var(--accent); }
.tgl.on::after { transform: translateX(22px); }

/* range slider */
input[type="range"] {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 4px; border-radius: 999px;
  background: rgba(255,255,255,0.14);
  outline: none;
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 22px; height: 22px; border-radius: 50%;
  background: #fff; cursor: pointer;
  box-shadow: 0 0 14px rgba(157,139,255,0.6);
}
input[type="range"]::-moz-range-thumb {
  width: 22px; height: 22px; border: 0; border-radius: 50%;
  background: #fff; cursor: pointer;
  box-shadow: 0 0 14px rgba(157,139,255,0.6);
}

/* sheet action buttons */
.sheet-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.btn {
  appearance: none; border: 1px solid var(--hair); background: rgba(255,255,255,0.04);
  color: var(--ink); font-family: inherit; font-weight: 600; font-size: 14px;
  padding: 15px; border-radius: 15px; cursor: pointer;
  display: flex; align-items: center; justify-content: center; gap: 9px;
  transition: background 0.25s var(--ease), border-color 0.25s var(--ease), transform 0.15s var(--ease);
}
.btn .ico { width: 19px; height: 19px; }
.btn:active { transform: scale(0.97); }
.btn:hover { background: rgba(255,255,255,0.08); }
.btn.primary { background: var(--accent); border-color: transparent; color: #0b0b16; }
.btn.primary:hover { filter: brightness(1.08); }
.btn.danger { color: #ff9b9b; }
.btn.full { grid-column: 1 / -1; }

.about { font-size: 13px; line-height: 1.6; color: var(--ink-dim); }
.about strong { color: var(--ink); font-weight: 600; }

/* ---------- intro ---------- */
.intro {
  position: fixed; inset: 0; z-index: 60;
  display: grid; place-items: center;
  background: radial-gradient(120% 90% at 50% 40%, rgba(11,11,20,0.4), rgba(4,4,9,0.92));
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  transition: opacity 1.1s var(--ease);
  /* 16 at the sides, not 30. The wordmark is the widest thing this app owns and it needs the
     room, see .intro-mark for the arithmetic. */
  padding: 30px 16px;
}
.intro.hide { opacity: 0; pointer-events: none; }
/* The BOX is wide enough for the wordmark; the sentences underneath are re-narrowed to a
   comfortable measure on their own. One max-width for both is what pinned the name into a
   340px hole in the first place. */
.intro-inner { text-align: center; width: 100%; max-width: 560px; }
/* CANDY, not lavender dusk. The mark used to run cream into lavender into cornflower, which
   is a beautiful gradient for an app about calm and completely wrong for this one. Six candy
   hues, the cast's own, so the wordmark and the characters waiting behind it agree. */
/* THE APP'S NAME WAS BEING CUT IN HALF, on the first screen, since the day it was renamed.
   Measured with probe=1 at a 492px viewport: "oomoomoo" set in Syne 800 renders 718px of ink
   at font-size 76, which is 9.45em. Syne ExtraBold is a very wide face and this is a very
   long word. The old rule asked for clamp(44px, 15vw, 72px) inside a 340px box, so the render
   read "oomo" and stopped. Nobody caught it because until intro=1 existed no screenshot of
   this app had ever contained the intro.

   So the size is derived from the width instead of guessed at: 9.6em of room (9.45 plus a
   hair) inside the viewport less its padding. At 390 that lands on ~37px, which sounds small
   and is not, it puts the name across 89% of the screen. Capped at 58 so a desktop does not
   get a billboard. If the wordmark ever changes, re-measure with probe=1 and move the 9.6. */
.intro-mark {
  font-family: "Syne", sans-serif; font-weight: 800;
  font-size: min(calc((100vw - 36px) / 10.1), 56px);
  letter-spacing: 0.005em;
  background: linear-gradient(104deg, #FF7AA8, #FFD666 34%, #6CE7C7 62%, #B29AFF);
  -webkit-background-clip: text; background-clip: text;
  -webkit-text-fill-color: transparent;
  filter: drop-shadow(0 0 30px rgba(255,122,168,0.42));
  animation: wobble-mark 3.4s var(--ease) infinite;
}
/* It WOBBLES, it does not breathe. A 6s inhale-and-out pulse was the calm app's signature
   move. This is a sticker on a lunchbox: a small tilt and a squash, 3.4s round trip.
   TRANSFORM ONLY. The old keyframes animated a drop-shadow, which is a filter re-run on every
   frame of a full-width text node. Well under 3Hz, like everything else in this app. */
@keyframes wobble-mark {
  0%, 100% { transform: rotate(-1.5deg) scale(1); }
  50%      { transform: rotate(1.5deg) scale(1.035); }
}
.intro-line {
  margin: 18px auto 0; max-width: 340px;
  font-size: 19px; line-height: 1.55; color: var(--ink); font-weight: 500;
}
/* THE PADDING WAS THE PROBLEM. 17px/58px around seven characters of 17px type made a pill about
   three times wider than its own content, which the owner called correctly: "the lets go on the
   first screen looks streched out". A button's shape should come from what is in it. The type is
   up two points, the horizontal padding is down twenty, and a character now rides next to the
   words — so the extra width is a friend rather than empty space. */
.begin {
  margin-top: 30px;
  appearance: none; border: 0; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; gap: 11px;
  font-family: "Syne", sans-serif; font-weight: 800; font-size: 19px; letter-spacing: 0.02em;
  color: #40182C;
  padding: 16px 38px; border-radius: 999px;
  background: linear-gradient(118deg, #FF8FBE, #FFC85A);
  box-shadow: 0 0 40px rgba(255,143,190,0.42), 0 10px 26px -12px rgba(0,0,0,0.6);
  transition: transform 0.2s var(--ease), box-shadow 0.3s var(--ease);
}
/* The character is a real sprite off the real cast, drawn at runtime, so it can never drift out
   of style with the app the way a pasted asset would. It bobs, because a kawaii button that
   holds perfectly still is a button with a sticker on it. */
.begin-char { width: 30px; height: 30px; display: block; flex: 0 0 auto; animation: beginBob 2.4s ease-in-out infinite; }
@keyframes beginBob {
  0%, 100% { transform: translateY(0) rotate(-4deg); }
  50%      { transform: translateY(-3px) rotate(4deg); }
}
@media (prefers-reduced-motion: reduce) { .begin-char { animation: none; } }
.begin:hover { transform: translateY(-1px); box-shadow: 0 0 58px rgba(255,143,190,0.62), 0 12px 30px -12px rgba(0,0,0,0.6); }
.begin:active { transform: scale(0.96); }
/* The middle option is a real way in, not a footnote, so it gets a shape. Three weights now:
   candy button, outlined pill, plain link, which is the actual order of how much of the app
   each one hands you. */
.begin-alt {
  display: block; margin: 14px auto 0;
  appearance: none; cursor: pointer;
  border: 1px solid rgba(255,255,255,0.28);
  background: rgba(255,255,255,0.07);
  color: var(--ink); font-family: inherit; font-weight: 600; font-size: 14px;
  min-height: 46px; padding: 0 26px; border-radius: 999px;
  transition: background 0.2s var(--ease), transform 0.2s var(--ease);
}
.begin-alt:hover { background: rgba(255,255,255,0.14); }
.begin-alt:active { transform: scale(0.96); }
/* MEASURED AT 18px TALL on a real 375px viewport, which is not a touch target, it is a dare.
   The underline still has to sit tight under the words, so the height comes from padding and
   the rule is pinned to the text with a decoration offset rather than from a taller box. */
.begin-quiet {
  display: block; margin: 6px auto 0;
  appearance: none; border: 0; background: transparent; cursor: pointer;
  min-height: 44px; padding: 0 18px;
  color: var(--ink-dim); font-family: inherit; font-size: 13.5px; text-decoration: underline; text-underline-offset: 3px;
}
.begin-quiet:hover { color: var(--ink); }
.intro-note { margin: 26px auto 0; max-width: 340px; font-size: 12.5px; color: var(--ink-faint); letter-spacing: 0.02em; }

/* ---------- toast ---------- */
.toast {
  position: fixed; z-index: 70;
  /* Rides on top of the whole bottom cluster, dock plus quick row. The old sab+110 cleared a
     dock that had nothing above it. With the quick row parked there, a toast at that height
     would land straight on the sound and undo buttons. */
  left: 50%; bottom: calc(var(--dock-lift) + var(--dock-h) + 90px);
  transform: translate(-50%, 12px);
  background: var(--glass-strong);
  border: 1px solid var(--hair);
  color: var(--ink);
  font-size: 13.5px; font-weight: 500;
  padding: 12px 20px; border-radius: 999px;
  backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
  opacity: 0; pointer-events: none;
  transition: opacity 0.35s var(--ease), transform 0.35s var(--ease);
  box-shadow: 0 12px 34px -10px rgba(0,0,0,0.7);
  max-width: 84vw; text-align: center;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }

/* save celebration: a soft luminous bloom, never a jarring flash */
.save-flash {
  position: fixed; inset: 0; z-index: 55; pointer-events: none;
  background: radial-gradient(circle at 50% 46%, rgba(255,255,255,0.5), rgba(255,240,200,0.12) 30%, rgba(0,0,0,0) 62%);
  opacity: 0;
  transition: opacity 0.85s var(--ease);
}
.save-flash.go { opacity: 0; animation: save-bloom 0.85s var(--ease); }
@keyframes save-bloom {
  0% { opacity: 0; transform: scale(0.85); }
  22% { opacity: 0.9; }
  100% { opacity: 0; transform: scale(1.15); }
}
@media (prefers-reduced-motion: reduce) { .save-flash.go { animation-duration: 0.4s; } }

/* ---------- gallery ---------- */
.gal-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.gal-card {
  appearance: none; border: 1px solid var(--hair); background: rgba(255,255,255,0.03);
  border-radius: 14px; padding: 0; cursor: pointer; overflow: hidden;
  position: relative; aspect-ratio: 3 / 4; display: block;
  transition: border-color 0.25s var(--ease), transform 0.15s var(--ease);
}
.gal-card:active { transform: scale(0.96); }
.gal-card:hover { border-color: var(--hair-2); }
.gal-card img { width: 100%; height: 100%; object-fit: cover; display: block; }
.gal-cap {
  position: absolute; left: 0; right: 0; bottom: 0;
  font-size: 10.5px; color: var(--ink); letter-spacing: 0.03em;
  padding: 14px 8px 6px; text-align: left;
  background: linear-gradient(to top, rgba(0,0,0,0.72), rgba(0,0,0,0));
}
/* a quiet dot marks the pieces that carry a timelapse, so the badge does not shout */
.gal-dot { color: var(--accent); margin-left: 5px; font-weight: 700; }

.gal-view {
  border-radius: 16px; overflow: hidden; border: 1px solid var(--hair);
  background: rgba(0,0,0,0.3); line-height: 0;
}
.gal-view img { width: 100%; height: auto; display: block; max-height: 46dvh; object-fit: contain; }

/* ---------- timelapse ---------- */
/* the chrome gets out of the way entirely while a piece redraws itself */
body.tl-on .topbar, body.tl-on .dock, body.tl-on .quick { opacity: 0; pointer-events: none; }
.tl {
  position: fixed; z-index: 45;
  left: 50%; bottom: calc(var(--sab) + 30px);
  transform: translate(-50%, 14px);
  width: min(420px, calc(100vw - 40px));
  display: flex; flex-direction: column; align-items: center; gap: 12px;
  opacity: 0; transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
  pointer-events: none;
}
.tl.show { opacity: 1; transform: translate(-50%, 0); }
.tl-label {
  font-size: 12.5px; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--ink-dim);
}
.tl-bar { width: 100%; height: 2px; border-radius: 999px; background: rgba(255,255,255,0.14); overflow: hidden; }
.tl-fill {
  width: 100%; height: 100%; border-radius: 999px; background: var(--accent);
  transform: scaleX(0); transform-origin: left center;
  transition: transform 0.25s linear;
}
.tl-stop {
  pointer-events: auto;
  appearance: none; cursor: pointer; font-family: inherit;
  font-size: 13px; font-weight: 600; color: var(--ink);
  padding: 11px 30px; border-radius: 999px;
  background: var(--glass-strong); border: 1px solid var(--hair);
  backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
}
.tl-stop:active { transform: scale(0.96); }
body.paper .tl-label { color: #55525f; }
body.paper .tl-bar { background: rgba(0,0,0,0.14); }

@media (max-width: 400px) { .gal-grid { grid-template-columns: repeat(2, 1fr); } }

/* ---------- paper mode chrome ----------
   The dock, sheets and toast stay dark glass on purpose: a floating dark control surface
   reads correctly over a light sheet and keeps one set of controls. Only the pieces that
   were drawn AS light — the top scrim, the wordmark glow, the breath cue — have to flip. */
body.paper .topbar {
  background: linear-gradient(to bottom, rgba(255,255,255,0.75), rgba(255,255,255,0));
}
body.paper .wordmark {
  color: #2c2a33;
  opacity: 0.62;
  text-shadow: none;
}
body.paper .mode-label {
  color: #45424f;
  background: rgba(255,255,255,0.6);
  border-color: rgba(0,0,0,0.1);
}
body.paper .breath-cue {
  color: rgba(30,28,38,0.8);
  text-shadow: 0 0 26px rgba(255,255,255,0.7);
}
/* The zen pill is one of the pieces drawn AS light, so on a sheet it has to flip to ink.
   Left alone it was white-on-white and vanished completely, which is the exact failure the
   affordance exists to prevent. */
body.paper .zen-exit {
  color: rgba(30,28,38,0.5);
  background: rgba(255,255,255,0.5);
  border-color: rgba(0,0,0,0.09);
}
body.paper .zen-exit:active { color: rgba(30,28,38,0.85); }
body.paper .intro {
  background: radial-gradient(120% 90% at 50% 40%, rgba(255,255,255,0.55), rgba(246,243,236,0.94));
}
/* The same candy hues, taken down far enough to survive a cream sheet. Straight candy on
   white is invisible: #FFD666 on #FBE7F3 has a contrast ratio of about 1.3, so the middle of
   the wordmark simply disappeared on the Cotton Candy ground. These are the deep ends of the
   same six. */
/* background-image, NOT the `background` shorthand. This rule used the shorthand, and a
   shorthand resets every longhand it covers, including background-clip, which the base rule
   had set to `text`. So on all seven paper and candy palettes the app's name rendered as a
   solid rainbow SLAB with no letters in it at all, and had done since the day paper palettes
   shipped. Nothing caught it because no screenshot of this app had ever included the intro.
   The longhand touches the picture and leaves the clip alone. */
body.paper .intro-mark {
  /* NO YELLOW ON THIS ONE. The dark wordmark runs strawberry-lemon-matcha-taro and gets away
     with it because every stop is high-value, so the amber-to-green crossing stays pale. Taken
     down to paper values the same crossing lands on olive, and there was a dull khaki band
     across the middle of the name on Cotton Candy. Pink-violet-teal-indigo crosses through a
     soft blue instead, which is a colour rather than a stain. */
  background-image: linear-gradient(104deg, #E0447E, #8B4FD0 34%, #1F8F7E 66%, #2F7CC0);
  filter: drop-shadow(0 0 20px rgba(224,68,126,0.22));
}
body.paper .intro-line { color: #38343f; }
body.paper .intro-note { color: #86828f; }
body.paper .begin-quiet { color: #55525f; }
body.paper .begin-quiet:hover { color: #201e28; }
body.paper .begin-alt {
  border-color: rgba(60,50,90,0.24);
  background: rgba(255,255,255,0.62);
  color: #2E2A3A;
}
body.paper .begin-alt:hover { background: rgba(255,255,255,0.85); }
/* A lavender halo over a white sheet is a smudge, not a glow. Over paper the pill lifts off
   the page the way the dock does, with a shadow. */
body.paper .save-btn {
  box-shadow: 0 12px 30px -12px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1);
  /* The fill had to go too, not just the halo. --glass is rgba(16,16,26,0.55) with no paper
     variant, so the violet gradient over it rendered as an opaque lavender slab sitting on
     cream: the loudest thing on a screen whose whole point is the drawing. On paper the pill
     keeps its shape and its word and nothing else, and it only lights when pressed. The four
     circles beside it already work this way, so the row now reads as one family. */
  background: rgba(255,255,255,0.62);
  border-color: rgba(70,60,110,0.30);
  color: #2E2A3A;
}
body.paper .save-btn:hover { background: rgba(255,255,255,0.82); }
body.paper .save-btn:active {
  box-shadow: 0 6px 14px -10px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1);
}
body.paper .save-flash {
  background: radial-gradient(circle at 50% 46%, rgba(255,255,255,0.9), rgba(255,252,244,0.3) 30%, rgba(0,0,0,0) 62%);
}

/* focus visibility for keyboard users */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
button:focus:not(:focus-visible) { outline: none; }

/* ---------- responsive ---------- */
@media (min-width: 768px) {
  .dock-btn { min-width: 74px; padding: 9px 16px; }
  /* A tablet or a desktop has width to spare beside the dock, so the rail goes back to being
     a column in the corner. It never reaches the middle there because the screen is wide
     enough that the dock does not fill it. This is the ONE case the old layout was right for,
     which is probably why nobody caught what it did on a phone. */
  .quick {
    left: auto;
    right: calc(var(--sar) + 16px);
    transform: none;
    flex-direction: column;
    gap: 10px;
    bottom: calc(var(--sab) + 24px);
  }
  .opt-grid { grid-template-columns: repeat(3, 1fr); }
}

/* wider screens: bring quick rail up beside dock */
@media (min-width: 900px) {
  .wordmark { font-size: 22px; }
}

@media (prefers-reduced-motion: reduce) {
  .intro-mark { animation: none; }
  * { scroll-behavior: auto; }
}

/* ---------- save fallback ----------
   The picture, full size, with the one instruction that works on every iPhone. Deliberately
   plain: this is a utility surface that appears when the share sheet could not, and dressing
   it up would make it look like a feature rather than a way out. */
.save-fallback {
  position: fixed; inset: 0; z-index: 60;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 16px;
  padding: calc(var(--sat) + 18px) 18px calc(var(--sab) + 18px);
  background: rgba(0,0,0,0.92);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  /* VISIBLE BY DEFAULT, and the animation only fades it in. It used to rely on the keyframe
     holding its end state, which means any condition that stops the animation running leaves the
     sheet at opacity 0: a throttled background tab, reduced-motion, or a browser that skips the
     animation entirely. An invisible save sheet is indistinguishable from Save doing nothing,
     which is the exact bug this whole feature exists to fix. */
  opacity: 1;
  animation: sfIn 0.28s var(--ease-out);
}
@keyframes sfIn { from { opacity: 0; } to { opacity: 1; } }
.save-fallback img {
  max-width: 100%;
  max-height: 68vh;
  border-radius: 14px;
  box-shadow: 0 18px 50px -18px rgba(0,0,0,0.9);
  /* the long-press menu is the whole point, so nothing may suppress it */
  -webkit-touch-callout: default;
  -webkit-user-select: auto;
  user-select: auto;
}
.save-fallback-tip {
  margin: 0; max-width: 30ch; text-align: center;
  font-size: 14px; line-height: 1.45; color: rgba(255,255,255,0.78);
}
.save-fallback-done {
  appearance: none; border: 1px solid rgba(255,255,255,0.22);
  background: rgba(255,255,255,0.07); color: #fff;
  font-family: inherit; font-size: 14px; font-weight: 600;
  min-height: 44px; padding: 0 28px; border-radius: 999px; cursor: pointer;
}
.save-fallback-done:active { transform: scale(0.95); }

/* ---------- who shows up: the character picker ----------
   Chips show the REAL baked sprite rather than a name, because half this cast has no name a
   person would recognise and a list reading "onigiri, daruma, taiyaki" teaches nobody what
   they are choosing. Tap toggles. An excluded character stays visible but goes flat and dim,
   so the set still reads as a complete cast with some of it switched off, which is what it is. */
/* FIVE ACROSS, NOT auto-fill. The owner's call once the cast reached fifty: "instead of having
   them 6 across, make it 5, so that its exactly 10 lines down. that will make them a bit bigger,
   I think people will like them."

   He is right on both counts. auto-fill packed six onto a 390px phone and left a ragged last row
   whose length depended on the cast size — 50 into 6 is eight rows and a widow of two, which is
   the orphaned-card problem this app fixes everywhere else. Fixed at five, fifty is exactly ten
   full rows with nothing left over, and every chip is about 20% bigger, which matters when the
   chip IS the character and choosing one is the point of the screen. */
.char-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 8px;
  margin-top: 4px;
}
/* ★ min-width: 0 IS WHY THE SHEET STOPPED SCROLLING SIDEWAYS, and it is the whole bug the owner
   hit: "this screen now moves.. it was stationed in place in the past... the 5 across is good,
   but they are a bit too big, they dont fit on the screen."

   He read it exactly right and the cause is one CSS default. Each chip contains a 96px canvas,
   and a grid item's `min-width` defaults to `auto`, which means it REFUSES to shrink below its
   own content's minimum size. So `repeat(5, 1fr)` did not give five equal fifths of the sheet —
   it gave five 96px tracks. Five of those plus four 8px gaps is 512px inside a 354px sheet, and
   a block wider than its container is exactly what makes a page scroll horizontally.

   `1fr` alone never protects you from this. It means "one part of the free space" and the
   auto minimum silently overrides it, which is why the columns looked right in the rule and
   wrong on the phone. min-width: 0 lets the track actually be a fifth, and the canvas inside
   scales down to it. */
.char-chip {
  appearance: none;
  aspect-ratio: 1;
  min-width: 0;
  padding: 4px;
  border-radius: 14px;
  border: 1px solid var(--hair);
  background: rgba(255, 255, 255, 0.05);
  cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: transform 0.14s var(--ease), opacity 0.2s var(--ease), background 0.2s var(--ease);
}
/* min-width: 0 again, for the same reason one level down: the chip is a flex container and a
   flex item has the identical auto minimum. The canvas is 96px of backing store painted into
   whatever box it lands in, which is what keeps it sharp on a retina phone at ~64px. */
.char-chip-img { width: 100%; height: 100%; min-width: 0; max-width: 100%; display: block; }
.char-chip:active { transform: scale(0.9); }
.char-chip.off {
  opacity: 0.26;
  background: transparent;
  filter: grayscale(1);
}
body.paper .char-chip { border-color: rgba(70, 60, 110, 0.22); background: rgba(255, 255, 255, 0.5); }

.char-actions { display: flex; gap: 10px; margin-top: 12px; }
.chip-btn {
  appearance: none; flex: 1;
  min-height: 44px; padding: 0 14px;
  border-radius: 999px;
  border: 1px solid var(--hair-2);
  background: rgba(255, 255, 255, 0.06);
  color: var(--ink);
  font-family: inherit; font-size: 14px; font-weight: 600;
  cursor: pointer;
}
.chip-btn:active { transform: scale(0.96); }
body.paper .chip-btn { border-color: rgba(70, 60, 110, 0.24); background: rgba(255, 255, 255, 0.55); color: #2E2A3A; }

/* Two ways off the save sheet, side by side, because one of them is a button and the other is
   an instruction and a person should not have to choose between reading and tapping. */
.save-fallback-row { display: flex; gap: 10px; align-items: center; }
.save-fallback-share {
  appearance: none;
  border: 1px solid rgba(157,139,255,0.5);
  background: linear-gradient(180deg, rgba(157,139,255,0.34), rgba(157,139,255,0.17)), rgba(16,16,26,0.55);
  color: #fff;
  font-family: inherit; font-size: 14px; font-weight: 600;
  min-height: 44px; padding: 0 22px; border-radius: 999px; cursor: pointer;
}
.save-fallback-share:active { transform: scale(0.95); }

/* The screenshot-me surface. Full bleed, nothing else on screen, because the whole point is
   that a phone screenshot of it is the finished picture with no dock in it. */
/* THE RAW FALLBACK IS A FULL-SCREEN PICTURE. It used to be a COLUMN — picture, then tip, then
   reason, then Done — anchored to the top with the picture eating 78vh. On a phone that pushed
   the Done button off the bottom of the screen entirely and the only way out of the app's own
   save screen was to force-quit the app. Now the picture is the screen and everything else
   floats over it, so nothing can be pushed anywhere. */
/* the character wallpaper screen. It reuses the raw save-fallback shell — full-bleed picture,
   floating chrome that fades for a clean screenshot, a close that only dims — because that is
   already the right shape for "look at this one picture" and having two of them would be two
   things to keep in step. */
/* FIVE ACROSS, TWO ROWS. Left to wrap on its own, ten swatches packed eight onto the first row
   and orphaned two on the second, which is the same ragged-last-row problem the owner had just
   pointed out in the character grid. Ten into five is two full rows with nothing left over, and
   it matches the picker they were opened from. */
.wall-swatches {
  display: grid; grid-template-columns: repeat(5, 34px);
  justify-content: center; gap: 10px;
  margin: 0 auto 4px;
}
.wall-sw {
  width: 34px; height: 34px; padding: 0; cursor: pointer;
  border-radius: 999px;
  border: 2px solid rgba(255,255,255,0.32);
  transition: transform 0.16s var(--ease), border-color 0.16s var(--ease);
}
.wall-sw.on { border-color: #fff; transform: scale(1.16); }
.wall-sw:active { transform: scale(0.92); }
/* the swatches go with the words, so the screenshot is only ever the character */
.charwall.bare .wall-swatches { opacity: 0; }

/* the dice buttons. Full width and set apart, because they are an alternative to the whole grid
   above them rather than one more option in it. */
.chip-btn.wide {
  display: block; width: 100%; margin-top: 16px;
  font-size: 15px; letter-spacing: 0.01em;
}

.save-fallback-raw { padding: 0; gap: 0; justify-content: center; }
.save-fallback-canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  /* CONTAIN, NEVER COVER. Cover would fill the screen by cropping the mandala's edges off,
     which is the same defect the wallpaper export exists to avoid. */
  object-fit: contain;
  display: block;
  border-radius: 0;
}
/* the words, floated at the bottom over the picture, and gone in four seconds so the screenshot
   he is being told to take is only ever the artwork */
.save-fallback-chrome {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 22px 18px calc(var(--sab) + 22px);
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  background: linear-gradient(180deg, rgba(0,0,0,0), rgba(0,0,0,0.72) 42%);
  transition: opacity 0.45s var(--ease-out);
}
.save-fallback-x {
  position: absolute; top: calc(var(--sat) + 12px); right: 12px;
  width: 38px; height: 38px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  appearance: none; cursor: pointer;
  border: 1px solid rgba(255,255,255,0.22);
  background: rgba(0,0,0,0.42);
  color: rgba(255,255,255,0.86);
  border-radius: 999px;
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  transition: opacity 0.45s var(--ease-out);
}
.save-fallback-x svg { width: 19px; height: 19px; }
.save-fallback-x:active { transform: scale(0.92); }
/* `bare` is the clean-screenshot state. The close button only dims rather than disappearing,
   because a screen with no visible exit is what trapped him in the first place. */
.save-fallback-raw.bare .save-fallback-chrome { opacity: 0; pointer-events: none; }
.save-fallback-raw.bare .save-fallback-x { opacity: 0.22; }
.save-fallback-why {
  margin: 0; padding: 0 18px;
  max-width: 34ch; text-align: center;
  font-size: 12px; line-height: 1.4; color: rgba(255,255,255,0.45);
}
@media (prefers-reduced-motion: reduce) {
  .save-fallback-chrome, .save-fallback-x { transition: none; }
}
