/* JoynUs app chrome, marketing, auth, host dashboard. Loaded after tokens.css. */

/* Wordmark face, self-hosted so the logo never depends on a CDN or flashes a
   fallback. Dancing Script, SIL OFL (see static/fonts/DancingScript-OFL.txt). */
@font-face {
  font-family: "Dancing Script";
  src: url("../fonts/dancing-script-700.1b7f787af95f.woff2") format("woff2");
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }

html { -webkit-text-size-adjust: 100%; }
body {
  font-family: var(--font-body);
  color: var(--ink);
  background-color: var(--paper);
  background-image: var(--grain);
  background-attachment: fixed;
  line-height: 1.6;
  font-size: var(--step-0);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

::selection { background: var(--clay); color: var(--paper); }

a { color: var(--clay-dark); text-decoration: none; text-underline-offset: 3px; }
a:hover { text-decoration: underline; }

h1, h2, h3 { font-family: var(--font-display); font-weight: 540; line-height: 1.08; letter-spacing: -0.015em; color: var(--ink); }
h1 { font-size: var(--step-3); }
h2 { font-size: var(--step-2); }
h3 { font-size: var(--step-1); }
p { max-width: 68ch; }

/* ---- Layout shell ---- */
.site-nav {
  display: flex; align-items: center; gap: var(--sp-3);
  width: 100%; max-width: 1080px; margin: 0 auto; padding: var(--sp-3) var(--sp-3);
  border-bottom: 1px solid var(--line);
  position: relative;
}
.site-nav .nav-links { display: flex; align-items: center; gap: var(--sp-3); }
/* Hamburger toggle, hidden on desktop, shown on narrow screens. */
.site-nav .nav-toggle {
  display: none; flex-direction: column; justify-content: center; gap: 5px;
  width: 44px; height: 44px; padding: 0; background: none; border: none; cursor: pointer;
}
.site-nav .nav-toggle-bar {
  display: block; width: 24px; height: 2px; margin: 0 auto; background: var(--ink);
  border-radius: 2px; transition: none;
}
.site-nav .brand {
  font-family: "Dancing Script", var(--font-display), cursive;
  font-size: 1.85rem; font-weight: 700; line-height: 1;
  letter-spacing: 0; color: var(--ink); display: inline-flex; align-items: center; gap: 0.5rem;
}
.site-nav .brand:hover { text-decoration: none; }
.site-nav .brand .seal {
  width: 1.6rem; height: 1.6rem;
  background: url("../img/brand/seal-64.b4b8367f9b9f.png") center / contain no-repeat;
}
.site-nav .spacer { flex: 1; }
.site-nav a:not(.brand) { color: var(--ink-soft); font-size: var(--step--1); letter-spacing: 0.02em; }
.site-nav a:not(.brand):hover { color: var(--ink); text-decoration: none; }

main { flex: 1 0 auto; width: 100%; max-width: 1080px; margin: 0 auto; padding: var(--sp-5) var(--sp-3) var(--sp-6); }

/* small-caps label */
.eyebrow {
  font-size: var(--step--1); letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--clay-dark); font-weight: 600;
}

/* ---- Buttons ---- */
button, .btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
  font-family: var(--font-body); font-size: var(--step-0); font-weight: 600;
  letter-spacing: 0.01em; line-height: 1;
  padding: 0.8em 1.4em; border-radius: var(--radius); border: 1px solid transparent;
  background: var(--ink); color: var(--paper); cursor: pointer;
  transition: transform .15s ease, box-shadow .15s ease, background .15s ease;
  text-decoration: none;
}
button:hover, .btn:hover { transform: translateY(-1px); box-shadow: var(--shadow); text-decoration: none; }
button:active, .btn:active { transform: translateY(0); }
.btn-primary { background: var(--clay); color: var(--paper); }
.btn-primary:hover { background: var(--clay-dark); }

/* "Add to Claude": a deep link into claude.ai's Add custom connector dialog,
   pre-filled (views_connections.claude_connector_link). Claude's own terracotta,
   so it reads as Claude's button rather than another JoynUs action. */
.add-to-claude { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-4); }
.add-to-claude > p { flex: 1 1 16rem; margin: 0; }
.add-to-claude-btn, .add-to-claude-btn:hover { background: #c96442; color: #fff; }
.add-to-claude-btn:hover { background: #b0553a; }
.btn-ghost { background: transparent; color: var(--ink); border-color: var(--line-strong); }
.btn-ghost:hover { background: var(--paper-2); box-shadow: none; }
.btn-lg { font-size: var(--step-1); padding: 0.85em 1.7em; }
button:focus-visible, .btn:focus-visible, a:focus-visible { outline: none; box-shadow: var(--ring); }

/* ---- Forms ---- */
label { display: block; font-size: var(--step--1); font-weight: 600; color: var(--ink-soft); margin-bottom: var(--sp-1); }
/* `font-size` AFTER `font: inherit`, and it is not redundant: it is what stops
   iOS zooming the page every time a field takes focus.

   iOS Safari zooms a page whenever it focuses a form control whose font-size is
   under 16px, and there is no way to decline that other than banning pinch zoom
   site-wide (`maximum-scale=1`), which is a real accessibility loss and is not
   on the table. So no control may compute under 16px.

   `font: inherit` alone does not deliver that, and which markup a field sits in
   decides whether it does. A page form puts the input NEXT TO its label, so it
   inherits the body's --step-0 (16.14px on a 390px phone) and is fine. The
   .modal-field pattern every dialog uses puts the input INSIDE the label
   (`<label class="modal-field"><span>…</span><input></label>`), so `inherit`
   walked up to `label { font-size: var(--step--1) }` and every field in every
   dialog computed 12.62px: under the threshold, and 22% smaller than the same
   field on a page form, which was never a decision anybody made.

   Reported as the Add-agenda-item dialog zooming the page on OPEN rather than
   on a tap, because its first field carries `autofocus`, so the focus that
   triggers the zoom happens before you touch anything.

   --step-0 rather than a bare 16px so the field still tracks the type scale;
   its clamp floor is exactly 1rem, and iOS zooms only BELOW 16px, not at it.
   test_form_controls_never_zoom_ios_browser.py pins the threshold. */
input, textarea, select {
  font: inherit; font-size: var(--step-0); width: 100%; color: var(--ink);
  background: var(--paper); border: 1px solid var(--line-strong);
  padding: 0.7em 0.85em; border-radius: var(--radius); margin-bottom: var(--sp-3);
  transition: border-color .15s ease, box-shadow .15s ease;
}
/* DATE AND TIME INPUTS, THE THREE-PART FIX. Ported from ClassPulse's
   static/css/app.css, which had already paid for all three lessons. Do not
   reduce it to one of them: each part fixes a different engine's behaviour and
   the third exists because the second CAUSES a regression.

   1. Chromium gives a date input a user-agent intrinsic min-width from the
      locale's formatted date string, which overrides `width: 100%` in a narrow
      container. `min-width: 0` and `max-width: 100%` are what let it shrink.
      (ClassPulse hit this on a student edit modal, 2026-05-26.)
   2. iOS WebKit draws a NATIVE control whose intrinsic min-width ignores even
      `min-width: 0`, and which centres its own text. On the owner's iPhone the
      Add-agenda-item dialog's Starts and Ends fields ran past the dialog's
      right edge with "1 Sep 2026 at 17:18" centred between left-aligned
      placeholders. `appearance: none` hands the box back to CSS; the tap still
      opens the native picker. It resets the WIDGET and not the alignment, so
      `text-align: left` is separate and also needed. (ClassPulse, 2026-08-13.)
   3. iOS renders no `::-webkit-calendar-picker-indicator` AT ALL, so part 2
      leaves an iOS date field with nothing marking it as tappable: it reads as
      an empty box. The `@supports` block below draws the affordance back. This
      is the part that makes the fix safe rather than a swap of one defect for
      another.

   NOT REPRODUCIBLE IN A HEADLESS BROWSER. Desktop WebKit lays this markup out
   correctly (measured here: all four dialog fields identical at 316.8px), and
   DevTools' iOS emulation is Blink. ClassPulse's comment says the same. So
   test_agenda_modal_datetime_browser.py guards the desktop relationship and
   cannot see any of part 2 or 3; a real device is the only check. */
input[type="date"], input[type="datetime-local"], input[type="time"],
input[type="month"], input[type="week"] {
  min-width: 0;
  max-width: 100%;
  -webkit-appearance: none;
  appearance: none;
  text-align: left;
}
/* `-webkit-touch-callout` is implemented only by WebKit on iOS, so this query
   targets exactly the browsers missing the picker indicator, including iOS
   Chrome and Firefox, which are all WebKit. It cannot double-draw next to
   Chromium's native glyph, because Chromium never matches it. The colour is
   baked in rather than taken from --ink-faint (#8b8071): a data: URI has no
   access to custom properties.

   A FOURTH PART, found on the owner's phone after #229 shipped: with the
   native widget handed back to CSS (part 2), an EMPTY iOS date/time field
   renders far shorter than its sibling text fields, an inch-high sliver until
   the first tap, when WebKit relays out the shadow `::-webkit-date-and-time-value`
   pseudo-element at its real size. `min-height` reproduces the same field's
   own padding/line-height/border box (0.7em top + 0.7em bottom + a 1.6
   line-height + the 1px top/bottom border, all from the base `input` rule
   above) so the box is never smaller than that, before or after the shadow
   content lays out. Scoped to iOS the same way as the indicator above: it is
   the same shadow-DOM quirk, on the same engine. */
@supports (-webkit-touch-callout: none) {
  input[type="date"], input[type="datetime-local"], input[type="time"],
  input[type="month"], input[type="week"] {
    padding-right: calc(0.85em + 1.25rem);
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%238b8071' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='4' width='18' height='18' rx='2'/%3E%3Cpath d='M16 2v4M8 2v4M3 10h18'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 0.85em center;
    background-size: 1rem 1rem;
    min-height: calc(3em + 2px);
  }
}
input::placeholder, textarea::placeholder { color: var(--ink-faint); }
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--clay); box-shadow: var(--ring); }
textarea { min-height: 6rem; resize: vertical; }
form p { margin: 0; max-width: none; }
.helptext, .help { font-size: var(--step--1); color: var(--ink-faint); font-weight: 400; }
.error, .errorlist { color: var(--danger); font-size: var(--step--1); list-style: none; margin-bottom: var(--sp-2); }

/* The `hidden` attribute must actually hide. The browser's own rule for it is
   `[hidden] { display: none }`, which any class setting display beats on
   specificity, so `el.hidden = true` on a `.modal-field` (display: flex) or any
   other laid-out element silently does nothing. That is not theoretical: the
   collaborator invite dialog hides its Allowance field when the role is set to
   designer, because views_collab forces that allowance to 0 whatever is posted,
   and the field stayed on screen inviting the host to type a number that would be
   discarded. Found by screenshotting the dialog rather than by reading the JS,
   which looked correct and was. */
[hidden] { display: none !important; }

/* ---- Notices (app-wide feedback surface, see templates/_messages.html) ---- */
.notices { display: grid; gap: var(--sp-2); margin-bottom: var(--sp-4); }
.notice {
  border: 1px solid var(--line); border-left-width: 4px;
  border-radius: var(--radius); padding: var(--sp-2) var(--sp-3);
  background: var(--paper-2); font-size: var(--step--1); max-width: none;
}
.notice-success { border-left-color: var(--ok); }
.notice-error { border-left-color: var(--danger); color: var(--danger); }

/* ---- Cards & panels ---- */
.card {
  background: var(--paper); border: 1px solid var(--line);
  border-radius: var(--radius-lg); padding: var(--sp-4); box-shadow: var(--shadow-sm);
}
.panel {
  background: linear-gradient(var(--paper), var(--paper-2));
  border: 1px solid var(--line); border-radius: var(--radius-lg);
  box-shadow: var(--shadow); padding: clamp(1.5rem, 4vw, 3rem);
}
/* A panel that is telling the host something is WRONG.

   Added because there was nothing to reach for. The day plan's "Check before
   sending" card is a plain `.panel`, so three warnings about a send that will
   silently do nothing rendered as an ordinary section with a display-serif
   heading, which a host scans straight past. That card exists precisely
   because every failure mode there is invisible: a person in no crews, a crew
   on no items, an item reaching nobody all produce a page that looks correct
   and tells its reader nothing.

   SHARED rather than page-local, because this is the second surface that
   wants it. `wa_merge.html` already carries two page-local reds
   (`.wa-selector-warning-text`), which is the first sign of the same gap.

   Amber and not `--danger`: nothing here has failed, and red for "you might
   want to look at this" is the crying-wolf that makes a host stop reading
   warnings at all. The left rule does the work, so it still reads as a panel
   rather than as an alert bar. */
