/* Leopard staff UI. The bulk of this is mockups/styles.css verbatim — design, kept byte-identical
 so the app and the mockup cannot drift. Leopard-specific additions are appended at the end,
 written against the same custom properties so they theme correctly. Re-sync with:
 cp./mockups/styles.css and re-append the block below. */

/* The `hidden` attribute must actually hide. The browser's own `[hidden] { display: none }` comes
 from the ua stylesheet, so any author rule setting `display` outranks it — and an element told
 to hide simply does not.

 This codebase patched that **seven separate times**, one class at a time, each with a comment
 explaining the same thing (`.photo-frame video`, `.photo-error`, `.photo-actions > div`,
 `.sub-block`, `.note-form`, `.search-clear`, `.info-hint`). It kept recurring because the fix
 kept being local.

 It was still live on the customer kiosk, found by sweeping every view for the pattern:
 `.kiosk-idle-panel { display: grid }` meant the "scan your barcode" panel stayed on screen
 underneath a check-in result — on the one screen every member looks at, every visit.

 `!important` is correct here and nowhere near it. `hidden` is not styling, it is a statement
 that the element is not currently relevant; there is no design in which a rule should win
 against it. */
[hidden] { display: none !important; }

:root {
  --bg: #fff1d2;
  --paper: #fffdf8;
  --paper-2: #fff7e8;
  --ink: #25180c;
  --muted: #6f5a43;
  --quiet: #9b7d5b;
  --line: #ead5ad;
  --line-strong: #d7a763;
  --brand: #e87812;
  --brand-dark: #b95708;
  --green: #a85b09;
  --green-soft: #fff0bf;
  --amber: #c55d0a;
  --amber-soft: #fff0cf;
  --red: #c64230;
  --red-soft: #ffebe7;
  --blue: #28748f;
  --blue-soft: #e8f6f8;
  --plum: #7b4b83;
  --plum-soft: #f6ecf5;
  --leopard-spot: rgba(80, 47, 15, 0.11);
  --leopard-ring: rgba(181, 99, 12, 0.14);
  --shadow: 0 14px 32px rgba(80, 47, 15, 0.12);
  /* The corner dial — three numbers, and changing them changes everything (D409). Until `--radius`
   was a lie on staff screens: `portal.css` loads after this file and redefined it on a bare
   `:root`, so 8px won and nothing here was ever in effect — and the buttons did not consult it
   at all, because portal.css hardcoded 7px over them too — so changing the number and
   refreshing correctly appeared to do nothing. Tiers, not one value: a chart bar 3px thick cannot
   carry a dialog's corner. Pills (999px) and circles (50%) are shapes rather than corner
   preferences and stay literal — putting them on this dial would square off the badges when you
   tighten a button. */
  --brand-soft: #e4f3f2;
  --radius-sm: 2px;
  --radius: 4px;
  --radius-lg: 10px;
  --sidebar-width: 310px;
  --font-ui: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --font-heading: "Aptos Display", "Aptos", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  --heading-weight: 700;
  --title-weight: 720;
  color-scheme: light;
  font-family: var(--font-ui);
  font-size: 15px;
  letter-spacing: 0;
}

:root[data-theme="light"] {
  --bg: #f5f6f7;
  --paper: #ffffff;
  --paper-2: #f9fafb;
  --ink: #161a1d;
  --muted: #66717d;
  --quiet: #8b949e;
  --line: #dde3e8;
  --line-strong: #c8d1d8;
  --brand: #0f8f8a;
  --brand-dark: #08736f;
  --green: #2f8f4e;
  --brand-soft: #e4f3f2;
  --green-soft: #e8f5ec;
  --amber: #bd6f13;
  --amber-soft: #fff4df;
  --red: #c64230;
  --red-soft: #ffebe7;
  --blue: #346ed8;
  --blue-soft: #ebf2ff;
  --plum: #7051a8;
  --plum-soft: #f1edfb;
  --shadow: 0 14px 32px rgba(22, 26, 29, 0.08);
  color-scheme: light;
}

:root[data-theme="dark"] {
  --bg: #11100e;
  --paper: #1d1a16;
  --paper-2: #262119;
  --ink: #f6efe3;
  --muted: #c8b89e;
  --quiet: #9e8c72;
  --line: #423527;
  --line-strong: #76562d;
  --brand: #f0a526;
  --brand-dark: #d17b10;
  --green: #f0b53b;
  --brand-soft: #3d2a0c;
  --green-soft: #3a2b12;
  --amber: #ffb02e;
  --amber-soft: #3a2914;
  --red: #ff7769;
  --red-soft: #3f1d1a;
  --blue: #7dcce2;
  --blue-soft: #12323a;
  --plum: #d6a6e3;
  --plum-soft: #302039;
  --leopard-spot: rgba(255, 190, 79, 0.07);
  --leopard-ring: rgba(255, 190, 79, 0.1);
  --shadow: 0 18px 36px rgba(0, 0, 0, 0.32);
  color-scheme: dark;
}

:root[data-theme="unicorn"] {
  --bg: #fff0fb;
  --paper: #fffaff;
  --paper-2: #fff2fc;
  --ink: #2c1738;
  --muted: #73517e;
  --quiet: #9c78a9;
  --line: #ead0f1;
  --line-strong: #c98ede;
  --brand: #c13ee8;
  --brand-dark: #8d25b5;
  --green: #8a35bd;
  --brand-soft: #f9e4ff;
  --green-soft: #f7e8ff;
  --amber: #d154a7;
  --amber-soft: #ffe6f7;
  --red: #c64266;
  --red-soft: #ffe8ef;
  --blue: #5d73f0;
  --blue-soft: #edf0ff;
  --plum: #b92ad7;
  --plum-soft: #f9e6ff;
  --leopard-spot: rgba(193, 62, 232, 0.1);
  --leopard-ring: rgba(247, 103, 198, 0.14);
  --shadow: 0 14px 32px rgba(128, 42, 147, 0.14);
  color-scheme: light;
}

