/* ==========================================================================
   GATHER — design system
   ==========================================================================

   Light-first, with a dark counterpart. Rebuilt from the ground up rather
   than retinted: radii, spacing, type scale, elevation and component
   shapes are all new, not just the palette.

   ---- The checkout-theme contract -----------------------------------------

   Owners can apply a colour theme to their own checkout pages. That works
   by overwriting eleven custom properties at runtime (see applyCheckoutTheme
   in js/app-helpers.js):

       --glaze  --glaze-rgb  --glaze-dark  --glaze-ink
       --ink    --ink-rgb    --surface     --surface-rgb
       --paper  --paper-rgb  --line

   Those eleven are the entire contract. Everything structural in this file —
   radius, spacing, type, borders, shadow — is expressed in OTHER tokens, so
   a checkout theme can only ever change colour. It cannot alter layout, and
   that separation is deliberate: keep it that way. If a new component needs
   to vary by theme, express it through the eleven above rather than adding
   a twelfth.

   ---- Reading this file ---------------------------------------------------

   1  Foundation      tokens, reset, type
   2  Layout          app column, page chrome
   3  Surfaces        cards, sheets, the ticket stub, modals
   4  Controls        buttons, toggles, steppers, switches
   5  Forms           fields, inputs, validation
   6  Tags & chips
   7  Event page      carousel, price badge, ticket detail
   8  Calendar & time ported geometry, re-skinned
   9  Scan & QR
   10 Utilities       carried over unchanged — the JS depends on them
   11 Dark theme      the eleven tokens, re-pointed
   ========================================================================== */


/* ==========================================================================
   1  FOUNDATION
   ========================================================================== */

:root {
  color-scheme: light;

  /* ---- the eleven a checkout theme may overwrite ---- */
  --ink: #ebeaea;              /* page ground */
  --ink-rgb: 235,234,234;
  --surface: #ffffff;          /* cards */
  --surface-rgb: 255,255,255;
  --paper: #111111;            /* primary text */
  --paper-rgb: 17,17,17;
  --glaze: #111111;            /* accent, and the primary-action fill */
  --glaze-rgb: 17,17,17;
  --glaze-dark: #000000;       /* accent, pressed */
  --glaze-ink: #ffffff;        /* text that sits ON the accent */
  --line: rgba(17,17,17,.10);

  /* The wash behind something chosen — a picked calendar day, a pressed
     toolbar button. It cannot be one fixed alpha of --paper: that ink is
     near-black in this theme and near-WHITE in dark, and 7% white on a
     near-black ground is invisible, which is what made a selected day
     impossible to find in dark mode. Each theme states its own strength. */
  --selected-wash: rgba(var(--paper-rgb),.07);
  --selected-wash-strong: rgba(var(--paper-rgb),.10);

  /* Stable copies of the real defaults. applyCheckoutTheme overwrites the
     tokens above, so the "Default" swatch needs its own untouched copy to
     preview — otherwise it would show whichever theme is currently on. */
  --default-glaze: #111111;
  --default-glaze-dark: #000000;
  --default-surface: #ffffff;

  /* ---- semantic colour: NOT part of the theme contract ----
     Meaning-carrying, so it stays put when a checkout theme changes the
     chrome. Every value clears 4.5:1 against both --surface and --ink. */
  --danger: #c0392b;      --danger-rgb: 192,57,43;
  /* Two ambers, because one value cannot do both jobs. --amber is read as
     text on a white card, so it has to clear 4.5:1, and anything yellow
     enough to look yellow is brown by the time it gets dark enough.
     --amber-hue is only ever a fill — dots and tints — where being seen is
     the whole requirement, so it can sit where the colour actually reads. */
  /* Brighter than the old #bf5700, which read brown. This is as far as it
     goes while white text on the fill still clears AA (4.5:1) — the
     submit button on the create form is filled with this and labelled in
     white. --amber-hue carries no text, so it goes brighter still. */
  --amber: #c25400;       --amber-rgb: 194,84,0;
  --amber-hue: #f28c00;   --amber-hue-rgb: 242,140,0;
  --free-color: #16803c;  --free-color-rgb: 22,128,60;
  --recurring: #7b3fb5;   --recurring-rgb: 123,63,181;
  --free-recurring: #1a73e8; --free-recurring-rgb: 26,115,232;

  /* ---- muted text, derived so it tracks whatever --paper becomes ---- */
  --muted: rgba(var(--paper-rgb),.52);
  --muted-soft: rgba(var(--paper-rgb),.38);

  /* ---- shape ---- */
  --radius-lg: 22px;
  --radius-md: 13px;
  --radius-sm: 9px;
  --radius-pill: 999px;
  /* ---- elevation. Two layers: a tight one to seat the element against
     the page, a wide soft one to lift it off. A single blur can do one or
     the other but reads flat attempting both. ---- */
  /* one soft, wide shadow rather than a tight pair — the reference lifts
     cards off the page instead of outlining them */
  --shadow-card: 0 10px 30px rgba(0,0,0,.07);
  --shadow-float: 0 2px 6px rgba(0,0,0,.18), 0 12px 32px rgba(0,0,0,.30);
  --shadow-modal: 0 8px 24px rgba(0,0,0,.14), 0 32px 64px rgba(0,0,0,.20);

  /* ---- rhythm ---- */
  /* 22px, from the test page's .section rhythm. The app had been sitting
     at 14, which is why it read tighter and busier than the reference
     even though the structure was the same. */
  --gap: 22px;
  /* The vertical rhythm, named so pages stop inventing their own numbers.
     --gap is the standard distance between two stacked blocks; the rest
     are the deliberate exceptions either side of it. */
  --space-pair: 6px;     /* two things that read as one unit */
  --space-heading: 8px;  /* a heading and the content it introduces */
  --space-section: 24px; /* a genuine break between sections */

  /* The type scale. Ten steps, and nothing should be added between them —
     the stylesheet previously carried 39 distinct sizes, most of which were
     a fraction of a pixel apart and existed only because they were written
     at different times. Names rather than numbers so a new rule has an
     obvious thing to reach for.
       micro   .62  legends, badge text, eyebrows
       caption .66  captions, helper text
       meta    .72  secondary body, list meta
       body    .78  body copy, inputs
       lead    .86  emphasised body
       title   .92  h3, card titles
       h2     1.05
       h1     1.35
       figure 1.5   a single reported number
       hero   2     the one big number on a payment screen */
  --fs-micro:.62rem; --fs-caption:.66rem; --fs-meta:.72rem; --fs-body:.78rem;
  --fs-lead:.86rem;  --fs-title:.92rem;   --fs-h2:1.05rem;  --fs-h1:1.35rem;
  --fs-figure:1.5rem; --fs-hero:2rem;
  /* clearance for the floating bar: its tallest form (66px) plus its own
     bottom offset and a gap, so content ends clear of it rather than
     against it */
  /* Clearance for the floating bar: its own height, how far it floats off
     the bottom, and then the same 28px every other page ends with — so a
     page with a bar breathes exactly like a page without one. */
  /* bar height + the gap it floats above the edge + breathing room. The
     middle term has to match the bars' bottom padding or the last card
     ends up under the bar. */
  /* How far the floating bar sits from every edge. The clearance below reads
     it, so the gap and the room left for it cannot drift apart. */
  --bar-inset: 12px;
  /* The bar, its inset, and one ordinary gap between boxes — so the last
     thing on a page sits the same distance above the bar as any two cards
     sit from each other. It was 28, which is not a spacing this app uses
     anywhere else. */
  --footer-space: calc(66px + var(--bar-inset) + 22px + env(safe-area-inset-bottom));
  --pad-card: 20px 18px;

  /* ---- content column. Phone-first, but not phone-only: past ~760px
     text lines get too long to read comfortably, so it stops there. ---- */
  --app-max-w: 520px;
  --app-pad-x: 16px;

  /* intl-tel-input reads these; mapped onto our tokens so its dropdown
     follows the theme instead of shipping its own light palette */
  --iti-border-color: var(--line);
  --iti-country-selector-bg: var(--surface);
  --iti-hover-color: rgba(var(--paper-rgb),.06);
  --iti-icon-color: rgba(var(--paper-rgb),.6);
}

@media (min-width:768px)  { :root { --app-max-w:640px; --app-pad-x:28px; } }
@media (min-width:1100px) { :root { --app-max-w:760px; --app-pad-x:32px; } }

* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }

/* THE KNOB FOR TEXT SIZE.
   Every font-size below is rem, i.e. a multiple of this. It is a
   percentage rather than a px value so it inherits the reader's own
   browser text-size setting instead of overriding it. It scales with the
   viewport because a phone is held a foot from your face and a monitor
   sits at arm's length — one global value can only be right for one. */
html {
  font-size:112.5%;
  -webkit-text-size-adjust:100%; -moz-text-size-adjust:100%; text-size-adjust:100%;
}
@media (min-width:768px)  { html { font-size:130%; } }
@media (min-width:1100px) { html { font-size:140%; } }

html, body {
  margin:0; padding:0; min-height:100vh;
  background:var(--ink); color:var(--paper);
  font-family:"Google Sans","Google Sans Text",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
  line-height:1.5;
}

/* A fixed gap below a heading rather than .35em. Being em-based, the old
   value scaled with the heading's own size — an h1 left 8.5px under it,
   an h3 5.8px — so the distance between a heading and its content changed
   from page to page purely with which level that page used. */
h1,h2,h3 { margin:0 0 var(--space-heading); font-weight:700; letter-spacing:-.03em; line-height:1.14; }
h1 { font-size:1.35rem; }
h2 { font-size:1.05rem; }
h3 { font-size:.92rem; letter-spacing:-.02em; }
p  { margin:0; }
a  { color:inherit; }

/* the workhorse secondary-text class — 128 uses across the app */
.meta { font-size:.72rem; color:var(--muted); margin:0; }
/* THREE ROLES, ONE DEFINITION EACH.
   These had drifted into four title styles and four different gaps under
   a heading — .92rem/700 next to .92rem/800, and 8px, 6px, 4px and 0
   below, all hard-coded. A settings section, an analytics box and an
   event card were three different-looking pages as a result.

   .eyebrow   names a section above its content
   .card-heading  titles a card or a record
   .card-sub      the line of detail under either of them          */
.eyebrow {
  text-transform:uppercase; letter-spacing:.12em; font-size:var(--fs-micro);
  color:var(--muted); font-weight:700; margin:0 0 var(--space-heading);
}
.card-heading,
.event-card-title {
  font-size:var(--fs-title); font-weight:700; letter-spacing:-.02em;
  margin:0 0 var(--space-pair);
}
.card-sub,
.event-card-meta,
.event-card-signup {
  font-size:var(--fs-meta); color:var(--muted); margin:0 0 var(--space-pair);
}
/* the message text on a notification card, which had no rule at all and
   fell back to the browser's default paragraph */
/* Set like the support conversation rows, which is the list-card idiom the
   rest of the site uses: a compact bold line, then the timestamp under it.
   These were running at title size, which made a notification the loudest
   text on any screen it appeared on and gave the list a rhythm nothing else
   in the app has. Unread keeps the heavier weight. */
