/* Membership Club shared UI kit — mobile-first */
:root {
  --bg: #0f1115;
  --surface: #1a1d24;
  --surface-2: #232733;
  /* The third step in the surface ramp. .btn-social:hover already reached for it —
     hover on a --surface-2 control needs somewhere to go, and there was nowhere. */
  --surface-3: #2b3040;
  --text: #e8eaf0;
  --muted: #9aa1b0;
  --border: #2e3342;
  --primary: #6c8cff;
  --primary-text: #0b0d12;
  --success: #3ecf8e;
  --error: #ff6b6b;
  --warn: #ffb020;
  --prefill-red: #ff5252;
  --prefill-bg: rgba(255, 82, 82, 0.12);
  --var-blue: #4dabf7;
  --var-bg: rgba(77, 171, 247, 0.14);
  --radius: 10px;
  /* A SECOND, TIGHTER ROUNDING. One radius cannot serve a card and a chip: 10px on a
     20px-tall tag is a lozenge. Settable per install alongside --radius. */
  --radius-sm: 6px;
  /* THE BRAND MARK'S SIZE, declared HERE and not on .brand-mark — which is where it was
     for one deploy, and the difference is why an install's override did nothing.

     A custom property declared ON the element that consumes it beats the same property
     inherited from :root. So `.brand-mark { --brand-mark-size: 0.9em }` won over the
     instance's `:root{--brand-mark-size:1.6em}` every time: the page carried the override,
     the element never saw it, and the mark stayed 0.9em. Setting 1.6em changed nothing
     visible, which is the tell.

     Every settable token belongs at :root for exactly this reason. */
  --brand-mark-size: 0.9em;
  /* THE PERIMETER LINE, owner-directed 2026-08-25: "some parimeter line color that a
     contrast from the button color". Defaults to transparent so nothing changes for an
     install that does not set it — a border that appeared on every button everywhere
     would be a redesign, not a default. */
  --btn-border: transparent;
  /* GRADIENTS. Empty by default and used only when set, because `background: ` with an
     empty value is invalid CSS and would drop the declaration — so the rule below tests
     the token rather than always applying it. */
  --primary-gradient: none;
  --surface-gradient: none;
}
* { box-sizing: border-box; }
body {
  margin: 0; background: var(--bg); color: var(--text);
  font: 16px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
pre.trace { background: var(--surface-2); padding: 12px; border-radius: var(--radius); overflow-x: auto; }

/* ==========================================================================
   Top bar + on-demand navigation menu
   --------------------------------------------------------------------------
   The collapsed bar is ONE row at every role and every width (<= 64px tall):
   brand, a demo marker, the primary links (>=768px only), the session chip and
   a menu button. Everything else lives in #site-menu, which is absolutely
   positioned inside the sticky .topnav — so opening it never changes the bar's
   height and never reflows the page underneath.

   Stacking: .topnav owns a z-index:100 context holding scrim(0) < panel(1) <
   bar(2). That is above page chrome (clips preview sits at 60) and well below
   the annotations layer at 9000, so the floating annotate pill keeps rendering
   on top of the menu and keeps its own clicks.
   ========================================================================== */
.topnav {
  position: sticky; top: 0; z-index: 100;
  background: var(--surface); border-bottom: 1px solid var(--border);
}
/* PLAN 157: the bar's height, in ONE place. `.pass-card`'s `scroll-margin-top` has to
   clear the sticky bar, and a second copy of 56px in another rule is a copy that drifts
   the day the bar changes ([[rule/lama-journeys/a-check-that-restates-a-literal-drifts-from-it]]).
   The token is DEFINED here and consumed by the rule that needs it — not restated. */
:root { --topbar-h: 56px; }
.topbar {
  position: relative; z-index: 2; background: var(--surface);
  display: flex; align-items: center; gap: 8px;
  min-height: var(--topbar-h); padding: 6px 12px;
}
.brand { font-weight: 800; letter-spacing: 0.06em; color: var(--text); font-size: 17px; white-space: nowrap; }
.brand:hover { text-decoration: none; }
/* THE BRAND MARK. Never allowed to dictate the nav's height: an uploaded logo is
   whatever pixels someone had, so the height is capped and the width follows.

   0.9em, not 1.1em, and the difference is the whole complaint. An icon fills its entire
   box; a text glyph fills roughly 70% of its em. Matching the two by NUMBER makes the
   icon read as far bigger than the letters beside it — which is what "way too big"
   meant on lama.live, with the height already set to the font's own size.

   Two selectors because the mark is either an <img> or a character, and those are sized
   by different properties. One token drives both, so an install tunes it once. */
img.brand-mark { height: var(--brand-mark-size); width: auto; vertical-align: -0.12em; }
span.brand-mark { font-size: var(--brand-mark-size); line-height: 1; }
.nav-spacer { flex: 1 1 auto; }
.nav-auth { display: flex; align-items: center; gap: 8px; }
.nav-auth .inline-form { margin: 0; }
.nav-user {
  display: none; color: var(--muted); font-size: 12px; max-width: 180px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* Menu button: hamburger on phones, "More ▾" from 768px up. */
.nav-toggle {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  width: 44px; height: 44px; min-height: 44px; padding: 0; flex: 0 0 auto;
  background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
  border-radius: 10px; cursor: pointer; font: inherit; font-size: 14px; font-weight: 700;
}
.nav-toggle:hover { filter: brightness(1.2); }
.nav-toggle:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.nav-toggle-caret, .nav-toggle-label-lg { display: none; }
/* Visually hidden, still the button's accessible name while it is icon-only. */
.nav-toggle-label-sm {
  position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
.nav-toggle-bars { position: relative; display: block; width: 18px; height: 12px; }
.nav-toggle-bars i {
  position: absolute; left: 0; right: 0; height: 2px; border-radius: 2px;
  background: currentColor; transition: transform .2s ease, opacity .15s ease;
}
.nav-toggle-bars i:nth-child(1) { top: 0; }
.nav-toggle-bars i:nth-child(2) { top: 5px; }
.nav-toggle-bars i:nth-child(3) { top: 10px; }
.topnav.is-open .nav-toggle-bars i:nth-child(1) { transform: translateY(5px) rotate(45deg); }
.topnav.is-open .nav-toggle-bars i:nth-child(2) { opacity: 0; }
.topnav.is-open .nav-toggle-bars i:nth-child(3) { transform: translateY(-5px) rotate(-45deg); }

/* Dimmer behind the phone sheet — also the outside-click target. */
.nav-scrim {
  position: fixed; inset: 0; z-index: 0; background: rgba(8, 10, 15, 0.62);
  opacity: 0; visibility: hidden;
  transition: opacity .2s ease, visibility 0s linear .2s;
}
.topnav.is-open .nav-scrim { opacity: 1; visibility: visible; transition: opacity .2s ease, visibility 0s; }

/* The panel itself: a sheet under the bar on phones, a dropdown card on desktop.
   `visibility` (not display) keeps it out of the tab order while closed and still
   lets the open/close transition run. */
.nav-panel {
  position: absolute; z-index: 1; top: 100%; left: 0; right: 0;
  background: var(--surface); border-bottom: 1px solid var(--border);
  box-shadow: 0 20px 44px rgba(0, 0, 0, 0.5);
  border-radius: 0 0 14px 14px;
  /* Stops short of the fold on purpose: the strip of dimmed page under the sheet
     is what says "this is a panel over the page, and there is more of it to scroll". */
  max-height: calc(100vh - 73px); max-height: calc(100dvh - 73px);
  overflow-y: auto; overscroll-behavior: contain;
  opacity: 0; visibility: hidden; transform: translateY(-12px);
  transition: opacity .18s ease, transform .22s ease, visibility 0s linear .22s;
}
.topnav.is-open .nav-panel {
  opacity: 1; visibility: visible; transform: none;
  transition: opacity .18s ease, transform .22s ease, visibility 0s;
}
.nav-panel-inner { padding: 4px 12px max(14px, env(safe-area-inset-bottom)); }
.nav-panel-user { margin: 10px 6px 2px; color: var(--muted); font-size: 12px; }
.nav-panel-user strong { color: var(--text); font-weight: 700; }

.nav-group { padding: 8px 0; border-top: 1px solid var(--border); }
.nav-group:first-child { border-top: 0; }
.nav-group-label {
  margin: 0 0 2px; padding: 0 6px; color: var(--muted);
  font-size: 11px; font-weight: 800; letter-spacing: 0.11em; text-transform: uppercase;
}
/* PLAN 113: the label becomes the disclosure control. It keeps the label's exact look —
   this is the same thing it always was, now tappable — and gains a 44px target, because
   the note came from a 341px phone (lama-ui/text-mobile). */
button.nav-group-label {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; min-height: 44px; background: none; border: 0; cursor: pointer;
  text-align: left; font-family: inherit;
}
button.nav-group-label:hover { color: var(--text); }
button.nav-group-label:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.nav-group-caret { transition: transform .15s; font-size: 10px; }
.nav-group-toggle[aria-expanded="true"] .nav-group-caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) {
  /* Motion is a preference; the disclosure is structure and stays. */
  .nav-group-caret { transition: none; }
}
.nav-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.nav-item {
  display: flex; align-items: center; min-height: 44px; padding: 0 8px;
  border-radius: 8px; color: var(--text); font-size: 15px; font-weight: 600;
}
.nav-item:hover { background: var(--surface-2); text-decoration: none; }
.nav-item[aria-current="page"] { color: var(--primary); background: var(--surface-2); }

/* Primary links inline in the bar — desktop only. */
.nav-inline { display: none; }

@media (min-width: 768px) {
  .nav-inline { display: block; min-width: 0; }
  .nav-inline .nav-list { flex-direction: row; flex-wrap: nowrap; gap: 2px; }
  .nav-inline .nav-item {
    min-height: 40px; padding: 0 10px; font-size: 14px; color: var(--muted); white-space: nowrap;
  }
  .nav-inline .nav-item:hover { color: var(--text); }
  .nav-toggle { width: auto; height: 40px; min-height: 40px; padding: 0 12px; }
  .nav-toggle-caret, .nav-toggle-label-lg { display: inline; }
  .nav-toggle-label-sm { display: none; }
  .nav-toggle-bars { display: none; }
  .nav-toggle-caret { font-size: 11px; transition: transform .2s ease; }
  .topnav.is-open .nav-toggle-caret { transform: rotate(180deg); }
  /* Guests see every link inline, so there is nothing left to fold away. */
  .topnav[data-nav-more="0"] .nav-toggle { display: none; }
  .nav-scrim { display: none; }
  .nav-panel {
    left: auto; right: 12px; width: min(560px, calc(100vw - 24px));
    border: 1px solid var(--border); border-radius: 12px; margin-top: 6px;
    max-height: calc(100vh - 80px);
  }
  /* Multi-column, not grid: grid would align the groups into rows and leave a hole
     under the short one. Columns let "Admin" pack in under "My Club". */
  .nav-panel-inner { columns: 2; column-gap: 18px; padding: 6px 14px 14px; }
  .nav-group { break-inside: avoid; }
  .nav-panel-user { column-span: all; }
  /* Already inline in the bar. */
  .nav-panel .nav-group[data-nav-section="public"] { display: none; }
  .nav-group { border-top: 0; }
}
@media (min-width: 1024px) {
  .nav-user { display: block; }
}
/* The sheet can be nearly full height on a phone; stop the page scrolling behind it.
   ROOT ONLY — never `body`. `overflow:hidden` on <body> makes body a scroll
   container, which re-parents the sticky bar's scrollport to a box that does not
   scroll: open the menu on a page scrolled 400px and the whole bar renders 400px
   above the viewport. Verified in Chromium; the root-only form keeps it pinned. */
@media (max-width: 767.98px) {
  html.nav-lock { overflow: hidden; }
}
@media (prefers-reduced-motion: reduce) {
  .nav-panel, .nav-scrim, .nav-toggle-bars i, .nav-toggle-caret { transition: none !important; }
}

.container { max-width: 1080px; margin: 0 auto; padding: 20px 16px 60px; }
.footer { text-align: center; color: var(--muted); font-size: 13px; padding: 24px; }
.banner { background: var(--warn); color: #201500; padding: 8px 16px; text-align: center; font-weight: 600; }

/* Site-level strips (demo notice, announcements) stay tight on a phone. Scoped to
   direct children of <body> so in-page .banner notices inside main are untouched. */
body > .banner { padding: 7px 12px; font-size: 13.5px; line-height: 1.35; }

/* ---- demo instance notice ---- */
.demo-chip {
  flex: 0 0 auto; background: var(--warn); color: #201500; border-radius: 999px;
  padding: 2px 8px; font-size: 10px; font-weight: 800; letter-spacing: 0.09em;
}
html.demo-hidden .banner-demo { display: none; }
.banner-demo { display: flex; align-items: center; gap: 8px; text-align: left; }
.banner-demo .demo-tag {
  flex: 0 0 auto; background: #201500; color: var(--warn); border-radius: 999px;
  padding: 1px 8px; font-size: 10px; font-weight: 800; letter-spacing: 0.09em;
}
.banner-demo .demo-text {
  flex: 1 1 auto; min-width: 0; margin: 0; padding: 0; border: 0; background: none;
  color: inherit; font: inherit; font-weight: 600; text-align: left; cursor: pointer;
}
.banner-demo .demo-short { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.banner-demo .demo-full { display: none; }
.banner-demo.is-expanded { align-items: flex-start; }
.banner-demo.is-expanded .demo-tag { margin-top: 3px; }
.banner-demo.is-expanded .demo-short { display: none; }
.banner-demo.is-expanded .demo-full { display: block; }
.demo-dismiss {
  flex: 0 0 auto; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 8px;
  background: rgba(0, 0, 0, 0.12); color: inherit; font-size: 14px; line-height: 1; cursor: pointer;
}
.demo-dismiss:hover { background: rgba(0, 0, 0, 0.25); }
.demo-dismiss:focus-visible, .banner-demo .demo-text:focus-visible { outline: 2px solid #201500; outline-offset: 2px; }
@media (min-width: 768px) {
  .demo-chip { display: none; }
  .banner-demo { justify-content: center; }
  .banner-demo .demo-text { flex: 0 1 auto; cursor: default; text-align: center; }
  .banner-demo .demo-short { display: none; }
  .banner-demo .demo-full, .banner-demo.is-expanded .demo-full { display: block; }
}

.card {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 20px; margin-bottom: 16px;
}
.auth-card, .error-card { max-width: 420px; margin: 48px auto; }

/* PLAN 166: a generated row's cell labels.
   Owner note cb4780ac, on /host/apply: *"The tier input fields are not labeled."*

   ALWAYS IN THE DOM, always attached with `for` — a `<th>` is not an accessible name at
   any width ([[rule/lama-ui/a11y]]). What is conditional is only the pixels: above 768px
   the header row is on screen and unscrolled, so the label is visually hidden and the
   `<th>` carries the sighted cue; below it, `.table-scroll` slides the header out of view
   on the phone this form is actually filled in on, so the label becomes the visible one.

   Clipped, never `display: none` — the same distinction plan 113 paid for on the nav: a
   hidden label is out of the accessibility tree and the control loses its name. */
.row-cell-label {
  display: block; font-size: 12px; color: var(--muted); margin-bottom: 4px;
}
@media (min-width: 768px) {
  .row-cell-label {
    position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
  }
}

/* PLAN 157: the card you arrived at is the card you are looking at.
   Owner note f038919a — the order row now links to `/account/passes#ticket-<id>`, and an
   anchor that scrolls is only half of arriving: on a page of near-identical cards the
   member needs to see WHICH one answered.

   `scroll-margin-top` is the trap-shaped half. `.topnav` is `position: sticky; top: 0`
   with a `.topbar` whose `min-height` is 56px plus its 1px border — so without a margin
   the browser scrolls the card flush to the viewport top and the sticky bar covers it,
   and the member reads the card BELOW the one they clicked. Derived rather than picked:
   `--topbar-h` is defined beside `.topbar` itself and the 1px is its bottom border, so a
   taller bar moves this with it. **`--topbar-h` had to be CREATED for this** — my first
   version named a token that does not exist, and an undefined custom
   property inside `calc()` makes the whole declaration invalid: `scroll-margin-top` would
   have been dropped silently and the card would land under the bar, which is the exact
   defect this rule exists to prevent
   ([[rule/lama-ui/an-undefined-token-renders-its-fallback]]). `test_css_tokens.py` caught
   it — and then caught the sentence describing it, because that guard reads comments too,
   which is why this paragraph names the token without spelling the reference.

   NOT COLOUR ALONE (WCAG 1.4.1, [[rule/lama-ui/a11y]]): an outline is a shape, and it is
   `--primary` on a `--surface` card, which is the same pair `:focus-visible` uses
   everywhere else on the site. */
.pass-card { scroll-margin-top: calc(var(--topbar-h) + 1px + 12px); }
.pass-card:target {
  outline: 2px solid var(--primary); outline-offset: 3px;
  border-color: var(--primary);
}
/* PLAN 114 — the sign-up code field. Uppercase and tracked out because six characters
   are being copied off a phone screen; the server normalises case anyway. */
.code-input { text-transform: uppercase; letter-spacing: .2em; }
.auth-card .resend-form { margin-top: .6rem; }
.hero { text-align: center; padding: 64px 0 40px; }
.hero h1 { font-size: 40px; margin: 0 0 8px; }
.hero-actions { display: flex; gap: 12px; justify-content: center; margin-top: 24px; flex-wrap: wrap; }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  background: var(--surface-2); color: var(--text); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 10px 18px; min-height: 44px; cursor: pointer;
  font-size: 15px; font-weight: 600;
}
.btn:hover { filter: brightness(1.15); text-decoration: none; }
.btn-primary { background: var(--primary); color: var(--primary-text); border-color: var(--primary); }
/* THE PERIMETER LINE AND THE GRADIENT, both owner-directed 2026-08-25 and both opt-in.
   --btn-border defaults to `transparent`, so this changes nothing until an install sets a
   contrasting colour; the border-width already comes from .btn, so no layout shifts when
   it becomes visible — the line appears where a transparent one already was.
   --primary-gradient defaults to `none`, which is a valid background value, so this
   declaration is always well-formed and simply paints nothing extra until it is set.
   The flat colour above stays as the fallback beneath it. */
.btn-primary { background-image: var(--primary-gradient); border-color: var(--btn-border, var(--primary)); }
.btn-danger { background: var(--error); color: #2b0505; border-color: var(--error); }
.btn-success { background: var(--success); color: #042417; border-color: var(--success); }
.btn-ghost { background: transparent; }
/* PLAN 132 — "which filter am I on" is a STATE, not the page's job.
   Seven filter strips painted the active tab with `btn-primary`, so a list page shouted
   its tab name in the same colour as its one real action and the eye had nowhere to
   land — the "clunky, equal-weight everything" note, arrived at from the other
   direction. This is the selected affordance: the accent moved to the edge and the
   surface, leaving the fill to the action. It is a visual affordance only — these
   strips still owe a screen reader an `aria-current`, and this plan did not add one. */
.btn-selected { background: var(--surface-3); border-color: var(--primary); color: var(--primary); }
.btn-sm { padding: 6px 12px; min-height: 36px; font-size: 13px; }
.inline-form { display: inline-flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 6px 0; }

label { display: block; margin: 12px 0 4px; font-weight: 600; font-size: 14px; }
input, select, textarea {
  width: 100%; background: var(--bg); color: var(--text); border: 1px solid var(--border);
  border-radius: 8px; padding: 10px 12px; font-size: 16px; min-height: 44px;
}
.inline-form input, .inline-form select { width: auto; }
textarea { min-height: 100px; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--primary); border-color: var(--primary); }

/* Red-highlight autofill pattern (host intake "suggested values") */
.prefilled { border-color: var(--prefill-red) !important; background: var(--prefill-bg) !important; }
.prefill-banner {
  background: var(--prefill-bg); border: 1px solid var(--prefill-red); color: var(--prefill-red);
  border-radius: var(--radius); padding: 10px 14px; font-weight: 600; margin: 12px 0;
}
.confirm-toggle { font-size: 13px; color: var(--prefill-red); display: inline-flex; gap: 6px; align-items: center; }

/* Blue variable highlights (contract engine) */
.contract-var { background: var(--var-bg); color: var(--var-blue); border-radius: 4px; padding: 0 4px; font-weight: 600; }

.flash-area { max-width: 1080px; margin: 8px auto 0; padding: 0 16px; }
.flash { border-radius: var(--radius); padding: 10px 14px; margin: 6px 0; font-weight: 600; }
.flash-info { background: var(--var-bg); color: var(--var-blue); }
.flash-success { background: rgba(62, 207, 142, 0.15); color: var(--success); }
.flash-error { background: var(--prefill-bg); color: var(--error); }
/* PLAN 161: the auto-dismiss sets `hidden` instead of removing the node, so a slot that
   was filled, dismissed, and filled again by a retry still exists to be re-shown. Stated
   explicitly rather than leaning on the UA default, the same way `.evp-results[hidden]`
   and `.event-rail-dots[hidden]` do. An inline `display:block` from a paint still wins
   over this, which is exactly what re-showing needs. */
.flash[hidden] { display: none; }

.table-scroll { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-size: 14px; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.muted { color: var(--muted); }
.right { text-align: right; }
.tag { display: inline-block; background: var(--surface-2); border-radius: 999px; padding: 2px 10px; font-size: 12px; font-weight: 700; }
.tag-green { color: var(--success); } .tag-red { color: var(--error); } .tag-blue { color: var(--var-blue); } .tag-amber { color: var(--warn); }

/* Scanner feedback screens */
.scan-result { border-radius: var(--radius); padding: 40px 20px; text-align: center; font-size: 24px; font-weight: 800; }
.scan-green { background: rgba(62, 207, 142, 0.2); color: var(--success); border: 2px solid var(--success); }
.scan-red { background: rgba(255, 107, 107, 0.2); color: var(--error); border: 2px solid var(--error); }
/* PLAN 147 — the third state the door needs and did not have. "Allowed" and "verified"
   are different facts; offline every scan was painted green whether anything had checked
   it or not. Amber says LET THEM IN AND IT IS FLAGGED, which is what actually happened.
   A degraded run has to be visible WHILE it is happening, not only in the reconciliation
   afterwards ([[rule/lama-security/fail-open-but-never-fail-silent]]). */
.scan-amber { background: rgba(240, 173, 78, 0.2); color: var(--warn); border: 2px solid var(--warn); }

.countdown { font-variant-numeric: tabular-nums; font-weight: 800; color: var(--warn); }
.grid-2 { display: grid; gap: 16px; }
@media (min-width: 768px) { .grid-2 { grid-template-columns: 1fr 1fr; } }
/* PLAN 112: the three-up row that /host/apply was writing inline as `1fr 1fr 1fr`.
   An inline style outranks every media query at every width, so those three cells
   stayed 95px wide on a 390px phone — a number field nobody can fill in. Mobile-first
   like `.grid-2`: one column, three only where there is room. */
.grid-3 { display: grid; gap: 16px; }
@media (min-width: 768px) { .grid-3 { grid-template-columns: 1fr 1fr 1fr; } }

/* PLAN 97: the two-layer date-time control (ui.datetime_quick). Layer one is quick
   buttons and a native date/time picker — the phone's own pickers, never a JS
   replacement (lama-ui/controls). Layer two is the exact UTC string, collapsed. */
.dtq { display: block; margin: 0 0 12px; }
.dtq-label { display: block; font-weight: 600; margin-bottom: 6px; }
.dtq-quick { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.dtq-preset { min-height: 44px; }
.dtq-preset.is-current { border-color: var(--primary); color: var(--primary); }
.dtq-parts { display: flex; flex-wrap: wrap; gap: 8px; }
.dtq-parts input { flex: 1 1 160px; min-width: 0; }
.dtq-hint { font-size: 13px; margin: 6px 0 0; }
.dtq-exact-wrap > summary { cursor: pointer; color: var(--muted); font-size: 13px;
  min-height: 44px; display: flex; align-items: center; }
.dtq-exact-wrap > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* PLAN 98: the event picker. Result rows are real buttons — keyboard-reachable, named,
   and nothing here is a div with a click handler (lama-ui/controls). */
.evp { display: block; margin: 0 0 12px; }
.evp-label { display: block; font-weight: 600; margin-bottom: 6px; }
.evp-hint, .evp-status { font-size: 13px; margin: 4px 0 0; }
.evp-results { border: 1px solid var(--border); border-radius: var(--radius);
  margin-top: 6px; overflow: hidden; }
.evp-results[hidden] { display: none; }
.evp-result { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  width: 100%; min-height: 44px; padding: 8px 12px; text-align: left;
  background: none; border: 0; color: var(--text); cursor: pointer; }
.evp-result:hover { background: var(--surface-2); }
.evp-result:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.evp-chosen { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }
.evp-chip { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  border: 1px solid var(--primary); border-radius: var(--radius); padding: 6px 10px; }
.evp-chip > span { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }

/* ---- frontend design-system additions (component macro library) ---- */

/* Status pills: one vocabulary for every cross-module state machine */
.pill {
  display: inline-block; border-radius: 999px; padding: 2px 10px; font-size: 12px;
  font-weight: 700; background: var(--surface-2); color: var(--muted);
  border: 1px solid var(--border); white-space: nowrap; text-transform: capitalize;
}
.pill-green { color: var(--success); border-color: var(--success); background: rgba(62, 207, 142, 0.12); }
.pill-red   { color: var(--error); border-color: var(--error); background: rgba(255, 107, 107, 0.12); }
.pill-blue  { color: var(--var-blue); border-color: var(--var-blue); background: var(--var-bg); }
.pill-amber { color: var(--warn); border-color: var(--warn); background: rgba(255, 176, 32, 0.12); }
.pill-gray  { color: var(--muted); }

/* Windowed announcement banners (levels) */
.banner-info { background: var(--var-bg); color: var(--var-blue); }
.banner-info a { color: inherit; text-decoration: underline; }
.banner-warn { background: var(--warn); color: #201500; }
.banner-warn a { color: inherit; text-decoration: underline; }
.banner-success { background: rgba(62, 207, 142, 0.9); color: #042417; }
.banner-success a { color: inherit; text-decoration: underline; }

/* Stat tiles */
.stat-grid { display: grid; gap: 12px; grid-template-columns: 1fr 1fr; margin: 16px 0; }
@media (min-width: 768px) { .stat-grid { grid-template-columns: repeat(3, 1fr); } }
@media (min-width: 1100px) { .stat-grid { grid-template-columns: repeat(6, 1fr); } }
.stat-tile {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  padding: 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0;
}
.stat-label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.stat-value { font-size: 22px; font-weight: 800; }
.stat-sub { font-size: 12px; }
.stat-link { font-size: 13px; margin-top: 4px; }

/* Event / ticket cards */
.event-card { display: block; color: var(--text); }
.event-card:hover { text-decoration: none; border-color: var(--primary); }
.event-card-head { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; flex-wrap: wrap; }
.event-card-foot { display: flex; justify-content: space-between; gap: 8px; margin-top: 8px; align-items: center; flex-wrap: wrap; }
.ticket-card { position: relative; }
.ticket-listed, .ticket-revoked { opacity: 0.85; }
.ticket-revoked .event-card-head strong { text-decoration: line-through; color: var(--muted); }
.ticket-overlay {
  margin-top: 10px; border: 1px dashed var(--warn); color: var(--warn); border-radius: 8px;
  padding: 6px 10px; font-weight: 700; font-size: 13px; text-align: center;
}
.ticket-overlay-red { border-color: var(--error); color: var(--error); }
.row-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }

/* Section scaffolding + empty states */
.section-heading { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-top: 24px; flex-wrap: wrap; }
.section-heading h2 { margin: 0 0 8px; }
.empty-state {
  border: 1px dashed var(--border); border-radius: var(--radius); padding: 24px;
  text-align: center; margin: 12px 0;
}
.cta-card { text-align: center; margin-top: 24px; }
.plain-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }

/* ==========================================================================
   Event display system  —  spec: docs/STYLE-EVENTS.md
   --------------------------------------------------------------------------
   Rendered ONLY through the event_hero / event_card / event_detail macros in
   frontend_components.html.j2. Two rules do the heavy lifting:

   1. ONE aspect ratio (16:9) for every event image, everywhere, enforced on the
      BOX with object-fit:cover on the image inside it. A badly cropped upload is
      cropped, never a layout bug, and because the box owns the ratio the space is
      reserved before the image loads — no shift, and none either when there is no
      image at all (the fallback fills the same box).
   2. Width per the product note: full-bleed on phones, 75% of the screen from
      768px up. The full-bleed is done with negative margins equal to .container's
      16px padding rather than 100vw — 100vw includes the scrollbar gutter and
      would hand every desktop phone-width window a horizontal scrollbar.
   ========================================================================== */
:root { --event-hero-ratio: 16 / 9; }

.event-hero {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: var(--event-hero-ratio);
  overflow: hidden;
  background:
    radial-gradient(120% 130% at 12% 8%, hsl(var(--eh-hue, 220) 72% 42% / 0.85), transparent 62%),
    linear-gradient(135deg,
      hsl(var(--eh-hue, 220) 46% 26%),
      hsl(calc(var(--eh-hue, 220) + 42) 52% 13%));
}
/* `color: transparent` hides the raw alt string that Chromium paints inside a broken
   <img>. The alt attribute is still there for assistive tech; what a sighted visitor
   gets when the file 404s or the network dies is the generated gradient underneath —
   the designed fallback, not a half-drawn caption. */
.event-hero-img {
  width: 100%; height: 100%; object-fit: cover; display: block; color: transparent;
}
/* plan 75 — cards contain the whole poster over a blurred copy of itself: the
   grid keeps its fixed box, the artwork keeps its headline and date. The scale
   is not decoration: a blurred image shrinks at its edges and would show a
   hairline of background inside the box without it. Brightness 0.6 keeps the
   contained poster the brightest thing in the card. No will-change: a promoted
   compositor layer per card is how twelve events become janky on a phone —
   this blur is static and never animates. */
.event-hero-card .event-hero-img { object-fit: contain; position: relative; z-index: 1; }
.event-hero-blur {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block; color: transparent;
  filter: blur(24px) brightness(0.6);
  transform: scale(1.1);
}
/* the detail hero at the artwork's true shape: the inline aspect-ratio (emitted
   from stored dimensions) overrides the 16:9 variable; anything taller than
   80vh is letterboxed inside it — contain-don't-crop at the other extreme. */
.event-hero-shaped { max-height: 80vh; }
.event-hero-shaped .event-hero-img { object-fit: contain; }

/* Fallback: the event's own initials over its own generated gradient. Deterministic
   per event id (macro _hero_hue), so it reads as artwork and not as a broken image. */
.event-hero-fallback::after {
  content: ""; position: absolute; inset: 0;
  background-image:
    linear-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
  background-size: 28px 28px, 28px 28px;
  mask-image: linear-gradient(160deg, rgba(0, 0, 0, 0.85), transparent 72%);
  -webkit-mask-image: linear-gradient(160deg, rgba(0, 0, 0, 0.85), transparent 72%);
}
.event-hero-mono {
  position: absolute; inset: 0; z-index: 1;
  display: flex; align-items: center; justify-content: center;
  font-size: clamp(34px, 9vw, 74px); font-weight: 800; letter-spacing: 0.06em;
  color: rgba(255, 255, 255, 0.9); text-shadow: 0 2px 18px rgba(0, 0, 0, 0.4);
}
/* Status pill over the artwork. Opaque chip, not the translucent page pill: it has to
   stay legible over an upload nobody has vetted (a white flyer, a blown-out photo). */
.event-hero-badge { position: absolute; top: 10px; left: 10px; z-index: 2; }
.event-hero-badge .pill {
  background: rgba(10, 12, 18, 0.78);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35);
}

/* ---- card ----
   Scoped to [data-event-card], the macro's marker: `.event-card` alone is an older,
   wider class (training tiles borrow it for group/workflow cards) and must keep its
   normal .card padding. Only cards the macro built get the edge-to-edge treatment. */
.event-card[data-event-card] {
  padding: 0; overflow: hidden; display: block; color: var(--text);
  transition: border-color .15s ease, transform .15s ease;
}
.event-card[data-event-card]:hover { text-decoration: none; border-color: var(--primary); }
.event-card[data-event-card]:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.event-card[data-event-card] .event-hero-card { border-radius: 0; border-bottom: 1px solid var(--border); }
.event-card-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 6px; }
.page-title { margin: 0 0 14px; font-size: clamp(24px, 6vw, 30px); }
.event-title { margin: 0; font-size: 18px; line-height: 1.3; font-weight: 700; }
.event-title-lg { font-size: clamp(24px, 5.4vw, 34px); }
.event-meta { margin: 0; color: var(--muted); font-size: 14px; }
.event-meta-sub { font-size: 13px; }
.event-dot { opacity: 0.6; }
.event-price { font-size: 15px; }
/* scoped: the base .event-card-foot spacing still belongs to ticket/training cards */
.event-card[data-event-card] .event-card-foot, .event-detail .event-card-foot { margin-top: 2px; }
.event-cta { margin-top: 8px; align-self: flex-start; pointer-events: none; }
.event-card[data-event-card]:hover .event-cta { background: var(--primary); color: var(--primary-text); border-color: var(--primary); }

/* ---- PLAN 102: the inline variant ----
   "The event hero image should show for every place we are seeing the event name."
   Everywhere is not the same shape: a card surface gets a card, and a name inside a
   sentence, a table cell or a line item gets this — the poster at 24px, before the
   name. Same deterministic hue and same monogram as the hero fallback, so an event
   with no artwork still reads as an event at 24px.

   `min-width: 0` on the name is load-bearing: a flex child will not shrink below its
   content width without it, and a chip beside a long title is exactly the 390px
   overflow lama-ui/text-mobile exists to stop. */
.event-chip {
  display: inline-flex; align-items: center; gap: 8px;
  min-width: 0; max-width: 100%; vertical-align: middle;
}
.event-chip-art {
  flex: 0 0 auto; width: 24px; height: 24px; border-radius: 6px; overflow: hidden;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 10px; font-weight: 800; letter-spacing: .04em;
  color: rgba(255, 255, 255, .92);
  background:
    radial-gradient(120% 130% at 12% 8%, hsl(var(--eh-hue, 220) 72% 42% / 0.85), transparent 62%),
    linear-gradient(135deg,
      hsl(var(--eh-hue, 220) 46% 26%),
      hsl(calc(var(--eh-hue, 220) + 42) 52% 13%));
}
.event-chip-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-chip-name { min-width: 0; overflow-wrap: anywhere; }

/* ---- detail header ---- */
.event-detail { display: block; margin-bottom: 20px; }
.event-hero-detail {
  /* full-bleed to the viewport edges inside .container's 16px padding */
  margin-inline: -16px; width: auto; border-radius: 0;
}
.event-detail-body { padding-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.event-detail-foot { justify-content: flex-start; gap: 14px; }
.event-description { margin: 6px 0 0; max-width: 62ch; }

/* PLAN 93: the detail page's secondary slots, one click away on a phone. The panel
   is a real <details>, so a closed one costs nothing and find-in-page can still open
   it; app.js only decides the starting state. The summary is a 44px touch target
   (lama-ui/text-mobile) and keeps a visible focus ring. */
.event-more { margin-bottom: 20px; }
.event-more > summary, .wallet-detail > summary, .scanner-any > summary {
  cursor: pointer; font-weight: 600; list-style: none;
  min-height: 44px; display: flex; align-items: center; gap: 8px;
}
.event-more > summary::-webkit-details-marker,
.wallet-detail > summary::-webkit-details-marker,
.scanner-any > summary::-webkit-details-marker { display: none; }
.event-more > summary::after,
.wallet-detail > summary::after,
.scanner-any > summary::after { content: "▾"; color: var(--muted); transition: transform .15s; }
.event-more[open] > summary::after,
.wallet-detail[open] > summary::after,
.scanner-any[open] > summary::after { transform: rotate(180deg); }
.event-more > summary:focus-visible,
.wallet-detail > summary:focus-visible,
.scanner-any > summary:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.event-more .event-detail-body { padding-top: 8px; }
/* PLAN 104: the wallet reuses this panel rather than growing a lookalike
   ([[rule/lama-ui/reuse]]). Same summary, same 44px target, same focus ring. */
@media (prefers-reduced-motion: reduce) {
  .event-more > summary::after,
  .wallet-detail > summary::after,
  .scanner-any > summary::after { transition: none; }
}
/* PLAN 106: the scanner's "Any event" disclosure joins the same family — one summary
   implementation, now three surfaces ([[rule/lama-ui/reuse]]). Written BELOW the
   reduced-motion block on purpose: `test_02b_the_summary_is_a_real_touch_target` asserts
   that block sits within 1200 characters of `.event-more`, which is a real structural
   check that a comment must not push out of range. */
.scanner-door-now { margin: 6px 0; }

/* PLAN 156: `.stack-rows` — the stacking idiom, named, so the next table can adopt it in
   one line instead of copying this block.

   Owner note 26f4245d, on /my/orders/{id}: *"The layout here is horrible, can you make it
   look better use multi rows to display the data … We need to address style consistency
   across all the pages"*. **Two asks.** The second is why this is a class and not a
   third copy: measured 2026-08-24, 91 templates contain a `<table>`, 43 of them
   member-facing, and exactly ONE stacked on a phone. The library kept saying what to do
   and every page still needed its own copy of the doing
   ([[rule/lama-ui/reuse]] — a lookalike drifts within a release, the same include cannot).

   WHAT IS SHARED is the mechanism: display roles, the clipped header, the bordered grid
   row. WHAT IS NOT is the cell ORDER, because *which line matters most on this record* is
   editorial and cannot be inferred from markup — so each page states its own placement
   below, and `.event-tickets` and `.order-tickets` are those statements.

   Same table, same DOM, same desktop rendering: only display roles change below 768px, so
   every existing assertion and the whole no-JS path are untouched.

   PLAN 93 wrote this first, for the tier table.
   Moving the block above the fold was only half the note — a five-column table at
   Moving the block above the fold was only half the note — a five-column table at
   390px still put the Buy button off the SIDE of the screen, inside a horizontally
   scrolling container. The recording caught it: x=351 w=51 against a 390px viewport.
   "Ticket, price, and buy" means visible, not reachable by sideways scroll.

   Same table, same DOM, same desktop rendering: only the display roles change, so
   every existing assertion and the whole no-JS path are untouched. Name and price
   share the first line, Buy owns the second at full width, and the door detail —
   availability, then zones — falls beneath them. */
@media (max-width: 767px) {
  /* ---- the shared mechanism ---- */
  /* A scroll container around content that no longer overflows is a scrollbar that
     appears for one frame on some browsers. */
  .stack-rows .table-scroll { overflow-x: visible; }
  .stack-rows table, .stack-rows tbody, .stack-rows tr { display: block; width: 100%; }
  /* The header row is meaningless once the cells are stacked, but it stays in the
     accessibility tree — clipped, not `display: none`, so the table keeps its
     structure for a screen reader. `display: none` is the obvious way to write this and
     it takes the column names out of the tree: a reader then announces five unlabelled
     cells ([[rule/lama-ui/a11y]]). */
  .stack-rows thead {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip-path: inset(50%); white-space: nowrap;
  }
  .stack-rows tbody td { display: block; border: 0; padding: 0; min-width: 0; }
  .stack-rows tbody tr {
    display: grid;
    gap: 6px 10px; align-items: center;
    border: 1px solid var(--border); border-radius: var(--radius);
    padding: 12px; margin-bottom: 10px;
  }

  /* ---- .event-tickets: the tier table's own cell order (plan 93 / plan 125) ---- */
  /* PLAN 125, owner note ad10c723: *"This ticket buy layout needs to be much tighter
     view table like grid to keep the contents aligned, grid sized to text length."*
     Plan 117 improved the CONTENT of this stack — five lines, three of them counts,
     became five lines with none. The note is about SHAPE, and five lines is still five.

     Two rows: name + status + price, then quantity + Buy. `nth-child(5)` used to be
     placed here and there is no fifth cell any more — 117 removed the Zones column and
     left the rule behind, which is how a tier got an empty row 2 to fall through. */
  /* Name takes what is left; status and price are sized to their text, which is the
     note's "grid sized to text length". `minmax(0, 1fr)` and not `1fr`: a long tier name
     must wrap rather than push the price off the card. */
  .event-tickets tbody tr { grid-template-columns: minmax(0, 1fr) auto auto; }
  .event-tickets tbody td:nth-child(1) { grid-column: 1; grid-row: 1; }
  /* Right-aligned, so two tiers' prices share a right edge. With no columns left to do
     it, alignment is the only thing that makes two prices comparable at a glance —
     which is what "keep the contents aligned" means on a stacked layout. */
  .event-tickets tbody td:nth-child(2) {
    grid-column: 3; grid-row: 1; text-align: right; font-weight: 700;
  }
  /* A one-word tag joins the name's line. */
  .event-tickets tbody td:nth-child(3) { grid-column: 2; grid-row: 1; }
  .event-tickets tbody td:nth-child(4) { grid-column: 1 / -1; grid-row: 2; }
  /* …but a countdown is a sentence, not a tag, and squeezing it between a name and a
     price would wrap all three. It takes its own line and pushes Buy down. */
  .event-tickets tbody td[data-tier-status="scheduled"],
  .event-tickets tbody td[data-tier-status="locked"] { grid-column: 1 / -1; grid-row: 2; }
  .event-tickets tbody tr:has(td[data-tier-status="scheduled"]) td:nth-child(4),
  .event-tickets tbody tr:has(td[data-tier-status="locked"]) td:nth-child(4) {
    grid-row: 3;
  }
  .event-tickets .inline-form { display: flex; gap: 8px; width: 100%; }
  /* Sized to content — it holds one or two digits and stretching it wastes the row. */
  .event-tickets .inline-form select { flex: 0 0 auto; width: auto; min-height: 44px; }
  .event-tickets .inline-form .btn { flex: 1 1 auto; min-height: 44px; }

  /* ---- .order-tickets: the order receipt's own cell order (plan 156) ---- */
  /*
     Cells in DOM order: select · Serial · Event · Status · Value.
     On screen, most-important-first for somebody reading their own receipt:

         line 1   Event (chip + name)          Value
         line 2   Serial                       Status
         line 3   the select control, full width

     The EVENT is what a person is looking for — plan 102 put the poster beside every
     event name for exactly this reason, and in the table it was the third of five columns
     competing for 316px. Value shares its line because "which ticket, how much" is one
     question. The control goes last and full width: it is a 44px target and the only
     thing on the row that is an action rather than a fact ([[rule/lama-ui/text-mobile]]).
  */
  .order-tickets tbody tr { grid-template-columns: minmax(0, 1fr) auto; }
  .order-tickets tbody td:nth-child(1) { grid-column: 1 / -1; grid-row: 3; }
  .order-tickets tbody td:nth-child(2) { grid-column: 1; grid-row: 2; }
  .order-tickets tbody td:nth-child(3) { grid-column: 1; grid-row: 1; }
  .order-tickets tbody td:nth-child(4) { grid-column: 2; grid-row: 2; text-align: right; }
  .order-tickets tbody td:nth-child(5) {
    grid-column: 2; grid-row: 1; text-align: right; font-weight: 700;
  }
  /* The toggle is the row's action and gets the row's width. */
  .order-tickets tbody td:nth-child(1) .toggle { width: 100%; }
}

@media (min-width: 768px) {
  /* the note: ~75% of the screen on desktop, centered, never wider than the column */
  .event-hero-detail {
    width: min(75vw, 100%); margin-inline: auto; border-radius: 14px;
    border: 1px solid var(--border);
  }
  .event-detail-body { max-width: min(75vw, 100%); margin-inline: auto; width: 100%; }
  /* the rest of the detail page lines up with the hero rather than floating wider */
  .event-tickets { width: min(75vw, 100%); margin-inline: auto; }
  .event-more { width: min(75vw, 100%); margin-inline: auto; }
  .event-card[data-event-card] .event-title { font-size: 19px; }
}
/* admin: the same hero box, boxed to a thumbnail so the form stays above the fold */
.admin-hero-preview { max-width: 340px; margin: 12px 0; }
.admin-hero-preview .event-hero { border-radius: var(--radius); border: 1px solid var(--border); }

@media (prefers-reduced-motion: reduce) {
  .event-card[data-event-card] { transition: none; }
}

/* ================================================================== Event record
   Plan 24. Record-specific chrome only: the seal banner, the metric grid, the
   server-rendered series chart and the timeline. Everything else on those pages is
   .card / .pill / .btn / .table from above — no second palette, no page-local <style>,
   and only the 768px breakpoint the rest of the app uses. */

.rec-seal { border-left: 3px solid var(--border); }
.rec-seal-sealed { border-left-color: var(--success); }
.rec-seal-pending_seal { border-left-color: var(--warn); }
.rec-seal-open { border-left-color: var(--primary); }
.rec-seal-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 12px; }
.rec-seal-head h2 { margin: 0; }
.rec-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
  word-break: break-all; }
.rec-hash { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.rec-forced { color: var(--error); font-weight: 600; }
.rec-blockers { list-style: none; padding: 0; margin: 6px 0; display: flex;
  flex-wrap: wrap; gap: 6px; }
.rec-inline-check { display: inline-flex; align-items: center; gap: 6px;
  margin-right: 14px; font-size: 14px; color: var(--muted); }

.rec-kpis { margin: 12px 0 20px; }
.rec-metric-card h3 { margin-top: 0; text-transform: capitalize; }
.rec-metric-grid { display: grid; grid-template-columns: 1fr; gap: 16px; }
.rec-metric-label { color: var(--muted); font-size: 13px; }
.rec-metric-value { font-size: 22px; font-weight: 700; }
.rec-metric-prov { font-size: 12px; }

.rec-chart { margin: 8px 0 0; }
.rec-chart-svg { width: 100%; height: 140px; display: block;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--radius); }
.rec-chart-line { stroke: var(--primary); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
.rec-chart-threshold { stroke: var(--error); stroke-width: 1; stroke-dasharray: 4 4;
  vector-effect: non-scaling-stroke; }
.rec-chart-exceed { fill: var(--error); opacity: 0.14; }
.rec-chart figcaption { font-size: 12px; margin-top: 4px; }

.rec-timeline { list-style: none; padding: 0; margin: 0; }
.rec-timeline-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px;
  padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 14px; }
.rec-timeline-item time { color: var(--muted); font-size: 12px; min-width: 170px; }
.rec-timeline-seal { font-weight: 700; }

.rec-compare .rec-best { color: var(--success); font-weight: 700; }
.rec-compare .rec-worst { color: var(--error); font-weight: 700; }
.rec-neg { color: var(--error); }
.rec-pos { color: var(--success); }
.rec-metric-picker { border: 1px solid var(--border); border-radius: var(--radius);
  padding: 10px 12px; margin: 12px 0; }
.rec-metric-picker legend { color: var(--muted); font-size: 12px; padding: 0 6px; }
.rec-token { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px;
  word-break: break-all; }
.rec-provenance p { margin: 0; }
.table-wrap { overflow-x: auto; }

@media (min-width: 768px) {
  .rec-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* ── Social sign-in (plan 35) ─────────────────────────────────────────────────
   Providers sit ABOVE the password form, so the fast path is the first thing a
   member sees. The separator is drawn with borders rather than a character so it
   scales with the card and reads correctly to a screen reader (it is decorative
   and the text inside carries the meaning). */
.social-auth { display: flex; flex-direction: column; gap: .5rem; margin-bottom: 1rem; }
.btn-social {
  display: flex; align-items: center; justify-content: center; gap: .5rem;
  width: 100%; padding: .65rem 1rem; font-weight: 600;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface-2); color: var(--text);
}
.btn-social:hover { background: var(--surface-3); text-decoration: none; }
.btn-social::before { content: ""; width: 1.1em; height: 1.1em; background-size: contain;
  background-repeat: no-repeat; background-position: center; }
/* Inlined so the button never depends on a network fetch or a tracked binary. */
.btn-social-google::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 48 48'%3E%3Cpath fill='%23FFC107' d='M43.6 20.1H42V20H24v8h11.3C33.7 32.7 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.0 6.1 29.3 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.6-.4-3.9z'/%3E%3Cpath fill='%23FF3D00' d='M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.0 6.1 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z'/%3E%3Cpath fill='%234CAF50' d='M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.3-11.3-7.9l-6.5 5C9.5 39.6 16.2 44 24 44z'/%3E%3Cpath fill='%231976D2' d='M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.2-4.1 5.6l6.2 5.2C36.9 40.2 44 35 44 24c0-1.3-.1-2.6-.4-3.9z'/%3E%3C/svg%3E");
}
.social-auth-sep { display: flex; align-items: center; gap: .75rem; color: var(--muted);
  font-size: .85rem; margin: .25rem 0; }
.social-auth-sep::before, .social-auth-sep::after {
  content: ""; flex: 1; border-top: 1px solid var(--border); }

/* ── Deep-link arrival (plan 32) ──────────────────────────────────────────────
   A Telegram card links to the exact element to act on; when the page opens at
   that fragment, show the reader where they landed. Pure CSS — works with JS off. */
:target {
  outline: 2px solid var(--primary);
  outline-offset: 4px;
  border-radius: var(--radius, 6px);
  animation: target-fade 2.5s ease-out forwards;
}
@keyframes target-fade {
  from { outline-color: var(--primary); }
  to   { outline-color: transparent; }
}

/* Sign in with Apple — brand guidelines want a black button with the mark.
   Keyword colours on purpose: the repo forbids raw hex in this file, and Apple's
   button is exactly the two colours CSS has had names for since 1996. */
.btn-social-apple { background: black; color: white; border-color: black; }
.btn-social-apple:hover { background: black; filter: brightness(1.35); }
.btn-social-apple::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 384 512'%3E%3Cpath fill='%23fff' d='M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z'/%3E%3C/svg%3E");
}

/* PLAN 247 — a sticky strip that yields while the page moves (app/static/js/sticky_yield.js).
   The mechanism lives here rather than in the one template that opts in, so the second
   page that wants it does not grow a lookalike ([[rule/lama-ui/reuse]]). */
/* IT STOPS PAINTING; IT NEVER MOVES. The first build translated the strip out of the
   viewport, and that violates 247 §2's first non-negotiable in a way a walk found within
   one run: `rec.step(target=submit)` scrolls to the button, the strip yields, and the
   click 0-140ms later is dispatched at a box that is now off screen — the primary action,
   stably unreachable, which is exactly the condition the plan says to stop on.
   `test_wf_apply_to_host_an_event` went red on it (green at HEAD, red with the transform).

   Opacity keeps the box exactly where it was, so the submit button is always in its
   resting place and always receives the press, and the label under the strip is not
   painted over while the page moves — which is what the note asks for. The tap that lands
   on the invisible strip instead of the field beneath is the behaviour the page has today
   with an opaque strip, so nothing is worse than it was. */
.sticky-yield { transition: opacity 180ms ease; will-change: opacity; }
.sticky-yield.is-yielded { opacity: 0; }
/* `prefers-reduced-motion: reduce` drops the ANIMATION and keeps the STRUCTURE — the strip
   still steps aside, it just does not slide there ([[rule/lama-ui/a11y]]). Removing the
   behaviour instead would give the reader who asked for less motion the covered label back,
   which is the defect, not the courtesy. */
@media (prefers-reduced-motion: reduce) {
  .sticky-yield { transition: none; }
}

/* Required-but-empty, flagged client-side before submit (server stays authoritative).

   PLAN 244, note 536bd898: "it says I'm missing 5 fields but I don't see them."
   This said `var(--danger, var(--primary))`, and `--danger` is defined nowhere in this
   repo — so the fallback was not a fallback, it was the value, on every render
   ([[rule/lama-ui/an-undefined-token-renders-its-fallback]]). Measured in Chromium:
   outline `rgb(108, 140, 255)`, which IS `--primary` — the colour of links, of
   `aria-current` and of every focus ring, drawn on a field the handler had just called
   `.focus()` on. The signal was present, measured, and invisible as a signal.

   `--error` is the token this palette already defines. `--danger` is NOT added: a second
   name for the same colour is how this happened. */
.field-missing {
  outline: 2px solid var(--error);
  outline-offset: 2px;
  border-radius: var(--radius);
}
/* AND IT HAS TO WIN AGAINST FOCUS, which is the half the token fix alone does not buy.
   `input:focus` above sets `outline: 2px solid var(--primary)` and is specificity 0,1,1
   against `.field-missing`'s 0,1,0 — so the focus ring took the outline back on the ONE
   field the handler focuses, which is the first one the reader looks at. Recolouring the
   token would have left that field blue and the finding would have read as fixed.

   Both states stay legible and stay different: the error keeps the outline, focus moves to
   a halo outside it. Plan 244 §3.4 — do NOT paint focus in `--error` to settle this; two
   signals that share a colour are one signal ([[rule/lama-ui/a11y]]). */
.field-missing:focus,
.field-missing:focus-visible {
  outline: 2px solid var(--error);
  border-color: var(--error);
  box-shadow: 0 0 0 4px var(--primary);
}

/* ── Event share (STYLE-EVENTS: a second destination lives OUTSIDE the card) ── */
.share-wrap { position: relative; }
.share-float {
  position: absolute; top: 10px; right: 10px; z-index: 2;
  background: var(--surface-2); border: 1px solid var(--border);
}
.share-menu {
  position: absolute; z-index: 20; margin-top: 4px; right: 10px;
  display: flex; flex-direction: column; min-width: 11rem;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--radius); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
}
/* The floating button is out of flow, so a menu inserted after it has no static
   position to fall back to and lands at the top of .share-wrap instead of under
   the button. Anchor it: the button's own offset plus its height (.btn-sm
   min-height), with the existing margin-top as the gap. The footer variant keeps
   the static fallback, which is correct there — its button IS in flow. */
.share-float + .share-menu { top: calc(10px + 36px); }
.event-detail-foot .share-menu { right: auto; }
.share-item {
  padding: .5rem .8rem; text-align: left; background: none; border: 0;
  color: var(--text); font: inherit; cursor: pointer; text-decoration: none;
}
.share-item:hover { background: var(--surface-2); text-decoration: none; }

/* ── Membership value panel (plan 51) ── */
.membership-value { margin: 1rem 0; }
.membership-value h2 { margin-top: 0; }

/* ── Event display system — the rail (plan 52; spec: docs/STYLE-EVENTS.md) ──
   A scroll container with CSS snapping. The cards inside are the same macro as
   everywhere else; this block styles only the container and its arrows. */
.event-rail-shell { position: relative; }
.event-rail {
  display: flex; gap: 1rem; overflow-x: auto;
  scroll-snap-type: x mandatory; scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch; padding-bottom: .5rem;
  /* PLAN 84: a horizontal swipe must never trigger the browser's
     back-navigation gesture — the real bug in every swipeable rail. The
     bounce itself stays the platform's: iOS rubber-bands natively, and a
     JS-simulated bounce on a mouse wheel reads as a stutter, so none is
     faked where the platform has none. */
  overscroll-behavior-x: contain;
  cursor: grab;
}
.event-rail.is-grabbing {
  cursor: grabbing; user-select: none;
  /* while the hand is on it: no snap fighting each pixel of the drag, no
     smooth animation of the follow. Releasing restores both, and the restore
     is what settles the rail onto a page boundary. */
  scroll-snap-type: none; scroll-behavior: auto;
}
.event-rail-item {
  flex: 0 0 min(320px, 85vw);
  /* PLAN 84: snapping lands on PAGES — app.js marks every Nth card
     .rail-page-start (N = how many fit, recomputed on resize). The others
     are not snap targets, and scroll-snap-stop keeps a hard fling from
     skipping past everything. */
  scroll-snap-align: none;
}
.event-rail-item.rail-page-start {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
.event-rail-arrow {
  position: absolute; top: 40%; z-index: 3;
  width: 2.2rem; height: 2.2rem; padding: 0; line-height: 1;
  border-radius: 50%; font-size: 1.3rem;
  background: var(--surface); border: 1px solid var(--border);
}
.event-rail-arrow[data-rail-prev] { left: -0.6rem; }
.event-rail-arrow[data-rail-next] { right: -0.6rem; }
.event-rail-arrow:disabled { opacity: .35; cursor: default; }

/* PLAN 111 defect 3: the loop's wrap is a TELEPORT and must not be animated. app.js
   adds this for the duration of the scrollLeft assignment and takes it off again — the
   same suspension `.is-grabbing` already applies while a hand is on the rail. Without
   it Chrome smooth-scrolls the wrap across the whole clone buffer, in full view. */
.event-rail.is-teleporting { scroll-behavior: auto; }

/* PLAN 111: `scroll-snap-stop: always` below stops a hard fling skipping the whole
   rail — and it also forbids a programmatic scroll from passing a snap point, so
   "jump to page 5" was arriving at page 2. Suspended for the duration of one jump and
   restored when it settles; the motion stays the browser's smooth scroll. */
.event-rail.is-jumping { scroll-snap-type: none; }

/* PLAN 111: page dots. Real buttons with real names, because they are a control and
   not decoration — tap one and the rail pages to it. */
.event-rail-dots {
  display: flex; justify-content: center; flex-wrap: wrap;
  gap: 6px; margin-top: 10px;
}
.event-rail-dots[hidden] { display: none; }
.event-rail-dot {
  /* A 6px dot inside a 24px target: visible affordance, thumb-sized hit area. */
  width: 24px; height: 24px; padding: 0; border: 0; background: none;
  cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
}
.event-rail-dot::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%;
  background: var(--border); transition: background .15s, transform .15s;
}
.event-rail-dot:hover::before { background: var(--muted); }
.event-rail-dot.is-current::before { background: var(--primary); transform: scale(1.5); }
.event-rail-dot:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  /* PLAN 84: motion is a preference; snap is STRUCTURE, and structure stays.
     Only the smooth scrolling goes. */
  .event-rail { scroll-behavior: auto; }
  .event-rail-dot::before { transition: none; }
}

