@font-face {
  font-family: 'Telegraf';
  src: url('/fonts/telegraf-ultrabold.otf') format('opentype');
  font-weight: 800;
  font-style: normal;
  font-display: swap;
}

/* Light Upon Light, Türkiye 2027
 * Accessibility: skip-link, landmarks, aria-current, aria-live flash, focus-ring,
 * reduced-motion.
 *
 * ---- Palette source of truth ----
 * The colours below come from the client's "Brand Identity Guideline 2024"
 * (Guideline LUL.pdf), not from taste. Its three declared brand colours are:
 *
 *   WARNA UTAMA  (primary)   Dark Gray   #333333  - strength, stability
 *   WARNA KEDUA  (secondary) Gold        #C1AB75  - luxury, spirituality
 *   WARNA KETIGA (tertiary)  Light Gray  #D6D6D6  - purity, openness
 *
 * NOTE ON THE GOLD HEX: page 13 of the guideline prints the gold's hex as
 * "#d6d6d6", which is a copy-paste of the swatch below it. The RGB printed
 * alongside (R:193 G:171 B:117) is the real value and resolves to #C1AB75;
 * sampling the printed swatch confirms #C1AB75 exactly. Trust the RGB, not the
 * mislabelled hex - do NOT "fix" the gold back to #D6D6D6.
 *
 * The deep violet (#482C79 -> #36205A) is not a fourth brand colour: it is the
 * third colourway the guideline gives its secondary graphics (pages 19-20,
 * alongside gold and gray). It is used here ONLY as the informational accent,
 * which is why it never appears as a page or brand surface.
 *
 * Typography is the guideline's too: Poppins is the declared primary typeface
 * (page 16). Anton SC and Damion are also listed, for display and script use,
 * and are deliberately NOT loaded - a condensed poster face and a script face
 * add noise to a data-dense admin, which is the opposite of what it needs.
 *
 * Visual language:
 *   - Cool light-gray page (#F4F5F7) with WHITE cards, so a surface reads as a
 *     surface. The previous cream-on-cream (card #FBF6EA on page #FAF7F0)
 *     differed by ~1% luminance, which is why nothing separated and every
 *     admin page read as one undifferentiated wash.
 *   - Gold accent (#C1AB75) for brand moments; SATURATED semantic colours for
 *     state, so "paid", "retrying" and "failed" are told apart by hue and not
 *     by reading the label.
 *   - Poppins for UI; JetBrains Mono for refs, dates and amounts
 *   - Type scale: 11 (eyebrow) / 13 / 14 / 16 / 19 / 26 / 32 / 48 px
 *   - 8px rounded rectangles, 10-14px cards
 *   - Graphite brand surfaces for the admin sidebar and ink CTAs
 */

:root {
  /* ---- Brand palette tokens ----
     `--ink` / `--paper` are CONTENT tokens - they flip in dark mode so body
     text and backgrounds invert correctly. `--brand-*` tokens are CHROME
     tokens - fixed regardless of theme, used on always-dark surfaces (admin
     sidebar, ink CTAs, ref-hero, hero-dark, chat fab). Otherwise dark mode
     would invert those surfaces too and the sidebar would render in white. */

  /* The three declared brand colours, by their guideline names. Everything
     else in this block is derived from these (a tint, a shade, or a neutral
     mixed toward them), so re-pointing these three re-skins the whole site. */
  --brand-graphite: #333333;  /* WARNA UTAMA  */
  --brand-gold:     #C1AB75;  /* WARNA KEDUA  - see the hex note in the header */
  --brand-lightgray:#D6D6D6;  /* WARNA KETIGA */

  /* The POSTER palette - the three colours lifted off the retreat artwork, used
     by the "What to expect" boxes so that section reads as part of the poster
     rather than as more site chrome. They are CHROME tokens, not content ones:
     they do NOT invert, because navy-on-cream is a PAIR taken from a printed
     design and inverting one half of it is how a card ends up with its own text
     invisible. `.hero-dark` is painted from literal darks for the same reason
     (s18). Gold here is the poster's #C5A15B, a shade off the guideline's
     --brand-gold; it is the artwork's gold and is deliberately not merged with
     it. It is NEVER text - 2.13:1 on the cream - so it appears only as a badge
     fill (with navy ink on it) and as a hairline. */
  --poster-cream: #F5EFE3;
  --poster-navy:  #172633;
  /* The body copy inside a poster box: navy at 72%, measured at 5.76:1 on the
     cream in Chrome. Below ~60% it drops under AA, so this is a floor and not a
     taste setting. */
  --poster-navy-2: rgba(23, 38, 51, .72);
  --poster-gold:  #C5A15B;
  --poster-line:  rgba(197, 161, 91, .38);

  --ink:        #1B1B1D;
  --ink-2:      #333333;   /* the brand primary, doing duty as body text */
  /* #68686F, not #6B6B73: --ink-3 is the muted-label colour, and its worst
     case is 11px uppercase table headers sitting on --paper-2, where #6B6B73
     lands at 4.43:1 and misses AA. This is the smallest darkening that clears
     4.5:1 on all three light surfaces (paper-2 4.64, paper 5.07, card 5.53). */
  --ink-3:      #68686F;
  --paper:      #F4F5F7;
  --paper-2:    #EAEBEF;
  --paper-3:    #D6D6D6;   /* the brand tertiary */
  --card:       #FFFFFF;
  --line:       #E2E3E8;
  --line-2:     #C6C7CE;
  /* The boundary of a FORM CONTROL, kept separate from --line (which edges
     cards). WCAG 1.4.11 asks 3:1 of anything that identifies a UI component,
     and a white input inside a white card is identified by its border alone -
     --line gave that border 1.17:1 and --line-2 1.69:1. #8C8D96 is the
     lightest value that clears 3:1 on BOTH the card and the page. Decorative
     card edges are exempt and stay on --line. */
  --line-input: #8C8D96;

  /* Focus indicator. The old ring was the brand gold, which is 2.06:1 on the
     page and 2.25:1 on a card - a keyboard user could not see where they were.
     Gold cannot be rescued on a light surface, so focus takes the violet here
     (10:1) and the gold on the dark theme (9.5:1), where gold is the legible
     one. --focus-halo is the contrasting inner ring that keeps the indicator
     visible even when it lands on a mid-tone fill such as the gold button. */
  --focus:      #482C79;
  --focus-halo: #FFFFFF;

  /* Gold: the brand value, a shade dark enough to be readable as TEXT on a
     light surface (the brand gold on white is ~1.9:1 and fails outright), and
     two tints for fills. Never set white text on --gold; use --ink. */
  --gold:       #C1AB75;
  /* #7E6934, not #8A7338: as body text this has to clear --paper (#F4F5F7),
     where #8A7338 landed at 4.19:1. On white alone it passed, which is how it
     slipped through. The brand gold itself is ~1.9:1 on white - never text. */
  --gold-2:     #7E6934;
  --gold-3:     #EFE8D7;
  --gold-4:     #F8F5ED;
  /* The primary button's light-to-dark gradient stops. They straddle --gold
     rather than starting from it, so the button's average value still reads as
     the brand gold while the face is lit top-to-bottom. Both ends are bounded
     by readability, NOT by taste: dark ink (#1B1B1D) must clear 4.5:1 on the
     DARKEST stop, which is what caps -b/-b-hi. Pinned by test/contrast.test.js. */
  --gold-grad-a:    #D3C193;
  --gold-grad-b:    #AE9760;
  --gold-grad-a-hi: #DECEA6;
  --gold-grad-b-hi: #BCA671;

  /* Violet: the guideline's third secondary-graphic colourway, used as the
     informational accent only. */
  --violet:     #482C79;
  --violet-2:   #36205A;
  --violet-3:   #EDE7FA;

  /* Saturated semantic hues. These are deliberately vivid: on the previous
     palette a paid booking, a retrying payment and a dead one were three
     shades of the same beige, so state had to be read rather than seen. */
  /* Each hue has to work TWICE: as text on the page, and as a fill with text
     on top of it. Both pull the same way (darker), so one value can satisfy
     both - these are the lightest values that clear 4.5:1 on --paper AND
     under white. The previous set sat at 3.9-4.4:1 and missed AA in ~25
     places at once (markdown links, kpi trends, .btn-danger, pills).

     Amber is the exception: it cannot be both. White on --warn is 3.35:1 and
     dark ink on it is 5.13:1, so --warn stays a FILL/graphic hue (>=3:1, which
     is all WCAG 1.4.11 asks of a non-text object) and --warn-2 is the value to
     use whenever amber is TEXT. Same split as --gold / --gold-2. */
  --ok:         #0C7550;
  --ok-2:       #076B45;
  --warn:       #C97A04;
  --warn-2:     #8A5200;
  --bad:        #C4342A;
  --bad-2:      #A62318;
  --sky:        #246DA6;
  --sky-2:      #1B5E93;
  --info:       var(--violet);

  /* The foreground to place ON a semantic fill. Needed because the fills
     INVERT between themes: --ok is dark in light mode (so white text) and
     light in dark mode (so near-black text). Hard-coding #fff on a pill was
     fine on the light theme and dropped to 2.3:1 on the dark one. */
  --on-ok:      #FFFFFF;
  --on-bad:     #FFFFFF;
  --on-sky:     #FFFFFF;
  --on-warn:    #1B1B1D;
  --on-gold:    #1B1B1D;

  /* Legacy hue aliases. `--champagne` and `--teal` are bound by a lot of older
     templates; they now resolve to the brand gold and the semantic green so
     those views rebrand without edits. */
  --champagne:   var(--gold);
  --champagne-2: var(--gold-2);
  --teal:       var(--ok);
  --teal-2:     var(--ok-2);
  --saffron:    var(--warn);

  /* Brand-fixed tokens - DO NOT redefine in the dark-mode block. */
  --brand-ink:       #2B2B2D;
  --brand-ink-2:     #3A3A3D;
  --brand-ink-3:     #48484C;
  --brand-paper:     #FBFBFC;
  --brand-paper-2:   #E8E8EC;
  --brand-champagne: var(--gold);
  --brand-line:      rgba(255,255,255,0.10);

  /* Legacy palette aliases, re-pointed at the active tokens so older
     templates rebrand for free. */
  --color-stormcloud-ink: var(--ink);
  --color-deep-slate:     var(--ink-2);
  --color-slate-text:     var(--ink-2);
  --color-light-slate:    var(--ink-3);
  --color-ash-border:     var(--line);
  --color-page-background:var(--paper);
  --color-pure-white:     var(--card);

  /* The "sage" alias is what every legacy view binds its accent to. */
  --color-sage:        var(--champagne);
  --color-sage-dark:   var(--champagne-2);
  --color-sage-tint:   var(--paper-2);
  --color-sage-soft:   var(--paper-3);

  --color-mist:    var(--paper-3);
  --color-stone:   #DCDDE2;
  --color-pine:    var(--ok);
  --color-clay:    var(--gold);

  /* Semantic statuses. Each is a triple: a tinted fill, a visible edge, and a
     foreground dark enough to clear AA on that fill. The fills carry real
     chroma - the old pastels (#E2EBE3 / #F2E4C5 / #EFD9CF) sat within a few
     points of each other and of the page, so a row of badges read as one
     colour and the state had to be read off the label. */
  --color-ok-fg:   #076B45;  --color-ok-bg:   #E1F7EE;  --color-ok-line:  #9BE0C2;
  --color-warn-fg: #8A5200;  --color-warn-bg: #FDF1DA;  --color-warn-line:#F2CD8B;
  --color-err-fg:  #A62318;  --color-err-bg:  #FDE8E5;  --color-err-line: #F5B4AB;
  --color-info-fg: #3E2670;  --color-info-bg: var(--violet-3); --color-info-line: #C6B6E7;
  --color-sky-fg:  #1B5E93;  --color-sky-bg:  #E4F1FB;  --color-sky-line: #A8D2F0;
  --color-gold-fg: #75601F;  --color-gold-bg: var(--gold-3); --color-gold-line: #DCCB9F;

  /* ---- Typography ---- */
  /* Poppins is the guideline's declared primary typeface (page 16). Geist is
     kept as the first fallback so a Google Fonts outage degrades to the face
     the site used before rather than to a system UI font. */
  --font-display: 'Poppins', 'Geist', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --font-body:    'Poppins', 'Geist', 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --font-sans:    var(--font-body);
  --font-mono:    'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  --text-caption:    12px;
  --text-body-sm:    13px;
  --text-body:       14px;
  --text-subheading: 19px;
  --text-heading:    26px;
  --text-heading-lg: 32px;
  --text-display:    48px;
  --fw-regular:  400;
  --fw-medium:   500;
  --fw-semibold: 500;
  --fw-bold:     500; /* The design uses 500 as its "strong" weight throughout */

  /* ---- Spacing ---- */
  --s-8: 8px;   --s-16: 16px;  --s-24: 24px;
  --s-32: 32px; --s-40: 40px;  --s-48: 48px;  --s-64: 64px;

  /* Admin rhythm. Three steps, and the GAPS BETWEEN THE STEPS are the point:
     items inside one group sit at --gap-tile, a whole section is separated by
     --gap-section, which is more than double it. The old admin used 12px
     between tiles and 18px between sections - too close to tell apart, so a
     page of twelve cards read as twelve peers with no grouping. */
  --gap-tile:    14px;   /* between sibling cards in one group   */
  --gap-block:   20px;   /* between blocks inside one section    */
  --gap-section: 40px;   /* between named sections of a page     */
  --pad-card:    22px;   /* inside a panel / stat / chart card   */

  /* ---- Radii ---- */
  --r-input:  8px;
  --r-card:   10px;
  --r-md:     8px;
  --r-pill:   4px;
  --r-sm:     6px;
  --r-lg:     12px;
  --radius:   var(--r-input);
  --radius-lg:var(--r-card);

  /* ---- Shadows ----
     Neutral-gray now, not warm-ink: a brown-tinted shadow under a white card
     on a cool page reads as a smudge. --shadow-xs is the resting elevation
     that lets a white card sit off the page without needing a hard border. */
  --shadow-xs:     0 1px 2px rgba(27,27,29,.05), 0 1px 1px rgba(27,27,29,.04);
  --shadow-sm:     0 1px 3px rgba(27,27,29,.07), 0 1px 2px rgba(27,27,29,.04);
  --shadow-md:     0 4px 6px -2px rgba(27,27,29,.05), 0 12px 28px -10px rgba(27,27,29,.16);
  --shadow-card:   0 2px 4px -1px rgba(27,27,29,.05), 0 24px 56px -24px rgba(27,27,29,.20);
  --shadow-focus:  0 0 0 2px var(--focus-halo), 0 0 0 4px var(--focus);

  /* ---- Layout ---- */
  --maxw: 1240px;
  /* THE PAGE GUTTER, declared once. .container, .wrap and .nav-inner all put
     this between the edge of the screen and the first letter, and the hero
     derives its own inset from it - so the header wordmark, a section heading
     and the hero copy line up instead of agreeing by coincidence. Bumped at
     600px, which is where it always was.

     NOT `--gutter`: .prog-timeline already declares one of those on itself for
     its rail, and a page-level token by that name would be shadowed to 56px
     for everything inside it. */
  --page-gutter: 16px;
  /* WHERE THE PAGE'S TEXT ACTUALLY BEGINS: the gutter, plus whatever the
     1240px cap adds on a wider screen once .container centres itself. This is
     what a full-bleed element pads by to sit on the same line as the contained
     ones.

     `100%`, NOT `100vw`. Percentages resolve against the using element's
     containing block, which for every user of this token is the full page
     width - the same basis .container centres against. 100vw includes the
     scrollbar, so on a desktop with one visible it would sit the copy several
     pixels left of the heading it is supposed to match, at only some widths.

     `max()` keeps it at the plain gutter below 1240px, where the cap is not
     reached and the calc() would go negative. */
  --page-inset: max(var(--page-gutter), calc((100% - var(--maxw)) / 2 + var(--page-gutter)));
  --nav-h: 72px;
  --section-gap: 48px;

  /* Aliases for legacy class refs */
  --bg: var(--paper);
  --bg-alt: var(--paper-2);
  --bg-soft: var(--paper-3);
  --accent: var(--gold);
  --accent-2: var(--gold-2);
  --accent-tint: var(--gold-3);
  --warn-bg: var(--color-warn-bg);
  --ok-bg: var(--color-ok-bg);
  --err: var(--color-err-fg); --err-bg: var(--color-err-bg);
  --info-bg: var(--color-info-bg);
}

/* ---------- Reset / base ---------- */
* { box-sizing: border-box; }

/* The `hidden` ATTRIBUTE is only a USER-AGENT rule, and an author declaration
   beats the user-agent origin whatever its specificity - so ANY class rule
   setting `display` silently un-hides the element. `.summary .line { display:
   flex }` did exactly that to the checkout summary: the Extra nights, Airport
   transfer and Cots rows are written `<div ... hidden>` when their amount is
   zero, and all three rendered anyway, so a checkout with no add-ons showed
   "Airport transfer £0.00" (reported 2026-08-17, and the reason it was noticed
   then is that withdrawing the add-on made the £0.00 line a lie as well as
   noise).

   This had been patched TWELVE times one class at a time (.lightbox[hidden],
   .settings-save-hint[hidden], .cookie-banner[hidden], ...), which is the
   signal that the rule belongs here once. `!important` is what makes it hold:
   an author rule cannot otherwise outrank another author rule that comes later
   or scores higher, and `hidden` has to mean hidden everywhere or it means
   nothing anywhere. Nothing in this sheet re-shows a hidden element - the only
   other reference is `.mobile-menu:not([hidden])`, which styles the visible
   case. To show an element, REMOVE the attribute; never style over it. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }
/* overflow-x:hidden is only on body. Putting it on html as well makes html
   a scrolling container and breaks `position: sticky` on the page header
   in mobile Safari/Chrome (sticky element resolves to the html scroll
   container instead of the viewport, and never engages). */
body { overflow-x: hidden; }
body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--text-body);
  line-height: 1.5;
  letter-spacing: -0.005em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}
body > footer { margin-top: auto; }

img, svg { max-width: 100%; display: block; }
a { color: var(--ink); text-decoration: underline; text-decoration-thickness: 1px; text-underline-offset: 3px; }
a:hover { color: var(--champagne-2); }

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: var(--fw-medium);
  letter-spacing: -0.02em;
  line-height: 1.05;
  margin: 0 0 .5em;
  color: var(--ink);
  text-wrap: balance;
}
h1 { font-size: clamp(32px, 5vw, var(--text-display)); line-height: 1.05; letter-spacing: -0.025em; }
h2 { font-size: clamp(24px, 3vw, var(--text-heading-lg)); line-height: 1.1; }
h3 { font-size: var(--text-subheading); line-height: 1.25; }
h4 { font-size: var(--text-body); line-height: 1.43; }
p  { margin: 0 0 1em; color: var(--ink-2); line-height: 1.55; text-wrap: pretty; }
small, .muted { color: var(--ink-3); font-size: var(--text-body-sm); }
.text-soft { color: var(--ink-3); }
/* Money that reads as a deduction or an amount still owed. Already referenced
   by the financial report spec and the discount panels; it had no rule, so
   those figures were rendering in body colour. */
.text-warn { color: var(--bad, #9A5642); }

.mono { font-family: var(--font-mono); font-feature-settings: 'tnum' 1, 'zero' 1; }
.num  { font-feature-settings: 'tnum' 1, 'lnum' 1; }
.eyebrow {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: var(--text-caption);
  text-transform: uppercase;
  letter-spacing: 0.12em;
  color: var(--ink-3);
}
.divider { height: 1px; background: var(--line); width: 100%; }
.divider-dash {
  height: 1px; width: 100%;
  background-image: linear-gradient(to right, var(--line-2) 50%, transparent 0%);
  background-size: 8px 1px; background-repeat: repeat-x;
}

/* ---------- Accessibility ---------- */
.skip-link {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0);
  white-space: nowrap; border: 0;
}
.skip-link:focus {
  position: absolute; width: auto; height: auto;
  padding: 10px 16px; margin: 0;
  overflow: visible; clip: auto;
  top: 8px; left: 8px;
  background: var(--ink); color: var(--paper);
  border-radius: var(--r-md); z-index: 100;
  text-decoration: none; font-weight: var(--fw-medium);
}
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
}

.container, .wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 var(--page-gutter); width: 100%; }
.wrap { padding-top: var(--s-24); padding-bottom: var(--s-48); }
@media (min-width: 760px) { .wrap { padding-top: var(--s-40); padding-bottom: var(--s-64); } }

/* On desktop the wrap does NOT clip/scroll, so the PAGE (window) is the scroll
   container and the table header can stick to the top of the viewport (see
   `.table th` below). On mobile we switch this to overflow-x:auto so wide tables
   scroll sideways instead of cramming (overridden in the mobile block). */
.table-wrap { max-width: 100%; }
.table-wrap::-webkit-scrollbar { height: 10px; }
.table-wrap::-webkit-scrollbar-track { background: var(--paper); border-radius: 999px; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 999px; border: 2px solid var(--paper); }
.table-wrap::-webkit-scrollbar-thumb:hover { background: var(--ink-3); }
pre, code { overflow-wrap: anywhere; word-break: break-word; }
.pagination { display: flex; gap: 12px; align-items: center; justify-content: center; margin: 16px 0; }
.pagination .is-disabled { opacity: .45; pointer-events: none; cursor: default; }

/* ---------- Nav (top bar, fixed) ---------- */
.nav {
  position: sticky; top: 0; z-index: 30;
  background: rgba(244, 245, 247, 0.94);
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  border-bottom: 1px solid transparent;
  transition: border-color .2s ease, box-shadow .2s ease;
}
.nav.scrolled { border-bottom-color: var(--line); }
.nav-inner {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-16); padding: 0 var(--page-gutter);
  max-width: var(--maxw); margin: 0 auto;
  height: var(--nav-h);
}

@media (min-width: 600px) { :root { --page-gutter: 24px; } }

/* Brand lockup - champagne L-tile + serif-feeling sans wordmark */
.brand { display: inline-flex; align-items: center; gap: 10px; color: var(--ink); text-decoration: none; min-width: 0; }
.brand:hover { color: var(--ink); }
/* The mark is the LOGO, and it is deliberately most of the height of the bar:
   at 26px in a 72px bar it read as a favicon beside the wordmark rather than as
   the brand. 46px leaves 13px of air top and bottom, which is enough that it
   still sits ON the bar rather than filling it.
   No border-radius any more - the artwork is a crest on transparency, so a
   corner radius was rounding nothing but empty pixels. */
.brand .mark {
  width: 46px; height: 46px;
  background: url("/logo.png") center/contain no-repeat;
  flex-shrink: 0; transition: transform .25s cubic-bezier(.34, 1.56, .64, 1);
}
.brand:hover .mark { transform: rotate(-4deg) scale(1.04); }
/* The wordmark's own ellipsis had never once been reachable, and the bar got
   away with it until it was busy enough to collide.

   `text-overflow: ellipsis` needs `overflow: hidden` to APPLY, and overflow
   does not apply to an inline box - `strong` is inline by default, so those
   three declarations were inert and the lockup simply overran its neighbours
   instead of truncating. It also has to be ALLOWED to shrink: the wrapper span
   is a flex item of `.brand`, and a flex item's `min-width` defaults to `auto`,
   which floors it at its own content width.

   Both halves are required. Fixing one alone leaves the text overflowing (no
   block box to clip it) or the box refusing to narrow (no room to clip into).
   At 320px with the booking pill in the bar this rendered as "Light Upon Light"
   printed straight through the theme toggle and under the gold button. */
.brand > span:not(.mark) { display: block; min-width: 0; overflow: hidden; }
.brand strong { display: block; font-family: 'Telegraf', var(--font-display); font-weight: 800; letter-spacing: -0.015em; font-size: 17px; line-height: 1.1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.brand small { display: block; font-weight: var(--fw-regular); font-size: 11px; color: var(--ink-3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: -0.005em; }
/* Hide the brand subtitle whenever the mobile burger menu is showing, so the
   wordmark sits at the optical centre of the bar instead of being pushed up
   by the (stacked-below) subtitle. Breakpoint matches the .nav-burger
   media query at 760px. */
@media (max-width: 760px) { .brand small { display: none; } }

/* Below ~430px the bar carries the mark, the wordmark, the theme toggle, the
   booking pill and the burger, and the wordmark is the only elastic thing in
   it - so it is the one that gives. A CLIPPED name ("Light Upon Li...") is
   worse branding than no name, and nothing is actually lost by dropping it:
   the mark is the full lockup, with LIGHT UPON LIGHT GLOBAL set inside the
   artwork. The anchor's aria-label carries the name for anyone not looking at
   it. Above this width the wordmark fits beside the pill and stays.
   The mark takes the freed space so the brand still holds the corner. */
@media (max-width: 430px) {
  .brand strong { display: none; }
  .brand .mark { width: 40px; height: 40px; }
}

.nav-links { display: none; gap: 28px; align-items: center; }
.nav-links a {
  color: var(--ink); font-weight: var(--fw-regular); font-size: 13px;
  text-decoration: none; position: relative; opacity: 0.7;
  transition: opacity .15s ease;
}
.nav-links a:hover { opacity: 1; }
.nav-links a[aria-current="page"] { opacity: 1; font-weight: var(--fw-medium); }
.nav-cta { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
@media (min-width: 760px) { .nav-links { display: flex; } }

/* ---------- The booking call to action (partials/book-cta) ----------
   One gold control with three states: "Book a place", the same button carrying
   the party they searched for, or "Finish booking" once a room is held.

   THE BUTTON IS ONE LINE AT ONE HEIGHT, ALWAYS. The party used to be a second
   line inside it, which made it taller than every other button in the bar as
   soon as a party was known: a control that changes size according to what the
   visitor happens to have done reads as a different KIND of control, and it
   broke the row's shared baseline. The party is a note underneath instead.

   In the bar that note is positioned OUT OF FLOW, which is the whole point of
   the wrapper. In flow it would grow the wrapper, and `.nav-cta` centres its
   items, so the button would ride up against its neighbours - the same defect
   arriving by a different route. Out of flow, the row cannot move at all.
   The nav sets no `overflow`, so there is nothing to clip it.

   Deliberately NOT a second persistent surface. A sticky bottom bar or a
   floating action button is the reflex here and it is the wrong one: the nav is
   already position:fixed on hero pages, so a second always-on bar fixes the
   long page by charging every short one a permanent strip (the same trade the
   ticket composer was reverted for). And chrome on this site means "this is for
   sale" - a gold bar plus a gold pill plus the in-page room CTAs is three peak
   affordances competing, and the eye lands nowhere. */
.book-cta-wrap { position: relative; display: inline-flex; }

/* The note now sits on the BAR, not on the gold, so it takes the muted ink the
   brand subtitle opposite it already uses (--ink-3 is 5.07:1 on --paper light,
   5.58:1 dark - both clear AA for small text). Mirroring `.brand small` rather
   than inventing a second treatment is also why the dark-hero override below
   is the same value that rule uses. */
.nav-cta .book-cta-note {
  position: absolute; top: calc(100% + 2px); right: 0;
  font-size: 10.5px; line-height: 1.2; font-weight: var(--fw-regular);
  letter-spacing: -0.005em; white-space: nowrap;
  color: var(--ink-3); pointer-events: none;
}
.book-cta-note[hidden] { display: none; }

/* The phone-width shorthand ("Book · 2+1"). Hidden by default, so the desktop
   bar and the drawer both show the written-out label. */
.book-cta .book-cta-compact { display: none; }

/* In the bar on a phone, swap the label for the shorthand and drop the note:
   the 60px bar has no room under a 36px button, and the shorthand already
   carries the party. The button itself stays visible - it used to be
   hide-on-mobile, which put the site's primary action two taps deep in the
   burger on every page. The anchor carries an aria-label written out in full,
   so the compressed label is never the accessible name. */
@media (max-width: 760px) {
  .nav-cta .book-cta .book-cta-label { display: none; }
  .nav-cta .book-cta .book-cta-compact { display: inline; }
  .nav-cta .book-cta-note { display: none; }
}

/* The drawer is full width with room in flow, so its note is an ordinary line
   under the button. `.mobile-menu a.btn` already supplies the top margin. */
.book-cta-wrap-drawer { display: block; width: 100%; }
.book-cta-drawer { width: 100%; }
.book-cta-wrap-drawer .book-cta-note {
  display: block; text-align: center; margin-top: 5px;
  font-size: 12px; color: var(--ink-3);
}

/* ---------- BUTTONS ----------
   Three ranks, and they are now visually three:
     .btn-primary / .btn-pom / .btn-accent  gold, dark text  - THE action
     .btn-ink                               graphite         - a strong action
     .btn-soft / .btn-ghost                 outlined         - everything else

   .btn-primary and .btn-ink used to be one rule painting both the same
   near-black, so a page offering "Broadcast" and "Export" gave them identical
   weight and the eye could not find the primary action. Primary now carries
   the brand gold, which is also the only place most admin pages spend it.

   NEVER set white text on the gold: #C1AB75 under white is ~1.9:1 and fails
   outright. Dark ink on gold is ~8.7:1 and is what these rules use.
*/
.btn {
  --mx: 50%; --my: 50%;
  position: relative; isolation: isolate;
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 11px 18px;
  border-radius: var(--r-md);
  font-family: inherit; font-weight: var(--fw-medium);
  border: 1px solid transparent; cursor: pointer;
  font-size: 13.5px; line-height: 1.25;
  text-decoration: none; white-space: nowrap;
  overflow: hidden;
  letter-spacing: -0.005em;
  transition: background-color .18s ease, border-color .18s ease, color .18s ease,
              transform .15s cubic-bezier(.34, 1.56, .64, 1), box-shadow .2s ease;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { box-shadow: var(--shadow-focus); }
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none !important; }

.btn-ink {
  background: var(--brand-ink); color: var(--brand-paper);
}
.btn-ink:hover { background: var(--brand-ink-2); color: var(--brand-paper); text-decoration: none; }

/* THE primary action. Painted as a light-to-dark vertical gradient so the
   button reads as a raised, lit object rather than a flat swatch.

   The flat `background-color` is set SEPARATELY from the `background-image`
   gradient, and that split is deliberate rather than stylistic: the brand gold
   stays the declared colour of this button, so anything that reads the computed
   background-color (the contrast suite, a print stylesheet, a client that drops
   gradients) still resolves --gold and not `transparent`. Writing it as one
   `background:` shorthand would reset background-color to transparent and put
   dark ink on the page's own surface at ~1:1 wherever the image did not paint.

   Both gradient stops are chosen so DARK INK clears AA against them: #1B1B1D on
   the darkest stop (--gold-grad-b) is ~6.1:1, on the lightest ~9.7:1. Never
   lighten the top stop toward white or darken the bottom past the token below
   without re-running test/contrast.test.js - and NEVER put white text on any of
   them (see the rule above). */
.btn-primary, .btn-pom, .btn-accent {
  background-color: var(--gold); color: #1B1B1D;
  background-image: linear-gradient(180deg, var(--gold-grad-a) 0%, var(--gold-grad-b) 100%);
  box-shadow: var(--shadow-xs);
}
.btn-primary:hover, .btn-pom:hover, .btn-accent:hover {
  background-color: #CDBA8B; color: #1B1B1D; text-decoration: none;
  background-image: linear-gradient(180deg, var(--gold-grad-a-hi) 0%, var(--gold-grad-b-hi) 100%);
}

.btn-ghost {
  background: transparent; color: var(--ink); border: 1px solid var(--ink);
}
.btn-ghost:hover { background: var(--ink); color: var(--paper); text-decoration: none; }

/* A positive-outcome action ("Resolve"), the counterpart to .btn-danger. Tinted
   rather than solid green: it sits beside a solid primary and must not compete
   with it for the eye. */
.btn-ok {
  background: var(--color-ok-bg); color: var(--color-ok-fg);
  border: 1px solid var(--color-ok-line);
}
.btn-ok:hover { background: var(--ok); color: var(--on-ok); border-color: var(--ok); text-decoration: none; }

.btn-soft {
  background: var(--card); color: var(--ink); border: 1px solid var(--line);
}
.btn-soft:hover { background: var(--paper-2); border-color: var(--line-2); text-decoration: none; }

.btn-danger { background: var(--bad); color: var(--on-bad); }
.btn-danger:hover { background: var(--bad-2); color: var(--on-bad); text-decoration: none; }

.btn-block { width: 100%; }
.btn-sm { padding: 8px 14px; font-size: 12px; line-height: 1.25; }
.btn-lg { padding: 16px 26px; font-size: 15px; line-height: 1.25; }

.btn.is-pressed { animation: btn-bounce 320ms cubic-bezier(.34, 1.56, .64, 1); }
@keyframes btn-bounce { 0% { transform: scale(0.94); } 50% { transform: scale(1.03); } 100% { transform: scale(1); } }

/* ---------- Breadcrumbs ---------- */
.breadcrumbs { max-width: var(--maxw); margin: 0 auto; padding: var(--s-16) 16px 0; width: 100%; }
@media (min-width: 600px) { .breadcrumbs { padding-left: 24px; padding-right: 24px; } }
.breadcrumbs ol { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 4px; align-items: center; font-size: 12px; }
.breadcrumbs li { display: inline-flex; align-items: center; gap: 4px; }
.breadcrumbs li + li::before { content: "/"; color: var(--ink-3); margin: 0 4px; }
.breadcrumbs a { color: var(--ink-3); text-decoration: none; padding: 4px 0; transition: color .15s ease; }
.breadcrumbs a:hover { color: var(--ink); text-decoration: underline; }
.breadcrumbs [aria-current="page"] { color: var(--ink); font-weight: var(--fw-medium); }

/* ---------- HERO - dark champagne treatment (landing) ---------- */
/* The main marketing hero. Dark ink background, hero image slot, stat strip. */
.hero-dark {
  /* A flat #1B1B1D left the whole right-hand half of the ring hero as dead
     black: the "photo" disc is a dark circle on a dark field, so with no image
     uploaded there was nothing there to see. The warm falloff sits BEHIND the
     rings and gives them something to be silhouetted against. It is invisible
     under .hero-full, where the backdrop photo covers it. */
  background:
    radial-gradient(120% 100% at 82% 42%, rgba(193,171,117,0.17) 0%, rgba(193,171,117,0) 56%),
    radial-gradient(90% 80% at 6% 0%, rgba(255,255,255,0.05) 0%, transparent 60%),
    linear-gradient(160deg, #232327 0%, #17171A 58%, #101012 100%);
  color: var(--brand-paper);
  margin: 16px; border-radius: var(--r-card);
  overflow: hidden; position: relative;
  isolation: isolate;
}
.hero-dark::before {
  /* Sun-disc accent - a single restrained motif from the design. */
  content: ""; position: absolute;
  right: -120px; top: -120px;
  width: 280px; height: 280px; border-radius: 50%;
  background: var(--champagne); opacity: 0.6;
  z-index: 0;
}

/* Ring layer: a constellation of small solid + outline circles. Sits behind
   the title text (z-index 0) so the hero-dark-inner content reads through it.
   Faded out on narrow screens where the title needs the whole frame.

   There used to be a big photographic disc here (.hero-ring-photo) with a
   second ring framing it. Both are gone - see the note in views/user/home.ejs.
   Do not reintroduce a large circle on the right: the headline runs under it
   and a portrait centre-cropped into a disc is framed by the layout rather
   than by whoever chose the photograph. */
.hero-rings {
  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0; /* Safari < 14.1 lacks `inset` shorthand */
  inset: 0;
  pointer-events: none;
  overflow: hidden;
  z-index: 0;
}
.hero-ring {
  position: absolute;
  border-radius: 50%;
  display: block;
}
.hero-ring-accent {
  background: var(--champagne);
  opacity: 0.35;
}
.hero-ring-accent-a {
  /* Small champagne disc tucked low-right, near the photo's bottom curve. */
  right: clamp(80px, 18vw, 220px);
  bottom: -40px;
  width: 110px; height: 110px;
  opacity: 0.55;
}
.hero-ring-accent-b {
  /* Tiny floating accent above the stats strip. */
  right: clamp(220px, 28vw, 380px);
  top: clamp(40px, 8vw, 90px);
  width: 56px; height: 56px;
  background: var(--teal-2);
  opacity: 0.7;
}
.hero-ring-accent-c {
  /* Pinprick highlight off to the upper-left of the photo. */
  right: clamp(360px, 40vw, 520px);
  top: clamp(160px, 22vw, 260px);
  width: 22px; height: 22px;
  background: var(--brand-paper);
  opacity: 0.6;
}
.hero-ring-outline {
  background: transparent;
  border: 1px solid rgba(255,255,255,0.16);
}
.hero-ring-outline-b {
  /* Smaller ring drifting in from bottom-left. */
  left: clamp(-60px, -4vw, 0px);
  bottom: clamp(-100px, -8vw, -40px);
  width: clamp(140px, 18vw, 220px);
  height: clamp(140px, 18vw, 220px);
  border-color: rgba(193,171,117,0.4);
}

/* On narrow screens the rings would crowd the headline - fade them out and
   keep only a small accent in the corner. */
@media (max-width: 760px) {
  .hero-ring-accent-a { right: -30px; bottom: -50px; width: 130px; height: 130px; opacity: 0.4; }
  .hero-ring-accent-b,
  .hero-ring-accent-c,
  .hero-ring-outline-b { display: none; }
}
/* ---------- HERO - full-bleed photographic variant (.hero-full) ----------
   Applied only when a backdrop photo has been uploaded (Admin -> Content).
   Without one the hero keeps the ring treatment above, unchanged.

   Two stacked <img> layers inside .hero-media:
     .hero-layer-bg      the landscape - the FAR layer
     .hero-layer-figures a TRANSPARENT cut-out of the speakers - the NEAR layer

   public/js/parallax.js drives both by writing translate3d() on scroll. The
   layers are DELIBERATELY given the same box and object-fit, so the two images
   stay registered with each other: the overlay is meant to be a cut-out of the
   same composition at the same aspect ratio, and anything else will drift out
   of alignment as the viewport changes shape, at every width except the one it
   was eyeballed at.

   The 112%/-6% overscan is what stops the parallax exposing an edge. It is
   belt-and-braces rather than the actual guarantee - the script offsets by a
   FRACTION of the distance already scrolled, so the gap it opens is always
   above the part of the hero still on screen (see the note in that file). This
   covers sub-pixel rounding and elastic/rubber-band overscroll, which is not
   bounded by that argument. */
/* The hero's own left padding, declared ONCE and at the ROOT rather than on
   .hero-full, because a third thing now has to agree with it: the breadcrumb
   trail, which on a full-bleed hero floats over the photograph as a SIBLING of
   the hero and so cannot inherit a variable declared on the hero itself. A
   second copy of the clamp is exactly the drift the note below warns about.

   The root rather than `body.has-hero-full`, which would also reach the trail:
   --hero-text-col is a calc() over this value, and a calc() over an undefined
   variable is invalid at computed-value time - so a .hero-full rendered
   without the body class (they come from one source today, but that is a
   coincidence a future route can break) would lose the rule that keeps the
   figures out of the text column, and the failure is a group of people
   standing on the headline. Defined always, it cannot happen. */
:root { --hero-text-pad: clamp(24px, 4vw, 48px); }
.hero-full {
  margin: 0; border-radius: 0;
  /* The text column, declared ONCE because two rules have to agree about it:
     the copy is capped at it, and the figures layer is kept OUT of it (see the
     min-width: 901px block below). Written as two separate rules they drift,
     and the failure is a group of people standing on the headline.
     .hero-dark-inner now PADS BY this token rather than restating the same
     clamp, which is what that sentence used to ask someone to remember. */
  --hero-text-w: clamp(460px, 54vw, 620px);
  --hero-text-col: calc(var(--hero-text-pad) + var(--hero-text-w) + clamp(16px, 2vw, 32px));
}

/* A hero with NO figures layer gets a wider column, and the modifier is written
   by the template rather than derived with :has() - Firefox ESR 115 is still in
   the field without it, and there the page would silently keep the narrow one.
   The 620px cap exists to hold the copy clear of the home page's speaker
   cut-out (--hero-text-col is the other half of that agreement). The programme
   hero has no cut-out to clear, so on a 1920px screen it was a 620px ribbon
   using under a third of the width for no reason at all.

   The lede goes up to 18px with it, and measure is the reason rather than
   emphasis: widening the box alone would have taken the line past 100
   characters. Measured in headless Chrome against the real page, 880px of 18px
   Poppins is 76 characters, which is inside the comfortable 60-80 range the
   620px column was already in. The bump is scoped to the same 901px query as
   the column, so a phone keeps the 16px that is right for it. */
.hero-full.hero-copy-wide {
  --hero-text-w: clamp(460px, 64vw, 880px);
  /* AND IT STARTS WHERE THE REST OF THE PAGE STARTS. Full-bleed, this hero
     padded by a viewport-proportional clamp, so on a 1600px screen the copy
     began 48px from the edge while the header wordmark and every section
     heading below began at 204px - the hero was the one block on the page that
     did not line up with anything.

     Only the no-figures hero does this. On the home page --hero-text-col is
     derived from this same padding to reserve the right-hand column for the
     speaker cut-out, so insetting the copy there narrows the artwork by the
     same amount: a separate decision, with the picture to look at, not a side
     effect of this one. */
  --hero-text-pad: var(--page-inset);
}
@media (min-width: 901px) {
  .hero-dark.hero-full.hero-copy-wide .lede { font-size: 18px; }
}
/* The gold sun-disc is the ring hero's motif and reads as a smudge over a
   photograph. */
.hero-full::before { display: none; }
.hero-media {
  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0;
  inset: 0;
  overflow: hidden;
  z-index: 0;
  pointer-events: none;
}
.hero-layer {
  position: absolute;
  left: 0; width: 100%;
  top: -6%; height: 112%;
  object-fit: cover;
  object-position: center;
  /* Promoted so the per-frame transform never triggers layout or paint. */
  will-change: transform;
}
.hero-layer-figures {
  /* CONTAIN, not cover. The background is a backdrop and may be cropped freely;
     the cut-out is a group of PEOPLE and may not. `cover` scales the shorter
     axis to fill, so a 16:9 overlay in a wider hero is blown up until the heads
     are cropped off the top - which is exactly how it shipped. `contain` fits
     the whole figure group inside the frame at its own aspect ratio.
     Consequence, stated so it is not a surprise: the two layers no longer share
     a scale, so the overlay must be a cut-out that reads on its own rather than
     one that has to register pixel-for-pixel with the backdrop. */
  object-fit: contain;
  /* The overlay is a FULL-FRAME composition, not a tight cut-out: the designer
     positioned the figures within a frame that matches the backdrop's, and the
     handwritten names sit under them in that same frame. So the job here is to
     reproduce that frame, not to place the people - they are already placed.

     `contain` fits the whole frame with nothing cropped, and CENTRING it (top
     and bottom, left and right) is what makes the figures land exactly where
     they were drawn relative to the backdrop.

     This is why the previous version was wrong twice over. `cover` scaled the
     frame until it filled a wider hero and cropped the heads off the top;
     bottom-anchoring it and shrinking it then dropped the whole composition
     into the corner, because it moved the FRAME while the people stayed put
     inside it.

     --fig-scale shrinks the frame about its own centre for a tight cut-out that
     genuinely does need placing; at the default 100 it is a no-op. */
  top: calc((100% - (var(--fig-scale, 100) * 1%)) / 2);
  height: calc(var(--fig-scale, 100) * 1%);
  object-position: var(--fig-align, center) center;
  z-index: 1;
}

/* The readability scrim. Dark down the LEFT (where the headline, lede and
   buttons are) and clear to nothing on the right, so the cut-out speakers stay
   at full brightness instead of being greyed by the same wash that makes the
   text legible. The bottom stop is the heaviest: the stat strip is small text
   and sits over whatever the photo happens to be doing down there. */
.hero-full .hero-dark-inner {
  /* The full viewport. `dvh` rather than `vh` so a mobile browser's collapsing
     address bar does not leave the stat strip cut in half - `vh` is measured
     against the LARGEST viewport and is wrong for the whole first scroll. */
  min-height: 100dvh;
  /* Clears the nav, which is lifted out of flow and floated over this hero,
     plus whatever drop this particular hero asks for. The drop is ADDED rather
     than replacing the clearance: a rewritten value here silently drops it and
     the copy lands under the bar. The stat strip keeps its own place - it is
     `margin-top: auto` in a flex column - so this moves the copy down without
     moving the strip up. */
  padding-top: calc(var(--nav-h) + clamp(28px, 5vw, 56px) + var(--hero-copy-drop, 0px));
  /* NO SCRIM. The photograph is shown exactly as it was uploaded - no wash, no
     tint, nothing laid over it at any width. The washes that used to sit here
     (a vertical one over the whole hero and a horizontal one over the text
     column) greyed the picture and the cut-out speakers along with it, which
     is the look this hero was rebuilt to get rid of.

     The readability job therefore moves ENTIRELY onto the copy itself, as
     text-shadow further down (`.hero-full .hero-dark-inner` children). A
     shadow darkens only the few pixels around each glyph, so the text stays
     legible over a bright photograph while the photograph itself is untouched
     - which a translucent panel, however light, cannot do.

     `none` is written explicitly because `.hero-dark-inner` further down sets
     two gradients of its own for the RING hero; without this the full-bleed
     variant would inherit them. */
  background: none;
}

/* The figures may not enter the text column, at any width.
   -------------------------------------------------------
   `contain` sizes the overlay against its BOX, and the box was the whole hero
   - so on a wide screen the frame was scaled up until it filled the height and
   the group spilled left across the headline. Live at 1440x900 the frame
   rendered 1180px wide from x=260 with the people starting at x=422, and the
   headline runs 48..668: about 250px of overlap, on every visit.

   Shrinking the overlay with --fig-scale only moves that collision to a
   different viewport, because the two are in different units - the scale is a
   fraction of the hero's HEIGHT and the overlap is decided by its WIDTH. So
   the fix is to take the text column away from the layer's box instead: the
   frame then fits the space that is actually free, which shrinks it as a
   consequence rather than as a setting, and no upload of any aspect ratio can
   reach the copy.

   --fig-scale keeps its meaning and is still the knob for a client who wants
   the group smaller again; --fig-align now places the frame within the free
   column rather than within the whole hero, which is the only reading of it
   that can be honoured once the column is reserved.

   Below 901px the text spans the full width, so there is no free column to
   give: the narrow-viewport rules further down fade the figures back instead. */
@media (min-width: 901px) {
  .hero-layer-figures {
    left: auto;
    right: 0;
    width: calc(100% - var(--hero-text-col));
  }
  /* The lede takes the hero's own text column, and this is the SAME cascade
     trap the text-shadow block below sets out. `.hero-full .hero-dark-inner >
     *` hands every child --hero-text-w, and `.hero-dark .lede` declares
     `max-width: 480px` LATER in this file at equal specificity - so the ribbon
     won and the paragraph was pinned to 480px at every width while the
     headline beside it spread to 620px. Measured before the fix: 480px and 12
     lines at 1440px AND at 1920px, growing the programme hero to 1099px tall.
     Doubling the class out-specifies it, as it does there.

     Two bounds are deliberate. It is not wider than --hero-text-w, because
     that token IS the column and --hero-text-col reserves the rest of the hero
     for the home page's speaker cut-out - a lede past it runs under the
     figures. And it lives INSIDE this query, because below 901px the copy runs
     full width by design (`max-width: none`, further down) and a rule this
     specific outside the query would silently re-narrow it on a tablet. */
  .hero-dark.hero-full .lede { max-width: var(--hero-text-w); }
}
.hero-full .hero-dark-inner > * { max-width: var(--hero-text-w); }
.hero-full .hero-stats { max-width: none; }

/* The whole of the hero copy's legibility, now that nothing darkens the photo.
   -------------------------------------------------------------------------
   A scrim and a text-shadow buy the same contrast in two different currencies:
   the scrim pays for it with the PICTURE (every pixel of it, including the
   speakers), the shadow pays for it with a few pixels around each glyph. With
   the washes gone these are deliberately heavier than the ring hero's, and
   they are DOUBLED - a tight, near-opaque shadow that separates the letterform
   from whatever is immediately behind it, plus a wide soft one that lifts the
   local area enough that a bright, busy patch of photograph does not swallow
   the text. One shadow cannot do both jobs: tight alone disappears over a
   high-frequency background, wide alone reads as a grey smear.

   Small text gets the most, not the least: the eyebrow, the stat labels and
   the speaker line are the first things to become unreadable over a
   photograph, and the headline is the last.

   EVERY selector here is written `.hero-dark.hero-full`, and that is
   load-bearing rather than tidy. `.hero-dark h1` and `.hero-dark .lede` are
   declared further down this file with text-shadows of their own, at exactly
   the same specificity as a bare `.hero-full h1` would have - so the later
   rule wins and the heavier shadow is silently dead, on a hero that no longer
   has a scrim to fall back on. The section carries both classes, so doubling
   up out-specifies them without a media query, an !important or a reorder.
   (The same cascade trap the mobile drawer shipped with: every token correct,
   and the wrong rule winning.) */
.hero-dark.hero-full .eyebrow,
.hero-dark.hero-full .hero-speakers,
.hero-dark.hero-full .hero-stats span,
.hero-dark.hero-full .hero-speakers-label {
  text-shadow: 0 1px 2px rgba(0,0,0,0.9), 0 2px 16px rgba(0,0,0,0.75);
}
.hero-dark.hero-full .lede,
.hero-dark.hero-full .hero-stats strong {
  text-shadow: 0 1px 3px rgba(0,0,0,0.85), 0 2px 20px rgba(0,0,0,0.7);
  /* AND FULL WHITE, the same ink as the h1 above it. `.hero-dark .lede` is
     `rgba(255,255,255,0.88)`, which was tuned when this hero had a scrim washed
     over the picture; with the photograph shown as uploaded, 12% of a sunset
     comes through every letter of the body copy. The headline never took that
     discount and is legible over the same photograph, which is the measurement
     that matters. Scoped to .hero-full: a hero with no picture behind it keeps
     the softer white, where it reads as hierarchy rather than as haze. */
  color: var(--brand-paper);
}
.hero-dark.hero-full h1 {
  text-shadow: 0 2px 4px rgba(0,0,0,0.8), 0 4px 28px rgba(0,0,0,0.65);
}
/* The two hero buttons sit on the photograph as well. The primary is a solid
   fill and needs nothing; the glass one is a translucent panel whose border
   was reading as a faint scratch over a light patch of picture. */
.hero-dark.hero-full .hero-cta .btn-glass {
  background: rgba(20,20,22,0.42);
  border-color: rgba(255,255,255,0.55);
}
.hero-dark.hero-full .hero-cta .btn-glass:hover { background: rgba(20,20,22,0.55); }

/* The landing page drops both the copy and the speakers a little below centre.
   ---------------------------------------------------------------------------
   Home only (`.hero-home`, written by the view): the programme page shares
   `.hero-full` and its banner has no figures to move.

   TWO KNOBS, ONE FOR EACH THING, because they move for different reasons - the
   copy is nudged clear of the floating nav, the artwork is placed against the
   composition somebody uploaded. A single shared value would tie a content
   decision to a chrome one.

   THE ARTWORK IS MOVED WITH `top`, NEVER `transform`. parallax.js writes
   `translate3d()` onto these layers on every scroll frame, so a transform here
   is overwritten the first time anybody scrolls - it would look right until it
   silently stopped applying.

   THE HEIGHT IS REDUCED BY THE SAME AMOUNT, and that is the part worth
   explaining. The layer is `object-fit: contain`, so the image is fitted to its
   BOX; `.hero-media` is `overflow: hidden`, so a box pushed past the bottom of
   the hero has that much of the fitted image CUT OFF - and the bottom is where
   the handwritten names sit in this artwork. Shrinking the box by the drop
   keeps its bottom edge exactly where it was, so nothing can ever be clipped.
   The cost is that `contain` then scales the artwork down by the same
   fraction, which at 3% is not visible; the alternative loses the names. */
.hero-home.hero-full {
  --fig-drop: 3%;
  --hero-copy-drop: clamp(12px, 3vh, 40px);
}
.hero-home .hero-layer-figures {
  top: calc(((100% - (var(--fig-scale, 100) * 1%)) / 2) + var(--fig-drop, 0%));
  height: calc((var(--fig-scale, 100) * 1%) - var(--fig-drop, 0%));
}
/* Every full-bleed hero that is NOT the landing page - in practice the
   programme. `:not(.hero-home)` rather than a class of its own because the
   distinction being drawn is real: home is the hero with the cut-out figures
   and the stat strip, and its drop is small precisely so neither collides with
   the copy. The programme hero has the frame to itself, so its copy sits lower
   and reads larger.

   The type sizes are written `.hero-dark.hero-full:not(.hero-home)` for the
   cascade reason the shadow block above sets out: `.hero-dark h1` and
   `.hero-dark .lede` declare font sizes LATER in this file, so a selector that
   does not out-specify them is silently dead. */
.hero-full:not(.hero-home) { --hero-copy-drop: clamp(40px, 10vh, 140px); }
.hero-dark.hero-full:not(.hero-home) h1 { font-size: clamp(48px, 9vw, 108px); }
.hero-dark.hero-full:not(.hero-home) .lede { font-size: clamp(17px, 1.45vw, 21px); }
.hero-dark.hero-full:not(.hero-home) .eyebrow { font-size: 13px; }

/* ---------- HERO - the POSTER variant (.hero-poster) ----------
   Set when the uploaded artwork already carries the title, the dates and the
   speakers, lettered into the picture by a designer (Admin -> Content).

   It is a different SHAPE of hero, not a restyling of this one, and the two
   differences below are both consequences of one fact - the picture contains
   words:

   1. IT MAY NOT BE CROPPED. Every other hero here is `cover`-fitted to a
      100dvh frame, which is right for a photograph and fatal for a poster: at
      any viewport whose ratio is not the artwork's, `cover` takes the
      overflowing axis off the edges, and on this artwork that is the dates
      down the bottom and the flag off the side. So the layer goes back into
      normal flow at `height: auto` and the section's height becomes the
      picture's own - which is also why parallax is not emitted in poster mode
      (it works by overscanning a CROPPED layer, and there is no overscan here
      to hide the edge it would expose).

   2. NOTHING IS WRITTEN OVER IT. The headline, the lede, the speaker line and
      the stat strip all say what the artwork is already saying, in a second
      typeface, on top of the letters the designer set. They stay in the
      DOCUMENT and stop being PAINTED - see the visually-hidden rule below.

   The narrow-viewport crop is an ordinary <picture> source, so the phone gets
   the portrait artwork rather than a letterboxed strip of the wide one.

   Home only in practice (the view writes .hero-poster on .hero-home), but the
   selectors are written on .hero-poster alone: the rule is a property of the
   ARTWORK, and the programme banner would want exactly this if its banner ever
   had a title lettered into it. */

/* The <picture> wrapper must add no box of its own: .hero-layer is declared on
   the <img> INSIDE it, and an inline wrapper between that and .hero-media
   would break the absolute positioning the non-poster hero still relies on. */
.hero-picture { display: contents; }

.hero-poster .hero-media {
  /* In flow, so the section is as tall as the artwork. `overflow: visible`
     because there is no longer an overscanned layer to clip - and clipping a
     box that exactly fits its image can only ever shave a rounding pixel off
     the bottom. */
  position: relative;
  overflow: visible;
}
.hero-poster .hero-layer-bg {
  position: static;
  top: auto; left: auto;
  display: block;
  width: 100%; height: auto;
  /* The promotion exists for the per-frame parallax transform, which poster
     mode does not emit. Left on, it holds a full-width bitmap on its own
     compositor layer for nothing. */
  will-change: auto;
}

/* The strip UNDER the picture, holding the one button.
   The button is deliberately not floated over the artwork: the free space in
   the composition moves with the crop and with the viewport, so an overlaid
   control is one breakpoint away from sitting on the lettering - and this hero
   has no scrim to lift it off a bright patch. Under the picture it is correct
   at every width, and the dark ground it sits on is the hero's own. */
.hero-poster .hero-dark-inner {
  min-height: 0;
  padding-top: clamp(18px, 2.6vw, 30px);
  padding-bottom: clamp(22px, 3.2vw, 38px);
  text-align: center;
}
.hero-poster .hero-cta {
  justify-content: center;
  max-width: none;
}

/* The copy is HIDDEN, not removed.
   The artwork's lettering is a bitmap, so this markup is the only <h1> on the
   page, the only machine-readable date range and the only list of who is
   speaking that a screen reader, a search engine or a translation can reach.
   `display: none` would take it from all three; these are the .sr-only
   declarations, restated because the class cannot be applied from the template
   - whether the copy is painted is a SETTING, and a template that hard-coded
   it would be a second answer to the same question. */
.hero-poster .eyebrow,
.hero-poster h1,
.hero-poster .lede,
.hero-poster .hero-speakers,
.hero-poster .hero-stats {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* Featured speakers, as text. The artwork letters the names over the photo,
   which no screen reader, search engine or translation can reach - so they are
   restated here and this is the only copy most machines will ever see. */
.hero-speakers {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px;
  margin: 0; font-size: 14px; color: rgba(255,255,255,0.9);
  text-shadow: 0 1px 12px rgba(0,0,0,0.5);
}
.hero-speakers-label {
  font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
  color: rgba(255,255,255,0.62);
}
.hero-speaker { font-weight: var(--fw-medium); }
.hero-speaker-sep { color: rgba(255,255,255,0.4); }

/* The speakers cut-out, IN FLOW on narrow viewports.
   -------------------------------------------------
   Above 900px the artwork is the .hero-layer-figures overlay, kept out of the
   text column by the rule further up. Below it there IS no free column - the
   copy runs the full width - so an overlay can only ever sit BEHIND the
   headline and the lede, whatever it is scaled or aligned to. The markup
   therefore carries a second, ordinary <img> inside .hero-dark-inner, between
   the lede and the Featuring line, and the two swap at this breakpoint:
   exactly one of them is ever displayed. Same src, so it is one fetch.

   In flow it cannot overlap anything, and the band it occupies is the gap the
   figures need. Capped by height rather than width because the artwork is a
   full-frame composition with the handwritten names along its bottom edge -
   `contain` keeps all of it, and a vh cap is what stops it pushing the buttons
   and the stat strip off a short phone screen. */
.hero-figures-inline { display: none; }

@media (max-width: 900px) {
  /* Narrow viewports have no room for a column of text beside a group of
     people, so the copy runs the full width. The figures move INTO the flow
     (see .hero-figures-inline above) rather than sitting behind the text. The
     photograph is still not darkened - the deepened text-shadow is what
     carries legibility here, for the same reason it does on desktop. */
  .hero-full .hero-dark-inner { background: none; }
  .hero-full .hero-dark-inner > * { max-width: none; }
  .hero-layer-figures { display: none; }
  .hero-figures-inline {
    display: block;
    width: 100%;
    max-height: 34vh;
    object-fit: contain;
    object-position: center;
    /* Nudged out of the flex `gap` a little: the artwork carries its own
       whitespace, so the default 22px on both sides reads as a hole. */
    margin: 4px 0 2px;
  }
}

.hero-dark-inner {
  position: relative; z-index: 1;
  padding: clamp(32px, 6vw, 64px) var(--hero-text-pad);
  min-height: 540px;
  display: flex; flex-direction: column; gap: 22px;
  background:
    linear-gradient(180deg, rgba(23,23,25,0.55) 0%, rgba(23,23,25,0.25) 40%, rgba(23,23,25,0.85) 100%),
    linear-gradient(90deg, rgba(23,23,25,0.65) 0%, rgba(23,23,25,0.10) 50%, rgba(23,23,25,0.30) 100%);
}
.hero-dark .eyebrow { color: rgba(255,255,255,0.75); letter-spacing: 0.14em; }
.hero-dark h1 {
  color: var(--brand-paper);
  font-size: clamp(40px, 7vw, 84px);
  font-weight: var(--fw-medium); letter-spacing: -0.03em;
  line-height: 1;
  text-shadow: 0 2px 24px rgba(0,0,0,0.4);
  max-width: 780px;
}
.hero-dark .lede {
  color: rgba(255,255,255,0.88);
  font-size: 16px; line-height: 1.55;
  max-width: 480px;
  text-shadow: 0 1px 12px rgba(0,0,0,0.4);
}
.hero-cta { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
.hero-cta .btn-glass {
  background: rgba(255,255,255,0.1); color: var(--brand-paper);
  border: 1px solid rgba(255,255,255,0.4);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
}
.hero-cta .btn-glass:hover { background: rgba(255,255,255,0.18); color: var(--brand-paper); }

/* Stat strip across the bottom of the hero.
   The columns are AUTO, not a fixed repeat(4): the tiles are admin-editable and
   a blank one is dropped by the view, so a four-column template would hold a
   quarter of the row open for a tile that is not there. grid-auto-flow: column
   sizes the row to however many survive.

   The rules (above the strip, between the tiles) hold the RING hero's shape and
   are dropped on the full-bleed photo variant, where they read as chrome laid
   over the photograph - see the .hero-full overrides further down. */
.hero-stats {
  margin-top: auto;
  padding-top: 18px;
  border-top: 1px solid rgba(255,255,255,0.18);
  /* Spread across the WHOLE strip, not equal columns. Equal 1fr columns size to
     the number of tiles, so with one dropped the survivors bunch to the left and
     leave a column and a half of empty hero after the last one - the strip stops
     reading as a row spanning the hero and starts reading as a mistake. Spacing
     them apart keeps the first flush left and the last flush right at any count.
     Flex rather than grid with auto columns: a long label then shrinks inside
     its tile instead of overflowing the hero. */
  display: flex; justify-content: space-between; gap: 0;
}
.hero-stats > div {
  padding: 0 16px;
  border-left: 1px solid rgba(255,255,255,0.18);
  flex: 0 1 auto; min-width: 0;
}
.hero-stats > div:first-child { border-left: 0; padding-left: 0; }
.hero-stats > div:last-child { padding-right: 0; }
.hero-stats strong {
  display: block; font-family: var(--font-display);
  font-size: 24px; letter-spacing: -0.02em; font-weight: var(--fw-medium);
}
.hero-stats span {
  display: block; font-size: 12px;
  color: rgba(255,255,255,0.7); margin-top: 4px;
}
@media (max-width: 760px) {
  /* Back to rows of two. `display: grid` has to be restated as well as the
     template: the strip is a FLEX row above this breakpoint (see .hero-stats),
     so without it the template is never consulted and the tiles stay on one
     line, squeezed to nothing. */
  .hero-stats {
    display: grid; grid-auto-flow: row; grid-template-columns: repeat(2, 1fr); gap: 18px 16px;
  }
  .hero-stats > div:nth-child(odd) { border-left: 0; padding-left: 0; }
}

/* Legacy hero (used by search-card on /rooms) keeps a paper variant */
.hero { padding: var(--s-32) 0 var(--s-32); background: var(--paper); }
.hero .lede { font-size: 18px; max-width: 640px; color: var(--ink-3); margin-bottom: 8px; line-height: 1.45; }
.hero .label {
  display: inline-block; padding: 4px 10px;
  background: var(--paper-2); color: var(--ink-2);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  font-size: 10.5px; font-weight: var(--fw-medium);
  text-transform: uppercase; letter-spacing: 0.08em;
  margin-bottom: var(--s-24);
}
.hero-meta { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px 24px; margin-top: var(--s-40); max-width: 720px; }
.hero-meta > div {
  font-size: 12px; color: var(--ink-3);
  border-top: 1px solid var(--line); padding-top: 12px;
}
.hero-meta strong {
  display: block; font-family: var(--font-display);
  font-size: 22px; color: var(--ink);
  font-weight: var(--fw-medium); letter-spacing: -0.02em;
  margin-bottom: 2px;
}
@media (min-width: 760px) { .hero-meta { grid-template-columns: repeat(4, 1fr); } }

/* ---------- Search card ---------- */
.search-card {
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-card); padding: var(--s-16);
  margin-top: var(--s-32);
  box-shadow: var(--shadow-sm);
  display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-16); align-items: end;
}
@media (min-width: 760px) {
  .search-card { grid-template-columns: 1fr 1fr 1fr 1fr auto; padding: var(--s-24); gap: var(--s-16); }
}
.search-card label, .search-card .search-label {
  font-size: 11px; color: var(--ink-3);
  text-transform: uppercase; letter-spacing: 0.06em;
  font-weight: var(--fw-medium); display: block; margin-bottom: 6px;
}
/* Fixed retreat dates: shown as plain, non-interactive text (not a date input)
   so they read as a stated fact, never as an editable / clickable control.
   No border, no cursor pointer, no focus ring, no hover affordance. */
.search-card .search-static {
  margin: 0; padding: 12px 0;
  font: inherit; color: var(--ink); font-weight: var(--fw-medium);
  background: transparent; border: 0;
  cursor: default; user-select: none; pointer-events: none;
}
.search-card input, .search-card select {
  width: 100%; padding: 12px 14px;
  border: 1px solid var(--line); border-radius: var(--r-input);
  font: inherit; background: var(--card); color: var(--ink);
  transition: border-color .15s ease, box-shadow .15s ease;
}
.search-card input:focus, .search-card select:focus { outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus); }
.search-card button { grid-column: 1/-1; }
@media (min-width: 760px) { .search-card button { grid-column: auto; } }

/* ---------- Sections ---------- */
.section { padding: var(--s-48) 0; }
@media (min-width: 760px) { .section { padding: var(--s-64) 0; } }
.section.alt { background: var(--paper-2); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }
.section h2 { margin-bottom: 10px; }
.section .lede { color: var(--ink-3); margin-bottom: var(--s-32); max-width: 640px; font-size: 18px; line-height: 1.45; }

/* ---------- Cards ---------- */
.cards { display: grid; grid-template-columns: 1fr; gap: var(--s-24); }
@media (min-width: 600px) { .cards { grid-template-columns: 1fr 1fr; } }
@media (min-width: 960px) { .cards { grid-template-columns: 1fr 1fr 1fr; } }
@media (min-width: 960px) { .cards.two  { grid-template-columns: 1fr 1fr; } }
@media (min-width: 960px) { .cards.four { grid-template-columns: repeat(4, 1fr); } }

.card {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-card); overflow: hidden;
  box-shadow: var(--shadow-sm);
  transition: box-shadow .25s ease, transform .25s cubic-bezier(.34, 1.2, .64, 1), border-color .15s ease;
  text-decoration: none; color: inherit; display: block;
}
/* Only anchor cards are clickable; static div/form/details panels keep flat. */
a.card:hover { box-shadow: var(--shadow-md); transform: translateY(-2px); border-color: var(--line-2); }
a.card:active { transform: translateY(0) scale(0.997); transition-duration: .1s; }
.card-img-wrap { aspect-ratio: 4/3; overflow: hidden; background: var(--paper-3); position: relative; }
.card-img-wrap::after {
  /* faint diagonal hatching - matches the design's placeholder ribbon */
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background-image: repeating-linear-gradient(135deg, rgba(20,18,16,.04) 0 1px, transparent 1px 14px);
}
.card-img-wrap img { width: 100%; height: 100%; object-fit: cover; transition: transform .55s cubic-bezier(.22, .61, .36, 1); position: relative; z-index: 1; }
a.card:hover .card-img-wrap img { transform: scale(1.04); }
/* Gallery video clips fill the same box as a photo. `contain`, not `cover`:
   a clip that is cropped hides the part of the room it was shot to show, and
   unlike a still there is no lightbox to open the whole frame in. Raised above
   the ::after hatch so the native controls are clickable. */
.card-img-wrap video { width: 100%; height: 100%; object-fit: contain; background: #000; position: relative; z-index: 2; display: block; }
.card-body { padding: var(--s-16) var(--s-24) var(--s-24); }
.card-body h3 { margin: 0 0 6px; font-weight: var(--fw-medium); font-size: var(--text-subheading); color: var(--ink); }
.card-body .sub { font-size: 13px; color: var(--ink-3); margin-bottom: 14px; line-height: 1.5; }
.card-meta { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-wrap: wrap; }
.card-price { font-family: var(--font-display); font-weight: var(--fw-medium); font-size: var(--text-subheading); color: var(--ink); }
.card-price small { font-weight: var(--fw-regular); font-size: 11px; color: var(--ink-3); display: block; margin-top: 2px; }

/* ---------- Room listing cards (/rooms) ---------- */
.room-cards { gap: var(--s-24); }
.room-card { display: flex; flex-direction: column; }
.room-card .card-img-wrap { aspect-ratio: 3/2; }
/* Availability / bundle badges float over the photo, top-left. */
.room-card-badges { position: absolute; z-index: 2; top: 12px; left: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
.room-card-badges .pill { box-shadow: 0 1px 3px rgba(27,27,29,.18); backdrop-filter: saturate(1.1); }
.room-card-body { display: flex; flex-direction: column; flex: 1; padding: var(--s-16) var(--s-24) var(--s-24); }
.room-card-title { margin: 0 0 4px; font-size: 21px; font-weight: var(--fw-medium); letter-spacing: -0.015em; line-height: 1.2; }
.room-card-sub { margin: 0; color: var(--ink-3); font-size: 13px; line-height: 1.5; }
.room-card-foot { margin-top: auto; padding-top: var(--s-16); }
.room-card-meta {
  display: flex; align-items: flex-end; justify-content: space-between; gap: 12px;
  padding-top: 14px; border-top: 1px solid var(--line);
}
.room-card-sleeps { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--ink-3); }
.room-card-sleeps svg { color: var(--ink-3); opacity: .8; }
.room-card-price { text-align: right; line-height: 1; }
.room-card-price small { display: block; font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 3px; }
.room-card-price strong { font-family: var(--font-display); font-size: 22px; font-weight: var(--fw-medium); letter-spacing: -0.02em; color: var(--ink); }
.room-card-cta {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 14px;
  font-size: 13px; font-weight: var(--fw-medium); color: var(--teal-2);
}
.room-card-cta span { transition: transform .25s cubic-bezier(.22,.61,.36,1); }
a.room-card:hover .room-card-cta span { transform: translateX(3px); }

/* ---------- Results list - Booking.com pattern room cards ---------- */
.variation {
  display: grid; grid-template-columns: 1fr;
  background: var(--card);
  border: 1.5px solid var(--line);
  border-radius: var(--r-card);
  overflow: hidden;
  margin-bottom: var(--s-16);
  transition: border-color .15s ease, box-shadow .2s ease;
}
.variation:hover { border-color: var(--line-2); box-shadow: var(--shadow-md); }
@media (min-width: 760px) {
  .variation { grid-template-columns: 200px 1fr 220px; align-items: stretch; }
}
.variation .v-img {
  position: relative;
  aspect-ratio: 4/3; background: var(--paper-3); overflow: hidden;
}
.variation .v-img::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background-image: repeating-linear-gradient(135deg, rgba(20,18,16,.04) 0 1px, transparent 1px 14px);
}
.variation .v-img img { width: 100%; height: 100%; object-fit: cover; position: relative; z-index: 1; }
@media (min-width: 760px) { .variation .v-img { aspect-ratio: auto; min-height: 180px; } }
.variation > div:nth-child(2) { padding: 18px; display: flex; flex-direction: column; gap: 8px; }
.variation h3 { margin: 0 0 2px; font-weight: var(--fw-medium); font-size: var(--text-subheading); }
.variation .v-sub { color: var(--ink-3); font-size: 13px; }
/* 'Superior Room (Forest View)' set as a small gold category over the view.
   The all-caps `short_pitch` line that used to sit here went with the three
   pills below it: it restated the same facts the chips now state properly. */
.variation .v-cat {
  margin: 0; font-size: 11px; font-weight: var(--fw-medium);
  letter-spacing: 0.12em; text-transform: uppercase; color: var(--gold-2);
}
.variation .v-name { margin: 0; font-size: 24px; letter-spacing: -0.015em; }
.variation .v-name a { color: inherit; text-decoration: none; }
.variation .v-name a:hover { color: var(--gold-2); }
/* The feature chips. They replaced "2 ADULTS / FULL BOARD / 4 NIGHTS", which
   was on every card restating the heading above the list - so what is left is
   what actually DIFFERS between these rooms. Set as quiet inline facts rather
   than as pills: a row of bordered capsules on every card was the noise the
   client asked to have taken out. */
.variation .v-feats {
  display: flex; flex-wrap: wrap; gap: 6px 20px;
  margin: 6px 0 0; padding: 0; list-style: none;
}
.variation .v-feats li {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 13px; color: var(--ink-2);
}
.variation .v-feats svg { color: var(--ink-3); flex-shrink: 0; }
/* The room COUNT is gone; the scarcity warning is not. --bad is the token for
   red AS TEXT here, and --warn-2 is amber as text (s18) - the fill values are
   for graphics and fail as small copy. */
.variation .v-stock { margin: 10px 0 0; font-size: 12px; font-weight: var(--fw-medium); }
.variation .v-stock.is-low { color: var(--warn-2); }
.variation .v-stock.is-gone { color: var(--bad); }
.pills { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.pill {
  display: inline-flex; align-items: center; gap: 6px;
  background: var(--paper-2); color: var(--ink-2);
  padding: 4px 10px;
  border-radius: var(--r-pill);
  font-family: var(--font-body); font-weight: var(--fw-medium);
  font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
  border: 1px solid var(--line);
}
.pill-ink { background: var(--brand-ink); color: var(--brand-paper); border-color: var(--brand-ink); }
.pill-pom { background: var(--brand-champagne); color: var(--brand-ink); border-color: var(--brand-champagne); }
.pill-teal{ background: var(--teal); color: var(--on-ok); border-color: var(--teal); }
.pill-ok  { background: var(--color-ok-bg); color: var(--color-ok-fg); border-color: var(--color-ok-line); }
.pill-warn{ background: var(--color-warn-bg); color: var(--color-warn-fg); border-color: var(--color-warn-line); }
.pill-bad { background: var(--color-err-bg); color: var(--color-err-fg); border-color: var(--color-err-line); }
/* These four are referenced by views (pill-info, pill-danger, pill-muted,
   pill-gold) but were never defined, so they rendered as a bare neutral pill
   and the distinction the view was drawing was lost. */
.pill-info  { background: var(--color-info-bg); color: var(--color-info-fg); border-color: var(--color-info-line); }
.pill-danger{ background: var(--bad); color: var(--on-bad); border-color: var(--bad); }
.pill-muted { background: var(--paper-2); color: var(--ink-3); border-color: var(--line); }
.pill-gold  { background: var(--color-gold-bg); color: var(--color-gold-fg); border-color: var(--color-gold-line); }
.variation .price {
  padding: 18px;
  border-top: 1px solid var(--line);
  display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between;
  gap: 12px;
}
@media (min-width: 760px) {
  .variation .price { border-top: 0; border-left: 1px solid var(--line); background: var(--paper-2); }
}
.variation .price strong {
  display: block; font-family: var(--font-display);
  font-size: 28px; font-weight: var(--fw-medium);
  color: var(--ink); letter-spacing: -0.02em; line-height: 1;
}
.variation .price small { color: var(--ink-3); font-size: 11px; margin-top: 4px; }
.variation .price .v-note { max-width: 200px; text-align: right; line-height: 1.35; margin-top: 8px; }
/* The instalment offer. inline-BLOCK, not inline-flex: the copy is a sentence
   ("or £300.00 deposit, then 9 x £100.00") whose length varies with the plan,
   and flex laid its wrapped halves out side by side instead of on two lines.
   Normal text flow lets it wrap, with the count and its amount held together
   by a .nowrap span in the view. */
.v-instalment {
  display: inline-block; margin-top: 8px;
  padding: 5px 10px; border-radius: var(--r-pill);
  background: var(--paper-3); color: var(--teal-2);
  font-size: 11px; font-weight: var(--fw-medium); letter-spacing: 0.01em;
  line-height: 1.45; text-align: right; max-width: 200px;
}

/* The party and the dates a visitor searched for, under the results heading.
   They are FACTS about this search rather than copy, so they are set apart from
   the heading and given the "Change guests" control they belong with. */
.search-meta {
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px; flex-wrap: wrap;
  margin: 0 0 var(--s-24);
}
.search-meta-facts { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; margin: 0; }
.search-meta-fact { display: inline-flex; align-items: center; gap: 9px; font-size: 15px; color: var(--ink-2); }
.search-meta-fact svg { color: var(--ink-3); flex-shrink: 0; }
/* A rule between the two facts, not a bullet: it is a divider and has no text
   content, so nothing reads it out. It is dropped once the row wraps, where a
   vertical line between two stacked items points at nothing. */
.search-meta-sep { width: 1px; align-self: stretch; min-height: 22px; background: var(--line-2); }
@media (max-width: 560px) { .search-meta-sep { display: none; } }
.search-meta-change { display: inline-flex; align-items: center; gap: 8px; }

/* ---------- Room detail ---------- */
.detail-hero { display: grid; gap: 8px; grid-template-columns: 1fr; margin: var(--s-16) 0 var(--s-32); }
.detail-hero .main { grid-column: 1/-1; aspect-ratio: 16/9; border-radius: var(--r-card); overflow: hidden; background: var(--paper-3); box-shadow: var(--shadow-sm); }
.detail-hero img { width: 100%; height: 100%; object-fit: cover; }
/* A clip in the hero strip is decorative (silent, looping, no controls), so it
   is cropped to the tile exactly like the photo it sits beside. */
.detail-hero video { width: 100%; height: 100%; object-fit: cover; display: block; background: #000; }
.detail-hero .thumb { aspect-ratio: 1/1; border-radius: var(--r-card); overflow: hidden; background: var(--paper-3); display: none; }
@media (min-width: 760px) {
  .detail-hero { grid-template-columns: 2fr 1fr 1fr; gap: 8px; }
  .detail-hero .main { grid-column: 1/2; grid-row: 1/3; aspect-ratio: auto; height: 480px; }
  .detail-hero .thumb { display: block; height: 236px; aspect-ratio: auto; }
}
.detail-grid { display: grid; grid-template-columns: 1fr; gap: var(--s-32); }
@media (min-width: 960px) { .detail-grid { grid-template-columns: 1fr 360px; } }
.detail-meta {
  display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--s-16);
  padding: var(--s-24) 0;
  border-top: 1px solid var(--line); border-bottom: 1px solid var(--line);
  margin: var(--s-24) 0;
}
@media (min-width: 600px) { .detail-meta { grid-template-columns: repeat(4, 1fr); } }
.detail-meta .item small {
  color: var(--ink-3); font-size: 11px;
  display: block; margin-bottom: 4px;
  text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--fw-medium);
}
.detail-meta .item strong { font-family: var(--font-display); font-weight: var(--fw-medium); color: var(--ink); font-size: 17px; }
.amenity-list { display: grid; grid-template-columns: 1fr; gap: 8px; padding: 0; margin: 0; }
@media (min-width: 500px) { .amenity-list { grid-template-columns: 1fr 1fr; } }
.amenity-list li {
  list-style: none; padding-left: 22px; position: relative;
  color: var(--ink-2); font-size: 13px;
}
.amenity-list li::before {
  content: ""; position: absolute; left: 0; top: 6px;
  width: 10px; height: 5px;
  border-left: 2px solid var(--champagne); border-bottom: 2px solid var(--champagne);
  transform: rotate(-45deg);
}

/* ---------- Bed layout, as a real choice ----------
   The radio is INVISIBLE BUT IN PLACE, never display:none and never 0x0: a
   control the browser cannot focus is one it refuses to validate, and hiding it
   outright takes it out of the accessibility tree with it.

   `input[type="radio"].bed-opt-input` is ATTRIBUTE-QUALIFIED on purpose. The
   section-9 base rule for checkboxes and radios is (0,1,1) and sits later in
   this sheet, so a bare `.bed-opt-input` (0,1,0) LOSES - and the "invisible"
   input comes back as an 18px box that shunts the label it drives sideways.
   The sheet already records that trap twice (.segmented-input, .ra-radio) and
   it has shipped once. */
.bed-choice { display: grid; gap: 10px; grid-template-columns: 1fr; }
@media (min-width: 480px) { .bed-choice { grid-template-columns: 1fr 1fr; } }
.bed-opt { display: block; position: relative; cursor: pointer; }
input[type="radio"].bed-opt-input {
  position: absolute; opacity: 0; margin: 0;
  width: 100%; height: 100%; left: 0; top: 0;
  appearance: none; -webkit-appearance: none; cursor: pointer;
}
.bed-opt-face {
  display: flex; align-items: center; gap: 10px;
  padding: 14px 16px;
  border: 1.5px solid var(--line); border-radius: var(--r-input);
  background: var(--card); color: var(--ink);
  transition: border-color .15s ease, background .15s ease;
}
.bed-opt-face svg { color: var(--ink-3); flex-shrink: 0; }
.bed-opt-label { flex: 1; font-size: 14px; font-weight: var(--fw-medium); }
/* The tick, drawn rather than glyphed - a font substitution away from arriving
   as a colour emoji on one platform and an outline on another. */
.bed-opt-tick {
  width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
  border: 1.5px solid var(--line-2); position: relative;
  transition: background .15s ease, border-color .15s ease;
}
/* Painted from the sibling combinator, NEVER `:has()`: Firefox ESR 115 is still
   in the field without it, and there no option would ever look chosen - which
   reads as a control that does not work. */
.bed-opt-input:checked ~ .bed-opt-face { border-color: var(--gold); background: var(--color-gold-bg); }
.bed-opt-input:checked ~ .bed-opt-face .bed-opt-tick { background: var(--gold); border-color: var(--gold); }
.bed-opt-input:checked ~ .bed-opt-face .bed-opt-tick::after {
  content: ''; position: absolute; left: 6px; top: 4px;
  width: 5px; height: 9px;
  /* Dark ink on gold, never white: the brand gold under white is 1.9:1 (s18). */
  border-right: 2px solid var(--brand-ink); border-bottom: 2px solid var(--brand-ink);
  transform: rotate(42deg);
}
.bed-opt-input:focus-visible ~ .bed-opt-face { outline: 2px solid var(--focus); outline-offset: 2px; }
@media (hover: hover) { .bed-opt:hover .bed-opt-face { border-color: var(--line-2); } }

/* ---------- Reserve panel ----------
   ONE price and ONE button. The list of every party this room sells - a
   mini-card and a Reserve button per row - is gone: it was a price matrix on a
   page whose job is to sell one room. `.reserve-option*` went with it. */
.reserve-head { margin: 0 0 6px; font-size: 18px; }
.reserve-note { font-size: 13px; margin: 0 0 4px; }
.reserve-price { display: flex; flex-direction: column; margin-bottom: 18px; }
.reserve-from { font-size: 13px; color: var(--ink-3); margin-bottom: 2px; }
.reserve-figure {
  font-family: var(--font-display); font-size: 38px; font-weight: var(--fw-medium);
  letter-spacing: -0.025em; line-height: 1.05; color: var(--ink);
}
.reserve-basis { font-size: 13px; color: var(--ink-3); margin-top: 6px; }
.reserve-instalment { font-size: 12px; color: var(--teal-2); margin-top: 8px; line-height: 1.45; }
.reserve-facts {
  display: grid; gap: 10px; margin: 0 0 18px; padding: 18px 0 0;
  border-top: 1px solid var(--line); list-style: none;
}
.reserve-facts li { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--ink-2); }
.reserve-facts svg { color: var(--ink-3); flex-shrink: 0; }
/* "Secure booking". Two lines from one <span>, so the strong is a block. */
.reserve-secure { display: flex; align-items: flex-start; gap: 10px; margin: 14px 0 0; font-size: 12px; color: var(--ink-3); }
.reserve-secure svg { color: var(--ink-3); flex-shrink: 0; margin-top: 1px; }
.reserve-secure strong { display: block; color: var(--ink-2); font-size: 13px; margin-bottom: 1px; }
/* "Need help?" sits OUTSIDE the priced panel, under it, because it is a way out
   of the decision rather than part of it. */
.reserve-help {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin-top: var(--s-16); padding: 16px;
  border: 1px solid var(--line); border-radius: var(--r-card); background: var(--paper-2);
}
.reserve-help svg { color: var(--ink-3); flex-shrink: 0; }
.reserve-help > span { flex: 1 1 120px; font-size: 12px; color: var(--ink-3); }
.reserve-help strong { display: block; color: var(--ink-2); font-size: 13.5px; margin-bottom: 1px; }

/* ---------- Programme page ---------- */
/* The title is the one heading on the site set above the 500 the tokens cap
   "bold" at: the client asked for it heavier, and at 84px over a photograph
   500 reads as a caption. Poppins 600 is loaded (see header.ejs), so this is
   a real weight and not a synthesised one. Scoped to .prog-hero on purpose. */
.prog-hero h1 { font-weight: 600; }
/* The promise, then the prose. The hero carries both and they are set apart:
   one line at lede weight over a paragraph that is deliberately quieter.

   SPACING BELOW THE PROSE. .hero-dark-inner is a flex column with a 22px gap,
   so every margin here ADDS to that gap. The facts row used to bring 22px of
   its own plus 20px above the rule, and the buttons 6px more: some 90px from
   the last line of prose to the buttons, which the client read as a hole in
   the page. The column gap alone now separates the blocks; the rule keeps a
   short breath above the facts and nothing else is added. */
/* The two paragraphs are told apart by WEIGHT, and by nothing else.
   `.prog-hero-body` used to carry `opacity: 0.88` over the .lede colour, which
   is itself `rgba(255,255,255,0.88)`: the prose landed at 0.774 against the
   promise line's 0.88, on a photograph with no scrim behind it. Element opacity
   also fades the text-shadow, so the paragraph lost contrast twice - once in
   the ink and once in the only thing separating it from a bright sky.

   It carried `font-size: 15.5px` too, which never applied: three classes of
   `.hero-dark.hero-full:not(.hero-home) .lede` out-specify one, and both
   paragraphs were already 17px. Declaring a size that loses is worse than not
   declaring one, because the next person reads it as the size in force. */
.prog-hero-lede { font-weight: var(--fw-medium); margin-bottom: 0; }
.prog-hero .hero-cta { margin-top: 0; }
/* The dates and the place, under a rule. `.hero-dark` paints near-white copy,
   so these take a softened white rather than --ink-3, which is a page-ink token
   and vanishes on a photograph. */
.prog-hero-facts {
  display: flex; flex-wrap: wrap; gap: 8px 26px;
  margin: 0; padding-top: 14px;
  border-top: 1px solid rgba(255,255,255,0.18);
  font-size: 14.5px; color: rgba(255,255,255,0.86);
}
.prog-hero-facts span { display: inline-flex; align-items: center; gap: 9px; }
.prog-hero-facts svg { color: var(--champagne); flex-shrink: 0; }

/* The two downloads. A row apiece rather than a grid of cards: there are two of
   them and they are errands, not products. */
.prog-downloads { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; max-width: 760px; }
.prog-downloads li {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  padding: 16px 18px;
  background: var(--card); border: 1px solid var(--line); border-radius: var(--r-card);
}
.prog-dl-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; flex-shrink: 0; border-radius: var(--r-input);
  background: var(--color-gold-bg); color: var(--color-gold-fg);
}
.prog-dl-copy { flex: 1 1 200px; min-width: 0; font-size: 12.5px; color: var(--ink-3); line-height: 1.5; }
.prog-dl-copy strong { display: block; color: var(--ink); font-size: 15px; margin-bottom: 2px; }
.prog-recap-head { margin: 0 0 20px; max-width: 620px; }

/* ---------- My bookings ---------- */
/* The empty state. It is the page somebody sees the day they make an account,
   so it has to say what this page will hold and hand them the way to fill it -
   it was one grey sentence with a link buried in it. */
.acct-empty { text-align: center; padding: clamp(32px, 5vw, 56px) 24px; }
.acct-empty-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 84px; height: 84px; border-radius: 50%;
  background: var(--color-gold-bg); color: var(--color-gold-fg);
  margin-bottom: 18px;
}
.acct-empty h2 { margin: 0 0 8px; font-size: 24px; }
.acct-empty p { margin: 0 auto 22px; max-width: 420px; color: var(--ink-3); font-size: 15px; line-height: 1.6; }

/* A booking card. The photo is a fixed-ratio tile on the left at reading width
   and a banner across the top on a phone, where a 120px column beside four
   lines of text leaves neither enough room. */
/* `.panel.acct-card`, NOT `.acct-card`. Both are one class, and `.panel` is
   declared ~800 lines LATER in this sheet, so a bare `.acct-card { padding: 0 }`
   lost the cascade and every booking card carried the panel's 22px padding: the
   photograph sat in a white gutter with square corners inside a rounded card,
   which is what "the image overflows its box" actually was. Measured, not
   guessed - the computed padding was 22px. `.panel-flush` below carries the
   same fix for the same reason. */
.panel.acct-card { padding: 0; overflow: hidden; }
/* The photo is flush to the card edge, so the card's own radius is what curves
   it - and `overflow: hidden` on the card is what does the clipping. Its own
   corners stay square deliberately: rounding them as well would draw a second,
   smaller curve just inside the card's. */
.acct-card-photo { border-radius: 0; }
.acct-card-top { display: grid; grid-template-columns: 1fr; }
@media (min-width: 620px) { .acct-card-top { grid-template-columns: 200px 1fr; } }
.acct-card-photo {
  width: 100%; height: 100%; min-height: 150px;
  object-fit: cover; display: block; background: var(--paper-3);
}
.acct-card-head { padding: clamp(18px, 2.2vw, 24px); min-width: 0; }
.acct-card-status { margin-bottom: 10px; }
.acct-card-title { margin: 0 0 10px; font-size: 21px; line-height: 1.25; }
.acct-card-facts {
  display: flex; flex-wrap: wrap; gap: 6px 20px; margin: 0 0 14px;
  font-size: 13.5px; color: var(--ink-3);
}
.acct-card-facts span { display: inline-flex; align-items: center; gap: 7px; }
.acct-card-facts svg { flex-shrink: 0; }
/* A rule ABOVE the room, because the room and the party are a different kind of
   fact from the event and its dates - the reference draws the same line. */
.acct-card-room {
  display: flex; align-items: center; gap: 10px;
  padding-top: 14px; border-top: 1px solid var(--line);
}
.acct-card-room svg { color: var(--ink-3); flex-shrink: 0; }
.acct-card-room strong { display: block; font-size: 14.5px; }
.acct-card-room em { display: block; font-style: normal; font-size: 12.5px; color: var(--ink-3); margin-top: 1px; }
.acct-card-ref { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 14px 0 0; font-size: 12px; color: var(--ink-3); }
.acct-card-ref code {
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: 13px; letter-spacing: 0.06em; color: var(--ink);
  background: var(--paper-2); border: 1px solid var(--line);
  padding: 3px 8px; border-radius: var(--r-input);
}
.acct-card-bundle { margin: 10px 0 0; font-size: 12px; color: var(--ink-3); }
/* Injected by main.js next to a [data-copy], so it exists only where a script
   ran - with none there is nothing to press rather than a dead button. */
.copy-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; padding: 0; flex-shrink: 0;
  border: 1px solid var(--line); border-radius: var(--r-input);
  background: var(--card); color: var(--ink-3); cursor: pointer;
  transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.copy-btn:hover { color: var(--ink); border-color: var(--line-2); }
/* Dark ink on gold, never white (s18). */
.copy-btn.is-done { background: var(--gold); border-color: var(--gold); color: var(--brand-ink); }

/* The payment progress block. The table of every row that used to sit on each
   card is one click away on "View booking": it is the right level of detail for
   a page ABOUT one booking and the wrong one for a list of them. */
.acct-pay {
  margin: 0 clamp(18px, 2.2vw, 24px) clamp(18px, 2.2vw, 24px);
  padding: 16px; border-radius: var(--r-card);
  background: var(--paper-2); border: 1px solid var(--line);
}
.acct-pay-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; font-size: 13px; }
.acct-pay-head strong { font-size: 13.5px; }
.acct-pay-head span { color: var(--ink-3); }
.acct-pay-bar { height: 8px; border-radius: 99px; background: var(--paper-3); overflow: hidden; margin: 10px 0 8px; }
.acct-pay-bar > span { display: block; height: 100%; border-radius: 99px; background: var(--gold); }
.acct-pay-foot { display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; font-size: 12.5px; color: var(--ink-3); }
.acct-pay-pct { font-weight: var(--fw-medium); color: var(--ink-2); }
.acct-pay-next { display: flex; align-items: center; gap: 8px; margin: 12px 0 0; font-size: 13px; color: var(--ink-2); }
.acct-pay-next svg { color: var(--ink-3); flex-shrink: 0; }
.acct-card .btn-row { padding: 0 clamp(18px, 2.2vw, 24px) clamp(18px, 2.2vw, 24px); margin-top: 0; }
.acct-card .callout { margin: 0 clamp(18px, 2.2vw, 24px) 14px; }
.acct-card .note { margin: 0 clamp(18px, 2.2vw, 24px) 14px; }
/* THE GUTTER UNDER THE PHOTOGRAPH. The image fills its whole grid column
   (height: 100%), so the bottom of .acct-card-top IS the bottom of the
   picture - while the text column beside it ends on its own 18-24px of
   padding. Every block below therefore began hard against the photograph and
   clear of the text, and the payment panel read as squashed into it.

   Declared on whatever FOLLOWS the top row, not as a margin on the row
   itself: the first block is the payment panel on a confirmed booking, the
   "we have your payment" note on a pending one, the transfer callout, or the
   buttons - and a bottom margin on the row would also show under a card that
   ends there. Two classes, so it outranks the three single-purpose rules
   above (and `.mt-3` on the callout and the buttons) without !important. */
.acct-card > .acct-card-top + * { margin-top: 14px; }

/* "Need help with a booking?" - a way to reach a person, under the list. */
.acct-help {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  margin-top: var(--s-16); padding: 18px 20px;
  border: 1px solid var(--line); border-radius: var(--r-card); background: var(--card);
}
.acct-help-icon { color: var(--ink-3); display: inline-flex; flex-shrink: 0; }
.acct-help-copy { flex: 1 1 200px; font-size: 13px; color: var(--ink-3); }
.acct-help-copy strong { display: block; color: var(--ink); font-size: 14.5px; margin-bottom: 1px; }

/* "Still have questions?" over the Türkiye backdrop. Dropped entirely when no
   photograph is set - the copy is short enough that on a flat slab it reads as
   an unfinished section rather than a band. */
.acct-faq-band {
  position: relative; isolation: isolate; overflow: hidden;
  margin-top: var(--s-16); border-radius: var(--r-card);
  background: #17171A; color: var(--brand-paper);
  padding: clamp(30px, 4.5vw, 52px) clamp(20px, 3vw, 40px);
  text-align: center;
}
.acct-faq-photo { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; z-index: -2; }
.acct-faq-band::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background: linear-gradient(180deg, rgba(18,18,20,0.72) 0%, rgba(18,18,20,0.84) 100%);
}
.acct-faq-copy { max-width: 460px; margin: 0 auto; }
.acct-faq-band h2 { color: var(--brand-paper); margin: 0 0 8px; font-size: 26px; }
.acct-faq-band p { margin: 0 0 20px; color: rgba(255,255,255,0.78); font-size: 14.5px; line-height: 1.6; }
/* The linked phrase inside that sentence: underlined, because on a photograph
   a colour change alone is not a reliable cue that a word is a link. */
.acct-faq-band p a { color: var(--brand-paper); text-decoration: underline; text-underline-offset: 3px; }
.acct-faq-band p a:hover { text-decoration-thickness: 2px; }

/* ---------- Forms ---------- */
.form-grid { display: grid; grid-template-columns: 1fr; gap: var(--s-16); }
@media (min-width: 600px) { .form-grid { grid-template-columns: 1fr 1fr; } }
.form-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: var(--s-16); }
.form-row:last-child { margin-bottom: 0; }
.form-row label, .form-row > label {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.form-row .hint { font-size: 11px; color: var(--ink-3); font-weight: var(--fw-regular); text-transform: none; letter-spacing: 0; }
.form-row input, .form-row select, .form-row textarea {
  padding: 12px 14px;
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  font: inherit; background: var(--card); color: var(--ink);
  width: 100%;
  transition: border-color .15s ease, box-shadow .15s ease;
}
.form-row input:focus, .form-row select:focus, .form-row textarea:focus {
  outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus);
}
.form-row input[aria-invalid="true"], .form-row select[aria-invalid="true"], .form-row textarea[aria-invalid="true"] {
  border-color: var(--bad); box-shadow: 0 0 0 3px rgba(196,52,42,0.22);
}
.form-grid > .span2,
.form-grid > .form-row.span2 { grid-column: 1 / -1; }

/* Placeholder text. There was no rule at all, so every field fell back to the
   UA default - roughly 54% of the text colour, about 3.3:1 on white, under the
   4.5:1 a placeholder owes as text. --ink-3 is the muted-text token and clears
   it on every light surface (5.53:1 on card, 5.07:1 on the page).
   NOTE: these must stay SEPARATE rules. ::placeholder and its -webkit/-moz
   prefixed forms are not a valid selector list to every engine, and one
   unrecognised selector drops the whole rule. */
::placeholder { color: var(--ink-3); opacity: 1; }
::-webkit-input-placeholder { color: var(--ink-3); opacity: 1; }
::-moz-placeholder { color: var(--ink-3); opacity: 1; }

/* ---------- Admin filter bars + form action rows ----------
   One consistent pattern for every admin search/filter form, and for the
   submit row of data-entry forms, so inputs and their buttons share a baseline
   and same-purpose buttons share one size. */

/* Horizontal filter/search bar: fields flow and wrap, all bottom-aligned so a
   field's label pushes its input down to the shared baseline the buttons meet.
   Zeroing the .form-row margin here is what stops the action buttons sitting a
   row-gap lower than the inputs they line up with. */
.filter-bar { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px; }
.filter-bar .form-row { margin-bottom: 0; flex: 1 1 180px; min-width: 150px; }
.filter-bar .form-row.grow { flex: 3 1 260px; }            /* the wide search field */
.filter-bar .filter-actions { display: flex; gap: 8px; align-items: flex-end; flex: 0 0 auto; }

/* A filter button sits directly beside its input on one baseline, so the two
   must share an exact height. Inputs/selects compute to ~47px from their
   padding + body line-height, while a bare `.btn-sm` is only ~33px - which
   reads as a visibly short button next to the field. Pin every control in the
   bar (fields and their action buttons) to one height so they line up flush. */
.filter-bar .form-row input:not([type="checkbox"]):not([type="radio"]),
.filter-bar .form-row select,
.filter-bar .filter-actions .btn { height: 44px; }

/* Mobile: the action buttons span the full bar and fill the row. A lone button
   stretches to 100%; two share the row 50/50; a third wraps to a new row and
   grows to full width (and so on). `flex-grow:1` on a 50%-basis child is what
   makes a row's last/only button expand to fill the space left beside it. */
@media (max-width: 600px) {
  .filter-bar .filter-actions { flex: 1 1 100%; flex-wrap: wrap; }
  .filter-bar .filter-actions .btn { flex: 1 1 calc(50% - 4px); }
}

/* Submit row for stacked data-entry forms (invite, settings, …): a full-width
   row under the fields so a primary button never sits half-aligned beside one
   input. Spans both columns inside a .form-grid. */
.form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: var(--s-8); }
.form-grid > .form-actions { grid-column: 1 / -1; }

/* A labelled break INSIDE a .form-grid, for a form with more than one subject -
   the discount-code form is "what it's worth" followed by "what it applies to".
   Spans both columns and carries a rule above it, so the second group reads as
   a section rather than as four more fields that happened to run on. */
.form-grid > .form-section-head {
  grid-column: 1 / -1;
  margin-top: var(--s-8);
  padding-top: var(--s-16);
  border-top: 1px solid var(--line);
}
.form-section-head h4 {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: 13px;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-2);
  margin: 0 0 4px;
}
.form-section-head .hint { display: block; font-size: 12px; color: var(--ink-3); margin: 0; }

/* Two or more inputs sharing ONE .form-row, because they are one question:
   a min/max pair, or the four fields that describe a single guest. They wrap
   rather than shrink - four inputs squeezed onto one line at 375px is how a
   number field ends up too narrow to read its own placeholder. */
.input-pair { display: flex; flex-wrap: wrap; gap: 8px; }
.input-pair > input, .input-pair > select { flex: 1 1 120px; min-width: 0; }

/* ---------- Safari date-input legibility ----------
   On WebKit/Safari, <input type="date"> renders its value through a stack of
   `::-webkit-datetime-edit*` pseudo-elements rather than a normal text node, so
   it ignores the input's `color` and falls back to the OS grey. The symptom the
   client hit: empty fields showed a greyed-out today's date, and filled fields
   were grey too. We force every part of the field - and the dd/mm/yyyy
   placeholder fields shown when empty - to the ink colour so the value reads
   black. The browser still renders the real "dd/mm/yyyy" mask for empty fields;
   we just recolour it. (Chrome/Firefox honour plain `color` and are unaffected;
   these pseudo-elements are simply ignored there.) */
input[type="date"],
input[type="datetime-local"],
input[type="month"],
input[type="week"],
input[type="time"] {
  color: var(--ink);
}
input[type="date"]::-webkit-datetime-edit,
input[type="datetime-local"]::-webkit-datetime-edit,
input[type="month"]::-webkit-datetime-edit,
input[type="week"]::-webkit-datetime-edit,
input[type="time"]::-webkit-datetime-edit { color: var(--ink); padding: 0; }
input[type="date"]::-webkit-datetime-edit-fields-wrapper,
input[type="datetime-local"]::-webkit-datetime-edit-fields-wrapper,
input[type="month"]::-webkit-datetime-edit-fields-wrapper,
input[type="week"]::-webkit-datetime-edit-fields-wrapper,
input[type="time"]::-webkit-datetime-edit-fields-wrapper { color: var(--ink); }
/* Each editable sub-field + the literal separators ("/" ":"). Colour them all
   to ink so neither the filled value nor the empty dd/mm/yyyy mask renders grey. */
input[type="date"]::-webkit-datetime-edit-text,
input[type="date"]::-webkit-datetime-edit-day-field,
input[type="date"]::-webkit-datetime-edit-month-field,
input[type="date"]::-webkit-datetime-edit-year-field,
input[type="datetime-local"]::-webkit-datetime-edit-text,
input[type="datetime-local"]::-webkit-datetime-edit-day-field,
input[type="datetime-local"]::-webkit-datetime-edit-month-field,
input[type="datetime-local"]::-webkit-datetime-edit-year-field,
input[type="datetime-local"]::-webkit-datetime-edit-hour-field,
input[type="datetime-local"]::-webkit-datetime-edit-minute-field,
input[type="month"]::-webkit-datetime-edit-text,
input[type="month"]::-webkit-datetime-edit-month-field,
input[type="month"]::-webkit-datetime-edit-year-field,
input[type="time"]::-webkit-datetime-edit-text,
input[type="time"]::-webkit-datetime-edit-hour-field,
input[type="time"]::-webkit-datetime-edit-minute-field {
  color: var(--ink);
}
/* The whole value container Safari exposes; keep it ink and vertically tidy. */
input[type="date"]::-webkit-date-and-time-value,
input[type="datetime-local"]::-webkit-date-and-time-value,
input[type="month"]::-webkit-date-and-time-value,
input[type="week"]::-webkit-date-and-time-value,
input[type="time"]::-webkit-date-and-time-value {
  color: var(--ink); text-align: left; margin: 0;
}
/* Empty state: the unfilled dd/mm/yyyy placeholder fields. WebKit applies
   `:-webkit-datetime-edit-*:empty`-ish styling we can't hook directly, but
   colouring the edit stack above already turns the mask from grey to ink.
   Keep the calendar picker indicator on-brand instead of OS-blue. */
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator,
input[type="month"]::-webkit-calendar-picker-indicator,
input[type="week"]::-webkit-calendar-picker-indicator,
input[type="time"]::-webkit-calendar-picker-indicator { opacity: .55; cursor: pointer; }
input[type="date"]:hover::-webkit-calendar-picker-indicator,
input[type="datetime-local"]:hover::-webkit-calendar-picker-indicator,
input[type="month"]:hover::-webkit-calendar-picker-indicator { opacity: .85; }
/* A genuinely empty Safari date field (no user value yet) gets a sentinel class
   from main.js; render its mask in the muted ink so "dd/mm/yyyy" reads as a
   placeholder, while a chosen value stays full-strength ink. Both are far darker
   than Safari's default grey, satisfying the "black dd/mm/yyyy" requirement. */
input[type="date"].is-date-empty::-webkit-datetime-edit { color: var(--ink-2); }

/* Sub-tabs - horizontal section nav within a single admin page (e.g. Settings).
   Each tab is its own route; the active one is highlighted. Scrolls on narrow
   screens rather than wrapping into an uneven block. */
/* Tabs stick directly under the page header bar (--stick-top). Opaque background
   + z-index below .admin-head (20) but above table headers (3) so the stacking
   reads header > tabs > table. main.js folds the tab height into
   --table-stick-top so table headers pin beneath the tabs (see `.table th`). */
/* The bottom rule is an INSET SHADOW, not a border, and the links carry no
   negative margin. `overflow-x: auto` forces overflow-y to `auto` as well, so
   the old "pull each link down 1px to overlap a real border-bottom" trick left
   the container 1px shorter than its content: that 1px was clipped, taking the
   bottom of the active tab's underline and the descenders of the labels with
   it. A shadow needs no overlap, so nothing overflows. */
.subtabs {
  position: sticky; top: var(--stick-top, 0px); z-index: 19;
  display: flex; gap: 4px; margin: 0 0 var(--s-24);
  box-shadow: inset 0 -1px 0 var(--line);
  background: var(--paper);
  overflow-x: auto; scrollbar-width: none;
}
.subtabs::-webkit-scrollbar { display: none; }
.subtabs a {
  flex: 0 0 auto; padding: 12px 14px; font-size: 13px; font-weight: var(--fw-medium);
  line-height: 1.6;
  color: var(--ink-2); text-decoration: none; white-space: nowrap;
  border-bottom: 2px solid transparent;
}
.subtabs a:hover { color: var(--ink); }
.subtabs a.active { color: var(--teal); border-bottom-color: var(--teal); }
.subtabs a { cursor: pointer; }

/* Client-side tabs (public/js/tabs.js): a hidden panel must stay hidden even
   when its base class (.panel, .content-section) sets its own display. */
[data-tabpanel][hidden] { display: none !important; }

/* ========== Admin & account design system ==========
   One shared vocabulary for every admin page and the signed-in account/payment
   pages, so they read as one product. The settings page (views/admin/settings.
   ejs) is the reference implementation; lib/settings-schema.js drives its fields.

   Primitives (use these instead of inline styles / ad-hoc markup):
     .admin-lede / .page-lede  - intro paragraph under a page title
     .panel                    - the canonical content card (defined elsewhere)
     .section-head             - a card's title + description block
     .callout(-info|warn|danger|ok) - an inline notice
     .meta-grid / .meta-item   - a grid of label/value readouts
     .switch                   - segmented On/Off control (settings forms)
     .admin-head-text/-actions - page-header title-stack + right-aligned actions */

/* Intro paragraph under a page title. .page-lede is the user-facing alias. */
.admin-lede, .page-lede {
  color: var(--ink-3); font-size: 13.5px; line-height: 1.55;
  margin: -6px 0 var(--s-24); max-width: 72ch;
}
.page-lede { font-size: 15px; }

/* Page-header helpers: a title stack on the left, actions on the right. Pairs
   with the existing .admin-head (sticky bar) so every page header is built the
   same way instead of with bespoke inline flex. */
.admin-head-text { min-width: 0; }
.admin-head-text h1 { margin: 0; }
.admin-head-text .admin-lede,
.admin-head-text .page-lede { margin: 6px 0 0; }
.admin-head-actions, .head-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }

/* Generic horizontal button/control row - replaces the ubiquitous inline
   `display:flex; gap:8px; flex-wrap:wrap`. Use for action rows under a panel or
   beside a heading. .btn-row.end right-aligns; .btn-row.center centres. */
.btn-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.btn-row.end { justify-content: flex-end; }
.btn-row.center { justify-content: center; }

/* A card's header: a title (h2/h3/h4) plus an optional description line. Works
   inside a .panel or a .settings-section, or standalone above a grid. */
.section-head { margin-bottom: var(--s-16); }
.section-head > :is(h2, h3, h4) {
  font-size: 15px; font-weight: var(--fw-medium); letter-spacing: -0.01em;
  margin: 0;
}
.section-head .section-desc,
.section-head .hint {
  display: block; margin-top: 5px;
  font-size: 12.5px; color: var(--ink-3); line-height: 1.5; max-width: 72ch;
}
/* A section header that carries its own actions (e.g. an "Add" button). */
.section-head.spread { margin-bottom: var(--s-16); }

/* Label/value readout grid - the "small grey label over a strong value" pattern
   used across account, payment, booking and customer pages. Replaces dozens of
   bespoke <div><small>..</small><br><strong>..</strong></div> blocks. */
.meta-grid {
  display: grid; gap: var(--s-16);
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.meta-item { min-width: 0; }
.meta-label {
  display: block; font-size: 11px; color: var(--ink-3);
  text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--fw-medium);
  margin-bottom: 4px;
}
.meta-value { font-weight: var(--fw-medium); font-size: 15px; color: var(--ink); }
.meta-value.lg { font-family: var(--font-display); font-size: 22px; letter-spacing: -0.02em; }
/* A value that is a date, an id, an address or a short sentence rather than a
   number: at 15px display size those wrap mid-word and read as headings. */
.meta-value.value-sm { font-size: 13px; letter-spacing: 0; overflow-wrap: anywhere; }
/* An optional third line under a value (a qualifier: "latest expires ...",
   "instalments left"). Sits tight under .meta-value rather than as a new item. */
.meta-item .muted { display: block; margin-top: 3px; font-size: 12px; line-height: 1.45; }
/* A value long enough to need the full grid width (URLs, referrers). */
.meta-item.wide { grid-column: 1 / -1; }

/* A card header carrying its own action keeps its title tight to the header row:
   .panel h2/h3 add a bottom margin meant for a title sitting straight above
   body copy, which here just pushes the action row out of alignment. */
.panel-head :is(h2, h3, h4),
.panel .panel-head :is(h2, h3, h4),
.panel .section-head > :is(h2, h3, h4) { margin: 0; }

/* Table-cell helpers, replacing the inline styles these repeated on every admin
   table: a cell that must not wrap (dates, refs, money), a small secondary line
   under a cell's primary value, and the single-cell "nothing here" row. */
.nowrap { white-space: nowrap; }
.cell-sub { display: block; font-size: 12px; color: var(--ink-3); line-height: 1.45; }
td.cell-sub { display: table-cell; }
.table-empty { text-align: center; padding: 32px 16px; color: var(--ink-3); }

/* A link that inherits its surroundings (breadcrumbs, a phone number in a
   readout) instead of painting itself as a link until hovered. */
.plain-link { color: inherit; text-decoration: none; }
.plain-link:hover { text-decoration: underline; }
/* A one-button form sitting inside a header row, where the default form margin
   would break the row's alignment. */
.inline-form { margin: 0; }

/* A table sitting flush inside a .panel (no outer frame, since the panel is the
   frame). Replaces inline `style="border:0"` on embedded tables. */
.table.table-flush { border: 0; }

/* A panel whose children sit flush to its edges (rows draw their own padding).
   `.panel.panel-flush` for the reason `.panel.acct-card` gives: `.panel` is
   declared later in this sheet at the same specificity, so the bare class lost
   and the padding it exists to remove was still there (measured: 22px). It is
   only ever used WITH `.panel` - `views/user/pay.ejs` is the live case - so
   qualifying it costs nothing and is what makes it work at all. */
.panel.panel-flush { padding: 0; overflow: hidden; }
/* A .spread row whose columns top-align rather than centre. */
.spread.spread-top { align-items: flex-start; }
/* A standalone section title with breathing room above a following block. */
.section-title-lg { margin: var(--s-32) 0 var(--s-16); }

/* One labelled sub-section (a card). Sections stack with a small gap. The
   settings forms use .settings-section; it's an alias of .panel's look. */
.settings-form { display: flex; flex-direction: column; gap: var(--s-16); }
.settings-section {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  padding: var(--s-24);
}

/* The rendered-email preview on Admin -> Email templates. The frame is tall
   because these are emails: a confirmation with a two-room order summary and a
   six-line schedule is most of a page, and a 300px window turns reviewing it
   into scrolling inside a scroll. The grey ground is the email shell's own, so
   the message sits on what it will sit on in a client. */
.tpl-preview { margin: 12px 0 0; }
.tpl-preview iframe {
  width: 100%; height: 620px; display: block;
  border: 1px solid var(--line); border-radius: var(--r-card);
  background: var(--paper-2);
}
@media (max-width: 720px) { .tpl-preview iframe { height: 460px; } }

/* A HEADING FOR A PANEL THAT HOLDS MORE THAN ONE THING. The Maintenance panel
   carries the launch gate and the bulk Stripe sync, which are two unrelated
   jobs on one tab, and a run of .settings-section cards with no break between
   them reads as one long list of settings for whatever the panel's lede last
   said. The first one loses its top margin: it is the first thing under the
   tab strip and has nothing to be spaced away from. */
.settings-panel-title {
  margin: var(--s-32) 0 var(--s-8);
  font-size: var(--text-heading); font-weight: var(--fw-medium);
}
[data-tabpanel] > .settings-panel-title:first-child { margin-top: 0; }

/* A save control that belongs to ONE section rather than to a tab. The
   Maintenance panel has no header Save (it is not in `tabs`, so no button is
   generated for it) and it holds two forms that post to two different routes,
   so each carries its own. */
.settings-section-save {
  display: flex; align-items: center; justify-content: flex-end; gap: 12px;
  margin-top: var(--s-16); padding-top: var(--s-16);
  border-top: 1px solid var(--line);
}

/* The save control for a settings tab, in the page header beside the title (the
   header is sticky, so Save stays reachable however long the form is). One per
   tab form; tabs.js shows only the active tab's, so only one is ever visible. */
.settings-head-save { display: flex; align-items: center; gap: 12px; }
.settings-head-save[hidden] { display: none; }
.settings-save-hint {
  font-size: 12.5px; color: var(--warn-2);
  display: inline-flex; align-items: center; gap: 6px;
}
/* A `display` declaration beats the `hidden` ATTRIBUTE, so without this the
   hint was on screen from the moment the page loaded - "Unsaved changes" over
   a form nobody had touched, on Settings and on Content, every single visit.
   settings.js has always set .hidden correctly; nothing was listening. The
   wrapper above already carries the same guard, which is why only the hint
   itself leaked. */
.settings-save-hint[hidden] { display: none; }
.settings-save-hint::before {
  content: ''; width: 7px; height: 7px; border-radius: 50%;
  background: var(--warn); flex: 0 0 auto;
}

/* Gated field: dimmed when its parent toggle is Off (set by settings.js). The
   value still submits - this is purely a visual "currently ignored" cue. */
.form-row.is-gated-off { opacity: 0.5; }
.form-row.is-gated-off label { color: var(--ink-3); }

/* Money / percent affix: a leading symbol inside the input frame. */
.input-affix { position: relative; display: block; }
.input-affix::before {
  content: attr(data-affix);
  position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
  color: var(--ink-3); font-size: 13px; pointer-events: none;
}
.input-affix input { padding-left: 26px; }

/* Segmented On/Off switch (two same-named radios; the checked one lights up).
   Replaces the old <select> toggle - clearer state, single value submitted. */
.switch {
  display: inline-flex; align-self: flex-start;
  background: var(--paper-2); border: 1px solid var(--line);
  border-radius: 8px; padding: 3px; gap: 2px;
}
/* Attribute-qualified on purpose - see the note on `.segmented-input`. A bare
   `.switch-radio` (0,1,0) loses to the `input[type="radio"]` base rule in
   section 9, which would give this invisible input an 18px box and push both
   labels off the pill. */
input[type="radio"].switch-radio { position: absolute; opacity: 0; width: 0; height: 0; min-height: 0; margin: 0; padding: 0; border: 0; pointer-events: none; }
.switch-opt {
  cursor: pointer; padding: 5px 16px; border-radius: 6px;
  font-size: 13px; font-weight: var(--fw-medium); color: var(--ink-3);
  line-height: 1.2; user-select: none;
  transition: background .15s ease, color .15s ease;
}
.switch-opt:hover { color: var(--ink); }
.switch-radio:focus-visible + .switch-opt { box-shadow: var(--shadow-focus); }
input.switch-radio[value="1"]:checked + .switch-on {
  background: var(--ok); color: var(--on-ok);
}
input.switch-radio[value="0"]:checked + .switch-off {
  background: var(--card); color: var(--ink); box-shadow: 0 1px 2px rgba(27,27,29,.12);
}

/* Small status tag (sender-identity descriptors). */
.tag {
  display: inline-block; font-size: 11px; font-weight: var(--fw-medium);
  padding: 2px 8px; border-radius: var(--r-pill); border: 1px solid var(--line);
  color: var(--ink-3); background: var(--paper-2); letter-spacing: 0.01em;
}
.tag-ok { color: var(--color-ok-fg); background: var(--color-ok-bg); border-color: var(--color-ok-line); }
.tag-muted { color: var(--ink-3); }

/* Email sender-identity block: a labelled row inside the senders section. */
.sender-id { padding-top: var(--s-16); border-top: 1px solid var(--line); margin-top: var(--s-16); }
.sender-id:first-of-type { padding-top: 0; border-top: 0; margin-top: 0; }
.sender-id-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.sender-id-head strong { font-weight: var(--fw-medium); }

/* Callout: an inline notice (info / warn / danger / ok). Reusable on any admin
   or account page above a form or section. */
.callout {
  border: 1px solid var(--line); border-left-width: 3px;
  border-radius: var(--r-md); padding: 14px 16px; margin-bottom: var(--s-16);
  background: var(--paper-2);
}
.callout :is(h3, h4) { margin: 0 0 4px; font-size: 13.5px; font-weight: var(--fw-medium); }
.callout p { margin: 0; font-size: 13px; color: var(--ink-2); line-height: 1.55; }
.callout p + p { margin-top: 8px; }
.callout-info   { border-left-color: var(--line-2); }
.callout-ok     { background: var(--color-ok-bg); border-color: var(--color-ok-line); border-left-color: var(--ok); }
.callout-ok :is(h3, h4), .callout-ok p { color: var(--color-ok-fg); }
.callout-warn   { background: var(--color-warn-bg); border-color: var(--color-warn-line); border-left-color: var(--warn); }
.callout-warn :is(h3, h4), .callout-warn p { color: var(--color-warn-fg); }
.callout-danger { background: var(--color-err-bg); border-color: var(--color-err-line); border-left-color: var(--bad); }
.callout-danger :is(h3, h4), .callout-danger p { color: var(--color-err-fg); }

/* Danger zone: red-edged cards with a typed-confirmation action row. Reusable
   anywhere a destructive bulk action needs guarding (settings maintenance, etc). */
.danger-zone { display: flex; flex-direction: column; gap: var(--s-16); }
.danger-card { border-color: var(--color-err-line); }
.danger-card .section-head > :is(h2, h3, h4) { color: var(--color-err-fg); }
.danger-action { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.danger-action .confirm-input { max-width: 180px; height: 40px; }
.danger-action .hint { font-size: 12px; }

/* Auth page */
.auth-panel { padding: 30px 28px; box-shadow: var(--shadow-md); }
@media (max-width: 420px) { .auth-panel { padding: 24px 20px; } }

/* Auth redesign - centred head, segmented toggle, OTP field.
   Shared by login / register / magic / verify / forgot / reset / confirmed. */
.auth-head { text-align: center; margin-bottom: var(--s-24); }
.auth-head h1 { font-size: var(--text-heading); margin: 0 0 6px; }
.auth-head p { margin: 0; color: var(--ink-3); font-size: 14px; line-height: 1.5; }

.seg {
  display: grid; grid-template-columns: 1fr 1fr; gap: 4px;
  background: var(--paper-2); border: 1px solid var(--line);
  border-radius: 10px; padding: 4px; margin-bottom: var(--s-24);
}
.seg a {
  text-align: center; padding: 9px 8px; border-radius: 7px;
  font-size: 13.5px; font-weight: var(--fw-medium); text-decoration: none; color: var(--ink-3);
}
.seg a:hover { color: var(--ink); text-decoration: none; }
.seg a.on { background: var(--card); color: var(--ink); box-shadow: 0 1px 2px rgba(27,27,29, .08); }

/* A .seg sized to sit in a panel header rather than to span an auth card: the
   same surface, on-state and hover, laid out to its own content. A modifier
   rather than a second block, so a change to the segmented treatment moves
   both and the two cannot drift into looking like different controls.
   The auth pages carry no .seg-inline, so they are untouched. */
.seg.seg-inline {
  display: inline-flex; grid-template-columns: none;
  margin: 0; flex: 0 0 auto; white-space: nowrap;
}
.seg.seg-inline a { padding: 6px 11px; font-size: 12.5px; }
/* MEASURED, not assumed: .seg's on-state is a --card fill plus a drop shadow,
   and in dark mode --card (#1A1A1D) is DARKER than the --paper-2 track
   (#1D1D20) by three points per channel while the shadow lands on near-black
   and does nothing at all. In headless Chrome the selected segment was
   indistinguishable from the unselected one - which on a filter switch means
   the reader cannot tell which set they are looking at.
   So the on-state is EDGED as well as filled, the fix section 9e already makes
   for the same inversion. --line-input rather than --line-2 because this ring
   is carrying the state identification by itself in dark mode (1.4.11 wants
   3:1): measured 3.55:1 against the fill and 3.43:1 against the track, where
   --line-2 is 1.77:1 against the track. In light mode the white-on-grey fill
   already carries it and the ring is merely a crisper edge. */
.seg.seg-inline a.on {
  box-shadow: inset 0 0 0 1px var(--line-input), 0 1px 2px rgba(27, 27, 29, .08);
}
/* The count rides inside the link, and .rolebar-count's own background is
   --paper-2, which is EXACTLY the .seg track - so on an off segment the pill
   would dissolve into the control and only its digits would show. --paper-3 is
   the one surface token that separates from BOTH the track and the --card of
   the on segment, in both themes and in the JS-off auto-dark fallback (where
   it is one of the tokens actually redefined). --ink-2 rather than the pill's
   default --ink-3: that pairing is 3.8:1 on --paper-3, and this is 11px text.
   Deliberately one rule for both states - an on-state pill painted --paper-2
   would land ~3 points per channel off --card in dark mode, which measures as
   no pill at all. */
.seg.seg-inline .rolebar-count {
  margin-left: 5px; background: var(--paper-3); color: var(--ink-2);
}

.label-row { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.label-row > label { margin: 0; }
.label-row a { font-size: 12.5px; color: var(--ink-3); text-transform: none; letter-spacing: 0; font-weight: var(--fw-regular); }

.code-input {
  letter-spacing: .5em; font-variant-numeric: tabular-nums; text-align: center;
  font-size: 22px; font-weight: var(--fw-medium);
}

.success-emblem {
  width: 56px; height: 56px; border-radius: 50%;
  display: grid; place-items: center; margin: 0 auto 18px;
  background: var(--ok); color: var(--on-ok);
}
.auth-form { display: flex; flex-direction: column; gap: 0; }
.auth-form .form-row { margin-bottom: var(--s-16); }
.auth-form button[type="submit"] { margin-top: var(--s-8); }
.auth-foot { text-align: center; margin: var(--s-16) 0 0; font-size: 13px; }

/* ---- MFA setup wizard (mfa-setup.ejs) ----
   Progressive enhancement: the inline script stamps [data-js] on .mfa-wizard and
   drives one step at a time. WITHOUT JS, the :not([data-js]) rules show every
   step stacked and hide the JS-only next/back buttons + progress, so the form
   still works. */
.mfa-progress { display: flex; align-items: center; justify-content: center; padding: 0; margin: 0 0 var(--s-24); }
.mfa-progress li { list-style: none; display: flex; align-items: center; }
.mfa-progress .step-dot {
  width: 28px; height: 28px; border-radius: 50%; display: grid; place-items: center;
  font-size: 13px; font-weight: var(--fw-medium); background: var(--paper-2);
  border: 1px solid var(--line); color: var(--ink-3); transition: background .2s, color .2s, border-color .2s;
}
.mfa-progress .step-bar { width: 34px; height: 2px; background: var(--line); margin: 0 3px; transition: background .2s; }
.mfa-progress li[data-state="current"] .step-dot { background: var(--ink); border-color: var(--ink); color: var(--paper); }
.mfa-progress li[data-state="done"] .step-dot { background: var(--ok); border-color: var(--ok); color: var(--on-ok); }
.mfa-progress .step-bar.done { background: var(--ok); }

.mfa-step { display: none; }
.mfa-step.on { display: block; }
.mfa-step h2 { font-size: 1.05rem; margin: 0 0 6px; }
.mfa-step > p { margin: 0 0 var(--s-16); color: var(--ink-3); font-size: 14px; line-height: 1.5; }

.mfa-wizard:not([data-js]) .mfa-step { display: block; }
.mfa-wizard:not([data-js]) .mfa-step + .mfa-step { margin-top: var(--s-24); padding-top: var(--s-24); border-top: 1px solid var(--line); }
.mfa-wizard:not([data-js]) .mfa-progress,
.mfa-wizard:not([data-js]) [data-mfa-next],
.mfa-wizard:not([data-js]) [data-mfa-back] { display: none; }

.mfa-app-list { list-style: none; padding: 0; margin: 0 0 var(--s-16); display: flex; flex-direction: column; gap: 8px; }
.mfa-app-list li { padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; font-size: 13.5px; color: var(--ink-2); }

.mfa-qr { text-align: center; margin: 0 0 var(--s-16); }
.mfa-qr img { border: 1px solid var(--line); border-radius: 10px; padding: 8px; background: #fff; }

.mfa-manual { margin: 0 0 var(--s-16); }
.mfa-manual > summary { cursor: pointer; font-size: 13px; color: var(--ink-3); text-align: center; list-style: none; }
.mfa-manual > summary::-webkit-details-marker { display: none; }
.mfa-manual > summary::before { content: "\25B8 "; font-size: 11px; }
.mfa-manual[open] > summary::before { content: "\25BE "; }
.mfa-manual > summary:hover { color: var(--ink); }
.mfa-key { margin-top: 10px; padding: 12px; background: var(--paper-2); border: 1px solid var(--line); border-radius: 8px; text-align: center; font-family: ui-monospace, monospace; font-size: 14px; letter-spacing: .06em; word-break: break-all; user-select: all; }

.mfa-nav { display: flex; gap: 10px; margin-top: var(--s-8); }
.mfa-nav .btn { flex: 1; }

/* Recovery codes screen (mfa-recovery.ejs) */
.mfa-codes { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 14px; margin: 0 0 var(--s-16); }
.mfa-codes span { font-family: ui-monospace, monospace; font-size: 15px; text-align: center; padding: 8px 4px; background: var(--paper-2); border: 1px solid var(--line); border-radius: 6px; user-select: all; }
.mfa-code-actions { display: flex; gap: 10px; margin-bottom: var(--s-16); }
.mfa-code-actions .btn { flex: 1; }

.oauth-stack { display: flex; flex-direction: column; gap: 10px; margin-bottom: var(--s-16); }
.oauth-btn {
  border: 1px solid var(--line); background: var(--card); color: var(--ink);
  font-weight: var(--fw-medium); border-radius: var(--r-md);
  padding: 11px 16px; justify-content: center;
}
.oauth-btn:hover { background: var(--paper-2); border-color: var(--line-2); }
/* SSO buttons stay visibly inert until the Turnstile check is solved. Pointer
   events remain on so the click handler can nudge the user to the widget. */
.oauth-btn.is-gated { opacity: .5; cursor: not-allowed; }
.oauth-btn.is-gated:hover { background: var(--card); border-color: var(--line); }
.oauth-glyph {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--paper-2); color: var(--ink);
  font-weight: var(--fw-medium); font-size: 12px;
  margin-right: 8px;
}
.auth-divider { display: flex; align-items: center; gap: 12px; color: var(--ink-3); font-size: 12px; margin: var(--s-16) 0; }
.auth-divider::before, .auth-divider::after { content: ""; flex: 1; height: 1px; background: var(--line); }

/* The fold-away "can't use the link?" escape hatch on the verify page. It is a
   fallback, so it reads as quiet secondary text until it is opened - the link
   in the email is the path we want people to take. */
.auth-fallback { margin-top: var(--s-16); border-top: 1px solid var(--line); padding-top: var(--s-16); }
.auth-fallback > summary {
  cursor: pointer; font-size: 13px; color: var(--ink-3); list-style: none;
}
.auth-fallback > summary:hover { color: var(--ink-2); }
.auth-fallback[open] > summary { color: var(--ink-2); margin-bottom: 2px; }
/* The rule above the fallback separates it from the primary action. On the
   post-signup "check your email" panel there is no primary action in the panel
   (the action is the link in the email), so with no Turnstile widget above it
   the fallback leads - and the divider would be a rule against nothing. */
.auth-panel > .auth-fallback:first-child { margin-top: 0; border-top: 0; padding-top: 0; }
.auth-secondary { margin-top: var(--s-16); }

/* Account-settings page: in-page section nav + linked-accounts list */
.settings-nav { display: flex; flex-wrap: wrap; gap: 8px; }
.settings-nav a {
  font-size: .85rem; font-weight: var(--fw-medium); color: var(--ink-2);
  border: 1px solid var(--line); border-radius: var(--r-md);
  padding: 5px 12px; background: var(--card); text-decoration: none;
}
.settings-nav a:hover { background: var(--paper-2); border-color: var(--line-2); }
.linked-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.linked-row {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card);
}
.linked-row .oauth-glyph { margin-right: 0; flex: 0 0 auto; }
.linked-meta { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
.linked-meta strong { font-weight: var(--fw-medium); }
.linked-meta .text-soft { font-size: .85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.phone-input { display: grid; grid-template-columns: 130px 1fr; gap: 8px; }
.phone-input select, .phone-input input {
  padding: 12px 12px; border: 1px solid var(--line); border-radius: var(--r-input);
  font: inherit; background: var(--card); color: var(--ink);
  transition: border-color .15s ease, box-shadow .15s ease;
}
.phone-input select:focus, .phone-input input:focus {
  outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus);
}
@media (max-width: 380px) { .phone-input { grid-template-columns: 110px 1fr; } }

/* ---------- Billing address block ----------
   The postcode sits beside its "Find address" button. The button is rendered
   `hidden` and revealed by public/js/address-lookup.js, so `auto` is the right
   track width: with no script there is no button and the input takes the row.

   The button is NOT stretched to the field height by a fixed value - it inherits
   the same vertical padding the inputs use, so a change to `--r-input` or the
   field padding keeps the two aligned instead of drifting a pixel at a time. */
.addr-find-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: start; }
.addr-find-row .btn { white-space: nowrap; }
/* Under ~420px the pair stacks: a "Find address" button squeezed beside a
   postcode field leaves neither wide enough to read. */
@media (max-width: 420px) { .addr-find-row { grid-template-columns: 1fr; } }
/* The status line reports what the lookup did. It is the only feedback the
   guest gets, so it reserves EXACTLY ONE LINE rather than shifting the fields
   below it every time a search runs.

   1.5em is the `.hint` line-height, not a guess: at its 11px font that is the
   16.5px a filled line actually measures, so an empty status and a filled one
   are the same height. The obvious `1.15em` reserved 12.65px and left a real
   ~4px jump under the cursor mid-search - measured in Chrome, and invisible to
   the hermetic suite, which has no layout engine. `1lh` says this directly but
   is unsupported on Firefox ESR 115, which is still in the field. */
.addr-block [data-addr-status] { display: block; min-height: 1.5em; }

/* The Places typeahead. The list is absolutely positioned so opening it never
   pushes the fields below it down the page - a form that reflows under the
   cursor while somebody is choosing is how a mis-click happens. */
.addr-search { position: relative; }
.addr-suggestions {
  position: absolute; z-index: 30; left: 0; right: 0; top: calc(100% + 4px);
  margin: 0; padding: 4px; list-style: none;
  max-height: 280px; overflow-y: auto;
  background: var(--card); color: var(--ink);
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  box-shadow: var(--shadow-md);
}
.addr-suggestion {
  display: block; padding: 8px 10px; border-radius: calc(var(--r-input) - 3px);
  cursor: pointer; line-height: 1.3;
  /* Transparent in the resting state so the edge below appears without moving
     the text sideways when a row becomes active. */
  border-left: 3px solid transparent;
}
/* Hover and keyboard land on the SAME rule, so the highlight a pointer user
   sees and the one an arrow-key user sees cannot drift apart.

   THE FILL IS --paper-3 AND THE EDGE IS --champagne, and both choices are
   forced rather than aesthetic. Two wrong answers were measured first:

     --paper-2, which is what ui.js's own dropdown uses for .lul-opt.is-active,
     resolves to #1C1A14 against a #1A1812 popover in dark mode - two points per
     channel, which is no highlight at all. A keyboard user arrowing through the
     list in the dark cannot see where they are.

     --gold-4, the token the admin tables use for a hovered row, is correct
     under html[data-theme="dark"] and WRONG in the JS-off auto-dark fallback:
     the `@media (prefers-color-scheme: dark) html:not([data-theme])` block
     redefines only a SUBSET of the palette and --gold-4 is not in it, so it
     stays #F8F5ED - a near-white row under near-white text.

   --paper-3 and --champagne are redefined in BOTH dark paths, so they separate
   from --card in all three states. The edge is what makes the selection
   unmistakable without depending on a fine background delta surviving a future
   palette change. */
.addr-suggestion:hover,
.addr-suggestion.is-active {
  background: var(--paper-3);
  border-left-color: var(--champagne);
}
.addr-suggestion-main { display: block; font-weight: 600; font-size: .94rem; }
.addr-suggestion-sub  { display: block; font-size: .82rem; color: var(--ink-3); margin-top: 1px; }
/* The browser's own clear (x) button inside type=search is deliberately KEPT.
   An earlier version of this block tried to suppress it; the rule did not
   apply (measured: appearance still resolved to `auto`), and dead CSS that
   looks like it is doing something is worse than none. It also earns its
   place - clearing a half-typed address search is exactly what somebody wants
   after picking the wrong suggestion. */

/* Every rule reads the same way: red until met, green once met. The optional
   symbol rule used to start neutral grey, which made a half-finished checklist
   look like three different states at once - it now says "(optional)" in its
   label instead and shares the red/green pair. */
.pw-checks { list-style: none; padding: 8px 0 0; margin: 0; display: grid; gap: 4px; }
.pw-checks li { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--bad); }
.pw-checks li .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--bad); flex-shrink: 0; transition: background-color .2s ease; }
.pw-checks li.ok { color: var(--ok); }
.pw-checks li.ok .dot { background: var(--ok); }

/* ---------- Panel / Summary ----------
   A panel is the workhorse surface. It separates from the page by being WHITE
   on gray plus a whisper of elevation, rather than by a hard 1px rule doing
   all the work - which is what it had to do back when the card and the page
   were the same colour. */
.panel {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: var(--pad-card);
  margin-bottom: var(--gap-block);
  box-shadow: var(--shadow-xs);
}
.panel h2 { margin-bottom: var(--s-16); font-size: var(--text-heading); font-weight: var(--fw-medium); }
.panel h3 { margin-bottom: 12px; }
/* The header gets a rule under it so a panel's title band reads as the header
   of what follows, not as its first line. Deliberately NOT full-bleed (no
   negative margins): .panel-head is used mid-panel in a number of views, and
   several pass inline padding/border overrides that would leave a bled header
   hanging outside its card. */
.panel-head {
  display: flex; justify-content: space-between; align-items: center;
  gap: 12px; flex-wrap: wrap;
  margin-bottom: var(--s-16); padding-bottom: 14px;
  border-bottom: 1px solid var(--line);
}
.panel-head :is(h2, h3, h4) { font-size: 16px; font-weight: var(--fw-semibold); letter-spacing: -0.01em; }

/* ---------- Admin page sections ----------
   The single biggest reason an admin page read as "overwhelming": every card
   on it had the same weight, so a page of a dozen surfaces offered the eye no
   place to land and no order to read them in. A section gives a group of cards
   ONE name and one band of whitespace around it, so a long page becomes three
   or four things instead of twelve.

   The rhythm is the point. Cards inside a section sit --gap-tile apart; the
   sections themselves sit --gap-section apart, which is nearly 3x that. When
   those two numbers are close (the old 12px vs 18px) the grouping is invisible
   no matter how the cards are labelled. */
.admin-section { margin-bottom: var(--gap-section); }
.admin-section:last-child { margin-bottom: 0; }
.admin-section > :last-child { margin-bottom: 0; }

.admin-section-head {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  margin-bottom: var(--gap-tile);
}
.admin-section-head h2 {
  margin: 0; font-size: 13px; font-weight: var(--fw-semibold);
  text-transform: uppercase; letter-spacing: 0.09em; color: var(--ink-2);
  display: inline-flex; align-items: center; gap: 9px;
}
/* A 3px colour tab in front of the section name. This is where most of the
   page's colour comes from: it costs no chrome, and it means the sections are
   told apart peripherally, before any label is read. */
.admin-section-head h2::before {
  content: ""; width: 3px; height: 15px; border-radius: 2px;
  background: var(--sec-accent, var(--gold)); flex: none;
}
.admin-section-head .admin-section-note {
  font-size: 12px; color: var(--ink-3); font-weight: var(--fw-regular);
  text-transform: none; letter-spacing: 0;
}
/* The rule fills whatever the head does not, so the section reads as a band
   across the page rather than a floating caption. */
.admin-section-head::after {
  content: ""; flex: 1 1 40px; height: 1px; background: var(--line); min-width: 24px;
}
.admin-section-head .admin-section-actions { display: flex; gap: 8px; flex: 0 0 auto; }
/* Accent presets, so a view names the meaning rather than picking a hex. */
.admin-section.sec-money     { --sec-accent: var(--ok); }
.admin-section.sec-attention { --sec-accent: var(--bad); }
.admin-section.sec-activity  { --sec-accent: var(--sky); }
.admin-section.sec-brand     { --sec-accent: var(--gold); }
.admin-section.sec-info      { --sec-accent: var(--violet); }

/* ---------- Content accordion (Admin -> Website content) ----------
   Each public page is a collapsible <details> so the editor opens one page
   at a time instead of scrolling past every form. Built on native
   <details>/<summary> (keyboard + screen-reader accessible, no JS), styled
   to read like a panel. The chevron mirrors the sidebar nav-group pattern:
   points down when collapsed, up when open. */
.content-accordion { display: flex; flex-direction: column; gap: var(--s-16); }
.content-section {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  overflow: hidden;
}
.content-section > summary {
  list-style: none;            /* hide default disclosure triangle (standards) */
  display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap;
  padding: var(--s-16) var(--s-24);
  cursor: pointer; user-select: none;
  transition: background .15s ease;
}
.content-section > summary::-webkit-details-marker { display: none; } /* hide default triangle (WebKit) */
.content-section > summary:hover { background: var(--paper-2); }
.content-section > summary:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.content-section-title { font-size: var(--text-heading); font-weight: var(--fw-medium); }
.content-section-sub { color: var(--ink-3); font-size: 13px; }
.content-section-chevron {
  margin-left: auto; align-self: center;
  font-size: 14px; line-height: 1; color: var(--ink-3);
  transition: transform .2s ease;
  transform: rotate(180deg);   /* collapsed: chevron points down */
}
.content-section[open] > summary .content-section-chevron { transform: rotate(0deg); }
.content-section[open] > summary { border-bottom: 1px solid var(--line); }
.content-section-body { padding: var(--s-24); }
.content-section-body > form:last-child > .btn:last-child,
.content-section-body > form > button:last-child { margin-bottom: 0; }

.checkout { display: grid; grid-template-columns: 1fr; gap: var(--s-24); }
@media (min-width: 960px) { .checkout { grid-template-columns: 1fr 340px; } }
.summary {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  padding: var(--s-24);
  position: sticky; top: calc(var(--nav-h) + 16px); align-self: start;
}
.summary h3 { margin-bottom: var(--s-16); font-size: var(--text-subheading); font-weight: var(--fw-medium); }
.summary .line { display: flex; justify-content: space-between; padding: 6px 0; color: var(--ink-3); font-size: 13px; }
.summary .line strong { color: var(--ink); font-weight: var(--fw-medium); }
.summary hr { border: 0; border-top: 1px solid var(--line); margin: 8px 0; }
.summary .total { font-family: var(--font-display); font-size: 22px; font-weight: var(--fw-medium); color: var(--ink); letter-spacing: -0.02em; }

/* ---------- Steps / Stepper ---------- */
.steps {
  display: flex; align-items: center;
  gap: 8px; margin-bottom: var(--s-24);
  flex-wrap: wrap;
  padding: 16px 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  background: var(--paper-2);
  margin-left: -16px; margin-right: -16px;
  padding-left: 16px; padding-right: 16px;
}
@media (min-width: 600px) { .steps { margin-left: -24px; margin-right: -24px; padding-left: 24px; padding-right: 24px; } }
.step {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 0;
  background: transparent; border: 0;
  color: var(--ink-3);
  font-size: 11px; font-weight: var(--fw-regular);
  letter-spacing: 0.06em; text-transform: uppercase;
}
.step .num {
  display: inline-flex; width: 22px; height: 22px; border-radius: 50%;
  background: transparent; color: var(--ink-3);
  border: 1px solid var(--line-2);
  align-items: center; justify-content: center;
  font-family: var(--font-body); font-size: 11px; font-weight: var(--fw-medium);
}
.step.active { color: var(--ink); font-weight: var(--fw-medium); }
.step.active .num { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.step.done { color: var(--teal-2); }
.step.done .num { background: var(--teal); color: var(--on-ok); border-color: var(--teal); }
.step + .step::before {
  content: ""; display: inline-block;
  width: 24px; height: 1px; background: var(--line-2);
  margin: 0 4px 0 -4px; vertical-align: middle;
}
a.step { text-decoration: none; cursor: pointer; }
/* The step bar reads as one continuous rail across the journey, so it must not
   collapse into a ragged two-line block on a narrow phone. Below 600px the
   labels go, leaving four numbered dots and the connecting rule - still a
   progress indicator, at a third of the width. */
@media (max-width: 600px) {
  .steps {
    display: flex; align-items: center; flex-wrap: nowrap;
    gap: 0; justify-content: space-between;
    padding: 12px 16px;
    /* NOWRAP HAS TO BE ABLE TO OVERFLOW SOMEWHERE, and without this it
       overflowed the PAGE. This block was written for a four-step rail (its own
       comment says so twice); a multi-room reservation has five, and five
       labelled steps do not fit 390px however small the type is - so the rail
       pushed the whole document 57px sideways, taking the room board and the
       summary with it. Scrolling the rail keeps every step reachable and keeps
       the overflow inside the one element it belongs to. The scrollbar is
       hidden because this band is page furniture, not content: it would draw a
       grey rule through the middle of the rail on every phone. */
    overflow-x: auto;
    scrollbar-width: none;
  }
  .steps::-webkit-scrollbar { display: none; }
  /* Labels stay - four bare numbered dots would say where you are without
     saying what it is. They shrink, and the connecting rule takes up the slack
     so the rail still spans the full width. */
  .step { flex: 0 0 auto; font-size: 10px; gap: 5px; }
  .step .num { width: 20px; height: 20px; font-size: 10px; }
  .step + .step::before { width: auto; flex: 1 1 auto; min-width: 6px; margin: 0 5px; }
}

/* The checkout countdown, continuing the step rail rather than sitting in a box
   of its own. It is the same full-bleed band and the same --paper-2 ground, so
   "where am I" and "how long have I got" read as one piece of page furniture;
   .steps-attached hands its bottom border down to here.

   A card was the reflex and is wrong twice over: chrome on this site means
   "this is for sale" (the room and payment cards), and a boxed warning above
   the form would out-shout the thing the guest is meant to be filling in. */
.steps-attached { margin-bottom: 0; border-bottom: 0; }
.hold-timer {
  display: flex; align-items: baseline; gap: 10px;
  padding: 10px 16px 12px;
  margin: 0 -16px var(--s-24);
  background: var(--paper-2);
  border-bottom: 1px solid var(--line);
  font-size: 13.5px; color: var(--ink-2);
}
@media (min-width: 600px) { .hold-timer { margin-left: -24px; margin-right: -24px; padding-left: 24px; padding-right: 24px; } }
.hold-timer-icon { flex: 0 0 auto; font-size: 14px; line-height: 1; color: var(--ink-3); }
.hold-timer-text { min-width: 0; }
.hold-timer-clock {
  font-weight: var(--fw-medium); color: var(--ink);
  /* Proportional digits reflow the sentence after them on every tick, so the
     note visibly twitches once a second for half an hour. */
  font-variant-numeric: tabular-nums;
}
/* The note is a second sentence, not a second voice: it sits on the same line
   on a wide screen and wraps under on a phone. */
.hold-timer-note { color: var(--ink-3); }
@media (max-width: 560px) { .hold-timer-note { display: block; margin-top: 2px; } }

/* Under five minutes. --warn-2 and NOT --warn: the amber token is a FILL value
   (3:1 is all a non-text object owes) and fails AA as text on a light ground -
   see the palette contract. The band itself stays as it was, because repainting
   a strip amber above a form the guest is mid-way through reads as an error
   they have made rather than a clock. */
.hold-timer.is-urgent { color: var(--warn-2); }
.hold-timer.is-urgent .hold-timer-clock { color: var(--warn-2); }
.hold-timer.is-urgent .hold-timer-icon { color: var(--warn-2); }

/* The nav note's urgent state. It is already --ink-3 at 10.5px, so the only
   change is the hue - and on the dark hero the amber TEXT value is the wrong
   one, since --warn-2 is tuned for a pale ground. There the fill value IS the
   readable choice. */
.book-cta-note.is-urgent { color: var(--warn-2); }
body.has-dark-hero .nav-cta .book-cta-note.is-urgent,
body.has-hero-full .mobile-menu .book-cta-note.is-urgent { color: var(--warn); }

/* ---------- Booking funnel (shared page chrome) ----------
   Every step of the journey renders the same four components in the same
   order: partials/checkout-steps (where am I), partials/page-head (what is
   this page), partials/checkout-summary (what am I buying) and
   partials/checkout-actions (how do I go on). Before this each page invented
   its own, so a guest crossed four differently-shaped pages to buy one room.
   Style them HERE - the funnel views carry no inline styles by design, and
   test/booking-funnel.test.js keeps it that way. */
.page-head { display: flex; justify-content: space-between; align-items: flex-start; gap: var(--s-16); flex-wrap: wrap; margin-bottom: var(--s-32); }
.page-head-text { min-width: 0; }
.page-head-eyebrow { margin: 0 0 10px; }
.page-head-title { margin: 0; max-width: 20ch; }
.page-head-lede { margin: 12px 0 0; max-width: 56ch; font-size: 15.5px; line-height: 1.5; color: var(--ink-2); }
.page-head-action { flex: 0 0 auto; }

/* Summary: identity block, then meta, then money. The room is a THING the
   guest picked, so it gets a picture and a name rather than another table row. */
.summary-eyebrow { margin: 0 0 12px; }
.summary-room { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 14px; }
.summary-room-thumb {
  width: 64px; height: 64px; flex: 0 0 auto;
  border-radius: var(--r-md); overflow: hidden; position: relative;
  background: var(--paper-2);
}
.summary-room-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.summary-room-thumb-empty {
  position: absolute; inset: 0;
  background-image: repeating-linear-gradient(135deg, rgba(20,18,16,.06) 0 1px, transparent 1px 12px);
}
.summary-room-text { min-width: 0; }
.summary-room-name { margin: 0 0 4px; font-size: 16px; font-weight: var(--fw-medium); line-height: 1.25; }
.summary-room-sub { margin: 0; font-size: 13px; color: var(--ink-3); }
.summary-units { list-style: none; margin: 0 0 12px; padding: 10px 12px; background: var(--paper-2); border-radius: var(--r-md); }
/* Two columns, and the party wraps onto its own row underneath. It was one
   flex row of three items, which is fine until a fourth (the room's price)
   joins it: in a ~300px aside that leaves every cell too narrow to hold its own
   words and the row breaks in four places. The price keeps the right edge to
   itself so a column of them reads as a column. */
.summary-units li {
  display: grid; grid-template-columns: 1fr auto; column-gap: 10px;
  font-size: 13px; color: var(--ink-3); padding: 3px 0;
}
.summary-units li + li { border-top: 1px solid var(--line); margin-top: 3px; padding-top: 6px; }
.summary-unit-label { color: var(--ink-2); }
.summary-unit-price { color: var(--ink-2); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
.summary-unit-party { grid-column: 1 / -1; color: var(--ink-3); }
.summary-line-soft { font-size: 13px; }
.summary-note { margin: 12px 0 0; font-size: 13px; color: var(--ink-3); }

/* Actions. On desktop: back left, continue right, at the foot of the column.
   On a phone: pinned to the bottom of the viewport with the running total, so
   the one control the guest is looking for is never below the fold of a long
   form. */
.checkout-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: var(--s-32); }
.checkout-actions-primary { margin-left: auto; }
.checkout-actions-total { display: none; }
@media (max-width: 760px) {
  .checkout-actions {
    position: sticky; bottom: 0; z-index: 20;
    margin: var(--s-24) -16px 0;
    padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
    background: var(--card);
    border-top: 1px solid var(--line);
    box-shadow: 0 -6px 24px -18px rgba(27,27,29,.5);
    flex-wrap: nowrap;
  }
  .checkout-actions-total { display: block; flex: 1 1 auto; min-width: 0; line-height: 1.15; }
  .checkout-actions-total-label { display: block; font-size: 10px; text-transform: uppercase; letter-spacing: .1em; color: var(--ink-3); }
  .checkout-actions-total-value { display: block; font-family: var(--font-display); font-size: 18px; font-weight: var(--fw-medium); letter-spacing: -0.02em; }
  .checkout-actions-primary { flex: 0 1 auto; margin-left: 0; }
  /* With the total and the primary CTA both present a phone has no room for a
     third control - the step bar and the browser's own back button both go
     back, so the text link is the one to drop. */
  .checkout-actions-total ~ .checkout-actions-back { display: none; }
}

/* Guest cards on the details step. One card per person, headed by the same
   initial-avatar the confirmation and the admin record use, so a guest is
   recognisably the same object everywhere in the product. */
.occupant-room-heading { margin: var(--s-24) 0 8px; color: var(--teal-2); }
.occupant-room-heading:first-of-type { margin-top: 0; }
.occupant-head { display: flex; align-items: center; gap: 12px; margin-bottom: var(--s-16); }
.occupant-role { margin: 0; font-size: 13px; font-weight: var(--fw-medium); color: var(--ink); }
/* Name takes the room, date and gender share the second line. Collapses to one
   column on a phone via the global .form-grid mobile rule. */
@media (min-width: 700px) { .occupant-grid { grid-template-columns: 2fr 1fr 1fr; } }
/* The native radio stays in the DOM and stays the source of truth (see §15c):
   it is transparent, NOT display:none, or Chrome refuses to submit a form
   holding an invalid control it cannot focus. */
/* The native radio stays in the DOM at its full size (transparent, NOT
   display:none and NOT 0x0) or Chrome refuses to submit a form holding an
   invalid control it cannot focus - and this one is `required`. It is
   position:absolute so it contributes no box to the flex row.

   The selector is attribute-qualified deliberately. `.segmented-input` alone is
   (0,1,0) and LOSES to the `input[type="radio"]` base rule in section 9
   (0,1,1, and declared later), which re-sets it to position:relative at 18x18 -
   an invisible box in front of each label, so the selected pill sat about 22px
   right of where it belonged and the last option overhung the track. */
input[type="radio"].segmented-input,
input[type="checkbox"].segmented-input { position: absolute; opacity: 0; min-height: 0; margin: 0; pointer-events: none; }
.hint-err { color: var(--bad); }

/* Panel furniture. Each of these was re-typed as an inline style on every page
   that needed it, which is why the same "secondary heading" came out at three
   different sizes across the funnel. */
.panel-lede { margin-bottom: 14px; }
.panel-note { font-size: 13px; margin-top: 12px; }
.panel-title-sm { font-size: 17px; margin-bottom: 12px; }
.panel-title-split { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
.panel-title-note { font-size: 11px; color: var(--ink-3); font-weight: var(--fw-regular); display: inline-flex; align-items: center; gap: 6px; }
.pay-unavailable-note { margin: -8px 0 20px; font-size: 13px; }

/* A selectable add-on: description on the left, price on the right. */
.choice-sub { margin-bottom: 0; }
.choice-price { text-align: right; font-weight: var(--fw-medium); font-size: 1.15rem; }
.free-tag { font-weight: var(--fw-regular); font-size: .9rem; }

/* An applied (or offered) saving, with its own remove/apply control. */
.savings-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; padding: 10px 12px; background: var(--paper-2); border-radius: var(--r-md); font-size: 13px; }
.savings-row-text { flex: 1; }
.inline-form { margin: 0; }
.field-inline {
  flex: 1; padding: 11px 14px;
  border: 1px solid var(--line); border-radius: var(--r-input);
  font: inherit; background: var(--card); color: var(--ink);
}
.field-inline:focus { outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus); }
/* The payment-plan radios are kept in the DOM (the Stripe script reads
   input[name="method"]) but moved off-screen rather than display:none'd - see
   the native-UI rules in §15c. */
.offscreen-radios { position: absolute; left: -9999px; }

/* The room photo on the confirmation. This is the page the customer keeps and
   screenshots, and the room they just spent four figures on was an 88px
   thumbnail on it - smaller than the guest avatars underneath. */
.confirm-room-photo {
  width: 100%; aspect-ratio: 16 / 7;
  border-radius: var(--r-md); overflow: hidden;
  background: var(--paper-2);
  margin-bottom: var(--s-16);
}
.confirm-room-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
@media (max-width: 600px) { .confirm-room-photo { aspect-ratio: 16 / 9; } }

/* The rest of the booking record's furniture. */
.booking-lede { max-width: 58ch; }
.room-name { margin: 0; font-size: 20px; font-weight: var(--fw-medium); }
.room-name-count { font-size: 13px; }
.booking-total-row { align-items: baseline; margin-top: var(--s-16); }
.booking-total-label { font-size: 12px; }
.booking-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: var(--s-16); }
.booking-panel-note { font-size: 12px; }
.booking-paid-date { font-size: 12px; }
.guest-room-heading { margin: 4px 0 2px; color: var(--teal-2); }
.booking-help { text-align: center; margin-top: var(--s-24); }
.booking-footer-actions { margin-top: var(--s-16); }
/* A count or an attention dot on a tab, so "you still owe us your flight
   numbers" is visible without opening the tab that says so. */
.subtab-count {
  margin-left: 6px; padding: 1px 6px; border-radius: 999px;
  background: var(--paper-2); color: var(--ink-3);
  font-size: 11px; font-weight: var(--fw-medium);
}
.subtab-dot {
  display: inline-block; width: 6px; height: 6px; border-radius: 50%;
  background: var(--bad); margin-left: 6px; vertical-align: middle;
}

/* ---------- Tables ---------- */
/* No overflow:hidden here - it would make the TABLE itself the sticky
   scroll-container and the header would pin to the table top instead of the
   window. Radius dropped for the same reason (it needs overflow to clip). */
/* border-collapse:separate (not collapse) is deliberate: Chromium does not paint
   a sticky th's background/border over scrolling rows when the table is
   border-collapse:collapse, so content bleeds through the pinned header. With
   `separate` + zero spacing it looks identical but the sticky header stays opaque. */
.table { width: 100%; border-collapse: separate; border-spacing: 0; background: var(--card); border: 1px solid var(--line); min-width: 560px; }
.table th, .table td { padding: 12px 14px; text-align: left; border-bottom: 1px solid var(--line); font-size: 13px; }
/* Sticky column headers, pinned to the top of the VIEWPORT - just below the
   sticky page header bar (.admin-head / .nav). --stick-top is that bar's height,
   measured in main.js and set on <html> (falls back to 0). The header has a
   fixed height so the per-room subheader (variations) can pin exactly beneath
   it. nowrap stops narrow columns wrapping "ACTIVE" into stacked letters.
   border-collapse drops the cell border when stuck, so the divider is an inset
   box-shadow that travels with the cell. */
.table th {
  /* --table-stick-top = page-bar height (+ sticky tabs, if any). Falls back to
     the bare page-bar height on tab-less pages. Set in main.js. */
  position: sticky; top: var(--table-stick-top, var(--stick-top, 0px)); z-index: 3;
  height: 42px; white-space: nowrap;
  background: var(--paper-2);
  color: var(--ink-3);
  font-weight: var(--fw-semibold);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  box-shadow: inset 0 -1px 0 var(--line-2);
}
.table tr:last-child td { border-bottom: 0; }
/* A hover tint distinct from the header's (both were --paper-2, so hovering a
   row made it read as a second header row). */
.table tr:hover td { background: var(--gold-4); }
.table .mono, .table code { font-family: var(--font-mono); font-size: 12px; }

/* ---------- Bulk-action bar (multi-select tables) ---------- */
.bulk-bar {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin-bottom: 12px; padding: 10px 14px;
  background: var(--paper-2); border: 1px solid var(--line); border-radius: 8px;
}
.bulk-bar .bulk-count { margin-right: auto; }

/* ---------- Variations editor ---------- */
.help-panel > summary { cursor: pointer; font-weight: var(--fw-medium); }
.help-panel .help-grid {
  display: grid; gap: 18px; margin-top: 14px;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.help-panel .help-grid h4 { margin: 0 0 5px; font-size: 13px; }
.help-panel .help-grid p { margin: 0; font-size: 13px; color: var(--ink-2); line-height: 1.5; }
/* ---------- Admin -> Room pricing & variations ----------
   Scoped on [data-table-mount="variations"], the hook the shared data-table
   partial emits. It was scoped on `.variations-table` - a class that stopped
   being rendered when this page moved onto the shared table package, so every
   rule here had been dead for as long as the page has looked the way it does.
   The band pills in particular were unstyled, which is why they read as a wall
   of text once each one started carrying a price.
   Two rules went with the rename rather than being re-pointed: `.group-row`
   (per-room sticky sub-headers, replaced by sorting the table by room) and
   `.party-edit` (a two-column labelled party editor, now two plain spinners in
   their own cells). Neither markup exists any more. */
[data-table-mount="variations"] .row-inactive { opacity: .55; }
/* The room's capacity sits directly under the room name and code, so it was a
   third quiet line in a stack of quiet lines: 11px on the weakest text token,
   under a 0.82rem muted code, with nothing between them. It is the number the
   Adults/Children spinners two cells over are bounded by, so it has to be
   readable at a glance - one step up in size, off --ink-3, and given a line of
   its own to sit on. Deliberately NOT emphasised with markup: the cell's
   sentence is pinned as a literal by test/variations-page.test.js, and a
   <strong> around the figure buys less than the size and colour already do. */
[data-table-mount="variations"] .hint { display: inline-block; margin-top: 3px; font-size: 12px; line-height: 1.4; color: var(--ink-2); }
[data-table-mount="variations"] .band-readout {
  display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; max-width: 260px;
}
[data-table-mount="variations"] .band-readout .pill {
  font-size: 9.5px; padding: 2px 7px; text-transform: none; letter-spacing: 0;
}
/* The price inside a band pill is the reason the pill is there: same children
   count, different ages, different money. Give it the weight to be read first. */
[data-table-mount="variations"] .band-readout .pill .mono {
  font-weight: var(--fw-medium); color: var(--ink-1);
}
/* Never white-on-gold, and never the brand gold as text (see CLAUDE.md s18). */
[data-table-mount="variations"] .text-bad { color: var(--bad); }

/* The age-band dropdowns and the readout under them.

   A variation is one age split since migration 083, so the bands are ordinary
   fields - four dropdowns in a row on the add form, four per row in the table -
   and need no widget of their own. What DOES need styling is the readout, which
   is the control's whole feedback loop: it names the party, prices it, and says
   whether the split is already taken or absent from the client's workbook,
   before the form is submitted rather than as a flash message after it.

   Three tones, and the colours are load-bearing rather than decorative: ok is
   quiet, warn means "this works but the client's sheet does not list it", bad
   means "this cannot be added". Text colours use the --*-2 tokens, never the
   fill hues - --warn and --gold are 3:1 graphic colours and fail as text on the
   page (CLAUDE.md s18). */
.split-readout {
  margin: 0; font-size: 13px; color: var(--ink-2);
  padding: 8px 12px; border-radius: var(--r-sm);
  border: 1px solid var(--line-input); background: var(--paper-2);
}
.split-readout:empty { display: none; }
.split-readout.is-warn { border-color: var(--warn); color: var(--warn-2); }
.split-readout.is-bad  { border-color: var(--bad);  color: var(--bad); }

/* The dropdowns are narrow and sit in a dense table, so they get the same
   tabular alignment the money columns use - a column of single digits that
   drifts is harder to scan than one that does not. */
select.split-band { font-family: var(--font-mono); font-feature-settings: 'tnum' 1; }
/* An option removed for not fitting simply vanishes; one that fits but is
   already taken stays and is disabled, so it has to READ as unavailable rather
   than merely fail to respond. */
select.split-band option:disabled { color: var(--ink-3); }

/* ---------- Badges ---------- */
.badge {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 4px 10px;
  border-radius: var(--r-pill);
  font-family: var(--font-body); font-weight: var(--fw-medium);
  font-size: 10.5px; letter-spacing: 0.08em; text-transform: uppercase;
  border: 1px solid transparent;
}
.badge-ok    { background: var(--color-ok-bg);   color: var(--color-ok-fg);   border-color: var(--color-ok-line); }
.badge-warn  { background: var(--color-warn-bg); color: var(--color-warn-fg); border-color: var(--color-warn-line); }
.badge-err   { background: var(--color-err-bg);  color: var(--color-err-fg);  border-color: var(--color-err-line); }
.badge-soft  { background: var(--paper-2); color: var(--ink-3); border-color: var(--line); }
/* badge-info marks a role (admin / staff). It was the same neutral gray as
   badge-soft (customer), so the two roles it exists to distinguish looked
   identical; it now carries the violet informational accent. */
.badge-info  { background: var(--color-info-bg); color: var(--color-info-fg); border-color: var(--color-info-line); }
.badge-gold  { background: var(--color-gold-bg); color: var(--color-gold-fg); border-color: var(--color-gold-line); }
.badge-sky   { background: var(--color-sky-bg);  color: var(--color-sky-fg);  border-color: var(--color-sky-line); }
/* Solid, for the one chip on a page that must out-shout the rest. */
.badge-alert { background: var(--bad); color: var(--on-bad); border-color: var(--bad); }

/* ---------- Image lightbox ---------- */
.is-zoomable { cursor: zoom-in; }
.lightbox {
  position: fixed;
  top: 0; right: 0; bottom: 0; left: 0; /* Safari < 14.1 lacks `inset` shorthand */
  inset: 0; z-index: 1000;
  display: flex; align-items: center; justify-content: center;
  background: rgba(12, 11, 9, 0.92); padding: 24px;
}
.lightbox[hidden] { display: none; }
.lightbox-stage { margin: 0; max-width: 92vw; max-height: 88vh; display: flex; flex-direction: column; align-items: center; gap: 10px; }
.lightbox-img { max-width: 92vw; max-height: 80vh; object-fit: contain; border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); }
/* A clip is letterboxed, never cropped: the whole frame is the point once
   somebody has opened it, and the browser draws its controls over the bottom
   of that box. `background: #000` fills the letterbox rather than showing the
   overlay's own scrim through it. */
.lightbox-video {
  max-width: 92vw; max-height: 80vh; background: #000;
  border-radius: 8px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.lightbox-caption { color: rgba(255,255,255,0.8); font-size: 13px; text-align: center; }
.lightbox-caption[hidden] { display: none; }
.lightbox-close, .lightbox-nav {
  position: absolute; background: rgba(255,255,255,0.14); color: #FFFFFF;
  border: 1px solid rgba(255,255,255,0.2); border-radius: 50%;
  width: 44px; height: 44px; font-size: 26px; line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
.lightbox-close:hover, .lightbox-nav:hover { background: rgba(255,255,255,0.2); }
.lightbox-close { top: 18px; right: 18px; }
.lightbox-prev { left: 18px; top: 50%; transform: translateY(-50%); }
.lightbox-next { right: 18px; top: 50%; transform: translateY(-50%); }
.lightbox-nav[hidden] { display: none; }
/* The foot of the overlay: pause control and position, together. They answer
   one question between them - is this moving, and where am I in it - so they
   share a row rather than the button becoming unrelated chrome in a corner. */
.lightbox-controls {
  position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
  display: flex; align-items: center; gap: 12px;
}
.lightbox-counter { color: rgba(255,255,255,0.7); font-size: 12px; letter-spacing: 0.05em; }
.lightbox-counter[hidden] { display: none; }
.lightbox-toggle {
  background: rgba(255,255,255,0.14); color: #FFFFFF;
  border: 1px solid rgba(255,255,255,0.2); border-radius: 50%;
  width: 34px; height: 34px; cursor: pointer;
  display: flex; align-items: center; justify-content: center; padding: 0;
}
.lightbox-toggle:hover { background: rgba(255,255,255,0.2); }
.lightbox-toggle[hidden] { display: none; }
/* Drawn, not lettered: a glyph depends on a font having loaded and on the
   platform not substituting an emoji for it. Same reason .gal-play is a CSS
   triangle. The paused state is one bar plus its own shadow. */
.lightbox-toggle::before {
  content: ''; display: block;
  width: 0; height: 0; margin-left: 3px;
  border-left: 10px solid currentColor;
  border-top: 6px solid transparent; border-bottom: 6px solid transparent;
}
.lightbox-toggle.is-playing::before {
  width: 3px; height: 12px; margin-left: -4px; border: 0;
  background: currentColor; box-shadow: 7px 0 0 currentColor;
}
/* The countdown to the next slide, so a page that moves on by itself has said
   in advance that it was going to. A clip slide hides it - the clip's own
   length is the countdown there, and there is no timer to describe. */
.lightbox-progress {
  position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: rgba(255,255,255,0.12); overflow: hidden;
}
.lightbox-progress[hidden] { display: none; }
.lightbox-progress-bar {
  height: 100%; background: var(--gold);
  transform: scaleX(0); transform-origin: left center;
  animation: lul-lb-progress 6000ms linear forwards;
}
/* Paused, the bar resets rather than freezing part-drawn: the timer is cleared
   outright, so resuming gives the slide its full time again and a bar left
   half-full would be describing time that no longer counts. */
.lightbox.is-paused .lightbox-progress-bar { animation: none; transform: scaleX(0); }
@keyframes lul-lb-progress { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@media (prefers-reduced-motion: reduce) {
  .lightbox-progress-bar { animation: none; transform: scaleX(0); }
}
@media (max-width: 640px) {
  .lightbox-close, .lightbox-nav { width: 38px; height: 38px; font-size: 22px; }
  .lightbox-prev { left: 8px; } .lightbox-next { right: 8px; }
}

/* ================= Admin -> Support tickets =================================
   A support queue is not a report, and this page stopped being one.

   It used to render the same 25 tickets TWICE: a wide sortable table, then 25
   stacked <details> cards below it, each holding a whole conversation and two
   forms. A row click jumped you a long way down into the second copy, a page
   of 25 open tickets was 25 conversations deep, and the queue and the thread
   could never be looked at together.

   It is now one list and one conversation: a queue rail that stays pinned,
   and a detail pane beside it. Selecting a ticket is an ordinary link
   (?open=<id>), so the page works with JavaScript off; script only adds
   keyboard triage.

   Colour here is LOAD-BEARING, not decoration: amber is waiting on us, gold
   is flagged, green has been satisfied, red is a real problem. Before that
   rule existed the page painted only the P1/P2 tier chip, so state had to be
   read off a badge.
   ------------------------------------------------------------------------- */

.tier {
  display: inline-flex; align-items: center;
  font-size: 11px; font-weight: var(--fw-medium);
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border-radius: var(--r-sm, 6px);
  background: var(--paper-3); color: var(--ink-2);
  border: 1px solid var(--line-2);
  white-space: nowrap;
}
/* Both classes required. The ticket CARD carries the same `tier-p1` class to
   pick up its left-edge colour, and a bare `.tier-p1 { color }` would then be
   inherited by every descendant of the card - the whole conversation rendered
   in SLA red. Scoping to `.tier.tier-p1` keeps the chip's ink on the chip. */
.tier.tier-p1  { background: var(--color-err-bg);  color: var(--color-err-fg);  border-color: var(--color-err-line); }
.tier.tier-p2  { background: var(--color-warn-bg); color: var(--color-warn-fg); border-color: var(--color-warn-line); }
.tier.tier-p3  { background: var(--color-sky-bg);  color: var(--color-sky-fg);  border-color: var(--color-sky-line); }
.tier.tier-std { background: var(--paper-2);       color: var(--ink-3);         border-color: var(--line); }

/* `.tier` survives for the tickets CSV/JSON column only: the tier chips, the
   rail legend and the tier selector are all off the ticket page (§15f). */

/* Everything below resolves through the tokens in `:root`. No literal hex. */

/* The saved-views strip is gone (2026-08-04). It was a row of named queries
   with live counts - Assigned to me, Unassigned, Waiting on us, Past SLA,
   Flagged, per mailbox, per channel, All open, On hold, Solved - and every one
   of them was a filter the bar above the queue already offers, so it was a
   second control for the same set of queries costing a row of the screen on
   every page view. The two counts anyone watches are in the page lede. */

/* ---------- The two panes ----------------------------------------------
   queue | ticket. Collapses to one column on a phone, where it behaves like a
   mail client (see the master/detail block). */
.tq { display: grid; gap: var(--gap-tile); grid-template-columns: 1fr; align-items: start; }
@media (min-width: 1080px) {
  .tq { grid-template-columns: minmax(300px, 360px) minmax(0, 1fr); }
}

/* ---------- Collapsing a pane ------------------------------------------
   Both toggles are driven by public/js/ticket-inbox.js, which also restores
   the last choice from localStorage. Collapsing the queue is what turns the
   page into a single wide conversation for reading a long thread; collapsing
   the properties gives that thread the last 250px back.

   The classes live on `.tq` (not on the pane) because hiding a grid ITEM is
   only half the job - the surviving pane has to be given the freed column, or
   the ticket keeps its old width and the page ends in dead space. */
.tq.queue-off .tq-rail { display: none; }
.tq.props-off .tq-side { display: none; }
@media (min-width: 1080px) {
  .tq.queue-off { grid-template-columns: minmax(0, 1fr); }
}
@media (min-width: 900px) {
  .tq.props-off .tq-body { grid-template-columns: minmax(0, 1fr); }
  .tq.props-off .tq-main { border-right: 0; }
}
/* Rendered by the view but revealed only once the script that makes them work
   has claimed the page - the same rule the keyboard hint follows. */
.tq-toggle { display: none; }
.tq-js .tq-toggle { display: inline-flex; }
/* Below the two-pane breakpoint the page is already master/detail: the queue
   is hidden whenever a ticket is open, so a "hide queue" button would collapse
   something that is not on screen. */
@media (max-width: 1079px) { .tq-js .tq-toggle[data-tq-toggle="queue"] { display: none; } }

/* ---------- Queue rail -------------------------------------------------
   Pinned on a wide screen so the queue never scrolls away from the
   conversation you are answering. `--stick-top` is the measured bottom edge
   of the sticky .admin-head (public/js/main.js), so the rail parks under the
   page header instead of sliding beneath it, whatever height that header is.

   NOTE the absent `overflow: hidden`. The sort menu is an absolutely
   positioned popover anchored in the rail head, and a clipping ancestor would
   cut it off at the head's bottom edge. The rounded corners are therefore put
   back on the head and on whichever element ends up last, rather than being
   clipped by the rail. */
.tq-rail {
  display: flex; flex-direction: column; min-width: 0;
  border: 1px solid var(--line); border-radius: var(--r-card);
  background: var(--card); box-shadow: var(--shadow-xs);
}
@media (min-width: 1080px) {
  .tq-rail {
    position: sticky; top: calc(var(--stick-top, 0px) + 12px);
    max-height: calc(100dvh - var(--stick-top, 0px) - 32px);
  }
}
.tq-rail-head {
  position: relative; flex: none;
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; padding: 9px 12px;
  border-bottom: 1px solid var(--line); background: var(--paper-2);
  border-radius: var(--r-card) var(--r-card) 0 0;
}
.tq-rail-count {
  font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
  font-weight: var(--fw-medium); color: var(--ink-3);
}
.tq-rail-count strong { color: var(--ink); font-weight: var(--fw-medium); }
.tq-sort { display: flex; align-items: center; gap: 6px; }
.tq-sort label {
  font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
  color: var(--ink-3); font-weight: var(--fw-medium);
}
:where(.admin-main) .tq-sort select { min-height: 0; padding: 5px 26px 5px 8px; font-size: 12px; }

.tq-list { list-style: none; margin: 0; padding: 0; overflow-y: auto; flex: 1 1 auto; }
/* When there is only one page there is no foot, so the list is what meets the
   rail's rounded bottom corners and has to be clipped to them itself. */
.tq-list:last-child { border-radius: 0 0 var(--r-card) var(--r-card); overflow: hidden auto; }

/* ---------- Sort menu --------------------------------------------------
   A <details> of links, not a <select>. The sortable table headers this page
   used to have are gone, so this is now the only way to re-sort the queue,
   and a native select cannot navigate without script. Links work with
   JavaScript off; `public/js/ticket-inbox.js` only adds click-away closing. */
.tq-sortmenu { position: relative; flex: none; }
.tq-sortmenu-sum {
  display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
  padding: 5px 10px; border-radius: var(--r-sm);
  border: 1px solid var(--line-2); background: var(--card);
  font-size: 12px; color: var(--ink-2); list-style: none; white-space: nowrap;
}
.tq-sortmenu-sum::-webkit-details-marker { display: none; }
.tq-sortmenu-sum::after {
  content: ''; width: 5px; height: 5px;
  border-right: 1.5px solid var(--ink-3); border-bottom: 1.5px solid var(--ink-3);
  transform: rotate(45deg) translate(-1px, -1px);
}
.tq-sortmenu[open] .tq-sortmenu-sum { border-color: var(--ink-3); }
.tq-sortmenu-sum:hover { background: var(--paper-2); }
.tq-sortmenu-sum:focus-visible { box-shadow: var(--shadow-focus); outline: none; }
.tq-sortmenu-list {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 12; min-width: 190px;
  display: flex; flex-direction: column; padding: 5px;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-md); box-shadow: var(--shadow-md);
}
.tq-sortmenu-list a {
  padding: 7px 10px; border-radius: var(--r-sm);
  font-size: 12.5px; color: var(--ink-2); text-decoration: none;
}
.tq-sortmenu-list a:hover { background: var(--paper-2); color: var(--ink); }
.tq-sortmenu-list a.is-on { background: var(--gold-4); color: var(--ink); font-weight: var(--fw-medium); }

/* ---------- A queue row ------------------------------------------------
   Three lines at most: what it is, who it is from, and (only when there is
   one) the reason it needs a human. The state stripe reuses the row classes
   the table spec already emits (`trow-waiting` / `trow-followup`), so the
   queue is tinted by the same rule that tinted the table it replaces. */
.tq-row {
  position: relative; display: block; padding: 11px 13px 11px 17px;
  border-bottom: 1px solid var(--line);
  color: inherit; text-decoration: none;
}
.tq-row::before {
  content: ''; position: absolute; left: 0; top: 0; bottom: 0;
  width: 3px; background: transparent;
}
.tq-row:last-child { border-bottom: 0; }
.tq-row:hover { background: var(--paper-2); }
.tq-row:focus-visible { outline: none; box-shadow: var(--shadow-focus); z-index: 1; }
.tq-row.trow-waiting::before  { background: var(--warn); }
.tq-row.trow-followup::before { background: var(--gold); }
/* Selected. Deliberately a surface change rather than a fourth stripe
   colour: the stripe already means "what state is this in", and overloading
   it with "and this is the one you are reading" makes both unreadable. */
.tq-row.is-on { background: var(--gold-4); box-shadow: inset 0 0 0 1px var(--color-gold-line); }
.tq-row.is-on.trow-waiting { background: var(--color-warn-bg); box-shadow: inset 0 0 0 1px var(--color-warn-line); }
.tq-row.is-done { opacity: .68; }
.tq-row.is-done:hover, .tq-row.is-on.is-done { opacity: 1; }

.tq-row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.tq-row-subj {
  flex: 1 1 auto; min-width: 0; font-size: 13.5px; font-weight: var(--fw-medium);
  color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.tq-row-age {
  flex: none; font-family: var(--font-mono); font-size: 11px;
  color: var(--ink-3); font-variant-numeric: tabular-nums;
}
.tq-row-sub { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 12px; color: var(--ink-3); }
.tq-row-who { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* A chat visitor who left no email. Muted and italic so it reads as a
   description of who they are, not as an address someone could write to. */
.tq-row-who.is-anon { color: var(--ink-3); font-style: italic; }
/* How it reached us, shown on the row only when that is NOT email - the queue
   is mostly email, so labelling every email row would be noise, while an
   unlabelled chat handoff is a ticket you cannot reply to and cannot tell. */
.tq-chan {
  flex: none; padding: 0 6px; border-radius: 999px; border: 1px solid var(--line);
  background: var(--paper-2); color: var(--ink-3);
  font-size: 10px; line-height: 16px; letter-spacing: .02em; text-transform: uppercase;
}
.tq-chan-chat {
  background: var(--color-sky-bg); color: var(--color-sky-fg); border-color: var(--color-sky-line);
}
.tq-row-n { flex: none; font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
.tq-row-foot {
  margin-top: 6px; display: flex; align-items: center; justify-content: space-between;
  gap: 8px; min-width: 0;
}
/* Who owns it. Unassigned is deliberately the LOUDER of the two states: an
   owned ticket is somebody's problem, an unowned one is nobody's. */
.tq-row-own {
  min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  font-size: 11px; color: var(--ink-3);
}
.tq-row-own.is-none {
  color: var(--bad-2); font-weight: var(--fw-medium);
}
.tq-row-state { flex: none; font-size: 11px; color: var(--ink-3); }
.tq-row-why {
  flex: none; display: inline-flex; align-items: center; gap: 5px;
  font-size: 11px; font-weight: var(--fw-medium); letter-spacing: .01em;
}
.tq-row-why::before { content: ''; width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
.tq-row-why.is-waiting  { color: var(--color-warn-fg); }
.tq-row-why.is-followup { color: var(--color-gold-fg); }

.tq-rail-foot {
  flex: none; display: flex; align-items: center; justify-content: space-between;
  gap: 8px; padding: 9px 12px; border-top: 1px solid var(--line); background: var(--paper-2);
}
.tq-rail-page { font-size: 11.5px; color: var(--ink-3); }
.tq-rail-empty { padding: 40px 20px; text-align: center; color: var(--ink-3); font-size: 13px; }

/* ---------- Detail pane ------------------------------------------------
   NOTE the absent `overflow: hidden`. An ancestor with overflow:hidden
   becomes the scroll container for anything sticky inside it, and since it
   cannot scroll, `.tq-dhead` would never pin. The rounded corners are put
   back on the first and last children instead. */
.tq-detail {
  min-width: 0; border: 1px solid var(--line); border-radius: var(--r-card);
  background: var(--card); box-shadow: var(--shadow-xs);
}
/* Fitting the whole grid to the window was tried and reverted (2026-08-04).
   Making the page unscrollable means every fixed row - the page header, the
   views strip, the ticket header, the SLA clocks, the composer - is paid for
   out of the conversation, and on a laptop-height window that left about one
   message visible. A capped thread inside a page that scrolls reads far
   better: `max-height` on `.tq-thread` is what stops the page growing without
   limit, and it is the only bound this layout needs. */
.tq-dhead {
  position: sticky; top: var(--stick-top, 0px); z-index: 4;
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 14px; flex-wrap: wrap; padding: 15px 20px;
  background: var(--card); border-bottom: 1px solid var(--line);
  border-radius: var(--r-card) var(--r-card) 0 0;
}
.tq-dhead-main { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.tq-dtitle { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.tq-dtitle h2 { margin: 0; font-size: 17px; line-height: 1.3; font-weight: var(--fw-medium); }
.tq-dmeta { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 12px; color: var(--ink-3); }
.tq-dmeta a { color: var(--ink-2); }
.tq-dhead-side { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }

/* The banner that says why this ticket is in front of you. One line, the
   same wording the rail row used, so the rail and the pane never disagree. */
.tq-why {
  display: flex; align-items: center; gap: 9px; padding: 10px 20px;
  font-size: 12.5px; font-weight: var(--fw-medium);
  border-bottom: 1px solid var(--line);
}
.tq-why.is-waiting  { background: var(--color-warn-bg); color: var(--color-warn-fg); border-bottom-color: var(--color-warn-line); }
.tq-why.is-followup { background: var(--color-gold-bg); color: var(--color-gold-fg); border-bottom-color: var(--color-gold-line); }
.tq-why-note { font-weight: var(--fw-regular); opacity: .9; }

/* ---------- Ticket body: conversation + properties ---------------------
   The properties sidebar is everything ABOUT the ticket as opposed to the
   conversation itself, which is the split every ticketing tool makes. It sits
   beside the thread when there is room and drops below it when there is not. */
.tq-body { display: grid; grid-template-columns: 1fr; align-items: start; }
@media (min-width: 900px) {
  .tq-body { grid-template-columns: minmax(0, 1fr) minmax(210px, 250px); }
  .tq-main { border-right: 1px solid var(--line); }
}
.tq-main { min-width: 0; }
/* One sidebar column holding two independent forms. They were two grid rows
   and stretched against the far taller conversation column, leaving a dead
   band between them. */
.tq-side { display: flex; flex-direction: column; min-width: 0; }

.tq-props, .tq-assign {
  display: flex; flex-direction: column; gap: 11px;
  padding: 14px 16px; background: var(--paper-2);
  border-bottom: 1px solid var(--line);
}
.tq-props .form-row, .tq-assign .form-row { margin-bottom: 0; }
.tq-props-head {
  font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase;
  font-weight: var(--fw-medium); color: var(--ink-3);
}
.tq-props-hint { display: block; margin-top: 4px; font-size: 11px; color: var(--ink-3); }
.tq-props-save { align-self: flex-start; }
.tq-props-sep { height: 1px; background: var(--line); margin: 2px 0; }
.tq-owner-now { font-size: 13px; color: var(--ink-2); }
.tq-owner-now.is-none { color: var(--bad-2); }
.tq-owner-when { display: block; font-size: 11px; color: var(--ink-3); }
/* "Assign to me" in the ticket header. A bare form would inherit the header's
   flex gap and stretch. */
.tq-inline-form { display: inline-flex; margin: 0; }

/* The two SLA meters lived here (2026-08-04). Removed with every other SLA
   surface: nobody works this queue against an hours target, and on a page
   whose scarcest resource is the height the conversation gets, they were a
   strip of colour reporting a deadline no one had agreed to. */

/* ---------- Conversation -----------------------------------------------
   The thread is the ONE part of this page whose height is unbounded - a
   30-message ticket made the page metres long, pushing the composer, the
   properties and the queue's own scroll off the end of it. So the
   conversation gets a ceiling and scrolls INSIDE that box, which keeps the
   page itself roughly one screen whatever the ticket holds.

   `overscroll-behavior: contain` stops a flick at either end of the thread
   from carrying on into the page behind it, which in a nested scroller reads
   as the whole layout jumping. */
.tq-thread {
  padding: 18px 20px; display: flex; flex-direction: column; gap: 14px;
  max-height: 58dvh; overflow-y: auto; overscroll-behavior: contain;
}
/* Oldest first, so the message that matters is at the BOTTOM.
   public/js/ticket-inbox.js parks the scroll there on load; with script off
   the thread simply opens on the first message. */
.tq-msg { display: flex; gap: 10px; align-items: flex-start; max-width: 86%; }
.tq-msg-out { flex-direction: row-reverse; margin-left: auto; }
.tq-avatar {
  flex: none; width: 30px; height: 30px; border-radius: 50%;
  display: grid; place-items: center;
  font-size: 10.5px; font-weight: var(--fw-medium);
  background: var(--paper-3); color: var(--ink-2);
}
.tq-msg-out .tq-avatar { background: var(--brand-ink); color: var(--brand-paper); }
.tq-bubble {
  min-width: 0; padding: 11px 14px; border-radius: var(--r-md);
  border: 1px solid var(--line); background: var(--card);
}
.tq-msg-in  .tq-bubble { border-color: var(--color-sky-line); }
.tq-msg-out .tq-bubble { background: var(--gold-4); border-color: var(--color-gold-line); }

/* ---------- Internal notes ---------------------------------------------
   The one mistake this whole feature exists to prevent is a colleague-facing
   note reaching the customer, so a note is drawn as a DIFFERENT KIND OF
   OBJECT rather than a tinted reply: full width (it is not part of the
   left/right conversation), a dashed edge, an amber wash and an explicit
   "Internal note" tag. A subtle tint is not enough to bet a customer
   relationship on, and staff scanning a thread fast must be able to tell what
   the customer has and has not seen without reading. */
.tq-msg-note { max-width: 100%; }
.tq-msg-note .tq-avatar { background: var(--color-warn-bg); color: var(--color-warn-fg); border: 1px dashed var(--color-warn-line); }
.tq-msg-note .tq-bubble {
  flex: 1 1 auto;
  background: var(--color-warn-bg);
  border: 1px dashed var(--color-warn-line);
}
.tq-msg-note .tq-btext, .tq-msg-note .tq-bwho { color: var(--color-warn-fg); }
.tq-note-tag {
  display: inline-flex; align-items: center; padding: 0 6px; border-radius: var(--r-pill);
  background: var(--warn); color: var(--on-warn);
  font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase;
  font-weight: var(--fw-medium);
}
.tq-bmeta { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 5px; font-size: 11px; color: var(--ink-3); }
.tq-bwho { font-weight: var(--fw-medium); color: var(--ink-2); overflow-wrap: anywhere; }
.tq-btext { font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; }
/* An internal note is rendered Markdown, not verbatim text. `pre-wrap` has to
   go with it: the source newlines are already spent building the <p> and <li>
   elements, so preserving them too prints a blank line between every paragraph
   and after every bullet. Everything else in the thread is a copy of an email
   and keeps the verbatim treatment above. */
.tq-btext.is-md { white-space: normal; }
.tq-btext.is-md > :first-child { margin-top: 0; }
.tq-btext.is-md > :last-child { margin-bottom: 0; }
.tq-btext.is-md p { margin: 0 0 8px; }
.tq-btext.is-md ul, .tq-btext.is-md ol { margin: 0 0 8px; padding-left: 20px; }
.tq-btext.is-md li { margin: 2px 0; }
.tq-btext.is-md h2, .tq-btext.is-md h3 { font-size: 13.5px; font-weight: var(--fw-medium); margin: 8px 0 4px; }
.tq-btext.is-md blockquote { margin: 0 0 8px; padding: 4px 10px; border-left: 2px solid currentColor; opacity: .85; }
.tq-btext.is-md a { color: inherit; text-decoration: underline; }
@media (max-width: 620px) {
  .tq-msg { max-width: 100%; }
  .tq-msg-out { flex-direction: row; }
}

/* Long threads collapse their middle. A 30-message thread is otherwise a
   wall you have to scroll past to reach the reply box, and the two messages
   that matter are the first and the last.

   `.thread-older-sum` is the class styles.css excludes from the generic
   <details> chevron (`summary:not(.thread-older-sum)::before`) because this
   summary draws its own. Renaming one without the other gives the row two
   markers, one on each side, and nothing errors. */
.thread-older { border: 1px dashed var(--line-2); border-radius: var(--r-md); background: var(--paper-2); }
.thread-older-sum {
  display: flex; align-items: center; gap: 8px; cursor: pointer;
  padding: 9px 14px; list-style: none;
  font-size: 12px; font-weight: var(--fw-medium); color: var(--ink-2);
}
.thread-older-sum::-webkit-details-marker { display: none; }
.thread-older-sum::after {
  content: ''; width: 6px; height: 6px;
  border-right: 1.5px solid var(--ink-3); border-bottom: 1.5px solid var(--ink-3);
  transform: rotate(45deg); transition: transform .18s ease;
}
.thread-older[open] .thread-older-sum::after { transform: rotate(-135deg); }
.thread-older-sum:focus-visible { box-shadow: var(--shadow-focus); outline: none; border-radius: var(--r-md); }
.thread-older-inner { display: flex; flex-direction: column; gap: 14px; padding: 2px 14px 14px; }

/* ---------- The dock: triage and reply ---------------------------------
   Two independent saves, so two sibling forms. They are NOT nested and must
   never be: HTML drops a nested <form> start tag and lets its </form> close
   the outer one, which silently orphans every control after it.

   The dock is in ordinary flow. It does not need to be pinned: the thread
   above it is height-capped and scrolls inside itself, so the composer is
   already a screen away at worst however long the conversation runs. */
.tq-dock {
  display: flex; flex-direction: column; gap: 10px;
  padding: 12px 20px 14px; background: var(--paper-2);
  border-top: 1px solid var(--line);
  border-radius: 0 0 var(--r-card) var(--r-card);
}
.tq-triage { display: flex; flex-wrap: wrap; gap: 10px 12px; align-items: flex-end; }
.tq-triage .form-row { margin-bottom: 0; }
.tq-triage .form-row.grow { flex: 1 1 220px; min-width: 0; }
.tq-reply {
  display: flex; flex-direction: column; gap: 10px; padding: 12px;
  background: var(--card); border: 1px solid var(--line); border-radius: var(--r-md);
}
/* The note composer carries the SAME amber language as a rendered note, so
   the box you are typing into looks like the thing it will produce.

   It FOLDS, so the composer costs one row until somebody wants it - the whole
   point of capping the thread is that this page stays about one screen tall,
   and a second full composer under the reply spends that budget on a box
   nobody asked for. A fold is not a tab: the public reply stays visible and
   labelled whatever this is doing, so there is never a single box whose
   meaning you have to read a control to establish - which is how a private
   note gets emailed to a customer. The amber wash is on the fold itself, so
   the closed state already looks like the thing it opens. */
.tq-note-fold {
  background: var(--color-warn-bg); border: 1px dashed var(--color-warn-line);
  border-radius: var(--r-md);
}
.tq-note-sum {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  cursor: pointer; list-style: none; padding: 9px 12px;
}
.tq-note-sum::-webkit-details-marker { display: none; }
.tq-note-sum::after {
  content: ''; width: 6px; height: 6px; margin-left: auto; align-self: center;
  border-right: 1.5px solid var(--color-warn-fg); border-bottom: 1.5px solid var(--color-warn-fg);
  transform: rotate(45deg); transition: transform .18s ease;
}
.tq-note-fold[open] .tq-note-sum::after { transform: rotate(-135deg); }
.tq-note-sum:focus-visible { box-shadow: var(--shadow-focus); outline: none; border-radius: var(--r-md); }
.tq-note {
  display: flex; flex-direction: column; gap: 10px; padding: 0 12px 12px;
}
.tq-note-sum .tq-compose-title, .tq-note-sum .tq-compose-to { color: var(--color-warn-fg); }
:where(.admin-main) .tq-note textarea {
  background: var(--card); border-color: var(--color-warn-line); min-height: 54px;
}
.tq-compose-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.tq-compose-title {
  font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase;
  font-weight: var(--fw-medium); color: var(--ink-2);
}
.tq-compose-to { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; }
/* Stands where the reply box would be on a chat handoff with no address to
   reply to. Informational violet rather than a warning colour: nothing is
   wrong, there is simply nowhere to send email, and the next action (answer in
   the chat console, note it here) is in the text. */
.tq-nomail {
  padding: 12px 14px; border-radius: var(--r-md); font-size: 12.5px; line-height: 1.55;
  background: var(--color-sky-bg); border: 1px solid var(--color-sky-line); color: var(--color-sky-fg);
}
.tq-nomail strong { font-weight: var(--fw-medium); }
.tq-nomail a { color: inherit; text-decoration: underline; }
.tq-nomail .mono { font-family: var(--font-mono); font-size: 11.5px; }
/* Same statement in the ticket's meta line, where an address would otherwise sit. */
.tq-anon { font-style: italic; }
.tq-reply .form-row { margin-bottom: 0; }
/* Starts at two rows and grows with the text (ticket-inbox.js). The old 92px
   floor was four empty rows of furniture in a layout that fits the window, and
   the conversation was paying for them. */
:where(.admin-main) .tq-reply textarea { min-height: 56px; }
.tq-actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.tq-actions-note { font-size: 11.5px; color: var(--ink-3); margin-left: auto; }
.tq-done-note {
  display: flex; align-items: center; gap: 10px; padding: 12px 14px;
  background: var(--color-ok-bg); border: 1px solid var(--color-ok-line);
  border-radius: var(--r-md); color: var(--color-ok-fg); font-size: 12.5px;
}
.tq-done-note .btn { margin-left: auto; }

/* ---------- Nothing selected ------------------------------------------- */
.tq-empty { padding: 72px 24px; text-align: center; color: var(--ink-3); }
.tq-empty h2 { margin: 0 0 6px; font-size: 15px; font-weight: var(--fw-medium); color: var(--ink-2); }
.tq-empty p { margin: 0; font-size: 13px; }

/* ---------- Master/detail on a narrow screen ---------------------------
   Below the two-pane breakpoint this behaves like every mobile mail client:
   the queue OR one conversation, never both stacked. `.tq-back` is the way
   out of the conversation and is the only thing that reveals the rail again. */
.tq-back { display: none; }
@media (max-width: 1079px) {
  /* One or the other, never both stacked. `has-open` is set only by an
     EXPLICIT ?open=, so a first visit lands on the queue: the detail pane is
     still populated for a wide screen, but a phone must not open a
     conversation the admin never picked. */
  .tq.has-open .tq-rail { display: none; }
  .tq.has-open .tq-back { display: inline-flex; }
  .tq:not(.has-open) .tq-detail { display: none; }
}

/* ---------- Keyboard triage hint ---------------------------------------
   Only shown once script is present, because with JS off none of these keys
   do anything and advertising them would be a lie. */
.tq-keys { display: none; gap: 12px; flex-wrap: wrap; margin-top: 10px; font-size: 11.5px; color: var(--ink-3); }
/* The class is set by public/js/ticket-inbox.js itself, so the hint can only
   appear where the keys actually work. Advertising them from a stylesheet
   would promise shortcuts to anyone with script disabled. */
.tq-js .tq-keys { display: flex; }
.tq-keys kbd {
  font-family: var(--font-mono); font-size: 10.5px; padding: 1px 5px;
  border: 1px solid var(--line-2); border-bottom-width: 2px; border-radius: 4px;
  background: var(--card); color: var(--ink-2);
}


/* Beat `.form-row label` (which forces uppercase 11px) when an inline checkbox
   label sits inside a form row. */
.check-inline,
.form-row label.check-inline {
  display: flex; gap: 6px; align-items: center;
  font-size: 13px; color: var(--ink-2);
  text-transform: none; letter-spacing: 0; font-weight: var(--fw-regular);
}
.check-inline input { width: auto; }

/* ---------- Flash ---------- */
.flash { padding: 12px 16px; border-radius: var(--r-md); margin-bottom: 18px; font-size: 13px; border: 1px solid; }
.flash-ok   { background: var(--color-ok-bg);   color: var(--color-ok-fg);   border-color: var(--color-ok-line); }
.flash-err  { background: var(--color-err-bg);  color: var(--color-err-fg);  border-color: var(--color-err-line); }
.flash-info { background: var(--paper-2);       color: var(--ink-2);         border-color: var(--line); }
.flash-warn { background: var(--color-warn-bg);  color: var(--color-warn-fg);  border-color: var(--color-warn-line); }

/* Persistent failed-payment alert banner (admin shell, every page). Red, full
   width, links to the bookings needing a chase. The dot gently pulses to draw
   the eye without being obnoxious (respects prefers-reduced-motion globally). */
.admin-alert {
  display: flex; align-items: center; gap: 10px;
  padding: 13px 16px; margin-bottom: var(--gap-block);
  border: 1px solid var(--color-err-line); border-left: 3px solid var(--bad);
  border-radius: var(--r-md);
  background: var(--color-err-bg); color: var(--color-err-fg);
  font-size: 13px; text-decoration: none;
}
.admin-alert:hover { filter: brightness(0.98); }
.admin-alert strong { font-weight: var(--fw-medium); }
.admin-alert-dot { flex: 0 0 auto; width: 9px; height: 9px; border-radius: 50%; background: var(--bad); animation: admin-alert-pulse 2s ease-in-out infinite; }
.admin-alert-cta { margin-left: auto; white-space: nowrap; font-weight: var(--fw-medium); }
@keyframes admin-alert-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

/* ---------- Footer ---------- */
/* Footer is brand chrome - always-dark surface - so it uses --brand-* tokens
   that don't flip in dark mode. Otherwise the bg would invert to cream while
   the text (hard-coded light) stayed cream too. */
footer { background: var(--brand-ink); color: rgba(255,255,255,0.78); padding: var(--s-40) 0 var(--s-24); margin-top: var(--s-64); width: 100%; border-top: 1px solid var(--brand-ink-2); }
footer .brand, footer .brand:hover { color: var(--brand-paper); }
footer .brand strong { color: var(--brand-paper); }
footer .brand small { color: rgba(255,255,255,0.55); }
@media (min-width: 760px) { footer { padding: var(--s-48) 0 var(--s-32); } }
footer .container { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-24); }
/* On a phone the three link groups sit TWO ACROSS, not stacked. Stacked, the
   nine links plus their headings ran to most of a screen of scrolling on their
   own, and the footer read as a page rather than as the end of one. The brand
   block keeps the full width above them: its column would otherwise be ~170px,
   which is barely wider than the two 40px social buttons and would break the
   tagline over four lines. `.legal` and `.footer-bottom` already span. */
@media (max-width: 480px) {
  footer .container { grid-template-columns: 1fr 1fr; gap: var(--s-20) var(--s-24); }
  footer .footer-brand { grid-column: 1 / -1; }
}
/* ON A PHONE THE SOCIAL BUTTONS SIT BESIDE THE LOCKUP, not under the tagline.
   Two 40px squares on a line of their own is a third row in a block that is
   already the tallest thing in the footer, and the right-hand half of the
   lockup's row is empty - so they move into it.

   GRID, and the placement is explicit, because the DOM order is brand ->
   tagline -> social and the wanted order is brand + social, then tagline. The
   markup is NOT reordered to suit: a screen reader and a keyboard follow the
   document, and "Instagram" announced between a name and its strapline is
   worse than a spare row.

   480px, matching the breakpoint above where `.footer-brand` starts spanning
   the whole row. It is not a guess at "phone": above it the brand sits in a
   HALF-WIDTH column beside the link groups, and pushing the buttons into that
   row truncated the wordmark to "Light Upon..." to make space for them. The
   buttons belong alongside only once the block owns the full width. */
@media (max-width: 480px) {
  footer .footer-brand {
    display: grid; grid-template-columns: minmax(0, 1fr) auto;
    align-items: center; column-gap: var(--s-16);
  }
  footer .footer-brand > .brand { grid-column: 1; grid-row: 1; margin-bottom: 0; }
  footer .footer-brand > .footer-social { grid-column: 2; grid-row: 1; margin: 0; }
  footer .footer-brand > .footer-tagline { grid-column: 1 / -1; grid-row: 2; margin: 10px 0 0; }
}
@media (min-width: 760px) { footer .container { grid-template-columns: 2fr 1fr 1fr 1fr; gap: var(--s-32); } }
footer h4 { margin-bottom: 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255,255,255,0.55); font-weight: var(--fw-medium); font-family: var(--font-body); }
footer a, footer .footer-link-btn { color: rgba(255,255,255,0.78); display: block; padding: 4px 0; font-size: 13px; text-decoration: none; transition: color .15s ease, transform .15s ease; word-break: break-word; }
footer a:hover, footer .footer-link-btn:hover { color: var(--brand-paper); }
footer .footer-link-btn { background: none; border: 0; font: inherit; text-align: left; cursor: pointer; width: 100%; }
footer .legal { grid-column: 1/-1; padding-top: var(--s-24); margin-top: var(--s-24); border-top: 1px solid rgba(255,255,255,0.1); font-size: 11px; color: rgba(255,255,255,0.62); word-break: break-word; }
/* Bottom row: editable legal line on the left, fixed build credit on the right.
   Spans the grid and carries the divider; the inner .legal drops its own. */
footer .footer-bottom { grid-column: 1/-1; padding-top: var(--s-24); margin-top: var(--s-24); border-top: 1px solid rgba(255,255,255,0.1); display: flex; flex-wrap: wrap; gap: 6px 20px; justify-content: space-between; align-items: baseline; }
footer .footer-bottom .legal { grid-column: auto; padding-top: 0; margin-top: 0; border-top: 0; flex: 1 1 320px; }
footer .footer-credit { margin: 0; font-size: 11px; color: rgba(255,255,255,0.62); white-space: nowrap; }
footer .footer-credit a { display: inline; padding: 0; color: rgba(255,255,255,0.78); text-decoration: underline; }
footer .footer-credit a:hover { color: var(--brand-paper); }
/* Social links. `footer a` is `display: block` so every link owns a line - fine
   for a nav column, wrong for a row of icons, hence the explicit inline-flex on
   the anchor. The 40px box is the touch target; the glyph inside is 20px. */
footer .footer-social { display: flex; gap: 6px; margin: 14px 0 0; }
/* The tagline's measure and size, off the inline style it used to carry - the
   phone layout has to be able to set its margin, and an inline one wins. */
footer .footer-tagline { font-size: .9rem; max-width: 380px; }
/* The lockup's own gap below it, likewise moved off the element so the phone
   layout can close it when the buttons come up alongside. */
footer .footer-brand > .brand { margin-bottom: 10px; }
footer .footer-social a { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; border-radius: var(--r-input); color: rgba(255,255,255,0.72); border: 1px solid rgba(255,255,255,0.14); }
footer .footer-social a:hover { color: var(--brand-ink); background: var(--brand-gold); border-color: var(--brand-gold); }
footer p, footer small, footer .muted { color: rgba(255,255,255,0.55); }

/* ---------- Admin shell ---------- */
.admin-shell { display: grid; grid-template-columns: 1fr; min-height: 100vh; background: var(--paper); }
@media (min-width: 880px) { .admin-shell { grid-template-columns: 220px 1fr; } }
.admin-topbar {
  display: none;
  position: sticky; top: 0; z-index: 30;
  background: var(--brand-ink); color: var(--brand-paper);
  border-bottom: 1px solid var(--brand-ink-2);
  padding: 10px 14px;
  align-items: center; justify-content: space-between;
}
.admin-topbar-brand { display: flex; align-items: center; gap: 8px; color: var(--brand-paper); text-decoration: none; font-weight: var(--fw-medium); }
.admin-topbar-brand strong { font-family: 'Telegraf', var(--font-display); font-weight: 800; font-size: 17px; letter-spacing: -0.015em; line-height: 1.1; }
.admin-topbar-brand .mark { width: 22px; height: 22px; background: url("/logo.png") center/contain no-repeat; border-radius: 6px; }
.admin-burger {
  width: 36px; height: 36px;
  background: transparent;
  border: 1px solid rgba(255,255,255,0.2);
  border-radius: var(--r-md);
  display: inline-flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px; cursor: pointer;
}
.admin-burger span { display: block; width: 16px; height: 2px; background: var(--brand-paper); transition: transform .2s ease, opacity .2s ease; }
.admin-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.admin-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.admin-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

.admin-sidebar {
  /* Graphite, per the guideline's WARNA UTAMA, with a barely-there vertical
     lift so a 100vh rail is not one flat slab. The old rail was a near-black
     warm brown (#1A1814) that fought the neutral page beside it. */
  background: linear-gradient(180deg, #3A3A3D 0%, var(--brand-ink) 42%, #262628 100%);
  color: var(--brand-paper);
  padding: 20px 14px 16px;
  display: flex; flex-direction: column; gap: 2px;
  min-width: 0;
  border-right: 1px solid rgba(0,0,0,0.28);
}
/* Lock the sidebar to the viewport on desktop. position:fixed is more
   reliable than position:sticky inside a grid item - sticky was failing
   intermittently on long admin pages, leaving the sidebar to scroll away
   with the content. The grid still reserves 220px on the left (the
   .admin-shell template), so .admin-main starts in the right place. */
@media (min-width: 880px) {
  .admin-sidebar {
    position: fixed;
    top: 0; left: 0; bottom: 0;
    width: 220px;
    overflow: hidden;
    z-index: 5;
  }
}
/* The nav list is the ONLY scrolling region: brand (above) and .sb-foot (below)
   are fixed flex siblings, so they stay put when every group is expanded.
   min-height:0 lets this flex child actually shrink-and-scroll instead of
   pushing the footer off the bottom. */
.admin-sidebar .sb-nav {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  display: flex; flex-direction: column; gap: 2px;
}
/* Custom scrollbar so the dark sidebar doesn't expose the OS scrollbar
   gutter (and accompanying light track) in dark surroundings. */
.admin-sidebar .sb-nav::-webkit-scrollbar { width: 6px; }
.admin-sidebar .sb-nav::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.20); border-radius: 999px; }
.admin-sidebar .sb-nav::-webkit-scrollbar-track { background: transparent; }

/* Collapsible group sections inside the sidebar. The .group element is a
   button by markup; its .open class flips the chevron and reveals the
   sibling .nav-section-items list. State is persisted per-group label in
   localStorage by main.js so the user's choice survives navigation. */
.admin-sidebar .nav-section { display: flex; flex-direction: column; }
.admin-sidebar .nav-section + .nav-section { margin-top: 4px; }
.admin-sidebar button.group {
  font-family: inherit;
  font-size: 10px; text-transform: uppercase;
  color: rgba(255,255,255,0.62);
  letter-spacing: 0.1em;
  padding: 12px 10px 6px;
  font-weight: var(--fw-semibold);
  background: transparent; border: 0;
  display: flex; align-items: center; justify-content: space-between;
  cursor: pointer; text-align: left;
  transition: color .15s ease;
}
.admin-sidebar button.group:hover { color: rgba(255,255,255,0.9); }
/* A hairline above each group so the rail reads as a few labelled clusters
   rather than one continuous list of sixteen links. */
.admin-sidebar .nav-section + .nav-section { border-top: 1px solid rgba(255,255,255,0.07); padding-top: 2px; }
.admin-sidebar button.group::after {
  content: "⌃"; /* up-chevron - rotates 180deg when collapsed */
  font-size: 12px; line-height: 1; opacity: 0.6;
  transition: transform .15s ease;
  transform: rotate(180deg);
}
.admin-sidebar .nav-section.open button.group::after { transform: rotate(0deg); }
.admin-sidebar .nav-section-items {
  display: flex; flex-direction: column; gap: 2px;
  overflow: hidden; max-height: 0;
  transition: max-height .25s ease;
}
.admin-sidebar .nav-section.open .nav-section-items { max-height: 600px; }

/* Below 880px the admin shell collapses to one column and the sidebar
   becomes a slide-in drawer (toggled by the admin-topbar burger). The
   desktop fixed-position rule is media-gated above so it never fires here. */
@media (max-width: 879.98px) {
  .admin-shell { display: block; grid-template-columns: 1fr; }
  .admin-topbar { display: flex; }
  /* Sit the sticky page header below the now-visible sticky topbar (~56px)
     instead of scrolling behind it. */
  .admin-head { top: 56px; }
  .admin-sidebar {
    position: fixed; top: 0; left: 0; bottom: 0;
    width: min(280px, 86vw);
    transform: translateX(-100%);
    transition: transform .25s ease;
    z-index: 50;
    overflow: hidden;
  }
  .admin-sidebar.is-open { transform: translateX(0); }
  .admin-sidebar .desktop-only-theme { display: none; }
}
.admin-sidebar .brand {
  color: var(--brand-paper);
  padding: 4px 8px 18px;
  border-bottom: 1px solid var(--brand-line);
  margin-bottom: 14px;
  display: flex; align-items: center; gap: 10px; text-decoration: none;
  /* Fixed header: a flex sibling that doesn't scroll (the .sb-nav list does). */
  flex: 0 0 auto;
}
.admin-sidebar .brand .mark {
  width: 22px; height: 22px; background: url("/logo.png") center/contain no-repeat; border-radius: 4px;
  flex-shrink: 0;
}
.admin-sidebar .brand strong { color: var(--brand-paper); font-family: var(--font-display); font-weight: var(--fw-medium); font-size: 13px; }
.admin-sidebar .brand small { color: rgba(255,255,255,0.62); display: block; font-size: 10px; }
.admin-sidebar a {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 11px;
  border-radius: var(--r-sm);
  /* Was 0.55 alpha, which put the resting nav label under 3:1 on the graphite
     rail and made the whole sidebar read as disabled. */
  color: rgba(255,255,255,0.68);
  font-size: 13px; text-decoration: none;
  font-weight: var(--fw-regular);
  position: relative;
  transition: background-color .12s ease, color .12s ease;
}
.admin-sidebar a:hover { background: rgba(255,255,255,0.07); color: #fff; }
/* The active page is the one place the sidebar spends the brand gold: a gold
   label over a gold-tinted fill, plus a gold tab on the rail's edge. It was
   previously a slightly-lighter gray on a slightly-lighter gray, which is a
   lot of chrome to say "you are here" without actually saying it. */
.admin-sidebar a.active, .admin-sidebar a[aria-current="page"] {
  background: rgba(193,171,117,0.12); color: #D8C89C;
  font-weight: var(--fw-medium);
}
/* -14px is the sidebar's own horizontal padding, so the tab sits flush against
   the rail's left edge rather than floating inside it. */
.admin-sidebar a.active::before, .admin-sidebar a[aria-current="page"]::before {
  content: ""; position: absolute; left: -14px; top: 50%; transform: translateY(-50%);
  width: 3px; height: 18px; border-radius: 0 2px 2px 0; background: var(--gold);
}
/* Minimalist nav icon + right-aligned count badge. The icon sits flush-left at
   a calmer opacity than the label; the badge is pushed to the far right with
   margin-left:auto so the label can wrap/ellipsis between them. */
.admin-sidebar .nav-ico { flex: 0 0 16px; opacity: 0.7; }
.admin-sidebar a:hover .nav-ico,
.admin-sidebar a.active .nav-ico { opacity: 1; }
.admin-sidebar .nav-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.admin-sidebar .nav-badge {
  flex: 0 0 auto; margin-left: auto;
  min-width: 18px; height: 18px; padding: 0 5px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 5px;
  background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9);
  font-size: 10px; font-weight: var(--fw-medium); line-height: 1;
  font-variant-numeric: tabular-nums;
}
/* Dark ink on the gold chip. This previously set --teal-2, which now resolves
   to a dark GREEN and read as a mis-tint on a gold pill. */
.admin-sidebar a.active .nav-badge { background: var(--gold); color: var(--on-gold); }
/* Alert variant: a failed-payment count that needs attention. Stays red even on
   the active link so it reads as a warning, not a neutral tally. */
.admin-sidebar .nav-badge.nav-badge-alert,
.admin-sidebar a.active .nav-badge.nav-badge-alert { background: var(--bad); color: var(--on-bad); }
.admin-sidebar .group {
  font-size: 10px; text-transform: uppercase;
  color: rgba(255,255,255,0.62);
  letter-spacing: 0.08em; padding: 14px 10px 6px;
  font-weight: var(--fw-medium);
}
.admin-sidebar .theme-toggle { background: transparent; border-color: rgba(255,255,255,0.40); color: var(--brand-paper); }
.admin-sidebar .theme-toggle:hover { background: rgba(255,255,255,0.06); }

/* Sidebar footer (user chip + quick links + collapse toggle). Styling lives
   here rather than inline so the collapsed-to-icons mode can re-flow it.
   The .sb-foot wrapper is a fixed flex sibling at the sidebar's bottom edge: it
   doesn't scroll, so it never disappears when every nav group is expanded. The
   middle .sb-nav list is the only scrolling region. */
.admin-sidebar .sb-foot { flex: 0 0 auto; }
.admin-sidebar .sb-foot-user { padding-top: 14px; border-top: 1px solid var(--brand-line); display: flex; align-items: center; gap: 10px; }
.admin-sidebar .sb-foot-meta { flex: 1; min-width: 0; font-size: 12px; line-height: 1.3; overflow: hidden; }
.admin-sidebar .sb-foot-meta .nm { color: var(--brand-paper); font-weight: 500; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; }
.admin-sidebar .sb-foot-meta .rl { color: rgba(255,255,255,0.62); font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; }
.admin-sidebar .sb-foot-actions { display: flex; gap: 6px; margin-top: 8px; }
.admin-sidebar .sb-foot-link {
  flex: 1; padding: 7px 10px; border-radius: 6px;
  background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.72);
  font-size: 11px; text-decoration: none;
  display: flex; align-items: center; justify-content: center; gap: 6px;
}
.admin-sidebar .sb-foot-link:hover { background: rgba(255,255,255,0.14); color: #fff; }
.admin-sidebar .sb-foot-link svg { flex: 0 0 14px; }
/* Collapse toggle: desktop only (the sidebar is a drawer below 880px). */
.admin-sidebar .sb-collapse-btn { display: none; }
@media (min-width: 880px) {
  .admin-sidebar .sb-collapse-btn {
    display: flex; align-items: center; justify-content: center; gap: 6px;
    margin-top: 8px; width: 100%; padding: 7px 10px; border-radius: 6px;
    background: transparent; border: 1px solid rgba(255,255,255,0.40);
    color: rgba(255,255,255,0.65); font-size: 11px; font-family: inherit; cursor: pointer;
    transition: background-color .12s ease, color .12s ease;
  }
  .admin-sidebar .sb-collapse-btn:hover { background: rgba(255,255,255,0.06); color: var(--brand-paper); }
  .admin-sidebar .sb-collapse-btn svg { transition: transform .2s ease; }
}

/* ----- Collapsed-to-icons sidebar (desktop ≥880px) ----------------------
   `html.admin-nav-collapsed` is set early (admin-shell head) + toggled by
   the .sb-collapse-btn. The sidebar narrows to a 64px icon rail: labels
   become hover tooltips, count badges become corner dots, group headers
   disappear (their items stay shown), and the footer stacks to icons. */
@media (min-width: 880px) {
  html.admin-nav-collapsed .admin-shell { grid-template-columns: 64px 1fr; }
  html.admin-nav-collapsed .admin-sidebar { width: 64px; padding-left: 10px; padding-right: 10px; }

  html.admin-nav-collapsed .admin-sidebar .brand { justify-content: center; padding: 4px 0 14px; gap: 0; }
  html.admin-nav-collapsed .admin-sidebar .brand strong,
  html.admin-nav-collapsed .admin-sidebar .brand small,
  html.admin-nav-collapsed .admin-sidebar button.group { display: none; }

  /* Always reveal group items - the collapsing chevron header is hidden. */
  html.admin-nav-collapsed .admin-sidebar .nav-section-items { max-height: none !important; overflow: visible; }
  html.admin-nav-collapsed .admin-sidebar .nav-section + .nav-section { margin-top: 6px; padding-top: 6px; border-top: 1px solid var(--brand-line); }

  /* Centre every nav row on its icon. */
  html.admin-nav-collapsed .admin-sidebar a { justify-content: center; gap: 0; padding: 9px 0; position: relative; }

  /* The label rides out as a hover tooltip to the right of the rail. */
  html.admin-nav-collapsed .admin-sidebar a .nav-label {
    position: absolute; left: calc(100% + 8px); top: 50%; transform: translateY(-50%);
    flex: none; overflow: visible; white-space: nowrap;
    background: var(--brand-ink-2); color: var(--brand-paper);
    padding: 5px 9px; border-radius: 6px; font-size: 12px;
    pointer-events: none; opacity: 0; visibility: hidden;
    transition: opacity .12s ease; z-index: 60;
    box-shadow: 0 4px 14px rgba(0,0,0,.35);
  }
  html.admin-nav-collapsed .admin-sidebar a:hover .nav-label { opacity: 1; visibility: visible; }

  /* Count badge shrinks to a corner dot (the number won't fit). */
  html.admin-nav-collapsed .admin-sidebar .nav-badge {
    position: absolute; top: 5px; right: 9px; margin: 0;
    min-width: 7px; width: 7px; height: 7px; padding: 0;
    font-size: 0; border-radius: 50%;
  }

  /* Footer stacks to an icon column. */
  html.admin-nav-collapsed .admin-sidebar .sb-foot-user { flex-direction: column; gap: 8px; }
  html.admin-nav-collapsed .admin-sidebar .sb-foot-meta { display: none !important; }
  html.admin-nav-collapsed .admin-sidebar .sb-foot-actions { flex-direction: column; }
  html.admin-nav-collapsed .admin-sidebar .sb-foot-link { gap: 0; padding: 8px 0; }
  html.admin-nav-collapsed .admin-sidebar .sb-link-label { display: none; }
  html.admin-nav-collapsed .admin-sidebar .sb-collapse-btn svg { transform: rotate(180deg); }
}

.admin-overlay { position: fixed; top: 0; right: 0; bottom: 0; left: 0; inset: 0; background: rgba(0,0,0,.5); z-index: 40; cursor: pointer; }
.admin-overlay[hidden] { display: none; }

/* overflow-x must be `clip`, not `hidden`: `hidden` makes overflow-y compute
   to `auto`, turning .admin-main into a scroll container, so .admin-head's
   position:sticky resolves against this (non-scrolling) box and scrolls away
   with the page instead of pinning. `clip` contains horizontal overflow
   without establishing a scroll container, so sticky engages on the viewport. */
.admin-main { padding: var(--s-24) var(--s-16) var(--s-48); background: var(--paper); min-height: 100vh; min-width: 0; overflow-x: clip; }
@media (min-width: 760px) { .admin-main { padding: var(--s-24) var(--s-32) var(--s-48); } }
/* Sidebar is position:fixed on desktop (removed from grid flow), so
   auto-placement would drop .admin-main into column 1 and hide it under
   the sidebar. Pin it to column 2 explicitly. */
@media (min-width: 880px) { .admin-main { grid-column: 2; } }
/* Pin the page header to the top so the h1 + primary actions stay in view
   while the page body scrolls. On desktop there's no topbar, so it sticks to
   the viewport top; on mobile it sits just below the sticky .admin-topbar
   (offset re-set in the <880px block). The paper background covers content
   scrolling underneath; padding-top gives a small band when stuck. */
.admin-head { position: sticky; top: 0; z-index: 20; display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--gap-block); gap: 12px; flex-wrap: wrap; padding-top: var(--s-16); padding-bottom: 20px; border-bottom: 1px solid var(--line); background: var(--paper); }
.admin-head h1 { margin: 0; font-size: var(--text-heading); font-weight: var(--fw-semibold); letter-spacing: -0.02em; }
/* The eyebrow carries a gold tick, the one brand mark on an otherwise neutral
   page header. */
.admin-head .subtitle { font-size: 11px; color: var(--ink-3); letter-spacing: 0.08em; text-transform: uppercase; font-weight: var(--fw-semibold); margin-bottom: 7px; display: flex; align-items: center; gap: 8px; }
.admin-head .subtitle::before { content: ""; width: 14px; height: 2px; border-radius: 1px; background: var(--gold); flex: none; }
.admin-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--gap-tile); margin-bottom: var(--gap-block); }
@media (min-width: 760px) { .admin-grid { grid-template-columns: repeat(4, 1fr); gap: var(--gap-tile); } }
.stat {
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-lg); padding: 20px;
  position: relative; overflow: hidden;
  box-shadow: var(--shadow-xs);
}
.stat .label { font-size: 11px; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--fw-medium); margin-bottom: 14px; }
.stat .val { font-family: var(--font-display); font-size: 30px; font-weight: var(--fw-medium); letter-spacing: -0.025em; color: var(--ink); }
/* Optional third line: the denominator or the caveat that makes .val readable
   on its own ("£1,200 given away" means little without "of £24,000 gross"). */
.stat .sub { margin-top: 6px; font-size: 12px; color: var(--ink-3); line-height: 1.4; }

/* ---------- Admin form-control baseline ----------
   Every bare <input>/<select>/<textarea> inside the admin panel - chiefly the
   inline-edit controls in admin tables (variations, discounts, inventory,
   users, booking-detail occupants/payments) and a few standalone selects -
   used to fall through to raw browser styling: system font, grey square
   borders, no champagne focus ring. This gives them the same look as the
   site's `.form-row` fields.

   The selectors are wrapped in `:where(...)` so their specificity collapses to
   that of the bare element (0,0,1). That means:
     - it always beats the user-agent default, but
     - it NEVER overrides the already-themed wrappers (`.form-row`,
       `.search-card`, `.phone-input`, `.md-editor` - all (0,1,1) or higher) or
       any inline `style=""` an individual control already carries.
   So this is purely additive - it dresses the controls nothing else reaches. */
:where(.admin-main) input:where(
    [type="text"], [type="email"], [type="number"], [type="date"],
    [type="datetime-local"], [type="month"], [type="week"], [type="time"],
    [type="search"], [type="tel"], [type="url"], [type="password"], :not([type])),
:where(.admin-main) select,
:where(.admin-main) textarea {
  padding: 10px 12px;
  border: 1px solid var(--line);
  border-radius: var(--r-input);
  font: inherit;
  color: var(--ink);
  background: var(--card);
  max-width: 100%;
  transition: border-color .15s ease, box-shadow .15s ease;
}
:where(.admin-main) textarea { resize: vertical; line-height: 1.55; }
:where(.admin-main) input:where(
    [type="text"], [type="email"], [type="number"], [type="date"],
    [type="datetime-local"], [type="month"], [type="week"], [type="time"],
    [type="search"], [type="tel"], [type="url"], [type="password"], :not([type])):focus,
:where(.admin-main) select:focus,
:where(.admin-main) textarea:focus {
  outline: none;
  border-color: var(--ink);
  box-shadow: var(--shadow-focus);
}
:where(.admin-main) input:disabled,
:where(.admin-main) select:disabled,
:where(.admin-main) textarea:disabled,
:where(.admin-main) input:read-only:not([type="checkbox"]):not([type="radio"]) {
  background: var(--paper-2);
  color: var(--ink-3);
  cursor: not-allowed;
}
/* Checkboxes / radios: just tint them with the brand accent. */
:where(.admin-main) input[type="checkbox"],
:where(.admin-main) input[type="radio"] { accent-color: var(--champagne); }

/* Inline-edit controls live inside dense table cells - tighten them so a row
   of inputs stays a comfortable height. (Same low specificity; declared after
   the baseline so the compact padding wins.) */
:where(.admin-main .table) input,
:where(.admin-main .table) select { padding: 7px 9px; min-height: 0; }

/* File pickers - dress the native "Choose file" button to match `.btn-soft`
   instead of the OS-grey default. */
:where(.admin-main) input[type="file"] { font-size: 13px; color: var(--ink-2); }
:where(.admin-main) input[type="file"]::file-selector-button {
  margin-right: 12px;
  padding: 8px 14px;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--card);
  color: var(--ink);
  font: inherit; font-weight: var(--fw-medium); font-size: 12px;
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease;
}
:where(.admin-main) input[type="file"]:hover::file-selector-button {
  background: var(--paper-2); border-color: var(--line-2);
}

/* ---------- Chat: the vendor widget's iframe ----------
   THE WIDGET'S IFRAME MUST BE TOLD OUR COLOUR SCHEME, OR THE BROWSER PAINTS IT
   WHITE. An iframe's canvas is transparent only while the embedded document's
   used `color-scheme` MATCHES its embedder's (CSS Color Adjust); differ, and
   the UA paints an opaque canvas of its own so the content is never left on an
   unknown background. `header.ejs` declares <meta name="color-scheme"
   content="light dark">, so in our dark theme this document resolves to DARK,
   while Starko's widget writes its own document forcing `color-scheme: light`.
   Mismatched - so the frame paints opaque white.

   What that looks like is NOT a subtle tint, and it is why this rule exists:
   the frame is sized to its content, so CLOSED it is a white slab behind the
   launcher and its greeting bubble, and OPEN it is resized to the whole
   viewport - a white sheet over the entire site with the chat panel floating
   on it. Both were reported from the live staging site.

   Starko tried to prevent exactly this and their guard cannot work twice over.
   They set `background: "rgba(255,255,255,0)!important"` through
   `Object.assign(el.style, ...)`, and a CSSOM value carrying `!important` is
   invalid, so it is silently DROPPED and never reaches the element. Even had it
   landed, `background` does not govern canvas opacity - `color-scheme` does.

   So we declare the frame `light`, matching the document it actually contains,
   and the canvas goes back to being transparent. It is deliberately NOT `dark`:
   the widget's own UI is light (their stylesheet hardcodes it), and claiming
   dark would hand a light panel a dark canvas - the same fault inverted.

   Verified in headless Chrome against the real widget, with the embedder forced
   to `color-scheme: dark`: the white slab and the full-page white sheet both
   appear without this rule and both are gone with it, closed and open. Nothing
   hermetic can see any of it - jsdom has no layout engine and paints no canvas.

   The second selector is the id the frame carries for the instant between
   creation and `init()` renaming it, so a vendor version that stops renaming
   cannot bring the white back silently. */
#starko-widget,
iframe[id^="widget-frame-"] { color-scheme: light; }

/* ---------- Chat (in-house fallback) ---------- */
.chat-fab {
  position: fixed; bottom: 20px; right: 20px; z-index: 60;
  background: var(--brand-ink); color: var(--brand-paper);
  width: 56px; height: 56px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  border: 0; cursor: pointer; font-size: 22px; font-weight: var(--fw-medium);
  box-shadow: var(--shadow-md); transition: transform .15s ease, background-color .15s ease;
}
.chat-fab:hover { background: var(--brand-ink-2); transform: scale(1.04); }
.chat-fab:focus-visible { box-shadow: var(--shadow-focus); outline: none; }
.chat-panel {
  position: fixed; bottom: 88px; right: 20px; width: 340px; max-width: calc(100vw - 24px); height: 460px; max-height: calc(100vh - 130px);
  background: var(--brand-ink); color: var(--brand-paper); border-radius: var(--r-card); box-shadow: var(--shadow-card);
  display: none; flex-direction: column; z-index: 60; overflow: hidden;
  border: 1px solid var(--brand-ink-2);
}
.chat-panel.open { display: flex; }
.chat-head { padding: 14px 16px; background: var(--brand-ink); color: var(--brand-paper); border-bottom: 1px solid rgba(255,255,255,0.08); }
.chat-head strong { display: block; font-family: var(--font-display); font-size: 14px; font-weight: var(--fw-medium); }
.chat-head small { font-size: 11px; opacity: 0.65; }
.chat-body { flex: 1; overflow-y: auto; padding: 14px 16px; background: var(--brand-ink); }
.chat-msg { padding: 9px 13px; border-radius: 10px; margin-bottom: 8px; font-size: 13px; max-width: 84%; line-height: 1.45; }
.chat-msg.bot { background: #1F1D18; color: var(--brand-paper); border-radius: 10px 10px 10px 2px; }
.chat-msg.user { background: var(--brand-champagne); color: var(--brand-ink); margin-left: auto; border-radius: 10px 10px 2px 10px; }
.chat-input { display: flex; padding: 10px; border-top: 1px solid rgba(255,255,255,0.08); background: #1F1D18; gap: 8px; }
.chat-input input {
  flex: 1; padding: 9px 14px;
  border: 1px solid rgba(255,255,255,0.2); border-radius: var(--r-md);
  font: inherit; background: #1B1B1D; color: var(--brand-paper);
}
.chat-input input:focus { outline: none; border-color: var(--brand-champagne); box-shadow: 0 0 0 2px rgba(193,171,117,.25); }
.chat-input button { background: var(--brand-champagne); color: var(--brand-ink); border: 0; border-radius: var(--r-md); padding: 0 18px; cursor: pointer; font-weight: var(--fw-medium); }

/* ---------- Misc helpers ---------- */
.row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.spread { display: flex; justify-content: space-between; align-items: center; gap: var(--s-16); flex-wrap: wrap; }
.text-right { text-align: right; }
.mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; } .mt-3 { margin-top: 24px; } .mt-4 { margin-top: 32px; }
.mb-1 { margin-bottom: 8px; } .mb-2 { margin-bottom: 16px; } .mb-3 { margin-bottom: 24px; } .mb-4 { margin-bottom: 32px; }
hr.div { border: 0; border-top: 1px solid var(--line); margin: var(--s-24) 0; }
ul.clean { list-style: none; padding: 0; margin: 0; }
ul.clean li { padding: 12px 0; border-bottom: 1px solid var(--line); }
ul.clean li:last-child { border-bottom: 0; }

/* ---------- FAQ accordion-style ---------- */
.faq-item {
  background: var(--card);
  border: 1px solid var(--line);
  border-radius: var(--r-card);
  padding: 18px 22px;
  margin-bottom: 8px;
  transition: border-color .15s ease, background .15s ease;
}
.faq-item:hover { border-color: var(--line-2); }
.faq-item.open { background: var(--card); border-color: var(--ink); border-width: 1.5px; padding: 17.5px 22px; }
.faq-item h4 { margin-bottom: 0; font-size: 16px; font-weight: var(--fw-medium); display: flex; justify-content: space-between; align-items: center; gap: 12px; cursor: pointer; }
.faq-item h4::after { content: "+"; color: var(--ink-3); font-weight: var(--fw-regular); flex-shrink: 0; font-size: 18px; transition: transform .15s ease; }
.faq-item.open h4::after { content: "–"; }
/* Collapse the body when the card isn't .open. The JS just toggles .open;
   visibility is driven entirely from here. */
.faq-item p { margin: 0; font-size: 14px; color: var(--ink-3); max-width: 640px; max-height: 0; overflow: hidden; opacity: 0; transition: max-height .25s ease, opacity .2s ease, margin-top .2s ease; }
.faq-item.open p { margin-top: 12px; max-height: 480px; opacity: 1; }

/* ---------- Choice cards (payment plan, transfer) ---------- */
.choice {
  display: flex; gap: 14px; align-items: flex-start; cursor: pointer;
  padding: 18px; border: 1.5px solid var(--line);
  border-radius: var(--r-card); margin-bottom: 10px;
  background: var(--card);
  transition: border-color .15s ease, background-color .15s ease;
}
.choice:hover { border-color: var(--line-2); }
.choice input { margin-top: 4px; accent-color: var(--champagne); }
/* Selected-state tint. `:has()` is the right tool (the input is a CHILD of the
   label, so no sibling combinator can reach the card), but it is unsupported on
   Safari < 15.4. Guard with @supports so we only rely on it where it works; the
   checkbox's own checked styling still communicates state where it doesn't. */
@supports selector(:has(*)) {
  .choice:has(input:checked) { background: var(--paper-2); border-color: var(--ink); }
}
/* Fallback for browsers without :has() - at least flag the card while the
   control inside it has keyboard focus. */
@supports not selector(:has(*)) {
  .choice:focus-within { background: var(--paper-2); border-color: var(--ink); }
}
.choice .label { flex: 1; }
.choice strong { font-weight: var(--fw-medium); }

/* ---------- Payment mode cards (full vs instalment) ---------- */
.pay-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 24px; }
@media (max-width: 600px) { .pay-modes { grid-template-columns: 1fr; } }
.pay-mode {
  text-align: left; background: var(--card); color: var(--ink);
  border: 1.5px solid var(--line);
  border-radius: var(--r-card); padding: 22px;
  cursor: pointer;
  display: flex; flex-direction: column; gap: 12px;
  font-family: inherit;
}
.pay-mode .pay-mode-top { display: flex; justify-content: space-between; align-items: center; }
.pay-mode .pay-mode-radio {
  width: 20px; height: 20px; border-radius: 50%;
  border: 1.5px solid var(--line-2);
  display: inline-flex; align-items: center; justify-content: center;
}
.pay-mode .pay-mode-radio::after {
  content: ""; width: 10px; height: 10px; border-radius: 50%; background: transparent;
}
/* The global `h1..h4` rule binds color to var(--ink) - explicitly inherit it
   here so .pay-mode.active (dark ink background, paper text) doesn't ship a
   near-invisible dark-on-dark heading. */
.pay-mode h3, .pay-mode .pay-mode-lines, .pay-mode .pay-mode-lines * { color: inherit; }
.pay-mode h3 { font-size: 22px; font-weight: var(--fw-medium); margin-bottom: 0; }
.pay-mode .pay-mode-lines { display: flex; flex-direction: column; gap: 4px; margin-top: auto; }
.pay-mode .pay-mode-lines > div { display: flex; justify-content: space-between; font-size: 13px; }
.pay-mode .pay-mode-lines > div span:first-child { opacity: 0.65; }
.pay-mode .pay-mode-lines > div span:last-child { font-weight: var(--fw-medium); }
.pay-mode.active {
  background: var(--ink); color: var(--paper); border-color: var(--ink);
}
.pay-mode.active .pay-mode-radio { border-color: var(--paper); }
.pay-mode.active .pay-mode-radio::after { background: var(--champagne); }
.pay-mode.active .pill { background: rgba(255,255,255,0.12); color: var(--paper); border-color: rgba(255,255,255,0.18); }

/* ---------- Payment schedule timeline ---------- */
.pay-schedule { display: flex; flex-direction: column; }
.pay-schedule-row {
  display: grid; grid-template-columns: 40px 1fr 1fr 110px;
  gap: 14px; align-items: center;
  padding: 14px 0;
  border-bottom: 1px dashed var(--line);
}
.pay-schedule-row:last-child { border-bottom: 0; }
.pay-schedule-row .num-circle {
  width: 28px; height: 28px; border-radius: 50%;
  border: 1.5px dashed var(--line-2);
  background: transparent; color: var(--ink-3);
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-mono); font-size: 11px;
}
.pay-schedule-row.now .num-circle { background: var(--ink); color: var(--paper); border: none; }
.pay-schedule-row.paid .num-circle { background: var(--teal); color: var(--on-ok); border: none; }
.pay-schedule-row .pay-label { font-weight: var(--fw-medium); font-size: 14px; }
.pay-schedule-row .pay-meta { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
.pay-schedule-row .pay-amount { font-family: var(--font-display); font-size: 20px; text-align: right; letter-spacing: -0.02em; font-weight: var(--fw-medium); }
.pay-schedule-row .pay-status { text-align: right; }
/* Padded variant: rows sit inside a flush panel with solid separators instead of
   the dashed timeline look (used on the payment page schedule). */
.pay-schedule-row-padded { padding: 16px 22px; border-bottom: 1px solid var(--line); }
.pay-schedule-row-padded:last-child { border-bottom: 0; }
.pay-schedule-error { padding: 0 22px 14px; font-size: 12px; color: var(--bad); }
@media (max-width: 600px) {
  .pay-schedule-row { grid-template-columns: 28px 1fr auto; gap: 10px; }
  .pay-schedule-row > .pay-cell-meta { display: none; }
}

/* ---------- Booking reference hero ---------- */
.ref-hero {
  background: var(--brand-ink); color: var(--brand-paper);
  border-radius: var(--r-lg); padding: 28px;
  display: grid; grid-template-columns: 1fr auto;
  align-items: center; gap: 24px;
  position: relative; overflow: hidden;
}
@media (max-width: 600px) { .ref-hero { grid-template-columns: 1fr; gap: 18px; } }
.ref-hero .ref-eyebrow {
  font-size: 11px; color: rgba(255,255,255,0.6);
  letter-spacing: 0.08em; text-transform: uppercase;
  font-weight: var(--fw-medium);
}
.ref-hero .ref-code {
  font-family: var(--font-mono); font-size: clamp(28px, 5vw, 48px);
  margin-top: 10px; letter-spacing: -0.01em; line-height: 1.05; font-weight: var(--fw-medium);
}
.ref-hero .ref-code .accent { color: var(--brand-champagne); }
.ref-hero .ref-note { margin-top: 10px; font-size: 13px; opacity: 0.7; max-width: 540px; }
.ref-hero h2 { color: var(--brand-paper); }
/* Right-aligned balance readout on the reference hero (e.g. the payment page). */
.ref-hero-balance { text-align: right; }
.ref-hero-balance .ref-balance-value {
  font-family: var(--font-display); font-size: 32px; margin-top: 4px;
  letter-spacing: -0.025em; font-weight: var(--fw-medium);
}
.ref-hero-balance .ref-balance-of { font-size: 13px; color: rgba(255,255,255,0.55); font-weight: var(--fw-regular); }
@media (max-width: 600px) { .ref-hero-balance { text-align: left; } }

/* ---------- Booking confirmation page ---------- */
/* Status line: a coloured dot + label + timestamp above the headline. */
.status-line { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-3); margin-bottom: 14px; }
.status-line .status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); flex: 0 0 auto; }
.status-line .status-label { font-weight: var(--fw-medium); text-transform: uppercase; letter-spacing: 0.08em; }
.confirm-hero-title { font-size: clamp(36px, 5vw, 48px); max-width: 720px; }
.confirm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-top: 18px; }
@media (max-width: 760px) { .confirm-grid { grid-template-columns: 1fr; } }
.confirm-grid > .panel { margin-bottom: 0; }
/* Room thumbnail (confirmation summary). */
.room-thumb { width: 88px; height: 88px; background: var(--paper-2); border-radius: var(--r-md); flex-shrink: 0; overflow: hidden; position: relative; }
.room-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.room-thumb-fallback { position: absolute; inset: 0; background: var(--champagne); background-image: repeating-linear-gradient(135deg, rgba(20,18,16,.06) 0 1px, transparent 1px 12px); }
.room-summary { display: flex; gap: 16px; margin-bottom: 18px; }
.room-summary .room-stay { font-family: var(--font-display); font-size: 20px; margin-top: 8px; letter-spacing: -0.02em; font-weight: var(--fw-medium); }
.room-summary .room-sub { font-size: 11px; color: var(--ink-3); margin-top: 2px; }
/* Guest list rows. */
.guest-list { display: flex; flex-direction: column; gap: 10px; }
.guest-row { display: flex; justify-content: space-between; gap: 8px; font-size: 13px; }
.guest-row .g-name { font-weight: var(--fw-medium); }
.guest-row .g-meta { color: var(--ink-3); font-size: 11px; }
/* Correcting one guest's details in place (migration 091). The disclosure sits
   directly under the row it edits, so it is inset and hairlined to the row
   rather than floated as a panel of its own - a guest list of six would
   otherwise read as six stacked cards. `.guest-list` is a flex column, so the
   negative top margin closes its gap and keeps the control attached to its
   guest instead of floating midway between two of them. */
.guest-edit { margin: -4px 0 2px; padding-left: 10px; border-left: 2px solid var(--line); }
.guest-edit > summary { font-size: 12px; color: var(--ink-3); cursor: pointer; padding: 2px 0; }
.guest-edit > summary:hover { color: var(--ink); }
.guest-edit[open] > summary { margin-bottom: 8px; }
.guest-edit .form-grid { margin-bottom: 6px; }
.guest-edit .panel-note { margin: 0 0 10px; font-size: 11px; }
/* Confirmation price breakdown (room, add-ons, savings) above the total. */
.confirm-breakdown { margin-bottom: 14px; }
.confirm-breakdown .cb-row { display: flex; justify-content: space-between; gap: 12px; padding: 5px 0; font-size: 13px; color: var(--ink-3); }
.confirm-breakdown .cb-row strong { color: var(--ink); font-weight: var(--fw-medium); }
.confirm-breakdown .cb-row.cb-save, .confirm-breakdown .cb-row.cb-save strong { color: var(--teal); }
/* Confirmation payment ledger rows. */
.confirm-total { font-family: var(--font-display); font-size: 26px; letter-spacing: -0.02em; font-weight: var(--fw-medium); }
.confirm-pay-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--line); }
.confirm-pay-row:last-child { border-bottom: 0; }
.confirm-pay-row .cp-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--line-2); flex: 0 0 auto; }
.confirm-pay-row.paid .cp-dot { background: var(--teal); }
.confirm-pay-row .cp-date { font-size: 12px; color: var(--ink-3); width: 110px; }
.confirm-pay-row .cp-amt { flex: 1; font-weight: var(--fw-medium); font-size: 14px; }
/* Soft inset note (receipt / reminder copy). */
.soft-note { margin-top: 14px; padding: 14px; background: var(--paper-2); border-radius: var(--r-md); font-size: 12px; color: var(--ink-2); }
.soft-note.with-tag { display: flex; gap: 10px; }
.soft-note .note-tag { font-family: var(--font-mono); font-size: 10px; color: var(--ink-3); letter-spacing: 0.1em; text-transform: uppercase; padding-top: 2px; flex: 0 0 auto; }

/* ---------- Activity feed grid (admin) ---------- */
.activity-row {
  display: grid; grid-template-columns: 56px 130px 130px 1fr;
  gap: 14px; align-items: center;
  padding: 14px 22px;
  border-bottom: 1px solid var(--line);
}
.activity-row:last-child { border-bottom: 0; }
.activity-row .activity-t { font-family: var(--font-mono); font-size: 11px; color: var(--ink-3); }
.activity-row .activity-e {
  font-size: 11px; font-weight: var(--fw-medium); letter-spacing: 0.02em;
  display: flex; align-items: center; gap: 6px;
}
.activity-row .activity-e .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
.activity-row .activity-ref { font-family: var(--font-mono); font-size: 11px; color: var(--ink-2); }
.activity-row .activity-detail { font-size: 13px; color: var(--ink-3); }
.activity-row .activity-detail strong { color: var(--ink); font-weight: var(--fw-medium); }
@media (max-width: 760px) {
  .activity-row { grid-template-columns: 1fr; gap: 4px; }
  .activity-row .activity-t { font-size: 10px; }
}

/* ---------- Stripe Elements ---------- */
.stripe-mount { background: var(--card); border: 1px solid var(--line); border-radius: var(--r-input); padding: 14px; min-height: 60px; }
#stripe-error { color: var(--bad); font-size: 13px; margin-top: 8px; min-height: 1.2em; }
.kbd { font-family: var(--font-mono); background: var(--paper-2); border: 1px solid var(--line); padding: 1px 6px; border-radius: var(--r-pill); font-size: 12px; }

/* ---------- Avatar circles (occupants) ---------- */
.avatar-circle {
  width: 32px; height: 32px; border-radius: 50%;
  background: var(--brand-ink); color: var(--brand-paper);
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--font-display); font-size: 13px; font-weight: var(--fw-medium);
  flex-shrink: 0;
}
.avatar-circle.tone-champagne { background: var(--brand-champagne); color: var(--brand-ink); }
.avatar-circle.tone-teal { background: var(--teal); color: var(--on-ok); }
.avatar-circle.tone-saffron { background: var(--saffron); color: var(--on-warn); }
.avatar-circle.tone-ink { background: var(--brand-ink); color: var(--brand-paper); }
.avatar-circle.lg { width: 44px; height: 44px; font-size: 16px; }

/* ---------- Admin -> Users ---------- */
/* Collapsible invite panel: a plain <details class="panel"> with a styled
   summary so it stays out of the way until an admin needs it. */
.invite-panel { padding: 0; }
.invite-panel > summary {
  list-style: none; cursor: pointer;
  display: flex; align-items: center; justify-content: space-between;
  padding: 16px 18px; font-weight: var(--fw-medium);
}
.invite-panel > summary::-webkit-details-marker { display: none; }
.invite-panel > summary:hover { background: var(--paper-2); }
.invite-panel-chevron { font-size: 18px; color: var(--ink-3); line-height: 1; transition: transform .15s ease; }
.invite-panel[open] > summary { border-bottom: 1px solid var(--line); }
.invite-panel[open] .invite-panel-chevron { transform: rotate(45deg); }
.invite-panel-body { padding: 18px; }

/* Search + role tabs + follow-up toggle bar. */
.users-controls {
  display: flex; flex-wrap: wrap; align-items: center; gap: 14px 20px;
}
.users-search { flex: 1 1 240px; min-width: 200px; }
.users-search input:not([type="checkbox"]):not([type="radio"]) { width: 100%; height: 40px; }
.users-rolebar { position: static; margin: 0; box-shadow: none; flex: 1 1 auto; }
.rolebar-count {
  display: inline-block; min-width: 18px; padding: 0 5px; margin-left: 2px;
  font-size: 11px; line-height: 17px; text-align: center;
  border-radius: 999px; background: var(--paper-2); color: var(--ink-3);
}
.subtabs a.active .rolebar-count { background: var(--teal); color: var(--on-ok); }
.users-followup-toggle {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 13px; color: var(--ink-2); cursor: pointer; white-space: nowrap;
}
.users-followup-toggle input { width: 16px; height: 16px; margin: 0; }

/* Roomy, scannable rows. */
.users-table td { vertical-align: middle; }
.user-identity { display: flex; align-items: center; gap: 12px; }
.user-identity-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.user-identity-text strong { line-height: 1.25; }
.user-identity-text .muted { font-size: .82rem; overflow: hidden; text-overflow: ellipsis; }
.user-identity-text .badge { align-self: flex-start; margin-top: 2px; }

/* Per-row "Manage" editor. Now a native <details> inside the row's last cell:
   the shared table partial emits exactly one <tr> per record, so the editor
   can no longer live in a second row, and <details> needs no script to open. */
.urow-manage-details { position: relative; }
.urow-manage-details > summary { list-style: none; cursor: pointer; display: inline-flex; }
.urow-manage-details > summary::-webkit-details-marker { display: none; }
/* The no-JS presentation. `public/js/user-manage.js` moves .manage-panel into a
   modal instead, so these rules are what the page falls back to if that script
   never loads - the panel and everything in it are server-rendered either way. */
.urow-manage-details[open] > .manage-panel {
  /* Floated out of the cell so the editor is not squeezed into a narrow
     right-hand column. Right-anchored to stay inside the table on wide rows. */
  position: absolute; right: 0; z-index: 5; margin-top: 8px;
  background: var(--paper-2); border: 1px solid var(--line);
  border-radius: var(--radius-sm, 8px); padding: 16px 18px;
  box-shadow: 0 10px 28px rgba(0,0,0,.12);
  text-align: left; white-space: normal;
  width: max-content; max-width: min(560px, 88vw);
  /* The record above the actions makes this taller than the old actions-only
     popover; cap it rather than let a row's panel run off the viewport. */
  max-height: 72vh; overflow: auto;
}
@media (max-width: 720px) {
  .urow-manage-details[open] > .manage-panel {
    position: static; box-shadow: none; width: auto; max-width: none; max-height: none;
  }
}
/* Per-charge refund on the booking's payment schedule. A refund needs a reason
   and a note before it can be issued (they decide whether the booking total
   comes down with the money), so the Refund button opens a small form in the
   row instead of firing on one click. Floated out of the cell like the Manage
   editor above: the actions column is far too narrow for a select. */
.refund-row { position: relative; }
.refund-row > summary { list-style: none; cursor: pointer; display: inline-flex; }
.refund-row > summary::-webkit-details-marker { display: none; }
.refund-row[open] > form {
  position: absolute; right: 0; z-index: 5;
  background: var(--paper-2); border: 1px solid var(--line);
  border-radius: var(--radius-sm, 8px); padding: 10px 12px;
  box-shadow: 0 10px 28px rgba(0,0,0,.12);
  text-align: left; white-space: normal;
}
@media (max-width: 720px) {
  .refund-row[open] > form { position: static; box-shadow: none; min-width: 0; }
}

.urow-manage > td { background: var(--paper-2); padding: 16px 18px; }
.manage-grid { display: flex; flex-wrap: wrap; gap: 18px 28px; }

/* The brief record above the actions. Staff open this panel to change
   something about a person, so who that person is comes first. */
.manage-summary {
  display: flex; flex-direction: column; gap: 12px;
  padding-bottom: 16px; margin-bottom: 16px;
  border-bottom: 1px solid var(--line);
}
.manage-summary-head { display: flex; align-items: center; gap: 12px; }
.manage-summary-id { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.manage-summary-id strong { line-height: 1.25; }
.manage-summary-id .muted { font-size: .82rem; overflow: hidden; text-overflow: ellipsis; }
.manage-facts {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 10px 24px; margin: 0;
}
.manage-facts > div { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.manage-facts dt {
  font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-3); font-weight: var(--fw-medium);
}
.manage-facts dd { margin: 0; font-size: .88rem; overflow-wrap: anywhere; }
.manage-facts dd .badge { vertical-align: middle; }
.manage-block { display: flex; flex-direction: column; gap: 6px; }
.manage-label {
  font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-3); font-weight: var(--fw-medium);
}
.manage-form { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0; }
.manage-form select,
.manage-form input:not([type="checkbox"]):not([type="radio"]) { height: 36px; }
.manage-form input[name="phone"] { width: 150px; }
/* The admin field baseline, restated keyed on the PANEL rather than on where it
   currently sits. Every rule that dresses a form control in the admin is scoped
   `:where(.admin-main) ...`, and user-manage.js MOVES this panel into a modal
   that LUL.dialog appends to <body> - outside `.admin-main`, where none of them
   match any more. Two things break when they stop matching, and only one of
   them is visible: the phone field falls back to the UA's white box on a dark
   panel, and - less obviously - the hidden native <select> loses `font:
   inherit` while the shim button drawn over it keeps it, so the button's text
   is wider than the box the select sized, and every dropdown truncates to
   "GB +...". Keep this in step with the `:where(.admin-main)` field rules. */
.manage-panel select,
.manage-panel textarea,
.manage-panel input:not([type="checkbox"]):not([type="radio"]) {
  padding: 7px 9px; min-height: 0;
  border: 1px solid var(--line); border-radius: var(--r-input);
  font: inherit; color: var(--ink); background: var(--card); max-width: 100%;
}
.manage-form .lul-select-btn { padding: 7px 9px; padding-right: 28px; }

@media (max-width: 720px) {
  .manage-grid { flex-direction: column; gap: 14px; }
  .manage-facts { grid-template-columns: minmax(0, 1fr); }
}

/* The same panel inside LUL.dialog (public/js/user-manage.js). The default
   modal is sized for a sentence of confirmation copy; this one holds a record
   and three action blocks, so it needs the width the row popover had. */
.lul-modal.manage-modal { max-width: 620px; }
.lul-modal.manage-modal .manage-panel { text-align: left; }
.lul-modal.manage-modal .manage-grid { flex-direction: column; gap: 16px; }
/* The dialog title is the person's name, so the record's own name line would be
   the same words twice, one under the other. The email is not a duplicate and
   stays. In the <details> fallback there is no title, so the name stays there. */
.lul-modal.manage-modal .manage-summary-id strong { display: none; }

/* ---------- Admin -> the user record (/admin/users/:id) ----------
   Staff open a person's record because something might be wrong with them, so
   the page answers that first: identity, then an attention strip, then the
   money, then the detail in tabs. The strip is what stops a failed instalment
   or a bouncing address from being findable only by opening every tab. */
.record-tags {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 8px; margin-top: 12px; font-size: 12.5px;
}
.user-identity-text h1 { line-height: 1.15; }

/* The attention strip's items. Rendered inside a .callout, so the tone colour
   comes from the callout and each line inherits it. */
.attn-list {
  list-style: none; margin: 10px 0 0; padding: 0;
  display: flex; flex-direction: column; gap: 8px;
  font-size: 13px; line-height: 1.5;
}
.attn-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
.attn-list strong { font-weight: var(--fw-medium); }
.attn-list a { color: inherit; font-weight: var(--fw-medium); white-space: nowrap; }
.attn-detail { opacity: 0.82; }
.callout-danger .attn-list { color: var(--color-err-fg); }
.callout-warn .attn-list { color: var(--color-warn-fg); }

/* Progress bar inside a KPI tile (paid against the account total). */
.kpi-meter { margin-top: 12px; height: 5px; border-radius: 3px; background: var(--paper-3); overflow: hidden; }
.kpi-meter > span { display: block; height: 100%; background: var(--teal); }

/* Panels that read as a set rather than a stack: they hold short readouts, so
   side by side they can be scanned in one pass instead of scrolled through. */
.record-grid { display: grid; gap: 12px; grid-template-columns: 1fr; margin-bottom: var(--s-24); }
@media (min-width: 900px) { .record-grid { grid-template-columns: repeat(auto-fit, minmax(330px, 1fr)); } }
/* Cards in a row are equal height (grid stretches them), so the closing note is
   pushed to the bottom edge instead of floating mid-card under short content. */
.record-grid > .panel { margin-bottom: 0; display: flex; flex-direction: column; }
.record-grid > .panel > .record-note { margin-top: auto; }
/* A closing note inside a panel: where to go for the full version of what the
   panel summarises. Ruled off so it does not read as another readout. */
.record-note {
  margin: var(--s-16) 0 0; padding-top: 12px; border-top: 1px solid var(--line);
  font-size: 12.5px; color: var(--ink-3); line-height: 1.5;
}

/* ---------- Admin -> a lead (/admin/leads/:id) ----------
   The funnel as a vertical stepper. The point of the page is one glance at how
   far someone got, so the steps they cleared, the one they stopped on and the
   ones they never saw have to be told apart without reading the labels. */
.lead-steps { list-style: none; margin: 0 0 var(--s-16); padding: 0; }
.lead-step {
  display: flex; align-items: baseline; gap: 10px;
  padding: 7px 0 7px 2px; font-size: 13px; color: var(--ink-3);
  position: relative;
}
/* The connector, drawn behind the dots so the column reads as one run rather
   than a stack of unrelated rows. Not on the last step - a line trailing off
   below the final label suggests a step that is missing. */
.lead-step:not(:last-child)::before {
  content: ""; position: absolute; left: 4px; top: 20px; bottom: -6px;
  width: 1px; background: var(--line);
}
.lead-step-dot {
  flex: 0 0 auto; width: 9px; height: 9px; border-radius: 50%;
  background: var(--paper-3); border: 1px solid var(--line);
  position: relative; z-index: 1;
}
.lead-step.done { color: var(--ink-2); }
.lead-step.done .lead-step-dot { background: var(--line-2); border-color: var(--line-2); }
.lead-step.here { color: var(--ink-1); font-weight: var(--fw-medium); }
.lead-step.here .lead-step-dot {
  background: var(--teal); border-color: var(--teal);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--teal) 18%, transparent);
}
.lead-step-label { flex: 1 1 auto; }
.lead-step-when { flex: 0 0 auto; font-size: 12px; color: var(--ink-3); font-weight: 400; }

/* ---------- Working a lead (migration 074) ----------
   Two columns: what happened on the left, what to do next on the right. The
   right column is narrow and sticky because it is a control panel, not reading
   material - the admin edits it while looking at the story beside it. */
.lead-work {
  display: grid; grid-template-columns: minmax(0, 1fr) 320px;
  gap: var(--gap-block); align-items: start;
}
.lead-work-main { min-width: 0; display: flex; flex-direction: column; gap: var(--gap-block); }
/* The column IS the spacing here (flex gap), so the panels' own bottom margin
   would double every gap in it. */
.lead-work .panel, .lead-work .record-grid { margin-bottom: 0; }
.lead-work-side {
  min-width: 0; display: flex; flex-direction: column; gap: var(--gap-tile);
  position: sticky; top: calc(var(--stick-top, 0px) + 12px);
}
/* One column below the point where 320px of controls stops leaving a readable
   measure for the story.

   The controls come FIRST once stacked, which reverses the source order. Side
   by side, the eye reaches both at once and the story deserves the wide column;
   stacked, leaving the markup order alone buries the outcome and owner panels
   under the whole record - the funnel, the guest table and the activity log -
   so the two controls somebody opened the page to use are the last thing on it.
   The header badges already carry the context needed to set them. */
@media (max-width: 1100px) {
  .lead-work { grid-template-columns: minmax(0, 1fr); }
  .lead-work-side { position: static; order: -1; }
}

.lead-props {
  display: flex; flex-direction: column; gap: 12px;
  background: var(--card); border: 1px solid var(--line); border-radius: var(--r-lg);
  padding: 14px; box-shadow: var(--shadow-xs);
}
.lead-props .form-row { margin-bottom: 0; }
.lead-props-head {
  font-size: 11px; text-transform: uppercase; letter-spacing: .06em;
  color: var(--ink-3); font-weight: var(--fw-medium);
}
.lead-props-hint { display: block; margin-top: 4px; font-size: 11px; color: var(--ink-3); }
.lead-props-save { align-self: flex-start; }

/* The two composers. They do different things to different people, so they are
   told apart by tint and by a standing line under each heading rather than by
   the reader remembering which one is which. */
.lead-composers {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--gap-tile); margin-bottom: var(--gap-tile);
}
.lead-compose {
  display: flex; flex-direction: column; gap: 8px;
  border: 1px solid var(--line); border-radius: var(--r-md); padding: 12px;
}
.lead-compose textarea, .lead-compose input[type="text"] { width: 100%; }
.lead-compose-head { display: flex; flex-direction: column; gap: 2px; }
.lead-compose-title { font-size: 13px; font-weight: var(--fw-medium); color: var(--ink); }
.lead-compose-who { font-size: 11px; color: var(--ink-3); }
.lead-compose-foot { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.lead-compose-kind select { min-width: 110px; }
/* Amber, dashed, and the same treatment an internal ticket note gets: a staff
   message that is never sent anywhere must not look like one that is. */
.lead-compose.is-internal {
  background: var(--color-warn-bg); border-style: dashed; border-color: var(--color-warn-line);
}
.lead-compose.is-internal .lead-compose-title,
.lead-compose.is-internal .lead-compose-who { color: var(--color-warn-fg); }
.lead-compose.is-outbound { background: var(--paper-2); }

/* The story. One list, staff notes and system events together, because two
   lists ordered by time is not a story - it is two lists. */
.lead-trail { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.lead-trail-item {
  border-left: 2px solid var(--line-2); padding: 2px 0 2px 12px;
}
.lead-trail-item.is-note    { border-left-color: var(--warn); }
.lead-trail-item.is-contact { border-left-color: var(--sky); }
.lead-trail-item.is-system  { border-left-color: var(--line-2); }
/* What the CUSTOMER changed, which is neither a note we wrote nor a system
   action - it is the only entry in the trail somebody outside the building
   caused, so it gets its own edge. --violet is the informational accent and is
   in the contrast suite's text-safe set, so it can carry both the 2px boundary
   and the label beside it in either theme. */
.lead-trail-item.is-customer { border-left-color: var(--violet); }
.lead-trail-item.is-customer .lead-trail-kind { color: var(--violet); }
.lead-change-list { list-style: none; margin: 4px 0 0; padding: 0; display: flex;
  flex-direction: column; gap: 2px; font-size: 12.5px; }
.lead-change-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
.lead-change-field { color: var(--ink-3); min-width: 96px; }
.lead-change-from { color: var(--ink-2); text-decoration: line-through; }
.lead-change-arrow { color: var(--ink-3); }
.lead-change-to { color: var(--ink); font-weight: var(--fw-medium); }
.lead-trail-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; }
.lead-trail-kind {
  font-size: 10.5px; text-transform: uppercase; letter-spacing: .06em;
  font-weight: var(--fw-medium); color: var(--ink-3);
}
.lead-trail-item.is-note .lead-trail-kind    { color: var(--warn-2); }
.lead-trail-item.is-contact .lead-trail-kind { color: var(--sky); }
.lead-trail-cap { flex: 1 1 auto; font-size: 13px; color: var(--ink); }
.lead-trail-when { flex: 0 0 auto; font-size: 11.5px; color: var(--ink-3); }
.lead-trail-body {
  margin-top: 4px; font-size: 13px; color: var(--ink-2); line-height: 1.5;
  white-space: pre-wrap; overflow-wrap: anywhere;
}
/* Staff-written kinds render as Markdown (see lib/leads.js MARKDOWN_EVENT_KEYS);
   the recorded EMAIL body keeps `pre-wrap` above because it is verbatim text. */
.lead-trail-body.is-md { white-space: normal; }
.lead-trail-body.is-md > :first-child { margin-top: 0; }
.lead-trail-body.is-md > :last-child { margin-bottom: 0; }
.lead-trail-body.is-md p { margin: 0 0 7px; }
.lead-trail-body.is-md ul, .lead-trail-body.is-md ol { margin: 0 0 7px; padding-left: 20px; }
.lead-trail-body.is-md li { margin: 2px 0; }
.lead-trail-body.is-md h2, .lead-trail-body.is-md h3 { font-size: 13px; font-weight: var(--fw-medium); margin: 7px 0 4px; }
.lead-trail-body.is-md blockquote { margin: 0 0 7px; padding: 4px 10px; border-left: 2px solid var(--line-2); }
.lead-trail-body.is-md a { color: var(--teal); }
.lead-trail-who { margin-top: 3px; font-size: 11.5px; color: var(--ink-3); }

/* A lead whose promised chase date has passed. The only condition on the list
   that is our fault, so it is the only one that colours its whole row. */
.table tr.row-late > td:first-child { box-shadow: inset 2px 0 0 var(--bad); }

/* An anchored panel (a link that switches tab AND scrolls to a section) must
   clear the sticky page header plus the sticky tab bar, or the heading it was
   meant to bring into view lands underneath them. */
.admin-main .panel[id] { scroll-margin-top: calc(var(--stick-top, 0px) + 64px); }

/* Segmented control (gender, etc.) */
.segmented {
  display: flex; gap: 4px;
  background: var(--paper-2);
  border-radius: var(--r-md); padding: 4px;
}
.segmented button, .segmented label {
  flex: 1; padding: 8px 10px; border-radius: 6px;
  border: none; background: transparent;
  color: var(--ink); font-family: inherit; font-size: 13px; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center;
}
.segmented button.active, .segmented label.active,
.segmented input:checked + label {
  background: var(--ink); color: var(--paper);
}
/* The input itself is transparent, so a keyboard user tabbing onto it would see
   nothing at all. The face it drives has to wear the ring instead. */
.segmented input:focus-visible + label {
  outline: none; box-shadow: var(--shadow-focus);
}

/* ---------- Reveal-on-scroll ---------- */
.lul-reveal { opacity: 0; transform: translateY(14px); transition: opacity .55s ease, transform .55s cubic-bezier(.22, .61, .36, 1); will-change: opacity, transform; }
.lul-reveal.lul-in { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { .lul-reveal { opacity: 1; transform: none; } }

input, select, textarea { transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease; }

/* ------------------------------------------------------------------ */
/* Skeleton loaders                                                   */
/* ------------------------------------------------------------------ */
.skeleton {
  position: relative;
  background: linear-gradient(90deg, var(--paper-2) 0%, var(--paper-3) 50%, var(--paper-2) 100%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
  overflow: hidden;
}
@keyframes skeleton-shimmer { 0% { background-position: 100% 0; } 100% { background-position: -100% 0; } }
@media (prefers-reduced-motion: reduce) { .skeleton { animation: none; } }
.skeleton img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity .25s ease; position: absolute; top: 0; left: 0; inset: 0; }
.skeleton img.is-loaded, .skeleton img[loading="eager"] { opacity: 1; }
.skeleton-line { display: block; height: 14px; background: linear-gradient(90deg, var(--paper-2) 0%, var(--paper-3) 50%, var(--paper-2) 100%); background-size: 200% 100%; animation: skeleton-shimmer 1.4s ease-in-out infinite; border-radius: 4px; margin-bottom: 8px; }
.skeleton-line.sm { width: 30%; height: 10px; }
.skeleton-line.md { width: 60%; }
.skeleton-line.lg { width: 100%; }

/* Room-card grid (admin /rooms etc.).
   These MUST stay scoped to .cards-grid. The public room cards (home, /rooms)
   share the .room-card / .room-card-body class names and are declared EARLIER
   in this sheet, so an unscoped rule here silently wins on every property it
   names. It did: `display: block` beat the public card's `display: flex`, so
   .room-card-body never stretched, `margin-top: auto` on .room-card-foot had
   no free space to consume, and the CTA sat directly under the copy instead of
   on the card's bottom edge - which is why an "Enquire" card with a one-line
   title put its button ~60px above the "Book this room" next to it. */
.cards-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }
.cards-grid .room-card { display: block; background: var(--card); border: 1px solid var(--line); border-radius: var(--r-card); overflow: hidden; color: inherit; text-decoration: none; transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; }
.cards-grid .room-card:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); border-color: var(--line-2); }
/* Not scoped: admin/room-edit.ejs renders a .room-card-img outside any grid. */
.room-card-img { aspect-ratio: 3 / 2; background: var(--paper-3); position: relative; }
.cards-grid .room-card-body { padding: 12px 14px 14px; }
.gallery-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; }
.gallery-item { display: flex; flex-direction: column; gap: 6px; }
.gallery-img { aspect-ratio: 4 / 3; background: var(--paper-3); border-radius: var(--r-input); overflow: hidden; position: relative; }
.gallery-img img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Video previews are letterboxed rather than cropped so the admin checking an
   upload sees the whole frame, including whatever the controls overlay. */
.gallery-img video { width: 100%; height: 100%; object-fit: contain; display: block; background: #000; }

/* ------------------------------------------------------------------ */
/* KPI cards                                                           */
/* ------------------------------------------------------------------ */
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--gap-tile); margin-bottom: var(--gap-block); }
@media (max-width: 520px) { .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 360px) { .kpi-grid { grid-template-columns: 1fr; } }
/* The dashboard's eight tiles want an even 4 + 4. Left to auto-fit they land
   5 + 3 on a wide screen, which reads as a row of tiles plus some leftovers
   rather than as one block of eight. Below 1080px it falls back to the shared
   auto-fit behaviour, where an uneven last row is unavoidable anyway. */
/* The dashboard strip is NINE tiles and each row of three is one question
   (money moved / money expected / how much there is - see dashboard.ejs), so it
   is pinned to three columns rather than auto-fit. Four columns strands the
   ninth tile alone on a row, which is the same fault the original four-column
   pin was written to avoid when there were eight. Three holds down to 760px
   (measured: 3x3, no overflow, tiles 209px); below that two, where a third
   column would squeeze a five-figure total against its own label. */
@media (min-width: 760px) { .kpi-grid.dashboard-kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 620px) and (max-width: 759.98px) { .kpi-grid.dashboard-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.kpi {
  display: flex; flex-direction: column; min-width: 0;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-lg); padding: 18px 20px;
  position: relative; overflow: hidden;
  box-shadow: var(--shadow-xs);
  transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
/* The semantic accent is a DOT on the label rather than a hairline down the
   tile edge. A 3px bar against a white card at arm's length is close to
   invisible, and it also collided with the card's own rounded corner; a filled
   dot beside the label reads at a glance and puts the colour next to the words
   it qualifies. Neutral tiles get a neutral dot rather than none, so the row
   of labels stays optically aligned. */
.kpi-label::before {
  content: ""; width: 8px; height: 8px; border-radius: 50%; flex: none;
  background: var(--paper-3);
}
.kpi.kpi-ok   .kpi-label::before { background: var(--ok); }
.kpi.kpi-warn .kpi-label::before { background: var(--warn); }
.kpi.kpi-err  .kpi-label::before { background: var(--bad); }
.kpi.kpi-info .kpi-label::before { background: var(--violet); }
/* And the figure itself carries the hue, which is the loudest signal a tile
   has: a failing number should not be set in the same ink as a healthy one. */
.kpi.kpi-ok   .kpi-value { color: var(--color-ok-fg); }
.kpi.kpi-warn .kpi-value { color: var(--color-warn-fg); }
.kpi.kpi-err  .kpi-value { color: var(--color-err-fg); }
.kpi.kpi-info .kpi-value { color: var(--color-info-fg); }
/* Only a genuine problem tints the whole tile - the escalation above a dot. */
.kpi.kpi-err { background: var(--color-err-bg); border-color: var(--color-err-line); }
.kpi.kpi-err .kpi-label, .kpi.kpi-err .kpi-sub { color: var(--color-err-fg); opacity: .85; }
.kpi-label {
  display: flex; align-items: center; gap: 8px;
  font-size: 11px; color: var(--ink-3); text-transform: uppercase;
  letter-spacing: 0.06em; font-weight: var(--fw-medium); margin-bottom: 12px;
}
.kpi-value {
  font-family: var(--font-display); font-weight: var(--fw-medium);
  /* Clamped rather than fixed: a six-figure money value at a flat 30px
     overflowed the narrowest tile in the auto-fit grid. */
  font-size: clamp(23px, 1.15rem + 0.75vw, 30px);
  line-height: 1.08; letter-spacing: -0.025em; color: var(--ink);
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
}
/* Pushed to the bottom so the captions line up across a row of tiles whose
   values wrap to different heights, instead of floating mid-tile. */
.kpi-sub { font-size: 12px; color: var(--ink-3); margin-top: auto; padding-top: 8px; }

/* Period-on-period delta, e.g. "+£1,240.00 vs the month before". */
.kpi-trend {
  display: inline-flex; align-items: center; gap: 4px;
  font-weight: var(--fw-medium); font-variant-numeric: tabular-nums;
  color: var(--ink-2);
}
.kpi-trend.is-up   { color: var(--ok); }
.kpi-trend.is-down { color: var(--bad); }
.kpi-trend .kpi-arrow { font-size: 9px; line-height: 1; }

/* A tile that navigates says so: hover lift plus an arrow in the corner. */
a.kpi { color: inherit; text-decoration: none; }
a.kpi:hover { border-color: var(--line-2); box-shadow: var(--shadow-md); transform: translateY(-2px); }
a.kpi::after {
  content: "\2192"; position: absolute; top: 12px; right: 14px;
  font-size: 13px; color: var(--ink-3); opacity: 0;
  transform: translateX(-3px); transition: opacity .15s ease, transform .15s ease;
}
a.kpi:hover::after, a.kpi:focus-visible::after { opacity: 1; transform: none; }

/* Active-users panel (partials/presence-panel + js/admin-presence.js) */
.presence-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--teal); display: inline-block; flex: none; }
.presence-dot.is-pulse { animation: presence-pulse 1s ease-out; }
@keyframes presence-pulse {
  0%   { box-shadow: 0 0 0 0 rgba(0, 128, 128, 0.45); }
  100% { box-shadow: 0 0 0 8px rgba(0, 128, 128, 0); }
}
/* Respect a reduced-motion preference - the dot still recolours, it just
   doesn't pulse. */
@media (prefers-reduced-motion: reduce) { .presence-dot.is-pulse { animation: none; } }
.presence-spark { display: flex; align-items: flex-end; gap: 2px; height: 42px; padding: 4px 0; }
.presence-spark-bar { flex: 1 1 0; min-width: 2px; background: var(--teal); opacity: 0.55; border-radius: 2px 2px 0 0; }

.chart-wrap { background: var(--card); border: 1px solid var(--line); border-radius: var(--r-lg); padding: var(--pad-card); margin-bottom: var(--gap-block); box-shadow: var(--shadow-xs); }
.chart-wrap canvas { max-height: 280px; }
@media (max-width: 768px) { .dashboard-chart-row { grid-template-columns: 1fr !important; } }

/* ------------------------------------------------------------------ */
/* Mobile nav burger + drawer                                          */
/* ------------------------------------------------------------------ */
.nav-burger {
  display: none;
  width: 40px; height: 40px;
  align-items: center; justify-content: center;
  flex-direction: column; gap: 4px;
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  cursor: pointer;
}
.nav-burger span { display: block; width: 18px; height: 2px; background: var(--ink); transition: transform .2s ease, opacity .2s ease; }
.nav-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

.mobile-menu {
  display: none;
  padding: 12px 16px 18px;
  border-top: 1px solid var(--line);
  background: var(--paper);
}
.mobile-menu a:not(.btn) {
  display: block; padding: 12px 4px;
  font-size: 16px; font-weight: var(--fw-medium);
  color: var(--ink); text-decoration: none;
  border-bottom: 1px solid var(--line);
}
.mobile-menu a:not(.btn):last-child { border-bottom: 0; }
.mobile-menu a:not(.btn)[aria-current="page"] { color: var(--champagne-2); }
.mobile-menu a.btn { margin-top: 10px; }

@media (max-width: 760px) {
  .nav-burger { display: inline-flex; }
  .hide-on-mobile { display: none !important; }
  .mobile-menu:not([hidden]) { display: block; }
}

/* ---------- Theme toggle button ---------- */
.theme-toggle {
  background: transparent;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  width: 36px; height: 36px;
  /* Zero the browser-default button padding (1px top/bottom, 6px sides) and
     collapse line-height - otherwise the `☀`/`☾` glyphs sit in the lower
     half of the button because their em-box is taller than their visual
     centre. With padding:0 + line-height:1 + flex centring, the glyph's
     bounding box matches the button's optical centre. */
  padding: 0;
  font-size: 16px;
  line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer; color: inherit;
  transition: background-color .15s ease, transform .15s ease;
}
.theme-toggle:hover { background: var(--paper-2); }
.theme-toggle .sun,
.theme-toggle .moon { display: none; line-height: 1; }
.theme-toggle .sun { display: inline; }
html[data-theme="dark"] .theme-toggle .sun { display: none; }
html[data-theme="dark"] .theme-toggle .moon { display: inline; }

/* ---------- Sticky-bottom mobile CTA (used on guest + payment flows) ---------- */
.sticky-cta {
  position: sticky; bottom: 0;
  padding: 12px 16px 30px;
  background: linear-gradient(180deg, rgba(244,245,247,0) 0%, var(--paper) 30%);
  margin: 0 -16px -16px;
  z-index: 5;
}
@media (min-width: 600px) { .sticky-cta { margin: 0 -24px -24px; padding-left: 24px; padding-right: 24px; } }
.sticky-cta .btn { width: 100%; justify-content: center; font-size: 15px; min-height: 52px; }
.sticky-cta .sticky-cta-note { text-align: center; font-size: 11px; color: var(--ink-3); margin-top: 8px; }

/* ---------- Mobile app-bar pattern (back arrow + title + action) ----------
   Used on every checkout step on mobile. Sits where the nav would be on the
   page but visually pops out as a top sheet. */
.mobile-appbar {
  display: none;
  align-items: center; gap: 14px;
  padding: 8px 16px 14px;
}
.mobile-appbar .mobile-appbar-back,
.mobile-appbar .mobile-appbar-action {
  width: 38px; height: 38px; border-radius: var(--r-md);
  border: 1px solid var(--line);
  background: transparent;
  color: var(--ink);
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer; flex-shrink: 0;
  text-decoration: none; font-size: 16px;
}
.mobile-appbar .mobile-appbar-title { flex: 1; min-width: 0; }
.mobile-appbar .mobile-appbar-title h1 { font-size: 18px; font-weight: 500; margin: 0; }
.mobile-appbar .mobile-appbar-title .sub { font-size: 11px; color: var(--ink-3); }
@media (max-width: 600px) { .mobile-appbar { display: flex; } }

/* ---------- Guest avatar carousel (mobile occupants) ----------
   On mobile the design shows one guest card at a time with a thumb-friendly
   avatar carousel above it. Wide screens keep the current stacked layout. */
.avatar-carousel {
  display: none;
  gap: 8px; padding: 0 0 14px;
}
.avatar-carousel .avatar-slot { flex: 1; text-align: center; }
.avatar-carousel .avatar-slot .avatar-circle {
  margin: 0 auto;
  border: 2px solid transparent;
  box-shadow: 0 0 0 2px var(--paper);
}
.avatar-carousel .avatar-slot.active .avatar-circle { border-color: var(--ink); }
.avatar-carousel .avatar-slot .name {
  font-size: 10px; margin-top: 6px;
  color: var(--ink-3);
}
.avatar-carousel .avatar-slot.active .name { color: var(--ink); font-weight: 500; }
@media (max-width: 600px) { .avatar-carousel { display: flex; } }

/* ---------- Mobile-specific tweaks for the design's signature surfaces ---------- */
@media (max-width: 600px) {
  /* Ref hero stacks gracefully on mobile and the giant code stays readable. */
  .ref-hero .ref-code { font-size: clamp(24px, 8vw, 36px); }
  /* Pay-mode cards stack 1-col automatically; tighten paddings. */
  .pay-mode { padding: 16px; }
  .pay-mode h3 { font-size: 18px; }
  /* Hide the meta column in schedule rows on small screens. */
  .pay-schedule-row { grid-template-columns: 28px 1fr auto !important; }
  .pay-schedule-row > .pay-cell-meta { display: none; }
  /* Booking summary in checkout stops being sticky on mobile so it doesn't
     pin a tall card to the bottom of the screen. */
  .summary { position: static !important; }
  /* Hero stat strip stacks to 2x2 cleanly. */
  .hero-dark h1 { font-size: clamp(36px, 11vw, 56px); }
  /* Tighten panel padding on mobile so cards don't dwarf the content. */
  .panel { padding: 18px; }
}

/* ---------- Dark hero on mobile uses an ink background bleed ---------- */
body.has-dark-hero { background: #1B1B1D; }
body.has-dark-hero .nav { background: rgba(23,23,25,0.85); border-color: rgba(255,255,255,0.08); }
/* Nav sits over a fixed-dark hero, so text must stay cream regardless of theme.
   --paper would flip to ink in dark mode and disappear against the dark bg. */
body.has-dark-hero .nav .brand,
body.has-dark-hero .nav .brand strong { color: var(--brand-paper); }
body.has-dark-hero .nav .brand small { color: rgba(255,255,255,0.55); }
/* The booking note is the same kind of muted line on the same dark bar, so it
   takes the same value. --ink-3 is tuned for a pale surface and would be
   near-invisible here.

   Scoped to .nav-cta - the BAR - and emphatically not to .nav. The mobile
   drawer is a child of <header class="nav"> too, and on has-dark-hero WITHOUT a
   hero backdrop the bar is dark while the drawer keeps the pale --paper
   surface. A `.nav` scope therefore painted the drawer's note white on white.
   The drawer's own dark case is has-hero-full, handled beside its links. */
body.has-dark-hero .nav-cta .book-cta-note { color: rgba(255,255,255,0.62); }
body.has-dark-hero .nav-links a { color: var(--brand-paper); }
body.has-dark-hero .nav .btn-ghost { color: var(--brand-paper); border-color: rgba(255,255,255,0.35); }
body.has-dark-hero .nav .btn-ghost:hover { background: rgba(255,255,255,0.1); color: var(--brand-paper); }
body.has-dark-hero .nav-burger { border-color: rgba(255,255,255,0.2); }
body.has-dark-hero .nav-burger span { background: var(--brand-paper); }
body.has-dark-hero .theme-toggle { border-color: rgba(255,255,255,0.2); color: var(--brand-paper); }
body.has-dark-hero main { background: var(--paper); border-radius: 0; }

/* ---------- Full-bleed hero: float the nav over the photograph ----------
   Set on <body> only when a hero backdrop has been uploaded, so a site running
   the ring hero keeps the sticky bar exactly as it was.

   `fixed` rather than `sticky` because the bar has to stop consuming flow
   space - that is what lets the photo start at the very top of the viewport
   instead of below a 72px strip. It keeps a transparent face until the visitor
   scrolls, at which point .scrolled (bound in main.js) gives it a dark backing
   so the links stay readable over the page below.

   These rules must stay AFTER the body.has-dark-hero block above: both are
   (0,2,1), so ordering is the only thing deciding which background wins. */
body.has-hero-full .nav {
  position: fixed; top: 0; left: 0; right: 0;
  background: transparent;
  backdrop-filter: none; -webkit-backdrop-filter: none;
}
body.has-hero-full .nav.scrolled {
  background: rgba(23,23,25,0.85);
  backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
}
/* The mobile menu drops out of a now-transparent bar, so it has to bring its
   own surface or it renders as links floating on the photograph. */
body.has-hero-full .mobile-menu { background: rgba(23,23,25,0.96); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); }
/* ...and painting the SURFACE dark is only half of it. The links keep
   `color: var(--ink)`, which is near-black on the light theme, so in light mode
   the whole drawer rendered as invisible text on its own dark backing - the
   separator rules were the only thing on screen. Dark mode was fine, which is
   what hid it: there --ink is already light. The current-page marker needs the
   same treatment for the same reason - --champagne-2 resolves to the DARKER
   gold on light (it is the value for gold AS TEXT on a pale surface, §18), so
   it disappears here too; on this backing the brand gold is the readable one. */
body.has-hero-full .mobile-menu a:not(.btn) {
  color: var(--brand-paper);
  border-bottom-color: rgba(255,255,255,0.12);
}
body.has-hero-full .mobile-menu a:not(.btn)[aria-current="page"] { color: var(--gold); }
/* Same trap, same fix, for the booking note under the drawer's button: --ink-3
   is a muted value for a PALE surface and would sit at ~1.5:1 on this one. */
body.has-hero-full .mobile-menu .book-cta-note { color: rgba(255,255,255,0.62); }

/* A flash sits between the header and <main>, so with the bar out of flow it
   would render underneath it and the visitor would never see the message. It
   gets the bar's height back as padding. This is why the flash container
   carries a class at all - the padding used to be an inline style, which no
   page-level rule can override. */
.flash-container { padding-top: 18px; }
body.has-hero-full .flash-container { padding-top: calc(var(--nav-h) + 18px); }

/* The breadcrumb trail has the flash's problem and one more of its own. With
   the bar out of flow <main> starts at the very top of the document, so the
   trail - main's first in-flow child - pushed the photograph down and left a
   strip of main's own pale background showing under the floating nav. The
   nav's cream links then sat on white and the trail's dark ink sat on top of
   the brand lockup: the whole header read as two pages printed over each other.

   Padding it down the way the flash is padded would only make that pale strip
   taller. It floats over the photograph instead, which is where every other
   word on this hero already lives. Anchored to <main> rather than the initial
   containing block so that a flash - which renders ABOVE main - cannot land
   underneath it. Aligned on --page-inset, which is where the copy of the one
   full-bleed hero that carries a trail (the programme page's, .hero-copy-wide)
   now begins - and where every heading further down the page begins too. The
   home page's hero is the other full-bleed one and has no breadcrumbs, being
   the root.

   NOTHING IS LAID OVER THE PICTURE. The copy pays for its own contrast with
   the doubled shadow, the currency the rest of this hero is bought in. */
body.has-hero-full main { position: relative; }
body.has-hero-full .breadcrumbs {
  position: absolute; z-index: 2;
  top: calc(var(--nav-h) + 4px); left: 0; right: 0;
  max-width: none; padding: 0 var(--page-inset);
}
body.has-hero-full .breadcrumbs ol {
  text-shadow: 0 1px 2px rgba(0,0,0,0.75), 0 2px 16px rgba(0,0,0,0.55);
}
body.has-hero-full .breadcrumbs a,
body.has-hero-full .breadcrumbs li + li::before { color: rgba(255,255,255,0.8); }
body.has-hero-full .breadcrumbs [aria-current="page"],
body.has-hero-full .breadcrumbs a:hover { color: var(--brand-paper); }

/* ------------------------------------------------------------------ */
/* Mobile-first overrides                                              */
/* ------------------------------------------------------------------ */
@media (max-width: 768px) {
  :root { --nav-h: 60px; }
  .panel { padding: 16px; min-width: 0; }
  .wrap { padding: 16px; }
  .spread { flex-wrap: wrap; }
  .row { flex-wrap: wrap; }
  .btn { max-width: 100%; white-space: normal; }

  .checkout { display: block; }
  .checkout .summary { margin-top: 16px; position: static; }

  .form-grid { grid-template-columns: 1fr !important; }
  .form-row.span2 { grid-column: span 1 !important; }

  /* Mobile: keep the table at a readable width and scroll it sideways rather
     than cramming columns into stacked single letters. (Window-level sticky
     headers are a desktop affordance; here the wrap is the horizontal scroller.) */
  .table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
  .table { min-width: 480px; }
  /* overflow-x:auto makes .table-wrap a scroll container on BOTH axes (the spec
     forces overflow-y from `visible` to `auto`). The desktop header rule
     `position: sticky; top: var(--table-stick-top)` then resolves against the
     wrap and pins the row that many px DOWN from its top - so the header floats
     in the MIDDLE of the table over the data rows. Drop sticky on mobile: the
     header sits in normal flow at the top of the table and scrolls with it
     (sideways + with the page). Covers the variation subheader (tbody th) too. */
  .table th, .table tbody th { position: static; top: auto; box-shadow: inset 0 -1px 0 var(--line); }
  /* The `.variations-table .group-row` companion to the line above went with
     the per-room sticky sub-headers themselves - neither the class nor the
     markup is rendered any more (see the variations block above). */

  /* The mobile step-rail treatment lives with the other .step rules above, so
     one block governs it - this used to re-declare .step here and silently win
     on source order. */
  .admin-shell { display: block; }
  .admin-topbar { display: flex; }
  .admin-main { padding: 16px; }

  .cards-grid { grid-template-columns: 1fr; }
  .gallery-grid { grid-template-columns: repeat(2, 1fr); }
  .admin-grid { grid-template-columns: repeat(2, 1fr); }

  .btn { min-height: 44px; }
  .btn-sm { min-height: 36px; }
  input, select, textarea { min-height: 44px; font-size: 16px; }

  .ref-hero { padding: 22px; }
  .activity-row { padding: 14px 16px; }
}

@media (max-width: 420px) {
  .gallery-grid { grid-template-columns: 1fr; }
  .admin-grid { grid-template-columns: 1fr; }
  .hero-dark { margin: 0; border-radius: 0; }
}

/* ------------------------------------------------------------------ */
/* Dark mode (kept - flips palette without reshaping layout)           */
/* ------------------------------------------------------------------ */
html[data-theme="dark"] {
  /* Neutral grays, matched to the light theme's new base. The old dark mode
     was a warm brown-black built for the cream palette; against the graphite
     brand it read as a different product. Surfaces still LIGHTEN with
     elevation here (card above page), the inverse of the light theme. */
  --ink:        #EDEDF0;
  --ink-2:      #C6C6CC;
  --ink-3:      #8B8B95;
  --paper:      #131315;
  --paper-2:    #1D1D20;
  --paper-3:    #2A2A2E;
  --card:       #1A1A1D;
  --line:       #2E2E33;
  --line-2:     #45454C;
  --line-input: #70707A;
  --focus:      #D2BE8C;
  --focus-halo: #131315;

  /* Gold is lifted for dark surfaces: the brand value at #C1AB75 is a mid-tone
     and drops to ~4:1 on near-black, so it is brightened here rather than left
     to sit on the contrast floor. --gold-2 has to INVERT its role: on light it
     is a darker shade for text, on dark it must be lighter still. */
  --gold:       #D2BE8C;
  --gold-2:     #E3D3AA;
  --gold-3:     #33301F;
  --gold-4:     #22211A;

  --violet:     #A78BE0;
  --violet-2:   #C0AAEB;
  --violet-3:   #241C36;

  --ok:         #3FBF8A;
  --ok-2:       #6FD6AB;
  --warn:       #E0A244;
  --warn-2:     #EFC181;
  --bad:        #EF6A5A;
  --bad-2:      #F79184;
  --sky:        #5CADE6;
  --sky-2:      #8AC7EF;

  --champagne:   var(--gold);
  --champagne-2: var(--gold-2);
  --teal:       var(--ok);
  --teal-2:     var(--ok-2);
  --saffron:    var(--warn);

  --color-stormcloud-ink: var(--ink);
  --color-deep-slate:     var(--ink-2);
  --color-slate-text:     var(--ink-2);
  --color-light-slate:    var(--ink-3);
  --color-ash-border:     var(--line);
  --color-page-background:var(--paper);
  --color-pure-white:     var(--card);
  --color-sage:        var(--gold);
  --color-sage-dark:   var(--gold-2);
  --color-sage-tint:   var(--paper-2);
  --color-sage-soft:   var(--paper-3);

  --color-ok-fg:   #6FD6AB; --color-ok-bg:   #12291F; --color-ok-line:  #1F4C38;
  --color-warn-fg: #EFC181; --color-warn-bg: #2B2113; --color-warn-line:#513C1D;
  --color-err-fg:  #F79184; --color-err-bg:  #2E1815; --color-err-line: #572622;
  --color-info-fg: #C0AAEB; --color-info-bg: #201A31; --color-info-line: #3D3159;
  --color-sky-fg:  #8AC7EF; --color-sky-bg:  #12222E; --color-sky-line: #21415A;
  --color-gold-fg: #E3D3AA; --color-gold-bg: #2A2517; --color-gold-line: #4C4327;

  /* Inverted: every semantic fill is a light tone on this theme, so anything
     sitting on one takes the page ink (7.4:1 to 10.2:1 across the set). */
  --on-ok:      #131315;
  --on-bad:     #131315;
  --on-sky:     #131315;
  --on-warn:    #131315;
  --on-gold:    #131315;

  --shadow-xs:     rgba(0,0,0,0.35) 0 1px 2px 0;
  --shadow-sm:     rgba(0,0,0,0.40) 0 2px 4px 0;
  --shadow-md:     rgba(0,0,0,0.55) 0 4px 16px 0;
  --shadow-focus:  0 0 0 2px var(--focus-halo), 0 0 0 4px var(--focus);
}

html[data-theme="dark"] body { background: var(--paper); color: var(--ink); }
html[data-theme="dark"] .nav { background: rgba(20,18,14,0.92); }
html[data-theme="dark"] .nav-burger span { background: var(--ink); }
html[data-theme="dark"] .mobile-menu { background: var(--card); }
html[data-theme="dark"] .mobile-menu a:not(.btn) { color: var(--ink); border-color: var(--line); }
html[data-theme="dark"] .pill { background: var(--paper-3); color: var(--ink-2); border-color: var(--line); }

/* Role/neutral badges (badge-soft = customer, badge-info = admin/staff) paint
   their chip with --paper-2, which in dark mode collapses to nearly the page
   background - the chip disappears and the muted --ink-3 text drops below a
   readable contrast. Lift the fill to --paper-3, brighten the text, and give
   the chip a visible edge so the role reads at a glance. */
html[data-theme="dark"] .badge-soft { background: var(--paper-3); color: var(--ink-2); border-color: var(--line-2); }
html[data-theme="dark"] .badge-info { background: var(--paper-3); color: var(--ink);   border-color: var(--line-2); }

/* Ink CTAs are painted with the brand-fixed --brand-ink, which doesn't flip in
   dark mode - so a near-black button lands on a near-black page with only a
   transparent border and no visible edge. Give primary/ink buttons a border so
   their shape reads against the dark surface. */
html[data-theme="dark"] .btn-primary,
html[data-theme="dark"] .btn-ink { border-color: var(--line-2); }

/* admin-topbar + sidebar are pinned to brand tokens so no dark-mode override
   is needed. .admin-main inherits from --paper, which already flips. */
html[data-theme="dark"] .admin-main { background: var(--paper); color: var(--ink); }

/* Auto-flip if the user hasn't manually picked a theme.
   ─────────────────────────────────────────────────────────────────────────
   THIS BLOCK IS A COMPLETE COPY OF THE DARK THEME ABOVE, AND IT HAS TO BE.

   It used to redefine a hand-picked SUBSET of eleven tokens - and worse, a
   subset of the RETIRED warm dark palette, which was rebuilt neutral-grey and
   never mirrored here. So a visitor whose OS is set to dark and who has never
   touched the toggle got a different product from one who clicked it: the old
   brown-black surfaces, with 34 of the 45 tokens the dark theme changes still
   holding their LIGHT values on top of them.

   Choosing which tokens to carry went wrong three separate times, each found
   only by measuring a real browser:

     * --gold-4 stayed near-white, so the address-lookup highlight put
       near-white text on a near-white row;
     * --warn-2 stayed #8A5200 and measured 2.78:1 - not a dim warning, an
       unreadable one - across the hold timer, the lead trail and the quote
       panel;
     * --bad / --ok / --sky stayed at their light values and measured
       3.1-3.5:1 as text, and --focus stayed violet at 1.7:1, which is a
       keyboard ring nobody can see.

   The mobile menu is the sharpest of them: CLAUDE.md documents an incident
   where its links rendered at 1.04:1, and the fix was applied to
   html[data-theme="dark"] and NOT here - so the bug it describes was still
   live on exactly the path that has no JavaScript to fix it.

   Plain CSS cannot share one declaration list across a media-query boundary,
   so the duplication is unavoidable. What makes it SAFE is
   test/theme-fallback.test.js, which fails the build the moment the two blocks
   disagree about any token or any rule, in either direction. Do not edit this
   block by hand: change the dark theme above and mirror it here, and the test
   will tell you exactly what is missing.
   ───────────────────────────────────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
  html:not([data-theme]) {
    /* Neutral grays, matched to the light theme's new base. The old dark mode
       was a warm brown-black built for the cream palette; against the graphite
       brand it read as a different product. Surfaces still LIGHTEN with
       elevation here (card above page), the inverse of the light theme. */
    --ink:        #EDEDF0;
    --ink-2:      #C6C6CC;
    --ink-3:      #8B8B95;
    --paper:      #131315;
    --paper-2:    #1D1D20;
    --paper-3:    #2A2A2E;
    --card:       #1A1A1D;
    --line:       #2E2E33;
    --line-2:     #45454C;
    --line-input: #70707A;
    --focus:      #D2BE8C;
    --focus-halo: #131315;

    /* Gold is lifted for dark surfaces: the brand value at #C1AB75 is a mid-tone
       and drops to ~4:1 on near-black, so it is brightened here rather than left
       to sit on the contrast floor. --gold-2 has to INVERT its role: on light it
       is a darker shade for text, on dark it must be lighter still. */
    --gold:       #D2BE8C;
    --gold-2:     #E3D3AA;
    --gold-3:     #33301F;
    --gold-4:     #22211A;

    --violet:     #A78BE0;
    --violet-2:   #C0AAEB;
    --violet-3:   #241C36;

    --ok:         #3FBF8A;
    --ok-2:       #6FD6AB;
    --warn:       #E0A244;
    --warn-2:     #EFC181;
    --bad:        #EF6A5A;
    --bad-2:      #F79184;
    --sky:        #5CADE6;
    --sky-2:      #8AC7EF;

    --champagne:   var(--gold);
    --champagne-2: var(--gold-2);
    --teal:       var(--ok);
    --teal-2:     var(--ok-2);
    --saffron:    var(--warn);

    --color-stormcloud-ink: var(--ink);
    --color-deep-slate:     var(--ink-2);
    --color-slate-text:     var(--ink-2);
    --color-light-slate:    var(--ink-3);
    --color-ash-border:     var(--line);
    --color-page-background:var(--paper);
    --color-pure-white:     var(--card);
    --color-sage:        var(--gold);
    --color-sage-dark:   var(--gold-2);
    --color-sage-tint:   var(--paper-2);
    --color-sage-soft:   var(--paper-3);

    --color-ok-fg:   #6FD6AB; --color-ok-bg:   #12291F; --color-ok-line:  #1F4C38;
    --color-warn-fg: #EFC181; --color-warn-bg: #2B2113; --color-warn-line:#513C1D;
    --color-err-fg:  #F79184; --color-err-bg:  #2E1815; --color-err-line: #572622;
    --color-info-fg: #C0AAEB; --color-info-bg: #201A31; --color-info-line: #3D3159;
    --color-sky-fg:  #8AC7EF; --color-sky-bg:  #12222E; --color-sky-line: #21415A;
    --color-gold-fg: #E3D3AA; --color-gold-bg: #2A2517; --color-gold-line: #4C4327;

    /* Inverted: every semantic fill is a light tone on this theme, so anything
       sitting on one takes the page ink (7.4:1 to 10.2:1 across the set). */
    --on-ok:      #131315;
    --on-bad:     #131315;
    --on-sky:     #131315;
    --on-warn:    #131315;
    --on-gold:    #131315;

    --shadow-xs:     rgba(0,0,0,0.35) 0 1px 2px 0;
    --shadow-sm:     rgba(0,0,0,0.40) 0 2px 4px 0;
    --shadow-md:     rgba(0,0,0,0.55) 0 4px 16px 0;
    --shadow-focus:  0 0 0 2px var(--focus-halo), 0 0 0 4px var(--focus);
  }

  html:not([data-theme]) body { background: var(--paper); color: var(--ink); }
  html:not([data-theme]) .nav { background: rgba(20,18,14,0.92); }
  html:not([data-theme]) .nav-burger span { background: var(--ink); }
  html:not([data-theme]) .mobile-menu { background: var(--card); }
  html:not([data-theme]) .mobile-menu a:not(.btn) { color: var(--ink); border-color: var(--line); }
  html:not([data-theme]) .pill { background: var(--paper-3); color: var(--ink-2); border-color: var(--line); }
  html:not([data-theme]) .badge-soft { background: var(--paper-3); color: var(--ink-2); border-color: var(--line-2); }
  html:not([data-theme]) .badge-info { background: var(--paper-3); color: var(--ink);   border-color: var(--line-2); }
  html:not([data-theme]) .btn-primary,
  html:not([data-theme]) .btn-ink { border-color: var(--line-2); }
  html:not([data-theme]) .admin-main { background: var(--paper); color: var(--ink); }
  html:not([data-theme]) .theme-toggle .sun { display: none; }
  html:not([data-theme]) .theme-toggle .moon { display: inline; }
}

/* ============================================================
   Visual Markdown editor (public/js/md-editor.js)
   A toolbar + Write/Preview wrapper built around <textarea
   data-md-editor>. Uses the shared design tokens so it themes
   automatically. The textarea keeps its own width/rows.
   ============================================================ */
.md-editor { border: 1px solid var(--line); border-radius: var(--r-md); background: var(--card); overflow: hidden; }

/* Toolbar - formatting controls styled to match the site .btn theme so the
   editor feels native to the rest of the admin / public UI. */
.md-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 9px 10px; background: var(--paper-2); border-bottom: 1px solid var(--line); }
.md-tools { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.md-tool {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 32px; padding: 7px 12px;
  background: var(--card); color: var(--ink); border: 1px solid var(--line);
  border-radius: var(--r-md);
  font-family: inherit; font-weight: var(--fw-medium); font-size: 12.5px; line-height: 1.25;
  letter-spacing: -0.005em; cursor: pointer;
  transition: background-color .18s ease, border-color .18s ease, color .18s ease,
              transform .15s cubic-bezier(.34, 1.56, .64, 1);
}
.md-tool:hover { background: var(--paper-2); border-color: var(--line-2); color: var(--ink); }
.md-tool:active { transform: translateY(1px); }
.md-tool:focus-visible { box-shadow: var(--shadow-focus); outline: none; }
.md-tool.md-b { font-weight: 700; }
.md-tool.md-i { font-style: italic; font-family: Georgia, serif; }

/* Side-by-side editable surfaces: Write (left) + live Preview (right). Each is
   a tinted, bordered box so it stands apart from the surrounding panel and
   reads as an editable area. Stacks on narrow screens. */
.md-split { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 12px; background: var(--card); }
.md-col { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--line); border-radius: var(--r-input); background: var(--paper-2); overflow: hidden; }
.md-col-label { padding: 6px 12px; font-size: 11px; font-weight: var(--fw-medium); text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); border-bottom: 1px solid var(--line); }
.md-col-write { transition: border-color .18s ease, box-shadow .18s ease; }
.md-col-write:focus-within { border-color: var(--teal); box-shadow: 0 0 0 1px var(--teal); }
.md-col-write textarea { width: 100%; flex: 1; min-height: 120px; border: 0; border-radius: 0; background: transparent; resize: vertical; padding: 12px 14px; font: inherit; line-height: 1.55; color: var(--ink); }
.md-col-write textarea:focus { outline: none; }

.md-preview { flex: 1; padding: 12px 14px; min-height: 120px; color: var(--ink); line-height: 1.6; overflow-wrap: anywhere; }

/* A long document has to scroll INSIDE the editor, not stretch it.
   Nothing capped the preview, and it is a grid sibling of the write column, so
   the row grew to whatever the rendered Markdown needed and dragged the
   textarea (flex: 1) up with it. On the real privacy policy - 13,400
   characters - that is a ~6,000px pair of columns inside a 6,400px panel: the
   toolbar, the tab strip and the "Save privacy policy" button are all off
   screen from the moment the tab opens, which reads as the page not showing
   the field at all rather than as a very tall one.

   Capped on the COLUMN rather than on .md-split, so the two panes stay the
   same height as each other and each scrolls its own overflow. Compact mode is
   excluded: it is already height-budgeted by its container (a ticket composer,
   a lead note) and has no preview column to run away. */
.md-editor:not(.is-compact) .md-col { max-height: 62vh; }
.md-editor:not(.is-compact) .md-preview { overflow: auto; }
/* The textarea keeps `resize: vertical`, so this is a starting height and not
   a ceiling somebody is stuck with. */
.md-editor:not(.is-compact) .md-col-write textarea { overflow: auto; }
@media (max-width: 720px) { .md-split { grid-template-columns: 1fr; } }
.md-preview h2 { font-size: 20px; margin: 0 0 8px; }
.md-preview h3 { font-size: 16px; margin: 14px 0 6px; }
.md-preview p { margin: 0 0 10px; }
.md-preview ul, .md-preview ol { margin: 0 0 10px; padding-left: 22px; }
.md-preview li { margin: 2px 0; }
.md-preview blockquote { margin: 0 0 10px; padding: 6px 14px; border-left: 3px solid var(--teal); color: var(--ink-2); background: var(--paper-2); border-radius: 0 6px 6px 0; }
.md-preview a { color: var(--teal); }
.md-preview .md-empty { color: var(--ink-3); font-style: italic; }

.md-help { margin: 0; padding: 7px 12px; font-size: 11px; color: var(--ink-3); background: var(--paper-2); border-top: 1px solid var(--line); }

/* Compact mode (data-md-editor="compact"): the toolbar over the box, and
   nothing else. Used by the staff working surfaces - the ticket internal note
   and the lead note - which sit inside panes whose height is measured and
   budgeted (see the ticket queue), so a preview column and a help line would
   be two rows of the conversation spent explaining a box the author is already
   looking at. The rendered result appears in the timeline the moment they post.

   `min-height: 0` is the load-bearing line: the shared rule gives every editor
   textarea a 120px floor, which is ~6 rows, and these composers deliberately
   start at 2 and grow with what is typed (public/js/ticket-inbox.js). Without
   it the box opens four rows taller than it has content for, every time. */
.md-editor.is-compact .md-split { grid-template-columns: 1fr; gap: 0; padding: 0; }
.md-editor.is-compact .md-col { border: 0; border-radius: 0; background: transparent; }
.md-editor.is-compact .md-col-write:focus-within { border-color: transparent; box-shadow: none; }
.md-editor.is-compact .md-col-write textarea { min-height: 0; padding: 9px 11px; }
.md-editor.is-compact .md-toolbar { padding: 6px 8px; }
.md-editor.is-compact .md-tool { padding: 3px 8px; font-size: 11px; }

/* Wrapping a composer moves the visible edge from the textarea to the wrapper,
   so anything the edge was SAYING has to move with it. On the ticket note that
   edge is amber, and it is the signal that this box is not emailed to anybody
   (§15f: colour is load-bearing here, not decoration) - left alone it would go
   plain grey and look exactly like the reply box above it. */
.tq-note .md-editor.is-compact { border-color: var(--color-warn-line); background: var(--card); }

/* The lead composers are already a framed box - dashed amber for the internal
   note, for the same reason - so a second solid border inside one reads as a
   box in a box. The wrapper steps back and the textarea keeps a field edge, or
   the toolbar would be floating over bare panel background with nothing to
   show where the typing area starts. */
.lead-compose .md-editor.is-compact { border: 0; border-radius: 0; background: transparent; }
.lead-compose .md-editor.is-compact .md-toolbar {
  padding: 0 0 6px; background: transparent; border-bottom: 0;
}
.lead-compose .md-editor.is-compact .md-col-write textarea {
  border: 1px solid var(--line-input); border-radius: var(--r-input); background: var(--card);
}

/* ============================================================
   Email-body composer (shared: Outbox templates + Broadcasts)
   Both surfaces edit a plain-text body that lib/email-template.js
   turns into HTML (blank line → paragraph, single newline → <br>,
   "- " lines → list, a lone URL → button). Whitespace is therefore
   semantic, so the box is monospace; insertable {token} chips sit
   below it. Insertion is handled by public/js/token-insert.js.
   ============================================================ */
.email-body-input { font-family: var(--font-mono); font-size: 12.5px; line-height: 1.55; }
.token-row { display: flex; flex-wrap: wrap; gap: 6px; }
.token-chip {
  display: inline-flex; align-items: center;
  padding: 4px 9px; border-radius: var(--r-pill);
  font-family: var(--font-mono); font-size: 11px; line-height: 1.4;
  letter-spacing: 0; text-transform: none;
  background: var(--paper-2); color: var(--ink-2);
  border: 1px solid var(--line); cursor: copy;
  transition: background-color .15s ease, border-color .15s ease;
}
.token-chip:hover { background: var(--paper-3); border-color: var(--champagne); }
.token-chip:focus-visible { outline: 2px solid var(--champagne); outline-offset: 1px; }

/* ============================================================
   Day-list chip editor (Admin -> Settings: reminder lead days,
   flight-reminder cadence). Progressive enhancement of a plain
   comma-separated text input by public/js/days-editor.js.
   ============================================================ */
.days-editor { display: flex; flex-direction: column; gap: 8px; margin-top: 2px; }
.days-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; min-height: 30px; }
.days-empty { font-size: 12px; }
.days-unit { font-size: 12px; }
.days-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 6px 4px 10px; border-radius: var(--r-pill);
  font-size: 12px; line-height: 1.4;
  background: var(--paper-2); color: var(--ink-1);
  border: 1px solid var(--line);
}
.days-chip-x {
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%;
  background: transparent; color: var(--ink-3); cursor: pointer;
  font-size: 14px; line-height: 1;
  transition: background-color .15s ease, color .15s ease;
}
.days-chip-x:hover { background: var(--paper-3); color: var(--ink-1); }
.days-controls { display: flex; gap: 6px; align-items: stretch; }
.days-num { width: 90px; }
.days-controls .btn { display: inline-flex; align-items: center; }
.days-presets { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.days-presets-label { font-size: 11px; }
.days-preset {
  padding: 3px 9px; border-radius: var(--r-pill);
  font-size: 11px; line-height: 1.4;
  background: var(--paper-2); color: var(--ink-2);
  border: 1px solid var(--line); cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease;
}
.days-preset:hover { background: var(--paper-3); border-color: var(--champagne); }
.days-preset:focus-visible { outline: 2px solid var(--champagne); outline-offset: 1px; }

/* Public-facing rendered Markdown (Terms body, FAQ answers). */
.md-body h2 { font-size: 19px; margin: 22px 0 8px; }
.md-body h2:first-child { margin-top: 0; }
.md-body h3 { font-size: 16px; margin: 18px 0 6px; }
.md-body p { margin: 0 0 12px; line-height: 1.65; }
.md-body ul, .md-body ol { margin: 0 0 12px; padding-left: 22px; }
.md-body li { margin: 4px 0; line-height: 1.6; }
.md-body blockquote { margin: 0 0 12px; padding: 8px 16px; border-left: 3px solid var(--teal); background: var(--paper-2); color: var(--ink-2); border-radius: 0 6px 6px 0; }
.md-body a { color: var(--teal); }
.md-body > :last-child { margin-bottom: 0; }

/* Cookie consent banner (partials/cookie-banner.ejs + js/consent.js).
   Fixed above the chat FAB (z 60) - a consent decision outranks chat. Hidden
   by default via [hidden]; consent.js reveals it when no choice exists. */
.cookie-banner {
  position: fixed; left: 16px; right: 16px; z-index: 70;
  bottom: calc(16px + env(safe-area-inset-bottom, 0px));
  display: flex; justify-content: center;
  pointer-events: none; /* only the card itself catches clicks */
}
.cookie-banner[hidden] { display: none; }
.cookie-banner-inner {
  pointer-events: auto;
  max-width: 720px; width: 100%;
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-md); box-shadow: var(--shadow-card);
  padding: 16px 18px;
  display: flex; flex-wrap: wrap; gap: 12px 18px;
  align-items: center; justify-content: space-between;
}
.cookie-banner-text { flex: 1 1 320px; min-width: 0; }
.cookie-banner-text strong { display: block; margin-bottom: 4px; font-size: 14px; }
.cookie-banner-text p { margin: 0; font-size: 13px; line-height: 1.5; color: var(--ink-2); }
.cookie-banner-actions { display: flex; gap: 8px; flex-wrap: wrap; }

/* ===========================================================================
   Shared admin data tables (partials/data-table + public/js/data-table.js)
   ===========================================================================
   The filter bar keeps the existing .filter-bar geometry; everything below is
   the additions the shared table needs: the advanced-filter drawer, the
   result-count line, sortable header affordances, and the small set of
   controls DataTables adds when it enhances the server-rendered table in
   place. */

/* ---- Advanced filter drawer -------------------------------------------- */
/* Full-width flex-basis forces the drawer onto its own row below the basic
   filters rather than trying to sit inline as another flex child. */
.dt-advanced { flex: 1 1 100%; margin-top: 4px; }
.dt-advanced[hidden] { display: none; }
.dt-advanced-inner {
  display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end;
  padding: 14px; border: 1px solid var(--line); border-radius: 10px;
  background: var(--paper-2);
}
.dt-advanced-inner .form-row { margin-bottom: 0; flex: 1 1 180px; min-width: 150px; }
.dt-advanced-inner .form-row input:not([type="checkbox"]):not([type="radio"]),
.dt-advanced-inner .form-row select { height: 44px; }
.dt-advanced-inner .form-row select[multiple] { height: auto; min-height: 88px; }

/* A dot on the toggle when advanced filters are active, so a collapsed drawer
   never hides the reason a list looks filtered. */
.dt-adv-dot {
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--champagne); margin-left: 5px; vertical-align: middle;
}
.dt-adv-toggle[aria-expanded="true"] { background: var(--paper-3); }

/* Paired range inputs (date from/to, amount min/max). */
.dt-range { display: flex; align-items: center; gap: 6px; }
.dt-range input { min-width: 0; flex: 1 1 0; }
.dt-range-sep { color: var(--ink-3); font-size: .82rem; flex: 0 0 auto; }

/* Composite filter: several inputs resolving to ONE predicate (the guest
   criteria on Bookings and Attendees). The enclosing box is not decoration - it
   is what tells a reader the parts describe ONE person rather than three
   independent constraints, which is the difference between "a female child
   aged 4-6" and "a female somebody and a child somebody". It takes the full
   row of the advanced drawer so the grouping survives at any width. */
.dt-composite {
  display: flex; flex-wrap: wrap; gap: 10px; align-items: flex-end;
  padding: 10px; border: 1px dashed var(--line-input); border-radius: 8px;
  background: var(--paper);
}
.dt-advanced-inner .form-row:has(.dt-composite) { flex: 1 1 100%; }
.dt-part { display: flex; flex-direction: column; gap: 3px; flex: 1 1 150px; min-width: 120px; }
.dt-part-label { color: var(--ink-3); font-size: .75rem; }
.dt-composite-hint {
  display: block; margin-top: 5px; color: var(--ink-3); font-size: .76rem;
}

/* ---- Result count ------------------------------------------------------- */
.dt-summary {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; margin: 16px 0 8px; font-size: .88rem; color: var(--ink-3);
}
.dt-summary strong { color: var(--ink-2); font-weight: 600; }
.dt-summary .muted { color: var(--ink-3); }

/* ---- Sortable headers (server-rendered table) --------------------------- */
.dt-sort {
  color: inherit; text-decoration: none; display: inline-flex;
  align-items: center; gap: 4px; white-space: nowrap;
}
.dt-sort:hover { color: var(--champagne-2); }
.dt-sort-ind { font-size: .78rem; color: var(--gold-2); }
.table th.is-sorted { color: var(--ink); }

.dt-pagination { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.dt-page-of { font-size: .85rem; color: var(--ink-3); }

/* ---- DataTables controls ------------------------------------------------
   DataTables 3 enhances the table the server already rendered, so the TABLE
   itself needs no skin at all: every `.table` / `.dt-table` rule above still
   applies, to the same markup, before and after enhancement. That is the whole
   reason this block is short. Its predecessor (Tabulator) drew a second table
   in its own DOM and took ~35 rules to talk out of a mid-grey vendor theme.

   We deliberately do NOT ship dataTables.dataTables.css: it restyles
   `table.dataTable` and would fight the site's table styling for no gain. The
   only things needing rules here are the controls DataTables ADDS - the sort
   indicator, the pager, the processing flag and the empty-table cell. */
.dt-container { position: relative; }
/* DataTables wraps the TABLE in a .dt-layout-row as well as the controls
   (.dt-layout-row.dt-layout-table). Making that one a flex row shrink-wraps the
   table and pushes it off to the right, so only the CONTROL rows get flexed. */
.dt-container .dt-layout-row:not(.dt-layout-table) { display: flex; justify-content: flex-end; }
.dt-container .dt-layout-table,
.dt-container .dt-layout-table > .dt-layout-cell { display: block; width: 100%; }

/* Sort affordance. The server-rendered header is an <a> for the no-JS path;
   data-table.js strips its href on enhancement, so it must stop looking and
   behaving like a link once DataTables owns the click. */
/* NOTE the absent `position`. `.table th` is `position: sticky` with a
   `top: var(--table-stick-top)` that pins the header under the sticky page bar.
   Setting `position: relative` here (to anchor the sort chevron) kept that
   `top` in play, and on a RELATIVE box `top` means "shift down 80px" - so every
   header cell dropped below the rows, leaving a gap where the header should
   be. Sticky is already a positioned ancestor, so the absolutely positioned
   .dt-column-order anchors to it without any help. */
.dt-table thead th.dt-orderable-asc,
.dt-table thead th.dt-orderable-desc {
  cursor: pointer;
  padding-right: 24px;
  user-select: none;
}
.dt-table thead th a.dt-sort { cursor: inherit; }
.dt-table thead th a.dt-sort:not([href]) { text-decoration: none; color: inherit; }
.dt-table thead th .dt-column-order {
  position: absolute; right: 9px; top: 50%;
  width: 6px; height: 6px; margin-top: -5px;
  border-right: 1.5px solid var(--ink-3);
  border-bottom: 1.5px solid var(--ink-3);
  transform: rotate(45deg);
  opacity: 0;
  transition: opacity .15s ease, transform .15s ease, border-color .15s ease;
}
/* Hint the column is sortable on hover, commit to it once it is the sort. */
.dt-table thead th.dt-orderable-asc:hover .dt-column-order,
.dt-table thead th.dt-orderable-desc:hover .dt-column-order { opacity: .45; }
.dt-table thead th.dt-ordering-asc,
.dt-table thead th.dt-ordering-desc { color: var(--ink); }
.dt-table thead th.dt-ordering-asc .dt-column-order {
  opacity: 1; margin-top: -2px; transform: rotate(-135deg); border-color: var(--ink);
}
.dt-table thead th.dt-ordering-desc .dt-column-order {
  opacity: 1; transform: rotate(45deg); border-color: var(--ink);
}
@media (prefers-reduced-motion: reduce) {
  .dt-table thead th .dt-column-order { transition: none; }
}

/* Pager. Mirrors `.btn-soft.btn-sm` so it sits in the same visual family as
   the server-rendered pager it replaces. */
.dt-container .dt-paging {
  display: flex; flex-wrap: wrap; gap: 6px;
  justify-content: flex-end; margin-top: 14px;
}
.dt-container .dt-paging nav { display: flex; flex-wrap: wrap; gap: 6px; }
.dt-container .dt-paging-button {
  padding: 8px 13px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card); color: var(--ink);
  font: inherit; font-size: 12px; font-weight: var(--fw-medium); line-height: 1.25;
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.dt-container .dt-paging-button:hover:not(.disabled):not(.current) {
  background: var(--paper-2); border-color: var(--line-2);
}
.dt-container .dt-paging-button:focus-visible {
  outline: none; box-shadow: var(--shadow-focus);
}
.dt-container .dt-paging-button.current {
  background: var(--brand-ink); color: var(--brand-paper); border-color: var(--brand-ink);
}
.dt-container .dt-paging-button.disabled {
  opacity: .4; cursor: default;
}

/* The in-flight flag for a remote sort/page. Positioned over the table rather
   than in flow: pushing the rows down mid-sort is the jump this whole design
   is trying to avoid. */
.dt-container .dt-processing {
  position: absolute; top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  z-index: 3;
  padding: 9px 16px;
  background: var(--card); color: var(--ink-2);
  border: 1px solid var(--line-2); border-radius: var(--r-md);
  box-shadow: var(--shadow-md);
  font-size: 12.5px;
}

/* DataTables' own empty-table cell, shown once it owns the rows. The server
   renders `tr.dt-empty-row` for the same purpose before enhancement. */
.dt-table td.dt-empty {
  text-align: center; padding: 32px; color: var(--ink-3);
}

@media (max-width: 720px) {
  .dt-advanced-inner { padding: 12px; }
  .dt-advanced-inner .form-row { flex: 1 1 100%; }
  .dt-summary { flex-wrap: wrap; }
}

/* Column alignment for the shared table. `.num` (styles.css:187) only sets
   font-feature-settings, so a spec declaring align:'right' had no visible
   effect. These rules are what actually right-align money, and they apply to
   the server-rendered cells AND to the ones DataTables inserts after a sort,
   because lib/table-render.js hands the same class list to both. */
.dt-table th.num, .dt-table td.num { text-align: right; }
.dt-table th.center, .dt-table td.center { text-align: center; }

/* ---- KPI tiles (Admin -> System log, Observability) ---------------------
   These were markup with no CSS behind them: `.kpi-card` / `.kpi-num` /
   `.kpi-row` were never defined, so the log page rendered bare underlined
   links with a stray coloured border-left and nothing else. The tone colour
   was also inline hex (#c0392b etc.) which ignored the palette and stayed
   fixed in dark mode; it is a modifier class now, driven by tokens. */
.kpi-row { display: flex; gap: 12px; flex-wrap: wrap; }

.kpi-card {
  flex: 1 1 150px;
  min-width: 140px;
  display: block;
  padding: 14px 16px;
  background: var(--card);
  border: 1px solid var(--line);
  border-left: 4px solid var(--line-2);
  border-radius: 10px;
  text-decoration: none;            /* these are links; kill the underline */
  color: inherit;
  transition: background .12s ease, border-color .12s ease, transform .12s ease;
}
/* Only the log tiles are links. The observability tiles are plain divs, so the
   interactive affordance keys off the element, not the class. */
a.kpi-card:hover {
  background: var(--paper-2);
  border-color: var(--line-2);
  transform: translateY(-1px);
}
a.kpi-card:hover .kpi-num { color: var(--ink); }

.kpi-card .kpi-num {
  font-size: 1.6rem;
  font-weight: 600;
  line-height: 1.1;
  color: var(--ink-2);
  font-variant-numeric: tabular-nums;
}
/* Scoped override: the global .kpi-label carries margin-bottom:14px for its
   original context, which left a gap under every tile here. */
.kpi-card .kpi-label { margin-bottom: 0; margin-top: 4px; }

/* Tone modifiers. Palette tokens, so they invert correctly in dark mode.
   SCOPED to .kpi-card: `.kpi-warn` unqualified also matches the dashboard's
   `.kpi` tiles, which share the modifier names but not this component. It won
   on source order and painted an amber left border on exactly the warn tiles
   there - a stripe the KPI tile design had deliberately dropped, so one row of
   the grid had a coloured edge and the rest a hairline. Same collision the
   `.kpi-card .kpi-label` override above documents; keep new modifiers scoped. */
.kpi-card.kpi-error { border-left-color: var(--bad); }
.kpi-card.kpi-warn  { border-left-color: var(--warn); }
.kpi-card.kpi-info  { border-left-color: var(--teal); }
.kpi-card.kpi-note  { border-left-color: var(--champagne); }
.kpi-card.kpi-accent{ border-left-color: var(--ink-3); }
/* The selected tile reads as an engaged toggle; clicking it again clears. */
.kpi-card.is-active {
  background: var(--paper-2);
  border-color: var(--line-2);
  box-shadow: inset 0 0 0 1px var(--line-2);
}
.kpi-card.is-active .kpi-num { color: var(--ink); }
.kpi-card.is-active .kpi-label { color: var(--ink-2); }

/* A zero count is not news - mute it so a real count stands out. */
.kpi-card.is-zero .kpi-num { color: var(--ink-3); font-weight: 500; }
.kpi-card.is-zero { border-left-color: var(--line-2); }

@media (max-width: 720px) {
  .kpi-card { flex: 1 1 calc(50% - 6px); min-width: 0; padding: 12px 14px; }
  .kpi-card .kpi-num { font-size: 1.35rem; }
}

/* ---------------------------------------------------------------------
   Checkout consent (terms + marketing opt-in)
   ---------------------------------------------------------------------
   Two stacked agreement rows above the pay button. They are given equal visual
   weight on purpose: making the optional marketing tick quieter than the
   required terms tick edges towards a dark pattern, and the whole point of
   separating them is that the customer can see and answer them independently.  */
.consent-block {
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 14px 16px;
  background: var(--paper);
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.consent-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-2);
  cursor: pointer;
}
/* Stop the box shrinking when the label wraps to two or three lines. */
.consent-row input[type="checkbox"] { flex: 0 0 auto; margin-top: 2px; }
.consent-row a { color: var(--champagne-2); font-weight: 600; }
.consent-error {
  font-size: 12.5px;
  font-weight: 600;
  color: var(--bad);
}

/* =====================================================================
   Native-UI replacements
   ---------------------------------------------------------------------
   Every control below is one the browser would otherwise draw itself, in OS
   chrome that ignores this design system and differs on every platform: the
   confirm/alert/prompt box, the constraint-validation bubble, the `title`
   tooltip, the select drop-down, the file-input button, the calendar panel,
   the number spinners, the checkbox and radio boxes, the disclosure triangle
   and the scrollbar.

   public/js/ui.js builds the markup; the native element always stays in the
   DOM as the source of truth (see the header comment there). Rules here are
   written so that with JS off the page still degrades to styled-but-native
   controls rather than to raw browser defaults.
   ===================================================================== */

/* ---------- 1. Modal (window.confirm / alert / prompt) ---------- */
.lul-scroll-locked { overflow: hidden; }

.lul-modal-root {
  position: fixed; inset: 0; z-index: 1200;
  display: flex; align-items: center; justify-content: center;
  padding: 20px;
}
.lul-modal-scrim {
  position: absolute; inset: 0;
  background: rgba(20, 18, 14, 0.52);
  backdrop-filter: blur(2px);
  animation: lul-fade .16s ease both;
}
.lul-modal {
  position: relative;
  width: 100%; max-width: 460px;
  background: var(--card); color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  padding: 24px 24px 20px;
  animation: lul-rise .18s cubic-bezier(.2, .8, .3, 1) both;
  max-height: calc(100vh - 40px);
  overflow-y: auto;
}
.lul-modal-root.no-anim .lul-modal,
.lul-modal-root.no-anim .lul-modal-scrim { animation: none; }
.lul-modal-root.is-closing { animation: lul-fade .12s ease reverse both; }

@keyframes lul-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes lul-rise {
  from { opacity: 0; transform: translateY(10px) scale(.985); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .lul-modal, .lul-modal-scrim, .lul-modal-root.is-closing { animation: none !important; }
}

/* The champagne rule above the title is the same mark the ink CTAs carry, so a
   dialog reads as part of the site rather than as a system box. */
.lul-modal-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.lul-modal-mark { width: 18px; height: 2px; background: var(--champagne); flex: 0 0 auto; border-radius: 2px; }
.lul-modal.is-danger .lul-modal-mark { background: var(--bad); }
.lul-modal-title {
  margin: 0; font-family: var(--font-display);
  font-size: 17px; font-weight: var(--fw-medium); letter-spacing: -0.01em;
}
.lul-modal-message {
  margin: 0; color: var(--ink-2); font-size: 14px; line-height: 1.6;
  white-space: pre-line;
}
/* A dialog carrying a second paragraph - the checkout grace popup's own
   countdown is the one in the field - would otherwise run flush against the
   first: .lul-modal-body is a plain block and the message zeroes its margins. */
.lul-modal-message + .lul-modal-message { margin-top: 10px; }
.lul-modal-field { margin-top: 16px; display: flex; flex-direction: column; gap: 6px; }
.lul-modal-label {
  font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-3); font-weight: var(--fw-medium);
}
.lul-modal-input {
  padding: 11px 13px; width: 100%;
  border: 1px solid var(--line); border-radius: var(--r-input);
  font: inherit; background: var(--paper); color: var(--ink);
  transition: border-color .15s ease, box-shadow .15s ease;
}
.lul-modal-input:focus { outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus); }
.lul-modal-input.is-invalid { border-color: var(--bad); box-shadow: 0 0 0 3px rgba(154, 86, 66, .18); }
.lul-modal-actions {
  display: flex; justify-content: flex-end; gap: 8px;
  margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--line);
}
@media (max-width: 480px) {
  .lul-modal-actions { flex-direction: column-reverse; }
  .lul-modal-actions .btn { width: 100%; }
}

/* ---------- 2. Field errors (constraint-validation bubble) ---------- */
.lul-field-error {
  margin: 6px 0 0; padding: 0;
  font-size: 12px; line-height: 1.45; font-weight: var(--fw-medium);
  color: var(--color-err-fg);
  display: flex; align-items: flex-start; gap: 6px;
}
.lul-field-error::before {
  content: "!"; flex: 0 0 auto;
  width: 14px; height: 14px; margin-top: 1px;
  border-radius: 50%; background: var(--bad); color: var(--on-bad);
  font-size: 10px; line-height: 14px; text-align: center; font-weight: var(--fw-medium);
}
.lul-select.is-invalid .lul-select-btn,
.lul-file.is-invalid .lul-file-face,
.lul-number.is-invalid > input {
  border-color: var(--bad); box-shadow: 0 0 0 3px rgba(154, 86, 66, .18);
}

/* ---------- 3. Tooltip (the `title` bubble) ---------- */
.lul-tip {
  position: fixed; z-index: 1300; pointer-events: none;
  max-width: 280px; padding: 7px 10px;
  background: var(--ink); color: var(--paper);
  border-radius: var(--r-sm);
  font-size: 12px; line-height: 1.45; font-weight: var(--fw-regular);
  box-shadow: var(--shadow-md);
  opacity: 0; transform: translateY(3px);
  transition: opacity .12s ease, transform .12s ease;
}
.lul-tip.is-in { opacity: 1; transform: none; }
.lul-tip::after {
  content: ""; position: absolute; left: var(--lul-tip-x, 50%);
  margin-left: -4px; border: 4px solid transparent;
}
.lul-tip[data-place="top"]::after { top: 100%; border-top-color: var(--ink); }
.lul-tip[data-place="bottom"]::after { bottom: 100%; border-bottom-color: var(--ink); }
@media (prefers-reduced-motion: reduce) { .lul-tip { transition: none; } }

/* ---------- 4. Popup panel shared by the select and the calendar ---------- */
.lul-pop {
  position: fixed; z-index: 1250;
  background: var(--card);
  border: 1px solid var(--line-2);
  border-radius: var(--r-card);
  box-shadow: var(--shadow-card);
  overflow: auto; overscroll-behavior: contain;
  animation: lul-pop-in .13s ease both;
}
@keyframes lul-pop-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
@media (prefers-reduced-motion: reduce) { .lul-pop { animation: none; } }

/* ---------- 5. Select ----------
   The native select is left in NORMAL FLOW at opacity 0 and the designed button
   is overlaid on top of it. That is deliberate and load-bearing: the select
   keeps sizing itself exactly as it always did - to its widest option, to
   `height: 44px` in a filter bar, to `height: 36px` in a manage row, to 100%
   inside a `.form-row` - so the wrapper box is always precisely the box the
   select would have occupied. Sizing the BUTTON instead would make the control
   resize every time the selection changed (native selects size to the widest
   option, not the current one) and would need every contextual height rule in
   this file duplicated. Keeping it rendered rather than display:none also
   matters for validation: Chrome refuses to submit a form containing an invalid
   control it cannot focus. */
.lul-select {
  position: relative; display: inline-block;
  max-width: 100%; min-width: 0; vertical-align: middle;
}
/* Field contexts where the select is width:100%: that percentage only resolves
   against a block wrapper, so the wrapper has to stop shrink-wrapping. */
.form-row .lul-select,
.search-card .lul-select,
.phone-input .lul-select { display: block; width: 100%; }

/* The shim deliberately keeps its native appearance. It is invisible either
   way, but `appearance: none` would drop the space the UA reserves for the
   drop-down arrow, and the wrapper (and so the designed button) would come out
   ~20px narrower than the select it replaced. */
.lul-select > select.lul-select-native {
  opacity: 0 !important;
  pointer-events: none !important;
}
.lul-select-btn {
  position: absolute; top: 0; left: 0;
  display: flex; align-items: center; gap: 8px;
  width: 100%; height: 100%;
  padding: 12px 14px; padding-right: 34px;
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  background: var(--card); color: var(--ink);
  font: inherit; text-align: left; cursor: pointer;
  min-width: 0;
  transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
}
.lul-select-btn:hover { border-color: var(--line-2); }
.lul-select-btn:focus-visible,
.lul-select.is-open .lul-select-btn {
  outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus);
}
.lul-select-value {
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lul-select.is-placeholder .lul-select-value { color: var(--ink-3); }
.lul-select-chev {
  position: absolute; right: 13px; top: 50%;
  width: 7px; height: 7px; margin-top: -5px;
  border-right: 1.5px solid var(--ink-3); border-bottom: 1.5px solid var(--ink-3);
  transform: rotate(45deg);
  transition: transform .18s ease;
  pointer-events: none;
}
.lul-select.is-open .lul-select-chev { transform: rotate(-135deg); margin-top: -2px; }
.lul-select.is-disabled { opacity: .6; }
.lul-select.is-disabled .lul-select-btn { cursor: not-allowed; background: var(--paper-2); color: var(--ink-3); }

.lul-select-pop { padding: 5px; }
.lul-select-list { display: block; }
.lul-opt {
  padding: 9px 11px; border-radius: var(--r-sm);
  font-size: 13.5px; line-height: 1.35; color: var(--ink);
  cursor: pointer; white-space: normal;
  display: flex; align-items: center; gap: 8px;
}
.lul-opt.is-active { background: var(--paper-2); }
.lul-opt.is-selected { color: var(--ink); font-weight: var(--fw-medium); }
/* The tick is drawn, not a glyph, so it inherits the champagne accent. */
.lul-opt.is-selected::after {
  content: ""; margin-left: auto; flex: 0 0 auto;
  width: 5px; height: 9px;
  border-right: 1.5px solid var(--champagne); border-bottom: 1.5px solid var(--champagne);
  transform: rotate(45deg) translate(-1px, -1px);
}
.lul-opt.is-disabled { color: var(--ink-3); cursor: not-allowed; opacity: .65; }
.lul-optgroup + .lul-optgroup { margin-top: 4px; }
.lul-optgroup-label {
  padding: 8px 11px 4px;
  font-size: 10.5px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-3); font-weight: var(--fw-medium);
}

/* Selects the enhancement deliberately leaves native (multi-selects, list
   boxes, and every select when JS is unavailable) still lose the OS drop-down
   arrow for a drawn one. */
select:not(.lul-select-native):not([multiple]):not([size]) {
  -webkit-appearance: none; appearance: none;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--ink-3) 50%),
    linear-gradient(135deg, var(--ink-3) 50%, transparent 50%);
  background-position: calc(100% - 19px) calc(50% - 2px), calc(100% - 14px) calc(50% - 2px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
  padding-right: 32px;
}
select[multiple], select[size] { -webkit-appearance: none; appearance: none; }
select[multiple] option, select[size] option { padding: 6px 8px; border-radius: 4px; }

/* Horizontal padding only: the button's HEIGHT comes from the wrapper, which is
   the native select's own box (see the note above), so no height rule is
   duplicated here. These just keep the label sitting where the select's text
   sat in each context. */
:where(.admin-main) .lul-select-btn { padding: 10px 12px; padding-right: 32px; }
:where(.admin-main .table) .lul-select-btn { padding: 7px 9px; padding-right: 28px; }
.phone-input .lul-select-btn { padding-left: 12px; padding-right: 30px; }

/* ---------- 6. File input ---------- */
.lul-file { position: relative; display: block; width: 100%; min-width: 0; }
.lul-file > input.lul-file-native {
  position: absolute; inset: 0; width: 100%; height: 100%;
  opacity: 0; cursor: pointer; z-index: 1;
  font-size: 0; min-height: 0; padding: 0; margin: 0; border: 0;
}
.lul-file-face {
  display: flex; align-items: center; gap: 12px;
  padding: 9px 11px;
  border: 1px dashed var(--line-2); border-radius: var(--r-input);
  background: var(--card);
  transition: border-color .15s ease, background-color .15s ease, box-shadow .15s ease;
  min-width: 0;
}
.lul-file:hover .lul-file-face { border-color: var(--ink-3); background: var(--paper-2); }
.lul-file.is-focus .lul-file-face { border-color: var(--ink); box-shadow: var(--shadow-focus); }
.lul-file.is-drop .lul-file-face { border-color: var(--champagne); background: var(--paper-2); }
.lul-file.has-file .lul-file-face { border-style: solid; }
.lul-file-btn {
  flex: 0 0 auto;
  padding: 6px 12px; border-radius: var(--r-md);
  border: 1px solid var(--line); background: var(--paper-2); color: var(--ink);
  font-size: 12px; font-weight: var(--fw-medium); line-height: 1.4;
}
.lul-file-name {
  flex: 1 1 auto; min-width: 0;
  font-size: 12.5px; color: var(--ink-3);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.lul-file.has-file .lul-file-name { color: var(--ink-2); font-family: var(--font-mono); font-size: 11.5px; }
.lul-file-clear {
  position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
  z-index: 2; display: none;
  width: 22px; height: 22px; padding: 0;
  border: 0; border-radius: 50%;
  background: var(--paper-3); color: var(--ink-2);
  font-size: 14px; line-height: 1; cursor: pointer;
}
.lul-file.has-file .lul-file-clear { display: block; }
.lul-file-clear:hover { background: var(--bad); color: var(--on-bad); }

/* JS-off fallback: dress the native button everywhere, not just in admin. */
input[type="file"]:not(.lul-file-native)::file-selector-button {
  margin-right: 12px; padding: 8px 14px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--card); color: var(--ink);
  font: inherit; font-weight: var(--fw-medium); font-size: 12px; cursor: pointer;
}

/* ---------- 7. Number steppers ----------
   Same wrapper contract as the select: the input stays in flow and keeps
   sizing itself, so a content-width input in a dense admin cell is not
   stretched to the width of the cell. */
.lul-number {
  position: relative; display: inline-block;
  max-width: 100%; min-width: 0; vertical-align: middle;
}
.form-row .lul-number,
.search-card .lul-number { display: block; width: 100%; }
.lul-number > input[type="number"] {
  padding-right: 26px;
  -moz-appearance: textfield; appearance: textfield;
}
/* The drawn steppers replace these, so the OS pair must go. */
.lul-number > input[type="number"]::-webkit-outer-spin-button,
.lul-number > input[type="number"]::-webkit-inner-spin-button {
  -webkit-appearance: none; appearance: none; margin: 0;
}
.lul-number-steps {
  position: absolute; right: 1px; top: 1px; bottom: 1px;
  width: 20px; display: flex; flex-direction: column;
  border-radius: 0 var(--r-input) var(--r-input) 0;
  overflow: hidden;
}
.lul-number-step {
  flex: 1 1 50%; position: relative;
  border: 0; background: transparent; cursor: pointer; padding: 0;
  transition: background-color .12s ease;
}
.lul-number-step:hover { background: var(--paper-2); }
.lul-number-step::before {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 5px; height: 5px; margin: -3px 0 0 -3px;
  border-right: 1.4px solid var(--ink-3); border-bottom: 1.4px solid var(--ink-3);
}
.lul-number-step.is-up::before { transform: rotate(-135deg); margin-top: -1px; }
.lul-number-step.is-down::before { transform: rotate(45deg); margin-top: -4px; }
.lul-number-step:hover::before { border-color: var(--ink); }
.lul-number.is-disabled .lul-number-steps { display: none; }

/* ---------- 8. Calendar (the browser's date panel) ---------- */
.lul-date {
  position: relative; display: inline-block;
  max-width: 100%; min-width: 0; vertical-align: middle;
}
.form-row .lul-date,
.search-card .lul-date { display: block; width: 100%; }
.lul-date > input[type="date"] { padding-right: 34px; }
/* Hide the user-agent's picker button; ours sits on top of it. */
.lul-date > input[type="date"]::-webkit-calendar-picker-indicator {
  opacity: 0; position: absolute; right: 0; width: 30px; height: 100%; cursor: pointer;
}
.lul-date-trigger {
  position: absolute; right: 5px; top: 50%; transform: translateY(-50%);
  width: 26px; height: 26px; padding: 0;
  border: 0; border-radius: var(--r-sm);
  background: transparent; cursor: pointer;
  transition: background-color .12s ease;
}
.lul-date-trigger:hover, .lul-date.is-open .lul-date-trigger { background: var(--paper-2); }
/* A small calendar glyph, drawn with borders so it inherits the palette. */
.lul-date-trigger::before {
  content: ""; position: absolute; left: 6px; top: 8px;
  width: 14px; height: 12px;
  border: 1.4px solid var(--ink-3); border-radius: 2px;
  border-top-width: 4px;
}
.lul-date-trigger:hover::before, .lul-date.is-open .lul-date-trigger::before { border-color: var(--ink); }

.lul-cal { padding: 10px; width: 268px; }
.lul-cal-head { display: flex; align-items: center; gap: 4px; margin-bottom: 8px; }
.lul-cal-nav {
  width: 28px; height: 28px; flex: 0 0 auto;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent; color: var(--ink-2);
  font-size: 15px; line-height: 1; cursor: pointer;
}
.lul-cal-nav:hover { background: var(--paper-2); border-color: var(--line); }
.lul-cal-title {
  flex: 1 1 auto; height: 28px;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent; color: var(--ink);
  font: inherit; font-size: 13px; font-weight: var(--fw-medium); cursor: pointer;
}
.lul-cal-title:hover { background: var(--paper-2); border-color: var(--line); }
.lul-cal-dows, .lul-cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.lul-cal-dows span {
  text-align: center; padding: 4px 0;
  font-size: 10px; letter-spacing: .05em; text-transform: uppercase;
  color: var(--ink-3);
}
.lul-cal-day {
  height: 30px; padding: 0;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent; color: var(--ink);
  font: inherit; font-size: 12.5px; cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.lul-cal-day.is-blank { visibility: hidden; }
.lul-cal-day:hover:not(:disabled) { background: var(--paper-2); }
.lul-cal-day.is-today { border-color: var(--line-2); }
.lul-cal-day.is-selected { background: var(--ink); color: var(--paper); border-color: var(--ink); font-weight: var(--fw-medium); }
.lul-cal-day:disabled { color: var(--ink-3); opacity: .4; cursor: not-allowed; }
.lul-cal-years { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.lul-cal-year {
  height: 34px; padding: 0;
  border: 1px solid transparent; border-radius: var(--r-sm);
  background: transparent; color: var(--ink);
  font: inherit; font-size: 12.5px; cursor: pointer;
}
.lul-cal-year:hover { background: var(--paper-2); }
.lul-cal-year.is-selected { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.lul-cal-foot {
  display: flex; gap: 6px; margin-top: 8px; padding-top: 8px;
  border-top: 1px solid var(--line);
}
.lul-cal-action {
  flex: 1 1 0; padding: 6px 0;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--card); color: var(--ink-2);
  font: inherit; font-size: 11.5px; cursor: pointer;
}
.lul-cal-action:hover { background: var(--paper-2); color: var(--ink); }

/* ---------- 9. Checkbox + radio ---------- */
input[type="checkbox"], input[type="radio"] {
  -webkit-appearance: none; appearance: none;
  flex: 0 0 auto; display: inline-grid; place-content: center;
  width: 18px; height: 18px; min-height: 0; padding: 0;
  border: 1px solid var(--line-input); border-radius: 5px;
  background: var(--card); color: inherit;
  cursor: pointer; vertical-align: middle; position: relative;
  transition: background-color .15s ease, border-color .15s ease, box-shadow .15s ease;
}
input[type="radio"] { border-radius: 50%; }
input[type="checkbox"]:hover:not(:disabled), input[type="radio"]:hover:not(:disabled) { border-color: var(--ink-3); }
input[type="checkbox"]:focus-visible, input[type="radio"]:focus-visible {
  outline: none; border-color: var(--ink); box-shadow: var(--shadow-focus);
}
input[type="checkbox"]::before {
  content: ""; width: 10px; height: 10px;
  transform: scale(0); transition: transform .12s ease;
  background: var(--brand-paper);
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="radio"]::before {
  content: ""; width: 8px; height: 8px; border-radius: 50%;
  transform: scale(0); transition: transform .12s ease;
  background: var(--brand-paper);
}
input[type="checkbox"]:checked, input[type="radio"]:checked {
  background: var(--ink); border-color: var(--ink);
}
input[type="checkbox"]:checked::before, input[type="radio"]:checked::before { transform: scale(1); }
input[type="checkbox"]:indeterminate { background: var(--ink); border-color: var(--ink); }
input[type="checkbox"]:indeterminate::before {
  transform: scale(1); clip-path: none;
  width: 9px; height: 2px; border-radius: 1px;
}
input[type="checkbox"]:disabled, input[type="radio"]:disabled {
  opacity: .5; cursor: not-allowed; background: var(--paper-2);
}
@media (prefers-reduced-motion: reduce) {
  input[type="checkbox"]::before, input[type="radio"]::before { transition: none; }
}

/* ---------- 10. Disclosure marker ---------- */
:where(details) > summary { list-style: none; }
:where(details) > summary::-webkit-details-marker { display: none; }
:where(details) > summary::marker { content: ""; }
/* Excluded: components that already draw their own indicator or whose summary
   is a button rather than a disclosure row. */
:where(details:not(.content-section, .mfa-manual, .urow-manage-details, .invite-panel, [data-no-chev]))
  > summary:not(.thread-older-sum)::before {
  content: ""; display: inline-block;
  width: 6px; height: 6px; margin-right: 9px;
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: rotate(-45deg) translate(1px, -1px);
  transition: transform .18s ease;
  opacity: .55;
}
:where(details[open]:not(.content-section, .mfa-manual, .urow-manage-details, .invite-panel, [data-no-chev]))
  > summary:not(.thread-older-sum)::before {
  transform: rotate(45deg) translate(-1px, -1px);
}
@media (prefers-reduced-motion: reduce) { :where(details) > summary::before { transition: none; } }

/* ---------- 11. Scrollbars + search clear ---------- */
html {
  scrollbar-width: thin;
  scrollbar-color: var(--line-2) transparent;
}
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--line-2); border-radius: 8px;
  border: 3px solid transparent; background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--ink-3); background-clip: content-box; }
::-webkit-scrollbar-corner { background: transparent; }

input[type="search"]::-webkit-search-cancel-button {
  -webkit-appearance: none; appearance: none;
  width: 14px; height: 14px; cursor: pointer;
  background: var(--ink-3);
  -webkit-mask: var(--lul-x-mask) center / 10px 10px no-repeat;
  mask: var(--lul-x-mask) center / 10px 10px no-repeat;
}
input[type="search"]::-webkit-search-cancel-button:hover { background: var(--bad); }
:root {
  --lul-x-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10'><path d='M1 1 L9 9 M9 1 L1 9' stroke='black' stroke-width='1.6' stroke-linecap='round'/></svg>");
}

/* ---------- 12. Touch sizing ---------- */
@media (max-width: 720px) {
  /* The blanket 44px min-height for fields would stretch the 18px boxes. */
  input[type="checkbox"], input[type="radio"] { min-height: 0; height: 18px; }
  .lul-select-btn, .lul-file-face { min-height: 44px; font-size: 16px; }
  .lul-file-btn, .lul-file-name { font-size: 13px; }
  .lul-cal { width: 100%; max-width: 300px; }
}


/* ---- Recipient picker (Send email on Admin -> Support tickets) ------------
   Lives inside .lul-modal, so it inherits the dialog's surface and spacing and
   only styles the list it adds. Rows are buttons, not links: choosing one is an
   action inside the dialog, not navigation, and a keyboard user gets Enter and
   Space for free. */
.picker-status { margin: 8px 0 6px; font-size: .85rem; }
.picker-results { max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
.picker-row {
  display: flex; flex-direction: column; gap: 2px; width: 100%;
  padding: 9px 10px; text-align: left; cursor: pointer;
  background: transparent; border: 1px solid transparent; border-radius: 8px;
  font: inherit; color: inherit;
}
.picker-row:hover { background: var(--paper-2); border-color: var(--line); }
/* Focus must be visible on a list the keyboard is the primary way to walk. */
.picker-row:focus-visible { outline: none; box-shadow: var(--shadow-focus); border-color: var(--focus); }
.picker-name { font-weight: 600; }
.picker-email { font-size: .82rem; color: var(--ink-3); }


/* ---- Needs-attention triage (Admin -> Observability) ---------------------
   The clear control is a <details> in a table cell, so its panel has to escape
   the cell without widening the column: absolute, right-aligned, above the rows
   below it. Collapsed it is a plain text button and costs one column of width. */
.attn-clear { position: relative; }
/* Keeps the shared disclosure chevron (section 10) - it is a real disclosure,
   not a button, and the caret is how a reader knows the panel is there. */
.attn-clear > summary {
  cursor: pointer; display: inline-block;
  padding: 6px 10px; border: 1px solid var(--line-input); border-radius: 8px;
  font-size: 12px; color: var(--ink-2); background: var(--paper);
}
.attn-clear > summary:hover { border-color: var(--line-2); }
.attn-clear > summary:focus-visible { outline: none; box-shadow: var(--shadow-focus); border-color: var(--focus); }
.attn-clear[open] > summary { border-color: var(--accent); }
.attn-clear > form {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 20;
  width: 330px; max-width: 78vw; white-space: normal;
  display: flex; flex-direction: column; gap: 8px;
  padding: 12px; border: 1px solid var(--line); border-radius: 10px;
  background: var(--paper); box-shadow: var(--shadow-md);
}
.attn-clear-label { font-size: 12px; font-weight: 600; color: var(--ink-2); }
/* The consequences list is the point of the panel, not fine print: "resolved"
   and "cannot be fixed" are one word apart and permanently different. */
.attn-clear-hints { margin: 0; padding-left: 16px; font-size: 11.5px; color: var(--ink-3); line-height: 1.5; }
.attn-clear-hints strong { color: var(--ink-2); font-weight: 600; }
/* Below 720px .table-wrap becomes a horizontal scroller, which would clip an
   absolutely positioned panel, and putting it back into the flow instead
   inflates the whole row to the panel's height in a column that is off-screen
   anyway. So it becomes a fixed sheet at the foot of the viewport: outside the
   scroll container, at a readable width, and costing the row no height at all. */
@media (max-width: 720px) {
  .attn-clear > form {
    position: fixed; left: 12px; right: 12px; bottom: 12px; top: auto;
    width: auto; max-width: none; z-index: 60; box-shadow: var(--shadow-card);
  }
}

/* ==========================================================================
   Client pages (Admin -> Pages)
   --------------------------------------------------------------------------
   Styling for the block palette in lib/page-templates.js, plus the editor
   chrome. Everything here is prefixed `.pg-` for one reason: a page's own CSS
   is rewritten by lib/page-html.js to sit under `#lul-page-<id>`, so these
   rules and an author's rules can never be told apart by name alone - the
   prefix is what keeps "ours" and "theirs" legible when debugging a page.

   Deliberately thin. Most palette blocks reuse `.section`, `.wrap`, `.card`,
   `.cards`, `.eyebrow`, `.lede`, `.btn` and `.callout`, so a client-built page
   inherits the brand and re-skins with it (CLAUDE.md s18). What is here is
   only the handful of shapes the site had no class for.
   ========================================================================== */

.pg-root { display: block; }

/* Columns. Single column on small screens, because a two-up split at 375px is
   two unreadable columns rather than a layout. */
.pg-cols { display: grid; grid-template-columns: 1fr; gap: var(--s-24); }
@media (min-width: 760px) {
  .pg-cols-2 { grid-template-columns: 1fr 1fr; }
  .pg-cols-3 { grid-template-columns: repeat(3, 1fr); }
}
.pg-media-row { align-items: center; }

.pg-rule { border: 0; border-top: 1px solid var(--line); margin: var(--s-40) 0; }
.pg-spacer { height: var(--s-40); }

.pg-head { margin-bottom: var(--s-24); }
.pg-head h2 { margin: 6px 0 8px; }

.pg-list { margin: 0 0 var(--s-16); padding-left: 1.15em; }
.pg-list li { margin-bottom: 6px; }

/* Figures. Mirrors .hero-stats' rhythm without inheriting its dark-hero
   colours, since these sit on the page rather than in the hero. */
.pg-stats {
  display: grid; grid-template-columns: repeat(2, 1fr);
  gap: var(--s-24) var(--s-16); margin: var(--s-24) 0;
}
@media (min-width: 760px) { .pg-stats { grid-template-columns: repeat(4, 1fr); } }
.pg-stat { display: flex; flex-direction: column; gap: 4px; }
.pg-stat strong { font-size: 26px; line-height: 1.1; }
.pg-stat span { font-size: 13px; color: var(--ink-3); }

.pg-quote {
  margin: var(--s-24) 0; padding: var(--s-16) var(--s-24);
  border-left: 3px solid var(--gold); background: var(--paper-2);
  border-radius: 0 var(--radius) var(--radius) 0;
}
.pg-quote p { margin: 0 0 8px; font-size: 18px; }
.pg-quote cite { font-style: normal; font-size: 13px; color: var(--ink-3); }

.pg-img { max-width: 100%; height: auto; display: block; border-radius: var(--radius); }

/* A 16:9 box that keeps its shape before the iframe loads, so the page does
   not jump (a layout shift the Core Web Vitals work cares about). */
.pg-embed { position: relative; padding-top: 56.25%; border-radius: var(--radius); overflow: hidden; }
.pg-embed iframe, .pg-embed video {
  position: absolute; inset: 0; width: 100%; height: 100%; border: 0;
}

/* ---- Forms on a page --------------------------------------------------- */
.pg-form { max-width: 620px; }
.pg-field { margin-bottom: var(--s-16); }
.pg-field > label { display: block; margin-bottom: 6px; font-weight: 500; }
.pg-field input[type="text"], .pg-field input[type="email"], .pg-field input[type="tel"],
.pg-field input[type="number"], .pg-field input[type="date"], .pg-field input[type="url"],
.pg-field select, .pg-field textarea {
  width: 100%;
}
/* A tick box labels itself inline, so the block-level rule above would put the
   caption on its own line and orphan the box. */
.pg-field-check > label { display: flex; align-items: center; gap: 8px; font-weight: 400; }

/* ---- Dates ------------------------------------------------------------- */
.pg-agenda { list-style: none; margin: 0; padding: 0; }
.pg-agenda > li {
  display: grid; grid-template-columns: 1fr; gap: 4px;
  padding: var(--s-16) 0; border-bottom: 1px solid var(--line);
}
@media (min-width: 620px) {
  .pg-agenda > li { grid-template-columns: 140px 1fr; gap: var(--s-24); }
}
.pg-agenda > li:last-child { border-bottom: 0; }
.pg-agenda-when { font-weight: 600; color: var(--ink-2); }
.pg-agenda h4 { margin: 0 0 4px; }
.pg-agenda p { margin: 0; color: var(--ink-3); }

.pg-cal { margin: var(--s-24) 0; }
.pg-cal-title { margin: 0 0 var(--s-16); }
/* The grid scrolls inside itself rather than widening the page: seven columns
   do not fit a narrow phone, and a horizontally scrolling BODY is the one
   layout failure that makes a whole page feel broken. */
.pg-cal-grid {
  width: 100%; border-collapse: collapse; display: block; overflow-x: auto;
  white-space: nowrap;
}
.pg-cal-grid th, .pg-cal-grid td {
  border: 1px solid var(--line); padding: 10px; text-align: center;
  min-width: 44px;
}
.pg-cal-grid th { font-size: 12px; color: var(--ink-3); font-weight: 600; }
/* `--gold` is a FILL here, never text, so the AA text rule does not apply -
   but the day number sits on top of it, hence --on-gold (CLAUDE.md s18). */
.pg-cal-grid td.is-on { background: var(--gold); color: var(--on-gold); font-weight: 600; }
.pg-cal-key { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-3); }
.pg-cal-swatch {
  width: 14px; height: 14px; border-radius: 3px; background: var(--gold);
  display: inline-block;
}

/* ---- Admin: the editor ------------------------------------------------- */
/* GrapesJS's own panel layout is switched off (public/js/page-editor.js), so
   the frame, the toolbar and the sidebar are all ours and are built from the
   site's tokens. What is left of the vendor stylesheet is the INSIDE of the
   four managers - the block tiles, the style sectors, the layer tree - which
   is re-coloured through GrapesJS's own custom properties below rather than
   overridden rule by rule. */
.pg-editor {
  border: 1px solid var(--line); border-radius: var(--r-lg);
  overflow: hidden; background: var(--card);

  /* Vendor theme -> our palette. GrapesJS ships a dark theme; these five are
     what it actually paints its widgets with. */
  --gjs-primary-color: var(--card);
  --gjs-secondary-color: var(--ink-3);
  --gjs-tertiary-color: var(--gold-2);
  --gjs-quaternary-color: var(--gold);
  --gjs-font-color: var(--ink);
  --gjs-font-color-active: var(--ink);
  --gjs-main-color: var(--card);
  --gjs-main-dark-color: var(--paper-2);
  --gjs-main-light-color: var(--card);
  --gjs-light-border: var(--line);
  --gjs-color-highlight: var(--gold);
  --gjs-main-font: inherit;
}

/* Toolbar. Dark, like the admin sidebar, so it reads as chrome rather than as
   part of the page being composed. */
.pg-ed-bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-16); padding: 8px 10px;
  background: var(--brand-graphite); border-bottom: 1px solid var(--line);
}
.pg-ed-group { display: flex; align-items: center; gap: 4px; }
.pg-ed-btn {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 10px; border-radius: var(--radius);
  border: 1px solid transparent; background: transparent;
  /* Matches the admin sidebar rail exactly: 0.68 alpha is the value that
     keeps a resting label above 3:1 on graphite (see .admin-sidebar a). */
  color: rgba(255,255,255,0.68);
  font: inherit; font-size: 12px; cursor: pointer;
}
.pg-ed-btn:hover { background: rgba(255,255,255,0.07); color: #fff; }
.pg-ed-btn:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
/* Gold on graphite for the active state - dark ink on gold is the pairing the
   contrast contract allows, and this bar is the one dark surface where the
   reverse (gold as a fill under light text) would fail it. */
.pg-ed-btn.is-on { background: var(--gold); color: var(--on-gold); border-color: var(--gold); }
@media (max-width: 900px) { .pg-ed-btn span { display: none; } }

/* Canvas + sidebar. A fixed height is required: GrapesJS measures its canvas
   against this box, and a box sized by its (empty) contents collapses. */
.pg-ed-main {
  display: grid; grid-template-columns: minmax(0, 1fr) 300px;
  height: 70dvh; min-height: 460px;
}
.pg-ed-canvas { overflow: hidden; background: var(--paper-2); min-width: 0; }
.pg-ed-side {
  border-left: 1px solid var(--line); background: var(--card);
  overflow-y: auto; min-width: 0; font-size: 13px;
}
@media (max-width: 860px) {
  /* Side by side stops working long before this; stack and give each a real
     height rather than letting the canvas shrink to a strip. */
  .pg-ed-main { grid-template-columns: 1fr; height: auto; }
  .pg-ed-canvas { height: 55dvh; }
  .pg-ed-side { border-left: 0; border-top: 1px solid var(--line); height: 40dvh; }
}

.pg-ed-tabs {
  display: flex; position: sticky; top: 0; z-index: 2;
  background: var(--card); border-bottom: 1px solid var(--line);
}
.pg-ed-tab {
  flex: 1; padding: 10px 6px; border: 0; background: transparent;
  border-bottom: 2px solid transparent;
  font: inherit; font-size: 12px; color: var(--ink-3); cursor: pointer;
}
.pg-ed-tab:hover { color: var(--ink); }
.pg-ed-tab.is-on { color: var(--ink); border-bottom-color: var(--gold); font-weight: 500; }
.pg-ed-tab:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
.pg-ed-pane { padding: 10px; }

/* Block tiles. The vendor default is a wide two-up grid with a large icon; at
   300px that gives two words per tile and truncates every label. */
.pg-editor .gjs-blocks-c { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.pg-editor .gjs-block {
  width: auto; min-height: 0; margin: 0; padding: 10px 6px;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--paper-2); color: var(--ink-2);
  font-size: 11px; line-height: 1.3; box-shadow: none;
}
.pg-editor .gjs-block:hover { border-color: var(--gold); background: var(--card); }
.pg-editor .gjs-block__media { margin-bottom: 6px; color: var(--ink-3); }
.pg-editor .gjs-block-category .gjs-title {
  background: transparent; border: 0; padding: 8px 2px 4px;
  font-size: 11px; letter-spacing: .04em; text-transform: uppercase; color: var(--ink-3);
}
.pg-editor .gjs-sm-sector-title, .pg-editor .gjs-clm-label-sel { font-size: 12px; }

/* The address choice in Page settings. Horizontal, because the two options
   are a single either/or read in one glance, not a list to work down - and
   because stacked radios in a .span2 grid cell leave the right half empty. */
/* The live address in the editor's status bar, as a link. The arrow marks it
   as leaving the admin; .sr-only carries the same fact to a screen reader,
   which cannot see the icon. */
.pg-live-link { display: inline-flex; align-items: center; gap: 5px; text-decoration: none; }
.pg-live-link code { text-decoration: underline; text-underline-offset: 2px; }
.pg-live-link svg { opacity: .6; flex: none; }
.pg-live-link:hover svg { opacity: 1; }

.pg-choice { display: flex; flex-wrap: wrap; gap: 8px 28px; align-items: center; }
.pg-choice .check-inline { gap: 8px; }
.pg-choice code { font-size: 12px; color: var(--ink-3); }

/* Style picker: the site's own classes as chips, so a client applies
   `.hero-dark` by recognising it rather than by typing it. */
.pg-styles { border-bottom: 1px solid var(--line); padding-bottom: 10px; margin-bottom: 10px; }
.pg-styles-group { margin-bottom: 10px; }
.pg-styles-title {
  font-size: 10px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-3); margin-bottom: 5px;
}
.pg-styles-row { display: flex; flex-wrap: wrap; gap: 4px; }
.pg-style-chip {
  padding: 4px 8px; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--line); background: var(--paper-2);
  font: inherit; font-size: 11px; color: var(--ink-2);
}
.pg-style-chip:hover:not(:disabled) { border-color: var(--gold); background: var(--card); }
.pg-style-chip:focus-visible { outline: none; box-shadow: var(--shadow-focus); }
/* Dark ink on gold: the pairing the contrast contract allows (CLAUDE.md s18). */
.pg-style-chip.is-on { background: var(--gold); border-color: var(--gold); color: var(--on-gold); font-weight: 500; }
.pg-style-chip:disabled { opacity: .45; cursor: default; }
/* With nothing selected the chips do nothing, so say why rather than leaving a
   panel of dead controls. */
.pg-styles-empty { font-size: 11px; margin: 0; display: none; }
.pg-styles.is-empty .pg-styles-empty { display: block; }

.pg-editor-fallback { padding: var(--s-16); }
.pg-editor-fallback textarea {
  width: 100%; font-family: var(--font-mono); font-size: 13px;
}

/* A prefix sitting flush against an input, for the /p/ in the address field. */
.input-prefix { display: flex; align-items: stretch; gap: 0; }
.input-prefix > span {
  display: flex; align-items: center; padding: 0 10px;
  border: 1px solid var(--line-input); border-right: 0;
  border-radius: var(--radius) 0 0 var(--radius);
  background: var(--paper-2); font-size: 14px;
}
.input-prefix > input { border-radius: 0 var(--radius) var(--radius) 0; flex: 1; min-width: 0; }

/* ============================================================
   CONTACT PAGE (/contact, views/user/contact.ejs)

   Form on the left, the other two ways to reach us on the
   right. The form is the wider column deliberately: chat and
   email are one line each, and giving them equal width would
   suggest the message box is the least of the three options
   when it is the one that works outside staffed hours.
   ============================================================ */
.contact-head { max-width: 640px; margin-bottom: var(--gap-block, 20px); }
.contact-head h1 { margin: 8px 0 12px; }

.contact-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  gap: var(--gap-tile, 14px);
  align-items: start;
}
@media (max-width: 860px) {
  /* The aside carries the fastest route to an answer (live chat), so on a
     narrow screen it goes ABOVE the form rather than being buried under a
     four-field form the visitor may not have needed to fill in. */
  .contact-grid { grid-template-columns: 1fr; }
  .contact-aside { order: -1; }
}

/* The form panel is stretched to the rail's height and the slack is handed to
   the message box, so the two columns end level instead of leaving a hole
   beside the last channel card. Only the FORM stretches: the aside keeps
   `align-items: start`, so when the form is the taller of the two (an
   anonymous visitor's four extra fields, or a deployment with chat and email
   switched off) the channel cards stay their own height rather than being
   pulled apart to fill a column. */
.contact-form-panel { min-width: 0; align-self: stretch; display: flex; flex-direction: column; }
.contact-form-title { margin: 0 0 6px; font-size: 22px; }
.contact-signed-in { font-size: 13px; margin: 0 0 18px; }
.contact-form-panel .callout { margin-bottom: 18px; }
.contact-form-panel .auth-form { flex: 1 1 auto; min-height: 0; }
/* flex-basis stays `auto` so the textarea can only ever GROW past its `rows`
   height - a basis of 0 would let it collapse on a short viewport. */
.contact-message-row { flex: 1 1 auto; min-height: 0; }
.contact-message-row textarea { flex: 1 1 auto; }

.contact-aside { display: grid; gap: var(--gap-tile, 14px); min-width: 0; }
.contact-channel h2 { margin: 0 0 8px; font-size: 17px; }
.contact-channel p { font-size: 14px; color: var(--ink-3); margin: 0 0 12px; line-height: 1.55; }
.contact-channel .btn { margin-top: 4px; }

/* Staffed hours. Set apart from the surrounding prose because it is the one
   fact on the panel a visitor scans for before deciding to click. */
.contact-hours {
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px; margin: 0 0 12px;
  background: var(--paper-2); border: 1px solid var(--line);
  border-radius: var(--r-md);
  color: var(--ink) !important; font-size: 14px;
}
.contact-hours-label {
  font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--ink-3);
}
/* The hours themselves are uppercased HERE rather than in the stored value:
   `support_hours` is one Settings string quoted on this page, in the footer, in
   Terms, in Privacy, in the emails and on the booking PDF, and it is the only
   one of those where the client asked for capitals. */
.contact-hours-value { text-transform: uppercase; }

.contact-email { margin: 0 0 6px !important; font-size: 15px; }
.contact-email a { font-weight: var(--fw-medium); word-break: break-word; }

/* ---------- /contact: "Getting here" ----------
   Full width under the form + channel rail, because it is reference material
   a guest returns to rather than a way of contacting us. The address and the
   map button sit on one line where there is room and stack where there is not.
   `--gap-section` above it: it is a named thing of its own, not another
   channel panel (s18). */
.venue-panel { margin-top: var(--gap-section, 40px); }
.venue-panel h2 { margin: 0 0 14px; }

.venue-where {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--gap-tile, 14px);
  padding: 14px 16px; margin-bottom: var(--gap-block, 20px);
  background: var(--paper-2); border: 1px solid var(--line);
  border-radius: var(--r-md);
}
/* <address> is italic in every UA stylesheet, which reads as a quotation
   rather than as a postal address. */
.venue-address {
  font-style: normal; margin: 0; flex: 1 1 260px; min-width: 0;
  font-size: 15px; line-height: 1.6; color: var(--ink);
}
.venue-map-btn { flex: 0 0 auto; }

/* The travel copy is admin-authored Markdown, so it arrives as h3 + p and the
   headings need to read as sub-sections of "Getting here" rather than as peers
   of it. There is no white-space: pre-wrap to undo here (unlike the ticket
   thread's .is-md blocks): this is rendered Markdown from the start, and the
   source newlines are already spent building the <p>. */
.venue-travel { max-width: 68ch; }
.venue-travel h2, .venue-travel h3 {
  font-size: 16px; margin: var(--gap-block, 20px) 0 6px;
  letter-spacing: 0.01em;
}
.venue-travel > :first-child { margin-top: 0; }
.venue-travel > :last-child { margin-bottom: 0; }
.venue-travel p { margin: 0 0 10px; line-height: 1.65; color: var(--ink-2); }
.venue-travel ul, .venue-travel ol { margin: 0 0 10px; padding-left: 20px; color: var(--ink-2); }

/* The click-to-load map. The placeholder and the frame that replaces it share
   one box, so pressing the button cannot reflow the directions underneath it -
   the same no-reflow rule the "What's included" hover follows (s9e). A fixed
   aspect ratio rather than a fixed height, or the map is a letterbox slot on a
   phone and a postage stamp on a desktop. */
.venue-map-embed {
  margin: 0 0 var(--gap-block, 20px);
  aspect-ratio: 16 / 9;
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  overflow: hidden;
  background: var(--paper-2);
}
@media (max-width: 640px) { .venue-map-embed { aspect-ratio: 4 / 3; } }

/* The placeholder fills that box and says WHY there is no map yet. A bare
   button in an empty frame reads as something that failed to load. */
.venue-map-ph {
  width: 100%; height: 100%;
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 12px; padding: 16px; text-align: center;
}
.venue-map-ph-note {
  margin: 0; max-width: 34ch;
  font-size: 13px; line-height: 1.5; color: var(--ink-3);
}
/* The frame replaces the placeholder inside the same box. `display: block`
   because an iframe is inline by default and would sit on a text baseline,
   leaving a few pixels of background showing under it. */
.venue-map-frame { display: block; width: 100%; height: 100%; border: 0; }

/* ============================================================
   ADMIN -> CONTENT: image slots + the curated collections
   (speakers, testimonials, gallery). See routes/admin.js
   IMAGE_SLOTS / COLLECTIONS and migration 088.
   ============================================================ */

/* A settings-key-backed image with its preview beside its upload form. */
/* ---------- Admin -> Content: field groups, jump list, field search ----------
   The Home tab is ~50 fields. Grouping them by the SECTION of the live page
   they belong to, in the order those sections appear, is what makes a field
   findable by someone who knows the site rather than the settings table. The
   rhythm is the admin's own (--gap-section between named things, s18). */
.cgroup {
  padding-top: var(--s-24); margin-top: var(--s-24);
  border-top: 1px solid var(--line);
}
.cgroup:first-of-type { padding-top: 0; margin-top: 0; border-top: 0; }
/* The same group at the TOP of a collection tab (Speakers / Testimonials /
   Gallery): the wording printed above that list, which used to sit on the Home
   tab while the thing it introduces sat here. It closes with a rule instead of
   opening with one, because what follows is a different kind of thing - the
   rows themselves, each saving on its own. */
.cgroup-lead {
  padding-bottom: var(--s-24); margin-bottom: var(--s-24);
  border-bottom: 1px solid var(--line);
}
.cgroup-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; flex-wrap: wrap; margin-bottom: 4px;
}
.cgroup-head h3 { margin: 0; font-size: 17px; font-weight: var(--fw-medium); }
.cgroup-see { font-size: 12px; white-space: nowrap; }
.cgroup-note { margin: 0 0 14px; font-size: 13px; color: var(--ink-3); max-width: 68ch; line-height: 1.55; }
.cgroup-sub {
  margin: 18px 0 8px; font-size: 11px; font-weight: var(--fw-medium);
  text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3);
}
.cgroup-jump {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  margin-bottom: var(--s-24); padding-bottom: var(--s-16);
  border-bottom: 1px solid var(--line);
  font-size: 12.5px;
}
.cgroup-jump > span { color: var(--ink-3); margin-right: 2px; }
.cgroup-jump a {
  padding: 3px 9px; border-radius: var(--r-pill);
  background: var(--paper-2); border: 1px solid var(--line); color: var(--ink-2);
  text-decoration: none;
}
.cgroup-jump a:hover { background: var(--gold-4); border-color: var(--gold-3); color: var(--ink); }

.content-find { margin-top: 14px; max-width: 520px; }
.content-find input { width: 100%; }
.content-find-result { margin: 8px 0 0; font-size: 12.5px; color: var(--ink-3); }
/* One class does all the hiding, and it has to out-specify nothing in
   particular - but `hidden` is what tabs.js sets on a panel, so a searched
   panel is un-hidden by script and re-hidden by this class instead. */
.cf-hidden { display: none !important; }
/* While a search is running the panels are stacked rather than tabbed, so each
   one has to say which tab it is - otherwise the results are a list of fields
   with no idea where they live. */
body.cf-searching [data-tabpanel].cf-showing::before {
  content: attr(data-tabpanel);
  display: block; margin-bottom: 10px;
  font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
  color: var(--gold-2); font-weight: var(--fw-medium);
}
body.cf-searching .subtabs { opacity: .45; pointer-events: none; }

.content-slots { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--gap-tile, 14px); }
.content-slot {
  display: flex; gap: 14px; align-items: flex-start;
  padding: 14px; border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--paper-2);
}
.content-slot > form { flex: 1; min-width: 0; }
/* The preview is the SHAPE THE SITE USES, not a convenient thumbnail size.
   These two files fill the full-bleed hero band (16/9); at the old 120x80 the
   admin was shown a 3:2 crop of a photo the page would crop to 16/9, so the
   tile disagreed with the site about what had been uploaded. Every preview box
   in the admin now matches its own surface - test/image-editor.test.js pins
   each one against the stylesheet rule that does the real cropping. */
.content-slot-preview {
  width: 142px; aspect-ratio: 16 / 9; flex-shrink: 0; overflow: hidden;
  border-radius: var(--r-sm, 6px); border: 1px solid var(--line);
  background: #1a1812;
  display: flex; align-items: center; justify-content: center;
  color: var(--ink-3); font-size: 10px; text-align: center; padding: 0 6px;
}
/* The share card is 1200x630 (1.905), not the hero band's 16/9 (1.778). Close
   enough to look like a rounding error and far enough that the tile which
   exists to show what will ship would be showing a different crop from the one
   that does - the exact fault test/image-editor.test.js pins. */
.content-slot-preview.is-share { aspect-ratio: 1200 / 630; }
.content-slot-preview img,
/* A slot may hold a VIDEO - the programme hero background takes either, and
   an unsized <video> ignores the box and overflows the tile it is meant to
   be previewing inside. */
.content-slot-preview video { width: 100%; height: 100%; object-fit: cover; }
/* The speakers overlay is a TRANSPARENT cut-out. On the dark preview tile above
   it would be a silhouette on near-black, so this one gets a checkerboard -
   which is also the fastest way for the admin to confirm the file they picked
   actually has an alpha channel, the single thing that makes it work. */
.content-slot-preview.is-alpha {
  background-color: var(--paper-3);
  background-image:
    linear-gradient(45deg, var(--line) 25%, transparent 25%),
    linear-gradient(-45deg, var(--line) 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, var(--line) 75%),
    linear-gradient(-45deg, transparent 75%, var(--line) 75%);
  background-size: 12px 12px;
  background-position: 0 0, 0 6px, 6px -6px, -6px 0;
}
.content-slot-preview.is-alpha img { object-fit: contain; }

/* One editable row of a collection: media on the left, fields on the right. */
.coll-row {
  display: flex; gap: 16px; align-items: flex-start;
  padding: 16px; margin-bottom: var(--gap-tile, 14px);
  border: 1px solid var(--line); border-radius: var(--r-md); background: var(--paper-2);
}
/* The default is the CIRCLE, because that is what a testimonial face is on the
   site (.quote-face). A speaker is not: .speaker-face is a 4/5 portrait card, so
   a circular preview showed the admin a crop the page never takes - a portrait
   photographed head-to-waist reads as a headshot here and as a full figure
   there. The modifier is on the row that needs it rather than the shared class. */
.coll-row-media {
  width: 92px; height: 92px; flex-shrink: 0; overflow: hidden;
  border-radius: 50%; border: 1px solid var(--line); background: var(--paper-3);
  display: flex; align-items: center; justify-content: center;
  color: var(--ink-3); font-size: 10px; text-align: center;
}
.coll-row-media.is-portrait {
  height: auto; aspect-ratio: 4 / 5; border-radius: var(--r-sm, 6px);
}
.coll-row-media img { width: 100%; height: 100%; object-fit: cover; }
.coll-row-fields { flex: 1; min-width: 0; }
.coll-row-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.coll-add {
  padding: 16px; border: 1px dashed var(--line-2); border-radius: var(--r-md);
  background: var(--card);
}
.coll-add .btn { margin-top: 12px; }

/* The two consent ticks are boxed away from the ordinary fields, because they
   are the difference between publishing a real person's name and face and not.
   They must never read as one more optional field in the grid. */
.coll-consent {
  display: flex; flex-direction: column; gap: 6px;
  margin-top: 12px; padding: 10px 12px;
  border: 1px solid var(--color-warn-line); border-left: 3px solid var(--warn);
  border-radius: var(--r-md); background: var(--color-warn-bg);
}
.coll-consent .checkline { color: var(--color-warn-fg); }

.checkline { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
.checkline input { flex-shrink: 0; }

/* The gallery is a grid of tiles rather than a list of rows: it is browsed by
   picture, and a stack of 60 full-width rows is not browsable. */
.coll-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: var(--gap-tile, 14px); }
.coll-tile {
  padding: 12px; border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--paper-2); min-width: 0;
}
.coll-tile-media {
  position: relative; aspect-ratio: 4 / 3; margin-bottom: 10px; overflow: hidden;
  border-radius: var(--r-sm, 6px); background: var(--paper-3);
}
.coll-tile-media img, .coll-tile-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.coll-tile-kind {
  position: absolute; left: 6px; bottom: 6px;
  padding: 2px 7px; border-radius: 999px;
  background: rgba(23,23,25,0.82); color: var(--brand-paper);
  font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase;
}
.coll-tile .form-row { margin-bottom: 8px; }
/* In-place save (public/js/coll-tiles.js): the tile dims while the request is
   out, a "Saved" note appears beside the buttons and fades, and a deleted tile
   folds away rather than the page reloading around the gap. */
.coll-tile.is-busy { opacity: 0.6; pointer-events: none; }
.coll-tile.is-gone { opacity: 0; transform: scale(0.97); transition: opacity .2s ease, transform .2s ease; }
.coll-saved {
  font-size: 12.5px; color: var(--color-ok-fg); font-weight: var(--fw-medium);
  opacity: 0; transition: opacity .25s ease;
}
.coll-saved.is-on { opacity: 1; }
.coll-saved.is-err { color: var(--color-err-fg); }
.coll-saved.is-on::before { content: '\2713\00a0'; }
.coll-saved.is-err::before { content: ''; }
@media (prefers-reduced-motion: reduce) {
  .coll-tile.is-gone, .coll-saved { transition: none; }
}

@media (max-width: 640px) {
  .coll-row { flex-direction: column; }
  .content-slot { flex-direction: column; }
}

/* ============================================================
   HOME / PROGRAMME CONTENT SECTIONS
   The client's page plan: "Experience the retreat", "What's
   included", speakers, booking steps, testimonials and the
   past-retreat gallery. Each lives in views/partials/sections/
   and is used by BOTH pages, so these rules are shared too.
   ============================================================ */

/* ---------- The marketing pages' rhythm ----------
   Scoped on body.has-dark-hero, which is exactly the two pages built out of
   these partials (home, and the programme page when it carries a dark hero).
   The rest of the site - rooms, checkout, account - keeps the tighter
   application rhythm, because a booking form is not a brochure.

   Before this, home was eight consecutive bands of white rounded cards on two
   greys a percent apart: the same box at the same size eight times, so nothing
   on the page said which part mattered. The card is now reserved for the
   PRODUCT (room cards, payment plans) and everything else is set as editorial
   type on the page itself. That is the whole redesign in one sentence. */
body.has-dark-hero .section { padding: clamp(56px, 7vw, 92px) 0; }
body.has-dark-hero .section h2 {
  font-size: clamp(28px, 3.6vw, 42px); line-height: 1.1; letter-spacing: -0.02em;
}
body.has-dark-hero .section .lede { font-size: 19px; max-width: 660px; }
/* One gold rule above every section eyebrow. It is the only ornament these
   pages use, so it is what makes eight different sections read as one system.
   A graphic, not text, so the brand gold at full strength is fine here (s18). */
body.has-dark-hero .section .eyebrow::before {
  content: ''; display: block; width: 30px; height: 2px;
  background: var(--gold); margin-bottom: 12px; border-radius: 2px;
}
/* Section heads only. Room cards carry an eyebrow of their own (the short
   pitch), and a rule above each one put a dozen gold ticks through the grid,
   which turned a section marker into wallpaper. */
body.has-dark-hero .section .card .eyebrow::before { content: none; }

/* ---------- "Experience the retreat" ---------- */
.experience-lead {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(24px, 4vw, 48px); align-items: center;
}
.experience-stills {
  display: grid; grid-template-columns: 1fr 1fr; gap: 10px;
}
.experience-stills img {
  width: 100%; aspect-ratio: 4 / 3; object-fit: cover;
  border-radius: var(--r-md); border: 1px solid var(--line);
}
/* The second still is nudged down so the block reads as a scatter of
   photographs rather than a 2x2 contact sheet. */
.experience-stills img:nth-child(even) { margin-top: 18px; }

.experience-video {
  position: relative; margin-top: clamp(24px, 4vw, 40px);
  aspect-ratio: 16 / 9; overflow: hidden;
  border-radius: var(--r-card); border: 1px solid var(--line);
  background: #1B1B1D;
}
.experience-video > iframe,
.experience-video > video {
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  border: 0; object-fit: cover;
}

@media (max-width: 820px) {
  /* `minmax(0, 1fr)`, never a bare `1fr`. A grid item's implicit `min-width` is
     `auto`, which resolves to MIN-CONTENT, and `1fr` is `minmax(auto, 1fr)` -
     so the column floored at the widest unbreakable thing in the body copy
     (measured: a 370px track inside a 288px container at 320px wide) and
     pushed the whole document sideways. The two-column rule above already says
     `minmax(0, ...)` for exactly this reason and this one did not, which is the
     same trap the room board carries a note about. */
  .experience-lead { grid-template-columns: minmax(0, 1fr); }
  .experience-stills img:nth-child(even) { margin-top: 0; }
}

/* ---------- "What to expect" (the `home_included_*` keys) ---------- */
/* A NAVY BAND OF CREAM BOXES, lifted off the retreat poster.

   This was a ruled list on --paper-2 with each description hidden until hover,
   and both halves of that went with the change rather than being layered over:
   a box whose copy only appears when pointed at is worse than either shape, and
   the out-of-flow reveal panel existed solely so a RULED ROW could grow without
   shoving the page down. Boxes carry their own copy, so there is nothing left
   to reveal, nothing to keep in register with a tick column, and no keyboard
   trap to mitigate - the `tabindex` on every row went too, because a box that
   discloses nothing has no reason to take focus.

   THE BAND IS NAVY BECAUSE THE BOXES ARE CREAM. Warm cream sitting on the
   site's cool --paper-2 grey reads as dirty paper, and in dark mode twelve
   cream slabs on near-black are a headlight. Painting the band in the poster's
   own navy makes the pair deliberate instead: a single dark section between two
   light ones, holding artwork-coloured cards, identical in both themes because
   every colour in it is literal. That is `.hero-dark`'s arrangement exactly,
   and it is why this section no longer takes a `bg` from its caller - one half
   of the pair cannot be chosen from outside the other.

   It is also one of the few places on this site that paints a card without
   meaning "this is for sale" (s9e). The licence is the palette: cream/navy/gold
   is the poster's, so these read as artwork rather than as another product
   tile, and the room cards further down keep white-on-grey to themselves.

   --cols is set per section from the ITEM COUNT (siteContent.balancedColumns),
   so a list never strands a half-empty last row. auto-fit cannot do this: it
   fills on width alone and leaves the remainder hanging. The media queries
   further down the sheet still take over on narrow screens, where the count is
   not the constraint. */
.included-section { background: var(--poster-navy); }
/* The section head is repainted for the band it now sits on. --ink would be
   near-black on navy in the light theme, which is the one direction that is
   invisible rather than merely wrong. */
.included-section h2 { color: var(--poster-cream); }
/* THE EYEBROW ON THESE BANDS IS A TITLE, NOT A LABEL, so it is sized like one.
   `home_included_heading` is blank on the live site, which leaves "What to
   expect" carrying the whole band at 12px with 0.12em of tracking - correct for
   a label sitting above a heading, far too quiet when it IS the heading. The
   tracking comes back down as the size goes up: 0.12em is what makes small caps
   readable and what makes large ones look spaced out.

   The weight stays `--fw-medium`. That token is 500 and so are `--fw-semibold`
   and `--fw-bold` - the design uses one strong weight throughout (s18), and the
   heft here comes from the size, not from a heavier face this typeface does not
   use anywhere else. */
.included-section .eyebrow {
  color: var(--poster-gold);
  font-size: clamp(26px, 4.6vw, 38px);
  line-height: 1.12;
  letter-spacing: 0.02em;
  margin-bottom: 14px;
}
/* No heading under it: it takes the heading's own gap so the cards do not crowd
   the title. The template writes this class (see included.ejs). */
.included-section .eyebrow-alone { margin-bottom: 28px; }
.included-section h2 { margin: 0 0 28px; max-width: 620px; }
/* THE FACILITIES BAND'S TITLE IS A LABEL, NOT A HEADLINE. "More to experience"
   introduces a list of the hotel's amenities on a page whose own headline is
   the retreat; set at headline size it competed with it, so it is set at the
   section eyebrow's size and tracking instead - the same small caps as "PLAN
   YOUR RETREAT" on the downloads band further down the Programme page.

   Three classes on the selector because `body.has-dark-hero .section h2` sizes
   every heading on the marketing pages, and a two-class selector loses to it.
   The colour is left alone: cream at full strength is what separates the label
   from the softened cream lede under it. */
.section.included-section.facilities-section h2 {
  font-family: var(--font-body);
  font-weight: var(--fw-medium);
  font-size: var(--text-caption);
  line-height: 1.4;
  letter-spacing: 0.12em;
  text-transform: uppercase;
}
/* The shared gold rule above every section eyebrow (see the rhythm block
   above) is repointed to the poster's gold so the band ties to its own boxes
   rather than to the guideline gold used everywhere else. */
.included-section .eyebrow::before {
  background: var(--poster-gold);
  /* Scaled with the text it sits over: 30x2 under 38px letters reads as a
     hairline offcut rather than as the ornament it is on every other section. */
  width: 46px; height: 3px; margin-bottom: 16px;
}
/* The line under the heading, which only the Programme page's facilities band
   uses - "What to expect" goes straight from its heading to the grid. Repainted
   for the same reason the h2 and the eyebrow above are: `.section .lede` is
   --ink-3, a grey tuned for the paper background, and on the poster navy it is
   the one direction that is invisible rather than merely wrong. Cream at .82
   rather than a second token, matching how the ghost button on this palette
   already softens the same colour. */
.included-section .lede {
  color: rgba(245, 239, 227, .82);
  max-width: 620px;
  margin: 0 0 26px;
}

/* THE CREAM CARD ON THE NAVY BAND, declared once for the one component that
   uses it. Both bands that sit on the poster navy - "What to expect" on the
   home page and the hotel's facilities on the Programme page - are the same
   thing: a thin bar that opens on a click. They were two different shapes
   (a static box of copy, and a disclosure) until the client asked for the
   first to behave like the second, and keeping one set of rules is what stops
   them drifting apart again.

   The colours do NOT come from --card / --ink. They are a fixed pair off the
   poster (see the --poster-* note in :root) and must travel together in both
   themes; theming one half of them is how a cream box ends up with cream text.
   The geometry rides with them because the badge and the padding are derived
   from it. 26px for the tick because a book, a cup and a bed have interior
   detail a checkmark did not, and at 22 they closed up into a smudge. */
.fold {
  --inc-tick: 26px;
  --inc-gap: 14px;
  --inc-pad: clamp(18px, 1.7vw, 24px);
  --inc-radius: 14px;
  background: var(--poster-cream);
  color: var(--poster-navy);
  border: 1px solid var(--poster-line);
  border-radius: var(--inc-radius);
  overflow: hidden;
  transition: border-color .18s ease;
}

/* The gold accent, and the only place the poster gold appears at full strength.
   It is a FILL with navy ink on it (6.33:1), never gold text - the same rule
   .btn-primary follows, and for the same reason: this gold is 2.13:1 on the
   cream and unreadable under white. A solid badge also keeps the icon legible
   on ANY surface, which matters because Admin -> Content reuses this class for
   its icon preview, outside the cream box and on either admin theme. */
.included-tick {
  flex-shrink: 0; width: var(--inc-tick, 22px); height: var(--inc-tick, 22px); border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  margin-top: 1px;
  background: var(--poster-gold); color: var(--poster-navy);
  border: 1px solid var(--poster-gold);
}

/* ---------- The cream bands: cards that open ---------- */
/* Thin bars that open on a click, on desktop and on mobile alike (the client's
   request). The element is a real <details>, so the open/close, the keyboard
   and the state announced to a screen reader are the browser's job and not a
   script's; everything below is appearance only.

   Two columns on a wide screen, one below it. NOT the item-count grid the other
   two lists use: `balancedColumns` answers "how do I avoid a half-empty last
   row", which is a question about boxes of copy. These are bars.

   `align-items: start` is load-bearing. Grid items stretch to their row by
   default, so opening one card would have pulled its neighbour's blank cream
   box down to match it. */
.fold-list {
  list-style: none; padding: 0; margin: 0;
  display: grid; grid-template-columns: 1fr; align-items: start;
  gap: clamp(10px, 1.2vw, 14px);
}
@media (min-width: 900px) {
  .fold-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* The summary stays a BLOCK and the flex row is a span inside it, so the card's
   layout does not depend on what the shared <details> rules do to a summary
   box. The disclosure marker and the site-wide `summary::before` chevron are
   both already handled: s10 suppresses the marker for every <details>, and this
   one carries `data-no-chev` because it draws its own on the right. Without
   that attribute the page renders TWO chevrons, and on a flex summary the
   shared one lands between the padding and the gold badge. */
.fold > summary { display: block; cursor: pointer; }
.fold-head {
  display: flex; align-items: center; gap: var(--inc-gap);
  padding: clamp(14px, 1.3vw, 18px) var(--inc-pad);
}
.fold > summary strong {
  flex: 1 1 auto; min-width: 0;
  font-size: 16px; line-height: 1.3; letter-spacing: -0.01em;
  font-weight: var(--fw-medium); color: var(--poster-navy);
}
/* The chevron turns over. It is the only moving part, so it is also the only
   thing reduced motion has to switch off. */
.fold-mark {
  flex: 0 0 auto; display: inline-flex; color: var(--poster-navy-2);
  /* rotate(0deg) rather than the implied `none`, so both ends of the transition
     are real values. (If you probe this in headless Chrome under
     --virtual-time-budget, a re-closed card reports a computed
     matrix(-1, 0, 0, -1) for ever: virtual time stops advancing after the last
     timer, so the reverse transition never runs. The rule is fine - the element
     does not match [open], and reading it with the transition switched off
     gives the identity matrix. Do not go looking for a bug there.) */
  transform: rotate(0deg);
  transition: transform .2s ease;
}
.fold[open] .fold-mark { transform: rotate(180deg); }
.fold-body { padding: 0 var(--inc-pad) var(--inc-pad); }
/* `.fold-body >` rather than a bare descendant, so the rule cannot reach past
   this card's own copy into anything nested inside it. */
.fold-body > .included-desc {
  display: block; font-size: 13.5px; line-height: 1.55; color: var(--poster-navy-2);
}
/* The ring is navy on cream rather than the page's violet --focus, which is
   tuned against --paper and is nearly invisible on this card. Inset, because
   the summary fills a box with `overflow: hidden` and an outset ring is clipped
   to nothing. */
.fold > summary:focus-visible {
  outline: 2px solid var(--poster-navy); outline-offset: -3px; border-radius: var(--inc-radius);
}
@media (hover: hover) and (pointer: fine) {
  .fold:hover { border-color: var(--poster-gold); }
}
@media (prefers-reduced-motion: reduce) {
  .fold-mark { transition: none; }
}

/* Admin -> Content: which icon each "What to expect" line resolved to. The
   badge reuses `.included-tick` on purpose - the point of the preview is that
   it looks like the live page, and a second set of rules is a second thing to
   keep in step. */
.icon-preview { margin-top: 10px; }
.icon-preview ul {
  list-style: none; margin: 6px 0 0; padding: 0;
  display: flex; flex-wrap: wrap; gap: 8px 18px;
}
.icon-preview li { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); }

/* ---------- Search results: combinations of rooms ---------- */
/* A result is a SET of rooms, not a room type, so the card lists the rooms in
   the set with the guests who would be in each and prices the set as a whole.
   White cards on the grey page, the site's own separation mechanism (s18). */
.room-counts {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin: var(--s-24) 0 var(--s-16);
}
.room-counts-label {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--ink-3); font-weight: var(--fw-medium); margin-right: 2px;
}
.room-count {
  display: inline-flex; align-items: baseline; gap: 6px;
  padding: 7px 12px; border-radius: var(--r-input);
  border: 1px solid var(--line); background: var(--card);
  color: var(--ink); text-decoration: none; font-size: 13px;
}
.room-count span { color: var(--ink-3); font-size: 12px; }
.room-count:hover { border-color: var(--ink-3); }
/* The selected count is a fill, not a border change: a border is what hover
   already uses, and two states drawn the same way read as one. */
.room-count.is-on { background: var(--brand-ink); border-color: var(--brand-ink); color: var(--brand-paper); }
.room-count.is-on span { color: rgba(255,255,255,.7); }

.combo-group-head { margin: var(--s-32) 0 var(--s-16); font-size: var(--text-heading); }
.combo-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--s-16); }
.combo {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-16);
  padding: var(--s-16);
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--r-card); box-shadow: var(--shadow-sm);
}
/* The rooms take the space and the price sits at the end, so a set of one room
   and a set of four line their buttons up down the page. */
.combo-rooms { list-style: none; margin: 0; padding: 0; flex: 1 1 320px; display: grid; gap: 10px; }
.combo-room { display: flex; align-items: center; gap: 12px; min-width: 0; }
.combo-room-img {
  flex: 0 0 auto; width: 96px; height: 72px; object-fit: cover;
  border-radius: var(--r-sm); background: var(--paper-2);
}
/* THE THUMBNAIL PAGES THROUGH THE ROOM'S PHOTOGRAPHS.
   96x72 rather than the 80x60 it was: the arrows have to sit inside the frame
   and at 80 wide they met in the middle of the picture.

   A NATIVE SCROLL STRIP. `scroll-snap` is what makes it swipe correctly on a
   phone with no script at all, and the arrows only call scrollBy() - so a
   blocked script costs the buttons and not the pictures. The scrollbar is
   hidden rather than styled: at 72px tall it would take a tenth of the frame. */
.combo-shots {
  position: relative; flex: 0 0 auto;
  width: 96px; height: 72px; border-radius: var(--r-sm);
  overflow: hidden; background: var(--paper-2);
}
.combo-shots-strip {
  display: flex; height: 100%;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  /* NO `scroll-behavior: smooth` HERE. On the element it animates every
     programmatic scroll, including a plain `scrollLeft =`, which makes the
     strip's position untestable and takes the choice away from a caller that
     wants to jump. The arrows ask for the animation themselves, and ask for
     `auto` under prefers-reduced-motion. */
  scrollbar-width: none; -ms-overflow-style: none;
}
.combo-shots-strip::-webkit-scrollbar { display: none; }
.combo-shots-strip img {
  flex: 0 0 100%; width: 100%; height: 100%;
  object-fit: cover; scroll-snap-align: center; scroll-snap-stop: always;
}
/* Small, dark and INSIDE the frame, because the frame is the whole control.
   The scrim is on the button rather than over the picture: a wash across a
   96px photograph is most of the photograph. */
.combo-shot-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 22px; height: 22px; padding: 0;
  display: flex; align-items: center; justify-content: center;
  border: 0; border-radius: 50%; cursor: pointer;
  background: rgba(20, 20, 22, 0.55); color: #FFFFFF;
  /* The one place white on a dark fill is right: it is a graphic over a
     photograph, not text on the brand gold (s18). */
  opacity: 0; transition: opacity 120ms ease;
}
.combo-shots:hover .combo-shot-nav,
.combo-shot-nav:focus-visible { opacity: 1; }
/* A touch screen has no hover, so the arrows would never appear - and a swipe
   alone does not tell anybody there is more than one picture. (The counter in
   the corner does, on every device, which is why the arrows can afford to be a
   hover affordance on a pointer.) */
@media (hover: none) {
  .combo-shot-nav { opacity: 1; }
}
/* AT THE ENDS THE ARROW IS DIMMED, NOT REMOVED: a control that disappears
   moves the one beside it, and these sit 22px apart in a 96px frame.
   `cursor` is safe to set unconditionally; the OPACITY is not, and must be
   stated only where the arrow is shown at all - a flat
   `.combo-shot-nav:disabled { opacity: .25 }` wins over the `opacity: 0`
   above it and leaves the spent arrow as the only one visible before hover,
   which is precisely backwards. */
.combo-shot-nav:disabled { cursor: default; }
.combo-shots:hover .combo-shot-nav:disabled,
.combo-shot-nav:disabled:focus-visible { opacity: 0.3; }
@media (hover: none) {
  .combo-shot-nav:disabled { opacity: 0.3; }
}
/* `hidden` is not styled here at all: nothing in this block sets a `display`,
   so the reset's `[hidden] { display: none !important }` still governs the
   counter the script reveals (s14). */
.combo-shot-nav.is-prev { left: 3px; }
.combo-shot-nav.is-next { right: 3px; }
.combo-shot-nav:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
/* The position counter. Server-rendered `hidden` and revealed by the script,
   because without one it would be stuck reading 1/4 after a swipe - a wrong
   number is worse than no number. */
.combo-shots-count {
  position: absolute; right: 3px; bottom: 3px;
  padding: 1px 5px; border-radius: 999px;
  background: rgba(20, 20, 22, 0.55); color: #FFFFFF;
  font-size: 10px; font-variant-numeric: tabular-nums; line-height: 1.5;
  pointer-events: none;
}
.combo-room-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.combo-room-text strong { font-size: 15px; font-weight: var(--fw-medium); line-height: 1.25; }
.combo-room-text > span { font-size: 13px; color: var(--ink-3); }
.combo-buy { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; }
.combo-price { margin: 0; display: flex; flex-direction: column; }
.combo-price strong { font-size: 22px; font-weight: var(--fw-medium); letter-spacing: -0.01em; }
.combo-price small { font-size: 12px; color: var(--ink-3); }
.combo-plan { margin: 0; font-size: 12px; color: var(--ink-3); }
@media (max-width: 620px) {
  /* The price and its button go full width rather than sitting in a narrow
     column beside the rooms, which is where a two-word button wraps. */
  .combo-buy { flex: 1 1 100%; align-items: stretch; }
  .combo-buy .btn { width: 100%; }
}

/* ---------- The room search party controls ---------- */
/* Four counts and a room count. Each is a real <select>; on a touch screen
   room-search.js puts a minus/plus either side of it and the select stays the
   field. The grid is the search card's own, so these sit in the same row as the
   dates above them and wrap together. */
/* SEVEN THINGS IN THE ROW, not five. `.search-card` is a 4-up grid built for
   check-in, check-out, guests and a button; the party is now four counts plus a
   room count, so the card takes its own column rule here rather than inheriting
   one sized for a different form. The counts are narrow by nature - a single
   digit - so they get a narrower track than the dates beside them. */
.search-party { grid-template-columns: 1fr 1fr; }
@media (min-width: 760px) {
  .search-party {
    grid-template-columns:
      minmax(0, 1.3fr) minmax(0, 1.3fr)      /* check-in, check-out */
      repeat(5, minmax(0, 0.8fr))            /* adults, 0-3, 4-6, 7-11, rooms */
      auto;                                  /* the button */
  }
}
.party-field { display: flex; flex-direction: column; min-width: 0; }
/* The band is the label and the ages are the qualifier, so the two children
   rows are told apart by the part that differs rather than by their order. */
.party-field > label .hint {
  display: block; text-transform: none; letter-spacing: 0;
  font-weight: 400; opacity: .75;
}
.party-stepper { display: flex; align-items: stretch; gap: 6px; }
/* The select between the buttons keeps its own width rather than stretching:
   a two-digit count in a full-width control reads as a text field. */
.party-stepper > select { flex: 0 1 4.5rem; text-align: center; }
.party-step {
  flex: 0 0 auto; width: 44px;            /* the touch target, not the glyph */
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 18px; line-height: 1;
  background: var(--card); color: var(--ink);
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  cursor: pointer;
}
.party-step:hover { border-color: var(--ink-3); }
.party-step:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

/* ---------- Programme: "What to expect" ---------- */
/* Four centred cards on the grey page, each with a ringed icon over a title, a
   sentence and a short gold rule. White cards on grey IS the separation
   mechanism here (s18), which is why nothing draws a heavy border.

   This is a DIFFERENT section from `.included-*` above, which is also called
   "What to expect": that one is left-aligned cream boxes on the poster navy and
   lists what the retreat includes. Sharing its classes would have tied two
   pages' worth of design decisions together through a name collision. */
.expect-head { text-align: center; max-width: 660px; margin: 0 auto var(--s-32); }
.expect-head .eyebrow { justify-content: center; margin-bottom: 10px; }
.expect-head h2 { margin: 0 0 10px; }
/* `.section .lede` centres itself here rather than keeping the left rail it has
   everywhere else: `margin: 0 auto` cannot do it while that rule sets a
   max-width AND this block is already centred, so both are restated. */
.expect-head .lede { margin: 0 auto; max-width: 560px; }

.expect-grid {
  list-style: none; padding: 0; margin: 0;
  display: grid; grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
  gap: clamp(14px, 1.5vw, 20px);
}
.expect-item {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  padding: clamp(22px, 2vw, 30px) clamp(16px, 1.6vw, 24px) clamp(18px, 1.8vw, 24px);
  background: var(--card); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--r-md);
}
/* The ring, not a fill. The gold is a FILL colour at full strength (s18) and
   this disc is 58px of it behind a 26px glyph, which is a lot of gold for a row
   of four; the ring gives the same mark at a tenth of the area. The tint inside
   is the same gold at 10%, which reads on both themes without becoming a
   second surface colour. */
.expect-icon {
  width: 58px; height: 58px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  margin-bottom: 16px;
  color: var(--gold-2);
  background: color-mix(in srgb, var(--brand-gold) 10%, transparent);
  border: 1px solid color-mix(in srgb, var(--brand-gold) 45%, transparent);
}
.expect-item strong {
  display: block; font-size: 17px; line-height: 1.25; letter-spacing: -0.01em;
  font-weight: var(--fw-medium); color: var(--ink);
}
.expect-desc {
  display: block; margin-top: 8px; font-size: 13.5px; line-height: 1.6; color: var(--ink-2);
}
/* The hairline the client's reference closes each card with. `margin-top: auto`
   pins it to the bottom, so cards of unequal copy still line their rules up. */
.expect-rule {
  display: block; margin-top: auto; padding-top: 18px;
  width: 34px; border-bottom: 2px solid var(--brand-gold);
}
/* Phones take two across rather than four: at one per row the four cards are a
   screen and a half of scrolling, and at four they are 80px wide. */
@media (max-width: 720px) {
  .expect-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 380px) {
  .expect-grid { grid-template-columns: 1fr; }
}

/* ---------- Booking steps ---------- */
/* NOT `.steps` / `.step`: both are already the CHECKOUT STEPPER (see s1788),
   which is a horizontal rail of numbered dots with its own .num children and a
   `.step + .step::before` connector. Reusing those names painted a second, gold,
   counter-numbered circle onto step 1 of the room page - the connector rule
   out-specified mine on steps 2-4, so only the FIRST step visibly broke, which
   is exactly the kind of half-symptom that gets mistaken for a content bug.
   Generic class names are inherited by whatever else claims them; see the
   `.tier` note in CLAUDE.md s15f for the same lesson. */
.booking-steps {
  list-style: none; padding: 0; margin: clamp(24px, 4vw, 36px) 0 0;
  counter-reset: bookingstep;
  display: grid; grid-template-columns: repeat(var(--cols, 4), minmax(0, 1fr));
  gap: var(--gap-tile, 14px);
}
.booking-step {
  counter-increment: bookingstep;
  position: relative; padding: 16px 20px 0 0;
  border: 0; border-top: 2px solid var(--gold); border-radius: 0; background: none;
}
/* The number IS the content of an ordered list, so it is generated from the
   counter rather than typed into the copy - renumbering is then automatic when
   the client adds or removes a step. */
/* Set as an editorial numeral ("01") rather than a filled counter dot: the rule
   above the step already carries the gold, and a second gold disc per step put
   four heavy marks in a row where the eye wanted the words. */
.booking-step::before {
  content: counter(bookingstep, decimal-leading-zero);
  display: block; margin-bottom: 10px;
  color: var(--gold-2); font-family: var(--font-display);
  font-size: 12px; font-weight: var(--fw-medium);
  letter-spacing: 0.16em; font-variant-numeric: tabular-nums;
}
.booking-step strong { display: block; font-size: 15px; margin-bottom: 4px; }
.booking-step span { display: block; font-size: 13.5px; color: var(--ink-3); line-height: 1.55; }

/* ---------- Speakers ---------- */
.speaker-grid {
  display: grid; grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr));
  gap: var(--gap-tile, 14px);
}
/* Portrait-led, not avatar-in-a-box. These are the people the retreat is being
   sold on, and a 104px disc centred in a white card gave them less presence
   than a room photograph. The card chrome is gone and the portrait carries the
   column. */
/* The card is CAPPED, because the column count is the item count
   (balancedColumns) and a section with ONE speaker therefore gets ONE column -
   the whole 1208px container, which an aspect-ratio portrait turns into a
   ~1200x1500 photograph of one person above the fold. The cap is the width the
   portrait is designed and SERVED at, so it bites only where the grid is wider
   than that (1 and 2 speakers) and leaves 3-up and 4-up exactly as they were.
   It stays left-aligned with the heading rather than centring in the column:
   one speaker is a column of the same grid, not a hero. */
.speaker-card {
  padding: 0; text-align: left; max-width: 400px;
  border: 0; border-radius: 0; background: none;
}
.speaker-face {
  width: 100%; height: auto; aspect-ratio: 4 / 5; margin: 0 0 16px;
  border-radius: var(--r-card); overflow: hidden; border: 1px solid var(--line);
  background: var(--paper-2);
}
.speaker-face img { width: 100%; height: 100%; object-fit: cover; }
.speaker-face.is-empty {
  display: flex; align-items: center; justify-content: center;
  background: var(--gold-4); color: var(--gold-2);
  font-size: 44px; font-weight: var(--fw-medium); letter-spacing: 0.02em;
}
.speaker-card h3 { margin: 0 0 3px; font-size: 21px; letter-spacing: -0.01em; }
.speaker-title { margin: 0 0 10px; font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--gold-2); }
.speaker-bio { margin: 0; font-size: 13.5px; color: var(--ink-3); line-height: 1.6; }

/* ---------- Testimonials (the slider) ----------
   The same problem the gallery has, in words: 29 quotes laid out three-up is
   ten rows and roughly 2,900px of page, and the reader has to scroll past all
   of it. A track that scrolls sideways is ONE row whatever the client adds.

   It shares the gallery's ENHANCER (`data-slider*` in main.js) and none of its
   look. Two rules carry the design:

    - No card chrome. A box means "this is for sale" on these pages, so a quote
      is set as type: a gold quotation mark opens the column, and the columns
      are divided the way a newspaper divides them, by a hairline in the gutter.
    - That hairline is a pseudo-element positioned INSIDE the gutter, never a
      `border-left` plus padding: a border eats into the column it is on, so the
      first card would be wider than the rest and the snap positions would drift
      a few pixels further out with every frame. */
/* Tighter than it was: the cards carry their own padding now, so the old
   24-48px gutter plus two lots of card padding read as a chasm between them. */
.quote-slider { --quote-gap: clamp(14px, 1.6vw, 20px); }

/* The heading and the arrows share a row: an arrow overlaid on the track (the
   gallery's arrangement) would sit on top of somebody's sentence.

   `.quote-slider-head`, NOT `.quote-head`: the admin booking-edit quote dialog
   further down this sheet already owns that name, and being later it would win
   here - it sets 11px uppercase muted type with a 4px margin, which is right
   for a label in a dialog and would silently re-set the section heading on the
   home page. Two unrelated components must never share a selector. */
.quote-slider-head {
  display: flex; align-items: flex-end; justify-content: space-between;
  gap: 24px; flex-wrap: wrap; margin-bottom: 28px;
}
.quote-slider-head-copy { max-width: 620px; }
.quote-slider-head h2 { margin: 0; }
.quote-nav-pair { display: flex; gap: 8px; }
/* Deliberately not `.gal-nav`: that one is absolutely positioned over a
   photograph, and a shared name for two different layouts is the trap the
   gallery's own comment already records. Each disables at its own end rather
   than hiding, so the pair never jumps sideways mid-use. */
.quote-nav {
  width: 40px; height: 40px; border-radius: 50%;
  border: 1px solid var(--line-2); background: var(--card); color: var(--ink);
  font-size: 22px; line-height: 1; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  transition: background .2s ease, opacity .2s ease;
}
.quote-nav:hover { background: var(--paper-2); }

.quote-track {
  display: grid; grid-auto-flow: column;
  /* A fraction with a REMAINDER, so the next column peeks in at the right edge
     and the track says it continues even before the arrows appear. */
  grid-auto-columns: calc((100% - 2 * var(--quote-gap)) / 2.4);
  gap: var(--quote-gap);
  margin: 0; padding: 0; list-style: none;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory; scroll-behavior: smooth;
  /* The bar would draw a line under the quotes and read as part of the layout;
     the peek, the arrows and a drag all say "there is more" without it. */
  scrollbar-width: none; -ms-overflow-style: none;
}
.quote-track::-webkit-scrollbar { display: none; }
.quote-track:focus-visible { outline: 2px solid var(--focus); outline-offset: 6px; border-radius: var(--r-md); }
/* EVERY CARD IN THE ROW IS THE SAME HEIGHT, at the client's request. The cards
   are boxes now, and a row of boxes that each stop at their own last line reads
   as damage rather than as variety - which is exactly why this was fine while
   the quotes were unchromed and is not any more.
   `align-items: stretch` (the flex default, stated because this rule used to
   say flex-start) plus `height: 100%` on the card is what does it: the item is
   a grid column of the track, so stretching it gives every card the height of
   the tallest in view.
   The byline is PUSHED TO THE BOTTOM (`margin-top: auto` on the figcaption),
   at the client's request: with the cards already the same height, a byline
   that stops wherever its own quote ended put the three names at three
   different heights across one row. The slack now falls between the quote and
   the rule, so every name sits on the same line. */
.quote-item {
  position: relative; scroll-snap-align: start;
  display: flex; align-items: stretch;
}
/* The column rule that used to separate the quotes is GONE: each card now draws
   its own border, and a hairline in the gutter as well put a third vertical
   line between every pair. The gap does the separating. */
.quote-card { position: relative; }
/* THE CARD IS BACK, at the client's request, and the "What's included" boxes it
   used to be confused with are a navy-and-cream poster band now - so the thing
   that made an unchromed quote the right call no longer exists.
   It is a QUIET card: a hairline, a warm ground a shade off the page, and no
   shadow. The room cards and the payment plans keep the shadow and the gold
   edge, so a box still means "this is for sale" and this one reads as a panel. */
.quote-card {
  margin: 0; width: 100%; height: 100%;
  padding: clamp(20px, 2.1vw, 26px);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  background: var(--card);
  display: flex; flex-direction: column; gap: 14px;
}
/* A serif face for this ONE character, deliberately. Poppins draws its opening
   quote as two upright slashes, which at this size reads as a glitch rather
   than a quotation mark; the classic comma-shaped mark only exists in a serif.
   It is an ornament with no text content, so nothing but its shape matters. */
.quote-card::before {
  content: '\201C';
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 52px; line-height: 0.78;
  color: var(--gold); height: 24px;
  /* The ::before is the card's FIRST flex child, so with a star row present it
     would sit above it. `order` puts it back between the stars and the quote
     without the template having to render the ornament itself. */
  order: 1;
}
.quote-stars { order: 0; }
.quote-card blockquote { order: 2; }
.quote-card figcaption { order: 3; }
.quote-card blockquote {
  margin: 0; padding: 0; border: 0;
  font-size: clamp(16px, 1.4vw, 18px); line-height: 1.6; color: var(--ink);
}
/* A long review is set smaller, the way a pull quote and a paragraph are set
   differently in print. The row is as tall as its TALLEST card, so one
   ten-line review among 29 one-liners was setting a 320px row and leaving 170px
   of nothing under most of them - and the length is known when the page is
   rendered (`is-long` is written by the template), so this is the one thing CSS
   cannot ask for itself. It shortens the outlier without truncating a word of
   what the guest actually said. */
.quote-card.is-long blockquote { font-size: clamp(15px, 1.15vw, 16px); line-height: 1.62; }
/* No inline curly quotes: the ornament above already opens the quotation, and
   printing both gave every card two opening marks. */
.quote-card figcaption {
  display: flex; align-items: center; gap: 12px;
  padding-top: 2px;
}
.quote-face { width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex-shrink: 0; border: 1px solid var(--line); }
/* The star row. The gold is the FILL of a graphic, never text, so the brand
   value is correct here - it is --gold-2 that exists for gold AS TYPE (s18). */
.quote-stars { display: flex; gap: 2px; color: var(--gold); line-height: 0; }
/* The gold dash that opens a byline with no consented photograph. It occupies
   the portrait's place so a row of cards does not step in and out as some
   carry a face and some do not. */
.quote-dash { display: block; width: 26px; height: 2px; background: var(--gold); flex-shrink: 0; border-radius: 1px; }
/* A rule over the byline, which the unchromed version deliberately did not
   have: without a card edge the section's only line was the gutter rule, and a
   second one under every quote turned the row into a grid of cells. Inside a
   card it is the join between a sentence and the person who said it. */
/* `margin-top: auto` is what levels the names across the row - see .quote-item. */
.quote-card figcaption { border-top: 1px solid var(--line); margin-top: auto; padding-top: 14px; }
.quote-slider-lede { margin: 10px 0 0; font-size: 15.5px; color: var(--ink-3); }
/* Under the track, opposite the arrows in the head - which is where the
   client's reference puts it. */
.quote-who { display: flex; flex-direction: column; min-width: 0; }
.quote-who strong { font-size: 14px; }
.quote-who span { font-size: 12px; color: var(--ink-3); }

/* ---------- The testimonials band ---------- */
/* Cream cards on navy, in the retreat poster's own colours - the same treatment
   "What to expect" carries, at the client's request, so the page has two
   poster bands rather than one band and one grey section that borrowed its
   shapes.

   The palette is LITERAL and does not invert (s9e): navy-on-cream is a pair
   taken off a printed design, so theming one half of it is how a card ends up
   with its own text invisible. In the LIGHT theme --ink is near-black, which on
   navy is the one direction that is invisible rather than merely wrong, so
   every piece of type on this band is repainted here explicitly. */
.quotes-section { background: var(--poster-navy); }
.quotes-section h2 { color: var(--poster-cream); }
.quotes-section .eyebrow { color: var(--poster-gold); }
.quotes-section .eyebrow::before { background: var(--poster-gold); }
.quotes-section .quote-slider-lede { color: rgba(245, 239, 227, 0.72); }
/* The arrows sit on navy now, so they take the cream rather than the card
   white - a white disc on navy reads as a hole punched in the band. */
.quotes-section .quote-nav {
  background: transparent; border-color: rgba(245, 239, 227, 0.38); color: var(--poster-cream);
}
.quotes-section .quote-nav:hover { background: rgba(245, 239, 227, 0.14); }
/* "View more reviews", which was --ink: near-black on navy. */
/* The card itself. Cream ground, navy ink, a hairline in the poster gold at low
   alpha - no shadow, because a grey shadow is invisible on navy and a black one
   would ring every card. */
.quotes-section .quote-card {
  background: var(--poster-cream);
  border-color: rgba(197, 161, 91, 0.45);
}
.quotes-section .quote-card blockquote { color: var(--poster-navy); }
.quotes-section .quote-card::before { color: var(--poster-gold); }
.quotes-section .quote-stars { color: var(--poster-gold); }
.quotes-section .quote-dash { background: var(--poster-gold); }
/* The byline: navy at full strength for the name, softened for the role, and
   the rule above it tinted rather than --line (a grey hairline on cream reads
   cold against a warm ground). */
.quotes-section .quote-card figcaption { border-top-color: rgba(23, 38, 51, 0.16); }
.quotes-section .quote-who strong { color: var(--poster-navy); }
.quotes-section .quote-who span { color: var(--poster-navy-2); }
.quotes-section .quote-face { border-color: rgba(23, 38, 51, 0.18); }

@media (max-width: 1000px) {
  .quote-track { grid-auto-columns: calc((100% - var(--quote-gap)) / 1.6); }
}
@media (max-width: 640px) {
  .quote-track { grid-auto-columns: calc(100% / 1.08); }
  /* A swipe is the native gesture at this width, and the pair costs a row of
     the head that the heading needs. */
  .quote-nav-pair { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .quote-track { scroll-behavior: auto; }
}

/* ---------- Past-retreat gallery (the slider) ----------
   Named `.gal-*`, NOT `.gallery-*`, deliberately: the admin room-gallery editor
   already ships an unscoped `.gallery-grid` / `.gallery-item` pair further up
   this sheet, and the public section used to sit on those same class names and
   win only by being declared later. Two unrelated components sharing a selector
   is a bug waiting for whoever next edits the earlier one.

   The track scrolls natively, so it works with no script, by touch and by
   keyboard. The slide width is a FRACTION with a remainder (3.25 up, not 3) so
   the next frame peeks in at the right edge - with an exact fit there is
   nothing on screen saying the track continues. */
.gal-slider { position: relative; --gal-gap: 12px; }
.gal-track {
  display: grid; grid-auto-flow: column;
  grid-auto-columns: calc((100% - 2 * var(--gal-gap)) / 3.25);
  gap: var(--gal-gap);
  margin: 0; padding: 0 0 4px; list-style: none;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory; scroll-behavior: smooth;
  /* The loop wraps by jumping one whole set-width onto identical content.
     Scroll anchoring exists to PRESERVE a position across a DOM change, which
     is the one thing that jump must be allowed to change. */
  overflow-anchor: none;
  /* The bar would sit under the frames and read as part of the artwork; the
     arrows, the peek and a drag all say "there is more" without it. */
  scrollbar-width: none; -ms-overflow-style: none;
}
.gal-track::-webkit-scrollbar { display: none; }
.gal-track:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-md); }
.gal-item { scroll-snap-align: start; }
.gal-figure {
  position: relative; margin: 0; overflow: hidden; height: 100%;
  border-radius: var(--r-md); border: 1px solid var(--line);
  background: var(--paper-2); aspect-ratio: 4 / 3;
}
.gal-figure img, .gal-figure video {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: transform .4s cubic-bezier(.22, .61, .36, 1);
}
@media (hover: hover) {
  .gal-figure:hover img, .gal-figure:hover video { transform: scale(1.04); }
}
.gal-figure figcaption {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 22px 12px 10px;
  display: flex; flex-direction: column; gap: 1px;
  /* Its own scrim: the caption sits over an image we know nothing about, so it
     cannot rely on the photograph being dark where the text lands. */
  background: linear-gradient(180deg, rgba(20,20,22,0) 0%, rgba(20,20,22,0.82) 100%);
  color: var(--brand-paper); font-size: 13px;
  pointer-events: none;                 /* the whole frame opens the lightbox */
}
.gal-figure figcaption em { font-style: normal; font-size: 11px; opacity: 0.75; }

/* The VIDEO carousel (sections/videos.ejs) is the photo slider in a different
   frame, which is the whole of the difference: 9/16, because the clips are
   reels shot upright on a phone and the importer keeps that shape. A landscape
   frame over a portrait clip showed a thin horizontal band out of the middle of
   each one, which is the least interesting slice of a reel. More up than the
   photo strip because a portrait tile is narrow.
   Everything else (the native scroll, the snap, the loop, the arrows, the
   lightbox) is inherited, so there is one slider on this site and not two. */
.gal-track.vid-track { grid-auto-columns: calc((100% - 3 * var(--gal-gap)) / 4.25); }
.vid-item .gal-figure { aspect-ratio: 9 / 16; }
@media (max-width: 1000px) { .gal-track.vid-track { grid-auto-columns: calc((100% - 2 * var(--gal-gap)) / 3.25); } }
@media (max-width: 640px) { .gal-track.vid-track { grid-auto-columns: calc((100% - var(--gal-gap)) / 2.15); } }

/* The play badge. A tile clip runs muted with no controls, so without this
   nothing says it opens into something you can actually watch. Drawn as a
   triangle rather than a glyph: it must not depend on a font that may not have
   loaded, and it carries no text for a screen reader to read out. */
.gal-play {
  position: absolute; top: 10px; left: 10px;
  width: 30px; height: 30px; border-radius: 50%;
  background: rgba(20,20,22,0.55); border: 1px solid rgba(255,255,255,0.5);
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
.gal-play::after {
  content: ''; width: 0; height: 0;
  border-style: solid; border-width: 5px 0 5px 9px;
  border-color: transparent transparent transparent var(--brand-paper);
  margin-left: 2px;                     /* optically centres a triangle */
}

/* The arrows ship `hidden` and are revealed by the enhancer, because they are
   the one part of this that cannot work without a script. Each is disabled at
   its own end of the track rather than hidden, so the pair never shifts. */
.gal-nav {
  position: absolute; top: calc(50% - 22px); z-index: 2;
  width: 40px; height: 40px; border-radius: 50%;
  border: 1px solid var(--line-2); background: var(--card); color: var(--ink);
  font-size: 22px; line-height: 1; cursor: pointer;
  box-shadow: var(--shadow-md);
  display: flex; align-items: center; justify-content: center;
  transition: opacity .2s ease, background .2s ease;
}
.gal-nav:hover { background: var(--paper-2); }
.gal-prev { left: -8px; }
.gal-next { right: -8px; }
.gal-cta { margin-top: clamp(24px, 4vw, 36px); display: flex; justify-content: center; }

@media (max-width: 1000px) {
  .gal-track { grid-auto-columns: calc((100% - var(--gal-gap)) / 2.25); }
}
@media (max-width: 640px) {
  .gal-track { grid-auto-columns: calc(100% / 1.15); }
  /* At this width an arrow overlaps the frame it is meant to move; a swipe is
     the native gesture here anyway. */
  .gal-nav { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .gal-track { scroll-behavior: auto; }
  .experience-stills[data-slider-track],
  .speaker-grid[data-slider-track] { scroll-behavior: auto; }
  .gal-figure img, .gal-figure video { transition: none; }
  .gal-figure:hover img, .gal-figure:hover video { transform: none; }
}

/* ---------- Carousels: the speakers and the "Experience" stills ----------
   Both were static grids. They are now the SAME sideways-scrolling component
   the gallery and the guest quotes already use - `data-slider` for the arrows,
   `data-slider-loop` for the wrap - because three sections that behave alike
   and are implemented three times is how they end up behaving differently.

   The track scrolls NATIVELY (overflow-x + scroll-snap), so with no script at
   all each one is a swipeable strip rather than a broken section. The arrows
   are the only part that needs JavaScript, which is why they ship `hidden`.

   Every slide width is a FRACTION WITH A REMAINDER, the rule the gallery's own
   comment records: with an exact fit there is nothing on screen saying the
   track continues, and at that point the arrows are hidden anyway because the
   set does not overflow. */
.car { position: relative; --car-gap: 14px; }

/* One declaration for both tracks: they differ only in how many slides they
   show at a time.

   `grid-template-columns: none` is LOAD-BEARING. `.speaker-grid` is named in
   two shared responsive rules further down this sheet (with `.booking-steps`
   and `.booking-steps`), and an explicit template surviving here would take
   the items into fixed columns before `grid-auto-columns` ever saw them - so
   the track would lay out as a grid and simply never scroll. The attribute
   qualifier is what wins that whatever the source order, which matters because
   those rules are declared AFTER this block and style two other sections that
   must not be touched. */
.experience-stills[data-slider-track],
.speaker-grid[data-slider-track] {
  display: grid; grid-auto-flow: column; grid-template-columns: none;
  gap: var(--car-gap);
  margin: 0; padding: 0; list-style: none;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory; scroll-behavior: smooth;
  /* As on .gal-track: the wrap jumps a whole set-width onto identical content,
     and scroll anchoring would fight it. */
  overflow-anchor: none;
  scrollbar-width: none; -ms-overflow-style: none;
}
.experience-stills[data-slider-track]::-webkit-scrollbar,
.speaker-grid[data-slider-track]::-webkit-scrollbar { display: none; }
.experience-stills[data-slider-track] > *,
.speaker-grid[data-slider-track] > * { scroll-snap-align: start; }
.speaker-grid[data-slider-track]:focus-visible {
  outline: 2px solid var(--focus); outline-offset: 6px; border-radius: var(--r-md);
}

/* The stills sit in half the container beside the copy, so one at a time is
   already a large photograph and there is no case for two. The nth-child
   offset that made the old 2x2 read as a scatter has nothing left to scatter
   and would step every second frame down inside the track. */
.experience-stills[data-slider-track] { grid-auto-columns: calc(100% / 1.12); }
.experience-stills[data-slider-track] img:nth-child(even) { margin-top: 0; }

/* 2.25-up, deliberately NOT the 3.25 the gallery uses. The cast this section
   ships with is three, and three at 3.25-up fits a desktop row with room to
   spare - so the track never overflows, the arrows stay hidden, no copies are
   made and the section renders exactly as the static grid it replaced. A
   carousel that is only a carousel on a phone is not what was asked for.

   Under 3 it must NOT go: the per-view has to stay below the number of
   slides, or the loop puts the same face on screen twice at once. `.car-card`
   drops the 400px cap with it - that cap is what stops ONE speaker becoming a
   1208px photograph, and a 523px column is not that, but leaving it on would
   strand every card against the left of a column wider than itself. */
.speaker-grid[data-slider-track] {
  grid-auto-columns: calc((100% - var(--car-gap)) / 2.25);
}
.speaker-car .speaker-card { max-width: none; }

/* The arrows. The same shape the gallery's carry, over the same kind of
   content - but deliberately NOT `.gal-nav`, which is positioned against a
   frame of a fixed 4/3 aspect ratio and belongs to that section. */
.car-nav {
  position: absolute; top: calc(50% - 20px); z-index: 2;
  width: 40px; height: 40px; border-radius: 50%;
  border: 1px solid var(--line-2); background: var(--card); color: var(--ink);
  font-size: 22px; line-height: 1; cursor: pointer;
  box-shadow: var(--shadow-md);
  display: flex; align-items: center; justify-content: center;
  transition: background .2s ease;
}
.car-nav:hover { background: var(--paper-2); }
/* THE ARROWS ARE ICONS, not the `‹` / `›` characters they used to be. A glyph
   depends on a font having loaded and on the platform not substituting an emoji
   for it - the same reason the gallery's play badge and its pause control are
   drawn rather than typed - and at 22px those two characters rendered at
   noticeably different weights across platforms.

   ONE chevron is drawn and the PREVIOUS button mirrors it, so the two
   directions cannot drift apart: there is a single path to change.
   `[data-slider-prev]` is the shared enhancer's own hook, so this one rule
   covers the gallery, the video strip, the speakers, the Experience stills and
   the reviews rather than three near-copies keyed on three class names. */
.nav-ico { display: block; }
[data-slider-prev] .nav-ico { transform: scaleX(-1); }
.car-prev { left: -8px; }
.car-next { right: -8px; }

/* The speaker portraits are tall, so the arrows would sit level with a chin at
   3-up and with a caption at 2-up. Against the top third of the frame they
   land on the photograph either way. */
.speaker-car .car-nav { top: 28%; }

@media (max-width: 640px) {
  /* One image at a time, with the remainder still doing its job. */
  .experience-stills[data-slider-track],
  .speaker-grid[data-slider-track] { grid-auto-columns: calc(100% / 1.08); }
  /* An arrow at this width lands on top of the frame it is meant to move, and
     a swipe is the native gesture here - the call .gal-nav already makes. */
  .car-nav { display: none; }
}

/* ---------- The two payment plans ---------- */
/* The framing is CENTRED and the cards are not. The section reads as a panel
   of its own rather than as another left-aligned band, which is what the
   client's reference does - and a centred heading over two equal cards says
   "here is a choice" where a left-aligned one says "here is the next thing". */
.pay-head { text-align: center; max-width: 620px; margin: 0 auto; }
.pay-head .eyebrow { margin-bottom: 10px; justify-content: center; }
.pay-head h2 { margin: 0 0 10px; }
.pay-head .lede { margin: 0 auto; }
.pay-cards { margin-top: clamp(24px, 3.4vw, 38px); }
.pay-card { padding: clamp(22px, 2.4vw, 30px); }
/* The head is a ROW of three: icon, title, and the badge pushed to the end.
   It was `.spread` (space-between over two children), which put the badge hard
   against the right edge of the card with the title stranded on the left - and
   it cannot stay `.spread` now there is an icon, since three children under
   space-between spreads all three. */
.pay-card-head { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.pay-card-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 42px; height: 42px; flex-shrink: 0;
  border-radius: 50%;
  background: var(--color-gold-bg); color: var(--color-gold-fg);
}
/* Pushed to the end of the row rather than the title being allowed to grow:
   `margin-left: auto` on the badge leaves the title sitting next to its icon,
   which is the pairing that has to read as one thing. */
.pay-card-badge { margin-left: auto; flex-shrink: 0; }
/* The plan that carries the discount is edged in gold. A border, never a gold
   FILL with text on it: the brand gold under white is 1.9:1 (s18). */
.pay-card-lead { border-color: var(--gold); box-shadow: var(--shadow-sm); }
.pay-card-head { margin-bottom: 14px; align-items: center; }
.pay-card-head h3 { margin: 0; font-size: 22px; letter-spacing: -0.01em; }
.pay-card-desc { font-size: 14.5px; color: var(--ink-3); margin: 0 0 20px; line-height: 1.55; }
.pay-card-points { font-size: 13.5px; }
/* Both plans lead to the same place: pick a room. The card is clickable via
   data-href and this is the anchor that makes it work without JavaScript and
   for a keyboard - so it is a real link and not a decorative arrow.
   `margin-top: auto` pins it to the bottom of whichever card is shorter, so
   the two calls to action line up across the pair. */
.pay-card { display: flex; flex-direction: column; }
.pay-card-cta {
  margin-top: auto; padding-top: 18px;
  font-size: 14px; font-weight: var(--fw-medium);
  color: var(--ink); text-decoration: none;
  display: inline-flex; align-items: center; gap: 7px; align-self: flex-start;
}
.pay-card-cta span { transition: transform .18s ease; }
.pay-card-cta:hover { text-decoration: underline; text-underline-offset: 5px; }
/* The whole card lifts, so the affordance is the CARD rather than the link
   alone - transform and shadow only, because this sits above the testimonials
   and the gallery and a hover that reflows would shift the page under the
   pointer (the same rule the "what to expect" boxes follow). Inside
   (hover: hover) so a tap on a touch screen cannot leave a card stuck lifted. */
@media (hover: hover) and (pointer: fine) {
  .pay-card-go { transition: transform .18s ease, box-shadow .18s ease; }
  .pay-card-go:hover { transform: translateY(-3px); box-shadow: var(--shadow-sm); }
  .pay-card-go:hover .pay-card-cta span { transform: translateX(3px); }
}

/* ---------- The closing band ---------- */
/* Literal darks, NOT var(--ink). --ink is the TEXT token and inverts with the
   theme, so a band painted with it turned pale grey in dark mode while its own
   copy stayed near-white: "Rooms are filling fast." at about 1.3:1. The hero
   solves the same problem the same way, and reusing its material also closes
   the page on the surface it opened with. */
.home-cta-band {
  background:
    radial-gradient(90% 130% at 86% 50%, rgba(193,171,117,0.15) 0%, transparent 58%),
    linear-gradient(160deg, #232327 0%, #17171A 100%);
  color: var(--brand-paper);
}
/* With a photograph uploaded the band becomes that picture. The scrim is a
   gradient on the SECTION rather than a second element, so there is nothing to
   keep in registration with the image, and it runs from opaque at the left
   (where the copy is) to nearly clear at the right (where the picture is worth
   seeing) - the same reading the reference has.

   `isolation: isolate` is what keeps the scrim above the <img> and below the
   copy without either needing a z-index of its own. */
.home-cta-band.has-photo { position: relative; isolation: isolate; overflow: hidden; background: #17171A; }
.home-cta-photo {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; z-index: -2;
}
.home-cta-band.has-photo::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background:
    linear-gradient(90deg, rgba(18,18,20,0.94) 0%, rgba(18,18,20,0.84) 38%, rgba(18,18,20,0.28) 78%, rgba(18,18,20,0.12) 100%);
}
/* Under 760px the picture has no free half to keep clear, so the scrim goes
   vertical and covers the whole frame - a horizontal fade at phone width leaves
   the last line of the paragraph over open water. */
@media (max-width: 760px) {
  .home-cta-band.has-photo::before {
    background: linear-gradient(180deg, rgba(18,18,20,0.86) 0%, rgba(18,18,20,0.92) 100%);
  }
}
/* The copy column and its button, stacked. The button used to sit opposite the
   copy (space-between), which is correct on a flat band and wrong on a
   photograph: a control on the far right lands on whatever happens to be in
   that half of the composition at this width. */
/* THE CAP BELONGS ON THE COPY, NOT ON THE CONTAINER. `.home-cta` IS the
   `.container`, which is `max-width: var(--maxw); margin: 0 auto` - so capping
   it at 620px left a 620px box still centred by that auto margin, and the
   closing pitch started a third of the way across the page while every other
   section began at the gutter. Capping the inner column instead keeps the
   container full width, so the copy aligns to the page like everything else. */
.home-cta { display: block; }
.home-cta > div { max-width: 620px; }
.home-cta-btn { margin-top: clamp(20px, 2.6vw, 28px); }
.home-cta h2 { color: var(--brand-paper); margin: 12px 0 10px; }
.home-cta .eyebrow { color: rgba(255,255,255,0.72); }
.home-cta-sub { margin: 0; max-width: 520px; font-size: 16px; line-height: 1.55; color: rgba(255,255,255,0.72); }

/* ---------- Home room cards ---------- */
/* Deliberately `home-room-*`, NOT the `room-card-*` names the /rooms listing
   card uses. They are two different components - the home card is a price and
   a button on ONE row, the listing card is a sleeps/price row with the "View &
   book" link on its OWN row underneath - and while they shared names this
   block, being later in the sheet, won on BOTH pages: the listing card lost
   the flex row that put its price to the right of "Sleeps", lost the rule
   above it (the border-top came back at the width of the collapsed meta, half
   the card), and had its call to action pulled up alongside the price. Do not
   re-merge them; a shared name here is a shared name for two different
   layouts. */
.home-room-meta { margin: 0 0 14px; font-size: 12.5px; color: var(--ink-3); }
.home-room-foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; margin-top: auto; flex-wrap: wrap;
}
.home-room-price { font-size: 18px; font-weight: var(--fw-medium); white-space: nowrap; }
.home-room-price span { font-size: 11px; color: var(--ink-3); font-weight: 400; }
/* Sold out is a <span>, not a link - there is nothing to click through to. It
   still has to look unavailable rather than merely quiet. */
.btn.is-disabled { opacity: .55; cursor: not-allowed; pointer-events: none; }

/* Narrow viewports: the item count stops being the constraint and the width
   takes over, so --cols is overridden rather than respected. Two breakpoints,
   because a 4-up grid of these cards is unreadable below roughly 1000px and a
   2-up one below roughly 620px. */
@media (max-width: 1000px) {
  .booking-steps, .speaker-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 620px) {
  .booking-steps, .speaker-grid {
    grid-template-columns: 1fr;
  }
}

/* ============================================================
   PROGRAMME PAGE (/programme)

   A day-by-day timeline rather than a stack of panels. The
   page's job is to be SCANNED - "what time is that" - so the
   times form a hard left column the eye can run down, and each
   day carries a numbered marker on a continuous rail.
   ============================================================ */

/* The rail is drawn by the LIST, not by each card, so it stays continuous
   through the gaps between days. `--rail` is the marker's centre line. */
.prog-timeline {
  /* ONE source for the geometry. --rail-x is the rail's centre line, measured
     from the list's left edge; --marker is the circle's diameter; --gutter is
     the space the whole apparatus occupies. Everything below is derived, so
     nudging the rail cannot leave the markers behind. */
  --gutter: 56px;
  --rail-x: 23px;
  --marker: 44px;
  list-style: none; margin: 0; padding: 0 0 0 var(--gutter);
  position: relative;
}
.prog-timeline::before {
  content: ""; position: absolute;
  left: calc(var(--rail-x) - 1px); top: 8px; bottom: 8px; width: 2px;
  /* Fades out at both ends so the rail does not stop dead against nothing. */
  background: linear-gradient(180deg, transparent, var(--line-2) 6%, var(--line-2) 94%, transparent);
}
.prog-day { position: relative; margin-bottom: var(--gap-tile, 14px); }
.prog-day-marker {
  /* Centred on the rail. The list item's box starts at --gutter, so the offset
     is measured back from there. */
  position: absolute;
  left: calc(var(--rail-x) - (var(--marker) / 2) - var(--gutter));
  top: 18px;
  width: var(--marker); height: var(--marker);
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%;
  background: var(--gold); color: var(--on-gold);
  font-size: 13px; font-weight: var(--fw-medium);
  font-variant-numeric: tabular-nums;
  /* A ring in the PAGE colour, so the rail appears to pass behind the marker
     rather than through it. */
  box-shadow: 0 0 0 6px var(--paper);
}
.prog-day-body {
  padding: 20px 22px;
  border: 1px solid var(--line); border-radius: var(--r-card);
  background: var(--card);
  transition: border-color .2s ease, transform .2s cubic-bezier(.22,.61,.36,1);
}
@media (hover: hover) {
  .prog-day:hover .prog-day-body { border-color: var(--line-2); transform: translateX(2px); }
}
.prog-day-body h3 { margin: 0 0 14px; font-size: 20px; }

/* The schedule itself. `grid-template-columns: auto 1fr` lets the time column
   size to the WIDEST time and then hold that width for every row - which is
   what makes the descriptions align. */
.prog-slots { list-style: none; margin: 0; padding: 0; display: grid; gap: 0; }
.prog-slots li {
  display: grid; grid-template-columns: 72px 1fr;
  gap: 14px; align-items: baseline;
  padding: 9px 0;
  border-top: 1px solid var(--line);
}
.prog-slots li:first-child { border-top: 0; padding-top: 0; }
.prog-time {
  font-variant-numeric: tabular-nums;
  font-size: 13px; font-weight: var(--fw-medium);
  color: var(--gold-2); letter-spacing: 0.01em;
  white-space: nowrap;
}
.prog-what { font-size: 14.5px; line-height: 1.55; color: var(--ink-2); }
.prog-prose { margin: 12px 0 0; font-size: 14px; line-height: 1.6; color: var(--ink-3); }
.prog-slots + .prog-prose { margin-top: 16px; }

/* A paragraph between days - scene-setting, not a scheduled thing. It sits on
   the rail without a marker so it reads as an aside. */
.prog-note { position: relative; margin: 0 0 var(--gap-tile, 14px); }
.prog-note p {
  margin: 0; padding: 4px 0 4px 2px;
  font-size: 15px; line-height: 1.65; color: var(--ink-3); max-width: 640px;
}

/* Below the rail's breathing room, the markers move inline above each card:
   a 44px circle plus a rail eats a third of a phone's width. */
@media (max-width: 720px) {
  .prog-timeline { padding-left: 0; }
  .prog-timeline::before { display: none; }
  .prog-day-marker {
    position: static; margin-bottom: 10px;
    width: 36px; height: 36px; box-shadow: none;
  }
  .prog-day-body { padding: 16px; }
  .prog-slots li { grid-template-columns: 60px 1fr; gap: 10px; }
}

/* The programme video, in the page's own frame rather than an inline style. */
.prog-video {
  position: relative; aspect-ratio: 16 / 9; overflow: hidden;
  border-radius: var(--r-card); border: 1px solid var(--line);
  background: #0F0F11;
}
.prog-video > iframe, .prog-video > video {
  position: absolute; top: 0; left: 0; width: 100%; height: 100%;
  border: 0; object-fit: cover; display: block;
}
/* The controls over the clip: back to the start, skip to the end, sound on /
   off, full screen. The ROW owns the position; each disc owns only its own
   chrome, so a button added or dropped needs no new coordinates. It was one
   absolutely-positioned button until September 2026, when the client asked for
   the other three.

   Every icon is DRAWN rather than a glyph, for the same reason as .gal-play:
   a font that fails to load takes a glyph with it, and the tofu box lands in
   the middle of somebody's video. Where a button has two states both icons are
   in it and a class picks which shows, so the swap is a class flip and never a
   re-render. */
.vid-controls {
  position: absolute; right: 12px; bottom: 12px; z-index: 2;
  display: flex; gap: 8px;
}
.vid-btn {
  width: 40px; height: 40px; border-radius: 50%; padding: 0;
  background: rgba(20,20,22,0.6); color: var(--brand-paper);
  border: 1px solid rgba(255,255,255,0.5); cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  transition: background .2s ease;
}
.vid-btn:hover { background: rgba(20,20,22,0.8); }
.vid-btn:focus-visible { outline: 2px solid var(--brand-paper); outline-offset: 2px; }
/* `hidden` is a user-agent rule and `display:flex` above would un-hide a
   button main.js hid because the browser has no full-screen API at all
   (CLAUDE.md s14). Restate it here rather than styling around it. */
.vid-btn[hidden] { display: none; }

.vid-mute .vid-mute-on { display: none; }
.vid-mute.is-muted .vid-mute-on { display: none; }
.vid-mute.is-muted .vid-mute-off { display: block; }
.vid-mute:not(.is-muted) .vid-mute-on { display: block; }
.vid-mute:not(.is-muted) .vid-mute-off { display: none; }

.vid-full .vid-full-out { display: none; }
.vid-full.is-on .vid-full-in { display: none; }
.vid-full.is-on .vid-full-out { display: block; }

/* FULL SCREEN IS THE ONE PLACE `object-fit: cover` IS WRONG. The clip is
   cropped to a 16/9 card in the page on purpose; blown up to the whole screen
   that same crop cuts the top and bottom off a viewer who asked to see more of
   it, not less. The box gives up its card geometry with it.

   Written twice, unprefixed and -webkit-, and NOT as one selector list: an
   unrecognised selector invalidates the whole rule, so a browser that knows
   only one of the two spellings would drop both. */
.prog-video:fullscreen { aspect-ratio: auto; width: 100%; height: 100%; border: 0; border-radius: 0; }
.prog-video:-webkit-full-screen { aspect-ratio: auto; width: 100%; height: 100%; border: 0; border-radius: 0; }
.prog-video:fullscreen > video { object-fit: contain; }
.prog-video:-webkit-full-screen > video { object-fit: contain; }

/* The non-full-bleed programme hero (no banner uploaded) does not need the
   landing page's 92vh. */
.hero-dark-inner-compact { min-height: 340px; }

/* ---------- Full-bleed hero: lose the rules ---------------------------------
   NO fade-out band at the bottom. One was tried: a gradient from transparent to
   the page colour, meant to dissolve the photograph into the content below. It
   read as a grey smear across the bottom fifth of the hero, for a reason worth
   recording - a gradient from `rgba(20,20,22,0)` to a light `--paper`
   interpolates through half-opaque BLACK at its midpoint, so over a photograph
   it produces a dirty band rather than a fade. (Matching the start colour to
   the end colour at zero alpha fixes that particular artefact, but the effect
   was not wanted either way.) The hero ends at its own edge.
*/

/* The reference carries no rules across the hero: no hairline under the nav, no
   divider above the stats, no separators between them. On a photograph they read
   as chrome laid over the image rather than as structure. Scoped to the
   full-bleed variant so the ring hero, which needs them to hold its shape,
   keeps them. */
body.has-hero-full .nav,
body.has-hero-full .nav.scrolled { border-bottom-color: transparent; }
.hero-full .hero-stats { border-top: 0; }
.hero-full .hero-stats > div { border-left: 0; }

/* ---------- Admin -> Booking: the "save these changes?" quote ---------------
   Rendered into LUL.dialog's `content` slot by public/js/booking-edit-quote.js,
   so it inherits the scrim, the focus trap and the champagne head mark - there
   is deliberately no second modal implementation here.

   The money row is the whole point of the panel, so it is the only thing that
   changes weight or colour: --ok / --bad are the TEXT-safe values of those hues
   (s18), never the fill ones. Everything else is set as plain type.
*/
.quote-panel { display: flex; flex-direction: column; gap: 16px; font-size: 14px; }
.quote-block { display: flex; flex-direction: column; gap: 4px; }
.quote-head {
  margin: 0 0 4px; font-size: 11px; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-3); font-weight: var(--fw-medium);
}
.quote-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 16px;
  padding: 4px 0; border-bottom: 1px solid var(--line);
  color: var(--ink-2); line-height: 1.5;
}
.quote-row:last-child { border-bottom: 0; }
.quote-label { flex: 1 1 auto; }
.quote-value { flex: 0 0 auto; text-align: right; font-variant-numeric: tabular-nums; }
.quote-row.is-strong { color: var(--ink); font-weight: var(--fw-medium); }
.quote-row.is-up .quote-value { color: var(--bad); }
.quote-row.is-down .quote-value { color: var(--ok); }
.quote-note { margin: 0; color: var(--ink-3); font-size: 13px; line-height: 1.6; }
.quote-warn { margin: 0; color: var(--warn-2); font-size: 13px; line-height: 1.6; }

/* The re-spread plan. A column of dates and amounts only reads as a schedule if
   the amounts line up, hence tabular numerals and a fixed right edge. Capped
   and scrolled: a booking taken a year out re-spreads across twelve months, and
   twelve rows would push the Save button off a laptop screen. */
.quote-plan {
  list-style: none; margin: 6px 0 0; padding: 0;
  max-height: 168px; overflow-y: auto;
  border: 1px solid var(--line); border-radius: var(--r-input);
}
.quote-plan li {
  display: flex; justify-content: space-between; gap: 16px;
  padding: 6px 10px; border-bottom: 1px solid var(--line);
  color: var(--ink-2);
}
.quote-plan li:last-child { border-bottom: 0; }
.quote-plan-amt { font-variant-numeric: tabular-nums; color: var(--ink); }
.quote-rooms {
  width: 100%; padding: 9px 11px;
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  font: inherit; background: var(--paper); color: var(--ink);
}

/* ---------- Pre-upload photo editor (public/js/image-editor.js) ----------
   Crop / rotate / resize a photo before it is uploaded. It exists because every
   surface that shows an uploaded photo gives it a fixed shape and object-fit:
   cover - the gallery slider is 4/3, a speaker 4/5, a testimonial face a circle
   - so the browser centre-crops whatever arrives and nobody is told. The crop
   box opens on the shape the site will actually use, so the framing decision is
   made by the person who chose the picture.

   It lives inside LUL.dialog, which appends to <body>. Every rule that dresses
   an admin form control is scoped `:where(.admin-main)`, so a select or a number
   field in here falls back to the UA's own chrome on a dark panel - the trap the
   Manage modal already documents. The field baseline is therefore restated on
   .imed itself, keyed on this component rather than on where it currently sits. */
.lul-modal.imed-modal { max-width: 720px; }
.imed { text-align: left; }
/* The padding is the room the CROP HANDLES need, not breathing space: they are
   16px squares hung 8px OUTSIDE the crop box, so with the crop pushed against
   an edge of the photo - which is exactly where a full-width crop starts - a
   padding under 8px puts half of each corner handle under `overflow: hidden`,
   where it is neither visible nor clickable. It is read back by the script
   (availSpace) rather than restated there, so the two cannot disagree. */
.imed-stage {
  display: flex; align-items: center; justify-content: center;
  height: min(46vh, 380px); padding: 10px;
  background: repeating-conic-gradient(var(--paper-3) 0% 25%, var(--paper-2) 0% 50%) 50% / 18px 18px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  overflow: hidden;
}
/* Never let flex shrink the frame. The script sizes it from `scale` and paints
   the crop box from the same number, so a frame the layout engine quietly
   narrowed would leave the box sitting over a different part of the photograph
   than the one it cuts. If anything ever does overflow, the stage centres and
   clips it - the crop stays registered with the photo, which is the failure
   worth having. */
.imed-frame { position: relative; line-height: 0; flex: 0 0 auto; }
.imed-canvas { display: block; max-width: 100%; }
/* The dimmed surround is the crop box's own shadow rather than four elements:
   one box to move and nothing that can fall out of register with it. */
.imed-crop {
  position: absolute; box-sizing: border-box;
  border: 1px solid var(--brand-paper);
  box-shadow: 0 0 0 9999px rgba(18, 18, 20, 0.58);
  cursor: move; touch-action: none;
}
.imed-crop:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
/* Thirds, so a horizon can be levelled against something. Decoration only. */
.imed-crop::before, .imed-crop::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background-repeat: no-repeat;
}
.imed-crop::before {
  background-image: linear-gradient(to right, rgba(255,255,255,.32) 1px, transparent 1px);
  background-size: 33.33% 100%;
  background-position: 33.33% 0, 66.66% 0;
}
.imed-crop::after {
  background-image: linear-gradient(to bottom, rgba(255,255,255,.32) 1px, transparent 1px);
  background-size: 100% 33.33%;
}
.imed-h {
  position: absolute; width: 16px; height: 16px;
  background: var(--brand-paper); border: 1px solid var(--ink-3);
  border-radius: 3px; touch-action: none;
}
.imed-h-nw { top: -8px; left: -8px; cursor: nwse-resize; }
.imed-h-ne { top: -8px; right: -8px; cursor: nesw-resize; }
.imed-h-sw { bottom: -8px; left: -8px; cursor: nesw-resize; }
.imed-h-se { bottom: -8px; right: -8px; cursor: nwse-resize; }

.imed-bar {
  display: flex; flex-wrap: wrap; align-items: flex-end; gap: 12px 18px;
  margin-top: 14px;
}
.imed-group { display: flex; gap: 8px; flex-wrap: wrap; }
.imed-field { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--ink-2); }
.imed-unit { color: var(--ink-3); font-size: 12px; }
.imed .imed-select,
.imed .imed-size {
  padding: 7px 9px; min-height: 0;
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  font: inherit; color: var(--ink); background: var(--card); max-width: 100%;
}
.imed .imed-size { width: 96px; }
.imed-note { margin: 12px 0 0; font-size: 12px; color: var(--ink-3); }

@media (max-width: 560px) {
  .imed-stage { height: min(40vh, 300px); }
  .imed-bar { gap: 10px 12px; }
  .imed-field { width: 100%; justify-content: space-between; }
}

/* A dialog whose content can outgrow the window. .lul-modal is itself the
   scroll container, which is right for a sentence and two buttons and wrong for
   a panel: on a laptop-height window the actions row scrolled out of sight, so
   the primary button was off screen with nothing saying it was there. The box
   becomes a flex column and the BODY takes the overflow, so the title and the
   buttons stay put however long the content is. `min-height: 0` is what lets a
   flex item shrink below its content - without it the body refuses to scroll
   and nothing changes. Opted into per dialog (booking-edit-quote.js adds the
   class), never with :has(): Firefox ESR 115 is still in the field. */
.lul-modal.is-tall { display: flex; flex-direction: column; }
.lul-modal.is-tall .lul-modal-head,
.lul-modal.is-tall .lul-modal-actions { flex: 0 0 auto; }
.lul-modal.is-tall .lul-modal-body {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  /* The scrolled edge needs air, or the last row sits flush against the
     buttons and reads as part of them. */
  padding-bottom: 4px;
}

/* The pinned price strip, between the dialog title and the scrolling detail.
   It is a flex sibling of the head and the body inside .lul-modal.is-tall, so
   it holds its place without `position: sticky` - and therefore without
   needing an opaque background to hide content passing underneath, which is
   the usual way a sticky bar inside a scroller goes wrong.

   The old total is struck through rather than merely dimmed: dimming a number
   next to another number reads as "less important", not as "no longer true",
   and these two are a before and an after. */
.quote-summary {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px;
  margin: 2px 0 12px; padding: 10px 12px;
  background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--r-input);
  font-variant-numeric: tabular-nums;
}
.quote-sum-was { color: var(--ink-3); text-decoration: line-through; font-size: 15px; }
.quote-sum-arrow { color: var(--ink-3); }
.quote-sum-now {
  color: var(--ink); font-size: 21px; font-weight: var(--fw-medium);
  font-family: var(--font-display); letter-spacing: -0.01em;
}
.quote-sum-tag {
  margin-left: auto; padding: 2px 8px; border-radius: 999px;
  font-size: 12px; font-weight: var(--fw-medium);
  border: 1px solid var(--line-2); color: var(--ink-2);
}
/* --bad / --ok are the TEXT-safe values of those hues, and the pill is edged in
   them rather than filled with them: a filled pill needs an --on-* pair, and
   this one sits on a surface that inverts with the theme. */
.quote-sum-tag.is-up { color: var(--bad); border-color: var(--bad); }
.quote-sum-tag.is-down { color: var(--ok); border-color: var(--ok); }
.quote-summary.is-unpriced { border-color: var(--warn-2); }
.quote-summary.is-unpriced .quote-sum-tag { color: var(--warn-2); border-color: var(--warn-2); }

/* The review under a wired file input: the chosen photo in the box the SITE
   will put it in. A file input says "wide.jpg" and nothing else, and on the
   "Add a speaker" / "Add to the gallery" forms there is no existing-photo tile
   to compare against either, so until the page was saved and reloaded there was
   nothing anywhere showing what had been picked. The box carries the input's
   own declared shape (written by the script) with object-fit: cover, so it
   crops exactly as the site does. */
.imed-review {
  display: flex; align-items: center; gap: 12px; margin-top: 10px;
  padding: 10px; border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--paper-2);
}
.imed-review-box {
  width: 108px; flex-shrink: 0; overflow: hidden;
  border-radius: var(--r-sm, 6px); border: 1px solid var(--line);
  background: var(--paper-3); line-height: 0;
}
/* No declared shape means the site does not crop it, so the box shows the whole
   photograph rather than inventing a ratio to crop it by. */
.imed-review-box.is-free { max-height: 96px; display: flex; align-items: center; }
.imed-review-box.is-free .imed-review-img { height: auto; object-fit: contain; }
.imed-review-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.imed-review-meta { display: flex; flex-direction: column; gap: 3px; min-width: 0; font-size: 12px; }
.imed-review-meta strong { font-size: 12px; }
.imed-review-line { color: var(--ink-3); }
.imed-review-meta .btn { align-self: flex-start; margin-top: 4px; }

/* ---------- /checkout/rooms: who sleeps in which room ---------- */
/* A ROOMING LIST, not a matrix. The first cut printed every guest inside every
   room, so ten guests over four rooms was forty chips and the reader had to
   work out which three of them were real. Each person now appears ONCE, in the
   room they are in, and the little numbers on their row are that person's radio
   group - so the browser still guarantees nobody is in two rooms, and the row
   itself drags between rooms. */
.ra-board { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
/* `minmax(0, 1fr)`, never a bare `1fr` - the two-column rule above already
   says so and the one-column one did not. A grid item's implicit `min-width` is
   `auto`, which resolves to MIN-CONTENT, and since this step moved behind the
   guest details a row carries a real name set `white-space: nowrap`. So one
   long name became the column's minimum width and the whole board pushed the
   page sideways at 390px instead of the name ellipsising, which is what the
   row was built to do. */
@media (max-width: 760px) { .ra-board { grid-template-columns: minmax(0, 1fr); } }

.ra-room {
  border: 1px solid var(--line); border-radius: var(--r-card);
  background: var(--card); padding: 12px 14px 10px;
}
.ra-room.is-unplaced { border-color: var(--warn); margin-bottom: 12px; }
.ra-room-head {
  display: flex; align-items: baseline; justify-content: space-between; gap: 10px;
  margin-bottom: 8px;
}
.ra-room-head h3 { margin: 0; font-size: 15px; }
.ra-tally { font-size: 12px; color: var(--ink-3); white-space: nowrap; }
.ra-note { margin: 0 0 8px; font-size: 12px; }
.ra-empty { margin: 2px 0 0; font-size: 12px; color: var(--ink-3); }

.ra-people { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.ra-person {
  display: flex; align-items: center; gap: 9px;
  padding: 5px 6px; border-radius: var(--r-input);
  cursor: grab;
}
.ra-person:hover { background: var(--paper-2); }
.ra-person .avatar-circle { width: 26px; height: 26px; font-size: 10px; }
/* The name leads, the fact behind it follows quietly. The step sits after the
   guest details precisely so this row can carry a real name, and a child's age
   still has to be on screen - it is what prices the room and what decides
   whether the room can take them. The NAME is the part that truncates: a row
   reading "Alexandra Fitzwil... Age 7" still says which child it is, where
   dropping the age to keep the surname says nothing. */
.ra-person-who {
  flex: 1; min-width: 0;
  display: flex; align-items: baseline; gap: 7px;
}
.ra-person-name {
  min-width: 0; font-size: 13px; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ra-person-detail {
  flex-shrink: 0; font-size: 11px; color: var(--ink-3); white-space: nowrap;
}

/* The move control: this person's room, and every room they could be moved to.
   Small and quiet, because it repeats on every row. */
.ra-move { display: inline-flex; gap: 3px; flex-shrink: 0; }
.ra-move-opt { position: relative; display: inline-flex; cursor: pointer; }

/* ATTRIBUTE-QUALIFIED ON PURPOSE - the trap `.segmented-input` documents. The
   section-9 `input[type="radio"]` base rule is (0,1,1) and sits later in the
   sheet, so a bare `.ra-radio` (0,1,0) loses on both counts and the "hidden"
   input comes back as an 18px box in flow, shunting every row sideways while
   staying invisible. It keeps a real 1px box and is never `display:none`, so it
   stays focusable and constraint-validatable. */
input[type="radio"].ra-radio {
  position: absolute; opacity: 0; margin: 0; min-height: 0;
  width: 1px; height: 1px; pointer-events: none;
}

.ra-move-face {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 6px;
  border: 1px solid var(--line); background: transparent;
  font-size: 11px; font-variant-numeric: tabular-nums; color: var(--ink-3);
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
/* Sibling selector, NOT `:has()`. Firefox ESR 115 is still in the field without
   it, and there no room would ever look chosen - which on this page reads as
   "nobody is anywhere". */
.ra-radio:checked ~ .ra-move-face {
  background: var(--gold); border-color: var(--gold); color: var(--on-gold);
}
.ra-radio:focus-visible ~ .ra-move-face { box-shadow: var(--shadow-focus); border-color: var(--focus); }
.ra-move-opt:hover .ra-move-face { border-color: var(--line-2); color: var(--ink); }
.ra-radio:checked ~ .ra-move-face:hover { color: var(--on-gold); }

/* Drag is enhancement only - the radios are what a keyboard and a touch screen
   use, and they are the real control. */
.ra-person.is-dragging { opacity: .4; }
.ra-room.is-drop { border-color: var(--gold); background: var(--paper-2); }

/* ─── The room picker (views/partials/room-picker.ejs) ──────────────────────
   Choose your own set of rooms. It replaced the connecting-pair room type
   (migrations 109 and 124), so this is the surface a party too big for one
   room now lands on.

   THE SHORTFALL MESSAGE USES THE `--color-warn-*` TRIO, not `--warn-2`, and
   that is the trap this file already documents twice. The
   `@media (prefers-color-scheme: dark) html:not([data-theme])` fallback
   redefines only a SUBSET of the palette: `--warn-2` IS in it (light amber),
   `--color-warn-bg` is NOT (so it keeps its near-white light value). Pairing
   the two would put light amber on near-white in the JS-off auto-dark path -
   the exact fault the `--warn-2` comment in that block was written about,
   pointing the other way. The `--color-warn-fg` / `-bg` / `-line` trio is
   consistent in every theme path because none of the three is overridden
   there, which is why `.callout-warn` is built from it. */
.rmx {
  margin: var(--s-24) 0;
  border: 1.5px solid var(--line);
  border-radius: var(--r-card);
  background: var(--card);
  overflow: hidden;
}
.rmx-head { padding: 18px 20px 0; }
.rmx-title { margin: 0 0 6px; font-size: var(--text-subheading); font-weight: var(--fw-medium); }
.rmx-lede { margin: 0; color: var(--ink-3); font-size: 14px; max-width: 62ch; }

.rmx-form { padding: 16px 20px 0; }
.rmx-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }

.rmx-room {
  display: grid; grid-template-columns: 1fr; gap: 0;
  border: 1px solid var(--line);
  border-radius: var(--r-input);
  background: var(--paper);
  overflow: hidden;
  transition: border-color .15s ease, background .15s ease;
}
/* A chosen room is edged in champagne, never FILLED with it: gold is a surface
   this palette only ever puts dark ink on (s18), and the row carries body copy
   at several weights. */
.rmx-room.is-chosen { border-color: var(--gold); background: var(--card); box-shadow: inset 3px 0 0 var(--gold); }
@media (min-width: 700px) {
  .rmx-room { grid-template-columns: 132px 1fr 190px; align-items: stretch; }
}
.rmx-img { position: relative; aspect-ratio: 3/2; background: var(--paper-3); overflow: hidden; display: block; }
.rmx-img img { width: 100%; height: 100%; object-fit: cover; }
@media (min-width: 700px) { .rmx-img { aspect-ratio: auto; min-height: 100%; } }

.rmx-body { padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.rmx-name { margin: 0; font-size: 15px; font-weight: var(--fw-medium); }
.rmx-name a { color: inherit; text-decoration: none; }
.rmx-name a:hover { text-decoration: underline; }
.rmx-pitch { margin: 0; color: var(--ink-3); font-size: 13px; }

.rmx-pick {
  padding: 12px 14px; display: flex; flex-direction: column; gap: 6px;
  align-items: flex-start; justify-content: center;
  border-top: 1px solid var(--line);
}
@media (min-width: 700px) {
  .rmx-pick { border-top: 0; border-left: 1px solid var(--line); align-items: flex-end; text-align: right; }
}
.rmx-from { margin: 0; font-size: 12px; color: var(--ink-3); }
.rmx-from strong { font-size: 15px; color: var(--ink); font-weight: var(--fw-medium); }
/* The same slot, once the room is in the mix and carries a real price. It sets
   the figure a step larger than the "from" floor it replaces, because it is now
   a number the guest is being asked to agree to rather than a browsing hint. */
.rmx-price { margin: 0; font-size: 12px; color: var(--ink-3); }
.rmx-price strong { font-size: 17px; color: var(--ink); font-weight: var(--fw-medium); }
.rmx-price span { display: block; }
.rmx-qty { display: flex; align-items: center; gap: 8px; }
.rmx-qty-label { font-size: 11px; letter-spacing: .08em; text-transform: uppercase; color: var(--ink-3); }
.rmx-qty select {
  font: inherit; padding: 7px 10px; min-width: 70px;
  border: 1px solid var(--line-input); border-radius: var(--r-input);
  background: var(--card); color: var(--ink);
}
/* Scarcity, as a CHIP rather than red text.
   `--bad` is NOT redefined in the JS-off auto-dark fallback (that block
   redefines only a subset of the palette), so red-on-near-black measures
   3.45:1 there - under AA, and invisible to the hermetic suite. The
   `--color-err-*` trio is self-consistent in every theme path because none of
   the three is overridden in that block, which is why `.callout-err` is built
   from it and why `.rmx-problems` above uses its amber twin. Measured in
   headless Chrome: 5.9:1 light, 6.7:1 dark, 5.9:1 in the auto-dark fallback. */
.rmx-left {
  margin: 0; font-size: 11px; font-weight: var(--fw-medium);
  padding: 2px 7px; border-radius: 999px;
  background: var(--color-err-bg); color: var(--color-err-fg);
  border: 1px solid var(--color-err-line);
}
.rmx-soldout { margin: 0; font-size: 13px; color: var(--ink-3); }

.rmx-update { margin: 14px 0 0; }

.rmx-summary {
  margin-top: 16px;
  padding: 16px 20px;
  border-top: 1px solid var(--line);
  background: var(--paper-2);
  display: grid; gap: 12px;
}
@media (min-width: 760px) {
  .rmx-summary { grid-template-columns: 1fr 260px; align-items: center; }
  .rmx-summary .rmx-problems, .rmx-summary .rmx-notes { grid-column: 1 / -1; }
}
.rmx-tally-main { margin: 0 0 4px; font-size: 14px; color: var(--ink-2); }
.rmx-total { margin: 0; font-size: 22px; font-weight: var(--fw-medium); }
.rmx-total small { display: block; font-size: 12px; font-weight: 400; color: var(--ink-3); }
.rmx-from-word { font-size: 14px; color: var(--ink-3); font-weight: 400; }
.rmx-instalment { margin: 6px 0 0; font-size: 13px; color: var(--ink-3); }
.rmx-note { margin: 4px 0 0; font-size: 12px; color: var(--ink-3); }

/* The shortfall message. Amber rather than red: the guest has not made a
   mistake, they are part-way through a decision, and a red alarm over a
   half-built selection reads as a broken page. */
.rmx-problems, .rmx-notes { list-style: none; margin: 0; padding: 10px 12px; border-radius: var(--r-input); font-size: 13px; }
.rmx-problems {
  background: var(--color-warn-bg); color: var(--color-warn-fg);
  border: 1px solid var(--color-warn-line); border-left: 3px solid var(--warn);
}
.rmx-notes { background: var(--paper-3); color: var(--ink-2); }
.rmx-problems li + li, .rmx-notes li + li { margin-top: 6px; }

.rmx-go { margin: 0; }
.rmx-foot { margin: 0; padding: 12px 20px 16px; font-size: 12px; color: var(--ink-3); border-top: 1px solid var(--line); }
/* A summary whose control has just changed but whose figures are still the
   previous answer. room-picker.js sets it while the page navigates; with no
   script it is never applied. */
.rmx-summary.is-stale { opacity: .55; transition: opacity .12s ease; }

/* The room TYPE beside the room number on the assignment board, and what that
   room sleeps. Both only render for a mixed reservation, where "Room 2" alone
   is not enough for a guest to arrange anybody. */
.ra-room-type {
  display: inline-block; margin-left: 6px;
  font-size: 12px; font-weight: 400; color: var(--ink-3);
}
.ra-capacity { margin: 0 0 8px; font-size: 12px; color: var(--ink-3); }
/* Over capacity. `--color-err-fg` rather than `--bad`, matching the hint-err
   convention: the trio is self-consistent in every theme path, including the
   JS-off auto-dark fallback that redefines only a subset of the palette. */
.ra-capacity.is-over { color: var(--color-err-fg); font-weight: var(--fw-medium); }
/* The room TYPE beside a room number in the checkout summary's per-room list.
   Only rendered when a reservation holds more than one type, where the number
   alone says nothing about which room is which. */
.summary-units .summary-unit-room { display: block; font-size: 12px; color: var(--ink-3); font-weight: 400; }

/* "Need more than one room?" - the link that replaced the per-card room-count
   dropdown. That control quoted the ONE-room price and charged the multi-room
   one (two adults shown £1200 and charged £2200), and pricing it live would
   mean a price per room count per card. The picker already computes exactly
   that on the server, for any mix. */
.v-more-rooms, .reserve-more-rooms { margin: 8px 0 0; font-size: 12px; text-align: center; }
.v-more-rooms a, .reserve-more-rooms a { color: var(--ink-3); text-decoration: underline; text-underline-offset: 2px; }
.v-more-rooms a:hover, .reserve-more-rooms a:hover { color: var(--ink); }

/* ── Admin -> Lead: the exact rooms, and the quote they add up to ──────────
   Two lists on one record, deliberately styled apart. The first is WHAT THEY
   CHOSE (a rooming list: which room, who is in it, what it cost); the second
   is inside the quote panel and its job is to ADD UP, so it carries a ruled
   total line and nothing else. Both are two-column grids with the party
   wrapping onto its own row, the same shape the checkout summary uses - a flex
   row cannot hold four cells in a sidebar column without breaking in four
   places. */
.lead-rooms, .lead-quote-rooms { list-style: none; margin: 12px 0 0; padding: 0; }
.lead-rooms li {
  display: grid; grid-template-columns: 1fr auto; column-gap: 10px;
  padding: 8px 0; font-size: 13px;
}
.lead-rooms li + li { border-top: 1px solid var(--line); }
.lead-room-n { font-size: 11px; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); grid-column: 1 / -1; }
.lead-room-name { color: var(--ink); font-weight: var(--fw-medium); min-width: 0; }
.lead-room-price { color: var(--ink); font-weight: var(--fw-medium); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
.lead-room-party { grid-column: 1 / -1; color: var(--ink-3); font-size: 12px; }

.lead-quote-rooms { margin-top: 10px; font-size: 12px; }
.lead-quote-rooms li { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; color: var(--ink-2); }
.lead-quote-party { color: var(--ink-3); margin-left: 6px; }
.lead-quote-price { font-variant-numeric: tabular-nums; white-space: nowrap; }
.lead-quote-sum { border-top: 1px solid var(--line); margin-top: 4px; padding-top: 6px; font-weight: var(--fw-medium); color: var(--ink); }

/* ---------------------------------------------------------------------------
   The checkout busy overlay.

   Raised the instant the pay button is pressed and lowered on every path that
   hands the form back. It is the answer to a page that looked dead while the
   card was being charged, which is what made customers refresh - and a reload
   mid-payment used to cost them the booking.

   z-index sits above .lul-modal-root (1200) deliberately: nothing on this page
   may draw over "do not refresh". Sized in tokens so it follows the theme, and
   the spinner stops entirely under prefers-reduced-motion rather than slowing
   down - a ring that never stops turning is the thing the preference is about.
   The global [hidden] guard in the reset is what hides it, so no
   `.pay-busy[hidden]` patch is needed here (s14).
--------------------------------------------------------------------------- */
.pay-busy {
  position: fixed; inset: 0; z-index: 1300;
  display: flex; align-items: center; justify-content: center;
  padding: 20px;
  background: rgba(20, 18, 14, 0.62);
  backdrop-filter: blur(3px);
  animation: lul-fade .16s ease both;
}
.pay-busy-box {
  width: 100%; max-width: 420px;
  background: var(--card); color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  padding: 30px 26px 24px;
  text-align: center;
}
.pay-busy-box h2 { margin: 16px 0 8px; font-size: 20px; }
.pay-busy-box p { margin: 0 0 6px; font-size: 14px; line-height: 1.5; }
.pay-busy-box p:last-child { margin-bottom: 0; }
.pay-busy-spinner {
  width: 38px; height: 38px; margin: 0 auto;
  border-radius: 50%;
  border: 3px solid var(--line-2);
  border-top-color: var(--gold);
  animation: pay-busy-spin .9s linear infinite;
}
@keyframes pay-busy-spin { to { transform: rotate(360deg); } }
/* The page behind must not scroll under a message telling you not to touch it. */
body.pay-busy-open { overflow: hidden; }
@media (prefers-reduced-motion: reduce) {
  .pay-busy { animation: none; }
  .pay-busy-spinner { animation: none; border-top-color: var(--gold); }
}

/* ---------- The launch holding page + split-flap countdown -----------------
   views/user/launch.ejs, driven by the second block of public/js/checkout-timer.js.

   A PHOTOGRAPH, A DARK GLASS PANEL AND A BOARD, which is the client's own
   reference for this page. It was a white card on the site's grey paper, and a
   white card is the site's PRODUCT surface (s9d) - the one shape that means
   "this is for sale" - on the one page where nothing is.

   EVERY COLOUR IN THIS BLOCK IS LITERAL, like the poster bands on the marketing
   pages and for the same reason. The panel is translucent over an uploaded
   photograph, so a token that inverts with the visitor's theme would put
   near-black text on a night-time picture. The page is dark in both themes,
   which is why there is no dark-mode rule for it at the foot of this sheet any
   more.

   The gold appears as TEXT here (the meta lead, the separators, the ornament)
   and never as a FILL under text, which is the direction s18 forbids. On the
   panel's near-black ground #C5A15B clears 7:1.

   The board is graphite cards with paper digits, NOT gold ones: a 38px digit is
   the largest piece of text on the page. Gold stays on the tile's edge and the
   unit labels. */
.launch-body {
  min-height: 100vh; min-height: 100dvh;
  margin: 0; display: flex; flex-direction: column;
  /* The fallback when no photograph has been uploaded, and the colour behind
     one that is still loading. A real page, not an empty rectangle. */
  background: radial-gradient(120% 100% at 50% 0%, #1B2836 0%, #10151C 55%, #0B0E13 100%);
  color: #F5EFE3;
}
/* The photograph. FIXED rather than absolute so it stays put when a small
   screen has to scroll the panel, and behind everything at z-index 0. */
.launch-bg {
  position: fixed; inset: 0; z-index: 0; display: block;
}
.launch-bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The scrim. The marketing heroes deliberately carry NONE (s9d) because the
   artwork is the message there; here two lines of text sit straight on the
   picture and a translucent panel sits over the middle of it, and neither can
   be made legible against a photograph nobody has seen yet. A vignette, so the
   middle of the artwork is darkened least. */
.launch-veil {
  position: fixed; inset: 0; z-index: 1; pointer-events: none;
  background:
    radial-gradient(118% 88% at 50% 42%, rgba(6,9,13,0.10) 0%, rgba(6,9,13,0.38) 62%, rgba(6,9,13,0.62) 100%),
    linear-gradient(180deg, rgba(6,9,13,0.30) 0%, rgba(6,9,13,0) 26%, rgba(6,9,13,0) 66%, rgba(6,9,13,0.48) 100%);
}
/* The panel is centred in whatever height is left over once the corner lines
   have taken theirs, rather than in the viewport: on a short screen that is the
   difference between a scrollable page and a title under the fold. */
.launch-wrap {
  position: relative; z-index: 2; flex: 1 1 auto;
  display: flex; align-items: center; justify-content: center;
  width: 100%; padding: clamp(18px, 2.4vw, 26px) var(--page-gutter, 16px);
}
/* 580 rather than 560: the title is the widest thing on the panel and at 540
   "Bookings open soon" broke over two lines, which turns a headline into a
   stack. The measure of the lede is held at 42ch regardless. */
.launch-stack { width: 100%; max-width: 580px; margin: 0 auto; text-align: center; }
/* The lockup sits ON the photograph above the panel, not inside it - the panel
   is the message and the mark is whose message it is. */
.launch-logo {
  display: block; margin: 0 auto clamp(10px, 1.6vw, 15px);
  width: clamp(88px, 13.5vw, 104px); height: auto; object-fit: contain;
  filter: drop-shadow(0 6px 22px rgba(0, 0, 0, 0.45));
}
.launch-card {
  border-radius: 22px;
  padding: clamp(20px, 3vw, 28px) clamp(20px, 4.2vw, 46px) clamp(18px, 2.6vw, 24px);
  background: linear-gradient(180deg, rgba(20, 23, 29, 0.74) 0%, rgba(10, 12, 16, 0.84) 100%);
  border: 1px solid rgba(197, 161, 91, 0.26);
  box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
  /* Progressive: a browser without it gets the gradient above, which is already
     opaque enough to read on. Nothing is conditional on the blur. */
  -webkit-backdrop-filter: blur(16px) saturate(115%);
  backdrop-filter: blur(16px) saturate(115%);
}
/* The three facts. One editable line and two DERIVED ones (the view builds
   them), set as the smallest type on the panel so the title stays the title. */
.launch-meta { margin: 0 0 clamp(12px, 2vw, 16px); }
/* WEIGHT, NOT JUST SIZE. Small caps at 11.5px with 0.18em of tracking are
   already working hard to be read; at Poppins' regular weight, over a
   translucent panel over a photograph, the strokes thin out to nothing. Every
   label on this page is --fw-medium and lifted well clear of the ground, which
   is what the panel's own glass costs. */
.launch-meta p {
  margin: 0; font-size: 11.5px; line-height: 2;
  font-weight: var(--fw-medium);
  letter-spacing: 0.18em; text-transform: uppercase;
  color: rgba(245, 239, 227, 0.86);
}
.launch-meta-lead {
  color: #E3CD9B; font-weight: var(--fw-medium);
  font-size: 12.5px !important; letter-spacing: 0.22em !important;
}
.launch-title {
  margin: 0 0 10px; font-weight: var(--fw-medium);
  font-size: clamp(28px, 5.4vw, 42px); line-height: 1.14; letter-spacing: -0.015em;
  color: #F9F5EC;
}
.launch-lede {
  margin: 0 auto; max-width: 50ch; line-height: 1.6;
  font-size: clamp(15px, 1.6vw, 16.5px); color: rgba(245, 239, 227, 0.9);
}
/* The rule between the panel's two halves - "come back" above it, "do you have
   a code" below it. The hairline fades out at both ends so it reads as an
   ornament rather than as a border somebody forgot to finish. */
.launch-orn {
  display: flex; align-items: center; gap: 14px;
  margin: clamp(13px, 1.8vw, 16px) 0;
}
.launch-orn::before, .launch-orn::after {
  content: ""; flex: 1 1 auto; height: 1px;
}
.launch-orn::before { background: linear-gradient(90deg, rgba(197,161,91,0) 0%, rgba(197,161,91,0.42) 100%); }
.launch-orn::after  { background: linear-gradient(90deg, rgba(197,161,91,0.42) 0%, rgba(197,161,91,0) 100%); }
.launch-orn-mark { color: #C5A15B; font-size: 12px; line-height: 1; }
.launch-orn-dot .launch-orn-mark {
  width: 5px; height: 5px; border-radius: 50%;
  background: rgba(197, 161, 91, 0.7); display: block;
}
.launch-countdown { margin: 0; }
.launch-countdown-label {
  margin: 0 0 clamp(10px, 1.6vw, 14px); font-size: 11px; letter-spacing: 0.2em;
  font-weight: var(--fw-medium);
  text-transform: uppercase; color: rgba(245, 239, 227, 0.88);
}
/* The no-JavaScript fallback sentence, replaced by the board once the script
   has decided it can do better. */
.launch-countdown-clock { margin: 0; font-size: 16px; font-weight: var(--fw-medium); }
.launch-form { margin: 0; text-align: center; }
.launch-code-heading {
  display: block; margin: 0 0 6px; font-size: 12.5px; font-weight: var(--fw-medium);
  letter-spacing: 0.18em; text-transform: uppercase; color: #F3ECDC;
}
.launch-code-note {
  margin: 0 0 14px; font-size: 14px; line-height: 1.5;
  color: rgba(245, 239, 227, 0.86);
}
/* The one light surface on the page, because it is the one thing being asked
   for. Dark ink on it, never the panel's cream.

   Qualified on the TYPE: the form also carries the hidden CSRF field, and a
   bare `.launch-form input` hands a width, a padding and a border to an element
   whose whole job is not to be there. */
.launch-form input[type="text"] {
  width: 100%; box-sizing: border-box;
  padding: 15px 18px; border-radius: 10px;
  background: rgba(246, 242, 234, 0.94);
  border: 1px solid rgba(246, 242, 234, 0.55);
  color: #1B1B1D; font-family: inherit; font-size: 15px;
  letter-spacing: 0.14em; text-transform: uppercase;
}
/* `text-transform` inherits into the placeholder, which would shout the hint
   back at somebody who has not typed anything yet. The VALUE stays uppercase -
   codes are - and only the hint is set in sentence case. */
.launch-form input[type="text"]::placeholder {
  text-transform: none; letter-spacing: 0.01em; color: rgba(27, 27, 29, 0.45);
}
/* The refused code. `aria-invalid` is what the view writes for a screen
   reader, so the border is hung off the same attribute rather than a class the
   two could get out of step over. */
.launch-form input[type="text"][aria-invalid="true"] {
  border-color: #C4553F; box-shadow: 0 0 0 3px rgba(196, 85, 63, 0.28);
}
.launch-form input[type="text"]:focus-visible {
  outline: 2px solid #C5A15B; outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(197, 161, 91, 0.22);
}
.launch-submit {
  margin-top: 12px; letter-spacing: 0.1em; text-transform: uppercase;
  font-weight: var(--fw-medium);
}
/* --bad is tuned to be read on --paper and is far too dark here, so the error
   takes a literal light red off the same hue. ~7:1 on the panel. */
.launch-err { margin: 10px 0 0; color: #F2A99B; font-size: 13.5px; }
.launch-footnote {
  margin: clamp(12px, 2vw, 16px) 0 0; font-size: 12.5px; line-height: 1.5;
  /* .52 was 4.6:1 on the panel, which passes AA at 12px by a margin too thin
     to survive a bright photograph showing through the glass. */
  color: rgba(245, 239, 227, 0.78);
}
.launch-help {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  /* The address is one long unbreakable word: on a 320px screen the icon, the
     question and the mailto do not fit on one line, and a flex row that cannot
     wrap pushes it out of the gutter instead. */
  flex-wrap: wrap;
  margin: clamp(13px, 2vw, 17px) 0 0; font-size: 13.5px;
  color: rgba(245, 239, 227, 0.92);
}
.launch-help svg { flex: none; opacity: 0.9; }
.launch-help a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }

/* ---- The board ----
   ONE TILE PER UNIT, not one per digit. The two cards of a pair sit flush
   inside a single gold-edged box, which is what the client's reference shows
   and what a departure board actually looks like: "13" is one reading, and
   two boxes with a gap down the middle is two.

   The box is what carries the edge and the corners; the CARDS carry the fill,
   and that fill is OPAQUE. It cannot be the translucent tint the panel uses:
   a folding half is a rotating solid, and a see-through one shows the
   photograph through the digit it is supposed to be replacing. */
.flipclock {
  display: flex; align-items: flex-start; justify-content: center;
  gap: clamp(5px, 1.8vw, 12px);
}
.flip-group { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.flip-digits {
  display: flex; gap: 0;
  border: 1px solid rgba(197, 161, 91, 0.45);
  border-radius: 12px;
  /* The corners are clipped here rather than on each card, which is what makes
     the pair read as one tile. */
  overflow: hidden;
  box-shadow: 0 8px 22px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.flip-label {
  font-size: 10px; letter-spacing: 0.16em; text-transform: uppercase;
  font-weight: var(--fw-medium); color: rgba(245, 239, 227, 0.84);
}
.flip-sep {
  font-size: clamp(18px, 4.4vw, 26px); font-weight: var(--fw-medium);
  color: rgba(197, 161, 91, 0.62); line-height: 1;
  /* Centred against the TILE, not against the group - the unit label below it
     is not something a colon belongs beside. */
  padding-top: clamp(12px, 3.2vw, 17px);
}

/* ---- One card ----
   `perspective` on the card and not on the board: on the board it is one
   vanishing point for eight cards, so the outer digits fold at a visibly
   different angle from the middle ones. Per card, every digit folds the same.

   The card is wider and taller than the glyph on purpose: the padding inside
   the tile IS this margin, because the tile has none of its own (it clips its
   children, so padding there would show as a rim of nothing). */
.flip {
  position: relative; display: block;
  width: clamp(31px, 7.8vw, 44px); height: clamp(45px, 11.2vw, 59px);
  perspective: 240px;
  font-size: clamp(23px, 6vw, 35px);
  font-weight: var(--fw-medium);
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.flip-half {
  position: absolute; left: 0; width: 100%; height: 50%;
  overflow: hidden; background: #1D1A16; color: #F7F2E6;
  backface-visibility: hidden; -webkit-backface-visibility: hidden;
}
/* Each half holds a FULL-height glyph box and shows half of it. That is what
   makes the two halves line up into one digit: they are the same glyph, clipped
   top and bottom, rather than two independently positioned characters. */
.flip-half > span {
  position: absolute; left: 0; width: 100%; height: 200%;
  display: flex; align-items: center; justify-content: center;
}
.flip-top { top: 0; }
.flip-top > span { top: 0; }
.flip-bot { bottom: 0; }
.flip-bot > span { bottom: 0; }
/* The fold line. A real split-flap has a physical gap here and without it the
   two halves read as one flat box. Dark rather than the gold it used to be:
   the gold is now on the tile's own edge, and a second gold line across the
   middle of every digit turned the ornament into a grid. */
.flip-top::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 1px;
  background: rgba(0, 0, 0, 0.42);
}

/* ---- The two folds ----
   Stacked above the static halves and inert until `is-flipping` is set. The
   top fold carries the OLD digit and falls away; the bottom fold carries the
   NEW one and rises into place. */
.flip-fold-top { z-index: 3; transform-origin: 50% 100%; }
.flip-fold-bot { z-index: 2; transform-origin: 50% 0; transform: rotateX(90deg); }
.flip:not(.is-flipping) .flip-fold-top,
.flip:not(.is-flipping) .flip-fold-bot { opacity: 0; }
.flip.is-flipping .flip-fold-top { animation: flip-fall 280ms ease-in forwards; }
.flip.is-flipping .flip-fold-bot { animation: flip-rise 280ms 280ms ease-out forwards; }
/* The depth cue, and the difference between a fold and a flat rectangle
   sliding: the falling half darkens as it turns away from the light, the
   rising half arrives out of shadow. Painted as a pseudo-element over each
   fold rather than by animating the card's own background, so the digit under
   it is shaded with it. */
.flip-fold-top::before,
.flip-fold-bot::before {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(180deg, rgba(0,0,0,0), rgba(0,0,0,0.55));
  opacity: 0;
}
.flip-fold-bot::before { background: linear-gradient(0deg, rgba(0,0,0,0), rgba(0,0,0,0.55)); }
.flip.is-flipping .flip-fold-top::before { animation: flip-shade-in 280ms ease-in forwards; }
.flip.is-flipping .flip-fold-bot::before { animation: flip-shade-out 280ms 280ms ease-out forwards; }
@keyframes flip-shade-in  { from { opacity: 0; } to { opacity: 1; } }
@keyframes flip-shade-out { from { opacity: 1; } to { opacity: 0; } }

@keyframes flip-fall {
  from { transform: rotateX(0deg); }
  to   { transform: rotateX(-90deg); }
}
@keyframes flip-rise {
  from { transform: rotateX(90deg); }
  to   { transform: rotateX(0deg); }
}

/* The script already skips the animation under this preference (it sets the
   digit outright), but the rule is restated so a card mid-flip when the
   preference changes settles instead of hanging folded. */
@media (prefers-reduced-motion: reduce) {
  .flip.is-flipping .flip-fold-top,
  .flip.is-flipping .flip-fold-bot { animation: none; opacity: 0; }
  .flip.is-flipping .flip-fold-top::before,
  .flip.is-flipping .flip-fold-bot::before { animation: none; opacity: 0; }
}

/* ---- Narrow screens ----
   The board is what has to give. Eight digits, three colons and
   four labels do not fit across a 320px screen at the desktop tile size; the
   clamps above take most of it, and the gap and the colon come down the rest of
   the way so the four groups stay on ONE line. A board that wraps is not a
   clock. */
@media (max-width: 640px) {
  .launch-help { font-size: 12.5px; }
  .flipclock { gap: 3px; }
  .flip-sep { font-size: 16px; padding-top: 14px; }
  .flip-label { font-size: 8.5px; letter-spacing: 0.1em; }
  .launch-card { border-radius: 18px; }
}
/* Below this the two-digit tiles are what decides the width, so the ornament
   either side of them is dropped to the narrowest it can be and still read. */
@media (max-width: 360px) {
  .flipclock { gap: 2px; }
  .flip-sep { font-size: 14px; padding-top: 12px; }
}