.notif-message {
  font-size:var(--fs-caption); font-weight:600;
  color:var(--paper); margin:0; line-height:1.5;
}
.notif-message.unread { font-weight:700; }
.banner {
  background:rgba(var(--glaze-rgb),.10); border:1px solid var(--glaze);
  border-radius:var(--radius-md); padding:10px 14px;
  font-size:var(--fs-meta); margin-bottom:var(--gap);
}
.banner-row { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.divider { height:1px; background:var(--line); border:none; margin:16px 0; }
.field-hint { margin:var(--space-pair) 0 0; font-size:var(--fs-meta); color:var(--muted); }
.note-centered { text-align:center; font-size:.66rem; color:var(--muted); }
.link-underline-glaze { text-decoration:underline; text-underline-offset:2px; cursor:pointer; font-weight:600; }
.cancelled-red { color:var(--danger); }

/* Switching a tab used to animate every direct child of the page
   independently — each one sliding up 4px into place. A dozen cards each
   moving on their own arrival reads as the content falling into the page
   rather than as the page being there. The page-level fade on .page-in is
   the whole arrival now; nothing moves.

   pageEnter is kept because the check-in screens still use it for a mark
   popping into view, which is a single element reacting to an action
   rather than a page assembling itself. */
@keyframes pageEnter { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }

@media (prefers-reduced-motion: reduce) {
  * { scroll-behavior:auto !important; }
}


/* ==========================================================================
   2  LAYOUT
   ========================================================================== */

#app { max-width:var(--app-max-w); margin:0 auto; padding:14px var(--app-pad-x) 28px; }

/* One row, always. The brand is 96px and the controls 236px, which came
   to 344 in a 343px bar at 375px wide — one pixel over, so the whole
   control cluster wrapped underneath the logo. Wrapping is the wrong
   answer for a bar of six controls anyway: the brand gives way instead. */
.topbar {
  display:flex; align-items:center; justify-content:space-between;
  margin-bottom:22px; flex-wrap:nowrap; gap:10px;
}

/* Stays put while an owner scrolls. Their pages are long — a month of
   events, a list of attendees — and every control for getting somewhere
   else lives in this row, so scrolling down meant scrolling all the way
   back up to leave the page.

   Only on owner pages: the attendee event page hands its controls to the
   hero over the photo, where a sticky row would sit on top of the image.
   The negative top and matching padding are what stop content showing
   through the gap above it as it scrolls under. */
body:has(#app > .topbar):not(:has([data-event])) .topbar {
  position:sticky; top:0; z-index:40;
  background:var(--ink);
  margin-top:-14px; padding-top:14px; padding-bottom:10px;
  margin-bottom:12px;
}
/* the mode toggle and the notification/profile icons must stay on one
   line — only the whole group drops below the logo when space is tight,
   it never splits apart internally */
.topbar-controls { display:flex; align-items:center; gap:6px; flex-wrap:nowrap; flex-shrink:0; }
/* The wordmark.

   Set as text rather than shipped as an image: it has to sit on a bar that
   is sometimes over a photograph and sometimes not, at whatever size the
   viewport gives it, and a picture of letters cannot take the page's own
   colour when it does. One font, one weight, one colour token — and it
   stays legible when the font has not arrived yet, because the fallback
   stack below is a real stack rather than a hopeful name.

   The icon that used to sit beside it is gone: the mark is the word. */
/* The mark: two offset halves, and the word split where they are.

   Only ONE of the two colours is new. The first half and the word 'class'
   are --paper, which is this app's own primary text — so they are black on
   the light theme and near-white on the dark one without being told to be.

   The second is the brand's own, and it has to be: the palette here is
   monochrome, so --glaze is #111 in the default theme and the two halves
   would have come out the same colour. --glaze is also the token a checkout
   theme overwrites, which would have meant the logo changing hue depending
   on which event somebody happened to be looking at. It gets its own token,
   stated once per theme, exactly as the app's own colours are. */
:root {
  --brand-font: "Manrope", "Segoe UI", system-ui, -apple-system, sans-serif;
  /* Lifted off the printed navy, because the light theme has near-black text
     and #2a3f6d sits only 22 points of L* away from it -- so the two halves
     came out almost the same colour and the split stopped reading. The dark
     theme separates its halves by 28 points; this is 30, which is the same
     relationship rather than the same hex. Still unmistakably the logo navy. */
  --brand-accent: #33509b;
}
:root[data-theme="dark"] {
  /* Lifted, because the navy of a printed logo is not legible on a near-black
     ground. Same hue, enough lightness to clear the surface behind it. */
  --brand-accent: #8fa6d8;
}
.brand {
  display:flex; align-items:center; gap:9px;
  font-family:var(--brand-font);
  font-weight:800; letter-spacing:-.035em; font-size:1.22rem;
  min-width:0; overflow:hidden;
}
/* Never shrinks: the word may be clipped on a narrow bar, the mark may not,
   because a half-drawn logo reads as a broken image. */
.brand-icon { width:25px; height:25px; flex:none; display:block; }
.brand-icon .half-a { fill:var(--paper); }
.brand-icon .half-b { fill:var(--brand-accent); }
.brand-word .a { color:var(--paper); }
.brand-word .b { color:var(--brand-accent); }
.brand > span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.brand .dot { width:9px; height:9px; border-radius:50%; background:var(--glaze); }

.row-between   { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.row-center-gap{ display:flex; align-items:center; gap:8px; }
.row-center-mid{ display:flex; align-items:center; justify-content:center; }
.flex-gap8     { display:flex; gap:8px; }
.u-mt-section { margin-top:var(--space-section); }
.u-mb-section { margin-bottom:var(--space-section); }
/* display:block is not optional here. Every one of these is used on a
   <button>, and margin:auto computes to zero on an inline-block — so the
   class named center-block was centring nothing, leaving each of them
   sitting against the left edge. */
.center-block    { display:block; margin-left:auto; margin-right:auto; }
.center-block-10 { display:block; margin:10px auto 0; }
.center-block-12 { display:block; margin:12px auto 0; }

/* Floating action bar. Detached from the window edges rather than
   spanning them, so content scrolls visibly beneath it and it reads as
   hovering above the page instead of cutting it off. */
.sticky-form-footer {
  position:fixed; left:0; right:0; bottom:0; z-index:30;
  /* One inset on every side, so the bar sits in an even frame rather than
     12px from the sides and 18px from the bottom. */
  padding:0 var(--bar-inset) calc(var(--bar-inset) + env(safe-area-inset-bottom));
  pointer-events:none;
}
.sticky-form-footer > * { pointer-events:auto; }
.sticky-form-footer .footer-inner,
.sticky-form-footer > .row-between {
  max-width:calc(var(--app-max-w) + 16px); margin:0 auto;
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  background:var(--glaze); color:var(--glaze-ink);
  border-radius:20px; padding:11px 11px 11px 22px;
  box-shadow:var(--shadow-float);
}
.sticky-form-footer .meta { color:rgba(var(--glaze-rgb),.001); }
.sticky-form-footer.overlapping { }


/* ==========================================================================
   3  SURFACES
   ========================================================================== */

/* A stacked card's spacing is the card's, not the call site's. These
   utilities were each added to a card to mean "space before the next
   one", with a different number every time — 10, 12, 16 — while .card
   already carries --gap. Adjacent margins collapse to the larger, so the
   real gap silently depended on which utility that page happened to
   reach for, and came out as 10, 14 or 20 across the app. Anything from
   --space-section up is deliberately left alone: at that size it reads as
   a section break rather than rhythm. */
.card.u-mt10, .card.u-mt12, .card.u-mt16 { margin-top:0; }
.card.u-mb10 { margin-bottom:var(--gap); }

/* Shadow only, no outline — the reference lifts a card off the page
   rather than drawing a box around it, and the border was doing both at
   once. Cards that mean something specific (an unread notification, a
   live waitlist offer, the current plan) still add one deliberately. */
/* A card that is a <button> — the support conversation rows and the ticket
   lookup results — inherited the button rule's white text and kept the
   card's white surface, so any text in it with no colour of its own was
   white on white. The conversation subject and the found ticket's event
   title were both invisible. .card sets the surface, so it has to set the
   ink that goes on it. */
button.card { color:var(--paper); }
.card {
  position:relative;
  background:var(--surface); border:1px solid transparent;
  border-radius:var(--radius-lg); padding:var(--pad-card);
  margin-bottom:var(--gap); cursor:pointer;
  box-shadow:var(--shadow-card);
  transition:box-shadow .18s ease, transform .18s ease, border-color .18s ease;
}
.card-static { cursor:default; }
@media (hover:hover) and (pointer:fine) {
  /* the lift is the whole hover — no border change, which read as the
     card flickering a different shape under the cursor */
  .card:not(.card-static):hover { transform:translateY(-2px); box-shadow:0 16px 36px rgba(0,0,0,.11); }
}
.card:not(.card-static):active { transform:scale(.995); }

.empty-state { text-align:center; padding:40px 16px; color:var(--muted); }

/* ---- modals ---- */
/* Above the booking sheet, which is at 1200.
   A dialog is a decision ABOUT what is on the surface underneath it, so it has
   to be on top of that surface. At 1000 it was underneath: pressing "Cancel
   entire reservation" inside the sheet opened its confirmation behind the
   sheet, where it could not be seen or answered — the sheet just sat there
   looking unresponsive. Same for the refund quote and every other dialog the
   form can raise.
   The order is: page, then sheet, then the work-in-progress mark, then
   anything asking a question. */
.modal-overlay {
  position:fixed; inset:0; z-index:1400;
  background:rgba(0,0,0,.45); -webkit-backdrop-filter:blur(2px); backdrop-filter:blur(2px);
  display:flex; justify-content:center; overflow-y:auto; padding:20px;
  animation:pageEnter .18s ease;
}
.modal-box {
  /* The anchor for .modal-close. Without it that button was positioned
     against the viewport instead, landing in the top-right corner of the
     screen directly on top of the topbar account icon rather than on the
     modal it closes. */
  position:relative;
  background:var(--surface); border:1px solid var(--line);
  border-radius:var(--radius-lg); padding:20px;
  max-width:420px; width:100%; margin:auto 0; flex-shrink:0;
  box-shadow:var(--shadow-modal);
}
/* Two actions side by side in a 375px-wide dialog get roughly 120px of
   text width each at the full-width button's padding, which is not enough
   for "Discard changes" — it wrapped onto two lines. Inside a button row
   they take the dialog's own scale instead of the page's hero scale.

   They wrap to one per line rather than truncating. Ellipsis turned
   "Remove & refund in full" into "Remove & refund in ful", which on a
   button that moves money is worse than the two lines it was avoiding. */
.modal-box .flex-gap8 { flex-wrap:wrap; }
.modal-box .flex-gap8 > button {
  padding:13px 12px; font-size:var(--fs-meta); white-space:nowrap;
  /* No min-width: with white-space:nowrap a flex item's automatic minimum
     IS its max-content width, so the row keeps two short labels side by
     side and wraps a long one onto its own line by itself. Spelling that
     out as min-width:max-content made every dialog stack. */
  /* width:auto matters: these carry .btn-hero, which is width:100%, so
     each one demanded the whole row and wrapped even when both would have
     fitted. With auto the basis comes from the content instead. */
  flex:1 1 auto; width:auto;
}
.modal-code-badge {
  display:inline-flex; align-items:center; gap:5px;
  padding:5px 11px; border-radius:var(--radius-pill);
  background:rgba(var(--paper-rgb),.05); border:1px solid var(--line);
  font-size:.62rem; font-weight:700;
}
.u-modal-intro { font-size:.72rem; color:var(--muted); margin:6px 0 14px; }

/* the lock overlay on a feature the current plan doesn't include */
.tier-lock-overlay {
  position:absolute; inset:0; z-index:4; display:grid; place-items:center;
  cursor:pointer; border-radius:inherit;
  /* The container is made inert with pointer-events:none so the locked
     controls underneath cannot be operated. This overlay lives inside it and
     inherited that, which killed its own click handler — so the lock showed
     a pointer cursor, promised an upgrade, and did nothing, on every
     tier-blocked control in the app. It takes its events back. */
  pointer-events:auto;
}


/* ==========================================================================
   4  CONTROLS
   ========================================================================== */

button, .btn {
  display:inline-block; font-family:inherit; cursor:pointer;
  background:var(--glaze); color:var(--glaze-ink);
  border:none; border-radius:var(--radius-pill);
  padding:12px 20px; font-size:.72rem; font-weight:700;
  text-decoration:none; -webkit-appearance:none; appearance:none; outline:none;
  transition:background-color .15s ease, transform .1s ease, box-shadow .15s ease, opacity .15s ease;
}
@media (hover:hover) and (pointer:fine) {
  button:hover, .btn:hover { background:var(--glaze-dark); }
}
button:active, .btn:active { transform:scale(.98); }
/* One dimming value for "switched off", used by every state below.
   Each of these call sites used to write its own opacity inline — .3,
   .35, .4, .5 and .6 all meant the same thing on screen and only
   differed because they were written months apart. */
button:disabled { opacity:.45; cursor:default; transform:none; }
/* A whole region switched off rather than a single control: a
   tier-locked feature, or every setting below a paused account. Dimmed
   rather than hidden so it stays a visible reminder of why it's inert. */
.is-inert { opacity:.45; }
/* A tier-locked region: everything under the lock is held back, the lock
   itself is not. Same 45% .is-inert uses, applied to the content rather
   than to the box the overlay also lives in. */
.has-tier-lock > *:not(.tier-lock-overlay) { opacity:.45; }
/* An event mirrored in from an external calendar. Slightly held back
   from the owner's own events, which are the ones they can act on. */
.card-synced { opacity:.8; }
/* keyboard focus has to stay visible — a blanket outline:none removes the
   only cue a keyboard user has. :focus-visible keeps it for keyboard
   navigation without drawing rings on mouse or touch. */
button:focus-visible, .btn:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible {
  outline:2px solid var(--glaze); outline-offset:2px;
}

.btn-hero {
  display:block; width:100%; text-align:center;
  padding:15px 22px; font-size:.78rem; border-radius:var(--radius-pill);
}
.btn-flex { flex:1; }
.u-w-auto, button.btn-hero.u-w-auto { width:auto; }

button.secondary, .btn-hero.secondary {
  background:transparent; color:var(--paper); border:1px solid var(--line);
}
@media (hover:hover) and (pointer:fine) {
  button.secondary:hover, .btn-hero.secondary:hover { background:rgba(var(--paper-rgb),.05); color:var(--paper); }
}
.btn-danger { background:var(--danger); color:#fff; }
@media (hover:hover) and (pointer:fine) { .btn-danger:hover { background:var(--danger); filter:brightness(.92); } }

button.ghost, .ghost {
  background:transparent; color:var(--muted); border:none;
  font-weight:600; font-size:.72rem; padding:10px 12px;
}
@media (hover:hover) and (pointer:fine) { button.ghost:hover { background:transparent; color:var(--paper); } }

.icon-btn {
  width:36px; height:36px; padding:0; flex-shrink:0;
  /* Centred under grid AND flex. Call sites add .u-d-flex to some of
     these, which swaps the display mode out from under place-items and
     left the back arrow pinned against the left edge of its circle. */
  display:grid; place-items:center; justify-content:center; align-items:center;
  border-radius:50%;
  /* The treatment the topbar uses, applied to every icon button rather than
     to the four in the bar. Half the surface colour: solid white made them
     cards in their own right, competing with the actual cards below, and the
     5% wash this used to be made them look disabled. At 50% the page tints
     through and they still read as something you press. */
  background:rgba(var(--surface-rgb),.5); color:var(--paper);
  border:1px solid rgba(var(--paper-rgb),.07);
  box-shadow:0 1px 2px rgba(0,0,0,.06), 0 4px 12px rgba(0,0,0,.05);
}
@media (hover:hover) and (pointer:fine) { .icon-btn:hover { background:rgba(var(--paper-rgb),.10); } }

/* ---- the row of controls at the top of a page ----
   Bigger, and a real surface rather than a wash.
   These were 36px circles filled with 5% of the text colour, which on the
   light theme is a grey slightly darker than the page — so the one row of
   controls every page depends on was the least distinct thing on it, and
   read as disabled. Cards on this ground are white and lifted; the controls
   sit on the same ground and should be the same kind of object. 40px is a
   comfortable thumb target where 36 was the minimum one.
   Not the ones over a photo: .contrast-adaptive buttons are coloured by JS
   from the image behind them, and an opaque fill would paint over the
   picture they are deliberately floating on. */
/* The two pixels each control gained come out of the space between them.
   Bar arithmetic at 375px wide: 97 for the brand, 10 to the controls, and
   240 of controls came to 347 in a 343px bar — which the brand absorbs by
   truncating itself to "Gath...". Tightening both gaps from 6 to 4 gives
   back 8px, which is the whole of the overrun. Larger objects, closer
   together, is the trade; a clipped product name is not. */
.topbar .topbar-controls { gap:4px; }
.topbar .u-flex-gap6 { gap:4px; }
.topbar .icon-btn:not(.contrast-adaptive),
.page-back-btn:not(.contrast-adaptive) {
  /* Only the size is particular to the bar now; the look is the base rule.
     38, not 40. The bar is 343px at 375 wide and the brand needs 96 of it;
     40px controls came to 344 and squeezed the logo to "Gat...". This is the
     largest these go without taking a letter off the name. */
  width:38px; height:38px;
}
@media (hover:hover) and (pointer:fine) {
  .icon-btn:not(.contrast-adaptive):hover {
    background:rgba(var(--surface-rgb),.75);
    box-shadow:0 1px 3px rgba(0,0,0,.09), 0 6px 16px rgba(0,0,0,.08);
  }
}

/* The current page's button, in the topbar specifically.
   .icon-btn-selected inverts the pill — page-ground icon on a text-colour
   fill. But the rule above is .topbar .icon-btn:not(.contrast-adaptive),
   which outweighs it (three classes to two), so the fill never applied
   while the inverted icon colour did: a near-white icon on the near-white
   half-surface wash, measured at 1.11:1. The other three buttons in the
   same row are 17.47:1, and 3:1 is the floor for something you are meant
   to be able to see and press. Same treatment, enough weight to land. */
.icon-btn.icon-btn-selected:not(.contrast-adaptive),
.page-back-btn.icon-btn-selected:not(.contrast-adaptive) {
  background:var(--paper);
  border-color:var(--paper);
  color:var(--ink);
}
@media (hover:hover) and (pointer:fine) {
  .topbar .icon-btn.icon-btn-selected:not(.contrast-adaptive):hover {
    background:var(--paper);
  }
}

/* strips a <button> back to inline text, for things that read as links but
   must stay real buttons for keyboard and screen-reader users.
   Note what is absent: `font: inherit`. That shorthand also resets
   font-size and font-weight, and as an inline style it outranked the size
   and weight utilities on the element. Inherit only the family. */
.btn-as-text {
  background:none; border:none; padding:0; cursor:pointer;
  font-family:inherit; line-height:inherit; text-align:left;
}

/* ---- segmented toggles (pricing / scope / subtle) ----
   One shared shape. The selected option is a solid fill rather than a
   subtle tint, matching the filled-vs-outline treatment used by chips. */
/* No border on the track. With one, its rounded edge sat a pixel or two
   outside the sliding fill and showed as a pale outline around the
   selected option — reading as a second, misaligned shape rather than as
   the edge of the track. The tinted fill alone defines the track. */
/* THE SEGMENTED TOGGLE.

   The fill is positioned by translateX(100%) of its OWN width, so its
   width and the option width have to agree exactly. With a 5px gap they
   did not: for two options the fill landed 2.5px left of the second
   label, which is the sliver that showed under the pill. The gap is zero
   and every width below is (100/N)% - 8/N px, which is the same
   arithmetic — change one and the other has to change with it.

   These now sit inside a card rather than on the page, so the track is a
   quieter tint than it was and carries no border of its own; a bordered
   panel inside a bordered card reads as a box in a box. */
.pricing-toggle, .scope-toggle {
  position:relative; display:flex; gap:0; padding:4px;
  background:rgba(var(--paper-rgb),.05); border:none;
  border-radius:var(--radius-pill);
}
.pricing-toggle-option, .scope-toggle-option {
  flex:1; position:relative; z-index:2;
  padding:11px 8px; border-radius:var(--radius-pill);
  border:none; background:transparent; color:var(--muted);
  font-family:inherit; font-size:.72rem; font-weight:700;
}
.pricing-toggle-option.active, .scope-toggle-option.active { color:var(--glaze-ink); }
@media (hover:hover) and (pointer:fine) {
  .pricing-toggle-option:hover, .scope-toggle-option:hover { background:transparent; color:var(--paper); }
  .pricing-toggle-option.active:hover, .scope-toggle-option.active:hover { background:transparent; color:var(--glaze-ink); }
}
/* The sliding fill behind the active option.
   The width defaults must stay: the JS sets an inline width only where a
   toggle carries a seg-N class for its option count. Everything else —
   the dashboard's Day/Week/Month among them — relies on the defaults
   here, and a thumb with no width collapses to zero and vanishes,
   leaving the active option as pale text on a pale track. */
.pricing-toggle-thumb, .scope-toggle-thumb {
  /* decoration only — it covers half the control, so without this a tap
     landing on the fill instead of the label registers as nothing */
  pointer-events:none;
  position:absolute; z-index:1; top:4px; left:4px;
  height:calc(100% - 8px);
  border-radius:var(--radius-pill); background:var(--glaze);
  transition:transform .25s cubic-bezier(.22,.68,.36,1), background-color .25s ease, opacity .2s ease;
}
.pricing-toggle-thumb { width:calc(50% - 4px); }      /* two options */
.scope-toggle-thumb   { width:calc(33.333% - 2.667px); } /* three options */
/* Option count drives the thumb width. Each of these was an inline
   width written at the call site; they live here so the toggle's
   measurements are all in one place and can't drift apart. */
.scope-toggle.seg-2 .scope-toggle-thumb { width:calc(50% - 4px); }
.scope-toggle.seg-3 .scope-toggle-thumb { width:calc(33.333% - 2.667px); }
.scope-toggle.seg-4 .scope-toggle-thumb { width:calc(25% - 2px); }
.pricing-toggle.compact-2 .pricing-toggle-thumb { width:calc(50% - 4px); }
.pricing-toggle.compact-4 .pricing-toggle-thumb { width:calc(25% - 2px); }
.pricing-toggle.compact-2 .pricing-toggle-option { font-size:.66rem; }
.pricing-toggle.compact-4 .pricing-toggle-option { font-size:.62rem; padding:10px 2px; letter-spacing:-.01em; }
.subtle-toggle { background:rgba(var(--paper-rgb),.04); }

/* ---- party size stepper ---- */
.party-stepper, .party-stepper-sm { display:flex; align-items:center; gap:10px; }
.party-stepper button, .party-stepper-sm button {
  width:34px; height:34px; padding:0; border-radius:50%;
  font-size:.92rem; font-weight:700; line-height:1; display:grid; place-items:center;
}
.party-stepper-sm button { width:26px; height:26px; font-size:.78rem; }
@media (hover:hover) and (pointer:fine) {
}
.party-stepper span { min-width:22px; text-align:center; font-size:.78rem; font-weight:700; }
.party-stepper-sm span { min-width:16px; text-align:center; font-size:.72rem; font-weight:700; }
.party-stepper-wrap { display:flex; flex-direction:column; }

/* ---- switch ---- */
.switch { position:relative; display:inline-block; width:42px; height:24px; flex-shrink:0; cursor:pointer; }
.switch input { opacity:0; width:0; height:0; position:absolute; }
.switch-slider {
  position:absolute; inset:0; border-radius:var(--radius-pill);
  background:rgba(var(--paper-rgb),.16); transition:background-color .2s ease;
}
.switch-slider::before {
  content:""; position:absolute; width:18px; height:18px; left:3px; top:3px;
  border-radius:50%; background:var(--surface); box-shadow:0 1px 3px rgba(0,0,0,.25);
  transition:transform .2s cubic-bezier(.22,.68,.36,1);
}
.switch input:checked + .switch-slider { background:var(--glaze); }
.switch input:checked + .switch-slider::before { transform:translateX(18px); }

/* ---- consent row ---- */
.consent-row { display:flex; align-items:flex-start; gap:9px; cursor:pointer; font-size:.72rem; }
.consent-row.py8 { padding:8px 0; }
.consent-row.py6 { padding:6px 0; }
.consent-checkbox {
  appearance:none; -webkit-appearance:none; flex-shrink:0;
  width:18px; height:18px; margin:0; border-radius:5px;
  border:1.5px solid var(--line); background:transparent; cursor:pointer;
  position:relative; transition:background-color .15s ease, border-color .15s ease;
}
.consent-checkbox:checked { background:var(--glaze); border-color:var(--glaze); }
.consent-checkbox:checked::after {
  content:""; position:absolute; left:5.5px; top:2px; width:4px; height:9px;
  border:solid var(--glaze-ink); border-width:0 2px 2px 0; transform:rotate(45deg);
}
.label-text { color:var(--paper); font-size:.78rem; }
/* the heading variant sits a step above the rows it introduces */
.label-text.heading-label-text { font-size:.92rem; }


/* ==========================================================================
   5  FORMS
   ========================================================================== */

.field { margin-bottom:14px; }
.field label, .label {
  display:block; font-size:.66rem; font-weight:600;
  color:var(--muted); margin-bottom:6px;
}
.field-row { display:flex; gap:10px; align-items:flex-start; }
.field-row .field { flex:1; min-width:0; }

/* Matched by exclusion, not by listing types. Plenty of these inputs are
   written without a type attribute at all — <input id="s-name" /> — and
   an input[type="text"] selector does NOT match those, because the
   attribute genuinely is not there even though .type reports "text".
   They fell through to the browser default and rendered as white boxes
   on a dark page. Excluding the handful of types that need their own
   treatment catches everything else however it was written. */
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="submit"]):not([type="button"]),
textarea, select {
  width:100%; padding:14px 15px;
  border-radius:var(--radius-md); border:1px solid var(--line);
  background:rgba(var(--paper-rgb),.03); color:var(--paper);
  font-family:inherit; font-size:.78rem; outline:none;
  transition:border-color .15s ease, background-color .15s ease;
}
input::placeholder, textarea::placeholder { color:var(--muted-soft); }
input:focus, textarea:focus, select:focus {
  border-color:rgba(var(--paper-rgb),.38); background:var(--surface);
}
textarea { resize:vertical; min-height:80px; }
select {
  appearance:none; -webkit-appearance:none;
  background-image:linear-gradient(45deg,transparent 50%,currentColor 50%),linear-gradient(135deg,currentColor 50%,transparent 50%);
  background-position:calc(100% - 18px) 52%, calc(100% - 13px) 52%;
  background-size:5px 5px, 5px 5px; background-repeat:no-repeat;
  padding-right:36px;
}

.error { color:var(--danger); font-size:.66rem; margin-top:5px; min-height:0; }
.error:empty { margin-top:0; }
input.invalid, textarea.invalid { border-color:var(--danger); }

/* intl-tel-input: it styles its own country selector but never sets a font
   size on it, so it inherited the root and rendered far larger than the
   input beside it. Pinned to the input's size. */
.field .iti, .field .iti input.iti__tel-input { width:100%; }
.field .iti { display:block; }
.field .iti input.iti__tel-input { border-radius:var(--radius-md) !important; }
.field .iti__selected-flag { border-radius:var(--radius-md) 0 0 var(--radius-md); }
.field .iti__selected-flag,
.field .iti__selected-country,
.field .iti__selected-dial-code { font-size:.72rem; }

/* rich-text editor toolbar on the description field */
.editor-toolbar-btn {
  width:30px; height:28px; padding:0; border-radius:var(--radius-sm);
  background:transparent; color:var(--muted); border:1px solid transparent;
  font-size:.72rem; font-weight:700; display:grid; place-items:center;
}
@media (hover:hover) and (pointer:fine) { .editor-toolbar-btn:hover { background:rgba(var(--paper-rgb),.07); color:var(--paper); } }
.editor-toolbar-btn.active { background:var(--selected-wash-strong); color:var(--paper); }
.editor-body {
  min-height:60px; max-height:220px; overflow-y:auto;
  padding:12px 14px; font-size:.72rem; color:var(--paper); outline:none;
}


/* ==========================================================================
   6  TAGS & CHIPS
   ========================================================================== */

/* Compact on purpose: these are dense factual labels, and at a larger size
   four of them wrap onto three rows on a phone. */
.tag {
  display:inline-flex; align-items:center; gap:5px;
  padding:5px 10px; border-radius:var(--radius-pill);
  border:1px solid var(--line); background:rgba(var(--paper-rgb),.04);
  font-size:.62rem; font-weight:600; line-height:1.3; font-family:inherit;
}
.tag + .tag, .tag-ml6 { margin-left:6px; }
/* Event type is carried by a small dot, not by colouring the whole pill.
   A list of events was previously a row of fully-tinted badges in four
   different colours competing with each other and with the genuinely
   urgent states (cancelled, sold out) that also live in this row. The dot
   says the same thing quietly. */
.tag.free, .tag.paid, .tag.recurring, .tag.free-recurring {
  display:inline-flex; align-items:center; gap:6px;
  border-color:var(--line); color:var(--paper); background:rgba(var(--paper-rgb),.04);
}
.tag.free::before, .tag.paid::before,
.tag.recurring::before, .tag.free-recurring::before {
  content:""; width:6px; height:6px; border-radius:50%; flex-shrink:0;
}
.tag.free::before           { background:var(--free-color); }
.tag.paid::before           { background:var(--amber-hue); }
.tag.recurring::before      { background:var(--recurring); }
.tag.free-recurring::before { background:var(--free-recurring); }
.tag-accent-dynamic { border-color:var(--accent-c); color:var(--accent-c); background:var(--accent-bg-c); }
/* Full. Louder than the type dot on purpose — it is the one state in this row
   that changes what the organizer can do next. */
.tag.sold-out {
  color:var(--danger); border-color:rgba(var(--danger-rgb),.45);
  background:rgba(var(--danger-rgb),.10);
}

/* The event's tags lead the details card, above the date they qualify. */
.manage-tags-row {
  display:flex; flex-wrap:wrap; gap:6px; margin-bottom:14px;
}
.manage-tags-row .tag + .tag { margin-left:0; }

/* The event photographs, above the facts. Rounded and inset like the cards
   they sit above, rather than running to the screen edges the way the
   attendee page hero does — this page is a stack of containers. */
/* The event photographs on the owner page: the same thumbnails the form uses,
   leading the facts card. */
/* The photographs share the width of the card rather than sitting at a fixed
   72px: five of them fit on one line whatever the screen, and one or two
   simply take more of it. Height is unchanged -- only the width flexes. */
.manage-photo-strip { margin-bottom:14px; flex-wrap:nowrap; }
.manage-photo-thumb {
  padding:0; border:0; background:none; cursor:pointer;
  flex:1 1 0; min-width:0; width:auto; height:72px;
}
.manage-photo-thumb img { width:100%; height:100%; object-fit:cover; border-radius:var(--radius-sm); display:block; }

/* The map is the last thing in the details card; it was pressed against the
   card's bottom edge, and the People card sat straight underneath. */
.manage-map { margin-bottom:6px; }
.manage-people-card { margin-top:8px; }

/* the four end-states a used-up ticket can be in — see ticketEndState()
   in js/page-event.js. Colour carries the meaning at a glance. */
.tag.ticket-state-tag { flex-shrink:0; align-self:flex-start; }
.tag.checked-in     { border-color:rgba(var(--free-color-rgb),.4); color:var(--free-color); background:rgba(var(--free-color-rgb),.10); }
.tag.cancelled      { border-color:rgba(var(--danger-rgb),.4);     color:var(--danger);     background:rgba(var(--danger-rgb),.10); }
.tag.refund-pending { border-color:rgba(var(--amber-rgb),.4);      color:var(--amber);      background:rgba(var(--amber-rgb),.10); }
.tag.finished       { border-color:var(--line); color:var(--muted); background:rgba(var(--paper-rgb),.05); }

.checkin-badge {
  display:inline-flex; align-items:center; gap:5px;
  padding:4px 9px; border-radius:var(--radius-pill);
  font-size:.62rem; font-weight:700; border:1px solid var(--line);
  background:rgba(var(--paper-rgb),.04); color:var(--muted);
}
.checkin-badge.yes { color:var(--free-color); border-color:rgba(var(--free-color-rgb),.4); background:rgba(var(--free-color-rgb),.10); }
.checkin-badge.no  { color:var(--muted); }
/* Cancelled is a negative state and reads as one everywhere. It was red on
   the attendee's ticket list and grey on the organizer's attendee list —
   the same word, the same fact, two different meanings to anyone who saw
   both. */
.checkin-badge.cancelled {
  color:var(--danger); border-color:rgba(var(--danger-rgb),.4);
  background:rgba(var(--danger-rgb),.09);
}

/* date pills for a recurring series */
.session-pill {
  padding:7px 13px; border-radius:var(--radius-pill); cursor:pointer;
  border:1px solid var(--line); background:transparent; color:var(--paper);
  font-family:inherit; font-size:.66rem; font-weight:600; white-space:nowrap;
}
/* A session date that falls in the range the calendar is showing. It was
   a solid accent fill — the loudest thing on a card whose job is to list
   dates, and several of them light up at once on a Month view. A faint
   tint and a firmer border say the same thing without shouting it. */
.session-pill.session-pill-current {
  background:rgba(var(--glaze-rgb),.10) !important;
  color:var(--paper) !important;
  border-color:rgba(var(--glaze-rgb),.45) !important;
  font-weight:700;
}
.session-pill.more, .session-pill.open { border-style:dashed; color:var(--muted); }
.chip-row { display:flex; flex-wrap:wrap; gap:6px; }


/* ==========================================================================
   10  UTILITIES
   Carried over unchanged. The JS references these by name in hundreds of
   places, so they are load-bearing — do not rename or drop one without
   grepping js/ and templates/ first.
   ========================================================================== */
.u-m0 { margin:0; }
.u-mt0 { margin-top:0; }
.u-mb2 { margin-bottom:2px; }
.u-mb4 { margin:0 0 4px; }
.u-mb0 { margin-bottom:0; }
/* Sets a weight and a margin and, until now, no size — so the one heading
   using it ("Auto-renew", on the plan and upgrade pages) fell through to the
   unstyled 18px root and was the only text in the app off the type scale
   besides the cancel card's body. It is a card heading; it takes the card
   heading's size. */
.u-mb4-bold { margin:0 0 4px; font-weight:700; font-size:var(--fs-title); }
.u-mb6 { margin:0 0 6px; }
.u-mb8 { margin-bottom:8px; }
.u-mt4 { margin:4px 0 0; }
.u-thumb-2way { width:calc(50% - 4px); background:rgba(var(--paper-rgb),.14); }
.u-tag-external { background:rgba(var(--paper-rgb),.08); color:rgba(var(--paper-rgb),.6); border-color:var(--line); }
.u-wheel-collapsed { flex-grow:0; flex-basis:0; opacity:0; padding-left:0; padding-right:0; pointer-events:none; }
.u-mb10 { margin:0 0 10px; }
.u-mb16 { margin-bottom:16px; }
.u-mt8 { margin-top:8px; }
.u-mt10 { margin-top:10px; }
.u-mt12 { margin-top:12px; }
.u-mt16 { margin-top:16px; }
.u-mt20 { margin-top:20px; }
.u-ml8 { margin-left:8px; }
.u-flex1 { flex:1; }
.u-flex-shrink0 { flex-shrink:0; }
.u-flex-auto-mb0 { flex:0 0 auto; margin-bottom:0; }
.u-v-hidden { visibility:hidden; }
.u-op0 { opacity:0; }
.u-pos-rel { position:relative; }
.u-fw700 { font-weight:700; }
.u-text-center { text-align:center; }
.u-w-auto { width:auto; }
.u-d-block { display:block; }
.u-d-flex { display:flex; }
.u-d-none { display:none !important; }
.u-visually-hidden { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.u-fs12 { font-size:.62rem; }
.u-fs13 { font-size:.66rem; }
.u-border-danger { border-color:var(--danger); }
.u-radius-pill { border-radius:var(--radius-pill); }
.u-no-pointer-events { pointer-events:none; }
.u-mt6 { margin-top:6px; }
.u-mb18 { margin-bottom:18px; }
.u-mb20 { margin-bottom:20px; }
.u-color-paper { color:var(--paper); }
.u-gap6 { gap:6px; }
.u-pb76 { padding-bottom:76px; }
.u-justify-between { justify-content:space-between; }
.u-fs14 { font-size:.72rem; }
.u-op1 { opacity:1; }
.u-cursor-not-allowed { cursor:not-allowed; }
.u-d-inline-flex { display:inline-flex; }
.u-border-top-line { border-top:1px solid var(--line); }
.u-text-right { text-align:right; }
.u-p-40-12 { padding:40px 12px; }
.u-radius-circle { border-radius:50%; }
.u-p0 { padding:0; }
.u-p-6-12 { padding:6px 12px; }
.u-p-7-0 { padding:7px 0; }
.u-p-8-14 { padding:8px 14px; }
.u-mx-auto-10 { margin:10px auto 0; }
.u-mx-auto-4 { margin:4px auto 0; }
.u-m-12-0 { margin:12px 0; }
.u-bg-transparent { background:transparent; }
.u-bg-paper-06 { background:rgba(var(--paper-rgb),.06); }
.u-gap8 { gap:8px; }
.u-pr38 { padding-right:38px; }
.u-border-top-dashed { border-top:1px dashed var(--line); }
.u-align-center { align-items:center; }
.u-no-select { user-select:none; }
.u-mb12 { margin-bottom:12px; }
.u-ml0 { margin-left:0; }
.u-pos-fixed { position:fixed; }
.u-pos-abs { position:absolute; }
.u-left-offscreen { left:-9999px; }
.u-fs11 { font-size:.62rem; }
.u-mt24 { margin-top:24px; }
.u-inherit-underline { color:inherit; text-decoration:underline; }
.u-danger-mb6 { margin:0 0 6px; color:var(--danger); }
.u-icon-btn-sq22,
button.u-icon-btn-sq22 { width:22px; height:22px; }
.u-mt4-mb14 { margin:4px 0 14px; }
.u-m0-mb12 { margin:0 0 var(--space-heading); }
.u-fs15-fw600-muted { font-size:.78rem; font-weight:600; color:rgba(var(--paper-rgb),.5); }
.u-fs10-fw600-muted { font-size:.62rem; font-weight:600; color:rgba(var(--paper-rgb),.5); }
.u-mt4-only-alt { margin-top:4px; }
.u-mt6-mb8 { margin:6px 0 8px; }
.u-color-amber { color:var(--amber); }
.u-color-free { color:var(--free-color); }
.u-mt4-only { margin-top:4px; }
.u-mt16-only { margin-top:16px; }
.u-uppercase { text-transform:uppercase; }
.u-fs135 { font-size:.66rem; }
.u-flex-display { display:flex; }
.u-mt2-mb12 { margin:2px 0 12px; }
.u-mt6-mb14 { margin:6px 0 14px; }
.u-fs12-bold700 { font-size:.62rem; font-weight:700; }
.u-border-glaze { border-color:var(--glaze); }
.u-color-muted-50 { color:rgba(var(--paper-rgb),.5); }
.u-flex-gap6 { display:flex; gap:6px; }
.u-flex-1-auto { flex:1; width:auto; }
.u-fw800-italic { font-weight:700; font-style:italic; }
.u-fw700-shrink0-ml8 { font-weight:700; flex-shrink:0; margin-left:8px; }
.u-cursor-pointer { cursor:pointer; }
.u-cursor-default { cursor:default; }
.u-row-static { cursor:default; display:flex; align-items:center; justify-content:space-between; gap:12px; }
.u-row-line { display:flex; justify-content:space-between; padding:6px 0; font-size:.66rem; border-bottom:1px solid var(--line); }
.u-row-total { display:flex; justify-content:space-between; padding:10px 0 0; font-weight:700; }
.u-color-danger { color:var(--danger); }
.u-paper-fw600 { color:var(--paper); font-weight:600; }
.u-mb0-fontweight700 { margin:0; font-weight:700; }
.u-modal-intro { margin:6px 0 14px; }
.u-gap12 { gap:12px; }
.u-mt14 { margin-top:14px; }
.u-m0-fs14 { margin:0; font-size:.72rem; }
.u-m0-fs13 { margin:0; font-size:.66rem; }
.u-list-reset { margin:0; padding:0; list-style:none; }
.u-list-reset-mt10 { margin:10px 0 0; padding:0; list-style:none; }
.u-danger-outline { border-color:var(--danger); color:var(--danger); }
.u-danger-corner-dot {
  position:absolute; top:-3px; right:-3px; width:12px; height:12px;
  border-radius:50%; background:var(--danger); border:2px solid var(--ink);
  transition:border-color .3s ease;
}
/* The ring is not decoration — it separates the dot from whatever is directly
   behind the icon. On the page ground that is --ink, which is why it is the
   default. Over a photo it is not, and the dot kept a ring in a colour that
   was nowhere on screen: an outline drawn round the dot rather than the icon
   showing through it.

   There are two over-photo systems and the dot has to answer to both. The
   event page dims its controls with --hero-scrim, which the hero itself
   updates as the picture under it changes; the manage carousel swaps a whole
   class. Same idea, different mechanisms, so the ring follows each on that
   system's own timing rather than a third one of its own. */
[data-event] .ev-hero-controls .u-danger-corner-dot {
  border-color:rgba(0,0,0,var(--hero-scrim,.34));
  transition:border-color .28s ease;
}
.contrast-adaptive .u-danger-corner-dot { border-color:rgba(0,0,0,.45); }
.contrast-adaptive.on-bright-photo .u-danger-corner-dot { border-color:rgba(255,255,255,.55); }
/* In the row, not over it. Absolutely positioned, the dot left the message
   the full width of the card and then sat on top of it — every notification
   that ran to a second line had this dot printed over its last word. The
   row it lives in is already a flex line with a gap, built to hold it; it
   just was not in the flow to use them. The nudge down aligns it with the
   cap-height of the first line rather than the top of the text box. */
.u-unread-dot { margin-top:6px; width:9px; height:9px; border-radius:50%; background:var(--glaze); flex-shrink:0; }
.u-notif-row { display:flex; align-items:flex-start; justify-content:space-between; gap:10px; }
.u-row-sb-gap8-mb4 { display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:4px; }
.u-row-gap7-fs135 { display:flex; align-items:center; gap:7px; font-size:.66rem; padding:3px 0; }
.u-mt16-mb6 { margin-top:16px; margin-bottom:6px; }
.u-text-left { text-align:left; }
.u-hidden { display:none !important; }
.u-justify-center { justify-content:center; }  /* was used by the JS but never defined */


/* ==========================================================================
   7  EVENT PAGE
   ========================================================================== */

/* ---- photo carousel ----
   Sticky, so the content rides up over it — that difference in travel IS
   the parallax. No z-index here on purpose: setting one makes this a
   stacking context and traps the floating controls inside it. */
.carousel-wrap {
  position:relative; width:100%; aspect-ratio:4/3;
  overflow:hidden; background:rgba(var(--paper-rgb),.06);
  border-radius:var(--radius-lg);
}
.carousel-img { width:100%; height:100%; object-fit:cover; display:block; transition:opacity .2s ease; opacity:1; }
.carousel-img.fading { opacity:0; }
/* The JS builds these as .carousel-arrow-btn, not .carousel-arrow — this
   block targeted the wrong name and the arrows rendered unpositioned,
   stacked in the flow instead of pinned to either side of the photo. */
.carousel-arrow-btn {
  position:absolute; top:50%; transform:translateY(-50%);
  width:32px; height:32px; border-radius:50%; padding:0;
  display:grid; place-items:center;
  z-index:4;
}
.carousel-arrow-btn.prev { left:12px; }
.carousel-arrow-btn.next { right:12px; }
.carousel-dots { position:absolute; left:0; right:0; bottom:14px; display:flex; gap:5px; justify-content:center; }
.carousel-dot { width:5px; height:5px; border-radius:50%; background:rgba(255,255,255,.55); }
.carousel-dot.active { width:16px; border-radius:99px; background:#fff; }

/* the price tab clipped onto the top edge of the event card. The offset
   is rem so it tracks the badge's own text size — a fixed px offset drifts
   toward the title as the type scale grows. */
.event-price-badge {
  position:absolute; top:-2.25rem; right:16px; z-index:6;
  background:var(--glaze); color:var(--glaze-ink);
  font-size:.78rem; font-weight:700; padding:7px 13px;
  border-radius:var(--radius-md); text-align:center; letter-spacing:-.02em;
  box-shadow:var(--shadow-card);
}
.event-price-badge-day { display:block; font-size:.62rem; font-weight:600; opacity:.72; margin-top:1px; letter-spacing:0; }

/* An event with photos has the carousel above to hang the badge over.
   Without one the card sits directly under the topbar, and the badge —
   which deliberately overhangs — needs room to clear the icons. */
.event-card-no-carousel { margin-top:3.75rem; }
.event-card-overlap, .ticket-stub-overlap { margin-top:-34px; position:relative; z-index:2; }
.event-top-zone-overlap { margin-bottom:0; }

.ticket-corner-icon {
  position:absolute; top:12px; right:12px; color:var(--free-color);
  display:grid; place-items:center;
}
.cancelled-ticket-icon { color:var(--muted-soft); }

.ticket-detail-item .meta { font-size:.62rem; font-weight:600; }
.ticket-detail-value { font-size:.78rem; font-weight:700; margin:2px 0 0; letter-spacing:-.01em; }
.ticket-stat-row { display:grid; grid-template-columns:repeat(auto-fit,minmax(0,1fr)); gap:8px; }
.ticket-stat-box {
  padding:12px 10px; border-radius:var(--radius-md); text-align:center;
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
}
.ticket-stat-box .meta { font-size:.62rem; }
.ticket-stat-box .ticket-detail-value { font-size:.86rem; }

/* the large monospace ticket code shown when a QR image is unavailable */
.ticket-code-box { margin:0 0 4px; padding:16px 40px; min-height:56px; }
.ticket-code-text {
  font-family:"Space Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  font-size:1.5rem; letter-spacing:.04em; word-break:break-all;
}
.link-box {
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
  border-radius:var(--radius-md); padding:12px 14px; font-size:.72rem;
}
/* callout for the one fact in a dialog that has to outrank the rest of
   the copy — the refund notice on checkout, the "this can't be undone"
   line on a confirmation. Built on the same tokens as .link-box above so
   the two callouts read as the same thing, and so a checkout theme
   recolours it along with everything else. */
.info-panel {
  display:flex; align-items:flex-start; gap:8px;
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
  border-radius:var(--radius-md); padding:10px 12px;
  font-size:.72rem; line-height:1.45; color:var(--paper);
}
.info-panel-icon { color:var(--muted); flex-shrink:0; margin-top:2px; }
.powered-by { font-size:.62rem; }
.gpay-g { font-weight:700; }


/* ==========================================================================
   8  CALENDAR & TIME
   Geometry ported from the previous stylesheet and re-skinned. These are
   functional layouts whose measurements the JS reads back (see
   wheelMetrics() in js/recurrence-and-widgets.js) — changing the numbers
   blind breaks selection, so only the skin is new.
   ========================================================================== */

/* Two more rules the stylesheet rewrite dropped. Without .calendar-box
   the grid had no reserved height, so the whole page below it jumped as
   Day, Week and Month swapped in grids of different sizes. .dash-top is
   the wrapper the dashboard shell builds around the strip and calendar;
   it holds the gap between them now that neither is pinned. */
/* As tall as the month it is showing.
   This reserved 392px so switching Day/Week/Month would not change the page
   height — which it did do, and also left a five-row month sitting in a
   six-row box with the leftover as a gap above the list. The reservation was
   solving a flicker by being wrong most of the time. The grid sizes itself
   now, and every scope keeps the same distance from the list below it —
   including Day, whose hour strip is not a .cal-grid and so had none at all. */
.calendar-box { display:flex; flex-direction:column; }
.calendar-box .cal-grid { margin-top:8px; margin-bottom:16px; }
.calendar-box .hour-col-scroll { margin-bottom:16px; }
.dash-top { display:block; margin-bottom:var(--space-section); }

.cal-grid { display:grid; grid-template-columns:repeat(7,1fr); gap:2px; }
.cal-dow { text-align:center; font-size:.62rem; color:var(--muted); padding-bottom:6px; text-transform:uppercase; letter-spacing:.06em; font-weight:700; }
.cal-day {
  aspect-ratio:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
  border-radius:50%; font-size:.72rem; font-weight:600; position:relative;
}
.cal-day.pick { cursor:pointer; }
@media (hover:hover) and (pointer:fine) { .cal-day.pick:hover { background:rgba(var(--paper-rgb),.07); } }
/* The JS marks these .past-day, not .past — styling the wrong name meant
   nothing faded at all. Kept dim enough to recede but still readable:
   a past day with events is still worth being able to look at. */
.cal-day.past-day, .cal-day.past { opacity:.38; }
/* A list card for a date that has already happened. The same held-back
   reading the calendar gives a past day, at a level that still lets the
   title, the type tag and the sign-up line be read — the point is that the
   past is visible and openable, just quieter. Not pointer-events:none:
   these are still cards you can open. */
.card.is-past-card { opacity:.62; }
/* Clamped body text with a Read more, for pages outside the attendee event
   view. line-clamp needs the flex display and the hidden overflow to bite,
   so the three move together. */
.clamped-copy { font-size:var(--fs-body); color:rgba(var(--paper-rgb),.72); margin:0; line-height:1.5; }
.clamped-copy.is-clamped {
  display:-webkit-box; -webkit-box-orient:vertical;
  -webkit-line-clamp:var(--clamp-lines, 4); overflow:hidden;
}
.clamped-copy-more {
  background:none; border:none; padding:0; margin-top:8px;
  font-size:var(--fs-caption); font-weight:700; color:var(--paper);
  text-decoration:underline; text-underline-offset:2px; cursor:pointer;
}
@media (hover:hover) and (pointer:fine) { .card.is-past-card:hover { opacity:1; } }
/* No extra dimming on top of the cell's own. The day is already at .38 and
   its dots inherit that; taking another .75 off put them at .285 — dimmer
   than the number they sit under, when what a past day wants is to recede
   as one piece. */
/* Two marks, different in kind so they can sit side by side: today is
   outlined, the day you picked is filled. Both are kept very quiet — a
   grid of 31 numbers does not need shouting at, it needs one number to
   look slightly different from the rest.
   The date picker is the exception and keeps the solid fill, because
   there the whole job of the control is to show what you picked. */
.cal-day.today { box-shadow:inset 0 0 0 1px rgba(var(--paper-rgb),.20); }
.cal-day.selected {
  background:var(--selected-wash); font-weight:800;
}
.date-picker-wrap .cal-day.pick.selected {
  background:var(--glaze); color:var(--glaze-ink); border-color:var(--glaze);
  font-weight:700;
}
/* when the picked day is also today, the fill already says so */
.cal-day.today.selected { box-shadow:none; }
.date-picker-wrap .cal-day.selected .dot { box-shadow:0 0 0 1px rgba(var(--glaze-rgb),.9); }
/* The dots under a calendar day say what kind of event falls on it. The
   type variants below are not optional decoration — without a background
   per type the dot renders transparent and the day looks empty, which is
   exactly what happened when this file first shipped without them. */
/* The row is centred under the date and can hold several dots — an
   imported calendar day often has three or four. Without a width limit
   and room at the sides they ran to the edges of the cell and touched the
   dots of the day beside them. */
.dot-row {
  position:absolute; bottom:5px; left:50%; transform:translateX(-50%);
  display:flex; gap:4px; justify-content:center; margin-top:0;
  max-width:calc(100% - 10px); padding:0 3px; flex-wrap:nowrap;
  /* Not overflow:hidden. The row is exactly as tall as a dot, so hiding the
     overflow sliced the sold-out ring flat across its top and bottom — the
     ring is a box-shadow and lives outside the dot's own box. The clip was
     there to stop a long line of dots reaching the edges of the cell, which
     max-width already prevents and the day-count fallback makes moot: past
     three events a day draws a number instead, so at most three dots and
     23px of them ever sit in a 61px cell. */
}
.dot { width:5px; height:5px; border-radius:50%; background:var(--glaze); }
/* Each type names its colour once, and the sold-out ring reads the same
   variable. The ring used to be handed eventAccentColor's answer instead,
   which returns --amber for a paid event while the dot is filled with
   --amber-hue — two deliberately different values (#c25400 text, #f28c00
   fill in light mode), so the ring sat a shade off its own dot. Set on the
   same line, they cannot drift. */
.dot.free           { --ring-c:var(--free-color);     background:var(--ring-c); }
.dot.paid           { --ring-c:var(--amber-hue);      background:var(--ring-c); }
.dot.recurring      { --ring-c:var(--recurring);      background:var(--ring-c); }
.dot.free-recurring { --ring-c:var(--free-recurring); background:var(--ring-c); }
.dot.finished       { background:rgba(var(--paper-rgb),.35); }
/* externally-synced calendar entries are a diamond, not a dot — they are
   not bookable, so they should not read as one of your own events */
.dot.external-diamond { border-radius:1px; background:rgba(var(--paper-rgb),.45); transform:rotate(45deg); width:4px; height:4px; }


/* wheel geometry lives here and ONLY here — buildWheel() reads these two
   custom properties off the element rather than repeating the numbers */
.time-columns { display:flex; gap:6px; position:relative; z-index:1; }
.compact-wheel .time-columns { gap:0; }
.time-row.compact-wheel { gap:8px; }
/* The wheels sit inside a card now, so the panel drops its own border and
   most of its tint — what marks the live row is the ring around it, not a
   box around the whole control. Same size as before: the instruction was
   to bring these in step with the theme, not to make them bigger. */
.time-picker-panel {
  position:relative; background:rgba(var(--paper-rgb),.03);
  border:1px solid transparent; border-radius:var(--radius-md); padding:8px 8px 6px;
}
.compact-wheel .time-picker-panel { padding:6px 3px 5px; }
/* Set like every other field label on the form — "Capacity (optional)",
   "How long it runs" — rather than as uppercase grey micro-type. Four of
   these (REPEAT EVERY, ENDS, AFTER, TIME) were the last uppercase headings
   left on the page after the box titles came off. */
.time-picker-label {
  display:block; font-size:.66rem; font-weight:600;
  color:var(--muted);
  margin:0 0 6px 2px;
}
.time-picker-wheel-area { position:relative; }
/* The selected row is marked by a ring, not just a tint. That outline is
   what made this read as a picker rather than a list of numbers, and it
   was dropped somewhere along the way — a flat grey band behind small
   grey text says nothing about which row is the live one. */
.time-picker-highlight {
  position:absolute; left:6px; right:6px; top:50%; transform:translateY(-50%);
  height:calc(var(--wheel-item-h,22px) + 4px);
  background:rgba(var(--paper-rgb),.06);
  border:1px solid rgba(var(--paper-rgb),.45);
  border-radius:var(--radius-sm); pointer-events:none; z-index:0;
}
/* compact wheels have less room — a smaller inset keeps the ring's own
   left/right border inside the panel instead of flush against it */
.compact-wheel .time-picker-highlight { left:2px; right:2px; }
/* The recurrence wheels sit side by side and must always fill the row,
   however many of them are showing. flex:1 alone keeps each one at its
   content width when a sibling is hidden; the basis of 0 makes them
   divide the row instead. */
.time-row { display:flex; align-items:flex-start; gap:12px; }
/* The wrapper is the flex child — the panel sits inside it — so sizing the
   panel alone left the row short of the page width. Whichever wheels are
   showing now divide the row between them, two or three alike. */
.time-row > .u-wheel-wrap,
.time-row > .time-picker-panel { flex:1 1 0; min-width:0; }
.time-row .u-wheel-wrap > .time-picker-panel { width:100%; }
/* A collapsed wheel still occupied one row gap, leaving the visible ones
   12px short of the page width. Taken out of the flow entirely instead. */
.time-row > .u-wheel-wrap.u-wheel-collapsed { display:none; }
.time-col {
  --wheel-item-h:22px;
  --wheel-rows:3;
  flex:1; min-width:0; height:calc(var(--wheel-rows) * var(--wheel-item-h)); overflow-y:auto;
  scroll-snap-type:y mandatory; touch-action:pan-y; overscroll-behavior-y:contain;
  -webkit-overflow-scrolling:touch;
  -webkit-mask-image:linear-gradient(to bottom, transparent 0, #000 18%, #000 82%, transparent 100%);
  mask-image:linear-gradient(to bottom, transparent 0, #000 18%, #000 82%, transparent 100%);
}
.time-col::-webkit-scrollbar { width:0; height:0; }
.time-col-item {
  display:flex; align-items:center; justify-content:center;
  width:100%; height:var(--wheel-item-h); padding:0;
  background:transparent; border:none; border-radius:var(--radius-pill);
  font-size:var(--fs-micro); color:var(--muted-soft);
  cursor:pointer; font-family:inherit; font-variant-numeric:tabular-nums;
  scroll-snap-align:center; touch-action:pan-y; flex-shrink:0; white-space:nowrap;
  transition:color .15s ease, font-weight .15s ease, font-size .2s cubic-bezier(.22,.68,.36,1);
}
.time-col-item.selected { color:var(--paper); font-weight:700; font-size:var(--fs-caption); }
.time-col-item:active, .time-col-item:hover { background:transparent !important; }
.time-col-item:focus-visible { outline:2px solid var(--glaze) !important; outline-offset:-2px; }
/* A mouse is not a thumb: on a pointer device the wheel gets taller rows
   and shows five instead of three, turning a fiddly flick target into
   something clickable. */
@media (hover:hover) and (pointer:fine) {
  .time-col { --wheel-item-h:30px; --wheel-rows:5; }
  .time-col-item:hover { color:var(--paper); background:rgba(var(--paper-rgb),.06) !important; }
}


/* ==========================================================================
   9  SCAN, QR & MEDIA
   ========================================================================== */

.scan-modal-overlay { padding:0; }
.scan-modal-box { max-width:none; width:100%; height:100dvh; border-radius:0; margin:0; display:flex; flex-direction:column; }
.scan-frame { position:relative; border-radius:var(--radius-lg); overflow:hidden; background:#000; aspect-ratio:1; }
#scan-video { width:100%; height:100%; object-fit:cover; display:block; }
.scan-success-overlay { position:absolute; inset:0; display:grid; place-items:center; background:rgba(0,0,0,.45); }
.scan-checkmark-fast { animation:pageEnter .2s ease; }
.scan-test-btn { position:absolute; left:50%; bottom:12px; transform:translateX(-50%); font-size:.62rem; padding:8px 14px; }
.scan-result { margin-top:10px; }
.scan-attendee-list { flex:1; min-height:0; overflow-y:auto; margin-top:0; padding-top:2px; }
.scan-attendee-empty { display:grid; place-items:center; padding:30px 12px; }
.scan-attendee-empty p { color:var(--muted-soft); font-size:.72rem; max-width:220px; margin:0; text-align:center; }
/* The pulse these bars were written for. Two rules below set an
   animation-delay on them and nothing ever declared an animation, so the
   delays were pointing at nothing and the rows sat there as flat grey
   blocks — which is exactly what the code building them says they must not
   look like: "subtly pulsing, so the empty state doesn't read as a flat
   dead end while still being unmistakably nobody-here-yet rather than an
   actual loading state". Static, they read as a load that never finished. */
.scan-skeleton-bar {
  height:10px; border-radius:var(--radius-pill); background:rgba(var(--paper-rgb),.08);
  animation:scanSkeletonPulse 1.8s ease-in-out infinite;
}
@keyframes scanSkeletonPulse { 0%, 100% { opacity:.5; } 50% { opacity:1; } }
@media (prefers-reduced-motion: reduce) {
  .scan-skeleton-bar { animation:none; }
}
.manual-code-wrap { margin-top:12px; }
#qr-box { display:grid; place-items:center; }
#qr-box img, #qr-box canvas { border-radius:var(--radius-md); background:#fff; padding:8px; }

.currency-list-scroll { height:280px; overflow-y:auto; margin-top:4px; }
.legal-scroll-block { max-height:50vh; overflow-y:auto; font-size:.66rem; white-space:pre-wrap; line-height:1.6; }
.avatar-crop-viewport { border-radius:var(--radius-md); overflow:hidden; background:rgba(var(--paper-rgb),.06); }
.avatar-crop-viewport img { display:block; max-width:100%; }
.crop-zoom-range { accent-color:var(--glaze); }
.location-modal-box { width:100%; max-width:480px; height:82vh; padding:0; display:flex; flex-direction:column; overflow:hidden; }

/* The hairline has to read against the card, or the circle looks bitten.
   Each swatch is a conic gradient whose last third is the theme's surface —
   white on most of them — and var(--line) is too faint to hold the edge, so
   that third vanished into the white card and the swatch came out looking
   like a circle with a chunk cut out of it. Worst on the first and last,
   which have no neighbour on one side to give the eye an edge. */
.theme-swatch-btn {
  flex:1 1 0; min-width:28px; max-width:48px; aspect-ratio:1;
  border-radius:50%; border:1px solid rgba(var(--paper-rgb),.20);
  position:relative; cursor:pointer; padding:0; overflow:hidden;
  background:transparent; line-height:0;
}
/* The disc itself. Drawn as SVG so the wedge edges and the rim are
   antialiased -- a conic-gradient stair-steps on both. */
.theme-swatch-svg { display:block; width:100%; height:100%; border-radius:50%; }
.swatch-row { display:flex; gap:8px; align-items:center; }
.swatch-row.u-mt0 { margin-top:0; flex:1; min-width:0; }


/* ==========================================================================
   11  DARK THEME

   Only the eleven contract tokens plus the semantic accents are re-pointed.
   Nothing structural is repeated here — radius, spacing and type are
   theme-independent by design, which is also what keeps a checkout theme
   unable to alter layout.

   The accent inverts: near-black on light, near-white on dark. That is what
   produces a black pill button in one and a white pill in the other, from
   the same rule.
   ========================================================================== */

:root[data-theme="dark"] {
  color-scheme: dark;

  --ink: #131316;
  --ink-rgb: 19,19,22;
  --surface: #1d1d21;
  --surface-rgb: 29,29,33;
  --paper: #f4f4f5;
  --paper-rgb: 244,244,245;
  --glaze: #f4f4f5;
  --glaze-rgb: 244,244,245;
  --glaze-dark: #ffffff;
  --glaze-ink: #131316;
  --line: rgba(244,244,245,.11);

  /* Light ink on a dark ground needs roughly three times the alpha to read
     as the same amount of "chosen" — see the note in the light block. */
  --selected-wash: rgba(var(--paper-rgb),.22);
  --selected-wash-strong: rgba(var(--paper-rgb),.28);

  --default-glaze: #f4f4f5;
  --default-glaze-dark: #ffffff;
  --default-surface: #1d1d21;

  /* Brightened for a dark ground. The light theme's values are tuned for
     contrast against white and go muddy here; these clear 4.5:1 against
     both --surface and --ink in this theme. */
  --danger: #ff6b6b;         --danger-rgb: 255,107,107;
  --amber: #fdd663;          --amber-rgb: 253,214,99;
  /* on a dark ground the readable value is already yellow, so both are one */
  --amber-hue: #fdd663;      --amber-hue-rgb: 253,214,99;
  --free-color: #81c995;     --free-color-rgb: 129,201,149;
  --recurring: #c4b5fd;      --recurring-rgb: 196,181,253;
  --free-recurring: #8ab4f8; --free-recurring-rgb: 138,180,248;

  --muted: rgba(var(--paper-rgb),.56);
  --muted-soft: rgba(var(--paper-rgb),.34);

  /* elevation reads differently on a dark ground — a soft grey blur is
     invisible, so it goes deeper and tighter */
  --shadow-card: 0 1px 2px rgba(0,0,0,.4), 0 8px 22px rgba(0,0,0,.34);
  --shadow-float: 0 2px 6px rgba(0,0,0,.5), 0 12px 32px rgba(0,0,0,.55);
  --shadow-modal: 0 8px 24px rgba(0,0,0,.5), 0 32px 64px rgba(0,0,0,.55);

  --iti-hover-color: rgba(var(--paper-rgb),.08);
}

/* the carousel arrows sit on a photo in both themes, so they stay light */


/* ==========================================================================
   12  REMAINING COMPONENTS

   Smaller pieces the JS emits. Geometry ported from the previous
   stylesheet where the layout was load-bearing; skin is new throughout.
   ========================================================================== */

/* ---- the mode switch in the topbar: a pill with an icon either side and
   a sliding knob that carries its own icon ---- */
/* 36 to sit with the 38px controls beside it — it was 32, and next to the
   larger circles it read as a different, smaller class of control rather
   than one of the same row. Width stays 64: the bar has no spare pixels,
   and widening it takes a letter off the logo again. */
.icon-switch { position:relative; display:inline-block; width:64px; height:36px; flex-shrink:0; cursor:pointer; }
/* The checkbox is the control — it takes the click and drives every state
   below through :checked. Without this it rendered as a literal checkbox
   sitting on top of the pill, and with the four :checked rules gone too,
   the knob never moved and the icons never swapped. Both the owner/
   attendee switch and the theme switch build on this markup, so losing
   these rules broke both. */
.icon-switch input {
  position:absolute; inset:0; opacity:0; width:100%; height:100%;
  margin:0; cursor:pointer; z-index:3;
}
.icon-switch-track {
  position:absolute; inset:0; border-radius:var(--radius-pill);
  background:rgba(var(--paper-rgb),.07); border:1px solid var(--line);
}
.icon-switch-bg-icon {
  position:absolute; top:50%; transform:translateY(-50%);
  color:var(--muted-soft); display:flex; z-index:1;
}
.icon-switch-bg-icon.left  { left:10px; }
.icon-switch-bg-icon.right { right:10px; }
.icon-switch-knob {
  /* 2, not 3. The track carries a 1px border and an absolutely positioned
     child is placed against the padding box, inside it — so top:3 put the
     knob 4px below the track's visible edge and left 2px under it, and it
     sat a pixel low in its own pill. Same on the left. 2 + the border reads
     as the 3 that was intended. */
  position:absolute; top:2px; left:2px; width:30px; height:30px;
  border-radius:50%; background:var(--glaze); z-index:2;
  transition:transform .22s cubic-bezier(.22,.68,.36,1);
  box-shadow:0 1px 4px rgba(0,0,0,.25);
}
/* the knob holds both icons stacked and cross-fades between them, so the
   swap happens in place instead of one icon popping out as another pops in */
.icon-switch-knob-icon {
  position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  color:var(--glaze-ink); transition:opacity .15s ease;
}
.icon-switch-knob-icon.a { opacity:1; }
.icon-switch-knob-icon.b { opacity:0; }
/* 64 wide − 30 knob − 3 visible inset each side = 28 of travel. The inset
   is the 2px offset above plus the track's 1px border. */
.icon-switch input:checked ~ .icon-switch-track .icon-switch-knob { transform:translateX(28px); }
.icon-switch input:checked ~ .icon-switch-track .icon-switch-knob .icon-switch-knob-icon.a { opacity:0; }
.icon-switch input:checked ~ .icon-switch-track .icon-switch-knob .icon-switch-knob-icon.b { opacity:1; }
.icon-switch input:focus-visible ~ .icon-switch-track { outline:2px solid var(--glaze); outline-offset:2px; }
/* The one frame where the rebuilt switch is put back to where the old one
   was standing. Without this the placement itself would animate, and the
   knob would slide the wrong way first. Released on the next frame, at
   which point the real transition above carries it across. */
.icon-switch.is-swapping .icon-switch-knob,
.icon-switch.is-swapping .icon-switch-knob-icon { transition:none; }
@media (prefers-reduced-motion: reduce) {
  .icon-switch-knob, .icon-switch-knob-icon { transition:none; }
}

/* ---- ticket action links ---- */
.ticket-action-row { display:flex; align-items:center; justify-content:space-between; gap:10px; text-align:left; }
.ticket-action-link {
  flex-shrink:0; font-size:.66rem; font-weight:700;
  color:var(--paper); white-space:nowrap;
  text-decoration:underline; text-underline-offset:2px;
}

/* ---- checkout theme swatches ---- */
.theme-swatch-check { position:absolute; inset:0; display:flex; align-items:center; justify-content:center; }
.theme-swatch-check-dot {
  width:20px; height:20px; border-radius:50%;
  background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center;
}

/* ---- odds and ends ---- */
.icon-btn-dismiss { position:absolute; top:8px; right:8px; width:26px; height:26px; }
.forgot-link { display:block; margin:2px 0 4px; font-size:.66rem; }
/* The pricing and frequency toggles are two separate choices. This rule
   was lost in the stylesheet rewrite, which left them stacked flush
   against each other reading as one four-option control. */
.combo-toggle-group { display:flex; flex-direction:column; gap:var(--space-pair); }
.combo-toggle-group .error:empty { display:none; }
.combo-toggle-group .pricing-toggle { background:rgba(var(--paper-rgb),.05); }
.card-clickable-block { display:block; width:100%; text-align:left; }
.calendar-box-flex { min-height:0; }
/* The attendee week strip is the organizer Week module, unchanged, standing
   on its own above a list instead of inside a dashboard — so it only needs
   the gap the dashboard gave it by being followed by a section. */
.attendee-week { margin-bottom:var(--gap); }
.paypal-branded { font-weight:700; letter-spacing:-.02em; }

/* transient success/failure states on the scan result panel */
.scan-result.pop  { animation:pageEnter .18s ease; }
.scan-result.fail { color:var(--danger); }
/* A ticket presented twice. Everything the person holding the scanner needs
   to act: who, how many, and when it was first used. */
.scan-result-dupe .scan-result-name-row { margin-top:8px; }
.scan-result-dupe .meta { margin-top:8px; }


/* ==========================================================================
   13  PORTED COMPONENTS

   The many small, mostly single-use pieces the JS emits. Geometry is
   carried over from the previous stylesheet — it was correct and is not
   what the redesign is about — but every colour goes through a token now
   rather than the old hardcoded olive accent, so these follow the theme
   and any checkout theme applied over it.
   ========================================================================== */

@keyframes checkinCheckmarkPop {
  0%   { opacity:0; transform:scale(.3); }
  60%  { opacity:1; transform:scale(1.15); }
  100% { opacity:1; transform:scale(1); }
}
@keyframes checkinCursorBlink { 50% { opacity:0; } }
@keyframes textPop {
  0%   { transform:scale(1); }
  40%  { transform:scale(1.12); }
  100% { transform:scale(1); }
}
/* Starts and ends where the button actually sits. It reset to the old 5%
   wash, so an icon button flashed and then settled at a colour it had never
   been. */
@keyframes iconFlash {
  0%   { transform:scale(1);    background:rgba(var(--surface-rgb),.5); border-color:rgba(var(--paper-rgb),.07); color:var(--paper); }
  40%  { transform:scale(1.15); background:rgba(var(--glaze-rgb),.18); border-color:var(--glaze); color:var(--glaze); }
  100% { transform:scale(1);    background:rgba(var(--surface-rgb),.5); border-color:rgba(var(--paper-rgb),.07); color:var(--paper); }
}
@keyframes stepperPop {
  0%   { transform:scale(1); }
  50%  { transform:scale(1.18); }
  100% { transform:scale(1); }
}

/* ---- month navigation above the calendar ---- */
.cal-header { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:10px; }
.cal-header .u-m0 { font-size:.86rem; font-weight:700; letter-spacing:-.02em; }
/* The calendar's month and week arrows. Same treatment as every other round
   control — they sit in the same row as the filter button, and that one
   picked up the new look while these kept the old grey wash, so two identical
   shapes an inch apart were two different colours. */
.nav-arrow-btn {
  width:34px; height:34px; padding:0; flex-shrink:0;
  display:grid; place-items:center; border-radius:50%;
  background:rgba(var(--surface-rgb),.5); color:var(--paper);
  border:1px solid rgba(var(--paper-rgb),.07);
  box-shadow:0 1px 2px rgba(0,0,0,.06), 0 4px 12px rgba(0,0,0,.05);
}
@media (hover:hover) and (pointer:fine) {
  .nav-arrow-btn:hover {
    background:rgba(var(--surface-rgb),.75);
    box-shadow:0 1px 3px rgba(0,0,0,.09), 0 6px 16px rgba(0,0,0,.08);
  }
}
/* "and more", at the size a week strip has room for — three dots and a plus
   rather than a bare number, which read as a stray digit under the date. */
.dot-row .dot-more {
  font-size:.58rem; font-weight:700; line-height:1; color:var(--muted-soft);
  margin-left:1px; align-self:center;
}
.day-count {
  position:absolute; bottom:3px; left:50%; transform:translateX(-50%);
  font-size:.62rem; line-height:1; color:var(--muted); margin-top:0;
}

/* ---- event page chrome ---- */
.event-top-zone-plain { position:relative; height:66px; overflow:hidden; background:transparent; }
.event-top-back-btn   { position:absolute; top:16px; left:16px; z-index:5; margin-bottom:0; }
.event-top-controls   { position:absolute; top:16px; right:16px; z-index:5; display:flex; align-items:center; gap:8px; }
.event-description-text { font-size:.72rem; line-height:1.55; color:rgba(var(--paper-rgb),.78); }
/* Sold out: the dot, a 1px gap in the page ground, then a 1px ring in the
   event's own pricing colour. Two shadows because a border would grow the
   dot instead of sitting outside it. */
.sold-out-ring { box-shadow:0 0 0 1px var(--ink), 0 0 0 2px var(--ring-c); }
.accent-outline-badge { border-color:var(--accent-c); color:var(--accent-c); }
.contrast-adaptive {
  color:#fff; background:rgba(0,0,0,.45); border-color:rgba(255,255,255,.25);
  transition:background-color .3s ease, border-color .3s ease, color .3s ease;
}
.contrast-adaptive.on-bright-photo {
  color:#0a0a12; background:rgba(255,255,255,.55); border-color:rgba(0,0,0,.15);
}

/* ---- lists & rows ---- */
.attendee-row {
  display:flex; align-items:center; gap:10px; padding:9px 0;
  border-bottom:1px solid var(--line); font-size:.72rem;
}
.attendee-row:last-child { border-bottom:none; }
.attendee-row .attendee-name { flex:1; }
.stat-row { display:flex; align-items:center; justify-content:space-between; padding:7px 0; font-size:.72rem; }
.stat-row h3 { margin:0; }
.stat-row-icon-wrap { display:flex; color:var(--muted); }
.stat-row-value { font-weight:700; font-size:.78rem; }
.stat-row-value-13 { font-size:.72rem; font-weight:700; }
/* What has arrived since the organizer last opened this event, beside the
   count it changed — the same mark the event's card carries in a list. */
.stat-row-new { font-size:.72rem; font-weight:700; color:var(--paper); }
.switch-row { padding:11px 0; cursor:pointer; }
.switch-row.border-top { border-top:1px solid var(--line); }
.notice-row-start { display:flex; align-items:flex-start; }
.banner-row { display:flex; align-items:center; justify-content:space-between; gap:10px; flex-wrap:wrap; }
.banner-fee-note { cursor:pointer; text-decoration:underline dotted; }
.lock-icon-svg { flex-shrink:0; margin-top:2px; }

/* ---- plans ---- */
.plan-feature-list { margin:0; padding:0; list-style:none; }
.plan-feature-item { display:flex; align-items:center; gap:7px; font-size:.72rem; padding:4px 0; }
.plan-feature-item svg { flex-shrink:0; }
.tier-price-label { font-weight:700; font-size:.78rem; text-align:right; letter-spacing:-.02em; }
.price-suffix, .price-display-block .price-suffix { font-size:.72rem; font-weight:600; color:var(--muted); }

/* ---- booking progress ---- */
.booking-progress-track { background:rgba(var(--paper-rgb),.09); border-radius:var(--radius-pill); height:7px; overflow:hidden; }

/* The same allowance as the Billing card, on one line under the profile
   header: label, bar, count. Smaller than a card on purpose -- it reports,
   it does not announce. */
.plan-usage-strip {
  display:flex; align-items:center; gap:8px; width:100%;
  margin:6px 0 0; padding:0; border:0; background:none; box-shadow:none;
  color:var(--muted); font-family:inherit; font-size:var(--fs-caption);
  cursor:pointer; text-align:left;
}
.plan-usage-strip:active { transform:none; }
.plan-usage-track {
  flex:1 1 auto; min-width:40px; height:5px; overflow:hidden;
  border-radius:var(--radius-pill); background:rgba(var(--paper-rgb),.09);
}
.plan-usage-fill { display:block; height:100%; border-radius:var(--radius-pill); background:var(--glaze); }
.plan-usage-fill.is-over { background:var(--danger); }
.plan-usage-count { flex:0 0 auto; font-variant-numeric:tabular-nums; color:var(--paper); font-weight:600; }
@media (hover:hover) and (pointer:fine) { .plan-usage-strip:hover .plan-usage-count { text-decoration:underline; } }
.booking-progress-fill  { height:100%; border-radius:var(--radius-pill); background:var(--glaze); }

/* ---- rich text editor frame ---- */
.editor-frame {
  border-radius:var(--radius-md); background:rgba(var(--paper-rgb),.03);
  border:1px solid var(--line); overflow:hidden;
}
.editor-frame:focus-within { outline:2px solid var(--glaze); outline-offset:1px; }
.editor-frame:focus-within [contenteditable] { outline:none; }

/* ---- photos & avatars ---- */
.photos-strip-row { display:flex; gap:8px; flex-wrap:wrap; }
/* The thumbnail's own box. Without it the image renders at its natural
   size and pushes the row apart, which is what "not bound by boxes"
   meant — .photo-thumb was defined before the stylesheet rewrite and did
   not survive it. The add tile matches, so the row reads as one strip. */
.photo-thumb { position:relative; width:72px; height:72px; flex-shrink:0; }
.photo-thumb img {
  width:100%; height:100%; object-fit:cover; display:block;
  border-radius:var(--radius-md); border:1px solid var(--line); cursor:pointer;
}
.photo-thumb-remove {
  position:absolute; top:-6px; right:-6px; width:22px; height:22px; padding:0;
  border-radius:50%; background:var(--danger); color:#fff;
  border:2px solid var(--ink); cursor:pointer;
  display:grid; place-items:center; font-size:var(--fs-micro);
}
.avatar-photo-wrap { position:relative; flex-shrink:0; }
.owner-avatar-lg { width:64px; height:64px; border-radius:50%; object-fit:cover; }
.owner-avatar-modal-photo { width:100%; border-radius:var(--radius-md); display:block; }
.crop-viewport {
  position:relative; width:100%; aspect-ratio:4/3; overflow:hidden;
  border-radius:var(--radius-md); background:#000; touch-action:none; cursor:grab;
}
.crop-img { position:absolute; top:0; left:0; transform-origin:0 0; user-select:none; -webkit-user-drag:none; }
.avatar-crop-box .cropper-view-box { outline:1px solid rgba(255,255,255,.8); }

/* ---- occurrences & chips ---- */
.occ-chips-row { display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
.occ-session-list { display:flex; flex-direction:column; gap:8px; }
.chip-row-scroll { display:flex; gap:6px; overflow-x:auto; padding:2px 2px 16px; }

/* ---- support threads ---- */
.convo-thread-meta { margin:0 0 3px; font-weight:700; font-size:.66rem; }
.convo-thread-text { font-size:.72rem; white-space:pre-wrap; margin:0; }

/* ---- scan results & skeletons ---- */
.scan-result-name-row {
  display:flex; align-items:baseline; justify-content:space-between; gap:12px;
}
.scan-result-name { font-size:1.05rem; font-weight:700; letter-spacing:-.02em; min-width:0; }
.scan-result-party { font-size:.72rem; font-weight:700; opacity:.7; white-space:nowrap; flex-shrink:0; }
.scan-result.ok   { background:rgba(var(--free-color-rgb),.12); border:1px solid rgba(var(--free-color-rgb),.4); border-radius:var(--radius-md); padding:12px; }
.scan-result.fail { background:rgba(var(--danger-rgb),.10);     border:1px solid rgba(var(--danger-rgb),.4);     border-radius:var(--radius-md); padding:12px; color:var(--danger); }
.scan-skeleton-rows { width:100%; display:flex; flex-direction:column; gap:14px; }
.scan-skeleton-row  { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.scan-skeleton-row .scan-skeleton-lines { display:flex; flex-direction:column; gap:6px; }
.scan-skeleton-row:nth-child(2) .scan-skeleton-bar { animation-delay:.25s; }
.scan-skeleton-row:nth-child(3) .scan-skeleton-bar { animation-delay:.5s; }
.checkin-spinner {
  width:26px; height:26px; border-radius:50%;
  border:2px solid var(--line); border-top-color:var(--glaze);
  animation:spin .7s linear infinite;
}
@keyframes spin { to { transform:rotate(360deg); } }
.checkin-checkmark-anim { animation:checkinCheckmarkPop .5s cubic-bezier(.34,1.56,.64,1) both; }
.checkin-typewriter-cursor::after { content:"|"; animation:checkinCursorBlink 1s step-start infinite; }

/* ---- manual code entry ---- */
.manual-code-input-row { position:relative; }
.manual-code-input-row input { padding-right:46px !important; }
.manual-code-submit-btn {
  position:absolute; right:6px; top:50%; transform:translateY(-50%);
  width:32px; height:32px; padding:0; border-radius:50%;
  display:grid; place-items:center;
}
.manual-code-submit-btn:active { transform:translateY(-50%) scale(.98); }

/* ---- legal / consent variants ---- */
.unified-legal-checkbox { flex-shrink:0; }
.unified-tos-link, .unified-privacy-link { text-decoration:underline; text-underline-offset:2px; cursor:pointer; font-weight:600; }
.unified-legal-error { color:var(--danger); font-size:.66rem; margin-top:5px; }

/* ---- misc ---- */
/* One line. Broken across three, a share URL is the loudest thing on the
   page, and nobody reads it — the button beside it is what gets used. */
.share-link-mono {
  font-family:"Space Mono",ui-monospace,monospace; margin:0; font-size:.66rem;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis; min-width:0;
}
.input-error { border-color:var(--danger) !important; }
.add-never-mind-btn { display:block; margin:12px auto 0; }
.event-type-submit { width:100%; }
.btn-compact, button.btn-hero.btn-compact { padding:9px 16px; font-size:.66rem; width:auto; }
.u-wheel-wrap { position:relative; }
.notif-severity-label { text-transform:uppercase; letter-spacing:.04em; font-size:.62rem; color:var(--sev-c); font-weight:700; }
.text-flash { display:inline-block; color:var(--glaze) !important; animation:textPop .35s ease; }
.party-stepper button.pop { animation:stepperPop .25s ease; }
.icon-btn.flash { animation:iconFlash .45s ease; }
.icon-btn.danger { flex-shrink:0; }
.flash-feedback { transition:background-color .2s ease, border-color .2s ease, color .2s ease, transform .2s ease !important; }
.flash-feedback.flash, .share-target-btn.flash {
  background:rgba(var(--glaze-rgb),.18) !important;
  border-color:var(--glaze) !important; color:var(--glaze) !important;
  transform:scale(1.12) !important;
}
/* fixed, not absolute: showTooltip places this against the viewport and
   clamps it inside both edges, so it cannot be pushed off screen by an
   anchor that sits near one. max-width keeps a long message inside a narrow
   screen, and it wraps rather than running off. */
/* Announced, not shown. Copies no longer raise a tooltip of their own — the
   platform posts its own notice — but a screen reader still needs to be told,
   and this is where that is said. Off-screen rather than display:none, which
   is not announced at all. */
.sr-only {
  position:absolute; width:1px; height:1px; margin:-1px; padding:0;
  overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; border:0;
}
.copy-tooltip {
  position:fixed; z-index:120; opacity:0; transform:translateY(4px);
  transition:opacity .15s ease, transform .15s ease; pointer-events:none;
  font-size:.62rem; background:var(--glaze); color:var(--glaze-ink);
  padding:4px 9px; border-radius:var(--radius-sm);
  max-width:calc(100vw - 16px); white-space:nowrap;
  overflow:hidden; text-overflow:ellipsis;
}
.copy-tooltip.show { opacity:1; transform:translateY(0); }


/* ==========================================================================
   14  THE PHOTO-HERO PAGE SHELL

   Structure applied by applyHeroPageShell() in js/app-helpers.js: the photo
   bleeds to the column edges and holds its place while an opaque column of
   content rides up over it.
   ========================================================================== */

/* Breaks out of #app's horizontal padding so the photo reaches the screen
   edges, and cancels its top padding so it starts flush. */
.event-top-zone.carousel-wrap,
.event-top-zone:not(.event-top-zone-plain) {
  margin-inline: calc(var(--app-pad-x) * -1);
  margin-top:-14px;
  border-radius:0;
  width:auto;
}

/* No z-index here on purpose. Setting one makes this a stacking context
   and traps the back/share controls inside it, so they can never paint
   above the content column — they would slide under the card instead of
   travelling with the photo. */
/* Same stale hint as .ev-hero had. This one animates nothing at all, and
   .carousel-img only crossfades for 200ms when the picture changes — a
   running transition gets its own layer for the duration without being
   told, which is the whole point of not making it permanent. */
.hero-sticky { position:sticky; top:0; }

/* THE opaque column. Carries the page colour itself so the gaps between
   cards read as gaps — without it the sticky photo behind shows through
   every one of them while scrolling. */
.page-content-column {
  position:relative; z-index:2;
  background:var(--ink);
  border-radius:var(--radius-lg) var(--radius-lg) 0 0;
  margin-top:-34px;
  padding-top:16px;
}
/* a hero with no photo is only reserving space for the icon row — there
   is nothing to ride over, so the column sits normally */
.event-top-zone-plain + .page-content-column {
  margin-top:0; padding-top:0; border-radius:0; background:transparent;
}

/* the carousel dots would be hidden under the column's top edge at their
   natural position */
.event-top-zone .carousel-dots { bottom:48px; }


/* Cards inside the column keep a single consistent gap. Set on the
   children directly rather than with an adjacent-sibling rule: that form
   only matches when the previous element is the same kind, so a card
   following a wrapper matched nothing and sat flush against it. */
.page-content-column > .card,
.page-content-column > .ev-stub { margin-top:var(--gap); }
.page-content-column > *:first-child { margin-top:0; }


/* ==========================================================================
   16  FLOATING ACTION BAR

   Detached from the window edges rather than spanning them, so content
   scrolls visibly beneath it and it reads as hovering above the page
   instead of cutting it off.
   ========================================================================== */

/* The footer element IS the bar. The JS appends its buttons straight into
   it with no inner wrapper, so styling a wrapper here would style nothing —
   and adding one would mean editing three separate call sites that each
   build their own footer contents. */
.sticky-form-footer {
  position:fixed; z-index:30;
  left:50%; transform:translateX(-50%);
  bottom:calc(9px + env(safe-area-inset-bottom));
  width:calc(100% - 16px); max-width:calc(var(--app-max-w) + 16px);
  display:flex; align-items:center; gap:10px;
  background:var(--glaze); color:var(--glaze-ink);
  border-radius:20px; padding:11px;
  box-shadow:var(--shadow-float);
  border:none;
}
/* the action inside the bar inverts — a light pill on a dark bar */
.sticky-form-footer .btn-hero,
.sticky-form-footer button:not(.icon-btn):not(.ghost) {
  flex:1; width:auto; background:var(--surface); color:var(--paper);
  padding:13px 20px; font-size:.78rem; border-radius:var(--radius-pill);
}
/* The create/save action is not a light pill — the JS fills it with the
   event-type accent, and sets its text colour inline at the same time.
   A CSS rule here cannot work: it would also fire before any type is
   picked, when the button really is a white pill, and paint white on
   white. The two belong together, so they are set together. */
@media (hover:hover) and (pointer:fine) {
  .sticky-form-footer .btn-hero:hover,
  .sticky-form-footer button:not(.icon-btn):not(.ghost):hover { background:var(--surface); filter:brightness(.94); }
}
.sticky-form-footer .icon-btn {
  background:rgba(var(--surface-rgb),.18); border-color:transparent; color:var(--glaze-ink); flex:none;
}
@media (hover:hover) and (pointer:fine) {
  .sticky-form-footer .icon-btn:hover { background:rgba(var(--surface-rgb),.3); }
}
.sticky-form-footer .meta, .sticky-form-footer .u-fs12 { color:var(--glaze-ink); opacity:.62; }
/* the swatch row rides inside the bar on the owner preview */
.sticky-form-footer .swatch-row { flex:1; min-width:0; }
/* the drop shadow this used to gain on scroll is now permanent — it
   floats above the page at all times, so there is no "overlapping" state */
.sticky-form-footer.overlapping { box-shadow:var(--shadow-float); }

/* A footer holding a single action does not need a bar around it. With a
   light pill sitting inside a dark bar of almost the same size, the two
   shapes read as a double border rather than as one control — so when
   there is only one child, the button simply IS the floating element. */
.sticky-form-footer:has(> :only-child) {
  background:transparent; box-shadow:none; padding:0;
}
/* the :not() chain is not decorative — it matches the specificity of the
   general in-bar button rule above, which would otherwise win and leave
   this button light-on-light */
.sticky-form-footer:has(> :only-child) > .btn-hero:not(.icon-btn):not(.ghost),
.sticky-form-footer:has(> :only-child) > button:not(.icon-btn):not(.ghost) {
  width:100%; flex:1;
  background:var(--glaze); color:var(--glaze-ink);
  box-shadow:var(--shadow-float);
  padding:15px 22px; font-size:.78rem;
}
@media (hover:hover) and (pointer:fine) {
  .sticky-form-footer:has(> :only-child) > button:hover { background:var(--glaze-dark); filter:none; }
}


/* ==========================================================================
   17  ORGANIZER TRACK RECORD
   Attendance signals in place of star ratings — see buildOrganizerTrackRecord()
   in js/ui-helpers.js. Five tiers, all derived from real bookings.
   ========================================================================== */

.organizer-card { margin-top:var(--gap); }
.organizer-head { display:flex; align-items:center; gap:11px; }
.organizer-avatar { width:42px; height:42px; flex-shrink:0; }
.organizer-who { flex:1; min-width:0; }
/* Matching the event page copy of this card, so the same card is not set at
   two sizes depending on which page you met it on. */
.organizer-name { font-size:var(--fs-body); font-weight:700; letter-spacing:-.02em; }

.organizer-badge {
  display:inline-flex; align-items:center; gap:5px; flex-shrink:0;
  padding:5px 11px; border-radius:var(--radius-pill);
  font-size:.62rem; font-weight:700; white-space:nowrap; border:1px solid;
}
/* The ladder reads as a ladder: the top tier is a solid fill, the ones
   below step down through tinted outlines to a plain neutral. Someone
   should be able to tell them apart without reading the words. */
.organizer-badge.tier-established  { background:var(--glaze); color:var(--glaze-ink); border-color:var(--glaze); }
.organizer-badge.tier-very_popular { background:rgba(var(--glaze-rgb),.12); color:var(--paper); border-color:rgba(var(--glaze-rgb),.45); }
.organizer-badge.tier-popular      { background:rgba(var(--free-color-rgb),.10); color:var(--free-color); border-color:rgba(var(--free-color-rgb),.4); }
.organizer-badge.tier-rising       { background:rgba(var(--amber-rgb),.10); color:var(--amber); border-color:rgba(var(--amber-rgb),.4); }
.organizer-badge.tier-new          { background:rgba(var(--paper-rgb),.05); color:var(--muted); border-color:var(--line); }

.organizer-stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(0,1fr)); gap:8px; margin-top:16px; }
/* a second row of the same boxes is one block, not a new section — it was
   taking the full 16px and reading as two unrelated groups */
.organizer-stats + .organizer-stats { margin-top:8px; }
.organizer-stat {
  padding:13px 10px; border-radius:var(--radius-md); text-align:center;
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
}
.organizer-stat-n { font-size:var(--fs-h2); font-weight:700; letter-spacing:-.03em; }
/* 600, matching the event page copy of this same card. It was 500 in both;
   the event page is now on the two-weight system and this follows it, so the
   card does not read differently depending on which page you found it on. */
.organizer-stat-k { font-size:var(--fs-meta); color:var(--muted); margin-top:2px; font-weight:400; }
.organizer-since { font-size:var(--fs-meta); color:var(--muted); font-weight:400; }
.xs { font-size:.66rem; }


/* ==========================================================================
   18  THE REST

   Components missed by the first coverage pass. That pass matched
   class="..." and classList.add("...") but not `className = "..."` written
   with spaces around the equals, so a whole set of pieces — the entire day
   calendar among them — shipped with no rules at all and rendered as bare
   markup.
   ========================================================================== */

/* ---- day view: one column per hour, scrolled horizontally ---------------
   The columns are deliberately narrow and scrollable rather than squeezed
   to fit: a day has 24 of them and forcing them all into a phone's width
   makes each too thin to read a title in. */
.hour-col-scroll {
  display:flex; gap:6px; margin-top:12px;
  overflow-x:auto; padding-bottom:6px; scroll-behavior:auto;
  scrollbar-width:thin;
}
.hour-col {
  flex:0 0 104px; min-height:72px;
  border-left:1px solid var(--line); padding:2px 0 2px 10px;
}
.hour-col-header {
  font-size:.62rem; color:var(--muted); font-weight:700;
  text-transform:uppercase; letter-spacing:.04em; margin-bottom:8px;
}
/* the current hour is the one piece of orientation this view gives, so it
   gets the accent rather than blending into the other 23 */
.hour-col.now { border-left-color:var(--glaze); }
.hour-col.now .hour-col-header { color:var(--glaze); }
/* hours already gone recede, matching how past days fade in the month and
   week grids. The events inside stay a touch more legible than the empty
   column around them — something that already happened is still worth
   being able to read. */
.hour-col.past-hour { opacity:.4; }
.hour-col.past-hour .hour-col-event { opacity:.85; }
/* --accent-c is set per event by the dashboard. It tints rather than
   fills: a solid block per event drowned the hour grid and left the
   titles fighting the background for legibility. */
.hour-col-event {
  display:block; width:100%; text-align:left;
  background:color-mix(in srgb, var(--accent-c, var(--paper)) 13%, transparent);
  border:1px solid color-mix(in srgb, var(--accent-c, var(--paper)) 30%, transparent);
  border-left:3px solid var(--accent-c, var(--paper));
  border-radius:var(--radius-sm); padding:8px 9px; margin-bottom:6px;
  color:var(--paper); font-family:inherit;
}
@media (hover:hover) and (pointer:fine) {
  .hour-col-event:hover { background:color-mix(in srgb, var(--accent-c, var(--paper)) 22%, transparent); }
}
.hour-col-event strong {
  display:block; font-size:.66rem; font-weight:700; letter-spacing:-.01em;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.hour-col-event span { display:block; font-weight:600; opacity:.7; font-size:.62rem; margin-top:2px; }

.ticket-divider { height:1px; background:var(--line); margin:14px 0; border:none; }

/* ---- sessions & pills ---- */
.session-pill-row { display:flex; gap:6px; flex-wrap:wrap; margin:10px 0 0; }
.occ-session-row {
  display:flex; align-items:center; justify-content:space-between; gap:10px;
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
  border-radius:var(--radius-md); padding:9px 9px 9px 14px;
}
.pill-choice {
  padding:7px 13px; border-radius:var(--radius-pill); cursor:pointer;
  border:1px solid var(--line); background:transparent; color:var(--paper);
  font-family:inherit; font-size:.66rem; font-weight:600;
}
.pill-choice.active { background:var(--glaze); color:var(--glaze-ink); border-color:var(--glaze); }
.filter-active-dot {
  position:absolute; top:6px; right:6px; width:7px; height:7px;
  border-radius:50%; background:var(--glaze);
}
.ghost-preview-dot { width:7px; height:7px; border-radius:50%; background:var(--muted-soft); }

/* ---- share sheet ---- */
.share-link-row {
  display:flex; align-items:center; gap:10px;
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
  border-radius:var(--radius-md); padding:12px 12px 12px 14px;
}
.share-link-text {
  flex:1; min-width:0; font-family:"Space Mono",ui-monospace,monospace;
  font-size:.66rem; word-break:break-all; color:var(--muted);
}
.share-target-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(76px,1fr)); gap:8px; }

/* ---- forms & auth ---- */
.password-field-wrap { position:relative; }
.password-toggle-btn {
  position:absolute; right:8px; top:50%; transform:translateY(-50%);
  width:32px; height:32px; padding:0; border-radius:50%;
  background:transparent; border:none; color:var(--muted);
  display:grid; place-items:center;
}
@media (hover:hover) and (pointer:fine) { .password-toggle-btn:hover { background:rgba(var(--paper-rgb),.07); color:var(--paper); } }
.pw-strength-track { height:4px; border-radius:var(--radius-pill); background:rgba(var(--paper-rgb),.09); overflow:hidden; margin-top:8px; }
.pw-strength-fill  { height:100%; border-radius:var(--radius-pill); transition:width .2s ease, background-color .2s ease; }
.currency-row {
  display:flex; align-items:center; justify-content:space-between; gap:10px;
  padding:11px 12px; border-radius:var(--radius-md); cursor:pointer; font-size:.72rem;
}
@media (hover:hover) and (pointer:fine) { .currency-row:hover { background:rgba(var(--paper-rgb),.06); } }
.currency-row.active { background:rgba(var(--glaze-rgb),.12); }

/* ---- location picker ---- */
/* The close button sits over the top-right corner, hence the right pad.
   Without display:flex the input and Search button stacked and the close
   button landed on top of them. */
.location-search-bar {
  display:flex; gap:8px; align-items:center;
  padding:14px; padding-right:56px; border-bottom:1px solid var(--line);
}
.location-search-bar input { flex:1; min-width:0; margin:0; }
.location-search-bar button { flex:none; }
/* Leaflet measures its container, so the map needs a real height of its
   own — the flexing wrapper around it is not enough, and without this the
   map rendered as a zero-height strip. */
.location-map-canvas { position:absolute; inset:0; background:rgba(var(--paper-rgb),.06); }
/* Stacked, not a flex row. The confirm button is a full-width .btn-hero,
   so in a row it claimed the whole width and squeezed the address into a
   one-word-per-line column beside a button drawn as a black circle. */
.location-footer { padding:14px; border-top:1px solid var(--line); }
.location-address {
  font-size:var(--fs-meta); color:var(--muted); margin-bottom:var(--space-heading);
  /* a full postal address should not push the map off the screen */
  max-height:2.9em; overflow:hidden; line-height:1.45;
}
.map-center-pin {
  position:absolute; left:50%; top:50%; transform:translate(-50%,-100%);
  /* above Leaflet, whose own panes sit at 400-700 — at z-index 5 the pin
     was drawn underneath the tiles and never appeared */
  pointer-events:none; z-index:800; color:var(--danger);
}

/* ---- photos ---- */
.photo-tile-add {
  display:grid; place-items:center; width:72px; height:72px; flex-shrink:0;
  border-radius:var(--radius-md); border:1.5px dashed var(--line);
  background:transparent; color:var(--muted); cursor:pointer;
  font-size:var(--fs-h2); line-height:1;
}
@media (hover:hover) and (pointer:fine) { .photo-tile-add:hover { border-color:var(--muted); color:var(--paper); } }

/* ---- misc ---- */
.bordered-subsection { border:1px solid var(--line); border-radius:var(--radius-md); padding:14px; }
.btn-row-tight { display:flex; gap:8px; }
.clickable { cursor:pointer; }
.modal-close {
  position:absolute; top:10px; right:10px; width:30px; height:30px; padding:0;
  border-radius:50%; background:rgba(var(--paper-rgb),.06); border:1px solid var(--line);
  color:var(--paper); display:grid; place-items:center;
}
.owner-avatar-sm {
  width:26px; height:26px; flex-shrink:0; padding:0; border:none;
  box-sizing:border-box; border-radius:50%; overflow:hidden;
  background-size:cover; background-position:center; background-repeat:no-repeat;
  display:inline-grid; place-items:center;
  font-size:.66rem; font-weight:700; line-height:1; color:#fff;
  cursor:pointer;
}
.plan-change-card { border:1px solid var(--glaze); }
/* Centred, with room under it. This sits alone above a centred heading on
   the payment-failed page — left as a bare block it pinned itself to the
   left edge while the heading beside it stayed centred, so the mark read
   as though it belonged to something else on the page. Bigger too, to
   match the mark on the result screen that precedes it. */
.status-icon-circle-danger {
  width:56px; height:56px; border-radius:50%; display:grid; place-items:center;
  background:rgba(var(--danger-rgb),.12); color:var(--danger); flex-shrink:0;
  margin:8px auto 14px;
}
.status-icon-circle-danger svg { width:30px; height:30px; }
/* The Google button is a primary action and has to look like one.
   It carried only a fill, so with no .btn-hero it fell back to a default
   button: shrink-to-fit width, its own padding, its own font size — sitting
   directly beneath a full-width "Create account" at half the width and a
   different height. It takes .btn-hero now and this supplies the fill only. */
/* The same fill every other primary button uses, not a literal black.
   Buttons take var(--glaze), which a checkout theme moves — so on a themed
   page "Create account" was terracotta or forest green and the Google button
   directly beneath it stayed pure #000. In the default theme the two were
   only a shade apart, which is how it went unnoticed. */
.gpay-btn { background:var(--glaze); color:var(--glaze-ink); }
.gpay-btn .gpay-g { font-weight:800; margin-right:2px; }
.scan-frame-error-overlay { position:absolute; inset:0; display:grid; place-items:center; background:rgba(0,0,0,.6); color:#fff; padding:20px; text-align:center; }
.payment-result-transition { transition:opacity .2s ease; }
.platform-notif-overlay { position:fixed; inset:0; z-index:900; display:grid; place-items:center; padding:20px; background:rgba(0,0,0,.45); }


/* ==========================================================================
   19  SCROLLBARS

   Hidden throughout. The app has several horizontally-scrolling strips —
   the hour grid, session pills, chip rows — where a visible bar sits
   directly under the content and reads as a stray rule. Scrolling itself
   is untouched: overflow still works by wheel, drag, trackpad and
   keyboard, and the hour strip keeps its own arrows as an explicit cue.
   ========================================================================== */

* { scrollbar-width:none; -ms-overflow-style:none; }
*::-webkit-scrollbar { width:0; height:0; display:none; }


/* ==========================================================================
   20  EVENT CARD

   One structure shared by all four card templates — attendee and owner,
   one-off and recurring. They previously carried ad-hoc spacing utilities
   that had drifted apart: the attendee recurring card put 2px between its
   meta line and its tags where every other card put 10px, which is why the
   gap under the event info looked different depending on the list you were
   looking at. Spacing lives here now so the four cannot disagree.
   ========================================================================== */

/* A LIST CARD, LED BY ITS DATE
   ----------------------------
   The card used to be a stack: title, then a line naming the organizer, the
   date, the time and the place, then a tag. That line ran past the width of a
   phone on most events and wrapped, so every card was five lines tall and the
   thing you actually scan a list for — when is it — sat in the middle of a
   sentence.
   A tile on the left holds the day and the month, the way a diary page does,
   and the line beside it drops the date it no longer has to say. The title is
   allowed two lines and the line under it one; a card is an index entry, and
   the page it points at has the whole of it. */
/* ---- an event, as a card in a list ----
   Three bands rather than two columns: where and when at the top, what it is
   in the middle, and whatever the card has to say about state at the bottom.
   The date is a disc rather than a bordered tile — it is the one fixed shape
   on the card and it reads as a stamp on it. Colour is left out on purpose:
   these sit in long lists and a different ground per card turns a list into
   a paint chart. */
/* One gap, used everywhere on the card.
   When, then who and what, then what it costs and how many are going: three
   bands with the same air between them, so the card reads as evenly spaced
   rather than as a head with two afterthoughts under it. */
/* The bands are set apart; the lines inside a band are not. Wider here, so
   the name has air above it and the foot has air below the line under it. */
.event-card-head { display:flex; align-items:center; gap:12px; margin-bottom:calc(var(--space-pair) * 1.5); }
.event-card-body { margin-bottom:calc(var(--space-pair) * 1.5); }
.event-card-body:last-child { margin-bottom:0; }
/* The event's own photograph, at the end of the when-and-where line. Square,
   the height of the date disc beside it, so the line has the same weight at
   both ends. */
.event-card-thumb {
  flex:0 0 auto; margin-left:auto;
  width:54px; height:54px; border-radius:var(--radius-md);
  object-fit:cover; background:rgba(var(--paper-rgb),.06);
}
.event-card-date {
  flex:0 0 auto; width:54px; height:54px; padding:0;
  display:flex; flex-direction:column; align-items:center; justify-content:center; gap:1px;
  border:0; border-radius:50%;
  background:var(--paper); color:var(--ink);
}
.event-card-date-day {
  font-size:var(--fs-lead); font-weight:700; line-height:1; letter-spacing:-.02em;
  font-variant-numeric:tabular-nums;
}
.event-card-date-mon {
  font-size:var(--fs-micro); font-weight:600; line-height:1;
  letter-spacing:.07em; text-transform:uppercase; opacity:.72;
}
.event-card-when { flex:1 1 auto; min-width:0; }
/* When, then where — the two facts the reference puts at the top of the card,
   in the order somebody scanning a list asks them. */
.event-card-time {
  /* The same colour as the line under it. They are one fact in two parts —
     when and where — and the time being darker made it read as a heading
     with the place as a note beneath it. */
  margin:0; font-size:var(--fs-meta); color:var(--muted); font-weight:600; line-height:1.25;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.event-card-time:empty { display:none; }
/* Where it is, on up to two lines — a venue and a street run long and the
   card would rather wrap than cut them off mid-word. */
.event-card-where {
  margin:0; font-size:var(--fs-meta); color:var(--muted); line-height:1.25;
  display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
.event-card-where:empty { display:none; }
/* Who is running it, above the title and quieter than it. */
.event-card-owner {
  margin:0 0 2px; font-size:var(--fs-meta); color:var(--muted);
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.event-card-owner:empty { display:none; }
/* On the attendee’s lists the name comes first and the organizer sits
   under it. Somebody browsing what to go to is looking for the thing; the
   organizer is who is running it, which matters second. On the owner’s own
   lists every event is theirs, so that order would be a line of their own
   name above each of their events. */
.card-name-first .event-card-body { display:flex; flex-direction:column; }
.card-name-first .event-card-owner { order:2; margin:2px 0 0; }
.card-name-first .event-card-title { order:1; }
.card-name-first .event-card-signup { order:3; }
.event-card-body .event-card-title {
  margin:0; font-size:var(--fs-lead);
  display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
}
/* How many are going. A count, where the reference stacks faces: the number
   is the fact anybody wants from that corner, and it does not need a
   photograph of a stranger to carry it. */
/* Far right of the foot, so the row reads price and dates from the left and
   the count anchors the other end whatever is between them. */
.event-card-going {
  flex:0 0 auto; margin-left:auto;
  min-width:38px; height:30px; padding:0 10px;
  display:inline-flex; align-items:center; justify-content:center;
  border-radius:99px; border:1px solid var(--line);
  background:rgba(var(--paper-rgb),.05);
  font-size:var(--fs-meta); font-weight:700; font-variant-numeric:tabular-nums;
}
/* Who is going, not just how many: a short stack of faces, then the total.
   The pill loses its left padding so the first face sits where the border
   does, which is what makes the group read as a group rather than as an icon
   dropped inside a button. */
.event-card-going.has-faces { padding:0 10px 0 3px; gap:7px; }
.going-faces { display:inline-flex; align-items:center; }
.going-face {
  width:22px; height:22px; border-radius:50%;
  display:inline-flex; align-items:center; justify-content:center;
  font-size:10px; font-weight:700; line-height:1; color:#fff;
  background:var(--muted);
  /* The ring is the card's own ground, so the faces read as overlapping
     rather than as one blurred shape. */
  box-shadow:0 0 0 2px var(--surface);
  background-size:cover; background-position:center;
}
.going-face.has-photo { color:transparent; background-color:var(--surface); }
.going-face + .going-face { margin-left:-8px; }
.going-count { line-height:1; }
/* Kept for later. Beside the count, quiet until it is on — a saved event
   should be findable at a glance down a list, and an unsaved one should not
   be shouting an invitation. */
.event-card-save {
  flex:0 0 auto; width:30px; height:30px; padding:0;
  display:inline-flex; align-items:center; justify-content:center;
  border-radius:99px; border:1px solid var(--line);
  background:rgba(var(--paper-rgb),.05); color:rgba(var(--paper-rgb),.35);
  cursor:pointer; transition:color .18s ease, background-color .18s ease;
}
.event-card-save.on { color:var(--danger); background:rgba(var(--danger-rgb),.10); border-color:rgba(var(--danger-rgb),.25); }
@media (hover:hover) and (pointer:fine) { .event-card-save:hover { color:var(--danger); } }
/* The count and the heart are one thing at the right-hand end, whichever of
   them is there.

   They were two: the heart added a margin of its own on top of the foot's
   gap, so the pair sat twice as far apart as anything else in the row, and
   with no count beside it the heart had nothing pushing it right at all.
   Then a fourth pill arrived and the row wrapped between them, leaving the
   heart stranded on a line by itself. Held together they wrap together, and
   a second line reads as the end of the row rather than as a stray. */
.event-card-end {
  display:flex; align-items:center; gap:6px;
  margin-left:auto; flex:0 0 auto;
}
.event-card-end:empty { display:none; }
/* One spacing for the whole row. The pills inside a group were 6px apart and
   the groups themselves 8px, so a row of four pills had two different gaps in
   it depending on which slot each pill had been handed to. */
.event-card-foot {
  display:flex; align-items:center; gap:6px; flex-wrap:wrap;
}
.event-card-foot .event-card-lead-tags,
.event-card-foot .event-card-tags {
  display:flex; align-items:center; flex-wrap:wrap; gap:6px;
  min-width:0;
}
/* .tag carries a left margin for the places that lay pills out as plain
   inline text. In here the gap does that job, and the two together were the
   uneven spacing. */
.event-card-foot .tag + .tag, .event-card-foot .tag-ml6 { margin-left:0; }
.event-card-foot:empty { display:none; }
.event-card-foot .event-card-lead-tags,
.event-card-foot .event-card-tags { margin:0; }
/* Tight under the name, exactly as the place sits under the time: the two
   lines are one block, and the air belongs around the block rather than
   inside it. */
.event-card-signup { margin:5px 0 0; line-height:1.25; }
.event-card-signup:empty { display:none; }

.event-card-tags   { display:flex; flex-wrap:wrap; gap:6px; align-items:center; margin-top:10px; }
/* .tag adds a left margin when it follows another tag, which double-counts
   against the flex gap here */
.event-card-tags .tag + .tag, .event-card-tags .tag-ml6 { margin-left:0; }
/* the session pills a recurring card appends sit below the tags */
.card > .session-pill-row, .card > .chip-row { margin-top:10px; }
/* Full width, now that the card is banded rather than columned. The indent
   existed to clear a date tile standing beside the text; the date is a disc
   at the top of the card and there is nothing beside these rows any more. */

/* .muted and .xs are used directly as classes in a few places, not only as
   the tokens they are named after. Both were referenced before they were
   defined, which left that text at full-strength colour and full size. */
.muted { color:var(--muted); }
.xs    { font-size:.66rem; }
/* Marker classes carrying no styling of their own — the owner-preview pass
   looks for .preview-back-btn to decide what stays interactive, and
   .plan-confirm-btn identifies the confirm action on plan changes. Listed
   here so a future dead-class sweep does not mistake them for orphans. */


/* ==========================================================================
   21  CALENDAR PARALLAX

   The dashboard calendar parks while the event list climbs over it, the
   same mechanic the event page uses for its photo. Applied by
   applyCalendarPageShell() in js/app-helpers.js.
   ========================================================================== */

/* The zone parks while the event list climbs over it, on Day, Week and
   Month alike. The column needs its own opaque ground and a rounded top
   edge, or the strip shows through the gaps between the cards below and
   the two read as one muddled layer.

   The zone is only the analytics strip now — the calendar sits inside the
   column and scrolls with it, so it needs no rounded lid of its own. */
.calendar-sticky { position:sticky; top:0; z-index:0; }
.page-content-column.over-calendar {
  background:var(--ink);
  position:relative; z-index:1;
  /* Clear of the strip it scrolls over. I removed this when trimming the
     rule and the column then started 10px inside the zone, covering the
     bottom of the activity strip at rest. */
  margin-top:10px;
}


/* ==========================================================================
   22  DAY CHIPS

   The weekday and month-day pickers on the create form. --chip-c is the
   event's accent, chosen at runtime and the only thing the JS still sets;
   everything else belongs here. Both were previously styled entirely
   inline, which meant they ignored the stylesheet outright — an inline
   style outranks every rule in it.
   ========================================================================== */

.day-chip {
  width:36px; height:36px; padding:0; flex-shrink:0;
  border-radius:50%; font-size:.66rem; font-weight:700; font-family:inherit;
  display:grid; place-items:center; cursor:pointer;
  background:rgba(var(--paper-rgb),.06); color:var(--paper);
  border:1px solid var(--line);
  transition:background-color .3s cubic-bezier(.22,.68,.36,1),
             border-color .3s cubic-bezier(.22,.68,.36,1),
             box-shadow .3s cubic-bezier(.22,.68,.36,1);
}
.day-chip.sm { width:32px; height:32px; font-size:.62rem; }
.day-chip.on {
  background:var(--chip-c, var(--glaze));
  border-color:var(--chip-c, var(--glaze));
  color:var(--glaze-ink);
  box-shadow:0 3px 12px -3px var(--chip-c, var(--glaze));
}
@media (hover:hover) and (pointer:fine) {
  .day-chip:not(.on):hover { background:rgba(var(--paper-rgb),.11); }
  .day-chip.on:hover { background:var(--chip-c, var(--glaze)); }
}


/* ==========================================================================
   23  BOOKING ACTION BAR

   The event page's own floating bar: money on the left, one short verb on
   the right. The button label used to carry the whole summary — "Continue
   to payment · $55.00", or "Continue · 3 sessions, $18 today, $54 total" —
   which wrapped onto two or three lines on a phone. Splitting them gives
   the numbers room and keeps the button to a single line at any width.
   ========================================================================== */

.booking-bar { gap:14px; }
.booking-summary { min-width:0; flex:1; }
.booking-total {
  font-weight:700; letter-spacing:-.02em; font-size:.86rem;
  color:var(--glaze-ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.booking-sub {
  font-size:.62rem; color:var(--glaze-ink); opacity:.62; margin-top:1px;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
/* the button keeps its own width and never shrinks below its label */
.booking-bar .btn-hero { flex:0 0 auto; width:auto; white-space:nowrap; }


/* ==========================================================================
   24  TICKET ACTIONS

   The manage-this-booking group under a held ticket. These used to be five
   full-width buttons stacked down the page, all shouting equally; editing
   now sits in the floating bar and the rest live here, grouped and ranked.
   ========================================================================== */

.ticket-actions { margin-top:var(--gap); }
.ticket-actions .eyebrow { margin-bottom:12px; }
.ticket-actions .btn-hero { width:100%; }
/* the destructive action stays reachable but stops competing with the
   ticket itself — outlined in the danger colour rather than filled with it */
.ticket-actions .btn-danger {
  background:transparent; color:var(--danger); border:1px solid rgba(var(--danger-rgb),.45);
}
@media (hover:hover) and (pointer:fine) {
  .ticket-actions .btn-danger:hover { background:rgba(var(--danger-rgb),.08); filter:none; }
}
.ticket-actions .ghost { width:100%; text-align:center; margin-top:8px; }


/* ==========================================================================
   25  EMPTY STATE

   A calendar with a single quiet line through it, rather than the
   magnifier-and-question-mark this replaced — nothing is being searched
   for or has gone missing, the stretch of time simply has nothing in it.
   ========================================================================== */

.empty-state { text-align:center; padding:44px 16px; }
.empty-state-icon {
  width:62px; height:62px; margin:0 auto 14px;
  display:grid; place-items:center; border-radius:50%;
  background:rgba(var(--paper-rgb),.05); color:var(--muted-soft);
}
.empty-state-title { font-size:.78rem; font-weight:700; letter-spacing:-.02em; margin:0; }
.empty-state-hint  { margin-top:4px; font-size:.66rem; }

/* the one big number on the analytics page */
.analytics-headline { font-size:var(--fs-figure); font-weight:700; letter-spacing:-.03em; margin:0 0 var(--space-pair); }


/* ==========================================================================
   26  OWNER HERO BAR
   ========================================================================== */

/* Inverted on purpose: --paper is the text colour and --ink the page
   ground, so swapping the two gives a strip that reads as the photographic
   negative of the page and keeps doing so in either theme, with no
   second set of dark-mode rules to keep in sync. */
  background:var(--paper); color:var(--ink);
  border-radius:var(--radius-lg); padding:16px 18px 14px;
  margin-bottom:var(--gap); cursor:pointer;
  box-shadow:var(--shadow-card);
}
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  margin-bottom:10px;
}
  font-size:.62rem; font-weight:700; letter-spacing:.12em; text-transform:uppercase;
  opacity:.55;
}
/* The countdown is the one thing on the strip worth pulling the eye. It
   can't use --glaze: the default glaze is the same near-black as --paper,
   so on an inverted ground the pill would be black on black. Inverting
   back to the page's own colours instead guarantees contrast in either
   theme and needs no dark-mode rule of its own. */
  font-size:.62rem; font-weight:700; letter-spacing:.02em;
  background:var(--ink); color:var(--paper);
  padding:4px 10px; border-radius:var(--radius-pill); white-space:nowrap;
}

  display:flex; flex-wrap:wrap; gap:8px 18px; margin-top:14px; padding-top:12px;
  border-top:1px solid rgba(var(--ink-rgb),.14);
}

/* A checkbox big enough to hit with a thumb — the browser default is
   sized for a mouse. Tinted to the accent so it reads as part of the
   app rather than the operating system. */
.checkbox-lg { width:20px; height:20px; accent-color:var(--glaze); }


/* ==========================================================================
   27  OCCURRENCE CHIPS
   ========================================================================== */

/* One date in a recurring series, picked from the booking form. Four
   states, each of which used to be assembled at the call site from seven
   utility classes plus a handful of inline colour writes. */
.occ-chip {
  display:inline-flex; align-items:center; gap:6px;
  padding:8px 14px; border-radius:var(--radius-pill);
  font-size:.66rem; font-weight:700;
  background:rgba(var(--paper-rgb),.06); color:var(--paper);
  border:1px solid rgba(var(--paper-rgb),.25);
}
.occ-chip.is-selected {
  background:var(--glaze); color:var(--glaze-ink); border-color:var(--glaze);
}
/* already holding a ticket for this date */
.occ-chip.is-booked {
  background:rgba(var(--free-color-rgb),.14); color:var(--free-color);
  border-color:var(--free-color); cursor:default;
}
/* sold out from under the attendee between opening the form and
   submitting — struck through so it reads as "this one specifically
   can't be picked" rather than "currently unselected" */
.occ-chip.is-unavailable {
  background:rgba(var(--danger-rgb),.08); color:rgba(var(--paper-rgb),.35);
  border-color:rgba(var(--danger-rgb),.3); text-decoration:line-through;
  cursor:default;
}

/* The tick box inside an unbooked chip. Drawn in currentColor so it
   follows whatever the chip's state already set, with no second colour
   rule to keep in sync. When checked it fills with that same colour and
   the glaze-stroked tick reads against it — which is why the fill has to
   be currentColor and not the accent: on a selected chip the ground is
   already the accent. */
.occ-chip-checkbox {
  display:inline-grid; place-items:center;
  width:14px; height:14px; flex-shrink:0;
  border:1.5px solid currentColor; border-radius:4px;
}
.occ-chip-checkbox svg { width:10px; height:10px; opacity:0; }
.occ-chip-checkbox.checked { background:currentColor; }
.occ-chip-checkbox.checked svg { opacity:1; }

/* Spacing classes replacing inline writes. The two dashed dividers used
   8px and 10px for the same job in the same kind of card; they share one
   value now. */
.divider-dashed { border-top:1px dashed var(--line); margin:10px 0; }
.u-mx-auto-16 { margin:16px auto 0; }
.u-mt4-mb10 { margin:4px 0 10px; }
.u-mt12-mb18 { margin:12px 0 18px; }
.u-p-6-0 { padding:6px 0; }
.u-mr8 { margin-right:8px; }
/* the "we couldn't take your payment" line, held to a readable measure
   rather than running the full width of the page */
.payment-fail-note { margin:6px auto 20px; max-width:280px; }

/* ---- classes replacing the last of the inline style writes ---- */
.u-color-glaze { color:var(--glaze); }
.u-color-glaze-ink { color:var(--glaze-ink); }
.u-color-muted-55 { color:rgba(var(--paper-rgb),.55); }
.u-fw600 { font-weight:600; }
.u-bg-glaze-14 { background:rgba(var(--glaze-rgb),.14); }
.u-border-dashed-paper { border:1px dashed rgba(var(--paper-rgb),.3); }
.u-border-dashed-line { border:1px dashed var(--line); }
.u-border-paper-25 { border:1px solid rgba(var(--paper-rgb),.25); }
.u-pb20 { padding-bottom:20px; }
.u-p16 { padding:16px; }
.u-p-16-0 { padding:16px 0; }
.u-gap5 { gap:5px; }
.u-right10 { right:10px; }
.u-d-inline-block { display:inline-block; }
.u-underline-dotted { text-decoration:underline dotted; }
.u-justify-start { justify-content:flex-start; }
.u-flex-col { flex-direction:column; }
.u-flex-wrap { flex-wrap:wrap; }

/* one row of the dashboard filter modal */
.filter-row { padding:12px 0; border-bottom:1px solid var(--line); }

/* the photo cropper is held narrower than a normal modal so the image
   sits at a size someone can actually aim at on a phone */
.crop-modal-box { max-width:420px; }

/* Wheel columns sized to their content rather than sharing the row
   equally. A day is one or two digits; giving it an equal share left
   dead space either side of "1" that read as a gap between columns. */
.wheel-col-interval { flex:0 0 36px; }
.wheel-col-day { flex:0 0 34px; }
.wheel-col-count { flex:0 0 28px; }


/* ==========================================================================
   28  CLASSES THE JS EMITTED THAT THIS FILE NEVER DEFINED
   ========================================================================== */

/* Found by diffing every class the app puts in the DOM against every
   selector defined here. Each of these was being applied at runtime and
   doing nothing at all. */

/* Which page you're on, in the topbar. All five icon buttons have always
   asked for this class and never got a rule, so the current page was
   never marked anywhere in the header. */
.icon-btn-selected,
button.icon-btn-selected {
  background:var(--paper); color:var(--ink); border-color:var(--paper);
}

/* The row holding B / I / list above the description editor. Its buttons
   and the frame around it were both styled; the row between them wasn't,
   so they sat flush against the text area with no separation. */
/* Below the editor, not inside it. The system selection menu appears
   above the selection on a phone, which put it straight over these. */
.editor-toolbar {
  display:flex; align-items:center; gap:2px;
  padding:0; margin-top:var(--space-pair);
}

/* A "Everything in Pro, plus:" line inside a plan's feature list —
   a heading among the ticked items, not one of them. */
.tier-section-label {
  font-size:.62rem; font-weight:700; letter-spacing:.08em;
  text-transform:uppercase; color:var(--muted);
  list-style:none; margin-bottom:2px;
}

/* A master switch standing in for the section heading it replaced, so
   the row still reads as a heading despite being a switch row. Matches
   h3 rather than the small .label-text default. */
.heading-label-text { font-weight:700; letter-spacing:-.02em; }

/* ---- state classes replacing the last of the inline writes ---- */
/* .crop-viewport already declares cursor:grab; this is only the held state */
.crop-viewport.is-dragging { cursor:grabbing; }
/* pages that run their own artwork to the bottom edge, and pages that
   have to clear a sticky footer */
#app.app-pad-flush { padding-bottom:0; }

/* Room for the floating bar. It is position:fixed, so it takes up no
   space in the flow and sat directly on top of whatever the page ended
   with — 61px of the organizer card covered on the event page, 47px on
   create, 29px on manage.
   :has rather than a class each page remembers to add: the padding
   appears exactly when a bar is actually on screen, and a new page with
   a bar gets it for free.
   Matched from body rather than #app because the bars are not all in the
   same place — the event page builds its own inside the page element,
   while create and manage append theirs to document.body. Anchoring at
   #app found only the first kind. This also outranks .app-pad-flush on
   specificity, which is what we want: a page running its artwork to the
   bottom edge still has to clear a bar sitting on that edge. */
body:has(.sticky-form-footer) #app { padding-bottom:var(--footer-space); }
/* briefly rings whatever a deep link scrolled the reader to */
.flash-target { outline:2px solid var(--glaze); outline-offset:2px; }
/* the page behind an open scan modal must not scroll — it reads as the
   camera view sliding away */
/* Nothing behind an overlay moves.
   This was overflow:hidden on the body alone, which does nothing on its own:
   the element that actually scrolls a page is <html>, so the page under a
   modal — and under the booking sheet — stayed perfectly scrollable.
   Measured: 200px down before opening the sheet, 618px after. */
body.scroll-locked { overflow:hidden; overscroll-behavior:none; }
html:has(> body.scroll-locked) { overflow:hidden; }
/* while a form is being bulk-disabled, so the controls don't animate
   their way into the disabled look one property at a time */
.is-frozen { transition:none !important; }


/* ==========================================================================
   29  BINARY STATES THAT USED TO BE INLINE WRITES
   ========================================================================== */

/* A card calling for attention: an unread notification, a waitlist offer
   that is still live, a paused account. All three used to set
   border-color inline to the same two values. */
.is-highlighted { border-color:var(--glaze); }
.is-alerting { border-color:var(--danger); }
/* the plan you are currently on, drawn heavier so it reads before the others */
.card.is-current { border-color:var(--glaze); border-width:2px; }

/* Which photo the carousel is showing. Fixed whites rather than tokens:
   these sit on top of the photograph, not on the page. */
[data-dot] { background:rgba(255,255,255,.45); }
[data-dot].is-active { background:#fff; }

/* A day cell filled with the event's accent colour. Light theme keeps
   every accent dark enough for white text; dark theme's brighter
   originals pair with the dark ink instead. */
.cal-cell-on-accent { color:#ffffff; }
:root[data-theme="dark"] .cal-cell-on-accent { color:#16201a; }

/* The two providers on a dark ground.

   Both brands are built for white. PayPal's #003087 is a navy that all but
   disappears against #1d1d21, and its #0070ba links land just under a
   readable contrast — so the dark side of each lockup lifts and the links
   go a step brighter. Still recognisably each company's blue and purple,
   which is the whole reason for hardcoding them; just at a weight that can
   be read on the ground they are actually sitting on. */
:root[data-theme="dark"] [data-event] .pay-pp-pay { color:#ffffff; }
:root[data-theme="dark"] [data-event] .pay-pp-pal { color:#4da3e0; }
:root[data-theme="dark"] [data-event] .pay-pp-change,
:root[data-theme="dark"] [data-event] .pay-pp-cancel,
:root[data-theme="dark"] [data-event] .pay-wordmark-paypal { color:#5cb0e8; }
:root[data-theme="dark"] [data-event] .pay-pp-option.on { border-color:#5cb0e8; box-shadow:0 0 0 1px #5cb0e8 inset; }
:root[data-theme="dark"] [data-event] .pay-pp-option.on .pay-pp-radio { border-color:#5cb0e8; }
:root[data-theme="dark"] [data-event] .pay-pp-option.on .pay-pp-radio::after { background:#5cb0e8; }
:root[data-theme="dark"] [data-event] .pay-wordmark-stripe { color:#a29dff; }

/* A tag tinted to one event's accent. The colour is genuinely per-event
   — free/paid crossed with one-off/recurring — so it arrives as a custom
   property rather than a class per combination. */
.pill-accented {
  background:var(--pill-bg); color:var(--pill-c); border-color:var(--pill-c);
}

/* ==========================================================================
   30  EVENT & TICKET PAGE
   --------------------------------------------------------------------------
   Ported from design-experiments/event-page-light.html, the page this
   design was agreed from. Everything is scoped under [data-event] so the
   rebuild owns its own page outright and inherits nothing from the generic
   card and form rules — that inheritance is what let the old version drift
   from the reference in the first place.

   Colours come from the same tokens as the rest of the app, so checkout
   themes and dark mode re-skin this page exactly as they do every other.
   ========================================================================== */

[data-event] { --ev-gap:14px; }

/* ---- hero ----
   No z-index on purpose: setting one makes .ev-hero a stacking context and
   traps the back/share buttons inside it, so they slide under the content
   column on scroll. Left auto, the photo sits behind the column (which has
   its own z-index) and the controls stay free to lift above it. */
/* will-change names OPACITY, because opacity is what moves.
   It said transform, left over from when the photo drifted and scaled
   against the scroll — startEvHeroFade dropped that and now writes opacity
   alone, so the hint was promoting a full-bleed image to its own permanent
   layer for a property that never changed, and not hinting the one that
   does. Cheap to get wrong on a desktop and expensive on a phone, where a
   sticky full-width layer is re-composited under the column all the way
   down the page. */
/* Tall enough to be the first thing about the event, and sticky so the
   content column rides up over it. */
[data-event] .ev-hero {
  position:sticky; top:0; overflow:hidden;
  aspect-ratio:auto; height:84vh; max-height:780px;
  background:rgba(var(--paper-rgb),.06);
}
/* An event with no photographs. The strip is here only to carry the back and
   account controls at the offset they sit at over a photo, so it keeps their
   height and nothing else — no aspect ratio to reserve, no ground to fill,
   and no negative overlap under it, because there is nothing to ride over. */
[data-event] .ev-hero-bare {
  position:relative; aspect-ratio:auto; height:66px; background:transparent;
}
[data-event] .ev-hero-bare + .ev-content { margin-top:0; }
/* Bare AND carrying nothing: no strip at all. */
[data-event] .ev-hero-bare.ev-hero-blank { height:0; }
/* ...and no travel height either. The min-height further down buys the column
   a screen to ride up over the photograph; with no photograph there is nothing
   to ride over, and it became a screenful of empty page under a short event —
   most visible in a preview of an event with no picture and no location. */
[data-event] .ev-hero-bare + .ev-content { min-height:0; }
/* Nothing animates the image itself — it is the parent that fades — so it
   needs no hint of its own. */
/* Taller than its frame by the same amount startEvHeroFade is allowed to
   pan (OVERSCAN), anchored at the top — so at rest you see the top of the
   picture and scrolling walks down it. will-change names transform, which is
   the only thing that moves. */
[data-event] .ev-hero-img {
  /* 132% and lifted by 16%, which is the smallest the picture can be and
     still do both things asked of it.

     Over a frame this tall every extra percent is magnification: cover has to
     scale the photograph until it fills the box, so at 230% a landscape shot
     was blown up to more than twice the size it needed and the frame showed a
     strip of its middle. The pan only ever uses 16% (OVERSCAN), so the other
     114% was zoom and nothing else.

     Centred means the overhang is split, not stacked at the bottom: 16% above
     and 16% below, so at rest the middle of the picture is in the frame, and
     the pan walks the lower 16% in and lands the bottom edge flush. */
  position:absolute; top:-16%; left:0; width:100%; height:132%;
  object-fit:cover; will-change:transform;
}
/* These belong to the photo and fade with it. Deliberately not lifted above
   the content column — given a higher z-index they escape the photo and
   hang over the card as it scrolls past. */
[data-event] .ev-hero-controls {
  position:absolute; inset:14px 14px auto 14px;
  display:flex; justify-content:space-between;
}
/* Back, and the way into full screen, as one group. */
[data-event] .ev-hero-left { display:flex; gap:8px; align-items:flex-start; }
/* The same button as everywhere else in the app — translucent fill,
   hairline border, no shadow. These were solid white with a drop shadow,
   which is a second button style existing on one page.

   They can look like the rest of the app because the hero now carries a
   scrim behind them (see .ev-hero::before). Without it a near-transparent
   button over somebody's photograph is invisible, which is why they were
   made white in the first place. */
[data-event] .ev-circle-btn {
  width:36px; height:36px; border-radius:50%; cursor:pointer; padding:0;
  display:grid; place-items:center;
  background:rgba(var(--paper-rgb),.05); color:var(--paper);
  /* var(--line), the same token .icon-btn uses — a hardcoded .10 made
     the back circle and the app icons beside it differ by a hair once a
     checkout theme moved the line colour. */
  border:1px solid var(--line);
  box-shadow:none;
}
/* ---- full-screen photo view ----------------------------------------------
   Opened by tapping the hero photo. The picture is the control, so it gets
   the cursor and the hit area rather than a button sitting on top of it. */
[data-event] .ev-hero-img.is-expandable { cursor:zoom-in; }

/* ---- controls against whatever photo is behind them ----------------------
   applyHeroControlContrast() sets --hero-scrim on the hero, once, and every
   control below inherits it. Two values only — a normal weight and a heavier
   one for a bright photo — so the controls have two looks rather than a
   different one per picture, and they always match each other.

   The fallback in the var() is the normal weight, not transparent. A control
   with no chip under it looks broken on a photo even where the glyph happens
   to be readable, so there is always a background; what changes is how dark
   it is. */
/* Same object as the top row everywhere else: half-white chip, dark glyph,
   38px. It survives both extremes of photo for the same reason a solid one
   would — at 50% it is lighter than any dark picture and, with the shadow
   and the darker rim, still separates from a bright one. The glyph is the
   page's own text colour rather than white, because the chip it sits on is
   now light in every case.
   No backdrop-filter, deliberately: blurring live pixels behind a STICKY
   element is re-done every frame of a scroll, and this hero is sticky. */
[data-event] .ev-hero-controls .ev-circle-btn,
[data-event] .ev-hero-controls .icon-btn,
[data-event] .ev-hero-arrows .ev-circle-btn {
  width:38px; height:38px;
  background:rgba(var(--surface-rgb),.5);
  color:var(--paper);
  border-color:rgba(var(--paper-rgb),.10);
  box-shadow:0 1px 2px rgba(0,0,0,.14), 0 4px 12px rgba(0,0,0,.16);
}
/* No photograph under them, so nothing to be translucent against.
   Half-transparent surface reads as a chip when there is a picture behind it
   and as almost nothing when there is only the page's own ground — which is
   exactly what a bare strip is. Solid here, with the ordinary line and the
   same lift the controls carry over a photo, so the way back is as findable
   on an event without pictures as on one with them. */
[data-event] .ev-hero-bare .ev-circle-btn,
[data-event] .ev-hero-bare .icon-btn {
  /* The surface itself, not half of it, and a line with enough weight to
     draw the circle against a ground of nearly the same value. The colour
     token is left alone deliberately: --paper is the text colour and follows
     the theme, and overriding it here painted a light icon onto a light chip
     under a themed event. */
  background:var(--surface);
  border-color:rgba(var(--paper-rgb),.16);
  box-shadow:0 1px 2px rgba(0,0,0,.10), 0 4px 12px rgba(0,0,0,.10);
}
/* The weight changes when the photo under it changes, and swapping it in one
   frame reads as a flicker on the controls rather than as a response to the
   picture. Easing it lets the two settle together.

   The custom property is not what animates — background-color is. Changing
   --hero-scrim changes the colour this resolves to, and that transitions. */
[data-event] .ev-hero-controls .ev-circle-btn,
[data-event] .ev-hero-controls .icon-btn,
[data-event] .ev-hero-arrows .ev-circle-btn,
[data-event] .ev-hero-dots,
.photo-pop-close {
  transition:background-color .28s ease;
}
@media (prefers-reduced-motion: reduce) {
  [data-event] .ev-hero-controls .ev-circle-btn,
  [data-event] .ev-hero-controls .icon-btn,
  [data-event] .ev-hero-arrows .ev-circle-btn,
  [data-event] .ev-hero-dots,
  .photo-pop-close { transition:none; }
}
/* The dots get the same treatment as a strip rather than per dot — a chip
   around each one would be five small blobs instead of an indicator. */
[data-event] .ev-hero-dots {
  padding:5px 9px; border-radius:99px;
  background:rgba(0,0,0,var(--hero-scrim,.34));
  width:max-content; margin:0 auto;
}
[data-event] .ev-hero-dots i { background:rgba(255,255,255,.55); }
[data-event] .ev-hero-dots i.on { background:#fff; }

[data-event] .ev-hero-arrows {
  position:absolute; top:50%; left:12px; right:12px; transform:translateY(-50%);
  display:flex; justify-content:space-between; pointer-events:none;
}
/* Size and hit-testing only; the fill is with the rest of the hero
   controls above. A white fill did once live here and beat the scrim rule
   it followed at equal specificity — worth remembering now the arrows are
   deliberately light again, because the reason has to stay one rule rather
   than two fighting. */
[data-event] .ev-hero-arrows .ev-circle-btn {
  width:38px; height:38px; pointer-events:auto;
}
/* Positioned against the column it now lives in, so it travels with it.
   The column overlaps the photo by 34px, so -31px from its top edge leaves
   the strip 16px clear of the rounded corner.

   The child selector rather than the bare class: .ev-content > * gives every
   child a 16px inline margin, which on an element positioned with left:50%
   is 16px of drift to the right. */
/* Centred by transform rather than by stretching edge to edge — the strip
   carries a background, and a full-width one would be a band across the
   photo instead of a chip under the dots. */
[data-event] .ev-content > .ev-hero-dots {
  position:absolute; left:50%; transform:translateX(-50%); top:-31px;
  margin:0; display:flex; gap:5px; align-items:center;
}
[data-event] .ev-hero-dots i {
  width:5px; height:5px; border-radius:50%; background:rgba(255,255,255,.55);
  cursor:pointer; transition:width .18s ease;
}
[data-event] .ev-hero-dots i.on { width:16px; border-radius:99px; background:#fff; }

/* ---- the opaque column everything below the photo rides in ----
   Without it the gaps between cards are see-through, and because the hero
   is sticky and sitting behind, the photo shows through every one of them
   while scrolling. */
/* The sheet starts halfway up the photograph, so the page opens on the top
   half of the picture rather than on a strip of it. Scrolling from there walks
   the image down to its bottom edge — by the time the hero has travelled its
   full height the whole photograph has been shown. */
[data-event] .ev-content {
  margin-top:calc(min(68vh, 620px) * -0.82);
}
[data-event] .ev-content {
  position:relative; z-index:2; background:var(--ink);
  border-radius:var(--radius-lg) var(--radius-lg) 0 0;
  margin-top:-34px; padding:14px 0 0;
  /* How far the column can travel is just how much page there is to scroll,
     and on a short event the page ran out with the column only part of the
     way up — it stopped against the end of the document, not against the top
     of the photo. A column at least a screen tall leaves room to carry it the
     rest of the way. min-height rather than padding because it is self
     limiting: a page with plenty in it is already past this and gains
     nothing. */
  /* A full window, plus the distance the page starts already scrolled by.
     landEventPageScrolledUp opens the page with the column at 30% of the
     window, which spends the difference between that and where it rests;
     carrying the same difference here means the travel left from the landing
     point is a whole frame, exactly as it would be on a page that opened at
     the top. The frame height appears here because the resting position is
     the frame height less the 34px overlap — the one place these two rules
     are tied together. */
  min-height:calc(100vh + min(84vh, 780px) - 34px - 30vh);
}
[data-event] .ev-content > * { margin-inline:16px; }
@media (min-width:768px)  { [data-event] .ev-content > * { margin-inline:28px; } }
@media (min-width:1100px) { [data-event] .ev-content > * { margin-inline:32px; } }

/* ---- cards ---- */
[data-event] .ev-sheet {
  background:var(--surface); border-radius:var(--radius-lg);
  padding:20px 18px; box-shadow:0 10px 30px rgba(0,0,0,.07);
}
/* Gap set on the blocks themselves rather than with .ev-sheet + .ev-sheet:
   that adjacent-sibling form only matches when the previous element is
   itself a sheet, and the stub wrapper breaks the chain. */
[data-event] .ev-content > .ev-sheet,
[data-event] .ev-content > .ev-stub { margin-top:var(--ev-gap); }
[data-event] .ev-content > .ev-stub:first-child,
[data-event] .ev-content > .ev-sheet:first-child { margin-top:0; }
[data-event] .ev-sheet.ev-overlap { position:relative; z-index:2; }

/* ---- top half: title, organizer, meta ---- */
/* One height, share button or not. The button is 34px and the title alone is
   28, so the row grew when it appeared — and it appears only on a live ticket.
   Everything below moved six pixels between one session and the next, which is
   the shift that reads as the page jumping when you change dates. */
/* The name centres, and the controls sit at the edges without moving it.
   A control taken out of the flow cannot push the centre off-centre, which
   is the whole reason share and star are positioned rather than laid out —
   the star appears only for a signed-in attendee and share only on a live
   ticket, and a centre that shifted with them would put the same title in a
   different place on two screens showing the same event. */
[data-event] .ev-title-row {
  position:relative;
  display:flex; align-items:center; justify-content:center; gap:12px;
  min-height:34px;
}
[data-event] .ev-title-row .ev-code-share {
  position:absolute; right:0; top:50%; transform:translateY(-50%);
  flex:0 0 auto; margin-left:0;
}
/* In the plate, at the right end of the code. The plate is a flex row, so
   this is a sibling of the code rather than anything positioned: it sits on
   the same baseline whatever the code is, and the plate grows by exactly the
   width of it. Sized against the plate's own 40px, and inheriting the plate's
   white on its dark ground. */
/* A date on the ticket's row that has been and gone. Still there — a row
   that dropped its past dates would lose half of what a twelve-week course
   looks like — and quiet, because there is nothing to be done about it and at
   full strength it competed with the dates there is. */
/* A date on the ticket's row that has been and gone. Still there — a row
   that dropped its past dates would lose half of what a twelve-week course
   looks like — and out of the way: faded, and slightly out of focus, which
   is what a date nobody can do anything about should look like beside ones
   they can. Its own colours otherwise, exactly as the row draws them. */
[data-event] .ev-session-stub.is-past { opacity:.45; filter:blur(.6px); }
@media (hover:hover) and (pointer:fine) {
  [data-event] .ev-session-stub.is-past:hover { opacity:.75; filter:none; }
}
/* When the money for this session moves, above the dates it sits with.

   One voice: the sentence and the link that follows it are the same size and
   the same weight, because the fact is the point and paying early is a small
   thing to offer about it. A heading over one line made an announcement of
   something an organizer already agreed with somebody. */
/* It arrives, and it leaves.

   Tapping through the dates of a series changes whether this line applies,
   and it was appearing and vanishing between two frames — the row of dates
   under it jumping up and down with it. Growing in and folding away says
   which date brought it, and the row moves with it rather than under it. */
[data-event] .ev-session-charge-note {
  margin:0 0 14px; overflow:hidden;
  animation:chargeNoteIn .22s cubic-bezier(.2,.8,.25,1);
}
/* Moving between two dates that are both waiting on a charge. The notice
   stays exactly where it is and at exactly its size — only what it says
   changes, so nothing below it moves. The icon does not animate either: it
   means the same thing on both dates. */
[data-event] .ev-session-charge-note.is-static { animation:none; }
/* Only while it is being eased from one height to another, and never on a
   box that is sizing itself — a standing transition on height would also
   catch a rotation or a font settling, and animate those too. */
[data-event] .ev-session-charge-note.is-resizing {
  transition:height .2s cubic-bezier(.2,.8,.25,1);
}
[data-event] .ev-session-charge-note.is-static .ev-charge-say,
[data-event] .ev-session-charge-note.is-static .ev-charge-pay {
  animation:chargeTextIn .2s cubic-bezier(.2,.8,.25,1);
}
@keyframes chargeTextIn {
  from { opacity:0; transform:translateY(3px); }
  to   { opacity:1; transform:none; }
}
[data-event] .ev-session-charge-note.is-leaving {
  animation:chargeNoteIn .18s cubic-bezier(.2,.8,.25,1) reverse forwards;
  pointer-events:none;
}
@keyframes chargeNoteIn {
  from { opacity:0; max-height:0; margin-bottom:0; transform:translateY(-4px); }
  to   { opacity:1; max-height:120px; margin-bottom:14px; transform:translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  [data-event] .ev-session-charge-note,
  [data-event] .ev-session-charge-note.is-static .ev-charge-say,
  [data-event] .ev-session-charge-note.is-static .ev-charge-pay,
  [data-event] .ev-session-charge-note.is-leaving { animation:none; }
  [data-event] .ev-session-charge-note.is-resizing { transition:none; }
  [data-event] .ev-session-charge-note.is-leaving { display:none; }
}
[data-event] .ev-session-charge-note .info-panel-icon { float:left; margin:2px 8px 0 0; }
[data-event] .ev-charge-say { color:var(--muted); }
[data-event] .ev-charge-pay {
  padding:0; border:0; background:none; color:var(--glaze);
  /* Inherited, so the link is the same strength as the sentence it sits in —
     the underline and the colour are what mark it as a control. */
  font-family:inherit; font-size:inherit; font-weight:inherit; cursor:pointer;
  text-decoration:underline; text-underline-offset:3px;
}
@media (hover:hover) and (pointer:fine) {
  [data-event] .ev-charge-pay:hover { color:var(--glaze-dark); }
}
[data-event] .ev-qr-wrap { position:relative; }
[data-event] .ev-code-share-inline {
  position:static; top:auto; right:auto; bottom:auto; left:auto;
  width:26px; height:26px; flex:0 0 auto; margin-left:8px;
  border-radius:50%; display:grid; place-items:center;
  color:inherit; background:rgba(255,255,255,.16); border:0;
}
@media (hover:hover) and (pointer:fine) {
  [data-event] .ev-code-share-inline:hover { background:rgba(255,255,255,.3); }
}
[data-event] .ev-code-share-inline:active { transform:scale(.94); }
/* The plate holds the code and the control side by side, and the code keeps
   the middle of the symbol: without this the pair centres as a unit and the
   code itself sits left of centre. */
[data-event] .ev-ticket-code:has(.ev-code-share-inline) { padding-right:8px; }
[data-event] .ev-title-row:has(.ev-code-share:not(.ev-code-share-corner)) h1 { padding:0 34px; }
/* The heart is still up there on its own. */
[data-event] .ev-title-row:has(.ev-star) h1 { padding:0 34px; }
[data-event] .ev-price {
  flex-shrink:0; text-align:right; white-space:nowrap;
  font-weight:700; font-size:var(--fs-h2); letter-spacing:-.03em;
}
[data-event] .ev-price span { font-weight:600; font-size:var(--fs-caption); color:var(--muted); letter-spacing:0; }

/* Avatar and name centre together as one unit — the organizer is a single
   thing being named, not a label with a picture beside it. */
[data-event] .ev-organizer {
  position:relative;
  display:flex; align-items:center; justify-content:center; gap:9px;
  margin-top:12px;
}
/* A shade smaller than it was. It sits directly under the event's own name
   now, and at .78 the two were close enough in weight to read as one heading
   broken over two lines. */
/* A button that reads as the line of text it replaced. It is a control now
   — it scrolls you to the organizer's card — so it says so on hover and
   takes a focus ring, but at rest it is the same name in the same place. */
[data-event] .ev-organizer b,
[data-event] .ev-organizer .ev-organizer-name {
  font-size:var(--fs-meta); font-weight:700; flex:0 1 auto; min-width:0;
  text-align:center;
}
[data-event] .ev-organizer-name {
  background:none; border:0; padding:0; margin:0; cursor:pointer;
  color:var(--paper); font-family:inherit; text-decoration:none;
}
/* Where the name sends you. Bright enough to find in one glance, gone
   before it becomes part of the furniture. */
[data-event] .ev-sheet-link.is-arrived {
  outline:2px solid rgba(var(--paper-rgb),.35); outline-offset:3px;
  transition:outline-color .4s ease;
}
/* The same object as the heart on an event card — same size, same ring, same
   two states — because it is the same gesture on the same kind of thing. It
   was a bare 17px glyph with no target around it. */
[data-event] .ev-star {
  width:36px; height:36px; padding:0; cursor:pointer;
  display:inline-flex; align-items:center; justify-content:center;
  border-radius:99px; border:1px solid var(--line);
  background:rgba(var(--paper-rgb),.05); color:rgba(var(--paper-rgb),.35);
  transition:color .18s ease, background-color .18s ease;
}
/* Same reasoning as the share button: out of the flow, so the name it sits
   beside stays centred on the page rather than centred on what is left over
   after the star has taken its width. */
[data-event] .ev-organizer .ev-star {
  position:absolute; right:0; top:50%; transform:translateY(-50%);
}
[data-event] .ev-organizer:has(.ev-star) b { padding:0 42px; }
/* Kept: the same red the cards use, not the star's old gold. */
[data-event] .ev-star.on { color:var(--danger); background:rgba(var(--danger-rgb),.10); border-color:rgba(var(--danger-rgb),.25); }
/* Reads as prose, not as another field on the card — full measure, normal
   weight, and the muted colour the rest of the card's secondary text uses. */
[data-event] .ev-owner-about {
  margin:14px 0 0; font-size:var(--fs-meta); line-height:1.5;
  color:var(--muted); white-space:pre-line;
}


/* ---- sections, chips, date pills ---- */
/* 26 rather than 22. The stub carries a lot of small blocks in a column —
   code, dates, tags, description, two detail rows, three boxes — and at 22
   they read as one dense stack rather than as separate things. */
[data-event] .ev-section { margin-top:26px; }
/* Paying for the whole series at once. A row rather than a card: it is one
   choice inside the booking form, not a section of its own. */
[data-event] .ev-payall-row {
  display:flex; align-items:flex-start; gap:11px; cursor:pointer;
}
[data-event] .ev-payall-box {
  flex:0 0 auto; width:20px; height:20px; margin:1px 0 0;
  accent-color:var(--glaze); cursor:pointer;
}
[data-event] .ev-payall-text { display:flex; flex-direction:column; gap:3px; min-width:0; }
[data-event] .ev-payall-title { font-size:var(--fs-body); font-weight:700; }
[data-event] .ev-payall-note { font-size:var(--fs-meta); color:var(--muted); }
[data-event] .ev-section > h3 { margin-bottom:10px; }
[data-event] .ev-row { display:flex; align-items:center; justify-content:space-between; gap:10px; }

[data-event] .ev-chips { display:flex; flex-wrap:wrap; gap:7px; }
/* compact on purpose — these are dense factual labels, and at a larger
   size four of them wrapped onto three rows */
[data-event] .ev-chip {
  display:inline-flex; align-items:center; gap:5px;
  padding:5px 10px; border-radius:var(--radius-pill);
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
  font-size:var(--fs-micro); font-weight:600; white-space:nowrap; color:var(--paper);
}
/* The same two states on the event's own count. */
[data-event] .ev-chip-low {
  color:var(--amber); border-color:rgba(var(--amber-rgb),.4); background:rgba(var(--amber-rgb),.09);
}
/* Full is not a warning, it is an invitation to the list — so it takes the
   event's own accent, the same one its type pill and its cards carry, and
   not the red kept for things that have gone wrong. */
[data-event] .ev-chip-waitlist {
  color:var(--ev-accent, var(--paper)); border-color:var(--ev-accent, var(--line));
  background:var(--ev-accent-wash, rgba(var(--paper-rgb),.06));
}
[data-event] .ev-chip-low svg, [data-event] .ev-chip-waitlist svg { opacity:.85; }
/* The ID reads as one of the chips, not as a quieter aside among them — same
   size, same weight, same face. It was set in a mono face at .75 opacity,
   which made the one chip you might want to read the hardest one to. */
[data-event] .ev-chip-id { letter-spacing:.04em; cursor:pointer; }
/* The mark leads. Trailing the code it read as punctuation after it. */
[data-event] .ev-chip-id .ev-id-copy-mark { display:inline-flex; opacity:.55; margin-right:1px; }
[data-event] .ev-chip-id:hover .ev-id-copy-mark { opacity:.9; }

/* The event's own number, under the card. A line rather than a tag: it does
   not describe this booking, it identifies the listing, and among the tags it
   sat beside "9 of 16 spots left" as though it were the same kind of fact. */
[data-event] .ev-ref {
  display:flex; align-items:center; justify-content:center; gap:7px;
  margin:16px auto 0; padding:0 4px;
  font-size:var(--fs-meta); color:var(--muted); cursor:pointer;
}
[data-event] .ev-ref-value { font-weight:600; letter-spacing:.04em; color:rgba(var(--paper-rgb),.72); }
[data-event] .ev-ref-copy { display:inline-flex; opacity:.5; }
[data-event] .ev-ref:hover .ev-ref-copy { opacity:.85; }
[data-event] .ev-chip svg { opacity:.55; width:11px; height:11px; flex-shrink:0; }

[data-event] .ev-filters { display:flex; flex-wrap:wrap; gap:6px; }
[data-event] .ev-filter {
  padding:7px 13px; border-radius:var(--radius-pill); cursor:pointer;
  border:1px solid var(--line); background:transparent; color:var(--paper);
  font-family:inherit; font-size:var(--fs-caption); font-weight:600;
}
[data-event] .ev-filter.dashed { border-style:dashed; color:var(--muted); }
/* Selected, and that is the whole vocabulary. A separate green for
   "already booked" left a deselected date sitting in a colour that meant
   nothing to the person looking at it. */
[data-event] .ev-filter.on {
  background:var(--glaze); color:var(--glaze-ink); border-color:var(--glaze);
}
/* A date already booked, in a picker that is not editing it.

   The note above is right that "selected" is the whole vocabulary WHILE
   EDITING — there every held date is also selected, so a second colour said
   nothing. Rebooking is the case it was not about: the dates you hold are
   sitting unselected next to the one you are adding, identical to dates you
   have never booked. Scoped to :not(.on) so it can only ever appear where
   the two genuinely differ, and it is a quiet outline rather than a second
   fill, so the selected date stays the loudest thing in the row. */
[data-event] .ev-filter.booked:not(.on) {
  border-color:rgba(var(--paper-rgb),.42);
  color:var(--paper);
}
[data-event] .ev-filter[disabled] { opacity:.45; cursor:default; text-decoration:line-through; }

/* Full, and nearly full.

   A sold-out date was only a faded pill with a line through it, which is what
   every unavailable date looks like — closed for registration, already
   attended, all the same grey. It keeps the strike, since it is still not
   something you can take, but it carries its own colour and holds enough
   contrast to be read rather than skimmed past.

   Nearly full is not disabled: it is still a date you can book, and the
   colour is there to make you decide sooner rather than to warn you off. */
/* No [disabled] here any more: a sold-out date stays tappable, because its
   tap offers the session's waitlist. No strike-through for the same reason. */
[data-event] .ev-filter.is-sold-out {
  opacity:.9;
  color:var(--danger);
  border-color:rgba(var(--danger-rgb),.45);
  background:rgba(var(--danger-rgb),.10);
}
/* Already in that session's queue: the danger tint gives way to a quiet
   dashed pill — the date is being waited on, not fought for. */
[data-event] .ev-filter.on-waitlist {
  color:var(--paper); border-style:dashed; border-color:var(--line);
  background:rgba(var(--paper-rgb),.05); opacity:1;
}
[data-event] .ev-filter.is-low {
  color:var(--amber);
  border-color:rgba(var(--amber-rgb),.45);
  background:rgba(var(--amber-rgb),.10);
}
/* Selected wins: a date you have picked reads as picked, whatever its state. */
[data-event] .ev-filter.is-low.on {
  color:var(--glaze-ink); background:var(--glaze); border-color:var(--glaze);
}
/* A session that was called off. Faded rather than removed — it is still part
   of what was booked. No strike-through: that is the disabled pill's way of
   saying "you cannot have this", and this one simply is not happening. */
/* A date that is no longer a booking. On the ticket's own row these appear
   once the booking is over, as the record of what happened, so the strike is
   what tells them apart from the dates that were kept. */
/* An ended date, wherever it is drawn. The line through it says it was
   cancelled; the blur is the same slight softening a session that has
   already happened gets, so the two kinds of over look like one kind of
   over and only the line separates them. */
[data-event] .ev-filter.is-spent { opacity:.4; text-decoration:line-through; filter:blur(.6px); }
[data-event] .ev-filter.is-spent.on { opacity:.65; }
/* A date on a ticket, taken off by this edit and not yet saved. Dashed and
   struck through, the same vocabulary the removed row below uses — drawn as a
   plain unselected pill it was indistinguishable from a date never picked,
   which read as the removal not having happened. */
[data-event] .ev-filter.is-dropping {
  border-style:dashed; color:var(--muted); text-decoration:line-through;
}
/* The dates this booking covers, under the QR. Left-aligned with everything
   else on the stub — centred, they read as a caption for the code rather than
   as a list of their own. */
/* Tighter to the code above it. The block sits inside a flex column with a
   10px gap of its own, so this is the part on top of that — 14px made the row
   of dates read as a separate section rather than as the code's own footnote. */
[data-event] .ev-ticket-sessions { margin-top:4px; margin-bottom:-4px; }

/* Changing session holds the lower half still and shows the app's own
   pending dots in it for a moment.

   Sliding was tried and moved the wrong things: the sheet carries the scooped
   corners that meet the ones above, so animating it pulled the tear apart on
   every tap, and animating its children left the join alone but sent the
   content past the edge of the card.

   The content is hidden rather than removed, so the sheet keeps the exact
   height it will have when it comes back and nothing below it moves. */
[data-event] .ev-stub-bottom.is-swapping { position:relative; }
[data-event] .ev-stub-bottom.is-swapping > *:not(.ev-swap-dots) { visibility:hidden; }
[data-event] .ev-swap-dots {
  position:absolute; inset:0; display:grid;
  justify-items:center; align-items:start; padding-top:34px;
  color:var(--paper); pointer-events:none;
}
/* Smaller than the one in the busy dialog: that one holds a whole screen,
   this one sits in a panel the reader is already looking at. */
[data-event] .ev-swap-spinner { width:17px; height:17px; border-width:2px; }
@media (prefers-reduced-motion: reduce) {
  [data-event] .ev-stub-bottom.is-swapping > *:not(.ev-swap-dots) { visibility:visible; }
  [data-event] .ev-swap-dots { display:none; }
}
/* align-items, because a flex row stretches its items to the tallest one by
   default and each wrapped line stretches on its own. Folded to one line the
   stubs took the load-more button's height; opened to two they shrank back to
   their own — the same pill changing height on a click. */
[data-event] .ev-ticket-sessions .ev-filters {
  justify-content:flex-start; align-items:flex-start;
}
/* The same date pill the booking page picks a session with. A stub-shaped
   variant was tried — scooped corners, one fixed width — and read as a row of
   objects rather than a row of choices. One control, one appearance. */
[data-event] .ev-filter.is-folded { display:none; }
[data-event] .ev-session-more { border-style:dashed; color:var(--muted); }

[data-event] .ev-body-copy { font-size:var(--fs-body); color:rgba(var(--paper-rgb),.72); }
/* The heading that opens a panel below the tear — "This event is full",
   "This event has finished". It follows .ev-ticket-facts, which pushes itself
   away from what is above it but says nothing about what comes after, so the
   heading landed hard against the location value with no gap at all while
   every other block on the page had one. Same 22px the facts use, so both
   sides of that boundary are spaced alike. */
[data-event] .ev-panel-title { margin-top:22px; }
/* Five lines, then it stops. A long description used to push the whole
   booking form off the first screen. line-clamp needs the flex display and
   the hidden overflow to do anything, so all three move together. */
[data-event] .ev-body-copy.is-clamped {
  display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:5;
  overflow:hidden;
}
/* Formatting inside a description: paragraphs and lists get room, and a
   clamped block still measures its full height so the clamp knows to bite. */
[data-event] .ev-body-copy p + p { margin-top:.7em; }
[data-event] .ev-body-copy ul { margin:.5em 0 0; padding-left:1.1em; }
[data-event] .ev-body-copy li + li { margin-top:.25em; }
[data-event] .ev-body-copy strong { font-weight:700; }
/* On the last line of the text, at the right end of it — not on a line of its
   own underneath, which cost a whole row of height to say one word.

   It sits over the text, so it carries a short fade from the surface colour to
   transparent: the line runs out under it rather than colliding with it. Once
   the text is open there is nothing to sit over, so it returns to the flow. */
/* The text's own font, size and colour — bold is the only thing that sets it
   apart. It was accent-coloured and underlined, which made a link out of the
   end of a sentence and pulled the eye to it over everything else on the
   page. */
[data-event] .ev-read-more {
  position:absolute; right:0; bottom:0;
  padding:0 0 0 72px; background:none; border:0; cursor:pointer;
  font-family:inherit; font-size:var(--fs-body); line-height:1.5;
  font-weight:700; color:rgba(var(--paper-rgb),.72);
  text-decoration:none;
  background-image:linear-gradient(to right, rgba(0,0,0,0), var(--surface) 64px);
}
[data-event] .ev-desc-section { position:relative; }
[data-event] .ev-desc-section.is-open .ev-read-more {
  position:static; margin-top:8px; padding-left:0; background-image:none;
}

/* ---- form controls ---- */
[data-event] .ev-label { font-size:var(--fs-caption); font-weight:600; color:var(--muted); margin-bottom:6px; display:block; }
/* "Pick a date", "Your session", "Reserve your spot" — the same label as the
   one over the name and email fields. They were h3s, which put a third weight
   and a third size into a panel that already had a label style for exactly
   this job: naming the block underneath. */
[data-event] .ev-section-label { margin-bottom:0; }
/* The heading over a section somebody reads.
   A step above the body it introduces and a step below the event's own title,
   so the page reads as a few named parts rather than one column of text. The
   hairline is what actually does the separating — it is the thing the eye
   catches when scrolling for the address. */
[data-event] .ev-h2 {
  font-size:var(--fs-lead); font-weight:700; letter-spacing:-.02em;
  margin:0 0 10px; padding-top:20px;
  border-top:1px solid var(--line);
}
/* The first one under the tear has the perforation above it already. */
[data-event] .ev-section:first-of-type > .ev-h2 { border-top:none; padding-top:0; }
/* A heading with one fact about its own section on the far side of it. The
   hairline and the spacing stay on the heading; the pill sets its own type,
   which is why the h2's font rules land on the text span and not on the row. */
[data-event] .ev-h2-row { display:flex; align-items:center; justify-content:space-between; gap:10px; }
[data-event] .ev-h2-row > span:first-child { flex:1 1 auto; min-width:0; }
[data-event] .ev-chip-series { flex:0 0 auto; }
[data-event] .ev-input {
  width:100%; padding:14px 15px; border-radius:var(--radius-md);
  border:1px solid var(--line); background:rgba(var(--paper-rgb),.03);
  font-family:inherit; font-size:var(--fs-body); color:var(--paper); outline:none;
}
[data-event] .ev-input::placeholder { color:var(--muted-soft); }
[data-event] .ev-input:focus { border-color:rgba(var(--paper-rgb),.4); background:var(--surface); }
[data-event] .ev-field + .ev-field { margin-top:14px; }
[data-event] .ev-err { color:var(--danger); font-size:var(--fs-caption); margin-top:5px; }

[data-event] .ev-segmented {
  display:flex; gap:5px; padding:4px; background:rgba(var(--paper-rgb),.05);
  border-radius:var(--radius-pill); border:1px solid var(--line);
}
[data-event] .ev-segmented button {
  flex:1; padding:11px 8px; border-radius:var(--radius-pill); cursor:pointer;
  border:none; background:transparent; color:var(--muted);
  font-family:inherit; font-size:var(--fs-meta); font-weight:700;
}
[data-event] .ev-segmented button.on { background:var(--glaze); color:var(--glaze-ink); }

[data-event] .ev-stepper { display:flex; align-items:center; gap:12px; }
[data-event] .ev-stepper button {
  width:36px; height:36px; border-radius:50%; cursor:pointer;
  font-family:inherit; font-size:var(--fs-h2); font-weight:700; line-height:1; padding:0;
}
[data-event] .ev-stepper span { min-width:22px; text-align:center; font-weight:700; font-size:var(--fs-lead); }

[data-event] .ev-callout {
  display:flex; gap:10px; align-items:flex-start;
  padding:14px 15px; border-radius:var(--radius-md);
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
  font-size:var(--fs-meta);
}
[data-event] .ev-callout svg { flex-shrink:0; margin-top:1px; opacity:.6; }

/* ---- per-session rows in the multi-date picker ---- */
[data-event] .ev-session-row {
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  padding:12px 14px; border-radius:var(--radius-md);
  border:1px solid var(--line); background:rgba(var(--paper-rgb),.03);
}
/* The date, and under it what changing it gives back. The stepper stays
   centred against the pair rather than pinned to the date: the refund line is
   part of what the stepper just did, so the control belongs opposite both
   lines and not only the first. */
[data-event] .ev-session-when { display:flex; flex-direction:column; gap:4px; min-width:0; }
[data-event] .ev-session-refund {
  font-size:var(--fs-caption); color:var(--muted); line-height:1.35;
}
/* Only the sum is at full strength. It is the one part of the line that is a
   number the reader is owed, and the words around it are just its label. */
[data-event] .ev-session-refund .amt {
  color:var(--paper); font-weight:700; font-variant-numeric:tabular-nums;
}
/* A date taken off the booking but still on the page, because the refund it
   earns is written underneath it. */
[data-event] .ev-session-row.is-dropped {
  border-style:dashed; background:transparent;
}
[data-event] .ev-session-row.is-dropped .d {
  text-decoration:line-through; color:var(--muted);
}
[data-event] .ev-session-undo {
  flex:0 0 auto; padding:8px 14px; border-radius:var(--radius-pill); cursor:pointer;
  border:1px solid var(--line); background:var(--surface); color:var(--paper);
  font-family:inherit; font-size:var(--fs-caption); font-weight:700;
}
/* The date being changed, on arrival. A ring rather than a fill, so it points
   without restyling the row into something that looks selectable. */
[data-event] .ev-session-row.is-highlighted {
  box-shadow:0 0 0 2px var(--glaze);
  transition:box-shadow .4s ease;
}
[data-event] .ev-session-row + .ev-session-row { margin-top:8px; }
[data-event] .ev-session-row .d { font-size:var(--fs-meta); font-weight:700; }

/* ---- the ticket stub ----
   One shadow for the whole ticket, never one per half — a shadow on each
   half falls onto the other across the tear and draws a dark band through
   the middle of what should read as a single card torn in two.

   drop-shadow, not box-shadow. box-shadow is cast from the border box, so
   it comes out a plain rounded rectangle and runs straight past the
   notches: the card has a bite taken out of it and the shadow behind it
   does not. A filter follows the alpha silhouette instead, and the
   silhouette here is the union of the two scooped halves — so the shadow
   traces the notches with them.

   The wrapper has no background of its own, which is what makes this work:
   the filter sees only the halves. The two overlap the strip by exactly
   --tear each and meet on its centre line, so the silhouette is continuous
   and the shadow stays one shape rather than two. */
[data-event] .ev-stub {
  position:relative; border-radius:var(--radius-lg);
  filter:drop-shadow(0 10px 14px rgba(0,0,0,.10));
}
[data-event] .ev-stub .ev-sheet { box-shadow:none; }
/* ---- the tear ----------------------------------------------------------
   corner-shape, and nothing else. No discs, no @supports, no fallback: one
   technique, one set of rules.

   --tear is the radius of the bite AND half the height of the strip, and
   those two have to stay tied together. The strip carries no fill and pulls
   up into both halves by exactly --tear, so the scoop on the top half's
   bottom corner and the scoop on the bottom half's top corner land on the
   same centre and meet as one round hole. Give them different numbers and
   they separate into four quarter-circles. */
[data-event] .ev-stub { --tear:17px; }

[data-event] .ev-stub-top {
  border-radius:var(--radius-lg) var(--radius-lg) var(--tear) var(--tear);
  corner-shape:round round scoop scoop;
  /* On an event page there is no code block here, so the organizer's name was
     the last thing before the tear and sat almost on it. */
  padding-bottom:24px;
}
[data-event] .ev-stub > .ev-sheet.ev-stub-bottom {
  border-radius:var(--tear) var(--tear) var(--radius-lg) var(--radius-lg);
  corner-shape:scoop scoop round round;
  padding-top:16px;
}
[data-event] .ev-perf {
  position:relative; background:transparent;
  height:calc(var(--tear) * 2);
  margin:calc(var(--tear) * -1) 0;
  /* Above both halves. The strip is pulled into them by --tear each, so the
     lower sheet paints after it and was covering the bottom of every hole —
     a row of half-circles rather than holes. */
  z-index:3;
}
/* Holes punched through the card, not a line drawn on it.

   Each is filled with the page ground rather than a tint, so what shows is
   what is behind the card. The inset shadow gives the punched edge its depth
   and the hairline outside it is the lip of the paper catching the light —
   without both it reads as a printed dot.

   The row stops short of the two side bites so the holes do not run into
   them, and spreads with space-between so it fits any width without the
   spacing drifting. */
[data-event] .ev-perf-row {
  /* As close to the bites as the holes can sit without touching them. At
     --tear + 8 the row stopped well short of both sides and the tear looked
     like it had been punched only in the middle. */
  /* Just clear of the bites. At --tear - 2 the end holes ran into the curve
     of the notch instead of stopping short of it. */
  position:absolute; left:calc(var(--tear) + 5px); right:calc(var(--tear) + 5px);
  top:50%; transform:translateY(-50%);
  display:flex; justify-content:space-between; align-items:center;
}
[data-event] .ev-perf-hole {
  width:7px; height:7px; border-radius:50%; box-sizing:border-box;
  /* the page ground, so what shows through the hole is what is behind the card */
  background:var(--ink);
  /* the cut edge of the paper, catching the light */
  border:1px solid rgba(var(--paper-rgb),.15);
  /* and the depth. Spread across the whole hole rather than only its top
     edge: a shadow at the top alone domes it, and the row came out looking
     like a line of scallops pressed into the card instead of holes through
     it. */
  /* Lighter than it was. At .22 the holes read as dark spots on the card
     rather than as openings in it. */
  box-shadow:inset 0 0 3px 1px rgba(0,0,0,.12);
  flex:0 0 auto;
}

/* Full width of the block it sits in, square by aspect-ratio rather than by a
   hardcoded height. The raster follows: page-event.js reads this box's real
   width and draws at it, so the two cannot drift apart. A fixed number here
   and a different one there is how a code ends up a small bitmap stretched
   wide and soft, and this is the one thing on the page that has to survive a
   camera. */
[data-event] .ev-qr {
  position:relative;
  width:100%; aspect-ratio:1; margin:4px auto 0; border-radius:20px;
  background:#fff; border:6px solid #fff;
  box-shadow:0 0 0 1px var(--line); display:grid; place-items:center; overflow:hidden;
}
/* No code yet. The box keeps its size and its square, so the stub does not
   change shape between one session and the next. */
[data-event] .ev-qr.is-pending {
  background:transparent; border-color:transparent; box-shadow:0 0 0 1px var(--line);
}
/* While the first code is being drawn. Centred in the white box it is about
   to fill, so nothing moves when it does. */
[data-event] .ev-qr-loading {
  position:absolute; inset:0; display:grid; place-items:center;
  color:#111;
}
[data-event] .ev-qr-loading .busy-spinner {
  border-color:rgba(17,17,17,.15); border-top-color:rgba(17,17,17,.55);
}
/* A session already spent, or one not yet paid for. A symbol stays on the
   card — the stub should still look like a ticket — but it is well out of
   focus, because it is not something to present and reading it is not an
   option being offered.

   Heavier than the 5px it started at, where the modules still read as a QR
   somebody might try to scan — the wrong invitation on a card that cannot
   admit anyone. Lighter than the 11px that replaced it, which took it past
   out-of-focus and into a grey smudge that no longer read as a ticket at all.
   What is under it carries no payload either way (see voidQrPayload) — the
   blur is how it READS, not what makes it safe. */
[data-event] .ev-qr.is-spent canvas,
[data-event] .ev-qr.is-spent img { filter:blur(7px); opacity:.5; }
/* The status over a withheld code is set like a tag, because that is what it
   is: a short factual label about this ticket, the same kind of thing as
   "Every week on Wed" or "4 of 10 spots left" a little further down the page.
   It inherited the code plate's type instead — 900 weight at --fs-h2, sized
   to be read at arm's length by somebody on a door — which gave "Cancelled by
   you" the emphasis of a boarding pass number. Same face, size and weight as
   .ev-chip now.

   The TYPE only. Everything holding it — the plate, its padding, the white
   ring separating it from the modules — is the code plate's, untouched, so
   the two states put the same object in the same place on the stub and only
   what is written on it differs.

   Both classes in the selector on purpose. The element carries .ev-ticket-code
   as well, and that rule is further down the file at the same specificity, so
   a single-class version of this lost every declaration it shared with it and
   the status kept the code plate's 900-weight display type. Chaining wins on
   specificity rather than on position, so it cannot be undone by either rule
   moving. */
[data-event] .ev-ticket-code.ev-ticket-outcome {
  font-size:var(--fs-micro); font-weight:600; letter-spacing:0;
}
/* One colour for all three. The plate is the same object as the code plate
   and reads as that object saying something — green, red and grey made it
   three different components, and the words already say which state it is. */
[data-event] .ev-qr-pending {
  padding:0 22px; text-align:center;
  font-size:var(--fs-meta); font-weight:600; color:var(--muted);
}
[data-event] .ev-qr img, [data-event] .ev-qr canvas { width:100%; height:100%; display:block; }
/* The code sits on the QR rather than under it. It is the fallback an
   organiser types when scanning fails, and at the centre it is found without
   hunting — the QR is what the eye goes to anyway.

   Safe because the code is drawn at correction level H, which recovers about
   30% of the modules; this plate covers roughly 8% of the symbol area. The
   plate is opaque on purpose — half-visible modules under translucent text
   read as noise to a scanner and to a person.

   Smaller and heavier than it was standing alone: at this size it has a white
   field of its own to sit in, so weight does the work that size used to. */
/* The code, on a plate in the middle of its own QR.

   A pill rather than a rounded box, with the white field around it carried by
   a ring instead of a second radius — the old 9px box inside a 3px square halo
   left two different corner radii a few pixels apart, which is what made it
   read as a sticker dropped on the code rather than as part of it.

   Still opaque, and still the same footprint. Half-visible modules under
   translucent text read as noise to a scanner and to a person, and the plate
   covers about 8% of a symbol drawn at correction level H, which recovers
   around 30%.

   A hairline of the code's own ink separates the plate from the modules
   behind it without adding weight, and the letter-spacing opens up because
   the code carries letters now — MQYZPW needs more air between characters
   than four digits did. */
[data-event] .ev-ticket-code {
  position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
  /* The page's own face, not a mono one. The code is six characters read at
     a glance, not a column of figures to line up, and the monospace face was
     the lightest thing on the whole stub at the size it needs to be read at. */
  font-family:inherit;
  font-weight:900; font-size:var(--fs-h2); letter-spacing:.06em;
  /* The ink of the code itself, with white on it. #fff on #111 is 18.9:1 —
     as far apart as this palette goes, and the plate is the one thing on the
     stub that has to be read at arm's length in bad light.

     A tighter wrapper than the white one needed: a dark plate reads as an
     object against the modules without padding to separate it from them, and
     the white ring does the separating. */
  color:#fff; text-align:center; white-space:nowrap;
  background:#111; padding:6px 12px 6px 14px; border-radius:var(--radius-pill);
  box-shadow:0 0 0 4px #fff;
  /* The plate is a fixed height, and the text sits in the middle of it.

     Height used to come from whatever was written on it, so the two states
     were not the same object: a six-character code at display size made a
     compact 110x40 pill, and a status at label size made a long low one. Same
     padding, same radius, same ink — and clearly different, because padding
     around small text in a long string is not the same shape as padding
     around large text in a short one.

     Set here on the base rather than on the variant, so a plate cannot end up
     a different size by being written differently. */
  min-height:40px; box-sizing:border-box;
  display:flex; align-items:center; justify-content:center;
}
/* Tappable, and saying so quietly — it sits on a code that has to stay
   readable, so this cannot be a button with an outline of its own. */
[data-event] .ev-ticket-code.is-copyable { cursor:pointer; }
[data-event] .ev-ticket-code.is-copyable:active { transform:translate(-50%,-50%) scale(.97); }
/* Its own copy-confirmation pop, because the shared one cannot be used here.
   textPop animates transform:scale() alone, and this plate is centred with
   transform:translate(-50%,-50%) — an animation that sets transform REPLACES
   that translate rather than adding to it, so the plate fell half its own
   width right and half its height down for the length of the flash.

   The same trap as the carousel arrows, which lost their centring to
   button:active for as long as they were held. */
[data-event] .ev-ticket-code.text-flash { animation:ticketCodePop .35s ease; }
@keyframes ticketCodePop {
  0%   { transform:translate(-50%,-50%) scale(1); }
  40%  { transform:translate(-50%,-50%) scale(1.12); }
  100% { transform:translate(-50%,-50%) scale(1); }
}
[data-event] .ev-state-tag {
  display:inline-block; padding:5px 11px; border-radius:var(--radius-pill);
  font-size:var(--fs-micro); font-weight:700; border:1px solid;
}
[data-event] .ev-state-tag.ok   { color:var(--free-color); border-color:rgba(var(--free-color-rgb),.4); background:rgba(var(--free-color-rgb),.09); }

/* Date and location, each with its action at the far right of the same row.
   The fact and the thing you do about it belong on one line — they used to be
   a cell in a grid and a loose link most of a page apart. */
[data-event] .ev-ticket-facts {
  display:flex; flex-direction:column; gap:18px; margin-top:22px; text-align:left;
}
[data-event] .ev-fact-row { display:flex; align-items:center; justify-content:space-between; gap:12px; }
[data-event] .ev-fact-main { min-width:0; }
/* The small labels that name a value: the stat captions, the date the
   organizer started, and the Date and Location keys. One step under the
   description they sit near, at its weight rather than heavier — they were
   two sizes below it and bolder than it, which made a caption louder than the
   sentence it was captioning. */
[data-event] .ev-fact-row .k { font-size:var(--fs-meta); color:var(--muted); font-weight:400; }
[data-event] .ev-fact-row .v { font-size:var(--fs-body); font-weight:700; margin-top:2px; }
/* Add to calendar and Get directions, as controls rather than as sentences.
   Two underlined phrases down the right of one column were the loudest thing
   on the panel, over the facts they belong to. */
[data-event] .ev-fact-action {
  flex:0 0 auto; width:36px; height:36px; border-radius:50%;
  display:grid; place-items:center;
  text-decoration:none;
  /* One of these two is a <button> now — the calendar one writes a file
     rather than following a link — and a button brings a filled background
     and a font of its own. Named here so both read as the same object. */
  padding:0; font:inherit; cursor:pointer;
}

/* The reservation itself, as three equal boxes. Equal because none of the
   three outranks the others — they are one record read across. */
[data-event] .ev-fact-boxes {
  display:grid; grid-template-columns:repeat(3, 1fr); gap:8px; margin-top:22px;
}
/* A free booking has no refund box, so the two that are left share the row
   rather than sitting in the first two thirds of it. */
[data-event] .ev-fact-boxes.is-two-up { grid-template-columns:repeat(2, 1fr); }
[data-event] .ev-fact-box {
  border:1px solid var(--line); border-radius:14px; padding:11px 8px; text-align:center;
  min-width:0;
}
[data-event] .ev-fact-box .k { font-size:var(--fs-caption); color:var(--muted); font-weight:600; }
[data-event] .ev-fact-box .v {
  font-size:var(--fs-body); font-weight:700; margin-top:3px;
  overflow-wrap:anywhere; font-variant-numeric:tabular-nums;
}

[data-event] .ev-detail-grid { display:grid; grid-template-columns:1fr 1fr; gap:16px; text-align:left; margin-top:16px; }
[data-event] .ev-detail-grid .k { font-size:var(--fs-caption); color:var(--muted); font-weight:600; }
[data-event] .ev-detail-grid .v { font-size:var(--fs-body); font-weight:700; margin-top:2px; }

/* ---- organizer track record ---- */
/* No top margin. It was clearing the "About this organizer" heading that
   used to sit above it; with the heading gone it was just a gap over the
   avatar. */
[data-event] .ev-track-head { display:flex; align-items:center; gap:11px; }
[data-event] .ev-stats { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:16px; }
[data-event] .ev-stat {
  padding:13px 10px; border-radius:var(--radius-md); text-align:center;
  background:rgba(var(--paper-rgb),.04); border:1px solid var(--line);
}
[data-event] .ev-stat .n { font-size:var(--fs-h2); font-weight:700; letter-spacing:-.03em; }
[data-event] .ev-stat .k { font-size:var(--fs-meta); color:var(--muted); margin-top:2px; font-weight:400; }
/* The month the organizer started, under their name. */
[data-event] .ev-since { font-size:var(--fs-meta); color:var(--muted); font-weight:400; margin-top:2px; }

[data-event] .ev-badge {
  display:inline-flex; align-items:center; gap:5px; flex-shrink:0;
  padding:5px 11px; border-radius:var(--radius-pill);
  font-size:var(--fs-micro); font-weight:700; border:1px solid; white-space:nowrap;
}
[data-event] .ev-badge.popular { color:var(--glaze-ink); background:var(--glaze); border-color:var(--glaze); }
[data-event] .ev-badge.rising  { color:var(--free-color); background:rgba(var(--free-color-rgb),.10); border-color:rgba(var(--free-color-rgb),.35); }
[data-event] .ev-badge.new     { color:var(--muted); background:rgba(var(--paper-rgb),.04); border-color:var(--line); }

/* ---- scoped typography ---- */
[data-event] .ev-sm { font-size:var(--fs-meta); }
[data-event] .ev-xs { font-size:var(--fs-caption); }
[data-event] .ev-muted { color:var(--muted); }
[data-event] h3 { font-size:var(--fs-title); font-weight:700; letter-spacing:-.02em; margin:0; }
[data-event] p { margin:0; }

/* ---- buttons ---- */
[data-event] .ev-btn-secondary {
  background:transparent; color:var(--paper); border:1px solid var(--line);
  font-family:inherit; font-weight:700; font-size:var(--fs-body);
  padding:14px 22px; border-radius:var(--radius-pill); cursor:pointer; width:100%;
}
/* Quieter than secondary: no accent, no fill, just an outline. This card
   already carries a tier badge and three figures, and a fourth thing
   competing for attention would leave nothing leading. */
[data-event] .ev-btn-neutral {
  display:block; width:100%; margin-top:16px;
  padding:11px 14px; border-radius:var(--radius-pill);
  background:transparent; color:var(--paper);
  border:1px solid var(--line);
  font-size:var(--fs-meta); font-weight:600; cursor:pointer;
}
.btn-neutral {
  display:block; width:100%; margin-top:14px;
  padding:11px 14px; border-radius:var(--radius-pill);
  background:transparent; color:var(--paper);
  border:1px solid var(--line);
  font-size:var(--fs-meta); font-weight:600; cursor:pointer;
}
/* Nothing to do yet. Quiet rather than gone, so the way back out of an edit
   is where it will be once there is something to discard. */
[data-event] .ev-btn-secondary:disabled {
  opacity:.45; cursor:default; color:var(--muted);
}
[data-event] .ev-btn-secondary + .ev-btn-secondary { margin-top:10px; }
[data-event] .ev-btn-danger { color:var(--danger); border-color:rgba(var(--danger-rgb),.4); }
[data-event] .ev-link-btn {
  background:none; border:none; cursor:pointer; font-family:inherit;
  font-size:var(--fs-meta); font-weight:700; color:var(--paper); padding:10px 0; width:100%;
}

/* ---- floating action bar ----
   Inset on all sides rather than spanning the window, so content scrolls
   visibly beneath it and it reads as floating rather than as a bar cutting
   the page off.

   The bottom gap is a real number, not env() alone. viewport-fit=cover is
   not set on this app, so env(safe-area-inset-bottom) resolves to 0 on
   every device — the bar was sitting 9px off the edge everywhere, which on
   a phone puts it against the system gesture bar and reads as fixed to the
   bottom rather than floating. The env() term stays so the gap grows if
   viewport-fit is ever turned on. */
.ev-reserve {
  position:fixed; left:0; right:0; bottom:0; z-index:20;
  /* One inset on every side, so the bar sits in an even frame rather than
     12px from the sides and 18px from the bottom. */
  padding:0 var(--bar-inset) calc(var(--bar-inset) + env(safe-area-inset-bottom));
  pointer-events:none;
}
.ev-reserve-inner {
  max-width:calc(var(--app-max-w) + 16px); margin:0 auto; pointer-events:auto;
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  background:var(--glaze); color:var(--glaze-ink);
  border-radius:20px; padding:11px 11px 11px 22px;
  /* two shadows: a tight one to seat it against the page, a wide soft one
     to lift it off. One blur can do either, but reads flat doing both. */
  box-shadow:0 2px 6px rgba(0,0,0,.18), 0 12px 32px rgba(0,0,0,.32);
}
/* The left block gives way before the action does. The bar now carries the
   page's own wording for its primary action, which can be long, and the
   price is the part that can afford to ellipsise. */
.ev-reserve-inner > div:first-child { min-width:0; }
.ev-reserve-total {
  font-weight:800; letter-spacing:-.02em; font-size:.78rem;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* A destructive primary action, mirrored from a page whose only action is
   destructive. */
.ev-btn-danger-solid { background:var(--danger); color:#fff; }
.ev-reserve-sub {
  font-size:.66rem; color:var(--glaze-ink); opacity:.62;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.ev-btn-primary {
  background:var(--surface); color:var(--paper); border:none; cursor:pointer;
  font-family:inherit; font-weight:700; font-size:.78rem;
  padding:13px 22px; border-radius:var(--radius-pill); white-space:nowrap;
}
.ev-btn-primary:active { transform:scale(.985); }
.ev-btn-primary[disabled] { opacity:.5; cursor:default; }

/* The track-record avatar is the larger one from the reference. The name
   beside it also needs an explicit min-width:0 — without it the flex item
   refuses to shrink, the organizer name wraps onto two lines and shoves
   the tier badge out of the row. */
[data-event] .ev-track-head .owner-avatar-sm { width:42px; height:42px; font-size:var(--fs-body); }
[data-event] .ev-track-head > div { flex:1; min-width:0; }
[data-event] .ev-track-head .ev-row { gap:8px; }
/* The size the date and the location are set at further up the page. At
   --fs-lead this name was a step larger than every other value on the card,
   which made the organizer card read as a heading for the page rather than as
   one more block on it. */
[data-event] .ev-track-head b {
  font-size:var(--fs-body); min-width:0;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}

/* The photo runs to the screen edges, so #app's own horizontal padding has
   to come off for this page — the inset lives on .ev-content's children
   instead, exactly as it does in the reference. Without this the hero sits
   16px in from each side and the cards end up 32px in. */
/* Either the shell's inner column carries the mark (the usual paint, where
   .page-in gets it) or #app carries it itself — which is what renderEvent
   sets, and all that is left after swapEventSession rebuilds the page in
   place. Matching only the descendant meant tapping another session of a
   series put the app's default 14px/16px padding back around the whole page,
   so the photograph stopped reaching the edges and everything shifted. */
#app[data-event],
#app:has([data-event]) { padding-left:0; padding-right:0; padding-top:0; }

/* The rebuilt page's bar is .ev-reserve, not .sticky-form-footer, so the
   clearance rule further up does not match it. Same reasoning: the bar is
   fixed, takes no space in the flow, and would otherwise sit on top of
   whatever the page ends with. */
body:has(.ev-reserve) #app { padding-bottom:var(--footer-space); }

/* The app's own controls sit on the photo beside Share, since the hero
   occupies the row the topbar would normally use. */
[data-event] .ev-hero-right { display:flex; align-items:center; gap:8px; }
/* The same 4px the topbar uses, because these are the same controls.
   The event page lifts the control row out of .topbar and drops it onto the
   photo, which leaves ".topbar .topbar-controls { gap:4px }" and
   ".topbar .u-flex-gap6 { gap:4px }" behind — so the icons sat 8px and 6px
   apart here and 4px apart on every other page, and the row visibly changed
   shape when you opened an event or a ticket. */
[data-event] .ev-hero-right .topbar-controls { display:flex; align-items:center; gap:4px; }
[data-event] .ev-hero-right .u-flex-gap6 { gap:4px; }

/* Nothing to override any more: these keep the app's own .icon-btn, the
   same as on My tickets and Finished. The scrim below is what makes that
   readable over a photograph. */
/* matched to .ev-circle-btn beside them — the back and share circles and
   the app icons form one row, so they take one size. 38 now, with the rest
   of the top row across the app. */
[data-event] .ev-hero-right .icon-btn { width:38px; height:38px; }

/* ---- checkout ----
   Same page family as the event it follows, so it takes the same scoped
   rules. It has no hero above it, so the content column drops the overlap
   and the ground it uses to cover one. */
/* Checkout has no full-bleed photo, so it keeps the page padding the event
   page gives up — without this its cards would sit flush to the screen.
   Placed after the event-page rule so it wins on order at equal weight. */
#app:has([data-event-flat]) {
  padding-left:var(--app-pad-x); padding-right:var(--app-pad-x); padding-top:14px;
}
/* one gap between checkout's stacked cards, same as the event page */
[data-event-flat] > .ev-sheet + .ev-sheet { margin-top:var(--ev-gap); }

/* the headline amount */
[data-event] .ev-price-block { text-align:center; margin:8px 0 18px; }
[data-event] .ev-price-block h1 { font-size:var(--fs-hero); font-weight:700; letter-spacing:-.04em; margin:0; }
[data-event] .ev-price-block .meta { margin-top:6px; }

/* payment-method switch, matching the delivery segmented control */
[data-event] .ev-seg-thumb { display:none; }
[data-event] .ev-seg-btn {
  flex:1; padding:11px 8px; border-radius:var(--radius-pill); cursor:pointer;
  border:none; background:transparent; color:var(--muted);
  font-family:inherit; font-size:var(--fs-meta); font-weight:700;
}
[data-event] .ev-seg-btn.on { background:var(--glaze); color:var(--glaze-ink); }

[data-event] .ev-btn-primary.ev-btn-wide {
  width:100%; padding:15px 22px; margin-top:20px;
  background:var(--glaze); color:var(--glaze-ink);
}

/* ---- fill-by-weekday chart ----
   Replaces the activity heatmap. Seven columns, one per weekday, height is
   the share of spots that got taken on sessions that have already run. */
.fill-chart { margin-top:10px; }
.fill-bars { display:grid; grid-template-columns:repeat(7,1fr); gap:6px; align-items:end; }
.fill-col { display:flex; flex-direction:column; align-items:center; gap:5px; }
.fill-track {
  width:100%; height:56px; border-radius:5px;
  background:rgba(var(--ink-rgb),.12);
  display:flex; align-items:flex-end; overflow:hidden;
}
.fill-bar { width:100%; border-radius:5px; background:rgba(var(--ink-rgb),.45); transition:height .25s ease; }
/* the day worth acting on is the one the chart exists to surface */
.fill-col.is-best .fill-bar { background:var(--ink); }
/* A weekday with no sessions to judge. An empty track is exactly what 0%
   looks like, and the title attribute that told them apart is dead on a
   phone — so the track itself says it: a dashed outline and no fill, which
   reads as "nothing here" rather than as "nobody came". */
.fill-col.is-empty .fill-track {
  background:transparent;
  border:1px dashed rgba(var(--ink-rgb),.28);
}
.fill-col.is-empty .fill-day { opacity:.3; }
.best-days-card .fill-col.is-empty .fill-track { border-color:rgba(var(--paper-rgb),.24); }
.fill-day { font-size:.62rem; opacity:.55; font-weight:600; }
.fill-caption { font-size:.66rem; opacity:.62; margin-top:10px; }

/* On the analytics page the chart sits on a normal card rather than the
   dashboard's inverted ACTIVITY strip, so it takes the page's own ink instead.
   This scope is load-bearing: unscoped, these paint near-black bars on the
   black strip and the dashboard chart disappears entirely. The card class
   travels with the chart, so moving it again keeps its colours. */
.best-days-card .fill-track { background:rgba(var(--paper-rgb),.10); }
.best-days-card .fill-bar { background:rgba(var(--paper-rgb),.45); }
.best-days-card .fill-col.is-best .fill-bar { background:var(--paper); }

/* Owner preview. Every control on the page is deliberately dead here, so
   the page has to say why and offer the way out — a small back arrow on a
   page where nothing else responds reads as broken, not as a preview. */
[data-event] .ev-preview-bar {
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  margin-top:var(--ev-gap); padding:12px 14px;
  border-radius:var(--radius-md);
  background:rgba(var(--paper-rgb),.06); border:1px dashed var(--line);
  font-size:var(--fs-meta); color:var(--muted);
}
[data-event] .ev-preview-exit {
  flex-shrink:0; border:none; cursor:pointer; font-family:inherit;
  font-size:var(--fs-meta); font-weight:700; padding:8px 14px;
  border-radius:var(--radius-pill);
  background:var(--glaze); color:var(--glaze-ink);
}


/* ==========================================================================
   BUSY INDICATOR

   One spinner for every action that takes a moment. Shown over the page
   rather than inside the pressed control, because the same indicator has
   to cover saving, uploading, account creation and the first paint after a
   reload — none of which have one obvious control to sit in.
   ========================================================================== */

/* Above the sheet too — a save started from the form must not be covered by
   the form it was started from. */
.busy-overlay {
  position:fixed; inset:0; z-index:1300;
  display:grid; place-items:center;
  background:rgba(var(--ink-rgb),.55);
  opacity:0; pointer-events:none;
  transition:opacity .16s ease;
}
.busy-overlay.is-on { opacity:1; pointer-events:auto; }
.busy-dialog {
  display:flex; align-items:center; gap:12px;
  padding:16px; border-radius:var(--radius-lg);
  background:var(--surface); box-shadow:var(--shadow-modal);
}
/* with no label it is just the wheel, so the box should be square */
.busy-overlay:not(.busy-labelled) .busy-dialog { padding:18px; }
.busy-label { font-size:var(--fs-body); font-weight:600; color:var(--paper); }
.busy-overlay:not(.busy-labelled) .busy-label { display:none; }
.busy-spinner {
  width:22px; height:22px; flex-shrink:0; display:block;
  border-radius:50%;
  border:2.5px solid rgba(var(--paper-rgb),.15);
  border-top-color:var(--glaze);
  animation:busySpin .7s linear infinite;
}
@keyframes busySpin { to { transform:rotate(360deg); } }

/* The very first paint. Sits in the markup so it is on screen before any
   script has run, and is torn out once the app has rendered — otherwise a
   reload shows a blank page for as long as the templates take to fetch. */
/* A notification, as a container rather than a page-width block.
   Same ground, radius, border and shadow as every settings card — only
   tighter, because a notification is one line about one thing and the full
   card padding made a list of them enormous. The gap between them matches
   the gap between settings containers, so the two pages are spaced alike.
   No uppercase label: there is nothing to name above a single sentence. */
.notif-card {
  padding:13px 15px;
  margin-bottom:12px;
  transition:opacity .2s ease;
}
.notif-card .notif-message { font-size:var(--fs-meta); line-height:1.45; }
.notif-card .meta { font-size:var(--fs-micro); }
/* Already read: still there, plainly behind the ones that are not. Opacity
   rather than a greyer ink, so the timestamp fades with the message and the
   row keeps its shape. */
.notif-card.is-read { opacity:.55; }
@media (hover:hover) and (pointer:fine) { .notif-card.is-read:hover { opacity:.8; } }

/* The two account notices, built to sit among the settings containers.
   .card already supplies the ground, radius, padding and shadow; this only
   arranges the actions under the text and keeps them small — they are things
   to get around to, not the point of the page. Full-width and stacked on a
   narrow screen, side by side once there is room. */
.account-notice .card-heading { margin-bottom:4px; }
.account-notice-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:12px; }
.account-notice-btn {
  width:auto; flex:0 0 auto; white-space:nowrap;
  padding:9px 14px; font-size:var(--fs-caption);
}
@media (max-width:360px) {
  .account-notice-btn { flex:1 1 100%; }
}

/* The activity card while its figures are still arriving.
   Its own shape, in its own colours — this card is inverted, so the skeleton
   is light on dark rather than the page-level one, which would vanish here.
   Seven columns at settled heights and three stat blocks, so the real chart
   and the real numbers land in the space already held for them and nothing
   moves. No spinner: every screen in this app waits the same way. */
  display:flex; align-items:flex-end; gap:8px;
  height:74px; margin:14px 0 12px;
}
  width:100%; border-radius:var(--radius-sm, 6px);
  background:rgba(var(--ink-rgb),.16);
  animation:scanSkeletonPulse 1.8s ease-in-out infinite;
}
  width:62px; height:15px; border-radius:var(--radius-pill);
  background:rgba(var(--ink-rgb),.16);
  animation:scanSkeletonPulse 1.8s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
}

/* What a page looks like while its records are on the way: the shape of the
   content, in the place the content will occupy, so nothing moves when it
   arrives. The pulse is scanSkeletonPulse, the one the scan screen already
   uses — the app has one idea of what waiting looks like. */
.page-skeleton { display:flex; flex-direction:column; gap:var(--space-pair, 14px); }
.skeleton-card {
  background:var(--surface); border-radius:var(--radius-lg);
  padding:18px; display:flex; flex-direction:column; gap:10px;
}
.skeleton-bar {
  height:13px; border-radius:var(--radius-pill);
  background:rgba(var(--paper-rgb),.09);
  animation:scanSkeletonPulse 1.8s ease-in-out infinite;
}
.skeleton-bar-sm { height:10px; background:rgba(var(--paper-rgb),.06); }
/* Where the share link and QR code go, while the event is still on its way
   to the record — the same bar the rest of the app waits with, sized to the
   box it stands in for. */
/* Centred on the card, because it is the whole of what the card is doing
   until the record answers. */
.share-waiting { display:flex; flex-direction:column; align-items:center; gap:10px; padding:14px 0 6px; }
.share-waiting-wheel {
  width:34px; height:34px; flex-shrink:0;
  border-radius:50%;
  border:3px solid var(--line);
  border-top-color:var(--glaze);
  animation:busySpin .8s linear infinite;
}
.share-waiting-title { margin:4px 0 0; font-weight:600; }
.share-waiting-note { margin:0; }
/* A wheel that cannot spin is a ring, which says nothing. Reduced motion
   gets a pulse instead of a still circle. */
@media (prefers-reduced-motion: reduce) {
  .share-waiting-wheel { animation:sharePulse 1.6s ease-in-out infinite; }
  @keyframes sharePulse { 0%,100% { opacity:.35; } 50% { opacity:1; } }
}
.skeleton-pills { display:flex; gap:7px; margin-top:2px; }
.skeleton-pill {
  width:58px; height:26px; border-radius:var(--radius-pill);
  background:rgba(var(--paper-rgb),.06);
  animation:scanSkeletonPulse 1.8s ease-in-out infinite;
}
/* Staggered, so the page breathes rather than blinking as one block. */
.skeleton-card:nth-child(2) .skeleton-bar,
.skeleton-card:nth-child(2) .skeleton-pill { animation-delay:.15s; }
.skeleton-card:nth-child(3) .skeleton-bar,
.skeleton-card:nth-child(3) .skeleton-pill { animation-delay:.3s; }
.skeleton-card:nth-child(4) .skeleton-bar,
.skeleton-card:nth-child(4) .skeleton-pill { animation-delay:.45s; }
@media (prefers-reduced-motion: reduce) {
  .skeleton-bar, .skeleton-pill { animation:none; }
}

/* The first paint. Laid out like the page it is standing in for — same
   ground, same width, same gutters — rather than a curtain over the app, so
   the skeleton the app draws a moment later lands in the same place and
   nothing jumps. */
/* The one waiting mark, in the boot markup and on every page that waits.
   Centred in the window rather than at the top of the column: it is not
   standing in for content, so it does not belong where content would be. */
.app-wait-mark {
  display:block;
  width:26px; height:26px; border-width:3px;
  position:fixed; top:50%; left:50%; margin:-13px 0 0 -13px;
}
#boot-splash {
  position:fixed; inset:0; z-index:60;
  background:var(--ink);
}
#boot-splash.is-done { opacity:0; pointer-events:none; }

@media (prefers-reduced-motion: reduce) {
  .busy-spinner { animation-duration:2s; }
}


/* ---- the QR code's own controls ----------------------------------------
   .qr-btn-col had no rule at all, so share and download stacked under the
   code with no spacing. They sit at its top right instead, where they read
   as belonging to the code rather than to the card around it. */
.qr-btn-col {
  position:absolute; top:0; right:0;
  display:flex; flex-direction:column; gap:10px; z-index:2;
}

/* the organizer card is a link to everything else they run */
.ev-sheet-link { cursor:pointer; }

/* the stepper's minus becomes a bin on the last seat — same gesture, and
   it reads as destructive because it is */
/* The same colour as the plus and the minus it sits between. Red made
   removing a date look like the dangerous option on a row where every control
   changes the same booking. */
.ev-stepper button.is-remove { color:inherit; }

/* A date and whether it is selected. Nothing else — no dot, no second
   colour. The dot was a marker this app uses nowhere else, and the green
   "already booked" tint meant a date left a strange colour behind the
   moment it was deselected. What the pill has to answer is "is this one
   in or out", which the selected fill already says. */

/* ---- the ticket code, above the tear -----------------------------------
   The part of the stub you actually hand over. Sharing sits with it,
   because the code is the thing being shared. */
/* The gap under the code lives here rather than on the session pills, so a
   one-off gets it too. With the pills carrying it, an event with no sessions
   to list had the QR 14px off the tear and an event with them had 80. */
.ev-code-block {
  position:relative; display:flex; flex-direction:column; align-items:stretch;
  gap:10px; margin-bottom:18px;
}
.ev-code-share {
  position:absolute; top:0; right:0;
  width:34px; height:34px; padding:0; border-radius:50%;
  display:grid; place-items:center; justify-content:center; align-items:center;
  cursor:pointer;
}
/* The link that sits at the right end of a detail row — the thing you do
   about the fact on that row. The column wrapper these used to sit in went
   with the loose link stack it existed to arrange. */
.ev-outlink {
  font-size:var(--fs-meta); font-weight:600; color:var(--glaze);
  text-decoration:underline; text-underline-offset:3px;
}

/* one-tap check-in beside the badge, for a door with no working camera */
.checkin-cell { display:inline-flex; align-items:center; gap:8px; justify-content:flex-end; }
button.checkin-manual { color:var(--free-color); border-color:rgba(var(--free-color-rgb),.4); }

/* ---- the create form's sections ---------------------------------------
   The form was a flat run of fields on the page ground while every other
   page groups its content into cards. Fields inside a section lose their
   own bottom margin — the card's padding is the spacing now, and the two
   together left a gap under the last field of every section. */
.create-section { padding:var(--pad-card); }
.create-section > .field:last-child { margin-bottom:0; }
.create-section > .field > label:first-child { margin-top:0; }

/* ---- checkout line items ----------------------------------------------
   The payment pages had drifted onto their own padding and font sizes via
   one-off utilities. One row style, on the shared type scale, so Stripe
   and PayPal read as the same app as everything before them. */
.pay-row {
  display:flex; align-items:baseline; justify-content:space-between; gap:12px;
  padding:7px 0; font-size:var(--fs-meta);
}
.pay-row .k { color:var(--muted); }
.pay-row .v { color:var(--muted); font-variant-numeric:tabular-nums; }
.pay-row.is-total { font-size:var(--fs-lead); padding-top:2px; }
.pay-row.is-total .k,
.pay-row.is-total .v { color:var(--paper); font-weight:700; }

/* ---- the provider checkout panes ----
   Stripe's Payment Element and PayPal's checkout, each built to the shape
   the provider really uses.

   Brand colours are hardcoded here, and only here. The eleven-token checkout
   contract is the OWNER's colour scheme, and running Stripe's purple or
   PayPal's yellow through it would repaint another company's identity in a
   caterer's brand colours. Everything else in these panes — ground, type,
   radius, borders — is on the tokens like the rest of the file, so both
   panes still follow the app into dark mode. */
[data-event] .pay-pane { display:flex; flex-direction:column; }
[data-event] .pay-group-label { margin:18px 0 8px; }
[data-event] .pay-field + .pay-field { margin-top:12px; }
[data-event] .pay-input-holder { position:relative; }
[data-event] .ev-input.is-locked { color:var(--muted); cursor:default; }
[data-event] .ev-input.is-bad,
[data-event] .ev-input.is-bad:focus { border-color:var(--danger); box-shadow:0 0 0 1px var(--danger); }
/* A message with something in it pushes the next label off it. The error
   slot is empty on every field most of the time, so the gap cannot live on
   the slot itself. */
[data-event] .pay-field .ev-err:not(:empty) { margin-bottom:8px; }
[data-event] .ev-input.has-brands { padding-right:132px; }

/* The row of brand marks inside the card field, lighting up the one the
   number has declared. Stand-ins for the logos rather than the artwork
   itself — the shape of the affordance without borrowing the trademark. */
[data-event] .pay-brands {
  position:absolute; right:10px; top:50%; transform:translateY(-50%);
  display:flex; gap:4px; pointer-events:none;
}
[data-event] .pay-brand {
  font-size:.5rem; font-weight:800; letter-spacing:.02em; line-height:1;
  padding:5px 5px; border-radius:4px; color:#fff; background:var(--brand);
  opacity:.9; transition:opacity .18s ease, filter .18s ease;
}
[data-event] .pay-brand.off { opacity:.18; filter:grayscale(1); }
[data-event] .pay-brand.on { opacity:1; }

[data-event] .pay-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
[data-event] .pay-grid2 .pay-field + .pay-field { margin-top:0; }

/* Method tabs. Stripe stacks the mark over the label in a row of equal
   boxes, which is what makes a method recognisable before it is read. */
[data-event] .pay-tabs { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
[data-event] .pay-tab {
  display:flex; flex-direction:column; align-items:center; gap:6px;
  padding:12px 4px; border-radius:var(--radius-sm); cursor:pointer;
  border:1px solid var(--line); background:var(--surface); color:var(--paper);
  font-family:inherit;
}
[data-event] .pay-tab.on { border-color:var(--paper); box-shadow:0 0 0 1px var(--paper) inset; }
[data-event] .pay-tab-label { font-size:var(--fs-caption); font-weight:700; text-align:center; }
[data-event] .pay-mark {
  display:inline-flex; align-items:center; justify-content:center;
  width:26px; height:18px; border-radius:4px; flex:0 0 auto;
  background:var(--brand); color:#fff; font-size:.6rem; font-weight:800; line-height:1;
}
[data-event] .pay-mark.is-wide { width:38px; font-size:.5rem; }
[data-event] .pay-mark-icon { color:#fff; }
[data-event] .pay-mark-icon svg { display:block; }

[data-event] .pay-card-form { margin-top:14px; }

[data-event] .pay-save-row { display:flex; align-items:flex-start; gap:11px; margin-top:16px; cursor:pointer; }
[data-event] .pay-check { flex:0 0 auto; width:18px; height:18px; margin:1px 0 0; accent-color:var(--glaze); cursor:pointer; }
[data-event] .pay-save-text { display:flex; flex-direction:column; gap:3px; }
[data-event] .pay-save-title { font-size:var(--fs-meta); font-weight:700; }
[data-event] .pay-save-note { font-size:var(--fs-caption); color:var(--muted); }

/* Link and Cash App: a method that is agreed to elsewhere, so the pane
   states what will happen rather than collecting anything. */
[data-event] .pay-link-box {
  margin-top:14px; padding:16px; border-radius:var(--radius-md);
  border:1px solid var(--line); background:rgba(var(--paper-rgb),.03);
}
[data-event] .pay-link-head { display:flex; align-items:center; gap:9px; }
[data-event] .pay-link-title { font-size:var(--fs-body); font-weight:700; }
[data-event] .pay-link-note { font-size:var(--fs-caption); color:var(--muted); margin-top:8px; line-height:1.45; }
[data-event] .pay-saved-card {
  display:flex; align-items:center; gap:10px; margin-top:12px;
  padding:11px 12px; border-radius:var(--radius-sm);
  border:1px solid var(--line); background:var(--surface);
}
[data-event] .pay-saved-text { font-size:var(--fs-meta); font-variant-numeric:tabular-nums; }

/* The test numbers, offered rather than described. */
[data-event] .pay-testcards { margin-top:16px; padding-top:14px; border-top:1px dashed var(--line); }
[data-event] .pay-testcards-label {
  display:block; font-size:var(--fs-micro); font-weight:700;
  letter-spacing:.09em; text-transform:uppercase; color:var(--muted-soft); margin-bottom:8px;
}
[data-event] .pay-testcard-row { display:flex; flex-wrap:wrap; gap:6px; }
[data-event] .pay-testcard {
  padding:6px 11px; border-radius:var(--radius-pill); cursor:pointer;
  border:1px dashed var(--line); background:transparent; color:var(--muted);
  font-family:inherit; font-size:var(--fs-caption); font-weight:600;
}
[data-event] .pay-testcard:hover { color:var(--paper); border-color:rgba(var(--paper-rgb),.35); }

/* One pay button, two identities. */
[data-event] .pay-btn {
  margin-top:20px; width:100%; padding:16px; border:none; cursor:pointer;
  border-radius:var(--radius-pill); font-family:inherit;
  font-size:var(--fs-lead); font-weight:700;
  display:flex; align-items:center; justify-content:center; gap:8px;
  min-height:54px;
}
[data-event] .pay-btn:disabled { cursor:default; }
[data-event] .pay-btn-stripe { background:#635bff; color:#fff; }
[data-event] .pay-btn-paypal { background:#ffc439; color:#003087; font-weight:800; }
[data-event] .pay-spinner { width:18px; height:18px; border-width:2px; }

[data-event] .pay-footer {
  display:flex; align-items:center; justify-content:center; gap:5px;
  margin-top:14px; font-size:var(--fs-caption); color:var(--muted-soft);
}
[data-event] .pay-lock { font-size:.6rem; }
[data-event] .pay-wordmark-stripe { font-weight:800; letter-spacing:-.03em; color:#635bff; }
[data-event] .pay-wordmark-paypal { font-weight:800; font-style:italic; letter-spacing:-.02em; color:#0070ba; }

/* ---- PayPal ---- */
[data-event] .pay-pp-brand {
  text-align:center; font-size:var(--fs-h1); font-weight:800; font-style:italic;
  letter-spacing:-.03em; line-height:1;
}
[data-event] .pay-pp-pay { color:#003087; }
[data-event] .pay-pp-pal { color:#0070ba; }
[data-event] .pay-pp-account {
  text-align:center; font-size:var(--fs-caption); color:var(--muted); margin-top:6px;
}
[data-event] .pay-pp-source {
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  padding:14px; border-radius:var(--radius-md);
  border:1px solid var(--line); background:rgba(var(--paper-rgb),.03);
}
[data-event] .pay-pp-option-text { display:flex; flex-direction:column; gap:3px; min-width:0; }
[data-event] .pay-pp-name { font-size:var(--fs-body); font-weight:700; }
[data-event] .pay-pp-detail { font-size:var(--fs-caption); color:var(--muted); }
[data-event] .pay-pp-change {
  flex:0 0 auto; border:none; background:none; cursor:pointer; padding:4px;
  font-family:inherit; font-size:var(--fs-meta); font-weight:700;
  color:#0070ba; text-decoration:underline;
}
[data-event] .pay-pp-list { display:flex; flex-direction:column; gap:8px; }
[data-event] .pay-pp-option {
  display:flex; align-items:center; gap:12px; width:100%; text-align:left;
  padding:14px; border-radius:var(--radius-md); cursor:pointer;
  border:1px solid var(--line); background:var(--surface); color:var(--paper);
  font-family:inherit;
}
[data-event] .pay-pp-option.on { border-color:#0070ba; box-shadow:0 0 0 1px #0070ba inset; }
[data-event] .pay-pp-radio {
  flex:0 0 auto; width:18px; height:18px; border-radius:50%;
  border:2px solid var(--muted-soft); position:relative;
}
[data-event] .pay-pp-option.on .pay-pp-radio { border-color:#0070ba; }
[data-event] .pay-pp-option.on .pay-pp-radio::after {
  content:""; position:absolute; inset:3px; border-radius:50%; background:#0070ba;
}
[data-event] .pay-pp-declined {
  margin-left:auto; flex:0 0 auto; font-size:var(--fs-micro); font-weight:700;
  letter-spacing:.06em; text-transform:uppercase; color:var(--danger);
}
[data-event] .pay-pp-total {
  display:flex; align-items:baseline; justify-content:space-between;
  margin-top:18px; padding-top:14px; border-top:1px solid var(--line);
}
[data-event] .pay-pp-total .k { font-size:var(--fs-body); font-weight:700; }
[data-event] .pay-pp-total .v {
  font-size:var(--fs-h2); font-weight:700; font-variant-numeric:tabular-nums;
}
[data-event] .pay-pp-cancel {
  text-align:center; margin-top:12px; cursor:pointer;
  font-size:var(--fs-caption); color:#0070ba; text-decoration:underline;
}

/* The decline, above the list that answers it. */
[data-event] .pay-error {
  display:flex; align-items:flex-start; gap:9px; margin-top:16px;
  padding:12px 14px; border-radius:var(--radius-sm);
  border:1px solid rgba(var(--danger-rgb),.35); background:rgba(var(--danger-rgb),.07);
  font-size:var(--fs-caption); color:var(--paper); line-height:1.45;
}
/* The standing sign-up prompt. Marked out from the cards around it, because
   it is the one card on the page that is not a record of anything. */
.create-account-nudge { border:1px solid var(--line); }
.create-account-nudge h3 { margin:0 0 6px; }
.create-account-nudge .meta { margin-bottom:14px; }

/* Proving a ticket is yours before changing it. */
.verify-field { margin-top:16px; }
.verify-input {
  width:100%; text-align:center; letter-spacing:.4em; font-weight:700;
  font-size:1.35rem; font-variant-numeric:tabular-nums; padding:14px 15px;
  border-radius:var(--radius-md); border:1px solid var(--line);
  background:rgba(var(--paper-rgb),.03); color:var(--paper);
  font-family:inherit; outline:none;
}
.verify-input:focus { border-color:rgba(var(--paper-rgb),.4); background:var(--surface); }
.verify-input::placeholder { letter-spacing:.3em; color:var(--muted-soft); }
/* The demo's own admission that nothing was sent anywhere. Dashed and muted,
   the same vocabulary the checkout's test cards use. */
.verify-demo {
  margin-top:12px; padding:9px 12px; border-radius:var(--radius-sm);
  border:1px dashed var(--line); color:var(--muted);
  font-size:var(--fs-caption); text-align:center;
}
.verify-actions { display:flex; flex-direction:column; gap:8px; margin-top:18px; }

/* The refund result, which is a page rather than a flash now.

   .payment-result-transition is sized to fill the screen on its own, because
   everywhere else it IS the whole screen for a second. Here it has a receipt
   and a button under it, so the 60vh it reserves opens a hole between the
   heading and the thing the heading is about. */
.refund-result .payment-result-transition { min-height:0; padding:32px 0 4px; }

.refund-spinner { width:36px; height:36px; border-width:3px; margin:0 auto 6px; }
.refund-receipt { margin-top:22px; }
.refund-receipt .pay-rule { margin:10px 0; border-top:1px solid var(--line); }

[data-event] .pay-error-mark {
  flex:0 0 auto; width:16px; height:16px; border-radius:50%; margin-top:1px;
  background:var(--danger); color:#fff; font-size:.6rem; font-weight:800;
  display:flex; align-items:center; justify-content:center; line-height:1;
}
.pay-rule { border-top:1px solid var(--line); margin:12px 0; }

/* the spinner standing in for a figure that has nothing behind it yet */

/* A figure that cannot be worked out yet — not one that is loading.
   A spinner sat here, and a spinner is a promise: something is happening and
   it will finish. Nothing is happening. No session has ended, so there is
   nothing to average and nothing is being fetched. Three dots settling in
   turn say the number is still to come without claiming it is on its way.

   currentColor, so it carries the weight of the figure it stands in for
   rather than needing a colour of its own in every theme. */
.stat-pending { display:inline-flex; align-items:center; gap:4px; height:1em; vertical-align:1px; }
.stat-pending i {
  width:5px; height:5px; border-radius:50%; background:currentColor; opacity:.28;
  animation:statPending 1.5s ease-in-out infinite;
}
.stat-pending i:nth-child(2) { animation-delay:.2s; }
.stat-pending i:nth-child(3) { animation-delay:.4s; }
@keyframes statPending { 0%, 100% { opacity:.22; } 50% { opacity:.8; } }
@media (prefers-reduced-motion: reduce) {
  .stat-pending i { animation:none; opacity:.45; }
}

/* the event ID reads as takeable — it exists to be quoted to an organizer */
/* No underline. The copy mark beside it carries the affordance now, and an
   underline made it look like somewhere to go rather than something to take. */
.ev-id-copy { text-decoration:none; }

/* ---- page arrival ------------------------------------------------------
   Content appearing with no transition at all reads as a flicker rather
   than as a new page. Short enough not to be a wait: this is a hint that
   something changed, not an animation to sit through. */
/* Opacity only — no transform. An element running a transform animation
   becomes the containing block for its position:fixed descendants, and the
   floating price bar is a child of this one. For the length of the
   animation the bar stopped being anchored to the viewport and anchored to
   this wrapper instead, which left it sitting ~110px up the page, over the
   card below it, with dead space underneath. The 3px slide it cost is not
   worth a bar that detaches from the bottom of the screen every time a page
   is opened. */
@keyframes pageIn { from { opacity:0; } to { opacity:1; } }
.page-in { animation:pageIn .13s ease-out both; }
@media (prefers-reduced-motion: reduce) { .page-in { animation:none; } }

/* Marks the end of a paged list. Zero-height on purpose — it is a trigger,
   not a thing to look at, and it sits 300px early so the next page is
   already there by the time the last card is. */
.list-sentinel { height:1px; margin:0; }

/* ---- attendee help ------------------------------------------------------
   Three ways out of a problem, as rows rather than three buttons: they are
   destinations, not decisions, and stacking full-width buttons would give
   them the weight of the actions on the ticket itself. */
.help-row {
  display:flex; flex-direction:column; align-items:flex-start; gap:2px;
  width:100%; text-align:left; background:transparent; border:none;
  padding:12px 0; border-bottom:1px solid var(--line); cursor:pointer;
  border-radius:0;
}
.help-row:last-child { border-bottom:none; padding-bottom:0; }
.help-row:first-child { padding-top:0; }
.help-row-title { font-size:var(--fs-body); font-weight:600; color:var(--paper); }
.help-row-note  { font-size:var(--fs-meta); font-weight:400; color:var(--muted); }
@media (hover:hover) and (pointer:fine) {
  .help-row:hover .help-row-title { color:var(--glaze); }
}

/* An automatic reply is marked as one. It sits on the support side of the
   thread but is not dressed as a person — if it answered wrongly, whoever
   reads it should know straight away who to disbelieve. */
.convo-thread-entry.bot .convo-thread-meta { color:var(--glaze); font-weight:700; }
.convo-thread-entry.bot .convo-thread-text {
  border-left:2px solid rgba(var(--glaze-rgb),.35); padding-left:10px;
}

/* A short scrim under the top of the hero photo. It exists so the control
   row can use the app's ordinary button instead of a white one invented
   for this page — the buttons stay legible over a light photo without
   being a different component from the rest of the app. Height is just
   the row plus its inset; the rest of the photo is untouched. */
/* No position here. It was set to relative so ::before below had something
   to position against — but the hero is position:sticky, which is already a
   containing block, and this rule sits later in the file at equal
   specificity, so it quietly won and the photo stopped sticking. The column
   then pushed it off the top instead of sliding over it, which is the whole
   effect. */
[data-event] .ev-hero::before {
  content:""; position:absolute; left:0; right:0; top:0; height:150px;
  /* Tall and weak rather than short and strong. At 92px and .92 it read as
     a dark stripe laid across the top of somebody photograph; spread over
     150px at .55 it is a shading nobody notices, which is the job. */
  background:linear-gradient(to bottom, rgba(var(--ink-rgb),.55), rgba(var(--ink-rgb),.18) 55%, rgba(var(--ink-rgb),0));
  pointer-events:none; z-index:1;
}
/* z-index only. Setting position:relative here overrode the absolute
   positioning above, so the row lost its right inset, became full-width
   from a 14px left offset, and pushed the last icon 14px off screen. */
[data-event] .ev-hero-controls { z-index:2; }
/* ---- payment and refund result ------------------------------------------
   The mark is the message: it has to land before the words are read. Both
   outcomes get the same treatment so neither reads as an afterthought —
   previously success had a 72px tick and failure had a bare line of text. */
.payment-result-transition {
  transition:opacity .2s ease;
  min-height:60vh; display:flex; flex-direction:column;
  align-items:center; justify-content:center; gap:14px; text-align:center;
}
.payment-result-transition h1 { font-size:var(--fs-h1); margin:0; }
.payment-result-transition svg { animation:resultMark .34s cubic-bezier(.2,.9,.3,1.2) both; }
@keyframes resultMark {
  from { opacity:0; transform:scale(.72); }
  60%  { opacity:1; transform:scale(1.06); }
  to   { opacity:1; transform:scale(1); }
}
/* A failure should not bounce — the same arrival, without the overshoot
   that reads as celebratory. */
.payment-result-transition.is-bad svg { animation-timing-function:cubic-bezier(.2,.7,.3,1); }
@keyframes resultMarkPlain { from { opacity:0; transform:scale(.9); } to { opacity:1; transform:scale(1); } }
.payment-result-transition.is-bad svg { animation-name:resultMarkPlain; }
@media (prefers-reduced-motion: reduce) {
  .payment-result-transition svg { animation:none; }
}

/* ---- wallet payment ----
   A wallet is not a form. Its button is the brand's own object and its
   sheet is a separate surface that slides over the page — so both are
   styled as themselves rather than as our primary button and our modal.
   Provider colours are literal on purpose, like the Stripe and PayPal
   panes: an owner's checkout theme colours OUR screens, not a wallet's. */
.wallet-btn {
  width:100%; height:48px; border-radius:var(--radius-pill); border:0;
  display:grid; place-items:center; cursor:pointer;
  font-family:inherit; font-weight:600; font-size:var(--fs-body);
  letter-spacing:-.01em;
}
.wallet-btn.is-apple  { background:#000; color:#fff; }
.wallet-btn.is-google { background:#000; color:#fff; }
@media (hover:hover) and (pointer:fine) { .wallet-btn:hover { opacity:.88; } }

/* The sheet. Bottom-anchored and full width, because that is where a
   wallet appears — a centred dialog would read as ours. */
/* Flush to the bottom edge: the overlay carries 20px of padding for the
   centred dialogs it usually holds, and a sheet that stops short of the
   edge is a dialog wearing sheet corners. */
.modal-overlay:has(.wallet-sheet) { align-items:flex-end; padding:0; }
/* .modal-box centres itself with margin:auto, which beats the overlay
   asking for flex-end — auto margins win over align-items every time.
   Only the bottom one is released. */
.wallet-sheet { margin:auto 0 0; }
.wallet-sheet {
  width:100%; max-width:var(--app-max-w);
  border-radius:20px 20px 0 0; padding:20px 18px calc(18px + env(safe-area-inset-bottom));
  background:var(--surface); color:var(--paper);
  animation:walletUp .22s cubic-bezier(.22,.68,.36,1);
}
@keyframes walletUp { from { transform:translateY(14px); opacity:0; } to { transform:none; opacity:1; } }
@media (prefers-reduced-motion: reduce) { .wallet-sheet { animation:none; } }
.wallet-head { display:flex; justify-content:center; margin-bottom:16px; }
.wallet-mark { font-weight:700; font-size:var(--fs-title); letter-spacing:-.02em; }
.wallet-row {
  display:flex; justify-content:space-between; align-items:baseline; gap:12px;
  padding:7px 0; font-size:var(--fs-meta);
}
.wallet-row .k { color:var(--muted); }
.wallet-row .v { font-weight:600; font-variant-numeric:tabular-nums; }
.wallet-row.is-total .k, .wallet-row.is-total .v { font-size:var(--fs-lead); font-weight:700; color:var(--paper); }
.wallet-rule { height:1px; background:var(--line); margin:8px 0; }
.wallet-pay {
  width:100%; height:46px; margin-top:14px; border:0; border-radius:var(--radius-pill);
  background:#000; color:#fff; font-family:inherit; font-weight:600;
  font-size:var(--fs-body); cursor:pointer; display:grid; place-items:center;
}
.wallet-pay:disabled { opacity:.75; cursor:default; }
.wallet-cancel {
  width:100%; height:40px; margin-top:8px; border:0; background:none;
  color:var(--muted); font-family:inherit; font-size:var(--fs-meta); cursor:pointer;
}

/* A page that cannot reach the record says so, once, without asking for a
   decision — there is nothing to press and the person did not cause it. */
.offline-notice {
  position:fixed; left:50%; transform:translateX(-50%);
  /* Clear of the bottom bar on every page, and never in the way of one.
     Anchored to the viewport rather than to --footer-space, which is tall on
     a page with a wide action bar and put this notice in the middle of the
     calendar. */
  bottom:16px; z-index:900;
  /* A notice is not a control. It sat over the date grid and swallowed taps
     for the seven seconds it was up. */
  pointer-events:none;
  max-width:min(92vw, var(--app-max-w));
  padding:10px 14px; border-radius:var(--radius-pill);
  background:var(--paper); color:var(--ink);
  font-size:var(--fs-meta); font-weight:600; text-align:center;
  box-shadow:var(--shadow-float);
  transition:opacity .9s ease;
}
.offline-notice.is-fading { opacity:0; }

/* The live-mode answer to an unreachable database. Covers the page, because
   in live mode there is nothing behind it worth seeing — the record is where
   the data lives, and an app with no events in it reads as "nobody has made
   one yet" rather than "we cannot reach the database". Tokens only, so it
   holds in both themes; the ground is opaque on purpose. */
.server-unavailable {
  position:fixed; inset:0; z-index:1000;
  display:flex; align-items:center; justify-content:center;
  padding:24px;
  background:var(--ink);
}
.server-unavailable-box {
  width:min(92vw, 420px);
  display:flex; flex-direction:column; gap:12px;
  padding:24px; border-radius:var(--radius-card);
  background:var(--surface); color:var(--paper);
  box-shadow:var(--shadow-float);
  text-align:center;
}
.server-unavailable-box h2 { margin:0; font-size:var(--fs-h2); letter-spacing:-0.01em; }
.server-unavailable-box .meta { margin:0; }

/* The stop screen carries two actions and they must not look like one
   choice offered twice. Try again is the filled one; the check-in escape is
   outlined, because it is the narrower thing to do — it keeps one screen
   working rather than fixing the problem. */
.server-unavailable-box .btn-secondary {
  width:100%;
  background:transparent;
  color:var(--paper);
  border:1px solid var(--paper);
}

/* Week view: the same room under the calendar that Month has.
   Month's last row is usually a plain date, so its cell leaves space under
   the number before the list begins. Week has one row, and that row is the
   one carrying the event dots — so the dots sat flush against the top of the
   first card with about four pixels between them, while Month had twenty.
   The gap is stated here rather than inherited from whatever happens to be
   in the last row. */
.calendar-box-flex .cal-grid { margin-bottom:16px; }

/* The gap the page's cards keep from each other, for the one block that is
   not a card — the event name above them. */
.u-mb22 { margin-bottom:22px; }



/* ==========================================================================
   ROUND ICON CONTROLS — one treatment, everywhere

   Half the surface colour with a hairline and a soft shadow: the look the
   topbar has always had. Everything round and pressable now takes it,
   because the alternative was a 5% wash of the text colour that reads as a
   disabled control and left the same shape wearing two different designs
   depending which screen it turned up on.

   Listed by selector rather than applied through a shared class, so nothing
   has to be found and re-tagged in the markup, and a control added later
   inherits it by using .icon-btn.
   ========================================================================== */
.modal-close,
.carousel-arrow-btn,
.ev-code-share,
.manual-code-submit-btn,
.party-stepper button,
.party-stepper-sm button,
[data-event] .ev-circle-btn,
[data-event] .ev-fact-action,
[data-event] .ev-stepper button {
  background:rgba(var(--surface-rgb),.5);
  color:var(--paper);
  border:1px solid rgba(var(--paper-rgb),.07);
  box-shadow:0 1px 2px rgba(0,0,0,.06), 0 4px 12px rgba(0,0,0,.05);
}
/* The two that sit ON a photograph. Everything else has a page behind it, so
   half the surface colour resolves against a known ground; over somebody's
   picture it resolves against whatever they uploaded, and a pale shot left
   the chip invisible. Blurring what is behind it gives it a ground of its
   own without changing the look. */
.carousel-arrow-btn,
[data-event] .ev-hero-arrows .ev-circle-btn {
  -webkit-backdrop-filter:blur(8px); backdrop-filter:blur(8px);
}
/* The same treatment, translated for a dark ground.
   "Half the surface colour" lifts a chip off the page in light, because
   surface is white and the ground is light grey. In dark the two are five
   values apart per channel — surface #1d1d21 over ground #131316 — so half
   of it lands almost exactly on the page and the control disappears. Seen at
   3x on the dark topbar: the refresh circle was a faint ring and no fill.
   A lift off a dark ground is a little of the LIGHT colour, which is what
   this is; the intent is unchanged and only the arithmetic differs. */
:root[data-theme="dark"] .icon-btn,
:root[data-theme="dark"] .nav-arrow-btn,
:root[data-theme="dark"] .modal-close,
:root[data-theme="dark"] .carousel-arrow-btn,
:root[data-theme="dark"] .ev-code-share,
:root[data-theme="dark"] .manual-code-submit-btn,
:root[data-theme="dark"] .party-stepper button,
:root[data-theme="dark"] .party-stepper-sm button,
:root[data-theme="dark"] [data-event] .ev-circle-btn,
:root[data-theme="dark"] [data-event] .ev-fact-action,
:root[data-theme="dark"] [data-event] .ev-stepper button {
  background:rgba(var(--paper-rgb),.09);
  border:1px solid rgba(var(--paper-rgb),.14);
  box-shadow:none;
}
/* ...but not the SELECTED one, which is an inverted pill: page-ground icon on
   a text-colour fill. The wash above lands later in the file than the
   .icon-btn-selected rule and outranks it, so in dark the fill was replaced by
   a 9% wash while the icon kept its near-black inverted colour — the pencil on
   the event page came out at about 1.1:1 against the page and could not be
   seen. Same fault the topbar note further up describes, one selector short. */
:root[data-theme="dark"] .icon-btn.icon-btn-selected:not(.contrast-adaptive),
:root[data-theme="dark"] .page-back-btn.icon-btn-selected:not(.contrast-adaptive) {
  background:var(--paper);
  border:1px solid var(--paper);
  color:var(--ink);
}
/* No prefers-color-scheme rule here, deliberately.
   This block used to give the round controls their dark-ground treatment
   whenever the OPERATING SYSTEM was dark. But this app's palette is driven
   entirely by data-theme and has never followed the OS — the media query I
   added was the only one in the file. So on a dark-OS phone with no theme
   chosen yet, the icons took the dark treatment while everything around them
   rendered light: a grey wash where the white chip belonged. Toggling to dark
   and back stamped data-theme="light", the selector stopped matching, and the
   right design appeared — which is exactly how it was spotted.

   Dark here means data-theme="dark", the same thing it means everywhere else
   in this stylesheet. If the app ever follows the OS, the tokens are the
   place to do it, once, not one component's borders. */

@media (hover:hover) and (pointer:fine) {
  .modal-close:hover,
  .carousel-arrow-btn:hover,
  .ev-code-share:hover,
  .manual-code-submit-btn:hover,
  .party-stepper button:hover,
  .party-stepper-sm button:hover,
  [data-event] .ev-circle-btn:hover,
  [data-event] .ev-fact-action:hover,
  [data-event] .ev-stepper button:hover {
    background:rgba(var(--surface-rgb),.75);
    box-shadow:0 1px 3px rgba(0,0,0,.09), 0 6px 16px rgba(0,0,0,.08);
  }
}

/* A pill that is a control, not a label. The plain .tag is muted on purpose —
   it describes a thing — and the same treatment on a button reads as
   disabled, which is exactly wrong for Reload. */
.tag-action {
  color: var(--paper);
  border: 1px solid var(--line);
  background: transparent;
  font-weight: 700;
}
.tag-action:hover { background: rgba(var(--paper-rgb), .06); }
.tag-action:disabled { opacity: .5; }

/* The moment a door accepts a ticket, on the holder's own screen.
   One fast turn and a small settle at the end, then the code is out of focus
   and the plate is over it. No colour moves: this says "that happened", and a
   green flash would be the app congratulating itself over somebody's ticket. */
@keyframes checkinSpinSettle {
  0%   { filter:blur(0);   opacity:1;  transform:rotate(0deg) scale(1); }
  42%  { filter:blur(5px); opacity:.7; transform:rotate(360deg) scale(1); }
  70%  { filter:blur(7px); opacity:.5; transform:rotate(360deg) scale(1.1); }
  100% { filter:blur(7px); opacity:.5; transform:rotate(360deg) scale(1); }
}
[data-event] .ev-qr.just-scanned canvas,
[data-event] .ev-qr.just-scanned img {
  animation:checkinSpinSettle .5s cubic-bezier(.3,0,.15,1) both;
}
/* The plate only arrives — it must not compete with the turn underneath it. */
@keyframes checkinPlateIn { from { opacity:0; } to { opacity:1; } }
[data-event] .just-scanned-plate {
  animation:checkinPlateIn .2s ease .3s both;
}
@media (prefers-reduced-motion: reduce) {
  [data-event] .ev-qr.just-scanned canvas,
  [data-event] .ev-qr.just-scanned img,
  [data-event] .just-scanned-plate { animation:none; }
}

/* A disabled button loses its transform (see button:disabled), and this one
   is centred in its field WITH a transform — so disabling it dropped the
   arrow to the bottom of the input. Positioning is not decoration. */
.manual-code-submit-btn:disabled { transform:translateY(-50%); }

/* The ticket's status line. It is appended into a panel that already has
   content above it, so it carries its own space — without it the pill landed
   against a clamped description and cut across the last line of text. */
[data-event] .ev-state-row { margin-top:16px; }

/* ---- the full-screen picture ---------------------------------------------
   Portrait: it fills the screen and overflows rather than sitting letterboxed
   in the middle of a black field. What overflows can be dragged — see the pan
   handlers — so nothing is unreachable. */
/* Fitted to the width and left at its own proportions — nothing cropped and
   nothing stretched. A picture taller than the screen can still be scrolled,
   because the overlay scrolls. */



/* ---- the picture, opened -------------------------------------------------
   A dimmed page and a card over it, the same shape as every other overlay in
   the app. The card is the width of the stub below, so it lines up with the
   page it came from, and the frame takes the picture's own aspect ratio. */
.photo-pop-overlay {
  position:fixed; inset:0; z-index:200;
  display:flex; align-items:center; justify-content:center;
  padding:16px;
  background:rgba(0,0,0,.62);
  -webkit-backdrop-filter:blur(3px); backdrop-filter:blur(3px);
  animation:photoPopFade .18s ease-out both;
}
.photo-pop {
  position:relative;
  width:100%; max-width:var(--app-max-w, 520px);
  display:flex; flex-direction:column; gap:10px;
  animation:photoPopIn .24s cubic-bezier(.2,.8,.3,1) both;
}
.photo-pop-frame {
  width:100%; max-height:78vh;
  border-radius:var(--radius-lg); overflow:hidden;
  background:rgba(var(--paper-rgb),.06);
}
.photo-pop-img { width:100%; height:100%; object-fit:cover; display:block; }
.photo-pop-bar { display:flex; align-items:center; justify-content:center; gap:12px; }
.photo-pop-dots { display:flex; align-items:center; gap:7px; }
.photo-pop-dots i {
  width:6px; height:6px; border-radius:50%; cursor:pointer;
  background:rgba(255,255,255,.42);
}
.photo-pop-dots i.is-on { width:18px; border-radius:100px; background:#fff; }
.photo-pop-close {
  width:30px; height:30px; border-radius:50%; padding:0; flex:0 0 auto;
  display:grid; place-items:center; cursor:pointer;
  background:rgba(255,255,255,.22); color:#fff;
  border:0; outline:0; box-shadow:none; -webkit-tap-highlight-color:transparent;
}
.photo-pop-close:focus { outline:none; }
.photo-pop-close:focus-visible { outline:2px solid rgba(255,255,255,.75); outline-offset:1px; }

@keyframes photoPopFade { from { opacity:0; } to { opacity:1; } }
@keyframes photoPopIn {
  from { opacity:0; transform:translateY(10px) scale(.94); }
  to   { opacity:1; transform:translateY(0) scale(1); }
}
@keyframes photoPopFadeOut { from { opacity:1; } to { opacity:0; } }
@keyframes photoPopOut {
  from { opacity:1; transform:translateY(0) scale(1); }
  to   { opacity:0; transform:translateY(10px) scale(.94); }
}
.photo-pop-overlay.is-closing { animation:photoPopFadeOut .18s ease-in both; }
.photo-pop-overlay.is-closing .photo-pop { animation:photoPopOut .18s ease-in both; }

/* A dot changing is a small movement, not a swap. Both rows — the one on the
   hero and the one under the opened picture — grow the current dot into a bar
   rather than cutting to it, so the eye follows which picture is current
   instead of re-finding it. */
[data-event] .ev-hero-dots i,
.photo-pop-dots i {
  transition:width .24s cubic-bezier(.2,.8,.3,1),
             background-color .24s ease;
}
@media (prefers-reduced-motion: reduce) {
  .photo-pop-overlay, .photo-pop,
  .photo-pop-overlay.is-closing, .photo-pop-overlay.is-closing .photo-pop { animation:none; }
  [data-event] .ev-hero-dots i, .photo-pop-dots i { transition:none; }
}

/* ---- the account, as a menu ----
   Who you are, then what you can go and do. The rows are the width of the
   page with the ground showing between them rather than cards in a stack:
   this is a list of destinations, and cards would make each one look like a
   thing in its own right. */
/* Avatar on the left, name and address stacked beside it — the shape a
   profile row takes everywhere else in the app, and it gives the name a full
   line to run along instead of a centred column. */
.account-identity { display:flex; flex-direction:row; align-items:center; gap:14px; margin:8px 0 26px; }
.account-identity-text { min-width:0; flex:1 1 auto; }
.account-identity-row { display:flex; align-items:center; gap:8px; }
.account-identity-name {
  margin:0; font-size:var(--fs-title); text-align:left;
  min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
/* A name nobody has set yet, offered rather than asserted. */
.account-identity-name.is-unset { color:var(--muted); font-weight:600; }
.account-identity-edit {
  padding:6px; border:0; border-radius:99px; background:none; box-shadow:none;
  color:var(--muted); cursor:pointer; display:inline-flex;
}
.account-identity-edit:active { transform:none; }
@media (hover:hover) and (pointer:fine) { .account-identity-edit:hover { color:var(--paper); } }
.account-identity-sub {
  margin:0; text-align:left;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.account-identity .owner-avatar-lg,
.account-identity .avatar-lg {
  /* Centred properly: the large avatar is used inline elsewhere, where the
     initial sits wherever the line puts it. On its own above a name it has to
     be in the middle of its own circle. */
  /* Smaller now that it leads a row rather than heading a centred column. */
  width:64px; height:64px; flex:0 0 64px; border-radius:50%;
  display:flex; align-items:center; justify-content:center;
  font-size:24px; font-weight:700; color:#fff; line-height:1;
  background-size:cover; background-position:center;
}
/* No trailing space under the last row: the menu ends where it ends. */
.account-menu { display:flex; flex-direction:column; margin-bottom:0; }
.account-menu + * { margin-top:0; }
.account-menu-row {
  display:flex; align-items:center; gap:14px; width:100%;
  /* No border, no corners. The base button rule gives every button a pill
     radius and a fill; a row in a menu is a line of text you press, not a
     control sitting on the page, so both are turned off here rather than
     left to look like a very wide pill. */
  padding:18px 4px; border:0; border-radius:0; background:none; box-shadow:none;
  cursor:pointer; color:var(--paper);
  font-size:var(--fs-body); text-align:left;
}
.account-menu-row:active { transform:none; }
.account-menu-icon { flex:0 0 auto; display:inline-flex; color:var(--paper); }
.account-menu-label { flex:1 1 auto; min-width:0; font-weight:500; }
.account-menu-note { flex:0 0 auto; color:var(--muted); font-size:var(--fs-meta); }
.account-menu-chevron { flex:0 0 auto; display:inline-flex; color:var(--muted); }
@media (hover:hover) and (pointer:fine) {
  .account-menu-row:hover .account-menu-label { text-decoration:underline; }
}
/* A section opened from the menu carries its own name and a way back. */
.account-section-head { display:flex; align-items:center; gap:12px; margin-bottom:18px; }
.account-section-head h1 { margin:0; font-size:var(--fs-title); }

/* ---- Load more ---------------------------------------------------------
   Sits under the last card it revealed, full width like the cards, and
   reads as the end of the list rather than as an action on the last card. */
.load-more-btn {
  width: 100%;
  margin-top: 14px;
}

/* ---- Owner ⇄ attendee messages -----------------------------------------
   A conversation, not a support ticket: the two sides are told apart by
   which edge they sit against, which is how every messaging surface anybody
   opening this has already learned to read. */
.chat-row { cursor:pointer; }
.chat-row-who { margin:0; font-weight:700; }
.chat-row-sub { margin:2px 0 0; }
.chat-row-last {
  margin:8px 0 0; font-size:var(--fs-meta);
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.chat-row-when { margin:4px 0 0; font-size:var(--fs-micro); }
/* Waiting on the reader. The card is already highlighted; this is the mark
   that survives being skimmed. */
.chat-row-dot {
  width:9px; height:9px; border-radius:50%;
  background:var(--glaze); flex-shrink:0; margin-top:5px;
}
.chat-thread { display:flex; flex-direction:column; gap:10px; margin-top:16px; }
.chat-empty { margin:18px 0; }
.chat-msg {
  align-self:flex-start; max-width:84%;
  background:var(--surface); border-radius:var(--radius-md);
  padding:10px 13px; box-shadow:var(--shadow-card);
}
.chat-msg.is-mine { align-self:flex-end; background:var(--glaze); }
.chat-msg-meta { margin:0 0 3px; font-weight:700; font-size:var(--fs-micro); }
.chat-msg.is-mine .chat-msg-meta { color:rgba(var(--glaze-ink-rgb,255,255,255),.7); }
.chat-msg-text { margin:0; font-size:var(--fs-meta); white-space:pre-wrap; }
.chat-msg.is-mine .chat-msg-text { color:var(--glaze-ink); }
/* The second contact route sits under the first, quieter than it. */
.ev-contact-alt { margin-top:8px; opacity:.75; }

/* ---- one address, two accounts ---------------------------------------- */
.account-type-card { margin-top:18px; }
.account-type-card .card-heading { margin:0 0 6px; }

/* ---- the dashboard summary --------------------------------------------
   Three figures and a sentence. Deliberately plain: it sits directly above
   the calendar it describes, so it does not restate the period. */
.dash-summary {
  background:var(--paper); color:var(--ink);
  border-radius:var(--radius-lg); padding:16px 18px; margin-bottom:var(--gap);
}
.dash-summary-row { display:flex; gap:22px; flex-wrap:wrap; }
.dash-summary-stat { display:flex; align-items:baseline; gap:5px; min-width:0; }
.dash-summary-n { font-size:1.05rem; font-weight:700; letter-spacing:-.02em; font-variant-numeric:tabular-nums; }
.dash-summary-k { font-size:.66rem; opacity:.6; }
.dash-summary-busiest { margin:12px 0 0; font-size:.68rem; opacity:.66; }

/* ---- which weekday books best -----------------------------------------
   Seven columns, each growing into place on load. The growth is a height
   transition from zero, staggered a little so the row fills left to right
   rather than snapping into existence all at once. */
.dash-bars {
  display:grid; grid-template-columns:repeat(7,1fr); gap:7px;
  align-items:end; margin-top:16px;
}
.dash-bar-col { display:flex; flex-direction:column; align-items:center; gap:6px; }
.dash-bar-track {
  width:100%; height:46px; border-radius:6px;
  background:rgba(var(--ink-rgb),.16);
  display:flex; align-items:flex-end; overflow:hidden;
}
.dash-bar-fill {
  width:100%; border-radius:6px;
  background:rgba(var(--ink-rgb),.55);
  transition:height .55s cubic-bezier(.2,.8,.25,1);
}
.dash-bar-col.is-best .dash-bar-fill { background:var(--ink); }
/* A weekday the period never lands on: an outline, not a flat bar, because a
   flat bar reads as "nobody came" rather than "nothing was on". */
.dash-bar-col.is-empty .dash-bar-track {
  background:transparent; border:1px dashed rgba(var(--ink-rgb),.28);
}
.dash-bar-col.is-empty .dash-bar-day { opacity:.35; }
.dash-bar-day { font-size:.62rem; font-weight:600; opacity:.6; }
.dash-bars-caption { margin:12px 0 0; font-size:.68rem; opacity:.66; }
/* Somebody who has asked not to be moved gets the finished bars. */
@media (prefers-reduced-motion: reduce) {
  .dash-bar-fill { transition:none; }
}

/* The eyebrow, and the figures now sitting under the chart. */
.dash-summary-eyebrow {
  margin:0 0 12px; font-size:.6rem; font-weight:700;
  letter-spacing:.09em; text-transform:uppercase; opacity:.55;
}
.dash-summary-row { margin-top:16px; }

/* A photograph on its way. Takes no room: the card is the same height
   whether or not one arrives. */
.event-card-thumb-slot { display:none; }

/* ---- the location, drawn -------------------------------------------------
   Not scoped to [data-event] any more. The same widget is now drawn wherever
   an address is stated — the event page, the ticket, the organizer's manage
   page, an external calendar entry — and a selector that only matched inside
   the event page left it unstyled everywhere else: a full-width, borderless,
   200px-tall map with no radius and no ring.
   ---------------------------------------------------------------------- */
/* The address above says where; this says whether that is anywhere near you.
   The panel is not itself a link — it can be dragged, and a drag ends as a
   click — so the corner control is what opens a maps app. */
.ev-map {
  display:block; margin-top:14px; border-radius:18px; overflow:hidden;
  background:var(--surface);
  /* Square. A wide letterbox says "here is a strip of a city"; a square says
     "here is a place", and it gives the pin room above and below it rather
     than pressing it between two edges. */
  aspect-ratio:1 / 1; position:relative; text-decoration:none;
  /* A ring, not a border. A 1px line around a drawing of a place reads as a
     frame; a ring at a tenth of the ink reads as the edge of the card. */
  box-shadow:inset 0 0 0 1px var(--line);
  /* Leaflet builds its own stacking contexts inside; without this the rounded
     corners are drawn over by square tiles. */
  isolation:isolate;
}
/* Nothing at all until there is something to draw.
   The box is created before the address has been looked up and removes itself
   if it cannot be — but in between it was a full-width square of empty card,
   and on an address that never resolves that is what somebody was left
   looking at: a large blank space where a map was supposed to be. It holds no
   room until it has a map to put in it. */
.ev-map.is-waiting {
  aspect-ratio:auto; height:0; box-shadow:none; margin-top:0; overflow:hidden;
}
.ev-map-canvas { position:absolute; inset:0; }

/* No filters on the tiles. The basemap is Positron, which is already a quiet
   drawing — see the note in buildLocationMapBox. Dark loads the dark style
   rather than inverting the light one. */

/* The place: a ring with a dot in it. */
.ev-map-mark { position:relative; }
.ev-map-mark i {
  position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
  display:block; border-radius:50%;
}
.ev-map-mark .halo {
  width:42px; height:42px;
  background:rgba(var(--paper-rgb),.12);
}
.ev-map-mark .dot {
  width:14px; height:14px;
  background:var(--paper);
  box-shadow:0 0 0 3px var(--surface), 0 1px 4px rgba(0,0,0,.30);
}

/* The affordance the pin icon used to be: this panel opens a maps app. */
.ev-map-open {
  position:absolute; z-index:500; right:10px; top:10px;
  width:32px; height:32px; border-radius:50%;
  display:grid; place-items:center;
  color:var(--paper);
  background:rgba(var(--surface-rgb),.94);
  box-shadow:0 1px 2px rgba(0,0,0,.12), 0 4px 14px rgba(0,0,0,.10);
}

/* Leaflet's zoom buttons, in this card's register rather than its own. */
.ev-map .leaflet-control-zoom { border:none; margin:10px; box-shadow:none; }
.ev-map .leaflet-control-zoom a {
  width:30px; height:30px; line-height:30px;
  color:var(--paper); border:none;
  background:rgba(var(--surface-rgb),.94);
  box-shadow:0 1px 2px rgba(0,0,0,.12), 0 4px 14px rgba(0,0,0,.10);
}
.ev-map .leaflet-control-zoom a:first-child { border-radius:9px 9px 0 0; }
.ev-map .leaflet-control-zoom a:last-child { border-radius:0 0 9px 9px; border-top:1px solid var(--line); }
.ev-map .leaflet-control-zoom a:hover { background:var(--surface); }

/* The licence line, down to a whisper. Positron and the OSM data both require
   it, so it stays — it just has no business being the second-loudest thing in
   the panel, which is what Leaflet's default makes it. */
.ev-map .leaflet-control-attribution {
  background:transparent; color:rgba(var(--paper-rgb),.42);
  font-size:9px; padding:0 5px; line-height:14px;
}
.ev-map .leaflet-control-attribution a {
  color:rgba(var(--paper-rgb),.42); text-decoration:none;
}

/* ---- booking, as a sheet over the event ---------------------------------
   The decision is about the thing behind it, so the event stays on screen and
   this rises over it. It carries none of the event's own furniture — no
   photograph, no perforated stub, no tags — because none of that is being
   decided here. */
.booking-sheet-overlay {
  position:fixed; inset:0; z-index:1200;
  background:rgba(0,0,0,.42);
  -webkit-backdrop-filter:blur(2px); backdrop-filter:blur(2px);
  display:flex; align-items:flex-end; justify-content:center;
  animation:bookingSheetFade .18s ease;
}
.booking-sheet-overlay.is-leaving { animation:bookingSheetFade .18s ease reverse; }
@keyframes bookingSheetFade { from { opacity:0 } to { opacity:1 } }

.booking-sheet {
  width:100%; max-width:520px;
  background:var(--surface); color:var(--paper);
  border-radius:22px 22px 0 0;
  /* Never the whole screen: a strip of the event stays visible above it, which
     is the reason this is a sheet and not a page.
     The height was the content's, capped at 88vh — so a short form sat low and
     a long one sat high, and the panel landed in a different place depending
     on the event. A floor as well as a ceiling gives it one resting position.
     82/94 put its top edge under the browser chrome with no event left in
     view, which is the one thing a sheet is supposed to keep. 70/85 sits a
     little higher than 62/82 did and still leaves that strip. */
  min-height:70vh; max-height:85vh; display:flex; flex-direction:column;
  box-shadow:0 -8px 40px rgba(0,0,0,.22);
  animation:bookingSheetUp .24s cubic-bezier(.2,.8,.25,1);
}
.is-leaving .booking-sheet { animation:bookingSheetUp .2s ease reverse; }
@keyframes bookingSheetUp { from { transform:translateY(100%) } to { transform:translateY(0) } }
@media (prefers-reduced-motion: reduce) {
  .booking-sheet, .booking-sheet-overlay,
  .is-leaving .booking-sheet, .booking-sheet-overlay.is-leaving { animation:none; }
}

/* The whole top block is the handle, not just the grip strip. A dismissal
   gesture you have to aim at is a gesture people stop using. The browser must
   not claim it for scrolling either, hence touch-action. */
.booking-sheet-head {
  position:relative; flex:0 0 auto;
  padding:10px 20px 12px;
  touch-action:none; cursor:grab;
}
.booking-sheet-head:active { cursor:grabbing; }
.booking-sheet-grip {
  display:block; margin:0 auto 14px;
}
.booking-sheet-heading {
  font-size:var(--fs-title); font-weight:700; letter-spacing:-.02em;
  text-align:left;
  /* Clear of the close button, so a long heading cannot run under it. */
  padding-right:38px;
}
/* The grab handle says which way this moves. It is decoration — the button
   beside it is what a keyboard and a screen reader use. */
.booking-sheet-grip {
  width:38px; height:4px; border-radius:999px;
  background:rgba(var(--paper-rgb),.18);
}
.booking-sheet-close {
  position:absolute; right:10px; top:8px;
  width:32px; height:32px; border-radius:50%;
  display:grid; place-items:center;
  border:none; background:rgba(var(--paper-rgb),.06); color:var(--paper);
  cursor:pointer; padding:0;
}
.booking-sheet-close:hover { background:rgba(var(--paper-rgb),.11); }



/* The form scrolls; the head and the action bar do not. */
.booking-sheet-body {
  flex:1 1 auto; overflow-y:auto; -webkit-overflow-scrolling:touch;
  padding:4px 20px 8px;
}
/* The first section inside the sheet has the title above it already. */
.booking-sheet-body > .ev-section:first-child { margin-top:14px; }

.booking-sheet-foot {
  flex:0 0 auto; display:flex; align-items:center; gap:12px;
  padding:12px 16px calc(12px + env(safe-area-inset-bottom));
  border-top:1px solid var(--line); background:var(--surface);
}
.booking-sheet-foot > div:first-child { flex:1 1 auto; min-width:0; }
/* How many are left on this particular date, under the date it belongs to. */
[data-event] .ev-session-spots {
  display:block; font-size:var(--fs-meta); color:var(--muted); margin-top:2px;
}
/* The page's floating bar is a dark plate, so its button is white and its
   subtitle is the colour of text ON the accent. This footer is the sheet's own
   surface — light — where both of those disappear. Same shapes, re-coloured
   for the ground they are actually on. */
.booking-sheet-foot .ev-btn-primary {
  flex:0 0 auto; background:var(--glaze); color:var(--glaze-ink);
}
.booking-sheet-foot .ev-reserve-total { color:var(--paper); }
.booking-sheet-foot .ev-reserve-sub { color:var(--muted); opacity:1; }

/* ---- More: loads the next stretch of dates into the row ----------------- */
[data-event] .ev-load-more-dates {
  padding:7px 13px; border-radius:var(--radius-pill); cursor:pointer;
  border:1px dashed var(--line); background:transparent; color:var(--muted);
  font-family:inherit; font-size:var(--fs-caption); font-weight:600;
}
[data-event] .ev-load-more-dates:hover { background:rgba(var(--paper-rgb),.05); }

/* ---- previewing an event theme ------------------------------------------
   The three screens across the top, and along the bottom a way out plus every
   theme to try. Both are fixed: an owner comparing themes should not have to
   find the controls again after each change. */
.preview-tabs {
  position:fixed; left:0; right:0; top:0; z-index:60;
  display:flex; gap:6px; justify-content:center;
  padding:10px 12px calc(10px + env(safe-area-inset-top, 0px));
  background:color-mix(in srgb, var(--ink) 88%, transparent);
  backdrop-filter:blur(10px);
  border-bottom:1px solid var(--line);
}
.preview-tab {
  flex:1 1 0; max-width:140px; padding:9px 10px;
  border-radius:var(--radius-pill); border:1px solid var(--line);
  background:transparent; color:var(--muted);
  font-family:inherit; font-size:var(--fs-caption); font-weight:700; cursor:pointer;
}
.preview-tab.is-on { background:var(--paper); border-color:var(--paper); color:var(--ink); }

.preview-theme-bar {
  position:fixed; left:0; right:0; bottom:0; z-index:60;
  display:flex; align-items:center; gap:10px;
  padding:10px 12px calc(10px + env(safe-area-inset-bottom, 0px));
  background:color-mix(in srgb, var(--ink) 88%, transparent);
  backdrop-filter:blur(10px);
  border-top:1px solid var(--line);
}
.preview-theme-back {
  flex:0 0 auto; width:38px; height:38px; padding:0;
  display:inline-flex; align-items:center; justify-content:center;
  border-radius:50%; border:1px solid var(--line);
  background:transparent; color:var(--paper); cursor:pointer;
}
/* The themes scroll rather than shrink: a swatch too small to read is not a
   choice anybody can make. */
.preview-theme-rail {
  flex:1 1 auto; min-width:0; display:flex; align-items:center; gap:8px;
  /* Room for the selected swatch ring. overflow-x:auto clips the other axis
     too, so without this padding the 2px ring was sliced off top and bottom. */
  padding:4px 2px;
  overflow-x:auto; scrollbar-width:none; -webkit-overflow-scrolling:touch;
}
.preview-theme-rail::-webkit-scrollbar { display:none; }
/* In the rail the same swatch keeps a fixed size rather than sharing the row. */
.preview-theme-rail .theme-swatch-btn { flex:0 0 auto; width:34px; height:34px; max-width:none; min-width:0; }
.theme-swatch-btn.is-on { box-shadow:0 0 0 2px var(--paper); border-color:var(--paper); }

/* Room for both bars, so neither sits on the page's own content. */
/* The tabs are fixed, so the page needs their height back — no more than that.
   62px double-counted the safe-area inset the bar already carries, which is
   what left a band of empty page under them. */
body:has(.preview-tabs) #app { padding-top:calc(64px + env(safe-area-inset-top, 0px)); }
/* The app own header has no business in a preview: the tabs above say where
   you are, and a Classtis logo with live account controls is not part of what an
   attendee sees on this page. Its height comes back as breathing room. */
body:has(.preview-tabs) .topbar { display:none; }
/* Room for the theme bar. */
body:has(.preview-theme-bar) #app { padding-bottom:90px; }
/* ...and for the inert reserve bar above it, but only on a page that has one.
   Checkout does not, and reserving its height there left a dead band under the
   last button. */
body:has(.preview-theme-bar):has(.ev-reserve) #app { padding-bottom:150px; }
body:has(.preview-theme-bar) .ev-reserve { bottom:calc(58px + env(safe-area-inset-bottom, 0px)); }
/* Nothing in the bar can be pressed in a preview; say so quietly rather than
   greying it into something that looks broken. */
body:has(.preview-theme-bar) .ev-reserve .ev-btn-primary { opacity:.55; }