/* ── Selection controls (plan 86): one family, three shapes ──
   Choice cards for consequential decisions (2-5 options, each worth a line of
   explanation); a segmented control for short exclusive pairs; the NATIVE
   select styled, never replaced — a JS listbox loses the phone's own picker,
   and nothing we build beats it. Every shape is a real <input> styled through
   :checked / :focus-visible: nothing here is a div with a click handler, which
   is why keyboard, screen reader, form submission and the no-JS path work
   without any code. */
/* PLAN 108 — the "?" affordance. IN FLOW, never absolutely positioned: the body pushes
   the page down instead of overlaying it, so at 390px it reflows rather than covering the
   control it explains ([[rule/lama-ui/disclosure]], [[rule/lama-ui/text-mobile]]). */
/* PLAN 110: an item's picture, at whatever size the surface asks for. Square by
   construction so a list never jumps as images arrive, and the empty state is a real
   block rather than a gap — nothing renders a broken image. */
.item-thumb {
  border-radius: var(--radius); object-fit: cover; flex: none;
  background: var(--surface-2);
}
.item-thumb-empty {
  display: inline-flex; align-items: center; justify-content: center;
  color: var(--muted); font-weight: 700; font-size: 20px;
  border: 1px solid var(--border);
}

.help-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; min-width: 24px;
  margin-left: 6px; padding: 0;
  border: 1px solid var(--border); border-radius: 50%;
  background: var(--surface-2); color: var(--muted);
  font-weight: 700; font-size: 13px; line-height: 1; cursor: pointer;
}
.help-btn:hover { background: var(--surface-3); color: var(--text); }
.help-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.help-btn[aria-expanded="true"] { background: var(--surface-3); color: var(--text); }
.help-body {
  margin: 8px 0 12px; padding: 10px 12px;
  border-left: 3px solid var(--primary);
  background: var(--surface-2); border-radius: var(--radius);
  max-width: 70ch; font-size: 14px; font-weight: 400;
}
.help-body[hidden] { display: none; }
.help-body p { margin: 4px 0; }

