/* slaps — "fresh vinyl sheet" design system.
   The subject is the material: die-cut stickers on backing liner. Light-first
   (a bright physical product for friends), with a night-studio dark mode.
   90s sticker palette: vivid fills, ink text, no outlines — color-block only.
   Type: one family — Archivo Black (loud caps headers) + Archivo (headlines
   700/800, body 500, labels + receipts). Admin console stays plainer below. */

@font-face {
  font-family: "Archivo";
  src: url("/static/fonts/archivo.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-display: swap;
}
@font-face {
  font-family: "Archivo Black";
  src: url("/static/fonts/archivo-black.woff2") format("woff2");
  font-display: swap;
}

:root {
  /* fresh vinyl sheet (light) — 90s sticker palette: vivid fills, ink text,
     no outlines. Every -text variant is AA (>=4.5) on vinyl; ink is AA on
     every vivid surface. Vivid fills carry ink text, never white. */
  --liner: #fafaf6;          /* backing paper */
  --liner-deep: #ebebe3;     /* grid lines / wells */
  --vinyl: #ffffff;          /* the sticker face */
  --ink: #1a1b1e;
  --ink-soft: #565a60;
  --reg-cyan: #00c6ff;       /* electric cyan — interaction fills */
  --reg-cyan-text: #0079b8;  /* AA on vinyl; also the light-mode focus ring */
  --focus-ring: #0079b8;
  --magenta: #ff2fa4;        /* hot magenta — money + fulfiller */
  --magenta-text: #cf0077;
  --good: #6ee600;           /* slime lime */
  --good-text: #22861b;
  --bad: #ff5247;
  --bad-text: #e01414;
  --yellow: #ffd60a;         /* sunny — surface only, always ink text */
  --grape: #7634e6;          /* the one dark vivid — white text allowed */
  --grape-text: #7634e6;
  /* deep shades — the unlit underside of each fill. Derived, not eyeballed:
     one uniform -0.075 lightness step in OKLCH with hue AND chroma held, so
     every underside reads as the same material turned over. (Hand-picked
     values drifted — yellow's especially, which went olive.) */
  --reg-cyan-deep: #00b3ee;
  --magenta-deep: #e9008f;
  --good-deep: #54d500;
  --bad-deep: #e9312c;
  --yellow-deep: #f0c500;
  --warn-text: #8a6a00;      /* amber dark enough to read on the liner */
  --grape-deep: #6100cd;
  --vinyl-deep: #f0ede5;     /* white vinyl's back = the liner side */
  --ink-deep: #0d0e10;
  --grid: 16px;               /* THE unit. flat elements align to it; stickers break it */
  /* ELEVATION — every level is contact + form + ambient (Material's umbra /
     penumbra / ambient, tuned for stickers on a liner). Height in grid units:
     e1 = 1px contact, e2 = 4px, e3 = 8px, e4 = 16px (1 cell). Peeling a
     sticker raises it a level; nothing in the app exceeds e4. */
  --e0: none;                                                  /* stuck flat */
  --e1: 0 1px 1px rgba(23,24,26,.10), 0 2px 4px rgba(23,24,26,.07), 0 0 1px rgba(23,24,26,.04);
  --e2: 0 1px 2px rgba(23,24,26,.10), 0 4px 8px rgba(23,24,26,.09), 0 8px 16px rgba(23,24,26,.05);
  --e3: 0 2px 3px rgba(23,24,26,.11), 0 8px 16px rgba(23,24,26,.11), 0 16px 32px rgba(23,24,26,.07);
  --e4: 0 3px 5px rgba(23,24,26,.12), 0 16px 28px rgba(23,24,26,.14), 0 32px 56px rgba(23,24,26,.10);
  /* MOTION — spring curves sampled from real physics; overshoot is the joy */
  --spring: linear(0, .2 3.5%, .77 9%, 1.05 13%, 1.14 16%, 1.08 21%, .99 27%, .97 32%, 1.01 42%, 1.01 55%, 1);
  --pop: cubic-bezier(.2, 1.5, .4, 1);      /* overshoot on press-release */
  --settle: cubic-bezier(.3, 1.2, .5, 1);   /* sticker landing */
  --quick: cubic-bezier(.4, 0, .2, 1);      /* utility, no personality */
  /* filter twins — for clipped surfaces, where box-shadow can't survive */
  --fe1: drop-shadow(0 1px 1px rgba(23,24,26,.12)) drop-shadow(0 2px 3px rgba(23,24,26,.08));
  --fe2: drop-shadow(0 1px 2px rgba(23,24,26,.13)) drop-shadow(0 4px 8px rgba(23,24,26,.10));
  --fe3: drop-shadow(0 2px 3px rgba(23,24,26,.14)) drop-shadow(0 8px 16px rgba(23,24,26,.13));
  --fe4: drop-shadow(0 3px 5px rgba(23,24,26,.15)) drop-shadow(0 16px 28px rgba(23,24,26,.16))
         drop-shadow(0 32px 44px rgba(23,24,26,.10));
  --shadow: var(--e2);         /* legacy aliases -> the scale */
  --shadow-lift: var(--e3);
  --holo: linear-gradient(105deg, #ffd9e8, #d9f3ff 28%, #d9ffe3 52%, #fff3c9 74%, #eadcff);
  --loud: "Archivo Black", -apple-system, system-ui, sans-serif;  /* caps-only headline register */
  --ui: "Archivo", -apple-system, system-ui, sans-serif;  /* label register */
  --mono: ui-monospace, "SF Mono", Menlo, monospace;      /* receipts only */
  --well-hover: color-mix(in oklab, var(--liner-deep) 91%, var(--ink));
  /* Surfaces that must sit ABOVE a well. In light mode white does this for
     free; on dark, "raised" cannot be another near-black — vinyl on
     liner-deep is a 1.05:1 difference and simply disappears. */
  --raised: #ffffff;          /* a control resting in a well (stepper keys) */
  /* the account chip's surface. Light: a white card lifted off the sheet.
     Dark: RECESSED below it — a light card floating in a dark bar competes
     with the mark, and 'a step darker' is the same idea expressed the way
     that mode allows. */
  --chip: #ffffff;
  --chip-hover: #ffffff;
  /* The ACTIVE tool's surface. Light mode already separates an open card from a
     button overlapping it with a shadow, so this is simply the chip; dark mode
     can't — a dark shadow on a dark sheet between two dark surfaces is nothing
     at all — so there it steps LIGHTER instead, which is how dark UI shows
     elevation. See the dark blocks. */
  --tool-open: var(--chip);
  --chip-lift: var(--e1);            /* a step off the sheet, not a floating card */
  /* the row wash. Pure greyscale on purpose: mixing --ink carries its slight
     cool cast, which tints the fill under a blue label and reads as a colour
     choice rather than a hover. */
  --row-hover: rgba(0, 0, 0, .05);
  --chip-lift-hover: var(--e2);
  --chip-lift-open: var(--e3);
  --knob: #ffffff;            /* the moving part of a switch */
  --well-card: var(--liner-deep);   /* a well cut into a card */
  --body: "Archivo", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* dark applies two ways: the system asks for it, or the visitor chose it.
   No stored choice means we simply follow the OS. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
  /* night studio — same vivid fills (they pop harder on dark), lifted -text */
  --liner: #141317;
  --liner-deep: #201e25;
  --vinyl: #232127;
  --ink: #f2f1f4;
  --ink-soft: #a09fa8;
  --reg-cyan-text: #3fd2ff;
  --focus-ring: #00c6ff;
  --magenta-text: #ff6ec4;
  --good-text: #52e468;
  --bad-text: #ff8577;
  --grape-text: #bda2ff;
  /* neutral undersides invert with the mode: one step further from the light */
  --vinyl-deep: #1c1a20;
  --ink-deep: #d9d7dc;
  --raised: #3d3b47;          /* clearly above the well, still a dark surface */
  --chip: color-mix(in srgb, var(--liner) 62%, #000);
  --chip-hover: color-mix(in srgb, var(--liner) 48%, #000);
  --tool-open: color-mix(in srgb, var(--liner) 95%, #fff);
  --chip-lift: none;
  --chip-lift-hover: none;
  --chip-lift-open: none;
  --row-hover: rgba(255, 255, 255, .06);
  --knob: #a6a4b0;            /* the knob reads as the lit, moving piece */
  --well-card: #0f0e13;       /* a well cut into a dark card must go DARKER */
  /* undersides take a SHALLOWER step on a dark sheet (-0.042 L vs -0.075):
     the same drop that reads as a turned-over edge on white sinks a vivid
     into the dark ground and goes muddy. Same hue, same chroma, less fall. */
  --reg-cyan-deep: #00b7ef;
  --magenta-deep: #eb0894;
  --good-deep: #60d800;
  --bad-deep: #ec3e36;
  --yellow-deep: #f2c900;
  --warn-text: #f2c900;      /* on a dark sheet the sunny one is the readable one */
  --grape-deep: #6514d0;
  /* night studio: shadows go deeper + tighter (less ambient bounce in a dark room) */
  --e1: 0 1px 1px rgba(0,0,0,.24), 0 2px 4px rgba(0,0,0,.16);
  --e2: 0 1px 2px rgba(0,0,0,.26), 0 4px 10px rgba(0,0,0,.20);
  --e3: 0 2px 3px rgba(0,0,0,.28), 0 8px 18px rgba(0,0,0,.26), 0 16px 32px rgba(0,0,0,.14);
  --e4: 0 3px 5px rgba(0,0,0,.30), 0 16px 28px rgba(0,0,0,.30), 0 32px 56px rgba(0,0,0,.22);
  --fe1: drop-shadow(0 1px 1px rgba(0,0,0,.26)) drop-shadow(0 2px 3px rgba(0,0,0,.18));
  --fe2: drop-shadow(0 1px 2px rgba(0,0,0,.28)) drop-shadow(0 4px 8px rgba(0,0,0,.22));
  --fe3: drop-shadow(0 2px 3px rgba(0,0,0,.30)) drop-shadow(0 8px 16px rgba(0,0,0,.26));
  --fe4: drop-shadow(0 3px 5px rgba(0,0,0,.32)) drop-shadow(0 16px 28px rgba(0,0,0,.30))
         drop-shadow(0 32px 44px rgba(0,0,0,.20));
  --shadow: var(--e2);
  --shadow-lift: var(--e3);
}
}
[data-theme="dark"] {
  /* night studio — same vivid fills (they pop harder on dark), lifted -text */
  --liner: #141317;
  --liner-deep: #201e25;
  --vinyl: #232127;
  --ink: #f2f1f4;
  --ink-soft: #a09fa8;
  --reg-cyan-text: #3fd2ff;
  --focus-ring: #00c6ff;
  --magenta-text: #ff6ec4;
  --good-text: #52e468;
  --bad-text: #ff8577;
  --grape-text: #bda2ff;
  /* neutral undersides invert with the mode: one step further from the light */
  --vinyl-deep: #1c1a20;
  --ink-deep: #d9d7dc;
  --raised: #3d3b47;          /* clearly above the well, still a dark surface */
  --chip: color-mix(in srgb, var(--liner) 62%, #000);
  --chip-hover: color-mix(in srgb, var(--liner) 48%, #000);
  --tool-open: color-mix(in srgb, var(--liner) 95%, #fff);
  --chip-lift: none;
  --chip-lift-hover: none;
  --chip-lift-open: none;
  --row-hover: rgba(255, 255, 255, .06);
  --knob: #a6a4b0;            /* the knob reads as the lit, moving piece */
  --well-card: #0f0e13;       /* a well cut into a dark card must go DARKER */
  /* undersides take a SHALLOWER step on a dark sheet (-0.042 L vs -0.075):
     the same drop that reads as a turned-over edge on white sinks a vivid
     into the dark ground and goes muddy. Same hue, same chroma, less fall. */
  --reg-cyan-deep: #00b7ef;
  --magenta-deep: #eb0894;
  --good-deep: #60d800;
  --bad-deep: #ec3e36;
  --yellow-deep: #f2c900;
  --warn-text: #f2c900;      /* on a dark sheet the sunny one is the readable one */
  --grape-deep: #6514d0;
  /* night studio: shadows go deeper + tighter (less ambient bounce in a dark room) */
  --e1: 0 1px 1px rgba(0,0,0,.24), 0 2px 4px rgba(0,0,0,.16);
  --e2: 0 1px 2px rgba(0,0,0,.26), 0 4px 10px rgba(0,0,0,.20);
  --e3: 0 2px 3px rgba(0,0,0,.28), 0 8px 18px rgba(0,0,0,.26), 0 16px 32px rgba(0,0,0,.14);
  --e4: 0 3px 5px rgba(0,0,0,.30), 0 16px 28px rgba(0,0,0,.30), 0 32px 56px rgba(0,0,0,.22);
  --fe1: drop-shadow(0 1px 1px rgba(0,0,0,.26)) drop-shadow(0 2px 3px rgba(0,0,0,.18));
  --fe2: drop-shadow(0 1px 2px rgba(0,0,0,.28)) drop-shadow(0 4px 8px rgba(0,0,0,.22));
  --fe3: drop-shadow(0 2px 3px rgba(0,0,0,.30)) drop-shadow(0 8px 16px rgba(0,0,0,.26));
  --fe4: drop-shadow(0 3px 5px rgba(0,0,0,.32)) drop-shadow(0 16px 28px rgba(0,0,0,.30))
         drop-shadow(0 32px 44px rgba(0,0,0,.20));
  --shadow: var(--e2);
  --shadow-lift: var(--e3);
}

/* [hidden] is a UA rule at the lowest possible specificity, so ANY author
   `display` on the same element beats it and the thing stays on screen. Half
   this app hides things that way — the cart, the sizer, the touch-up modal, the
   account menu — so hidden means hidden, full stop. */
[hidden] { display: none !important; }

* { box-sizing: border-box; -webkit-font-smoothing: antialiased;
     -moz-osx-font-smoothing: grayscale; }
/* no widows/orphans: balance short display text, pretty-wrap running text */
h1, h2, h3, h4, figcaption, .tagline, .sg-note, button, .q-chip, .hint { text-wrap: balance; }
p, li { text-wrap: pretty; }
html { height: 100%; }
body {
  margin: 0; min-height: 100vh;
  color: var(--ink);
  font-family: var(--body);
  font-weight: 500;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  font-size: 16px; line-height: 1.45;
  /* the liner: paper white with a faint cutting-mat grid */
  background:
    linear-gradient(var(--liner-deep) 1px, transparent 1px) 0 -0.5px / var(--grid) var(--grid),
    linear-gradient(90deg, var(--liner-deep) 1px, transparent 1px) calc(50% + 7.5px) 0 / var(--grid) var(--grid),
    var(--liner);
}
/* soften the grid so it reads as texture, not table */
body::before {
  content: ""; position: fixed; inset: 0; pointer-events: none; z-index: 0;
  background: var(--liner); opacity: .38;
}
body > * { position: relative; z-index: 1; }

/* dragging over text is an interaction, so it wears the interaction colour */
::selection { background: var(--reg-cyan); color: #10222b; }

/* ---------- die-cut primitive: everything interactive is a sticker ---------- */
.diecut {
  background: var(--vinyl);
  border-radius: 18px 22px 19px 23px;
  box-shadow: var(--shadow);
}

/* ---------- chrome ---------- */
.bar {
  display: flex; justify-content: space-between; align-items: center;
  padding: 14px 18px;
  /* Above the studio. With nothing uploaded the studio is a fixed sheet across
     the whole viewport so the drop square can centre against the window, and it
     was painting over the bar — the account chip still LOOKED right, it just
     never got the tap. Anything full-bleed and fixed has to be told the bar
     outranks it. */
  position: relative; z-index: 2;
}
.bar nav { display: flex; align-items: center; gap: 10px;
  /* to the TOP of the bar, not its middle. The bar is as tall as the mark,
     so a centred chip hangs well below the corner it belongs in. */
  align-self: flex-start; }
.bar nav > a {
  color: var(--reg-cyan-text); text-decoration: none;
  font-family: var(--ui); font-size: .78rem;
  padding: 6px 12px; border-radius: 9999px;
  background: var(--vinyl); box-shadow: var(--shadow);
}
.bar nav > a { transition: transform .26s var(--pop), box-shadow .26s var(--quick); }
/* > a, like the rule it belongs to: these dress the bar's own link-chips, and
   an unscoped descendant selector reaches into the account menu and lifts a
   menu row off the card it's part of */
.bar nav > a:hover { transform: translateY(-2px); box-shadow: var(--e3); }
/* the paste route sits inside the dropzone but is its own target — clicking it
   must not also open the file picker the dropzone opens */
.dropzone #paste { margin-top: 14px; }

/* THE ACCOUNT MENU. Not a panel that drops NEAR the avatar — the chip itself is
   a card, and clicking it grows that same card downward into the menu. One
   object the whole time.
   Radii are derived, not picked: the card is R outside with P of padding, so an
   item inside is R − P. Anything else and the corners visibly disagree. */
.who-wrap {
  /* always out of flow, so opening changes only size — not position. Flipping
     position and width in the same frame gives the width transition no stable
     'before' to travel from, and it snaps. */
  position: absolute; top: 0; right: 0;
  /* ONE radius for this card, closed or open — the chip is a pill and the
     open card keeps its corners, so nothing has to travel. Derived: the chip
     measures 44 inside the margins (the avatar's 40 plus the button's line
     box), so half of (44 + 2 x --wP) is 22 + --wP. */
  --wR: calc(22px + var(--wP));
  /* the card's inner margin. Rows, the divider and the avatar all sit inside
     it, so widening it gives every edge the same breathing room at once —
     and the derived item radius (--wR - --wP) follows. */
  --wP: 10px;
  /* max-content, so the chip sizes to its own contents rather than to the
     slot it sits in. Absolutely positioned, its shrink-to-fit is otherwise
     capped by the slot — and if the pointer is already over the chip when
     the page loads, no pointerenter ever fires, no width is ever pinned, and
     the chevron animates out past a pill stuck at the cap. */
  width: max-content;
  padding: var(--wP);
  border-radius: var(--wR);
  /* a step DARKER than the sheet, not lighter: --raised is a card surface, and a
     card floating in the bar competes with the mark. Recessed, it reads as part
     of the chrome. One expression works in both modes — mix the sheet toward
     black and it darkens whichever way the sheet is pointing. */
  background: var(--chip); box-shadow: var(--chip-lift);
  /* radius is NOT transitioned. The card opens instantly, so an easing radius
     lags behind its own shape — pill corners on an already-tall card for a fifth
     of a second. It snaps with the shape it belongs to. */
  /* The card is never given a width. It fits its contents, and its contents
     animate — a max-content box re-measures every frame, so the chevron opening
     and the menu unrolling move the card's edge smoothly with no width
     transition and no JS measuring anything. */
  transition: background .2s var(--quick), box-shadow .26s var(--quick);
}
.who-wrap:hover { background: var(--chip-hover); box-shadow: var(--chip-lift-hover); }
/* 12px + the card's own 6px puts the chevron at 18px from the card's edge,
   which is exactly where the menu icons below it sit (6px card + 12px item) */
.who-wrap:hover .who, .who-wrap.is-open .who { padding-left: 12px; }
/* the anchor the card hangs off — the card is absolute and right-aligned to it,
   and needs no reserved size, since nothing else lives in the nav */
.who-slot { position: relative; }
/* used only while the menu's natural width is read, once, at startup */
.who-wrap.is-measuring, .who-wrap.is-measuring .who-menu { transition: none !important; }
/* Open, the WHOLE card leaves the flow — chip and menu together, one element.
   Two stacked boxes were the choppiness: the menu's shadow fell across the chip
   above it, so the same white read as two different surfaces at two elevations.
   One box, one background, one shadow. The slot holds the closed chip's
   footprint (set in JS) so the bar doesn't reflow as the card grows. */
.who-wrap.is-open {
  z-index: 31;
  /* No width here on purpose. JS owns it, because a min-width clamps the used
     value instantly and a transition cannot travel below its own minimum — the
     card snapped to 236 the moment the class landed. The constraints live in the
     measuring pass, which is where the target width comes from. */
  /* the chip's own surface, carried through: opening changes how much of the
     thing there is, not what it's made of */
  background: var(--chip);
  box-shadow: var(--chip-lift-open);
}
/* The menu leaves the flow. In flow it grew the bar, and the bar pushed the mark
   and the whole page down — an account menu must never move the page it's over.
   It hangs flush under the chip and shares its background, so the two still read
   as one card that grew rather than a panel that appeared. */
/* The menu opens by GROWING, not appearing: one grid row travelling 0fr -> 1fr,
   which is the only way to animate to an automatic height. Items live in an
   inner .who-list because the trick needs exactly one row to interpolate. */
.who-menu {
  position: relative;
  display: grid; grid-template-rows: 0fr;
  width: 0;                                   /* ...and no width to give the card */
  padding: 0; margin-top: 0; opacity: 0;
  background: none; box-shadow: none; border-radius: 0;
  overflow: hidden;
  transition: grid-template-rows .3s var(--pop), width .3s var(--pop),
              opacity .18s var(--quick),
              padding-top .3s var(--pop), margin-top .3s var(--pop);
}
.who-menu > .who-list { min-height: 0; }      /* or the row will never shrink */
/* Closed, the menu must not size the card. Its height is already collapsed, but
   its WIDTH still fed the card's shrink-to-fit, so the chip was as wide as the
   longest menu item and the handle sat in 70px of empty room. contain:inline-size
   cuts that dependency — and it's lifted during the measuring pass, which is the
   one moment we DO want the items' width. */
/* no gap: the rows are a continuous list, and a hover fill that stops short
   of its neighbours reads as three separate objects rather than one menu */
.who-list { display: flex; flex-direction: column; gap: 0; }
.who-wrap.is-open .who-menu {
  grid-template-rows: 1fr; opacity: 1;
  width: var(--menu-w, 208px);                /* measured once, in JS */
  padding-top: 6px; margin-top: 6px;
}
.who-menu::before {
  /* full width of a row, so it lines up with the hover fill's edges rather
     than floating inside them */
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 1px;
  background: color-mix(in srgb, var(--ink) 9%, transparent);
}
/* Items style off their OWN class, not off being an <a> or a <button>. Those two
   arrived looking different (the bar's link-chip rule dressed anchors as raised
   pills) which is a trap for whoever adds the next item. */
.who-item {
  display: flex; align-items: center; gap: 11px; width: 100%;
  /* more room on the right than the left: the icon column fixes the left edge,
     so the only place a long label can breathe is the trailing side, and the
     longest row is the one that sets the card's width */
  padding: 0 20px 0 12px; height: 40px; line-height: 40px;
  background: none; box-shadow: none;
  /* a pill, like the chip above it — half of the 40px row. The card's corners
     are pills too, so a row with a squarer radius reads as a different family */
  border-radius: 20px;
  font-family: var(--ui); font-size: .82rem; font-weight: 500;
  text-transform: none; letter-spacing: 0; text-align: left;
  color: var(--ink); text-decoration: none; white-space: nowrap;
  transition: background .14s var(--quick), color .14s var(--quick);
}
/* The row lights up: label and icon both take the interaction colour, on a fill
   barely separated from the card — enough to say 'this one', not enough to read
   as a second surface laid on top. */
.who-item:hover, .who-item:focus-visible {
  background: var(--row-hover);
  color: var(--reg-cyan-text);
  transform: none; box-shadow: none; outline: none;
}
.who-item .ico { width: 17px; height: 17px; background: var(--ink-soft);
  transition: background .14s var(--quick); }
.who-item:hover .ico, .who-item:focus-visible .ico { background: var(--reg-cyan-text); }
/* danger is a property of the ACTION, not of the label sitting in a list —
   it reads as an error until you reach for it, and the red is louder as a
   warning precisely because it arrives on hover */
.who-item.is-danger { color: var(--ink); }
.who-item.is-danger:hover, .who-item.is-danger:focus-visible {
  background: color-mix(in srgb, var(--bad, #e5484d) 9%, transparent);
  color: var(--bad-text, var(--ink)); }   /* danger keeps its own colour */
.who-item.is-danger:hover .ico { background: var(--bad-text, var(--ink)); }

/* a bare icon button that lives in the bar */
.nav-ico {
  width: 34px; height: 34px; padding: 0; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--ink-soft);
  transition: color .16s var(--quick), transform .26s var(--pop);
}
.nav-ico .ico { width: 18px; height: 18px; }
.nav-ico:hover { color: var(--reg-cyan-text); transform: translateY(-1px); }

/* who's signed in: the handle, then their bean as a round avatar. The variants
   are full-body characters, so the avatar uses the face-framed crop
   (bean-lab/<v>-head.svg) — a whole bean shrunk to 34px reads as a smudge. */
.who { display: inline-flex; align-items: center; gap: 9px;
  /* tight at rest, opening up as the chevron slides in — the room is there
     BECAUSE something arrived to use it, rather than sitting empty waiting */
  padding: 0 0 0 4px;
  /* same clock and curve as the card's width: the chevron's position is the
     SUM of the two, and on different durations it stalls when the shorter
     one ends and then jumps as the longer finishes */
  transition: padding-left .3s var(--pop); height: auto; background: none; box-shadow: none;
  border-radius: 9999px; cursor: pointer; }
.who { text-transform: none; letter-spacing: 0;      /* a handle keeps its case */
  width: 100%; justify-content: flex-end; }
/* the way back out, and only while there's something to go back from */
/* A chevron, not an arrow: an arrow says "back", a chevron says "this opens".
   It slides out on hover pointing DOWN — the direction the card grows — and
   flips UP when open, which is the same glyph saying "and this closes it".
   (.ico sets display later in the file and would win a specificity tie, so these
   carry the extra class weight.) */
.who .who-close {
  /* set here, not inline on the element: the phone breakpoint swaps this glyph,
     and an inline style would outrank the rule that does it */
  --ico: url('/static/icons/ui/chevron-down.svg');
  width: 17px; height: 17px; background: var(--ink-soft);
  margin-right: auto; margin-left: 6px;
  opacity: 0; max-width: 0; margin-left: 0;
  transform: translateX(6px);
  transition: opacity .18s var(--quick), max-width .3s var(--pop),
              margin-left .3s var(--pop), transform .3s var(--pop);
}
.who-wrap:hover .who .who-close,
.who-wrap.is-open .who .who-close {
  opacity: 1; max-width: 17px; margin-left: 0;
  transform: translateX(0);
}
/* open, it flips: same glyph, opposite promise */
.who-wrap.is-open .who .who-close { transform: rotate(180deg); }
/* the chevron is one of the card's icons — the rows' muted grey by default, and
   it takes the interaction colour when you point at ITS row, exactly as a menu
   row's icon does. Open, with the pointer on a row instead, it stays grey. */
.who-wrap:hover .who-close, .who-wrap.is-open .who-close { background: var(--ink-soft); }
/* hover-capable only: iOS leaves :hover stuck on whatever you last tapped, so
   ungated this paints the glyph blue from the first tap and never lets go */
@media (hover: hover) {
  .who:hover .who-close { background: var(--reg-cyan-text); }
}
.who:hover { transform: none; box-shadow: none; }
/* The handle takes the rows' type, but the ICONS' colour: it names who you are
   rather than offering an action, so it sits back with the chevron beside it
   instead of competing with the choices below. */
.who b { font-family: var(--ui); font-size: .82rem; font-weight: 500;
  color: var(--ink-soft); }
.who .av {
  width: 40px; height: 40px; flex: none; display: block;
  border-radius: 50%; overflow: hidden;
  /* white, not a brand colour: the bean is already full-colour and the disc is
     its backing sheet, not a second accent competing with it */
  /* no shadow: the disc sits ON the card, and the system's own rule is that a
     control on a card recesses rather than lifts */
  background: #fff;
}
.who .av img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* THE FALLBACK FACE — an initial, for everyone who has no photo and no bean.
   The disc's white backing is right behind a bean, which is full-colour art on
   transparency; a monogram is not art, so it fills the disc itself and the
   white would only show as a rim. */
.who .av.is-mono {
  background: var(--mono, var(--ink-soft));
  display: grid; place-items: center;
  /* --vinyl, which is the disc's own backing colour, so the letter reads as the
     disc showing through rather than as ink laid on top.
     It has to be a token that FLIPS, because the hue does: --*-text is deep on
     light and bright on dark, so a fixed white letter measures 1.6:1 in dark
     mode. Not --ink-deep either — "ink" is the foreground, so on dark it IS
     light (#d9d7dc) and fails the same way. Measured worst case across the five
     hues: 4.67:1 on light, 6.28:1 on dark, both AA at this size. */
  color: var(--vinyl);
  font-family: var(--ui); font-weight: 600; font-size: 1rem;
  line-height: 1; letter-spacing: 0;
  /* a letter is text; nobody should be able to select it out of a chip */
  user-select: none;
}
/* Two different questions, so two different queries.
   Whether the chip can SPELL OUT the handle is about width. Whether it has to
   show you a way in is about hover — a phone held sideways is 844px wide and
   would have sailed past a width breakpoint while still having no pointer to
   reveal anything with. */

/* --- no pointer: the way in has to be standing there ------------------- */
@media (hover: none) {
  /* Without this the chip is a picture of a bean that happens to be tappable —
     the chevron waits for a hover that never comes. So the affordance stands
     there, as the glyph everyone already reads as a menu rather than a chevron
     caught mid-promise. */
  .who .who-close {
    --ico: url('/static/icons/ui/hamburger.svg');
    opacity: 1; max-width: 17px; margin-left: 0; transform: none;
    transition: width .3s var(--pop), height .3s var(--pop), max-width .3s var(--pop);
  }
  /* Open, it becomes the way out. Three level bars look the same upside down,
     so a rotation says nothing — the glyph has to change instead.
     22px, not 17: an X reaches 65% across its box where the bars reach 83%, so
     at a matched box it would arrive visibly smaller than the thing it replaced.
     Sized to put the same amount of ink on screen. */
  .who-wrap.is-open .who .who-close {
    --ico: url('/static/icons/ui/dismiss.svg');
    width: 22px; height: 22px; max-width: 22px; transform: none;
  }

  /* Open, the menu is the only thing you're meant to be reading, so everything
     behind it goes soft.
     The scrim is the BAR's own ::after, which is what makes the layering work:
     inside the bar's stacking context it lands above the mark and the mode
     toggle — earlier siblings — while the chip, lifted a level, stays sharp
     above it. A scrim outside the bar could be above the studio or above the
     bar, but never between the bar's own two halves.
     It takes the taps too. Left transparent to them, a tap meant to dismiss
     would fall through to the drop zone and open a file picker. */
  .bar::after {
    content: ""; position: fixed; inset: 0;
    backdrop-filter: blur(7px); -webkit-backdrop-filter: blur(7px);
    opacity: 0; pointer-events: none;
    /* 1, to clear the wordmark — the lockup carries a z-index of its own, so a
       scrim at the default level sat under it and left the mark sharp while
       everything around it went soft. Same level, later in the DOM, wins. */
    z-index: 1;
    transition: opacity .25s var(--quick);
  }
  body.menu-open .bar::after { opacity: 1; pointer-events: auto; }
  .bar nav { position: relative; z-index: 2; }
}

/* --- narrow: no room to spell the handle out --------------------------- */
@media (max-width: 560px) {
  .who { padding-left: 2px; }
  .bar { padding: 12px 14px; }
  .bar nav { gap: 8px; }
  /* Folded away, not deleted — with the card open there IS room for it, and it
     belongs between the way out and the face it names. The negative margin
     cancels the flex gap it would otherwise hold open while collapsed; without
     it the closed chip carries a 9px space around nothing. */
  .who b {
    max-width: 0; opacity: 0; overflow: hidden; white-space: nowrap;
    margin-right: -9px;
    transition: max-width .3s var(--pop), opacity .18s var(--quick),
                margin-right .3s var(--pop);
  }
  .who-wrap.is-open .who b { max-width: 14ch; opacity: 1; margin-right: 0; }
}
.bar nav > a:active { transform: translateY(1px); box-shadow: var(--e1); transition-duration: .07s; }

.wordmark {
  font-family: var(--body);
  font-weight: 800; font-size: 2.6rem; letter-spacing: -0.015em;
  color: var(--ink); text-decoration: none; line-height: 1;
}
.wordmark.sm { font-size: 1.2rem; }

/* the canon lockup (injected inline by /static/js/lockup.js) — lettering is
   currentColor; on dark it goes PURE white to match the bean's die-cut border */
.lockup-slot, .wordmark-slot { display: block; color: var(--ink); }
[data-theme="dark"] .lockup-slot, [data-theme="dark"] .wordmark-slot { color: #fff; }
.lockup-slot svg, .wordmark-slot svg { display: block; width: 100%; height: auto; }
h1.wordmark-lockup { margin: 0 auto; }
.wordmark-lockup { width: min(400px, 76vw); }
/* the signed-in bar: the mark still leads, so it carries real size */
.wordmark-lockup.sm { width: 200px; }
@media (max-width: 560px) { .wordmark-lockup.sm { width: 152px; } }

/* THE DIE-CUT BEAN'S LIFT — on the element, NOT inside the .svg file.
 *
 * The bean art used to carry its own <feDropShadow>. Safari rasterises an SVG
 * that contains a filter into an offscreen bitmap and does NOT give that bitmap
 * the device pixel ratio, so every filtered bean arrived on a retina screen at
 * roughly half the resolution it should have — visibly soft next to the page's
 * own text, which is drawn at full device resolution. Measured in WebKit at 2x
 * on the same 119x150 mark: mean edge gradient 29.8 filtered against 52.6
 * unfiltered, p95 84 against 134. Chromium renders both alike, which is why it
 * only ever showed up in Safari.
 *
 * So the file carries only art, and the lift is a CSS filter on the <img>. That
 * is applied to the composited element in device space, where Safari does honour
 * the pixel ratio: the shadow survives and the art stays sharp.
 *
 * Two things follow from the units. A CSS filter here measures in CSS pixels
 * rather than the viewBox's user units, so the shadow no longer scales itself
 * with the artwork and each size picks the step of the elevation scale that
 * matches it — which is what the scale is for. And --fe* is theme-aware, so the
 * bean finally gets a shadow that works on a dark background too; the baked one
 * was a fixed near-black at 20% and simply vanished there.
 *
 * NOT usable for the lockup: its bean and its lettering are siblings in one
 * document and only the bean is meant to lift, and WebKit computes but never
 * paints a CSS filter on an SVG child element (verified: zero shadow pixels
 * against a no-shadow control). The lockup keeps its baked filter, and being
 * injected inline it takes a far smaller hit — 43.8 against 52.6 — than an
 * <img> does. */
.bean-art { filter: var(--fe2); }
/* past a few hundred pixels the small step stops reading as contact */
.bean-art.lg { filter: var(--fe3); }

/* ---------- shared controls ---------- */
button, .primary {
  cursor: pointer; border: 0;
  /* Safari still paints its native button bezel under an author background —
     visible as a pale disc behind anything we make transparent. */
  -webkit-appearance: none; appearance: none;
  -webkit-tap-highlight-color: transparent;
  background: var(--vinyl); color: var(--ink);
  padding: 0 46px; border-radius: 9999px;   /* matches the primary's rhythm */
  height: 44px; line-height: 44px;
  font-family: var(--loud); font-weight: 400; font-size: .82rem;
  text-transform: uppercase; letter-spacing: .02em;
  /* Centring measured, not guessed: line box 44, Archivo Black ascent 12 /
     descent 3, so half-leading puts the baseline at 26.5 and the caps
     (ascent 9.2, no descenders in an all-caps label) centre at exactly 22 —
     the button's own centre. But a geometrically centred cap row READS high
     (the eye weights the mass below the optical middle), so it takes a 1px
     correction down — measured centre 22.0, optical centre 23.0. */
  padding-top: 1px;
  box-shadow: var(--shadow);
  text-decoration: none; display: inline-block;
  transition: transform .26s var(--pop), box-shadow .26s var(--quick);
}
/* CONTROLS ARE NOT TEXT. A drag on a slider, a long-press on a chip, a swipe
   across a peel — none of those are selection gestures, but a browser treats
   them as one and puts handles over the UI. Chrome opts out; CONTENT (receipts,
   order details, addresses — anything worth copying) stays selectable, and so do
   the fields you type into, which live inside some of these. */
button, .primary, .peel, .chip, .chips, .q-chip, .tool, .tool-row,
.segmented, .stepper, .budget, .finishes, .oc-status, .mode-toggle,
.bar nav, .who, .cal-strip, .pi-pill, .pi-answer, .ed-stage, label {
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
}
input, textarea, [contenteditable] {
  user-select: text; -webkit-user-select: text;
}

/* peel up on hover, SLAP down on press: shadow collapses to contact as the
   sticker meets the sheet, then springs back with overshoot on release.
   .bare opts a button out of every surface treatment — background, shadow,
   lift. Without it these rules outrank a plain `.thing:hover` override (the
   :not() counts a class), so a chrome-less button still cast a rectangular
   shadow with nothing above it to justify the shape. */
button:not(.peel-front):not(.bare):not(.link):hover, .primary:not(.peel-front):not(.bare):not(.link):hover {
  transform: rotate(-1.2deg); box-shadow: var(--e3); }
/* PRESS — slap flat: the one active state for anything that doesn't peel */
button:not(.peel-front):not(.bare):not(.link):active, .primary:not(.peel-front):not(.bare):not(.link):active {
  transform: scale(.97);
  box-shadow: var(--e1);
  transition-duration: .07s;
}
/* chrome-less: no surface, no shadow, no lift — the content is the control */
button.bare { background: none; box-shadow: none; }
button.bare:hover, button.bare:active, button.bare:focus { box-shadow: none; }
button.primary, .primary {
  position: relative;
  border-radius: 9999px;          /* the CTA is a pill; peel.js clamps the
                                     radius to half the short side for the
                                     fold's corner correction */
  /* the fold eats the left corner, so the label needs a lead-in clear of the
     crease. It must be here, not on a .peel-scoped rule: peel.js measures the
     button before wrapping it, and a post-wrap rule would pin the old width. */
  padding-left: 46px;
  padding-right: 46px;
  /* the CTA speaks in the loud register — it is the one thing on the page
     asking to be pressed */
  font-family: var(--loud); font-weight: 400;
  text-transform: uppercase; letter-spacing: .02em;
  background: var(--reg-cyan); color: #1a1b1e;   /* ink on a vivid, per the palette rule */
  box-shadow: var(--shadow);
}
/* ---------- THE PEEL ----------
   1:1 port of Šehovac's "Sticker Peel v2" (docs/peel-reference.sass), with the
   Sass compile-time trig replaced by native CSS math so every element computes
   its own geometry live. How it works:

     container  a square sized to the sticker's DIAGONAL (hypot), centered on
                the sticker, so nothing clips during rotation
     rot        rotated by the peel angle — this puts the crease on the X axis
     clip       overflow:hidden; translateX slides its left edge — that edge
                IS the crease sweeping across the sticker
     front      the element itself, counter-rotated and counter-translated so
                the artwork stays perfectly still while the crease moves
     back       the underside: same box rotated peel-angle+180° and mirrored
                across the crease — the vinyl genuinely turns over

   Every box shares one center, so default transform-origins make the math
   hold. Peel amount --p (0..1 of the sweepable span) is the single animated
   value; everything derives. Angle restricted to (0°, 90°] for now (the pen's
   other quadrants need sign fixups — see reference). NO shadows/elevation on
   peeled surfaces: the pen is flat color, and the motion carries the depth. */
@property --p  { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --w  { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --h  { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --pr { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --pa { syntax: "<angle>";  inherits: true; initial-value: 45deg; }
@property --orb  { syntax: "<angle>"; inherits: false; initial-value: 0deg; }

.peel {
  position: relative; display: inline-block;
  width: var(--w); height: var(--h);
  -webkit-tap-highlight-color: transparent;
  --peel-amt: .5;                 /* how far hover peels (0..1 of the sweep) */
  --p: 0;
  transition: --p .55s var(--settle);
  /* derived geometry — the pen's formulas verbatim */
  --size: hypot(var(--w), var(--h));
  --offT: calc((var(--size) - var(--h)) / 2);
  --offL: calc((var(--size) - var(--w)) / 2);
  --raf: calc(90deg - var(--pa));
  --diag: atan2(var(--h), var(--w));
  --afix: calc(var(--raf) + var(--diag));
  --offD: calc((var(--size) - sin(var(--afix)) * var(--size)) / 2);
  --offR: calc((var(--pr) * sqrt(2) - var(--pr)) * sin(calc(var(--pa) * 2)));
  --off: calc(var(--offD) + var(--offR));
  --offB: calc(var(--off) - var(--size));
  --pt: calc((var(--size) - var(--off) * 2) * var(--p));
  --ab: calc(var(--pa) + 180deg);
}
.peel:hover, .peel.is-peeled { --p: var(--peel-amt); }
.peel:active { --p: 0; transition-duration: .12s; }
.peel * { backface-visibility: hidden; }
/* The scaffolding is a square sized to the sticker's DIAGONAL — far bigger
   than the sticker and completely transparent. Left hit-testable it catches
   the pointer well away from anything visible, so hover fires over empty
   space. Only the painted faces should be hoverable (the pen does the same:
   `* { pointer-events: none }` + `front, back { pointer-events: all }`). */
/* an outline on the face is drawn on its UNCLIPPED box, so it traces straight
   through the peeled corner — a stray ring hanging in the gap. The ring belongs
   to the whole control, so it goes on the wrapper. */
.peel-front:focus, .peel-front:focus-visible { outline: none !important; }
.peel:has(.peel-front:focus-visible) {
  outline: 2.5px solid var(--focus-ring); outline-offset: 3px;
  border-radius: var(--peel-radius, 12px);
}
.peel-c, .peel-r, .peel-clip { pointer-events: none; }
.peel-front, .peel-back { pointer-events: auto; }
/* the lifted corner is still part of the control — it should feel like one */
.peel:has(button) .peel-back, .peel:has(a) .peel-back { cursor: pointer; }

/* THE POINTER — over anything you can press, the bean's own horns hand takes
   over from the arrow. Hotspot at the raised fingertip; every rule keeps a
   real fallback so the control is never left without a cursor. */
button:not(:disabled), .primary, a[href], .q-chip, .switch, .segmented label,
.peel-back, .pi-field, summary, [role="button"] {
  cursor: url('/static/cursor-horns.png') 16 1, pointer;
}
input[type=range]::-webkit-slider-thumb { cursor: url('/static/cursor-horns.png') 16 1, grab; }
/* EXCEPT IN THE TOOLBAR. The hand is a good joke on a page you are reading and a
   bad one on an instrument you are working with: a 32px cartoon fist over a row
   of 22px swatches covers the thing it is pointing at, and the whole reason
   these controls are small and close together is that you go between them
   quickly. Ordinary arrow in the tools, hand everywhere else — including the
   button at the bottom that actually sends the sticker. */
.ed-dock button:not(:disabled), .ed-dock .q-chip, .ed-dock label,
.ed-dock summary, .ed-dock [role="button"] { cursor: pointer; }
.ed-dock input[type=range]::-webkit-slider-thumb { cursor: grab; }
.ed-dock input[type=range]:active::-webkit-slider-thumb { cursor: grabbing; }
/* once you've clicked in and started typing, the hand gets out of the way —
   it comes back the moment the pointer moves (see js/cursor.js) */
.cursor-idle, .cursor-idle * { cursor: none !important; }
.peel-c {
  /* block, not the default inline: width/height don't apply to an inline box, so
     the scaffold collapsed to zero and took its static position from the
     inherited text-align — inside a centred container that slid the whole peel
     half its own width to the right. As a block it always starts at the peel's
     own left edge, whatever the text alignment around it. */
  display: block;
  position: relative; transform-style: preserve-3d;
  width: var(--size); height: var(--size);
  top: calc(-1 * var(--offT)); left: calc(-1 * var(--offL));
}
.peel-r {
  position: absolute; top: 0; left: 0;
  width: var(--size); height: var(--size);
  transform: rotate(var(--pa));
}
.peel-clip {
  position: absolute; top: 0; left: 0;
  width: var(--size); height: var(--size);
  overflow: hidden;
  transform: translateX(calc(var(--off) + var(--pt)));
}
.peel-front, .peel-back {
  /* a peeled control is pinned to its measured width, and text-wrap: balance
     will happily split a label that used to sit on one line */
  white-space: nowrap;
  transition: none !important;   /* --p drives these; a second clock = bounce */
  position: absolute !important;
  left: var(--offL) !important; top: var(--offT) !important;
  width: var(--w) !important; height: var(--h) !important;
  margin: 0 !important; box-shadow: none !important;
}
.peel-front { filter: none; }  /* components' elevation stays off inside a peel */
.peel-front {
  z-index: 1;
  transform: rotate(calc(-1 * var(--pa)))
             translateX(calc((var(--off) + var(--pt)) * cos(var(--pa)) * -1))
             translateY(calc((var(--off) + var(--pt)) * sin(var(--pa)) * -1));
}
.peel-back {
  z-index: 2;
  border-radius: var(--peel-radius, 0);
  /* dimension on the flap: light gets trapped where the vinyl bends, so the
     underside darkens into the crease and clears toward the flipped tip.
     Run the gradient tip -> fold (pa + 90deg, since the back's transform
     R(pa+180)·diag(1,-1) makes fold->tip the local (-cos pa, -sin pa)) and
     place the stops in PIXELS from the tip: the flap is exactly --pt long,
     so the crease always lands on the last stop no matter the shape, angle,
     or sweep. Percentage stops would key off the bounding box instead, which
     put the shading outside the visible flap entirely. */
  --pb: var(--peel-back, var(--vinyl-deep));
  /* the fold traps light: darkest where the vinyl bends (the crease),
     clearing toward the flipped tip.
     ANCHORING (learned the hard way): a gradient's line starts at the
     ELEMENT'S BOX corner, not at the flap — so stops measured from 0 land
     arbitrarily per shape/angle, which left grape's whole flap past the ramp
     (a uniform dark slab) and vinyl's ramp clipping one corner. The flap
     always occupies the LAST --pt of the line, so anchor from 100%. Measured:
     the 100% end is the flap's TIP and (100% - pt) is the CREASE, so the dark
     stop sits at (100% - pt) and clears a third of the way out.
     The angle carries (pa - 90deg) because scaleY(-1) — the mirror that makes
     an asymmetric silhouette flip correctly — also mirrors the gradient. */
  background:
    linear-gradient(calc(var(--pa) - 90deg),
      color-mix(in oklab, var(--pb) 93%, black) calc(100% - var(--pt)),
      transparent calc(100% - var(--pt) * .66)),
    var(--pb);
  /* complex silhouettes: mask the underside with the shape's own alpha. the
     mask lives in the back's LOCAL space, so the mirror transform flips it
     exactly the way flipping real vinyl would. */
  -webkit-mask: var(--peel-mask, none) center / contain no-repeat;
  mask: var(--peel-mask, none) center / contain no-repeat;
  /* the pen composes wrapper-rotate(pa) + back-rotate(pa+180) = a ROTATION by
     2pa+180. For a solid underside that is indistinguishable from a mirror, but
     an asymmetric silhouette comes out rotated (the bean landed feet-up). A
     reflection across the crease is R(2pa+180)·diag(1,-1), so scaleY(-1) —
     applied first, about the box centre, which leaves the position math
     untouched — turns the rotation into the true mirror. */
  transform: rotate(var(--ab))
             translateX(calc((var(--offB) + var(--pt)) * cos(var(--ab))))
             translateY(calc((var(--offB) + var(--pt)) * sin(var(--ab)) * -1))
             scaleY(-1);
}
/* known fills -> their -deep underside (peel.js computes a fallback otherwise) */
.peel:has(.q-chip.is-on) { --peel-back: var(--reg-cyan-deep); }
.peel:has(.badge)        { --peel-back: var(--magenta-deep); }
/* PRIMARY CTA — the one control that peels, built like the peel input (which
   reads right): a corner ALREADY lifted at rest so the invitation is visible,
   opening further on hover and slapping flat on press. The flap shows the
   sticker's own back — matte liner, not transparent; with nothing to see the
   corner just vanishes and reads as a rendering fault. One curve, one clock. */
.peel:has(.primary) {
  /* the back of the button's own vinyl, not the sheet: --ink-deep is the face
     colour one step further from the light, and it already inverts with the
     mode (near-black under a dark face in light mode; a soft grey under the
     light face at night) */
  --peel-back: var(--reg-cyan-deep);
  --pa: 34deg;

  --p: 0;                         /* flat until you reach for it */
  --peel-amt: .13;
  transition: --p .3s var(--settle);
}
.peel:has(.primary):hover { --p: var(--peel-amt); }
/* NO press-flatten on a peeling CTA: :active fires on mousedown, so the corner
   slapped flat and the outcome's tug then started from zero — reading as a
   flicker through the hover state. The fold's own answer (fly off, or tug) is
   the press feedback. */

/* ---------- CTA outcomes (see js/cta.js) ----------
   Success: the sticker turns green, finishes peeling and leaves — revealing
   the confirmation that was printed underneath all along. Failure: it tugs,
   flushes red, settles back, and says why. */
.cta { position: relative; display: inline-block; isolation: isolate; }
.cta .peel { position: relative; z-index: 1; }   /* the sticker sits ON the message */
.cta .primary { transition: background .28s var(--quick), color .28s var(--quick); }
.cta.is-ok .primary { background: var(--good); color: #1a1b1e; }
.cta.is-ok .peel { --peel-back: var(--good-deep) !important; }
.cta.is-flash .primary { background: var(--bad); color: #1a1b1e; }
.cta.is-flash .peel { --peel-back: var(--bad-deep) !important; }
/* the confirmation sits where the button was, printed on the sheet */
.cta-msg {
  position: absolute; inset: 0; display: flex; align-items: center;
  justify-content: center; gap: 8px; white-space: nowrap; pointer-events: none;
  font-family: var(--ui); font-weight: 600; font-size: .82rem;
  color: var(--good-text); opacity: 0; z-index: 0;
}
/* no transition: it is uncovered, not faded in */
.cta.is-ok .cta-msg { opacity: 1; }
.cta-check { width: 18px; height: 18px; flex: none; background: var(--good-text);
  -webkit-mask: url('/static/icons/ui/checkmark.svg') center / contain no-repeat;
  mask: url('/static/icons/ui/checkmark.svg') center / contain no-repeat; }
/* the refusal sits below, clear of the fold (which hangs down-left) */
.cta-err {
  position: absolute; top: 100%; left: 0; right: 0; margin-top: 12px;
  display: flex; align-items: center; justify-content: center; gap: 6px;
  font-family: var(--ui); font-weight: 500; font-size: .72rem; line-height: 16px;
  color: var(--bad-text); opacity: 0; pointer-events: none; white-space: nowrap;
  transition: opacity .2s var(--quick);
}
.cta.is-no .cta-err { opacity: 1; }
.cta-x { width: 14px; height: 14px; flex: none; background: var(--bad-text);
  -webkit-mask: url('/static/icons/ui/dismiss.svg') center / contain no-repeat;
  mask: url('/static/icons/ui/dismiss.svg') center / contain no-repeat; }
.cta-fly {
  transition: transform .9s cubic-bezier(.32, .72, .36, 1),
              opacity .9s cubic-bezier(.6, 0, .9, 1) !important;
  transform: translate(88vw, -54vh) rotate(30deg) scale(.84);
  opacity: 0; pointer-events: none;
}
/* ON A CARD, CONTROLS RECESS. A raised white pill on a white card is white on
   white held apart by a shadow — it reads as floating debris. On the liner a
   control is a sticker (vinyl + shadow); on vinyl it becomes a well, which is
   the treatment the finishes already used and the reason they read cleanly. */
.diecut button:not(.primary), .a-card button:not(.primary),
.sticker-row button:not(.primary), .modal-card button:not(.primary) {
  background: var(--well-card);
  box-shadow: none;
}
/* TOUCH — the one hover for utility controls: the sticker shifts a degree
   under your finger. No lift, no scale, no hue wash (a part-strength tint of
   the accent reads as mud, and colour is reserved for STATE, not feedback). */
.diecut button:not(.primary):hover, .a-card button:not(.primary):hover,
.sticker-row button:not(.primary):hover, .modal-card button:not(.primary):hover,
.tool:hover {
  background: var(--well-hover);
  transform: rotate(-1.2deg);
  box-shadow: none;
}
.diecut button.link, .a-card button.link,
.sticker-row button.link, .modal-card button.link { background: none; }

/* Utility roles step out of the loud register — they are not asking to be
   pressed, they are just there. The tool names on the rail are the same kind of
   thing, a word naming a control, so they take this type rather than a second
   definition of it. */
button.link, button.tool, button.q-chip, .ed-tool-name {
  font-family: var(--ui); font-weight: 500; text-transform: none; letter-spacing: 0;
}
button.link { border: 0; background: none; box-shadow: none; padding: 4px; }
button.link:hover, button.link:active { box-shadow: none; }
button.danger, .danger {
  color: var(--bad-text);
  transition: background .16s var(--quick), color .16s var(--quick),
              transform .26s var(--pop), box-shadow .2s var(--quick);
}
/* the one control whose hover changes COLOUR: a destructive action should
   commit to what it is the moment you reach for it. Ink on red, not white —
   the palette rule holds even here (#1a1b1e on --bad clears AA). */
button.danger:hover, .danger:hover,
.diecut button.danger:hover, .a-card button.danger:hover,
.sticker-row button.danger:hover, .modal-card button.danger:hover {
  background: var(--bad); color: #1a1b1e;
}
button.danger:hover .ico, .danger:hover .ico { background: #1a1b1e; }
button:focus-visible, a:focus-visible, input:focus-visible {
  outline: 2.5px solid var(--focus-ring); outline-offset: 2px;
}

input, select {
  height: 52px; padding: 0 24px; border-radius: 9999px; font-size: 1rem;
  border: 0;
  background: var(--liner-deep); color: var(--ink); width: 100%;
  font-family: var(--body); font-weight: 500;
  transition: background .18s var(--quick), color .18s var(--quick);
}
input::placeholder { color: var(--ink-soft); }
/* focus is a COLOUR BLOCK, not a ring — a whole-field fill is a bigger, higher
   contrast indicator than a hairline, and keeps the no-strokes rule. It fills
   SOLID with the interaction hue: a part-strength mix landed halfway between
   the well and the cyan and read as muddy slate. Same behaviour as a chip
   turning on. */
input:not([type=range]):focus, select:focus {
  outline: none;
  background: var(--reg-cyan);
  color: #1a1b1e;
  caret-color: #1a1b1e;
}
input:not([type=range]):focus::placeholder { color: rgba(26, 27, 30, .5); }

/* the range is drawn entirely by us — the native track carries a border that
   no amount of background styling removes */
input[type=range] {
  -webkit-appearance: none; appearance: none;
  height: 32px; padding: 0; background: none; border: 0; outline: none;
}
/* two-tone: chosen behind the thumb, empty ahead of it (--pct from range.js) */
input[type=range]::-webkit-slider-runnable-track {
  height: 8px; border-radius: 9999px; border: 0;
  background: linear-gradient(to right,
    var(--reg-cyan) 0 var(--pct, 50%), var(--liner-deep) var(--pct, 50%) 100%);
}
input[type=range]::-moz-range-track {
  height: 8px; border-radius: 9999px; border: 0;
  background: linear-gradient(to right,
    var(--reg-cyan) 0 var(--pct, 50%), var(--liner-deep) var(--pct, 50%) 100%);
}
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 24px; height: 24px; margin-top: -8px; border: 0;
  border-radius: 50%; background: var(--reg-cyan); box-shadow: var(--e1);
  cursor: grab;
}
input[type=range]::-moz-range-thumb {
  width: 24px; height: 24px; border: 0;
  border-radius: 50%; background: var(--reg-cyan); box-shadow: var(--e1);
  cursor: grab;
}
input[type=range]:active::-webkit-slider-thumb { transform: scale(.94); cursor: grabbing; }

/* ---------- gate + collage ---------- */
.gate-page { overflow: hidden; }
#collage { position: fixed; inset: 0; z-index: 0; }
.collage-slap {
  position: absolute; max-width: 132px; max-height: 132px;
  pointer-events: none;
  filter: drop-shadow(0 1px 1px rgba(23,24,26,.14)) drop-shadow(0 3px 6px rgba(23,24,26,.10));
  animation: slap-in .62s var(--settle) backwards;
}
/* SLAP-IN — a sticker thrown at the sheet: arrives big and high, overshoots
   past flat (squash on impact), settles. Shadow collapses as it lands. */
@keyframes slap-in {
  0%   { transform: scale(1.4) rotate(4deg); opacity: 0; }
  55%  { opacity: 1; }
  70%  { transform: scale(.94) rotate(-1deg); }
  85%  { transform: scale(1.02) rotate(.4deg); }
  100% { transform: scale(1) rotate(0); opacity: 1; }
}
/* THE GATE — no card. The lockup, its tagline, and the peel field, on the
   liner. The tagline is set right-aligned in two lines and tucked under the
   wordmark so it sits just left of the p's descender: it reads as part of the
   mark rather than a caption beneath it. */
/* The gate is one screen and always has been — there is nothing below the fold
   to reach. Fixing the page kills both the drag that collapses Safari's URL bar
   and the rubber-band underneath it, which are the only reasons it ever moved.
   The gate itself still centres in --vvh, so the keyboard case is unaffected. */
body.gate-page {
  position: fixed; inset: 0; overflow: hidden;
  overscroll-behavior: none;
}

.gate {
  /* --vvh tracks the VISUAL viewport (see index.html): on iOS the keyboard does
     not shrink the layout viewport, so svh/dvh both keep centring the gate
     behind the keyboard. Centring inside the visible height instead keeps the
     field where you can read what you're typing. */
  min-height: var(--vvh, 100svh);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 64px; padding: 32px 16px;
}
/* with a keyboard up there is far less room — tighten before anything clips */
@media (max-height: 680px) { .gate { gap: 32px; padding: 16px; } }
@media (max-height: 520px) { .gate { gap: 18px; } .gate-tag { display: none; } }
.gate-mark { width: min(568px, 88vw); }
.gate-mark .lockup-slot { width: 100%; }
/* The lockup's box carries the bean's drop-shadow padding on its right edge and
   nothing on its left, so the INK sits 1.41% of the width left of the box's
   centre (measured off the rendered alpha bbox). Flex centres the box; this
   centres what the eye actually reads. The tagline rides along, keeping the
   placement dialled in at /tagline-tuner. */
.gate-mark { position: relative; transform: translateX(1.41%);
  /* the tagline is locked to the MARK, not to the page — see .gate-tag */
  container-type: inline-size; }
/* the tagline is placed, not flowed: --tag-x / --tag-y / --tag-size are the
   three knobs (see /tagline-tuner to dial them in) */
.gate-tag {
  position: absolute;
  right: var(--tag-x, 50%);
  top: calc(100% + var(--tag-y, -4.930cqw));
  margin: 0;
  text-align: right; white-space: nowrap;
  font-family: var(--ui); font-weight: 500;
  /* cqw = 1% of the mark's width, so the placement dialled in at 568px holds
     at every size: 1.17rem -> 3.296cqw, -28px -> -4.930cqw, x already a %. */
  font-size: var(--tag-size, 3.296cqw); line-height: 1.35; color: var(--ink-soft);
}

/* The mode toggle. ONE behaviour wherever it appears — the orbit below. It used
   to have two (a bloom on the gate, the orbit in the bar), which is two answers
   to the same question and twice the surface to keep in agreement. */
.mode-toggle {
  position: fixed; top: 16px; right: 16px; z-index: 3;
  padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
}
/* signed in, the switch tucks in beside the mark instead of floating in the
   corner — same component, placed by its context */
.bar-mark { display: flex; align-items: center; gap: 6px; }
/* the mark paints over the sky: the bodies swing BEHIND the bean rather than
   across it, so the orbit reads as depth instead of overlap */
.bar-mark .lockup-slot { position: relative; z-index: 1; }
.bar .mode-toggle {
  /* relative, NOT static: the sticker is an absolutely-positioned ::after, so a
     static button hands it off to some ancestor and it paints full-size in the
     wrong place */
  position: relative;
  /* z-index belongs HERE, not on the component: it exists so the bodies swing
     behind the bar's mark. Applied globally it put the gate's toggle behind the
     page and swallowed its clicks. */
  z-index: 0;
  /* offset, not margin: a negative margin on a centred flex item gets eaten
     by the centring maths, so only half the nudge lands. top/left move it
     exactly, and leave the bar's layout alone. */
  top: -18px; left: -22px;
}
/* a little sky, and the bodies are in ORBIT. The two stickers sit at opposite
   ends of one diameter, the orbit's centre pinned to the control's bottom edge,
   so the body you're in rests at the TOP of the circle and its opposite waits
   directly below — out of sight under the horizon that overflow:hidden cuts.
   Hovering turns the whole wheel a half turn: one arcs down and sets, the other
   swings up and rises. Each body counter-rotates by the same angle so it stays
   upright the whole way round — a setting sun doesn't roll over. */
.mode-toggle {
  /* geometry: hub on the bottom edge, radius R, body S. The resting body needs
     H >= R + S/2 to clear the top, and the waiting one needs R >= S/2 to stay
     fully under the horizon. The diameter also has to CLEAR the bodies: they sit
     2R apart, so 2R must exceed S or they collide crossing the horizon. S=30,
     R=24 puts 48px between them, and H = R + S/2 = 41 — plus 2px of headroom,
     since --pop overshoots and would otherwise shave the top of the resting
     body against the box edge. */
  width: 52px; height: 43px;
  /* clip-path, not overflow:hidden. Safari falls back to a RECTANGULAR clip once
     content inside a rounded overflow:hidden box is composited — which an
     animating body is — so the horizon showed up as a square during the orbit.
     A clip-path is honoured by the compositor as the shape it actually is. */
  clip-path: inset(0 round 9999px);
}
.mode-toggle:hover { transform: none; }
.mode-toggle::before, .mode-toggle::after {
  content: ""; position: absolute; width: 34px; height: 34px;
  left: 50%; top: 100%; margin: -17px 0 0 -17px;  /* centred on the orbit's hub */
  background-position: center; background-size: contain; background-repeat: no-repeat;
  /* the gate's version hides its sticker until hover; here the horizon does the
     hiding, so both bodies stay fully painted and simply orbit out of view */
  opacity: 1;
  /* and no drop-shadow: overflow:hidden crops a filter to the box, so an
     elevation that follows the die-cut edge would read as a grey rectangle */
  filter: none;
  transform: rotate(var(--orb)) translateY(-24px) rotate(calc(-1 * var(--orb)));
  transition: --orb .62s var(--pop);
}
.mode-toggle::after  { background-image: var(--mode-sticker);     --orb:   0deg; }
.mode-toggle::before { background-image: var(--mode-sticker-alt); --orb: 180deg; }
/* .is-turning is the tap's stand-in for a hover: no pointer to arrive on a touch
   screen, so the turn is driven from the click instead. */
.mode-toggle.is-turning::after  { --orb: 180deg; }
.mode-toggle.is-turning::before { --orb: 360deg; }
/* And the hover half is gated to devices that actually hover. iOS leaves :hover
   STUCK on whatever you last tapped, so an ungated rule kept the wheel turned
   after the tap finished — showing the body you'd just left. A moon in light
   mode, a sun in dark: the toggle reading exactly backwards. */
@media (hover: hover) {
  .mode-toggle:hover::after  { --orb: 180deg; }
  .mode-toggle:hover::before { --orb: 360deg; }
}
/* just clicked, pointer still on it: hold the new sky rather than run the orbit
   backwards. Must follow the :hover rules — same weight, later wins. */
.mode-toggle.is-settled::before, .mode-toggle.is-settled::after { transition: none; }
.mode-toggle.is-settled::after  { --orb:   0deg; }
.mode-toggle.is-settled::before { --orb: 180deg; }
/* show the mode you're IN: a sun on a light sheet, a moon on a dark one */
.mode-toggle { --mode-sticker: url('/static/sun-sticker.svg');
  --mode-sticker-alt: url('/static/moon-sticker.svg'); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .mode-toggle { --mode-sticker: url('/static/moon-sticker.svg');
    --mode-sticker-alt: url('/static/sun-sticker.svg'); }
}
[data-theme="dark"] .mode-toggle { --mode-sticker: url('/static/moon-sticker.svg');
  --mode-sticker-alt: url('/static/sun-sticker.svg'); }
[data-theme="light"] .mode-toggle { --mode-sticker: url('/static/sun-sticker.svg');
  --mode-sticker-alt: url('/static/moon-sticker.svg'); }

/* THE MODE CHANGE.
   One cross-fade of the whole page, not a transition on every element in it.
   The page is snapshotted before and after and the two are dissolved — so the
   sheet, the mark, the icons and the stickers all travel on the same clock by
   construction, with nothing left to fall out of step.
   It replaces a blanket `html.is-theming *` rule that gave every element on the
   page its own 500ms colour transition. That was uniform on paper and wrong in
   practice: .ico is a masked box filled with currentColor, so animating its
   colour makes the mask re-rasterise every frame, and hundreds of elements were
   being asked to do it at once — the icons and the chip flickered. The wordmark
   lagged for a related reason: it's fill="currentColor" inside an SVG, which
   doesn't re-resolve per frame while a colour transition runs on its parent.
   A snapshot has neither problem. Nothing is animating; two pictures dissolve.
   Where view transitions aren't supported the change simply lands, which is
   what it did before any of this. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: .5s;
  animation-timing-function: ease;
}
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}
.gate-msg { min-height: 1.3em; color: var(--bad-text); font-family: var(--ui); font-size: .8rem; }

/* the quiet door */
#moon {
  position: fixed; right: 16px; bottom: 16px; z-index: 2;
  width: 16px; height: 16px; opacity: .22; background: var(--ink-soft);
  --ico: url('/static/icons/ui/moon.svg');
  -webkit-mask: var(--ico) center / contain no-repeat;
  mask: var(--ico) center / contain no-repeat;
  transition: opacity .25s;
}
#moon:hover, #moon:focus-visible { opacity: .9; }

/* ---------- studio ---------- */
.studio { max-width: 620px; margin: 0 auto; padding: 18px 16px 60px; }

/* ---------- holo: the one gradient, in motion ----------
   --holo is rationed — it is THE holographic gradient, and this doesn't invent
   another. Foil doesn't change colour, it changes with ANGLE: stretch the same
   gradient wider than its box and drift it back and forth, and you get the walk
   of the spectrum you see tilting a real card. `alternate` matters — the token's
   two ends don't match, so a one-way loop would snap at the wrap; tilting back
   is both seamless and what a hand actually does. A specular sweep rides over
   the top on its own slower clock so the two never fall into step. */
.holo {
  position: relative; isolation: isolate;
  background: var(--holo);
  background-size: 320% 100%;
  animation: holo-tilt 6.5s ease-in-out infinite alternate;
}
.holo::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  background: linear-gradient(102deg,
    transparent 40%, rgba(255,255,255,.75) 49%, rgba(255,255,255,.3) 55%, transparent 66%);
  background-size: 280% 100%;
  animation: holo-sheen 4.7s cubic-bezier(.45,0,.55,1) infinite;
  pointer-events: none;
}
.holo > * { position: relative; z-index: 1; }   /* content rides above the foil */
@keyframes holo-tilt  { from { background-position:   0% 50%; }
                        to   { background-position: 100% 50%; } }
@keyframes holo-sheen { from { background-position: 130% 0; }
                        to   { background-position: -30% 0; } }
@media (prefers-reduced-motion: reduce) {
  .holo, .holo::after { animation: none; }
}

/* While the card is being matched, nothing else is on stage — the bar included.
   A card stood upright is ~550 CSS px of the ~750 a portrait phone has, so the
   screen has to be given over to it or the page scrolls and you can't hold a
   card against something that moves. */
body.is-calibrating #drop,
body.is-calibrating #cart,
body.is-calibrating .bar { display: none; }
/* an upright card is ~540px of a ~750px screen, so the trailing space the studio
   normally carries is exactly what pushes this over the fold */
body.is-calibrating .studio { padding-bottom: 0; }

/* AND IT TAKES THE TOP LAYER, rather than the surfaces above it being hidden one
   at a time as they get invented.
   The three rules above are an enumerated list of things to park, and the editor
   arrived after that list was written — so choosing "recalibrate screen size"
   from the menu mid-edit put the card behind a fixed, opaque editor at z-index
   50 and looked like the menu item did nothing at all. Naming a fourth surface
   would fix today and rot the same way; a layer above everything doesn't care
   what is open. It also means the edit SURVIVES: calibrate, and the sticker is
   still there underneath exactly as it was, rather than being thrown away to
   make room. */
body.is-calibrating .cal-strip {
  position: fixed; inset: 0; z-index: 200; margin: 0;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 4px;
  padding: 14px 0 6px;
  background: var(--liner);
  overflow: auto; overscroll-behavior: contain;
}
/* and whatever is underneath stands down while it happens */
body.is-calibrating .editor { pointer-events: none; }
/* The peel's scaffold is a square the size of the button's DIAGONAL, so it hangs
   ~108px below the last thing on the page and grows the document even though it
   paints nothing there. Clip at the VIEWPORT, not at the strip: the success
   animation flies the button to translate(88vw, -54vh), and a strip-level clip
   would swallow it two pixels in. Only when the screen is actually tall enough
   for the content — otherwise this would trap it out of reach. */
@media (min-height: 800px) {
  body.is-calibrating { overflow: hidden; }
}

/* ---------- the card sizer ----------
   A browser can't report physical DPI, so the visitor matches a real bank card
   against a drawn one. No container card behind it: a card next to a card
   reads as two cards, and only one of them is the thing being matched.
   A card is 85.6mm on its long edge — about 550 CSS px on a phone, wider than
   the screen — so on a narrow viewport the drawn card stands the long edge
   VERTICAL, where the room is, and the handle drags down instead of across. */
.cal-strip {
  padding: 4px 0 22px; margin-bottom: 18px; text-align: center;
  /* A drag here is a measurement, not a text gesture. Without this iOS grabs the
     label on long-press and puts selection handles over the card. */
  user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
}
.cal-ask { margin: 0; font-family: var(--body); font-weight: 700;
  font-size: 1.05rem; color: var(--ink);
  /* even lines instead of a near-orphan on the second */
  text-wrap: balance; }   /* p defaults to pretty, which fixes orphans but not evenness */
.cal-why { margin: 12px 0 0; font-family: var(--ui); font-size: .78rem;
  color: var(--ink-soft); }
/* the way past. Quieter than the reason above it — this is the exit, not the
   invitation, and it should be findable without being the thing you read first */
.cal-skip { margin: 6px 0 0; font-family: var(--ui); font-size: .74rem;
  color: var(--ink-soft); opacity: .85; }
.cal-skip .link { font-size: inherit; font-family: inherit; padding: 0;
  text-decoration: underline; text-underline-offset: 2px; }
#cal-card {
  position: relative; margin: 22px auto; border-radius: 12px;
  /* a real card is an object resting on the sheet, so it carries elevation */
  box-shadow: var(--e2);
  display: flex; align-items: center; justify-content: center;
  /* fixed dark, not var(--ink): the foil stays light in BOTH themes, so an ink
     that flips with the theme would go white on a pale card */
  color: #33343a; user-select: none;
  font-family: var(--ui); font-size: .78rem; letter-spacing: .02em;
  aspect-ratio: 85.6 / 53.98; max-width: 100%;
}
/* stood on its end. max-width must come OFF: the width is derived from the
   height here, so clamping it would quietly shrink the card below true size —
   the one thing this control cannot get wrong. */
.cal-strip.is-vert #cal-card {
  /* bottom margin clears the drag knob, which hangs 16px below the card */
  aspect-ratio: 53.98 / 85.6; margin: 12px auto 38px; max-width: none;
}
/* The two things that are this size, drawn as layouts and traded back and
   forth. Blocks, not art: enough structure to recognise a card and a licence
   at a glance, not enough to look like a real one. */
.cal-mock { position: absolute; inset: 0; pointer-events: none;
  /* the header band runs edge to edge, so the layer takes the card's corners */
  border-radius: inherit; overflow: hidden; }
/* Stood on its end the card is upright, and so is the real one you hold against
   it — so the layout turns with it. Sized from the card's own ratio inverted,
   which keeps it exact at any drag position. */
.cal-strip.is-vert .cal-mock {
  inset: auto; top: 50%; left: 50%;
  width: 158.58%; height: 63.06%;          /* 85.6/53.98 and its reciprocal */
  transform: translate(-50%, -50%) rotate(90deg);
}
.mock { position: absolute; inset: 0; opacity: 0;
  animation: cal-mock-swap 8s ease-in-out infinite; }
.mock-id { animation-delay: 4s; }
/* b and i are marks and paint themselves; span is only ever a wrapper. Keeping
   that split means no mark can go missing behind a rule meant for a group. */
.mock b, .mock i { position: absolute; display: block;
  background: currentColor; border-radius: 2px; }
.mock span { position: absolute; display: block; }

/* --- a bank card: chip, four groups of digits, name and expiry, the mark --- */
.mock-bank .chip   { left: 9%; top: 26%; width: 13%; height: 20%;
  border-radius: 3px; opacity: .34; }
.mock-bank .digits { left: 9%; right: 9%; top: 60%; height: 8%;
  display: flex; gap: 4.5%; }
.mock-bank .digits i { position: static; flex: 1; opacity: .26; }
.mock-bank .foot   { left: 9%; top: 79%; width: 46%; height: 6%;
  display: flex; gap: 6%; }
.mock-bank .foot i { position: static; flex: 1; opacity: .2; }
.mock-bank .marks  { right: 8%; top: 72%; width: 20%; height: 14%; }
.mock-bank .marks i { top: 0; width: 62%; aspect-ratio: 1;
  border-radius: 50%; opacity: .2; }
.mock-bank .marks i:first-child { left: 0; }
.mock-bank .marks i:last-child  { right: 0; }

/* --- a licence. Header band, portrait, the fields printed under it, a few
   lines alongside. Held to the same handful of marks as the card opposite: the
   job is to be recognised in the second you glance at it, and past about eight
   shapes the extra ones stop adding recognition and start adding texture. What
   went: a document-type bar, four caption-over-value pairs, a ghost portrait,
   a signature strip. None of them were what made it read as a licence. --- */
.mock-id .band  { left: 0; right: 0; top: 0; height: 14%;
  border-radius: 0; opacity: .13; }
.mock-id .title { left: 6%; top: 4%; width: 26%; height: 5.5%; opacity: .38; }
.mock-id .photo { left: 6%; top: 22%; width: 20%; height: 46%;
  border-radius: 3px; opacity: .3; }
/* the fields printed under the portrait — the detail that makes it a licence
   rather than a membership card */
.mock-id .under   { left: 6%; top: 73%; width: 20%; height: 13%; }
.mock-id .under i { left: 0; opacity: .19; }
.mock-id .under i:first-child { top: 0;   width: 100%; height: 30%; }
.mock-id .under i:last-child  { top: 52%; width: 65%;  height: 24%; }
.mock-id .lines { left: 31%; right: 8%; top: 25%; height: 38%;
  display: flex; flex-direction: column; justify-content: space-between; }
.mock-id .lines i { position: static; height: 20%; opacity: .26; }
.mock-id .lines i:nth-child(2) { width: 76%; }
.mock-id .lines i:nth-child(3) { width: 92%; }

/* each holds long enough to read, with a beat of plain foil between them */
@keyframes cal-mock-swap {
  0%, 3%    { opacity: 0; }
  11%, 44%  { opacity: 1; }
  52%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .mock { animation: none; }
  .mock-bank { opacity: 1; }             /* one layout, held still */
}
/* the grip: a knob on the edge you drag, with a rule to show the axis */
#cal-handle {
  position: absolute; right: -16px; top: 50%; transform: translateY(-50%);
  width: 30px; height: 52px; border-radius: 9999px;
  background: var(--reg-cyan); cursor: ew-resize; touch-action: none;
  box-shadow: var(--e2);
  display: flex; align-items: center; justify-content: center;
}
#cal-handle i { width: 3px; height: 20px; border-radius: 2px;
  background: #10222b; opacity: .5; }
.cal-strip.is-vert #cal-handle {
  right: 50%; top: auto; bottom: -16px; transform: translateX(50%);
  width: 52px; height: 30px; cursor: ns-resize;
}
.cal-strip.is-vert #cal-handle i { width: 20px; height: 3px; }
/* THE DROP SQUARE. A sticker is square-ish and so is the thing you drop it on:
   a square reads as a target rather than a banner. It frosts what's behind it,
   so the grid keeps going under the glass instead of being covered by a slab. */
/* 464 = 29 × 16 — an ODD number of cells. With the rules aligned to its edges
   that puts a whole cell at the centre of the square rather than a rule through
   it, on both axes. The studio moves the GRID to the square (see studio.js)
   instead of nudging the square onto the grid: the square is what the page is
   about, so it holds still and the ruling lines up behind it. */
.dropzone {
  /* Sized by studio.js, which is the only place that knows the bar's real
     height and can keep the result an odd number of grid cells. 464 = 29 cells
     is the ceiling, not the answer. */
  width: var(--drop-size, 464px); aspect-ratio: 1;
  margin: 0 auto; padding: 32px 28px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 6px; text-align: center; color: var(--ink-soft); cursor: pointer;
  position: relative; isolation: isolate;
  border-radius: 30px 34px 31px 33px;
  /* no shadow: the square is a pane of glass laid ON the sheet, and glass that
     hovers reads as a second object floating over the grid rather than part of it */
  box-shadow: none;
  /* the edge is the SVG dashes below — a cut line, which is what this square
     stands for. No CSS border at all, so nothing sits under them. */
  border: 0;
  transition: border-color .18s var(--quick);
}
/* The frost is its OWN layer under the content, not a property of the box the
   content sits in. As a property, every nested element that makes a stacking
   context — the peel's scaffold especially — re-samples the backdrop and leaves
   visible seams either side of the button. */
.dropzone::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  border-radius: inherit;
  /* Just enough to read as glass. With no fill left, the blur is the only thing
     marking the square out, and past a few pixels it stops softening the rules
     and starts erasing them. */
  /* no fill at all — the blur alone marks the square out, and the blue edge
     draws it. Any tint here made it a panel sitting on the sheet rather than a
     region of the sheet itself. */
  background: none;
  -webkit-backdrop-filter: blur(3px) saturate(1.08);
  backdrop-filter: blur(3px) saturate(1.08);
  /* promote to its own compositing layer: without this Chrome computes the blur
     in tiles that get split by the composited controls above, and the seams show
     as faint vertical bands either side of the button */
  transform: translateZ(0);
  transition: background .18s var(--quick);
}
.dropzone > * { position: relative; z-index: 1; }
/* the lift is the whole hover response: flooding the square with colour buries
   the grid it was built to show */

/* no lift: the square is aligned to the ruling, and a box that shifts 2px off
   the grid it was placed on breaks the very thing the alignment bought */

/* HOVERING THE SQUARE IS THE INVITATION. The mark and the headline take the
   interaction colour together — one message, not a label and a decoration — and
   the solid edge hands over to a moving dashed one, the way a drop target has
   signalled "let go here" since forever. */
/* scoped under .dropzone for weight: .ico sets width/height/background later in
   the file and wins a bare-class tie */
.dropzone .drop-mark {
  width: 30px; height: 30px; background: var(--ink);
  margin-bottom: 10px;
  transition: background .18s var(--quick);
}
/* -text, not the fill: #00c6ff is mixed to sit UNDER dark ink as a background,
   and used as ink on a pale sheet it comes in around 2:1. The text variant is
   the one that carries per mode — darker on light, lighter on dark. The ants
   below keep the fill, because a line isn't read. */
.dropzone:hover .drop-mark, .dropzone.over .drop-mark { background: var(--reg-cyan-text); }
.dropzone:hover .drop-lead strong, .dropzone.over .drop-lead strong { color: var(--reg-cyan-text); }
.dropzone .drop-lead strong { transition: color .18s var(--quick); }

/* The edge, always dashed — a cut line rather than a frame. An SVG stroke, not a
   CSS border: border-style can't animate, and the rounded corners rule out the
   four-gradient trick. At rest it holds still; hovering sets it marching. */
.drop-ants {
  position: absolute; inset: 0; z-index: 0;
  width: 100%; height: 100%; overflow: visible;
  pointer-events: none;
}
.drop-ants rect {
  opacity: .22;
  x: 0.5px; y: 0.5px; width: calc(100% - 1px); height: calc(100% - 1px);
  rx: 31px; ry: 31px;
  fill: none; stroke: var(--ink-soft); stroke-width: 1;
  transition: stroke .18s var(--quick), opacity .18s var(--quick);
  /* solid at rest — a quiet ruled edge. The dashes only exist while you're
     reaching for it, which is what makes them mean something. The pattern is
     measured onto the path in studio.js; a fixed one leaves a stub dash where
     the path closes. */
  stroke-dasharray: none;
}
/* the edge is quiet ruling until you reach for it, then it takes the colour and
   starts marching — same promise the mark and headline make */
.dropzone:hover .drop-ants rect, .dropzone.over .drop-ants rect {
  stroke: var(--reg-cyan); opacity: 1;
  stroke-dasharray: var(--ant-dash, 9 7);
  animation: ants 700ms linear infinite;
}
/* travel exactly one dash+gap period, so the loop closes on itself */
@keyframes ants { to { stroke-dashoffset: var(--ant-step, -16px); } }



.drop-lead strong {
  font-family: var(--body); font-weight: 700; font-size: 1.3rem; color: var(--ink);
}
.drop-lead { margin: 0; }
.dropzone .hint { margin: 0 0 22px; }
.drop-actions { display: flex; flex-direction: column; align-items: center; gap: 12px; }
/* the secondary sits on glass, not on the sheet — the lightest step in the
   scale is enough to lift it, and the default reads as a slab at this size */
.drop-actions .tool { box-shadow: var(--e1); }
.drop-actions .tool:hover { box-shadow: var(--e2); }
/* blue means live in this square, so the second way in fills with it too —
   reached for, it is the primary action, and only one of them is ever hovered */
.drop-actions .tool:hover {
  background: var(--reg-cyan); color: #10222b;
}
.drop-actions .tool:hover .ico { background: #10222b; }
.drop-actions .tool { transition: background .16s var(--quick), color .16s var(--quick),
                                  box-shadow .26s var(--quick); }
.drop-actions .tool .ico { transition: background .16s var(--quick); }
.dropzone.busy { opacity: .5; pointer-events: none; }

/* The studio's ruling is laid out FROM the drop square: --grid-x / --grid-y are
   set in studio.js to the square's own edges, so the rules meet its corners and a
   cell sits dead centre. Elsewhere the grid keeps its page-anchored origin. */
body.studio-page {
  background:
    linear-gradient(var(--liner-deep) 1px, transparent 1px)
      0 var(--grid-y, -0.5px) / var(--grid) var(--grid),
    linear-gradient(90deg, var(--liner-deep) 1px, transparent 1px)
      var(--grid-x, calc(50% + 7.5px)) 0 / var(--grid) var(--grid),
    var(--liner);
}

/* with nothing uploaded the square IS the page — centre it in what's left of the
   viewport under the bar */
/* Centred in the VIEWPORT, not in what's left under the bar. Centring in the
   remainder put the square half the bar's height low — which reads as a
   mistake, because the eye centres against the window, not against a region. */
body.is-empty:not(.is-calibrating) .studio {
  position: fixed; inset: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 0;
}
/* Centring against the window is right while there's room to spare — the eye
   centres against the window, not against a region. Turn the phone sideways and
   there ISN'T room to spare: the square fills what's left, and centring it in
   the window walks it up under the bar. So when it's tight, the bar's height
   comes out of the box first and the square centres in what remains. */
body.is-tight.is-empty:not(.is-calibrating) .studio { padding-top: var(--bar-h, 0px); }

/* A phone held sideways has ~390px of height and the bar was taking 107 of it,
   most of that the mark. Everything gives a little so the square keeps enough
   room to stay square: at full size the content needs 318px of the 390 before
   the bar has had any. */
body.is-tight .wordmark-lockup.sm { width: 128px; }
body.is-tight .bar { padding: 10px 14px; }
body.is-tight .dropzone { padding: 16px 22px; gap: 2px; }
body.is-tight .drop-actions { gap: 10px; }
body.is-tight .drop-lead strong { font-size: 1.35rem; }
body.is-tight .hint { font-size: .74rem; }
/* ...and with the studio fixed there is nothing left to scroll, so the page is
   pinned the way the gate is. iOS bounces a page that has nothing below the
   fold, and the bounce reads as the layout being loose rather than as the
   browser being springy. The lock lifts the moment there IS something down
   there — a card being sized, or stickers in the cart. */
body.studio-page.is-empty:not(.is-calibrating) {
  position: fixed; inset: 0; overflow: hidden;
  overscroll-behavior: none;
}
.hint { font-size: .8rem; font-family: var(--ui); }

.cart { margin-top: 20px; }

/* the sheet-fill meter: the budget IS a sheet of 5 cells filling up */
/* THE SHEET-FILL METER — the meter IS the sheet: five die-cut cells, filled
   whole. Real pill cells on the grid (32px tall = 2 rows, 8px gutters) rather
   than a striped gradient: a unit is indivisible, so a fill that cut through
   the middle of a cell was lying about the quantity. The count sits below,
   never on the fill — white type on a vivid breaks the palette rule. */
.budget { margin-bottom: 24px; }
.budget-cells {
  display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px;
  height: 32px; margin-bottom: 8px;
}
.budget-cells i {
  border-radius: 9999px; background: var(--liner-deep);
  transition: background .22s var(--quick), transform .26s var(--pop);
}
.budget-cells i.on { background: var(--reg-cyan); }
.budget.is-full .budget-cells i.on { background: var(--magenta); }
.budget span {
  display: block; line-height: 16px;
  font-family: var(--ui); font-weight: 500; font-size: .72rem; color: var(--ink-soft);
}

.sticker-row {
  display: flex; gap: 16px; align-items: flex-start; padding: 16px;
  margin-bottom: 14px;
}
.sticker-row:nth-child(odd) { transform: rotate(.35deg); }
.sticker-row:nth-child(even) { transform: rotate(-.3deg); }
/* The finish is painted ON THE PREVIEW by finish.js — the studio's own laminate
   renderer — rather than approximated here. There was a CSS version: masked
   gradients, a multiply for foil, a screened bar for gloss. It was close and it
   was still wrong, because the real thing pushes the spectrum's chroma, rules a
   grating over it and dodges rather than screens, and none of that survives
   translation. Two implementations of one material is one too many. */
/* A DEFINITE BOX, before there is anything in it. The preview arrives over the
   network and then gets swapped for a canvas wearing its finish; with the box
   sized by its contents, the row was a different height at each of those three
   moments and everything below it jumped twice. Reserving the space costs
   nothing and is the difference between a page settling and a page snapping. */
.preview-wrap {
  flex-shrink: 0; display: flex; justify-content: center; align-items: center;
  min-width: 96px; height: 104px; position: relative;
}
.preview-wrap img, .preview-wrap canvas {
  display: block; max-width: 42vw; height: 104px; object-fit: contain;
  /* A sticker lying on the card, not printed into it. The FILTER form of the
     elevation scale, because that is the one that follows an alpha silhouette —
     a box-shadow here would draw a rectangle around a die cut. */
  filter: var(--fe2);
}
.processing .preview-wrap { opacity: .35; }
.processing .preview-wrap::after {
  content: "printing preview…"; font-family: var(--ui); font-size: .72rem; color: var(--ink-soft);
}
.controls { flex: 1; min-width: 0; }
.ctl-label {
  display: block; font-family: var(--ui); font-size: .74rem; color: var(--ink-soft);
  margin-bottom: 2px;
}
.controls input[type=range] { width: 100%; padding: 0; accent-color: var(--reg-cyan); }
/* No padding override. The base button centres its label by measurement —
   line box = height, plus a 1px optical nudge — and `padding: 14px` kept the
   44px line box while shrinking the content box to 16px, so the label overflowed
   14px past the bottom of the pill and read as sitting on the floor of it.
   Height and line-height move together or not at all. */
#checkout-btn {
  width: 100%; margin-top: 16px; font-size: 1rem;
  height: 52px; line-height: 52px;
}

/* The row's own controls: edit, remove, how many, and what the material costs.
   The stepper is the design system's, one size down to sit in a row of tools
   rather than tower over them; the price is pushed to the far end, where a
   number that changes when you press the stepper can be seen changing. */
.sr-qty { height: 40px; padding: 3px; flex: none; }
.sr-qty button { width: 34px; height: 34px; }
.sr-qty button .ico { width: 15px; height: 15px; }
.sr-qty output { min-width: 26px; font-size: .88rem; }
.sr-price {
  margin-left: auto; align-self: center;
  font-family: var(--ui); font-size: .88rem; font-weight: 600;
  color: var(--ink); font-variant-numeric: tabular-nums;
}
.tool-row { align-items: center; }

.shape-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0 4px; }
/* One size for both, next to the utility-role rule that gives them the rest of
   their type. They had drifted by .04rem and a weight before anyone looked. */
.q-chip, .ed-tool-name { font-size: .72rem; }
.q-chip {
  display: inline-flex; align-items: center; gap: 6px;
  height: 32px; line-height: 32px; min-height: 0;
  padding: 0 18px; border-radius: 9999px; border: 0;
  background: var(--liner-deep); color: var(--ink-soft);
  cursor: pointer;
  box-shadow: none;
  transition: transform .26s var(--pop), background .18s var(--quick),
              color .18s var(--quick), box-shadow .2s var(--quick);
}
.q-chip:hover {
  background: var(--well-hover); color: var(--ink);
  transform: rotate(-1.2deg);
}
.q-chip:active { transform: scale(.97); transition-duration: .07s; }
.q-chip.is-on {
  background: var(--reg-cyan); color: #1a1b1e; font-weight: 600;
  transform: rotate(-1deg);              /* stuck down, slightly askew */
  box-shadow: var(--e1);
}
.q-chip.is-on:hover { transform: rotate(-2.2deg); }
.tool-row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.tool {
  font-family: var(--ui); font-size: .72rem;
  height: 32px; line-height: 32px; padding: 0 20px;
  display: inline-flex; align-items: center; gap: 6px; border-radius: 9999px;
}

.ico { display: inline-block; width: 1em; height: 1em; flex: none;
  vertical-align: -0.12em; background: currentColor;
  -webkit-mask: var(--ico) center / contain no-repeat;
  mask: var(--ico) center / contain no-repeat; }

/* ---------- checkout: the honest receipt ---------- */
.checkout { max-width: 500px; margin: 0 auto; padding: 18px 16px 60px; }
.checkout h2 {
  font-family: var(--body); font-weight: 700; font-size: 1.15rem;
  margin: 26px 0 12px;
}
.checkout form input { margin-bottom: 10px; }
.row3 { display: grid; grid-template-columns: 2fr 1fr 1.2fr; gap: 8px; }
.finishes { display: flex; gap: 8px; flex-wrap: wrap; }
.finishes label {
  display: inline-flex; gap: 7px; align-items: center; cursor: pointer;
  padding: 10.5px 16px; border-radius: 9999px; border: 0;
  background: var(--liner-deep);
  font-family: var(--ui); font-size: .8rem;
}
.finishes label:has(input:checked) { background: var(--reg-cyan); color: #1a1b1e; font-weight: 600; }
.finishes label:last-child:has(input:checked) { background: var(--holo); color: #1a1b1e; }
.finishes input { position: absolute; opacity: 0; width: 0; height: 0; }
.finishes label .ico { width: 16px; height: 16px; background: var(--ink-soft);
  transition: background .18s var(--quick); }
.finishes label:has(input:checked) .ico { background: #1a1b1e; }

/* The tear needs a mask; the elevation needs a drop-shadow. They cannot live
   on the SAME element — a mask clips that element's own filter output, which
   is why the receipt kept rendering flat no matter how heavy the shadow got.
   So: the wrapper carries the shadow, the inner card carries the mask. */
.price-lines {
  max-width: 400px;
  filter: drop-shadow(0 1px 2px rgba(23, 24, 26, .09))
          drop-shadow(0 6px 14px rgba(23, 24, 26, .09));
}
.price-lines > .receipt-paper {
  /* the one place mono belongs: a receipt is a printed artifact, and a column
     of money only lines up when every digit has the same advance width */
  font-family: var(--mono); font-size: .8rem; letter-spacing: -.01em;
  font-variant-numeric: tabular-nums;
  display: block;
  background: var(--vinyl); padding: 20px 20px 28px;
  border-radius: 6px 6px 0 0;
  --tear: conic-gradient(from -45deg at 50% 100%, #000 90deg, transparent 0) 50% 100% / 14px 8px repeat-x;
  -webkit-mask: linear-gradient(#000, #000) 0 0 / 100% calc(100% - 8px) no-repeat, var(--tear);
          mask: linear-gradient(#000, #000) 0 0 / 100% calc(100% - 8px) no-repeat, var(--tear);
}
.price-lines .receipt-paper div { display: flex; justify-content: space-between; gap: 16px;
  padding: 6px 0; line-height: 16px; color: var(--ink-soft); }
.price-lines .receipt-paper div span:last-child { color: var(--ink); }
.price-lines .receipt-paper .total {
  border-top: 1.5px dashed var(--liner-deep); font-weight: 700; margin-top: 8px; padding-top: 10px;
  color: var(--ink);
}
.price-lines .receipt-paper .total span:last-child { color: var(--reg-cyan-text); }
/* The caption is a label ABOVE its field, so it needs the gap that says so —
   without one the question and the answer were a single stacked block and the
   placeholder read as the caption's second line. */
.promo-label { display: block; margin: 16px 0; color: var(--ink-soft); font-size: .88rem; }
.promo-label input { margin-top: 8px; }

.consent { display: flex; gap: 10px; align-items: center; margin: 22px 0; font-size: .9rem; }
/* The global input rule dresses real form fields: 52px tall, 24px of side
   padding, full width. A checkbox is none of those things — it inherited a 52px
   box and drew its little square at the top of it, so the tick floated well
   above the sentence it belongs to however the row was aligned. Give it back its
   own size and the centring works. */
.consent input[type=checkbox] {
  width: 18px; height: 18px; flex: none; margin: 0; padding: 0;
  accent-color: var(--magenta);
}

/* Height and line-height move together or not at all — `padding: 14px` kept the
   44px line box from the base button while shrinking the content box, so the
   label sat on the floor of the pill. Same fix as #checkout-btn above it. */
#submit-btn { width: 100%; font-size: 1rem; height: 52px; line-height: 52px; }
#co-msg { color: var(--bad-text); font-family: var(--ui); font-size: .8rem; }

/* PAYING, made short. The button is for a phone — one tap into Venmo with the
   amount and the order code already filled in — and the code is for the laptop
   you are almost certainly sitting at, where the phone in your pocket is the
   thing that can actually pay. */
/* The button and the code are two routes to the same payment, so they are the
   same width and share a centre — one block you either tap or point a phone at,
   rather than a full-bleed bar with a small square under it. */
.pay-open {
  display: block; width: var(--pay-w, 172px); margin: 4px auto 16px;
  padding: 0; text-align: center;
}
.pay-qr { text-align: center; }
.pay-qr-code {
  width: var(--pay-w, 172px); margin: 0 auto; padding: 12px;
  background: #fff; border-radius: 14px; box-shadow: var(--e1);
}
/* segno emits a viewBox and no size, so it scales to whatever it is given —
   and it is currentColor-free on purpose: a QR needs true black on true white
   to read, in either theme. */
.pay-qr-code svg { display: block; width: 100%; height: auto; }
.pay-qr-note {
  margin: 8px 0 0; font-family: var(--ui); font-size: .78rem; color: var(--ink-soft);
}

/* An order card, opened. The contents read like the cart that made them — same
   facts, same order — because this IS that cart, after the fact. */
.oc-open { margin-top: 12px; }
.oc-body { margin-top: 14px; }
.oc-items { display: flex; flex-direction: column; gap: 12px; }
.oc-item {
  display: flex; align-items: center; gap: 12px;
  font-family: var(--ui); font-size: .82rem; color: var(--ink-soft);
}
.oc-item img {
  width: 54px; height: 54px; object-fit: contain; flex: none;
  filter: var(--fe2);
}
.oc-item strong { color: var(--ink); font-size: .9rem; }
.oc-item-price {
  margin-left: auto; color: var(--ink); font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.oc-pay {
  margin-top: 18px; padding-top: 16px;
  border-top: 1.5px dashed var(--liner-deep);
}
.oc-pay p { font-family: var(--ui); font-size: .86rem; margin: 0 0 10px; text-align: center; }
#venmo-panel { padding: 22px; }

/* ---------- orders ---------- */
.orders { max-width: 500px; margin: 0 auto; padding: 18px 16px 60px; }
.orders h2 { font-family: var(--body); font-weight: 700; letter-spacing: -0.01em; }
.order-card { padding: 16px; margin-bottom: 14px; }
.order-card:nth-child(odd) { transform: rotate(.3deg); }
.order-card:nth-child(even) { transform: rotate(-.3deg); }
.oc-head { display: flex; justify-content: space-between; align-items: center; }
.oc-head strong { font-family: var(--ui); letter-spacing: .02em; }
/* status is the one thing an orderer opens the page to see — so it is a filled
   sticker in the state's own colour, not tinted text on grey. Vivid fill,
   ink label, icon that says the same thing without reading it. */
.oc-status {
  display: inline-flex; align-items: center; gap: 6px;
  height: 32px; padding: 0 16px; border-radius: 9999px;
  font-family: var(--ui); font-weight: 600; font-size: .74rem;
  background: var(--liner-deep); color: var(--ink-soft);
}
.oc-status .ico { width: 15px; height: 15px; }
.oc-status.s-received { background: var(--reg-cyan); color: #1a1b1e; }
.oc-status.s-in_production { background: var(--yellow); color: #1a1b1e; }
.oc-status.s-mailed { background: var(--good); color: #1a1b1e; }
.oc-meta { color: var(--ink-soft); font-family: var(--ui); font-size: .74rem; margin: 8px 0 12px; }
.oc-track { display: flex; gap: 6px; }
.oc-track { display: flex; gap: 8px; }
.oc-step {
  display: inline-flex; align-items: center; gap: 6px;
  height: 36px; padding: 0 16px;
  font-family: var(--ui); font-weight: 500; font-size: .7rem;
  border-radius: 9999px; background: var(--liner-deep); color: var(--ink-soft);
  transition: background .22s var(--quick), color .22s var(--quick);
}
.oc-step .ico { width: 14px; height: 14px; }
.oc-step.on { background: var(--reg-cyan); color: #1a1b1e; font-weight: 600; }
.oc-step.on:nth-child(2) { background: var(--yellow); }
.oc-step.on:last-child { background: var(--good); }

/* ---------- touch-up modal ---------- */
.modal { position: fixed; inset: 0; z-index: 40;
  background: rgba(15, 16, 18, .5); backdrop-filter: blur(5px);
  display: flex; align-items: center; justify-content: center; padding: 14px; }
.modal[hidden] { display: none; }
.modal-card { max-width: 620px; width: 100%; padding: 18px; }
.modal-card h3 { margin: 0 0 12px; font-family: var(--body); font-weight: 700; font-size: 1.05rem; }
.mono-hint { color: var(--ink-soft); font-family: var(--ui); font-size: .7rem; font-weight: 400; }
#tu-canvas { width: 100%; border-radius: 12px; touch-action: none; cursor: crosshair;
  box-shadow: var(--shadow); }
.tu-tools { display: flex; gap: 8px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
.tu-size { font-family: var(--ui); font-size: .72rem; color: var(--ink-soft);
  display: inline-flex; align-items: center; gap: 8px; flex: 1; min-width: 160px; }
.tu-size input { padding: 0; accent-color: var(--reg-cyan); }
.tu-actions { display: flex; justify-content: flex-end; gap: 8px; }

/* ---------- admin: stays in the utility register ---------- */
.admin { max-width: 600px; margin: 0 auto; padding: 16px; }
.admin h2 { font-family: var(--ui); font-size: .85rem; margin: 26px 0 8px; color: var(--ink-soft);
  text-transform: lowercase; letter-spacing: .04em; }
.admin-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.badge { font-family: var(--ui); font-size: .7rem; padding: 3px 11px; border-radius: 9999px;
  background: var(--magenta); color: #1a1b1e; font-weight: 600; }
.a-card { background: var(--vinyl); border-radius: 12px; box-shadow: var(--shadow);
  padding: 13px; margin-bottom: 10px; }
.a-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.a-head strong { font-family: var(--ui); font-size: .85rem; }
.a-meta { color: var(--ink-soft); font-family: var(--ui); font-size: .74rem; margin: 6px 0; }
.a-acts { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.a-acts button { font-size: .78rem; }
.proof { width: 100%; border-radius: 8px; margin-top: 8px;
  box-shadow: var(--shadow); background: #fff; }
.inline-form { display: flex; gap: 8px; }
.inline-form input { flex: 1; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.chip { border: 0; border-radius: 9999px; background: var(--liner-deep);
  padding: 5px 12px; font-family: var(--ui); font-size: .74rem;
  display: inline-flex; gap: 6px; align-items: center; }
.chip.off { opacity: .45; text-decoration: line-through; }
.chip-x { border: 0; background: none; box-shadow: none; padding: 0 2px; color: var(--bad-text); }

/* ---------- responsive + a11y floor ---------- */
@media (max-width: 440px) {
  .sticker-row { flex-direction: column; align-items: stretch; }
  .preview-wrap img { max-width: 68vw; }
  .gate-card { margin-top: 13vh; }
  .gate-card .wordmark { font-size: 2.7rem; }
}
@media (prefers-reduced-motion: reduce) {
  .collage-slap, .gate-card { animation: none; }
  *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; }
}


/* ==================== FORM CONTROLS ====================
   Everything the ordering flow needs, in one voice: pills, colour-block
   states, no strokes anywhere. Each control has an optional icon slot rather
   than a separate iconned twin. */

/* --- field: any input, with an optional leading icon --------------------- */
.field { position: relative; display: block; }
.field > .ico {
  position: absolute; left: 22px; top: 50%; margin-top: -9px;
  width: 18px; height: 18px; background: var(--ink-soft);
  pointer-events: none; transition: background .18s var(--quick);
  z-index: 1;
}
.field.has-icon input, .field.has-icon select { padding-left: 52px; }
.field:focus-within > .ico { background: #1a1b1e; }
.field > .chev {
  position: absolute; right: 22px; top: 50%; margin-top: -9px;
  width: 18px; height: 18px; background: var(--ink-soft);
  pointer-events: none; z-index: 1;
  -webkit-mask: url('/static/icons/ui/chevron-down.svg') center / contain no-repeat;
  mask: url('/static/icons/ui/chevron-down.svg') center / contain no-repeat;
}
.field:focus-within > .chev { background: #1a1b1e; }

select {
  -webkit-appearance: none; appearance: none;
  padding-right: 52px; cursor: pointer;
}

textarea {
  width: 100%; min-height: 112px; padding: 16px 24px; border: 0;
  border-radius: 24px; background: var(--liner-deep); color: var(--ink);
  font-family: var(--body); font-weight: 500; font-size: 1rem; line-height: 1.5;
  resize: vertical; outline: none;
  transition: background .18s var(--quick), color .18s var(--quick);
}
textarea::placeholder { color: var(--ink-soft); }
textarea:focus { background: var(--reg-cyan); color: #1a1b1e; caret-color: #1a1b1e; }
textarea:focus::placeholder { color: rgba(26, 27, 30, .5); }

/* --- stepper: small counts, big targets ---------------------------------- */
.stepper {
  display: inline-flex; align-items: center; gap: 4px;
  height: 52px; padding: 4px; border-radius: 9999px;
  background: var(--liner-deep);
}
.stepper button {
  width: 44px; height: 44px; padding: 0; border-radius: 50%;
  background: var(--raised); color: var(--ink); box-shadow: none;
  display: inline-flex; align-items: center; justify-content: center;
}
.stepper button:disabled { opacity: .35; cursor: not-allowed; }
.stepper button .ico { width: 18px; height: 18px; }
.stepper output {
  min-width: 52px; text-align: center;
  font-family: var(--ui); font-weight: 600; font-size: 1rem; color: var(--ink);
  font-variant-numeric: tabular-nums;
}

/* --- switch: a sticker that slides ---------------------------------------- */
.switch {
  display: inline-flex; align-items: center; gap: 14px; cursor: pointer;
  font-family: var(--ui); font-weight: 500; font-size: .82rem; color: var(--ink);
}
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch .track {
  position: relative; flex: none; width: 56px; height: 32px;
  border-radius: 9999px; background: var(--liner-deep);
  transition: background .2s var(--quick);
}
.switch .track::after {
  content: ""; position: absolute; top: 4px; left: 4px;
  width: 24px; height: 24px; border-radius: 50%;
  background: var(--knob); box-shadow: var(--e2);
  transition: transform .3s var(--pop), background .2s var(--quick);
}
/* ON: the knob goes white — it is a physical part catching the light, not
   type on a fill, so the ink-on-vivid rule doesn't apply to it. (Dark mode
   keeps ink: a white knob there would out-glare the whole night sheet.) */
.switch input:checked + .track { background: var(--reg-cyan); }
.switch input:checked + .track::after {
  transform: translateX(24px);
  background: #ffffff;
}
[data-theme="dark"] .switch input:checked + .track::after { background: #1a1b1e; }
.switch input:focus-visible + .track { background: var(--reg-cyan-deep); }

/* --- segmented: one of a few, all visible --------------------------------- */
.segmented { position: relative; display: inline-flex; gap: 4px;
  --seg-pad: 4px; padding: var(--seg-pad);
  border-radius: 9999px; background: var(--liner-deep); }
/* the selection is one sticker that SLIDES between segments — it doesn't blink
   from one to the next. Width animates too, since the segments are content-sized.
   Height comes from the control rather than a number: pinned at 40px it hung out
   of any segmented built smaller than the default one. */
.seg-thumb {
  position: absolute; top: var(--seg-pad); bottom: var(--seg-pad); left: 0;
  height: auto; z-index: 0;
  width: var(--w, 0px); transform: translateX(var(--x, 0px));
  border-radius: 9999px; background: var(--reg-cyan);
  transition: transform .38s var(--pop), width .38s var(--pop);
}
.seg-thumb.ready { transition: transform .38s var(--pop), width .38s var(--pop); }
.segmented label {
  position: relative; z-index: 1;
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  height: 40px; padding: 0 20px; border-radius: 9999px;
  font-family: var(--ui); font-weight: 500; font-size: .78rem; color: var(--ink-soft);
  transition: background .18s var(--quick), color .18s var(--quick);
}
.segmented input { position: absolute; opacity: 0; width: 0; height: 0; }
.segmented label .ico { width: 16px; height: 16px; background: var(--ink-soft);
  transition: background .18s var(--quick); }
.segmented label:has(input:checked) { color: #1a1b1e; font-weight: 600; }
.segmented label:has(input:checked) .ico { background: #1a1b1e; }

/* --- address: the composed pattern ---------------------------------------- */
.addr { display: grid; gap: 12px; }
.addr .row { display: grid; gap: 12px; grid-template-columns: 2fr 1fr 1fr; }
@media (max-width: 640px) { .addr .row { grid-template-columns: 1fr; } }


/* ==================== THE PEEL INPUT ====================
   A prompt printed on a sticker with the answer underneath. Lives here, not
   in a page's local styles — the gate uses it as the front door, and any form
   can reach for it. Behaviour is per-page; this is the shape. */
.pi-stage { margin: 32px 0 16px; display: flex; flex-direction: column;
  align-items: center; width: 100%; }
/* the peel's container is a square sized to the sticker's DIAGONAL, so it
   is always wider than the pill and would push a phone sideways. clip
   (not hidden) keeps the page from scrolling without making a scroll
   container — the flap still hangs freely below, the fly-off still flies. */
html, body { overflow-x: clip; }
/* belt and braces on a phone: clip the stage horizontally too (clip + a
   visible y is legal, so the flap still hangs below the pill) */
@media (max-width: 640px) { .pi-stage { overflow-x: clip; overflow-y: visible; } }
.pi-pill {
  display: inline-flex; align-items: center; justify-content: center;
  width: 100%; height: 100%; border-radius: 9999px;
  background: var(--reg-cyan); color: #1a1b1e;
  font-family: var(--loud); font-size: clamp(1.05rem, 4.6vw, 1.75rem);
  text-transform: uppercase;
  letter-spacing: .02em; white-space: nowrap;
}
.peel:has(.pi-pill) { --peel-back: var(--reg-cyan-deep); }
.pi-field { --pi-min: 544px;
  position: relative; width: 544px; max-width: 100%;
  height: clamp(72px, 19vw, 96px);
  cursor: url('/static/cursor-horns.png') 16 1, pointer;
  transition: width .28s var(--settle); }
/* --w is a registered <length>, so the peel's geometry can grow in step
   with the field instead of snapping when the handle outruns the pill */
.pi-field .peel { position: absolute; inset: 0; z-index: 2;
  transition: --p .4s var(--settle), --w .28s var(--settle); }
.pi-input { position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; z-index: 3; padding: 0; background: none; box-shadow: none;
  cursor: url('/static/cursor-horns.png') 16 1, pointer; }
.pi-input:focus { outline: none; }
/* the answer lives UNDER the instruction sticker — peeling reveals it */
.pi-answer { position: absolute; inset: 0; border-radius: 48px;
  background: var(--vinyl); color: var(--ink);
  display: flex; align-items: center; justify-content: flex-start; gap: 3px;
  padding-left: clamp(24px, 7vw, 42px);
  font-family: var(--body); font-weight: 500; font-size: clamp(1rem, 4.2vw, 1.6rem);
  white-space: nowrap; box-shadow: var(--e1); }
/* a good handle: the sticker peels clean off and sails away */
.pi-fly { transition: transform .9s cubic-bezier(.32,.72,.36,1),
                      opacity .9s cubic-bezier(.6,0,.9,1) !important;
  transform: translate(96vw, -58vh) rotate(34deg) scale(.82);
  opacity: 0; pointer-events: none; }
/* accepted: the whole field turns over to the success hue as the sticker
   goes — face, underside, and caret together */
.pi-field.pi-open { cursor: default; }
.pi-field.pi-open .pi-pill { background: var(--good); }
.pi-field.pi-open .peel { --peel-back: var(--good-deep) !important; }
.pi-field.pi-open .pi-answer { color: var(--good-text); }
.pi-field.pi-open .pi-caret { background: var(--good-text); }
.pi-pill, .pi-answer, .pi-caret { transition: background .3s var(--quick),
  color .3s var(--quick); }
/* the check is PRINTED on the layer underneath — it doesn't animate in;
   it is simply already there when the sticker lifts away. (At rest the
   fold only clears the left wedge, so the right edge stays covered.) */
.pi-check { position: absolute; right: clamp(20px, 6vw, 34px); top: 50%; margin-top: -15px;
  width: 30px; height: 30px; flex: none;
  background: var(--good-text);
  -webkit-mask: url('/static/icons/ui/checkmark.svg') center / contain no-repeat;
  mask: url('/static/icons/ui/checkmark.svg') center / contain no-repeat;
  opacity: 0; }
.pi-field.pi-open .pi-check { opacity: 1; }
.pi-field.pi-open .pi-caret { opacity: 0; }
/* refused: the corner TUGS (a stuck sticker that won't come up) and the
   whole field flushes red, then settles back to blue to try again */
.pi-field.pi-bad .pi-pill { background: var(--bad); }
.pi-field.pi-bad .peel { --peel-back: var(--bad-deep) !important; }
.pi-field.pi-bad .pi-caret { background: var(--bad-text); }
.pi-msg { display: flex; align-items: center; justify-content: center; gap: 8px;
  font-family: var(--ui); font-weight: 500; font-size: .74rem;
  line-height: 16px; color: var(--bad-text); margin: 0 0 16px;
  opacity: 0; transition: opacity .2s var(--quick); }
.pi-x { width: 16px; height: 16px; flex: none; background: var(--bad-text);
  -webkit-mask: url('/static/icons/ui/dismiss.svg') center / contain no-repeat;
  mask: url('/static/icons/ui/dismiss.svg') center / contain no-repeat; }
.pi-msg.is-on { opacity: 1; }
/* SMALL — the same control at field scale, so choosing between a peel
   input and a plain one is purely cosmetic: same height, same type size,
   same lead-in as .in-group input. */
.pi-field.sm { --pi-min: 320px; width: 320px; height: 52px; }
.pi-field.sm .pi-pill { font-size: .82rem; }
.pi-field.sm .pi-answer { font-size: 1rem; padding-left: 24px; }
.pi-field.sm .pi-caret { height: 20px; }
.pi-field.sm .pi-check { width: 20px; height: 20px; margin-top: -10px;
  right: 20px; }
.pi-caret { width: 3px; height: 34px; background: var(--reg-cyan-text);
  display: inline-block; animation: pi-blink 1.06s steps(2, start) infinite; }
/* the blink is the invitation — once you're in, it steadies and gets
   out of the way while you type */
.pi-field:focus-within .pi-caret { animation: none; }
@keyframes pi-blink { 50% { opacity: 0; } }
.peel-stage { display: grid; grid-template-columns: repeat(auto-fill, 176px);
  gap: 48px 16px; margin: 32px 0 16px; }
.peel-cell { margin: 0; display: flex; flex-direction: column;
  align-items: center; justify-content: flex-end; }
.peel-cell figcaption { font-family: var(--ui); font-weight: 500; font-size: .6rem;
  line-height: 16px; letter-spacing: .03em; color: var(--ink-soft);
  margin-top: 16px; text-align: center; }
.peel-stage .peel { --peel-rest: 18px; --peel-hover: 36px; }
.pd { display: inline-block; }
.pd-cyan    { width: 176px; height: 64px;  border-radius: 9999px; background: var(--reg-cyan); }
.pd-magenta { width: 128px; height: 128px; border-radius: 20px;   background: var(--magenta); }
.pd-lime    { width: 112px; height: 112px; border-radius: 0;      background: var(--good); }
.pd-yellow  { width: 112px; height: 112px; border-radius: 50%;    background: var(--yellow); }
.pd-grape   { width: 96px;  height: 144px; border-radius: 16px;   background: var(--grape); }
.pd-vinyl   { width: 144px; height: 96px;  border-radius: 14px;   background: var(--vinyl); }
.peel:has(.pd-cyan)    { --peel-back: var(--reg-cyan-deep); }
.peel:has(.pd-magenta) { --peel-back: var(--magenta-deep); }
.peel:has(.pd-lime)    { --peel-back: var(--good-deep); }
.peel:has(.pd-yellow)  { --peel-back: var(--yellow-deep); }
.peel:has(.pd-grape)   { --peel-back: var(--grape-deep); }
.peel:has(.pd-vinyl)   { --peel-back: var(--vinyl-deep); }
/* the pen's sticker-6: a stadium, in ink — the vinyl demo's negative */
/* the face lifts off pure ink so the darker underside can read beneath it —
   mixed from the tokens, so it inverts with the mode on its own */
.pd-stadium { width: 96px; height: 144px; border-radius: 48px;
  background: color-mix(in oklab, var(--ink) 86%, var(--liner)); }
.peel:has(.pd-stadium) { --peel-back: var(--ink-deep); }
/* a die-cut sticker: the art is the face, the liner side is the back */
.pd-bean { width: 128px; height: 168px; background: url('/static/bean.png') center / contain no-repeat; }
/* a printed sticker's underside is the liner: still white, just a shade
   off the printed face. Physical material — it does not theme. */
.peel:has(.pd-bean) { --peel-back: #f3f1ec; }
/* the vinyl face matches the liner too closely — a whisper of e1 separates it */
.peel:has(.pd-vinyl) .peel-front { filter: var(--fe1) !important; }
.btn-groups, .in-groups { display: grid; gap: 48px; }
.bt { display: grid; gap: 32px; margin: 32px 0 16px; }
.bt-row { display: grid; gap: 8px; }
.bt-stage { display: flex; align-items: flex-end; gap: 32px; flex-wrap: wrap; }
.bt-swatch { width: 128px; height: 128px; display: flex; align-items: flex-end;
  justify-content: center; flex: none; }
.bt-swatch span { display: flex; align-items: center; justify-content: center;
  background: var(--reg-cyan); color: #1a1b1e; border-radius: 14px;
  font-family: var(--ui); font-weight: 600; font-size: .66rem;
  transition: width .2s var(--pop), height .2s var(--pop), background .2s var(--quick); }
.bt-stage .budget { flex: 1 1 320px; margin: 0; }
.in-group input[type=range] { height: auto; padding: 0; background: none; }
.sg-label, .btn-label, .hue-title {
  font-family: var(--ui); font-weight: 500; font-size: .64rem;
  line-height: 16px; letter-spacing: .04em; color: var(--ink-soft);
  margin-bottom: 16px; }
.btn-group .sg-row { gap: 16px; }


/* ---------- THE EDITOR ----------
   A fixed overlay, not a route: nothing to navigate to, nothing to rebuild on
   the way back, and "the stage must not move" is what a fixed overlay is for.
   The stage is sized in whole grid cells by editor.js and the panel below it
   has a reserved height, so a warning appearing never shifts the picture. */
/* The editor starts BELOW the bar rather than covering it, so the mark, the
   mode switch and the account chip are still there — editing a sticker is a
   place in the app, not a modal that suspends it. Duplicating the bar inside
   the overlay would have meant two of everything to keep in step. */
.editor {
  /* the biggest the empty sheet grows to. Past this the stage only grows because
     the sticker asked it to — read by editor.js, so it lives in one place */
  --ed-mat: 720px;
  /* full window, not below the bar: the ruling has to pass BEHIND the mark and
     the chip. The bar's height comes back as padding so nothing lands under it. */
  position: fixed; inset: 0; z-index: 50;
  display: flex; flex-direction: column; align-items: center;
  background: var(--liner);
  padding: calc(var(--bar-h, 0px) + 10px) 14px calc(14px + env(safe-area-inset-bottom));
  overflow: hidden;
}
/* THE CUTTING MAT — the same ruling as the studio, laid FROM the stage the way
   the studio lays it from the drop square, so the rules meet the stage's corners
   and a cell sits dead centre in it. The editor covers the page, so without this
   it is a flat slab and the sticker loses the sheet it is supposed to be on.
   ITS OWN LAYER, and that is the point. The offsets move whenever the stage
   resizes, and they used to be written on .editor — where a custom property
   invalidates inherited style for everything inside, and everything inside
   includes 26 icons that are each a CSS mask resolved through an inherited
   property. An element with no descendants can be rewritten as often as it likes
   and nothing else has to be re-resolved.
   z-index: -1 puts it above .editor's own flat background and below ::before,
   which is exactly where it sat when it was .editor's background layer. */
.editor > .ed-sheet {
  position: absolute; inset: 0; z-index: -1; pointer-events: none;
  background:
    linear-gradient(var(--liner-deep) 1px, transparent 1px)
      0 var(--ed-grid-y, -0.5px) / var(--grid) var(--grid),
    linear-gradient(90deg, var(--liner-deep) 1px, transparent 1px)
      var(--ed-grid-x, calc(50% + 7.5px)) 0 / var(--grid) var(--grid);
}
.editor::before {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0;
  background: var(--liner); opacity: .38;
}
.editor > * { position: relative; z-index: 1; }
.editor[hidden] { display: none; }
body.editing { overflow: hidden; }

.ed-head {
  width: 100%; max-width: 620px; flex: none;
  display: flex; align-items: center; justify-content: space-between;
}
.ed-close { width: 40px; height: 40px; display: inline-flex;
  align-items: center; justify-content: center; }
.ed-close .ico { width: 20px; height: 20px; background: var(--ink-soft); }

/* The checkerboard is a CSS background, not thousands of fillRect calls per
   pointermove the way the touch-up canvas does it — the stage has to stay at
   60fps under a finger. */
/* No surface of its own. The stage was a checkered panel sitting on the sheet,
   which made the sticker look like it was in a window rather than on the mat —
   and gave transparency two different meanings on one screen. Now it's the same
   cutting mat as everywhere else, and what shows through a transparent image is
   the sheet the sticker will actually be cut from. */
/* The stage is sized to the sticker at true scale, so on a small screen it can
   be taller than the room. This is what absorbs that: the stage keeps its real
   size and this scrolls, rather than the sticker shrinking and the preview
   quietly ceasing to be actual size. Centred while it fits, which is the common
   case now that the size control stops at what an envelope will take. */
.ed-view {
  flex: 1 1 auto; min-height: 0; width: 100%;
  display: flex;
  /* SAFE centring, which is centring right up until it would cost you something.
     A 90mm sticker at true size is 641px on a 390px phone — not a bug, a 3.5in
     sticker — and plain `center` overflows a box in BOTH directions at once,
     where only the trailing half can ever be scrolled to. 126px of that sticker
     sat off the left edge with no way to reach it. `safe` falls back to
     flex-start the moment the content stops fitting, so everything is reachable.
     (Auto margins alone don't cover this: with negative free space they resolve
     to zero and justify-content decides, which is what was happening.) */
  align-items: safe center; justify-content: safe center;
  overflow: auto; overscroll-behavior: contain;
  scrollbar-width: thin;
}
/* WHERE THE STICKER RUNS OFF THE MAT, it fades rather than stopping dead.
   A sticker can be bigger than the room — a 9in one is a phone screen and a half
   — and a hard horizontal cut across the artwork with mat below it reads as a
   rendering fault. Softened, the same edge says "this carries on", which is what
   is actually true.
   The mask is on the MAT, which is the scroll container: its own box is the
   window the sticker is seen through, so the fade stays at the window's edge
   while the artwork moves behind it. Two layers intersected rather than one per
   side, so the corners of a sticker that overruns two edges at once fade once
   instead of twice as dark.
   Each side's length is 0 until markCut() says that side is cutting something,
   and a 0-length ramp is a hard stop at the very edge — i.e. no fade at all —
   so a sticker that fits is untouched. */
/* Registered so they can ANIMATE. A plain custom property is a token as far as
   the engine is concerned and transitions on it do nothing; typed as a length it
   interpolates, which is what turns the fade appearing into a fade rather than a
   flick. Where @property isn't supported the values still apply, the change just
   snaps — the cue works either way. */
@property --fade-t { syntax: '<length>'; inherits: false; initial-value: 0px; }
@property --fade-b { syntax: '<length>'; inherits: false; initial-value: 0px; }
.ed-view {
  --fade: 34px;
  --fade-t: 0px; --fade-b: 0px;
  /* Where the bottom fade lands, measured back from the mat's edge by markCut():
     the sticker runs on under the tool buttons and gives out just above the name.
     0 until the bottom is really cutting something, so a sticker that fits is
     untouched. */
  --cover-b: 0px;
  /* TOP AND BOTTOM ONLY. The sides used to fade too, and they were saying
     something the sticker already says: if it runs off the side you can push it
     back with a finger, and the fact it moves IS the affordance. Vertically the
     sticker passes under the tools and the name, where there is furniture rather
     than a screen edge to explain it, and that is the edge worth softening.
     One gradient now instead of two, so no compositing either. */
  -webkit-mask-image:
    linear-gradient(to bottom, transparent 0, #000 var(--fade-t),
                    #000 calc(100% - var(--cover-b) - var(--fade-b)),
                    transparent calc(100% - var(--cover-b)));
  mask-image:
    linear-gradient(to bottom, transparent 0, #000 var(--fade-t),
                    #000 calc(100% - var(--cover-b) - var(--fade-b)),
                    transparent calc(100% - var(--cover-b)));
}
.ed-view[data-cut*="t"] { --fade-t: var(--fade); }
.ed-view[data-cut*="b"] { --fade-b: var(--fade); }
@media (prefers-reduced-motion: no-preference) {
  /* the fade appears and disappears as the sticker grows past the edge; without
     this it snaps on the frame the threshold is crossed */
  .ed-view { transition: --fade-t .18s linear, --fade-b .18s linear; }
}

.ed-stage {
  position: relative;
  flex: none; margin: 6px 0 2px; overflow: hidden;
  border-radius: 31px;
  /* A FINGER ON THE STICKER MOVES THE STICKER, until the crop tool wants it.
     This used to be a flat `none` — "the stage owns every gesture on it" — from
     when the editor did own them all. It hasn't since gestures were gated to the
     crop tool, and the flat value outlived that: touch-action on an ancestor
     restricts everything inside it, so the canvas's own `pan-x pan-y` could
     never take effect and a one-finger drag on a sticker bigger than the screen
     did nothing at all. The one obvious way to see the rest of it was the one
     thing that didn't work.
     Same pair as .ed-canvas below, and it has to be BOTH: whichever of the two
     is the more restrictive is what the finger gets. */
  touch-action: pan-x pan-y;
}
.editor[data-tool="crop"] .ed-stage { touch-action: none; }
/* The same ruled edge the drop square wears, and for the same reason: this is
   the working area, and its bounds should be visible without being a surface.
   The stage is a whole number of grid cells, so the line lands on the ruling and
   reads as part of the sheet rather than a box drawn on top of it — and it takes
   the drop square's corner radius, because it is the same object: the square you
   dropped onto, now holding what you dropped. */
.ed-edge {
  position: absolute; inset: 0; z-index: 0;
  width: 100%; height: 100%; overflow: visible;
  pointer-events: none;
}
.ed-edge rect {
  x: 0.5px; y: 0.5px; width: calc(100% - 1px); height: calc(100% - 1px);
  rx: 31px; ry: 31px;          /* the drop square's corner, so the two read as
                                  the same object at two moments of its life */
  fill: none; stroke: var(--ink-soft); stroke-width: 1; opacity: .22;
}
.ed-canvas {
  position: relative; z-index: 1;
  will-change: transform;
  /* PAN BY DEFAULT, and only surrender the finger while the crop tool is open.
     A flat `none` here was right for gestures and wrong for everything else: the
     editor only reframes while crop is open, so the rest of the time a drag over
     a sticker did nothing at all — no reframe, and no scroll either, because the
     browser had been told to keep its hands off. On a phone a sticker printed at
     true size is routinely wider and taller than the screen, and getting to the
     rest of it IS dragging it around. */
  touch-action: pan-x pan-y;
}
/* ...and while it IS open, the editor owns the finger: a browser panning the
   view mid-drag or zooming the page mid-pinch is the difference between an
   editor and a picture you can only look at. */
.editor[data-tool="crop"] .ed-canvas { touch-action: none; }
/* the ease is for the drift and the way home — never while the pointer is
   driving, or the lean trails half a second behind the hand */
.ed-canvas.is-settling { transition: transform .5s var(--quick); }
/* Some people get motion sick, and a surface that never stops moving is exactly
   the kind that does it. The finish still reads — the foil and the sheen are
   still there — it just stops leaning. */
@media (prefers-reduced-motion: reduce) {
  .ed-canvas { transform: none !important; transition: none; }
}
.ed-canvas { display: block; width: 100%; height: 100%; cursor: grab; }
.ed-canvas:active { cursor: grabbing; }
/* a die cut has nothing to frame, so the surface stops offering to be dragged */
.editor.is-diecut .ed-canvas { cursor: default; }

.ed-dims {
  flex: none; margin: 2px 0 8px;
  display: flex; align-items: center; gap: 12px;
  font-family: var(--ui); font-size: .78rem;
  color: var(--ink-soft); font-variant-numeric: tabular-nums;
}
/* the smallest the segmented gets — this is a preference, not a decision, and
   it should sit next to the numbers it governs without competing with them */
.ed-units { --seg-pad: 2px; }
.ed-units label { padding: 0 10px; height: 26px; font-size: .72rem; }

.ed-panel {
  width: 100%; max-width: 620px; flex: none;
  display: flex; flex-direction: column; gap: 6px;
}
.ed-row { display: flex; align-items: center; gap: 10px; }
.ed-row .ctl-label { flex: none; width: 46px; margin: 0; }
.ed-row input[type=range] { flex: 1; min-width: 0; }
.ed-row output {
  flex: none; min-width: 46px; text-align: right;
  font-family: var(--ui); font-size: .78rem; font-variant-numeric: tabular-nums;
  color: var(--ink-soft);
}

/* ONE row, that scrolls. Ten shapes wrapped to three rows on a phone and pushed
   everything else off the screen — and a wrapping row also reflows as the set
   changes, which is the one thing this panel must never do. */
.ed-shapes {
  display: flex; flex-wrap: nowrap; gap: 6px; margin: 0 0 2px;
  overflow-x: auto; overscroll-behavior-x: contain;
  scrollbar-width: none; -webkit-overflow-scrolling: touch;
  /* the row runs to the panel's edges, so it reads as continuing rather than
     stopping — and it fades out instead of being cut off square */
  mask-image: linear-gradient(90deg, transparent, #000 12px,
                              #000 calc(100% - 12px), transparent);
  padding: 2px 0;
}
.ed-shapes::-webkit-scrollbar { display: none; }
.ed-shapes .q-chip { flex: none; }

/* the colours belong to the border, so they sit on its row rather than taking
   one of their own — and they're only meaningful once there IS a border */
.ed-swatches { display: flex; gap: 6px; flex: none; }
/* The hairline is an INSET SHADOW, not a border, and the fill is clipped to the
   padding box. A translucent 1px border sits OUTSIDE the fill and the fill paints
   under it by default, so every colour showed through its own outline — barely
   visible on a flat swatch, and a mess on the picker, whose conic gradient came
   out with a ragged fringe of half-lit hues wrapped around the circle. Inset
   keeps the line on top of the fill where it belongs, and padding-box keeps the
   fill off the edge entirely. */
.ed-swatch {
  width: 22px; height: 22px; padding: 0; border: 0; border-radius: 9999px;
  background: var(--sw); background-clip: padding-box;
  box-shadow: var(--e1), inset 0 0 0 1px rgba(128,128,128,.3);
}
/* SELECTED. A ring, held off the swatch by a gap in the card's own colour, so it
   reads as one object with a halo rather than as a circle with a second circle
   loose around it. Built from box-shadow rather than `outline`, because the gap
   has to be painted — an outline-offset leaves whatever is behind showing
   through it, which on a white swatch on a white card is a ring floating in
   nothing. The shadow also follows the border-radius exactly. */
.ed-swatch.is-on {
  box-shadow: 0 0 0 2px var(--chip), 0 0 0 4px var(--reg-cyan);
}
/* Three of them now, so they get room to breathe instead of packing a dozen
   circles into a row. */
.ed-swatches { flex-wrap: wrap; gap: 10px; }

/* THE DROPPER — a mode, not a colour, so it is drawn as a control rather than as
   a swatch: the surface of the card instead of a colour of its own. Armed, it
   wears the same stuck-down blue every other mode in this editor wears. */
/* NO DISC. The other two are colours — round because they are showing you one —
   and this is a tool. Drawn as a third circle it read as a third colour, and the
   colour it appeared to offer was grey. Bare icon, same 22px footprint so the row
   still measures evenly and the target stays where a finger expects it. */
.ed-swatch-drop {
  display: inline-flex; align-items: center; justify-content: center;
  background: none; box-shadow: none; cursor: pointer;
}
.ed-swatch-drop .ico {
  width: 18px; height: 18px; background: var(--ink-soft);
  transition: background .16s var(--quick);
}
.ed-swatch-drop:hover .ico { background: var(--ink); }
/* armed: the tool is in your hand, said without a disc appearing under it */
.ed-swatch-drop.is-on .ico { background: var(--reg-cyan); }
/* While it is armed the pointer BECOMES the tool — the same eyedropper that is
   stuck down in the row, carried on the hand, with its hotspot on the tip where
   the colour is taken from. Two-tone rather than currentColor: a cursor image
   has no element to inherit from, and a black dropper is invisible on the dark
   sheet exactly as a white one is on the light one. So: white body, dark
   keyline, legible on both. `crosshair` is the fallback if the data URI is ever
   refused. */
.editor.is-picking .ed-canvas {
  cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26' viewBox='0 0 24 24'%3E%3Cpath d='M6.45711 18.9539L15.0208 10.3902L13.6066 8.97596L5.04289 17.5397L6.45711 18.9539ZM12.1924 7.56174L10.7782 6.14753L12.1924 4.73331L13.9602 6.50108L16.7886 3.67265C17.1791 3.28213 17.8123 3.28213 18.2028 3.67265L20.3241 5.79397C20.7146 6.1845 20.7146 6.81766 20.3241 7.20819L17.4957 10.0366L19.2635 11.8044L17.8492 13.2186L16.435 11.8044L7.24264 20.9968H3V16.7541L12.1924 7.56174Z' fill='none' stroke='#fff' stroke-width='3.4' stroke-linejoin='round'/%3E%3Cpath d='M6.45711 18.9539L15.0208 10.3902L13.6066 8.97596L5.04289 17.5397L6.45711 18.9539ZM12.1924 7.56174L10.7782 6.14753L12.1924 4.73331L13.9602 6.50108L16.7886 3.67265C17.1791 3.28213 17.8123 3.28213 18.2028 3.67265L20.3241 5.79397C20.7146 6.1845 20.7146 6.81766 20.3241 7.20819L17.4957 10.0366L19.2635 11.8044L17.8492 13.2186L16.435 11.8044L7.24264 20.9968H3V16.7541L12.1924 7.56174Z' fill='#1a1b1e'/%3E%3C/svg%3E") 4 22, crosshair;
}

/* The picker. Unset, it wears a colour wheel that says "anything"; once it holds
   the chosen colour it shows that instead and behaves like every other swatch. */
.ed-swatch-pick {
  position: relative; overflow: hidden; cursor: pointer;
  background: var(--sw, conic-gradient(#ff2fa4, #ffd60a, #6ee600, #00c6ff, #a855f7, #ff2fa4));
}
.ed-swatch-pick input[type="color"] {
  position: absolute; inset: -4px; width: calc(100% + 8px); height: calc(100% + 8px);
  padding: 0; border: 0; opacity: 0; cursor: pointer;
}

.ed-points { flex-wrap: wrap; gap: 6px; }
.ed-pt-hint { font-family: var(--ui); font-size: .72rem; color: var(--ink-soft); }

/* the row is just the rotate button and the count now */
.ed-modes { justify-content: flex-start; gap: 8px; }
.ed-budget {
  font-family: var(--ui); font-size: .76rem; color: var(--ink-soft);
  font-variant-numeric: tabular-nums; margin-left: auto;
}
.ed-budget.is-over { color: var(--bad-text); font-weight: 600; }

/* Reserved height, always — the line is empty most of the time, and if it
   collapsed when it had nothing to say the stage would jump every time the
   quality crossed a band while dragging the size. */
.ed-note {
  margin: 0; min-height: 1.2em;
  font-family: var(--ui); font-size: .76rem; color: var(--ink-soft);
}
.ed-note[data-tone="warn"] { color: var(--warn-text); }
.ed-note[data-tone="bad"] { color: var(--bad-text); }

/* On a phone the tools keep their icons and drop their words — four labelled
   buttons plus a hint do not fit across 390px, and an icon that has to be
   guessed at is better than a row that wraps under the fold. */
@media (max-width: 560px) {
  .ed-row .ctl-label { width: 38px; font-size: .7rem; }
  .ed-row output { min-width: 40px; font-size: .72rem; }
  .ed-swatch { width: 20px; height: 20px; }
  /* The colours drop to their own line rather than sharing the border's row:
     six swatches plus a label and a readout left the slider about 130px wide,
     which is not enough to set a millimetre with a thumb. They stay put whether
     or not there IS a border — dimmed when unused — because appearing on demand
     would change the panel's height, and the stage is measured from that. */
  .ed-border-row { flex-wrap: wrap; row-gap: 4px; }
  .ed-swatches { gap: 6px; flex-basis: 100%; padding-left: 48px; }
  .ed-points .tool span { display: none; }
  .ed-points .tool { padding: 0 12px; }
  .ed-pt-hint { flex-basis: 100%; }
}

@media (max-width: 560px) {
  .ed-row .ctl-label { width: 44px; font-size: .72rem; }
  .ed-swatches { padding-left: 54px; }
  .ed-modes { flex-wrap: wrap; }
}

/* the cart row, now that a row is a glance rather than a control panel */
.sr-name {
  margin: 0 0 2px; font-family: var(--ui); font-weight: 600; font-size: .86rem;
  color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sr-dims { margin: 0 0 2px; }
.sr-meta {
  margin: 0 0 8px; font-family: var(--ui); font-size: .74rem;
  color: var(--ink-soft);
}

/* Controls beside the sticker rather than under it.
   Two screens want this for opposite reasons. A phone on its side has ~390px of
   height and the panel alone wants 287 of them, so stacking cannot work — but
   there are 844px of WIDTH going spare. A desktop has room either way, and puts
   the controls next to what they're controlling because that's simply where they
   belong: reaching for the size slider shouldn't mean looking away from the
   sticker.
   The panel column is a fixed width on purpose. Sizing it from what's left would
   let the stage's width decide the panel's, and the panel's height decide the
   stage's — the loop studio.js warns about, one axis removed. */
@media (min-width: 900px), (max-height: 560px) and (min-width: 640px) {
  .editor {
    display: grid;
    /* Capped and CENTRED rather than stretched to the window. Two columns pinned
       to opposite edges of a 1440px screen put the size slider a foot away from
       the sticker it sizes — which is the thing this layout existed to fix. */
    grid-template-columns: minmax(0, 760px) 380px;
    /* The last row is the 1fr, so the view keeps a definite height for its
       max-height to resolve against while the slack collects at the bottom of the
       control column rather than between the sticker and its dimensions. */
    grid-template-rows: auto auto 1fr;
    grid-template-areas:
      "view head"
      "view dims"
      "view panel";
    gap: 0 22px;
    justify-content: center;
    align-items: start;
    padding: calc(var(--bar-h, 0px) + 8px) 14px calc(8px + env(safe-area-inset-bottom));
  }
  /* The dimensions are a readout with a control attached, so on a wide screen
     they join the other controls rather than sitting a window's width away from
     them at the foot of the sticker column. */
  .ed-dims { grid-area: dims; margin: 10px 0 14px; }
  .ed-head  { grid-area: head; width: auto; max-width: none; }
  /* The VIEW takes the cell, not the stage — the stage keeps whatever size true
     scale costs and scrolls inside this when that's more than the cell holds.
     Its height is DEFINITE (the mat, or the row if that's smaller) and never
     derived from the stage inside it. Letting it size to its content instead —
     align-self: center with no height — closes a loop: the view's height comes
     from the stage, and sizeStage() measures the view to decide the stage. The
     stage collapsed to a letterbox. This is the same hazard studio.js documents
     at sizeSquare(), and the same answer: measure something the result can't
     move. */
  .ed-view {
    grid-area: view; align-self: center;
    height: var(--ed-mat); max-height: 100%; min-height: 0;
  }
  .ed-panel { grid-area: panel; width: auto; max-width: none; gap: 6px; }
  /* Wrapping is safe HERE and only here. The stacked layout scrolls this row
     because three rows of chips on a phone push everything else off the screen;
     the column has the vertical room, and the chip set is fixed — die cut is
     disabled when there's no traced outline, never removed — so the number of
     rows can't change under you. Hiding four of seven shapes behind a sideways
     scroll on a desktop is the worse trade. */
  .ed-shapes { flex-wrap: wrap; overflow-x: visible; mask-image: none; }
  .ed-swatches { padding-left: 0; }
  .ed-row .ctl-label { width: 44px; }
}

/* A phone on its side has 390px of height and the bar wants 70 of them. The mark
   and the chip are worth their space everywhere there IS space — on a desktop
   they stay — but here the sticker needs it more, and the way out is the same X
   it always was. */
@media (max-height: 560px) and (min-width: 640px) {
  body.editing .bar { display: none; }
  .editor { padding-top: 8px; }   /* no bar to clear */
}

/* ======================================================================
   LAYOUT REDESIGN IN PROGRESS — 2026-08-16
   The controls are PARKED, not deleted. editor.js binds every one of these
   elements and reads their values on every refresh, so removing the markup
   would break the editor; this hides it. Undoing the redesign is deleting
   this block.
   What's left is the surface itself: a square stage, centred, on the sheet.
   Placed after the side-by-side media query on purpose — same specificity,
   later wins, so the two-column grid stands down while there's one column of
   content to lay out.
   ====================================================================== */
/* The way out and the way forward are parked too — Escape still cancels
   (editor.js binds it), so the surface isn't a trap while it's being rebuilt. */
.ed-panel { display: none; }
/* The way forward comes back — the way OUT stays parked (Escape still cancels).
   Bottom centre: the primary action belongs under the thing it acts on, and the
   left rail already owns the side. */
.ed-close { display: none; }
/* IN FLOW, UNDER THE STICKER — not pinned to the bottom of the window.
   Pinned, it was a fixed strip that a big sticker ran straight through: the
   artwork passed behind the name and the button, and the only thing that
   scrolled was the pane the sticker sat in, so the top of a tall sticker was
   simply gone under the bar with no way to reach it. In flow, a tall sticker
   pushes the name down instead of colliding with it, and the surface scrolls as
   one piece. */
.ed-head {
  position: static; width: auto; max-width: none;
  flex: none; align-self: center;
  /* LAST in the column, though it is first in the markup — it is first there so
     the cancel button leads for a keyboard, and it belongs under the sticker for
     an eye. Without this the view's auto margins centre the stage and leave the
     head stranded at the top of the surface. */
  order: 3;
  /* A column: the name reads as the title of the thing you're about to add,
     directly over the button that adds it. Beside it, it was just a stray field
     sharing a row. */
  flex-direction: column; align-items: center; justify-content: flex-end; gap: 10px;
  margin: 26px 0 calc(10px + env(safe-area-inset-bottom));
  z-index: 6;
}
/* and the sheet holds the sticker without drawing a box around it */
.ed-edge { display: none; }
/* THE SURFACE HOLDS STILL AND THE MAT SCROLLS INSIDE IT.
   This was the other way round — the whole surface scrolled, bar included — and
   the cost was that nothing was fixed to the viewport any more. The fade has to
   be painted on the window it is fading at, and if the window scrolls away with
   its contents there is no window. One scroller, and it is the mat: the bar and
   the tools stay put, the sheet moves under them, and a sticker bigger than the
   screen is reached by moving the sheet rather than the room. */
.editor {
  display: flex; flex-direction: column; align-items: center;
  overflow: hidden;
  /* Symmetric, so "centred" means the page's centre and not the centre of
     what's under the bar. Clearing the bar with top padding alone hangs the
     square half a bar-height low, which reads as a mistake at this size. */
  padding-block: calc(var(--bar-h, 0px) + 10px);
}
/* `margin: auto`, NOT `justify-content: center`. They agree while the content
   fits and disagree exactly when it doesn't: centred justification overflows in
   BOTH directions and puts the top of the content above the scroll origin, where
   no scrollbar can reach it — the oldest flexbox trap there is. Auto margins
   collapse to zero once there is no room to give, so a tall sticker simply
   starts at the top and scrolls. */
/* THE MAT IS THE WINDOW, and it is the thing that scrolls. One model, both
   layouts.
   It used to be sized to its content here, on the theory that the whole surface
   should scroll instead. Two things fell out of that and both were bugs. Content-
   sized, it had no scroll range of its own, but it was still `overflow: auto` —
   a scroll PORT with nothing to give — and its `overscroll-behavior: contain`
   then stopped a one-finger drag reaching the surface behind it, with 441px of
   scroll sitting unused. And the fade lives on this box, so a box narrower than
   the screen faded the sticker 42px early and went on faded while the sticker
   panned underneath: it moved, and stayed clipped, which is worse than not
   moving.
   Filling the column fixes both at once. The box is now the window you look
   through — fixed while its contents move — which is what the fade, the cut
   marks and the centring all already assumed. */
.ed-view { flex: 1 1 auto; margin: 0; max-height: none; min-height: 0; }
/* AUTO ON BOTH AXES, for the same reason the view has it vertically: a sticker
   wider than the screen — a 90mm one is 567px on a 390px phone, which is not a
   layout mistake but a 3.5in sticker — overflows a CENTRED box in both
   directions at once, and the half that overflows to the left of the scroll
   origin cannot be reached by scrolling. Auto margins collapse to zero the
   moment there is no room to give, so the sticker starts at the edge and every
   part of it can be scrolled to. */
.ed-stage { margin: auto; }

/* The pair in the size card. Same control as the rules on the sheet, so it gets
   the same behaviour — type a width or a height and the sticker takes it. Styled
   as a field rather than as text, because a number you can change should not
   look like a number you can only read. */
.ed-dims-pair { display: inline-flex; align-items: baseline; gap: 4px; }
.ed-x { color: var(--ink-soft); font-size: .74rem; }
.ed-dim {
  font-family: var(--ui); font-size: .78rem; font-variant-numeric: tabular-nums;
  color: var(--ink); background: none;
  border: 0; border-radius: 6px; padding: 2px 5px; margin: 0;
  width: auto; min-width: 0; height: auto; text-align: center; cursor: text;
  box-shadow: inset 0 0 0 1px transparent;
  transition: background .16s var(--quick), box-shadow .16s var(--quick);
}
.ed-dim:hover { background: var(--liner-deep); }
.ed-dim:focus {
  outline: none; background: var(--liner-deep);
  box-shadow: inset 0 0 0 1px var(--reg-cyan);
}

/* DIMENSION RULES — the sticker, measured where it is.
   A line spanning the sticker with a tick at each end, and the measurement
   sitting in a break in the middle of it, the way a drawing is dimensioned.
   Positioned from four custom properties the renderer writes on every frame, so
   they track the sticker exactly rather than being laid out near it. */
.ed-rule {
  position: absolute; z-index: 2; pointer-events: none;
  --tick: 7px;
  /* The rules belong to the size tool, and appear with it. visibility as well as
     opacity, so a faded-out rule can't be tabbed into or have its number typed
     in by a keyboard that can't see it's gone. */
  opacity: 0; visibility: hidden;
  transition: opacity .2s var(--quick), visibility .2s;
  /* Muted through the COLOUR, never through opacity on the container: opacity
     makes a stacking context and takes the number down with the line, and the
     number is the one part that has to stay readable. */
  color: color-mix(in srgb, var(--ink) 42%, transparent);
}
.ed-rule::before, .ed-rule::after {      /* the end ticks */
  content: ""; position: absolute; background: currentColor;
}
/* A rule is DIMENSIONING the sticker, so it hangs off the sticker's edge and
   follows it as it resizes — that is the whole grammar of a dimension line, and
   pinning it to the stage instead put a 3.9" rule through the middle of a 5.2"
   sticker. What must not move is the tilt: these are children of the stage and
   only the canvas carries the 3D transform, so leaning the sticker leaves them
   where they are. Resize moves them; tilt doesn't. */
.ed-rule-w {
  left: var(--m-x, 0); width: var(--m-w, 0);
  top: calc(var(--m-y, 0px) - 26px); height: 1px;
  background: linear-gradient(90deg, currentColor, currentColor);
  background-size: 100% 1px; background-repeat: no-repeat;
}
.ed-rule-w::before, .ed-rule-w::after {
  width: 1px; height: var(--tick); top: calc(var(--tick) / -2 + .5px);
}
.ed-rule-w::before { left: 0; }
.ed-rule-w::after  { right: 0; }
.ed-rule-h {
  top: var(--m-y, 0); height: var(--m-h, 0);
  left: calc(var(--m-x, 0px) - 26px); width: 1px;
  background: linear-gradient(180deg, currentColor, currentColor);
  background-size: 1px 100%; background-repeat: no-repeat;
}
.ed-rule-h::before, .ed-rule-h::after {
  height: 1px; width: var(--tick); left: calc(var(--tick) / -2 + .5px);
}
.ed-rule-h::before { top: 0; }
.ed-rule-h::after  { bottom: 0; }

.editor[data-tool="size"] .ed-rule { opacity: 1; visibility: visible; }

/* The number sits IN the line, not beside it — the sheet's own colour behind it
   breaks the rule where the text goes, which is what makes it read as a
   dimension rather than a label that happens to be nearby. */
/* Centred by POSITION, not by layout. Laid out inside its rule, the height's
   number was squeezed to nothing — that rule is a 1px-wide box, and a flex child
   of a 1px box gets 1px. Floating it on the line sidesteps the container's size
   entirely, and does the same job on both axes. */
/* The measurement, sitting in a break in the rule.
   `height: auto` matters more than it looks: the global input rule sets a 52px
   height for the app's real form fields, and this inherited it — so a 12px
   number was carrying a 52px slab of backing behind it, tall enough to reach
   down and cover the top of the sticker it was measuring. It should be a gap in
   a line, no taller than the number in it. */
.ed-num {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  pointer-events: auto;
  font-family: var(--ui); font-size: .74rem; font-variant-numeric: tabular-nums;
  line-height: 1.25;
  color: var(--ink); background: var(--liner);
  border: 0; border-radius: 6px; padding: 1px 7px; margin: 0;
  height: auto; text-align: center; width: auto; min-width: 0;
  cursor: text; opacity: 1;
}
.ed-num:hover { background: var(--chip); }
.ed-num:focus { outline: 2px solid var(--reg-cyan); outline-offset: 1px;
                background: var(--chip); }
/* THE TOOLBAR — a rail of circle buttons, top left, like any design program.
   Anchored rather than centred: a toolbar built out over time has to grow
   downward from a fixed corner, or every tool added moves all the others.

   The hard part is that an open card must not cover the buttons beside it.
   Growing the card out of the circle put it straight over the next tool. So the
   card lives entirely to the RIGHT of the rail and reaches back to its own icon
   through a neck — a bubble that bulges left to collect the icon and is a plain
   box everywhere else. The neck is exactly as tall as the circle, so nothing
   below the active tool is ever covered.

   Three shapes, one silhouette: circle + neck + card, all the same fill. The
   shadow is a FILTER on the wrapper rather than a box-shadow on each, because
   three box-shadows draw seams where the shapes meet — each casts over its
   neighbour. drop-shadow() sees only the union's outline. */
.ed-dock {
  position: absolute; left: 18px; z-index: 6;
  top: calc(var(--bar-h, 0px) + 20px);
  display: flex; flex-direction: column; gap: 14px;
  pointer-events: none;               /* the sheet between the buttons stays live */
}
/* The slot is what makes room on the rail, so the slot is what animates. It
   collapses its own height and swallows the dock's 14px gap with a negative
   margin — without that the tools below stop 14px short of closing up and the
   rail keeps a hole where the tool used to be.
   The SPACE eases and only the BUTTON pops: --pop overshoots, and a height
   cannot overshoot past zero, so on that curve the rail snapped shut in under
   90ms while the button went on shrinking for another quarter second. Two
   speeds for one event reads as a glitch. */
.ed-tool-slot {
  position: relative; width: 56px; height: 56px;
  transition: height .3s var(--quick), margin-bottom .3s var(--quick);
}
.ed-tool-wrap {
  /* out of flow, so opening changes only what the card IS and never where the
     button sits — moving and resizing in one frame gives the size transition no
     stable 'before' to travel from, and it snaps */
  position: absolute; left: 0; top: 0;
  pointer-events: auto;
  display: flex; align-items: flex-start; gap: 14px;
  width: max-content;
  /* One shadow for the whole bubble, from the FILTER-form elevation scale that
     already exists for exactly this — hand-rolled values would have been a
     light-mode guess painted onto a dark sheet. */
  filter: var(--fe2);
  /* the scale is about the button's own centre, so it bubbles out of the rail
     rather than growing from the corner of a box nobody can see */
  transform-origin: 28px 28px;
  transition: filter .26s var(--quick),
              transform .34s var(--pop), opacity .2s var(--quick);
}
.ed-tool-wrap.is-open { filter: var(--fe3); }   /* a step up while it's open */
/* The neck: a plain BAND, no radius.
   Rounding it made it a pill, and a pill's right end is a semicircle bulging
   into the card — three scalloped bumps instead of one shape. Square, it meets
   the circle exactly at the circle's top and bottom points (28,0) and (28,56),
   where the circle's tangent is already horizontal, so the join is seamless and
   the silhouette reads as a half-circle with a straight band leaving it.
   It runs from the circle's CENTRE, so it can never leave a gap however the
   radii land, and it is only ever as tall as the circle. */
.ed-tool-wrap::before {
  content: ""; position: absolute; left: 28px; top: 0;
  width: 0; height: 56px;
  background: var(--chip);
  transition: width .3s var(--pop), background .2s var(--quick);
}
.ed-tool-wrap.is-open::before { width: 68px; }

/* The inverted corner, where the neck's underside meets the card's left edge.
   Left square it was a hard 90 degree notch — the one thing that gave the bridge
   away. A quarter-disc of transparency at the box's bottom-left leaves material
   in the top-right bounded by a concave arc, which is the fillet: flush with the
   neck along its top edge and flush with the card down its right edge. */
.ed-tool-wrap::after {
  content: ""; position: absolute; left: 56px; top: 56px;
  width: 14px; height: 14px; opacity: 0;
  background: radial-gradient(circle 14px at 0 100%,
                              transparent 0 99.5%, var(--chip) 100%);
  transition: opacity .18s var(--quick);
}
.ed-tool-wrap.is-open::after { opacity: 1; transition-delay: .1s; }
.ed-tool-wrap:hover::before,
.ed-tool-wrap:hover button.ed-tool-btn { background: var(--chip-hover); }

/* THE OPEN TOOL WEARS A DIFFERENT SURFACE FROM THE CLOSED ONES.
   All four parts of the bubble together — button, neck, fillet, card — so the
   silhouette stays one shape. In light mode this resolves to the same chip it
   already was and the shadow does the separating. In dark mode it is a step
   lighter than the sheet, because there the shadow cannot: hovering the crop
   button while the shape card was open put one near-black rounded rectangle on
   top of another and they read as a single blob with a bite out of it.
   Placed after the hover rules deliberately — same specificity, and open beats
   hovered when a tool is both. */
.ed-tool-wrap.is-open .ed-tool-menu,
.ed-tool-wrap.is-open::before,
.ed-tool-wrap.is-open button.ed-tool-btn,
.ed-tool-wrap.is-open:hover button.ed-tool-btn { background: var(--tool-open); }
.ed-tool-wrap.is-open::after {
  background: radial-gradient(circle 14px at 0 100%,
                              transparent 0 99.5%, var(--tool-open) 100%);
}

/* `button.ed-tool-btn`, not `.ed-tool-btn`: these carry .bare, and
   `button.bare { background: none }` outranks a lone class — which is why the
   closed tools were invisible, an icon floating on the sheet with no button
   under it. Matching element+class ties the specificity and wins on order. */
button.ed-tool-btn {
  /* The padding is load-bearing. The base button rule carries 46px of side
     padding, and a border-box width can't go below its own padding — so the
     circle was resolving to 92px wide and the bubble came out an oval.
     17px on the left centres a 22px icon in a 56px circle: (56 - 22) / 2. */
  position: relative; z-index: 1;
  /* min-width, not width: the pill SIZES TO ITS CONTENTS and the label's own
     animation is what grows it. Nothing here is told a number that a
     measurement had to produce — see .ed-tool-name. */
  min-width: 56px; width: auto; height: 56px; flex: none;
  padding: 0 0 0 17px;
  border-radius: 9999px;      /* pill, so it can grow sideways and stay round */
  display: inline-flex; align-items: center; justify-content: flex-start;
  cursor: pointer; overflow: hidden;
  background: var(--chip); box-shadow: none;
  transition: background .2s var(--quick), padding-right .24s var(--pop);
}
/* The tool says what it is under the pointer. A rail of circles is only legible
   once you already know it, and the alternative — a tooltip — arrives late, in
   a box of its own, from a different design language. Growing the button is the
   same move the card makes, one size down.
   Suppressed while open: the button is inside a flex row with the card, so
   widening it there would shove the card sideways mid-use. */
/* MAX-WIDTH, NOT A MEASURED WIDTH.
   This used to be clipped to a width JS had measured off the text. Measuring
   text is a race nobody wins: read it before the webfont lands and every label
   comes out short, so "crop" was rendering as "cr". The pass was moved later,
   then later again, and it still lost — `document.fonts.ready` can resolve
   BEFORE a font that only gets requested when the element it styles first
   appears, which is exactly what an editor built on demand does.
   Animating a max-width from 0 to a number bigger than any label needs the same
   thing without knowing anything: the label reveals at its own natural width,
   the pill sizes to its contents, and the transition simply finishes early. The
   font can land whenever it likes. */
.ed-tool-name {
  display: block; flex: none;
  max-width: 0; overflow: hidden; white-space: nowrap;
  opacity: 0; margin-left: 0;
  /* Quiet, but not faint. Type comes from the utility-role rule; the one
     deliberate step away from it is the weight — a chip's label has a row of
     siblings to be read against, and this is a single word alone on a pill, so
     it carries one step more to hold the same presence. */
  color: var(--ink-soft); font-weight: 600;
  transition: max-width .24s var(--pop), opacity .16s var(--quick),
              margin-left .24s var(--pop);
}
@media (hover: hover) {
  .ed-tool-wrap:not(.is-open):hover button.ed-tool-btn {
    /* 20 on the right, not 8. The pill's end is a semicircle, so its visual
       edge sits well inside its box and a gap that measures even reads tight. */
    padding-right: 20px;
  }
  .ed-tool-wrap:not(.is-open):hover .ed-tool-name {
    max-width: 140px; opacity: 1; margin-left: 12px;
  }
}
.ed-tool-btn .ico { width: 22px; height: 22px; background: var(--ink-soft); }
.ed-tool-wrap:hover .ed-tool-btn .ico,
.ed-tool-wrap.is-open .ed-tool-btn .ico { background: var(--ink); }
/* One grid row travelling 0fr -> 1fr — the only way to animate to an automatic
   height — and a width from 0 to a measured value, because a width cannot
   animate to `auto`. The body is the single row that interpolates. */
/* WIDTH: MAX-CONTENT, CLAMPED — not a number JS measured and wrote here.
   `width: max-content` with a `max-width` that animates from 0 is the pair that
   does what `fit-content(356px)` would if it were allowed as a width: closed,
   the clamp is 0 and the card is a sliver; open, the clamp lifts and the card
   takes exactly what its contents need, up to the ceiling, where text starts
   wrapping instead. The transition runs on to 356 after the card has stopped
   growing, which nobody can see.
   Everything that went wrong with the measured version came from the same
   place — reading a width before the thing being measured was in its final
   state. Hidden at build (zero), before the font landed (short), while a
   wrapping row was laid out on one line (much too wide). This can't be early. */
.ed-tool-menu {
  position: relative; z-index: 1;
  display: grid; grid-template-rows: 0fr;
  width: max-content; max-width: 0; opacity: 0; overflow: hidden;
  background: var(--chip); border-radius: 26px;
  transition: grid-template-rows .3s var(--pop), max-width .3s var(--pop),
              opacity .18s var(--quick);
}
.ed-tool-menu > .ed-tool-body { min-height: 0; }   /* or the row never shrinks */
.ed-tool-wrap.is-open .ed-tool-menu {
  grid-template-rows: 1fr; opacity: 1;
  /* The ceiling. It matches the shape card, the one card whose own contents set
     a limit (.ed-shapes caps at 320 + 36 of padding) — so the widest card is a
     measured fact about the design rather than a taste, and every other card
     wraps under the same bar. */
  max-width: 356px;
}
/* Padding on the BODY, not the card: the card animates from zero width, and
   padding on the card would hold it open by twice that before there was any
   content to justify it. */
.ed-tool-body {
  display: flex; flex-direction: column; gap: 8px;
  padding: 14px 18px; min-width: 0;
}
/* NOWRAP. This wrapped back when the swatches were a member of the row and
   needed a line of their own; they are a sibling now, and the leftover
   flex-wrap was letting the measurement — the one thing that has to stay beside
   the slider it reads — drop onto a line by itself the moment the row got tight.
   Label, slider, number: one line, always. */
.ed-tool-body .ed-border-row { flex-wrap: nowrap; align-items: center; }

/* NO BORDER, NO COLOURS. They used to sit there greyed out at 30% — present,
   unusable, and asking to be clicked — because the panel this card grew out of
   had a fixed height that the stage was measured from, so anything appearing on
   demand moved the artwork. A card has no such duty: it is a popover whose
   height already animates, so the colours can simply not be there until there is
   something to colour, and arrive when there is.

   They flow in rather than blink on: the row opens, and the three of them rise
   into it a beat apart, so the eye follows the change instead of finding the
   card suddenly taller. */
/* The clip box is INSET FROM the swatches, not flush with them: a selected
   swatch wears a ring outside its own circle, and with the box flush the reveal's
   `overflow: hidden` sliced that ring off at the row's edges — the selection came
   out as two stray arcs. 6px of padding gives the ring somewhere to be while the
   height still animates. No negative margin pulling it back, because the row is
   centred rather than aligned to a rail, so there is nothing to compensate
   against — and a -6px margin on a fixed-width box slides it rather than
   widening it, which is how it ended up 6px off centre.
   NO flex-basis: this is a child of the body's COLUMN now, where flex-basis
   means height — it would be asking to be as tall as the card. */
.ed-tool-body .ed-swatches {
  padding: 6px; margin: 2px 0 0;
  overflow: hidden; max-height: 46px;
  transition: max-height .3s var(--pop), margin-top .3s var(--pop),
              padding-top .3s var(--pop), padding-bottom .3s var(--pop),
              opacity .2s var(--quick);
}
.ed-tool-body .ed-swatches > * {
  transition: transform .34s var(--pop), opacity .26s var(--quick);
}
.ed-tool-body .ed-swatches > :nth-child(2) { transition-delay: .05s; }
.ed-tool-body .ed-swatches > :nth-child(3) { transition-delay: .1s; }
.ed-tool-body .ed-swatches.is-none {
  max-height: 0; opacity: 0; pointer-events: none;
  padding-top: 0; padding-bottom: 0;
  margin-top: -8px;                    /* and the body's gap with it — see .ed-note */
}
.ed-tool-body .ed-swatches.is-none > * {
  transform: translateY(10px); opacity: 0;
  /* leaving, they go together — the stagger is for arriving */
  transition-delay: 0s;
}
@media (prefers-reduced-motion: reduce) {
  .ed-tool-body .ed-swatches,
  .ed-tool-body .ed-swatches > * { transition-duration: .001s; }
}
/* The border runs 0 to 6mm in half-millimetre steps — thirteen stops, which do
   not need the width of the card to be reachable. It is the smallest range in
   the editor and it can look like it. */
.ed-tool-body .ed-border-row .ed-border { flex: 0 1 132px; }

/* An empty row is not a row. The note and the reset button share the crop card's
   last line, and on a freshly opened tool neither exists yet — so the card was
   holding a chip's height of nothing under its sliders, waiting. Same collapse
   the other reveals use, gap and all. */
.ed-tool-body .ed-modes {
  overflow: hidden; max-height: 44px;
  transition: max-height .28s var(--pop), margin-top .28s var(--pop),
              opacity .18s var(--quick);
}
.ed-tool-body .ed-modes.is-quiet {
  max-height: 0; margin-top: -8px; opacity: 0; pointer-events: none;
}

/* A die cut has no crop — the path IS the art — so the zoom control says so
   rather than sitting there doing nothing. */
.ed-tool-body .ed-zoom-row.is-off { opacity: .35; pointer-events: none; }
/* The note and the button share the bottom line — the remark on the left, the
   way to act on it hard right. Space-between holds the button against the right
   edge whether or not the note has anything to say, since an empty note is a
   zero-width first item. */
.ed-tool-body .ed-modes {
  gap: 10px; justify-content: space-between; align-items: center;
  /* This row wears `shape-chips` to borrow the chip styling, and that class
     brings flex-wrap with it — so a long note shoved the button onto a line of
     its own, which is the opposite of putting them side by side. The note wraps
     inside itself instead. */
  flex-wrap: nowrap;
}
/* `width: 0` and let flex-grow do the sizing — the note must not be allowed to
   VOTE on how wide this card is.
   The card takes its width from its contents now, and this note's contents
   change on every frame of a zoom drag ("113 dpi, a little soft at this size"
   is shorter than "75 dpi, too soft to print well at this size"), so the card
   was resizing continuously under the hand using it. A flex item with a zero
   width contributes zero to its container's intrinsic width and then grows into
   whatever is left, which is the whole of what this line should do: fill the
   space the controls leave and wrap inside it. */
.ed-tool-body .ed-modes .ed-crop-note {
  text-align: left; flex: 1 1 0; width: 0; min-width: 0;
  /* two short lines rather than a full one and a stub */
  text-wrap: balance;
}
.ed-tool-body .ed-modes .ed-recentre {
  flex: none;
  transition: opacity .2s var(--quick), transform .26s var(--pop);
}
/* nothing to put back yet */
.ed-tool-body .ed-modes .ed-recentre.is-idle {
  opacity: 0; transform: scale(.94); pointer-events: none;
}
/* the chips get the room to wrap here; the scrolling row was for a panel that
   had one line to spare, and a card has as many as it needs */
.ed-tool-body .ed-shapes {
  flex-wrap: wrap; overflow: visible; mask-image: none; max-width: 320px;
}
/* A chip row carries 12px above and 4px below for use in a panel. Inside a card
   the body's own 8px gap already spaces everything, so those margins land on top
   of it and open a hole under each heading. */
.ed-tool-body .shape-chips { margin: 0; }
/* Inside a tool card a chip is a list row, not a sticker. The peel-and-tilt
   hover belongs to things sitting ON the sheet; here it lifts a chip off the
   card it is already inside, which is one surface too many. It just darkens.
   `button.q-chip`, not `.q-chip`: the global lift is written as
   `button:not(.peel-front):not(.bare):not(.link):hover`, which counts as FOUR
   classes and an element — each :not() contributes its argument, and :hover is a
   class too. Two goes at this lost on specificity before the third: the row
   below is three classes plus :hover plus an element, which ties it and wins on
   order. */
/* At REST a chip is a key sitting on the card: white, with just enough shadow
   to sit above it. On HOVER the shadow goes and it darkens — the key pressing
   in. That is the opposite of the sheet's peel-up hover, and right here: these
   are set into a surface rather than stuck onto one. */
.ed-tool-body .shape-chips button.q-chip {
  background: var(--raised); color: var(--ink-soft); box-shadow: var(--e1);
  transition: background .16s var(--quick), box-shadow .16s var(--quick),
              color .16s var(--quick);
}
/* The SELECTED chip stays blue. The rest state above is written specifically
   enough to outrank plain `.q-chip.is-on`, so without this the selection vanished
   under it — the chip you had chosen looked exactly like the seven you hadn't.
   The same rule, restated where it can be seen. */
/* NO WEIGHT CHANGE ON SELECTION, and that is the point rather than an omission.
   A chip in bold is a wider chip, and in a row that wraps, widening one chip can
   push the last one onto a line of its own — so choosing "holographic" made the
   row it was in re-lay itself out around the choice. Chips moving as you pick
   between them is the worst possible moment for the layout to shift, because the
   next thing anyone does is compare. The cyan already says which one is on
   loudly enough; the weight was saying it twice and charging pixels for it. */
.ed-tool-body .shape-chips button.q-chip.is-on {
  background: var(--reg-cyan); color: #1a1b1e; font-weight: 500;
  box-shadow: none; transform: none;
}
.ed-tool-body .shape-chips button.q-chip:hover,
.ed-tool-body .shape-chips button.q-chip:focus-visible {
  transform: none; box-shadow: none;
  background: var(--well-hover); color: var(--ink);
}
.ed-tool-body .shape-chips button.q-chip:active {
  transform: none; box-shadow: none;
}
/* hovering the selected chip changes nothing: it is already where you put it */
.ed-tool-body .shape-chips button.q-chip.is-on:hover {
  transform: none; box-shadow: none;
  background: var(--reg-cyan); color: #1a1b1e;
}

/* Room to breathe above a section heading that follows something. The first one
   sits at the top of the card where the padding already handles it. */
.ed-tool-body .ed-sec-crop { margin-top: 10px; }

/* The label column, sized per KIND of row rather than globally.
   74px was set so "proportion" — a shape knob — would fit, and then every other
   row in the editor paid for it: "size" is 21px wide and was sitting in a 74px
   column, so the slider started 53px away from its own label and the row read as
   two unrelated things. The knobs keep the wide column, because they still have
   the long words in them; the four standing controls get one sized to "border",
   which is the longest of them. */
.ed-tool-body .ed-row .ctl-label { width: 46px; }
.ed-tool-body .ed-knob .ctl-label { width: 74px; }

/* Section headings inside a tool card. Small, quiet, and doing real work: they
   are what tells you that cutting out the background and cropping to a shape are
   two different acts rather than nine equal options. */
.ed-sec {
  margin: 2px 0 0; font-family: var(--ui); font-size: .68rem; font-weight: 600;
  letter-spacing: .04em; text-transform: uppercase; color: var(--ink-soft);
  opacity: .8;
}
.ed-cut[hidden] { display: none; }
.ed-cut { display: flex; flex-direction: column; gap: 6px; }
.ed-knobs { display: flex; flex-direction: column; gap: 6px; }
.ed-knobs[hidden] { display: none; }
.ed-knob output { min-width: 42px; text-align: right; }

/* A tool with nothing to do LEAVES — and it leaves the way everything else in
   here arrives, by bubbling. Dimmed, it read as "broken" rather than "not
   applicable": you could still see it, so you still wondered what it would have
   done. display:none was honest about being gone but said nothing about where
   it went — the rail silently had one fewer tool than a moment before. Now the
   slot closes under the tools below it while the button shrinks into the gap it
   is leaving, and reverses to arrive. */
.ed-tool-slot.is-gone {
  height: 0; margin-bottom: -14px; pointer-events: none;
}
.ed-tool-slot.is-gone .ed-tool-wrap {
  transform: scale(.35); opacity: 0;
}

/* The one instruction on the surface, because dragging the picture inside its
   shape is the only thing here with no control attached to it — and so the only
   thing nobody finds. Reserved height, like the size tool's note: it must not
   move the rows under it when it changes. */
/* On the SHEET, above the sticker — the same place the size tool puts its
   measurements. An instruction about dragging the sticker belongs beside the
   sticker, not in a panel you have to look away from it to read.
   Anchored to the STAGE, not to the artwork. The dimension rules track the
   sticker because they are measuring it; this is measuring nothing, and text
   that slides around whenever the shape or size changes is text you have to
   find again every time you look for it. */
/* A LINE ABOVE THE MAT, in the editor's own column.
   It used to hang at a fixed distance above the stage's centre. That was chosen
   so it would hold still while the picture was dragged around inside its frame —
   which it did — but it is a fixed offset inside a box the sticker can grow to
   fill, and past about 100mm the instruction was being printed across the
   artwork. Out here it holds just as still, can never be reached, and it keeps
   its line whether or not it is showing, so the stage doesn't jump when the crop
   tool opens. */
/* ONE reserved line, holding both hints stacked on top of each other. Only one
   can ever show — crop's belongs to the crop tool, tilt's to the finish tool —
   so giving them a slot each would reserve two lines to display at most one,
   and every sticker on every phone would lose that height for good. */
.ed-hints { flex: none; display: grid; }
.ed-hints > .ed-hint { grid-area: 1 / 1; }
.ed-hint {
  flex: none; margin: 0; z-index: 2;
  /* Rides DOWN to the sticker's own top edge. It lives outside the stage so a
     large sticker can never grow into it and the stage can never clip it, but
     that leaves it stranded at the top of the mat when the sticker is small —
     which is where nobody is looking. --m-y is how far the artwork's top sits
     below the stage's, published by measure(); translating by it puts the line
     just above the sticker at any size. Transform, not margin, so moving it
     never reflows the stage under it. */
  transform: translateY(calc(var(--m-y, 0px) - 4px));
  transition: transform .18s var(--quick),
              opacity .2s var(--quick), visibility .2s;
  font-family: var(--ui); font-size: .72rem; line-height: 1.35;
  color: var(--ink-soft); text-align: center; white-space: nowrap;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  opacity: 0; visibility: hidden;
}
.editor[data-tool="crop"] .ed-crop-hint { opacity: 1; visibility: visible; }
/* Only when tilting is really what is driving the light. `is-tilting` is set
   once the grant has come back and orientation events are actually arriving, so
   a desktop, a refused prompt or a browser without the API is never told to tilt
   something — an instruction that does nothing is worse than no instruction. */
.editor.is-tilting[data-tool="finish"] .ed-tilt-hint { opacity: 1; visibility: visible; }
/* The gesture, drawn. An instruction that shows the motion is read in about the
   time it takes to not read the sentence — which is the whole problem with hint
   text. Small, quiet, and looping slowly enough to be scenery rather than a
   thing demanding attention. */
.ed-hint-bit { display: inline-flex; align-items: center; gap: 6px; }
.ed-hint-sep { opacity: .5; }
.ed-hint-gfx { width: 20px; height: 16px; flex: none; overflow: visible; }
.ed-hint-gfx .hg-frame, .ed-hint-gfx .hg-line {
  fill: none; stroke: currentColor; stroke-width: 1.3;
  stroke-linecap: round; opacity: .55;
}
.ed-hint-gfx .hg-dot { fill: currentColor; }
/* Animate the GROUP, not the geometry: `transform` on an SVG child needs a box
   to resolve percentages and an origin against, and view-box gives it the same
   user units the path is written in. */
.ed-hint-gfx g {
  transform-box: view-box; transform-origin: center;
}
.hg-slide { animation: hint-slide 2.6s var(--quick) infinite; }
.hg-wheel { animation: hint-wheel 1.9s var(--quick) infinite; }
.hg-pinch-a { animation: hint-pinch-a 2.2s var(--quick) infinite; }
.hg-pinch-b { animation: hint-pinch-b 2.2s var(--quick) infinite; }
/* a phone rocking side to side — the whole instruction, in one glyph */
.hg-rock { animation: hint-rock 2.4s var(--quick) infinite; }
@keyframes hint-rock {
  0%, 10%   { transform: rotate(0deg); }
  35%       { transform: rotate(13deg); }
  65%       { transform: rotate(-13deg); }
  90%, 100% { transform: rotate(0deg); }
}
@keyframes hint-slide {
  0%, 12%   { transform: translate(0, 0); }
  38%, 50%  { transform: translate(4.5px, -2px); }
  76%, 100% { transform: translate(0, 0); }
}
@keyframes hint-wheel {
  0%, 10%   { transform: translateY(0); opacity: 1; }
  45%       { transform: translateY(3.4px); opacity: .35; }
  46%       { transform: translateY(-3.4px); opacity: 0; }
  70%, 100% { transform: translateY(0); opacity: 1; }
}
@keyframes hint-pinch-a {
  0%, 12%   { transform: translateX(0); }
  46%, 58%  { transform: translateX(2.8px); }
  92%, 100% { transform: translateX(0); }
}
@keyframes hint-pinch-b {
  0%, 12%   { transform: translateX(0); }
  46%, 58%  { transform: translateX(-2.8px); }
  92%, 100% { transform: translateX(0); }
}
/* One of the two zoom hints, never both — and the one that matches the thing
   they are actually holding. */
.ed-crop-hint[data-input="mouse"] .ed-hint-touch,
.ed-crop-hint[data-input="touch"] .ed-hint-mouse { display: none; }
@media (prefers-reduced-motion: reduce) {
  .ed-hint-gfx g { animation: none !important; }
}
.ed-crop-note {
  margin: 0; min-height: 1.1em;
  font-family: var(--ui); font-size: .72rem; color: var(--ink-soft);
}
.ed-crop-note[data-tone="warn"] { color: var(--warn-text, var(--ink)); }
.ed-crop-note[data-tone="bad"] { color: var(--bad-text); font-weight: 600; }

/* The finish card. The chips name the material; the sticker itself is the
   preview, because a swatch of "matte" is a grey rectangle and tells you
   nothing about what your own artwork will look like under it. */
.ed-finishes { flex-wrap: wrap; margin: 0; }
/* on foil stock the "white" swatch is not white, and says so */
.ed-swatch.is-foil { background: var(--holo); background-size: 200% 100%; }
.ed-finish-blurb {
  margin: 2px 0 0; max-width: 260px;
  font-family: var(--ui); font-size: .7rem; line-height: 1.4; color: var(--ink-soft);
}

/* the rows inside are the panel's rows, now that they live here */
/* THE SIZE CARD, READ TOP TO BOTTOM.
   Two controls, then two things the card is telling you about them. It had been
   accreting: the measurements shouldered apart across the top, the sheet meter
   shoved to the right rail, the warning starting at the left — three different
   alignments in a 300px card, so nothing looked related to anything.
   Now it is controls above, status below. The pair of status lines share a
   centre and sit together under a hairline, which is what makes them read as
   commentary on the controls rather than as two more controls. */
.ed-tool-body .ed-dims { display: flex; margin: 0; justify-content: space-between; }
/* Every card ends the same way: whatever the card is telling you, centred under
   the controls it is telling you about. A note that starts at the left rail
   while the meter above it ends at the right rail is two alignments doing the
   same job — which is what made these read as random rather than composed. */
.ed-tool-body .ed-sheet,
.ed-tool-body .ed-note,
.ed-tool-body .ed-crop-note,
.ed-tool-body .ed-finish-blurb { text-align: center; justify-content: center; }
/* and the finish blurb, which changes with the finish under the pointer — same
   zero-width, full-width treatment so trying finishes on doesn't resize the card
   you are trying them from */
.ed-tool-body .ed-finish-blurb {
  max-width: none; margin: 2px 0 0; width: 0; min-width: 100%;
}
/* and the colours sit under the middle of the slider they belong to, not shoved
   against the left edge of the card */
.ed-tool-body .ed-swatches { justify-content: center; }
.ed-tool-body .ed-budget { margin-left: 0; }
/* The note takes NO room until it has something to say. It used to hold a line's
   worth of height open permanently, because the panel it lived in was what the
   stage got measured from and a note appearing would have moved the artwork. In
   a card it can simply grow — same reveal the border colours get, keyed off
   `:empty`, which is exactly true here because the note is set by writing
   textContent and an empty string leaves no child nodes at all. */
.ed-tool-body .ed-note {
  margin: 0; min-height: 0; overflow: hidden;
  max-height: 60px; opacity: 1;
  text-wrap: balance;
  /* Zero-width, full-width — the same trick as the crop note, for the same
     reason: this text changes as the size slider moves, and a card that takes
     its width from its contents would resize on every frame of the drag. Zero
     contributes nothing to the intrinsic width; the percentage min-width fills
     whatever the controls settled on. */
  width: 0; min-width: 100%;
  transition: max-height .3s var(--pop), opacity .2s var(--quick),
              margin-top .3s var(--pop);
}
.ed-tool-body .ed-note:not(:empty) { margin-top: 4px; }
/* -8px, not 0: the body is a flex COLUMN with an 8px gap, and a gap is paid for
   the moment an item exists — so a note collapsed to zero height still left a
   row of space under the controls, which is the "why is the size card so tall"
   question. The negative margin eats exactly the gap it costs. */
.ed-tool-body .ed-note:empty { max-height: 0; opacity: 0; margin-top: -8px; }

/* THE RAIL LIES DOWN ON A PHONE.
   One layout everywhere was the intention, and it held right up until the
   numbers came in: measured on a 375px screen, an open card covered 84% of the
   sticker — half of it on a 390 — which is the one thing this editor exists to
   let you look at. There is no arranging a 340px sticker, a 280px card and a
   commit row inside 667px of phone; something has to give, and what gives is the
   card's POSITION, not the sticker's size.
   So on a phone the tools go where phone controls go: a row along the bottom,
   with the card opening upward as a sheet above them and the artwork holding the
   whole space above that. The bubble's neck and fillet are a left-rail idea and
   stand down here — a sheet is a sheet. */
@media (max-width: 460px) {
  .ed-dock {
    position: fixed; left: 0; right: 0; top: auto;
    bottom: calc(var(--ed-foot, 128px) + 8px);
    flex-direction: row; justify-content: center; align-items: flex-end;
    gap: 10px; padding: 0 10px;
  }
  /* a row collapses sideways, not downward */
  .ed-tool-slot { height: auto; margin-bottom: 0; width: 56px; }
  .ed-tool-slot.is-gone { height: auto; width: 0; margin-bottom: 0; overflow: hidden; }
  /* BACK IN FLOW, AND NO FILTER.
     Absolute is what let the desktop card hang off its button; in a row the
     wraps have to take their own space or the rail collapses to nothing. And the
     filter has to go with it — a filtered element becomes the containing block
     for anything `fixed` inside it, so the sheet below was being laid out inside
     a 56px button and came out 40px wide. The elevation moves onto the pieces
     that are actually raised. */
  .ed-tool-wrap,
  .ed-tool-wrap.is-open {          /* .is-open sets the filter too, and a media
                                      query adds no specificity — both or neither */
    position: relative; filter: none;
    transform-origin: 50% 50%; width: 56px;
  }
  .ed-tool-wrap::before, .ed-tool-wrap::after { display: none; }
  button.ed-tool-btn { box-shadow: var(--e2); }
  /* no room for a hover label on a device with no hover */
  .ed-tool-name { display: none; }

  /* The card: a sheet across the bottom, above the rail. Fixed, so it stays put
     while the sticker scrolls behind it. */
  .ed-tool-menu {
    position: fixed; left: 8px; right: 8px;
    bottom: calc(var(--ed-foot, 128px) + 74px);
    width: auto; max-width: none;
    border-radius: 22px; box-shadow: var(--e3, var(--e2));
  }
  .ed-tool-wrap.is-open .ed-tool-menu {
    max-width: none;
    /* THE SHEET NEVER RUNS OFF THE TOP. The shape card is the tallest — eight
       chips that wrap — and on a 667px phone it reached 76px above the screen,
       where the first section heading simply wasn't there. It stops at the bar
       and scrolls inside itself instead. */
    max-height: calc(100dvh - var(--ed-foot, 128px) - var(--bar-h, 0px) - 110px);
  }
  .ed-tool-menu > .ed-tool-body { overflow-y: auto; overscroll-behavior: contain; }
  .ed-tool-body { padding: 14px 16px; }

  /* The commit row is pinned too, so "add to envelope" is always one tap away
     rather than a scroll away at the bottom of a tall sticker. */
  .ed-head {
    position: fixed; left: 0; right: 0; bottom: 0; margin: 0;
    padding: 10px 0 calc(8px + env(safe-area-inset-bottom));
    background: linear-gradient(to top, var(--liner) 62%, transparent);
  }
  /* THE SHEET RUNS EDGE TO EDGE, and the furniture floats on it.
     The mat used to be inset to clear the bar above and the tools below, which
     kept everything tidy and made the sticker small: on the one screen with the
     least room, a third of it went to margins the sticker was not allowed into.
     It reads better running under the tools and giving out just before the name —
     and under the mark too, which costs nothing, because the bar is already
     pinned above the editor with no background of its own (see body.editing .bar)
     so the sheet's ruling, and now the sticker, carry on behind it.
     The fade is what makes this legible rather than messy: markCut() measures the
     visible mat as stopping at the name, so the artwork gives out there instead
     of sliding under the words. */
  .editor { padding: 0; }
  /* FULL BLEED BOX, INSET CONTENT. The mat's box is the whole screen — that is
     what lets a sticker run under the mark and under the tools — but where a
     sticker RESTS is decided by the content box, and padding is what separates
     the two. Without it the mat centres its sticker on the middle of the screen,
     which parks a large one with the logo sitting on its face: fine to pan into,
     wrong to arrive at.
     Padding rather than a smaller box, because padding is also scrollable room:
     the sticker settles clear of the furniture and can still be pushed under it. */
  .ed-view {
    padding-top: calc(var(--bar-h, 0px) + 10px);
    padding-bottom: calc(var(--ed-foot, 128px) + 10px);
  }
  /* Out of the column, since the mat now owns the whole surface. It still rides
     down to the artwork's top edge on --m-y; this only decides where it starts
     from, which is under the mark rather than above the sheet. */
  .ed-hints {
    position: absolute; left: 0; right: 0; z-index: 3;
    top: calc(var(--bar-h, 0px) + 6px);
  }

  /* NO DIMENSION RULES ON A PHONE. They hang 26px off the sticker's edge, and a
     sticker printed at true size very nearly fills the screen — so the left-hand
     rule lands off it, showing as a stray sliver of line at the edge. The size
     card is open whenever they would be, and it states both dimensions in figures
     you can also type into. The rules are the desktop's way of saying the same
     thing in the space it has to spare. */
  .editor[data-tool="size"] .ed-rule { display: none; }
}

/* A cart row's controls on a phone: edit, remove, how many, what it costs — one
   line if they can manage it. They were taking three, which turns a list of two
   stickers into a page of scrolling. The stepper gives up the width; it is four
   taps at most and none of them need a 156px target. */
@media (max-width: 460px) {
  .sticker-row .tool-row { gap: 6px; row-gap: 8px; }
  .sr-qty { height: 38px; padding: 2px; gap: 2px; }
  .sr-qty button { width: 32px; height: 32px; }
  .sr-qty output { min-width: 20px; font-size: .84rem; }
  .sticker-row .tool { padding: 0 14px; }

  /* A COLOUR IS A TARGET, and on a phone it was a 22px one — half the 44px a
     finger is reckoned to need. The circles stay the size they look (a swatch
     that size is right beside a slider), and grow a transparent margin of hit
     area around themselves instead, which is what the guidance is actually
     about. The row's gap absorbs it, so nothing moves. */
  .ed-swatches { gap: 16px; }
  .ed-swatch { position: relative; }
  .ed-swatch::after {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: 44px; height: 44px; transform: translate(-50%, -50%);
  }
  /* the picker clips its hidden colour input, and that clipped away the target
     with it — the circle is drawn by border-radius, so nothing needs hiding */
  .ed-swatch-pick { overflow: visible; }
}

/* TURN IT UPRIGHT.
   A phone on its side has ~390px of height, and a sticker printed at true size
   plus a row of tools plus the way forward do not fit in it — not by
   rearranging, by arithmetic. Rather than ship a cramped third layout that
   nobody would enjoy using, ask for the one that works. Touch devices only:
   a desktop window this short is a person resizing a window, not a phone. */
@media (orientation: landscape) and (max-height: 460px) and (pointer: coarse) {
  body.editing .editor,
  body.editing .bar { display: none; }
  body.editing::after {
    content: "turn your phone upright to edit";
    position: fixed; inset: 0; z-index: 200;
    display: flex; align-items: center; justify-content: center;
    padding: 0 40px; text-align: center;
    background: var(--liner); color: var(--ink);
    font-family: var(--ui); font-size: 1rem; font-weight: 600;
  }
}

/* the point editor's own row, and its cursor */
.ed-pt-hint { font-family: var(--ui); font-size: .72rem; color: var(--ink-soft); }
/* placing points is a mode you're IN, so the tool that turns it on wears the
   same stuck-down look a selected chip does */
.editor.is-points .ed-canvas { cursor: crosshair; }
.ed-pt-add.is-on { background: var(--reg-cyan); color: #1a1b1e; }
.ed-pt-add.is-on .ico { background: #1a1b1e; }

/* THE COMMIT PAIR — the name, and the button that acts on it.
   One grid column, which is the whole trick: a grid track takes the width of the
   WIDEST item in it and `justify-items: stretch` hands that width back to both,
   so the field is exactly as wide as the button without either of them being
   told a number. The button is the wider of the two (the field's `size` is
   pinned to 4 in editor.js precisely so it can never win and drag the button out
   with it), so the button's own padding sets the width of the pair, for good. */
.ed-commit {
  display: grid; justify-items: stretch; gap: 10px;
  width: max-content; max-width: min(92vw, 520px);
}
/* HOW MANY, then WHERE IT GOES — in that order, because that is the order the
   two decisions happen in and the second one is final. The name above still
   spans the pair: it is the title of the thing both of these act on. */
.ed-commit-row {
  display: flex; align-items: center; gap: 10px;
}
/* The peel's scaffold makes .cta taller than the button inside it, so centring
   the two boxes left the stepper and the button three pixels apart. Match their
   heights and the line is straight. */
.ed-commit-row .cta { display: flex; align-items: center; flex: 1 1 auto; height: 44px; }
.ed-commit-row .ed-done { width: 100%; }
/* The stepper is 52px tall by design-system default and the button is 44 — match
   the button, since they sit on one line and the button is the one being aimed
   at. Everything inside scales with it. */
.ed-qty { height: 44px; padding: 3px; flex: none; }
.ed-qty button { width: 38px; height: 38px; }
.ed-qty button .ico { width: 16px; height: 16px; }
.ed-qty output { min-width: 30px; font-size: .92rem; }

/* Naming sits directly above the way forward, because that is the order it
   happens in — and at rest it is NOT a field. It is the title of the thing you
   are about to add, set as text on the sheet. The pill arrives when you reach
   for it: a shade under the pointer to say it can be touched, the full raised
   pill once the caret is in it and it really is an input. A box drawn around a
   name nobody has questioned is a form where there wasn't one.

   When it does become a field it behaves like any other text field — caret where
   you clicked, nothing selected, no coloured ring. The blue read as an error or
   a validation state on what is just a name. */
.ed-name-pill {
  position: relative; display: flex; align-items: center;
  min-width: 0; border-radius: 9999px; cursor: text;
  background: none; box-shadow: none;
  transition: background .16s var(--quick), box-shadow .16s var(--quick);
}
.ed-name-pill:hover { background: var(--liner-deep); }
.ed-name-pill:focus-within {
  background: var(--raised); box-shadow: var(--e1);
}
/* Selected text in here is NOT the system blue. Blue is this app's interaction
   colour — it means "this control is on" everywhere else — so seeing it flood a
   name reads as a state you've entered rather than as characters you've picked
   up. A wash of the ink itself says the same thing and says only that, and it
   holds its contrast in both themes because it is made from the text colour. */
.ed-name::selection {
  background: color-mix(in oklab, var(--ink) 18%, transparent);
  color: var(--ink);
}
.ed-name {
  flex: 1 1 auto; min-width: 0; margin: 0;
  font-family: var(--ui); font-size: .9rem; font-weight: 600;
  color: var(--ink); background: none; border: 0; border-radius: inherit;
  padding: 11px 30px; text-align: center; cursor: text;
}
/* The app-wide rule turns a focused input SOLID cyan, the way a chip turns on —
   right for the little numeric fields, wrong here. This is a name, typed in
   prose, and flooding it with the interaction colour both makes the text you are
   writing hard to read and says "on" about something that has no on. It behaves
   like an ordinary text field instead: the pill is the surface, the ink stays
   ink, and the only cyan is the caret — the one mark that genuinely means
   "you're typing here".
   `.ed-name-pill .ed-name:focus` is three classes against the global rule's two
   plus an element, which is what it takes to win. */
.ed-name-pill .ed-name:focus {
  outline: none;
  background: none;
  color: var(--ink);
  caret-color: var(--reg-cyan);
}
.ed-name-pill .ed-name:focus::placeholder { color: var(--ink-soft); }
.ed-name::placeholder { color: var(--ink-soft); font-weight: 400; }
/* UNTOUCHED. What's in the field is a suggestion we made, not something anyone
   chose, so it carries the weight of a placeholder — lighter, and a pencil
   beside it to say the suggestion can be replaced. Both go the moment it is
   theirs, because by then it is a name and not an invitation. */
.ed-name-pill.is-fresh .ed-name { color: var(--ink-soft); font-weight: 500; }
.ed-name-pen {
  position: absolute; right: 14px; width: 15px; height: 15px;
  background: var(--ink-soft); pointer-events: none;
  opacity: 0; transition: opacity .16s var(--quick);
}
.ed-name-pill.is-fresh .ed-name-pen { opacity: .75; }
/* and it steps aside while they type, rather than sitting over the caret */
.ed-name-pill.is-fresh:focus-within .ed-name-pen { opacity: 0; }

/* the primary carries an icon now, so it needs to lay out as a row */
.ed-done { display: inline-flex; align-items: center; gap: 9px; padding: 0 30px; }
.ed-done .ico { width: 18px; height: 18px; }

/* The bar is in normal flow, so if the page had been scrolled it would sit above
   the viewport while the editor is open — pinned for the duration, and above the
   editor so its menu still opens over it.
   NO background. It used to carry one, which put the mark and the chip on a slab
   with the ruling stopping underneath them — the one place in the app where the
   sheet doesn't run edge to edge. Nothing scrolls under it here (body.editing
   locks the page), so there is nothing for it to hide. */
body.editing .bar {
  position: fixed; top: 0; left: 0; right: 0; z-index: 51;
  background: none;
}

/* THE SHEET, FILLING UP.
   It used to be five die-cut cells filled whole, because a unit was indivisible.
   Units are gone — an order is bounded by the sheet it prints on and priced by
   the material it uses — so the meter is the sheet itself, and it fills
   continuously because that is what actually happens to it. */
.sheet-bar {
  height: 32px; border-radius: 9999px; background: var(--liner-deep);
  overflow: hidden; margin-bottom: 8px;
}
.sheet-bar i {
  display: block; height: 100%; width: var(--used, 0%);
  background: var(--reg-cyan); border-radius: 9999px;
  transition: width .26s var(--pop), background .22s var(--quick);
}
.budget.is-full .sheet-bar i { background: var(--magenta); }