* {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  background:
    radial-gradient(circle at 18px 22px, transparent 0 5px, var(--leopard-ring) 5.5px 8px, transparent 8.5px),
    radial-gradient(circle at 84px 56px, var(--leopard-spot) 0 4px, transparent 4.5px),
    radial-gradient(circle at 128px 30px, transparent 0 4px, rgba(72, 44, 18, 0.09) 4.5px 7px, transparent 7.5px),
    linear-gradient(135deg, #fff7e3, var(--bg));
  background-size: 156px 118px, 156px 118px, 156px 118px, auto;
  color: var(--ink);
}

:root[data-theme="light"] body {
  background: var(--bg);
}

:root[data-theme="dark"] body {
  background:
    radial-gradient(circle at 18px 22px, transparent 0 5px, var(--leopard-ring) 5.5px 8px, transparent 8.5px),
    radial-gradient(circle at 84px 56px, var(--leopard-spot) 0 4px, transparent 4.5px),
    linear-gradient(135deg, #11100e, var(--bg));
  background-size: 156px 118px, 156px 118px, auto;
}

:root[data-theme="unicorn"] body {
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='150' viewBox='0 0 180 150'%3E%3Cg fill='none' stroke='%23b92ad7' stroke-opacity='.18' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M82 55 95 20 103 56'/%3E%3Cpath d='M73 66c6-18 25-24 37-12 10 10 5 31-12 37-18 7-34-5-35-22'/%3E%3Cpath d='M112 65c9 2 18 8 22 17'/%3E%3Cpath d='M64 72c-8-1-15-5-20-12'/%3E%3Cpath d='M90 82h.1'/%3E%3C/g%3E%3Cpath fill='%23ff7ace' fill-opacity='.13' d='M125 38c18 8 24 24 20 40-10-16-24-25-42-27 4-12 11-17 22-13Z'/%3E%3C/svg%3E"),
    radial-gradient(circle at 34px 28px, rgba(255, 122, 206, 0.18) 0 3px, transparent 3.5px),
    radial-gradient(circle at 110px 72px, rgba(129, 102, 255, 0.14) 0 4px, transparent 4.5px),
    conic-gradient(from 215deg at 14% 12%, rgba(255, 122, 206, 0.18), rgba(255, 205, 97, 0.16), rgba(105, 214, 255, 0.14), rgba(193, 62, 232, 0.16), transparent 42deg),
    linear-gradient(135deg, #fff8ff, var(--bg));
  background-size: 180px 150px, 150px 120px, 150px 120px, 520px 360px, auto;
}

button,
input {
  font: inherit;
  letter-spacing: 0;
}

button {
  cursor: pointer;
}

button:focus-visible,
input:focus-visible {
  outline: 3px solid rgba(232, 120, 18, 0.28);
  outline-offset: 2px;
}

:root[data-theme="light"] button:focus-visible,
:root[data-theme="light"] input:focus-visible {
  outline-color: rgba(15, 143, 138, 0.24);
}

:root[data-theme="dark"] button:focus-visible,
:root[data-theme="dark"] input:focus-visible {
  outline-color: rgba(240, 165, 38, 0.36);
}

:root[data-theme="unicorn"] button:focus-visible,
:root[data-theme="unicorn"] input:focus-visible {
  outline-color: rgba(193, 62, 232, 0.28);
}

button:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

.icon-defs {
  display: none;
}

.icon {
  width: 1rem;
  height: 1rem;
  fill: none;
  stroke: currentColor;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-width: 2;
  flex: 0 0 auto;
}

/* No `min-height` here — it made the bottom of the sidebar unreachable. With `height: 100vh` and
 `min-height: 680px` and `overflow: hidden`, any window shorter than 680px produced a shell
 taller than the viewport whose overflow was clipped rather than scrolled: Admin, Suggest an
 improvement and **Logout** simply vanished with no way to reach them.

 `100dvh` rather than `100vh` — on a phone or a short laptop window the dynamic unit accounts for
 browser chrome that appears and disappears, which `vh` does not. */
.app-shell {
  display: grid;
  grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
  /* One row, the window's height. The shell holds more than the rail and the page — the cash drawer's
   element, the desk banner's frame — and each would otherwise be a row of its own, taking a share of the
   height and leaving the rail ending wherever the page's content happens to (D955). Anything beyond the
   first row is given no height; what those elements show is fixed or in the top layer. */
  grid-template-rows: minmax(0, 1fr);
  grid-auto-rows: 0;
  height: 100dvh;
  overflow: hidden;
}

/* One page fills the screen (D355): the staff check-in kiosk, which sits on its own machine by the
 reader and is never navigated from. A modifier on the shell rather than a second layout, so the page
 keeps the flash messages, the icon sprite and the PIN dialog; the customer-facing `kiosk` layout is
 bare for a security reason (S55) that does not apply to a screen facing the counter.
 The rail is the way out of that page, so the view adds its own "Back to Leopard" link. Hiding the
 rail without one leaves a dead end. */
.app-shell.is-focused {
  grid-template-columns: minmax(0, 1fr);
}

.app-shell.is-focused .sidebar {
  display: none;
}

/* A descendant selector, not `>`: the frame sits inside the collapse wrapper, which is
 `display: contents`, so it is a flex item of the sidebar while being its grandchild in the DOM. A
 direct-child selector stops matching and takes this whole rule with it (D478).

 The frame is a flex column itself because `.customer-list` inside it scrolls with `flex: 1` and
 `overflow-y: auto`, which do nothing unless their parent is a flex container. A `<turbo-frame>` is a
 custom element and defaults to `display: inline`; left that way the list cannot scroll and the nav is
 pushed off the end of the rail instead (D323). */
.sidebar turbo-frame {
  display: flex;
  flex-direction: column;
  /* It grows again — and the reason it did not is gone (D323). This was `0 1 auto` because growing
   left "a big gap between the bottom of the newest customers and the quick links". That gap
   existed because the frame was full by default: a newest-customers list rendered whether or not
   anybody had searched, so a short list left a visible band under it.

   That list was removed a day later, on staff feedback, and the frame has been empty until
   somebody types ever since — so the same rule now produces the opposite complaint. Growing does
   both at once: the results take the whole column and the nav is pushed to the floor. */
  flex: 1 1 auto;
  /* A floor, not zero. `min-height: 0` alone let the rail shrink away
 entirely on a short window — the nav came back, but the customer list (the
 rail's whole purpose, and where quick-search results land) vanished. 9rem
 keeps the heading and a few names, and the column scrolls for the rest,
 which is the trade a laptop-sized window has to make somewhere. */
  min-height: 9rem;
}

/* The column scrolls as a last resort. Normally the customer list absorbs
 the squeeze and the nav stays put; on a window too short even for the brand,
 search and nav together, this is what stops the bottom being lost. */
.sidebar {
  display: flex;
  flex-direction: column;
  min-width: 0;
  background:
    linear-gradient(180deg, rgba(255, 253, 248, 0.96), rgba(255, 247, 232, 0.96)),
    radial-gradient(circle at 24px 28px, transparent 0 4px, rgba(181, 99, 12, 0.1) 4.5px 7px, transparent 7.5px);
  background-size: auto, 132px 108px;
  border-right: 1px solid var(--line);
  /* Two axes, said separately — and this was a real collision (D323). The rule
 opened with `overflow-y: auto` and closed with `overflow: hidden`, which cancelled
 it: the comment above about the column scrolling "as a last resort" so "the bottom
 [is not] lost" described behaviour the next line switched off. On a 620px window the
 signed-in block and its sign-out button sat below the fold and **could not be
 scrolled to**.
 `hidden` on X is still wanted — a long customer name must not widen the rail. */
  overflow-x: hidden;
  overflow-y: auto;
}

:root[data-theme="light"] .sidebar,
:root[data-theme="dark"] .sidebar {
  background: var(--paper);
}

:root[data-theme="unicorn"] .sidebar {
  background:
    linear-gradient(180deg, rgba(255, 250, 255, 0.96), rgba(255, 242, 252, 0.96)),
    radial-gradient(circle at 26px 28px, rgba(255, 122, 206, 0.12) 0 4px, transparent 4.5px),
    conic-gradient(from 220deg at 12% 9%, rgba(255, 122, 206, 0.14), rgba(255, 205, 97, 0.12), rgba(105, 214, 255, 0.1), rgba(193, 62, 232, 0.12), transparent 40deg);
  background-size: auto, 132px 108px, 280px 220px;
}

/* The brand is an <a> now — it is the way home, so it has to stop inheriting link styling and
 start behaving like a target: no underline, inherited colour, and a visible hover so it reads as
 clickable rather than as decoration somebody happens to be able to click. */
.brand {
  display: flex;
  align-items: center;
  gap: 10px;
  /* Trimmed with the rest of the column. Even full screen, the shared desk's PIN panel and
   sign-out were pushed off the bottom. */
  padding: 12px 14px 11px;
  border-bottom: 1px solid var(--line);
  color: inherit;
  text-decoration: none;
  transition: background-color 120ms ease;
}

.brand:hover {
  background: var(--surface-2, rgba(127, 127, 127, 0.08));
}

/* Keyboard users get the same affordance — this is now the primary way out of
 a customer profile, so it must be reachable without a mouse. */
.brand:focus-visible {
  outline: 2px solid var(--accent, #e87812);
  outline-offset: -2px;
}


/* The leopard is A CSS mask, not an <img>. The supplied artwork is a jpeg —
 black on white, no alpha — so dropping it in would have shown a white box on
 the dark theme and a white square on the gold chip. `leopard-mark.png` is that
 jpeg with the white keyed out (kept alongside as the source), and masking it
 means the shape is painted in `currentColor`, so it inherits whatever the chip
 already sets per theme: dark on gold, light on the black chip, and it still
 works on unicorn. One asset, four themes, no per-theme overrides. */
.brand-mark {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  border-radius: var(--radius);
  background:
    radial-gradient(circle at 12px 13px, rgba(37, 24, 12, 0.72) 0 4px, transparent 4.5px),
    radial-gradient(circle at 26px 24px, rgba(37, 24, 12, 0.5) 0 3px, transparent 3.5px),
    linear-gradient(135deg, #ffcb52, #e87812);
  color: #211408;
  font-weight: 700;
  font-size: 1.2rem;
}
.brand-mark::after {
  content: "";
  /* Not the full chip — the mark needs to breathe, and this is detailed line
 art rather than a letterform, so it wants a little more room than a glyph
 would. */
  width: 78%;
  height: 78%;
  background-color: currentColor;
  -webkit-mask: url("/assets/leopard-mark-15c706e8.png") center / contain no-repeat;
  mask: url("/assets/leopard-mark-15c706e8.png") center / contain no-repeat;
}


/* Light theme only — inverted to black line on white, so the artwork reads the way it was drawn.
 The default gold chip, the dark theme's gradient and unicorn are deliberately left alone; the
 mask paints in `currentColor`, so this is a two-property change rather than a second asset.

 The chip is now the same white as the sidebar behind it, which is the intent: no visible chip,
 just the leopard. */
:root[data-theme="light"] .brand-mark {
  background: var(--paper);
  color: var(--ink);
}

:root[data-theme="dark"] .brand-mark {
  background: linear-gradient(135deg, #f0a526, #7a3f06);
  color: #11100e;
}

:root[data-theme="unicorn"] .brand-mark {
  background:
    linear-gradient(135deg, rgba(255, 255, 255, 0.35), transparent 38%),
    conic-gradient(from 215deg, #ff7ace, #ffcd61, #69d6ff, #c13ee8, #ff7ace);
  color: #2c1738;
}

.brand-title,
.brand-subtitle,
.small-label,
.profile-meta,
.status-note,
.row-meta,
.helper-text,
.session-name,
.field-label,
.field-value,
.empty-state,
.toast,
.customer-meta,
.customer-status,
.basket-total,
.product-meta {
  margin: 0;
}

.brand-title {
  font-weight: 700;
  font-size: 1rem;
}

.brand-subtitle {
  color: var(--muted);
  font-size: 0.82rem;
  margin-top: 1px;
}

.search-box {
  display: flex;
  align-items: center;
  gap: 8px;
  /* Trimmed twice: 87px, then this. The input inside carries its own padding, so the box only
   needs enough to seat the icon. */
  margin: 8px 12px 6px;
  padding: 3px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgba(255, 247, 232, 0.86);
  color: var(--muted);
}

:root[data-theme="light"] .search-box,
:root[data-theme="dark"] .search-box {
  background: var(--paper-2);
}

:root[data-theme="unicorn"] .search-box {
  background: rgba(255, 242, 252, 0.88);
}

.search-box input {
  min-width: 0;
  width: 100%;
  border: 0;
  outline: 0;
  color: var(--ink);
  background: transparent;
}

.search-box input::placeholder {
  color: var(--quiet);
}

.rail-section {
  min-height: 0;
  display: flex;
  flex: 1;
  flex-direction: column;
  padding: 4px 10px 10px;
}

.section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  color: var(--muted);
  font-size: 0.76rem;
  font-weight: 700;
  padding: 8px 4px;
}

.customer-list {
  display: flex;
  flex: 1;
  min-height: 0;
  flex-direction: column;
  /* 4px → 1px. With the hover highlight marking the row, the gap was separating
 things that no longer needed separating (D323). */
  gap: 1px;
  overflow-y: auto;
  padding-right: 4px;
}

.customer-button {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: center;
  width: 100%;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: transparent;
  color: inherit;
  padding: 6px 8px;
  text-align: left;
}

.customer-button:hover,
.customer-button.active {
  background: var(--paper-2);
  border-color: var(--line-strong);
}

.customer-button.active {
  box-shadow: inset 3px 0 0 var(--brand);
}

.customer-name {
  margin: 0 0 2px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: 700;
  font-size: 0.9rem;
}

.customer-meta,
.customer-status {
  overflow: hidden;
  color: var(--muted);
  font-size: 0.76rem;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.customer-status {
  justify-self: end;
  color: var(--quiet);
}

.avatar {
  display: grid;
  place-items: center;
  border-radius: var(--radius);
  background: var(--amber-soft);
  color: var(--brand-dark);
  font-weight: 700;
}

.avatar {
  width: 58px;
  height: 58px;
  font-size: 1.05rem;
}

.avatar.with-photo {
  overflow: hidden;
  background: var(--line);
}

.avatar.with-photo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Never squashed. Without `flex-shrink: 0` the nav is the first thing a flex
 column compresses, so Logout is the first casualty of a short window — which
 is exactly backwards: the customer list is the part that can afford to lose
 room, and it already scrolls. */
/* ── The narrow-screen menu (D478) ──────────────────────────────────────────

 Inert by default, and that is the whole design. The toggle and its checkbox draw nothing, and
 both wrappers are `display: contents` — removed from the box tree, so the sidebar's flex
 children stay the brand, the search, the results frame, the nav and the session panel. Wrapping
 them without this would turn two flex items into one and move the desktop layout, which is the
 one thing this change may not do. Everything that switches it on lives in the `max-width: 920px`
 block, which a desktop browser never reads. */
.rail-toggle-input,
.rail-toggle {
  display: none;
}

.rail-head,
.rail-collapse {
  display: contents;
}

.side-nav {
  flex-shrink: 0;
  /* Belt and braces with the frame's `flex: 1 1 auto` above (D323): if the
 frame is ever removed or made non-growing again, this still holds the nav and
 the signed-in block at the floor rather than letting them float up. */
  margin-top: auto;
  padding: 5px 8px;
  border-top: 1px solid var(--line);
}

.side-nav-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  /* 39px x 7 rows was 273px of nav alone. 31px stays comfortably above the
 ~24px that gets fiddly with a mouse, and returns ~56px to the column. */
  min-height: 31px;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--muted);
  padding: 9px 10px;
  text-align: left;
  font-weight: 700;
}

.side-nav-item:hover,
.side-nav-item.active {
  background: #ffedc6;
  color: var(--ink);
}

:root[data-theme="light"] .side-nav-item:hover,
:root[data-theme="light"] .side-nav-item.active,
:root[data-theme="dark"] .side-nav-item:hover,
:root[data-theme="dark"] .side-nav-item.active {
  background: var(--paper-2);
}

:root[data-theme="unicorn"] .side-nav-item:hover,
:root[data-theme="unicorn"] .side-nav-item.active {
  background: #f8e3ff;
}

.logout {
  margin: 0 10px 12px;
  width: calc(100% - 20px);
}

/* The checkbox and its label on one line. `.field-check` had no CSS at all, and portal.css's bare
 `label { display: grid }` reaches staff pages (FR-6) — so the label was a block and dropped
 underneath its own checkbox. */
.field-check {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 4px 0;
}

.field-check label {
  display: inline;
  font-weight: 400;
}

.field-uses { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; }
/* Store credit's form: the house fields, one above another, no wider than a reason needs (D910). */
.credit-add-form { display: grid; gap: 14px; max-width: 28rem; margin-top: 12px; }
.credit-add-form .primary-button { justify-self: start; }
.field-uses legend { padding: 0 4px; }

/* The logout lives inside the signed-in bubble now — the rail is the narrowest
 column on the screen and it was two rows saying one thing. An icon only, because
 the panel already names who you are; the label is on `title` and `aria-label`. */
.session-logout-form { margin: 0; flex: 0 0 auto; }

.session-logout {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

.session-logout:hover { background: var(--paper); color: var(--ink); }

/* The checkbox and its label on one line. `.field-check` had no CSS at all, and portal.css's bare
 `label { display: grid }` reaches staff pages (FR-6) — so the label was a block and dropped
 underneath its own checkbox. */
.field-check {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 4px 0;
}

.field-check label {
  display: inline;
  font-weight: 400;
}

.field-uses {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 10px 12px;
}

.field-uses legend { padding: 0 4px; }

/* The logout lives inside the signed-in bubble now — the rail is the narrowest
 column on the screen and it was two rows saying one thing. An icon only, because
 the panel already names who you are; the label is on `title` and `aria-label`. */
.session-logout-form { margin: 0; flex: 0 0 auto; }

.session-logout {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

.session-logout:hover { background: var(--paper); color: var(--ink); }

.session-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin: 6px 8px;
  padding: 8px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgba(255, 247, 232, 0.86);
}

:root[data-theme="light"] .session-panel,
:root[data-theme="dark"] .session-panel {
  background: var(--paper-2);
}

:root[data-theme="unicorn"] .session-panel {
  background: rgba(255, 242, 252, 0.88);
}

.session-name {
  font-weight: 700;
  margin-top: 1px;
}

.small-label {
  color: var(--muted);
  font-size: 0.74rem;
  font-weight: 700;
}

.main {
  display: flex;
  min-width: 0;
  flex-direction: column;
  overflow: hidden;
}

.profile-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 18px;
  /* Trimmed so the content always lands under the shared band, letting
 `min-height` be the thing that decides the height — see `--top-band` at the
 end of this file. Padding that pushed content one pixel past the band would
 put the two columns back out of step, which is the whole complaint. */
  padding: 5px 24px;
  border-bottom: 1px solid var(--line);
  background: rgba(255, 253, 248, 0.94);
  backdrop-filter: blur(10px);
}

:root[data-theme="light"] .profile-header,
:root[data-theme="dark"] .profile-header {
  background: var(--paper);
  backdrop-filter: none;
}

:root[data-theme="unicorn"] .profile-header {
  background: rgba(255, 250, 255, 0.94);
}

.profile-heading {
  display: flex;
  align-items: center;
  min-width: 0;
  gap: 14px;
}

.profile-title {
  min-width: 0;
}

.title-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}

/* The membership named on the profile header (D932): capped, its name cut short before the state beside it is. */
.membership-badge {
  max-width: 16rem;
}

.membership-badge-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

h1,
h2,
h3 {
  margin: 0;
  color: var(--ink);
  font-family: var(--font-heading);
  font-weight: var(--heading-weight);
  line-height: 1.26;
}

h1 {
  font-size: 1.45rem;
  font-weight: var(--title-weight);
}

h2 {
  font-size: 1.1rem;
}

h3 {
  font-size: 0.95rem;
}

.profile-meta {
  color: var(--muted);
  font-size: 0.9rem;
  margin-top: 5px;
}

.profile-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 8px;
}

.profile-actions .checkin-main-button {
  margin-right: -8px;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
}

.action-menu-wrap {
  position: relative;
}

.checkin-menu-button {
  width: 34px;
  min-height: 40px;
  border-left: 1px solid rgba(37, 24, 12, 0.24);
  border-top-left-radius: 0;
  border-bottom-left-radius: 0;
  background: linear-gradient(180deg, #ffbd3e, var(--brand));
  color: #211408;
}

:root[data-theme="light"] .checkin-menu-button {
  border-color: var(--brand-dark);
  background: var(--brand);
  color: var(--paper);
}

:root[data-theme="dark"] .checkin-menu-button {
  border-color: #8b540f;
  background: linear-gradient(180deg, #f0b84b, var(--brand-dark));
  color: #11100e;
}

:root[data-theme="unicorn"] .checkin-menu-button {
  border-color: #8d25b5;
  background: linear-gradient(180deg, #ff91d6, var(--brand));
  color: #2c1738;
}

.checkin-menu-button:hover {
  background: linear-gradient(180deg, #ffa927, var(--brand-dark));
  border-color: var(--brand-dark);
  color: var(--paper);
}

:root[data-theme="light"] .checkin-menu-button:hover {
  background: var(--brand-dark);
  color: var(--paper);
}

:root[data-theme="dark"] .checkin-menu-button:hover {
  background: linear-gradient(180deg, #ffc95f, #b86407);
  color: #11100e;
}

:root[data-theme="unicorn"] .checkin-menu-button:hover {
  background: linear-gradient(180deg, #ff7ace, var(--brand-dark));
  color: #fffaff;
}

.checkin-caret {
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid currentColor;
}

.action-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 20;
  display: none;
  min-width: 230px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper);
  box-shadow: var(--shadow);
  padding: 5px;
}

.action-menu.open {
  display: block;
}

.action-menu button {
  width: 100%;
  min-height: 36px;
  border: 0;
  border-radius: var(--radius);
  background: transparent;
  color: var(--ink);
  padding: 8px 10px;
  text-align: left;
}

.action-menu button:hover {
  background: var(--paper-2);
}

.primary-button,
.secondary-button,
.ghost-button,
.icon-button,
.mini-button,
.danger-button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: 35px;
  border-radius: var(--radius);
  font-size: 14px;
  font-weight: 700;
  /* A link styled as a button is underlined unless this says otherwise (D440): that is
 the browser's default for an <a href>. It is the staff twin of the portal's
 link-in-a-card problem — there the winner was `.card a` on specificity, here there was
 no rule at all. A button is never underlined, so it belongs on the shared block rather
 than on each caller. */
  text-decoration: none;
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
}

.primary-button {
  border: 1px solid #ca6209;
  background: linear-gradient(180deg, #ffbd3e, var(--brand));
  color: #211408;
  padding: 0 10px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.36);
}

/* `--on-brand` and `--brand-hover` are set only where the brand is the gym's colour, which may be
 pale (D553); everywhere else they fall back to the theme's own. */
:root[data-theme="light"] .primary-button {
  border-color: var(--brand);
  background: var(--brand);
  color: var(--on-brand, var(--paper));
  box-shadow: none;
}

:root[data-theme="dark"] .primary-button {
  border-color: #b56d12;
  background: linear-gradient(180deg, #f0b84b, var(--brand-dark));
  color: #11100e;
}

:root[data-theme="unicorn"] .primary-button {
  border-color: #a72bd0;
  background: linear-gradient(180deg, #ff91d6, var(--brand));
  color: #2c1738;
}

.primary-button:hover {
  background: linear-gradient(180deg, #ffa927, var(--brand-dark));
  border-color: var(--brand-dark);
  color: var(--paper);
}

.primary-button.checkin-success {
  animation: checkinPulse 0.72s ease;
}

@keyframes checkinPulse {
  0% {
    transform: scale(1);
    box-shadow: 0 0 0 0 rgba(232, 120, 18, 0.34);
  }
  48% {
    transform: scale(1.04);
    box-shadow: 0 0 0 10px rgba(232, 120, 18, 0.16);
  }
  100% {
    transform: scale(1);
    box-shadow: 0 0 0 16px rgba(232, 120, 18, 0);
  }
}

:root[data-theme="light"] .primary-button:hover {
  background: var(--brand-hover, var(--brand-dark));
  border-color: var(--brand-hover, var(--brand-dark));
  color: var(--on-brand, var(--paper));
}

:root[data-theme="dark"] .primary-button:hover {
  background: linear-gradient(180deg, #ffc95f, #b86407);
  color: #11100e;
}

:root[data-theme="unicorn"] .primary-button:hover {
  background: linear-gradient(180deg, #ff7ace, var(--brand-dark));
  color: #fffaff;
}

.secondary-button {
  border: 1px solid rgba(232, 120, 18, 0.3);
  background: #fff0cf;
  color: var(--brand-dark);
  padding: 0 14px;
}

:root[data-theme="light"] .secondary-button {
  border-color: rgba(52, 110, 216, 0.28);
  background: var(--blue-soft);
  color: var(--blue);
}

:root[data-theme="dark"] .secondary-button {
  border-color: var(--line-strong);
  background: var(--paper-2);
  color: var(--brand);
}

:root[data-theme="unicorn"] .secondary-button {
  border-color: rgba(193, 62, 232, 0.3);
  background: #f8e3ff;
  color: var(--brand-dark);
}

.secondary-button:hover {
  border-color: rgba(232, 120, 18, 0.5);
  background: #ffe3aa;
}

:root[data-theme="light"] .secondary-button:hover,
:root[data-theme="dark"] .secondary-button:hover {
  border-color: var(--line-strong);
  background: var(--paper-2);
}

:root[data-theme="unicorn"] .secondary-button:hover {
  border-color: rgba(193, 62, 232, 0.5);
  background: #f0ccff;
}

.ghost-button {
  border: 1px solid var(--line);
  background: var(--paper);
  color: var(--muted);
  padding: 0 10px;
}

.ghost-button:hover {
  border-color: var(--line-strong);
  color: var(--ink);
}

/* A pager's Newer/Older at the end of the list: a span, kept in place so the controls do not
 shift, and visibly unavailable. Used by the recent reports and the sent history (D669). */
.ghost-button.is-disabled,
.ghost-button.is-disabled:hover {
  opacity: 0.45;
  border-color: var(--line);
  color: var(--muted);
  cursor: default;
}

.danger-button {
  border: 1px solid rgba(198, 66, 48, 0.26);
  background: var(--red-soft);
  color: var(--red);
  padding: 0 14px;
}

.danger-button:hover {
  border-color: rgba(198, 66, 48, 0.42);
}

.icon-button {
  width: 38px;
  min-height: 38px;
  border: 1px solid var(--line);
  background: var(--paper-2);
  color: var(--muted);
  padding: 0;
}

.icon-button:hover {
  color: var(--ink);
  border-color: var(--line-strong);
  background: #ffedc6;
}

:root[data-theme="light"] .icon-button:hover,
:root[data-theme="dark"] .icon-button:hover {
  background: var(--paper-2);
}

:root[data-theme="unicorn"] .icon-button:hover {
  background: #f8e3ff;
}

.mini-button {
  min-height: 32px;
  border: 1px solid var(--line-strong);
  background: #fff5dc;
  color: var(--brand-dark);
  padding: 0 10px;
  font-size: 0.82rem;
}

:root[data-theme="light"] .mini-button,
:root[data-theme="dark"] .mini-button {
  border-color: var(--line);
  background: var(--paper);
  color: var(--ink);
}

:root[data-theme="unicorn"] .mini-button {
  border-color: var(--line-strong);
  background: #fff0fb;
  color: var(--brand-dark);
}

.mini-button:hover {
  border-color: #c9771b;
  background: #ffe7b8;
}

:root[data-theme="light"] .mini-button:hover,
:root[data-theme="dark"] .mini-button:hover {
  border-color: var(--line-strong);
  background: var(--paper-2);
}

:root[data-theme="unicorn"] .mini-button:hover {
  border-color: #b84de3;
  background: #f7d9ff;
}

.compact {
  min-height: 34px;
  padding: 0 10px;
  font-size: 0.82rem;
}

.full {
  width: 100%;
}

/* The bar must not scroll sideways, and for a long time it did. Measured: twelve tabs needed
 1438px against roughly 1040px of content column, so half the profile was off-screen behind a
 scrollbar nobody looks for. Two changes fixed it together — the rarely-used tabs moved into a
 "More" disclosure (D230), and what is left got tighter: 13px instead of 15px, 8px of side
 padding instead of 10.

 `overflow-x` stays `auto` as the last resort. A narrow laptop or a gym that promotes six more
 tabs into the bar must still be able to reach them. */
/* Two elements, and the split is load-bearing. `.tabbar` scrolls; the "More"
 disclosure sits beside it in `.tabbar-row` and does not. A scroll container clips
 an absolutely-positioned child, so with the menu inside the scroller it opened and
 drew nothing — and the one control that must never scroll out of reach would have
 been the one that scrolls away. */
.tabbar-row {
  display: flex;
  align-items: stretch;
  gap: 2px;
  padding: 0 18px;
  border-bottom: 1px solid var(--line);
  background: rgba(255, 253, 248, 0.94);
  backdrop-filter: blur(10px);
}

.tabbar {
  display: flex;
  gap: 2px;
  /* `min-width: 0` or the flex child sizes to its content and never scrolls — it
 just pushes "More" off the right instead. */
  min-width: 0;
  /* `0 1 auto`, not `1 1 auto`. Growing to fill the row threw "More" against the far right edge,
   most of a metre from the tabs it belongs to. Now the bar takes only what it needs and the
   button sits immediately after the last tab. */
  flex: 0 1 auto;
  overflow-x: auto;
}

/* The responsive half (D230, revised). Below the breakpoint the rarely-used tabs
 are hidden and the "More" menu carries them; above it every tab fits, so the menu
 is hidden entirely rather than sitting there duplicating what is already on screen.
 1560px is measured, not guessed: the thirteen tabs need 1301px, plus a ~195px
 sidebar and the page's own padding. */
/* `.tabbar .tab.tab-overflow`, not `.tab-overflow`. `.tabbar .tab` is (0,2,0) and
 is declared further down this file, so a bare `.tab-overflow` at (0,1,0) lost and
 every tab stayed visible with the More button beside it — the worst of both. Caught
 by looking at 1280px; the test could not see it. */
.tabbar .tab.tab-overflow { display: none; }

@media (min-width: 1560px) {
  .tabbar .tab.tab-overflow { display: inline-flex; }
  .tab-more { display: none; }
}

:root[data-theme="light"] .tabbar-row,
:root[data-theme="dark"] .tabbar-row {
  background: var(--paper);
  backdrop-filter: none;
}

:root[data-theme="unicorn"] .tabbar-row {
  background: rgba(255, 250, 255, 0.94);
}

.tab {
  min-height: 46px;
  flex: 0 0 auto;
  border: 0;
  border-bottom: 3px solid transparent;
  background: transparent;
  color: var(--muted);
  padding: 0 8px;
  /* 13px, not smaller. This is read at a counter with somebody waiting, and the
 next step down starts costing legibility for a few pixels each. */
  font-size: 13px;
  font-weight: 700;
}

.tab:hover,
.tab.active {
  color: var(--ink);
}

.tab.active {
  border-bottom-color: var(--brand);
}

.content {
  min-height: 0;
  flex: 1;
  overflow: auto;
  padding: 20px 24px 28px;
}

.snapshot-grid {
  display: grid;
  /* Narrower than it was. The photo is roughly square and the tile was wide, so most of it was
   blank — the frame below now sets a portrait aspect and this stops the column stretching it
   back out.

   The photo holds its size and the text column absorbs the squeeze (D428). Both columns were
   `fr`, so both shrank in proportion — and a face at 155px is unusable while the health list
   simply reflows. A capped range for the photo, and everything else gives. `minmax(0, 1fr)`,
   never a bare `1fr`: a grid column's default minimum is its content, so the text column would
   refuse to shrink and push the photo down instead — the same trap the bottom nav carries a
   comment about (D410). */
  /* One expression, no breakpoint (D435). The rule: the text column compresses first, and once it
 is as narrow as the photo column, the two scale down together in equal proportion. That is
 exactly `min(a fixed size, half the row)`:
 • while half the row is wider than the photo needs, the photo takes its fixed size and
 the text column absorbs every pixel of shrinking — D428's rule, kept;
 • the moment half the row is narrower, `min()` switches to 50% and the two shrink
 together, in step, for ever after.
 No media or container query, so there is no breakpoint to be wrong about and no
 arithmetic subtracting a sidebar whose width may change.
 `calc(50% - 8px)` rather than `50%`: percentages resolve against the full row, so two
 of them plus the 16px `gap` overflow by exactly the gap. Half of it each.
 422 = a 384px photo plus `.photo-panel`'s chrome (18px padding and a 1px border either side),
 measured. The 384 is the PHOTO, not the column — a 384px column renders a 346px face.
 `minmax(0, 1fr)`, never a bare `1fr`: a grid column's default minimum is its content,
 so the text column would refuse to shrink and push the photo down instead (D410, D428). */
  /* And no taller than the screen allows (D1033): on a short screen the photo gives way, so the
   Alerts & notes panel below starts on screen. 340px is the page header, the tabs and the room
   the first note needs; 260px is the smallest face still worth looking at. */
  grid-template-columns: min(422px, calc(50% - 8px), max(260px, calc(100dvh - 340px))) minmax(0, 1fr);
  gap: 16px;
  align-items: start;
}

/* The photo ratio is wrong for two panels of text (D120). These are the Snapshot tab's
 proportions, which exist because the left column holds a portrait photograph — on Memberships &
 passes both columns hold prose, and a 0.42/1.58 split squeezes membership rows into a gutter
 while passes sprawl. A modifier rather than a change to `.snapshot-grid`, which the Snapshot tab
 still needs at its original ratio. */
/* Capped, or the row wraps to three lines. Each `.signin-field` in a flex row
 sizes to its own content, and the voucher select renders "Free shoe hire — Free
 shoe hire" — wider than the column on its own, so every control landed on a line
 of its own and the Issue button ended up under them (D245). */
/* The table's 650px floor has to go in A 570px column. `.data-table` carries
 `min-width: 650px` so tables do not squash on a narrow screen — right for a
 full-width table, wrong inside a 40:60 split, where it made the action column
 overflow and clip. The Redeem button is what staff came to this tab for, so it is
 the one thing that must never be off the edge. Cells already wrap. */
.credits-grid .data-table { min-width: 0; }
/* Same override, same reason, one tile wide (D286). `min-width: 650px` inside a
 446px home tile pushes the last column off the side behind a horizontal scrollbar —
 and a column you have to scroll sideways to reach is one nobody reads, which is the
 whole point of putting it there. Measured: 650px of table in 446px of tile. */
.home-tile .data-table { min-width: 0; }
.home-tile .data-table td { padding-block: 6px; }
/* And on Today's check-ins (D684): at 1024px the floor pushed Admit off the edge of a row whose
 whole purpose is that button. Cells already wrap. */
.refusals-table { min-width: 0; }
/* And on check-in history's Detailed tab (D688), where the column that mattered — the free-entry
 note — was the one past the edge. */
.check-in-log-table { min-width: 0; }
/* And in a grid of panels a third of the page wide — the revenue report's splits (D1003). */
.panel-grid .data-table { min-width: 0; }
/* And on the cutover page (D849), whose last column is the one worked from: the link to end each
 membership there, or whether the series has ended. */
.cutover-table { min-width: 0; }
/* The weekly tab's last column: the link to the old system and the two buttons, stacked (D855). */
.cutover-actions { display: flex; flex-direction: column; align-items: stretch; gap: 0.35rem; width: 9.5rem; }
.cutover-actions form { margin: 0; }
.cutover-actions .compact { width: 100%; white-space: normal; text-align: center; }

/* The refusal queue is dense on purpose (D297). layout: the name and its two actions share a line,
 the reason sits under them, and the padding comes down — a tile that is a to-do list should show
 the whole list. */
.home-list.is-condensed > li { padding: 8px 0; gap: 0; }
.refusal-row { display: flex; flex-direction: column; gap: 2px; width: 100%; }
.refusal-head { display: flex; align-items: center; gap: 8px; }
/* `auto` margin rather than `justify-content: space-between`, so the buttons stay
 right-aligned however long the name is. */
.refusal-head .home-list-actions { margin-left: auto; display: flex; gap: 6px; }
.refusal-reason { margin: 0; line-height: 1.35; }

.voucher-issue-form .signin-field { flex: 1 1 10rem; min-width: 0; }
.voucher-issue-form select,
.voucher-issue-form input[type="text"] { width: 100%; min-width: 0; }

/* 40:60 for credits & vouchers (D245). The snapshot's 21:79 exists because its
 left column holds a roughly square photo; here it holds a form, and the vouchers
 table beside it does not need four fifths of the row. */
.snapshot-grid.credits-grid {
  grid-template-columns: minmax(280px, 0.4fr) minmax(380px, 0.6fr);
}

/* 40:60 for credits & vouchers (D245). The snapshot's 21:79 exists because its
 left column holds a roughly square photo; here it holds a form, and the vouchers
 table beside it does not need four fifths of the row. */
.snapshot-grid.credits-grid {
  grid-template-columns: minmax(280px, 0.4fr) minmax(380px, 0.6fr);
}

.snapshot-grid.even-columns {
  grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
}

/* One column below the point where 1:1 would crush both. */
@media (max-width: 900px) {
  .snapshot-grid.even-columns { grid-template-columns: 1fr; }
}

/* Looks like a link only on hover, the same rule as the check-in datetime — a
 table of names all in brand colour reads as navigation rather than as data. */
.detail-link {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px dotted var(--line);
}

.detail-link:hover {
  border-bottom-color: currentColor;
}

/* A table row whose whole area opens the row's detail (D371, D1024).

 `row_link.js` presses the row's one `.detail-link` when anywhere else on the row is pressed, so a
 keyboard user tabs to one link and a screen reader announces one. Nothing is drawn over the row: a
 stretched `::after` bounded by the row was tried, and Safari does not let a <tr> bound it, so it
 covered the whole page. Other controls in the row keep working. */
.data-table tr.row-link {
  cursor: pointer;
}

.data-table tr.row-link:hover {
  background: var(--bg);
}

/* The date keeps its own underline: the row hover tells a mouse it is clickable, but a
 keyboard user sees only the focus ring, and somebody reading a screenshot sees neither.
 The link still has to look like one. */
.data-table tr.row-link .detail-link {
  border-bottom-color: var(--muted, #6b7280);
}

.span-two {
  grid-column: 1 / -1;
}

.panel {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgba(255, 253, 248, 0.95);
  box-shadow: 0 1px 1px rgba(80, 47, 15, 0.04);
  overflow: hidden;
}

/* A Turbo Frame that is itself the panel, so the spacing between panels still matches it (D666).
 A frame is inline by default. */
turbo-frame.panel { display: block; }

:root[data-theme="light"] .panel,
:root[data-theme="dark"] .panel {
  background: var(--paper);
  box-shadow: 0 1px 1px rgba(22, 26, 29, 0.02);
}

:root[data-theme="unicorn"] .panel {
  background: rgba(255, 250, 255, 0.95);
  box-shadow: 0 1px 1px rgba(128, 42, 147, 0.05);
}

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 14px 16px;
  border-bottom: 1px solid var(--line);
}

/* A header action never wraps (D444). `.panel-title` takes what it needs and the button
 gets the rest, so a long blurb beside "Add one" broke it across two lines — 70px wide and
 two lines tall. Found on the kiosk greeting page; it was latent on every panel whose
 heading carries a sentence. */
.panel-header > .primary-button,
.panel-header > .secondary-button,
.panel-header > .ghost-button {
  flex: none;
  white-space: nowrap;
}

.panel-title {
  display: flex;
  align-items: center;
  /* Wraps so a `.balance-figure` can take its own line under the heading
 (D212). Without this the flex row squashes everything onto one line. */
  flex-wrap: wrap;
  gap: 8px;
}

.panel-body {
  padding: 14px 16px;
}

.photo-panel {
  display: flex;
  flex-direction: column;
}

/* The two actions that left the header in D441 — "Use a shared pass" and "See their portal" — side
 by side under the photo.

 `flex: 1` on both the anchor and the `button_to` form, because one of these is a link and the
 other is a form wrapping a button. Sizing only the buttons would leave the form shrink-to-fit
 and the pair uneven. `min-width: 0` so the labels can shrink rather than forcing the column
 wider — the photo column is the one that gives way first as the window narrows (D428). They wrap
 rather than squeeze: below about 300px of column two of these become unreadable side by side,
 and a stacked pair still reads. */
.photo-panel-action {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 12px 14px 14px;
}

.photo-panel-action > form,
.photo-panel-action > .ghost-button {
  flex: 1 1 130px;
  min-width: 0;
}

.photo-panel-action > form > .ghost-button {
  width: 100%;
}

.photo-frame {
  position: relative;
  /* Square, and the height comes from the aspect rather than a min-height. The original wide frame
   letterboxed a square photo with blank bars; a 4:5 portrait then went too far the other way and
   cropped the top and bottom off faces. Square matches what the camera captures, so `cover`
   crops nothing at all — and at a fixed width it is exactly 20% shorter than the 4:5 it
   replaced. The full file is still one click away in a new tab, which is what the zoom cursor
   now actually does. */
  aspect-ratio: 1;
  overflow: hidden;
  cursor: zoom-in;
  background:
    radial-gradient(circle at 22px 28px, transparent 0 5px, rgba(181, 99, 12, 0.14) 5.5px 8px, transparent 8.5px),
    linear-gradient(135deg, #ffecbf, #fff9ec);
  background-size: 132px 104px, auto;
}

.photo-frame:focus-visible {
  outline: 3px solid rgba(232, 120, 18, 0.28);
  outline-offset: 2px;
}

:root[data-theme="light"] .photo-frame {
  background: linear-gradient(135deg, #d7e1e7, #f6f7f8);
}

:root[data-theme="dark"] .photo-frame {
  background:
    radial-gradient(circle at 22px 28px, transparent 0 5px, rgba(240, 165, 38, 0.1) 5.5px 8px, transparent 8.5px),
    linear-gradient(135deg, #211c17, #312617);
  background-size: 132px 104px, auto;
}

:root[data-theme="unicorn"] .photo-frame {
  background:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='150' viewBox='0 0 180 150'%3E%3Cg fill='none' stroke='%23b92ad7' stroke-opacity='.16' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M82 55 95 20 103 56'/%3E%3Cpath d='M73 66c6-18 25-24 37-12 10 10 5 31-12 37-18 7-34-5-35-22'/%3E%3Cpath d='M112 65c9 2 18 8 22 17'/%3E%3C/g%3E%3Cpath fill='%23ff7ace' fill-opacity='.12' d='M125 38c18 8 24 24 20 40-10-16-24-25-42-27 4-12 11-17 22-13Z'/%3E%3C/svg%3E"),
    conic-gradient(from 215deg at 18% 18%, rgba(255, 122, 206, 0.18), rgba(255, 205, 97, 0.15), rgba(105, 214, 255, 0.13), rgba(193, 62, 232, 0.16), transparent 44deg),
    linear-gradient(135deg, #ffe8f9, #f7f0ff);
  background-size: 180px 150px, 420px 280px, auto;
}

.photo-frame img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* `cover`, not `contain` — the blank bars were the complaint. */
  object-fit: cover;
  object-position: center center;
}

/* The link wrapping the photo fills the frame so the whole tile is the target. */
.photo-frame a {
  position: absolute;
  inset: 0;
  display: block;
}

/* Webcam capture (§4.2). The live view and the frozen frame stack on top of the
 still, in the frame it already occupies — so opening the camera does not
 resize the panel or move anything below it. */
.photo-frame video,
.photo-frame canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: #000;
}
/* `[hidden]` needs saying explicitly: the rule above sets `display` via
 position, and a bare `hidden` attribute loses to a class selector. */
.photo-frame video[hidden],
.photo-frame canvas[hidden] { display: none; }

.photo-error {
  margin: 0;
  padding: 8px 16px;
  font-size: 13px;
  color: var(--red);
  background: var(--red-soft);
}
.photo-error[hidden] { display: none; }


/* Positioned by `.photo-actions` now, not by itself — there can be two of
 them side by side (Capture + Cancel), so the group owns the placement. */
.photo-overlay-button {
  z-index: 2;
  min-height: 34px;
  border: 1px solid rgba(255, 255, 255, 0.34);
  border-radius: var(--radius);
  /* The background is fixed dark because it floats over a photograph, so the text must be fixed
   light (D441). This read `color: var(--paper)`, which is near-white in light and near-black in
   dark — so in dark mode "Take photo" was black text on a black panel and simply invisible.
   Spotted in a screenshot. The border three lines up already had it right
   (`rgba(255,255,255,…)`), and so does `.is-primary` three lines down (`color: #fff`) — the
   correct answer was written on both sides of the wrong one. The rule: a surface that does not
   follow the theme cannot take its foreground from a variable that does. */
  background: rgba(22, 26, 29, 0.72);
  color: #fff;
  padding: 0 12px;
  backdrop-filter: blur(8px);
  cursor: pointer;
}

.photo-overlay-button:hover {
  background: rgba(22, 26, 29, 0.86);
  border-color: rgba(255, 255, 255, 0.5);
}

/* Bottom-left, over dead space in a portrait crop. This replaced a strip under the photo that cost
 a row of panel height to hold one button and a name the page header already shows. */
.photo-actions {
  position: absolute;
  left: 12px;
  bottom: 12px;
  z-index: 2;
}
.photo-actions > div { display: flex; gap: 6px; }
.photo-actions > div[hidden] { display: none; }

/* The one the keyboard is already focused on, so it reads as the default. */
.photo-overlay-button.is-primary {
  background: var(--brand);
  border-color: var(--brand-dark);
  color: #fff;
  font-weight: 600;
}
.photo-overlay-button.is-primary:hover { background: var(--brand-dark); }
/* Capture while it saves (D681). */
.photo-overlay-button:disabled { cursor: progress; }

.photo-placeholder {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--quiet);
  font-size: 3.2rem;
  font-weight: 700;
}


.status-stack {
  display: grid;
  gap: 10px;
}

.decision-state {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 15px;
  border-radius: var(--radius);
  margin-bottom: 12px;
}

.decision-state.approved {
  background: var(--green-soft);
  color: var(--green);
}

.decision-state.blocked {
  background: var(--red-soft);
  color: var(--red);
}

.decision-state.pending {
  background: var(--amber-soft);
  color: var(--amber);
}

.decision-state .icon {
  width: 1.3rem;
  height: 1.3rem;
}

.decision-state strong {
  display: block;
  color: inherit;
  font-size: 1rem;
}

.status-note {
  color: var(--ink);
  font-size: 0.88rem;
  margin-top: 3px;
}

.gate-list,
.row-list,
.basket-list,
.product-list {
  display: grid;
  gap: 8px;
}

.gate-row,
.info-row,
.activity-row,
.product-row,
.basket-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-bottom: 1px solid var(--line);
}

.gate-row:last-child,
.info-row:last-child,
.activity-row:last-child,
.product-row:last-child,
.basket-row:last-child {
  border-bottom: 0;
}

.gate-row.separated {
  margin-bottom: 6px;
  border-bottom: 1px solid var(--line-strong);
}

.gate-row.muted .row-title,
.gate-row.muted .row-meta {
  color: var(--quiet);
}

.gate-icon {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: var(--radius);
}

.gate-icon.pass {
  background: var(--green-soft);
  color: var(--green);
}

.gate-icon.fail {
  background: var(--red-soft);
  color: var(--red);
}

.gate-icon.warn {
  background: var(--amber-soft);
  color: var(--amber);
}

.gate-icon.info {
  background: var(--blue-soft);
  color: var(--blue);
}

.gate-icon.muted {
  background: var(--paper-2);
  color: var(--muted);
}

.row-title {
  margin: 0;
  font-weight: 700;
}

.row-meta {
  color: var(--muted);
  font-size: 0.84rem;
  margin-top: 2px;
}

.badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 24px;
  border: 1px solid rgba(232, 120, 18, 0.28);
  border-radius: var(--radius);
  background: var(--green-soft);
  color: var(--green);
  padding: 3px 8px;
  font-size: 0.75rem;
  font-weight: 700;
  white-space: nowrap;
}

:root[data-theme="light"] .badge {
  border-color: rgba(47, 143, 78, 0.24);
}

:root[data-theme="unicorn"] .badge {
  border-color: rgba(193, 62, 232, 0.26);
}

.badge.muted {
  border-color: var(--line);
  background: var(--paper-2);
  color: var(--muted);
}

.badge.warn {
  border-color: rgba(189, 111, 19, 0.28);
  background: var(--amber-soft);
  color: var(--amber);
}

.badge.danger {
  border-color: rgba(198, 66, 48, 0.24);
  background: var(--red-soft);
  color: var(--red);
}

.badge.blue {
  border-color: rgba(232, 120, 18, 0.24);
  background: #fff6d8;
  color: var(--brand-dark);
}

:root[data-theme="light"] .badge.blue {
  border-color: rgba(52, 110, 216, 0.24);
  background: var(--blue-soft);
  color: var(--blue);
}

:root[data-theme="dark"] .badge.blue {
  border-color: rgba(125, 204, 226, 0.28);
  background: var(--blue-soft);
  color: var(--blue);
}

:root[data-theme="unicorn"] .badge.blue {
  border-color: rgba(93, 115, 240, 0.24);
  background: var(--blue-soft);
  color: var(--blue);
}

.badge.plum {
  border-color: rgba(112, 81, 168, 0.24);
  background: var(--plum-soft);
  color: var(--plum);
}

.notes-list {
  display: grid;
  gap: 10px;
}

.note-item {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  padding: 11px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.note-item.alert {
  border-color: rgba(189, 111, 19, 0.28);
  background: var(--amber-soft);
}

.note-item.medical {
  border-color: rgba(198, 66, 48, 0.2);
  background: var(--red-soft);
}

.note-item p {
  margin: 0;
}

.note-text {
  font-weight: 750;
}

.note-meta {
  color: var(--muted);
  font-size: 0.82rem;
  margin-top: 4px;
}

.metric-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 10px;
}

.metric {
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.metric strong {
  display: block;
  font-size: 1.2rem;
  line-height: 1.2;
}

.metric span {
  display: block;
  color: var(--muted);
  font-size: 0.8rem;
  margin-top: 2px;
}

.credit-balance-metric {
  max-width: 240px;
}

.voucher-card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 8px;
}

.voucher-card {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.voucher-card .mini-button {
  white-space: nowrap;
}

.voucher-card.expired {
  border-color: var(--line);
  background: #f2f4f5;
  color: var(--muted);
}

.voucher-card.expired .row-title {
  color: var(--muted);
}

.expired-voucher-list {
  display: grid;
  gap: 8px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  background: var(--paper-2);
}

.table-panel {
  width: 100%;
  overflow-x: auto;
}

.pagination-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 14px;
  border-top: 1px solid var(--line);
  background: var(--paper-2);
  color: var(--muted);
  font-size: 0.82rem;
}

.pagination-row p {
  margin: 0;
}

.pagination-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.pagination-controls span {
  color: var(--ink);
  font-weight: 700;
  white-space: nowrap;
}

.checkin-hero {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  padding: 16px;
  border-bottom: 1px solid var(--line);
  background: var(--paper-2);
}

.checkin-hero p,
.checkin-hero strong,
.checkin-hero span {
  margin: 0;
}

.checkin-hero strong {
  display: block;
  font-size: clamp(2.1rem, 6vw, 4.4rem);
  line-height: 0.95;
  font-family: var(--font-heading);
  font-weight: 760;
}

.checkin-hero span {
  display: block;
  color: var(--muted);
  font-weight: 700;
}

.checkin-hero .row-meta {
  text-align: right;
}

.personal-grid {
  display: grid;
  grid-template-columns: minmax(300px, 0.85fr) minmax(380px, 1.15fr);
  gap: 16px;
  align-items: start;
}

.personal-stack {
  display: grid;
  gap: 16px;
}

.personal-grid .field-label,
.personal-grid .field-value,
.personal-grid .compact-table td {
  font-size: 0.88rem;
  line-height: 1.35;
}

.personal-grid .compact-table th {
  font-size: 0.78rem;
}

.stacked-panels {
  display: grid;
  gap: 12px;
}

.compact-table th,
.compact-table td {
  padding: 7px 10px;
  font-size: 0.82rem;
  line-height: 1.25;
}

.compact-table th {
  font-size: 0.72rem;
}

.compact-table .badge {
  min-height: 21px;
  padding: 2px 7px;
}

.compact-panel .panel-header {
  padding: 11px 14px;
}

.compact-panel .panel-body {
  padding: 10px 14px;
}

.compact-summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 8px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  background: var(--paper-2);
}

.compact-summary .metric {
  padding: 8px 10px;
  background: var(--paper);
}

.compact-summary .metric strong {
  font-size: 1rem;
}

table {
  width: 100%;
  border-collapse: collapse;
  min-width: 650px;
}

th,
td {
  padding: 12px 14px;
  border-bottom: 1px solid var(--line);
  text-align: left;
  vertical-align: top;
}

th {
  color: var(--muted);
  font-size: 0.78rem;
  font-weight: 700;
}

td {
  font-size: 0.9rem;
}

tr:last-child td {
  border-bottom: 0;
}

.two-column {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 16px;
}

.field-grid {
  display: grid;
  gap: 10px;
}

.field-row {
  display: grid;
  grid-template-columns: minmax(110px, 0.45fr) minmax(0, 1fr);
  gap: 14px;
  padding: 10px 0;
  border-bottom: 1px solid var(--line);
}

.field-row:last-child {
  border-bottom: 0;
}

.field-label {
  color: var(--muted);
  font-weight: 700;
}

.field-value {
  min-width: 0;
  overflow-wrap: anywhere;
}

.progress-track {
  width: 100%;
  height: 10px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--line);
}

.progress-fill {
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, #ffbf3f, var(--brand), var(--plum));
}

.competition-tab .metric strong {
  font-family: var(--font-heading);
  font-weight: var(--heading-weight);
}

.competition-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
}

.competition-profile-grid {
  margin-top: 16px;
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

.competition-empty {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
}

.competition-empty h3 {
  margin: 0 0 4px;
  font-size: 1rem;
}

.competition-note {
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  background: var(--paper-2);
}

.competition-note p {
  margin: 0;
  color: var(--muted);
  font-size: 0.84rem;
  line-height: 1.35;
}

.competition-input-table {
  min-width: 860px;
}

.problem-pill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 44px;
  min-height: 24px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--ink);
  font-size: 0.78rem;
  font-weight: 750;
}

.drawer {
  position: fixed;
  top: 0;
  right: 0;
  z-index: 30;
  display: flex;
  width: min(430px, 100vw);
  height: 100vh;
  flex-direction: column;
  border-left: 1px solid var(--line);
  background: var(--paper);
  box-shadow: var(--shadow);
  transform: translateX(100%);
  transition: transform 0.18s ease;
}

.drawer.open {
  transform: translateX(0);
}

.drawer-header,
.modal-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  padding: 18px;
  border-bottom: 1px solid var(--line);
}

.drawer-body {
  display: grid;
  gap: 18px;
  min-height: 0;
  overflow: auto;
  padding: 14px 18px 18px;
}

.product-row {
  grid-template-columns: minmax(0, 1fr) auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 12px;
}

.product-title {
  margin: 0;
  font-weight: 700;
}

.product-meta {
  color: var(--muted);
  font-size: 0.82rem;
  margin-top: 3px;
}

.basket-panel {
  display: grid;
  gap: 10px;
  padding-top: 8px;
  border-top: 1px solid var(--line);
}

.basket-row {
  grid-template-columns: minmax(0, 1fr) auto;
}

.basket-total {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 0;
  border-top: 1px solid var(--line);
  font-size: 1rem;
}

.empty-state {
  color: var(--muted);
  padding: 12px 0;
}

.modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: none;
  align-items: center;
  justify-content: center;
  background: rgba(22, 26, 29, 0.36);
  padding: 20px;
}

.modal-backdrop.open {
  display: flex;
}

.modal {
  width: min(680px, 100%);
  max-height: min(760px, calc(100vh - 40px));
  overflow: auto;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--paper);
  box-shadow: var(--shadow);
}

.small-modal {
  width: min(360px, 100%);
}

.photo-modal {
  width: min(980px, calc(100vw - 40px));
}

.photo-modal-body {
  padding: 14px;
  background: var(--paper-2);
}

.photo-modal-body img {
  display: block;
  width: 100%;
  max-height: calc(100vh - 180px);
  object-fit: contain;
  border-radius: var(--radius);
  background: var(--paper);
}

.modal-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 0 18px 18px;
}

.checkin-summary {
  padding: 18px;
}

.note-field {
  display: grid;
  gap: 7px;
  margin-top: 14px;
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 700;
}

.note-field textarea {
  width: 100%;
  min-height: 86px;
  resize: vertical;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--ink);
  padding: 10px;
  font: inherit;
  line-height: 1.35;
}

.note-field textarea::placeholder {
  color: var(--quiet);
}

.shared-pass-body {
  display: grid;
  gap: 12px;
  padding: 14px 18px 18px;
}

.shared-pass-search {
  margin: 0;
}

.shared-pass-list {
  display: grid;
  gap: 10px;
}

.shared-pass-card {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  overflow: hidden;
}

.shared-pass-owner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 11px 12px;
  border-bottom: 1px solid var(--line);
}

.shared-pass-owner p {
  margin: 0;
}

.shared-pass-card .compact-table {
  min-width: 0;
}

.pin-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  padding: 18px;
}

.pin-grid button {
  min-height: 54px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--ink);
  font-weight: 700;
}

.pin-grid button:hover {
  border-color: var(--line-strong);
  background: var(--paper);
}

.theme-panel {
  padding: 16px 18px 18px;
}

.theme-options {
  display: grid;
  gap: 8px;
}

.theme-option {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: center;
  gap: 12px;
  width: 100%;
  min-height: 62px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--ink);
  padding: 10px;
  text-align: left;
}

.theme-option:hover,
.theme-option.active {
  border-color: var(--line-strong);
  background: var(--paper);
}

.theme-option.active {
  box-shadow: inset 3px 0 0 var(--brand);
}

.theme-option strong,
.theme-option small {
  display: block;
}

.theme-option small {
  color: var(--muted);
  font-size: 0.78rem;
  margin-top: 2px;
}