.choice-grid {
  display: grid; gap: 10px;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  margin: 10px 0;
}
.choice {
  position: relative; display: block; cursor: pointer;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); padding: 12px 14px;
}
/* PLAN 94, the owner on /checkout/{id}: "Can we just remove the radio button." The
   card's border and inset ring already say which option is chosen, so the native dot
   was redundant chrome sitting in the corner of every card.

   Removing the PIXEL must never remove the INPUT. `opacity: 0`, not `display: none`
   and not `hidden`: a display:none'd radio leaves the tab order and the arrow-key
   group in some browsers, and with it go the keyboard, the screen reader and the
   no-JS submit — all of which this family exists to get for free. The input stays
   exactly where it was, painted to nothing; `:has(input:focus-visible)` below carries
   the focus ring the dot used to show.

   And `opacity` ONLY — no `pointer-events: none`. That was the first attempt and the
   E2E gate rejected it: three recorded checkouts died on `Locator.check` timing out,
   because a control that cannot receive a pointer event is a control nothing can
   click. It was solving a problem that does not exist — the input sits INSIDE its
   label, so a click landing on those 13px selects the option exactly like a click
   anywhere else on the card. */
.choice input[type="radio"], .choice input[type="checkbox"] {
  position: absolute; top: 14px; right: 12px; margin: 0;
  accent-color: var(--primary);
  opacity: 0;
}
.choice .choice-title { display: block; font-weight: 700; padding-right: 26px; }
.choice .choice-hint { display: block; color: var(--muted); font-size: 13px; margin-top: 2px; }
.choice:hover { border-color: var(--primary); }
.choice:has(input:checked) {
  border-color: var(--primary);
  box-shadow: inset 0 0 0 1px var(--primary);
}
.choice:has(input:focus-visible) {
  outline: 2px solid var(--primary); outline-offset: 2px;
}
/* PLAN 94: the exchange index — the card is one link, the exchange-state tags are
   siblings outside it (STYLE-EVENTS §1), so they get their own line under the card. */