.panel-warn {
  background: linear-gradient(var(--paper), #f7efdd);
  border-color: var(--gold);
  border-left: 4px solid var(--gold);
}
.panel-warn > h2 {
  font-family: var(--font-body); font-size: var(--step-0);
  font-weight: 700; margin-bottom: var(--sp-2);
}
.panel-warn ul { margin: 0; padding-left: 1.1rem; }
.panel-warn li { margin-bottom: var(--sp-1); }
/* `.panel` carries no vertical margin of its own, so two panels stacked on one
   page touch along their borders and read as a single card. Every page that
   stacked them worked around it by hand (`mt-4` on the second one, or `mb-16px`
   on the first), and the pages added on 2026-08-10 did not: the partner
   dashboard, the design page and the platform partner admin all shipped with
   their cards touching. Supply the gap once, here, rather than remembering the
   utility on every new template.

   The value is exactly what `mt-4` gives, so a page that already carries it
   renders identically. `margin-top` is one property and not a sum, so two rules
   setting it cannot double the gap, and where the panel ABOVE carries a bottom
   margin instead the two margins collapse to the larger of the pair rather than
   adding up. So there is no page on which this widens a gap by more than
   bringing it up to --sp-4.

   `:where()` keeps the selector at zero specificity on purpose, so a single
   class still beats it: the legal pages wrap their panels in `.stack`, whose
   `> * + *` deliberately sets a tighter --sp-3, and that keeps winning. Anything
   that wants a different gap says so on the element, as before.

   `~` and NOT `+`, which is what this rule shipped as on 2026-08-10 and why it
   did not actually fix the design page named in the paragraph above. `+` asks for
   the IMMEDIATELY preceding sibling, and both pages that still had their cards
   touching keep something between the two panels that renders nothing at all: a
   `<dialog>` on the design page, which lives outside #design-body because
   test_no_dialog_sits_inside_a_swap_region requires it to, and a `<script>` on
   account settings. Neither occupies a pixel, so the gap was missing for a reason
   invisible on the page and invisible in the markup review. `~` asks the question
   the rule actually means: is there a panel above me in this stack.

   Widening it this way newly MATCHES three sites and visibly moves exactly one,
   measured in a browser rather than assumed. The design page is the one that
   moves: its gap was 0px and is now 32px. Account settings already carried an
   `mt-4` by hand on the panel below its `<script>`, so it was never touching and
   renders identically. wa_merge.html's panel that follows a `.mb-16px
   .wa-warning` div is held at zero by the exception below, whose 0,1,1 still
   beats this rule's 0,0,0.

   So the case for changing one rule rather than reordering the templates is that
   the blast radius is one page. test_panel_spacing_css.py keeps that list closed:
   a fourth site means somebody put something between two panels and should look
   at the page. */
:where(.panel ~ .panel) { margin-top: var(--sp-4); }
/* Except after an explicit `mb-16px`, which is a choice somebody already made.
   `wa_merge.html` numbers its panels as a sequence of steps and spaces them
   tighter on purpose. Collapsing takes the LARGER of the two margins, so the rule
   above would have widened those to 32px, and this change is supposed to stop
   cards touching rather than to restyle a page that was already spaced. Measured
   before and after, so this is a number and not a guess. */
.mb-16px + .panel { margin-top: 0; }
/* A narrower panel for single-column settings-style forms. */
.panel-narrow { max-width: 640px; }
.legal-stack { max-width: 680px; }
/* A compact panel for a single stat line (e.g. guests-page allowance summary). */
.panel-stats { max-width: 560px; margin-bottom: var(--sp-3); }
/* Centered single-column panel for the event create/edit form. */
.panel-form { max-width: 600px; margin: 0 auto; }
/* Muted intro paragraph directly under a panel/section heading. */
.panel-intro { margin: 0.25rem 0 var(--sp-3); }

/* ---- Auth card ---- */
.auth-wrap { min-height: calc(100dvh - 6rem); display: grid; place-items: center; }
.auth-card { width: min(440px, 100%); }
.auth-card .panel { position: relative; overflow: hidden; }
.auth-card .panel::before {
  content: ""; position: absolute; inset: 0 0 auto 0; height: 4px;
  background: linear-gradient(90deg, var(--clay), var(--gold));
}
.auth-card h1 { font-size: var(--step-2); margin: 0.4rem 0 0.25rem; }
.auth-card .sub { color: var(--ink-soft); margin-bottom: var(--sp-4); }
.auth-card button { width: 100%; }

/* ---- Standalone confirm pages (unsubscribe, etc.) ---- */
.confirm-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: var(--sp-4); background: var(--paper); }
.confirm-card { max-width: 440px; width: 100%; text-align: center; }
.confirm-card h1 { font-size: var(--step-2); margin: 0 0 var(--sp-3); }
.confirm-card .lede { color: var(--ink-soft); margin: 0 0 var(--sp-4); }
.confirm-card .fine-print { margin: var(--sp-3) 0 0; font-size: var(--step--1); color: var(--ink-faint); }

/* ===========================================================================
   HOME PAGE (marketing/home.html)
   ---------------------------------------------------------------------------
   These rules live in app.css rather than a home.css loaded from
   {% block extra_css %}, and that is deliberate rather than lazy:
   test_every_class_on_a_host_page_has_a_rule resolves a page's own CSS from
   its inline <style> blocks only, never from a linked stylesheet, so a class
   whose rule sat in a separate file would read to that sweep as a class with
   no rule at all. The home page's CSS has always lived here for the same
   reason. IBM Plex Mono, being a font and not a class, IS loaded per page.

   Motion is used in four places and nowhere else: the envelope, the RSVP
   board, the day plan filter and the assistant console. Everything decorative
   is switched off wholesale in the reduced-motion block at the end.
   =========================================================================== */

/* Runtime scratch properties, written by home.js at most once per frame: the
   cursor's position over the viewport (--mx/--my, read by the ambient glow)
   and over a card (--gx/--gy, read by that card's ::before). Declared here
   with their resting values rather than left to a var() fallback, so the page
   has a defined starting state before any pointer has moved and so
   test_app_css_defines_every_variable_it_uses can see them. They are not
   design tokens and deliberately do not live in tokens.css. */
:root { --mx: 80%; --my: 8%; --gx: 50%; --gy: 50%; }

/* ---- The gradient rule and the scroll progress bar ---- */
.topbar { height: 5px; width: 100%; background: var(--ai); background-size: 220% 100%; animation: ai-flow 7s linear infinite; }
@keyframes ai-flow { to { background-position: 220% 0; } }
.scroll-progress {
  position: fixed; top: 0; left: 0; height: 3px; width: 0; z-index: 60;
  background: var(--ai); box-shadow: 0 0 10px rgba(138, 99, 210, 0.45);
}

/* Ambient layers: a drifting dot grid and a glow that follows the cursor.
   Both fixed, both pointer-transparent, both behind everything. The glow
   reads --mx/--my, which home.js writes at most once per frame. */
.home-ambient::before, .home-ambient::after { content: ""; position: fixed; inset: 0; z-index: 0; pointer-events: none; }
.home-ambient::before {
  background-image: radial-gradient(circle, rgba(34, 30, 24, 0.05) 1px, transparent 1.5px);
  background-size: 26px 26px; opacity: 0.5; animation: grid-drift 60s linear infinite;
}
.home-ambient::after { background: radial-gradient(420px circle at var(--mx, 80%) var(--my, 8%), rgba(190, 91, 67, 0.10), transparent 62%); }
@keyframes grid-drift { to { background-position: 26px 52px; } }
.home-ambient main, .home-ambient .site-foot { position: relative; z-index: 1; }
/* The nav sits a layer ABOVE main, not alongside it, and that is the whole
   fix for a menu that opened and then ignored every tap. This rule used to
   name all three in one list at z-index 1, which reads as "the chrome is above
   the ambient layers" and is true. What it also did was put the nav and main
   on the SAME layer, where the later element in the document wins, and the
   hamburger panel is `position: absolute; top: 100%`, so the one thing it
   hangs over is main. main's own box is transparent, so the panel was still
   perfectly visible below the bar, and elementFromPoint at the centre of the
   first menu link returned the hero's `<p class="eyebrow">`. A host tapped
   Events and the hero swallowed it.
   Only this page, because only this page positions main at all: everywhere
   else main is static, so the nav wins by being positioned and the same menu
   has always worked. That is why it reads as home-page-specific, and why it
   is not an iOS bug: it reproduces in Chromium at 390px. Pinned by
   test_home_browser.test_the_hamburger_menu_is_tappable_on_the_home_page,
   which asks what is actually under the tap rather than whether the panel is
   displayed, because the panel WAS displayed the whole time. */
.home-ambient .site-nav { position: relative; z-index: 2; }

/* The jump links, added to the anonymous nav by home.html's nav_public block.
   Hidden on narrow screens rather than folded into the hamburger, because the
   hamburger and #nav-links only exist for a signed-in host: putting them there
   would make them unreachable on a phone. */
.nav-jump { display: flex; align-items: center; gap: var(--sp-3); }
@media (max-width: 860px) { .nav-jump { display: none; } }

/* ---- Reveal on scroll ----
   The resting state is VISIBLE. home.js adds .js-reveal to <html> only once it
   has confirmed an IntersectionObserver and that motion is wanted, so a reader
   with no JS gets the whole page rather than a column of blanks. */
.js-reveal .reveal { opacity: 0; transform: translateY(22px); transition: opacity 0.7s cubic-bezier(0.2, 0.7, 0.2, 1), transform 0.7s cubic-bezier(0.2, 0.7, 0.2, 1); }
.js-reveal .reveal.in { opacity: 1; transform: none; }

/* ---- Numbered section heads ----
   The numbering runs 01 to 06 over the life of an event: the invitation, the
   sending, the replies, the room, the day, the assistant. It is a real
   sequence, which is the only reason it is numbered. */
/* sp-5, not sp-6: six sections at 5rem apart made the page feel padded out
   rather than paced. */
.sec { margin-top: var(--sp-5); scroll-margin-top: 2rem; }
.sec-head { display: flex; align-items: baseline; gap: var(--sp-3); margin-bottom: var(--sp-4); }
.sec-num { font-family: var(--font-mono); font-size: 12px; font-weight: 600; letter-spacing: 0.1em; background: var(--ai); -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; }
.sec-title { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.3em; text-transform: uppercase; color: var(--ink); font-weight: 600; white-space: nowrap; }
.sec-rule { flex: 1; height: 1px; background: var(--line); transform: scaleX(0); transform-origin: left; transition: transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) 0.1s; }
.sec-head.in .sec-rule { transform: scaleX(1); }
.sec-lede { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.35rem, 1.1rem + 1.3vw, 2.1rem); line-height: 1.32; letter-spacing: -0.01em; max-width: 22ch; margin-bottom: var(--sp-2); text-wrap: balance; }
.sec-sub { color: var(--ink-soft); max-width: 58ch; margin-bottom: var(--sp-4); }

/* main carries var(--sp-5) of top padding and var(--sp-6) of bottom for every
   page on the site, and the hero and the closing CTA each bring their own on
   top of that, so the home page opened 6.5rem below the nav and closed on a
   double gap. Trimmed HERE rather than on `main`, because main's padding is
   the vertical rhythm of every other page and changing it would move all of
   them for the sake of this one. */
.home-ambient main { padding-top: 0; padding-bottom: var(--sp-4); }

/* ---- Hero ---- */
.hero { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr); gap: clamp(var(--sp-4), 5vw, var(--sp-6)); align-items: center; padding: var(--sp-4) 0; }
.hero-copy { max-width: 34ch; }          /* the measure lives on the text column, not the section */
.hero .eyebrow { font-family: var(--font-mono); font-size: 12px; letter-spacing: 0.32em; color: var(--clay-dark); font-weight: 500; }
.hero .eyebrow .spark { display: inline-block; background: var(--ai); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; animation: ai-sweep 5s ease-in-out infinite, spark-pulse 3s ease-in-out infinite; }
@keyframes spark-pulse { 0%, 100% { transform: scale(1); opacity: 0.85; } 50% { transform: scale(1.25) rotate(15deg); opacity: 1; } }
.hero h1 {
  font-size: var(--step-4); line-height: 0.98; letter-spacing: -0.02em;
  margin: var(--sp-3) 0; font-optical-sizing: auto; text-wrap: balance;
  background: var(--ai); background-size: 260% 100%;
  -webkit-background-clip: text; background-clip: text;
  color: transparent; -webkit-text-fill-color: transparent;
  animation: ai-sweep 9s ease-in-out infinite;
}
.hero h1:hover { animation-duration: 3.5s; }
@keyframes ai-sweep { 0% { background-position: 0% 50%; } 50% { background-position: 100% 50%; } 100% { background-position: 0% 50%; } }
.hero-copy p { font-size: var(--step-1); color: var(--ink-soft); max-width: 44ch; margin-bottom: var(--sp-4); }
.hero-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.hero-note { font-family: var(--font-mono); font-size: 12px; color: var(--ink-faint); margin-top: var(--sp-3); letter-spacing: 0.02em; }

/* ---- The envelope that opens ----
   Four custom properties govern the whole thing and they are related, so they
   are named rather than scattered through the rules:
     --env-h   the envelope itself
     --card-h  MUST be smaller than --env-h. A card taller than its envelope
               sticks out of the top before the flap has opened, which is the
               bug the first draft of this shipped.
     --rise    the peak of the card's travel. Set so the card's bottom edge
               lands exactly on the envelope's top edge, which is the one frame
               where the two do not overlap and so the only frame where the
               card's z-index can change without it being visible.
     --rest    where the card settles: 0, its original height, so it comes back
               down and covers the envelope the way a real one does.
   .env is sized to hold the envelope plus the whole rise, or the card is
   clipped at the top of its arc.

   The flap's point and the pocket's notch are the SAME angle (--vee), so the
   two shapes tile the rectangle and leave no gap at any x.

   THE TIMELINE. Each beat finishes before the next begins. Change one number
   and check the two either side of it:
     0.35 - 1.50   the flap swings open, the seal riding it, and settles behind
     1.60 - 3.60   the card is drawn out to --rise, comes over the top of the
                   envelope, and is laid back down on it at --rest
     2.75 - 4.06   the details fade in, line by line
   RUN_MS in home.js is the end of that last beat. Retime one, retime both. */