.theme-swatch {
  display: block;
  width: 36px;
  height: 36px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.theme-swatch.light {
  background: linear-gradient(135deg, #ffffff 0 52%, #0f8f8a 52%);
}

.theme-swatch.dark {
  background: linear-gradient(135deg, #11100e 0 52%, #f0a526 52%);
}

.theme-swatch.leopard {
  background:
    radial-gradient(circle at 11px 12px, transparent 0 4px, rgba(80, 47, 15, 0.42) 4.5px 6px, transparent 6.5px),
    linear-gradient(135deg, #ffcb52, #e87812);
}

.theme-swatch.unicorn {
  background:
    radial-gradient(circle at 9px 11px, transparent 0 3px, rgba(185, 42, 215, 0.45) 3.5px 5px, transparent 5.5px),
    conic-gradient(from 215deg, #ff7ace, #ffcd61, #69d6ff, #c13ee8, #ff7ace);
}

.toast {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 50;
  max-width: min(420px, calc(100vw - 36px));
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--ink);
  color: var(--paper);
  box-shadow: var(--shadow);
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.18s ease, transform 0.18s ease;
  pointer-events: none;
}

.toast.show {
  opacity: 1;
  transform: translateY(0);
}


@media (max-width: 920px) {
  .app-shell {
    grid-template-columns: 1fr;
    /* Stacked, the rail is a row its own height and the page takes the rest of the screen. */
    grid-template-rows: auto 1fr;
    height: auto;
    min-height: 100vh;
    overflow: visible;
  }

  .app-shell.is-focused {
    grid-template-rows: 1fr;
  }

  .sidebar {
    max-height: none;
    border-right: 0;
    border-bottom: 1px solid var(--line);
  }

  /* The floor is a desktop rule doing nothing here (D323/D478). It stops the customer list being
   squeezed away in a short window, and a stacked rail is not squeezed — so on a phone it was
   9rem of empty box between the search and the nav. Results still push the frame open and cap
   at `.rail-section`'s own height below. */
  .sidebar turbo-frame {
    min-height: 0;
  }

  .rail-section {
    max-height: 280px;
  }

  /* The brand row becomes the menu bar. The label takes the rest of it so the target is most of
   the width rather than the icon; the brand itself stays a link, and Home is in the menu. */
  .rail-head {
    display: flex;
    align-items: center;
    gap: 8px;
    border-bottom: 1px solid var(--line);
  }

  /* The band moves from the brand to the row. `.brand` is full width on desktop and is now only
   as wide as its own text, so its tint and its underline covered a third of the bar and stopped.
   Kept inside this block rather than adding `.rail-head` to the rule up in the theme section:
   nothing a desktop browser reads should change for this. */
  :root[data-theme="light"] .rail-head {
    background: linear-gradient(
      180deg,
      color-mix(in srgb, var(--brand) 12%, var(--paper)),
      color-mix(in srgb, var(--brand) 4%, var(--paper))
    );
  }

  .rail-head .brand {
    flex: 0 1 auto;
    min-width: 0;
    background: none;
    border-bottom: 0;
  }

  .rail-toggle {
    display: flex;
    flex: 1 1 auto;
    align-items: center;
    justify-content: flex-end;
    gap: 8px;
    align-self: stretch;
    padding: 0 14px;
    color: var(--muted);
    font-weight: 700;
    cursor: pointer;
  }

  /* Focusable but not drawn — the label is the visible control, and a keyboard reaches the menu
   through the box it is for. */
  .rail-toggle-input {
    display: block;
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
  }

  .rail-toggle-input:focus-visible + .rail-head .rail-toggle {
    outline: 2px solid var(--brand);
    outline-offset: -2px;
  }

  .rail-collapse {
    display: none;
  }

  .rail-toggle-input:checked ~ .rail-collapse {
    display: block;
    border-top: 1px solid var(--line);
  }

  .side-nav {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    border-top: 0;
  }

  .logout {
    margin-top: 0;
  }

  .main {
    overflow: visible;
  }

  .profile-header {
    align-items: flex-start;
    flex-direction: column;
  }

  .profile-actions {
    justify-content: flex-start;
  }

  .content {
    overflow: visible;
  }

  .snapshot-grid,
  .personal-grid,
  .competition-profile-grid,
  .two-column {
    grid-template-columns: 1fr;
  }

  .competition-empty {
    align-items: flex-start;
    flex-direction: column;
  }
}

@media (max-width: 620px) {
  .profile-header,
  .content {
    padding-left: 14px;
    padding-right: 14px;
  }

  .profile-heading {
    align-items: flex-start;
  }

  .avatar {
    width: 48px;
    height: 48px;
  }

  h1 {
    font-size: 1.2rem;
  }

  .metric-grid {
    grid-template-columns: 1fr;
  }

  .compact-summary {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .pagination-row {
    align-items: flex-start;
    flex-direction: column;
  }

  .field-row {
    grid-template-columns: 1fr;
    gap: 2px;
  }

  .photo-panel {
    min-height: 294px;
  }

  .photo-frame {
    min-height: 210px;
  }

  .drawer {
    width: 100vw;
  }
}


/* ===================================================================
 Leopard additions — classes the app needs that the mockup did not
 define. Written against the mockup's own custom properties so they
 inherit every theme (light / dark / leopard / unicorn) for free.
 =================================================================== */

.panel-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 16px;
}

.panel {
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 16px 18px;
  box-shadow: var(--shadow);
}

/* "Under construction" and other empty states. Centred and quiet — it should
 read as an absence, not as an error. */
.panel.construction {
  text-align: center;
  padding: 44px 24px;
  color: var(--muted);
}
.panel.construction h2 {
  margin: 12px 0 6px;
  font-family: var(--font-heading);
  font-weight: var(--heading-weight);
  color: var(--ink);
}
.panel.construction p { margin: 4px 0; }

.icon.large { width: 34px; height: 34px; opacity: 0.5; }
.icon.ok { color: var(--green); }

.section-head.spaced { margin-top: 18px; }

/* Two or three headline numbers at the top of a panel. */
.stat-row { display: flex; gap: 26px; margin-bottom: 14px; flex-wrap: wrap; }
.stat strong {
  display: block;
  font-family: var(--font-heading);
  font-size: 24px;
  font-weight: var(--title-weight);
  line-height: 1.15;
}
.stat small { color: var(--muted); font-size: 12px; }

.detail-list { display: grid; grid-template-columns: minmax(110px, auto) 1fr; gap: 7px 16px; margin: 0; }
.detail-list dt { color: var(--muted); font-size: 13px; }
.detail-list dd { margin: 0; }
.detail-list dd small { margin-left: 6px; }

.plain-list { list-style: none; margin: 0; padding: 0; }
.plain-list li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 7px 0;
  border-top: 1px solid var(--line);
}

.line { display: flex; align-items: center; gap: 8px; margin: 7px 0; flex-wrap: wrap; }
.line.warn { color: var(--amber); }
.line small { font-weight: 400; }

.data-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.data-table th {
  text-align: left;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  font-weight: 600;
  padding: 6px 10px 6px 0;
  border-bottom: 1px solid var(--line);
}
.data-table td { padding: 9px 10px 9px 0; border-bottom: 1px solid var(--line); vertical-align: top; }
/* Reversing an SMS opt-out: label in the column header, field and button on one line (D306). The
 form's own inputs inherit full-width block styling from the admin form rules, which is what
 stacked them three deep and made each row 90px tall. */
.optout-reverse { display: flex; gap: 8px; align-items: center; }
.optout-reverse input[type="text"] {
  flex: 1; min-width: 0; margin: 0; padding: 4px 8px; font-size: 13px;
}
.optout-reverse input[type="submit"] {
  width: auto; margin: 0; padding: 4px 12px; font-size: 13px; white-space: nowrap;
}

/* A reversal is not a deletion (D9) — the row stays and is struck through. */
.data-table tr.reversed td { opacity: 0.55; text-decoration: line-through; }

/* A row for something switched off — a retired badge, an automation that is not live. Dimmed
 rather than hidden, because "it exists but is not running" is the thing the row has to
 communicate.

 This class was in use with no definition at all: `admin/badges/index` has been marking retired
 badges `is-archived` since D96 and nothing has ever styled them, so a retired badge looked
 identical to a live one. Found while copying the same markup for the automations list. */
.data-table tr.is-archived td { opacity: 0.5; }

/* Condition slots on the automation forms (§8.6). The select carries the label
 and the value box follows it on the same line — stacked, they read as two
 unrelated fields, which is exactly how the first version rendered. */
.condition-row {
  display: flex;
  gap: 8px;
  align-items: center;
  margin-bottom: 8px;
}
.condition-row select { flex: 1 1 60%; min-width: 0; }
.condition-row input { flex: 1 1 40%; min-width: 0; }

/* The conditions in groups that fold (D1020). Closed, a group's heading says what it checks,
 so a trigger can be read without opening anything. */
.condition-group { border-top: 1px solid var(--line); padding: 8px 0; }
.condition-group:first-of-type { border-top: 0; }
.condition-group > summary { cursor: pointer; padding: 2px 0; }

/* The links from a send step to its template and the template list (D1020). */
.template-links { margin: -4px 0 10px; }
.condition-group[open] > summary { margin-bottom: 10px; }

/* ── An automation drawn as a flow (D190) ────────────────────────────────────
 Small cards joined by a line, with a `+` in every gap, and a fork opening into
 two columns. Server-rendered: no canvas, no drag-and-drop, nothing for the CSP
 to block — the `+` is a link that carries where it was pressed. */
.flow-chain { display: flex; flex-direction: column; align-items: stretch; }

.flow-step {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 9px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper);
}
.flow-step.is-orphan { opacity: 0.65; margin-bottom: 8px; }
.flow-step .flow-body { flex: 1; min-width: 0; }
.flow-number {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12px;
  color: var(--muted);
  min-width: 1.4rem;
}

/* The connector is the gap — a 20px run of line with the + centred on it, so the
 sequence reads as connected rather than as a stack of unrelated cards. */
.flow-insert {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 26px;
  position: relative;
}
.flow-insert::before {
  content: "";
  position: absolute;
  top: 0; bottom: 0;
  width: 1px;
  background: var(--line);
}
.flow-plus {
  position: relative;
  width: 20px; height: 20px;
  line-height: 18px;
  text-align: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--bg);
  color: var(--muted);
  text-decoration: none;
  font-size: 14px;
}
.flow-plus:hover { border-color: var(--brand); color: var(--brand); }

/* Two columns below a fork. Drawn in one column, a fork reads as a sequence and
 the reader has to carry "but only if…" down every row after it. */
.flow-fork { display: flex; gap: 16px; align-items: flex-start; }
.flow-column {
  flex: 1 1 0;
  min-width: 0;
  border-top: 2px solid var(--line);
  padding-top: 4px;
}
.flow-branch-label {
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  text-align: center;
  padding: 4px 0;
}
.flow-end { text-align: center; padding: 6px 0 0; }

/* The builder's reasoning, quoted under the step (D192). Left border rather than a
 box, so it reads as an annotation on the step rather than another step. */
.flow-note {
  margin: 6px 0 0;
  padding: 4px 0 4px 9px;
  border-left: 2px solid var(--line-strong);
  font-size: 13px;
  color: var(--muted);
  white-space: pre-wrap;
}

/* The step number in an automation's sequence. Monospaced so 1, 2, 3a, 10 line up
 in a column — the whole reason the number is there is to be scannable. */
.seq-number {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 13px;
  color: var(--muted);
  white-space: nowrap;
  vertical-align: top;
}

/* A note attached to one condition row, indented under its control. */
.condition-hint { margin: -2px 0 8px; padding-left: 2px; }

/* Marketing preferences on the Personal tab. One flowing line per category.
 Not a flex row with a fixed label width — this panel is the narrow left column
 of the profile, and a fixed label squeezed the value into "Emai l and SMS". */
.pref-line { margin: 0 0 3px; }
.pref-line:last-child { margin-bottom: 0; }
.pref-name { color: var(--muted); }

/* The unsubscribe page (§9.6). `.unsub-all` was in use with no definition at all
 — the same gap `.is-archived` had — so "stop everything" sat flush against the
 button above it with no separation. */
.unsub-all { margin-top: 12px; }

/* Choosing what to keep, collapsed by default (D188). Open, four checkboxes
 compete with the one button most people came to press; collapsed, the choice is
 discoverable without arguing with the reader. The summary is deliberately worded
 as an offer rather than a warning. */
.unsub-choose { margin-top: 18px; border-top: 1px solid var(--line); padding-top: 14px; }
.unsub-choose summary {
  cursor: pointer;
  font-size: 14px;
  color: var(--muted);
  margin-bottom: 10px;
}
.unsub-choose .signin-field.inline { margin-bottom: 8px; }

/* Picking what a journey step does, before filling anything in. Each is a whole
 clickable block rather than a radio button with a label beside it — the choice
 carries two lines of explanation, and those explanations are the point. */
.step-choice {
  display: block;
  padding: 14px 16px;
  margin-bottom: 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  text-decoration: none;
  color: inherit;
}
.step-choice:hover { border-color: var(--brand); background: var(--paper); }
.step-choice strong { display: block; margin-bottom: 3px; }
.step-choice span { display: block; }

/* Scrolls once the gym sells enough products to make the list long, rather than
 pushing everything below it off the screen. */
.condition-choices {
  max-height: 190px;
  overflow-y: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 8px 10px;
}
.condition-choices .signin-field.inline { margin-bottom: 5px; }

/* A select with the button that plays what it names beside it (D636). A list of six sounds
 is unusable without one — you cannot pick a noise by reading its name. */
.tone-picker { display: flex; align-items: center; gap: 10px; }
.tone-picker select { flex: 1 1 auto; max-width: 22rem; min-width: 0; }
.tone-picker button { flex: none; }

/* A grouped set of fields with a heading, for the plain phase-2 forms. */
.plain-fieldset {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px 10px;
  margin: 18px 0;
}
.plain-fieldset legend {
  padding: 0 6px;
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

/* The six-character customer ID (D3 amended). Monospaced and letter-spaced,
 because it is read aloud at the counter and typed back on another screen. */
.customer-id {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.95em;
  letter-spacing: 0.08em;
  padding: 2px 6px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

/* Merging duplicate accounts (§4.6). */
.merge-picker { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
.merge-picker .signin-field { flex: 1 1 260px; margin: 0; }
/* Scrolls inside its own box rather than pushing the page sideways, which is
 the one layout failure that makes a screen feel broken. No column widths and
 no word-break: an earlier attempt at both squeezed "Benjamin" into a column
 one letter wide. The table only ever overflowed because a raw location UUID
 was being rendered — that field is gone, and the wrapper is the backstop for
 whatever long value turns up next. */
.merge-table-scroll { overflow-x: auto; }
.merge-table td { vertical-align: middle; }
.merge-choice { display: flex; align-items: center; gap: 8px; cursor: pointer; }
/* `width: auto` is the load-bearing one. Form inputs are `width: 100%`
 app-wide, which made each radio 268px — the full column — and shoved its own
 label two hundred pixels to the right, off the side of the table. It looked
 like a table-layout problem and was not. */
.merge-choice input[type="radio"] { margin: 0; flex: none; width: auto; }
/* Email and barcode are shown for comparison but cannot be taken — greyed so
 nobody hunts for a control that deliberately does not exist. */
.merge-table tr.is-fixed td { background: var(--paper-2); }
.duplicate-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.duplicate-actions form { margin: 0; }

/* Staff suggestions. */
.suggestion-kind { border: 0; padding: 0; margin: 0 0 14px; display: flex; gap: 18px; flex-wrap: wrap; }
.suggestion-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 14px; }
.suggestion { padding-bottom: 14px; border-bottom: 1px solid var(--line); }
.suggestion:last-child { border-bottom: 0; padding-bottom: 0; }
/* Done items stay visible but recede — a resolved list nobody can see is a list
 nobody trusts was read. */
.suggestion.is-resolved { opacity: 0.55; }
.suggestion-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.suggestion-body { margin: 6px 0 0; font-size: 0.92rem; }

/* One checkbox per line on the membership form (D433). `<label class="inline">` carries no rule of
 its own, so it fell back to a `<label>`'s default inline display and every box ran together on
 one row. Scoped to this form rather than fixed on `.inline` globally: the advanced-search page
 uses the same class for filter chips, where sitting side by side is the point. */
.membership-type-form label.inline {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 10px;
}

/* `.signin-field input` is `width: 100%` app-wide — right for a text box, absurd for a
 checkbox, which is the trap the stylesheet already carries a comment about. */
.membership-type-form label.inline input[type="checkbox"] {
  width: auto;
  flex: none;
}

/* The alert on the desk kiosk screen (D436). Large, because it is read across a counter —
 and never rendered on the customer-facing screen at all (S16/S55). */
.kiosk-alert {
  margin: 10px 0 0;
  padding: 10px 14px;
  border-radius: var(--radius);
  border: 1px solid var(--amber);
  background: var(--amber-soft);
  /* An explicit colour, or it inherits white from the verdict panel and disappears
 (D444). The panel is red, green or amber with white text; this box sets its own pale
 background and, until now, no colour — so a non-blocking alert on a refusal was white
 on pale amber and unreadable. Shipped that way in D436 and found a day later, on the
 screen, while building the birthday notice beside it.
 `.is-blocking` below sets a colour and was fine, which is why nobody caught it.
 D441's rule, one step over: a surface that does not follow its container cannot
 inherit that container's foreground. */
  color: var(--ink);
  font-size: 1.05rem;
}
.kiosk-alert.is-blocking { border-color: var(--red); background: var(--red-soft); color: var(--red); }

/* "an obvious notification" (D444) — a staff suggestion via. Sized with the alert above it and in
 the brand colour rather than a warning one: this is the one thing on the desk screen that is
 good news, and it should not read as a problem. The emoji is a second, non-colour signal, the
 same reasoning as the tick and the arrow on the customer screen — roughly one man in twelve
 cannot rely on the first. */
.kiosk-birthday {
  margin: 10px 0 0;
  padding: 10px 14px;
  border-radius: var(--radius);
  border: 1px solid var(--brand);
  background: var(--brand-soft);
  /* Same reason as `.kiosk-alert` above — the panel behind it is red, green or amber
 with white text, and this box brings its own pale background. */
  color: var(--ink);
  font-size: 1.05rem;
}

/* An alert, above every tab (D436). User feedback via It had lived only inside the Alerts & notes
 panel — below the fold on Snapshot, absent on the other twelve tabs. Two treatments, because
 "they cannot climb" and "read this" are different instructions and one colour for both is what
 taught staff to stop reading them. */
.customer-alert {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  /* Its own margin rather than a grid gap: it sits beside `.duplicate-banner` outside any
 `.content` section, so there is no parent laying it out. */
  margin: 0 0 12px;
  padding: 14px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* The theme selectors are not decoration: `:root[data-theme] .panel`-style rules outrank a
 bare class, and the same trap ate D427's background. These are their own element rather
 than a `.panel`, so a plain class is enough — but the colours are still tokens, so the
 whole thing flips in dark mode. */
.customer-alert.is-blocking { border-color: var(--red); background: var(--red-soft); }
.customer-alert.is-notice { border-color: var(--amber); background: var(--amber-soft); }

.customer-alert-mark { display: grid; place-items: center; width: 34px; height: 34px; flex: none; }
.customer-alert.is-blocking .customer-alert-mark { color: var(--red); }
.customer-alert.is-notice .customer-alert-mark { color: var(--amber); }

.customer-alert-title {
  margin: 0;
  font-size: 0.78rem;
  font-weight: 800;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.customer-alert.is-blocking .customer-alert-title { color: var(--red); }
.customer-alert-body { margin: 2px 0 4px; font-size: 0.95rem; font-weight: 600; }

/* No Square profile (D1041): the size and colour of an alert that does not block, with its own class so it is
 never counted as one of the account's alerts. */
.square-profile-banner {
  display: flex;
  gap: 14px;
  align-items: flex-start;
  margin: 0 0 12px;
  padding: 14px 16px;
  border: 1px solid var(--amber);
  border-radius: var(--radius);
  background: var(--amber-soft);
}
.square-profile-banner .customer-alert-mark { color: var(--amber); }

/* The Clear button on the alert banner (D448). Pushed to the right and not allowed to shrink —
 the alert body is the long part and should take the space. */
.customer-alert-actions { margin-left: auto; flex: none; align-self: center; display: flex; gap: 6px; }
.customer-alert-actions form { margin: 0; }

/* One event choice on the ad-tracking page: the tick, then what it fires on (D450). The
 explanation belongs with the box rather than behind an (i), because it is a fact about what the
 tick does, re-read every time somebody reconsiders it. */
.event-choice + .event-choice { margin-top: 14px; }
.event-choice .signin-field { margin-bottom: 2px; }
.event-choice p { margin: 0 0 0 26px; }

/* ── The suggestions backlog (D433) ──────────────────────────────────────────

 The FR-39 "copy the open ones" export lived here and is deleted */

/* The two numbers at the top: open, and how many of those are flagged. */
.suggestion-status-body { display: flex; gap: 40px; }
.suggestion-status-body .home-figure { margin: 0; }
/* Red only when there is something flagged — a permanently red zero teaches
 staff that the colour means nothing. */
.suggestion-status-body .is-flagged .home-figure { color: var(--red); }

/* The three buttons sit together at the right; `.suggestion-head form` used to
 push a single one there with `margin-left: auto`, which put a gap between each
 of three. */
.suggestion-actions { display: flex; gap: 6px; margin-left: auto; }
.suggestion-actions form { margin: 0; }

/* A flagged item is marked on the row, not only by its badge — the point is that
 it is findable while scanning, which a badge among other badges is not. */
.suggestion.is-priority { border-left: 3px solid var(--red); padding-left: 10px; }

/* The closed pile, collapsed by default. It keeps the muted treatment the rows
 already had, so opening it still reads as history rather than as work. */
.suggestion-closed { margin-top: 18px; }
.suggestion-closed > summary { cursor: pointer; font-size: 0.85rem; font-weight: 600; color: var(--muted); }
.suggestion-closed .suggestion-list { margin-top: 14px; }

/* The photo and the health column each occupy one grid cell; this stacks the
 concession above profile health inside the second one. */
.snapshot-stack { display: flex; flex-direction: column; gap: 16px; }

/* Concession (§4.4). Deliberately the loudest thing on the snapshot: missing
 it means charging a discounted customer full price, which happens regularly
 in the system being migrated from. Amber rather than red — nothing is wrong, but somebody is about
 to take money and needs to know. */
/* ── The kiosk (§7.1, S55) ──────────────────────────────────────────────────
 The customer screen is read from a metre or two away, across a counter, by
 somebody who is not looking for it. Everything here is sized for that: one
 message, very large, high contrast, nothing else on the page. */
/* Turbo's stream source is a child of the body, and a grid counts it as an item (D514).
 `turbo_stream_from` renders a `<turbo-cable-stream-source>`, an unknown element, so it blockifies into
 a grid item and takes a row of its own, pulling the centred content off centre. It has no visual
 representation anywhere, so it is hidden outright rather than patched for the kiosk alone: any other
 layout that becomes a grid or a flex container would hit the same thing. */
turbo-cable-stream-source { display: none; }

.kiosk-body {
  display: grid;
  place-items: center;
  min-height: 100vh;
  /* `dvh` after `vh`, so an older browser keeps the fallback. On a tablet `100vh` does not subtract
   the browser's chrome, so opened in a normal tab rather than the installed app the page is taller
   than the screen and the centred message drifts down (D514). */
  min-height: 100dvh;
  margin: 0;
  background: var(--bg);
}
.kiosk-screen {
  display: grid;
  place-items: center;
  width: 100%;
  padding: 32px;
  text-align: center;
}
.kiosk-idle {
  font-size: clamp(1.4rem, 4vw, 2.6rem);
  color: var(--muted);
}
.kiosk-mark {
  font-size: clamp(3rem, 12vw, 8rem);
  line-height: 1;
  margin-bottom: 12px;
}
.kiosk-message {
  font-size: clamp(1.6rem, 5vw, 3.4rem);
  font-weight: 700;
  margin: 0;
  line-height: 1.15;
}
/* The kiosk does not use the theme's status colours, and this is the one place in the app that
 opts out. `--green` is a semantic slot whose actual value is themed: it is #a85b09 (orange) in
 the default theme, #f0b53b (yellow) in dark, and #8a35bd (purple) in another — while `--amber`
 is #C55d0a. Caught on screen: the success tick and the refusal mark rendered as rgb(168,91,9)
 and rgb(197,93,10), which are the same colour to anyone glancing at a screen from two metres
 away.

 Everywhere else that is harmless — a themed badge next to readable text. Here it is the entire
 message: somebody who has just scanned looks up, reads the colour, and either walks in or goes
 to the desk. So these are fixed hex, and they do not move when the gym picks a different theme.

 And the whole screen carries it, not a small glyph — legible across a room and past somebody's
 shoulder, which a 3rem tick is not. Colour is never the only signal: the tick is present on
 success and absent otherwise. */
/* Deep and saturated, not pale tints — measured, not eyeballed. The first
 version used washes (#dcfce7 / #ffedd5) on the assumption they would read
 against the page. They do not: the gym's palette is cream, so the refusal
 wash scored a contrast ratio of **1.024** against the idle background. The
 screen barely changed, which means a refused customer sees nothing happen,
 assumes the scan failed, and scans again.

 Full-bleed colour with light text is the fix. It is unmistakable from across
 the room, it cannot be confused with the idle state, and the two outcomes
 cannot be confused with each other. */
/* A refusal is the staff screen's red, so the counter and the queue show one colour for one
 outcome (D643). Against the green it differs mostly in hue rather than brightness, so for
 somebody with red-green colour blindness the outcome rides on the other signals: the tick,
 drawn only on success, the words, and the failure tone. */
.kiosk-result.kiosk-admitted,
.kiosk-result.kiosk-already_in {
  --kiosk-ink: #ffffff;
  --kiosk-wash: #15803d;
}
/* Amber for the outcomes that are neither a yes nor a no, as on the staff screen (FR-26, D643).
 Green would read as "you're in" from the queue, and red as "you're not". */
.kiosk-result.kiosk-other_reader,
.kiosk-result.kiosk-busy {
  --kiosk-ink: #ffffff;
  --kiosk-wash: #b45309;
}
/* Still no cross: a refusal is usually a lapsed card or a spent pass, and the words send them
 to the desk rather than turning them away (D643). */
.kiosk-result.kiosk-refused,
.kiosk-result.kiosk-unknown {
  --kiosk-ink: #ffffff;
  --kiosk-wash: #b91c1c;
}
/* Covers the viewport, via `fixed` rather than by cancelling the parent's
 padding with a negative margin. This screen has nothing else on it, and the
 wash only works as a signal if it reaches the edges — a tinted card floating
 in a cream page reads as a notification, not as "you're in". */
.kiosk-result {
  position: fixed;
  inset: 0;
  z-index: 1;
  display: grid;
  /* `align-content`, not `align-items`. With two children, `place-items:
 center` centres each one inside its own row — so the tick floats near the
 top and the greeting near the bottom, with a hole between them. They are one
 message and should read as one. */
  align-content: center;
  justify-items: center;
  gap: 4px;
  padding: 32px;
  background: var(--kiosk-wash);
  color: var(--kiosk-ink);
}
.kiosk-result .kiosk-mark { color: var(--kiosk-ink); }
.kiosk-result .kiosk-message { color: var(--kiosk-ink); }

/* The staff verdict banner — big, and read from across a counter. Staff want success or failure
 in large red or green and little else, which is why this replaced a bordered card at normal page
 density: it said the same thing in a size that had to be walked over to and read.

 Red here, brown on the customer screen, and the difference is deliberate.
 That screen faces a queue, where a refusal must read as direction rather than
 rejection; this one faces staff, who asked for red because they need the
 answer at a glance and nobody is embarrassed by it.

 The colour is never the ONLY signal. The verdict word is always rendered beside it, for the
 same reason the customer screen carries a tick: a significant fraction of people cannot separate
 red and green at a glance, and "did that work" is not a question to answer in hue alone. */
/* One height for every outcome. A refusal is the state where the face matters most — staff have to
 find this person in a queue — and a panel that changed size between outcomes would make the
 screen jump on the one event anybody is watching for. A fifth smaller than it was, so a tablet
 shows more of the list below it (D682): the photo caps at 307px and the panel at 355px. */
.kiosk-verdict {
  --verdict-ink: #ffffff;
  --verdict-wash: #4b5563;
  --verdict-photo: clamp(176px, 24vw, 307px);

  display: flex;
  align-items: stretch;
  gap: 28px;
  margin-top: 16px;
  padding: 24px;
  min-height: calc(var(--verdict-photo) + 48px);
  border-radius: var(--radius-lg);
  background: var(--verdict-wash);
  color: var(--verdict-ink);
}
.kiosk-verdict.kiosk-admitted { --verdict-wash: #15803d; }
.kiosk-verdict.kiosk-refused,
.kiosk-verdict.kiosk-unknown { --verdict-wash: #b91c1c; }
/* Neither a success nor a failure: a second scan, or a reader running hot. */
.kiosk-verdict.kiosk-already_in,
.kiosk-verdict.kiosk-other_reader,
.kiosk-verdict.kiosk-busy { --verdict-wash: #b45309; }

.kiosk-verdict-body { min-width: 0; }
/* The answer is the biggest thing on the screen. */
.kiosk-verdict-state {
  margin: 0;
  font-size: clamp(2.4rem, 5vw, 3.6rem);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
}
.kiosk-verdict-name {
  margin: 4px 0 0;
  font-size: clamp(1.3rem, 2.6vw, 1.9rem);
  font-weight: 600;
  /* A long name must not push the photo out of the banner. */
  overflow-wrap: anywhere;
}
/* Only a refusal renders this, and it answers "why not" after the banner has
 answered "did it work". It was 0.9rem on the compact bar, where it was a
 footnote; on the full panel it is the line staff actually act on, so it is
 sized to be read at the same distance as the name above it. */
.kiosk-verdict-detail {
  margin: 8px 0 0;
  font-size: clamp(1rem, 1.9vw, 1.3rem);
  font-weight: 500;
  opacity: 0.95;
}

/* The photo is what confirms the card belongs to the person holding it, which
 is the one check staff make without reading anything. Fixed square so the
 banner height does not jump between a customer with a photo and one without. */
.kiosk-face { flex: 0 0 auto; }
.kiosk-photo {
  width: clamp(72px, 9vw, 104px);
  aspect-ratio: 1;
  border-radius: var(--radius-lg);
  object-fit: cover;
  background: rgba(255, 255, 255, 0.18);
}
.kiosk-photo.is-placeholder {
  display: grid;
  place-items: center;
  font-size: 1.6rem;
  font-weight: 700;
}

/* `min-height` is tied to the photo variable so the panel cannot collapse
 around a customer with no photo — the placeholder fills the same square. */
.kiosk-face { display: flex; }
.kiosk-verdict .kiosk-photo {
  width: var(--verdict-photo);
  height: var(--verdict-photo);
  border-radius: var(--radius-lg);
}
.kiosk-verdict .kiosk-photo.is-placeholder { font-size: clamp(3rem, 7vw, 5rem); }
/* Nobody to show — quieter than a set of initials, which would read as a name. */
.kiosk-photo.is-nobody { opacity: 0.55; font-weight: 400; }

/* The body sits against the photo rather than floating mid-panel. */
.kiosk-verdict-body {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
}

/* Entry method and visit count — two labelled facts, not a sentence. */
.kiosk-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 32px;
  margin: 14px 0 0;
}
.kiosk-facts div { min-width: 0; }
.kiosk-facts dt {
  margin: 0;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.75;
}
.kiosk-facts dd {
  margin: 2px 0 0;
  font-size: clamp(1rem, 1.8vw, 1.25rem);
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* What the customer screen said, quoted back. Set apart by a rule rather than
 a panel — it is evidence, not another headline, and it must not compete with
 the verdict above it. */
.kiosk-said {
  margin: 16px 0 0;
  padding-top: 12px;
  border-top: 1px solid rgba(255, 255, 255, 0.28);
}
.kiosk-said-label {
  display: block;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.75;
}
.kiosk-said-text {
  display: block;
  margin-top: 2px;
  font-size: clamp(0.95rem, 1.7vw, 1.15rem);
  font-style: italic;
  overflow-wrap: anywhere;
}

/* The desk screen is often a Surface in portrait at the door. Below that the
 photo would leave no room for the facts beside it, so they stack. */
@media (max-width: 720px) {
  .kiosk-verdict {
    --verdict-photo: clamp(128px, 32vw, 176px);
    flex-direction: column;
    align-items: flex-start;
  }
}
/* A scan that did not get through, or a page that cannot hear the reader (D696). Loud, because the
 reader beeps on every read and the desk takes that beep as the answer. */
.kiosk-scan-warning {
  margin: 12px 0 0;
  padding: 12px 16px;
  border-radius: var(--radius);
  background: #b91c1c;
  color: #fff;
  font-size: clamp(1rem, 1.8vw, 1.25rem);
  font-weight: 700;
}

.kiosk-recent { list-style: none; margin: 0; padding: 0; }
.kiosk-recent li {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 6px 0;
  border-bottom: 1px solid var(--line);
}
.kiosk-recent li:last-child { border-bottom: 0; }
/* One line a row, so the list shows as many as it can: a long pass name is cut short rather than
 wrapping (D682). */
.kiosk-recent-who {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.kiosk-recent-who .muted { margin-left: 8px; }
.kiosk-recent-when { flex: 0 0 auto; }

/* Badges (D96) — chips on the snapshot. Deliberately quiet next to the
 concession panel: this is a nice-to-notice, not a must-not-miss, and two loud
 things in one column means neither reads as loud. */
.badge-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.badge-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 5px 11px 5px 8px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-2);
  font-size: 0.86rem;
  white-space: nowrap;
}
.badge-chip-emoji { font-size: 1.05rem; line-height: 1; }
.badge-chip-name { font-weight: 600; }

/* The header variant, since badges moved beside the name (staff feedback,). Smaller than the panel
 version because it now shares a row with an h1 — and the row is inside the fixed top band, so a
 chip that set its own height would push the band open on exactly the customers who earned one. */
.badge-chip.compact {
  gap: 5px;
  padding: 2px 9px 2px 6px;
  font-size: 0.78rem;
}
.badge-chip.compact .badge-chip-emoji { font-size: 0.92rem; }

/* The profile health panel when somebody cannot be let in (D427). A tint and a border, not a red
 fill: the panel's job is still to be read — it lists what to fix — and white-on-red body text is
 a worse version of the same information. The `.concession-panel` above uses exactly this shape
 in amber. Only for `blocking`, never for an advisory gap (D419) — a colour that appears when
 nothing is actually wrong stops being believed. */
/* The theme selectors are not decoration — without them the background does nothing.
 `:root[data-theme="light"] .panel` sets `background: var(--paper)` at specificity (0,2,1),
 which beats a plain `.panel.is-blocked` at (0,2,0) however far down the file it sits. The
 border goes red and the panel stays white.
 Same family as D409's `--radius`: a theme-scoped rule quietly outranking a class. Check what the
 browser computed, not what the file says. */
:root[data-theme="light"] .panel.is-blocked,
:root[data-theme="dark"] .panel.is-blocked,
:root[data-theme="unicorn"] .panel.is-blocked,
.panel.is-blocked {
  border-color: var(--red);
  background: var(--red-soft);
}

.panel.is-blocked .panel-title h2 {
  color: var(--red);
}

/* The rows sit on the tint now, so their own divider has to stay visible against it. */
.panel.is-blocked .health-row {
  border-bottom-color: rgba(198, 66, 48, 0.22);
}

.concession-panel {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 16px;
  border-color: var(--amber);
  background: var(--amber-soft);
}
.concession-mark {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  flex: none;
  border-radius: 50%;
  background: var(--amber);
  color: #fff;
}
.concession-mark .icon { width: 21px; height: 21px; }
.concession-title {
  margin: 0;
  font-weight: 700;
  font-size: 1.02rem;
  color: var(--amber);
  letter-spacing: 0.01em;
}
.concession-detail { margin: 1px 0 0; font-size: 0.86rem; }
/* The detail beside a concession's name on the panel (D693) — part of the title, quieter. */
.concession-title .muted { font-weight: 400; }

/* Adding a concession (D693): each type's extra field appears once that type is picked, in CSS
 alone so the form is whole without a script. */
.concession-choices { display: grid; gap: 8px; margin: 0 0 14px; padding: 0; border: 0; }
/* Two classes, because `.signin-field`'s own `display: flex` comes later in this file and would
 otherwise show every field before anything is chosen. */
.signin-field.concession-choice-detail { display: none; margin: 6px 0 0 27px; }
.concession-choice:has(input[type="radio"]:checked) .concession-choice-detail { display: flex; }

/* The Cancel form's refund: its payment, destination and pass questions once a refund is chosen, the amount only for
   part of it (D885). Hidden only where :has() is understood, so a browser without it shows them all. */
/* No box of its own: its fields are the form's grid items, spaced as the rest of it. */
.refund-ask { display: contents; }
.manage-dialog .refund-ask > * { margin: 0; }
.refund-ask:has(input[name="refund"][value="none"]:checked) .refund-detail { display: none; }
.refund-ask:has(input[name="refund"]):not(:has(input[name="refund"][value="part"]:checked)) .refund-part-only { display: none; }

/* The freeze form, the same way (D909): the override's reason once it is ticked, the start date only for "On a date". */
.freeze-override, .freeze-start { display: contents; }
.manage-dialog .freeze-override > *, .manage-dialog .freeze-start > * { margin: 0; }
/* A hint inside the wrapper sits under its field, as one placed straight in the form does. */
.manage-dialog .freeze-override > .field-hint { margin-top: -8px; }
.freeze-override:has(input[name="override_limits"]):not(:has(input[name="override_limits"]:checked)) .override-detail { display: none; }
.freeze-override:has(input[name="override_term"]):not(:has(input[name="override_term"]:checked)) .override-detail { display: none; }
.freeze-start:has(input[name="freeze_start"]):not(:has(input[name="freeze_start"][value="on_date"]:checked)) .freeze-start-date { display: none; }
.freeze-start:has(input[name="freeze_end"][type="radio"]):not(:has(input[name="freeze_end"][value="on_date"]:checked)) .freeze-end-date { display: none; }
.cancel-when { display: contents; }
.manage-dialog .cancel-when > * { margin: 0; }
.cancel-when:has(input[name="when_to_end"]):not(:has(input[name="when_to_end"][value="on_date"]:checked)) .cancel-on-date { display: none; }

/* Profile health (§4.4) — what this account is missing. Three states, because
 "we don't know yet" is a real answer for anything the source system still owns. */
.health-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.health-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  border-bottom: 1px solid var(--line);
}
.health-row:last-child { border-bottom: 0; }
.health-text { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.health-text strong { font-size: 14px; font-weight: 600; }
.health-text small { font-size: 12px; }
/* Fixed width so the labels line up whatever the mark is. */
.health-mark { width: 18px; text-align: center; font-weight: 700; font-size: 15px; }
.health-row.is-ok .health-mark { color: var(--green); }
.health-row.is-missing .health-mark { color: var(--red); }
/* Amber, not red, and the same argument as the `unknown` rule below (D419). A missing induction
 is a conversation; a missing waiver is a refusal. Drawing both as an identical red ✗ makes the
 first read as a blocker, which is how the panel gets misread.
 It comes after `.is-missing` so it wins on order at equal specificity — both are
 (0,2,0), so moving this rule above that one would silently undo it. */
.health-row.is-advisory .health-mark { color: var(--amber, #b7791f); }

/* Muted, not red. An unknown entitlement is not a failure — it is a thing
 Leopard cannot answer until cutover, and colouring it like a refusal would
 train staff to ignore the row that will matter most. */
.health-row.is-unknown .health-mark { color: var(--muted); }
.health-row.is-unknown .health-text strong { color: var(--muted); font-weight: 500; }
.health-note { display: flex; align-items: center; gap: 8px; margin: 10px 0 0; }
/* On the profile the label and its detail share a line, wrapping only when they do not fit, so
 the panel's five rows leave room for Alerts & notes below it (D1033). A term's note keeps a
 line of its own. */
.snapshot-stack .health-row { padding: 5px 0; }
.snapshot-stack .health-text { flex-direction: row; flex-wrap: wrap; align-items: baseline; column-gap: 8px; row-gap: 0; }
.snapshot-stack .health-term { flex-basis: 100%; }

/* When the intro period ends, under the membership it belongs to (D445). Brand-coloured
 rather than muted: it is the line staff are hunting for when somebody asks, and a third
 grey line under two grey lines is not findable.
 Not `.health-note`, which already existed for the concession banner and carries a
 10px top margin and `display: flex`. */
.health-term {
  font-size: 12px;
  font-weight: 600;
  color: var(--brand-dark);
}

/* Signed-in browsers, hanging under the person they belong to (S46). Indented
 and borderless so it reads as detail attached to the row above rather than as
 another staff member. */
.data-table tr.subrow > td { padding-top: 0; padding-left: 10px; }
.session-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 8px 10px;
  border-left: 2px solid var(--line);
  background: var(--paper-2);
  border-radius: var(--radius);
}
.session-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  font-size: 13px;
}
/* The buttons sit inline with the text, so they must not stretch the row. */
.session-row form { margin: 0; }

.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 14px; }

/* A submit in a pager must not stretch, and this is the third place the same
 trap has been hit (after `.merge-choice` and `.setting-row`). Form inputs are
 `width: 100%` app-wide — right for the last button in a stacked form, wrong
 for a button sitting in a flex row, where it becomes 852px of Save. The
 settings pages (Display, Check-in rules) both looked like this. Measured on
 screen, not guessed. */
.pager .primary-button,
.pager input[type="submit"],
.pager .secondary-button,
.pager .ghost-button {
  flex: 0 0 auto;
  width: auto;
}

.badge.quiet { background: var(--paper-2); color: var(--muted); border-color: var(--line); }
.badge.warn { background: var(--amber-soft); color: var(--amber); border-color: var(--amber); }
/* Was missing while already in use — the staff page's "two-factor: On" badge has been rendering
 with base styling since it shipped. Found. */
.badge.good { background: var(--green-soft); color: var(--green); border-color: var(--green); }

.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
.quiet { color: var(--quiet); }
.muted { color: var(--muted); }
.small { font-size: 12px; }
p.muted.small { margin-top: 14px; }

/* The mockup drove tabs and rows with JS buttons; here they are anchors, so
 link styling has to be reset or everything renders blue and underlined. */
.tabbar { scrollbar-width: thin; }
.tabbar .tab,
.tabbar-row .tab {
  text-decoration: none;
  color: inherit;
  white-space: nowrap;
  /* The mockup's tabs were <button>s, which centre their own text. These are
 anchors, which do not — so the label sat at the top of the 46px row. */
  display: inline-flex;
  align-items: center;
}
.tabbar .tab:hover,
.tabbar-row .tab:hover { text-decoration: none; }

/* ── The "More" menu (D230) ──────────────────────────────────────────────────
 A native `<details>`, so it needs no javascript. The CSP blocks inline scripts
 (D184); a disclosure driven by a script is a menu that does not open. Everything
 below is presentation only — the open/closed state is the browser's. */
.tab-more { position: relative; flex: 0 0 auto; }

/* The default triangle is removed on both engines and replaced with our own caret,
 because the marker sits outside the 46px flex row and pushes the label off-centre. */
.tab-more > summary { list-style: none; cursor: pointer; }
.tab-more > summary::-webkit-details-marker { display: none; }
.tab-more > summary::marker { content: ""; }

.tab-more-caret { margin-left: 4px; font-size: 10px; }
.tab-more[open] .tab-more-caret { transform: rotate(180deg); display: inline-block; }

/* Absolute, so opening the menu does not push the page down or make the bar taller.
 A disclosure that reflows the profile under somebody's cursor is how you click the
 wrong thing at a counter. */
.tab-more-menu {
  position: absolute;
  top: 100%;
  right: 0;
  z-index: 30;
  min-width: 190px;
  padding: 6px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper);
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.12);
}

.tab-more-item {
  display: block;
  padding: 8px 10px;
  border-radius: var(--radius);
  color: var(--ink);
  font-size: 13px;
  font-weight: 600;
  text-decoration: none;
  white-space: nowrap;
}

.tab-more-item:hover { background: var(--paper-2); text-decoration: none; }
.tab-more-item.active { background: var(--brand-soft); color: var(--brand-dark); }

/* Rows are anchors here for the same reason. */
.customer-row {
  display: flex;
  align-items: center;
  gap: 10px;
  /* Tighter than it looks safe, on purpose (D323). The hover state is doing the work the
   whitespace was doing. Still ~34px tall with two lines of text — above the ~24px where a row
   gets fiddly with a mouse, which is the floor `.side-nav-item` already records. */
  padding: 3px 9px;
  border-radius: var(--radius);
  border: 1px solid transparent;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
.customer-row:hover { background: var(--paper-2); border-color: var(--line); }
.customer-row strong, .customer-row small { text-decoration: none; }

.customer-row-body { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.customer-row-body strong { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.customer-row-body small { color: var(--muted); font-size: 12px; }

.customer-list.wide .customer-row { padding: 10px 12px; }

/* The cap is gone, and `max-height` was the wrong tool for the job (D323). It was here so the last
 row could not be clipped by the nav below it — a real problem, but 280px is a guess about the
 window, and on a tall screen it left ~390px of blank between the last result and the nav while
 the list scrolled inside its own box.

 The frame is a flex column with `min-height: 0` and the list is `flex: 1` inside it, so the list
 already stops exactly where the frame does — the nav cannot clip it. The scroll happens at
 whatever height the window actually gives, instead of at a number somebody picked. */
.sidebar .customer-list { min-height: 0; }

.avatar.small { width: 32px; height: 32px; font-size: 12px; flex: 0 0 auto; }
.avatar img, .avatar.small img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }

.profile-header.simple { align-items: flex-start; }
.profile-header.simple h1 { margin: 0 0 4px; }

/* The subtitle sits under the title, not 15px below it (D434). Read against the sidebar's brand
 block beside it: two two-line headers whose second lines were 15px apart on screen. The 15px is the global `p` top margin, which collapses against the h1's 4px
 bottom and wins. The stylesheet already zeroes the last child's bottom margin two rules along
 for the same family of reason; this is the top half of that. */
.profile-header.simple .profile-title h1 + p { margin-top: 0; }

/* The home tiles' headings sit on one line whatever is beside them (D434). Measured: three
 headers were 50px tall and "Just checked in" was 63px, because it carries an "All" button
 (34px) that a 21px `<h2>` does not — so with `align-items: center` its heading was drawn
 **7px lower** than the other three, on a row of tiles read side by side.
 A `min-height` rather than shrinking the button: the button is a real touch target on a
 desk screen, and every tile that gains one later gets the alignment for free. */
.home-tile .panel-header { min-height: 63px; }

/* Disabled rail items and buttons: visible so the shape of the finished
 product is legible, but plainly not clickable. */
.side-nav-item[disabled], .primary-button[disabled], .secondary-button[disabled] {
  opacity: 0.42;
  cursor: not-allowed;
}

.flash {
  padding: 10px 16px;
  border-radius: var(--radius);
  margin-bottom: 14px;
  border: 1px solid var(--line);
  background: var(--paper-2);
}
.flash-alert { background: var(--red-soft); border-color: var(--red); color: var(--red); }
.flash-notice { background: var(--green-soft); border-color: var(--line-strong); }

/* The logout button_to wraps its button in a form. */
.sidebar form { margin: 0; }


/* ── Sign-in ────────────────────────────────────────────────────────────────
 Signed out, there is no app shell, so this page centres its own card. Uses
 the same custom properties as everything else, so it themes along with the
 rest of the staff UI rather than sitting outside it. */

.signin {
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: 24px;
}

.signin-card {
  width: 100%;
  max-width: 380px;
  background: var(--paper);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 30px 28px 24px;
}

.signin-brand {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 26px;
}

.signin-form { display: flex; flex-direction: column; gap: 16px; }
/* The passkey button beneath the emailed code, on the member log-in page (D858). */
.passkey-signin .passkey-or { text-align: center; margin: 14px 0 10px; }
.passkey-signin [data-passkey-target="message"]:empty { display: none; }

.signin-field { display: flex; flex-direction: column; gap: 6px; }
.signin-field span {
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: var(--muted);
}
.signin-field input {
  width: 100%;
  padding: 10px 12px;
  font: inherit;
  color: var(--ink);
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
/* A swatch, not a text box (D566). The rule above makes every field full width, which drew the
 colour picker as a 700px bar with no hint of what it was for. */
.signin-field input[type="color"] {
  width: 76px;
  height: 42px;
  padding: 3px;
  cursor: pointer;
}

/* Narrow, because a hex code is seven characters and the rule above would draw the box the
 width of the panel (D587). The gap above it is the seam between two forms — without it the
 label reads as a caption on the save button of the one before. */
.accent-hex { margin-top: 20px; }
.accent-hex input { max-width: 140px; }

.signin-field input:focus {
  outline: none;
  border-color: var(--line-strong);
  background: var(--paper);
}

/* `f.submit` renders <input type="submit">, which the mockup's .primary-button
 rules do not target on their own. */
.signin-form input[type="submit"] { margin-top: 6px; cursor: pointer; }

/* Small and pushed to the right — the rare path, not a rival to "Sign in" (D408, sized and aligned
 by ). No `a.` prefix needed: this is `.signin-card`, not `.card`, so the specificity trap that
 catches button-styled links does not apply. */
.signin-forgot {
  margin: 12px 0 0;
  text-align: right;
  font-size: 11px;
}

.signin-note {
  margin: 22px 0 0;
  padding-top: 16px;
  border-top: 1px solid var(--line);
  font-size: 12px;
  line-height: 1.5;
  color: var(--muted);
}


/* ── The public waiver form ─────────────────────────────────────────────────
 Signed out, so no app shell. Mobile-first on purpose: people fill this in on
 their own phone while standing at the counter (§4.5). */

.waiver-page {
  max-width: 640px;
  margin: 0 auto;
  padding: 28px 18px 64px;
}

.waiver-header { margin-bottom: 22px; }
.waiver-header h1 {
  margin: 0 0 6px;
  font-family: var(--font-heading);
  font-weight: var(--title-weight);
  font-size: 30px;
}

.waiver-form { display: flex; flex-direction: column; gap: 18px; }
.waiver-form .panel { display: flex; flex-direction: column; gap: 14px; }
.waiver-form h2 {
  margin: 0;
  font-family: var(--font-heading);
  font-weight: var(--heading-weight);
  font-size: 18px;
}
.waiver-form h3 { margin: 0 0 10px; font-size: 14px; font-weight: 700; }

/* Two-up on a desktop, stacked on a phone — thumbs beat columns. */
.field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 520px) { .field-pair { grid-template-columns: 1fr; } }

.signin-field small { font-weight: 400; text-transform: none; letter-spacing: 0; }

/* Checkbox rows: the box stays aligned with the first line of a long label
 rather than floating in the middle of a wrapped paragraph. */
.check-row {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 10px;
  align-items: start;
  line-height: 1.45;
}
.check-row input[type="checkbox"] { margin-top: 3px; width: 17px; height: 17px; }
.check-row input[type="radio"] { margin-top: 3px; width: 17px; height: 17px; }

/* The waiver's opening choice (D452). Two tappable rows rather than two words
 beside two dots — this is answered on a phone, held one-handed, by somebody
 who has just walked in, and the whole rest of the form hangs off it. */
.waiver-choice { display: grid; gap: 10px; }
.waiver-choice .check-row {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 14px 16px;
  cursor: pointer;
}
.waiver-choice .check-row:has(input:checked) {
  border-color: var(--brand);
  background: var(--brand-soft);
}

.sub-block {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  padding: 14px;
}
.sub-block[hidden] { display: none; }

.minor-block { margin-bottom: 12px; display: flex; flex-direction: column; gap: 12px; }
.minor-head { display: flex; justify-content: space-between; align-items: center; }
.minor-head h3 { margin: 0; }

/* The terms themselves. Scrollable so the signature stays reachable without a
 page of scrolling first, and rich text is rendered as written (D77). */
.waiver-terms {
  max-height: 340px;
  overflow-y: auto;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  line-height: 1.55;
}
.waiver-terms h1, .waiver-terms h2 { font-size: 17px; margin: 14px 0 6px; }
.waiver-terms p { margin: 0 0 10px; }
.waiver-terms:focus-visible { outline: 2px solid var(--line-strong); }

/* ── Alerts and notes (D55) ───────────────────────────────────────────────
 Level is meaning, not colour — info/good/warning/danger map onto the theme's
 own tokens, so an alert reads the same way in light, dark and unicorn rather
 than a stored colour fighting the palette. */
.notes-list { display: flex; flex-direction: column; gap: 8px; }

.note-item {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-left: 3px solid var(--line-strong);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.note-text {
  margin: 0;
  font-size: 14px;
  line-height: 1.5;
  white-space: pre-wrap;
}

.note-main { display: flex; align-items: flex-start; gap: 8px; min-width: 0; }
.note-actions { display: flex; gap: 6px; flex: none; }
.note-actions form { margin: 0; }
.note-icon { flex: none; margin-top: 2px; }

.note-meta {
  margin: 4px 0 0;
  font-size: 12px;
  color: var(--muted);
  display: flex;
  align-items: center;
  gap: 8px;
}

/* An alert interrupts; a note does not. Only alerts get the tinted background —
 a wall of colour would make the loud ones ordinary. */
/* Three levels since D448 — `info`/`warning`/`danger` became `neutral`/`bad`, so these selectors
 moved with them. A stale `.note-danger` here would silently stop colouring anything. */
.note-item.note-alert.note-bad { border-left-color: var(--red); background: var(--red-soft); }
.note-item.note-alert.note-good { border-left-color: var(--green); background: var(--green-soft); }
.note-item.note-alert.note-neutral { border-left-color: var(--blue); background: var(--blue-soft); }
.note-item.note-alert .note-text { font-weight: 600; }
.note-item.note-alert .icon { color: currentColor; }

/* Notes keep the plain background and only mark the spine. */
.note-item.note-bad { border-left-color: var(--red); }
.note-item.note-good { border-left-color: var(--green); }
.note-item.note-neutral { border-left-color: var(--blue); }

.note-item form { margin: 0; flex: none; }

.note-form {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 12px;
  margin-bottom: 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper-2);
}

.note-form .field-pair { display: flex; gap: 10px; }
.note-form .field-pair > * { flex: 1; }

/* Required. The browser's own `[hidden] { display: none }` is a bare
 attribute selector, so any `display` set on a class outranks it — the form
 above would sit open on every profile despite carrying `hidden`. */
.note-form[hidden] { display: none; }

/* ── Possible duplicate accounts (D78) ────────────────────────────────────
 Sits above the tabbar, so it reads as a fact about the account rather than
 the contents of one tab. Loud on purpose — it is an exception, and the whole
 reason it exists is that an event nobody reads was not enough. */
/* ── A retired or archived account (D131, D703) ───────────────────────────────
 Never to be read as a live one. The banner sits above the name, outside the scroller, so it never
 scrolls away; the frame goes round the whole page, so a tab left open from before a merge still
 says what it is at a glance. An outline rather than a border, so nothing in the layout moves. */
.account-state-banner {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 24px;
  background: var(--red-soft);
  border-bottom: 3px solid var(--red);
  color: var(--red);
}
.account-state-banner > .icon { flex: none; width: 22px; height: 22px; }
.account-state-body { flex: 1; min-width: 0; }
.account-state-title { margin: 0; font-size: 18px; font-weight: 800; }
.account-state-meta { margin: 2px 0 0; font-size: 13px; color: var(--ink); }
.account-state-actions { flex: none; }
.app-shell.is-retired .main,
.app-shell.is-archived .main { outline: 4px solid var(--red); outline-offset: -4px; }

.duplicate-banner {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 14px;
  margin: 0 0 14px;
  border: 1px solid var(--amber);
  border-left: 3px solid var(--amber);
  border-radius: var(--radius-lg);
  background: var(--amber-soft);
  color: var(--amber);
}

.duplicate-banner .icon { flex: none; margin-top: 2px; }
.duplicate-body { flex: 1; min-width: 0; }

.duplicate-title {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
}

.duplicate-title a { color: inherit; text-decoration: underline; }

.duplicate-meta {
  margin: 3px 0 0;
  font-size: 12px;
  opacity: 0.85;
}

.duplicate-banner form { margin: 0; flex: none; }

/* The acknowledgement labels on the waiver editor (D77). */
.ack-fields {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin: 16px 0 0;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper-2);
}

.ack-fields legend {
  padding: 0 6px;
  font-size: 13px;
  font-weight: 600;
}

.ack-fields .muted { margin: 0 0 4px; }

/* These hold customer-facing wording, so they should look like prose rather
 than code — textarea defaults to monospace. */
.ack-fields textarea {
  font-family: inherit;
  font-size: 14px;
  line-height: 1.5;
  resize: vertical;
}

/* The barcode reissue sits inline beside the number rather than among the
 primary actions — it is a rare repair, not something to invite (D48). */
.field-value .inline-form { display: inline; margin-left: 10px; }
.field-value .inline-form .ghost-button { padding: 2px 8px; font-size: 12px; }

/* ── Competition admin (§17) ─────────────────────────────────────────────
 A horizontal group of form fields. Not `.field-row`, which is already
 taken by the two-column label/value display rows on the profile tabs —
 reusing that name here would restyle every customer profile. */
.form-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 12px;
  align-items: end;
}

.form-row .signin-field select,
.form-row .signin-field input,
.signin-field select {
  width: 100%;
  padding: 10px 12px;
  font: inherit;
  color: var(--ink);
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* The checkbox sits beside its label rather than under it. */
.signin-field.inline {
  flex-direction: row;
  align-items: center;
  gap: 8px;
}

.signin-field.inline span {
  text-transform: none;
  font-size: 0.9rem;
  letter-spacing: 0;
}

/* A row of the rounds edit form, which is not the row of the public rounds list below —
 they shared the name `round-row`, and the later rule won both ways: the edit form lost
 `.form-row`'s column gap, and the first row of the public list drew a second line under the
 list's own border (D620). */
.round-edit-row {
  padding: 12px 0 4px;
  border-top: 1px solid var(--line);
}

.round-note {
  margin: 0 0 10px;
}

/* Live sessions on the staff list, folded away (D388).

 Modelled on `.detail-ids`, which is the same job in the payment drawer — but its own
 class, because that name is about identifiers and reusing it here would mislead the
 next person to read either one.
 Native `<details>`: no JavaScript, so it cannot ship dead under the CSP (D184). */
.session-fold > summary {
  cursor: pointer;
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 700;
  padding: 2px 0;
}

/* The count stays legible when closed and when open — this is a security screen, and
 the number of signed-in browsers is the fact somebody is scanning for. */
.session-fold[open] > summary { margin-bottom: 8px; }

/* The buttons that finish a form (D383).

 `.form-actions` was used by two forms and defined nowhere, so both rendered their
 submit button at full panel width with Cancel stranded on the line below — the
 concession-type form and the SMS template one. A class in use with no definition looks
 deliberate in the markup and is invisible until somebody opens the page, which is how
 `.is-archived` shipped with retired badges looking live.
 Left-aligned rather than right: every other form in this app puts its primary button
 under the first field, and the `pager` shape these were imitating needs an inline style
 override to do the same. */
.form-actions {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 16px;
}

/* The submit inside one is a button, not a field — `.signin-field input` does not
 reach it, but a bare `input[type=submit]` in a panel can still inherit a stretched
 width from elsewhere. Sized to its own label, like every other button. */
.form-actions input[type="submit"],
.form-actions button {
  width: auto;
  flex: 0 0 auto;
}

/* `.signin-field input` sets width:100%, which is right for a text box and
 absurd for a checkbox — it stretched the box across the panel and shoved its
 label against the far edge. Checkboxes size to themselves. */
.signin-field.inline input[type="checkbox"] {
  width: auto;
  flex: 0 0 auto;
  margin: 0;
}

/* Excludes the hint, and the exclusion is load-bearing. `.signin-field.inline span` is two classes
 plus an element, so it beats `.info-hint` on specificity and stretched the 18px badge across the
 whole row — the enormous (i) reported. The label still takes the slack; the badge is not a
 label. */
.signin-field.inline span:not(.info-hint) {
  flex: 1 1 auto;
}

/* In a row that has a hint, the label stops taking the slack — otherwise it grows and shoves the
 (i) to the far right, reading as a separate control rather than a footnote on the words beside
 it. Scoped with `:has` so the eight other `.signin-field.inline` rows across admin keep the
 stretched label they were built with. */
.signin-field.inline:has(.info-hint) span:not(.info-hint) {
  flex: 0 1 auto;
}

/* Competition problem editing — a whole round's grades in one form. */
.round-problems {
  margin: 0 0 14px;
}

.round-problems summary {
  cursor: pointer;
  padding: 6px 0;
  color: var(--muted);
  font-size: 0.86rem;
  font-weight: 700;
}

.data-table.compact td,
.data-table.compact th {
  padding: 4px 8px 4px 0;
}

.cell-input {
  width: 100%;
  max-width: 180px;
  padding: 6px 8px;
  font: inherit;
  color: var(--ink);
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* ── Till products: what a product grants, and switching one off (D547) ────

 The select appears only once its row is ticked, and a ticked row is tinted, so the few products
 that grant something stand out from the many that only record a sale. `:has()` rather than a
 script: the page works the same with none. */
.till-products-grants { display: flex; align-items: center; gap: 8px; }
.till-products-grants select { display: none; max-width: 240px; }
.till-products-grants:has(.till-products-grants-toggle:checked) select { display: inline-block; }

.data-table tr:has(.till-products-grants-toggle:checked) td { background: var(--brand-soft); }

/* A disabled product is still listed so it can be switched back on; everything but its two
 checkbox columns is dimmed. */
.data-table tr.is-disabled td:nth-child(-n + 3) { opacity: 0.5; }

/* The rules JSON editor — monospaced, because it is code. */
.code-area {
  width: 100%;
  padding: 10px 12px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.86rem;
  line-height: 1.45;
  color: var(--ink);
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* ── The admin index ─────────────────────────────────────────────────────
 A list of links to full pages, not a grid of tiles editing settings in
 place. Each row is a whole clickable block, because a link that is only
 the title is a small target on a touchscreen at the front desk. */
/* Three columns, not a list. Fourteen full-width rows used about a third of a 1280px screen and
 turned the index into a scroll.

 `auto-fill` with a floor, not `repeat(3, 1fr)`. Three is what it should be at the desk;
 hard-coding three would squeeze the same cards into unreadable slivers on a laptop and leave the
 count wrong forever on a wider screen. The floor is what actually decides it. */
.admin-links {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(265px, 1fr));
  gap: 10px;
  list-style: none;
  margin: 0;
  padding: 0;
}

/* Each entry is its own card now that they sit side by side. A shared top
 border was what separated stacked rows; between columns it would draw a line
 across unrelated neighbours. */
.admin-link {
  position: relative;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper);
}

.admin-link > a {
  display: grid;
  gap: 3px;
  /* Right padding clears the arrow, which is absolutely positioned. */
  padding: 12px 30px 12px 14px;
  height: 100%;
  align-content: start;
  color: inherit;
  text-decoration: none;
}

.admin-link:hover,
.admin-link:focus-within {
  border-color: var(--line-strong);
  background: var(--paper-2);
}

.admin-link > a:focus-visible { outline: none; }

.admin-link-head {
  display: flex;
  align-items: center;
  gap: 8px;
}

.admin-link-head strong {
  font-size: 1rem;
}

/* An arrow, so a row reads as somewhere to go rather than something to
 read. The list is navigation; the settings live on the pages behind it. */
.admin-link > a::after {
  content: "→";
  position: absolute;
  top: 12px;
  right: 12px;
  color: var(--muted);
}

.admin-link-detail {
  margin-top: 1px;
}

.admin-link .line.warn {
  margin-top: 4px;
}

/* The one card that needs attention must still find the eye. In a stacked
 list an amber line was enough, because the eye ran down a single column; in a
 grid of fourteen cards it is one small orange mark among many, so the card
 itself is tinted. Same reasoning as the home page's flagged tile. */
.admin-link.has-warning,
.admin-link.has-warning:hover,
.admin-link.has-warning:focus-within {
  border-color: var(--amber);
  background: var(--amber-soft);
}

/* The louder half of the same idea (FR-36). Amber is "soon"; red is "already
 broken". Both variables existed — this needed no new colour, only a second
 level, which is why it is worth having rather than overloading the amber. */
.admin-link.has-critical,
.admin-link.has-critical:hover,
.admin-link.has-critical:focus-within {
  border-color: var(--red);
  background: var(--red-soft);
}

.admin-link .line.bad {
  margin-top: 4px;
  color: var(--red);
}

/* ── Customer-portal modules ─────────────────────────────────────────────
 A row per section, with the switch on the left so the eye can run down
 the column and see what is on without reading every name. */
.module-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.module-row {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 12px 0;
}

.module-row + .module-row {
  border-top: 1px solid var(--line);
}

/* Dimmed, not hidden. An owner should be able to see what Leopard will
 eventually offer and when — a roadmap is useful; a mystery gap is not. */
.module-row.unavailable {
  opacity: 0.62;
}

.module-toggle {
  flex: 0 0 92px;
  display: flex;
  align-items: center;
  min-height: 24px;
}

.module-toggle input[type="checkbox"] {
  width: 20px;
  height: 20px;
  margin: 0;
  accent-color: var(--brand-dark);
}

.module-detail {
  display: grid;
  gap: 2px;
}

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


/* ── The database inspector ──────────────────────────────────────────────
 The table scrolls inside its own box. Some of these have thirty columns,
 and letting the page scroll sideways makes the whole admin unusable at that
 width — the sidebar slides away and nothing lines up. */
.table-scroll {
  overflow-x: auto;
  margin: 0 -14px;
  padding: 0 14px;
}

.table-picker {
  display: flex;
  align-items: end;
  gap: 10px;
}

.table-picker .signin-field {
  flex: 1 1 auto;
  max-width: 380px;
}

/* The submit is a fallback for browsers without requestSubmit-on-change, and
 must not stretch. `flex: 0 0 auto` alone is not enough — the basis resolves
 to the element's own `width`, which a wider button rule had already set to
 100%, so it filled the row and squeezed the select down to 40px. */
.table-picker input[type="submit"] {
  flex: 0 0 auto;
  width: auto;
  min-width: 90px;
}

.table-picker .signin-field {
  min-width: 240px;
}

.data-table.grid {
  font-size: 0.82rem;
  white-space: nowrap;
}

.data-table.grid th,
.data-table.grid td {
  padding: 5px 10px 5px 0;
  vertical-align: top;
  font-variant-numeric: tabular-nums;
}

.data-table.grid th {
  position: sticky;
  top: 0;
  background: var(--paper);
  white-space: nowrap;
}

.data-table.grid td {
  max-width: 320px;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── The waiver approval queue (D83) ─────────────────────────────────────
 A row per waiver, with the actions on the right so the eye runs down the
 names rather than the buttons. */
.approval-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  padding: 12px 0;
}

.approval-row + .approval-row {
  border-top: 1px solid var(--line);
}

.approval-detail {
  display: grid;
  gap: 3px;
  min-width: 0;
}

.approval-actions {
  flex: 0 0 auto;
  display: flex;
  gap: 8px;
}

/* ── The public competition page (§17.7) ─────────────────────────────────────
 self-contained, and not portal.css. The obvious move was to load the
 portal's stylesheet here — it already has a leaderboard — but portal.css
 sets :root variables and a body background unscoped, so loading it into the
 public layout would restyle the waiver form too. These are the few classes
 that page needs, on top of the panels and tables application.css already has.

 Read on a phone, standing at the wall, in a gym, so: big touch targets, big
 numbers, and nothing that depends on hover. */
.comp-eyebrow {
  margin: 0 0 4px;
  color: var(--muted);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.full-width {
  display: block;
  width: 100%;
  text-align: center;
}

/* A button that is also `.full-width` (D611).
 The buttons have no vertical padding — they are `inline-flex` with `min-height` and centre
 their label with `align-items` — and the `display: block` above drops that, so the label sits
 at the top of the box. An `<input>` centres its own value and looked fine; a `<a>` or
 `<button>` did not, which is why it showed on exactly one page.
 The portal's own `.full-width` sets width alone, so its buttons were never affected — that
 sheet and this one are separate documents (D557/D409). */
.primary-button.full-width,
.secondary-button.full-width,
.ghost-button.full-width,
.danger-button.full-width {
  display: flex;
}

.rule-list {
  display: grid;
  gap: 10px;
  margin: 0;
  padding-left: 20px;
  line-height: 1.5;
}

/* The gym's own words, set apart from the rules the format generates so it is
 clear which is house rule and which is scoring. */
.rule-note {
  margin-top: 14px;
  padding: 12px 14px;
  border-left: 3px solid var(--brand, #f1b434);
  border-radius: 0 var(--radius) var(--radius) 0;
  background: var(--bg);
  line-height: 1.5;
}

.rule-note p { margin: 0 0 8px; }
.rule-note p:last-child { margin-bottom: 0; }

/* The gym's own words on a competition page (D605).
 `.rule-note` is a pull quote — a bar down the left and a tinted panel — which suited the
 one-line aside it was written for and turns a page of event copy into a giant quotation.
 Prose spacing only; the panel around it is already the separation. */
.comp-copy { margin-top: 14px; line-height: 1.5; }
.comp-copy p { margin: 0 0 10px; }
.comp-copy p:last-child { margin-bottom: 0; }
.comp-copy ul, .comp-copy ol { margin: 0 0 10px; padding-left: 22px; }
.comp-copy li { margin-bottom: 4px; }

/* Headings on the public competition page carry no bottom margin, because the reset clears
 every heading and each block below one resets its own. Declared once for whatever follows a
 heading rather than block by block: three of the four were flush against their heading after
 the first was fixed on its own (D620). Set on the sibling, so a block that already asks for
 this gap is not given it twice. */
.comp-public .panel-body > h2 + * {
  margin-top: 10px;
}

/* Switching which leaderboard is showing (D606). Buttons rather than a select, because it is
 the common action on this page and a select hides the other boards behind a tap. They wrap on
 a phone rather than scrolling sideways — this page is read standing at the wall. */
.comp-categories {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  /* The heading above carries no bottom margin, so without this the buttons sit flush
     against "Leaderboard" and read as part of it (D617). Measured at 0px. */
  margin: 10px 0 14px;
}

.category-button {
  padding: 6px 12px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface);
  color: inherit;
  font-size: 14px;
  line-height: 1.3;
  text-decoration: none;
}

/* `a.` prefixed: `.card a` and `.panel a` are more specific than a bare class, so without it
 these render underlined and link-coloured inside a card. */
a.category-button { text-decoration: none; }
a.category-button.is-on,
.category-button.is-on {
  border-color: var(--brand, #6B7F66);
  background: var(--brand, #6B7F66);
  color: var(--on-brand, #fff);
}

.comp-board {
  display: grid;
  gap: 0;
  margin: 0;
  padding: 0;
  overflow: hidden;
  list-style: none;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.comp-board-row {
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr) auto;
  align-items: center;
  gap: 10px;
  min-height: 46px;
  padding: 7px 12px;
}

.comp-board-row + .comp-board-row { border-top: 1px solid var(--line); }
.comp-board-row:nth-child(odd) { background: var(--bg); }

/* The podium reads at a glance from arm's length. */
.comp-board-row:nth-child(-n + 3) .comp-place { color: var(--brand-dark, #8d5b00); }

.comp-place {
  color: var(--muted);
  font-size: 15px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
}

.comp-entrant {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.comp-entrant strong {
  overflow: hidden;
  font-weight: 650;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.comp-entrant small { font-size: 11px; }

.comp-score {
  font-size: 17px;
  font-weight: 750;
  font-variant-numeric: tabular-nums;
  text-align: right;
  white-space: nowrap;
}

.round-list {
  display: grid;
  gap: 0;
  margin: 0;
  padding: 0;
  overflow: hidden;
  list-style: none;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.round-row {
  display: grid;
  gap: 3px;
  padding: 10px 12px;
}

.round-row + .round-row { border-top: 1px solid var(--line); }
.round-row.is-open { background: var(--brand-soft, #fff3cf); }

.round-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.round-detail { font-size: 12px; }

/* ── Two-factor (S8) ─────────────────────────────────────────────────────────
 The QR is scanned off this screen by a phone held at arm's length. Big enough
 to catch reliably, small enough that the code field under it is on the same
 screen — a setup flow that needs scrolling between the code and the box for
 the code is one people abandon. */
.qr-wrap {
  display: flex;
  justify-content: center;
  margin: 16px 0;
}

.totp-qr {
  width: 220px;
  max-width: 100%;
  height: auto;
  border-radius: var(--radius);
  /* Shape-rendering matters: without it the browser antialiases the module
 edges and some scanners lose the contrast they need. */
  shape-rendering: crispEdges;
}

.totp-secret {
  display: inline-block;
  padding: 6px 10px;
  border-radius: var(--radius);
  background: var(--surface-3);
  font-size: 14px;
  letter-spacing: 0.08em;
}

.row-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: flex-end;
}

/* ── Role permissions (D88) ──────────────────────────────────────────────────
 A grid of permissions × roles. The hard part is not the checkboxes, it is
 that the row label has to stay readable while four columns compete for the
 width — so the description wraps and the role columns stay narrow. */
.perm-table-wrap {
  /* The table scrolls inside its panel rather than pushing the page sideways.
 Four roles today; a fifth would otherwise break the whole layout. */
  overflow-x: auto;
  margin: 0 -14px;
  padding: 0 14px;
}

.perm-table {
  min-width: 620px;
}

.perm-table td:first-child,
.perm-table th:first-child {
  min-width: 240px;
}

.perm-role,
.perm-cell {
  width: 92px;
  text-align: center;
  white-space: nowrap;
}

.perm-cell .switch {
  display: inline-flex;
  margin: 0;
}

/* A permission Leopard has not built: present so the shape is visible, greyed
 so nobody waits for it to do something. */
.perm-table tr.is-unbuilt td {
  opacity: 0.55;
}

/* Visible to screen readers, invisible on screen. Each checkbox in the grid
 carries its own label — "Competitions for Manager" — because a bare tick box
 in a table cell is unlabelled to anyone not looking at the column heading,
 and that includes everybody using a screen reader. Without this rule those
 labels render as literal text next to every box. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* A permanent tick, not a ticked checkbox. Owners hold every permission as a
 fact rather than a setting, and this renders it as one: grey, not blue, and
 with no hit target — so it reads as "yes, and that isn't yours to change"
 rather than as a control that happens to be on. */
.perm-fixed {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  cursor: default;
}

.perm-fixed .icon {
  width: 17px;
  height: 17px;
}

/* The kiosk idle screen — the state it is in for most of the day. */
.kiosk-idle-panel { display: grid; place-items: center; gap: 6px; }

/* The instruction is the biggest thing on the screen (D119). It was competing with a 210px QR code
 and losing. Size is what says which action is primary; wording cannot outrank a large graphic. */
.kiosk-idle-title {
  font-size: clamp(2.1rem, 7vw, 4.2rem);
  font-weight: 800;
  letter-spacing: -0.02em;
  margin: 0;
}
.kiosk-idle-sub {
  font-size: clamp(1.5rem, 4.4vw, 2.6rem);
  font-weight: 600;
  color: var(--ink);
  margin: 0 0 40px;
}

/* The fallback, and it should look like one. Separated by a rule so it reads
 as a footnote to the instruction above rather than a second, equal option. */
.kiosk-qr-block {
  display: grid;
  place-items: center;
  gap: 10px;
  padding-top: 22px;
  border-top: 1px solid var(--line);
  max-width: 22rem;
}
.kiosk-qr-block .kiosk-qr-caption { opacity: 0.75; }

/* Below its caption (D119), and big enough to scan from a phone held at arm's length (D922). Capped by the
 screen's height as well as its width, so a landscape tablet still fits the whole screen. Full contrast:
 faded squares are slower for a phone camera to read. */
.kiosk-qr { width: clamp(160px, min(20vw, 30vh), 240px); height: auto; border-radius: var(--radius); }

.kiosk-qr-caption {
  margin: 0;
  font-size: clamp(0.8rem, 1.3vw, 0.95rem);
  color: var(--muted);
  line-height: 1.4;
  text-align: center;
}

/* ── The advanced customer lookup (§4.5) ────────────────────────────────── */
.search-box.wide { width: 100%; }
.lookup-filters { margin-top: 14px; }
.lookup-filters > summary {
  cursor: pointer;
  font-weight: 600;
  padding: 6px 0;
  display: flex;
  align-items: center;
  gap: 8px;
}
/* A count on the summary, so a collapsed panel still says it is filtering. */
.filter-count {
  display: inline-grid;
  place-items: center;
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  border-radius: 999px;
  background: var(--amber);
  color: #fff;
  font-size: 0.75rem;
}
.filter-grid {
  display: grid;
  /* 250px, not 210 — a <select> holding "An active membership" overflowed the
 narrower column and painted across the fieldset beside it. */
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: 16px;
  margin: 12px 0 16px;
}
.filter-grid fieldset {
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: 10px 12px 12px;
  margin: 0;
  /* Grid items default to min-content width, so a long option keeps the
 column from ever shrinking and the contents spill. */
  min-width: 0;
}
/* Scoped to the real controls, never a bare `input`. A blanket
 `input { width: 100% }` in this file once stretched a radio button to 268px;
 the `.inline` rows below hold checkboxes and are deliberately excluded. */
.filter-grid label:not(.inline) > select,
.filter-grid label:not(.inline) > input {
  width: 100%;
  min-width: 0;
}
.filter-grid legend { font-weight: 600; font-size: 0.85rem; padding: 0 4px; }
.filter-grid label {
  display: grid;
  grid-template-columns: 4.5rem 1fr;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}
.filter-grid label > span { font-size: 0.85rem; color: var(--muted); }
/* The checkbox rows are not the two-column grid — `input { width: 100% }`
 elsewhere in this file once stretched a radio to 268px, and a full-width
 checkbox is the same bug. */
.filter-grid label.inline {
  display: flex;
  grid-template-columns: none;
  align-items: center;
  gap: 8px;
}
.filter-grid label.inline input { width: auto; }
/* A row shaped like the labelled selects beside it, for a control that cannot be a <label>. */
.filter-grid .filter-field {
  display: grid;
  grid-template-columns: 4.5rem 1fr;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}
.filter-grid .filter-field > span { font-size: 0.85rem; color: var(--muted); }

/* A dropdown of checkboxes that reads like a select when closed (D920). The list floats over the
 tiles below it; `.panel:has(.multi-select)` keeps the panel from clipping it (D475). */
.multi-select { position: relative; min-width: 0; }
.multi-select > summary {
  list-style: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  padding: 2px 6px;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--paper);
  font-size: 0.85rem;
  white-space: nowrap;
  overflow: hidden;
}
.multi-select > summary::-webkit-details-marker { display: none; }
.multi-select > summary span { overflow: hidden; text-overflow: ellipsis; }
.multi-select > summary::after { content: "▾"; color: var(--muted); flex: none; }
.multi-select-menu {
  position: absolute;
  z-index: 20;
  top: calc(100% + 4px);
  left: 0;
  width: max(100%, 18rem);
  max-height: 20rem;
  overflow-y: auto;
  padding: 6px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper);
  box-shadow: 0 8px 24px rgba(22, 26, 29, 0.14);
}
.multi-select-menu label.inline { margin: 0; padding: 3px 0; }
.filter-membership-states { margin-top: 6px; }
.filter-membership-states label.inline { margin-bottom: 2px; }
.data-table td.num, .data-table th.num { text-align: right; font-variant-numeric: tabular-nums; }
/* The class is `.panel-header`, not `.panel-head`. Four screens built on used the latter, which is
 styled nowhere — so their headers rendered as unstyled stacked blocks. Caught by inspecting a
 computed style (`display: block` on something that should have been flex) rather than by
 looking, which would have read as "a bit cramped". This rule keeps the results header's sort
 control on the same line as its count. */
.panel-header .inline {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0;
}

/* The filter tile: one closed line most of the day. */
.lookup-filters > summary {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 16px;
  cursor: pointer;
  font-weight: 600;
  list-style: none;
}
.lookup-filters > summary::-webkit-details-marker { display: none; }
/* A caret that turns, so the row reads as openable without a label saying so. */
.lookup-filters > summary::after {
  content: "›";
  margin-left: auto;
  font-size: 1.3rem;
  line-height: 1;
  color: var(--muted);
  transition: transform 0.15s ease;
}
.lookup-filters[open] > summary::after { transform: rotate(90deg); }
.lookup-filters > summary:hover { background: var(--surface-2); }
.filter-summary-label { display: inline-flex; align-items: center; gap: 8px; }
/* A slot the results frame redraws (D933): its count and words lay out as the summary's own. */
.filter-summary-words { display: contents; }
/* The active filters in words, readable while the panel is shut — a panel
 that is both closed and filtering is how a partial number gets reported as
 the whole gym. Truncates rather than wrapping the row to two lines. */
.filter-summary {
  font-weight: 400;
  font-size: 0.9rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.filter-body { padding: 0 16px 16px; border-top: 1px solid var(--line); }
.filter-body .filter-grid { margin-top: 16px; }

/* ── The staff home page ───────────────────────────────────────────────────
 Tiles, not a report. Somewhere to land after a customer profile, readable in
 a glance from across the desk. */
/* A wrapping row rather than a grid of equal columns (D1033). Four tiles share one row on the
 desk's laptop, about 1,160px of page beside the rail — at 300px columns only three did, and the
 fourth sat below the fold. The two tables take a larger share than the two figures, which need
 less room. A tile goes to the next line only once it would be narrower than 220px. */
.home-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: flex-start;
}
.home-tile { margin: 0; flex: 1 1 0; min-width: min(220px, 100%); }
.home-tile.is-wide { flex-grow: 1.4; }
.home-tile .data-table td.home-time { white-space: nowrap; }
/* The count is deliberately smaller than the verdict. A raw number is
 unreadable at 9am and means opposite things on a Tuesday and a Saturday;
 "busier than a usual Sunday morning" is the thing somebody wants. */
.home-figure {
  margin: 0;
  font-size: 2.6rem;
  font-weight: 700;
  line-height: 1;
}
.home-figure-label { margin: 2px 0 14px; color: var(--muted); }

/* The reading and its arrow on one baseline (D284). layout puts the number, what it counts, and
 how it compares on a single line — three stacked paragraphs is the shape that made the old tile
 answer only one question. `baseline` so a 48px figure and a 14px label sit on the same line
 rather than centring against each other. */
.home-reading {
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
}
.home-reading .home-figure { margin: 0; }
.home-reading .home-figure-label { margin: 0; }

/* Colour and the arrow carry the direction — one man in twelve cannot rely on the
 first, which is the same reason the kiosk shows a tick as well as a green wash. */
/* The arrow and the figure it is measured against travel together, right-aligned as
 one block so the two readings line up down the tile (D288). */
.home-trend-group {
  margin: 0 0 0 auto;
  display: flex;
  align-items: baseline;
  gap: 8px;
  white-space: nowrap;
}
.home-trend {
  font-weight: 700;
  font-size: 0.95rem;
  white-space: nowrap;
}
/* Quiet on purpose — it is the number the percentage is of, not a second headline. */
.home-trend-typical {
  color: var(--muted);
  font-size: 0.85rem;
  font-weight: 500;
}
.home-trend.is-up { color: var(--green, #15803d); }
.home-trend.is-down { color: var(--red, #b91c1c); }
/* Neither green nor red — "the same as usual" is not a good or a bad outcome, and
 colouring it either way is the tile making a claim it does not have (D287). */
.home-trend.is-level { color: var(--muted); font-weight: 600; }
.home-verdict { margin: 0 0 4px; font-weight: 600; }
.home-verdict.is-busier { color: var(--amber); }
.home-verdict.is-quieter { color: var(--muted); }
.home-verdict.is-unknown { color: var(--muted); font-weight: 400; }
/* The one tile with work in it announces itself. */
.home-tile.is-flagged { border-color: var(--amber); }
.home-list { list-style: none; margin: 0; padding: 0; }
.home-list li {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid var(--line);
}
.home-list li:last-child { border-bottom: 0; }
.home-list-name { font-weight: 600; }
.home-list p { margin: 2px 0 0; }
.home-list-actions { display: flex; gap: 6px; flex: none; }
/* `button_to` renders a whole form, so the row's actions need the form to stop
 behaving like a block or the Remove button drops onto its own line (FR-42). */
.home-list-actions form { margin: 0; display: inline-flex; }

/* One tier's machine-readable perks, grouped under its name (FR-42). */
.tier-perks + .tier-perks { margin-top: 18px; }
.tier-perks .small-label { margin-bottom: 4px; }

/* Clear the quick search. An icon, because the rail is the narrowest column on the screen and the
 word would eat the field. */
.search-box { position: relative; }
.search-clear {
  display: grid;
  place-items: center;
  flex: none;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}
.search-clear:hover { background: var(--surface-2); color: var(--text); }
/* `display: grid` above beats the `hidden` attribute — the ua stylesheet's
 `[hidden] { display: none }` loses to any explicit display, so the button
 showed on an empty box despite being marked hidden. Any element in this file
 with both a display rule and a `hidden` toggle needs this pairing. */
.search-clear[hidden] { display: none; }
.search-clear .icon { width: 15px; height: 15px; }
/* A box with its own clear button hides the browser's (D686). Chrome and Edge draw a second ✕ in
 every `type="search"` field; the other search boxes have no clear button but that one, so it
 stays there. */
.search-box:has(.search-clear) input[type="search"]::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
}

/* ── Memberships & passes tab ──────────────────────────────────────────────
 The headline answers the question staff actually walked over with: can this
 person climb right now? Everything else is supporting detail. */
.entitlement-headline { margin-bottom: 16px; }
.entitlement-headline.is-yes { border-color: var(--green); }
.entitlement-headline.is-no { border-color: var(--amber); background: var(--amber-soft); }
.entitlement-verdict { margin: 0 0 2px; font-size: 1.2rem; font-weight: 700; }
.entitlement-headline.is-yes .entitlement-verdict { color: var(--green); }
.entitlement-headline.is-no .entitlement-verdict { color: var(--amber); }
.entitlement-row {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: 16px;
  padding: 10px 0;
  border-bottom: 1px solid var(--line);
}
.entitlement-row:last-of-type { border-bottom: 0; }
.entitlement-name { margin: 0 0 2px; font-weight: 600; }
/* It does not shrink, so it is capped instead: a sentence here — a cancellation, who pays — wraps in place under the
 price, rather than widening the column past the tile, whose panel clips it (D720, D783). */
.entitlement-figures { text-align: right; flex: none; max-width: 12rem; overflow-wrap: anywhere; margin-left: auto; }
/* Where the left column's longest word and the figures cannot sit side by side — a narrow screen, a wider font — the
 figures go under it, still on the right, rather than past the tile (D1017). */
.entitlement-row:has(> .entitlement-figures) { flex-wrap: wrap; row-gap: 4px; }
.entitlement-row:has(> .entitlement-figures) > :first-child { flex: 1 1 0; }

/* The answer to a question somebody is asking right now (D281) — "when's my last
 day?", "how many cheap payments left?". It sat inside a `.muted small` paragraph
 after the status word, which is where staff could not find it fast enough. Readable
 weight, its own line, and a rule to separate it from the biography above. */
.entitlement-term {
  margin: 6px 0 0;
  padding-top: 6px;
  border-top: 1px solid var(--line, #e5e7eb);
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--ink, #1f2937);
}
.entitlement-term + .entitlement-term { border-top: 0; padding-top: 0; margin-top: 2px; }
.entitlement-row p, .entitlement-figures p { margin: 2px 0 0; }
/* Names in the left column break inside a long one rather than widening it past the figures (D936). */
.entitlement-covers { overflow-wrap: anywhere; }
/* History is kept and collapsed — staff-only, occasionally needed, never the
 point of the panel (D91/D93). */
.history-details { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }
.history-details > summary { cursor: pointer; color: var(--muted); font-size: 0.9rem; }
.history-details .data-table { margin-top: 10px; }
.shared-passes { margin-top: 14px; border-top: 1px solid var(--line); padding-top: 10px; }

/* ── Check-in history report (§14, D105) ─────────────────────────────────────
 Bars are divs, not a charting library: CSP is strict (S36) so a CDN script is
 not an option, and the shape is all this report needs to convey. */
.period-switch {
  display: flex;
  gap: 4px;
}

.period-switch .active {
  background: var(--surface-2, rgba(127, 127, 127, 0.12));
  font-weight: 600;
}

.checkin-chart {
  list-style: none;
  margin: 0 0 12px;
  padding: 0;
  display: grid;
  gap: 3px;
}

.chart-row {
  display: grid;
  /* Label · bar · count · change — the bar takes whatever is left */
  grid-template-columns: 5.5rem 1fr 3.5rem 4.5rem;
  align-items: center;
  gap: 10px;
}

.chart-label,
.chart-value {
  font-variant-numeric: tabular-nums;
  font-size: 0.85rem;
}

.chart-value {
  text-align: right;
  font-weight: 600;
}

.chart-track {
  background: var(--surface-2, rgba(127, 127, 127, 0.1));
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.chart-bar {
  display: block;
  height: 16px;
  border-radius: var(--radius-sm);
  background: linear-gradient(90deg, #e87812, #ffcb52);
}

/* The period still running is drawn hatched rather than solid, so a
 half-finished month cannot be misread as a fall. */
.is-partial .chart-bar {
  background: repeating-linear-gradient(
    45deg, rgba(232, 120, 18, 0.55) 0 6px, rgba(232, 120, 18, 0.2) 6px 12px
  );
}

.chart-change {
  font-size: 0.8rem;
}

@media (max-width: 700px) {
  .chart-row { grid-template-columns: 4.5rem 1fr 3rem; }
  .chart-change { display: none; }
}

/* ── One-line settings ───────────────────────────────────────────────────────
 A setting that is one value should be one line. As a full panel — heading, stacked label,
 two-sentence rationale, button — it reads as more important than the table beside it, and as
 settings are added the page grows enormous.

 Named and reusable on purpose. Every future one-number or one-toggle setting should use this
 rather than growing another panel. */
/* Panels stacked in a `.content` column need air between them or they read as one surface —
 Applies to every stacked panel, not just this pair, so a third setting inherits the spacing. */
.content > .panel + .panel,
/* And a panel followed by a form of panels, or a form followed by a panel — the featured membership
 page is one, and its two tiles touched because neither rule matched (D551). */
.content > .panel + form,
.content > form + .panel,
/* And the same panels inside a form (D389). The rule above uses the direct-child combinator, so a
 page that wraps its tiles in a `<form>` — the membership type editor — matched nothing and its
 tiles sat flush against each other. 18px is what they have; this is that number, not a new one. */
.content form > .panel + .panel {
  margin-top: 18px;
}

.setting-row {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 14px 16px;
}

/* Everything groups at the left, reading as one phrase: "Minutes before a shared desk forgets who
 is acting (i) [5] [Save]". The first attempt let the label take the slack, which pushed the
 field and button to the far edge of a wide panel. A setting is a sentence, not a table row. */
.setting-row label {
  flex: 0 1 auto;
  font-size: 0.9rem;
}

.setting-input {
  /* `flex: 0 0 auto` — inputs are flex items here and would otherwise be
 stretched or squeezed by the label beside them. */
  flex: 0 0 auto;
  width: 5rem;
  text-align: right;
}

/* The button must not stretch. `.primary-button` is full-width by design
 everywhere else (it is normally the last thing in a stacked form), which
 inside a flex row made it 822px and pushed itself onto its own line — the
 exact opposite of one line. Measured, not guessed. */
.setting-row .primary-button,
.setting-row input[type="submit"] {
  flex: 0 0 auto;
  width: auto;
}

/* The rationale that used to be a paragraph. `cursor: help` and a focus ring
 because it is reachable by keyboard — a hint only a mouse can read is a hint
 half the people never see. */
.info-hint {
  position: relative;
  /* `inline-grid`, never `grid` (D248). A bare `grid` is block-level, so a hint placed inside a
   label's own <span> — the format asked for: "checkbox, descriptor, (i)" — dropped onto a line
   of its own underneath the descriptor, which is the exact layout the rule exists to prevent.
   `inline-grid` keeps `place-items: center` centring the glyph while the badge stays on the
   line. */
  display: inline-grid;
  place-items: center;
  vertical-align: -3px;
  /* Pinned against its container. `.signin-field` is a column flex, whose default `align-items:
   stretch` blew the 18px circle into a full-width ellipse `flex`/`align-self` here mean the
   badge keeps its shape wherever it is dropped, rather than each new container needing a rule of
   its own. */
  flex: 0 0 auto;
  align-self: center;
  width: 18px;
  min-width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1px solid var(--line);
  color: var(--muted, #6b7280);
  font-size: 0.72rem;
  font-style: italic;
  font-weight: 600;
  cursor: help;
  user-select: none;
}

.info-hint:focus-visible {
  outline: 2px solid var(--accent, #e87812);
  outline-offset: 2px;
}

/* `[hidden]` loses to an explicit `display`, and this codebase has been bitten
 by that before. `.info-hint { display: grid }` overrode the attribute, so the
 two-factor row showed both hints at once — the one for a person and the one
 for a desk, contradicting each other, on an account that is only one of them.
 Any rule that sets `display` on something toggled by `hidden` needs this. */
.info-hint[hidden] {
  display: none;
}

/* A CSS tooltip, because the native `title` did not work — reported it never appeared. `title`
 waits about a second, is easy to miss on an 18px target, and is suppressed outright in some
 browsers. This shows instantly, on hover and on keyboard focus, with no JavaScript so the CSP
 (S36) stays untouched. */
.info-hint::after {
  content: attr(data-hint);
  position: absolute;
  bottom: calc(100% + 8px);
  /* The comment and the code disagreed, and the comment was right. It said "anchored left rather
   than centred", but `left: 0` with `translateX(-50%)` pulls the bubble half its own width to
   the left of the icon — up to 160px at the 20rem cap. A hint in the first column of a table
   therefore rendered under the left rail and was unreadable. Left-anchored can only ever
   overflow to the right, where there is panel padding and page margin to absorb it; leftward
   there is a fixed rail. */
  left: 0;
  right: auto;
  transform: none;
  z-index: 30;
  width: max-content;
  max-width: 20rem;
  padding: 8px 10px;
  border-radius: var(--radius);
  background: var(--ink, #161a1d);
  color: var(--paper, #fff);
  font-size: 0.78rem;
  font-style: normal;
  font-weight: 400;
  line-height: 1.45;
  text-align: left;
  opacity: 0;
  visibility: hidden;
  transition: opacity 120ms ease;
}

/* A panel never clips its own tooltip (D475). `.panel` is `overflow: hidden` so its rounded corners cut
 the content inside, which would truncate a hint bubble mid-sentence. `:has()` stops any panel that
 contains a hint from clipping, so no view has to remember an opt-in class.

 `overflow: visible` is safe here because nothing full-bleed relies on the panel's own clipping: tables
 scroll inside `.table-scroll`, which carries its own overflow. Raising `z-index` would not help: an
 `overflow: hidden` ancestor clips a descendant however it is stacked.
 `test/system/info_hint_test.rb` catches the next container that reintroduces it. */
.panel:has(.info-hint),
.panel:has(.info-hint) .panel-body,
.panel:has(.multi-select),
.panel:has(.multi-select) .panel-body {
  overflow: visible;
}

/* Kept so the explicit class still works where a view says it, and harmless where it does not. */
.panel.has-hints {
  overflow: visible;
}

/* Opens downward for a hint near the top of a panel. The bubble is anchored
 above by default, which is right in the middle of a form and wrong on the
 first row — there the text runs off the top of the panel and the reader gets
 the last line only. Seen on Check-in rules, whose single setting is the first
 row. CSS cannot measure the space available, so the page says which way it
 goes; `.below` is the whole fix. */
.info-hint.below::after {
  bottom: auto;
  top: calc(100% + 8px);
}

/* Anchored to the right, for a hint in the last column of a wide table. Left-anchored, its bubble
 reaches past the page's right edge, and even while hidden that widens the page enough to scroll
 sideways (D547). */
.info-hint.end::after {
  left: auto;
  right: 0;
}

/* Plain `:focus` as well as `:focus-visible` — clicking the hint should show
 it too, not only tabbing to it. `:focus-visible` alone means a mouse user who
 clicks gets nothing, which is the same complaint the native title drew. */
.info-hint:hover::after,
.info-hint:focus::after,
.info-hint:focus-visible::after {
  opacity: 1;
  visibility: visible;
}

/* Undoes `.panel { overflow: hidden }`, which would clip the tooltip against
 the panel's own edge — the bubble sits above the row and outside its box. */
.setting-panel {
  overflow: visible;
}

.primary-button.compact,
.ghost-button.compact {
  padding: 6px 12px;
  font-size: 0.85rem;
}

/* ── The PIN dialog (D63, §15.8) ─────────────────────────────────────────────
 Asked at the moment of the action rather than standing in the corner, so
 nobody claims a window they were not about to use. */
.pin-dialog {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0;
  background: var(--paper, #fff);
  color: inherit;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
}

.pin-dialog::backdrop {
  background: rgba(17, 16, 14, 0.45);
}

.pin-dialog-body {
  display: grid;
  gap: 10px;
  width: min(22rem, 90vw);
  padding: 20px;
}

.pin-dialog h2 {
  margin: 0;
  font-size: 1.05rem;
}

/* Large and spaced: this is typed at a counter, often on a touch screen,
 with somebody waiting. */
.pin-dialog-input {
  width: 100%;
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  font-size: 1.4rem;
  letter-spacing: 0.5em;
  text-align: center;
}

.pin-dialog-error:empty {
  display: none;
}

.pin-dialog-error {
  margin: 0;
  color: var(--danger, #b3261e);
  font-size: 0.85rem;
}

.pin-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

/* ── Confirmations (D672) ────────────────────────────────────────────────────
 Every `data-turbo-confirm`, drawn by `confirm_dialog.js` in place of the browser's own box.
 Same surface as the PIN dialog. `pre-line` keeps any line break written into a message. */
.confirm-dialog {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 0;
  background: var(--paper, #fff);
  color: inherit;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
}

.confirm-dialog::backdrop {
  background: rgba(17, 16, 14, 0.45);
}

.confirm-dialog-body {
  display: grid;
  gap: 16px;
  width: min(26rem, 90vw);
  padding: 20px;
}

.confirm-dialog-message {
  margin: 0;
  white-space: pre-line;
  line-height: 1.45;
}

.confirm-dialog-actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}

/* Buttons are `width: 100%` app-wide; in this row each is its own size. */
.confirm-dialog-actions button {
  flex: 0 0 auto;
  width: auto;
}

/* A checkbox setting is one row: box, label, hint — the three on the staff form each had a
 paragraph under them and read as one wall. The row itself is already defined above; this only
 adds the breathing room between consecutive settings. */
.signin-field.inline {
  margin: 6px 0;
}

/* Check-in history headline (D114). Deliberately left-aligned and unboxed — it is the first thing
 on the tab, so it needs size rather than decoration. */
.checkin-headline {
  padding-bottom: 6px;
}

.checkin-total-eyebrow {
  margin: 0 0 2px;
  color: var(--muted);
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.checkin-total {
  margin: 0;
  font-size: 2.4rem;
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--ink);
}

.checkin-total-label {
  margin: 2px 0 0;
  color: var(--muted);
  font-size: 0.86rem;
}

/* `display: block` so the entitlement sits under the badge rather than beside
 it — the pass name is the answer to "which one", and reading it inline with
 "Pass" produces "Pass Day Pass". */
.checkin-entitlement {
  display: block;
  margin-top: 2px;
}

/* The check-in detail dialog (D116). */
.detail-dialog {
  max-width: 30rem;
  /* The containing block for the X below, which is positioned against the dialog rather
 than against the scrolling body so that it cannot move (D413). */
  position: relative;
}

/* Anchored near the top rather than centred (D442).

 A `<dialog>` opened with `showModal` is centred, so growing it by 242px moves everything already
 on screen up by 121px — the heading, the details, the summary you just clicked. That movement is
 what reads as "something more than a drop down box", not the extra height. Anchored at a fixed
 offset, opening a panel only extends the dialog downwards and nothing that was on screen moves
 at all.

 Measured before choosing this: closed → tallest-open is 465→707 on the pass drawer, 461→765 on
 emergency contacts, 336→518 on proficiencies. **One shared `min-height` cannot serve that
 range** — 765px would leave the proficiency drawer two-thirds empty, and anything smaller puts
 the Void button below a scroll, which is the exact fault D413 fixed. Not moving is the fix;
 reserving space is not.

 `margin-bottom: auto` is what stops it re-centring — with `margin: auto` on both axes the
 browser splits the free space, which is the movement itself. */
.detail-dialog {
  margin-top: 7vh;
  margin-bottom: auto;
}

/* Scrolls inside itself instead of running off the screen (D413). There was no height cap at all,
 so a payment with a long payload grew the dialog past the viewport and took its own Close button
 with it — the exact failure `.raw-payload`'s own comment warns about, one level up. 86vh − the
 7vh top offset, or the cap and the offset together push the bottom of a tall dialog off the
 screen and take the Close button with it again (D442). */
.detail-dialog .pin-dialog-body {
  min-height: 19rem;
  max-height: 86vh;
  overflow-y: auto;
}

/* Reserved space, or a long heading runs underneath the X. */
.detail-dialog h2 {
  padding-right: 2.2rem;
}

.dialog-close {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}

.dialog-close:hover {
  border-color: var(--line);
  background: var(--paper-2);
  color: var(--ink);
}

/* `auto 1fr` rather than two fixed columns — a UUID is 36 characters and a
 label is one word, so an even split wastes half the dialog and wraps the id. */
.detail-grid {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 4px 14px;
  margin: 12px 0 0;
  font-size: 0.88rem;
}

.detail-grid dt {
  color: var(--muted);
  font-weight: 600;
  white-space: nowrap;
}

.detail-grid dd {
  margin: 0;
  color: var(--ink);
}

/* `break-all` on the ids. A UUID has no spaces, so without it the grid column
 refuses to shrink and the dialog grows past the viewport on a phone. */
.detail-grid.mono dd {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.78rem;
  word-break: break-all;
}

/* Collapsed by default. The ids matter when something is wrong and are noise
 the rest of the time; open by default would make every reader scroll past
 two UUIDs to reach the Close button. */
.detail-ids {
  margin-top: 14px;
  border-top: 1px solid var(--line);
  padding-top: 10px;
}

.detail-ids summary {
  cursor: pointer;
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 700;
}

/* The refund form's fields, one even gap apart: the questions it asks after where the money goes sat on the field
 above them (D836, D850). Its notes keep their own margin off, so a note sits close to the field it is about. */
.refund-box form { display: grid; gap: 12px; margin-top: 10px; }
.refund-box form > p.muted.small { margin: -6px 0 0; }
.refund-box form > input[type="submit"] { justify-self: start; }

/* Looks like a link only on hover. Every row in the table would otherwise be
 a wall of blue, which reads as navigation rather than as a table of visits. */
.checkin-when {
  color: inherit;
  text-decoration: none;
  border-bottom: 1px dotted var(--line);
}

.checkin-when:hover {
  color: var(--brand-ink, var(--ink));
  border-bottom-color: currentColor;
}

/* Entering a customer into the comp from their profile (D117). `width: auto` on the submit for the
 same reason as `.pager` — form inputs are `width: 100%` app-wide, which turns "Enter " into a
 full-width slab. */
.enter-comp-form {
  margin-top: 12px;
  max-width: 22rem;
}

.enter-comp-form input[type="submit"] {
  width: auto;
  margin-top: 10px;
}

/* ── The staff climb card (§17.8, D118) ──────────────────────────────────────


 the height was the buttons, not the table. `.compact` is `min-height: 34px`, which with 9px of
 cell padding above and below made a 52px row — twenty of those is a screen and a half for one
 week. The buttons here are 22px, and the padding 2px, which is the bulk of the reduction.
 Measured on screen, not guessed. */
.climb-table td {
  padding: 1px 8px 1px 0;
  vertical-align: middle;
  border-bottom: 0;
}

/* A hairline every fifth row instead of every row. Twenty full-width rules in
 a 400px block is a grid, not a table — but with no rules at all the eye loses
 its place halfway down. Fives match how the problems are numbered. */
.climb-table tr:nth-child(5n) td {
  border-bottom: 1px solid var(--line);
}

/* Fixed width so the numbers form a column rather than jittering between
 one and two digits. */
.climb-number {
  width: 2.4rem;
  color: var(--muted);
}

.climb-actions {
  display: flex;
  gap: 4px;
  margin: 0;
}

/* `width: auto` because form inputs are `width: 100%` app-wide — the same trap
 as `.pager` and `.setting-row`. Without it each button fills the cell and the
 pair stack. */
.climb-button {
  width: auto;
  min-height: 20px;
  padding: 0 7px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--muted);
  font-size: 0.74rem;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.climb-button:hover {
  border-color: var(--ink);
  color: var(--ink);
}

/* The recorded result is filled, not merely outlined. Scanning twenty rows for
 "what did they get" has to work from colour alone — reading two similar
 outlines per row is the thing that makes a long card tiring. */
.climb-button.is-on {
  border-color: var(--brand);
  background: var(--brand);
  color: #211408;
}

.climb-button.is-remove {
  min-width: 20px;
  padding: 0 6px;
  font-size: 0.9rem;
}

.climb-button.is-remove:hover {
  border-color: var(--red, #c0392b);
  color: var(--red, #c0392b);
}

.climb-points {
  width: 3rem;
  font-variant-numeric: tabular-nums;
  font-weight: 700;
}

.climb-meta {
  white-space: nowrap;
}

.climb-remove {
  width: 2rem;
  text-align: right;
}

/* Present for the screen reader on the remove button, which is an × to
 everyone else. A bare × is "times" read aloud, with no object. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The history tables must scroll, not clip (D120). Evening the columns to
 1:1 narrowed the membership panel, and a four-column table inside it lost its
 last column off the edge — silently, which is the worst version. These are
 forensic tables; a value you cannot see is a value you will conclude is
 missing.

 The scroller is a div, and it has to be. The first version put
 `display: block` on the <table> itself, which does make it scroll — and also
 stops it being a table for layout purposes, so the rows collapse to their
 content width and `width: 100%` is quietly ignored — five columns squeezed into the left third
 of a wide panel with the rest of it empty. A wrapper scrolls without taking the table's layout
 away from it. */
.history-details .table-scroll {
  overflow-x: auto;
  max-width: 100%;
}

/* `min-width` so it still fills the panel when the columns are narrow, and
 grows past it when they are not — which is what the wrapper is there to
 absorb. */
.history-details .data-table {
  width: 100%;
  min-width: 100%;
}

/* `white-space: nowrap` on the cells so a date does not wrap to three lines
 in a narrow column — scrolling is the intended escape, not wrapping.

 The first column is left to take the slack, so the extra width goes to the
 product name rather than being shared out into five columns of padding. */
.history-details .data-table td,
.history-details .data-table th {
  white-space: nowrap;
}

.history-details .data-table th:first-child,
.history-details .data-table td:first-child {
  width: 100%;
}

/* No box of its own. This element exists purely to give one Stimulus
 controller scope over the header button and the drawer; `display: contents`
 means the header stays a direct child of whatever laid it out. */
.sale-scope { display: contents; }

/* ── The till (§13.2, D128) ───────────────────────────────────────────────────

 A modal taking the screen, not A 25% column.

 A native <dialog> opened with showModal gives the backdrop, Escape and focus trapping for free.

 Sized as a share of the screen, never in pixels (D544). A 1180 × 760 cap left a large monitor more
 than half empty around a till that could have been twice the size. The width stops at twice the
 height, so an ultra-wide screen gets a till rather than a strip. */
.till-dialog {
  width: min(92vw, 176vh);
  height: 88vh;
  max-width: none;
  max-height: none;
  padding: 0;
  border: 0;
  border-radius: var(--radius-lg);
  background: var(--paper);
  color: var(--ink);
  overflow: hidden;
}

.till-dialog::backdrop { background: rgba(0, 0, 0, 0.55); }

.till-frame { display: flex; flex-direction: column; height: 100%; }

.till-header {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--line);
}

.till-header h2 { margin: 1px 0 0; font-size: 1.15rem; }
/* Keeps the note beside the title rather than under it, which would cost a
 whole row of tile height. */
.till-header-note { margin: 0; flex: 1 1 auto; }

/* Pinned to the top right, whatever else is in the header. It was only ever pushed there by the
 note's `flex: 1 1 auto` — and that note renders on the walk-up sale only, so the customer sale
 left the X stranded beside the title. The walk-up looked correct by accident, which is why this
 went unnoticed. A close button's position must not depend on whether an optional paragraph
 happens to be present. */
.till-header .icon-button { margin-left: auto; }

/* ── Three columns, as the Register has ─────────────────────────────────── */
/* The rail and the basket grow with the till between a floor and a ceiling, so a large screen does
 not give all of its extra room to the tiles (D544). The rail is wide enough for a two-word page
 name on one line at a desk-sized screen (D549). */
.till-body {
  flex: 1 1 auto;
  display: grid;
  grid-template-columns: clamp(14rem, 16%, 17rem) 1fr clamp(19rem, 24%, 26rem);
  min-height: 0;
}

/* The rail is navigation, and Frequent carries 90%+ of transactions (D124) —
 so it is first, selected, and never more than one tap away. */
.till-rail {
  display: flex;
  flex-direction: column;
  border-right: 1px solid var(--line);
  overflow-y: auto;
  padding: 6px 0;
}

/* Sized with the screen, like the till around it (D544, D546). */
.till-rail-item {
  padding: 11px 16px;
  text-align: left;
  font-size: clamp(1rem, 1vw, 1.25rem);
  font-weight: 600;
  color: var(--muted);
  background: none;
  border: 0;
  border-left: 3px solid transparent;
  cursor: pointer;
}

.till-rail-item:hover:not(:disabled) { color: var(--ink); background: var(--paper-2); }

.till-rail-item.is-active {
  color: var(--ink);
  background: var(--paper-2);
  border-left-color: var(--brand);
}

/* Named but not built (D70) — saying so beats a rail that silently lacks them. */
.till-rail-item:disabled { opacity: 0.4; cursor: not-allowed; }

.till-grid-pane { overflow-y: auto; padding: 10px; min-height: 0; }
/* The frame the pages arrive in (D598) takes no box of its own, so each page sizes against the pane
 exactly as it did when it was drawn straight into it. */
.till-grid-pane > turbo-frame { display: contents; }

/* `auto-fill`, so the floating grid's column count follows the till's width (D544) and degrades on a
 narrow screen instead of overflowing. */
/* Every tile the same size (D495). `min-height` is only a floor and a grid row sizes to its tallest
 item, so one picture or one long name made its whole row taller than the next. `grid-auto-rows` fixes
 the row, and the name clamps rather than pushing. */
.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
  grid-auto-rows: 8.6rem;
  gap: 6px;
}

/* The picture takes whatever height the words leave, so it grows and shrinks with the tile — and
 the tile with the screen (D549). The words always keep the same room — two lines of name, one
 line under it — so every picture on a page is the same size and they line up (D550). */
.product-tile {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto auto;
  gap: 0;
  align-content: center;
  padding: 5px 6px;
  /* No `min-height`: the row owns the height now, and a floor here would let a tile grow past it
   and reintroduce the ragged rows this replaced. `hidden` is the backstop for a name so long
   the clamp still cannot hold it. */
  overflow: hidden;
  text-align: center;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  cursor: pointer;
  transition: border-color 0.12s ease, background 0.12s ease;
}

.product-tile:hover:not(:disabled) { border-color: var(--brand); background: var(--paper); }

/* Only ever disabled for something genuinely unbuyable — never for stock
 (D126). A count is a hint, not a gate. */
.product-tile:disabled { opacity: 0.5; cursor: not-allowed; }

/* Two lines, then an ellipsis (D495), so a long name cannot make its tile taller. The ellipsis is only
 a guess at a shorter name; a per-tile label (FR-48) is how somebody sets what it should read. */
.product-tile-name {
  font-weight: 700;
  font-size: 0.82rem;
  line-height: 1.2;
  /* Room for two lines whether the name needs them or not (D550). */
  height: 2.4em;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* One line, kept even when empty (D550): "Not mapped yet", "4 options", or both. */
.product-tile-meta {
  display: block;
  height: 1.25em;
  margin-top: 2px;
  overflow: hidden;
  color: var(--muted);
  font-size: 0.68rem;
  line-height: 1.25;
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* The tile's own row height is fixed, and the picture only fills what is left of it with
 `contain`, so a slow or broken Square image cannot reflow the grid mid-service. */
.product-tile-image {
  display: grid;
  place-items: center;
  min-height: 0;
  margin-bottom: 2px;
  /* Measured as a box, so the picture can be the largest square that fits in it (D550). */
  container-type: size;
}

/* A square, cut to fill, whatever shape Square's image is (D550). */
.product-tile-image img {
  width: min(100cqw, 100cqh);
  height: min(100cqw, 100cqh);
  object-fit: cover;
}

/* A tile with no picture centres its name rather than keeping an empty space above it (D549), and
 drops the room it would otherwise keep — there is no picture for it to line up. */
.product-tile:not(:has(.product-tile-image img)) { grid-template-rows: none; }
.product-tile:not(:has(.product-tile-image img)) .product-tile-image { display: none; }
.product-tile:not(:has(.product-tile-image img)) .product-tile-name { height: auto; }
.product-tile:not(:has(.product-tile-image img)) .product-tile-meta:empty { display: none; }

/* ── The cart ─────────────────────────────────────────────────────────────

 its own column, so the total and the pay button are always on screen (D127). */
.till-cart {
  display: flex;
  flex-direction: column;
  border-left: 1px solid var(--line);
  padding: 10px 14px;
  min-height: 0;
}

/* Only the lines scroll; the foot is pinned. */
.basket-list { flex: 1 1 auto; display: grid; gap: 2px; align-content: start; margin: 4px 0; overflow-y: auto; min-height: 0; }
.till-cart-foot { flex: 0 0 auto; }

.basket-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 1px 0; }
.basket-row .row-title { margin: 0; font-weight: 600; font-size: 0.84rem; line-height: 1.25; }
.basket-row .row-meta { margin: 0; line-height: 1.2; }

.basket-total {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin: 6px 0 8px;
  padding-top: 6px;
  border-top: 1px solid var(--line);
  font-size: 1.05rem;
}

.basket-total strong { font-size: 1.35rem; font-weight: 800; }

.till-note { margin-top: 6px; font-size: 0.72rem; }
.till-empty { padding: 40px 0; }

/* One column on a narrow screen — the rail becomes a scrolling strip rather
 than eating half the width. */
@media (max-width: 820px) {
  .till-dialog { width: 100vw; height: 100vh; border-radius: 0; }
  .till-body { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
  .till-rail { flex-direction: row; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--line); }
  .till-rail-item { border-left: 0; border-bottom: 3px solid transparent; white-space: nowrap; }
  .till-rail-item.is-active { border-left-color: transparent; border-bottom-color: var(--brand); }
  .till-cart { border-left: 0; border-top: 1px solid var(--line); max-height: 40vh; }
}

/* ── The variation picker (D125) ─────────────────────────────────────────────

 One tile per item; the sizes live here.
 Most of the till, and the same size whatever the product (D667): a landscape counter screen has room
 for four columns of tiles large enough to read at a glance, and a picker that keeps its size keeps
 its Cancel button where it was last time. Sized by the screen, like the till (D544). */
.variation-dialog {
  width: min(60vw, 115vh);
  height: 70vh;
  max-width: none;
  max-height: none;
}

/* The body is a column: title, prompt, the tiles taking what is left, and Cancel at the foot. */
.variation-dialog .pin-dialog-body {
  width: auto;
  height: 100%;
  box-sizing: border-box;
  grid-template-rows: auto auto minmax(0, 1fr) auto;
  padding: 24px;
}

.variation-dialog h2 { font-size: 1.3rem; }

/* Four across and four down, so a full size run is on screen at once; a longer one scrolls, and so
 does a screen too short to give each tile its floor. */
.variation-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  grid-auto-rows: max(5.5rem, calc((100% - 36px) / 4));
  align-content: start;
  gap: 12px;
  margin: 4px 0;
  overflow-y: auto;
}

/* The size at the top; the price and the count along the foot, so neither sits on the name. */
.variation-tile {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-rows: 1fr auto;
  grid-template-areas: "name name" "price stock";
  gap: 6px 10px;
  padding: 14px 16px 12px;
  text-align: left;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  cursor: pointer;
}

.variation-tile:hover:not(:disabled) {
  border-color: var(--brand);
  background: var(--paper);
}

/* Dimmed, not disabled (D126). A size at or below zero is still sellable —
 the count may simply be wrong, and refusing a real sale is worse than a
 negative number. The styling says "check this", not "you cannot". Only an
 unpriced variation is genuinely unbuyable. */
.variation-tile:disabled { cursor: not-allowed; opacity: 0.4; }
.variation-tile.is-out { border-style: dashed; }

/* The count below zero is the thing staff most need to see — it is the
 evidence of a mis-keyed size, so it is coloured rather than hidden. */
.variation-stock.is-low { color: var(--red, #c0392b); }

.variation-name { grid-area: name; align-self: start; font-weight: 700; font-size: 1.1rem; line-height: 1.25; }
.variation-price { grid-area: price; align-self: end; color: var(--muted); font-size: 0.95rem; }
.variation-tile.is-out .variation-price { color: var(--red, #c0392b); font-weight: 700; }

.variation-stock {
  grid-area: stock;
  align-self: end;
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--muted);
}

/* A phone has the width for two, and the whole screen to put them on. */
@media (max-width: 820px) {
  .variation-dialog { width: 94vw; height: 84vh; }
  .variation-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* ══ The top band ═══════════════════════════════════════════════════════════
 One strip across both columns: the rail header and the main header must land on the same pixel.
 At 72px against 77-98px the two bottom borders miss each other by a handful of pixels on every
 screen — close enough to read as a mistake rather than a design, which is exactly the kind of
 thing that makes an app feel unfinished without anybody being able to say why.

 `min-height`, never `height`. A page with a taller title grows instead of
 clipping its own heading; the band stops aligning on that page, which is the
 right way for this to fail. */
:root { --top-band: 60px; }

.brand,
.profile-header {
  box-sizing: border-box;
  min-height: var(--top-band);
}

/* 15 pixels of nothing. The header subtitle is a `<p>`, so it carried the global paragraph
 `margin-bottom` — 15px of empty space under the last line of a header, pushing the band taller
 for no reason anybody could see. It was a quarter of the height asked to get back. */
.profile-header .profile-title > :last-child { margin-bottom: 0; }

/* `.sidebar .brand-mark`, not `.brand-mark`, and that is not style pedantry.
 `portal.css` is being served on staff pages too (see the note below) and sets
 `.brand-mark` to 48px at the same specificity but later in the cascade, so the
 plain selector here loses. 48px was the real floor under the header: no amount
 of trimming padding could shrink a band that had a 48px chip inside it. */
.sidebar .brand-mark {
  width: 36px;
  height: 36px;
}

/* Known and not fixed here: `portal.css` leaks onto the staff pages.
 `stylesheet_link_tag :app` passes a symbol, which Propshaft reads as "link
 every stylesheet in the load path" — so every staff page loads
 `application.css`, `actiontext.css` and `portal.css`, in that order. The
 customer portal's sheet therefore wins every selector the two share.

 It is 29 selectors, and they are not obscure ones: `:root` (the whole theme
 token set), `html`, `h1`, `h2`, `h3`, `button`, `.primary-button`, `.badge`,
 `.icon`, `.flash`, `.brand-mark`. The staff h1 renders at portal.css's
 1.65rem, not this file's.

 Not unpicked as part of a header tweak. Every one of those 29 resolves
 differently the moment the leak is closed, which is a change to the whole
 staff UI at once and needs to be looked at page by page — see FR-6. Until
 then, anything in this file that must beat portal.css needs a selector one
 step more specific, as above. */

/* Centred now that the band has a fixed floor. `simple` used flex-start
 because the header's height was whatever its content made it, so top-aligning
 was the only way two unequal columns lined up. */
.profile-header.simple { align-items: center; }

/* The light theme's colour sits in the header (D130). The tint runs across both halves, the main header
 and the rail's brand block, so the top of the app reads as one bar; a wash on the main header alone
 would draw a line down the middle of that band.
 Light theme only: the default theme already carries the gym's colour, and a dark theme does not want
 a wash. */
:root[data-theme="light"] .brand,
:root[data-theme="light"] .profile-header {
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--brand) 12%, var(--paper)),
    color-mix(in srgb, var(--brand) 4%, var(--paper))
  );
  border-bottom-color: color-mix(in srgb, var(--brand) 30%, var(--line));
}

/* The hover has to sit on top of the wash, or the brand block flashes back to
 flat grey when the pointer crosses it. */
:root[data-theme="light"] .brand:hover {
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--brand) 20%, var(--paper)),
    color-mix(in srgb, var(--brand) 10%, var(--paper))
  );
}

/* ══ Membership signup (§5.4, §6.4) ═════════════════════════════════════════
 A whole row is the target, not just the radio — this is used at a counter
 with somebody waiting, and a 13px circle is a poor thing to aim at.

 Do not also PUT `.list-row` on these. It reads like the obvious companion
 class and it is defined only in `portal.css` — which is served on staff pages
 by accident (FR-6). The first draft used both and the portal's
 `justify-content: space-between` threw the radio to the middle and the label
 to the right edge. Anything a staff page needs belongs in this file. */
.selectable-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 4px;
  border-bottom: 1px solid var(--line);
  cursor: pointer;
}

.selectable-row:last-child { border-bottom: 0; }
.selectable-row:hover { background: var(--paper-2); }
/* The radio has to be told its own size, and finding out why cost twenty
 minutes. `portal.css` carries a bare `input { width: 100% }` for its own form
 fields, and it is served on staff pages by accident (FR-6) — so the radio
 stretched to 731px, pushing its label to the far right and making the row look
 like a deliberate space-between layout. Nothing in this file was wrong; the
 winning rule was in a stylesheet this page should never have loaded.

 `flex: 0 0 auto` as well as `width`, so a flex parent cannot grow it back. */
.selectable-row input[type="radio"],
.selectable-row input[type="checkbox"] {
  flex: 0 0 auto;
  width: auto;
  margin-top: 3px;
}
.selectable-row .row-title { margin: 0; font-weight: 600; }
/* A list of choices under a field's heading keeps ordinary text: `.signin-field span` makes every span in a field a
 capitalised heading, which is why radios were kept out of fields (D528). The refund questions need the heading (D850). */
.signin-field .selectable-row span {
  font-size: inherit;
  font-weight: inherit;
  letter-spacing: normal;
  text-transform: none;
  color: inherit;
}
.selectable-row .small-text { margin: 2px 0 0; }

/* Named-but-not-built rows stay legible rather than vanishing (D70). */
.selectable-row.is-disabled { cursor: default; opacity: 0.55; }
.selectable-row.is-disabled:hover { background: transparent; }

/* A membership this customer cannot take (D554): greyed where it always sits, with its reason at
 full strength, because the reason is the part somebody has to read. */
.selectable-row.is-unavailable { cursor: default; }
.selectable-row.is-unavailable:hover { background: transparent; }
.selectable-row.is-unavailable .type-choice-mark,
.selectable-row.is-unavailable .row-title,
.selectable-row.is-unavailable .small-text:not(.unavailable-reason) { opacity: 0.55; }
.unavailable-reason { color: var(--ink); }

/* Square draws the card fields in its own iframe, so this element is a
 container with a reserved height — without one the panel jumps as the sdk
 mounts, under somebody's finger. */
.card-entry { padding: 12px 0 4px; }
#square-card { min-height: 90px; }

.field-error { margin: 6px 0 0; color: var(--red); font-size: 0.85rem; }

/* Panel headers that carry an action as well as a count. */
.panel-actions { display: flex; align-items: center; gap: 10px; }


/* ── The membership options dialog (D138) ─────────────────────────────────────
 An options button that opens a dialog of membership actions — freeze, cancel, transfer.

 NOT `.action-menu` — that name is already taken, twenty pages up, by an
 absolutely-positioned dropdown (`position: absolute; display: none`). Reusing
 it left this list out of flow, so the dialog sized itself to the two lines of
 header it could still see and clipped 350px of buttons. Nothing was wrong in
 this block; the winning rule was one written for something else entirely —
 the same shape of bug as FR-6, in the same stylesheet.

 The items are <button>s, so they inherit the browser's button styling and
 need it stripped — `background: none` and `text-align: left` are both
 load-bearing. The disabled ones are <div>s carrying the same class, because a
 disabled button is not focusable and the reason it is disabled is the useful
 part. */
/* Real spacing between them, because they are separate choices rather than rows of one table. */
.membership-actions { display: grid; gap: 8px; margin-top: 8px; }

/* `display: grid` on a class beats `[hidden]`, and this is the second time
 in one screen that a rule written for something else won. The browser's own
 `[hidden] { display: none }` comes from the ua sheet, so any author rule that
 sets `display` outranks it — the Stimulus controller set `.hidden = true`, the
 element ignored it, and the menu sat on top of the form it had just opened.
 Every container in this dialog that carries a `display` needs the partner
 rule, not just the one that was noticed. */
.membership-actions[hidden],
.membership-action[hidden],
.pin-dialog-actions[hidden] { display: none; }

/* A border and a surface, not a hairline underneath. These were `border: 0` with a bottom rule,
 which reads as a list of table rows rather than a set of things you can press. Each one is now
 bounded, so the target is visible before you hover it, which matters on a screen used at a
 counter with somebody waiting. */
.membership-action {
  display: grid;
  gap: 2px;
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}

/* The one row that goes to another page. `a.`-prefixed like the other link rows, so a link
 rule from whatever surrounds the dialog cannot restyle it. */
a.membership-action { color: inherit; text-decoration: none; }

.membership-action:hover {
  border-color: var(--line-strong, var(--brand));
  background: var(--paper);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}

/* A visible focus ring — these are reachable by keyboard and the border alone does
 not say which one is selected. */
.membership-action:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 1px;
}
.membership-action .row-title { font-weight: 600; }

/* Named-but-not-built stays legible rather than vanishing, the same choice
 the till made for Library and Keypad (D128) — a staff member who cannot find
 "transfer" spends a minute looking, one who sees it greyed knows in a second. */
/* The unbuilt ones must not look pressable — same border so the row still reads as
 a card, but flat and dimmed, and the hover does nothing. Named rather than absent
 (D128), and obviously inert. */
.membership-action.is-disabled {
  cursor: default;
  opacity: 0.55;
  background: transparent;
  border-style: dashed;
}

.membership-action.is-disabled:hover {
  background: transparent;
  border-color: var(--line);
  box-shadow: none;
}

/* `button_to` wraps its button in a form, which is a block element in the
 grid — the button inside it has to fill it or the row collapses to the
 button's own width. */
.membership-actions form { margin: 0; }
.membership-actions form .membership-action { display: grid; }

.action-panel { margin-top: 8px; }

/* The alert being acknowledged, quoted above the choices (D1031). */
.acknowledge-dialog .acknowledge-quote {
  margin: 10px 0 4px;
  padding: 10px 12px;
  border-left: 3px solid var(--line-strong, var(--line));
  background: var(--paper);
}

/* The dialog's spacing (D555). The title and the line under it read as one heading, with one
 distance to whatever follows. Inside each form, one gap between every field, hint and the buttons:
 the elements had relied on their own margins, and between two fields there were none, so each
 label sat on the input above it. */
.manage-dialog .pin-dialog-body > h2 + p { margin: -6px 0 0; }
.manage-dialog .pin-dialog-body > .flash { margin: 8px 0 0; }
.action-panel > form { display: grid; gap: 14px; }
.manage-dialog .action-panel > form > * { margin: 0; }
/* A hint sits with the field it is about, not halfway to the next one. */
.manage-dialog .action-panel > form > .field-hint { margin-top: -8px; }
.manage-dialog .action-panel > form > .pin-dialog-actions { margin-top: 6px; }

/* The Options button sits under the figures, right-aligned with them. */
.entitlement-actions { margin-top: 8px !important; }

/* `.row-actions` already existed — this block used to redefine it, which is
 the third name collision in one day (`.action-menu`, `.membership-actions`,
 this). The existing rule is correct and already flexes; what was wrong was the
 column being too narrow, so `flex-wrap: wrap` stacked the buttons and doubled
 every row's height.

 The lesson is not "pick better names", it is **grep first**. A stylesheet
 with 179 display-setting classes cannot be held in anybody's head. */
/* `.row-actions` wraps by design elsewhere; in a table it must not, or every
 row doubles in height. Opt in per use rather than changing the shared rule. */
.row-actions.no-wrap { flex-wrap: nowrap; }
/* A table cell of text kept on one line, a date say. Not a row's buttons above, whose words may still wrap. */
.data-table td.no-wrap:not(.row-actions) { white-space: nowrap; }
.row-actions form { margin: 0; }
/* A waiting waiver's Approve and Decline, under its badge on the profile (D706). */
.row-actions.waiver-decision { justify-content: flex-start; margin-top: 6px; }
.table-footnote { margin: 10px 0 0; padding: 0 4px; }

/* ── The payment detail dialog (D143) ────────────────────────────────────────

 Wider than the other detail dialogs because it carries a raw JSON payload — asked for "all fine
 details that are available, even if its a jsonb", and 22rem of it wrapped to forty lines is not
 detail, it is a wall. Its width is set on both the dialog and its body, because capping only one
 of them clips the other (`.wide-dialog` below). */

/* The membership options dialog (D138): a definite width and a fixed top, as the drawers have, so
 opening one of its forms neither resizes nor moves it (D442, D555). The body is `width: 100%`
 rather than the same figure — the dialog's width includes its 1px borders, so a body demanding
 the full figure overflows by 2px, which is enough for a horizontal scrollbar. */
.manage-dialog { width: min(46rem, 94vw); max-width: 94vw; margin-top: 7vh; margin-bottom: auto; }
.manage-dialog .pin-dialog-body { width: 100%; }
/* 86vh under the 7vh top, as the drawers (D442). The browser's own cap is the whole screen less 2em, so a form long
 enough to scroll — Cancel, with its refund (D850) — ran its last buttons off the bottom of the screen. */
.manage-dialog { max-height: 86vh; }

/* A definite width, not just a cap (D442). A `<dialog>` is shrink-to-fit, so with only a
 `max-width` its width follows its content — measured on the pass drawer, opening a panel took it
 from 298px to 690px, more than doubling it sideways. The height was the half that was easy to
 see; this was the half that made it feel like a different window. The body stays `width: 100%`
 rather than repeating the figure — see `.manage-dialog` above, where giving both the same `min`
 overflowed by exactly the 2px of border. */
.wide-dialog { width: min(46rem, 92vw); max-width: 92vw; }
.wide-dialog .pin-dialog-body { width: 100%; }

/* The profile row drawers (D414) — narrower than the payment one, which needs its width
 for a raw JSON payload. Same rule: a definite width so opening a panel cannot change it. */
.row-dialog { width: min(38rem, 92vw); max-width: 92vw; }
.row-dialog .pin-dialog-body { width: 100%; }

.detail-subhead { margin: 16px 0 6px; font-size: 0.95rem; font-weight: 700; }

/* Scrolls inside itself. A 60-line payload must not stretch the dialog past
 the viewport, taking the Close button with it. */
.raw-payload {
  max-height: 320px;
  overflow: auto;
  margin: 8px 0 0;
  padding: 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  font-size: 0.78rem;
  line-height: 1.45;
  white-space: pre;
}

/* ── What a member owes (D144) ───────────────────────────────────────────────
 Amber rather than red. An outstanding charge is a conversation at the
 counter, not an alarm — the member is standing there and usually about to pay
 it. Red is reserved for the entitlement verdict itself. */
.owing-panel { border-color: var(--line-strong); }
.owing-total { font-size: 1.25rem; font-weight: 700; }
.entitlement-owing { margin: 6px 0 0; }

/* ── Redeeming at the till (FR-15/FR-16) ──────────────────────────────
 Reward dollars (D212), vouchers (D226) and store credit (D280) are real tenders here. A tender is
 greyed only while the member has nothing to spend in it, and the view says so (D505). */
.till-tenders { display: grid; gap: 4px; margin-bottom: 10px; }

.till-tender {
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: inherit;
  font: inherit;
  text-align: left;
  opacity: 0.55;
  cursor: default;
}

/* Bold, so the figure stays legible on a greyed tender too. */
.till-tender-figure { font-weight: 700; }

/* ── The public signup, screens 1 and 2 (D151) ───────────────────────────────

 Scoped under `.join-` so nothing here can reach the staff app. The public pages share
 `application.css` with everything else, and a bare `.option` or `.summary` would be a collision
 waiting to happen. */

/* `--accent` is the gym's own colour, set as a style attribute on <html> from `GymBranding` (FR-52,
 D523). Every tint is derived with `color-mix` rather than written down, because the colour belongs to
 the gym. Each such declaration is paired with a plain fallback first: where `color-mix` is not
 understood the second line is dropped and the first still paints something. */
.join-section {
  margin: 22px 0 10px;
  font-size: 1.05rem;
  display: flex;
  align-items: center;
  gap: 10px;
}
/* A short rule in the gym's colour, rather than colouring the words: a heading that is both
 coloured and bold competes with the prices, which are the thing being compared. */
.join-section::before {
  content: "";
  width: 22px;
  height: 3px;
  border-radius: 2px;
  flex: 0 0 auto;
  background: var(--accent, var(--line));
}
.join-section:first-of-type { margin-top: 14px; }

/* The panel wears the gym's colour along its top edge. One stripe, not a filled header — this
 page is read on a phone and a solid band costs a fifth of the first screen. */
.waiver-page .panel { border-top: 3px solid var(--accent, var(--line)); }

/* Plain links and ticked choices in the gym's colour as well (D552). Left to the browser they are
 its default blue, beside buttons and panels in the gym's colour. */
.waiver-page { accent-color: var(--brand); }
.waiver-page a:not([class]) { color: var(--brand-dark); }

.waiver-page h1 { color: var(--ink); }

/* ── The featured offer (FR-52) ──────────────────────────────────────────────

 It stands out by being the only filled thing on the page — the list below is deliberately
 quiet, so one block of colour carries it without any of the rest having to shout back.

 Its text is `--on-accent`, white or near-black by contrast with the gym's colour, and every
 shade is drawn toward `--accent-shade`, away from that text, so the gradient never lowers the
 contrast it was chosen for (D553). */
.join-featured {
  position: relative;
  margin: 18px 0 4px;
  padding: 18px 18px 16px;
  border-radius: var(--radius-lg);
  color: #fff;
  color: var(--on-accent, #fff);
  background: #6b7f66;
  background: linear-gradient(140deg,
    var(--accent, #6b7f66),
    color-mix(in srgb, var(--accent, #6b7f66) 78%, var(--accent-shade, black)));
}

/* Not a rotated corner banner: at 390px a diagonal ribbon either clips or eats the name.
 A pill sits in the flow and stays readable at every width. */
.join-featured-ribbon {
  display: inline-block;
  margin: 0 0 10px;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #fff;
  color: var(--on-accent, #fff);
  background: rgba(255, 255, 255, 0.22);
  background: color-mix(in srgb, var(--on-accent, #fff) 22%, transparent);
}

.join-featured-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
}
.join-featured-name {
  margin: 0;
  font-size: 1.25rem;
  line-height: 1.25;
  color: #fff;
  color: var(--on-accent, #fff);
}
.join-featured-price {
  margin: 0;
  font-size: 1.45rem;
  font-weight: 700;
  white-space: nowrap;
}
.join-featured-cadence { font-size: 0.9rem; font-weight: 400; opacity: 0.85; }

.join-featured-blurb { margin: 10px 0 0; font-size: 0.95rem; opacity: 0.95; }
.join-featured-blurb p:last-child { margin-bottom: 0; }

/* The terms partial is shared with the quiet rows below, where it sits on white. Inside the card it
 has to invert, and the rules stay here rather than in the partial so the partial never needs to know
 which surface it is on. */
.join-featured .join-option-terms,
.join-featured .join-option-facts {
  color: rgba(255, 255, 255, 0.92);
  color: color-mix(in srgb, var(--on-accent, #fff) 92%, transparent);
}
.join-featured .join-option-facts li::marker {
  color: rgba(255, 255, 255, 0.6);
  color: color-mix(in srgb, var(--on-accent, #fff) 60%, transparent);
}

/* `a.`-prefixed, because `.card a` and friends beat a bare class on specificity and would draw a link
 underline straight through a filled button — the same trap `portal.css` documents. */
a.join-featured-button,
.join-featured-button {
  display: inline-block;
  margin-top: 14px;
  padding: 11px 18px;
  border-radius: var(--radius);
  font-weight: 600;
  text-decoration: none;
  color: #1f2937;
  color: color-mix(in srgb, var(--accent, #6b7f66) 82%, var(--accent-shade, black));
  background: #fff;
  background: var(--on-accent, #fff);
}
a.join-featured-button:hover {
  background: rgba(255, 255, 255, 0.88);
  background: color-mix(in srgb, var(--on-accent, #fff) 88%, transparent);
  text-decoration: none;
}

/* The same panel, previewed on its admin page (D524). Capped at the join page's own column
 width, because a preview stretched across a wide admin screen is not what a visitor sees. */
.featured-preview { max-width: 604px; }
.featured-preview .join-featured { margin: 0; }

/* A membership or pass as its row on the signup page, previewed on its edit page (D551). The same
 column width as the page, for the same reason as the featured preview. */
.signup-preview { max-width: 604px; margin: 0; }
.signup-preview-note { margin: 0 0 12px; }

/* One tile, above the memberships list (D525). The admin index's grid, so it sizes and behaves
 exactly like the tiles staff already know — just with the room below it before the table. */
.featured-membership-tile { margin-bottom: 14px; }

/* The long text on a membership's own signup page (FR-53). Tight paragraphs, because it is read
 as a block of terms rather than as prose, and the last one must not push the price box down. */
.join-full-description { margin: 8px 0 14px; }
.join-full-description p { margin: 0 0 8px; }
.join-full-description p:last-child { margin-bottom: 0; }

/* The gym's own terms on the featured panel (D528) — the same size and tone as the membership's
 terms they replace, so switching between the two does not change how the panel reads. */
.join-featured-terms {
  margin: 10px 0 0;
  font-size: 0.9rem;
  color: rgba(255, 255, 255, 0.92);
  color: color-mix(in srgb, var(--on-accent, #fff) 92%, transparent);
}
.join-featured-terms p { margin: 0 0 6px; }
.join-featured-terms p:last-child { margin-bottom: 0; }

.featured-terms-choice { margin: 14px 0 0; }

/* The Full details editor on a membership (D529). Ten lines tall to start, the height the plain box
 had — a rich editor that opened three lines tall would be the same complaint again. */
trix-editor.details-editor { min-height: 15em; }

/* Underline is Leopard's own button, not one of Trix's icons, so it is a letter rather than an
 image — sized to sit level with the icon buttons either side of it. */
.trix-button.details-editor-underline {
  width: 2.6em;
  font-size: 0.95rem;
  font-weight: 600;
  line-height: 1;
}

/* Full details on the public page: lists get their indent and spacing back inside the muted block,
 and links read as links there too. */
.join-full-description.trix-content ul,
.join-full-description.trix-content ol { margin: 6px 0 8px; padding-left: 1.3em; }
/* In the gym's colour as well as underlined: underlined text is allowed in these descriptions too, so a
 link underlined in grey would be indistinguishable from it. */
.join-full-description.trix-content a { color: var(--brand-dark, var(--accent, inherit)); text-decoration: underline; font-weight: 600; }



/* Screen 1 — the catalogue, as a native <details> accordion. */
.join-catalogue { display: grid; gap: 10px; margin: 18px 0; }

.join-option {
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper);
  overflow: hidden;
}
.join-option[open] { border-color: var(--accent, var(--line)); }

/* The whole row is the control, and it must look like one — a summary that
 reads as a heading gets scrolled past rather than opened. */
/* Not `space-between`. The summary has three flex children — name, price and
 the drawn caret — so space-between spread them evenly and the price landed
 wherever the name stopped: $19.00 under one row, $99.00 two centimetres right
 of it under the next. Prices you cannot compare down a column are the one
 thing this list exists to let somebody do.
 The name takes the slack instead, and the price is pushed to the right by
 its own auto margin, so every figure ends on the same edge. */
.join-option > summary {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 14px 16px;
  cursor: pointer;
  font-weight: 600;
  list-style: none;
}
.join-option > summary::-webkit-details-marker { display: none; }
/* The disclosure caret, drawn rather than relying on the platform's own. */
.join-option > summary::after {
  content: "";
  flex: 0 0 auto;
  width: 9px;
  height: 9px;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
  opacity: 0.55;
  transition: transform 120ms ease;
}
.join-option[open] > summary::after { transform: rotate(-135deg) translate(-2px, -2px); }
.join-option > summary:hover { background: var(--paper-2); }
/* A faint wash in the gym's colour when a row is open, so the thing being read is obviously
 the thing that was pressed. Paired fallback, as above. */
.join-option[open] > summary { background: var(--paper-2); }
.join-option[open] > summary { background: color-mix(in srgb, var(--accent, #6b7f66) 7%, white); }

.join-option-name { flex: 1 1 auto; min-width: 0; }
.join-option-price {
  flex: 0 0 auto;
  margin-left: auto;
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.join-option-cadence { font-weight: 400; opacity: 0.7; }

.join-option-body {
  padding: 4px 16px 16px;
  border-top: 1px solid var(--line);
}
.join-option-terms { margin: 12px 0; }
.join-option-facts {
  margin: 0 0 16px;
  padding-left: 18px;
  color: var(--muted);
  font-size: 0.92rem;
}
.join-option-facts li { margin: 2px 0; }

/* Screen 2 — the adaptive form. */
/* Set apart so it does not read as another form section — this is the panel
 somebody scrolls past on the way to nothing, and it should look like the end
 of the page rather than one more thing to fill in. */
.danger-panel {
  margin-top: 28px;
  border-color: var(--amber, var(--line));
}
.danger-panel .panel-title h2 { color: var(--amber, inherit); }

/* Two figures read together (D158) — 200 visits is a different conversation
 depending on whether the last one was Tuesday or in March. */
/* One line each side, so the panel is a strip rather than a block (D1033). */
.visits-panel {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 10px 18px;
}
.visits-figure { min-width: 0; display: flex; align-items: baseline; gap: 8px; }
.visits-figure .home-figure { font-size: 2rem; }
.visits-figure .home-figure-label { margin: 0; }
.visits-last { text-align: right; min-width: 0; }
.visits-last p { margin: 0; }

/* The terms, restated at the top of the checkout. Boxed so it reads as the
 thing being bought rather than as more page copy — it repeats the catalogue
 deliberately, and somebody who arrived by a direct link never saw it. */
.join-terms-summary {
  margin: 14px 0 22px;
  padding: 12px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper-2);
}
.join-terms-summary .join-option-terms { margin: 0; }
.join-terms-summary .join-option-facts { margin-bottom: 0; }

.join-choice { display: grid; gap: 8px; margin: 10px 0 20px; }

/* Indented and ruled, so a revealed block reads as belonging to the question
 above it rather than as a new section that appeared from nowhere. */
.join-reveal {
  margin: 0 0 20px;
  padding-left: 14px;
  border-left: 3px solid var(--line);
}
.join-reveal h2 { margin-top: 0; font-size: 1rem; }

/* Visibly unavailable rather than absent (FR-19) — the same treatment the
 till's reward tenders get. Somebody holding a code should be told it is
 coming, not left wondering where to type it. */
.join-disabled {
  margin: 0 0 20px;
  padding: 12px 14px;
  border: 1px dashed var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper-2);
  opacity: 0.75;
}
.join-disabled input { width: 100%; }
.join-disabled input:disabled { cursor: not-allowed; }

/* A note under the field it is about (D530): plain helper text rather than a box, close under its field,
 because read as a standalone block it looks like something to act on before typing. With no fill it
 cannot merge with the input above it, and a positive margin cannot slide under it; this form has no
 gap between fields for a negative one to work against. */
.join-hint {
  margin: 6px 0 18px;
  line-height: 1.45;
}

/* The consent checkbox starts a new group, so it gets the gap a new group has. It sat directly on the
 date-of-birth field with nothing between them: 0px, measured. */
.join-opt-in { margin-top: 18px; }

/* The browser's own margins around a checkbox differ between engines (Chrome adds 4px left and 3px
 right), so the words beside it start at a different offset in each. Zeroing them on this checkbox makes
 the offset exactly 17px box + 10px gap everywhere, so the note below can line up with the words (D530).
 Scoped here rather than on `.check-row`, which the waiver's checkboxes share. */
.join-opt-in input[type="checkbox"] { margin: 3px 0 0; }
.join-opt-in + .join-hint { padding-left: 27px; }

/* Name on card, directly above Square's card fields (D531). Without a gap the two boxes touched and
 read as one field — D530's lesson one section down. Only the public form puts a field inside
 `.card-entry`, so this reaches nothing else. */
.card-entry > .signin-field { margin-bottom: 12px; }

/* A membership's own words in place of its terms in the signup list (D532) — the same place, size and
 tone as the terms they replace, so choosing between the two does not change how the row reads. */
.join-option-custom-terms { margin: 12px 0; font-size: 0.9rem; color: var(--muted); }
.join-option-custom-terms p { margin: 0 0 6px; }
.join-option-custom-terms p:last-child { margin-bottom: 0; }

.join-summary {
  margin: 10px 0 18px;
  padding: 14px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--paper-2);
}
.join-summary-line {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 16px;
  margin: 0 0 4px;
  font-size: 1.05rem;
}

/* ── Segmented control (D172) ────────────────────────────────────────────────

 Three quiet links read as navigation — as though each goes somewhere else. A segmented control
 reads as one setting with three states, which is what it is. */
.segmented {
  display: inline-flex;
  padding: 2px;
  gap: 2px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.segmented-option {
  padding: 5px 14px;
  border-radius: var(--radius);
  font-size: 13px;
  font-weight: 600;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
}

.segmented-option:hover { color: var(--ink); }

/* The selected one is the only filled surface, so which is active is legible
 at a glance rather than by comparing font weights. */
.segmented-option.is-on {
  background: var(--paper);
  color: var(--ink);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}

/* Was missing while already in use — the check-in report's period links and the store-credit
 "Remove" button have both been rendering unstyled since they shipped. Same shape as the
 `.badge.good` gap found. */
.button-quiet {
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius);
  padding: 4px 10px;
  font-size: 13px;
  color: var(--muted);
  cursor: pointer;
}

.button-quiet:hover { color: var(--ink); border-color: var(--line); }
/* The store credit row's Remove… is a link that opens a dialog (D1032); it reads as the button it was. */
a.button-quiet { text-decoration: none; display: inline-block; white-space: nowrap; }

/* One field and two buttons, so it is not held open to the row drawers' height. */
.credit-remove-dialog .pin-dialog-body { min-height: 0; }

.custom-range {
  margin-top: 16px;
  border-top: 1px solid var(--line);
  padding-top: 12px;
}

.custom-range > summary {
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
  color: var(--muted);
}

.custom-range-form {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 12px;
  margin-top: 12px;
}

.custom-range-form .signin-field { margin: 0; }

/* Without this the submit wraps onto its own line and a flex row stretches it
 to full width — a 600px "Show" button under three small fields. */
.custom-range-form input[type="submit"] {
  flex: 0 0 auto;
  width: auto;
}

/* ── Check-in columns (D173) ─────────────────────────────────────────────────

 Time runs left to right, check-ins up the side. Colours come from tokens so the chart follows
 the theme — an SVG with baked-in hex goes invisible in dark mode. */
.checkin-figure { margin: 0 0 12px; }

/* Height follows the viewBox rather than being fixed. Filling a fixed height
 needs `preserveAspectRatio="none"`, which stretches every axis label
 horizontally along with the chart. */
.checkin-chart-svg {
  display: block;
  width: 100%;
  height: auto;
}

.chart-grid { stroke: var(--line); stroke-width: 1; vector-effect: non-scaling-stroke; }

/* `non-scaling-stroke` keeps hairlines exactly 1px whatever the panel width
 scales the viewBox to. */
.chart-axis-label {
  fill: var(--muted);
  font-size: 15px;
  font-variant-numeric: tabular-nums;
}

.chart-column { fill: var(--brand, #2f7d6b); }

/* The running period is hatched rather than solid, so a half-finished bar is
 never read as a finished one. */
.chart-column.is-partial { fill: var(--brand, #2f7d6b); opacity: 0.4; }

.chart-previous {
  stroke: var(--muted);
  stroke-width: 2;
  stroke-dasharray: 5 4;
  vector-effect: non-scaling-stroke;
  opacity: 0.75;
}

.chart-hover { fill: transparent; }
.chart-hover:hover { fill: var(--ink); opacity: 0.05; }

/* The members report's trend (D689): one line, a light wash beneath it, and a dot on the latest day. */
.chart-line {
  fill: none;
  stroke: var(--brand, #2f7d6b);
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
.chart-area { fill: var(--brand, #2f7d6b); opacity: 0.1; }
.chart-dot { fill: var(--brand, #2f7d6b); }

/* A report's choices in one row above what they change. */
.report-filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 12px;
  margin-bottom: 14px;
}
/* On a phone a control wraps onto a second line rather than running off the panel. */
.report-filters .segmented { flex-wrap: wrap; }
.report-filter-chip { font-size: 13px; font-weight: 600; }
.report-filter-chip a { margin-left: 6px; font-weight: 500; }

/* The failed-payments list's time of the last attempt, kept on one line under the count (D1030). */
.failed-payment-attempted { white-space: nowrap; }

/* The state being shown, picked out wherever its number appears. */
.metric.is-on { border-color: var(--brand, #2f7d6b); background: var(--paper); }
.report-split { margin: 10px 0 14px; }
.members-table th.is-on,
.members-table td.is-on { background: var(--paper-2); }
.members-table tbody tr.is-on td:first-child { font-weight: 700; }
.members-table tfoot td { font-weight: 700; }

/* ── The signup confirmation, as its editor draws it (D564) ──────────────────

 Two screens side by side where there is room, one under the other on a narrow one — a gym
 writing the second wording has no other way to see it.

 The links are inert: it is a picture of a page, and a preview that navigates away from the form
 somebody is filling in loses what they typed. */
.signup-confirmation-previews {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 16px;
}

.signup-confirmation-previews a { pointer-events: none; }

.signup-confirmation-previews .panel { margin-top: 6px; }

/* ── The numbers under the chart (D562) ──────────────────────────────────────

 A tooltip is a pointer feature, so on a touch screen every column was a shape with no value
 attached. The table is the same numbers in text.

 Both widths are load-bearing:

 `min-width: 0` — the bare `table` rule sets a 650px floor so wide admin tables do not squash,
 which on a phone put 650px of table inside a 345px panel. `.panel` CLIPS rather than scrolls, so
 the count column went off the side with no scrollbar to bring it back. Same override as
 `.home-tile .data-table` (D286), and a `max-width` alone could not do it: min-width wins (D348).

 `max-width` — two columns stretched across a wide panel put a date and its number most of a
 screen apart, and the eye loses the row between them. */
.checkin-table { min-width: 0; max-width: 460px; margin-top: 4px; }

/* Dimmed like its column, so a period still running is never read as a finished one. */
.checkin-table tr.is-partial td { opacity: 0.7; }

.checkin-table tfoot td {
  border-top: 1px solid var(--line);
  border-bottom: 0;
  font-weight: 700;
}

/* ── Admin groups (D176) ─────────────────────────────────────────────────────

 Headings give the eye somewhere to land before it starts reading titles. */
.admin-group { margin-bottom: 26px; }

.admin-group-title {
  margin: 0 0 10px;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ── The open day on the cash report (FR-3, D203) ────────────────────────────
 A new class, and grepped first: `.is-selected` exists only in portal.css for
 a result button, and `.is-archived` means retired rather than focused. Neither
 says "this is the row you have open below".

 `--paper-2` and `--brand`, both real tokens. The first draft used
 `--surface-2` and `--accent`, neither of which this stylesheet defines — an
 undefined custom property is not an error, it is a transparent background and
 no bar, so the row would simply have looked unhighlighted forever. */
.data-table tr.is-open-day > td { background: var(--paper-2); }

/* The marker bar goes on the first cell only. On every `td` it drew a stripe
 down each column — four bars across one row, which reads as a table border
 somebody got wrong rather than "this row is open". Seen on screen; the tests
 could not have caught it. */
.data-table tr.is-open-day > td:first-child { box-shadow: inset 3px 0 0 var(--brand); }

/* ── A redeemable tender at the till (FR-16, D212) ───────────────────────────
 It is a label with a checkbox, not a button, because it has to submit with
 the sale. `.till-tender` already styles the dead ones; this makes the live one
 look pressable rather than greyed. */
.till-tender.is-redeemable {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  opacity: 1;
  border-color: var(--brand);
}

.till-tender.is-redeemable input[type="checkbox"] {
  width: 18px;
  height: 18px;
  margin: 0;
  accent-color: var(--brand-dark);
}

/* Pushes the figure to the right edge, matching the disabled tenders beside it
 so the row does not jump when a balance appears. */
.till-tender.is-redeemable .till-tender-figure { margin-left: auto; }

/* ── A balance, under its heading rather than off to the right ───────────────

 On a wide profile tab `.panel-actions` puts the figure a full page-width from the words that
 name it, so the eye has to travel to find the one number the panel is about. Under the heading
 it reads as the answer to the heading. Not `.owing-total`, which is still right where a figure
 genuinely is an action's counterpart (an amount owing beside a Charge button). */
.balance-figure {
  /* `.panel-title` is a flex row, so a `<p>` dropped into it lands beside the
 description rather than under the heading — which is what the first attempt
 did, and it read as trailing the sentence. `flex-basis: 100%` breaks it onto
 its own line without changing the container every other panel shares. */
  flex-basis: 100%;
  margin: 4px 0 0;
  font-size: 1.6rem;
  font-weight: 700;
  line-height: 1.1;
  /* Tabular figures so a balance does not jiggle horizontally as it changes —
 these sit directly above a form that changes them. */
  font-variant-numeric: tabular-nums;
}

/* The redemption lines under the basket total (D212). Same row shape as
 `.basket-total` so the three read as one block; the "to pay" figure is the one
 staff say out loud, so it is the emphasised one rather than the total above it. */
.basket-redeemed strong { font-weight: 600; }
.basket-due { border-top: 1px solid var(--line); padding-top: 6px; }
.basket-due strong { font-size: 1.1rem; }

/* An (i) sitting alone under the pay buttons needs no text beside it, but it
 still needs somewhere to sit — without this the row collapses to nothing and
 the badge overlaps the button above. */
.till-note { min-height: 20px; margin: 8px 0 0; }

/* A plain divider between two forms in one panel — the waiver page publishes a
 legal version above it and saves ordinary settings below, and they must not read
 as one form (D220). `.rule` was not already defined; grepped before naming it. */
.section-rule {
  margin: 24px 0 16px;
  border: 0;
  border-top: 1px solid var(--line);
}

/* ── The account activity log (D221) ─────────────────────────────────────────

 A forensic table, not a feed. So the payload column is allowed to be dense and to wrap, rather
 than being truncated into uselessness. */
.activity-log code { font-size: 0.82rem; }

.activity-data {
  max-width: 42rem;
  font-size: 0.8rem;
  line-height: 1.45;
  /* `anywhere`, not `break-word` — event payloads carry UUIDs with no spaces in
 them, and without this one id stretches the table past the viewport. */
  overflow-wrap: anywhere;
  color: var(--muted);
}

/* ── The discounts-by-staff chart (D223) ─────────────────────────────────────
 Each bar is a link, because a click without JavaScript is a link — and the
 CSP blocks inline scripts. It has to look pressable and show which one is open. */
.discount-bar-link {
  display: grid;
  grid-template-columns: minmax(9rem, 14rem) 1fr auto;
  align-items: center;
  gap: 12px;
  width: 100%;
  padding: 6px 8px;
  border-radius: var(--radius);
  color: inherit;
  text-decoration: none;
}

.discount-bar-link:hover { background: var(--surface-2, rgba(127, 127, 127, 0.08)); }

/* The open row stays marked — clicking a second bar swaps the table below, and
 without this nothing on screen says which one you are looking at. */
.discount-bar-link.is-selected { background: var(--brand-soft); }

/* `min-width: 0` so the track can shrink inside the grid column; a flex/grid
 child defaults to its content width and would push the value off the row. */
.discount-bar-track { min-width: 0; }

.discount-bar-value { font-variant-numeric: tabular-nums; }

/* ── Condensing the basket column (D242) ─────────────────────────────────────

 The column's job is showing what is in the basket; everything below it was crowding that out. */
.till-pay-row {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 8px;
  margin-top: 8px;
}

.till-pay-row input[type="submit"] { width: 100%; }

/* Which reader a card sale goes to, where there is more than one (D725). One line above the pay
 buttons: the label, then the name or the choices. */
.till-reader {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 12px;
  margin-top: 8px;
  font-size: 0.85rem;
}

.till-reader-label { color: var(--muted); }
.till-reader-choice { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.till-reader-missing { color: var(--red); }

/* An automation's name, changed on its own page above its switch (D1020). */
.journey-rename { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; flex-wrap: wrap; }
.journey-rename label { font-weight: 600; }
.journey-rename input[type="text"] {
  flex: 1 1 16rem;
  max-width: 28rem;
  padding: 6px 10px;
  font: inherit;
  color: var(--ink);
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.journey-rename input[type="submit"] { flex: 0 0 auto; width: auto; }

/* A reader's name and its Rename button, on one line in the terminals table (D725). */
.terminal-rename { display: flex; align-items: center; gap: 8px; margin: 0; }
.terminal-rename input[type="text"] {
  width: 12rem;
  max-width: 100%;
  padding: 6px 10px;
  font: inherit;
  color: var(--ink);
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.terminal-rename input[type="submit"] { flex: 0 0 auto; width: auto; }

/* ── The sheet of other ways to pay (D727) ───────────────────────────────────
 Opens over the till once a sale has gone to the reader: the amount, how the sale stands, and
 the other ways to take the money, one to a row. */
.payment-sheet-frame { gap: 16px; }
/* It takes the focus as it opens, so that no button does; it is not itself something to act on. */
.payment-sheet-frame:focus { outline: none; }
.payment-sheet-content { display: grid; gap: 16px; }
/* Not to be pressed yet: just drawn, or waiting on the answer to a press (D737). */
.payment-sheet-frame [inert] { opacity: 0.6; }

.payment-sheet-amount {
  margin: 6px 0 0;
  font-size: 2.4rem;
  font-weight: 700;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.payment-sheet-status {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 12px 14px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.payment-sheet-status strong { display: block; }
.payment-sheet-content.is-waiting .payment-sheet-status,
.payment-sheet-content.is-attention .payment-sheet-status { background: #fdf6e3; border-color: #e8d9a8; }
.payment-sheet-content.is-done .payment-sheet-status { background: #e9f5ec; border-color: #b6dcc2; }
.payment-sheet-content.is-failed .payment-sheet-status { background: #fbeaea; border-color: #e6b8b8; }

.payment-sheet-note { margin: 0; }
/* The sums that might be handed over in cash, two to a row. */
.payment-sheet-cash { grid-template-columns: repeat(2, 1fr); }
.payment-sheet-cash .payment-sheet-way { text-align: center; }
/* A step's own form — a gift card's number — with its button under the field, at its own width. */
.payment-sheet-lookup { display: grid; gap: 10px; justify-items: start; }
.payment-sheet-split .primary-button { min-width: 120px; }
.payment-sheet-lookup .signin-field { width: 100%; }
.payment-sheet-ways { display: grid; gap: 8px; }
.payment-sheet-way { width: 100%; padding: 12px 14px; text-align: left; }
.payment-sheet-actions { justify-content: flex-end; }
.payment-sheet-actions form { margin: 0; }

/* One button across the column where there is one way in (D727). */
.till-pay-row.is-charge { grid-template-columns: 1fr; }

/* Why the reader could not take the sale. Empty until it is needed, and then it takes no more
 room than the sentence; the pulse is how a second, identical refusal is seen to have arrived. */
.till-refusal:empty { display: none; }
.till-refusal-message { margin: 8px 0 0; animation: till-refusal-arrive 0.6s ease-out; }

@keyframes till-refusal-arrive {
  from { opacity: 0.2; }
  to { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .till-refusal-message { animation: none; }
}

/* Smaller tenders */
.till-tenders { margin-top: 8px; }
.till-tender { padding: 7px 10px; }
.till-tender + .till-tender { margin-top: 4px; }

/* The till's discount note. Full width under the picker rather than beside it —
 the drawer's tender column is narrow, and a note box sharing a row with a select
 ends up too small to write "manager approved, damaged item" in. */
.till-discount-note {
  width: 100%;
  margin-top: 6px;
}

/* ── The till's voucher picker (D226) ────────────────────────────────────────

 The button prompts when something applies and nothing is chosen. A member holding a free-coffee
 voucher who buys a coffee and is not offered it is the failure this exists to prevent. */
.till-tender.is-prompting {
  border-color: var(--accent, #e87812);
  box-shadow: 0 0 0 2px rgba(232, 120, 18, 0.18);
}

.voucher-choice {
  display: grid;
  gap: 2px;
  padding: 12px;
  text-align: left;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper);
  cursor: pointer;
}

/* A settings row for something that is not built yet (D398).

 Dimmed and disabled rather than absent. A missing row reads as a decision nobody made; a greyed
 one reads as a thing that is coming. Same reasoning as `.voucher-choice.is-unavailable` below
 and `.module-row.unavailable` (D128). Its own selector because that one is scoped to
 `.voucher-choice` and does not reach a field row — grepped before writing, which is the order
 D383 got wrong. */
.signin-field.is-unavailable {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Dimmed, not hidden. Hiding the inapplicable ones would leave staff wondering
 whether the voucher exists at all — which is the question the panel is open to
 answer. Same reasoning as `.module-row.unavailable` (D128). */
.voucher-choice.is-unavailable {
  opacity: 0.5;
  cursor: not-allowed;
}

.voucher-choice-state {
  margin-top: 4px;
  font-weight: 700;
}

/* The till's two discount boxes side by side (D228) — "% off" and "$ off" are one
 decision, not two, so they share a row and the note under them says to use one. */
.till-discount-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  /* Only the first label carries an (i), which makes it taller — so the boxes line up by their
   bottoms. */
  align-items: end;
}

/* A full-width button in the basket column, left-aligned like the rows around it (D545). */
.till-discount-button { width: 100%; justify-content: flex-start; margin-bottom: 8px; }
/* The plus is for adding one; it would misdescribe "Change discount". */
.till-discount-button.is-set .icon { display: none; }

/* ── The till's small panels: discount and vouchers (D226, D242, D545) ──────

 Each is a `<dialog>` inside the till carrying `.till-dialog`, so without these it takes the whole
 till's size and none of a panel's shape. */
/* `fit-content`, not `auto`: a modal dialog is pinned to the top and bottom of the screen, so an
 auto height stretches to fill it. `fit-content` is the browser's own default for a dialog, which
 `.till-dialog` overrides. */
.till-dialog.picker-dialog {
  width: min(34rem, 92vw);
  height: fit-content;
  max-height: 85vh;
}

.picker-frame {
  display: grid;
  gap: 14px;
  max-height: 85vh;
  padding: 18px 20px 20px;
  overflow-y: auto;
}

.picker-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.picker-header h3 { margin: 0; font-size: 1.05rem; }

.picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
  gap: 8px;
}

/* ── The wall's grade spread (FR-18, D229) ───────────────────────────────────
 not `.list-row` — that is defined only in portal.css and reaches staff pages
 by accident (FR-6); the same mistake threw the signup radios across the row.
 Same grid as `.discount-bar-link`, narrower label column: "Grade 7" is short. */
.spread-row {
  display: grid;
  grid-template-columns: 5.5rem 1fr auto;
  align-items: center;
  gap: 12px;
  padding: 5px 8px;
}

/* ── People pickers (D233) ───────────────────────────────────────────────────

 not `.spread-row`, and this is what went wrong. That grid was built for the grade-spread list —
 a 5.5rem label and a bar that should stretch. Reused for a list of people it did two bad things
 at once: names wrapped inside 82px, and the 1fr swallowed everything else so the Remove button
 sat most of a metre from the row it acts on.

 So the row is capped rather than stretched. The action stays beside the name at any window
 width, which is the whole point — a counter screen is wide, and a control that drifts to the far
 edge is a control you have to hunt for. */
/* Its own container, not `.list`. `.list` is itself a `display: grid` shared with
 half the app, and a `.person-row` dropped inside it collapsed to 101px — the grid
 sized its column to content and the `max-width` below never got a chance to apply.
 Reusing a layout class whose behaviour you have not read is how that happens. */
.person-list {
  display: block;
  max-width: 40rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.person-row {
  display: grid;
  grid-template-columns: minmax(0, 14rem) minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  max-width: 40rem;
  padding: 7px 0;
  border-top: 1px solid var(--line);
}

.person-row:first-child { border-top: 0; }

/* `input, textarea { width: 100% }` is a global rule, so a submit button inside a
 picker form stretched the full panel — an "Apply" 837px wide. Scoped back here
 rather than loosened globally, which would move every other form on the site. */
.person-picker { max-width: 40rem; }
.person-picker input[type="submit"] { width: auto; }

.person-picker .signin-field { margin-bottom: 8px; }

/* ── Who's paying (D233) ─────────────────────────────────────────────────────
 It was a `.selectable-row` — a flex row built for a radio and a big two-line
 label — which on a wide screen left the box marooned in the middle with its text
 far to the right. It is one quiet line now, because 95% of memberships are paid
 for by the member and the screen should not ask. */
.payer-form {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}

.payer-choice {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}

/* Only the margin now — portal.css no longer stretches checkboxes (the rule there
 exempts them), so the local `width: auto` this used to need is gone. */
.payer-choice input[type="checkbox"] { margin: 0; }

.payer-apply { width: auto; }

/* The desk's override, under the list it opens up (D864). */
.override-form { margin-top: 14px; }

/* A discount typed at the desk: how much and why, once a kind is chosen (D866). */
.desk-discount-fields { display: none; margin-top: 10px; }
.desk-discount:has(input[name="discount_kind"]:checked:not([value="none"])) .desk-discount-fields { display: block; }
.desk-discount-fields .signin-field + .signin-field { margin-top: 10px; }

/* A set photo is for orientation, not documentation — capped so it does not push the
 table of problems off the screen, which is what somebody came to the page for. */
.set-photo {
  display: block;
  width: 100%;
  max-width: 640px;
  height: auto;
  border-radius: var(--radius);
}

/* ── The membership type picker (D233) ──────────────────────────────────────
 Links now, not radios, because choosing a type has to reach the server before
 the rest of the page can be drawn. `.selectable-row` already supplies the layout;
 this only replaces the radio with a tick and keeps the anchor from reading as a
 link (blue, underlined) inside a form. */
a.selectable-row {
  color: inherit;
  text-decoration: none;
}

a.selectable-row:hover { background: var(--paper-2); }

.type-choice-mark {
  flex: 0 0 20px;
  display: grid;
  place-items: center;
  min-height: 20px;
  margin-top: 2px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  font-size: 12px;
  font-weight: 800;
  line-height: 1;
}

a.selectable-row.is-chosen { background: var(--brand-soft); }

.is-chosen .type-choice-mark {
  border-color: var(--brand);
  background: var(--brand);
  color: #fff;
}

/* ── The till keypad (D249) ──────────────────────────────────────────────────
 Sized for a finger on a counter touchscreen, not a mouse — and styled as a
 `.product-tile` is, because that is the gesture staff are already making on
 this screen. */
.till-keypad {
  display: flex;
  flex-direction: column;
  gap: 14px;
  max-width: 420px;
}

/* `tabular-nums` so the amount does not jitter sideways as digits arrive —
 the readout is the number somebody is reading aloud to a customer. */
.keypad-readout {
  display: block;
  font-size: 40px;
  font-weight: 650;
  letter-spacing: -0.02em;
  text-align: right;
  padding: 12px 16px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background: var(--paper);
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.keypad-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
}

.keypad-key {
  font-size: 22px;
  font-weight: 600;
  padding: 16px 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--ink);
  cursor: pointer;
  font-variant-numeric: tabular-nums;
  transition: border-color 0.12s ease, background 0.12s ease;
}

.keypad-key:hover { border-color: var(--brand); background: var(--paper); }
.keypad-key.is-quiet { color: var(--muted); }

.keypad-reason { margin: 0; }

.keypad-actions { display: flex; gap: 10px; align-items: center; }

/* ── The till library (D250) ─────────────────────────────────────────────────
 The grid is `.product-grid`, the same tiles as every other page — only the
 search head is new. */
.till-library {
  display: flex;
  flex-direction: column;
  gap: 12px;
  min-height: 0;
}

.library-head {
  display: flex;
  align-items: center;
  gap: 12px;
}

/* `.search-box` already styles the rail's customer search; this only lets it
 take the width it has here rather than the rail's. */
.library-search { flex: 1 1 auto; margin: 0; }

/* The tiles scroll, the search box does not — typing then losing the field
 off the top is the friction D127 removed from the pay button. */
.till-library .product-grid { overflow-y: auto; align-content: start; }

/* The voucher form's item picker can be the whole catalogue, so it scrolls
 rather than pushing the amount fields off the page (D251). */
.item-choices {
  max-height: 260px;
  overflow-y: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 8px;
}

/* ── Challenge submissions (D261) ────────────────────────────────────────── */
.review-row {
  display: flex;
  gap: 16px;
  align-items: flex-start;
  justify-content: space-between;
  padding: 12px 0;
  border-bottom: 1px solid var(--line);
}
.review-row:last-child { border-bottom: none; }

.review-detail { min-width: 0; }

/* A submitted URL can be long and is member input — it wraps rather than
 stretching the row and pushing the buttons off the panel. */
.review-link { word-break: break-all; }

.review-actions { display: flex; gap: 8px; align-items: flex-start; flex: 0 0 auto; }
.review-reject { display: flex; gap: 6px; align-items: center; margin: 0; }
.compact-input { max-width: 12rem; }

/* ── The window choice's worked example (D267) ───────────────────────────────
 Set apart from the fields so it reads as explanation rather than as another
 thing to fill in — but quiet, because only one is ever on screen. */
.window-example {
  border-left: 3px solid var(--line-strong);
  background: var(--paper-2);
  border-radius: var(--radius);
  padding: 10px 14px;
  margin: 8px 0 14px;
}

.window-example-title { margin: 0 0 6px; font-weight: 600; }
.window-example p:last-child { margin-bottom: 0; }

/* ── Form sections (D272) ────────────────────────────────────────────────────


 A hairline and space, not a card. Boxing each group would turn one form into five things to read
 and double its height — the ask was a break, not a container. The rule is 1px and the space
 around it does most of the work. */
.form-section + .form-section {
  margin-top: 22px;
  padding-top: 22px;
  border-top: 1px solid var(--line);
}

/* The last group runs into the Save button, so it gets the same separation —
 otherwise "visibility" reads as though it belongs to the buttons. */
.form-section + .pager {
  margin-top: 22px;
  padding-top: 22px;
  border-top: 1px solid var(--line);
}

/* A field opening a section should not add its own top margin on top of the
 section's padding — that is what made the old spacing look arbitrary. */
.form-section > :first-child { margin-top: 0; }

/* ── Email preview (D312) ────────────────────────────────────────────────────
 Kept when the block composer was retired (D312): the two-width preview belongs to
 the preview page, not to the editor, and is useful whatever writes the HTML. */
/* The preview frames sit side by side on a wide screen and stack on a narrow one;
 the phone frame is a real 375px so what you see is what a phone gets. */
.email-preview-row {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  align-items: flex-start;
}

.email-preview-desktop { flex: 1 1 520px; min-width: 0; }
.email-preview-phone { flex: 0 0 375px; max-width: 100%; }

.email-preview-frame {
  width: 100%;
  height: 620px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: #fff;
}

.email-preview-phone .email-preview-frame { height: 620px; }

/* ── The Terminal checkout panel (D325) ──────────────────────────────────────
 Four tones, and the colours are semantic rather than the app's accent: staff read
 this from a metre away while talking to a member, so "did it work" has to land before
 the words do. */
/* Pinned to the bottom so staff can work while a card is on the reader (D331). It used to sit at
 the top of the content — directly under the profile's own tab bar, pushing down the buttons
 staff were reaching for — and it only rendered when the URL carried `?checkout=…`, so clicking
 any tab dropped it mid-payment.

 It clears the rail using `--sidebar-width` rather than a copied number, and is anchored right
 rather than centred: centring on the viewport would slide a wide toast underneath the rail on a
 narrow window, and `position: fixed` would draw it on top. */
.checkout-status {
  position: fixed;
  right: 18px;
  bottom: 18px;
  z-index: 60;
  width: min(680px, max(240px, calc(100vw - var(--sidebar-width) - 36px)));
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  padding: 12px 16px;
  margin: 0;
  border-radius: var(--radius);
  border: 1px solid var(--line);
  background: var(--paper-2);
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18);
}

.checkout-dismiss { margin-left: 8px; }

/* The receipt button beside Dismiss on a paid banner (D726): one group at the banner's end. */
.checkout-actions { display: flex; align-items: center; }

.checkout-status-main { display: flex; align-items: center; gap: 12px; min-width: 0; }
.checkout-status strong { display: block; }

.checkout-status.is-waiting,
.checkout-status.is-sent { background: #fdf6e3; border-color: #e8d9a8; }

/* The signup form's own "waiting on the reader" box (FR-51). Inline rather than the fixed
   toast above: staff are looking at this form and the answer changes what they press next,
   so it belongs in the flow. Same wash as the toast's waiting state — one meaning, one
   colour, wherever the reader is being waited on. */
/* Where the payment button sits when Swipe is the chosen way to pay (D692), spaced as the pager is. */
.signup-swipe-step { margin-top: 14px; }

.card-capture-waiting {
  padding: 12px 14px;
  margin: 0 0 12px;
  border: 1px solid #e8d9a8;
  border-radius: var(--radius);
  background: #fdf6e3;
}

.card-capture-waiting .row-title { margin: 0; }
/* While the terminal is asking for a card, or a card it saved is being charged, who pays stays as it is (D940). */
.signup-payer-locked { display: none; }
body:has(.card-capture-waiting, [data-controller~="card-capture"]) .signup-payer-controls { display: none; }
body:has(.card-capture-waiting, [data-controller~="card-capture"]) .signup-payer-locked { display: block; }
.card-capture-waiting button { margin-top: 10px; }
.checkout-status.is-done { background: #e9f5ec; border-color: #b6dcc2; }
.checkout-status.is-failed { background: #fbeaea; border-color: #e6b8b8; }

/* CSS only — the CSP blocks inline scripts, and a spinner that needs JavaScript is
 missing at exactly the moment the page is struggling. */
/* The charging pop-up's own (D1005), drawn the same: a page holding one is not a banner still waiting. */
.checkout-spinner,
.busy-spinner {
  flex: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid rgba(0, 0, 0, 0.15);
  border-top-color: var(--ink);
  animation: checkout-spin 800ms linear infinite;
}

@keyframes checkout-spin { to { transform: rotate(360deg); } }

/* Somebody who has asked not to see motion still needs to know it is working, so the
 ring pulses instead of spinning rather than simply vanishing. */
@media (prefers-reduced-motion: reduce) {
  .checkout-spinner, .busy-spinner { animation: checkout-pulse 1.4s ease-in-out infinite; }
  @keyframes checkout-pulse { 50% { opacity: 0.35; } }
}

.checkout-status form { margin: 0; }

/* A card being charged (D1005): small and in the middle, over everything, the spinner beside what is happening. */
.busy-dialog {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 18px 22px;
  box-shadow: var(--shadow);
  background: var(--paper);
  color: var(--ink);
}
.busy-dialog[open] { display: flex; align-items: center; gap: 14px; }
.busy-dialog p { margin: 0; font-weight: 600; }
.busy-dialog::backdrop { background: rgba(0, 0, 0, 0.25); }


/* ── The email template editor (FR-37, D344) ─────────────────────────────────

 side by side, and that is the feature. Watching it change while typing is what makes an HTML
 editor usable by somebody who does not read HTML. */
/* Stacked by default, and side by side only when there is genuinely room.
 Measured rather than guessed: at a 1280px viewport the content column is 837px
 — the 310px rail eats the rest — so two panes get 411px each. An email is
 600px wide, so a 411px preview is a lying preview, and SunEditor's toolbar
 collapses into a vertical stack at that width.

 The first version of this broke at `max-width: 1100px`, which measured the
 viewport when the thing that matters is the content column. Side by side needs
 roughly 1250px of content, which is a ~1560px window.

 Stacked is not a consolation prize: SunEditor's wysiwyg pane already renders
 the formatted email, so the preview below is confirmation at true width rather
 than the only way to see it. */
.email-editor {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 16px;
  align-items: start;
  margin: 12px 0;
}

/* Always stacked, and the side-by-side breakpoint is gone (D346). It was set at 1560px on the
 arithmetic that two 600px panes would just fit — but "just fits" is not the same as usable:
 SunEditor's toolbar wraps, the email touches both edges, and the content scrolls sideways inside
 its own tile.

 Scrolling is the cheaper cost. Both panes get the full content column, the toolbar stays on one
 row, and the preview shows a 600px email at 600px — which is the only width at which a preview
 tells the truth. */

.email-editor-pane { min-width: 0; }

/* ── The preview's desktop / phone toggle (D350) ─────────────────────────────
 The label and the toggle share a row, so the control sits with the thing
 it changes instead of floating above the tile. */
.email-preview-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 4px;
}

/* The house segmented control (D172) is sized for a word — `padding: 5px
 14px` — which leaves a 16px icon adrift in a wide button. Squared up here
 rather than by changing the shared control, which four other screens use.
 `border`/`background`/`font` are reset because those options are links
 everywhere else and these are <button>s, which arrive with a ua border, a
 grey fill and their own font. */
.email-preview-head .segmented-option {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 5px 10px;
  border: 0;
  background: transparent;
  font: inherit;
  color: var(--muted);
  cursor: pointer;
}

/* The active fill has to be restated here. `.segmented-option.is-on` is (0,2,0) and so is
 `.email-preview-head .segmented-option` — equal specificity, so the later
 rule wins, and the `background: transparent` above would quietly cancel the
 selected state. The whole control would then look like two flat icons with
 nothing to say which one is on. Same shape as the link-styled-as-a-button
 inside a card. */
.email-preview-head .segmented-option.is-on {
  background: var(--paper);
  color: var(--ink);
}


/* The textarea is what SunEditor measures itself against, and what remains visible
 if the editor fails to load. Full width either way. */
.email-editor-pane textarea { width: 100%; box-sizing: border-box; }

/* The formatting toolbar's own field (D594).
 `.signin-field` is a flex column, so a textarea that is its DIRECT child is stretched to
 the full width. Wrapping one in a div to carry a Stimulus controller makes the DIV the
 child instead, and the textarea falls back to its `cols` default — about 165px, which the
 editor then copies because it sizes itself from the box it replaces. */
.rich-text-field { display: flex; flex-direction: column; }
.rich-text-field textarea { width: 100%; box-sizing: border-box; }

/* The competition graphic (D604).
 Declared here AND in portal.css on purpose. The portal never loads this sheet and the public
 page never loads the portal's (D557/D409), so a class defined once is a class with no rule on
 one of the two pages — which is how the graphic would have rendered at its natural size, full
 bleed, on the page it was added for. */
.comp-banner {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-lg);
  margin-bottom: 12px;
}

/* White, not the page's paper colour — an email renders on whatever the client
 gives it, and previewing on a tinted background flatters templates that would
 look wrong in a real inbox. */
/* 640px tall when stacked: enough to show the top of an email without pushing
 the editor off the screen. The email itself is 600px wide, so at any content
 width above that the preview is truthful. */
.email-editor-preview {
  width: 100%;
  height: 640px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: #fff;
}

/* SunEditor ships its own chrome; these only stop it fighting the admin theme. */
.email-editor .sun-editor {
  border-radius: var(--radius);
  border-color: var(--line);
}

/* ── The editor pane must render an email the way the preview does (D348) ─────

 the editor inherits leopard's admin stylesheet and the preview inherits nothing. The preview is
 a sandboxed `srcdoc` iframe — a separate document, so this file never reaches it. The editable
 area is a div on this page, so every bare element rule here lands on the email's own markup. Two
 panes, two different stylesheets, one set of HTML: they were always going to disagree, and saw
 it on all eight foundations as "alignment or width, or both".

 The width half was leopard's own rule, not suneditor's — and the first fix here blamed SunEditor
 and did not work. `table { width: 100%; min-width: 650px }` (line ~1966) exists so admin data
 tables do not squash on a narrow screen. Applied to an email it is fatal, because **`min-width`
 beats `max-width`**: a masthead carrying `width:600px; max-width:600px` inline still computed to
 766px, and no amount of overriding `max-width` could ever have moved it. Measured on
 `announcement`: table 766px inside a 995px pane, against 600px in the preview beside it. The
 image was the obvious suspect and was innocent — capping the 1200px placeholder to 544px changed
 the table's width by nothing. Checked before it was fixed, which is the only reason the real
 cause was found.

 The other half is SunEditor's, and is real: it styles every table as a data table too (`td`/`th`
 borders and `0.4em` padding, `tr` borders), which drew grey outlines round every cell of every
 email.

 So the fix is `revert`, not a list of replacement values. `revert` rolls a property back to the
 user-agent default, discarding what any author stylesheet said — which is precisely the state
 the preview iframe renders in. Naming replacement values would be guessing at the browser's
 defaults and re-deriving them wrong; this asks for them by name. The email's own inline styles
 are untouched either way, because inline beats both.

 Scoped to `.email-editor`, so admin tables everywhere else keep the min-width they were given,
 and SunEditor's table tool is unaffected. */
.email-editor .sun-editor-editable :is(table, tr, td, th) {
  border: revert;
  border-bottom: revert;
  border-collapse: revert;
  padding: revert;
  margin: revert;
  width: revert;
  min-width: revert;
  max-width: revert;
  background-color: revert;
  text-align: revert;
  vertical-align: revert;
  font-size: revert;
  font-weight: revert;
  color: revert;
}

/* `h1`–`h3` are given the admin theme's heading font, colour and sizes on
 line ~801. An email sets its own inline and mostly wins — but `line-height`
 and `font-family` are not always declared inline, so a headline sat at the
 admin's 1.26 line-height in one pane and the browser's default in the
 other. Same reasoning as above: revert, do not re-specify. */
.email-editor .sun-editor-editable :is(h1, h2, h3, h4) {
  margin: revert;
  color: revert;
  font-family: revert;
  font-weight: revert;
  font-size: revert;
  line-height: revert;
}

/* `align="center"` is how every one of these emails centres its 600px
 column. Browsers implement it as a presentational hint (margin-inline
 auto), so reverting `margin` above restores it — this states it outright
 rather than relying on that, because a table left-shoved against a 995px
 pane is the single most visible way these two panes can disagree. */
.email-editor .sun-editor-editable table[align="center"] {
  margin-left: auto;
  margin-right: auto;
}

/* Both panes reserve their scrollbar, so the column sits in the same place
 whether or not one is showing (D349). A scroll container takes its
 scrollbar's width out of its content box, so the pane that happened to be
 scrolling was 15px narrower than the other and centred the email 8px off.
 Both are scroll containers now; `stable` stops that flipping about as
 content grows past the box.
 The preview's half is set in the iframe's own `<style>`, in the Stimulus
 controller — this file cannot reach into a separate document, which is the
 whole of D348.
 Where `scrollbar-gutter` is unsupported this degrades to the common case
 being right — any real email overflows 640px, so both panes scroll anyway. */
.email-editor .sun-editor-editable {
  scrollbar-gutter: stable;
}

/* Blue underlined links are SunEditor's default, not the email's. The
 templates set colour inline; this only stops the editor's own default
 showing through anywhere they do not. */
.email-editor .sun-editor-editable a {
  color: inherit;
}

/* Adding a row from the Personal tab (D382).

 No javascript, deliberately. A <dialog> opened by Stimulus is the pattern the
 payment drawer uses, and it has one property that is wrong for a form: if the script
 does not run, the markup is in the page and invisible. Two forms have already shipped
 silently broken under the CSP in this app (D184), so this popup is rendered by the
 server when `?add=…` is present and closed by a link. It cannot fail to open.
 It is not a modal — no focus trap, no Escape. The trade is deliberate: a form that
 always works beats one that behaves better when a script happens to load. */
.add-popup {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: 16px;
  background: rgba(24, 18, 10, 0.42);
}

.add-popup-card {
  width: 100%;
  max-width: 460px;
  max-height: 85vh;
  overflow-y: auto;
  padding: 20px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper);
  box-shadow: 0 12px 32px rgba(40, 26, 10, 0.28);
}

.add-popup-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
}

.add-popup-head h2 { margin: 0; }

/* `a.` prefix and inside the list of link-buttons — `.card a` and `.panel a` beat a bare class on
 specificity, so without it this renders underlined and link-green on a button. The rule is
 written on the portal stylesheet and was hit again three lines from its own comment. */
.panel-header a.add-link,
a.add-link {
  font-size: 12px;
  padding: 3px 9px;
  white-space: nowrap;
  text-decoration: none;
}

/* The submit sat flush against the field above it in all four popups`.signin-field` has an
 internal gap between its label and input and nothing between one field and the next, so the form
 has to space its own children. On the form rather than on the submit, so the three-field
 emergency-contact popup is spaced too, and so any popup added later gets it without being
 remembered. `justify-self: start` on the submit: a grid item stretches by default, which would
 turn "Add it" into a full-width slab. Same reason `.pager` sets `width: auto`. Hidden inputs are
 `display: none` in the ua stylesheet, so they generate no grid item and no phantom gap —
 checked, because `<%= hidden_field_tag:kind %>` sits in two of these forms. */
.add-popup form {
  display: grid;
  gap: 12px;
}

.add-popup form input[type="submit"],
.add-popup form button[type="submit"] {
  justify-self: start;
}

/* The rows a search returns, as links rather than buttons — the pick travels in the
 URL because a search box is a form and HTML forbids nesting one inside another. */
.add-popup .match-list { display: grid; gap: 6px; margin: 10px 0; }

.add-popup a.match-row {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  text-decoration: none;
  color: inherit;
}

.add-popup a.match-row:hover { background: var(--bg); }

/* ── The till layout editor (FR-48) ────────────────────────────────────────

 Shaped like the till itself: pages down the side, tiles in a grid, arranged in place (D496). It reuses
 `.product-tile` rather than restyling one, so what is arranged here cannot drift from what the counter
 shows. */
.till-editor {
  display: grid;
  grid-template-columns: 15rem 1fr;
  gap: 16px;
  align-items: start;
}

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

.till-editor-pages {
  padding: 12px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
}

.till-editor-page-list { list-style: none; margin: 8px 0 14px; padding: 0; }

.till-editor-page {
  display: flex;
  align-items: center;
  gap: 4px;
  border-radius: var(--radius);
}

.till-editor-page.is-active { background: var(--paper); box-shadow: inset 3px 0 0 var(--brand); }

.till-editor-page-name {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: 7px 9px;
  text-decoration: none;
  color: inherit;
  font-weight: 600;
  font-size: 0.9rem;
}

.till-editor-page-name:hover { text-decoration: none; color: var(--ink); }

.till-editor-page-moves { display: flex; gap: 2px; padding-right: 4px; }

/* The move buttons are `button_to`, so each is wrapped in its own form. Without this the forms
 stack as blocks and the arrows sit one above the other. */
.inline-form { display: inline; margin: 0; }

.till-editor-rename { display: flex; gap: 6px; align-items: center; }
.till-editor-rename input { max-width: 16rem; }

/* Its own grid rather than `.product-grid`, and taller: the controls under each tile need the height, and
 `.product-grid` fixes its rows at the till's 8.6rem (D495). Raising that shared value would make every
 tile at the counter taller too. */
.till-editor-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
  grid-auto-rows: 10.8rem;
  gap: 8px;
}

.till-editor-tile {
  display: grid;
  grid-template-rows: 1fr auto;
  gap: 4px;
}

.till-editor-tile.is-editing .product-tile { border-color: var(--brand); }

/* The preview is not a control here — it shows what the counter will show, and every act on it is
 a button underneath. Without this it keeps the till's hover and pointer, which promises a tap
 that does nothing. */
.product-tile.is-static { cursor: default; }
.product-tile.is-static:hover { border-color: var(--line); background: var(--paper-2); }

.till-editor-tile-actions { display: flex; gap: 2px; justify-content: center; }
.till-editor-tile-actions .icon-button { width: 30px; min-height: 30px; font-size: 12px; }

.till-editor-add { margin-top: 14px; }

.till-picker-list { list-style: none; margin: 10px 0 0; padding: 0; }

.till-picker-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  border-bottom: 1px solid var(--line);
}

.till-picker-name { flex: 1; display: flex; flex-direction: column; }

.till-picker-custom { margin-top: 18px; }
.till-picker-custom summary { cursor: pointer; font-weight: 600; }

/* The picker is the shared `add-popup` (D382, D543), wider because a catalogue row carries a name,
 a price, a category and a button. Pressing the dimmed page behind it closes it — a link, like
 Done, laid under the card. */
.add-popup-card.is-wide { max-width: 640px; }
.add-popup-backdrop { position: absolute; inset: 0; }
.add-popup-backdrop ~ .add-popup-card { position: relative; }

/* A search box and its button share a row. `.add-popup form` makes every popup form a grid, which is
 right for a stack of fields and would put the button under the box here. */
.add-popup form.till-picker-search { display: flex; align-items: center; gap: 8px; }
.add-popup form.till-picker-search .search-box { flex: 1; }

/* Each Add is a `button_to`, and `.add-popup form` would make its form a grid item that stretches. */
.add-popup .till-picker-row form.inline-form { display: inline; }

/* The label, edited on the tile itself (D543). The form takes the tile's place and shape, so what
 the tile will read is typed where it will be read. */
.product-tile.till-editor-label-form {
  align-content: center;
  gap: 6px;
  cursor: default;
  border-color: var(--brand);
  background: var(--paper);
}

.till-editor-label-input { width: 100%; min-width: 0; padding: 5px 7px; font-size: 0.8rem; }

.till-editor-tile.is-editing .till-editor-tile-actions a,
.till-editor-tile.is-editing .till-editor-tile-actions button { min-height: 30px; padding: 0 9px; font-size: 12px; }

/* Shown and unselectable, never removed (FR-48, D496): a product Square deactivates keeps its place on
 the till, greyed, with the reason on it saying which system to fix. */
.product-tile.is-deactivated {
  opacity: 0.55;
  background: var(--paper);
  border-style: dashed;
}

.product-tile.is-deactivated .product-tile-effect { color: var(--red, #c0392b); font-weight: 700; }

/* The whole-till note, not a page — see the comment in the drawer. Sits above the grid so the
 Library beneath it is still usable while somebody goes and builds the pages. */
.till-no-pages { margin: 0 0 10px; }

/* ── A fixed till, for muscle memory (FR-49) ───────────────────────────────

 Staff learn a tile by its position and reach for it without looking, so nothing moves it: six across,
 five down, always, with the tiles scaling to the pane rather than the count changing (D497). */
/* The pane gives the page its height and the page gives it to the grid, or `repeat(5, 1fr)` has nothing
 to divide and every row sizes to its content, which draws one row of tiles and four of nothing. Scoped
 with `:has()` to the fixed grid alone: the Library and the Keypad manage their own overflow, and
 forcing a height on them would break both. */
.till-page:has(> .product-grid.is-fixed) { height: 100%; }

.product-grid.is-fixed {
  /* `1fr` on both axes: the pane owns the size and the tiles divide it, which is what makes them
   scale with the screen instead of the column count changing. */
  grid-template-columns: repeat(6, 1fr);
  grid-template-rows: repeat(5, 1fr);
  grid-auto-rows: unset;
  height: 100%;
  /* The type scales with the grid's own width, not the viewport's — the till is a modal inside a
   page, so a viewport unit would be measuring the wrong box. */
  container-type: inline-size;
}

/* A slot nobody has filled. It holds the position and nothing else — no border, because an empty
 cell that looks like a tile is a tile staff will try to press. */
.product-slot { min-width: 0; min-height: 0; }

/* `min-height: 0` on the tile too: a grid item's default `min-height: auto` refuses to shrink below its
 content, so without it a full row would push the grid taller than the pane and bring back scrolling. */
.product-grid.is-fixed .product-tile {
  min-width: 0;
  min-height: 0;
  padding: 4px;
}

/* Container query units, so every part of a tile scales together. `clamp` keeps it readable on a
 small screen and stops it ballooning on a large one. The ceilings suit a till sized by the screen
 (D544): the old ones were set for an 1180px till and left a large tile holding small text. */
@container (min-width: 1px) {
  .product-grid.is-fixed .product-tile-name { font-size: clamp(0.68rem, 1.55cqi, 1.3rem); }
  .product-grid.is-fixed .product-tile-meta { font-size: clamp(0.56rem, 1.2cqi, 0.95rem); }
}

/* A fixed grid cannot hold its shape on a handset: six columns at 380px is a 60px tile, smaller than a
 fingertip. Below the point where the till already goes to one column it falls back to the floating
 grid, and the empty slots go with it, since there is no position to hold once the layout reflows. On a
 phone the drawer opens on the Library anyway (D497). */
@media (max-width: 820px) {
  .product-grid.is-fixed {
    grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
    grid-template-rows: none;
    grid-auto-rows: 8.6rem;
    height: auto;
    container-type: normal;
  }

  .product-slot { display: none; }
}

/* The layout switch sits above the editor, because it changes the shape of everything below it. */
.till-layout-switch { margin-bottom: 14px; }
.till-layout-switch .panel-body { padding-top: 12px; padding-bottom: 12px; }

/* The editor's fixed grid mirrors the counter's six across but keeps its own row height rather than
 dividing a pane, because an admin page scrolls and the till does not. Matching puts slot 13 in the same
 place on both screens. */
.till-editor-grid.is-fixed { grid-template-columns: repeat(6, 1fr); }

/* An empty slot, and the only place in the app where one is pressable: here it is "put a tile in
 THIS position". Dashed and quiet, so it never reads as a tile with a blank name. */
.till-editor-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  /* Matches a tile's own height, minus the row of controls that a slot has no need of, so the
   grid rows stay level. */
  margin-bottom: calc(30px + 4px);
  border: 1px dashed var(--line-strong, var(--line));
  border-radius: var(--radius);
  color: var(--muted);
  font-size: 1.4rem;
  text-decoration: none;
}

.till-editor-slot:hover {
  border-color: var(--brand);
  color: var(--brand);
  background: var(--paper-2);
  text-decoration: none;
}

/* ── Picking a tile up and putting it down (D498) ──────────────────────────

 While one tile is armed every other cell becomes a destination, so the whole cell is the target
 rather than a small arrow on it — the press is the point, and a 30-slot grid full of small arrows
 is what this replaces. */
.till-editor-moving { margin: 0 0 12px; }

.till-editor-tile.is-moving .product-tile {
  border-color: var(--brand);
  border-style: dashed;
  background: var(--paper);
}

/* `button_to` wraps its button in a form, and a form is a block — without this the target fills
 none of the cell it is supposed to be. */
.till-editor-target-form { display: contents; }

.till-editor-target {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  /* Matches a tile's own height, minus the controls a destination has no need of, so the rows stay
   level while the grid is armed. */
  margin-bottom: calc(30px + 4px);
  padding: 6px;
  border: 1px dashed var(--brand);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--muted);
  font-size: 0.72rem;
  line-height: 1.25;
  text-align: center;
  cursor: pointer;
}

.till-editor-target:hover { background: var(--brand); color: #fff; }
.till-editor-target:hover strong { color: #fff; }

.till-editor-slot.is-target {
  width: 100%;
  border-style: dashed;
  border-color: var(--brand);
  color: var(--brand);
  font-size: 0.72rem;
  cursor: pointer;
}

.till-editor-slot.is-target:hover { background: var(--brand); color: #fff; }

.till-editor-target-label { pointer-events: none; }

/* ── Screenshots on a suggestion (FR-51) ───────────────────────────────────

 Shared by the form, where they are a preview of what is about to be sent, and the admin list,
 where they are the evidence. One definition, because two would drift the first time either was
 tweaked. */
.screenshot-thumbs {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  list-style: none;
  margin: 10px 0 0;
  padding: 0;
}

.screenshot-thumb {
  position: relative;
  width: 132px;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--paper-2);
}

/* A screenshot is wide; the thumbnail crops rather than letterboxes, so a row of them stays
 level and the top-left — where a page's own heading is — remains visible. */
.screenshot-thumb img {
  display: block;
  width: 100%;
  height: 84px;
  object-fit: cover;
  object-position: top left;
}

/* Only on the form's own previews; the admin list renders no button. */
.screenshot-thumb-remove {
  position: absolute;
  top: 3px;
  right: 3px;
  width: 22px;
  height: 22px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.62);
  color: #fff;
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
}

.screenshot-thumb-remove:hover { background: rgba(0, 0, 0, 0.85); }

/* Adding and removing a tier (D503) — sibling forms under the save-all form, because HTML
 forbids the nested one that would otherwise be natural. */
.tier-editor { border-top: 1px solid var(--line); }
.tier-add-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 10px; }
.tier-add-form .tier-add-perks { flex: 1 1 16rem; }
/* A div, not a p: `button_to` emits a form, which a paragraph cannot contain (D512). */
.tier-remove-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 14px; }

/* The what-if rate, at the top of the Tiers panel (D507). Separated from the ladder below it,
 because everything else in that panel saves and this one does not. */
.tier-rate { border-bottom: 1px solid var(--line); }
.tier-rate .custom-range-form { display: flex; align-items: flex-end; gap: 12px; }
.tier-rate p { margin: 8px 0 0; }

/* Flagged duplicate pairs, on the merge page (D511). */
.flagged-duplicates { list-style: none; margin: 0; padding: 0; }

.flagged-duplicate {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--line);
}

.flagged-duplicate:last-child { border-bottom: 0; }
.flagged-duplicate-pair { flex: 1; min-width: 0; }
.flagged-duplicate-pair p { margin: 3px 0 0; }

/* The booked-freeze line and its Cancel button, which has to sit IN the sentence — it was a `<p>`
 and the form was hoisted out of it (D512). `inline-form` keeps the button on the line. */
.booked-freeze { display: block; }
.booked-freeze .inline-form { display: inline; }
/* A button that reads as a link, for an act that sits inside a sentence. */
.link-button {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--brand-dark);
  text-decoration: underline;
  cursor: pointer;
}
/* Where a booking the old system holds is changed (D720). Its own line under the sentence, in
 the column that wraps — the figures column does not shrink, and the panel clips whatever
 overflows it. */
.booked-freeze-note { display: block; }

/* The tier perk's one-line summary (D513) — set apart from the fields it describes. */
.perk-preview {
  margin: 16px 0 0;
  padding: 10px 12px;
  border-left: 3px solid var(--brand);
  background: var(--paper-2);
}

/* ══ Customer export dialog (FR-57, D918) ═══════════════════════════════════ */
.customer-export-dialog .pin-dialog-body > h2 + p { margin: -6px 0 0; }
.customer-export-dialog .small-label { margin: 16px 0 4px; }
.customer-export-fields {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  gap: 6px 16px;
}
.customer-export-fields label.inline {
  display: flex;
  align-items: center;
  gap: 8px;
}
/* Its own width: portal.css stretches a bare input to 100% on staff pages (FR-6). */
.customer-export-fields input[type="checkbox"] { width: auto; flex: none; margin: 0; }
.customer-export-unreachable { margin-top: 16px; }
.customer-export-unreachable summary { cursor: pointer; color: var(--muted); }
.customer-export-unreachable ul { margin: 8px 0 0; padding-left: 18px; }

/* ── The cash drawer (D955) ──────────────────────────────────────────────────

 What the drawer has to say, top right, above a till or payment sheet that is open: a popover sits in
 the top layer, which nothing else outside a modal dialog does. */
.cash-drawer-said {
  inset: 18px 18px auto auto;
  margin: 0;
  max-width: min(480px, calc(100vw - 36px));
  display: none;
  align-items: center;
  gap: 12px;
}
.cash-drawer-said:popover-open { display: flex; }

.open-till-said {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: 8px 0 0;
}

/* Ask a question (D1013). The global `input, textarea { width: 100% }` would stretch the buttons across the panel,
 so they are put back to their own width here. */
.question-form { display: flex; flex-direction: column; gap: 10px; max-width: 48rem; }
.question-form textarea {
  width: 100%;
  min-height: 86px;
  resize: vertical;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--paper-2);
  color: var(--ink);
  padding: 10px;
  font: inherit;
  line-height: 1.35;
}
.question-form input[type="submit"] { width: auto; align-self: flex-start; }
.question-save input[type="submit"] { width: auto; }
.question-actions { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; margin-top: 14px; }
.question-save { display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end; }
.question-save .signin-field { min-width: 18rem; }
.question-query { margin-top: 14px; }
.question-query pre {
  white-space: pre-wrap;
  word-break: break-word;
  font-size: 13px;
  background: var(--paper-2);
  padding: 10px;
  border-radius: var(--radius);
}

/* The email editor's cell background menu (D1020). Scoped under `.sun-editor`: SunEditor's own
 stylesheet loads after this one and resets every button inside the editor. */
.sun-editor .se-list-layer.cell-colour-layer .se-list-inner { padding: 10px; width: 236px; }
.sun-editor .cell-colour-targets { display: flex; gap: 4px; margin-bottom: 8px; }
.sun-editor .cell-colour-targets button {
  flex: 1;
  padding: 4px 6px;
  font-size: 12px;
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  cursor: pointer;
}
.sun-editor .cell-colour-targets button.is-on { background: var(--ink); color: var(--paper-2); border-color: var(--ink); }
.sun-editor .cell-colour-heading { font-size: 11px; font-weight: 700; color: var(--muted); margin: 6px 0 4px; }
.sun-editor .cell-colour-swatches { display: grid; grid-template-columns: repeat(7, 1fr); gap: 4px; }
.sun-editor .cell-colour-swatch {
  height: 24px;
  border: 1px solid rgba(0, 0, 0, 0.2);
  border-radius: 4px;
  cursor: pointer;
  padding: 0;
}
.sun-editor .cell-colour-custom { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
.sun-editor .cell-colour-custom label { display: flex; align-items: center; gap: 4px; font-size: 12px; }
.sun-editor .cell-colour-custom input { width: 6.5em; padding: 3px 6px; font: inherit; font-size: 12px; }
.sun-editor .cell-colour-custom button,
.sun-editor .cell-colour-remove {
  padding: 3px 8px;
  font-size: 12px;
  background: var(--paper-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  cursor: pointer;
}
.sun-editor .cell-colour-remove { margin-top: 8px; width: 100%; }
.sun-editor .cell-colour-message { margin: 0 0 6px; font-size: 12px; color: var(--red); }

/* The AI data interrogator (D1038): a little magic on a page of its own. The hero holds an (i), so it clips nothing
   (D475); its stars sit inside it and keep still for anybody who asks the system for less motion. */
.side-nav-magic .icon { color: var(--plum); }
.ask-hero {
  position: relative;
  padding: 26px 28px 24px;
  margin-bottom: 18px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background:
    radial-gradient(circle at 92% 18%, color-mix(in srgb, var(--plum) 22%, transparent), transparent 42%),
    linear-gradient(135deg, var(--plum-soft), var(--brand-soft));
}
.ask-hero h1 { margin: 0; font-size: 1.6rem; letter-spacing: -0.01em; }
.ask-hero .ask-tagline { margin: 6px 0 18px; max-width: 46rem; color: var(--muted); }
.ask-hero.compact { padding: 16px 22px; }
.ask-hero.compact h1 { font-size: 1.2rem; }
.ask-hero.compact .ask-tagline { margin: 4px 0 0; }
.ask-star { position: absolute; color: var(--plum); opacity: 0.55; animation: ask-twinkle 3.2s ease-in-out infinite; }
.ask-star-1 { top: 14px; right: 26px; font-size: 1.4rem; }
.ask-star-2 { top: 46px; right: 70px; font-size: 0.9rem; animation-delay: 1.1s; }
.ask-star-3 { top: 20px; right: 118px; font-size: 0.7rem; animation-delay: 2s; }
@keyframes ask-twinkle { 0%, 100% { opacity: 0.2; transform: scale(0.85); } 50% { opacity: 0.8; transform: scale(1.1); } }
.ask-form { display: grid; gap: 12px; max-width: 46rem; }
.ask-form textarea { background: var(--paper); font-size: 1.05rem; }
.ask-form .ask-button { justify-self: start; }
/* A button of its own, not a theme's primary one, whose colours each theme sets more specifically. */
.ask-button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 38px;
  padding: 0 16px;
  border: 0;
  border-radius: var(--radius);
  background: linear-gradient(135deg, var(--plum), var(--brand));
  color: #fff;
  font-size: 15px;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 4px 14px color-mix(in srgb, var(--plum) 35%, transparent);
  transition: filter 0.15s ease, transform 0.15s ease;
}
.ask-button:hover { filter: brightness(1.08); transform: translateY(-1px); }
.ask-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); gap: 18px; align-items: start; }
.ask-working {
  background: linear-gradient(90deg, var(--muted), var(--plum), var(--muted));
  background-size: 200% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  animation: ask-shimmer 2.4s linear infinite;
}
@keyframes ask-shimmer { from { background-position: 200% 0; } to { background-position: 0 0; } }
@media (prefers-reduced-motion: reduce) {
  .ask-star, .ask-working { animation: none; }
}
@media (max-width: 640px) {
  .ask-hero { padding: 20px 16px; }
  .ask-star-3 { display: none; }
}