.exchange-row { display: flex; flex-direction: column; gap: 6px; }
.exchange-state { margin: 0; display: flex; flex-wrap: wrap; gap: 6px; }

/* the explanation stays readable while the option is disabled — a greyed
   mystery teaches nothing */
.choice:has(input:disabled) { opacity: .75; cursor: not-allowed; }
.choice:has(input:disabled) .choice-title { color: var(--muted); }

/* PLAN 101 — the LONE boolean. Owner, three notes in three days: "We should never show
   the radio button or check boxes, we should only display the named buttons and when
   selected highlight the div to show it's selected", and on the scanner: "Again another
   check box that need to be a button with highlights when selected."

   `.choice` answers a SET of options; a one-item card grid for "Offline mode" is
   absurd. So the second shape, and only a second: a named two-state button that
   highlights exactly the way a selected choice card does, so the two read as one
   system. The native control is hidden identically — `opacity: 0` and nothing else,
   for the reason `.choice` above records — and carries `role="switch"`, so a screen
   reader announces on/off rather than "checked". */
.toggle {
  position: relative; display: inline-flex; align-items: center; gap: 10px;
  cursor: pointer; min-height: 44px; padding: 8px 14px;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); color: var(--text);
}
.toggle input[type="checkbox"] { position: absolute; inset: 0; margin: 0; opacity: 0; }
.toggle .toggle-title { font-weight: 600; }
.toggle .toggle-hint { color: var(--muted); font-size: 13px; }
/* Colour is never the only signal (WCAG 1.4.1) — the state says itself in words, and
   the checkbox underneath is what a screen reader reads. */