.env-stage { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); --env-h: 280px; --card-h: 250px; --rise: 265px; --rest: 0px; --vee: 52%; }
/* --env-scale shrinks the whole performance on a phone, see the narrow-screen
   block at the bottom of this file. At its default of 1 this calc is exactly
   the `--env-h + --rise + 20px` it replaces, so nothing above 640px moves. */
.env { position: relative; width: min(100%, 380px); height: calc((var(--env-h) + var(--rise)) * var(--env-scale, 1) + 20px); display: flex; align-items: flex-end; justify-content: center; }
.env-inner { position: relative; width: 100%; height: var(--env-h); }

.env-back { position: absolute; inset: 0; z-index: 1; border-radius: 6px; background: linear-gradient(168deg, #f0e4cd, #e2d2b6); border: 1px solid var(--line-strong); box-shadow: var(--shadow); }
/* the shadowed mouth, so the card reads as coming from inside */
.env-back::after { content: ""; position: absolute; left: 0; right: 0; top: 0; height: var(--vee); background: linear-gradient(#c9b795, rgba(201, 183, 149, 0)); clip-path: polygon(0 0, 100% 0, 50% 100%); opacity: 0.75; }

.env-card {
  position: absolute; left: 6%; width: 88%; bottom: 15px; z-index: 2;
  height: var(--card-h); border-radius: 4px; background: #fffdf8;
  border: 1px solid var(--line); box-shadow: 0 8px 22px -16px rgba(34, 30, 24, 0.5);
  /* The content measures 212px. Less at the top than the bottom, so the venue
     line is not sitting on the inner rule. */
  padding: 14px 22px 20px; text-align: center; overflow: hidden; transform: translateY(0);
}
.env-card::before { content: ""; position: absolute; inset: 8px; border: 1px solid var(--clay-tint); border-radius: 2px; pointer-events: none; }
/* The card is drawn OUT of the envelope and laid back down ON it, the way you
   actually handle one. transform, z-index and box-shadow all move together,
   which a transition cannot do, hence keyframes. */
.env.is-open .env-card { animation: env-card-rise 2s 1.6s both; }
@keyframes env-card-rise {
  0%   { z-index: 2; transform: translateY(0); box-shadow: 0 8px 22px -16px rgba(34, 30, 24, 0.5); animation-timing-function: cubic-bezier(0.22, 0.72, 0.24, 1); }
  55%  { z-index: 2; }
  56%  { z-index: 7; transform: translateY(calc(-1 * var(--rise))); box-shadow: 0 42px 64px -26px rgba(34, 30, 24, 0.5); animation-timing-function: cubic-bezier(0.45, 0, 0.55, 1); }
  81%  { z-index: 7; transform: translateY(calc(7px - var(--rest))); animation-timing-function: cubic-bezier(0.3, 0, 0.25, 1); }
  100% { z-index: 7; transform: translateY(calc(-1 * var(--rest))); box-shadow: 0 16px 34px -14px rgba(34, 30, 24, 0.42); }
}

/* Line heights are pinned rather than inherited from the body's 1.6, which is
   what pushed the content past the card and clipped the venue line. */
.env-flourish { font-family: "Pinyon Script", cursive; font-size: 20px; line-height: 1.15; color: var(--gold); }
.env-kicker { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--ink-faint); margin-top: 12px; }
.env-name { font-family: var(--font-display); font-weight: 600; font-size: 29px; line-height: 1.1; letter-spacing: -0.02em; margin-top: 8px; }
.env-name-2 { font-family: var(--font-secondary); font-size: 21px; line-height: 1.6; color: var(--ink-soft); margin-top: 2px; }
.env-rule { display: block; width: 46px; height: 1px; background: var(--gold); margin: 11px auto; }
.env-meta { font-size: 12.5px; color: var(--ink-soft); line-height: 1.7; }
.env-meta-2 { font-family: var(--font-secondary); font-size: 13px; color: var(--ink-soft); }

.env-card > * { opacity: 0; transform: translateY(8px); transition: opacity 0.55s ease, transform 0.55s ease; }
.env.is-open .env-card > * { opacity: 1; transform: none; }
.env.is-open .env-card > :nth-child(1) { transition-delay: 2.75s; }
.env.is-open .env-card > :nth-child(2) { transition-delay: 2.88s; }
.env.is-open .env-card > :nth-child(3) { transition-delay: 3.01s; }
.env.is-open .env-card > :nth-child(4) { transition-delay: 3.13s; }
.env.is-open .env-card > :nth-child(5) { transition-delay: 3.24s; }
.env.is-open .env-card > :nth-child(6) { transition-delay: 3.34s; }
.env.is-open .env-card > :nth-child(7) { transition-delay: 3.43s; }
.env.is-open .env-card > :nth-child(8) { transition-delay: 3.51s; }

/* The rewind, used by home.js's replay. Suppresses every animation and
   transition for one frame so the envelope can be snapped shut with nothing
   running, which is the only state a replay can safely start from.

   Not optional, and its absence is silent: without this rule the class still
   goes on and comes off, nothing is suppressed, and removing .is-open merely
   starts the flap's 350ms-delayed closing transition, which the immediate
   re-add then cancels by re-targeting the angle the flap already holds. The
   card replays, the flap never moves, and the page looks almost right. That
   is exactly what shipped in the first draft, and again in the port when this
   rule was left behind: test_the_replay_button_actually_reopens_the_flap is
   the only thing in the suite that can see it, because the class is applied
   by JS and so never appears in the markup the CSS sweeps read. */
.env.is-rewinding,
.env.is-rewinding *,
.env.is-rewinding *::before,
.env.is-rewinding *::after { animation: none !important; transition: none !important; }

/* The pocket. Its notch reaches the TOP CORNERS and dips to --vee in the
   middle, the exact inverse of the flap's triangle, so between them they cover
   the rectangle. An earlier pair (notch at 32%, flap ending at 56%) did not,
   and the card's top corners showed through before it had opened. */
.env-front { position: absolute; inset: 0; z-index: 4; border-radius: 6px; overflow: hidden; pointer-events: none; filter: drop-shadow(0 -1px 0 rgba(198, 180, 146, 0.95)); }
.env-front::before { content: ""; position: absolute; inset: 0; background: linear-gradient(168deg, #f5ecdc, #e6d6ba); clip-path: polygon(0 0, 50% var(--vee), 100% 0, 100% 100%, 0 100%); }
.env-front::after {
  content: ""; position: absolute; inset: 0;
  background:
    linear-gradient(103deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 46%),
    linear-gradient(to bottom right, rgba(160, 140, 105, 0.16), rgba(160, 140, 105, 0) 42%),
    linear-gradient(to bottom left, rgba(160, 140, 105, 0.16), rgba(160, 140, 105, 0) 42%);
  clip-path: polygon(0 0, 50% var(--vee), 100% 0, 100% 100%, 0 100%);
}

/* The flap takes its perspective from its OWN transform. `perspective` on a
   parent applies to that parent's direct children only, and the flap is a
   grandchild, so an earlier version rotated flat: it squashed instead of
   swinging. */
.env-flap {
  position: absolute; top: 0; left: 0; width: 100%; height: var(--vee); z-index: 5;
  transform-origin: top center; transform: perspective(1150px) rotateX(0deg);
  transform-style: preserve-3d;               /* so the seal rides it in 3D */
  transition: transform 1.15s cubic-bezier(0.58, 0.02, 0.32, 1) 0.35s;
}
.env-flap::before { content: ""; position: absolute; inset: 0; transform: translateZ(0); background: linear-gradient(186deg, #f3e9d8, #e4d3b6); clip-path: polygon(0 0, 100% 0, 50% 100%); transition: background 0.3s ease 0.78s; }
.env.is-open .env-flap { transform: perspective(1150px) rotateX(-174deg); animation: env-flap-behind 1.15s linear 0.35s forwards; }
/* Once past vertical the flap belongs BEHIND everything, including the card on
   its way up through the same space. */
@keyframes env-flap-behind { 0%, 44% { z-index: 5; } 45%, 100% { z-index: 0; } }
/* past vertical we are looking at the liner, not the outside of the flap */
.env.is-open .env-flap::before { background: linear-gradient(186deg, #e0cfb1, #efe6d4); }

/* The seal is stuck to the flap, so it IS a child of the flap and rides the
   rotation rather than being a sibling that fades out on its own. It sits at
   the flap's point and 2px proud of the paper so the two do not z-fight inside
   the preserve-3d context. backface-visibility retires it the instant the flap
   passes vertical, which is exactly where a real seal goes: face down on the
   underside of the open flap. At 90 degrees the element is edge-on and zero
   width, so the cut cannot be seen. */
.env-wax {
  position: absolute; left: 50%; top: 100%; width: 56px; height: 56px;
  margin: -28px 0 0 -28px; border-radius: 50%;
  transform: translateZ(2px);
  -webkit-backface-visibility: hidden; backface-visibility: hidden;
  background: radial-gradient(circle at 34% 30%, #d0705a, var(--clay) 45%, var(--clay-dark));
  box-shadow: 0 6px 16px -6px rgba(156, 70, 49, 0.8), inset 0 -2px 5px rgba(0, 0, 0, 0.25);
  display: grid; place-items: center; color: #fff2e8;
  font-family: var(--font-display); font-weight: 600; font-size: 15px; letter-spacing: 0.04em;
}

/* The controls arrive TOGETHER, at the end of the run, on the 3.95s beat the
   note used to carry on its own. A button that says "Play it again" while
   nothing has played yet is a small lie, and the old load timer hid it by
   accident: the envelope had always finished before a desktop reader could
   read the button. Now that the run waits for the reader, a phone reader
   arrives at a sealed envelope, so the button would sit there inviting them
   to replay something they have not seen.

   pointer-events, because an invisible button is still a tappable one, and a
   tap on it before the first play would rewind a run that had not happened.
   It also means a reader with JS blocked is not offered a control that could
   never do anything: nothing adds .is-open, so nothing appears. */
.env-controls { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; justify-content: center;
  opacity: 0; pointer-events: none; transition: opacity 0.6s ease 3.95s; }
.env-stage.is-open .env-controls { opacity: 1; pointer-events: auto; }
.env-replay { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--ink-soft); background: var(--paper-2); border: 1px solid var(--line); border-radius: 999px; padding: 7px 15px; cursor: pointer; transition: border-color 0.2s ease, color 0.2s ease, transform 0.2s ease; }
.env-replay:hover:not([disabled]) { border-color: var(--clay); color: var(--clay-dark); transform: translateY(-2px); }
.env-replay[disabled] { opacity: 0.45; cursor: default; }
.env-note { display: inline-flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); }
.env-note b { color: var(--clay); font-size: 15px; animation: note-bob 1.9s ease-in-out infinite; }
@keyframes note-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
.hero-music-credit { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); text-align: center; margin-top: var(--sp-2); }
.hero-music-credit a { color: var(--clay-dark); }

/* ---- The facts strip ----
   Product facts, not usage metrics: the platform has no customer numbers it
   could honestly put here. The 1px grid gap over a --line background is what
   draws the hairlines between cells. */
.facts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius-lg); overflow: hidden; margin-top: var(--sp-4); }
.fact { background: var(--paper-2); padding: 24px 22px; transition: transform 0.25s ease, background 0.25s ease; }
.fact:hover { transform: translateY(-3px); background: #f7f0e4; }
.fact-n { font-family: var(--font-display); font-weight: 600; font-size: 38px; line-height: 1; font-variant-numeric: tabular-nums; }
.fact-n em { font-style: normal; color: var(--clay); }
.fact-l { font-family: var(--font-mono); font-size: 10.5px; line-height: 1.5; letter-spacing: 0.05em; text-transform: uppercase; color: var(--ink-soft); margin-top: 11px; }

/* ---- Feature cards ---- */
.feature-grid { display: grid; gap: var(--sp-3); grid-template-columns: repeat(3, 1fr); }
.feature-grid--2 { grid-template-columns: repeat(2, 1fr); }
/* Blocks inside a section space themselves off the block above them.
   This was `.feature-grid + .feature-grid`, which covered exactly one of the
   pairings these sections actually use, so the day plan's panel sat flush
   against the two cards underneath it. Written as every ordered pair rather
   than as a margin on one of them, because which block comes first changes
   from section to section. :where() so it has zero specificity and any
   page-level override still wins, the same reasoning as `.panel + .panel`. */
:where(.feature-grid, .demo-panel, .rsvp-board, .roles, .tables)
  + :where(.feature-grid, .demo-panel, .rsvp-board, .roles) { margin-top: var(--sp-3); }
.feature-card { position: relative; background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 24px 22px; overflow: hidden; transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; }
.feature-card > * { position: relative; z-index: 1; }
/* the glow follows the pointer inside the card; home.js writes --gx/--gy */
.feature-card::before { content: ""; position: absolute; inset: 0; z-index: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; background: radial-gradient(240px circle at var(--gx, 50%) var(--gy, 50%), rgba(190, 91, 67, 0.14), transparent 62%); }
.feature-card:hover::before { opacity: 1; }
.feature-card:hover { transform: translateY(-3px); border-color: var(--line-strong); box-shadow: 0 18px 40px -26px rgba(34, 30, 24, 0.45); }
.feature-card h3 { font-size: var(--step-1); margin-bottom: var(--sp-1); }
.feature-card p { color: var(--ink-soft); font-size: 15px; line-height: 1.6; }
.feature-card-n { position: absolute; top: 18px; right: 20px; font-family: var(--font-mono); font-size: 10.5px; color: var(--ink-faint); z-index: 1; }
.feature-card--lead { border-color: var(--clay); box-shadow: 0 0 0 1px var(--clay) inset; }
.tagrow { display: flex; flex-wrap: wrap; gap: 6px; margin-top: var(--sp-3); }
.tag { font-family: var(--font-mono); font-size: 11px; color: var(--ink-soft); border: 1px solid var(--line); background: var(--paper); padding: 4px 9px; border-radius: 6px; transition: transform 0.18s ease, border-color 0.18s ease, color 0.18s ease; }
.tag:hover { transform: translateY(-2px); border-color: var(--clay); color: var(--clay-dark); }
.badge-new { display: inline-block; font-family: var(--font-mono); font-size: 9.5px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase; color: #fff; background: var(--sage); padding: 3px 8px; border-radius: 999px; vertical-align: middle; margin-left: 6px; }

/* ---- Demo panels (RSVP board, day plan) ----
   Named demo-panel rather than panel because .panel is a real host-portal
   component with fifteen rules of its own. */
.demo-panel { position: relative; background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 22px; overflow: hidden; }
.demo-panel > * { position: relative; z-index: 1; }
.demo-panel::before { content: ""; position: absolute; inset: 0; z-index: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; background: radial-gradient(280px circle at var(--gx, 50%) var(--gy, 50%), rgba(190, 91, 67, 0.10), transparent 62%); }
.demo-panel:hover::before { opacity: 1; }
.demo-panel h4 { font-family: var(--font-mono); font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--clay-dark); font-weight: 600; margin-bottom: var(--sp-3); }
.demo-note { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); margin-top: var(--sp-3); }

/* No margin-bottom: the pair rule above spaces whatever follows it, and having
   both would double the gap under the headcount board. */
.rsvp-board { display: grid; grid-template-columns: 1.15fr 1fr; gap: var(--sp-3); }
.tally { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-2); }
.tally-cell { background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 15px; }
.tally-v { font-family: var(--font-display); font-weight: 600; font-size: 32px; line-height: 1; font-variant-numeric: tabular-nums; }
.tally-c { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); margin-top: 7px; }
.tally-cell--yes .tally-v { color: var(--ok); }
.tally-cell--no .tally-v { color: var(--danger); }
.tally-cell--wait .tally-v { color: var(--ink-faint); }

.meter { margin-top: var(--sp-3); }
.meter-row { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; font-size: 13.5px; }
.meter-name { width: 88px; color: var(--ink-soft); flex: none; }
.meter-bar { flex: 1; height: 9px; background: var(--paper); border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
.meter-fill { display: block; height: 100%; width: 0; border-radius: 999px; background: var(--clay); transition: width 1.1s cubic-bezier(0.2, 0.7, 0.2, 1); }
.meter-fill--2 { background: var(--gold); }
.meter-fill--3 { background: var(--sage); }
.meter-qty { width: 34px; text-align: right; font-family: var(--font-mono); font-size: 12px; color: var(--ink-soft); font-variant-numeric: tabular-nums; flex: none; }

/* ---- Seating sketch ---- */
.tables { display: flex; flex-wrap: wrap; gap: 14px; margin-top: var(--sp-3); }
.tbl { width: 76px; height: 76px; border-radius: 50%; background: var(--paper); border: 1px solid var(--line-strong); position: relative; display: grid; place-items: center; font-family: var(--font-mono); font-size: 10px; color: var(--ink-faint); transition: transform 0.25s ease, border-color 0.25s ease; }
.tbl:hover { transform: translateY(-3px) scale(1.05); border-color: var(--clay); }
/* Six seats around a 76px table. Placed here rather than with a style
   attribute per dot: test_no_inline_styles_in_chrome_templates refuses those,
   and six repeated coordinates in the markup is the worse way to say it. */
.tbl i { position: absolute; width: 8px; height: 8px; border-radius: 50%; background: var(--ok); opacity: 0.9; }
.tbl i:nth-child(1) { top: 4px; left: 34px; }
.tbl i:nth-child(2) { top: 19px; left: 59px; }
.tbl i:nth-child(3) { top: 49px; left: 59px; }
.tbl i:nth-child(4) { top: 64px; left: 34px; }
.tbl i:nth-child(5) { top: 49px; left: 9px; }
.tbl i:nth-child(6) { top: 19px; left: 9px; }
/* an empty seat is an outline, so a half-full table reads at a glance */
.tbl--open i:nth-child(n + 5) { background: transparent; border: 1px dashed var(--line-strong); }

/* ---- Day plan filter ----
   An agenda item with NO crews belongs to everyone. That is the product's
   actual rule, and home.js implements it rather than approximating it. */
.crews { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: var(--sp-3); }
.crew { font-family: var(--font-mono); font-size: 11.5px; letter-spacing: 0.06em; border: 1px solid var(--line-strong); background: var(--paper); color: var(--ink-soft); padding: 6px 13px; border-radius: 999px; cursor: pointer; transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease, transform 0.2s ease; }
.crew:hover { transform: translateY(-2px); border-color: var(--clay); }
.crew[aria-pressed="true"] { background: var(--clay); border-color: var(--clay); color: #fff; }
.agenda { list-style: none; border-top: 1px solid var(--line); }
.agenda li { display: grid; grid-template-columns: 64px 1fr auto; gap: var(--sp-3); align-items: baseline; padding: 13px 4px; border-bottom: 1px solid var(--line); transition: opacity 0.35s ease, filter 0.35s ease; }
.agenda li.dim { opacity: 0.26; filter: grayscale(0.6); }
.agenda-time { font-family: var(--font-mono); font-size: 12.5px; color: var(--clay-dark); font-variant-numeric: tabular-nums; }
.agenda-title { font-size: 15px; }
.agenda-who { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--ink-faint); text-align: right; }

/* ---- Collaborator roles ---- */
.roles { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.role { position: relative; background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: 24px 22px; overflow: hidden; transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease; }
.role > * { position: relative; z-index: 1; }
.role::before { content: ""; position: absolute; inset: 0; z-index: 0; border-radius: inherit; pointer-events: none; opacity: 0; transition: opacity 0.3s ease; background: radial-gradient(240px circle at var(--gx, 50%) var(--gy, 50%), rgba(190, 91, 67, 0.14), transparent 62%); }
.role:hover::before { opacity: 1; }
.role:hover { transform: translateY(-3px); border-color: var(--line-strong); box-shadow: 0 18px 40px -26px rgba(34, 30, 24, 0.45); }
.role-cap { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--clay-dark); font-weight: 600; }
.role h3 { font-size: var(--step-1); margin: 8px 0 6px; }
.role p { color: var(--ink-soft); font-size: 14.5px; }
.role-does { margin-top: var(--sp-3); border-top: 1px dashed var(--line-strong); padding-top: 12px; font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); line-height: 1.7; }

/* ---- The assistant band ---- */
.ai-band { position: relative; border-radius: var(--radius-lg); overflow: hidden; border: 1px solid var(--line); background: var(--paper-2); }
.ai-band::before { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: var(--ai); background-size: 220% 100%; animation: ai-flow 7s linear infinite; }
.ai-inner { display: grid; grid-template-columns: 0.85fr 1.15fr; gap: var(--sp-4); padding: var(--sp-5) var(--sp-4); }
.ai-copy h2 { font-size: clamp(1.6rem, 1.2rem + 1.8vw, 2.5rem); line-height: 1.12; letter-spacing: -0.02em; text-wrap: balance; background: var(--ai); background-size: 260% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; animation: ai-sweep 9s ease-in-out infinite; }
.ai-copy p { color: var(--ink-soft); margin-top: var(--sp-3); max-width: 38ch; }
.ai-copy .tagrow { margin-top: var(--sp-4); }

.console { background: #1d1913; border: 1px solid #352d23; border-radius: 10px; padding: 18px 20px; font-family: var(--font-mono); font-size: 12.5px; line-height: 1.85; color: #d9cdb9; overflow-x: auto; box-shadow: var(--shadow-lg); }
.console-dots { display: flex; gap: 6px; margin-bottom: 14px; }
.console-dots i { width: 9px; height: 9px; border-radius: 50%; background: #3f362a; }
.console-dots i:first-child { background: #be5b43; }
.console-prompt { color: #f2e7d4; white-space: pre-wrap; min-height: 5.6em; }
.console-caret { display: inline-block; width: 7px; background: #e23e7b; animation: caret-blink 1s step-end infinite; color: transparent; }
@keyframes caret-blink { 50% { opacity: 0; } }
.console-line { display: grid; grid-template-columns: 16px 138px 1fr; gap: 10px; opacity: 0; transform: translateY(4px); transition: opacity 0.3s ease, transform 0.3s ease; }
.console-line.in { opacity: 1; transform: none; }
.console-ok { color: #7ec98f; }
.console-fn { color: #8ab4f8; }
.console-rs { color: #a99a84; }
.console-done { margin-top: 12px; padding-top: 12px; border-top: 1px solid #352d23; color: #f2e7d4; opacity: 0; transition: opacity 0.4s ease; }
.console-done.in { opacity: 1; }
.console-done b { background: var(--ai); background-size: 200% 100%; -webkit-background-clip: text; background-clip: text; color: transparent; -webkit-text-fill-color: transparent; font-weight: 600; }

/* ---- Steps ----
   .step is shared with magic_link_sent.html, wa_merge.html and
   day_plan_wa_merge.html, so it stays here even though the home page no
   longer renders one. */
.section-head { font-size: var(--step-2); max-width: 20ch; margin-bottom: var(--sp-4); }
.step { position: relative; padding-top: var(--sp-3); border-top: 2px solid var(--line-strong); }
.step h3 { font-size: var(--step-1); margin: var(--sp-1) 0; }
.step p { color: var(--ink-soft); font-size: var(--step-0); }

/* ---- Trust strip, a deliberate privacy-pledge band with a shield-check
   motif, so the message reads as an intentional trust statement rather than
   an orphaned mid-page footnote. Lighter than the .cta-band below it (a warm
   tint + gold hairline, no shadow) so the two bands don't merge. ---- */
.trust-strip { display: flex; align-items: flex-start; justify-content: center; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-5); padding: var(--sp-3) var(--sp-4); background: var(--clay-tint); border: 1px solid var(--gold); border-radius: var(--radius-lg); }
.trust-strip__icon { width: 1.6em; height: 1.6em; color: var(--clay-dark); flex: none; margin-top: 2px; }
.trust-strip p { margin: 0; max-width: 60ch; color: var(--ink-soft); font-size: var(--step--1); }
.trust-strip a { color: var(--clay-dark); white-space: nowrap; }

/* ---- Pricing ---- */
.pricing { text-align: center; margin-top: var(--sp-5); }
.pricing-card { display: inline-block; background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: var(--sp-5) var(--sp-4); max-width: 32rem; box-shadow: var(--shadow); }
.pricing-card h2 { font-family: var(--font-display); font-weight: 400; font-size: clamp(1.35rem, 1.1rem + 1.3vw, 2.1rem); margin-bottom: var(--sp-3); text-wrap: balance; }
.pricing-card p { color: var(--ink-soft); font-size: var(--step-0); max-width: 40ch; margin: 0 auto var(--sp-2); }
.pricing-card strong { color: var(--ink); }
.pricing-price { font-family: var(--font-display); font-size: var(--step-3); color: var(--clay-dark); margin: var(--sp-2) 0; }
/* Not a leftover: measured, this line still earns its place. The global .fine
   (one class, no type) loses on specificity to `.pricing-card p` just above
   (one class plus the `p` type), so without this the fine paragraph here would
   go back to reading at ink-soft/step-0 the moment .fine became global. */
.pricing-card .fine { color: var(--ink-faint); font-size: var(--step--1); }
.pricing-card .btn { margin-top: var(--sp-3); }

/* ---- Closing CTA ---- */
.cta-band { margin-top: var(--sp-5); padding: var(--sp-4) var(--sp-4) var(--sp-3); border-top: 1px solid var(--line); text-align: center; }
.cta-band h2 { font-size: var(--step-2); max-width: 22ch; margin: 0 auto var(--sp-3); text-wrap: balance; }
.music-credit { font-family: var(--font-mono); font-size: 11px; color: var(--ink-faint); margin-top: var(--sp-4); }
.music-credit a { color: var(--clay-dark); }

/* ---- Home responsive ---- */
@media (max-width: 900px) {
  .ai-inner { grid-template-columns: 1fr; }
  .rsvp-board { grid-template-columns: 1fr; }
  .feature-grid, .roles { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) {
  .facts { grid-template-columns: repeat(2, 1fr); }
  .feature-grid, .feature-grid--2, .roles { grid-template-columns: 1fr; }
  .sec-title { letter-spacing: 0.18em; }
  .agenda li { grid-template-columns: 56px 1fr; row-gap: 2px; }
  .agenda-who { grid-column: 2; text-align: left; }
  .console { font-size: 11px; }
  .console-line { grid-template-columns: 14px 108px 1fr; gap: 7px; }
  .console-prompt { min-height: 8em; }
}

/* Stack the hero on narrow viewports; the envelope follows the copy */
@media (max-width: 720px) {
  /* A tight row gap, and only because of what .env is: the envelope sits at
     the BOTTOM of a box that reserves the card's whole flight path above it,
     so the stage already opens with --rise of deliberate empty room. Stacked,
     var(--sp-5) put another 52px on top of that and the copy read as though
     it had been cut off from the envelope by half a screen of nothing. The
     column gap is irrelevant here, there is one column. */
  .hero { grid-template-columns: 1fr; gap: var(--sp-2); padding-top: var(--sp-4); }
  .hero-copy { max-width: none; }
  .env-stage { order: 2; }

  /* Collapse the nav into a hamburger: the links drop down as a full-width
     panel below the bar instead of overflowing it. */
  .site-nav .nav-toggle { display: flex; }
  .site-nav .nav-links {
    display: none; position: absolute; top: 100%; left: 0; right: 0; z-index: 100;
    flex-direction: column; align-items: stretch; gap: 0;
    background: var(--paper); border-bottom: 1px solid var(--line);
    box-shadow: var(--shadow-sm); padding: var(--sp-2) var(--sp-3);
  }
  .site-nav .nav-links.open { display: flex; }
  .site-nav .nav-links a,
  .site-nav .nav-links form button {
    display: flex; align-items: center; width: 100%; min-height: 44px;
    padding: 0.5em 0; font-size: var(--step-0); text-align: left;
  }
  .site-nav .nav-links form { width: 100%; }
  .site-nav .nav-links form button { justify-content: flex-start; }
}

/* Everything decorative stops. The envelope keeps its FINISHED state, card out
   and laid on the envelope with the flap open behind, so the product is still
   shown, it just does not perform. */
@media (prefers-reduced-motion: reduce) {
  .home-ambient::before, .home-ambient::after,
  .topbar, .scroll-progress { animation: none; }
  .js-reveal .reveal { opacity: 1; transform: none; transition: none; }
  .sec-head .sec-rule { transform: scaleX(1); transition: none; }
  .hero h1, .hero .eyebrow .spark, .ai-copy h2, .ai-band::before { animation: none; }
  .env-card { animation: none; transform: translateY(calc(-1 * var(--rest))); z-index: 7; box-shadow: 0 16px 34px -14px rgba(34, 30, 24, 0.42); }
  .env-card > * { opacity: 1; transform: none; transition: none; }
  .env-flap { transform: perspective(1150px) rotateX(-174deg); z-index: 0; transition: none; animation: none; }
  .env-flap::before { background: linear-gradient(186deg, #e0cfb1, #efe6d4); transition: none; }
  .env-wax { opacity: 0; }                 /* it is face down on the open flap */
  .env-controls { opacity: 1; pointer-events: auto; transition: none; }
  .env-note b { animation: none; }
  .console-line, .console-done { opacity: 1; transform: none; transition: none; }
  .console-caret { animation: none; }
  .meter-fill { transition: none; }
  .agenda li { transition: none; }
}

/* ---- Page header ---- */
.page-head { display: flex; flex-wrap: wrap; align-items: end; gap: var(--sp-3); justify-content: space-between; margin-bottom: var(--sp-4); padding-bottom: var(--sp-3); border-bottom: 1px solid var(--line); }
.page-head h1 { font-size: var(--step-3); }

/* ---- Event grid ---- */
.event-grid { display: grid; gap: var(--sp-3); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
.event-card { display: block; background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-4); box-shadow: var(--shadow-sm); transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; }
.event-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--line-strong); text-decoration: none; }
.event-card h2 { font-size: var(--step-1); margin-bottom: var(--sp-1); }
.event-card .meta { color: var(--ink-faint); font-size: var(--step--1); }

/* status chips */
.chip { display: inline-block; font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; font-weight: 700; padding: 0.25em 0.6em; border-radius: 100px; }
.chip-draft { background: var(--paper-3); color: var(--ink-soft); }
.chip-published { background: var(--clay-tint); color: var(--clay-dark); }
.chip-ok { background: #e8ede6; color: var(--ok); }

/* The add-a-person combobox, shared by honours.html and day_plan_manage.html.
   The list OVERLAYS rather than pushing the dialog's other fields down: a
   dialog that grows and shrinks under the pointer is how you misclick. */
.combo { position: relative; }
.combo-list { position: absolute; z-index: 2; inset-inline: 0; top: 100%;
              margin: 2px 0 0; padding: 4px; list-style: none;
              max-height: 15rem; overflow-y: auto;
              background: var(--paper); border: 1px solid var(--line);
              border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.combo-list[hidden] { display: none; }
.combo-group { padding: 6px 8px 2px; font-size: var(--step--1);
               letter-spacing: .08em; text-transform: uppercase;
               color: var(--ink-faint); }
.combo-opt { padding: 6px 8px; border-radius: 6px; cursor: pointer; }
/* The highlight follows the KEYBOARD, not only the mouse. aria-activedescendant
   is what a screen reader tracks, and a mouse-only highlight leaves somebody
   arrowing through with no idea where they are. */
.combo-opt[aria-selected="true"], .combo-opt:hover { background: var(--paper-3); }
.combo-empty { padding: 6px 8px; color: var(--ink-faint); font-style: italic; }

/* ---- Tables ---- */
table { width: 100%; border-collapse: collapse; font-size: var(--step-0); }
thead th { font-size: var(--step--1); letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-faint); text-align: left; font-weight: 700; padding: 0.6rem 0.75rem; border-bottom: 2px solid var(--line-strong); }
td { padding: 0.7rem 0.75rem; border-bottom: 1px solid var(--line); }
tbody tr:hover { background: var(--paper-3); }

/* ---- Stats ---- */
.stats { display: grid; gap: var(--sp-2); grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); margin-bottom: var(--sp-4); }
.stat { background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--radius); padding: var(--sp-3); }
.stat .n { font-family: var(--font-display); font-size: var(--step-2); display: block; }
.stat .k { font-size: var(--step--1); color: var(--ink-faint); letter-spacing: 0.06em; text-transform: uppercase; }

/* Tiles are links to the filtered guest list. Keep them reading as tiles,
   inherit the text colour, no underline, but give a clear hover and a
   visible keyboard focus ring. */
a.stat { display: block; color: inherit; text-decoration: none;
         transition: transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease; }
a.stat:hover { transform: translateY(-2px); box-shadow: var(--shadow-sm); border-color: var(--clay); }
a.stat:focus-visible { outline: 2px solid var(--clay-dark); outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) { a.stat { transition: none; } a.stat:hover { transform: none; } }

/* ---- Button variants ---- */
.btn-sm { font-size: var(--step--1); padding: 0.5em 0.9em; }
/* Compact button for tight table-row actions (e.g. remove/revoke), smaller than .btn-sm. */
.btn-compact { padding: 0.4em 0.8em; font-size: var(--step--1); }
/* Touch devices: small buttons (copy-link, toolbar actions) still need a
   44px tap target, the compact padding alone leaves them ~30px tall. */
@media (pointer: coarse) {
  .btn-sm { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
}
.btn-link { background: none; border-color: transparent; color: var(--clay-dark); padding: 0.2em 0.4em; font-weight: 600; box-shadow: none; }
.btn-link:hover { background: var(--paper-2); box-shadow: none; transform: none; text-decoration: underline; }
.btn-danger-link { color: var(--danger); }
.btn-danger-link:hover { background: var(--clay-tint); color: var(--danger); box-shadow: none; transform: none; }

/* ---- Inline form (label + compact input on one line) ---- */
/* Cost report: the seat budget + per-head rate (templates/events/cost_report.html).
   Two short numbers, so they sit on one line with the button; the help text
   breaks to its own row underneath rather than stretching the inputs. */
.cost-form input { max-width: 11rem; }
.cost-form > p { flex-basis: 100%; margin: 0; font-size: var(--step--1); }

.inline-form { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: flex-end; }
.inline-form label { margin-bottom: 0; }
.inline-form input { width: auto; margin-bottom: 0; }

/* ---- Person chips (gender-coded; used on guests + seating pages) ---- */
.person-chip {
  display: inline-flex; align-items: center; gap: 3px;
  padding: 0.2em 0.65em; border-radius: 999px; font-size: var(--step--1);
  /* 1.5px, and transparent on EVERY chip: the status ring is a border-color
     swap, so the width must be identical whether or not a chip has a status,
     otherwise attending chips would sit smaller than their neighbours and the
     row edges stop lining up. The half-pixel is deliberate, 1px read too faint
     against the pale fills, 2px was the heavy band this replaced. */
  margin: 2px; border: 1.5px solid transparent;
  text-decoration: none; color: var(--ink); font-weight: 500; cursor: pointer;
}
.person-chip:hover { text-decoration: none; opacity: 0.85; }
.person-chip-M { background: #dbeafe; }
.person-chip-F { background: #fce7f3; }
.person-chip-u, .person-chip- { background: var(--paper-3); }
/* RSVP status per person, not per household: a household where one person comes
   and three don't cannot be summarised by one chip. Gender owns the background,
   so status takes the border and a ring and the two never fight. color-mix
   because --rsvp-yes/--danger are variables and cannot be fed to rgba() as
   channels. Attending uses --rsvp-yes (neon), NOT --ok: --ok is the muted sage
   the success notices are built from, and repointing it would turn every notice
   on the site neon. Declined deliberately stays the muted brick --danger, it
   sits at 45% opacity as the de-emphasised state, and a matching neon red would
   make declines shout louder than acceptances.
   The .45 opacity on declined predates the ring and is unscoped on purpose: it
   applies on every view (filtered or not, see guests.html's rsvp_state use),
   same as it always has. The ring (border-color + box-shadow) is the new bit
   and only makes sense on the unfiltered list, where colour actually varies
   chip-to-chip, .guest-list--status is the ancestor guests.html adds only
   there (mirrors show_person_status). */
.person-chip.declined { opacity: 0.45; }
/* A thin line and a soft halo, no hard ring. An earlier version stacked a 3px
   ring on a 10px bloom, which on a list of twenty households read as a heavy
   band rather than a lit edge. The glow is a single blurred shadow with no
   spread, so it stays behind the chip instead of thickening it. */
.guest-list--status .person-chip.attending { border-color: var(--rsvp-yes);
  box-shadow: 0 0 7px 0 color-mix(in srgb, var(--rsvp-yes) 60%, transparent); }
/* No halo on declined: it is the de-emphasised state (45% opacity), and only
   the thing you're scanning for should glow. */
.guest-list--status .person-chip.declined { border-color: var(--danger); }
.person-chip.selected { border-color: var(--clay-dark); }
.person-chip.dragging { opacity: 0.4; }
.chip-remove { background: none; border: none; cursor: pointer; font-size: 1em; line-height: 1; padding: 0 0 0 4px; color: var(--ink-soft); }
.chip-remove:hover { color: var(--danger); }

.drag-over { outline: 2px dashed var(--clay); outline-offset: 3px; background: var(--clay-tint) !important; }

.dnd-toast {
  position: fixed; bottom: var(--sp-4); left: 50%; transform: translateX(-50%);
  background: var(--danger); color: var(--paper); padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius); font-size: var(--step--1); z-index: 1000;
  pointer-events: none;
}
/* Danger red is the default because every original caller was a failure. The
   htmx flows also report successes ("Added, it's hidden by your search"), and
   a success in the failure colour reads as a failure. */
.dnd-toast.is-info { background: var(--ink-soft); }

/* ---- Utilities ---- */
.muted { color: var(--ink-faint); }
/* Fine print: small, muted body text. Written once as `.pricing-card .fine`
   for the marketing pricing card and never promoted, so twelve of its
   thirteen uses (the event dashboard, guests, the cost report and the RSVP
   answers page) sat outside any .pricing-card and rendered as full-size body
   text: every one of those authors assumed .fine already worked everywhere,
   which is the same defect class recorded in template_library.html's own
   comment about .detail-list and .collab-role. Global now, so a bare .fine
   is what every future author's assumption is actually correct about.
   The pricing-card-scoped rule stays (see the pricing section further up)
   rather than going, because it is not actually redundant: `.pricing-card p` sits
   right next to it with a type selector, which outranks this bare class
   selector on specificity the moment there is no scoped .fine to win instead.
   Deleting it was tried first and measured: the pricing card's fine print
   went from 13.76px muted back to 17.28px ink-soft, a real regression this
   comment exists so nobody re-introduces. */
.fine { color: var(--ink-faint); font-size: var(--step--1); }
/* Visually hidden but available to screen readers (skip-links, field labels
   whose purpose is otherwise clear from context). */
.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;
}
.d-inline { display: inline; }
.m-0 { margin: 0; }
.mt-0 { margin-top: 0; }
.mt-2 { margin-top: var(--sp-2); }

/* Checkboxes/radios must not inherit the text-input box styling (border, padding,
   radius), render them as native controls, not little bordered boxes. */
input[type="checkbox"], input[type="radio"] {
  width: auto; margin-bottom: 0; padding: 0; border: 0; border-radius: 0;
  background: none; box-shadow: none;
  /* Without this the tick is the browser's own blue, which is the one colour on
     the page belonging to no palette: it read as a stray system control against
     clay and paper. `accent-color` tints the NATIVE control, so the checkbox
     keeps its platform shape, its keyboard behaviour and its high-contrast
     handling, none of which a hand-drawn replacement box keeps. */
  accent-color: var(--clay);
}

/* Stacked page-form field: label above a full-width control (contrast with
   .modal-field, which is the label-left/input-right layout for dialogs only). */
.field { margin-bottom: var(--sp-3); }
.field > label { display: block; margin-bottom: var(--sp-1); }
.field > input, .field > textarea, .field > select { width: 100%; margin-bottom: 0; }

/* Checkbox rows rendered by Django's {{ form.as_p }}: keep label + box inline
   instead of the label floating far from the control.

   THE HELP TEXT IS THE THIRD FLEX ITEM, and forgetting that is what made this
   row look broken. `as_p` emits label, input and `<span class="helptext">` as
   siblings, so a plain `display: flex` laid all three across one line: the
   label shrank to its min-content width and wrapped "Email me for every RSVP:"
   onto three lines, the box sat in the middle of them, and the help text took
   everything left over. Every field with help_text got it; only the ones with
   a long label looked wrong enough to notice.

   So: the box goes FIRST (`order: -1`), the label sits beside it and does not
   shrink, and the help text is pushed onto its own full-width line and
   indented to line up under the label. That is the shape a checkbox and its
   explanation have everywhere else in this app. */
form p:has(> input[type="checkbox"]) {
  display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
}
form p:has(> input[type="checkbox"]) > input[type="checkbox"] {
  order: -1; flex: 0 0 auto;
}
form p:has(> input[type="checkbox"]) > label {
  display: inline-block; margin: 0; flex: 0 1 auto;
}
form p:has(> input[type="checkbox"]) > .helptext {
  /* The basis is the line break: a full row, less the indent, cannot share a
     line with the two items above it, so it wraps whatever the label's length.
     It was `100%` plus the margin, which is wider than the row by exactly the
     indent and pushed the help text past the panel's edge on a phone. */
  flex: 1 0 calc(100% - 1rem - var(--sp-2));
  margin-left: calc(1rem + var(--sp-2));
  margin-top: var(--sp-1);
}

/* <label class="inline-check"> in a dialog: the checkbox and its wording on one
   line. Promoted out of questions_manage.html's page <style> block, where it was
   written as `.modal-form .inline-check` and therefore did nothing for the guests
   page's reassign dialog, which uses the same class. Measured in a browser there:
   display:block, align-items:normal, so the intended row with a gap was never
   applied and the only reason it did not look broken is the
   input[type=checkbox]{width:auto} rule above. Exactly the defect commit f00369d
   ("the new pages use classes that exist") exists to stop.

   Scoped to `dialog` rather than global on purpose. accounts/account_settings.html
   also uses .inline-check, on a full-page form, and that page is one of the ten
   tools/visual_regression.py pins, so a global rule would silently restyle it.
   Every .modal-form in questions_manage.html is inside a <dialog>, so that page
   renders identically with the page-level rule removed. */
dialog .inline-check { display: flex; align-items: center; gap: var(--sp-2); }
dialog .inline-check input { width: auto; margin: 0; }
.mt-3 { margin-top: var(--sp-3); }
.mt-4 { margin-top: var(--sp-4); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-3 { margin-bottom: var(--sp-3); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-5 { margin-bottom: var(--sp-5); }
.my-3 { margin: var(--sp-3) 0; }
.ml-auto { margin-left: auto; }
.mx-auto { margin-left: auto; margin-right: auto; }
.py-4 { padding: var(--sp-4) 0; }
.p-4 { padding: var(--sp-4); }
.text-right { text-align: right; }
.text-center { text-align: center; }
.text-soft { color: var(--ink-soft); }
.text-danger { color: var(--danger); }
.cursor-pointer { cursor: pointer; }
.font-mono { font-family: monospace; }
.bg-clay-tint { background: var(--clay-tint, #f3e0d8); }

.stack > * + * { margin-top: var(--sp-3); }
/* `.stack` only ever set the gap BETWEEN its children, so a `<ul class="stack">`
   kept the browser's bullets and its 40px indent. The same unstyled-element trap
   this file already records for `.detail-list`, `.collab-role` and `dialog menu`:
   markup that reads correctly and renders as a browser default.
   `design_home.html`'s invitations list is the only one, and it was invisible
   until now because it draws only on an event with more than one invitation.
   Generating a public invitation is what made that common. */
ul.stack, ol.stack { list-style: none; padding-inline-start: 0; margin: 0; }
.toolbar { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }

/* ── The kebab menu ────────────────────────────────────────────────────────
   Lived in an inline <style> in guests.html while the guests page was its only
   caller. The event page's Invitation row is the second, so it moved here
   rather than being copied: two divergent copies of a menu is how one of them
   quietly stops matching the other.

   MOVED VERBATIM apart from the two item selectors, which are widened below.
   Every rule keeps its specificity, and `.row-menu-group > button` at (0,2,1)
   already beat `.btn` at (0,1,0), so nothing depends on which file it sits in.

   Anchored to its own container rather than the top layer, so it needs no
   anchor positioning and works everywhere <details> does. */
.row-menu { position: relative; }
.row-menu > summary { cursor: pointer; list-style: none; user-select: none;
                      display: inline-flex; align-items: center; justify-content: center;
                      width: 2rem; height: 2rem; border-radius: 999px;
                      color: var(--ink-soft); font-weight: 700; line-height: 1;
                      font-size: var(--step-1); }
.row-menu > summary::-webkit-details-marker { display: none; }
.row-menu > summary:hover, .row-menu[open] > summary { background: var(--paper-3); color: var(--ink); }
.row-menu > summary:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
/* A LIGHT-DISMISS BACKDROP, and it is not decoration. On the guests page an
   open menu drops over the card below and lands exactly on that row's own
   kebab, so without this the click that looks like "open the next household's
   menu" hits an ITEM of the menu still open above it. The backdrop swallows
   that first click and closes the menu instead, which is what every dropdown
   does and what Playwright refuses to click through. */
.row-menu[open]::before { content: ""; position: fixed; inset: 0; z-index: 19; }
/* A hairline between the groups. Every template emits a group only when it
   holds something, so `:not(:first-child)` is exactly "there is a section above
   this one". `:empty` was tried and cannot work: a Django condition that renders
   no button still leaves the whitespace between its tags, and `:empty` matches
   only an element with neither children nor text. */
.row-menu-group { display: grid; }
.row-menu-group:not(:first-child) { border-top: 1px solid var(--line);
                                    margin-top: var(--sp-1); padding-top: var(--sp-1); }
.row-menu-items { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20;
                  min-width: 12rem; display: grid; padding: var(--sp-1);
                  background: var(--paper); border: 1px solid var(--line);
                  border-radius: var(--radius); box-shadow: var(--shadow-lg); }
/* `justify-content`, not `text-align`: app.css sets every button to
   `display: inline-flex; justify-content: center`, so text-align has nothing to
   act on and the items sat centred in a 12rem menu.

   THREE SHAPES, not one. The guests page's items are all bare <button>s; the
   event page's are a mix of <a> (Preview, Public invite, both of which leave
   the page), <button> (the copy-to-clipboard pair) and a <button> inside a
   <form> (Create public invitation, which POSTs). They have to look identical
   or the menu reads as three different kinds of thing. */
.row-menu-group > button,
.row-menu-group > a,
.row-menu-group > form > button { font: inherit; text-transform: none; letter-spacing: 0;
                           justify-content: flex-start; text-align: left; text-decoration: none;
                           display: block; width: 100%; margin: 0; padding: 0.5em 0.75em;
                           background: transparent; border: none; border-radius: var(--radius);
                           color: var(--ink); cursor: pointer; transform: none;
                           box-shadow: none; }
.row-menu-group > form { display: block; margin: 0; }
.row-menu-group > button:hover,
.row-menu-group > a:hover,
.row-menu-group > form > button:hover { background: var(--paper-2); color: var(--ink);
                                 transform: none; box-shadow: none; }

/* The unfurl preview. It used to sit inside `.toolbar` alongside the
   public-invite buttons, which is most of why that row read as cluttered: it is
   a thing to LOOK at before posting the link, not a verb, and it was competing
   with five of them. It now has its own strip under the row. */
.public-strip { display: flex; align-items: flex-start; gap: var(--sp-3);
                margin-top: var(--sp-3); flex-wrap: wrap; }
.public-strip .help { margin: 0; max-width: 52ch; }
.public-preview { margin: 0; text-align: center; }
.public-preview img { display: block; border-radius: var(--radius); border: 1px solid var(--line); }
.public-preview figcaption { font-size: var(--step--1); color: var(--ink-faint); margin-top: var(--sp-1); max-width: 120px; }

/* Modal footer. `<menu>` carried NO styling at all, so all nine dialog footers
   rendered with the browser default (display:block, list-style, and a 40px
   padding-inline-start) and their buttons sat left and indented. That is the same
   unstyled-element trap this file's other comments record for .detail-list and
   .collab-role: markup that looks right and renders as a browser default.

   Right-aligned, because that is where a dialog's confirm/cancel pair belongs and
   because every one of these already orders them Cancel then primary, so the
   primary lands closest to the corner the pointer is heading for. One rule rather
   than a class per template, so a tenth dialog is correct the day it is written.
   Scoped to `dialog` so a <menu> used as real navigation elsewhere is untouched. */
dialog menu {
    display: flex;
    justify-content: flex-end;
    flex-wrap: wrap;
    gap: var(--sp-2);
    align-items: center;
    margin: var(--sp-4) 0 0;
    padding: 0;
    list-style: none;
}

/* Panel header: title on the left, a primary action on the right.
   (Distinct from `.section-head`, which is the marketing section-title style.) */
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); }
.panel-head > :is(h1, h2, h3) { margin: 0; }

/* Grouped action bar: labelled clusters instead of one random row of buttons. */
.action-bar { display: flex; flex-wrap: wrap; gap: var(--sp-4) var(--sp-5); align-items: flex-start; margin-bottom: var(--sp-2); }
.action-group { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-group-label { font-size: var(--step--1); text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-faint); font-weight: 600; }
.action-group .toolbar { gap: var(--sp-2); }
hr { border: none; border-top: 1px solid var(--line); margin: var(--sp-4) 0; }
/* Wider vertical rhythm for an hr separating a major page section (e.g. the
   "Previous messages" divider on the broadcast page). A class always beats
   the bare `hr` element selector, so no ordering/specificity concern here. */
.hr-lg { margin: var(--sp-5) 0; }

/* ---- Collaborators ---- */
.collab-list { display: flex; flex-direction: column; gap: var(--sp-2); }
.collab-subhead { font-size: var(--step--1); text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-faint); font-weight: 600; margin: var(--sp-4) 0 var(--sp-2); }
.collab-row { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; padding: var(--sp-2) var(--sp-3); background: var(--paper-2); border: 1px solid var(--line); border-radius: var(--radius); }
.collab-row .who { flex: 1 1 12rem; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.collab-row .who.is-pending { font-weight: 500; color: var(--ink-soft); }
.collab-actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.allowance-field { display: inline-flex; align-items: center; gap: var(--sp-2); }
.allowance-field > label { margin: 0; font-size: var(--step--1); color: var(--ink-faint); font-weight: 600; }
.allowance-field > input { width: 4.5rem; margin-bottom: 0; text-align: center; }
.collab-empty { color: var(--ink-faint); }
.copy-link { font-family: var(--font-body); font-size: var(--step--1); background: var(--paper-2); border: 1px dashed var(--line-strong); border-radius: var(--radius); padding: 0.5rem 0.7rem; word-break: break-all; }
/* <pre class="copy-link copy-link-pre">, preformatted config snippets that need horizontal scroll instead of wrapping. */
.copy-link-pre { white-space: pre; overflow-x: auto; }

/* position:fixed + inset:0 + margin:auto re-centres modal dialogs; the global
   `* { margin:0 }` reset above otherwise strips the UA centering -> top-left. */
dialog { border: 1px solid var(--line); border-radius: var(--radius-lg); padding: var(--sp-4); box-shadow: var(--shadow-lg); width: min(520px, 92vw); max-width: 92vw; position: fixed; inset: 0; margin: auto; }
dialog::backdrop { background: rgba(34, 30, 24, 0.4); }
/* Modal headings run one step smaller than page headings; `.stack` on the form
   handles the vertical rhythm, so modal markup needs no inline spacing. */
/* All modal titles render at one size regardless of heading level (h2/h3). */
dialog h2, dialog h3 { font-size: var(--step-1); }
/* Some modal headings (e.g. seating's "Add tables") run one step smaller than
   the default dialog h2 with a fixed margin-bottom instead of relying on
   .stack; a single class beats the two-element `dialog h2` rule above. */
.modal-title-sm { font-size: var(--step-0); margin-bottom: var(--sp-3); }
/* Modal form rows: label left, input right-aligned to a common width so fields
   line up into a unified column. Use <label class="modal-field"><span>…</span><input></label>. */
.modal-field { display: flex; align-items: center; gap: var(--sp-3); margin-bottom: var(--sp-2); }
.modal-field > span { flex: 0 0 38%; color: var(--ink-faint); font-size: 0.85rem; }
.modal-field > input, .modal-field > select, .modal-field > textarea { flex: 1 1 auto; min-width: 0; margin-bottom: 0; }
/* A textarea was left out of the rule above, so it kept its intrinsic `cols`
   width and sat as a small fixed block beside a label sized for a full-width
   control. Both of honours.html's Words fields have looked like that since they
   were written, and the RSVP dialog's note reproduced it exactly, which is what
   an incomplete shared rule does: every page that reaches for it gets the same
   defect and each author assumes it is the house style. Found by screenshotting.
   The label goes to the TOP beside a multi-line control, because centring it
   against a three-row box leaves it floating in the middle of nothing. `:has`
   degrades to today's centred alignment where it is unsupported. */
.modal-field:has(> textarea) { align-items: flex-start; }
.modal-field:has(> textarea) > span { padding-top: 0.5em; }
/* .modal-field is defined after .mb-3 in this file, so a same-specificity
   class="modal-field mb-3" tie would let .modal-field's own margin-bottom win
   regardless of markup order (broadcast.html needs the wider sp-3 gap). The
   compound selector forces the intended precedence. */
.modal-field.mb-3 { margin-bottom: var(--sp-3); }
/* STACKED ON A PHONE, and this is the whole dialog family rather than one page.
   The 38% label column is right on a 520px dialog and wrong on a 345px one: at
   375px it leaves the control 127px while "Meal choice (usually required)"
   wraps to FOUR lines beside it, so the label reads as a paragraph and the
   select as an afterthought. Every dialog in the portal has looked like that on
   a phone, and a host recording a WhatsApp reply is the most likely person in
   this product to be holding one.

   640px matches the breakpoint app.css already uses elsewhere. The textarea
   rules above are horizontal-alignment rules, so they have to be unwound here:
   in a column, `align-items` is the cross axis and `stretch` is what makes a
   control full width, and the label's optical padding-top has nothing left to
   align against. */
@media (max-width: 640px) {
  dialog { padding: var(--sp-3); }
  .modal-field { flex-direction: column; align-items: stretch; gap: var(--sp-1); }
  .modal-field > span { flex: 0 0 auto; }
  .modal-field:has(> textarea) { align-items: stretch; }
  .modal-field:has(> textarea) > span { padding-top: 0; }
}

.invite-rule { display: flex; align-items: center; gap: var(--sp-2); color: var(--ink-faint); font-size: var(--step--1); }
.invite-rule::before, .invite-rule::after { content: ""; height: 1px; flex: 1; background: var(--line); }

/* Rate-limited notice (accounts/rate_limited.html) */
.rate-limited-notice { max-width: 32rem; margin: 4rem auto; text-align: center; }

/* Branded error pages (404/403/400/500), a centred wax-seal motif. */
.error-page { text-align: center; max-width: 34rem; margin: clamp(2.5rem, 11vh, 6rem) auto; padding: 0 var(--sp-3); }
.error-seal { width: 5rem; height: 5rem; margin: 0 auto var(--sp-3);
  background: url("../img/brand/seal-128.66efc540e5c0.png") center / contain no-repeat;
  filter: drop-shadow(0 8px 22px rgba(34,30,24,0.18)); }
.error-code { display: block; font-family: var(--font-display); font-size: var(--step-0);
  letter-spacing: 0.32em; text-transform: uppercase; color: var(--gold); margin-bottom: var(--sp-2); }
.error-page h1 { font-size: var(--step-4); margin-bottom: var(--sp-2); }
.error-page h1 em { font-style: italic; color: var(--clay-dark); }
.error-page p { color: var(--ink-soft); font-size: var(--step-1); margin-bottom: var(--sp-4); max-width: 30ch; margin-left: auto; margin-right: auto; }
.error-actions { display: flex; gap: var(--sp-2); justify-content: center; flex-wrap: wrap; }

footer.site-foot { width: 100%; max-width: 1080px; margin: 0 auto; padding: var(--sp-4) var(--sp-3); border-top: 1px solid var(--line); color: var(--ink-faint); font-size: var(--step--1); display: flex; flex-wrap: wrap; gap: var(--sp-3); justify-content: space-between; }

.hero-music-credit { font-size: var(--step--1); color: var(--ink-faint); margin-top: var(--sp-2); text-align: center; }
.music-credit { font-size: var(--step--1); color: var(--ink-faint); text-align: center; margin-top: var(--sp-4); }

@media (max-width: 640px) {
  .hide-on-mobile { display: none !important; }
}

/* The envelope on a phone.

   The empty room above the envelope is not padding, it is the card's flight
   path: --rise is --env-h minus the card's 15px bottom inset, because the card
   has to clear the envelope's top edge completely for the one frame where its
   z-index flips (see THE TIMELINE above). So the room cannot simply be
   deleted, and --card-h cannot shrink either: the card's copy needs 246px of
   the 250px it has, measured, which is why --env-h is 280 and the room above
   it is 265.

   What CAN move is the size of the whole performance, and on a phone it had to:
   at full size the stage is 665px tall, so on a 375x667 handset the peak of the
   card's arc was cut off 56px above the top of the screen and the reader never
   saw the part the reveal is for. One scale on .env-inner takes the envelope,
   the card, the flap, the seal and the arc together, and because it is a paint
   scale rather than a type change there is no chance of the card's copy
   clipping. .env's height carries the same factor, so the reserved room
   shrinks with what flies through it and the controls stay tight underneath.

   Kept out of the wider 720px block on purpose: a stacked 700px tablet has the
   room and does not want a smaller invitation. */
@media (max-width: 640px) {
  .env-stage { --env-scale: 0.8; }
  .env-inner { transform: scale(var(--env-scale)); transform-origin: bottom center; }
}

/* ---- Spacing / text utilities (raw px, these values don't line up with the
   --sp-*/--step-* scale, so they use an explicit "-Npx" suffix rather than
   the token-index naming (.mt-4 etc.) to avoid implying a scale relationship
   that isn't there.) ---- */
.mt-4px  { margin-top: 4px; }
.mt-6px  { margin-top: 6px; }
.mt-8px  { margin-top: 8px; }
.mt-12px { margin-top: 12px; }
.mb-4px  { margin-bottom: 4px; }
.mb-8px  { margin-bottom: 8px; }
.mb-10px { margin-bottom: 10px; }
.mb-12px { margin-bottom: 12px; }
.mb-16px { margin-bottom: 16px; }
/* Beats the page-local .wa-warning { margin-bottom: 12px } rule in wa_merge.html
   on specificity (two classes > one) regardless of source order. */
.wa-warning.mb-16px { margin-bottom: 16px; }
.gap-10px { gap: 10px; }
.text-80 { font-size: 0.8rem; }
.text-85 { font-size: 0.85rem; }
.text-875 { font-size: 0.875rem; }
.text-90 { font-size: 0.9rem; }
.text-step-neg1 { font-size: var(--step--1); }
/* Matches the wa_merge.html page-local <style> block, which is the point: both
   used to name a --color-* palette that tokens.css never defined, so both fell
   through to the hardcoded cool greys. They moved onto the real tokens together
   on 2026-08-31, and they have to keep moving together or this page grows two
   greys. */
.wa-text-muted { color: var(--ink-faint); }
.is-hidden { display: none; }

/* ---- WhatsApp merge page (templates/events/wa_merge.html) ---- */
.wa-step-title    { font-size: 1rem; margin-bottom: 12px; }
.wa-step-title-sm { font-size: 1rem; margin-bottom: 8px; }
.wa-msg-textarea {
  width: 100%; font-family: inherit; padding: 8px;
  border: 1px solid var(--line); border-radius: 6px;
  font-size: 0.9rem; resize: vertical;
}
.wa-country-code-input {
  width: 60px; margin-left: 6px; padding: 2px 6px;
  border: 1px solid var(--line); border-radius: 4px;
  font-size: 0.9rem;
}
.wa-delay-slider { width: 200px; }
/* Static portion of #selector-warning / #wa-notice; both start hidden and are
   toggled via el.style.display in wa-merge.js, so the display:none stays a
   bespoke inline style (removing it would leave them permanently hidden once
   the JS clears the inline style back to ''). */
.wa-selector-warning-text { font-size: 0.85rem; color: #dc2626; }
.wa-notice-box {
  font-size: 0.9rem; color: #92400e; background: #fef3c7;
  border: 1px solid #f59e0b; border-radius: 6px; padding: 8px 12px;
}

/* ---- Help page (templates/marketing/help.html) ---- */
.help-lede { color: var(--ink-soft); margin: 0; }
/* Two columns: a sticky nav on the left, the section cards on the right. */
.help-body {
  display: grid; grid-template-columns: 210px minmax(0, 1fr);
  gap: var(--sp-4); align-items: start;
}
.help-sections { min-width: 0; }
/* Numbering comes from a counter, not the markup, because sections are filtered
   per role: a designer sees a subset, and hardcoded numbers would read 1, 2, 11.
   The counter renumbers whatever is actually on the page. */
.help-sections { counter-reset: help-section; }
.help-sections .help-section h2 { counter-increment: help-section; }
.help-sections .help-section h2::before { content: counter(help-section) ". "; }

/* Jump-to: a sticky vertical menu that stays visible while scrolling. */
.help-jump {
  position: sticky; top: var(--sp-3);
  display: flex; flex-direction: column; gap: 2px;
  padding: var(--sp-3); background: var(--paper-2);
  border: 1px solid var(--line); border-radius: var(--radius-lg);
}
.help-jump > strong {
  font-size: var(--step--1); text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--ink-faint); margin-bottom: var(--sp-1);
}
.help-jump a {
  font-size: var(--step--1); color: var(--ink-soft); text-decoration: none;
  padding: 0.4em 0.6em; border-radius: var(--radius);
}
.help-jump a:hover { background: var(--paper); color: var(--clay-dark); text-decoration: none; }

/* On narrow screens, drop the sidebar: nav becomes a wrap of pills on top. */
@media (max-width: 720px) {
  .help-body { grid-template-columns: 1fr; }
  .help-jump {
    position: static; flex-direction: row; flex-wrap: wrap; align-items: center;
    gap: var(--sp-2); margin-bottom: var(--sp-4);
  }
  .help-jump > strong { margin-bottom: 0; margin-right: var(--sp-1); }
  .help-jump a { border: 1px solid var(--line); background: var(--paper); border-radius: 100px; }
}

/* Each section is its own card. */
.help-section {
  background: var(--paper); border: 1px solid var(--line); border-radius: var(--radius-lg);
  padding: clamp(1.4rem, 3vw, 2rem); box-shadow: var(--shadow-sm);
  margin-bottom: var(--sp-4); scroll-margin-top: var(--sp-4);
}
.help-section h2 { font-size: var(--step-1); margin-bottom: var(--sp-2); }
/* Global h3 is --step-1, the same size as the section heading, a subheading
   inside a section has to sit below it, not beside it. */
.help-section h3 { font-size: var(--step-0); margin: var(--sp-3) 0 var(--sp-1); }
.help-section p { margin-bottom: var(--sp-2); }
.help-section ul { padding-left: 1.4em; margin-bottom: var(--sp-2); }
.help-section li { margin-bottom: var(--sp-1); }
.help-section > :last-child { margin-bottom: 0; }

/* ---- Contacts page (templates/accounts/contacts.html) ---- */
.error-banner {
  background: #fee2e2; border: 1px solid #fca5a5; border-radius: var(--radius);
  padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-3); color: #991b1b;
}

/* ---- Broadcast page (templates/events/broadcast.html) ---- */
/* The broadcast channel picker, as a segmented control rather than two loose
   native radios. Two options that are mutually exclusive and change what the
   whole form does deserve to read as a choice, not as a pair of dots.

   The native input stays in the DOM and is only visually hidden, never
   `display: none`: it is what a keyboard tabs to, what a screen reader
   announces, and what the form posts. `:has(:checked)` styles the wrapping
   label from the input's own state, so there is no JS and nothing to re-apply
   after a swap. Focus is drawn on the LABEL via `:focus-visible` on the input,
   because the input itself is the thing being focused and it cannot be seen. */
.channel-row {
  display: flex; gap: var(--sp-2); align-items: center;
  font-size: var(--step--1); margin-bottom: var(--sp-3); flex-wrap: wrap;
}
.channel-option {
  display: inline-flex; align-items: center; gap: var(--sp-1);
  font-weight: 600; color: var(--ink-soft);
  padding: 0.45em 0.9em; border: 1px solid var(--line-strong);
  border-radius: 999px; background: var(--paper);
  cursor: pointer; user-select: none;
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}
.channel-option:hover { background: var(--paper-3); }
/* Clipped rather than hidden. `display:none` and `visibility:hidden` both take
   the control out of the tab order, which would make this keyboard-unreachable
   and leave the choice impossible to change without a mouse. */
.channel-option > input[type="radio"] {
  position: absolute; width: 1px; height: 1px;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
.channel-option:has(> input[type="radio"]:checked) {
  background: var(--clay-tint); border-color: var(--clay); color: var(--clay-dark);
}
.channel-option:has(> input[type="radio"]:focus-visible) {
  border-color: var(--clay); box-shadow: var(--ring);
}
.broadcast-history-title { font-size: var(--step-0); margin-bottom: var(--sp-3); }
.broadcast-date-cell { white-space: nowrap; font-size: var(--step--1); }

/* ---- Registry manage page (templates/events/registry_manage.html) ---- */
.table-scroll { overflow-x: auto; }
/* Every table sits in a .table-scroll, so a wide one scrolls inside its panel
   rather than pushing the page sideways (test_every_table_is_in_a_scroll_wrapper).
   Scrolling is the floor, not the goal: where a column can go on a phone without
   losing the row's meaning, hide it so the actions stay in view. */
@media (max-width: 520px) { .hide-narrow { display: none; } }
.row-actions { display: flex; flex-wrap: wrap; gap: .3rem; align-items: center; }

/* Gift registry manage page. These two classes were used from the day the page
   shipped but never had rules, so the table fell back to browser defaults. */
.registry-manage { display: flex; flex-direction: column; gap: 1rem; }
.registry-table { width: 100%; border-collapse: collapse; font-size: var(--step-0); }
.registry-table th,
.registry-table td { padding: 0.6rem 0.75rem; text-align: left; vertical-align: top;
  border-bottom: 1px solid var(--line-strong); }
.registry-table th { font-weight: 600; }

/* ---- RSVP answer summaries (guests + rsvp_answers pages) ----
   TWO pages render this component, so its rules live here rather than in either
   one of them. They used to live in `guests.html`'s `{% block extra_css %}`, and
   when the free-text answers moved to their own page on 2026-08-19 the markup
   went and the styling stayed behind: `rsvp_answers.html` drew three of these
   classes with no rule matching any of them. Nothing failed, because CSS that
   does not exist looks exactly like CSS that does nothing.

   Grepping `static/css/*.css` cannot catch that, and neither can grepping the
   template: the class was in one file and the rule in another, and both files
   were correct on their own. `test_every_class_on_a_host_page_has_a_rule` asks
   the stronger question, the same one
   `test_every_class_on_a_guest_utility_page_has_a_rule` already asks of the
   guest pages. */
.question-summary-list { display: grid; gap: var(--sp-3); }
.question-summary-item h3 { margin: 0 0 var(--sp-1); font-size: 1em; }
.question-summary-counts { display: flex; flex-wrap: wrap; gap: var(--sp-1); }

/* Not a bulleted list, and not "Name: what they typed" run together in one line.
   What a host reads here is a dietary note per person, so each answer is a row
   with the name as a quiet label above the sentence: a column of them scans by
   person, and an answer running to two lines stays inside its own block instead
   of wrapping under a marker.

   `app.css` resets all padding to 0, so the browser default `<ul>` this replaces
   had its bullets hanging outside the text column, half a character left of the
   paragraph above it. That misalignment was the visible half of the missing
   rules. */
.question-summary-texts { list-style: none; display: grid; gap: var(--sp-2); }
.question-summary-texts > li {
  display: grid; gap: 0.1em;
  padding: 0.1rem 0 0.1rem var(--sp-2);
  border-left: 2px solid var(--line-strong);
}
.question-summary-who {
  font-size: var(--step--1); font-weight: 700; color: var(--ink-soft);
}
/* No `max-width` reset needed: these are `<span>`s, not the `<p>` that `p { max-width: 68ch }` bounds. */
.question-summary-answer { color: var(--ink); }
/* One question answered and another not is a real state, since the page draws
   whenever ANY question has an answer. A dashed rule reads as absence, so an
   unanswered question does not look like a row of content. */
.question-summary-texts > li.muted { border-left-style: dashed; }

/* The answers page reads several questions in a row, so it needs the hierarchy
   the guests page does not. The shared `font-size: 1em` above was written for a
   compact panel of count chips sitting under its own h2, and applied here it left
   the prompt visibly lighter than the bold names underneath it: the heading read
   as a caption for the block it was supposed to introduce. Dropping back to the
   site's own h3 size fixes that without touching the other page, and the wider
   gap keeps two questions from reading as one list. */
.question-summary-reading { gap: var(--sp-4); }
.question-summary-reading .question-summary-item h3 {
  font-size: var(--step-1); margin-bottom: var(--sp-2);
}

/* Simple mode's four pickers. The swatch is the preset's own asset, never a
   rendered thumbnail: a thumbnail renderer would be a third renderer. */
/* The bottom margin matches the top. `.toolbar` carries none of its own, so
   "Open the editor" sat flush against the last row of preset cards, which
   only became visible once those cards had a real height. */
.picker-set { display: flex; flex-direction: column; gap: var(--sp-4);
              margin: var(--sp-4) 0; }
.picker { border: 0; padding: 0; margin: 0; }
.picker legend { font-size: .8rem; letter-spacing: .08em; text-transform: uppercase;
                 color: var(--ink-faint); padding: 0 0 var(--sp-2); }
.picker-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
               gap: var(--sp-2); }
/* Every tile in a row is the same height, and this is what makes that true.
   The grid item is the FORM, not the button inside it, so a grid's default
   `stretch` was stretching the form and leaving the button at its own content
   height. Invisible while every tile held the same content; the upload tile's
   footer made one form taller than the rest and the row went ragged, with the
   preset tiles top-aligned above a strip of gap. */
.picker-grid form { margin: 0; display: flex; }
.picker-grid form > .picker-opt { flex: 1 1 auto; }
/* `align-items: stretch` is not decoration: a <button> gets `align-items: center`
   from the UA stylesheet, so .picker-swatch, which has no content and no width of
   its own, computed to 0x0 and drew nothing whatever its colours were. Measured,
   not guessed. */
.picker-opt { display: flex; flex-direction: column; align-items: stretch;
              gap: 6px; width: 100%;
              padding: 8px; background: var(--paper); cursor: pointer;
              border: 2px solid var(--line); border-radius: 8px;
              font: inherit; color: inherit; text-align: center; }
.picker-opt:hover { border-color: var(--clay); }
.picker-opt.is-picked { border-color: var(--clay); box-shadow: 0 0 0 3px var(--clay-tint); }
.picker-swatch { display: block; border-radius: 4px; background-color: var(--paper-2);
                 background-size: cover; background-position: center;
                 border: 1px solid var(--line); }
.picker-swatch--layout, .picker-swatch--background { aspect-ratio: 4 / 5; }
.picker-swatch--stamp { aspect-ratio: 4 / 5; background-size: contain;
                        background-repeat: no-repeat; }
.picker-swatch--seal { aspect-ratio: 1 / 1; background-size: contain;
                       background-repeat: no-repeat; }
.picker-name { font-size: .78rem; line-height: 1.3; }
.simple-preview { width: 100%; aspect-ratio: 4 / 5; max-width: 420px; border: 0;
                  border-radius: 8px; background: var(--paper-2); }
.part-picker { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin-top: var(--sp-4); }
.part-btn { font: inherit; font-size: var(--step--1); font-weight: 600; text-transform: none;
            letter-spacing: 0; color: var(--ink-soft); background: var(--paper-2);
            border: 1px solid var(--line); border-radius: 999px; padding: 0.25em 0.9em;
            margin: 0; width: auto; cursor: pointer; transform: none; box-shadow: none; }
.part-btn:hover { background: var(--paper-3); transform: none; box-shadow: none; }
.part-btn.is-active { background: var(--clay); border-color: var(--clay); color: #fff; }
/* The upload tile: a `.picker-opt` (it shares the same grid cell and swatch
   sizing) drawn as a dashed invitation to add a photograph rather than a
   choice already offered. `.is-filled` switches to the same solid border a
   picked preset gets, once the host's own image is showing in the swatch. */
/* The tile is the FORM here, not a label, so that the no-JS submit button can
   be a footer inside the bordered box. Put it after the label and it hangs
   below the grid, which is what shipped: every picker with an upload slot drew
   a stray button under its row. It cannot go inside the label instead, because
   a label may hold no labelable descendant but its own control and a button is
   labelable. `.picker-grid form { margin: 0 }` above already makes the form the
   grid item, so this needs no new layout. */
.picker-opt--upload { border-style: dashed; cursor: pointer; }
.picker-opt--upload.is-filled { border-style: solid; }
/* The label carries the click target and the swatch, and grows so the footer
   sits on the tile's bottom edge whatever the row's height, the same
   `margin-top: auto` trick the seating card's icons use. */
.upload-face-wrap { display: flex; flex-direction: column; gap: 6px;
                    flex: 1 1 auto; cursor: pointer; }
.upload-face { display: flex; align-items: center; justify-content: center;
               width: 100%; height: 100%; }
.plus { font-size: 1.6rem; line-height: 1; color: var(--ink-faint); }
/* A quiet footer, never a second call to action: the host uploads by picking a
   file and app.js submits on change, so this is only ever pressed with
   JavaScript off. Sized and coloured to read as part of the tile rather than as
   a button competing with "Open the editor" below the picker set. */
.picker-upload-go { font: inherit; font-size: 0.72rem; text-transform: none;
                    letter-spacing: 0; margin: auto 0 0; padding: 0.25em 0;
                    width: 100%; border: 0; border-top: 1px dashed var(--line);
                    border-radius: 0; background: transparent;
                    color: var(--ink-faint); cursor: pointer;
                    transform: none; box-shadow: none; }
.picker-upload-go:hover { color: var(--clay); border-top-color: var(--clay);
                          background: transparent; transform: none;
                          box-shadow: none; }

/* Permission grid, shared by the OAuth consent screen and the connections page.
   One component, two mounts: the interactive behaviour (collapse, mass actions,
   live counts) lives in static/js/permission-grid.js and is delegated through
   the data- attributes here, never an inline handler. */
.grid-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2);
                align-items: center; margin: var(--sp-3) 0; }
/* A COLLAPSED GROUP IS A ROW, not an empty box. It was a <fieldset> with a
   <legend>, which paints a border around a body that is `hidden`, so fourteen
   collapsed groups rendered as fourteen empty rectangles with a caption floating
   on the edge of each. At phone width the legend's buttons wrapped out of the
   border entirely. A plain div with a flex head has neither problem. */
.grid-group { border: 1px solid var(--line); border-radius: var(--radius);
              padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
.grid-group[data-outbound] { border-color: var(--clay); background: var(--paper-2); }
/* space-between so the count and the All/None pair land on the SAME x on every
   row. Following the group name inline put them at fourteen different positions,
   which is what made the list read as ragged. */
/* Deliberately NOT flex-wrap. Wrapping dropped the count and the All/None pair
   onto a second line for the longer group names only, so at phone width some rows
   were one line tall and some two, and the list read as ragged. Without it the
   name wraps inside its own column instead and the controls stay pinned right on
   every row, which is the consistency that matters more than the saved line. */
.grid-group-head { display: flex; align-items: center; justify-content: space-between;
                   gap: var(--sp-2); }
.grid-group-name { background: transparent; border: 0; padding: 0;
                   font-weight: 600; cursor: pointer; color: var(--ink);
                   text-align: left; display: flex; align-items: center;
                   /* flex-start explicitly: the shared button rule centres its
                      content, which on a full-width flex row put every group
                      name at a different x and read as centred text. */
                   justify-content: flex-start; white-space: normal;
                   gap: var(--sp-1); min-width: 0; flex: 1 1 auto; }
.grid-group-meta { display: flex; align-items: center; gap: var(--sp-2);
                   margin-left: auto; flex-shrink: 0; }
/* The affordance the first version had none of: nothing said a group could open,
   so the rows read as inert labels. */
.grid-chevron { width: 0; height: 0; flex-shrink: 0;
                border-left: 0.42rem solid var(--clay);
                border-top: 0.42rem solid transparent;
                border-bottom: 0.42rem solid transparent;
                transition: transform 120ms ease; }
.grid-group-name[aria-expanded="true"] .grid-chevron { transform: rotate(90deg); }
/* A chevron alone was too quiet to read as an affordance: reported as "there is
   no way to know that this is expandable". So the whole row answers the pointer
   too, which is the signal people actually notice. The button already spans the
   row (flex: 1 1 auto), so the hover target is the name AND the space after it,
   not just the few characters of text. */
.grid-group:hover { border-color: var(--line-strong); }
.grid-group-name:hover .grid-chevron,
.grid-group-name:focus-visible .grid-chevron { border-left-color: var(--ink); }
.grid-group-name:hover { color: var(--clay); }
@media (prefers-reduced-motion: reduce) { .grid-chevron { transition: none; } }
/* Phone: the controls take about 170px, which left the longest group name
   ("Sending, money and deleting") wrapping onto four lines while its neighbours
   sat on one. Giving the name the whole first row and the controls the whole
   second makes all fourteen rows the SAME shape, which is worth more here than
   the line it costs. A breakpoint rather than a wrap, because wrapping only the
   rows that needed it was the ragged version. */
@media (max-width: 480px) {
  .grid-group-head { flex-wrap: wrap; }
  .grid-group-name { flex: 1 0 100%; }
  .grid-group-meta { margin-left: auto; }
}
.grid-group-note { font-size: var(--step--1); color: var(--ink-soft);
                   margin: var(--sp-1) 0 0; }
.grid-group-body { display: flex; flex-direction: column; gap: var(--sp-1);
                   margin-top: var(--sp-2); }
.grid-tool { display: block; font-size: var(--step--1); color: var(--ink-soft); }
.grid-total { margin-left: auto; }
.consent-title { margin-bottom: 0; }
.consent-sub { font-size: var(--step-1); color: var(--ink-soft); margin: 0 0 var(--sp-3); }
.consent-facts { display: grid; grid-template-columns: auto 1fr; gap: 0.35rem var(--sp-2);
                 margin: 0 0 var(--sp-3); font-size: var(--step--1); }
.consent-facts dt { color: var(--ink-soft); }
.consent-facts dd { margin: 0; }
.consent-flag { font-size: var(--step--1); color: var(--ink-soft);
                border-left: 2px solid var(--clay); padding-left: var(--sp-2);
                margin: 0 0 var(--sp-2); }
.consent-lede { margin: 0 0 var(--sp-2); }
.consent-actions { display: flex; gap: var(--sp-2); align-items: center;
                   margin-top: var(--sp-3); flex-wrap: wrap; }
.consent-footnote { font-size: var(--step--1); margin-top: var(--sp-2); }
/* Busy submit. Paired with the handler in app.js, which deliberately does NOT
   set `disabled`: a disabled control is not submitted, and the consent screen's
   Cancel carries name="deny", so disabling it would post an approval instead.
   pointer-events is what stops the second click; the spinner is what says why. */
.form-busy { pointer-events: none; }
button.is-busy { position: relative; }
button.is-busy::before { content: ""; display: inline-block;
                         width: 0.8em; height: 0.8em; margin-right: 0.45em;
                         vertical-align: -0.1em; border-radius: 50%;
                         border: 2px solid currentColor;
                         border-right-color: transparent;
                         animation: btn-spin 620ms linear infinite; }
@keyframes btn-spin { to { transform: rotate(360deg); } }
/* Reduced motion keeps the RING, which is the part that carries the meaning,
   and drops only the rotation. */
@media (prefers-reduced-motion: reduce) {
  button.is-busy::before { animation: none; }
}
/* The Copy button sat BELOW the address it copies, which reads as a second,
   unrelated control. Beside it, the pair is one thing. min-width:0 so a long
   address shrinks rather than pushing the button off the row. */
.copy-row { display: flex; align-items: center; gap: var(--sp-2);
            margin-bottom: var(--sp-3); }
.copy-row .copy-link { flex: 1 1 auto; min-width: 0; margin: 0; }
.steps { margin: 0 0 var(--sp-3); padding-left: 1.2rem;
         display: flex; flex-direction: column; gap: var(--sp-2);
         font-size: var(--step--1); }
.steps li { padding-left: 0.2rem; }