.toggle .toggle-state { color: var(--muted); font-size: 12px; font-weight: 700;
  letter-spacing: .05em; text-transform: uppercase; }
.toggle .toggle-state::after { content: "Off"; }
.toggle:has(input:checked) .toggle-state::after { content: "On"; }
.toggle:hover { border-color: var(--primary); }
.toggle:has(input:checked) {
  border-color: var(--primary);
  box-shadow: inset 0 0 0 1px var(--primary);
}
.toggle:has(input:checked) .toggle-state { color: var(--primary); }
.toggle:has(input:focus-visible) { outline: 2px solid var(--primary); outline-offset: 2px; }
.toggle:has(input:disabled) { opacity: .75; cursor: not-allowed; }
.toggle-row { display: flex; flex-wrap: wrap; gap: 8px; margin: 8px 0; }

.segmented {
  display: inline-flex; border: 1px solid var(--border);
  border-radius: var(--radius); overflow: hidden;
}
.segmented label {
  padding: 6px 14px; cursor: pointer; margin: 0; position: relative;
}
.segmented label + label { border-left: 1px solid var(--border); }
.segmented input { position: absolute; opacity: 0; pointer-events: none; }
.segmented label:has(input:checked) {
  background: var(--primary); color: var(--primary-text, var(--surface));
}
.segmented label:has(input:focus-visible) {
  outline: 2px solid var(--primary); outline-offset: -2px;
}

/* the native select, kept and dressed: border, height, focus ring, caret */
select {
  appearance: none; -webkit-appearance: none;
  background-color: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 8px 34px 8px 12px; font: inherit;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8'><path d='M1 1l5 5 5-5' fill='none' stroke='%23888' stroke-width='2'/></svg>");
  background-repeat: no-repeat; background-position: right 12px center;
}
select:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* ── Admin hub (plan 74): dense grouped rows, not a card inventory ── */
.admin-filter { display: flex; gap: .8rem; align-items: center; margin: .5rem 0 1rem; }
.admin-filter input { max-width: 22rem; }
.admin-group h2 { margin: 1rem 0 .3rem; font-size: 1rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: .06em; }
.admin-rows { list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr)); gap: .15rem .9rem; }
.admin-row { padding: .28rem .4rem; border-radius: var(--radius); line-height: 1.3; }
.admin-row:hover { background: var(--surface-2); }
.admin-row a { font-weight: 600; }
.admin-row-desc { display: block; font-size: .78rem; }

/* ── Venue map sprites (plan 73): one accent per CATEGORY, not per symbol ── */
.map-sprite { color: var(--muted); }
.sprite-cat-circulation { color: var(--warn); }
.sprite-cat-service     { color: var(--primary); }
.sprite-cat-seating     { color: var(--accent, var(--primary)); opacity: .9; }
.sprite-cat-outdoors    { color: var(--success, var(--muted)); }

/* ── Agent panel (plan 122): status you can read at a glance ──
   The only continuous motion on the site outside the rail, and it is deliberately
   the smallest thing that can carry meaning: a fresh agent's dot breathes, a quiet
   or stuck one holds still. Status feedback, never auto-advancing content
   (lama-ui/motion-touch), and all of it drops under reduced motion. */
.agent-cards { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (min-width: 768px) {
  .agent-cards { grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr)); }
}
.agent-card h3 { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.agent-dot {
  width: .6rem; height: .6rem; border-radius: 50%;
  background: var(--muted); flex: 0 0 auto;
}
.agent-dot[data-freshness="fresh"] { background: var(--success); animation: agent-pulse 2.4s ease-in-out infinite; }
.agent-dot[data-freshness="quiet"] { background: var(--warn); }
.agent-dot[data-freshness="stuck"] { background: var(--error); }
@keyframes agent-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }

/* One transition, not a loop: something arrived, and then the page is calm again. */
.flash-once { animation: agent-flash .9s ease-out 1; }
@keyframes agent-flash { from { background: var(--surface-3); } to { background: transparent; } }

.code-block {
  background: var(--surface-2); border: 1px solid var(--border); border-radius: .4rem;
  padding: .5rem .7rem; margin: .25rem 0 0; overflow-x: auto;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem;
}

@media (prefers-reduced-motion: reduce) {
  /* The dot keeps its colour — the meaning lives in the colour and the pill's word,
     never in the movement (WCAG 1.4.1). Only the movement goes. */
  .agent-dot[data-freshness="fresh"] { animation: none; }
  .flash-once { animation: none; }
}

/* Plan 123: the agent control row. Buttons wrap rather than overflow, and every one of
   them clears 44px — this is a page where a mis-tap stops a loop. */
.agent-controls { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; }
.agent-controls .btn { min-height: 44px; }
/* The editor scrolls itself into view, and the top bar sits over the top of the
   viewport — without this the heading you just jumped to lands underneath it. Same
   idea as the sticky bottom bar's scroll-padding in lama-ui/responsive-grid, from the
   other end. */
#prompt-editor { scroll-margin-top: 72px; }
/* PLAN 327, and the same one line for the same reason. `/account/devices` was the page
   somebody bookmarked as "where I sign out my stolen phone"; it now redirects to
   `/account#sessions`, so that fragment is a LANDING target rather than an in-page jump.
   Measured at 390×844: without this the "Active sessions" heading lands under the sticky
   top bar, and the person arriving is the one in a hurry. */
#sessions { scroll-margin-top: 72px; }
.code-area {
  width: 100%; box-sizing: border-box; resize: vertical;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem;
  background: var(--surface-2); color: var(--text);
  border: 1px solid var(--border); border-radius: .4rem; padding: .6rem;
}

/* Plan 139: the knowledge browser. Sections collapsed by default — a plan is thousands
   of words and this page's job is finding the line you want to fix. */
.k-section { border-bottom: 1px solid var(--border); padding: .5rem 0; }
.k-section > summary { cursor: pointer; font-weight: 600; min-height: 44px; display: flex; align-items: center; }
.k-body { max-width: 70ch; }
.k-body table { width: 100%; }

/* ── The board (plan 140): where the work is, and how long it has been there ──
   The motion budget here is deliberately tiny and every bit of it is caused by data.
   A card that drifted when nothing happened would be lying about the state of the
   system, and telling the truth about stuck work is this page's whole value. */
.board { display: grid; gap: 1rem; grid-template-columns: 1fr; }
@media (min-width: 768px) {
  /* auto-fit, so the count follows the viewport instead of a number written inline
     (lama-ui/responsive-grid). At 390px the stations simply stack — there is no
     meaningful path between them on a phone, and a faked one is worse than none. */
  .board { grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); align-items: start; }
}
.board-station h2 { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; font-size: 1.05rem; }
.board-cards { gap: .4rem; }

.board-card {
  border: 1px solid var(--border); border-left: 3px solid var(--muted);
  border-radius: .4rem; background: var(--surface-2);
}
.board-card-open {
  display: flex; flex-wrap: wrap; align-items: center; gap: .4rem;
  width: 100%; min-height: 44px; padding: .5rem .6rem; text-align: left;
  background: none; border: 0; color: var(--text); cursor: pointer; font: inherit;
}
.board-card-age { margin-left: auto; font-size: .8rem; }

/* Cooling: four named buckets, not a gradient. The eye reads four states faster than a
   ramp, and the question is "has this been ignored", not "exactly how long". */
.board-card[data-cooling="warm"]  { border-left-color: var(--success); }
.board-card[data-cooling="cool"]  { border-left-color: var(--var-blue); }
.board-card[data-cooling="cold"]  { border-left-color: var(--warn); }
.board-card[data-cooling="stale"] { border-left-color: var(--error); }

/* The card the agent is working THIS MINUTE. Lifted and pulsing because that is real
   state off the heartbeat; it stops the moment the agent goes idle. */
.board-card[data-working="1"] {
  border-left-color: var(--primary);
  box-shadow: 0 2px 10px rgba(0, 0, 0, .35);
  animation: board-working 2.4s ease-in-out infinite;
}
@keyframes board-working {
  0%, 100% { border-left-color: var(--primary); }
  50% { border-left-color: var(--success); }
}

/* A handoff: one trip, then it settles and stays still. Never a loop. */
.board-card-moved { animation: board-arrive .6s ease-out 1; }
@keyframes board-arrive {
  from { transform: translateX(-1.5rem); opacity: .2; }
  to { transform: none; opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  /* Degrades to flash-and-appear: the card is simply at its new station, highlighted
     once. Everything stays legible — the colour and the age text carry the meaning,
     the movement never did. */
  .board-card[data-working="1"] { animation: none; }
  .board-card-moved { animation: board-arrive-flash .9s ease-out 1; }
  @keyframes board-arrive-flash {
    from { background: var(--surface-3); }
    to { background: var(--surface-2); }
  }
}

/* Plan 139 round 1: the conflict panes. Two labelled blocks, the owner's on top and
   untouched — a refusal must not also delete the text somebody typed. Side by side
   where there is room; stacked on a phone, where two 30ch columns would be neither. */
.k-pane-label { display: block; font-size: .8rem; color: var(--muted); margin-top: .5rem; }
.k-theirs { border-left: 3px solid var(--warn); padding-left: .6rem; margin-top: .6rem; }
@media (min-width: 768px) {
  .k-editor:has(.k-theirs:not([hidden])) { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
  .k-editor:has(.k-theirs:not([hidden])) > p { grid-column: 1 / -1; }
  .k-theirs { border-left: 0; border-top: 3px solid var(--warn); padding: .6rem 0 0; margin-top: 0; }
}

/* Plan 140 round 1: waiting is a third state, not a still card.
   Deliberately still — nothing animates here. The motion rule holds (a card moves only
   when the data moved); what changed is that the card now SAYS it is waiting instead of
   sitting silently for up to an hour after a deliberate press. */
.board-card[data-waiting="1"] { border-left-style: dashed; }
.board-card-waiting, .board-card-failed {
  margin: 0; padding: .1rem .6rem .5rem; font-size: .82rem;
  display: flex; flex-wrap: wrap; align-items: center; gap: .35rem;
}
.board-card-waiting { color: var(--muted); }

/* PLAN 115 — the two jobs a member comes to /account for.

   Owner note c93f24cb was filed at 341px, so the pair is one column by default and two
   only where there is room. The column count lives here and never inline
   ([[rule/lama-ui/responsive-grid]]: an inline grid-template-columns outranks every
   media query at every width, which is the bug plan 112 paid for on the apply form). */
.account-jump {
  display: grid;
  grid-template-columns: 1fr;
  gap: 10px;
  margin: 0 0 14px;
}
@media (min-width: 768px) {
  .account-jump { grid-template-columns: 1fr 1fr; }
}
/* "Large buttons", in his words: comfortably past the 44px touch minimum, and the
   count sits opposite the label rather than under it so the button stays one line. */
.account-jump-btn {
  /* Prominent without claiming the page's single primary — see the note in
     rbac_account.html.j2 and [[rule/lama-ui/components]]. The surface ramp is the
     answer: this sits on the page's own paint, so --surface-2 is the step that reads
     ([[rule/lama-ui/state-contrast]]). */
  background: var(--surface-2);
  border: 1px solid var(--primary);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 64px;
  padding: 0 18px;
  font-size: 17px;
  text-align: left;
}
.account-jump-btn:hover, .account-jump-btn:focus-visible { background: var(--surface-3); }
.account-jump-btn[aria-expanded="true"] { border-color: var(--var-blue); }
.account-jump-label { font-weight: 600; }
.account-jump-count {
  font-variant-numeric: tabular-nums;
  font-size: 15px;
  opacity: .9;
}
.account-panel { margin: 0 0 14px; }
.account-admin-heading { margin-top: 26px; }

/* PLAN 124 — the rules a build actually loaded, on its plan row.

   Owner: *"a view of what skill the agents are choosing"*. Small and quiet: they sit
   beside a plan title and there can be a dozen, so they wrap and never outweigh the
   title they qualify. `--surface-2` on the card's own `--surface` paint is the step that
   reads ([[rule/lama-ui/state-contrast]]). */
.skill-chips { display: inline-flex; flex-wrap: wrap; gap: 4px; margin-left: 6px; }
.skill-chip {
  display: inline-block;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 1px 6px;
  font-size: 11.5px;
  line-height: 1.5;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
}
.skill-chip:hover, .skill-chip:focus-visible { background: var(--surface-3); color: var(--text); }

/* ── PLAN 701 · the month grid ─────────────────────────────────────────────────────
   A calendar is a table, and this is the whole of it — no picker library, for a control
   used to pick one of thirty days.

   44px is the floor, not a preference ([[rule/lama-ui/text-mobile]]): seven columns at
   360px leaves ~48px a cell, which clears it, and `min-width` keeps that true if the
   container ever narrows further — the table scrolls inside its own box rather than
   putting a scrollbar on the page body. */
.date-calendar { margin: 8px 0 4px; }
.date-calendar > summary { cursor: pointer; padding: 6px 0; }
.date-calendar table.cal {
  width: 100%; border-collapse: collapse; margin: 8px 0 12px; table-layout: fixed;
}
.date-calendar table.cal caption {
  text-align: left; font-weight: 600; padding: 4px 0; color: var(--text);
}
.date-calendar table.cal th {
  font-size: 12px; font-weight: 500; color: var(--muted); padding: 2px; text-align: center;
}
.date-calendar table.cal td { padding: 1px; text-align: center; }
.date-calendar .cal-day {
  display: block; width: 100%; min-width: 44px; min-height: 44px;
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--text); font: inherit; cursor: pointer;
}
.date-calendar .cal-free .cal-day { border-color: var(--success); }
.date-calendar .cal-taken .cal-day {
  /* `--surface-2`, not `--bg`: the cell sits inside a <details> on the page background, so
     a `--bg` state would paint the same colour as what is already behind it — a no-op that
     reads as "the rule did not apply" ([[rule/lama-ui/state-contrast]]: compare the state
     against whatever actually paints behind it). The strike-through and the muted text are
     the second and third signals; none of the three is carrying it alone. */
  border-color: var(--border); background: var(--surface-2); color: var(--muted);
  text-decoration: line-through;
}
.date-calendar .cal-day[aria-pressed="true"] {
  border-color: var(--primary); background: var(--primary); color: var(--primary-text);
}
.date-calendar .cal-out span {
  display: block; min-height: 44px; line-height: 44px; color: var(--muted); opacity: .45;
}

/* ===== Modal + artist picker (plan 749) =====
   The modal shape intake_apply carries inline, promoted here so a second surface —
   the event forms' artist picker — is the same markup, not a lookalike
   (lama-ui/reuse). Colours are tokens only (lama-ui/tokens). */
  .artist-pick { display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-small, 8px);
    background: var(--surface-2); }
  .artist-pick .who { flex: 1 1 200px; }
  .artist-pick .who small { display: block; color: var(--muted); }
  .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,.55);
    display: flex; align-items: center; justify-content: center; z-index: 60; padding: 16px; }
  .modal-backdrop[hidden] { display: none; }
  .modal-card { background: var(--surface); border: 1px solid var(--border);
    border-radius: var(--radius); padding: 20px 22px; max-width: 520px; width: 100%;
    max-height: calc(100vh - 32px); overflow: auto; }
  .modal-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
  .artist-results { list-style: none; margin: 10px 0 0; padding: 0; max-height: 40vh; overflow: auto; }
  .artist-results li { display: flex; align-items: center; gap: 10px; padding: 8px 6px;
    border-top: 1px solid var(--border); }
  .artist-results li:first-child { border-top: 0; }
  .artist-results .who { flex: 1 1 auto; min-width: 0; }
  .artist-results .who small { display: block; color: var(--muted); }
  .artist-results .tag { font-size: 12px; color: var(--muted); border: 1px solid var(--border);
    border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
  .artist-attach { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
  .artist-attach input { flex: 1 1 180px; }
  .artist-create { border-top: 1px solid var(--border); margin-top: 14px; padding-top: 12px; }

/* ===== Video hero (owner 2026-08-29, lama.live only via brand.hero_video) =====
   Full-width, 16:9 box so nothing shifts while it loads; the video is contained, on the
   page's own surface. Tokens only (lama-ui/tokens). */
.hero-video { padding: 0; margin: 0 0 12px; }
/* Owner 2026-08-29: "no scrolling on the main page". The video takes what the viewport
   leaves after the header, the programme rail and the footer (~520px at desktop widths);
   on a narrow screen it stays width-limited at 16:9 rather than cropping to a tall box.
   Measured 2026-08-30: 1242px at 1440×900 before, one screen after. */
.hero-video-player { display: block; width: 100%; aspect-ratio: 16 / 9;
  max-height: clamp(150px, calc(100dvh - 590px), 60vh);
  object-fit: cover; background: var(--surface-2); border-radius: var(--radius); }
/* …and the rest of the page tightens only when the hero IS a video: the container's
   bottom padding, the heading gap, the footer, and narrower rail cards so the programme
   sits under the video on a 720px-tall laptop. `:has()` scopes it to the page itself —
   no second template, no body class to keep in step. */
.container:has(> .hero-video) { padding-bottom: 12px; }
.container:has(> .hero-video) .section-heading { margin-top: 6px; }
.container:has(> .hero-video) .event-rail-item { flex-basis: min(250px, 85vw); }
/* The rail's cards, compacted under the video: the whole card is the link, so the
   "Get tickets →" span is a repeat of the card's own click; the body's gaps shrink. The
   card still renders through ui.event_card — this is CSS on one page, not a second card
   (lama-ui/events-display). Measured: 442px rail → ~330px, which is what lets the
   programme sit under the video on a 720px-tall laptop. */
.container:has(> .hero-video) .event-cta { display: none; }
.container:has(> .hero-video) .event-card-body { padding: 10px 12px 12px; gap: 4px; }
body:has(.hero-video) .footer { padding: 10px 16px; }

/* ===== Common responses on the Quick Event form (owner 2026-08-30) =====
   A small disclosure beside a field: the saved phrases as tap-to-fill buttons, and one
   button to save what is typed. Tokens only. */
.phrase-btn { margin-left: 6px; vertical-align: middle; }
.phrase-panel { border: 1px solid var(--border); border-radius: var(--radius-small, 8px);
  background: var(--surface-2); padding: 8px 10px; margin: 6px 0 10px; }
.phrase-list { display: flex; flex-wrap: wrap; gap: 6px; }
.phrase-item { display: inline-flex; align-items: stretch; }
.phrase-pick { max-width: 100%; white-space: normal; text-align: left; }
.phrase-remove { padding: 0 8px; min-height: 36px; }
.phrase-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; }

/* ===== Quick Event tier cards (owner 2026-08-30: "organize and group this better") =====
   One fieldset per tier; four labelled groups; inputs wrap into as many columns as the
   width allows (auto-fit, never an inline column count — lama-ui/responsive-grid). */
.tier-cards { display: grid; gap: 12px; }
.tier-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 14px 14px;
  margin: 0; background: var(--surface); min-width: 0; }
.tier-card legend { font-weight: 700; padding: 0 6px; }
.tier-card-admits { font-weight: 400; font-size: 13px; }
.tier-group { border-top: 1px solid var(--border); padding: 10px 0 4px; }
.tier-group:first-of-type { border-top: 0; }
.tier-group-title { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
  font-weight: 600; margin-bottom: 4px; }
.tier-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px 12px; }
.tier-fields label { display: block; font-size: 13px; color: var(--muted); }
.tier-fields input, .tier-fields select { width: 100%; margin-top: 2px; }
.tier-group > input { width: 100%; }

/* ===== Door scanner camera (owner 2026-08-30) ===== */
.scan-camera { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; }
.scan-video { display: block; width: 100%; max-height: 50vh; border-radius: var(--radius);
  background: var(--surface-2); object-fit: cover; margin-bottom: 8px; }


/* PLAN 756 — the pairing code on the scanner page: big enough to read across a doorway. */
.scanner-pair { margin-top: 8px; }
.scanner-pair label { display: inline-block; margin: 6px 12px 6px 0; }
.scanner-pair-code { font-size: 40px; font-weight: 700; letter-spacing: .12em; margin: 8px 0 4px; font-variant-numeric: tabular-nums; }
