/* Faithy web — design system.
   One stylesheet, custom properties for theming, mobile-first with two breakpoints:
   - phone            single column, bottom tab bar (edge-to-edge modules under 620px)
   - >= 900px         navigation rail + feed
   - >= 1180px        rail + feed + communities sidebar
   Light/dark follow the OS preference unless the user pinned a theme (data-theme on <html>).

   Two page widths: --page-w (1104px) is the feed, --page-w-wide (1376px) is opt-in per page with
   `.shell.wide`, for the screens that are read across rather than down.

   ---- Everything directional in here is a logical property ----

   margin-inline / padding-block / inset-inline-start / border-inline-start / text-align: start —
   never margin-left, never top, never text-align: right. Arabic and Hebrew scripture put a
   right-to-left page on this product's road map, and a sheet that is half-converted is worse than
   one that is not converted at all: the mirrored half and the unmirrored half fight, and the bug
   is a layout that looks deliberate. Verified by walking the feed, a profile and a chat with
   dir="rtl" on <html>.

   There are exactly two exceptions, both of them things that are genuinely physical:

   - `env(safe-area-inset-*)`. The notch is milled into one side of the glass and does not move
     when the text does. Those declarations stay physical, and say so where they sit.
   - `background-position` on the valid-field tick, which has no logical form. It carries an
     explicit `:dir(rtl)` flip instead.

   Centring is done with `inset-inline: 0` + `margin-inline: auto`, never with the 50% inset plus
   `translateX(-50%)` idiom: `translateX` is physical and the pair lands a full width off centre
   on an RTL page.

   The visual language is VK's: a grey page carrying flat white modules with no border and no
   shadow, one blue accent, and a single grey for every piece of secondary text. Depth is spent
   only where something genuinely floats above the page (menus, modals, toasts) — inside the page,
   separation comes from the gap between a white module and the grey behind it. */

:root {
  color-scheme: light dark;

  --bg: #edeef0;
  --surface: #ffffff;
  --surface-2: #f9f9f9;
  --surface-3: #f0f2f5;
  --border: #e7e8ec;
  --border-strong: #d7d8d9;
  /* The boundary a CONTROL is recognised by, and it is a separate token from the two above on
     purpose. `--border` is a hairline between things — a divider, a card's edge — and WCAG 1.4.11
     has nothing to say about those. This is the other kind: the edge of a text field, the ring of
     an unticked roll-call check, the circle of a step marker. Those carry information ("this is a
     box you can type in", "this name is not marked yet") and are required to reach 3:1 against
     what surrounds them, which #d7d8d9 did not — it is 1.43:1 on white, a hairline a leader
     marking four hundred names in a bright hall cannot see at all.
     One token rather than darkening `--border-strong`, because that one is a HOVER accent in
     nineteen of its twenty uses and a mid-grey hover border on every card would be a design change
     rather than a fix. */
  --control-edge: #86898c;
  --text: #000000;
  /* The greys, and the tier that did not survive the arithmetic.
     `--muted` was #818c99 (VK's own) at 3.42:1 on white and 2.94:1 on the page ground — below AA
     for body text on both, and it carries every timestamp, every row subtitle and every field
     label in the app. `--faint` was #99a2ad, quieter still.
     Both are now at 4.5:1, which means the three-tier scale this file used to have is really two:
     there is no third readable grey on white, and there never was. What separates a hint from a
     subtitle now is size and weight, which is where that distinction always actually lived. */
  --muted: #636d7a;
  --faint: #6c7886;

  /* Modern VK accent — vk.com moved from the old #2787f5 to #0077ff in its rebrand, and the whole
     page hangs off this one blue. The dark theme keeps VKUI's softened #529ef4.
     Darkened one step from #0077ff, and the reason is that this token is BOTH a text colour and a
     button fill: #0077ff was 4.13:1 against white either way round, so a link in a post body and
     the word "Send" on a primary button failed the same check for opposite reasons. Contrast is
     symmetric, so one value fixes both — 5.13:1 now, and it is still recognisably the same blue. */
  --primary: #0069e0;
  --primary-strong: #0062cc;
  --primary-soft: #e7f0fa;
  --on-primary: #ffffff;
  /* The blue-grey fill of a secondary button and of a selected menu row, with the text that sits
     on it. Quieter than the accent and warmer than plain grey — VK uses it for anything offered
     but not urged. */
  --tint: #e5ebf1;
  --tint-text: #55677d;
  /* The status colours, all four darkened to carry TEXT rather than only a tone. Each of them is
     read as a word somewhere — `.scripture-ref` is gold, `.ok-text` is green, a form error is red —
     and each was between 2.68:1 and 4.46:1 on white. They are also each checked against their own
     15–18% tint, because a chip draws the same colour on a wash of itself and that is the tighter
     of the two constraints every time. */
  --gold: #8e620d;
  --ok: #327732;
  --warn: #a66900;
  --bad: #d51d1d;
  --bad-soft: #faebeb;

  /* The devotional surface: paper and ink, for the places a member reads at length rather than
     scans — a chapter, a daily verse, a prayer someone wrote out in full. The app's white module
     on grey page is built for scanning a feed, and it is the wrong ground for a passage: pure
     white at reading length glares, and the blue accent keeps asking to be looked at.
     These four are the only colours this sheet has gained. Everything a devotional screen needs
     is expressed with them or with the tokens already above; there is no fifth. */
  --paper: #faf7f2;
  --paper-2: #f3ede3;
  --ink: #221f1a;
  /* Darkened from #7a7268. This is the one that mattered most and showed least: it is the verse
     number, the masthead's byline and the footnote on the DEVOTIONAL surface — the screens whose
     entire purpose is being read for minutes at a time — and it was 4.43:1 on paper and 4.07:1 on
     the second paper. Under AA on the reading ground, which is the worst place in this app to be
     under it. */
  --ink-muted: #726b61;

  --radius: 12px;
  --radius-sm: 8px;
  --radius-pill: 999px;
  /* Modules are flat; only overlays cast anything. */
  --shadow: none;
  --shadow-lg: 0 4px 32px rgba(0, 0, 0, .16), 0 0 4px rgba(0, 0, 0, .08);

  --topbar-h: calc(48px + env(safe-area-inset-top, 0px));
  --tabbar-h: calc(58px + env(safe-area-inset-bottom, 0px));
  /* The grid the header and the page share. The feed column is deliberately narrow — a column of
     around 550px is what makes the page read as a feed rather than as a document, and it is why
     the sidebar is the wider of the two flanks. */
  --page-w: 1104px;
  /* The second width, for the pages that are read across rather than down: the member registry,
     the service scheduler, the giving ledger. A table of a dozen columns folded into the 584px
     feed slot turns every row into three, and the eye loses the row it was on. Opt in per page
     with `.shell.wide`; the feed and everything else stays on --page-w. */
  --page-w-wide: 1376px;
  --col-nav: 168px;
  /* The two rail variants. --col-nav is what the shell's grid track and the header's brand slot
     both read, so a variant swaps this one value rather than resizing the rail on its own. */
  --col-nav-icons: 56px;
  --col-nav-wide: 208px;
  --col-side: 320px;
  --gap: 16px;
  --topbar-bg: #ffffff;
  /* ---- the two stacks, and the tail each of them grew for Arabic and Hebrew ----
   *
   * A font-family list falls through PER CHARACTER, not per element: the first family in the list
   * that has a glyph for this character is the one that draws it. So appending Arabic and Hebrew
   * faces after the Latin ones cannot change a single Latin letter anywhere in the app — it only
   * decides what happens to the characters the Latin faces have never had.
   *
   * Without a tail, that decision is the browser's default font, and for --sans that is usually
   * survivable. For --serif it is not, and it is the one that matters most: --serif is `.reading`,
   * which is the surface scripture is read on, and its Latin faces (Iowan, Palatino, Georgia) have
   * no Arabic or Hebrew at all. A sura set in the browser's fallback UI sans, inside a page whose
   * whole design argument is that a reader should know the voice before reading a word, is the
   * failure this token exists to prevent — arriving only for the readers it was added for.
   *
   * `font-src 'self'` (http_static.go) means system faces only: there is no web font to reach for
   * and there should not be one, on a prepaid bundle, for a script whose faces are large. So each
   * tail names what the platforms actually ship — Apple, Windows, then the Noto families Android,
   * ChromeOS and most Linux desktops carry — and ends at the generic, which is the honest floor. */
  --sans:
    -apple-system, BlinkMacSystemFont, system-ui, "Segoe UI", Roboto, "Helvetica Neue",
    /* Arabic, then Hebrew. "Segoe UI" and Tahoma above and below carry both on Windows; these are
       what the other platforms answer with. */
    "SF Arabic", "Geeza Pro", "Noto Sans Arabic", "Droid Arabic Naskh",
    "SF Hebrew", "Arial Hebrew", "Noto Sans Hebrew", "Droid Sans Hebrew",
    Tahoma, sans-serif;
  --serif:
    "Iowan Old Style", "Palatino Linotype", Palatino, Georgia,
    /* Naskh for Arabic — the style a printed mushaf and a printed book are both set in, and the one
       a reader expects a long passage in. NOT a Kufi or a display face: those are for headings, and
       a chapter set in one reads the way a Latin chapter set in a poster face reads. */
    "Noto Naskh Arabic", "Al Bayan", "Baghdad", "Traditional Arabic", "Simplified Arabic",
    /* Hebrew. Times New Roman is last of these and genuinely earns its place — it carries a full
       Hebrew serif and is on nearly every machine, which is what makes it the floor rather than a
       guess. */
    "Noto Serif Hebrew", "David Libre", David, "Frank Ruehl CLM", "Times New Roman",
    serif;
}

/* The dark palette, applied both by OS preference and by an explicit pin. */
/* Dark is not the light theme inverted. The page goes near-black and the modules sit only a shade
   above it, so the separation that white-on-grey gets for free has to come from very little
   contrast — which is also why nothing here carries a border. A secondary button is plain grey
   rather than tinted blue: on this background the blue-grey of the light theme turns muddy. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #0f0f0f;
    --surface: #1c1c1d;
    --surface-2: #232324;
    --surface-3: #2c2d2e;
    --border: #2a2a2b;
    --border-strong: #3a3a3b;
    --control-edge: #757577;
    --text: #e1e3e6;
    --muted: #8a94a0;
    --faint: #84868a;
    --primary: #529ef4;
    --primary-strong: #7cb9f7;
    --primary-soft: #1d3d63;
    /* NOT white, and this is the one token that inverts between the themes.
       On a dark ground the accent has to be LIGHT to work as text — #529ef4 is 6.13:1 on the module
       surface, which is what makes a link readable. That same lightness is what makes white on top
       of it 2.78:1: the word "Send" on a primary button, the text of one's own chat bubbles, the
       number in the composer's current step. All of it failed, and all of it looked fine, because
       white-on-blue is what a button is supposed to look like.
       So in dark mode a light-blue button takes dark text — 6.90:1 — which is what every platform
       that solved this before us also concluded. */
    --on-primary: #0f1114;
    --tint: #2c2d2e;
    --tint-text: #e1e3e6;
    --gold: #e0ac48;
    --ok: #4bb34b;
    --warn: #d9a03c;
    --bad: #ff5c5c;
    --bad-soft: #3a2222;
    --shadow-lg: 0 4px 32px rgba(0, 0, 0, .6), 0 0 4px rgba(0, 0, 0, .4);
    --topbar-bg: #1c1c1d;
    /* Paper has no dark counterpart to invent. The warm off-white reads as paper because paper is
       what a lit page looks like; warming the dark theme's near-black by the same amount just
       produces a brown screen, and it would be a fifth, sixth and seventh colour in a file that
       has exactly four new ones. So the devotional surface resolves to the dark palette already
       above — by reference, not by copied hex, so it cannot drift when that palette is retuned.
       What carries the reading voice in dark mode is the serif and the measure, which are not
       colours and survive the theme intact. */
    --paper: var(--surface);
    --paper-2: var(--surface-2);
    --ink: var(--text);
    --ink-muted: var(--muted);
  }
}

:root[data-theme="dark"] {
  --bg: #0f0f0f;
  --surface: #1c1c1d;
  --surface-2: #232324;
  --surface-3: #2c2d2e;
  --border: #2a2a2b;
  --border-strong: #3a3a3b;
  --control-edge: #757577;
  --text: #e1e3e6;
  --muted: #8a94a0;
  --faint: #84868a;
  --primary: #529ef4;
  --primary-strong: #7cb9f7;
  --primary-soft: #1d3d63;
  /* Dark text on the light accent — see the note in the media-query block above. The two dark
     palettes are one palette written twice and Test_the_dark_palettes_agree pins that they stay
     identical, so this comment is deliberately the short one. */
  --on-primary: #0f1114;
  --tint: #2c2d2e;
  --tint-text: #e1e3e6;
  --gold: #e0ac48;
  --ok: #4bb34b;
  --warn: #d9a03c;
  --bad: #ff5c5c;
  --bad-soft: #3a2222;
  --shadow-lg: 0 4px 32px rgba(0, 0, 0, .6), 0 0 4px rgba(0, 0, 0, .4);
  --topbar-bg: #1c1c1d;
  /* Same mapping as the OS-preference block above, and for the same reason. */
  --paper: var(--surface);
  --paper-2: var(--surface-2);
  --ink: var(--text);
  --ink-muted: var(--muted);
}

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

/* The skip link (index.html says why it exists).
 *
 * Off screen rather than hidden, because `display: none` and `visibility: hidden` both take an
 * element out of the tab order — which would make a control whose entire purpose is to be the first
 * tab stop unreachable, while looking exactly like it was working. Clipped to a 1px box rather than
 * moved to a negative offset: an inline-start of -9999px in an RTL document pushes it 9999px to the
 * RIGHT, onto the page, where it sits over the header.
 *
 * It comes back at its natural size on focus, over everything, in the page's own colours — a member
 * who cannot see it has no way to know they landed on it. */
.skip-link {
  position: absolute; inset-block-start: 0; inset-inline-start: 0; z-index: 100;
  inline-size: 1px; block-size: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
  padding: 0; margin: 0;
  /* It is a <button> rather than an anchor — index.html says why — so it carries the reset a
     button needs to stop looking like one when it comes back on screen. */
  border: 0; background: none; font: inherit; color: inherit; cursor: pointer;
}
.skip-link:focus {
  inline-size: auto; block-size: auto; overflow: visible; clip-path: none;
  margin: 8px; padding: 10px 16px;
  background: var(--surface); color: var(--text);
  border: 2px solid var(--primary); border-radius: var(--radius-sm);
  font-weight: 600; text-decoration: none; box-shadow: var(--shadow-lg);
}

/* Several layout classes set an explicit display, which ties `[hidden]` on specificity and wins on
   source order — so the attribute the JS toggles would never hide them. Force hidden to win. */
[hidden] { display: none !important; }

html, body { height: 100%; }
html { min-width: 280px; }

body {
  margin: 0;
  font: 15px/1.4 var(--sans);
  background: var(--bg);
  color: var(--text);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  overflow-wrap: break-word;
}
/* The lightbox covers the window; without this the page carries on scrolling behind it, and closing
   the viewer leaves the reader somewhere they never went. */
body.scroll-locked { overflow: hidden; }

/* A link is blue but not underlined, and stays that way on hover: with this much blue on a page,
   underlines on hover make the whole thing twitch. */
a { color: var(--primary); text-decoration: none; }
a:hover { color: var(--primary-strong); text-decoration: none; }
h1, h2, h3, h4 { margin: 0; line-height: 1.25; font-weight: 600; }
h1 { font-size: 21px; }
h2 { font-size: 17px; }
h3 { font-size: 15px; }
p { margin: 0 0 10px; }
img { max-width: 100%; }

:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-radius: 4px;
}
@supports not selector(:focus-visible) {
  :focus { outline: 2px solid var(--primary); outline-offset: 2px; }
}

.muted { color: var(--muted); }
.faint { color: var(--faint); }
.small { font-size: 13px; }
.tiny { font-size: 12px; }
.strong { font-weight: 600; }
.center { text-align: center; }
.spacer { flex: 1; }
.row { display: flex; align-items: center; gap: 10px; }
.row-wrap { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.error-text { color: var(--bad); }
.error-text:empty { display: none; }

/* Layout and spacing utilities.
 *
 * These exist because there is no style attribute to reach for. The site is served under
 * `style-src 'self'`, which drops every inline style the browser sees — a style="" attribute and a
 * write to `element.style` alike — so a one-off margin has to be a class. (Computed values, the
 * ones that cannot be written down here, go through js/style.js instead.)
 *
 * The set is deliberately small and on the same rhythm as the rest of the sheet. Reach for the
 * furniture — `card`, `list-row`, `chip`, `stat-grid`, `field` — or for a named class first, and
 * add to this list only when the thing being spaced has no name worth giving it. */
.grow { flex: 1; min-width: 0; }
/* A flex child allowed to shrink past its own content: the `min-width: 0` flexbox does not give
   for free, without the `flex: 1` that would also make it claim the slack. */
.shrink { min-width: 0; }
.clip { overflow: hidden; }
/* A link that takes the colour of what it sits in — a title inside a card, where blue would make
   the heading look like a control. `:hover` too, or it turns blue under the pointer. */
.plain, .plain:hover { color: inherit; }
.prewrap { white-space: pre-wrap; }
.upper { text-transform: uppercase; }
.row.top { align-items: flex-start; }
.gap-6 { gap: 6px; }
/* Something whose upload has not finished. Faded rather than hidden: the picture is what tells the
   author which attachment is still on its way up. */
.pending { opacity: .5; }
.m-0 { margin: 0; }
.my-12 { margin-block: 12px; }
.mt-6 { margin-block-start: 6px; }
.mt-8 { margin-block-start: 8px; }
.mt-10 { margin-block-start: 10px; }
.mt-18 { margin-block-start: 18px; }
.mb-6 { margin-block-end: 6px; }
.mb-12 { margin-block-end: 12px; }
.mb-14 { margin-block-end: 14px; }
.mb-16 { margin-block-end: 16px; }
/* `.rail.icons .rail-label` rides along rather than repeating the clip: the icon rail hides its
   labels the same way everything else in the app hides text it still wants announced, and one
   copy of these six declarations means one place to fix them. */
.sr-only,
.rail.icons .rail-label {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- boot ---------------------------------------------------------- */

.boot {
  position: fixed; inset: 0; display: grid; place-content: center;
  justify-items: center; gap: 16px; background: var(--bg); z-index: 40;
}
/* icons/icon-96.png, not logo.png. The 512px original is 297 KB and this box is 76 CSS px; it sat
   on the critical path of every cold load, because the boot screen is the first thing painted. The
   96px copy is 13 KB and is the same picture at this size. */
.boot-mark {
  width: 76px; height: 76px;
  background: url("https://cdn.faithy.org/assets/img/95030cc3c59c2365e537/icons/icon-96.png") center / contain no-repeat;
  animation: pulse 1.4s ease-in-out infinite;
}
.boot-text { color: var(--muted); margin: 0; }
@keyframes pulse { 0%, 100% { opacity: .35; transform: scale(.92); } 50% { opacity: 1; transform: scale(1); } }

/* ---------- brand mark ---------------------------------------------------- */

/* The mark is the Android client's intro_logo.png, used as-is. It carries its own colours (the
   praying hands on the blue disc), so it is deliberately not tinted or themed — the logo looks the
   same on a light screen, a dark screen and a phone. */
.brand { display: flex; align-items: center; gap: 9px; color: var(--text); font-weight: 700; }
.brand:hover { text-decoration: none; }
.brand-mark {
  width: 30px; height: 30px; flex: none;
  background: url("https://cdn.faithy.org/assets/img/95030cc3c59c2365e537/icons/icon-96.png") center / contain no-repeat;
}
.brand-name { font-size: 18px; letter-spacing: -.01em; }

/* ---------- auth ---------------------------------------------------------- */

.auth {
  min-height: 100vh; min-height: 100dvh;
  display: grid; place-items: center; padding: 24px 18px;
  background:
    radial-gradient(1000px 460px at 50% -8%, var(--primary-soft), transparent 70%),
    var(--bg);
}
.auth-card {
  width: min(420px, 100%); background: var(--surface); border: 0;
  border-radius: var(--radius); padding: 26px 24px 24px; box-shadow: var(--shadow-lg);
}
/* Sits above the logo on every auth screen: someone who cannot read the form yet is exactly who
   needs to reach this, and it has to work before an account exists. */
.auth-language { display: flex; justify-content: flex-end; margin-block-end: 6px; }
.auth-language select {
  width: auto; padding: 5px 9px; font-size: 13px; font-weight: 600;
  border-radius: var(--radius-pill); background: var(--surface-3); border-color: transparent;
}

.auth-head { display: grid; justify-items: center; gap: 8px; margin-block-end: 20px; text-align: center; }
/* Intro-sized, matching the logo's presence on the Android sign-in flow. */
.auth-head .brand-mark { width: 104px; height: 104px; }
.auth-head h1 { font-size: 26px; letter-spacing: -.01em; }
.auth-head p { margin: 0; color: var(--muted); font-size: 14px; }
.auth-foot { margin-block-start: 18px; text-align: center; font-size: 14px; color: var(--muted); }
.auth-foot button { margin-inline-start: 4px; }
/* The one alternative offered inside a form — "forgot password", "resend code" — sitting under the
   submit button rather than out in `.auth-foot`, which is for leaving the form entirely. */
.auth-aside { margin-block-start: 12px; text-align: center; }
/* The terms, which arrive from the server as a list of statements. */
.stack.bullets { padding-inline-start: 18px; gap: 8px; }

/* ---------- forms --------------------------------------------------------- */

.field { display: block; margin-block-end: 14px; }
.field > span {
  display: block; margin-block-end: 5px; font-size: 13px; font-weight: 600; color: var(--muted);
}
input[type="text"], input[type="email"], input[type="password"], input[type="search"],
input[type="tel"], input[type="number"], input[type="url"], input[type="datetime-local"],
select, textarea {
  width: 100%; padding: 10px 12px; font: inherit; color: var(--text);
  /* `--control-edge`, not `--border`: the field's fill is #f9f9f9 on a #ffffff card, which is
     1.03:1 — no boundary at all — so the 1px rule IS what says a field is here, and WCAG 1.4.11
     asks that for 3:1. See the token's note in the palette. */
  background: var(--surface-2); border: 1px solid var(--control-edge); border-radius: var(--radius-sm);
  transition: border-color .15s, background .15s, box-shadow .15s;
}
/* Safari and old Edge draw their own bevels and inner shadows on some input types (notably
   search); flattening appearance keeps one look everywhere. Selects keep their native arrow. */
input[type="text"], input[type="email"], input[type="password"], input[type="search"],
input[type="tel"], input[type="number"], input[type="url"], input[type="datetime-local"],
textarea {
  -webkit-appearance: none;
  appearance: none;
}
input[type="search"]::-webkit-search-decoration { -webkit-appearance: none; }
/* Neither type is in the list above, so neither is stretched — but a box and a radio sitting in a
   form full of full-width inputs is exactly the place someone reaches for a width, and saying it
   here means they do not have to. */
input[type="checkbox"], input[type="radio"] { width: auto; }
input:focus, select:focus, textarea:focus {
  outline: none; border-color: var(--primary); background: var(--surface);
  box-shadow: 0 0 0 3px rgba(0, 119, 255, .18);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}

/* ---------- dropdown ------------------------------------------------------
   A custom control (js/ui/select.js), not a styled <select>: the OS draws a native option list that
   ignores the app's type, spacing and dark theme, and cannot show a flag, a right-aligned dial code
   or a filter box. The trigger deliberately matches the text inputs exactly, so a form reads as one
   set of controls. */

.dropdown { position: relative; display: block; }
.dropdown-trigger {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 10px 12px; font: inherit; text-align: start; color: var(--text);
  background: var(--surface-2); border: 1px solid var(--control-edge); border-radius: var(--radius-sm);
  cursor: pointer; transition: border-color .15s, background .15s, box-shadow .15s;
}
.dropdown-trigger:hover { border-color: var(--border-strong); }
.dropdown-trigger:focus-visible {
  outline: none; border-color: var(--primary); background: var(--surface);
  box-shadow: 0 0 0 3px rgba(0, 119, 255, .18);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.dropdown-trigger[aria-expanded="true"] {
  border-color: var(--primary); background: var(--surface);
}
.dropdown-label { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
.dropdown-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-prefix { flex: none; font-size: 17px; line-height: 1; }
.dropdown-detail { flex: none; color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
.dropdown-placeholder { color: var(--faint); }
.dropdown-chevron {
  flex: none; width: 18px; height: 18px; background: var(--muted);
  -webkit-mask: var(--i-chevron) center / contain no-repeat;
  mask: var(--i-chevron) center / contain no-repeat;
  transition: transform .15s;
}
.dropdown-trigger[aria-expanded="true"] .dropdown-chevron { transform: rotate(180deg); background: var(--primary); }
:root { --i-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); }

/* The popup is a popover positioned in fixed coordinates, so neither a card's overflow nor a modal
   dialog's top layer can clip or cover it. `inset: auto` and `margin: 0` undo the UA popover
   defaults (which centre it), leaving the JS-computed top/left in charge. */
.dropdown-popup {
  position: fixed; inset: auto; margin: 0; padding: 0; z-index: 80;
  flex-direction: column; overflow: hidden;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-lg); color: var(--text);
  animation: dropdown-in .12s ease;
}
/* `display` is deliberately NOT set on the base rule. The UA hides a closed popover with
   `[popover]:not(:popover-open) { display: none }`, and overriding that leaves the element laid out
   while invisible — which also stalls the entry animation at frame 0, i.e. permanently transparent
   and untouchable by the pointer. Declare the display only for the two states that are actually
   shown. */
.dropdown-popup:popover-open { display: flex; }
.dropdown-popup:not([popover]) { display: flex; }
.dropdown-popup::backdrop { background: transparent; }
/* Fallback for browsers without the popover API, where the popup is parented INTO the dialog: the
   modal's own clipping has to give way, or the list is cut off at the card edge. */
.modal:has(.dropdown-popup) { overflow: visible; }
.modal:has(.dropdown-popup) .modal-body { overflow: visible; }
.modal.dropdown-host-open { overflow: visible; }
.modal.dropdown-host-open .modal-body { overflow: visible; }
/* Transform only, deliberately no opacity. An animation that starts at `opacity: 0` leaves the
   popup invisible yet still hit-testable for as long as the animation does not advance — which is
   exactly what happens in a document that is not being rendered. A slide has the same feel and no
   state in which the list can be clicked but not seen. */
@keyframes dropdown-in { from { transform: translateY(-5px) scale(.99); } }

.dropdown-search-wrap { padding: 8px; border-block-end: 1px solid var(--border); flex: none; }
.dropdown-search { padding: 8px 11px; border-radius: var(--radius-sm); }
.dropdown-search::-webkit-search-cancel-button { cursor: pointer; }

.dropdown-list { overflow-y: auto; padding: 6px; outline: none; overscroll-behavior: contain; }
.dropdown-option {
  display: flex; align-items: center; gap: 9px; padding: 9px 10px;
  border-radius: var(--radius-sm); cursor: pointer; color: var(--text); line-height: 1.35;
}
.dropdown-option .dropdown-text { flex: 1; }
/* One highlight, driven by the keyboard AND the pointer, so arrowing and hovering cannot disagree
   about which row is current. */
.dropdown-option.active { background: var(--surface-3); }
.dropdown-option.selected { color: var(--primary-strong); font-weight: 650; }
.dropdown-option.selected.active { background: var(--primary-soft); }
.dropdown-option.disabled { opacity: .5; cursor: not-allowed; }
.dropdown-check {
  flex: none; width: 17px; height: 17px; background: var(--primary);
  -webkit-mask: var(--i-check) center / contain no-repeat;
  mask: var(--i-check) center / contain no-repeat;
}
:root { --i-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m9.6 16.6-4.2-4.2 1.4-1.4 2.8 2.8 7.6-7.6 1.4 1.4z'/%3E%3C/svg%3E"); }
.dropdown-empty { padding: 18px 12px; text-align: center; color: var(--muted); font-size: 13.5px; }

/* Compact form for a settings row, where the control sits at the end of a line of text. */
/* Held back from the full width of its row — the composer's target picker sits beside the author's
   name, where a control as wide as the modal would read as the subject of the form. */
.dropdown.narrow { max-width: 280px; }
.dropdown.inline { display: inline-block; min-width: 150px; }
.dropdown.inline .dropdown-trigger { padding: 7px 11px; font-size: 13.5px; }
.dropdown.inline .dropdown-prefix { font-size: 15px; }

/* A field wrapper adds its own bottom margin; the control must not add a second one. */
.field > .dropdown { margin: 0; }
input::placeholder, textarea::placeholder { color: var(--faint); }
input:disabled, select:disabled, textarea:disabled, button:disabled { opacity: .55; cursor: not-allowed; }
textarea { resize: vertical; min-height: 84px; }
.field-row { display: flex; gap: 10px; }
.field-row > * { flex: 1; min-width: 0; }
.hint { font-size: 12px; color: var(--faint); margin-block-start: 4px; }

/* Inline validation. The message lives under its own field so a rejected value is marked where it
   can be fixed, never only in a summary at the top. It is collapsed to zero height when empty, so
   the form does not jump as messages come and go. */
.field-error {
  margin: 4px 0 0; min-height: 0; font-size: 12.5px; font-weight: 550; color: var(--bad);
}
.field-error:empty { display: none; }
.field.invalid input, .field.invalid select, .field.invalid textarea {
  border-color: var(--bad); background: var(--bad-soft);
}
.field.invalid input:focus { border-color: var(--bad); }
/* A quiet tick rather than a green field: confirmation without shouting. */
.field.valid input {
  border-color: var(--ok);
  border-color: color-mix(in srgb, var(--ok) 55%, var(--border));
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2317864a'%3E%3Cpath d='m9.6 16.6-4.2-4.2 1.4-1.4 2.8 2.8 7.6-7.6 1.4 1.4z'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 10px center;
  background-size: 17px;
  padding-inline-end: 34px;
}
/* `background-position` has no logical form — its keywords name physical edges and always will.
   It is the one place in this sheet where a right-to-left page needs an explicit flip, so the tick
   follows the text instead of stranding itself on the side the field ends. `:dir()` rather than
   `[dir="rtl"]`: a single Arabic or Hebrew field inside an English form carries its own direction,
   and the attribute selector would miss it. */
.field.valid input:dir(rtl) { background-position: left 10px center; }
/* The phone channel is a country picker and a number that read as one control. The picker is given
   a fixed, generous share so long country names truncate rather than squeezing the number. */
.phone-group { display: flex; gap: 10px; align-items: flex-start; }
.phone-group > .field { flex: 1; min-width: 0; margin-block-end: 14px; }
.phone-group > .field.country { flex: 0 0 44%; }
.phone-group .select-wrap { display: block; }
.phone-group select { text-overflow: ellipsis; }
.phone-group input { font-variant-numeric: tabular-nums; letter-spacing: .02em; }
/* A dial code typed by hand, on the forms that ask for one without the country picker beside it.
   Four characters wide: any more and it reads as the field for the number itself. */
.phone-code { max-width: 96px; }
.field.code input {
  letter-spacing: .4em; text-align: center; font-size: 21px; font-weight: 650;
  font-variant-numeric: tabular-nums;
}

/* Request-level failures — unreachable, rate-limited, wrong credentials — sit directly above the
   inputs. A toast is the wrong home for these: it disappears while the member is still reading, it
   is nowhere near the button they pressed, and on a phone it can hide behind the keyboard. */
.form-error {
  display: flex; align-items: flex-start; gap: 8px;
  margin: 0 0 14px; padding: 10px 13px;
  border: 1px solid var(--bad);
  border-color: color-mix(in srgb, var(--bad) 35%, transparent);
  border-radius: var(--radius-sm);
  background: var(--bad-soft); color: var(--bad);
  font-size: 13.5px; font-weight: 550; line-height: 1.45;
}
.form-error::before { content: "!"; font-weight: 800; }

.code-input {
  letter-spacing: .5em; text-align: center; font-size: 21px; font-weight: 650;
  font-variant-numeric: tabular-nums;
}

/* Social sign-in. Google draws its own button into `.social-slot` (its brand guidelines require
   it), so the Facebook button is styled to sit beside it at the same height and radius rather than
   the two looking like they came from different apps. */
.social-buttons { display: grid; gap: 9px; margin-block-end: 4px; }
.social-slot { display: flex; justify-content: center; min-height: 40px; }
.social-slot > div { max-width: 100%; }
.btn.social {
  height: 40px; gap: 10px; font-size: 14.5px; font-weight: 600;
  border-color: var(--border-strong);
}
.btn.social.facebook { background: #1877f2; border-color: #1877f2; color: #fff; }
.btn.social.facebook:hover:not(:disabled) { background: #1568d8; border-color: #1568d8; }
.social-mark {
  width: 19px; height: 19px; flex: none; background: currentColor;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: center; mask-position: center;
  -webkit-mask-size: contain; mask-size: contain;
}
.social-mark.facebook {
  -webkit-mask-image: var(--i-facebook); mask-image: var(--i-facebook);
}
:root {
  --i-facebook: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M22 12a10 10 0 10-11.6 9.9v-7H7.9V12h2.5V9.8c0-2.5 1.5-3.9 3.8-3.9 1.1 0 2.2.2 2.2.2v2.5h-1.3c-1.2 0-1.6.8-1.6 1.6V12h2.8l-.4 2.9h-2.4v7A10 10 0 0022 12z'/%3E%3C/svg%3E");
}

/* "or" rule between the social buttons and the form. */
.social-divider {
  display: flex; align-items: center; gap: 12px; margin: 16px 0 14px;
  color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: .06em;
}
.social-divider::before, .social-divider::after {
  content: ""; flex: 1; height: 1px; background: var(--border);
}

/* Channel chooser on the auth forms — a real radio group, mirroring the RadioGroup at the top of
   the Android sign-in / sign-up / reset forms. Real <input type="radio"> rather than styled buttons
   so arrow keys, labels and screen readers all behave as expected. */
.channel-choice { display: flex; gap: 20px; margin: 0 0 14px; }
.channel-choice label {
  display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
  font-weight: 600; color: var(--text);
}
.channel-choice input[type="radio"] {
  width: 18px; height: 18px; margin: 0; accent-color: var(--primary); cursor: pointer;
}

/* ---------- buttons ------------------------------------------------------- */

/* Two weights only: an accent button for the one thing a screen is for, and the blue-grey
   secondary for everything else. Rounded rectangles rather than pills — the pill is reserved for
   the feed's counters, where it reads as a tally rather than a command. */
/* Double-tap on a control must never zoom the page — a real risk on iOS Safari where a fast
   like/unlike reads as a double tap. Scroll gestures still pass through. */
.btn, .icon-button, .post-action, .tabs button, .tabbar a, .chip, .dropdown-trigger {
  touch-action: manipulation;
}

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: 8px 16px; font: inherit; font-size: 14px; font-weight: 500; line-height: 1.28;
  border: 0; border-radius: var(--radius-sm);
  background: var(--tint); color: var(--tint-text); cursor: pointer;
  transition: background .12s, color .12s;
  white-space: nowrap;
}
.btn:hover:not(:disabled) { background: var(--surface-3); background: color-mix(in srgb, var(--tint) 88%, var(--text)); color: var(--tint-text); }
.btn.primary { background: var(--primary); color: var(--on-primary); }
.btn.primary:hover:not(:disabled) { background: var(--primary-strong); color: var(--on-primary); }
.btn.danger { background: var(--bad-soft); color: var(--bad); }
.btn.danger:hover:not(:disabled) { background: var(--bad-soft); background: color-mix(in srgb, var(--bad-soft) 88%, var(--bad)); color: var(--bad); }
.btn.ghost { background: transparent; color: var(--muted); }
.btn.ghost:hover:not(:disabled) { background: var(--surface-3); color: var(--text); }
.btn.wide { width: 100%; }
.btn.small { padding: 6px 12px; font-size: 13px; }
.btn.busy { position: relative; color: transparent !important; }
.btn.busy::after {
  content: ""; position: absolute; width: 15px; height: 15px; border-radius: 50%;
  border: 2px solid currentColor; border-top-color: transparent;
  color: var(--on-primary); animation: spin .7s linear infinite;
}
.btn.ghost.busy::after, .btn:not(.primary).busy::after { color: var(--text); }
@keyframes spin { to { transform: rotate(360deg); } }

.icon-button {
  position: relative; display: inline-grid; place-items: center;
  width: 38px; height: 38px; border: 0; border-radius: 50%;
  background: transparent; color: var(--muted); cursor: pointer;
}
.icon-button:hover { background: var(--surface-3); color: var(--text); text-decoration: none; }

/* Icons are drawn with CSS masks over a single inline SVG sprite-free approach: each glyph is a
   data-URI mask so it inherits currentColor and needs no icon font. */
.icon { width: 21px; height: 21px; display: inline-block; background: currentColor; flex: none; }
.icon-home { -webkit-mask: var(--i-home) center/contain no-repeat; mask: var(--i-home) center/contain no-repeat; }
.icon-search { -webkit-mask: var(--i-search) center/contain no-repeat; mask: var(--i-search) center/contain no-repeat; }
.icon-people { -webkit-mask: var(--i-people) center/contain no-repeat; mask: var(--i-people) center/contain no-repeat; }
.icon-groups { -webkit-mask: var(--i-groups) center/contain no-repeat; mask: var(--i-groups) center/contain no-repeat; }
.icon-calendar { -webkit-mask: var(--i-calendar) center/contain no-repeat; mask: var(--i-calendar) center/contain no-repeat; }
.icon-clock { -webkit-mask: var(--i-clock) center/contain no-repeat; mask: var(--i-clock) center/contain no-repeat; }
.icon-book { -webkit-mask: var(--i-book) center/contain no-repeat; mask: var(--i-book) center/contain no-repeat; }
.icon-bookmark { -webkit-mask: var(--i-bookmark) center/contain no-repeat; mask: var(--i-bookmark) center/contain no-repeat; }
.icon-gift { -webkit-mask: var(--i-gift) center/contain no-repeat; mask: var(--i-gift) center/contain no-repeat; }
.icon-settings { -webkit-mask: var(--i-settings) center/contain no-repeat; mask: var(--i-settings) center/contain no-repeat; }
.icon-bell { -webkit-mask: var(--i-bell) center/contain no-repeat; mask: var(--i-bell) center/contain no-repeat; }
.icon-chat { -webkit-mask: var(--i-chat) center/contain no-repeat; mask: var(--i-chat) center/contain no-repeat; }
.icon-person { -webkit-mask: var(--i-person) center/contain no-repeat; mask: var(--i-person) center/contain no-repeat; }
.icon-theme { -webkit-mask: var(--i-theme) center/contain no-repeat; mask: var(--i-theme) center/contain no-repeat; }
.icon-heart { -webkit-mask: var(--i-heart) center/contain no-repeat; mask: var(--i-heart) center/contain no-repeat; }
.icon-comment { -webkit-mask: var(--i-comment) center/contain no-repeat; mask: var(--i-comment) center/contain no-repeat; }
.icon-share { -webkit-mask: var(--i-share) center/contain no-repeat; mask: var(--i-share) center/contain no-repeat; }
.icon-bookmark-o { -webkit-mask: var(--i-bookmark-o) center/contain no-repeat; mask: var(--i-bookmark-o) center/contain no-repeat; }
.icon-globe { -webkit-mask: var(--i-globe) center/contain no-repeat; mask: var(--i-globe) center/contain no-repeat; }
.icon-lock { -webkit-mask: var(--i-lock) center/contain no-repeat; mask: var(--i-lock) center/contain no-repeat; }
.icon-more { -webkit-mask: var(--i-more) center/contain no-repeat; mask: var(--i-more) center/contain no-repeat; }
.icon-camera { -webkit-mask: var(--i-camera) center/contain no-repeat; mask: var(--i-camera) center/contain no-repeat; }
.icon-video { -webkit-mask: var(--i-video) center/contain no-repeat; mask: var(--i-video) center/contain no-repeat; }
.icon-pin { -webkit-mask: var(--i-pin) center/contain no-repeat; mask: var(--i-pin) center/contain no-repeat; }
.icon-plus { -webkit-mask: var(--i-plus) center/contain no-repeat; mask: var(--i-plus) center/contain no-repeat; }
.icon-close { -webkit-mask: var(--i-close) center/contain no-repeat; mask: var(--i-close) center/contain no-repeat; }
.icon.xs { width: 15px; height: 15px; }
.icon.sm { width: 18px; height: 18px; }

:root {
  --i-home: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 3 2 11h3v10h6v-6h2v6h6V11h3z'/%3E%3C/svg%3E");
  --i-search: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10 2a8 8 0 105.3 14l5.4 5.4 1.4-1.4-5.4-5.4A8 8 0 0010 2zm0 2a6 6 0 110 12 6 6 0 010-12z'/%3E%3C/svg%3E");
  --i-people: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2 4 6v6c0 5 3.4 9.3 8 10 4.6-.7 8-5 8-10V6z'/%3E%3C/svg%3E");
  --i-groups: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8 11a3 3 0 100-6 3 3 0 000 6zm8 0a3 3 0 100-6 3 3 0 000 6zM2 19c0-3 3-5 6-5s6 2 6 5zm12.5-4.6c2 .7 3.5 2.3 3.5 4.6h4c0-2.7-2.6-4.4-7.5-4.6z'/%3E%3C/svg%3E");
  --i-calendar: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 2v2H5a2 2 0 00-2 2v13a2 2 0 002 2h14a2 2 0 002-2V6a2 2 0 00-2-2h-2V2h-2v2H9V2zm12 8v9H5v-9z'/%3E%3C/svg%3E");
  /* Module G1's rail row and the sacred clock's own headings. */
  --i-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2a10 10 0 100 20 10 10 0 000-20zm0 2a8 8 0 110 16 8 8 0 010-16zm-1 3v6l5 3 1-1.7-4-2.3V7z'/%3E%3C/svg%3E");
  --i-book: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 2a2 2 0 00-2 2v16a2 2 0 002 2h13V2zm1 3h9v10H7z'/%3E%3C/svg%3E");
  --i-bookmark: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 2h12v20l-6-4.5L6 22z'/%3E%3C/svg%3E");
  --i-gift: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 4.2A3 3 0 0117.8 6H20a1 1 0 011 1v3H3V7a1 1 0 011-1h2.2A3 3 0 0112 4.2zM4 12h7v9H5a1 1 0 01-1-1zm9 0h7v8a1 1 0 01-1 1h-6z'/%3E%3C/svg%3E");
  --i-settings: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 8a4 4 0 100 8 4 4 0 000-8zm9 4-2.1-1.2.4-2.4-2.1-1.2-1.8 1.6-2.2-1L12 5l-1.2 2.8-2.2 1L6.8 7.2 4.7 8.4l.4 2.4L3 12l2.1 1.2-.4 2.4 2.1 1.2 1.8-1.6 2.2 1L12 19l1.2-2.8 2.2-1 1.8 1.6 2.1-1.2-.4-2.4z'/%3E%3C/svg%3E");
  --i-bell: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 22a2.5 2.5 0 002.5-2.5h-5A2.5 2.5 0 0012 22zm7-6v-5a7 7 0 10-14 0v5l-2 2v1h18v-1z'/%3E%3C/svg%3E");
  --i-chat: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4 3h16a2 2 0 012 2v10a2 2 0 01-2 2H9l-5 4V5a2 2 0 012-2z'/%3E%3C/svg%3E");
  --i-person: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 12a5 5 0 100-10 5 5 0 000 10zm0 2c-4.4 0-8 2.5-8 5.5V22h16v-2.5c0-3-3.6-5.5-8-5.5z'/%3E%3C/svg%3E");
  --i-theme: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2a10 10 0 000 20zm0 2.5A7.5 7.5 0 0112 19.5z'/%3E%3C/svg%3E");

  /* The feed's own glyphs are drawn as outlines rather than solids: an action you have not taken
     yet should read as an outline, and taking it fills the shape in. That is what makes a liked
     post legible at a glance in a long column of them. */
  --i-heart: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linejoin='round'%3E%3Cpath d='M12 20.3 4.9 13.2a4.85 4.85 0 010-6.9 4.9 4.9 0 016.9 0l.2.2.2-.2a4.9 4.9 0 016.9 0 4.85 4.85 0 010 6.9z'/%3E%3C/svg%3E");
  --i-heart-fill: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 20.7 4.5 13.4a5.3 5.3 0 010-7.6 5.4 5.4 0 017.5 0 5.4 5.4 0 017.5 0 5.3 5.3 0 010 7.6z'/%3E%3C/svg%3E");
  --i-comment: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linejoin='round'%3E%3Cpath d='M20 4H4a1.2 1.2 0 00-1.2 1.2v10.4A1.2 1.2 0 004 16.8h2.6V21l4.7-4.2H20a1.2 1.2 0 001.2-1.2V5.2A1.2 1.2 0 0020 4z'/%3E%3C/svg%3E");
  --i-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linejoin='round'%3E%3Cpath d='M4 19.5c.4-5.4 3.8-8 9-8.2V6.5l7 6.4-7 6.4v-4.7c-3.8 0-6.6.9-9 4.9z'/%3E%3C/svg%3E");
  --i-bookmark-o: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linejoin='round'%3E%3Cpath d='M6.6 3.6h10.8c.6 0 1 .5 1 1v16.1L12 16.1l-6.4 4.6V4.6c0-.5.5-1 1-1z'/%3E%3C/svg%3E");
  --i-globe: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M3 12h18M12 3c2.5 2.4 3.8 5.4 3.8 9S14.5 18.6 12 21c-2.5-2.4-3.8-5.4-3.8-9S9.5 5.4 12 3z'/%3E%3C/svg%3E");
  --i-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linejoin='round'%3E%3Crect x='5' y='10' width='14' height='11' rx='2'/%3E%3Cpath d='M8.2 10V7a3.8 3.8 0 017.6 0v3'/%3E%3C/svg%3E");
  --i-more: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ccircle cx='5' cy='12' r='1.9'/%3E%3Ccircle cx='12' cy='12' r='1.9'/%3E%3Ccircle cx='19' cy='12' r='1.9'/%3E%3C/svg%3E");
  --i-camera: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linejoin='round'%3E%3Cpath d='M3 8.4c0-1.1.9-2 2-2h2.4l1.3-2.1h6.6l1.3 2.1H19c1.1 0 2 .9 2 2v8.8c0 1.1-.9 2-2 2H5c-1.1 0-2-.9-2-2z'/%3E%3Ccircle cx='12' cy='12.6' r='3.4'/%3E%3C/svg%3E");
  --i-video: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linejoin='round'%3E%3Crect x='3' y='6' width='12.4' height='12' rx='2'/%3E%3Cpath d='m15.4 11 5.6-3.4v8.8l-5.6-3.4z'/%3E%3C/svg%3E");
  --i-pin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9 2.6h6l-1 5.2 4 3.9v2.1h-5.1V22h-1.8v-8.2H6v-2.1l4-3.9z'/%3E%3C/svg%3E");
  --i-plus: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11 4h2v7h7v2h-7v7h-2v-7H4v-2h7z'/%3E%3C/svg%3E");
  /* Drawn as two strokes rather than a filled cross so it keeps its weight at the 14px the dialog's
     close disc renders it at, where a solid glyph turns into a blob. */
  --i-close: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round'%3E%3Cpath d='M6 6l12 12M18 6L6 18'/%3E%3C/svg%3E");
}


/* ---------- the module glyphs --------------------------------------------
 *
 * The set above is the MEMBER surface's: a home, a bell, a bookmark, a gift. Everything a
 * congregation's staff opens was borrowing from it, and borrowing badly — the ledger wore the
 * settings gear, Records wore Saved's bookmark, Attendance and Serving both wore the tick, and
 * Events, Appointments and the service planner were three rows of one calendar. A rail whose rows
 * repeat four glyphs between them is a rail nobody navigates by glyph, which is the whole reason
 * the narrow `.rail.icons` variant exists.
 *
 * Drawn in the same filled 24px style as the set above and, WHERE ANDROID HAS ONE, ported from the
 * drawable rather than redrawn — `app/src/main/res/drawable/ic_*.xml`, whose own comments carry the
 * reasoning for the symbol and are worth reading before changing one. Two clients showing different
 * marks for the same module is the same class of divergence as terms.js drifting from
 * CommunityTerms.java: nothing fails, the two apps simply stop naming a thing the same way.
 *
 * One glyph here has no Android original and says so in its own comment (--i-dues).
 *
 * The four chevrons are a repair rather than an addition: views/calendar.js, workspace_roster.js
 * and workspace_services.js were already asking `icon("chevron-left")` and friends of a stylesheet
 * that had no such class. A missing mask does not degrade to a missing icon — `.icon` paints
 * `background: currentColor` and the mask is what cuts the shape out of it, so an undefined mask
 * renders as a SOLID 21px BLOCK. Same for the bin and the pencil, which ui/cards.js and
 * views/lists.js ask for.
 */
:root {
  /* A register: three names, each with its box ticked off. Android's ic_attendance_20, redrawn on
     this set's 24px grid. */
  --i-attendance: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3,4.6h3.6v3.6H3z M3,10.2h3.6v3.6H3z M3,15.8h3.6v3.6H3z'/%3E%3Cpath d='M9,5.4h12v2.2H9z M9,11h12v2.2H9z M9,16.6h12v2.2H9z'/%3E%3C/svg%3E");
  /* A serving rota: one person, confirmed for their slot. Android's ic_serving_20 —
     deliberately NOT another ruled list, because Serving and Attendance sit two rows apart and
     would read as the same glyph twice. */
  --i-serving: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7.6,3.2a3.85,3.85 0 1,1 0,7.7a3.85,3.85 0 1,1 0,-7.7z'/%3E%3Cpath d='M2.2,19.8a5.4,5.4 0 0,1 10.8,0z'/%3E%3Cpath d='M16.8,21L13.3,17.5L14.5,16.3L16.8,18.6L20.8,14.6L22,15.8Z'/%3E%3C/svg%3E");
  /* Lifecycle records (module G3): a sheet with a seal at its foot, which is what all three
     traditions hand a family after a rite. Android's ic_certificate_24. Deliberately not a cross,
     a crescent or a star — the icon heads a register a church, a mosque and a synagogue each
     read as their own. */
  --i-certificate: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5,2C4.4,2 4,2.4 4,3V21C4,21.6 4.4,22 5,22H12.2C11.5,21.2 11.1,20.2 11,19.1H6.5C6.2,19.1 6,18.9 6,18.6V4.4C6,4.2 6.2,4 6.5,4H17.5C17.8,4 18,4.2 18,4.4V11.2C18.7,11.4 19.4,11.7 20,12.2V3C20,2.4 19.6,2 19,2H5Z'/%3E%3Cpath d='M8,7H16V8.6H8V7ZM8,10.6H16V12.2H8V10.6ZM8,14.2H12V15.8H8V14.2Z'/%3E%3Cpath d='M17,12.6C15.1,12.6 13.6,14.1 13.6,16C13.6,17.9 15.1,19.4 17,19.4C18.9,19.4 20.4,17.9 20.4,16C20.4,14.1 18.9,12.6 17,12.6ZM17,17.8C16,17.8 15.2,17 15.2,16C15.2,15 16,14.2 17,14.2C18,14.2 18.8,15 18.8,16C18.8,17 18,17.8 17,17.8Z'/%3E%3Cpath d='M14.6,19.9V23L17,21.8L19.4,23V19.9C18.7,20.3 17.9,20.5 17,20.5C16.1,20.5 15.3,20.3 14.6,19.9Z'/%3E%3C/svg%3E");
  /* An appointment (module C2): a calendar with a person in it — a slot booked with somebody,
     rather than a date in the abstract. Android's ic_appointment_20, which already draws on a 24
     viewport. */
  --i-appointment: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19,4h-1V2h-2v2H8V2H6v2H5C3.89,4 3.01,4.9 3.01,6L3,20c0,1.1 0.89,2 1.99,2H19c1.1,0 2,-0.9 2,-2V6c0,-1.1 -0.9,-2 -2,-2zM19,20H5V10h14v10zM19,8H5V6h14v2z'/%3E%3Cpath d='M12,12.5c1.1,0 2,-0.9 2,-2s-0.9,-2 -2,-2 -2,0.9 -2,2 0.9,2 2,2zM12,13.5c-1.5,0 -4,0.75 -4,2.25L8,17h8v-1.25c0,-1.5 -2.5,-2.25 -4,-2.25z'/%3E%3C/svg%3E");
  /* A bookable room (module C3): a doorway with its handle, standing on the floor line. Android's
     ic_room_24 — deliberately a DOOR rather than a building, because the congregation's
     building already has its own glyph and a fourth would read as a fourth faith rather than as a
     room inside the one you are in. */
  --i-room: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5,3h11a1,1 0 0,1 1,1v16a1,1 0 0,1 -1,1h-11a1,1 0 0,1 -1,-1v-16a1,1 0 0,1 1,-1zM6,5v14h9v-14z'/%3E%3Cpath d='M13,11.2a1.15,1.15 0 1,1 0,2.3a1.15,1.15 0 1,1 0,-2.3z'/%3E%3Cpath d='M2.6,20.2h18.8v1.6h-18.8z'/%3E%3C/svg%3E");
  /* Courses (module F1): an open book, which is what all three traditions mean by a class that
     runs in sequence. Android's ic_course_24 — deliberately not a graduation cap (that is a
     certificate, module G3's) and not a screen (this is not a video course). */
  --i-course: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11,6.5C9.6,5.6 7.8,5 6,5C4.7,5 3.4,5.3 2.3,5.8C1.5,6.1 1,6.9 1,7.8V18.3C1,19.5 2.2,20.4 3.4,20C4.2,19.7 5.1,19.6 6,19.6C7.6,19.6 9.3,20.1 10.5,21C10.7,21.1 10.9,21.1 11,21V6.5Z'/%3E%3Cpath d='M13,6.5C14.4,5.6 16.2,5 18,5C19.3,5 20.6,5.3 21.7,5.8C22.5,6.1 23,6.9 23,7.8V18.3C23,19.5 21.8,20.4 20.6,20C19.8,19.7 18.9,19.6 18,19.6C16.4,19.6 14.7,20.1 13.5,21C13.3,21.1 13.1,21.1 13,21V6.5Z'/%3E%3C/svg%3E");
  /* Memorization (module F3): an open book with its lines ruled — the same object as the course
     glyph, being learnt by heart rather than read through. Android's ic_menu_book_24, which is
     what distinguishes the two rows there. */
  --i-menu-book: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M21,5c-1.11,-0.35 -2.33,-0.5 -3.5,-0.5c-1.95,0 -4.05,0.4 -5.5,1.5c-1.45,-1.1 -3.55,-1.5 -5.5,-1.5S2.45,4.9 1,6v14.65c0,0.25 0.25,0.5 0.5,0.5c0.1,0 0.15,-0.05 0.25,-0.05C3.1,20.45 5.05,20 6.5,20c1.95,0 4.05,0.4 5.5,1.5c1.35,-0.85 3.8,-1.5 5.5,-1.5c1.65,0 3.35,0.3 4.75,1.05c0.1,0.05 0.15,0.05 0.25,0.05c0.25,0 0.5,-0.25 0.5,-0.5V6C22.4,5.55 21.75,5.25 21,5zM21,18.5c-1.1,-0.35 -2.3,-0.5 -3.5,-0.5c-1.7,0 -4.15,0.65 -5.5,1.5V8c1.35,-0.85 3.8,-1.5 5.5,-1.5c1.2,0 2.4,0.15 3.5,0.5V18.5z'/%3E%3Cpath d='M17.5,10.5c0.88,0 1.73,0.09 2.5,0.26V9.24C19.21,9.09 18.36,9 17.5,9c-1.7,0 -3.24,0.29 -4.5,0.83v1.66C14.13,10.85 15.7,10.5 17.5,10.5zM13,12.49v1.66c1.13,-0.64 2.7,-0.99 4.5,-0.99c0.88,0 1.73,0.09 2.5,0.26V11.9c-0.79,-0.15 -1.64,-0.24 -2.5,-0.24C15.8,11.66 14.26,11.96 13,12.49zM17.5,14.33c-1.7,0 -3.24,0.29 -4.5,0.83v1.66c1.13,-0.64 2.7,-0.99 4.5,-0.99c0.88,0 1.73,0.09 2.5,0.26v-1.52C19.21,14.42 18.36,14.33 17.5,14.33z'/%3E%3C/svg%3E");
  /* The song library (module D2): a microphone on its stand. Android's ic_mic_20, which already
     draws on a 24 viewport. A repertoire is what is SUNG, so the glyph is the singing rather than
     the page it is written on — which is also what keeps it clear of the three book glyphs two
     rows above it. */
  --i-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12,14c1.66,0 3,-1.34 3,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17,11c0,2.76 -2.24,5 -5,5s-5,-2.24 -5,-5L5,11c0,3.53 2.61,6.43 6,6.92L11,21h2v-3.08c3.39,-0.49 6,-3.39 6,-6.92h-2z'/%3E%3C/svg%3E");
  /* The service planner (module D1): a running order with the clock that times it. The third of
     the three rows that were all wearing the calendar — and the one where the calendar was
     least true, because a service plan is not a date, it is what happens in the hour after the
     date. Deliberately not the register's marks-in-the-margin: Attendance is two sections above
     and they would read as the same list twice. No Android original —
     CongregationActivitiesActivity has no service-planner destination. */
  --i-order: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3,4h14v2.2H3zM3,9h14v2.2H3zM3,14h8v2.2H3z'/%3E%3Cpath d='M16.6,12.4a5.4,5.4 0 1,1 0,10.8a5.4,5.4 0 1,1 0,-10.8zM15.8,14.8v3.4l2.9,1.7l0.8,-1.3l-2.3,-1.35V14.8z'/%3E%3C/svg%3E");
  /* The treasury (module E4): a columned strongroom on its plinth. Android's ic_treasury_24,
     ported path for path. */
  --i-treasury: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12,2L2,7v2h20V7L12,2zM12,4.24L17.53,7H6.47L12,4.24z'/%3E%3Cpath d='M4,10h2v7H4zM9,10h2v7H9zM13,10h2v7h-2zM18,10h2v7h-2zM2,18h20v2H2z'/%3E%3C/svg%3E");
  /* A budget (module E4): three columns of different heights on their baseline — a plan
     measured against itself. Android's ic_budget_20, which already draws on a 24 viewport. */
  --i-budget: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5,9.2h3V19H5zM10.6,5h2.8v14h-2.8zM16.2,13H19v6h-2.8z'/%3E%3Cpath d='M3,21h18v2H3z'/%3E%3C/svg%3E");
  /* The ledger report (module E4): a sheet with its figures charted on it. Beside --i-budget,
     which is the bars alone — the report is the DOCUMENT and the budget is the plan drawn over
     it, which is why the budget row opens this same page with its editor raised. No Android
     original: the treasury card carries ic_treasury_20 for the whole family and does not separate
     the two. */
  --i-report: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5,2h14a1,1 0 0,1 1,1v18a1,1 0 0,1 -1,1H5a1,1 0 0,1 -1,-1V3a1,1 0 0,1 1,-1zM6,4v16h12V4z'/%3E%3Cpath d='M8,13.6h2v4.4H8zM11,9.6h2v8.4h-2zM14,11.6h2v6.4h-2z'/%3E%3C/svg%3E");
  /* A receipt (module E3): a till roll with its torn foot and three ruled lines. Android's
     ic_receipt_24. */
  --i-receipt: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2L7.5,3.5L6,2L4.5,3.5L3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2L19.5,3.5zM19,19.09H5V4.91h14V19.09z'/%3E%3Cpath d='M6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z'/%3E%3C/svg%3E");
  /* Dues (module E2): a coin inside the ring of a standing commitment. THE ONE GLYPH IN THIS
     BLOCK WITH NO ANDROID ORIGINAL — CongregationActivitiesActivity gives the row
     ic_giving_history_20, which is a raster with no path to port, and reusing the treasury or the
     gift would put dues under a mark that already means something else on the same rail. What
     separates dues from a donation is that it RECURS, so that is what the ring says and the coin
     in the middle is what it recurs in. */
  --i-dues: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12,6v3l4,-4l-4,-4v3c-4.42,0 -8,3.58 -8,8c0,1.57 0.46,3.03 1.24,4.26L6.7,14.8c-0.45,-0.83 -0.7,-1.79 -0.7,-2.8c0,-3.31 2.69,-6 6,-6zM18.76,7.74L17.3,9.2c0.44,0.84 0.7,1.79 0.7,2.8c0,3.31 -2.69,6 -6,6v-3l-4,4l4,4v-3c4.42,0 8,-3.58 8,-8c0,-1.57 -0.46,-3.03 -1.24,-4.26z'/%3E%3Cpath d='M12,7.9a4.1,4.1 0 1,1 0,8.2a4.1,4.1 0 1,1 0,-8.2z'/%3E%3C/svg%3E");
  /* The leadership logbook (module C4): a bound ledger, closed, with its ribbon marker hanging
     out of the bottom. Android's ic_logbook_24. Deliberately NOT the open book Courses uses —
     that one is a text read together, and this is a record kept by a few people and closed when
     the meeting ends; deliberately not a padlock, which would advertise what is inside. */
  --i-logbook: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6.5,2 h12 a1.5,1.5 0 0,1 1.5,1.5 v17 a1.5,1.5 0 0,1 -1.5,1.5 h-12 a2.5,2.5 0 0,1 -2.5,-2.5 v-15 a2.5,2.5 0 0,1 2.5,-2.5 z M6.5,4 a0.5,0.5 0 0,0 -0.5,0.5 v15 a0.5,0.5 0 0,0 0.5,0.5 h11.5 v-16 z'/%3E%3Cpath d='M8.5,7.5 h7 v1.4 h-7 z'/%3E%3Cpath d='M8.5,11 h7 v1.4 h-7 z'/%3E%3Cpath d='M12.6,15.5 h2.8 v6.5 l-1.4,-1.5 l-1.4,1.5 z'/%3E%3C/svg%3E");
  /* A journal entry (module C4): one written sheet with its corner turned — the counterpart of
     the logbook glyph, which is the bound book the module IS. Android's ic_note_24. */
  --i-note: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5,3.5 a1.5,1.5 0 0,1 1.5,-1.5 h7.1 v5.2 a1.5,1.5 0 0,0 1.5,1.5 h4.4 v11.8 a1.5,1.5 0 0,1 -1.5,1.5 h-11.5 a1.5,1.5 0 0,1 -1.5,-1.5 z M6.9,4 v16 h10.2 v-9.4 h-3.4 a1.9,1.9 0 0,1 -1.9,-1.9 v-4.7 z'/%3E%3Cpath d='M15.1,2.4 l4.6,4.6 h-4.1 a0.5,0.5 0 0,1 -0.5,-0.5 z'/%3E%3Cpath d='M8.6,13 h6.8 v1.4 h-6.8 z'/%3E%3Cpath d='M8.6,16.3 h4.6 v1.4 h-4.6 z'/%3E%3C/svg%3E");
  /* Pastoral care (module G5): an open hand with a heart resting in it — somebody being
     carried. Android's ic_care_24. Deliberately NOT a padlock or a shield, which would advertise
     on the dashboard that there is something here worth prying at; deliberately not a plain heart
     either, which already means love on every post card. */
  --i-care: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12,10.9 l-0.72,-0.66 C9.02,8.19 7.5,6.8 7.5,5.09 7.5,3.7 8.6,2.6 10,2.6 c0.79,0 1.55,0.37 2,0.95 0.45,-0.58 1.21,-0.95 2,-0.95 1.4,0 2.5,1.1 2.5,2.49 0,1.71 -1.52,3.1 -3.78,5.15 L12,10.9 z'/%3E%3Cpath d='M4,13.2 c0,-0.66 0.54,-1.2 1.2,-1.2 0.66,0 1.2,0.54 1.2,1.2 0,2.24 1.6,4.1 3.72,4.51 v2.03 C6.9,19.3 4,16.58 4,13.2 z'/%3E%3Cpath d='M17.6,13.2 c0,-0.66 0.54,-1.2 1.2,-1.2 0.66,0 1.2,0.54 1.2,1.2 0,3.38 -2.9,6.1 -6.12,6.54 v-2.03 C16,17.3 17.6,15.44 17.6,13.2 z'/%3E%3Cpath d='M10.8,19.4 h2.4 v2.2 h-2.4 z'/%3E%3C/svg%3E");
  /* The step back. Drawn rather than derived from chevron-right so the two are one mask each, and
     flipped together under :dir(rtl) below. */
  --i-chevron-left: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14.7,4.6L7.3,12l7.4,7.4l1.9,-1.9L11.1,12l5.5,-5.5z'/%3E%3C/svg%3E");
  /* A step forward through a series — a month, a week, a page. Its RTL flip is the block at the
     foot of this section; the NAME is physical because the call site means 'the one after this',
     and a reader stepping through an Arabic calendar presses the control on the leading side
     either way. */
  --i-chevron-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9.3,4.6L16.7,12l-7.4,7.4l-1.9,-1.9L12.9,12L7.4,6.5z'/%3E%3C/svg%3E");
  /* Move a line up an order. Vertical, so it is the same glyph on a right-to-left page and
     carries no flip. */
  --i-chevron-up: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4.6,14.7L12,7.3l7.4,7.4l-1.9,1.9L12,11.1l-5.5,5.5z'/%3E%3C/svg%3E");
  /* Move a line down an order. Vertical, so it carries no flip either. */
  --i-chevron-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M4.6,9.3L12,16.7l7.4,-7.4l-1.9,-1.9L12,12.9L6.5,7.4z'/%3E%3C/svg%3E");
  /* A bin. Already asked for by two views (ui/cards.js, views/lists.js) with no mask behind it,
     which renders as a solid 21px block. */
  --i-trash: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M9,2h6l1,1.4h4v2H4v-2h4zM6,7h12l-0.9,13.1A2,2 0 0,1 15.1,22H8.9a2,2 0 0,1 -2,-1.9zM9.6,9.4l0.4,9.6h1.4l-0.3,-9.6zM13,9.4l-0.3,9.6h1.4l0.4,-9.6z'/%3E%3C/svg%3E");
  /* A pencil. Asked for by views/lists.js with no mask behind it, same as the bin above. */
  --i-edit: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3,17.2V21h3.8L18,9.8L14.2,6zM20.7,6.9a1,1 0 0,0 0,-1.4l-2.2,-2.2a1,1 0 0,0 -1.4,0l-1.8,1.8L19.1,8.7z'/%3E%3C/svg%3E");
}

.icon-attendance { -webkit-mask: var(--i-attendance) center/contain no-repeat; mask: var(--i-attendance) center/contain no-repeat; }
.icon-serving { -webkit-mask: var(--i-serving) center/contain no-repeat; mask: var(--i-serving) center/contain no-repeat; }
.icon-certificate { -webkit-mask: var(--i-certificate) center/contain no-repeat; mask: var(--i-certificate) center/contain no-repeat; }
.icon-appointment { -webkit-mask: var(--i-appointment) center/contain no-repeat; mask: var(--i-appointment) center/contain no-repeat; }
.icon-room { -webkit-mask: var(--i-room) center/contain no-repeat; mask: var(--i-room) center/contain no-repeat; }
.icon-course { -webkit-mask: var(--i-course) center/contain no-repeat; mask: var(--i-course) center/contain no-repeat; }
.icon-menu-book { -webkit-mask: var(--i-menu-book) center/contain no-repeat; mask: var(--i-menu-book) center/contain no-repeat; }
.icon-mic { -webkit-mask: var(--i-mic) center/contain no-repeat; mask: var(--i-mic) center/contain no-repeat; }
.icon-order { -webkit-mask: var(--i-order) center/contain no-repeat; mask: var(--i-order) center/contain no-repeat; }
.icon-treasury { -webkit-mask: var(--i-treasury) center/contain no-repeat; mask: var(--i-treasury) center/contain no-repeat; }
.icon-budget { -webkit-mask: var(--i-budget) center/contain no-repeat; mask: var(--i-budget) center/contain no-repeat; }
.icon-report { -webkit-mask: var(--i-report) center/contain no-repeat; mask: var(--i-report) center/contain no-repeat; }
.icon-receipt { -webkit-mask: var(--i-receipt) center/contain no-repeat; mask: var(--i-receipt) center/contain no-repeat; }
.icon-dues { -webkit-mask: var(--i-dues) center/contain no-repeat; mask: var(--i-dues) center/contain no-repeat; }
.icon-logbook { -webkit-mask: var(--i-logbook) center/contain no-repeat; mask: var(--i-logbook) center/contain no-repeat; }
.icon-note { -webkit-mask: var(--i-note) center/contain no-repeat; mask: var(--i-note) center/contain no-repeat; }
.icon-care { -webkit-mask: var(--i-care) center/contain no-repeat; mask: var(--i-care) center/contain no-repeat; }
.icon-chevron-left { -webkit-mask: var(--i-chevron-left) center/contain no-repeat; mask: var(--i-chevron-left) center/contain no-repeat; }
.icon-chevron-right { -webkit-mask: var(--i-chevron-right) center/contain no-repeat; mask: var(--i-chevron-right) center/contain no-repeat; }
.icon-chevron-up { -webkit-mask: var(--i-chevron-up) center/contain no-repeat; mask: var(--i-chevron-up) center/contain no-repeat; }
.icon-chevron-down { -webkit-mask: var(--i-chevron-down) center/contain no-repeat; mask: var(--i-chevron-down) center/contain no-repeat; }
.icon-trash { -webkit-mask: var(--i-trash) center/contain no-repeat; mask: var(--i-trash) center/contain no-repeat; }
.icon-edit { -webkit-mask: var(--i-edit) center/contain no-repeat; mask: var(--i-edit) center/contain no-repeat; }

/* ---- the glyphs that mirror on a right-to-left page ----
 *
 * A mask has no logical form: `mask-image` names a picture, and a picture of an arrow pointing at
 * the next month points at LAST month once the page runs the other way. So the flip is explicit,
 * the way `.field.valid input:dir(rtl)` flips the tick's background-position, and it is `scaleX`
 * rather than a second data URI per glyph — one rule against twenty-odd duplicated paths that would
 * then have to be kept in step with their originals by hand.
 *
 * `:dir(rtl)` rather than `[dir="rtl"]` for the reason the tick gives: a single Arabic or Hebrew
 * element inside an English page carries its own direction, and the attribute selector would miss
 * it. That case is not hypothetical here — a scripture reader shows an Arabic or Hebrew passage
 * inside an app the member may be running in French.
 *
 * WHAT GOES IN THIS LIST IS A JUDGEMENT AND THE RULE IS: mirror a glyph that depicts DIRECTION OF
 * TRAVEL or THE DIRECTION TEXT RUNS; leave one that depicts a physical object alone. A chevron, a
 * share arrow and a pencil are the first kind. A microphone, a bank, a door, a bell and a
 * microphone stand are the second — a mosque's microphone does not face the other way, and
 * mirroring it would only make the set look unstable next to the ones that cannot mirror.
 *
 * The bin is symmetric and the ring on --i-dues is a closed loop, so neither is listed: flipping a
 * symmetric glyph costs a composite layer to change nothing.
 */
.icon-chevron-left:dir(rtl),
.icon-chevron-right:dir(rtl),
/* The feed's forward arrow. */
.icon-share:dir(rtl),
/* A pencil is held in the writing hand, and the writing hand follows the script. */
.icon-edit:dir(rtl),
/* A speech bubble's tail comes off the side the speaker is on, which is the side the text starts.
   Both of these carry one, and an unflipped tail is the single most visible thing on an RTL feed —
   every post card and every message row has one. */
.icon-chat:dir(rtl),
.icon-comment:dir(rtl),
/* Pages: the marks live in the margin, the fold is in the outer corner, the spine is on the
   binding side, and every one of those is decided by which way the text runs. */
.icon-attendance:dir(rtl),
.icon-certificate:dir(rtl),
.icon-logbook:dir(rtl),
.icon-note:dir(rtl),
.icon-menu-book:dir(rtl) {
  transform: scaleX(-1);
}

/* ---------- app shell ----------------------------------------------------- */

.app { min-height: 100vh; display: flex; flex-direction: column; }
@supports (min-height: 100dvh) { .app { min-height: 100dvh; } }

/* A thin white band across the whole window, with its contents held to the same grid as the page
   below it — so the mark sits over the menu and the avatar over the sidebar. */
.topbar {
  position: sticky; inset-block-start: 0; z-index: 20; height: var(--topbar-h);
  background: var(--topbar-bg); border-block-end: 1px solid var(--border);
}
.topbar-inner {
  height: 100%; max-width: var(--page-w); margin: 0 auto;
  /* Deliberately physical, and the only family of declarations in this sheet that is. An
     `env(safe-area-inset-*)` names a real edge of a real device — the notch is milled into one
     side of the glass and does not move when the page flips to right-to-left. Reading the left
     inset into `padding-inline-start` would put the cutout's allowance on the wrong edge on every
     RTL page. The plain-pixel pair above each `max()` is the fallback for engines without `env()`,
     where the whole `max()` declaration is invalid; it stays physical so the pair reads as one
     unit. Both sides get the same 12px, so nothing here is asymmetric anyway. */
  padding: env(safe-area-inset-top, 0px) 12px 0;
  padding-left: max(12px, env(safe-area-inset-left, 0px));
  padding-right: max(12px, env(safe-area-inset-right, 0px));
  display: flex; align-items: center; gap: 12px;
}
.topbar .brand-name { font-size: 17px; }

.topbar-search { position: relative; flex: 1; max-width: 230px; }
/* A pill, not a rounded rectangle: it is the one control in the bar and the shape is what tells
   you so before you have read the placeholder. */
.topbar-search input {
  height: 32px; padding-block: 0; padding-inline: 34px 14px; font-size: 14px;
  border-radius: var(--radius-pill); background: var(--surface-3); border-color: transparent;
}
.topbar-search input:focus { background: var(--surface-2); border-color: transparent; box-shadow: none; }
.topbar-search::before {
  content: ""; position: absolute; inset-inline-start: 11px; inset-block-start: 8px; width: 16px; height: 16px;
  background: var(--faint); pointer-events: none;
  -webkit-mask: var(--i-search) center/contain no-repeat;
  mask: var(--i-search) center/contain no-repeat;
}
/* On a phone the bar has to hold a mark, a search box and four controls in 375px. The wordmark is
   the one part that carries no function — the mark beside it already says where "home" is — so it
   is what gives up its room rather than the search field shrinking to a slot. */
@media (max-width: 700px) {
  .topbar .brand-name { display: none; }
  .topbar-search input::placeholder { text-overflow: ellipsis; }
}
/* The bar is laid out on the page's own columns: the mark sits over the menu, the search box
   begins exactly where the feed does, and the avatar ends where the sidebar does. Getting this
   wrong is what makes a header look bolted on rather than part of the page. */
@media (min-width: 900px) {
  .brand { width: calc(var(--col-nav) + var(--gap) - 12px); flex: none; }
}
.topbar-actions { display: flex; align-items: center; gap: 2px; flex: 1; }
.topbar-avatar { display: block; width: 32px; height: 32px; margin-inline-start: auto; }
.icon-button { width: 34px; height: 34px; border-radius: 50%; }
.icon-button .icon { width: 20px; height: 20px; }

.badge {
  position: absolute; inset-block-start: 1px; inset-inline-end: 0; min-width: 16px; height: 16px; padding: 0 4px;
  display: grid; place-items: center; border-radius: var(--radius-pill);
  background: var(--bad); color: #fff; font-size: 11px; font-weight: 500; line-height: 1;
  border: 2px solid var(--surface);
}

.shell {
  flex: 1; width: 100%; max-width: var(--page-w); margin: 0 auto;
  display: grid; grid-template-columns: 1fr; min-height: 0;
}
.rail, .side { display: none; }
.view {
  min-width: 0; padding: 10px 8px calc(var(--tabbar-h) + 24px);
  outline: none;
}

.tabbar {
  position: fixed; inset-inline: 0; inset-block-end: 0; z-index: 20; height: var(--tabbar-h);
  display: flex; background: var(--surface); border-block-start: 1px solid var(--border);
  /* Physical on purpose — see the note on `.topbar-inner`. A phone held sideways puts the notch
     over the first tab without this, and which side the notch is on is a fact about the hardware,
     not about the writing direction. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
}
.tabbar a {
  position: relative; flex: 1; display: grid; place-items: center; gap: 2px;
  color: var(--faint); font-size: 11px; font-weight: 500; padding-block-start: 7px;
}
.tabbar a.active { color: var(--primary); }
.tabbar .badge { inset-block-start: 4px; inset-inline-end: 50%; margin-inline-end: -22px; }

/* Phones and tablets already expose Messages in the bottom bar. Keeping the same destination in
   the header only squeezes the search field, so the compact header keeps the unique actions. */
@media (max-width: 899px) {
  .topbar-actions > a[href="#/chats"],
  .topbar-actions > #theme-toggle { display: none; }
  .topbar-actions { flex: 0 0 auto; }
}

@media (min-width: 621px) and (max-width: 899px) {
  .view { width: min(100%, 680px); margin: 0 auto; padding: 12px 0 calc(var(--tabbar-h) + 28px); }
}

@media (min-width: 900px) {
  .topbar-inner { max-width: 920px; }
  .shell {
    width: calc(100% - 24px); max-width: 920px;
    grid-template-columns: var(--col-nav) minmax(0, 1fr); gap: var(--gap);
  }
  /* The menu is a plain column of text on the page itself — no card behind it — tight enough that
     the whole map of the app is one glance. Thirty-pixel rows are what let a dozen destinations
     fit above the fold without the list feeling cramped. */
  .rail {
    display: block; position: sticky; inset-block-start: calc(var(--topbar-h) + 12px);
    align-self: start; padding: 12px 0 20px;
    max-height: calc(100vh - var(--topbar-h) - 16px);
    max-height: calc(100dvh - var(--topbar-h) - 16px);
    overflow: auto;
  }
  .rail a {
    position: relative; display: flex; align-items: center; gap: 10px; padding: 5px 8px;
    min-height: 32px; border-radius: var(--radius-sm); color: var(--text); font-size: 14px; font-weight: 400;
  }
  .rail a:hover { background: var(--surface-2); }
  .rail a.active { background: var(--tint); color: var(--text); font-weight: 500; }
  .rail .icon { width: 20px; height: 20px; color: var(--faint); }
  .rail a.active .icon { color: var(--primary); }
  .rail .badge { inset-block-start: 7px; inset-inline-end: 7px; border-color: var(--bg); }
  /* Groups are separated by air rather than by a heading, so the eye runs down one list instead of
     stopping at three labels on the way. The label text is clipped rather than removed: it costs
     nothing to leave "Communities" in the accessibility tree, and a screen reader has no whitespace
     to read the grouping from. */
  .nav-section { height: 0; margin-block-start: 16px; overflow: hidden; }
  .view { padding: 12px 0 40px; }
  .tabbar { display: none; }

  /* ---- rail variants ------------------------------------------------------
     Two alternatives to the member rail above, for the console pages. `.rail` is not touched by
     any of this: the navigation that ships today is the one thing on the page that a member has
     already learnt, and a variant must never move it.

     The width is set by overriding --col-nav on `.app`, not by sizing the rail. Two other things
     read that token — the shell's first grid track and the header's brand slot, which is what
     puts the mark over the menu — and a rail that resized itself would slide out from under both.
     One value, three things in step.

     This and the `.shell.wide` header rule below are the only two places layout leans on `:has()`,
     and in both it is deliberately not load-bearing — see the note in router.js about why the
     profile layout is toggled with a class instead. Where relational selectors are missing, the
     rail still renders at its own width inside the default 168px track: a wider gutter than
     intended, not a broken page. */
  .app:has(.rail.icons) { --col-nav: var(--col-nav-icons); }
  .app:has(.rail.wide) { --col-nav: var(--col-nav-wide); }

  /* Icon-only, for a console where the navigation is a permanent fixture rather than something
     read: five destinations you already know, kept out of the way of the table beside them.
     The label is clipped, never dropped — see `.sr-only`, which this shares. A row whose only
     content is a masked <span> has no accessible name at all, and a screen reader would announce
     eleven identical "link"s. `title` is not the fix: it is not spoken by every reader and never
     appears on a touch device. The text stays in the tree, exactly as the wide rail reads it. */
  .rail.icons { width: var(--col-nav-icons); }
  .rail.icons a {
    justify-content: center; gap: 0;
    width: 40px; min-height: 40px; padding-inline: 0; margin-inline: auto;
  }
  .rail.icons .icon { width: 22px; height: 22px; }
  .rail.icons .badge { inset-block-start: 3px; inset-inline-end: 3px; }
  /* The clipped label leaves the air-gap grouping with nothing to separate, and a 16px hole in a
     column of icons reads as a missing row rather than as a division. */
  .rail.icons .nav-section { margin-block-start: 8px; }

  /* Labelled and roomier, for a console whose destinations are not the ones a member has in
     muscle memory — "Attendance", "Pledges", "Service rota". Those need their names, and they
     need to be told apart in groups, which is why this is the one rail that spends a line on a
     heading where the member rail spends air. */
  .rail.wide { width: var(--col-nav-wide); }
  .rail.wide a { padding-inline: 10px; min-height: 36px; }
  .rail.wide .section-title { margin-block: 14px 4px; margin-inline: 10px; }
  .rail.wide > .section-title:first-child { margin-block-start: 0; }
}

@media (min-width: 1180px) {
  .topbar-inner { max-width: var(--page-w); }
  /* The page widens with the header it sits under; the leftover after nav and sidebar puts the
     feed at ~550px, the width that makes the column read as a feed. */
  .shell { max-width: var(--page-w); grid-template-columns: var(--col-nav) minmax(0, 1fr) var(--col-side); }
  .side {
    display: block; position: sticky; inset-block-start: calc(var(--topbar-h) + 12px);
    align-self: start; padding: 12px 0 20px;
    max-height: calc(100vh - var(--topbar-h) - 16px);
    max-height: calc(100dvh - var(--topbar-h) - 16px);
    overflow: auto;
  }
  /* A profile is already two columns wide; a third would squeeze the wall to nothing, so the
     communities sidebar steps aside there — as it does on the pages it belongs to. */
  .shell.profile-layout { grid-template-columns: 200px minmax(0, 1fr); }
  .shell.profile-layout .side { display: none; }

  /* The opt-in wide page. Only `max-width` changes: the rail track and the sidebar track are both
     fixed widths, so every one of the extra 272px lands in the middle column — 584px becomes
     856px, which is where a registry table stops folding. The rail stays where it is and the
     sidebar stays where it is, so moving between a wide page and the feed does not re-teach the
     page to the eye.
     `.shell` itself is untouched, and this rule is inside the 1180px block, so a page that does
     not ask for the width is laid out to the pixel it was before — the feed column stays at 1104,
     and below 1180 there is no room to widen into and nothing tries. */
  .shell.wide { max-width: var(--page-w-wide); }
  /* The header follows the page it caps, or the mark and the avatar stop lining up with the rail
     and the sidebar beneath them — the "bolted on" look the base `.topbar-inner` rule exists to
     avoid. `:has()` again, and again not load-bearing: without it the bar simply stays at
     --page-w while the page below is wide.
     It has to take the shell's 24px gutter as well as its max-width, not just the max-width. On a
     1280px screen a wide page is held off its own max-width by that gutter and lands at 1241,
     while a bar bounded only by max-width fills 1265 — and the mark ends up 12px adrift of a menu
     that is right there under it. Below --page-w the two agree by accident, because both are
     pinned to the same max-width and neither is anywhere near the gutter; widen the page and the
     accident stops. */
  .app:has(.shell.wide) .topbar-inner { max-width: var(--page-w-wide); width: calc(100% - 24px); }
}

/* ---------- page furniture ------------------------------------------------ */

.page-head {
  display: flex; align-items: center; gap: 12px; margin-block-end: 12px; min-height: 34px;
}
.page-head h1 { flex: 1; min-width: 0; }
.back-link {
  display: inline-grid; place-items: center; width: 32px; height: 32px; flex: none;
  border-radius: var(--radius-sm); background: var(--surface); color: var(--muted);
  font-size: 17px; line-height: 1;
}
.back-link:hover { background: var(--surface-3); color: var(--text); }

/* A module: white, flat, no outline. What separates it from the next one is the grey page
   showing through the gap, which is why the gap is a token and not an afterthought. */
.card {
  background: var(--surface); border: 0;
  border-radius: var(--radius); box-shadow: var(--shadow); margin-block-end: 12px;
}
.card-pad { padding: 14px 16px; }
.card-head {
  display: flex; align-items: center; gap: 10px; padding: 13px 16px 6px;
}
.card-head h2, .card-head h3 { flex: 1; min-width: 0; }

.section-title {
  font-size: 13px; font-weight: 500; color: var(--muted); margin: 16px 4px 6px;
}
.side .section-title { margin-block-start: 0; }

/* The sidebar's modules put their own heading inside the card, with avatar-led destinations under
   it — one block per group rather than a floating label above a list. */
.side-module { padding: 6px 0; overflow: hidden; }
.side-title {
  display: flex; align-items: center; gap: 8px; padding: 7px 16px;
  font-size: 15px; font-weight: 500; color: var(--text);
}
.side-title:hover { background: var(--surface-2); color: var(--text); }
.side-title .count { color: var(--faint); font-weight: 400; font-size: 13px; margin-inline-start: auto; }
.side-link {
  display: flex; align-items: center; gap: 10px; padding: 6px 12px;
  color: var(--muted); font-size: 14px;
}
.side-link:hover { background: var(--surface-2); color: var(--text); }
.side-link .truncate { min-width: 0; }

.utility-footer { padding: 0 12px; font-size: 12px; }
.utility-links { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.utility-links a { color: var(--muted); padding-block: 4px; }
.utility-links a:hover { color: var(--text); text-decoration: underline; }
.utility-brand { display: block; margin-block-start: 6px; color: var(--muted); }
.auth-utilities {
  justify-content: center; margin-block-start: 18px; padding-block-start: 12px;
  border-block-start: 1px solid var(--border); font-size: 12px;
}
.public-fallback { justify-content: center; padding: 16px; font-size: 13px; }
.side-link .chip { margin-inline-start: auto; padding: 2px 6px; font-size: 11px; }
.content-list.stack { gap: 0; }

.empty {
  padding: 40px 22px; text-align: center; color: var(--muted);
  background: var(--surface); border-radius: var(--radius);
}
.empty h3 { margin-block-end: 6px; color: var(--text); }
.empty p { margin: 0 auto; max-width: 34ch; }
.empty .btn { margin-block-start: 14px; }

.loading { padding: 34px; text-align: center; color: var(--muted); }

.skeleton {
  background: var(--surface); border-radius: var(--radius);
  padding: 15px; margin-block-end: 12px;
}
.skeleton-line, .skeleton-avatar {
  background: linear-gradient(90deg, var(--surface-3) 25%, var(--surface-2) 37%, var(--surface-3) 63%);
  background-size: 400% 100%; animation: shimmer 1.3s ease infinite; border-radius: 6px;
}
.skeleton-avatar { width: 40px; height: 40px; border-radius: 50%; flex: none; }
.skeleton-line { height: 11px; margin-block-start: 9px; }
/* The three lengths a placeholder row is made of: a name, the smaller line under it, and a body
   line that stops short so the block does not read as a solid rectangle. */
.skeleton-line.name { width: 40%; margin-block-start: 0; }
.skeleton-line.meta { width: 25%; }
.skeleton-line.tail { width: 80%; }
@keyframes shimmer { 0% { background-position: 100% 50%; } 100% { background-position: 0 50%; } }

.tabs {
  display: flex; gap: 2px; overflow-x: auto; margin-block-end: 12px; padding: 4px;
  background: var(--surface); border-radius: var(--radius); scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
/* A community header runs edge to edge (the cover photo does), so its tab strip is inset by hand to
   line up with the padded content above it rather than with the card's edge. */
.tabs-inset { padding-inline: 15px; }
/* Whatever a tab is showing, hung below the header it belongs to. */
.tab-panel { margin-block-start: 16px; }
.tabs button {
  padding: 7px 14px; border: 0; border-radius: var(--radius-sm); background: none;
  font: inherit; font-size: 14px; font-weight: 500; color: var(--muted); cursor: pointer; white-space: nowrap;
}
.tabs button:hover { background: var(--surface-3); color: var(--text); }
.tabs button.active { background: var(--tint); color: var(--text); }
/* A tab that LEAVES this page rather than swapping its body — the directory (module A3). Same
   furniture as the buttons beside it, because it is the same affordance to the reader; it simply
   never takes the active state, since arriving there means this strip is no longer on screen. */
.tabs .tab-link {
  padding: 7px 14px; border-radius: var(--radius-sm);
  font-size: 14px; font-weight: 500; color: var(--muted); white-space: nowrap;
}
.tabs .tab-link:hover { background: var(--surface-3); color: var(--text); text-decoration: none; }

.chip {
  display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px;
  border-radius: var(--radius-sm); background: var(--surface-3); color: var(--muted);
  font-size: 12px; font-weight: 500; border: 1px solid transparent;
}
.chip.tint { background: var(--primary-soft); color: var(--primary-strong); }
.chip.gold { background: var(--surface-3); background: color-mix(in srgb, var(--gold) 16%, transparent); color: var(--gold); }
.chip.ok { background: var(--surface-3); background: color-mix(in srgb, var(--ok) 15%, transparent); color: var(--ok); }
.chip.warn { background: var(--surface-3); background: color-mix(in srgb, var(--warn) 16%, transparent); color: var(--warn); }
.chip.bad { background: var(--bad-soft); color: var(--bad); }
button.chip { cursor: pointer; }
button.chip:hover { border-color: var(--border-strong); }
button.chip.active { background: var(--primary); color: var(--on-primary); }

.filters { display: flex; gap: 7px; flex-wrap: wrap; margin-block-end: 12px; }

/* Posts that arrived while you were reading wait behind this rather than being spliced in above
   the line you are on. It rides just under the header so it is reachable from anywhere in the
   column without scrolling back to the top to find it. */
.new-posts {
  position: sticky; inset-block-start: calc(var(--topbar-h) + 8px); z-index: 10;
  display: block; margin: 0 auto 12px; box-shadow: var(--shadow-lg);
}

/* ---------- avatars ------------------------------------------------------- */

.avatar {
  position: relative; display: inline-grid; place-items: center; flex: none;
  width: 40px; height: 40px; border-radius: 50%; overflow: hidden;
  background: var(--primary); color: #fff; font-weight: 650; font-size: 15px;
  -webkit-user-select: none; user-select: none;
}
.avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* The monogram colour is drawn from the identity (js/style.js style_class), and only a monogram
   wants it: behind a photograph with transparency, a coloured disc would tint the picture. */
.avatar.photo, .profile-photo.photo { background: transparent; }
.avatar.sm { width: 32px; height: 32px; font-size: 13px; }
.avatar.lg { width: 64px; height: 64px; font-size: 23px; }
.avatar.xl { width: 92px; height: 92px; font-size: 32px; }
.avatar.square { border-radius: var(--radius-sm); }
.presence {
  position: absolute; inset-inline-end: -1px; inset-block-end: -1px; width: 11px; height: 11px; border-radius: 50%;
  background: var(--ok); border: 2px solid var(--surface);
}
.avatar-link { display: inline-block; line-height: 0; }
.avatar-link:hover { text-decoration: none; }

.verified {
  display: inline-block; width: 14px; height: 14px; flex: none; vertical-align: -2px;
  background: var(--primary);
  -webkit-mask: var(--i-verified) center/contain no-repeat;
  mask: var(--i-verified) center/contain no-repeat;
}
:root {
  --i-verified: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='m12 1 2.6 2.1 3.3-.3.9 3.2 2.8 1.8-1.3 3.1 1.3 3.1-2.8 1.8-.9 3.2-3.3-.3L12 23l-2.6-2.1-3.3.3-.9-3.2L2.4 16l1.3-3.1L2.4 9.8l2.8-1.8.9-3.2 3.3.3zm-1.2 14.4 5.7-5.7-1.5-1.4-4.2 4.2-2-2-1.4 1.4z'/%3E%3C/svg%3E");
}

/* ---------- list rows ----------------------------------------------------- */

/* Rows inside one module, split by a hairline that stops short of the edges — the module's own
   outline is the page behind it, so a full-width rule would read as a second border. */
.list { background: var(--surface); border: 0; border-radius: var(--radius); overflow: hidden; }
.list-row {
  display: flex; align-items: center; gap: 12px; padding: 10px 16px;
  color: var(--text); background: none; border: 0;
  width: 100%; text-align: start; font: inherit;
}
.list-row + .list-row { box-shadow: inset 0 1px 0 var(--border); }
a.list-row:hover, button.list-row:hover { background: var(--surface-2); cursor: pointer; }
.list-row-main { flex: 1; min-width: 0; }
.list-row-title { font-weight: 500; display: flex; align-items: center; gap: 6px; }
.list-row-sub { color: var(--muted); font-size: 13px; }
.list-row-side { color: var(--faint); font-size: 12px; text-align: end; flex: none; }
.list-row.unread { background: var(--primary-soft); }
.list-row.unread:hover { background: var(--primary-soft); }

/* ---------- post & event cards -------------------------------------------- */

.post { padding: 0; overflow: hidden; }
.post-head { display: flex; align-items: flex-start; gap: 10px; padding: 12px 16px 0; }
.post-head .avatar { width: 40px; height: 40px; font-size: 15px; }
.post-head-main { flex: 1; min-width: 0; }
/* The author's name is the one black thing in the header; everything under it is grey, so the eye
   goes name → text → picture and never lingers on the plumbing. */
.post-author { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-weight: 500; }
.post-author a { color: var(--text); }
.post-author a:hover { color: var(--primary); }
.post-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; color: var(--muted); font-size: 13px; }
.post-meta a { color: var(--muted); font-weight: 400; }
.post-meta a:hover { color: var(--primary); }
.post-meta .icon { width: 13px; height: 13px; color: var(--faint); }
.dot-sep::before { content: "·"; margin: 0 3px; }

.post-more {
  flex: none; width: 28px; height: 28px; border: 0; border-radius: var(--radius-sm);
  display: grid; place-items: center; background: none; color: var(--faint); cursor: pointer;
}
.post-more:hover { background: var(--surface-3); color: var(--muted); }
.post-more .icon { width: 18px; height: 18px; }

.role-pill {
  padding: 1px 7px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 500;
  background: var(--surface-3); color: var(--muted);
}
.role-pill.leader { background: var(--surface-3); background: color-mix(in srgb, var(--gold) 18%, transparent); color: var(--gold); }
.role-pill.admin { background: var(--primary-soft); color: var(--primary-strong); }

.post-body { padding: 9px 16px 0; white-space: pre-wrap; font-size: 15px; line-height: 20px; }
.post-body:empty { display: none; }
.mention, .scripture-ref { cursor: pointer; }
.scripture-ref { color: var(--gold); }

/* A long post is folded down to a readable height and unfolds in place. The fold is a real
   max-height rather than a line clamp: the text can contain a photo caption, a mention or a verse
   reference on its own line, and clamping by line count cuts those unpredictably.
   --fold-height is the one place the height is written; cards.js reads it back to decide whether a
   given post is actually tall enough to be worth folding at the current column width. */
.post-body { --fold-height: 320px; }
.post-body.folded { max-height: var(--fold-height); overflow: hidden; }
.post-fold { position: relative; }
.post-body.folded + .post-unfold::before {
  content: ""; position: absolute; inset-inline: 0; inset-block-end: 100%; height: 46px;
  background: linear-gradient(transparent, var(--surface)); pointer-events: none;
}
.post-unfold {
  position: relative; display: block; padding: 2px 16px 0; border: 0; background: none;
  font: inherit; font-size: 15px; color: var(--primary); cursor: pointer; text-align: start;
}
.post-unfold:hover { color: var(--primary-strong); }

/* A text-only post written on one of the client's canvas presets. */
.post-canvas {
  display: grid; place-items: center; min-height: 190px; padding: 26px 22px; margin: 9px 0 0;
  text-align: center; font-size: 21px; font-weight: 600; line-height: 1.35; color: #fff;
  white-space: pre-wrap;
}

.post-media { display: grid; gap: 2px; background: var(--surface-3); margin-block-start: 10px; }
.post-media.count-1 { grid-template-columns: 1fr; }
.post-media.count-2 { grid-template-columns: 1fr 1fr; }
.post-media.count-3 { grid-template-columns: 1fr 1fr; }
.post-media.count-3 > :first-child { grid-row: span 2; }
.post-media.count-4 { grid-template-columns: 1fr 1fr; }
.post-media-item {
  position: relative; display: block; overflow: hidden; background: var(--surface-3);
  aspect-ratio: 4 / 3; border: 0; padding: 0; cursor: pointer; width: 100%;
}
.post-media.count-1 .post-media-item { aspect-ratio: auto; max-height: 520px; }
.post-media.count-1 .post-media-item img { max-height: 520px; }
.post-media-item img, .post-media-item video { width: 100%; height: 100%; object-fit: cover; display: block; }
.post-media-item .play {
  position: absolute; inset: 0; margin: auto; width: 52px; height: 52px; border-radius: 50%;
  background: rgba(0, 0, 0, .55); color: #fff; display: grid; place-items: center; font-size: 20px;
}
.media-more {
  position: absolute; inset: 0; display: grid; place-items: center;
  background: rgba(0, 0, 0, .5); color: #fff; font-size: 22px; font-weight: 700;
}

.link-preview {
  display: block; margin: 10px 16px 0; border: 1px solid var(--border);
  border-radius: var(--radius-sm); overflow: hidden; color: var(--text);
}
.link-preview:hover { background: var(--surface-2); color: var(--text); }
.link-preview img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; }
.link-preview-text { padding: 9px 11px; }

/* One bar at the foot of a post rather than a count row above a button row: each action carries
   its own tally, so the number and the thing it counts are never two separate places to look. */
.post-actions {
  display: flex; align-items: center; gap: 6px; padding: 10px 16px 12px;
}
.post-action {
  display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 11px;
  border: 0; border-radius: var(--radius-pill); background: var(--surface-3);
  font: inherit; font-size: 13px; font-weight: 500; color: var(--muted); cursor: pointer;
  transition: background .12s, color .12s;
}
.post-action:hover { background: var(--tint); color: var(--tint-text); }
.post-action .icon { width: 17px; height: 17px; }
.post-action .count { font-variant-numeric: tabular-nums; }
.post-action .count:empty { display: none; }
.post-action.on { background: var(--primary-soft); background: color-mix(in srgb, var(--primary) 14%, transparent); color: var(--primary); }
.post-action.on:hover { background: var(--primary-soft); background: color-mix(in srgb, var(--primary) 22%, transparent); color: var(--primary); }
.post-action.on.love, .post-action.on.like {
  background: var(--bad-soft); background: color-mix(in srgb, var(--bad) 12%, transparent); color: var(--bad);
}
.post-action.on.love:hover, .post-action.on.like:hover {
  background: var(--bad-soft); background: color-mix(in srgb, var(--bad) 20%, transparent); color: var(--bad);
}
.post-action.on.saved { background: var(--surface-3); background: color-mix(in srgb, var(--gold) 16%, transparent); color: var(--gold); }
.post-action.on .icon-heart {
  -webkit-mask-image: var(--i-heart-fill); mask-image: var(--i-heart-fill);
}
.post-action.on .icon-bookmark-o {
  -webkit-mask-image: var(--i-bookmark); mask-image: var(--i-bookmark);
}
/* Which reactions a post actually drew, sat next to the button that casts one. */
.reaction-stack {
  display: inline-flex; align-items: center; gap: 1px; font-size: 13px; line-height: 1;
  padding-inline-start: 1px; color: var(--muted);
}
.post-actions .spacer { flex: 1; }
.post-side-count { color: var(--muted); font-size: 13px; }
.post-side-count:hover { color: var(--primary); }

.reaction-menu {
  position: absolute; z-index: 30; display: flex; gap: 4px; padding: 6px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-pill);
  box-shadow: var(--shadow-lg);
}
.reaction-menu button {
  width: 40px; height: 40px; border: 0; border-radius: 50%; background: none;
  font-size: 21px; cursor: pointer; line-height: 1;
}
.reaction-menu button:hover { background: var(--surface-3); transform: scale(1.12); }

.post-banner {
  display: flex; align-items: center; gap: 6px; padding: 10px 16px 0;
  font-size: 13px; color: var(--warn);
}
.post-banner .icon { width: 14px; height: 14px; }
/* "Pinned" is a label, not an alert: grey like the rest of a post's plumbing. */
.post-banner.pinned { color: var(--muted); }

/* ---------- event cards --------------------------------------------------- */

.event-cover { position: relative; display: block; aspect-ratio: 2 / 1; background: var(--surface-3); }
.event-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-date-badge {
  position: absolute; inset-block-start: 12px; inset-inline-start: 12px; width: 52px; padding: 5px 0; text-align: center;
  background: var(--surface); border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); line-height: 1.1;
}
.event-date-badge .m { font-size: 11px; font-weight: 600; text-transform: uppercase; color: var(--bad); }
.event-date-badge .d { font-size: 19px; font-weight: 600; }
.event-when { color: var(--primary); font-weight: 500; font-size: 13px; }
.event-where { color: var(--muted); font-size: 13px; }
/* The organiser's own description, kept as they typed it — line breaks and all. */
.event-blurb { margin: 10px 0 0; white-space: pre-wrap; }
.rsvp-group { display: flex; gap: 7px; padding: 0 16px; }

/* ---------- comments ------------------------------------------------------ */

.comment { display: flex; gap: 10px; padding: 10px 15px; }
.comment-bubble {
  background: var(--surface-3); border-radius: var(--radius); padding: 8px 12px; min-width: 0;
}
.comment-bubble .name { font-weight: 650; font-size: 13.5px; }
.comment-body { white-space: pre-wrap; }
.comment-quote {
  border-inline-start: 3px solid var(--border-strong); padding-inline-start: 8px; margin-block-end: 5px;
  color: var(--muted); font-size: 12.5px;
}
/* The same quote above the composer rather than inside a bubble, showing who is being replied to.
   Indented to sit under the avatar column instead of against the panel edge. */
.comment-quote.reply-context { margin-block: 0; margin-inline: 15px; }
/* A recorded comment or chat message. Held to a width that reads as a message rather than as a
   media player filling the bubble. */
.voice-note { max-width: 220px; }
.comment-tools { display: flex; gap: 12px; margin-block: 3px 0; margin-inline: 12px 0; font-size: 12px; color: var(--faint); }
.comment-tools button { border: 0; background: none; font: inherit; color: inherit; cursor: pointer; padding: 0; font-weight: 600; }
.comment-tools button:hover { color: var(--text); }
.comment.reply { padding-inline-start: 48px; }
.comment.highlight .comment-bubble { outline: 2px solid var(--primary); }

.composer-inline {
  display: flex; gap: 10px; align-items: flex-end; padding: 11px 15px; border-block-start: 1px solid var(--border);
}
.composer-inline textarea {
  min-height: 40px; max-height: 160px; border-radius: var(--radius); padding: 9px 12px;
}

/* ---------- composer ------------------------------------------------------ */

/* The wall's front door is a module-shaped button, not a text field. Posting here opens a dialog
   with a target, tags, mentions and uploads in it — a fake input would promise that typing starts
   in place and then take it away, so the control says what it does instead. */
.composer-box { padding: 0; overflow: hidden; }
.composer-trigger {
  display: flex; align-items: center; justify-content: center; gap: 8px;
  width: 100%; padding: 14px 16px; border: 0; background: none;
  font: inherit; font-size: 15px; font-weight: 500; color: var(--text); cursor: pointer;
}
.composer-trigger:hover { background: var(--surface-2); }
.composer-trigger .icon { width: 20px; height: 20px; color: var(--text); }

.composer-form textarea { min-height: 120px; border: 0; background: none; padding: 0; font-size: 16px; }
/* Everything but a canvas stays transparent on focus, so the textarea reads as part of the card.
   The canvas is exempt by name: its fill arrives as a generated class (js/style.js), which a bare
   `textarea:focus` would outrank — and the canvas would blank the moment it was typed into. */
.composer-form textarea:focus:not(.canvas) { background: none; }
.composer-previews { display: flex; gap: 8px; flex-wrap: wrap; margin-block-start: 12px; }
/* In a chat the strip hangs under a composer that is already flush with the window, so it carries
   the padding the surrounding panel does not. */
.composer-previews.inset { padding-inline: 12px; }
.composer-preview { position: relative; width: 92px; height: 92px; border-radius: var(--radius-sm); overflow: hidden; }
.composer-preview img, .composer-preview video { width: 100%; height: 100%; object-fit: cover; }
.composer-preview button {
  position: absolute; inset-block-start: 3px; inset-inline-end: 3px; width: 22px; height: 22px; border-radius: 50%;
  border: 0; background: rgba(0, 0, 0, .65); color: #fff; cursor: pointer; line-height: 1; font-size: 14px;
}
.composer-bar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  padding-block-start: 12px; margin-block-start: 12px; border-block-start: 1px solid var(--border);
}
.backgrounds { display: flex; gap: 7px; flex-wrap: wrap; margin-block-start: 12px; }
.background-swatch {
  width: 30px; height: 30px; border-radius: 50%; border: 2px solid transparent; cursor: pointer;
}
.background-swatch.active { border-color: var(--primary); }
/* "No background" — the only swatch whose fill is the app's own surface rather than a catalog
   entry, so it is the only one not coloured from POST_BACKGROUNDS. */
.background-swatch.none { background: var(--surface-3); }
/* The textarea wearing the chosen canvas: the catalog supplies the fill and the text colour (via
   js/style.js), everything about how the words sit on it is the same whichever one is picked. */
.composer-form textarea.canvas {
  text-align: center; font-size: 21px; font-weight: 650;
  border-radius: var(--radius); padding: 26px 18px; min-height: 190px;
}
/* Width is the upload's progress, written from js/style.js as the bytes go out. */
.upload-progress { height: 3px; width: 0; border-radius: 3px; background: var(--primary); transition: width .2s; }

.mention-popup {
  position: absolute; z-index: 30; width: 260px; max-height: 240px; overflow: auto;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
}

/* ---------- chat ---------------------------------------------------------- */

.chat {
  display: flex; flex-direction: column;
  height: calc(100vh - var(--topbar-h) - var(--tabbar-h));
  height: calc(100dvh - var(--topbar-h) - var(--tabbar-h));
}
@media (min-width: 900px) {
  .chat {
    height: calc(100vh - var(--topbar-h) - 58px);
    height: calc(100dvh - var(--topbar-h) - 58px);
  }
}
.chat-head {
  display: flex; align-items: center; gap: 11px; padding: 10px 14px;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0;
}
.chat-log {
  flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 4px;
  background: var(--surface-2); border-inline: 1px solid var(--border);
}
.bubble-row { display: flex; }
.bubble-row.out { justify-content: flex-end; }
.bubble {
  max-width: min(78%, 520px); padding: 8px 12px; border-radius: 16px;
  background: var(--surface); border: 1px solid var(--border); box-shadow: var(--shadow);
}
.bubble-row.out .bubble { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
.bubble-row.out .bubble .bubble-meta { color: rgba(255, 255, 255, .75); color: color-mix(in srgb, var(--on-primary) 75%, transparent); }
.bubble-row.first .bubble { border-start-start-radius: 6px; }
.bubble-row.out.first .bubble { border-start-start-radius: 16px; border-start-end-radius: 6px; }
.bubble-text { white-space: pre-wrap; }
.bubble-meta { display: flex; align-items: center; justify-content: flex-end; gap: 4px; font-size: 11px; color: var(--faint); margin-block-start: 2px; }
.bubble-quote {
  border-inline-start: 3px solid currentColor; opacity: .8; padding-inline-start: 7px; margin-block-end: 4px; font-size: 12.5px;
}
.bubble img { border-radius: 10px; display: block; margin-block-end: 4px; }
/* A photograph in a message: big enough to see, small enough that the bubble is still a bubble.
   It opens the lightbox, so it points like the link it is. */
.chat-photo { max-width: 260px; cursor: pointer; }

/* A shared post/event/community/person inside a bubble (see ui/shared_entity.js). It carries its own
 * surface rather than inheriting the bubble's, so it reads as a thing being handed over rather than
 * as more message — and so it stays legible on an outgoing bubble, whose background is the accent. */
.shared-card {
  display: flex; gap: 9px; align-items: center; margin-block-end: 4px; padding: 8px;
  background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
  cursor: pointer; color: var(--text); text-align: start; min-width: 190px;
}
.shared-card:hover, .shared-card:focus-visible { border-color: var(--border-strong); }
/* Unresolved: the title IS the "no longer available" line, so it must not read as a name. */
.shared-card.unavailable { cursor: default; }
.shared-card.unavailable .shared-card-title { color: var(--muted); font-weight: 400; }
.shared-card-thumb { width: 44px; height: 44px; border-radius: 8px; object-fit: cover; flex: none; margin: 0; }
.shared-card-body { min-width: 0; }
.shared-card-kind {
  color: var(--primary); font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em;
}
.shared-card-title {
  font-size: 13.5px; font-weight: 600; margin-block-start: 1px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.shared-card-subtitle {
  color: var(--muted); font-size: 12px; margin-block-start: 1px;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.chat-day { align-self: center; margin: 8px 0; }
.chat-compose {
  display: flex; gap: 9px; align-items: flex-end; padding: 10px 12px; background: var(--surface);
  border: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius);
}
.chat-compose textarea { min-height: 40px; max-height: 140px; border-radius: var(--radius-pill); padding: 9px 14px; }
.typing-dots { display: inline-flex; gap: 3px; align-items: center; height: 14px; }
.typing-dots i { width: 5px; height: 5px; border-radius: 50%; background: var(--muted); animation: bounce 1.1s infinite; }
.typing-dots i:nth-child(2) { animation-delay: .15s; }
.typing-dots i:nth-child(3) { animation-delay: .3s; }
@keyframes bounce { 0%, 60%, 100% { transform: translateY(0); opacity: .5; } 30% { transform: translateY(-4px); opacity: 1; } }

/* ---------- community headers --------------------------------------------- */

.cover {
  height: 168px; background: linear-gradient(135deg, var(--primary), var(--primary-strong));
  border-radius: var(--radius) var(--radius) 0 0; overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.profile-head { padding: 0 16px 16px; margin-block-start: -46px; }
.profile-head .avatar { border: 4px solid var(--surface); }
.profile-name { display: flex; align-items: center; gap: 7px; margin-block-start: 10px; }
.profile-stats { display: flex; gap: 18px; margin-block-start: 8px; color: var(--muted); font-size: 13px; }
.profile-stats b { color: var(--text); font-weight: 500; }
.profile-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-block-start: 14px; }

.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr)); gap: 12px; }
.stat { background: var(--surface); border-radius: var(--radius); padding: 13px 16px; }
.stat .v { font-size: 21px; font-weight: 500; line-height: 1.2; }
/* A sum of money is a longer figure than a count — it carries its currency — so it is set smaller
   to keep it on one line in the same tile. */
.stat .v.money { font-size: 17px; }
.stat .k { color: var(--muted); font-size: 13px; }
/* The ledger's totals sit inside a card that has none of its own, between the head and the rows. */
.stat-grid.padded { padding: 15px; }

/* ---------- member profile -------------------------------------------------
 *
 * Two columns: who this is on the left — the photograph, what you can do about them, and the few
 * numbers worth a link — and what they have to say on the right. The split is the point: identity
 * stays put on the left while the wall scrolls past it. */

.profile-grid { display: grid; gap: 12px; align-items: start; }
.profile-main { min-width: 0; }
@media (min-width: 760px) {
  .profile-grid { grid-template-columns: 250px minmax(0, 1fr); }
  .profile-aside { position: sticky; inset-block-start: calc(var(--topbar-h) + 12px); }
}

.profile-cover {
  height: 200px; margin-block-end: 12px; border-radius: var(--radius); overflow: hidden;
  background: linear-gradient(135deg, var(--primary), var(--primary-strong));
}
.profile-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }

.profile-photo {
  display: block; width: 100%; aspect-ratio: 1; border-radius: var(--radius);
  overflow: hidden; background: var(--primary); position: relative;
}
.profile-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.profile-photo .monogram {
  position: absolute; inset: 0; display: grid; place-items: center;
  color: #fff; font-size: 76px; font-weight: 500; -webkit-user-select: none; user-select: none;
}
.profile-photo .presence { inset-inline-end: 10px; inset-block-end: 10px; width: 16px; height: 16px; border-width: 3px; }

/* The picture just chosen in the edit form, standing in for the avatar it is about to replace and
   drawn at the same size so the swap is what it looks like. */
.avatar-preview { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; }

.profile-aside-actions { display: grid; gap: 6px; margin-block-start: 10px; }
.profile-aside-actions .btn { width: 100%; }

/* Counter rows: the number leads because it is what the eye is looking for, and the whole row is
   the target so a fifteen-pixel figure is never the thing you have to hit. */
.profile-links { margin-block-start: 12px; overflow: hidden; }
.profile-link {
  display: flex; align-items: baseline; gap: 6px; padding: 9px 16px;
  color: var(--text); font-size: 13px; width: 100%; border: 0; background: none;
  font: inherit; text-align: start;
}
.profile-link + .profile-link { box-shadow: inset 0 1px 0 var(--border); }
a.profile-link:hover, button.profile-link:hover { background: var(--surface-2); cursor: pointer; }
.profile-link b { font-size: 15px; font-weight: 500; font-variant-numeric: tabular-nums; }
.profile-link span { color: var(--muted); font-size: 13px; }

.profile-title { padding: 14px 16px 0; }
.profile-title h1 { font-size: 21px; font-weight: 500; display: flex; align-items: center; gap: 7px; }
.profile-status { color: var(--muted); font-size: 13px; margin-block-start: 2px; }

/* Label on the left, value on the right, both on one baseline — the shape a person reads as
   "facts about someone" without being told. */
.info-table { padding: 12px 16px 14px; display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; }
.info-table dt { color: var(--muted); font-size: 13px; }
.info-table dd { margin: 0; font-size: 13px; }
.info-table dd a { color: var(--primary); }

/* ---------- scripture reader ---------------------------------------------- */

.reader { font-family: var(--serif); font-size: 18px; line-height: 1.85; }
/* A verse quoted somewhere other than the reader — the feed's verse of the day. The serif and the
   leading carry over; the size steps down so a card does not out-shout the posts under it. */
.reader.excerpt { margin: 0; font-size: 17px; }
.reader .verse { display: block; padding: 2px 4px; border-radius: 5px; }
.reader .verse:hover { background: var(--surface-3); }
.reader .verse-number {
  font-family: var(--sans); font-size: 11px; font-weight: 700; color: var(--primary);
  vertical-align: super; margin-inline-end: 4px;
}
.reader .verse.has-posts { background: rgba(201, 138, 18, .12); background: color-mix(in srgb, var(--gold) 12%, transparent); }
/* The verse a link arrived at. Ringed rather than filled, so it still reads as itself next to a
   `has-posts` neighbour instead of looking like a third kind of verse. */
.reader .verse.focused { outline: 2px solid var(--primary); }
.book-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: 7px; }
.chapter-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(48px, 1fr)); gap: 7px; }
.chapter-grid button { padding: 9px 0; }

/* ---------- giving -------------------------------------------------------- */

.amount-input { font-size: 26px; font-weight: 700; text-align: center; font-variant-numeric: tabular-nums; }
.receipt { display: flex; justify-content: space-between; padding: 6px 0; border-block-end: 1px dashed var(--border); }
.receipt:last-child { border-block-end: 0; }
.receipt .k { color: var(--muted); }
.receipt.total { font-weight: 700; font-size: 16px; }
.ussd {
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 15px;
  background: var(--surface-3); padding: 10px 12px; border-radius: var(--radius-sm); word-break: break-all;
}

/* ---------- dialogs, toast, lightbox --------------------------------------- */

/* Three shapes drawn on one <dialog>, chosen by a class from js/ui/overlay.js: the plain form or
   content dialog, `.alert` for a question, `.sheet` for a list of actions. What the three share is
   here; what separates them follows.

   The look is VK's ModalCard and Android's AlertDialog rather than the framework default everyone
   recognises, and the difference is mostly subtraction: no rule under the title, no rule above the
   buttons, a radius wider than a card's, and a scrim that darkens AND blurs the page instead of
   tinting it. The hairlines a framework draws unconditionally are drawn here only when the body
   really does have content out of view — `.cut-top` / `.cut-bottom`, set from overlay.js — so a
   short dialog is one uninterrupted surface and a long one says that it continues.

   `display` is declared on `.modal[open]` and never on `.modal`. The UA hides a closed dialog with
   `dialog:not([open]) { display: none }`, and an author rule outranks a UA rule whatever its
   specificity, so a bare `.modal { display: flex }` would leave every closed dialog laid out and
   visible in the page. The same trap is written up over `.dropdown-popup`. */
.modal {
  width: min(520px, calc(100vw - 32px));
  max-height: min(86vh, 780px); max-height: min(86dvh, 780px);
  padding: 0;
  border: 0; border-radius: 16px; background: var(--surface); color: var(--text);
  box-shadow: var(--shadow-lg); overflow: hidden;
  /* Transform only, deliberately no opacity — the reason is spelled out over `@keyframes
     dropdown-in`: an entry animation starting at `opacity: 0` leaves the element invisible yet
     still hit-testable for as long as the animation does not advance, and for a MODAL dialog that
     is an invisible sheet over a page that has stopped taking clicks. */
  animation: modal-in .22s cubic-bezier(.2, .9, .3, 1);
}
.modal[open] { display: flex; flex-direction: column; }
/* Forms with two columns of fields, and anything holding a composer, ask for the wider dialog. */
.modal.wide { width: min(760px, calc(100vw - 32px)); }
/* A list of actions wants to be narrower than a form, not wider: every row is one short label, and
   a 520px row leaves the label stranded at one end of a lot of nothing. */
.modal.sheet { width: min(420px, calc(100vw - 32px)); }
.modal::backdrop {
  background: rgba(0, 0, 0, .45);
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
  animation: backdrop-in .22s ease;
}

/* The exit. `forwards` holds the last frame until overlay.js calls close() on `animationend`;
   without it the dialog snaps back to full size for the frame in between. */
.modal.closing { animation: modal-out .16s ease-in forwards; }
.modal.closing::backdrop { animation: backdrop-out .16s ease-in forwards; }

@keyframes modal-in { from { transform: translateY(12px) scale(.97); } }
@keyframes modal-out { to { opacity: 0; transform: translateY(8px) scale(.98); } }
@keyframes backdrop-in { from { opacity: 0; } }
@keyframes backdrop-out { to { opacity: 0; } }

/* The strip above the body — the grip, the title, and the way out. On a phone it is also the drag
   handle, which is where its `touch-action` goes. */
.modal-top { flex: none; }
.modal-grip { display: none; }
.modal-head { display: flex; align-items: center; gap: 8px; padding: 17px 20px 11px; }
.modal-title { flex: 1; min-width: 0; font-size: 19px; font-weight: 700; letter-spacing: -.015em; }

/* A soft disc rather than a bare glyph on the surface. It is the only control in the head, so it
   has to read as one without a border to say so. */
.modal-close {
  flex: none; display: inline-grid; place-items: center;
  width: 32px; height: 32px; border: 0; border-radius: 50%;
  background: var(--surface-3); color: var(--muted); cursor: pointer;
  transition: background .12s, color .12s; touch-action: manipulation;
}
.modal-close:hover { background: var(--tint); color: var(--text); }
.modal-close .icon { width: 14px; height: 14px; }

/* The body takes the slack rather than carrying a `calc()` of the dialog's height minus a guessed
   head and foot. That arithmetic was wrong the moment a title wrapped to two lines; flex measures
   the two for itself. */
.modal-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 3px 20px 20px; }
.modal-foot { flex: none; display: flex; justify-content: flex-end; gap: 9px; padding: 14px 20px 18px; }

/* Drawn as a box-shadow and not a border: a line that comes and goes as the body scrolls must not
   also change the height of the thing it sits on. */
.modal.cut-top .modal-top { box-shadow: 0 1px 0 var(--border); }
.modal.cut-bottom .modal-foot { box-shadow: 0 -1px 0 var(--border); }

/* The drag gesture (phone only — drag_to_dismiss() in overlay.js). While the finger is down the
   transform is rewritten on every pointer event, so nothing may smooth it; on release the rule is
   dropped and `.settling` is what carries the value home. Neither is declared on `.modal` itself,
   where a transition on transform could catch the entry animation on its way out. */
.modal.dragging { transition: none; }
.modal.settling { transition: transform .2s cubic-bezier(.2, .8, .2, 1); }

/* ---- the alert: a question with two answers -------------------------------
   Narrower than a form and centred at every width, phone included. Its contents are centred too,
   which is what separates a question from a form: there is nothing here to fill in, only something
   to read and answer, and centring is what stops the eye hunting for a field. */
.modal.alert { width: min(400px, calc(100vw - 40px)); border-radius: 18px; }
.modal.alert .modal-body { padding: 26px 24px 4px; text-align: center; }
.alert-title { font-size: 19px; font-weight: 700; letter-spacing: -.015em; }
.alert-text { margin: 9px 0 0; color: var(--muted); font-size: 14.5px; line-height: 1.45; }
.modal.alert .modal-foot { gap: 8px; padding: 20px; }
/* `flex: 1` beats the `width: 100%` the buttons carry for the stacked phone layout — a flex item's
   basis of 0 is what makes the two exactly equal rather than each as wide as its own label. */
.modal.alert .modal-foot .btn { flex: 1; min-height: 42px; }

/* ---- the action sheet -----------------------------------------------------
   A list of the things that can be done to something. Its title is a caption for the list rather
   than a heading over it — small, grey, centred — because the rows are the content, and a 19px
   heading above four 15px rows makes the question look more important than its answers. */
.modal.sheet .modal-head { justify-content: center; padding: 15px 20px 6px; }
.modal.sheet .modal-title {
  flex: 0 1 auto; text-align: center;
  font-size: 13px; font-weight: 600; letter-spacing: 0; color: var(--muted);
}
.modal.sheet .modal-title:empty { display: none; }
.modal.sheet .modal-body { padding: 6px 8px 8px; }
.sheet-list { display: flex; flex-direction: column; }
.sheet-item {
  display: flex; align-items: center; gap: 12px; width: 100%;
  padding: 13px 14px; border: 0; border-radius: 10px;
  background: transparent; color: var(--text); font: inherit; font-size: 15.5px; font-weight: 500;
  text-align: start; cursor: pointer;
  transition: background .12s; touch-action: manipulation;
}
.sheet-item:hover { background: var(--surface-3); }
.sheet-item:active { background: var(--tint); }
.sheet-item.danger { color: var(--bad); }
.sheet-item-label { flex: 1; min-width: 0; }

/* Cancel is set apart from the rows rather than listed among them: it is the way out of the sheet,
   not one of the things the sheet offers to do. */
.modal.sheet .modal-foot { padding: 2px 8px 10px; }
.sheet-cancel {
  width: 100%; padding: 13px 14px; border: 0; border-radius: 10px;
  background: transparent; color: var(--muted); font: inherit; font-size: 15.5px; font-weight: 600;
  cursor: pointer; transition: background .12s, color .12s; touch-action: manipulation;
}
.sheet-cancel:hover { background: var(--surface-3); color: var(--text); }

/* Centred by stretching to both inline edges and letting `auto` margins share the slack, not by
   the `inset-inline-start: 50%` + `translateX(-50%)` idiom. `translateX` is physical: on an RTL
   page the inset resolves to the right edge while the transform still pulls left, and the pair
   lands the toast a full width off centre. Auto margins have no such handedness. */
.toasts {
  position: fixed; inset-inline: 0; margin-inline: auto; width: fit-content;
  inset-block-end: calc(var(--tabbar-h) + 16px); z-index: 60;
  display: flex; flex-direction: column; gap: 8px; align-items: center; pointer-events: none;
}
@media (min-width: 900px) { .toasts { inset-block-end: 24px; } }
.toast {
  padding: 10px 16px; border-radius: var(--radius-pill); background: var(--text); color: var(--bg);
  box-shadow: var(--shadow-lg); font-size: 14px; font-weight: 550; max-width: 90vw;
  animation: toast-in .22s ease;
}
.toast.bad { background: var(--bad); color: #fff; }
/* Set on the way out, a beat before the node is removed, so the toast fades instead of vanishing
   mid-sentence. The transition is declared here rather than on `.toast` so it cannot also catch the
   entry animation. */
.toast.going { opacity: 0; transition: opacity .2s; }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } }

/* Auto-margin centring, for the reason given above `.toasts`. */
.connection {
  position: fixed; inset-block-start: calc(var(--topbar-h) + 8px); z-index: 50;
  inset-inline: 0; margin-inline: auto; width: fit-content;
  padding: 6px 14px; border-radius: var(--radius-pill); background: var(--warn); color: #fff;
  font-size: 12.5px; font-weight: 650; box-shadow: var(--shadow);
}

.lightbox {
  position: fixed; inset: 0; z-index: 70; background: rgba(0, 0, 0, .93);
  display: grid; place-items: center; padding: 20px;
}
.lightbox img, .lightbox video {
  max-width: 100%; max-height: 88vh; max-height: 88dvh; object-fit: contain;
}
.lightbox-close {
  position: absolute; inset-block-start: 14px; inset-inline-end: 16px; width: 40px; height: 40px; border-radius: 50%;
  border: 0; background: rgba(255, 255, 255, .16); color: #fff; font-size: 21px; cursor: pointer;
}
.lightbox-nav {
  position: absolute; inset-block-start: 50%; transform: translateY(-50%); width: 46px; height: 46px; border-radius: 50%;
  border: 0; background: rgba(255, 255, 255, .16); color: #fff; font-size: 22px; cursor: pointer;
}
.lightbox-nav.prev { inset-inline-start: 14px; }
.lightbox-nav.next { inset-inline-end: 14px; }

.sentinel { height: 1px; }
.load-more { display: grid; place-items: center; padding: 16px; color: var(--muted); }

/* ---------- reading ------------------------------------------------------- */

/* The devotional surface, for anything read continuously rather than scanned: a chapter, the
   daily verse, a prayer written out at length. Three things make it, and each is doing a job the
   feed's furniture actively works against.

   Paper, because a passage held for minutes is not a post glanced at for seconds — pure white at
   that length glares, and the warm ground takes the edge off without becoming a colour anyone
   notices. The serif, because it is what scripture has been set in for five hundred years and the
   reader knows the voice before reading a word. And the measure: 62 characters is the width at
   which the eye still finds the start of the next line without hunting for it. That last one is
   the whole reason this is a class and not a font-family declaration — the app's columns are
   sized for a feed, and a passage poured into 856px of a wide page is unreadable no matter how
   good the type is.

   `max-inline-size`, not `max-width`: the measure is a fact about the line of text, so it belongs
   on the axis the text runs along, and an Arabic or Hebrew passage gets its 62 characters on the
   same terms. Centred, because a measure pinned to the start edge of a wide column reads as a
   layout that failed rather than one that chose. */
.reading {
  background: var(--paper);
  color: var(--ink);
  font-family: var(--serif);
  max-inline-size: 62ch;
  margin-inline: auto;
}
/* Secondary text on paper needs paper's own grey. --muted is tuned against the blue-grey page and
   goes cold and slightly foreign here; --ink-muted is the same relationship measured from --ink. */
.reading .muted, .reading .faint, .reading .hint { color: var(--ink-muted); }
/* The second paper tone, for anything set into the page rather than on it — a pull quote, a verse
   held apart from the passage around it. It is what --surface-3 is to a card, on this ground. */
.reading .inset { background: var(--paper-2); border-radius: var(--radius-sm); padding: 12px 14px; }

/* ---- Arabic and Hebrew need more air between the lines, and a little more of it -------
 *
 * The one typographic thing about these two scripts that a font stack cannot fix. Arabic sets
 * below its baseline as freely as above it — descenders on ج ح خ ع, and vowel marks stacked under
 * as well as over — and a vocalised passage puts marks on nearly every letter. Hebrew does the same
 * with nikkud and, in scripture, cantillation above AND below. Latin leading that is comfortable at
 * 1.85 lets those collide with the line beneath, and what a reader sees is not "tight leading", it
 * is marks touching the words under them. On an unvocalised text nothing collides and the page
 * simply reads cramped, which is the version most people will meet.
 *
 * The size bump goes with it: at the same nominal size these scripts have a smaller effective
 * x-height than a Latin serif, so 18px Arabic reads noticeably smaller than 18px Georgia beside it.
 *
 * `:lang()` rather than `:dir()`, deliberately, and the two are NOT interchangeable here. `:dir` is
 * about which way the page runs; this is about which script the glyphs are, and the page runs
 * right-to-left for both of these while the leading each one wants is not the same. `:lang` also
 * matches an Arabic passage sitting inside a French page — which is the case the whole bidi block
 * at the foot of this sheet exists for — as long as the node carries the language. Today only
 * <html> does, from i18n.js's stamp, so this reaches the whole page in Arabic and nothing in
 * French. A future `lang` on wire prose would extend it exactly where it should go. */
:lang(ar) .reader,
:lang(ar) .reading,
:lang(ar) .reader-page { font-size: 19px; line-height: 2.05; }
:lang(he) .reader,
:lang(he) .reading,
:lang(he) .reader-page { line-height: 1.95; }

/* ---------- phone polish ---------------------------------------------------
   These blocks live at the END of the sheet on purpose: they re-declare properties that the
   component rules above set at the same specificity, and the cascade breaks ties by source
   order. Earlier in the file, every one of these would silently lose. */

@media (max-width: 620px) {
  .topbar-inner {
    /* The `max()` pair stays physical — see the note in the `.topbar-inner` base rule. */
    padding-inline: 8px;
    padding-left: max(8px, env(safe-area-inset-left, 0px));
    padding-right: max(8px, env(safe-area-inset-right, 0px));
    gap: 8px;
  }
  .brand { gap: 0; }
  .brand-mark { width: 32px; height: 32px; }
  .topbar-search { min-width: 0; max-width: none; }
  .topbar-actions { gap: 0; }
  .topbar-actions .icon-button { width: 38px; height: 38px; }
  .topbar-avatar { width: 34px; height: 34px; }

  /* Modules run edge to edge, the way VK's phone client draws its feed: on a 375px screen the
     page-grey gutters are dead pixels, and the hairline gap between modules does the separating. */
  .view { padding: 8px 0 calc(var(--tabbar-h) + 20px); }
  .card, .list { border-radius: 0; margin-block-end: 8px; }
  .card-pad { padding: 14px 12px; }
  .page-head, .filters { margin-inline: 12px; }
  .post-head { padding-inline: 12px; }
  .post-body { padding-inline: 12px; }
  .post-unfold { padding-inline: 12px; }
  .post-actions { gap: 5px; padding: 10px 12px 12px; overflow-x: auto; scrollbar-width: none; }
  .post-actions::-webkit-scrollbar { display: none; }
  .post-action { min-width: 38px; height: 32px; padding: 0 10px; }
  .link-preview { margin-inline: 12px; }
  .rsvp-group { padding-inline: 12px; }
  .comment { padding-inline: 12px; }
  .comment.reply { padding-inline-start: 38px; }
  .composer-inline { padding-inline: 12px; }

  /* 16px is the threshold under which iOS Safari zooms the page to a focused field. The topbar
     search keeps 14px: it sits in a fixed bar where the zoom does not fire the same way, and the
     pill has no room for the larger size. */
  input[type="text"], input[type="email"], input[type="password"], input[type="search"],
  input[type="tel"], input[type="number"], input[type="url"], input[type="datetime-local"],
  select, textarea { font-size: 16px; }
  .topbar-search input { font-size: 14px; }
  .btn { min-height: 40px; }
  .btn.small { min-height: 34px; }

  /* Community and profile covers scale with the narrower column instead of towering over it. */
  .cover { height: 120px; }
  .profile-cover { height: 140px; }
  .post-canvas { min-height: 150px; font-size: 19px; }

  /* Dialogs become bottom sheets, the shape a phone hand expects: pinned to the bottom edge, full
     width, rounded only at the top, sliding up rather than scaling in, and pulled back down by the
     grip. `.wide` rides along explicitly — its own rule is one class more specific than this one,
     so left to the cascade a wide dialog kept its desktop width and, with no inline margin to
     centre it, sat against the start edge of the screen. */
  .modal, .modal.wide, .modal.sheet {
    width: 100vw; max-width: none;
    margin-block: auto 0; margin-inline: 0;
    border-radius: 18px 18px 0 0;
    max-height: 92vh; max-height: 92dvh;
    animation: sheet-in .26s cubic-bezier(.2, .9, .3, 1);
  }
  .modal.closing { animation: sheet-out .2s ease-in forwards; }
  .modal-foot { padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px)); }

  /* The strip is the drag handle. `touch-action: none` is what stops the browser claiming the
     vertical gesture as a scroll before the pointer events reach drag_to_dismiss() — the strip does
     not scroll, so nothing is lost, and the body it sits above keeps its own gesture untouched. */
  .modal-top { touch-action: none; }
  .modal-grip {
    display: block; width: 36px; height: 4px; margin: 7px auto 0;
    border-radius: var(--radius-pill); background: var(--border-strong);
  }
  .modal-head { padding-block: 11px; }
  .modal.sheet .modal-head { padding-block: 12px 4px; }
  .modal.sheet .modal-foot { padding: 2px 8px calc(10px + env(safe-area-inset-bottom, 0px)); }
  /* A row a thumb reaches for, not one a pointer clicks. */
  .sheet-item, .sheet-cancel { padding-block: 15px; }

  /* The alert is exempt from all of it and keeps its centred card — the reason is written out over
     the shapes in overlay.js: a question is not a sheet, and its destructive answer must not land
     where the thumb that opened it is already resting. */
  .modal.alert {
    width: min(400px, calc(100vw - 40px)); max-width: none; margin: auto;
    border-radius: 18px;
    animation: modal-in .22s cubic-bezier(.2, .9, .3, 1);
  }
  .modal.alert.closing { animation: modal-out .16s ease-in forwards; }
  /* Stacked, and reversed so the DOM order [cancel, confirm] puts the answer that goes ahead on
     top — next to the sentence it answers, and the furthest of the two from where a thumb rests. */
  .modal.alert .modal-foot {
    flex-direction: column-reverse; gap: 8px;
    padding: 18px; padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
  }
  .modal.alert .modal-foot .btn { flex: none; min-height: 46px; }

  /* Declared inside the query because they exist only for this shape. */
  @keyframes sheet-in { from { transform: translateY(100%); } }
  @keyframes sheet-out { to { transform: translateY(100%); } }

  .lightbox { padding: 10px; }
  .lightbox-nav { width: 40px; height: 40px; }
  .lightbox-nav.prev { inset-inline-start: 8px; }
  .lightbox-nav.next { inset-inline-end: 8px; }
}

/* Side-by-side fields (first/last name, date/time) stack once two of them cannot both hold a
   readable value. Each field keeps its own bottom margin, so no extra gap is needed. */
@media (max-width: 480px) {
  .field-row { flex-direction: column; gap: 0; }
  .phone-group { flex-direction: column; gap: 0; }
  .phone-group > .field.country { flex: none; }
}

@media (max-width: 360px) {
  .topbar-search input { padding-inline-end: 8px; }
  .post-side-count { display: none; }
  .post-actions .spacer { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

/* ---------- congregation workspace ---------------------------------------- */

/* Three glyphs the member side of the app never needed. Declared here beside what uses them, the
   way the sheet already declares --i-chevron and --i-check next to the dropdown. */
:root {
  --i-announce: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 10v4h3l5 4V6L6 10zm12.5 2a3.5 3.5 0 00-2-3.15v6.3A3.5 3.5 0 0015.5 12zM13 4.5v2.1a5.5 5.5 0 010 10.8v2.1a7.5 7.5 0 000-15z'/%3E%3C/svg%3E");
  --i-mail: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2 6a2 2 0 012-2h16a2 2 0 012 2l-10 6zm0 2.3V18a2 2 0 002 2h16a2 2 0 002-2V8.3l-10 6z'/%3E%3C/svg%3E");
  --i-money: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2c4.4 0 8 1.3 8 3s-3.6 3-8 3-8-1.3-8-3 3.6-3 8-3zM4 8.5C5.6 9.5 8.6 10 12 10s6.4-.5 8-1.5V12c0 1.7-3.6 3-8 3s-8-1.3-8-3zm0 6c1.6 1 4.6 1.5 8 1.5s6.4-.5 8-1.5V18c0 1.7-3.6 3-8 3s-8-1.3-8-3z'/%3E%3C/svg%3E");
}
.icon-check { -webkit-mask: var(--i-check) center/contain no-repeat; mask: var(--i-check) center/contain no-repeat; }
.icon-announce { -webkit-mask: var(--i-announce) center/contain no-repeat; mask: var(--i-announce) center/contain no-repeat; }
.icon-mail { -webkit-mask: var(--i-mail) center/contain no-repeat; mask: var(--i-mail) center/contain no-repeat; }
.icon-money { -webkit-mask: var(--i-money) center/contain no-repeat; mask: var(--i-money) center/contain no-repeat; }

/* ---- the switcher in the top bar ----
   Absent below the rail's own breakpoint, and that is the whole of the phone rule: the tab bar
   keeps the member's five destinations and gains no sixth, so a phone enters a workspace from the
   congregation page and navigates it from the sheet. A 375px bar holding a mark, a search field and
   four controls has nowhere to put this, and a workspace is not something a member reaches for
   while standing up. */
.topbar-switch { display: none; }
@media (min-width: 900px) {
  /* `:not([hidden])` rather than a bare class: an author rule outranks the UA's `[hidden]`, so a
     plain `display: block` here would show the empty host for every member who has no workspace —
     the one case switcher.js hides it for. */
  .topbar-switch:not([hidden]) { display: block; flex: none; min-width: 0; }
}

.switch-trigger {
  display: flex; align-items: center; gap: 8px; max-width: 220px;
  height: 32px; padding-inline: 8px 6px; border: 0; border-radius: var(--radius-pill);
  background: var(--surface-3); color: var(--text);
  font: inherit; font-size: 14px; font-weight: 500; cursor: pointer;
}
.switch-trigger:hover { background: var(--tint); }
.switch-trigger[aria-expanded="true"] { background: var(--tint); }
.switch-trigger .icon { width: 18px; height: 18px; color: var(--muted); }
/* Smaller than `.avatar.sm`, which is the 32px a list row wants and would fill the whole control. */
.switch-trigger .avatar { width: 22px; height: 22px; font-size: 10px; }
.switch-name { min-width: 0; }
.switch-chevron {
  width: 16px; height: 16px; flex: none; background: var(--faint);
  -webkit-mask: var(--i-chevron) center/contain no-repeat;
  mask: var(--i-chevron) center/contain no-repeat;
}

/* Positioned from js/ui/switcher.js through the runtime sheet — `inset: auto` is what undoes the
   UA's `inset: 0` on a popover, leaving the two edges that file actually sets to place it. */
.switch-menu {
  position: fixed; inset: auto; margin: 0; padding: 6px; z-index: 80;
  flex-direction: column; overflow: auto; overscroll-behavior: contain;
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow-lg); color: var(--text);
}
/* `display` only in the states that are shown, for the reason spelt out over `.dropdown-popup`:
   overriding the UA's hide rule leaves a closed popover laid out and hit-testable. */
.switch-menu:popover-open { display: flex; }
.switch-menu:not([popover]) { display: flex; }
.switch-menu::backdrop { background: transparent; }
.switch-menu-head {
  padding: 6px 10px 8px; color: var(--muted); font-size: 12px; font-weight: 500;
}
.switch-item {
  display: flex; align-items: center; gap: 10px; padding: 7px 10px;
  border-radius: var(--radius-sm); color: var(--text); font-size: 14px;
}
.switch-item:hover, .switch-item:focus-visible { background: var(--surface-2); text-decoration: none; }
.switch-item.current { background: var(--tint); font-weight: 500; }
.switch-item .avatar { width: 26px; height: 26px; font-size: 11px; }
.switch-check {
  width: 16px; height: 16px; flex: none; margin-inline-start: auto; background: var(--primary);
  -webkit-mask: var(--i-check) center/contain no-repeat;
  mask: var(--i-check) center/contain no-repeat;
}

/* ---- the way out, at the head of the wide rail ----
   Set apart from the module rows by a rule rather than by air: it is not one of the destinations
   below it, it is the way off this page altogether. */
.rail-back-arrow { width: 20px; flex: none; text-align: center; font-size: 15px; line-height: 1; }
@media (min-width: 900px) {
  /* Two classes deep, so it outranks `.rail a` without an !important — the whole reason the arrow
     is a <span> of its own rather than a masked .icon is that `.rail .icon` would then be the rule
     to fight as well. */
  .rail.wide .rail-back {
    color: var(--muted); font-size: 13px; border-radius: 0;
    margin-block-end: 10px; padding-block-end: 10px;
    border-block-end: 1px solid var(--border);
  }
  .rail.wide .rail-back:hover { background: transparent; color: var(--text); }
}

/* ---- the phone's rail: the same rows as a sheet ---- */
.workspace-bar { display: flex; align-items: center; margin-block-end: 10px; }
@media (min-width: 900px) { .workspace-bar { display: none; } }
.workspace-bar .switch-trigger { height: 36px; max-width: 100%; }

.rail-sheet { display: flex; flex-direction: column; }
.rail-sheet a {
  display: flex; align-items: center; gap: 12px; min-height: 42px; padding: 6px 4px;
  color: var(--text); font-size: 15px;
}
.rail-sheet a:hover { text-decoration: none; }
.rail-sheet a.active { color: var(--primary); font-weight: 500; }
.rail-sheet .icon { width: 21px; height: 21px; color: var(--faint); }
.rail-sheet a.active .icon { color: var(--primary); }
.rail-sheet .section-title { margin: 14px 0 2px; }
.rail-sheet .rail-back {
  color: var(--muted); font-size: 14px;
  margin-block-end: 6px; padding-block-end: 12px; border-block-end: 1px solid var(--border);
}

/* ---- the module placeholders ----
   Every destination in the rail lands on one of these until its own screen is built. The chip
   carrying the permission name is monospaced because it is an identifier to be read character by
   character and quoted to whoever grants it, not a phrase to be skimmed. */
.chip.mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-size: 11px; letter-spacing: -.01em;
}
.module-stub h1 { margin-block: 2px 8px; }
.module-stub p { margin-block-start: 12px; }

.module-group { margin-block-start: 6px; }
.module-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
.module-tile {
  display: flex; flex-direction: column; align-items: flex-start; gap: 6px;
  padding: 12px; border-radius: var(--radius-sm); background: var(--surface-2); color: var(--text);
}
.module-tile:hover { background: var(--surface-3); text-decoration: none; }
.module-tile-name { font-size: 15px; font-weight: 500; }

/* ---------- workspace overview -------------------------------------------
 *
 * `#/c/:id`: four counters, the queue that is the page's subject, and a right flank of what is
 * coming. Built from the tokens in `:root` and the furniture already above — `card`, `list-row`,
 * `chip`, `stat-grid` — so dark mode follows without a second palette.
 *
 * Nothing here is computed. The two things that vary with data — a sparkline column's height and a
 * bar's share of its row — are handled two different ways on purpose: the column heights are
 * continuous, so js/style.js writes them per node into runtime.css; the bars are quantised to
 * eleven steps, which is a bounded set and therefore belongs in this sheet. There is no inline
 * style to fall back on either way (`style-src 'self'`). */

.overview { display: flex; flex-direction: column; gap: 12px; }

/* The counters hold their own row and do not sit in a column, because they are context for
   everything below rather than for the queue alone. Four across where there is room; two-up on a
   phone, which keeps each figure legible instead of shrinking all four to fit one line. */
.counter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
@media (min-width: 700px) {
  .counter-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
.counter-tile { display: block; color: var(--text); }
a.counter-tile:hover { background: var(--surface-2); text-decoration: none; }
.counter-tile .v { min-height: 26px; display: flex; align-items: center; }
.counter-tile .v .money { font-size: 17px; }
/* The em dash is quieter than a figure and deliberately so: it is the absence of a number, and a
   dash at full weight reads as a value of its own. It is never hidden, though — a dropped tile
   would say the figure does not exist. */
.counter-tile.masked .v { color: var(--faint); }
.counter-tile.pending .v { color: var(--faint); }
.counter-wait {
  display: block; width: 46px; height: 14px; border-radius: var(--radius-sm);
  background: var(--surface-3); animation: overview-wait 1.4s ease-in-out infinite;
}
@keyframes overview-wait { 0%, 100% { opacity: 1; } 50% { opacity: .45; } }
@media (prefers-reduced-motion: reduce) {
  .counter-wait { animation: none; }
}

/* One column until there is room for the flank. The queue comes first in the source and therefore
   first on a phone, which is the right order: it is what the page is for, and a reader on a small
   screen should not scroll past three cards of context to reach it. */
.overview-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
@media (min-width: 1000px) {
  .overview-columns { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; }
}
.overview-main, .overview-flank { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.overview-card .card-head { margin-block-end: 10px; }
.overview-card .card-head h3 { font-size: 15px; font-weight: 500; }

/* ---- the queue ----
 *
 * The page's subject, so it is the one card that reads as a list of work rather than as a panel:
 * rows edge to edge, the action on the row, and no padding between the header and the first thing
 * to do. */
.queue-card { padding-block-end: 6px; }
.queue-list { background: none; border-radius: 0; }
.queue-row { padding-inline: 0; gap: 10px; }
.queue-group:not(:empty) + .queue-group:not(:empty) .queue-row:first-child {
  box-shadow: inset 0 1px 0 var(--border);
}
.queue-glyph {
  flex: none; width: 32px; height: 32px; border-radius: 50%;
  display: grid; place-items: center; background: var(--surface-3);
}
.queue-glyph .icon { width: 17px; height: 17px; background: var(--muted); }
/* For a queue that has gone wrong rather than one that is merely outstanding. */
.queue-row.bad .queue-glyph { background: var(--bad-soft); }
.queue-row.bad .queue-glyph .icon { background: var(--bad); }
.queue-actions { flex: none; display: flex; align-items: center; gap: 6px; }
.queue-row.settled { color: var(--muted); }
/* The failure of an inline action, on the row and not in a toast: a message that vanishes while it
   is still being read is no use when it is the reason the thing did not happen. */
.queue-failed { color: var(--bad); font-size: 13px; }
.queue-more { display: block; padding: 9px 0 4px; font-size: 13px; color: var(--primary); }
/* The same line where there is no screen to send anyone to yet: the count is still stated, but it
   is not dressed as something that can be followed. */
.queue-more.inert { color: var(--muted); }
.queue-clear { padding: 4px 0 12px; font-size: 14px; }

/* ---- the sparkline ----
 *
 * Columns rather than a polyline — see the note in js/ui/overview.js. Fixed height so the card does
 * not resize as the numbers land, and the columns align to the bottom because the axis is zero. */
.sparkline { padding-block: 4px 2px; }
.sparkline.empty { padding-block: 6px 10px; }
.spark-bars { display: flex; align-items: flex-end; gap: 5px; height: 68px; }
.spark-col { flex: 1; height: 100%; display: flex; align-items: flex-end; min-width: 0; }
.spark-fill {
  width: 100%; border-radius: 3px 3px 0 0; background: var(--primary-soft);
  transition: height .18s ease-out;
}
/* The most recent session, which is the one the "Last service" counter above states. Reading the
   two together is the point of putting them on the same page. */
.spark-col:last-child .spark-fill { background: var(--primary); }
@media (prefers-reduced-motion: reduce) {
  .spark-fill { transition: none; }
}
.spark-footer { display: flex; justify-content: space-between; gap: 8px; padding-block-start: 8px; }
.spark-trend.up { color: var(--ok); }
.spark-trend.down { color: var(--warn); }

/* ---- the confidential card ----
 *
 * Set apart by its border rather than by a colour that would read as a warning: these two modules
 * are not a problem, they are simply not everyone's. No count appears on either row — their
 * existence is the thing being withheld. */
.confidential-card { border: 1px solid var(--border-strong); }
.confidential-card .card-head { align-items: center; }
.confidential-card p { margin-block-end: 10px; }
.confidential-card .list { background: var(--surface-2); }
.confidential-card .list-row:hover { background: var(--surface-3); text-decoration: none; }

/* ---- the right flank ---- */

.readiness, .pipeline, .fund-split { display: flex; flex-direction: column; gap: 2px; }

.readiness-row { display: flex; align-items: center; gap: 8px; padding-block: 6px; }
.readiness-label { flex: none; width: 74px; font-size: 14px; }
.readiness-detail { flex: 1; min-width: 0; text-align: end; }
.readiness-mark {
  flex: none; width: 20px; text-align: center; color: var(--faint); font-size: 14px;
}
.readiness-row.ready .readiness-mark { color: var(--ok); }
.readiness-row.unknown .readiness-label { color: var(--muted); }

.pipeline-open { padding-block-end: 6px; }
.pipeline-row { display: flex; align-items: center; gap: 8px; padding-block: 5px; color: var(--text); }
a.pipeline-row:hover { text-decoration: none; }
a.pipeline-row:hover .pipeline-name { color: var(--primary); }
.pipeline-name { flex: none; width: 88px; font-size: 13px; }
.pipeline-count { flex: none; width: 34px; text-align: end; font-size: 13px; font-weight: 500; }
/* Closed sits under a rule rather than in the funnel: it is where "please stop contacting me" is
   recorded, and it is not part of what the pipeline is working through. Never dropped, though —
   a funnel that hid it would be one nobody could reconcile. */
.pipeline-row.closed { margin-block-start: 4px; box-shadow: inset 0 1px 0 var(--border); padding-block-start: 8px; }

.fund-row { display: flex; align-items: center; gap: 8px; padding-block: 5px; }
.fund-name { flex: 1; min-width: 0; font-size: 13px; display: flex; align-items: center; gap: 6px; }
.fund-name .chip { flex: none; }
.fund-amount { flex: none; font-size: 13px; font-weight: 500; }

/* The eleven steps both bars share. Quantised rather than computed because that is what makes them
   a bounded set and therefore this sheet's business — a percentage per row would be a rule per row
   in runtime.css that nothing ever reclaims. Ten steps is finer than the eye reads at 54px anyway. */
.pipeline-bar, .fund-bar {
  flex: 1; min-width: 0; height: 6px; border-radius: var(--radius-pill);
  background: var(--surface-3); position: relative; overflow: hidden;
}
.pipeline-bar::after, .fund-bar::after {
  content: ""; position: absolute; inset-block: 0; inset-inline-start: 0;
  border-radius: var(--radius-pill); background: var(--primary);
}
.fund-bar::after { background: var(--ok); }
.pipeline-bar.step-0::after, .fund-bar.step-0::after { inline-size: 0; }
.pipeline-bar.step-1::after, .fund-bar.step-1::after { inline-size: 10%; }
.pipeline-bar.step-2::after, .fund-bar.step-2::after { inline-size: 20%; }
.pipeline-bar.step-3::after, .fund-bar.step-3::after { inline-size: 30%; }
.pipeline-bar.step-4::after, .fund-bar.step-4::after { inline-size: 40%; }
.pipeline-bar.step-5::after, .fund-bar.step-5::after { inline-size: 50%; }
.pipeline-bar.step-6::after, .fund-bar.step-6::after { inline-size: 60%; }
.pipeline-bar.step-7::after, .fund-bar.step-7::after { inline-size: 70%; }
.pipeline-bar.step-8::after, .fund-bar.step-8::after { inline-size: 80%; }
.pipeline-bar.step-9::after, .fund-bar.step-9::after { inline-size: 90%; }
.pipeline-bar.step-10::after, .fund-bar.step-10::after { inline-size: 100%; }

/* ---------- the registry -------------------------------------------------
 *
 * The back office's table (js/ui/registry.js), and the one shape in this app that is genuinely a
 * table rather than a list of cards. Members is the first module drawn with it; the giving ledger
 * and the dues board are the same furniture with other columns.
 *
 * CSS GRID, NOT <table>. Every row is its own grid with the same track list, which is what lets a
 * row FOLD at 900px into two lines without a second DOM — a real <table> can only ever be a table,
 * and the alternative would be building the phone list separately and keeping the two in step
 * forever. `role="table"/"row"/"columnheader"` carries the semantics a <table> would have given.
 *
 * The tracks are declared once here and shared by the header, the rows and the selection bar, so
 * nothing can drift out of alignment with the column titles above it.
 */

.registry { position: relative; }

.registry-toolbar {
  display: flex; align-items: center; gap: 10px; margin-block-end: 10px; flex-wrap: wrap;
}
.registry-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
.registry-chips .chip { cursor: pointer; }
.chip-count { margin-inline-start: 6px; opacity: .65; font-variant-numeric: tabular-nums; }
/* Pushed to the far end and capped: a search field that grows to fill a 1376px page is a search
   field you have to travel to. */
.registry-search { margin-inline-start: auto; flex: 0 1 260px; min-width: 160px; }

/* No `overflow: hidden` here, however much the rounded corners want it. An overflow other than
   `visible` makes this element a scroll container, and the sticky header inside it then sticks
   relative to THIS box rather than to the viewport — so it scrolls away with the table instead of
   staying put, taking the selection bar with it. That bar staying on screen while a long list is
   scrolled is most of the reason the selection lives in the header at all.
   The corners are rounded on the pieces that actually paint instead: the head slot at the top and
   the last row at the bottom. */
.registry-table { background: var(--surface); border-radius: var(--radius); }

/* The track list comes from the COLUMN SPEC, so it is computed per registry and arrives through
   js/style.js as a custom property on the table (see the note above `tracks` in js/ui/registry.js).
   It inherits, so ONE node carries it and the header, the rows and every row a later page appends
   all read the same value — which is what keeps a row aligned with the title above it.

   The fallback is what a registry with no spec would get, and it is deliberately a real layout
   rather than a broken one: equal columns.

   The property is left FREE below 900px on purpose. The narrow rule sets grid-template-columns
   outright rather than redefining the variable, so it does not have to out-specify a generated rule
   that doubles its own class and lives in a stylesheet loaded after this one. */
.registry-head, .registry-row {
  display: grid; align-items: center; gap: 12px;
  grid-template-columns: var(--registry-tracks, repeat(auto-fit, minmax(0, 1fr)));
  padding: 10px 14px;
}

.registry-cell { min-width: 0; }
.registry-cell.end { text-align: end; justify-self: end; }
.registry-cell.tick { justify-self: start; }
.registry-cell-label { display: none; }
.registry-cell-value { display: block; min-width: 0; }

/* ---- the header, and the selection bar that replaces it ----------------------
 *
 * Both live in the same slot and only one is ever shown. That is the point: ticking a row must not
 * move the row under the pointer, and a bar ABOVE the header would push the whole list down by its
 * own height on the very first tick. See the note at the top of js/ui/registry.js.
 *
 * `min-height` is on the slot rather than on either child, so the swap cannot change the height by
 * a pixel even though the bar holds buttons and the header holds text. */
.registry-head-slot {
  position: sticky; inset-block-start: var(--topbar-h); z-index: 3;
  min-height: 46px; display: grid; background: var(--surface);
  box-shadow: inset 0 -1px 0 var(--border);
  border-start-start-radius: var(--radius); border-start-end-radius: var(--radius);
}
.registry-head-slot > * { grid-area: 1 / 1; }

.registry-head {
  font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .02em;
}
.registry-head .registry-cell { display: flex; align-items: center; min-height: 26px; }
.registry-cell.head.end { justify-content: flex-end; }

.registry-sort {
  display: inline-flex; align-items: center; gap: 4px; padding: 2px 4px; margin-inline: -4px;
  border-radius: var(--radius-sm); color: inherit; font: inherit; letter-spacing: inherit;
  text-transform: inherit; cursor: pointer; background: transparent; border: 0;
}
.registry-sort:hover { background: var(--surface-3); color: var(--text); }
.registry-cell.sorted .registry-sort { color: var(--primary); }
/* Reserved whether or not this column is the sorted one, so turning the sort on does not shove the
   neighbouring titles sideways. */
.registry-caret { display: inline-block; min-width: 9px; font-size: 11px; }

.registry-select {
  display: flex; align-items: center; gap: 10px;
  background: var(--primary-soft);
}
/* The bar's single grid cell — one `role="gridcell"` spanning every column, because what it holds
   is about the selection rather than about any one column (ui/registry.js says why). It carries the
   padding the bar used to, so the flex row is the cell and not the row around it. */
.registry-select-cell {
  display: flex; align-items: center; gap: 10px; padding: 6px 14px; flex: 1; min-width: 0;
}
.registry-count { font-size: 13px; font-weight: 600; color: var(--primary-strong); flex: none; }
.registry-actions { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; flex-wrap: wrap; }
.registry-actions .icon { width: 16px; height: 16px; }
.registry-clear {
  flex: none; display: inline-grid; place-items: center; width: 28px; height: 28px;
  border-radius: var(--radius-sm); background: transparent; border: 0;
  color: var(--primary-strong); cursor: pointer;
}
.registry-clear:hover { background: var(--surface); }
.registry-clear .icon { width: 16px; height: 16px; }

/* ---- rows ------------------------------------------------------------------- */

.registry-row { box-shadow: inset 0 -1px 0 var(--border); font-size: 14px; }
/* The last row carries the table's bottom corners, since the table itself cannot clip them — see
   the note on `.registry-table`. `last-of-type` rather than `last-child`: the loader's status line
   and its sentinel are the real last children of the body. */
.registry-row:last-of-type {
  box-shadow: none;
  border-end-start-radius: var(--radius); border-end-end-radius: var(--radius);
}
.registry-row.openable { cursor: pointer; }
.registry-row.openable:hover { background: var(--surface-2); }
/* Every focusable row, not only the openable ones. A registry with ticks and no detail pane has
   rows that are focusable and NOT `.openable` — arrowing onto one of those drew no ring at all,
   which is a keyboard user's whole sense of where they are. */
.registry-row[tabindex]:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
/* The row the arrows are on, said a second way. `aria-selected` is the tick's state, and a ring
   alone cannot show it on a row whose checkbox is off screen in the folded layout. */
.registry-row[aria-selected="true"] { background: var(--primary-soft); }
/* The row whose drawer is open. Tinted rather than outlined, so a column of rows does not gain a
   box while one of them is being read. */
.registry-row.open { background: var(--primary-soft); }

.registry-person { display: flex; align-items: center; gap: 10px; min-width: 0; }
.registry-person-text { min-width: 0; display: flex; flex-direction: column; }
.registry-person-name { font-weight: 500; }
.registry-person-sub { line-height: 1.3; }

/* The identity line has to BE one line once the row has folded, or "two lines" quietly becomes
   three for every member whose presence is public — and only for those, so the list would come out
   ragged. Presence is the least of what this row says and the most perishable; it is on the row's
   own detail route, where it is still true when you read it. */
@media (max-width: 899px) {
  .registry-person-sub { display: none; }
}

.registry-roles { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
/* `contents` so the ministry chips are flex items of `.registry-roles` alongside the role pill,
   rather than one nested box that wraps as a unit. */
.registry-ministries { display: contents; }
.chip.tiny { font-size: 11px; padding: 1px 7px; }

/* On a folded row the ROLE is the standing and the ministries are detail. Two ministry chips are
   as wide as the rest of the second line put together, and the whole point of folding to two lines
   is that it stays two — so they step aside here and are read in the drawer or on the row's own
   route, where there is room for them. */
@media (max-width: 899px) {
  .registry-ministries { display: none; }
}

/* Tabular figures so a column of rates reads down as a column rather than as ragged text. */
.registry-rate { display: inline-flex; align-items: baseline; gap: 6px; font-variant-numeric: tabular-nums; }
.registry-rate-value { font-weight: 500; }
.registry-household { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; }

/* The pending shape, shared with the overview's counters: a placeholder that is a SHAPE and not a
   glyph, because a dash while something loads would read as a masked figure. */
.registry-rate.pending, .registry-household.pending { opacity: .6; }

.registry-status { padding: 18px 14px; margin: 0; text-align: center; }

/* ---- the detail drawer -------------------------------------------------------
 *
 * Only at >= 1180px, where there is room to keep the list and read one row beside it. Below that
 * the same content is a route of its own — js/ui/registry.js decides which, and never draws this
 * panel at a width it would swallow the page at. */
.registry-drawer { display: none; }

@media (min-width: 1180px) {
  .registry.with-drawer {
    display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: var(--gap);
    align-items: start;
  }
  /* The toolbar spans both columns; the table takes the first and the drawer the second. Declared
     by area rather than by source order so the drawer can be appended last in the DOM — where a
     screen reader meets it after the row that opened it. */
  .registry.with-drawer .registry-toolbar { grid-column: 1 / -1; }
  .registry.with-drawer .registry-status { grid-column: 1; }

  .registry-drawer {
    display: block; position: sticky; inset-block-start: calc(var(--topbar-h) + 12px);
    max-height: calc(100dvh - var(--topbar-h) - 24px); overflow: auto;
    background: var(--surface); border-radius: var(--radius); box-shadow: var(--shadow-lg);
  }
  .registry-drawer-head {
    position: sticky; inset-block-start: 0; z-index: 1;
    display: flex; align-items: center; gap: 8px; padding: 12px 14px;
    background: var(--surface); box-shadow: inset 0 -1px 0 var(--border);
  }
  .registry-drawer-title { flex: 1; min-width: 0; font-size: 16px; margin: 0; }
  .registry-drawer-body { padding: 14px; }
}

/* ---- two lines under 900px ---------------------------------------------------
 *
 * The same cells, re-laid — never a second list. Each column declared a `line` (1 for the identity
 * line, 2 for the line under it, 0 for "not on a phone at all"), and this is where those are read.
 *
 * `data-line="0"` columns are dropped rather than wrapped: two lines hold about five facts before
 * they stop being two lines, and everything dropped here is on the row's own detail route anyway.
 * The column's LABEL appears only in this layout — two lines of bare values with no idea which is
 * which is what a folded table usually becomes. */
@media (max-width: 899px) {
  /* The row stops being a grid and becomes ordinary FLOW, which is the one layout in CSS where an
     unknown number of siblings can share a line and wrap onto the next. Grid cannot do it: every
     line-2 cell would need the same named area, and items sharing an area stack on top of each
     other rather than flowing along it. Flex cannot either — the tick would sit on the first line
     only, and the second would start back at the left margin instead of under the name.

     So: the row is a block, the identity cell is a block, and every line-2 cell is inline-flex —
     which puts them all on one line as `label value` pairs that wrap when they run out. A row per
     column would be six lines, which is a card and not a row, and the whole point of folding rather
     than rebuilding is that this stays a list.

     The tick is taken out of the flow and the row is padded to make room for it, so it sits beside
     BOTH lines the way a list row's control always has. */
  .registry-row {
    display: block; position: relative;
    padding: 12px 14px; align-items: start;
  }
  .registry-table.selectable .registry-row { padding-inline-start: 40px; }
  .registry-cell.tick {
    position: absolute; inset-inline-start: 14px; inset-block-start: 14px;
  }
  .registry-cell[data-line="1"] { display: block; margin-block-end: 2px; }
  .registry-cell[data-line="2"] {
    display: inline-flex; align-items: baseline; gap: 4px;
    margin-inline-end: 10px; font-size: 13px; color: var(--muted);
  }
  .registry-cell.wide-only { display: none; }
  .registry-cell.end { text-align: start; justify-self: start; }

  /* The column's name, announced and not drawn.
     It has to exist: the header titles are gone at this width, so without it the second line is a
     run of values with nothing saying which column each came from — and a screen reader would read
     one long unpunctuated string. It cannot be VISIBLE: "Roles and ministries" and "Attendance"
     spelled out beside their values are wider than the values themselves, and four of them wrapped
     the second line into three. Two lines was the brief, and a fourth line is not a smaller
     failure than a missing label.
     What a sighted reader gets instead is the values, which at this width are a role chip, a
     figure and a status chip — self-describing in a way "Mar 28, 2026" is not, which is why the
     columns that are NOT self-describing are the ones the caller marks `line: 0`. */
  .registry-cell-label {
    position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
    overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }

  .registry-head-slot { min-height: 40px; }
  /* The column titles mean nothing once the row has folded — the per-cell labels above say which
     value is which. The SELECTION bar stays, because it is the one thing in this slot that is not
     about columns. */
  .registry-head .registry-cell.head:not(.tick) { display: none; }
  /* And the head stops being a GRID, not merely an empty one.
     Hiding the cells is not enough: a grid still lays out its track list whether or not anything
     occupies it, and `sm` columns are a fixed 112px each. So a folded table kept the full width of
     the desktop header — six columns is 648px against a 375px phone — and the page scrolled
     sideways with nothing visible in the overflow to explain why. Dropping the display drops the
     tracks with it. The tick keeps its place: it is a block in a block, which is where it sits
     anyway. */
  .registry-head { display: block; }
  .registry-search { margin-inline-start: 0; flex: 1 1 100%; }
}

/* ---------- members (module A1) ------------------------------------------- */

.members-page .page-head { align-items: baseline; }

.member-detail-head { display: flex; gap: 12px; align-items: flex-start; margin-block-end: 16px; }
.member-detail-head-row { display: flex; align-items: center; gap: 8px; margin-block-end: 6px; }
.member-detail-head-row .member-detail-title { flex: 1; min-width: 0; margin: 0; }

.member-detail-section { margin-block-end: 18px; }
.member-detail-section:last-child { margin-block-end: 0; }
.member-detail-title {
  margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .02em;
}

.detail-list { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; margin: 0; }
.detail-list dt { color: var(--muted); font-size: 13px; }
.detail-list dd { margin: 0; font-size: 13px; }

/* The refusal that is SPOKEN rather than hidden — see the note above giving_panel() in
   views/workspace_members.js. Tinted rather than greyed out: it is a real answer to the question
   the section asks, not a disabled control. The bit's name sits under it as a chip, because it is
   an identifier to be carried to whoever grants it and not a sentence to be read. */
.gated-note {
  padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-3);
}
.gated-note .chip.mono { margin-block-start: 6px; }

.member-household .list-row { padding-inline: 0; }
.picker-list .list-row.pickable {
  width: 100%; text-align: start; background: transparent; border: 0; cursor: pointer;
  font: inherit; color: inherit;
}
.picker-list .list-row.pickable:hover { background: var(--surface-2); }
.sms-dialog textarea { margin-block: 8px; }

/* ---------- households & the directory (module A3) ------------------------
 *
 * One card, three homes: the member drawer in the back office, a member's own profile, and the
 * directory's detail. It has no width of its own for that reason — it fills whatever it is put in,
 * and everything below is spacing and rhythm rather than layout.
 */

.household-card { display: flex; flex-direction: column; gap: 10px; }

.household-head { display: flex; align-items: flex-start; gap: 10px; }

/* "You have no household yet" is a SUCCESSFUL answer, not a failure — so it is laid out like a
   statement with a way forward rather than like an error with a retry. */
.household-empty { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }

.household-list { margin: 0; }
.household-row { padding-inline: 0; gap: 10px; }
.household-row .list-row-title a { color: inherit; }
.household-row .list-row-title a:hover { color: var(--primary-strong); }

/* Guardianship, stated once and in both directions. Tinted rather than plain, because it is the
   one block on the card that is DERIVED rather than read off a row — and because a check-in desk
   reads it under pressure. See care_note() in js/ui/household.js. */
.household-care {
  padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-3);
}
.household-care-title {
  margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .02em;
}
.household-care .detail-list { align-items: start; }
.household-care-rule { margin: 8px 0 0; }

.household-form { display: flex; flex-direction: column; gap: 4px; }
.household-form .field { margin-block-start: 4px; }
.household-date { width: 100%; }

/* The person picker: ui/registry.js's table inside a dialog. The height is capped so the dialog
   never grows past the viewport and takes its own footer off screen — the list scrolls inside it,
   which is what an infinite scroller expects anyway. */
.person-picker { min-height: 220px; max-height: min(60vh, 520px); overflow-y: auto; }
.person-picker .registry-toolbar { position: sticky; inset-block-start: 0; z-index: 1; background: var(--surface); }

.directory-page .page-head { align-items: center; gap: 10px; }
.profile-household { margin-block-end: 12px; }

/* The folded card on a profile: a row of faces answering "who are they" without naming minors down
   somebody else's page. See the note on `compact` in js/ui/household.js. */
.household-faces { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.household-faces-more { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }

/* ---------- attendance & check-in (module A2 / A2b) ------------------------
 *
 * Two shapes, and the difference between them is the whole section. The INDEX reuses the registry
 * wholesale and adds only a trend strip above it. The SESSION is a working surface: one number
 * large enough to read from across a hall, three tabs under it, and a list built to be tapped
 * rather than read.
 */

.attendance-strip { margin-block-end: 14px; display: flex; flex-direction: column; gap: 10px; }
.attendance-trend-caption { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0; }
/* The trend, tinted only when the server actually computed one — trend_tone() answers "" for a
   trend it declined to state, and an uncoloured figure is how that refusal reads. */
.attendance-trend-caption .trend { font-weight: 600; font-variant-numeric: tabular-nums; }
.attendance-trend-caption .trend.ok { color: var(--ok); }
.attendance-trend-caption .trend.bad { color: var(--warn); }
.attendance-chart { min-height: 78px; }

/* ---- one session ----
 *
 * THE COUNT IS THE HEADLINE. A leader props this on a table at the door and reads it from a metre
 * away, so the number is display-sized and everything that names the session is the line beneath
 * it. tabular-nums so the figure does not jitter sideways as it climbs. */
.attendance-session-head { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
.attendance-count {
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin: 0; line-height: 1.05;
}
.attendance-count-value { font-size: 46px; font-weight: 700; font-variant-numeric: tabular-nums; }
.attendance-count-sub { display: flex; align-items: baseline; gap: 10px; font-size: 15px; font-weight: 400; }
/* Children, counted apart from members on the wire and drawn apart here for the same reason: the
   two must never read as one total. */
.attendance-count-kids { color: var(--muted); }
.attendance-count-kids::before { content: "·"; margin-inline-end: 10px; }
.attendance-session-name {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-block-start: 4px;
  font-weight: 500;
}
.attendance-session-name .truncate { max-width: 28ch; }

/* ---- the quorum meter ----
 *
 * Drawn only for a Jewish congregation (see uses_minyan). The fill's width is computed and comes
 * from js/style.js — an inline style is dropped outright by `style-src 'self'`, and the bar would
 * silently never move. */
.minyan { display: flex; flex-direction: column; gap: 5px; min-width: 148px; }
.minyan-track {
  display: block; height: 7px; border-radius: 999px; background: var(--surface-3); overflow: hidden;
}
.minyan-fill {
  display: block; height: 100%; border-radius: inherit; background: var(--primary);
  transition: width .2s ease-out;
}
.minyan.ok .minyan-fill { background: var(--ok); }
.minyan-label { font-size: 12px; font-weight: 500; color: var(--muted); }
.minyan.ok .minyan-label { color: var(--ok); }
@media (prefers-reduced-motion: reduce) { .minyan-fill { transition: none; } }

/* ---- the tab strip ---- */

.attendance-tabs {
  display: flex; gap: 4px; margin-block: 12px 0; border-block-end: 1px solid var(--border);
}
.attendance-tab {
  appearance: none; border: 0; background: none; cursor: pointer; padding: 9px 14px;
  font: inherit; font-weight: 500; color: var(--muted); border-block-end: 2px solid transparent;
  margin-block-end: -1px;
}
.attendance-tab:hover { color: var(--text); }
.attendance-tab.active { color: var(--primary); border-block-end-color: var(--primary); }
.attendance-tab-body { padding-block-start: 14px; }
/* The roster split and the "no code" case both land here: a sentence, not an empty list. */
.attendance-gated { padding: 22px 4px; color: var(--muted); }

/* ---- the check-in code ---- */

.attendance-code { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
/* Big enough to be read out across a hall, and grouped in fours by the view. Monospaced with wide
   tracking because the characters are hex and get transcribed by hand — an l/1 confusion at the
   door costs somebody their check-in. */
.attendance-code-value {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: clamp(28px, 5vw, 44px); font-weight: 600; letter-spacing: .12em;
  padding: 14px 18px; border-radius: var(--radius); background: var(--surface-3); user-select: all;
}
.attendance-closed-note { color: var(--warn); margin: 0; }

/* ---- the roll call ----
 *
 * 60px rows, and the whole row is the button. See the note at the top of js/ui/rollcall.js for why
 * that is not a checkbox: at one person per second, a 20px target inside a 60px row means most taps
 * land on nothing, and "nothing happened" gets tapped again. */

.rollcall { display: flex; flex-direction: column; gap: 10px; }
.rollcall-toolbar { display: flex; gap: 8px; }
.rollcall-toolbar .registry-search { flex: 1; }
.rollcall-body { display: flex; flex-direction: column; }

.rollcall-row {
  appearance: none; border: 0; background: none; font: inherit; text-align: start; cursor: pointer;
  display: flex; align-items: center; gap: 12px; inline-size: 100%;
  /* The number the whole list is built around. Fixed rather than min-height so every row is the
     same target and the eye can run down the ticks without the spacing moving. */
  block-size: 60px; padding-inline: 10px; border-radius: var(--radius-sm);
}
.rollcall-row + .rollcall-row { box-shadow: inset 0 1px 0 var(--border); }
.rollcall-row:hover { background: var(--surface-2); }
.rollcall-row:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.rollcall-row[disabled] { cursor: default; opacity: .72; }
.rollcall-row[disabled]:hover { background: none; }

.rollcall-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rollcall-name { font-weight: 500; }
.rollcall-sub { min-height: 16px; }

/* The tick: an outline while nobody is counted, filled the moment they are. It is the only thing on
   the row that changes, so it carries the whole state and is drawn big enough to scan. */
.rollcall-check {
  flex: none; inline-size: 26px; block-size: 26px; border-radius: 50%;
  /* The empty ring, and it has to be VISIBLE — it is the difference between a name that has been
     marked and one that has not, read at arm's length in a bright room, four hundred times. At
     #d7d8d9 it was 1.43:1 against the row behind it. */
  border: 2px solid var(--control-edge); display: grid; place-items: center; color: transparent;
  transition: background-color .12s ease-out, border-color .12s ease-out;
}
.rollcall-check .icon { inline-size: 15px; block-size: 15px; }
.rollcall-row.present { background: color-mix(in srgb, var(--ok) 8%, transparent); }
.rollcall-row.present .rollcall-check {
  background: var(--ok); border-color: var(--ok); color: var(--on-primary);
}
.rollcall-row.present .rollcall-name { font-weight: 600; }
/* A row past the end of a roster that could not be read whole. Neither ticked nor unticked — the
   dashed ring is the em dash's job on a row: "we did not look", never "they did not come". */
.rollcall-row.unknown .rollcall-check { border-style: dashed; }
@media (prefers-reduced-motion: reduce) { .rollcall-check { transition: none; } }

.rollcall-status { padding: 18px 4px; }
.rollcall-notice {
  display: flex; align-items: flex-start; gap: 8px; margin: 0; padding: 10px 12px;
  border-radius: var(--radius-sm); background: var(--surface-3); color: var(--muted); font-size: 13px;
}
.rollcall-notice .icon { flex: none; margin-block-start: 2px; }
.rollcall-notice.warn { background: color-mix(in srgb, var(--warn) 12%, transparent); color: var(--warn); }
.rollcall-notice.bad { background: var(--bad-soft); color: var(--bad); }

/* ---- the kids desk (module A2b) ---- */

.kids-desk { display: flex; flex-direction: column; gap: 14px; }
.kids-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.kids-count { display: flex; align-items: baseline; gap: 10px; }
.kids-count strong { font-size: 17px; }
.kids-list { display: flex; flex-direction: column; gap: 16px; }
.kids-empty { padding: 20px 4px; }

.kids-group { display: flex; flex-direction: column; }
.kids-group-title {
  display: flex; align-items: baseline; justify-content: space-between; gap: 8px;
  margin: 0 0 4px; font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .02em;
}
/* Children who have gone home. Still on the register — the day's total includes them, and the
   override note on one of them is exactly what somebody scrolling back is looking for — but stepped
   back, because the question this screen answers is who is behind the door right now. */
.kids-group.gone { opacity: .78; }
.kids-row { gap: 10px; padding-inline: 0; }
.kids-row.out .list-row-title { color: var(--muted); }
.kids-age { font-weight: 400; }

/* The guardian's own copy of the code, and only ever theirs: the server leaves pickup_code empty
   for the desk, which is what makes it mean anything at handover. Set apart so a parent finds it
   at a glance on a crowded row. */
.kids-code { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: none; }
.kids-code-label { text-transform: uppercase; letter-spacing: .02em; font-size: 11px; }
.kids-code-value {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: 17px; font-weight: 600; letter-spacing: .08em; color: var(--primary-strong);
  user-select: all;
}

.kids-checkout { display: flex; flex-direction: column; gap: 10px; }
.kids-checkout-child { display: flex; align-items: baseline; gap: 4px; margin: 0; font-size: 16px; }
/* The escalated warning, past ATTENDANCE_PICKUP_CODE_MAX_ATTEMPTS. Louder than an ordinary field
   error because it has stopped being about a typo — but never a lock: the child is still
   collectable, by a manager who records the override. */
.kids-checkout .field.severe .field-error { color: var(--bad); font-weight: 500; }

.attendance-composer { display: flex; flex-direction: column; gap: 4px; }
.attendance-composer .input { inline-size: 100%; }
.attendance-target:empty { display: none; }

/* ---- phone ----
 *
 * The count stays large — it is the reason the page exists — but the head stacks, and the tab strip
 * scrolls sideways rather than wrapping onto two rows and pushing the list down. */
@media (max-width: 700px) {
  .attendance-count-value { font-size: 38px; }
  .attendance-session-head { gap: 8px; }
  .attendance-session-name .truncate { max-width: 20ch; }
  .minyan { min-width: 100%; }
  .attendance-tabs { overflow-x: auto; scrollbar-width: none; }
  .attendance-tabs::-webkit-scrollbar { display: none; }
  .attendance-tab { white-space: nowrap; }
  /* The code is read off a phone held up at the door, so it keeps as much size as the width allows
     and wraps rather than overflowing. */
  .attendance-code-value { align-self: stretch; text-align: center; overflow-wrap: anywhere; }
  .kids-code { align-items: flex-start; }
}

/* ---------- the phone at the door (views/rollcall.js) ----------------------
 *
 * `#/rollcall/:session_id` — one session, the app's own navigation taken away, operated with a
 * thumb while the leader looks at the room. The desk's version of this screen is the section above;
 * this is not a narrow copy of it but a different shape for a different posture, so it gets its own
 * furniture rather than a pile of overrides on the desk's.
 */

/* The chrome, gone. Claimed by `.fullscreen-page` on the node a view returns and applied to `.app`
   by router.js — on `.app` rather than on `.shell`, because the top bar and the tab bar are the
   shell's siblings and not its children.

   A page that does this owes the member another way out, drawn on the page itself: there is no
   longer a rail, a tab bar or a home link anywhere on screen. views/rollcall.js draws a back arrow
   and a "Done" button for exactly that reason. */
.app.fullscreen .topbar,
.app.fullscreen .rail,
.app.fullscreen .side,
.app.fullscreen .tabbar { display: none; }
.app.fullscreen .shell {
  display: block; max-inline-size: none; margin: 0; padding: 0; grid-template-columns: none;
}
.app.fullscreen .view { padding: 0; }

.rollcall-page {
  /* The whole viewport, with the footer pinned to the bottom of it. `100dvh` rather than `100vh`:
     on a phone the address bar collapses as the list scrolls, and `vh` would leave the footer a
     browser-chrome's height below the fold for the whole first screen. */
  min-block-size: 100dvh; display: flex; flex-direction: column;
  background: var(--bg);
}

/* ---- the headline ----
 *
 * The count, then the session's name under it. That order is the point: a leader knows which
 * gathering they are standing in and is reading the number from arm's length. */
.rollcall-page-head {
  display: flex; align-items: flex-start; gap: 10px;
  padding: max(12px, env(safe-area-inset-top)) 16px 10px;
  border-block-end: 1px solid var(--border);
  /* Sticky so the count stays readable while the roll scrolls under it. It is the one number
     somebody may be asked for at any moment. */
  position: sticky; inset-block-start: 0; z-index: 3; background: var(--bg);
}
.rollcall-page-back {
  flex: none; inline-size: 40px; block-size: 40px; display: grid; place-items: center;
  border-radius: 50%; color: var(--text); font-size: 20px; text-decoration: none;
}
.rollcall-page-back:hover { background: var(--surface-2); }
.rollcall-page-headline { flex: 1; min-width: 0; }
.rollcall-page-count-line {
  margin: 0; display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; font-weight: 400;
}
.rollcall-page-count { font-size: 44px; line-height: 1.05; font-weight: 700; font-variant-numeric: tabular-nums; }
.rollcall-page-sub { display: flex; align-items: baseline; gap: 10px; font-size: 15px; color: var(--muted); }
/* Children are said beside the head count and never added to it: present_count is MEMBERS-ONLY, so
   that a quorum of ten adults never starts counting the crèche. */
.rollcall-page-kids::before { content: "·"; margin-inline-end: 10px; }
.rollcall-page-name {
  display: flex; align-items: center; gap: 8px; margin-block-start: 2px; font-size: 15px;
}
.rollcall-page-name .truncate { max-width: 22ch; }
.rollcall-page-stamp { display: block; margin-block-start: 2px; }

/* ---- the segmented switch ----
 *
 * One object with one selected value, not a tab strip: three equal ways of doing the same job, all
 * one tap away. Nothing scrolls sideways here — the third segment is the children, and a control
 * that hid it behind a swipe would hide the half of this screen with a safeguarding duty on it. */
.rollcall-segments {
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; gap: 4px;
  margin: 12px 16px 0; padding: 4px;
  background: var(--surface-3); border-radius: var(--radius);
}
.rollcall-segment {
  appearance: none; border: 0; background: none; font: inherit; cursor: pointer;
  /* 44px is the smallest target a thumb hits reliably; this list is operated one-handed. */
  min-block-size: 44px; padding-inline: 8px; border-radius: calc(var(--radius) - 4px);
  color: var(--muted); font-weight: 500; text-align: center;
}
.rollcall-segment:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Raised out of the trough rather than tinted: the modules in this design are flat (`--shadow` is
   `none`), so the selected segment is drawn by lifting it to the page's own surface colour against
   the trough behind it, with a hairline to hold the edge in dark mode where the two are close. */
.rollcall-segment.active {
  background: var(--surface); color: var(--text); font-weight: 600;
  border: 1px solid var(--border);
}

.rollcall-page-body { flex: 1; padding: 14px 16px 20px; }
.rollcall-kids { display: flex; flex-direction: column; gap: 12px; }

/* ---- the sticky footer ----
 *
 * Thumb height, always visible, and it states the queue. The one question a leader has after twenty
 * minutes in a hall with no signal is whether any of it is being kept, and an answer they have to
 * scroll for is not an answer. */
.rollcall-page-foot {
  position: sticky; inset-block-end: 0; z-index: 3;
  display: flex; align-items: center; gap: 12px;
  padding: 10px 16px max(10px, env(safe-area-inset-bottom));
  background: var(--surface); border-block-start: 1px solid var(--border);
}
.rollcall-page-foot .queue-bar { flex: 1; min-width: 0; }

/* ---------- the write queue's status bar (js/ui/queue_bar.js) --------------
 *
 * A STATEMENT, not a warning — see the note at the top of that file. Hence no alarm colour on the
 * ordinary states: a queue draining itself is the system working, and painting it amber would teach
 * a leader to read "31 marks queued" as something having gone wrong. Only a refusal is loud, and a
 * refusal is the one state where something did.
 */
.queue-bar {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  font-size: 13px; line-height: 1.3; color: var(--muted);
}
.queue-bar-icon { flex: none; display: grid; place-items: center; }
.queue-bar-icon .icon { inline-size: 16px; block-size: 16px; }
.queue-bar-text { min-width: 0; }
.queue-bar.queued { color: var(--text); }
.queue-bar.queued .queue-bar-icon { color: var(--primary); }
.queue-bar.busy .queue-bar-icon { color: var(--primary); }
.queue-bar.clear .queue-bar-icon { color: var(--ok); }
.queue-bar.offline .queue-bar-icon { color: var(--muted); }
.queue-bar.bad { color: var(--bad); }

/* Only ever drawn for a refusal — the one state that is about something having gone wrong and that a
   member should be able to put away. It sits at the end of the bar rather than beside the text so
   the sentence still reads straight through. */
.queue-bar-dismiss {
  appearance: none; border: 0; background: none; font: inherit; cursor: pointer;
  flex: none; margin-inline-start: auto; padding: 4px 8px; border-radius: var(--radius-sm);
  color: var(--muted); font-size: 12px; font-weight: 500;
}
.queue-bar-dismiss:hover { background: var(--surface-3); color: var(--text); }
.queue-bar-dismiss:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* Used outside the sticky footer too — a module that queues writes drops one at the top of its page
   and gets the same sentence. There it is a card-like strip rather than a bare line. */
.queue-bar.block {
  padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-3);
}

/* ---- phone ---- */
@media (max-width: 700px) {
  .rollcall-page-count { font-size: 38px; }
  .rollcall-page-name .truncate { max-width: 16ch; }
  /* The code is read off a phone held up at the door: as large as the width allows, centred, and
     wrapping rather than overflowing. */
  .attendance-code.compact { align-items: stretch; }
  .attendance-code.compact .attendance-code-value { text-align: center; overflow-wrap: anywhere; }
}

/* ---------- money (js/ui/money.js, module E1) ------------------------------
 *
 * The money shape: a currency stated once, three totals, one chart, a table of entries and a flank
 * that reconciles them. Giving is the first page drawn with it; the dues board and the treasury
 * ledger are the same furniture with other numbers.
 *
 * THE BREAKPOINT IS 900px, and the same one the registry folds at, deliberately. Three things
 * change there and all three are this stylesheet's business rather than the DOM's:
 *
 *   - the totals STACK, because three money figures side by side on a 380px screen are three
 *     figures nobody can read;
 *   - the chart becomes a SPARKLINE — the same columns, without the scale, the month labels or the
 *     caption, because at that width the shape is all that survives anyway and the labels turn into
 *     a row of overlapping stubs;
 *   - the entries become ROWS, which the registry already does for itself (see the note above
 *     `.registry-head`) — nothing here has to repeat it.
 *
 * There is no second render path for any of them. A phone layout built separately is a phone layout
 * that drifts, and every fix after the first has to be made twice.
 *
 * Everything computed goes through js/style.js: the chart's column heights per node (they vary
 * continuously) and nothing else. The progress bar's fill is QUANTISED to twenty-one steps and
 * therefore lives here as real rules — a bounded set belongs in the stylesheet, and style_class()
 * never evicts, so a continuously varying width would leave a rule behind on every repaint.
 */

.money-page { display: flex; flex-direction: column; gap: 12px; }

/* ---- the currency, said once ---- */

.money-head {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  padding: 8px 12px; background: var(--surface-2); border-radius: var(--radius);
  font-size: 14px;
}
.money-head-label { color: var(--muted); }
.money-head-code { display: inline-flex; align-items: baseline; gap: 6px; font-variant-numeric: tabular-nums; }
.money-head-local { color: var(--muted); }
/* The exception the head exists to make visible: a row in a retired currency is listed and is
   deliberately NOT in the total above it. Warned rather than tinted, because it is the reason two
   numbers on this page do not agree. */
.money-head-mixed { color: var(--warn); }
.money-head-note { color: var(--muted); }

/* ---- the three totals ----
 *
 * Stacked below 900px and across above it. `.money-page` scopes this so the overview's own
 * four-counter grid, which answers a different question at a different width, is untouched. */
.money-page .counter-grid { grid-template-columns: minmax(0, 1fr); }
@media (min-width: 900px) {
  .money-page .counter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* ---- the two columns ----
 *
 * The table is the subject and comes first in the source, so it is first on a phone: a treasurer on
 * a small screen should not scroll past a reconciliation panel to reach the gifts it is about. */
.money-columns { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; }
@media (min-width: 1180px) {
  .money-columns { grid-template-columns: minmax(0, 1fr) 320px; align-items: start; }
}
.money-main, .money-flank-column { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.money-flank { display: flex; flex-direction: column; gap: 12px; }

/* ---- the chart ----
 *
 * Columns, aligned to a zero axis. Above 900px they carry their month underneath and a caption
 * saying what the chart covers; below it, the whole thing collapses to the sparkline. */
.money-chart { padding-block: 4px 2px; }
.money-chart.empty { padding-block: 6px 10px; }
.money-bars { display: flex; align-items: flex-end; gap: 6px; height: 120px; }
.money-bar {
  flex: 1; min-width: 0; height: 100%;
  display: flex; flex-direction: column; justify-content: flex-end; align-items: stretch; gap: 4px;
}
.money-bar-fill {
  width: 100%; border-radius: 3px 3px 0 0; background: var(--primary-soft);
  transition: height .18s ease-out;
}
/* The month the "given this month" tile above states. Reading the two together is the point of
   putting them on the same page. */
.money-bar:last-child .money-bar-fill { background: var(--primary); }
.money-bar-label {
  flex: none; font-size: 11px; color: var(--muted); text-align: center;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.money-chart-note { margin-block: 8px 0; }
@media (prefers-reduced-motion: reduce) {
  .money-bar-fill { transition: none; }
}

/* ---- the folded gift row ----
 *
 * The registry folds every table to two lines at 900px, and a money table needs one thing more.
 * DATE and AMOUNT are both `line: 1` here, because both are what identifies a gift — but two
 * line-1 cells are two blocks, which makes three lines out of a fold whose whole brief is two. So
 * the amount is lifted out of the flow and parked at the end of the first line, which is where a
 * column of money is read anyway. The row is already `position: relative` at this width.
 *
 * Addressed by `data-key` rather than by :last-child, so adding a column later cannot silently move
 * it onto something else. */
@media (max-width: 899px) {
  .money-table-card .registry-row { padding-inline-end: 124px; }
  .money-table-card .registry-cell[data-key="amount"] {
    position: absolute; inset-inline-end: 14px; inset-block-start: 12px;
    margin: 0; text-align: end; font-size: 14px; color: var(--text);
  }
}

/* The sparkline. Same DOM, less of it: at this width a month label is four characters in a column
   about eight wide, so the labels stack into an unreadable band and the caption pushes the table
   below the fold. The shape is what still reads, so the shape is what is kept. */
@media (max-width: 899px) {
  .money-bars { height: 56px; gap: 3px; }
  .money-bar-label { display: none; }
  .money-chart-note { display: none; }
}

/* ---- the entries ----
 *
 * The table itself is ui/registry.js's, folding to two lines at 900px on its own. Only the cells
 * this module adds are styled here. */
.money-table-card .card-head { flex-wrap: wrap; gap: 8px; }
.filters.small .chip { font-size: 12px; }

.money-when { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.money-when-date { font-variant-numeric: tabular-nums; }
.money-when-title { color: var(--muted); font-size: 13px; }
/* Once the row has folded, the identity line has to BE one line — the label is on the row's own
   detail, where there is room for it. */
@media (max-width: 899px) {
  .money-when { flex-direction: row; align-items: baseline; gap: 6px; }
}

.money-fund { display: inline-flex; align-items: baseline; gap: 6px; min-width: 0; }

/* The rail, tinted per operator so a column of them can be read down at a glance rather than word
   by word. A bounded set of five, so they are real rules here and not generated. */
.money-rail {
  display: inline-block; padding: 1px 7px; border-radius: var(--radius-pill);
  font-size: 12px; background: var(--surface-3); color: var(--muted); white-space: nowrap;
}
.money-rail.rail-1 { background: color-mix(in srgb, #ff7900 16%, transparent); color: #b35500; }
.money-rail.rail-2 { background: color-mix(in srgb, #ffcc00 22%, transparent); color: #8a6d00; }
.money-rail.rail-3 { background: color-mix(in srgb, #00a3e0 16%, transparent); color: #00688f; }
.money-rail.rail-4 { background: color-mix(in srgb, #1dc8f2 16%, transparent); color: #0a7f9c; }
/* Cash is not an operator and is deliberately not given an operator's colour: it never travelled a
   rail, and the congregation was already holding it. */
.money-rail.rail-5 { background: var(--surface-3); color: var(--muted); }

/* A row with no clearing state at all — nothing arrived, so nothing is outstanding. The dash is
   quiet on purpose: a chip in a colour here would put a row of alarm on a table where nothing has
   gone wrong. */
.money-state-none { color: var(--faint); }
.money-amount { font-variant-numeric: tabular-nums; font-weight: 500; }
/* A figure the viewer is not entitled to. Quieter than a number and never hidden — see the note in
   ui/overview.js. */
.money-masked { color: var(--faint); }

/* ---- one entry, opened ---- */

.money-detail { display: flex; flex-direction: column; gap: 12px; }
.money-detail-amount { font-size: 24px; font-weight: 700; font-variant-numeric: tabular-nums; }
.money-detail-list, .money-reconcile-list {
  display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 6px 14px;
  margin: 0; font-size: 14px;
}
.money-detail-list dt, .money-reconcile-list dt { color: var(--muted); }
.money-detail-list dd, .money-reconcile-list dd {
  margin: 0; text-align: end; font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
}

/* ---- the reconciliation flank, and the relay panel ---- */

.money-reconcile-note, .money-relay-note {
  margin-block: 10px 0; padding-block-start: 10px; box-shadow: inset 0 1px 0 var(--border);
}
.money-relay .card-head { align-items: center; gap: 8px; flex-wrap: wrap; }
/* Set apart by its border rather than by a colour: the relay is infrastructure, and a panel that
   painted itself green or red would be claiming a health it cannot see (see relay_panel()). */
.money-relay { border: 1px solid var(--border-strong); }

/* ---- funds and campaigns ---- */

.money-fund-row { align-items: flex-start; gap: 10px; }
.money-fund-actions { flex: none; display: flex; align-items: center; gap: 6px; }
.money-campaign-pick {
  width: 100%; text-align: start; background: none; border: 0; cursor: pointer; color: inherit;
  font: inherit;
}
.money-campaign-pick:hover { background: var(--surface-2); }

/* The bar. Only ever drawn on a campaign — a fund has no target to fill. Twenty-one steps, which is
   finer than the eye reads off a bar this size and is a BOUNDED set, so these are real rules rather
   than a generated one per repaint. */
.money-progress { display: flex; flex-direction: column; gap: 5px; margin-block-start: 6px; }
.money-progress-track {
  height: 8px; border-radius: var(--radius-pill); background: var(--surface-3); overflow: hidden;
}
.money-progress-fill {
  display: block; height: 100%; border-radius: var(--radius-pill); background: var(--primary);
  transition: width .24s ease-out;
}
.money-progress-fill.met { background: var(--ok); }
@media (prefers-reduced-motion: reduce) {
  .money-progress-fill { transition: none; }
}
.money-progress-fill.step-0 { width: 0; }
.money-progress-fill.step-1 { width: 5%; }
.money-progress-fill.step-2 { width: 10%; }
.money-progress-fill.step-3 { width: 15%; }
.money-progress-fill.step-4 { width: 20%; }
.money-progress-fill.step-5 { width: 25%; }
.money-progress-fill.step-6 { width: 30%; }
.money-progress-fill.step-7 { width: 35%; }
.money-progress-fill.step-8 { width: 40%; }
.money-progress-fill.step-9 { width: 45%; }
.money-progress-fill.step-10 { width: 50%; }
.money-progress-fill.step-11 { width: 55%; }
.money-progress-fill.step-12 { width: 60%; }
.money-progress-fill.step-13 { width: 65%; }
.money-progress-fill.step-14 { width: 70%; }
.money-progress-fill.step-15 { width: 75%; }
.money-progress-fill.step-16 { width: 80%; }
.money-progress-fill.step-17 { width: 85%; }
.money-progress-fill.step-18 { width: 90%; }
.money-progress-fill.step-19 { width: 95%; }
.money-progress-fill.step-20 { width: 100%; }

.money-progress-legend {
  display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font-size: 13px;
}
.money-progress-legend .money { font-weight: 600; font-variant-numeric: tabular-nums; }
.money-progress-percent { margin-inline-start: auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.money-progress-percent.met { color: var(--ok); font-weight: 600; }

/* ---- the give flow's additions ---- */

.give-campaign:empty { display: none; }
.fund-campaign-fields[hidden] { display: none; }

/* The monthly reminder. A switch and, under it, what it will do — the body is hidden until the
   switch is on, because three fields for something switched off is three fields of noise. */
.reminder-card { display: flex; flex-direction: column; gap: 10px; }
.reminder-switch { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
.reminder-switch input { margin-block-start: 3px; flex: none; }
.reminder-switch b { display: block; }
.reminder-body { display: flex; flex-direction: column; gap: 10px; }
.reminder-body[hidden] { display: none; }

/* ---------- dues & pledges (module E2) -------------------------------------------
 *
 * Nothing here paints money. The figures come through format.js's amount() and the bars reuse the
 * `.money-progress` furniture from the giving section above — a due's bar and a campaign's are the
 * same geometry with different meanings, which is a decision expressed in ui/money.js rather than
 * in two sets of rules here.
 */

.dues-hint { margin: 0 0 12px; }

/* A badge beside a name — "Archived" on a collection, a role pill on a member. The identity line is
   a block so its text truncates, so the chip needs its own space rather than a flex gap. */
.registry-person-name .chip { margin-inline-start: 6px; }

/* The letterhead row, which is a card that behaves like a list row: it is a link to a whole page,
   and it sits between the page's own explainer and its table. */
.dues-letterhead {
  display: flex; align-items: center; gap: 12px;
  padding: 12px 14px; margin-block-end: 14px;
  color: inherit; text-decoration: none;
}
.dues-letterhead:hover { background: var(--surface-2); }
.dues-letterhead .list-row-side { font-size: 18px; }
/* The "not set up yet" state, which is the one thing on that row worth looking at twice. Coloured
   rather than bolded: it is an invitation, not a warning — receipts are issued either way. */
.tint-text { color: var(--primary-strong); }
.ok-text { color: var(--ok); font-weight: 600; }

/* The board's period stepper. Centred between its two arrows so the label does not move as the
   period name changes width, which it does between "Period 9" and "Period 10". */
.dues-summary { display: flex; flex-direction: column; gap: 12px; }
.dues-period { display: flex; align-items: center; gap: 10px; }
.dues-period-label {
  flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: center;
}
.dues-period .btn { flex: none; }

/* A due's bar carries a sentence rather than the three-part legend a campaign's does — what is
   left, or that it is settled, or that it was waived. One line, left aligned under the track. */
.dues-progress .money-progress-legend { font-size: 12px; color: var(--muted); }

.dues-row-actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
.dues-limit { margin: 0 0 8px; text-align: end; }
.dues-mine-error { margin: 12px 16px; }

/* The three sheets: cash, adjustment, plan. `.form` is the column every one of them is, and it is
   defined here rather than in the shared block above because these are the first modals in the app
   built from stacked `field()`s rather than from bespoke rows. */
.form { display: flex; flex-direction: column; gap: 12px; }
.form .field { margin: 0; }
.form > p { margin: 0; }

/* Who the sheet is about, above the inputs. A treasurer works down a table and opens these one
   after another; the name is what stops a cash entry going against the row above. */
.dues-form-head {
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px; border-radius: var(--radius); background: var(--surface-3);
}

/* A checkbox with an explanation under its label. Same shape as `.reminder-switch` in the giving
   block, named for what it is rather than for the one screen that had it first. */
.check-row { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
.check-row input { margin-block-start: 3px; flex: none; }
.check-row > span { display: flex; flex-direction: column; gap: 2px; }

.dues-history-head {
  display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; margin-block-end: 10px;
}
.dues-history-head .money { font-size: 20px; }

/* WAIVED, which is settled without being paid. The calm secondary rather than the success green,
   because a board tinted green for waivers reads as money collected — see js/dues.js. */
.chip.muted { background: var(--surface-3); color: var(--muted); }

/* ---- the member's own card ---- */

.dues-mine-row { align-items: flex-start; gap: 12px; }
.dues-mine-row .list-row-main { display: flex; flex-direction: column; gap: 4px; }
.dues-mine-row .list-row-title { display: flex; align-items: center; gap: 8px; }
.dues-mine-row .money { font-variant-numeric: tabular-nums; }
.dues-mine-row .btn { flex: none; align-self: center; }
.dues-mine-row .money-progress { margin-block-start: 2px; }

/* The give flow's dues header: what this payment settles, stated rather than offered. */
.give-for-plan { display: flex; flex-direction: column; gap: 2px; margin-block-end: 12px; }

/* ---------- digital receipts (module E3) -----------------------------------------
 *
 * A receipt is a DOCUMENT, and the card below is the only place in this app that is laid out like
 * one: a letterhead, a number, a figure, then term/value rows. It deliberately does not look like
 * the rest of the UI, because what it is for is being read, filed and shown to somebody else.
 */

.receipts-page > .muted { margin: 0 0 12px; }

/* The waiting band. Tinted rather than warned: a payment that has not cleared has not failed, and a
   red panel over money somebody has already sent is a false alarm they can do nothing about. */
.receipt-pending {
  border: 1px solid var(--border);
  background: var(--surface);
  margin-block-end: 14px;
}
.receipt-pending[hidden] { display: none; }
.receipt-pending .card-head { align-items: flex-start; gap: 10px; margin-block-end: 6px; }
.receipt-pending .card-head h3 { margin: 0; font-size: 15px; }
.receipt-pending .queue-glyph { flex: none; }
.receipt-pending > p { margin: 0 0 10px; }
.receipt-pending .list-row-side { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }

/* ---- one receipt, as the document it is ---- */

.receipt-card { display: flex; flex-direction: column; gap: 12px; }

.receipt-letterhead {
  display: flex; flex-direction: column; gap: 2px;
  padding-block-end: 12px; border-block-end: 1px solid var(--border); text-align: center;
}
.receipt-letterhead strong { font-size: 16px; }

.receipt-number { display: flex; flex-direction: column; align-items: center; gap: 2px; }
/* The number is read digit by digit against another document, so it is tabular and monospaced —
   and it is the one string on this card somebody may copy by hand. */
.mono {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-variant-numeric: tabular-nums;
  letter-spacing: .02em;
}
.receipt-number .mono { font-size: 15px; }

.receipt-amount {
  text-align: center; font-size: 28px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.receipt-paid { text-align: center; color: var(--muted); font-size: 13px; }

.receipt-rows { display: flex; flex-direction: column; gap: 0; }
.receipt-row {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  padding-block: 8px; border-block-end: 1px solid var(--border); font-size: 14px;
}
.receipt-row:last-child { border-block-end: none; }
.receipt-row .k { color: var(--muted); flex: none; }
.receipt-row > span:last-child { text-align: end; min-width: 0; overflow-wrap: anywhere; }

/* The community's own line at the foot — a thank-you, a tax note, a "no goods or services were
   provided in exchange for this gift". Set apart because it is the community speaking rather than
   the document stating. */
.receipt-footer {
  margin: 0; padding-block-start: 10px; border-block-start: 1px solid var(--border);
  text-align: center; font-style: italic;
}

/* ---- the annual statement ---- */

.statement-page .page-head .dropdown { flex: none; }
.statement-head { display: flex; flex-direction: column; gap: 2px; text-align: center; }
.statement-head strong { font-size: 17px; }
.statement-head .money-head { justify-content: center; margin-block-start: 8px; }
.statement-donor { margin: 8px 0 0; }

/* Module E5's line: what this statement does NOT cover. Tinted rather than warned for the same
   reason the waiting band is — nothing is wrong, there is simply a second document. */
.statement-others {
  background: var(--primary-soft);
  border: 1px solid transparent;
  margin-block-end: 14px;
}
.statement-others p { margin: 0 0 10px; color: var(--primary-strong); }
.statement-others .filters { margin: 0; }

@media (max-width: 700px) {
  .dues-mine-row { flex-wrap: wrap; }
  .dues-mine-row .btn { align-self: flex-start; }
  .receipt-amount { font-size: 24px; }
}

/* ---------- the treasury: ledger, budget & reports (module E4) --------------------
 *
 * Two pages sharing one skin: the entry list a treasurer keys receipts into, and the report they
 * read the month back from. Both reuse the money furniture above — .money-head states the currency,
 * .counter-grid carries the totals, .money-progress draws the budget bars — so what is here is only
 * what those did not already answer.
 *
 * The one rule this section is really for: A VOIDED ROW LOOKS VOIDED AND STAYS PUT. It is struck
 * through and faded, and it keeps its place in the list. It is never hidden, because a void is not
 * a delete — see the note at the head of views/workspace_ledger.js.
 */

/* A head that carries actions needs its title to take the slack, which .page-head's own `h1 {
   flex: 1 }` cannot do once the title is a block with a subtitle under it. */
.page-title { flex: 1; min-width: 0; }
.page-title h1 { margin: 0; }
.page-title p { margin: 2px 0 0; }
.page-actions { display: flex; gap: 8px; flex: none; }

.ledger-period {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-block-end: 12px;
}
.ledger-period .dropdown { flex: none; min-width: 132px; }

.ledger-head { margin-block-end: 10px; }
.ledger-totals { margin-block-end: 14px; }

/* ---- the entry list ---- */

.ledger-row { align-items: center; gap: 12px; text-align: start; width: 100%; }
.ledger-payee { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex-wrap: wrap; }
.ledger-row-side {
  display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: none;
}
.ledger-amount { font-variant-numeric: tabular-nums; font-weight: 650; white-space: nowrap; }
.ledger-amount.bad { color: var(--bad); }
.ledger-amount.ok { color: var(--ok); }
.ledger-amount.muted { color: var(--muted); }

/* A voided row, and the whole of what makes it legible as a correction rather than as a row that
   quietly changed. Struck and faded, never removed: the record is what was recorded AND what
   replaced it, and a list that dropped the first would be missing half of that. */
.ledger-row.voided .ledger-payee > span:first-child,
.ledger-row.voided .ledger-amount { text-decoration: line-through; }
.ledger-row.voided { opacity: .72; }

.ledger-more { margin-block-start: 10px; }
.ledger-more .btn { width: 100%; }

/* ---- one entry, opened ---- */

.ledger-detail { display: flex; flex-direction: column; gap: 12px; }
.ledger-detail-amount { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; }
.ledger-detail-amount.bad { color: var(--bad); }
.ledger-detail-amount.ok { color: var(--ok); }
.ledger-detail-amount.muted { color: var(--muted); text-decoration: line-through; }
.ledger-void-note { position: static; }
.ledger-receipt-photo {
  max-width: 100%; border-radius: var(--radius-sm); border: 1px solid var(--border);
}

.ledger-composer .ledger-photo { display: flex; flex-direction: column; gap: 6px; }
.ledger-photo-actions { display: flex; gap: 8px; }

/* ---- the report ---- */

.ledger-report-body { display: flex; flex-direction: column; gap: 20px; }
.ledger-section { display: flex; flex-direction: column; gap: 10px; }
.ledger-section h2 { margin: 0; font-size: 16px; }
.ledger-section > p { margin: 0; }

/* Wide content scrolls INSIDE its own box. The page body must never scroll sideways — a report on a
   phone would otherwise push the whole app off the screen to fit six columns of figures. */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.ledger-table { width: 100%; border-collapse: collapse; font-size: 14px; }
.ledger-table th, .ledger-table td {
  padding: 8px 10px; text-align: start; border-block-end: 1px solid var(--border);
  white-space: nowrap;
}
.ledger-table thead th { color: var(--muted); font-size: 12.5px; font-weight: 600; }
.ledger-table tbody th { font-weight: 550; }
.ledger-table tbody th .chip { margin-inline-start: 6px; }
/* Figures line up under each other or they cannot be compared down a column, which is the only
   reason somebody reads a table of them. */
.ledger-table .num { text-align: end; font-variant-numeric: tabular-nums; }
.ledger-table tbody tr:last-child th, .ledger-table tbody tr:last-child td { border-block-end: 0; }

/* Zakat is a pair read together, so it is set apart from the totals it sits inside rather than
   buried among them — the same decision the annual statement makes for the same reason. */
.ledger-zakat { padding: 12px 14px; border-radius: var(--radius-sm); background: var(--surface-3); }

/* ---- the budget ---- */

.ledger-budget-lines { display: flex; flex-direction: column; gap: 14px; }
.ledger-budget-line { display: flex; flex-direction: column; gap: 6px; }
.ledger-budget-head { display: flex; align-items: center; gap: 8px; justify-content: space-between; }

.budget-editor { gap: 12px; }
.budget-editor-lines { display: flex; flex-direction: column; gap: 10px; }
.budget-editor-line {
  display: grid; gap: 8px; align-items: center;
  grid-template-columns: minmax(0, 7rem) minmax(0, 1fr) minmax(0, 9rem);
}
.budget-editor-line .budget-label { grid-column: 1 / -1; }
.budget-amount-wrap { display: flex; align-items: center; gap: 6px; }
.budget-amount-wrap input { text-align: end; font-variant-numeric: tabular-nums; }
.budget-editor-line > .small { grid-column: 1 / -1; }

/* ---- money in another currency (module E5) ---- */

/* Tinted rather than warned. A period that held two currencies is not an error and nothing is
   wrong: there is simply a second set of figures, and the reason it has a block of its own is that
   it CANNOT be added to the first. Same treatment as the annual statement's "other currencies"
   line, for the same reason. */
.ledger-other-currencies {
  padding: 12px 14px; border-radius: var(--radius-sm); background: var(--primary-soft);
}
.ledger-other-currencies > p { color: var(--primary-strong); }
.ledger-other-currencies .is-report-currency th { color: var(--muted); }

@media (max-width: 700px) {
  .page-head { flex-wrap: wrap; }
  .page-actions { width: 100%; }
  .page-actions .btn { flex: 1; }
  .ledger-row-side { align-items: flex-end; }
  .budget-editor-line { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .budget-editor-line .budget-amount-wrap { grid-column: 1 / -1; }
}


/* ---------- the stepped composer (ui/composer.js) ------------------------------------------
 *
 * ONE DOM, TWO SKINS. The flow builds a head, a body and a bar, and which of the two shapes it
 * looks like is decided here and nowhere else — the rail and the "Step 2 of 4" line are both
 * present and each is hidden where the other is drawn, exactly as `.workspace-bar` is hidden
 * wherever the real rail appears. 900px is the same breakpoint, for the same reason: it is where
 * this app decides it is on a pointer device with room.
 */

.composer-flow { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }

.flow-head { flex: none; padding-block-end: 10px; }
.flow-what { margin: 0 0 2px; }
.flow-title { margin: 0; font-size: 19px; font-weight: 600; outline: none; }
.flow-hint { margin: 4px 0 0; }
.flow-hint[hidden] { display: none; }

/* The rail. Not a progress bar: every step is a button and all of them are reachable, because a
   leader who has written the words and picked the audience may want the words again without
   walking forwards through the audience a second time. */
.flow-rail { display: none; list-style: none; margin: 0 0 10px; padding: 0; gap: 6px; }
.flow-rail > li { display: contents; }
.flow-rail-step {
  display: flex; align-items: center; gap: 7px;
  padding: 5px 11px 5px 6px;
  border: 0; border-radius: var(--radius-pill);
  background: var(--surface-3); color: var(--muted);
  font: inherit; font-size: 13px; font-weight: 550; cursor: pointer;
}
.flow-rail-step:hover { background: var(--tint); color: var(--tint-text); }
.flow-rail-num {
  display: grid; place-items: center;
  inline-size: 21px; block-size: 21px;
  border-radius: var(--radius-pill);
  background: var(--surface); color: var(--muted);
  font-size: 12px; font-weight: 600;
}
.flow-rail-step.current { background: var(--primary-soft); color: var(--primary-strong); }
.flow-rail-step.current .flow-rail-num { background: var(--primary); color: var(--on-primary); }
.flow-rail-step.done { color: var(--tint-text); }
.flow-rail-step.done .flow-rail-num { background: var(--ok); color: #fff; }

.flow-counter { margin: 0 0 4px; }

.flow-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

/* THE BAR, AND THE ORDER INSIDE IT IS THE POINT OF THE WHOLE FILE.
   What this does, then the doing of it: reach, then any blocking sentence, then the buttons. The
   reach line is never beside the button and never after it — it is the thing the eye crosses on
   its way there. Do not reorder these three. */
.flow-bar {
  flex: none;
  display: flex; flex-direction: column; gap: 10px;
  padding-block-start: 12px;
  background: var(--surface);
}
.flow-reach[hidden] { display: none; }
.flow-actions { display: flex; justify-content: flex-end; gap: 9px; }

/* Not `:disabled`. The button still works — it takes you to the step that is not finished — so it
   must not look like a control that has stopped responding, and it must not look ready either. */
.flow-actions .btn.unready { opacity: .62; }

/* Anything about the STEP as a whole, in the one place forms.js puts anything about a request as a
   whole: above the buttons, staying put, never a toast. */
.flow-error {
  margin: 0; padding: 9px 12px;
  border-radius: var(--radius-sm);
  background: var(--bad-soft); color: var(--bad);
  font-size: 13px;
}
.flow-error[hidden] { display: none; }

/* The dialog host. The flow owns the whole body so its bar can be the footer — the modal is given
   no footer of its own, which is what lets the same three elements be a pinned action bar on a
   phone without a second layout. */
.modal-body:has(> .composer-flow) { display: flex; padding: 3px 20px 16px; overflow: hidden; }

/* The route host: full height, and the bar pinned where a thumb reaches. */
.composer-route { display: flex; flex-direction: column; min-block-size: 100%; }
.composer-route .composer-flow { flex: 1 1 auto; }

@media (max-width: 899px) {
  .composer-route .flow-bar {
    position: sticky; inset-block-end: 0;
    margin-inline: -14px; padding: 12px 14px;
    /* Clear of the home indicator on a phone that has one, so the primary action is not sitting
       under the gesture that leaves the app. */
    padding-block-end: calc(12px + env(safe-area-inset-bottom, 0px));
    border-block-start: 1px solid var(--border);
  }
  /* One decision per screen: the buttons go full width and the one that advances is the wider of
     the two, in DOM order [back, next] so a screen reader hears them the way they are laid out. */
  .composer-route .flow-actions { gap: 10px; }
  .composer-route .flow-actions .btn { flex: 1; }
  .composer-route .flow-actions .btn.primary { flex: 2; }
}

@media (min-width: 900px) {
  /* The rail replaces the counter: two ways of saying where you are, and only one of them fits. */
  .flow-rail { display: flex; flex-wrap: wrap; }
  .flow-counter { display: none; }
}

/* ---------- announcements (module B1) -------------------------------------------------- */

.announce-head { display: flex; align-items: flex-start; gap: 12px; margin-block-end: 14px; }
.announce-head h1 { margin: 0 0 3px; font-size: 20px; }
.announce-head .btn { flex: none; }

.announce-draft-slot:empty { display: none; }

/* THE TWO DRAFTS LOOK DIFFERENT, and the difference is the warning.
   A never-sent draft is quiet: nobody has seen it and sending it is an ordinary first broadcast.
   One copied off a notice that already went out is tinted, because sending it rings the same
   phones a second time — the wire has no edit, so it is a new announcement beside the old one. */
.announce-draft { margin-block-end: 14px; border-inline-start: 3px solid var(--border-strong); }
.announce-draft.resent { border-inline-start-color: var(--warn); background: var(--surface-2); }
.announce-draft-body { margin: 8px 0; }
.announce-draft .row.gap-6 { margin-block-start: 10px; }

.announce-chip-draft { background: var(--surface-3); color: var(--muted); }
.announce-chip-resent { background: #f6ecd8; color: var(--warn); }
.announce-chip-scheduled { background: var(--primary-soft); color: var(--primary-strong); }
.announce-chip-sent { background: var(--surface-3); color: var(--tint-text); }

.announce-body { min-block-size: 150px; resize: vertical; }
.announce-count { margin: 6px 0 0; text-align: end; }

.announce-scopes { margin-block-end: 12px; }
.announce-picked:empty { display: none; }
.announce-picked { margin: 0 0 10px; }
.announce-picker { min-block-size: 120px; }

.announce-timing { display: flex; flex-direction: column; gap: 8px; }
.announce-choice { inline-size: 100%; text-align: start; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); cursor: pointer; }
.announce-choice.active { border-color: var(--primary); background: var(--primary-soft); }
.announce-choice .list-row-main { display: flex; flex-direction: column; gap: 2px; }
.announce-toggle { display: flex; align-items: center; gap: 10px; cursor: pointer; }
.announce-toggle .list-row-main { display: flex; flex-direction: column; gap: 2px; }
.announce-extras { margin-block-start: 14px; display: flex; flex-direction: column; gap: 8px; }
.announce-when { inline-size: 100%; }
/* Kept in the DOM rather than removed so the input holds its value while "Send now" is selected —
   a leader who toggles back to a schedule finds the time they already picked. */
.hidden-field { display: none; }

.announce-review { display: flex; flex-direction: column; gap: 14px; }
.announce-preview { background: var(--surface-2); }
.announce-preview-body { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.announce-summary { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 14px; margin: 0; }
.announce-summary dd { margin: 0; }

/* THE REACH BOX. Tinted with the accent rather than warned in red: nothing is wrong, and a leader
   about to make an ordinary announcement must not be trained to read this as an error — the day it
   IS a warning (a second send to people who already got it) is the day the amber stripe below
   appears, and that only works if the ordinary state is calm. */
.announce-reach { padding: 12px 14px; background: var(--primary-soft); }
.announce-reach-line { margin: 0; color: var(--primary-strong); }
.announce-reach > p + p { margin-block-start: 6px; }
.announce-reach-again {
  margin-block-start: 8px; padding: 9px 11px;
  border-inline-start: 3px solid var(--warn); border-radius: var(--radius-sm);
  background: #f6ecd8; color: var(--warn);
  font-size: 13px;
}

.announce-stats { margin-block-start: 12px; }
.announce-detail-actions { margin-block-start: 14px; }
.announce-detail-page { margin: 14px; }

@media (max-width: 700px) {
  .announce-head { flex-wrap: wrap; }
  .announce-head .btn { inline-size: 100%; justify-content: center; }
  .announce-summary { grid-template-columns: minmax(0, 1fr); gap: 2px; }
  .announce-summary dd { margin-block-end: 8px; }
}

/* ---------- SMS campaigns (module B2) --------------------------------------------------
 *
 * The reach rail's second page, and the only one where a screen decision costs money. Most of the
 * furniture is B1's — the head, the drafts, the review, the summary — because the two screens are
 * the same job on two channels and a second visual language would suggest otherwise. What is new
 * here is the wallet and the cost line.
 */

/* A hint under an input. `.field > span` styles EVERY span in a field as a bold label, so without
   this the explanatory line beneath a control renders as a second heading. */
.field > span.field-hint {
  margin: 5px 0 0; font-size: 12px; font-weight: 400; color: var(--muted);
}

.sms-wallet-slot:empty, .email-reach-slot:empty { display: none; }

.sms-wallet, .email-reach { margin-block-end: 14px; }
.sms-wallet .row { align-items: flex-start; gap: 12px; }
.sms-balance { margin: 2px 0 0; font-size: 26px; line-height: 1.1; }
.sms-balance strong { font-weight: 700; }
.sms-balance span { font-size: 15px; font-weight: 400; }

/* What a credit BUYS. Set apart from the balance because the balance is meaningless without it —
   "1 840 credits" answers no question anybody actually has. */
.sms-credit-explainer {
  margin: 10px 0 0; padding-block-start: 10px; border-block-start: 1px solid var(--border);
}
.sms-wallet-stats { margin-block-start: 12px; }

/* Money already sent that has not landed yet. Tinted rather than muted: a treasurer scanning this
   card for "where did my payment go" has to find it, and grey is what the eye skips. */
.sms-pending {
  margin: 12px 0 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: var(--primary-soft); color: var(--primary-strong); font-size: 13px;
}

/* An unconfigured channel is an operator's problem and not a fault of the community's, so it reads
   as an explanation rather than as an error — no red, no alarm. */
.sms-wallet.unavailable, .email-reach.unavailable { background: var(--surface-2); }
.sms-wallet.unavailable h2, .email-reach.unavailable h2 { margin: 0 0 6px; font-size: 17px; }

.sms-topup .field:last-of-type { margin-block-end: 8px; }
.sms-topup .row.gap-6 { margin-block-start: 14px; }

/* The message as it will land. Monospaced-ish measure rather than a rendered post: an SMS arrives
   as plain text on a small screen, and showing it in the app's own body font invites somebody to
   paste in formatting that will not survive. */
.sms-preview { background: var(--surface-2); }
.sms-preview-body {
  margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 34em;
}

/* ---- the cost line ----
 *
 * Rendered by ui/composer.js directly above the send button. It inherits .announce-reach's box and
 * adds the three states a price can be in: fine, explained, and short. */
.sms-cost-line { margin: 6px 0 0; font-size: 15px; }
.sms-cost-line strong { font-weight: 700; }

/* WHY it costs that. The Unicode case is the expensive accident nobody can see — the sender is
   looking at a sentence that reads perfectly normally — so it is the one line on this screen that
   is allowed to be loud. */
.sms-cost-warn {
  margin: 10px 0 0; padding: 10px 12px; border-radius: var(--radius-sm);
  border-inline-start: 3px solid var(--warn);
  background: var(--surface-2); color: var(--warn); font-size: 13px;
}

/* Not enough credit. Stated in the destructive colour because it is the one thing on the screen
   that stops the send, and the sentence beside it says how to clear it. */
.sms-cost-short {
  margin: 10px 0 0; padding: 10px 12px; border-radius: var(--radius-sm);
  border-inline-start: 3px solid var(--bad);
  background: var(--bad-soft); color: var(--bad); font-size: 13px;
}

.sms-chip-sending { background: var(--primary-soft); color: var(--primary-strong); }
.sms-chip-failed { background: var(--bad-soft); color: var(--bad); }

/* ---------- email newsletters (module B3) ----------------------------------------------
 *
 * B2's screen with the money taken out — same furniture, one card swapped: REACH where the wallet
 * was, because on this channel that is the figure a leader has to watch.
 */

.email-reach-count { margin: 2px 0 6px; font-size: 26px; line-height: 1.1; }
.email-reach-count strong { font-weight: 700; }
.email-reach-count span { font-size: 15px; font-weight: 400; }

/* The unsubscribe rate. Given weight because a leader should see it move, and given the muted
   palette rather than a warning one because people leaving a mailing list is ordinary — it becomes
   a problem at a rate, not at an event, and colouring it red would make every single opt-out look
   like a failure. */
.email-optout {
  margin: 10px 0 0; padding-block-start: 10px; border-block-start: 1px solid var(--border);
  font-size: 13px;
}
.email-optout strong { color: var(--text); }

.email-preview { background: var(--surface-2); }
.email-preview-subject { margin: 6px 0 0; font-weight: 700; overflow-wrap: anywhere; }
.email-preview-body {
  margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; max-inline-size: 42em;
}

/* The shared audience step (ui/audience.js), which all three channels draw. Its innards keep B1's
   class names — the picker was lifted out of that file wholesale — so this is only the wrapper. */
.audience-step { display: block; }

@media (max-width: 700px) {
  .sms-wallet .row { flex-wrap: wrap; }
  .sms-wallet .row .btn { inline-size: 100%; justify-content: center; }
  .sms-balance, .email-reach-count { font-size: 22px; }
}


/* ---------- the scheduler (ui/scheduler.js) ---------------------------------------------
 *
 * One time grid with lanes, and the agenda it becomes below 900px. Modules C1 and C3 both draw
 * through it — the lane is a DAY on an events week and a ROOM on a resources day, and nothing here
 * knows which.
 *
 * ---- Everything in this section serves one rule: a clash is DRAWN ----
 *
 * Overlapping items share a lane's width in columns, so two things wanting one hour are both on
 * screen, side by side, at the same height. Nothing is ever stacked: a block hidden behind another
 * is a clash nobody can see, which is the whole failure this view exists to prevent. The `.clash`
 * spine and tint are the mark on top of that, not the mechanism — the layout is the mechanism, and
 * it lives in scheduler.js because it is arithmetic.
 *
 * The GEOMETRY of every block (its top, its height, its column) is computed per item and arrives
 * through js/style.js into runtime.css — there is no inline style to fall back on under
 * `style-src 'self'`, and no amount of CSS here could position a 09:15 start. What this file owns
 * is everything that does NOT depend on a timestamp.
 */

.scheduler { display: block; }
.sched-shape { display: block; }

/* The grid is one CSS grid: an hour gutter, then one track per lane. --sched-lanes is written by
   scheduler.js because it is DATA — a week is seven and a building with three rooms is three. */
.sched-grid {
  --sched-lanes: 1;
  --sched-height: clamp(420px, 62vh, 760px);
  --sched-gutter-w: 56px;
  --sched-accent: var(--primary);
  background: var(--surface); border-radius: var(--radius); overflow: hidden;
}
.sched-heads, .sched-body {
  display: grid;
  grid-template-columns: var(--sched-gutter-w) repeat(var(--sched-lanes), minmax(0, 1fr));
}
.sched-heads { border-block-end: 1px solid var(--border); background: var(--surface); }
/* Sticky so the lane names stay legible while a long day is scrolled. `position: sticky` on the
   header row rather than a second fixed copy of it: two copies drift the moment a lane is renamed. */
.sched-heads { position: sticky; inset-block-start: 0; z-index: 2; }
.sched-head-spacer { border-inline-end: 1px solid var(--border); }

.sched-head {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  padding: 8px 10px; min-inline-size: 0;
  border-inline-start: 1px solid var(--border);
}
.sched-head-label { font-weight: 600; font-size: 14px; }
.sched-head-sub { color: var(--muted); font-size: 12px; }
.sched-head.today .sched-head-label { color: var(--primary); }
.sched-head .chip { font-size: 11px; padding: 1px 7px; }

.sched-body { position: relative; block-size: var(--sched-height); overflow-y: auto; }

/* The hour column. Each label is positioned by scheduler.js at its own fraction of the window, and
   nudged up by half a line so it reads as sitting ON the rule rather than under it. */
.sched-gutter { position: relative; border-inline-end: 1px solid var(--border); }
.sched-hour {
  position: absolute; inset-inline: 0; transform: translateY(-50%);
  padding-inline-end: 8px; text-align: end;
  color: var(--faint); font-size: 11px; font-variant-numeric: tabular-nums;
}

.sched-lane { position: relative; border-inline-start: 1px solid var(--border); min-inline-size: 0; }
.sched-lane.today { background: color-mix(in srgb, var(--primary) 4%, transparent); }
.sched-lane.bookable { cursor: copy; }
.sched-rule { position: absolute; inset-inline: 0; block-size: 1px; background: var(--border); }

/* The keyboard's ladder onto empty grid time (ui/scheduler.js says why it exists). Transparent and
   UNDER the blocks, so a pointer still hits the lane background and nothing about the drawing
   changes — what the rungs add is a tab stop and an accessible name per half hour, on a grid whose
   whole purpose could otherwise only be reached with a mouse.
   `z-index: 0` rather than none: the blocks establish their own stacking above it, and a rung that
   painted over a booking would take the click that belongs to the booking. */
/* A flex column filling the lane: the rungs are an even subdivision, so they need no computed
   geometry at all and cost nothing in runtime.css. ui/scheduler.js says why that matters here and
   not for the blocks beside them. */
.sched-slots { position: absolute; inset: 0; z-index: 0; display: flex; flex-direction: column; }
.sched-slot {
  flex: 1; min-block-size: 0; padding: 0; margin: 0;
  background: none; border: 0; cursor: copy; color: inherit; font: inherit;
}
/* Nothing on hover — the lane already answers a pointer, and forty rungs lighting up one at a time
   would turn an empty morning into a flicker. The ring is the whole visual, and it is what tells a
   keyboard user which half hour they are standing on. */
.sched-slot:focus-visible {
  outline: 2px solid var(--primary); outline-offset: -2px;
  background: color-mix(in srgb, var(--primary) 10%, transparent);
}
/* Now, on today's lane only. A line rather than a band, because it names an instant. */
.sched-now { position: absolute; inset-inline: 0; block-size: 2px; background: var(--bad); z-index: 1; }
.sched-now::before {
  content: ""; position: absolute; inset-inline-start: 0; inset-block-start: -3px;
  inline-size: 8px; block-size: 8px; border-radius: 50%; background: var(--bad);
}

/* One item. Positioned entirely from runtime.css; everything below is what it looks like once it is
   where it belongs. `overflow: hidden` rather than a fixed height, so a 20-minute block shows its
   time and clips its title instead of spilling over the one under it. */
.sched-block {
  position: absolute; z-index: 1;
  display: flex; flex-direction: column; gap: 1px; overflow: hidden;
  margin: 0; padding: 4px 6px;
  border: 0; border-inline-start: 3px solid var(--sched-accent);
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--sched-accent) 14%, var(--surface));
  color: var(--text); text-align: start; font: inherit; cursor: pointer;
  /* A hairline of page colour between two blocks in adjacent columns, so a full lane reads as
     several things rather than as one wide one. Inset rather than a margin: a margin would make the
     percentage widths no longer add up to the lane. */
  outline: 2px solid var(--surface); outline-offset: -1px;
}
.sched-block:hover { background: color-mix(in srgb, var(--sched-accent) 22%, var(--surface)); }
.sched-block:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.sched-block-time { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.sched-block-title { font-size: 13px; font-weight: 600; line-height: 1.25; overflow-wrap: anywhere; }
.sched-block-sub { font-size: 11px; color: var(--muted); overflow-wrap: anywhere; }
.sched-block-badge {
  align-self: flex-start; margin-block-start: 2px;
  padding: 0 6px; border-radius: var(--radius-pill);
  background: var(--surface); color: var(--muted); font-size: 10px;
}

/* An item continuing from the day before, or into the next one. The square corner is the whole
   signal: this block has been CLIPPED at the edge of the day, and the thing it belongs to is longer
   than what is drawn. A two-day conference reads as one hold on both mornings. */
.sched-block.from-before { border-start-start-radius: 0; border-start-end-radius: 0; }
.sched-block.into-after { border-end-start-radius: 0; border-end-end-radius: 0; }

/* Tones. A caller says what an item IS and the lane says nothing about it — a pending booking is
   `warn` because somebody still has to answer it, an approved one is plain. */
.sched-block.ok, .sched-row.ok { --sched-accent: var(--ok); }
.sched-block.warn, .sched-row.warn { --sched-accent: var(--warn); }
.sched-block.bad, .sched-row.bad { --sched-accent: var(--bad); }
.sched-block.muted, .sched-row.muted { --sched-accent: var(--muted); }

/* A row that is no longer live — a refused booking, a cancelled date. DIMMED AND STILL THERE, never
   removed: a cancelled service that vanished from the calendar is a service somebody turns up to.
   The same rule the ledger's voided row follows one module over. */
.sched-block.dim, .sched-row.dim { opacity: .6; }
.struck { text-decoration: line-through; }

/* THE CLASH. The spine is doubled in width and the fill deepened, on both of the blocks involved —
   never on one of them, because neither is "the wrong one" and the client has no way to know which
   the leader will keep. */
.sched-block.clash { border-inline-start-width: 5px; }
.sched-block.clash.warn { background: color-mix(in srgb, var(--warn) 20%, var(--surface)); }
.sched-block.clash.bad { background: color-mix(in srgb, var(--bad) 18%, var(--surface)); }

.sched-unknown { display: flex; align-items: center; gap: 8px; margin-block-start: 10px; }

/* ---- the agenda: the same items on a phone --------------------------------------------
 *
 * One day at a time. The day bar above it is what the grid's seven lanes were — which days carry
 * anything, and which of them carry a clash — because a phone loses the week at a glance the moment
 * the grid goes, and getting it back in a row of chips costs one line.
 */
.sched-agenda-head { display: flex; align-items: center; gap: 10px; margin-block-end: 10px; }
.sched-agenda-day { margin: 0; font-size: 17px; }
.sched-step { min-inline-size: 40px; justify-content: center; font-size: 18px; line-height: 1; }
.sched-daybar { margin-block-end: 10px; }
.sched-agenda { background: var(--surface); border-radius: var(--radius); overflow: hidden; }

.sched-row { display: flex; align-items: center; gap: 12px; inline-size: 100%; border: 0; font: inherit; text-align: start; }
.sched-row { border-inline-start: 3px solid var(--sched-accent, var(--primary)); background: var(--surface); }
.sched-row-time {
  display: flex; flex-direction: column; flex: none; inline-size: 52px;
  font-variant-numeric: tabular-nums; line-height: 1.3;
}
.sched-row.clash { border-inline-start-width: 5px; }

/* The bracket between two overlapping rows — the agenda's version of two blocks side by side. It
   sits BETWEEN them, at the point in the day the overlap happens, rather than as a summary at the
   top that somebody then has to go and find. */
.sched-clash {
  display: flex; align-items: center; gap: 8px;
  padding: 6px 12px; font-size: 12px;
  background: color-mix(in srgb, var(--warn) 12%, var(--surface)); color: var(--warn);
}
.sched-clash.bad { background: var(--bad-soft); color: var(--bad); }
.sched-clash-mark {
  flex: none; inline-size: 3px; block-size: 16px; border-radius: 2px; background: currentColor;
}

@media (max-width: 620px) {
  .sched-agenda { border-radius: 0; }
  .sched-row-time { inline-size: 46px; font-size: 13px; }
}


/* ---------- events: capacity, recurrence, reminders (module C1) --------------------------
 *
 * The member-facing card gained three chips and a banner; the organizer gained a panel under it.
 * Both live on the same page for the reason views/event.js gives — a cap of 40 means nothing
 * without the 38 already going beside it.
 */

/* A CANCELLED DATE IS SHOWN, NOT HIDDEN, and it has to be unmissable at the top of the card: the
   whole job of the announcement is to reach somebody who would otherwise turn up. Red rather than
   the muted grey a pinned banner uses — this is not metadata about the post, it is the news. */
.post-banner.cancelled { background: var(--bad-soft); color: var(--bad); font-weight: 500; }

.event-chips { margin-block-start: 8px; }

/* A button that reads as a link: the single-date exception under the RSVP row, which must not
   compete with the three answers above it. */
.btn.link-btn {
  background: none; border: 0; padding-inline: 4px;
  color: var(--primary); font-weight: 400;
}
.btn.link-btn:hover { background: none; text-decoration: underline; }

.event-admin { display: flex; flex-direction: column; gap: 12px; }
.event-admin-line { margin: 0; }
.event-admin-block {
  padding-block-start: 12px; border-block-start: 1px solid var(--border);
  display: flex; flex-direction: column; gap: 10px;
}
.event-admin-block .card-head { margin: 0; }
.event-admin-block h4 { margin: 0; font-size: 15px; }
.event-reminders { border-radius: var(--radius-sm); overflow: hidden; background: var(--surface-2); }
.event-reminder-form { display: flex; flex-direction: column; gap: 10px; }

/* The cancelled state of the organizer's own panel — the same red the member's banner uses, so an
   organizer and a member reading the same event are looking at the same fact. */
.event-cancelled {
  padding: 12px; border-radius: var(--radius-sm);
  background: var(--bad-soft); color: var(--bad);
}
.event-cancelled p { margin: 0 0 6px; }
.event-cancelled .muted { color: inherit; opacity: .8; }

/* The two irreversible buttons, kept together and away from the ones that merely edit. */
.event-admin-danger { display: flex; flex-wrap: wrap; gap: 9px; align-items: center; }
.event-admin-danger p { flex-basis: 100%; margin: 0; }

/* THE REACH LINE, wherever a form is about to make phones ring. Same box .announce-reach gives B1,
   named for what it IS rather than for the first module that needed one — the event reminder form
   and the room-booking composer both owe the reader this sentence, and it must look the same in all
   three places or it stops reading as the one thing the app always says before it acts. */
.reach-line {
  margin: 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: var(--primary-soft); color: var(--primary-strong);
}

/* ---------- the events week (views/workspace_events.js) --------------------------------- */

.events-bar { gap: 8px; margin-block-end: 12px; flex-wrap: wrap; }
.events-range { margin: 0; font-size: 16px; font-weight: 500; }
.events-note:not(:empty) { margin-block-end: 10px; }

/* ---------- rooms & resources (module C3) ------------------------------------------------ */

.resources-tabs { margin-block-end: 12px; }
.resources-bar { gap: 8px; margin-block-end: 12px; flex-wrap: wrap; }
.resources-day { margin: 0; font-size: 16px; font-weight: 500; }
.resources-panel { display: block; }

/* The draft sits ABOVE the grid, never over it: the point of a proposed hold is to be compared with
   the ones already there, and a dialog would cover the comparison. */
.resource-draft { margin-block-end: 12px; display: flex; flex-direction: column; gap: 10px; }
.resource-draft .card-head { margin: 0; }

.resource-request { align-items: flex-start; gap: 10px; }
.resource-request .list-row-sub { margin-block-start: 2px; }
.resource-glyph { flex: none; font-size: 20px; line-height: 1; }
.resource-tags { margin-block-start: 6px; }

@media (max-width: 700px) {
  .events-bar, .resources-bar { inline-size: 100%; }
  .event-admin-danger .btn { inline-size: 100%; justify-content: center; }
  .resource-request { flex-wrap: wrap; }
  .resource-request .row-wrap { inline-size: 100%; }
}

/* ---------- appointments (module C2) ----------------------------------------------------
 *
 * Two surfaces share this section because they share a vocabulary: the leader's diary editor
 * (views/workspace_appointments.js) and the member's meetings and booking flow
 * (views/appointments.js). Everything computed — a window's position on the grid, an avatar's
 * colour — goes through js/style.js into runtime.css; nothing here is ever an inline style.
 */

/* ---- the member's diary ---- */

.appointments-view .page-head-row { display: flex; align-items: center; gap: 12px; }
.appointments-view .page-head-row h1 { flex: 1; min-width: 0; margin: 0; }
.appointments-view .page-head .chip { margin-block-start: 8px; }

/* The strip's own furniture, plus the active state. The directory's link-tab deliberately never
   takes one — arriving there leaves the strip behind — but both halves of a diary stay on this
   page, so the reader needs to see which half they are in. */
.tabs .tab-link.active { background: var(--tint); color: var(--text); }

.appointment-day + .appointment-day { margin-block-start: 18px; }
/* The day a group of meetings falls on. Sticky so the date stays readable while a long day is
   scrolled — the row underneath deliberately never repeats it. */
.day-heading {
  position: sticky; inset-block-start: 0; z-index: 1;
  margin: 0 0 8px; padding: 6px 0;
  background: var(--bg); color: var(--muted);
  font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .04em;
}
.day-heading.small { font-size: 12px; text-transform: none; letter-spacing: 0; }

.appointment-row { align-items: flex-start; gap: 10px; }
.appointment-row .list-row-main { flex: 1; min-width: 0; }
.appointment-row-side {
  display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none;
}
/* A called-off or declined meeting STAYS LISTED, struck through rather than hidden — the whole
   point of saying a meeting is off is that the other person would otherwise turn up. Only the
   words are struck; the chip saying which it was must stay legible. */
.appointment-row.struck .list-row-title,
.appointment-row.struck .list-row-sub { text-decoration: line-through; }
.appointment-row.struck { opacity: .72; }

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

/* ---- the slot picker, shared by booking and by a leader's counter-offer ---- */

.slot-picker { display: flex; flex-direction: column; gap: 14px; max-block-size: 46vh; overflow-y: auto; }
.slot-day { display: flex; flex-direction: column; gap: 6px; }
.slot-chips { display: flex; flex-wrap: wrap; gap: 6px; }
.slot-chip {
  padding: 7px 12px; border: 1px solid var(--border-strong); border-radius: var(--radius-pill);
  background: var(--surface); color: var(--text);
  font: inherit; font-size: 13px; font-variant-numeric: tabular-nums; cursor: pointer;
}
.slot-chip:hover { border-color: var(--primary); color: var(--primary-strong); }
.slot-chip.current { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }

/* The booking composer's reach block. The sentence itself reuses .reach-line — the box every form
   in this app shows before it makes a phone ring — and what is added here is only the two lines of
   consequence underneath it. */
.appointment-reach { display: flex; flex-direction: column; gap: 6px; }
.appointment-reach p { margin: 0; }

.confirm-form { display: flex; flex-direction: column; gap: 12px; }
.confirm-form .alert-text { margin: 0; }

/* ---- the leader's diary editor ---- */

.appointments-admin-body { display: flex; flex-direction: column; gap: 16px; }
.appointments-grid { min-inline-size: 0; }

.appointments-section-head { display: flex; align-items: center; gap: 12px; margin-block-end: 10px; }
.appointments-section-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 16px; font-weight: 600; }

/* Away and extra hours read as opposites, so they are marked as opposites: a spine down the start
   of the row, muted for time that is closed and green for time that is opened. */
.exception-row { border-inline-start: 3px solid transparent; padding-inline-start: 9px; }
.exception-row.away { border-inline-start-color: var(--border-strong); }
.exception-row.extra { border-inline-start-color: var(--ok); }

.appointments-bar { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
.appointments-bar p { margin: 0; }
/* The timezone warning, and it is deliberately the loudest thing on this screen. Publishing from a
   different zone than the diary was published in re-stamps every window — the hours keep their
   numbers and stop meaning the same moments — and it is the one mistake here that is invisible
   afterwards. */
.appointments-warning {
  padding: 10px 12px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--warn) 14%, transparent); color: var(--warn);
  font-size: 13px;
}

.appointments-form { display: flex; flex-direction: column; gap: 12px; }
.appointments-form .field-hint { display: block; margin-block-start: 4px; }

@media (max-width: 700px) {
  .appointments-view .page-head-row { flex-wrap: wrap; }
  .appointments-view .page-head-row .btn { inline-size: 100%; justify-content: center; }
  .appointment-row { flex-wrap: wrap; }
  /* The chip and the buttons go full width rather than crushing against the name — a row whose
     actions have collapsed to two characters each is a row nobody can hit. */
  .appointment-row-side { inline-size: 100%; align-items: stretch; }
  .row-actions { justify-content: flex-start; }
  .appointments-bar .btn { inline-size: 100%; justify-content: center; }
}

/* ---------- the worship block: modules D1, D2 and D3 -------------------------------
 *
 * Three screens over one morning: what happens (the service planner), what is sung (the song
 * library) and who makes it run (the serving rota). Everything below reuses the custom properties
 * from :root and the furniture already in this file — `card`, `list-row`, `chip`, `field`, `notice`
 * — so dark mode follows without a second palette.
 *
 * Nothing here is computed. The rota's grid positions every block by arithmetic, and that goes
 * through js/style.js into runtime.css exactly as C1's and C3's do; there is no inline style to
 * fall back on and `el()` throws on a `style:` key.
 */

/* A standing sentence about what a control is ABOUT to do — the reach line's cousin, for the
   one-screen forms that carry the same kind of consequence. Above the button, never after it, and
   never a toast: a message that vanishes while it is being read is no use when it is the reason
   somebody is about to ring four hundred phones, or believe four people were asked. */
.notice {
  margin: 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--tint) 12%, transparent);
  color: var(--text); font-size: 13px; line-height: 1.45;
}
.notice.bad {
  background: color-mix(in srgb, var(--bad) 14%, transparent); color: var(--bad);
}

.gap-4 { gap: 4px; }
.gap-8 { gap: 8px; }

/* A read-only pair list, for the detail sheets that state facts rather than offer fields. */
.detail-grid { display: flex; flex-direction: column; gap: 10px; margin: 14px 0; }
.detail-pair { display: flex; flex-direction: column; gap: 2px; }
.detail-pair dt { margin: 0; }
.detail-pair dd { margin: 0; }

/* ---------- D1: the service planner ------------------------------------------------ */

.services-page { display: flex; flex-direction: column; gap: 14px; }
.services-tabs { margin-block-end: 2px; }
.services-panel { display: flex; flex-direction: column; gap: 10px; }
.services-step { display: flex; flex-direction: column; gap: 12px; }

.order-lines { display: flex; flex-direction: column; gap: 8px; }
/* A line, and the actions sit on its own row rather than in a trailing column, so the title keeps
   the width. A running order is read down the titles. */
.order-line {
  display: flex; gap: 10px; align-items: flex-start;
  padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--surface);
}
.order-line-main { flex: 1; min-inline-size: 0; display: flex; flex-direction: column; gap: 4px; }
.order-line-actions { display: flex; gap: 2px; flex-shrink: 0; }
.order-line-actions .btn { padding: 4px 6px; }
.order-line-form { display: flex; flex-direction: column; gap: 12px; }
.order-line-attachment { display: flex; flex-direction: column; gap: 10px; }

/* The review step's numbered order. An <ol> because the numbers ARE the order — a planner reading
   it aloud on Sunday morning is reading positions. */
.order-review { margin: 0; padding-inline-start: 22px; display: flex; flex-direction: column; gap: 8px; }
.order-review li { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; }

.services-review { gap: 10px; }
.services-event-pick { display: flex; flex-direction: column; gap: 8px; }

/* The reach line inside the composer's own bar. It is the sentence the eye crosses on the way to
   the button, so it is given weight rather than tucked under the fold as a hint. */
.flow-reach-line {
  margin: 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--tint) 12%, transparent);
  font-size: 13px; line-height: 1.45;
}

/* ---------- D2: the song & liturgy library ----------------------------------------- */

.songs-page { display: flex; flex-direction: column; gap: 12px; }
.songs-filters { display: flex; flex-direction: column; gap: 6px; }
.songs-actions { display: flex; gap: 6px; }
.songs-editor-host { display: flex; flex-direction: column; gap: 12px; }
.song-editor { display: flex; flex-direction: column; gap: 12px; }
/* A lyrics field is not a comment box. Fourteen rows is a verse and a chorus visible at once, which
   is what makes the section markers checkable while they are being typed. */
.song-body-input {
  inline-size: 100%; min-block-size: 8rem; resize: vertical;
  padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--surface); color: var(--text);
  font: inherit; line-height: 1.5;
}

.song-detail, .song-sheet, .song-route { display: flex; flex-direction: column; gap: 14px; }
.song-head { display: flex; flex-direction: column; gap: 6px; }
.song-head h2 { margin: 0; }
.song-sections { display: flex; flex-direction: column; gap: 14px; }
.song-section { display: flex; flex-direction: column; gap: 2px; }
.song-section-name { margin: 0 0 2px; text-transform: uppercase; letter-spacing: .04em; }
/* One <p> per line, and the line breaks are the song's own. `white-space: pre-wrap` would work on a
   single block, but a paragraph per line is what lets a projection screen and a phone wrap the same
   text differently without either inventing a break the hymn does not have. */
.song-line { margin: 0; line-height: 1.55; }
.song-line:empty { block-size: .6em; }
.song-translit {
  padding-block-start: 12px; border-block-start: 1px solid var(--border);
  display: flex; flex-direction: column; gap: 10px;
}

/* The module a community has declined. Deliberately unadorned: it is not an error and it is not a
   refusal, it is a fact about this community, so it reads as a page rather than as a warning. */
.songs-off { display: flex; flex-direction: column; gap: 12px; align-items: flex-start; }
.songs-off h1 { margin: 0; }
.songs-off p { margin: 0; max-inline-size: 52ch; line-height: 1.55; }

/* ---------- D3: the serving rota ---------------------------------------------------- */

.roster-page { display: flex; flex-direction: column; gap: 12px; }
.roster-tabs { margin-block-end: 2px; }
.roster-day { min-inline-size: 9rem; text-align: center; }
.roster-summary:empty { display: none; }
.roster-headline { display: flex; }
/* The headline is the page's one number and it is the server's arithmetic — see the file header.
   Given the size of a heading rather than a chip, because a leader scanning this page is looking
   for exactly one thing. */
.roster-count { display: flex; gap: 10px; align-items: baseline; flex-wrap: wrap; }
.roster-count strong { font-size: 18px; }

.roster-panel { min-block-size: 12rem; }
.roster-positions { display: flex; flex-direction: column; gap: 12px; }
.roster-detail { display: flex; flex-direction: column; gap: 14px; }
.roster-assign, .roster-position-form { display: flex; flex-direction: column; gap: 12px; }
/* An edit cannot move a position between ministries — the server ignores group_id there, because
   moving one would silently re-scope every assignment already made against it. Drawn as settled
   rather than removed, so the field still says which ministry this job belongs to. */
.roster-position-form .settled { opacity: .65; pointer-events: none; }

/* A gap block, drawn OUTLINED rather than faded — because a hole is a thing to act on and not a
   thing that is less real than the blocks beside it. The whole design of this page is that an
   unfilled slot has its own tap target, and a block a leader reads as "nothing here" is a block
   they will not press. The warn tone comes from the scheduler; this only trades the fill for a
   dashed edge so an empty slot reads as an outline rather than as somebody's name. */
.sched-block.outline {
  background: transparent;
  border-style: dashed;
}
.sched-block.outline .sched-block-title { font-style: italic; }

/* ---------- the member surface: "My serving" ---------------------------------------- */

.serving-page { display: flex; flex-direction: column; gap: 14px; }
.serving-badge:empty { display: none; }
.serving-list { display: flex; flex-direction: column; gap: 10px; }
/* Three columns on a pointer screen — when, what, and the answer — because the answer is what the
   page is for and it must not be hunted for at the end of a paragraph. */
.serving-row { display: flex; gap: 14px; align-items: flex-start; }
.serving-when {
  display: flex; flex-direction: column; gap: 2px;
  min-inline-size: 6.5rem; flex-shrink: 0;
}
.serving-what { flex: 1; min-inline-size: 0; display: flex; flex-direction: column; gap: 4px; }
.serving-note { margin: 4px 0 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--border); }
.serving-answer { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; flex-shrink: 0; }
.serving-row.dim { opacity: .6; }
.serving-away { display: flex; flex-direction: column; gap: 10px; }
.serving-away-form, .serving-swap { display: flex; flex-direction: column; gap: 12px; }

@media (max-width: 700px) {
  /* The answer buttons go under the row rather than crushing against the date. A row whose "I can"
     has collapsed to two characters is a row nobody can hit — and this is the one control on the
     page a member came here to press. */
  .serving-row { flex-wrap: wrap; }
  .serving-answer { inline-size: 100%; }
  .serving-answer .btn { flex: 1; justify-content: center; }
  .order-line { flex-wrap: wrap; }
  .order-line-actions { inline-size: 100%; justify-content: flex-end; }
  .roster-day { min-inline-size: 0; flex: 1; }
  .songs-off p { max-inline-size: none; }
}

/* The passage picker (ui/passage.js), and the passage rows a running order's line carries. */
.passage-picker { display: flex; flex-direction: column; gap: 12px; }
/* The reference read back in words, live, above the button. Three number fields cannot be checked
   one at a time — the sentence is what somebody actually confirms. */
.passage-preview {
  margin: 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--tint) 12%, transparent);
}
.passage-list { display: flex; flex-direction: column; gap: 6px; }
.passage-row { display: flex; gap: 4px; align-items: center; }
.passage-row .btn.grow { justify-content: flex-start; }

/* ---------- the board (ui/board.js) -----------------------------------------------------
 *
 * A stage funnel as columns, and the segmented filter it becomes below 900px. Modules A4 (the
 * newcomer pipeline) and G5 (pastoral care) both draw through it — the stage is a PIPELINE STAGE
 * there and a CASE STATE here, and nothing in this section knows which.
 *
 * ---- Everything here serves one rule: the stage is a FIELD on the record ----
 *
 * A column is a rendering of that field, so a card's position is never this stylesheet's business
 * and never board.js's either. There is no drag ghost to position, no placeholder to slot, and no
 * transition on a card's home: a move is a write, and what follows a write is a repaint from what
 * the server actually stored. The only drag styling below is on the two things that are genuinely
 * transient — the card being carried (`.dragging`) and the column under the pointer (`.over`) — and
 * both are gone the moment the gesture ends.
 *
 * Unlike the scheduler this shape computes NO geometry, so it writes nothing into runtime.css and
 * owes no clear_style() at teardown. Every value it needs is here.
 */

.board { display: block; }

/* The columns. `overflow-x: auto` rather than a shrinking track: six columns at 220px is 1320px,
   and squeezing them to fit is how a pipeline ends up with column headings wider than the names
   under them. Scroll the strip; keep the card readable. */
.board-cols {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(220px, 1fr);
  gap: 10px;
  overflow-x: auto;
  padding-block-end: 4px;
}

.board-col {
  display: flex; flex-direction: column; min-inline-size: 0;
  background: var(--surface-3); border-radius: var(--radius);
  border: 1px solid transparent;
}
/* The drop target, lit ONLY where the drop would be accepted — board.js never calls preventDefault
   on a column that would refuse the card, so a column that will not take it never lights up. A board
   that lit every column and then failed on drop would teach the gesture and then punish it. */
.board-col.over { border-color: var(--primary); background: var(--primary-soft); }

/* The terminal stage — module A4's CLOSED, the way OUT of the funnel rather than a step along it.
   Set apart by a rule and a quieter ground, so it does not read as the sixth thing to aim for. */
.board-col.terminal { background: var(--surface-2); border-inline-start: 2px solid var(--border-strong); }

.board-col-head {
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px 8px; border-block-end: 1px solid var(--border);
}
.board-col-title { display: flex; align-items: baseline; gap: 8px; }
.board-col-label { font-weight: 600; font-size: 14px; flex: 1; min-inline-size: 0; }
/* Tabular figures so a column of counts does not jitter as it updates. The value is the SERVER's
   total over the whole set, never the number of cards below it — see tally() in board.js. */
.board-col-count { color: var(--muted); font-size: 13px; font-variant-numeric: tabular-nums; }
.board-col.tone-ok .board-col-label { color: var(--ok); }
.board-col.tone-muted .board-col-label { color: var(--muted); }

.board-col-body { display: flex; flex-direction: column; gap: 8px; padding: 8px; min-block-size: 60px; }
/* The honesty line: a column holding fewer cards than its count says so. Deliberately NOT styled as
   a warning — nothing is wrong, the list is simply paged, and a red note here would make an ordinary
   state look like a failure. */
.board-col-partial, .board-col-empty { margin: 2px 0 0; text-align: center; }

.board-card {
  position: relative;
  display: flex; flex-direction: column; gap: 6px;
  padding: 10px 34px 10px 12px;
  background: var(--surface); border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  cursor: default;
}
.board-card.openable { cursor: pointer; }
.board-card.openable:hover { border-color: var(--border-strong); }
.board-card[draggable="true"] { cursor: grab; }
.board-card.dragging { opacity: .45; cursor: grabbing; }

/* The card's own control, stretched over its face.
 *
 * The card used to be `role="button"` itself, which swallowed the move control nested inside it —
 * see the note in ui/board.js. So the opening action is a real button now, laid over the card and
 * transparent: a pointer sees no change at all, and a keyboard gets something with a name.
 *
 * Under everything else in the card (`z-index: 0` against the move control's 1) so the move button
 * and any link the caller's own card content carries still take their own clicks. The ring is drawn
 * on the CARD rather than on the button, because a 1px outline tracing a stretched, invisible box
 * inside the border reads as a rendering artefact rather than as focus. */
.board-open {
  position: absolute; inset: 0; z-index: 0;
  padding: 0; margin: 0; border: 0; background: none; font: inherit; color: inherit;
  cursor: pointer; border-radius: var(--radius-sm);
}
/* The ring is drawn on the stretched button itself. It is `inset: 0` on the card, so an outline on
   it traces the card's own edge — which is what a reader expects to see light up — while leaving
   the move control free to draw its own, smaller ring. Two controls in one card, each saying where
   the keyboard actually is. No `:has()` anywhere: this has to be right in every browser that runs
   the rest of the app. */
.board-open:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* The move control, and it is the PRIMARY path rather than a fallback for the drag: it is present in
   both shapes, it is the only one on a phone, and it is the only one reachable by keyboard or by a
   screen reader. Parked in the card's own padding so it never reflows the content beside it. */
.board-move {
  /* Above the stretched open control, so the move button keeps its own click. Without this the two
     are both positioned at the same level and the later one in the DOM happens to win — which is
     true today and is exactly the kind of thing a reorder breaks silently. */
  z-index: 1;
  position: absolute; inset-block-start: 6px; inset-inline-end: 6px;
  display: flex; align-items: center; justify-content: center;
  inline-size: 26px; block-size: 26px; padding: 0;
  border: none; border-radius: var(--radius-sm);
  background: transparent; color: var(--muted); cursor: pointer;
}
.board-move:hover { background: var(--surface-3); color: var(--text); }
.board-move:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }

/* The phone shape: one list, with the segments above it deciding what the CALLER fetches. */
.board-segs { display: flex; gap: 6px; overflow-x: auto; padding-block-end: 8px; }
.board-seg {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  padding: 6px 12px; border-radius: var(--radius-pill);
  border: 1px solid var(--border); background: var(--surface);
  color: var(--text); font-size: 13px; cursor: pointer;
}
.board-seg.active { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
.board-seg-count { font-variant-numeric: tabular-nums; opacity: .7; }

.board-list { display: flex; flex-direction: column; gap: 8px; }
/* On one flat list the card has to carry the stage the column header was saying, or a mixed list
   gives a leader no way to tell somebody contacted last week from somebody who has been coming for a
   month. */
.board-card-stage { align-self: flex-start; }

.board-loading, .board-unknown { margin: 0 0 8px; }
.board-spare { margin-block-start: 14px; display: flex; flex-direction: column; gap: 8px; }

/* ---------- the newcomer pipeline (module A4) --------------------------------------------
 *
 * Everybody drawn here is somebody with NO ACCOUNT — a name and, if they gave one, a number, written
 * down after a conversation at a door. Two consequences show up in this section: the phone is styled
 * as text rather than as a link (a one-tap dial beside a drag surface is a call placed by accident),
 * and the CLOSED note is drawn as a plain statement rather than as an error, because "do not
 * contact" is a fact about a person's wishes and not a problem with a record.
 */

.outreach { display: flex; flex-direction: column; gap: 14px; }
.outreach-scope { display: flex; }
.outreach-more:empty, .care-more:empty, .follow-ups-more:empty { display: none; }

.outreach-card-body { display: flex; flex-direction: column; gap: 5px; min-inline-size: 0; }
.outreach-card-name { font-weight: 600; font-size: 14px; }
.outreach-card-phone { font-variant-numeric: tabular-nums; }
.outreach-card-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.outreach-card-who { display: inline-flex; align-items: center; gap: 5px; min-inline-size: 0; font-size: 12px; }
.outreach-card-due { display: flex; gap: 5px; color: var(--muted); }
.outreach-card-due.overdue { color: var(--warn); font-weight: 600; }
/* NOT `.bad`: somebody asking not to be contacted is not an error state, and colouring it red would
   read as a problem to be fixed rather than a wish to be kept. */
.outreach-card-closed { margin: 2px 0 0; color: var(--muted); }
.outreach-card-joined { margin: 2px 0 0; color: var(--ok); }

.prospect-detail { display: flex; flex-direction: column; gap: 12px; }
.prospect-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.prospect-joined {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 12px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--ok) 12%, transparent); color: inherit; text-decoration: none;
}
.prospect-closed {
  margin: 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: var(--surface-3); color: var(--muted);
}
.prospect-note { margin: 0; padding-inline-start: 10px; border-inline-start: 2px solid var(--border); }
.prospect-facts { display: flex; flex-wrap: wrap; gap: 16px; }
.prospect-fact { display: flex; flex-direction: column; gap: 2px; }
.prospect-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.prospect-section { margin: 4px 0 0; font-size: 15px; }
.prospect-log { display: flex; flex-direction: column; gap: 10px; }
.prospect-log-row { padding-inline-start: 10px; border-inline-start: 2px solid var(--border); }
/* The one row nobody performed — written by the server when the phone match fires. Marked so it
   reads as a milestone in the record rather than as somebody's log entry. */
.prospect-log-row.joined { border-inline-start-color: var(--ok); }
.prospect-log-head { display: flex; justify-content: space-between; gap: 8px; }
.prospect-log-kind { font-weight: 600; font-size: 13px; }
.prospect-log-note { margin: 2px 0; }

/* The member half — "My follow-ups". */
.follow-ups { display: flex; flex-direction: column; gap: 14px; }
.follow-up-row.closed { opacity: .65; }
.follow-up-due { display: flex; gap: 4px; color: var(--muted); }
.follow-up-due.overdue { color: var(--warn); font-weight: 600; }
.follow-up-closed { margin: 2px 0 0; color: var(--muted); }

/* ---------- pastoral care & visitation (module G5) ----------------------------------------
 *
 * The module whose EXISTENCE is the thing being withheld, and this section has one job beyond
 * looking right: NEVER GIVE THIS MODULE A BADGE, A COUNT PILL OR A NOTIFICATION DOT. There is no
 * such class below and none may be added. A number a colleague could read over a shoulder is a fact
 * about who is being visited this week, even when they cannot open the list behind it.
 *
 * The other rule is in the striking: A CLOSED CASE IS KEPT, struck through, in the list. It is not
 * hidden, not faded to invisibility, and never drawn as deleted — there is no delete on this wire,
 * and a coordinator who believed there was would have been misled by this stylesheet.
 */

.care { display: flex; flex-direction: column; gap: 14px; }
.care-tabs { display: flex; gap: 6px; border-block-end: 1px solid var(--border); }
.care-tab {
  padding: 8px 12px; border: none; background: none; cursor: pointer;
  color: var(--muted); font-size: 14px; border-block-end: 2px solid transparent;
}
.care-tab.active { color: var(--text); font-weight: 600; border-block-end-color: var(--primary); }
.care-control-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.care-control-row p { margin: 0; flex: 1; min-inline-size: 12rem; }

.care-card-body { display: flex; flex-direction: column; gap: 5px; min-inline-size: 0; }
.care-card-top { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.care-card-name { font-weight: 600; font-size: 14px; }
/* The strike is the whole point of the closed state: the row is still here, still readable, and
   visibly finished. Applied to the NAME rather than to the card, so the timeline under it stays
   legible — what the community did for somebody is the record. */
.care-card-body.closed .care-card-name { text-decoration: line-through; color: var(--muted); }
.care-card-summary { margin: 0; color: var(--muted); font-size: 13px; }
.care-card-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.care-card-carers { display: inline-flex; align-items: center; }
.care-card-carers .avatar + .avatar { margin-inline-start: -6px; border: 2px solid var(--surface); }
.care-card-activity { margin: 2px 0 0; }
/* The staleness mark, and in this module it carries more weight than a mark usually does: G5 has no
   badge, no counter and no feed row, so a neglected case is invisible to everybody except here and
   in the server's weekly nudge. Warn rather than bad — nobody did anything wrong, and a red case
   would read as a failing rather than as a reminder. */
.care-card-stale {
  display: flex; align-items: center; gap: 5px; margin: 2px 0 0;
  color: var(--warn); font-weight: 600;
}

.care-detail { display: flex; flex-direction: column; gap: 12px; }
.care-detail-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.care-detail-summary { margin: 0; }
.care-outcome {
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px; border-radius: var(--radius-sm); background: var(--surface-3);
}
.care-outcome p { margin: 0; }
.care-detail-stale {
  margin: 0; padding: 10px 12px; border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
}
.care-section { margin: 4px 0 0; font-size: 15px; }
.care-carers { display: flex; flex-direction: column; gap: 8px; }
.care-carers-add { display: flex; flex-direction: column; gap: 4px; }
.care-carers-add p { margin: 0; }
.care-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.care-timeline { display: flex; flex-direction: column; gap: 10px; }
.care-log-row { padding-inline-start: 10px; border-inline-start: 2px solid var(--border); }
.care-log-head { display: flex; justify-content: space-between; gap: 8px; }
.care-log-kind { font-weight: 600; font-size: 13px; }
.care-log-note { margin: 2px 0; }
.care-interview-note { margin: 4px 0 0; color: var(--muted); font-size: 13px; }

/* ---------- shared form furniture, first needed by A4 and G5 -------------------------------
 *
 * Named for what they are rather than for either module, because the next composer wants the same
 * three. Anything that names a prospect or a case belongs in the sections above.
 */
.form-stack { display: flex; flex-direction: column; gap: 12px; }
/* The two-part phone entry: a narrow dial code beside the number. Both keep their own <label> rather
   than sharing one — a second input with a blank label is announced as unlabelled. */
.field-pair { display: flex; gap: 8px; align-items: flex-start; }
.field-pair .field.narrow { flex: none; inline-size: 6.5rem; }
.field-pair .field:not(.narrow) { flex: 1; min-inline-size: 0; }
/* Where a value is chosen through a dialog rather than typed. Wrapped in a .field so it lines up with
   the inputs beside it, and it holds the chips of a multi-choice. */
.picker-row { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.seg-row { display: flex; gap: 6px; flex-wrap: wrap; }
.chip.pickable { cursor: pointer; border: 1px solid var(--border); background: var(--surface); }
.chip.pickable.active { background: var(--primary); border-color: var(--primary); color: var(--on-primary); }
/* The remove control on a chosen chip. Its accessible name is set per chip in the view, so this is
   only the geometry. */
.chip-x {
  display: inline-flex; align-items: center; justify-content: center;
  inline-size: 16px; block-size: 16px; padding: 0; margin-inline-start: 4px;
  border: none; border-radius: 50%; background: transparent; color: inherit; cursor: pointer;
}
.chip-x:hover { background: color-mix(in srgb, currentColor 18%, transparent); }

/* ---------- the teaching block: modules F1, F2 and F3 -------------------------------
 *
 * Three modules, two postures. F1's catalogue and F3's console are WORKING surfaces and use the
 * app's own furniture — the registry, the card, the list row. F1's reader and F2's player are
 * READING and CAPTURE surfaces, and they borrow the devotional ground the scripture reader already
 * defines (--paper, --ink): a member reads a lesson at length, and pure white at reading length
 * glares.
 *
 * Every width a view has to compute goes through js/style.js into runtime.css — the two progress
 * bars below own only their track and their transition, never their fill's width.
 */

/* ---- F1: the catalogue (views/workspace_courses.js) ------------------------------ */

.courses-page .page-head,
.memo-page .page-head { display: flex; align-items: flex-start; gap: 12px; }
.course-detail-body { display: flex; flex-direction: column; gap: 12px; }
.course-detail-actions { margin-block-start: 4px; }
.course-teacher { align-items: center; }
/* A section heading inside a contents list. Quiet: it groups rows, it is not a card of its own. */
.course-section {
  font-size: 13px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.04em;
  margin: 14px 0 4px;
}
.course-lesson-row { display: flex; align-items: center; gap: 10px; }
.course-lesson-row .strong { display: block; }
.course-roll-list, .course-contents { display: flex; flex-direction: column; }
.course-roll-row { display: flex; align-items: center; gap: 10px; }

/* ---- F1: the reader (views/course.js) -------------------------------------------- */

/* The devotional ground. The same four tokens the scripture reader uses, and for its reason: a
   lesson is read at length rather than scanned, and the app's white-on-grey is built for a feed. */
.reader-page {
  max-inline-size: 46rem; margin-inline: auto;
  background: var(--paper); color: var(--ink);
  border-radius: var(--radius); padding: 24px 20px 32px;
  display: flex; flex-direction: column; gap: 14px;
}
.reader-page .muted { color: var(--ink-muted); }
.course-head { display: flex; flex-direction: column; gap: 8px; }
.course-blurb { color: var(--ink-muted); white-space: pre-wrap; }
/* The TRACK only. The fill's width is arithmetic and is written by js/style.js — style-src 'self'
   forbids a style attribute in every form, and the only symptom would be a bar that never moves. */
.course-progress-bar, .memo-progress-bar {
  block-size: 6px; border-radius: var(--radius-pill);
  background: var(--paper-2); overflow: hidden;
}
.memo-progress-bar { background: var(--surface-3); }
.course-progress-fill, .memo-progress-fill {
  block-size: 100%; border-radius: var(--radius-pill);
  background: var(--primary); transition: width 160ms ease-out;
}
/* Finished is the SERVER's answer — a non-zero completion instant — never "count >= total"
   recomputed in the view. See js/courses.js. */
.course-progress-bar.ok .course-progress-fill { background: var(--ok); }
.course-reader .course-lesson-row {
  padding: 10px 8px; border-radius: var(--radius-sm);
  color: inherit; text-decoration: none;
}
.course-reader .course-lesson-row:hover { background: var(--paper-2); }
.course-reader .course-lesson-row.done .strong { color: var(--ink-muted); }
.course-lesson-tick {
  inline-size: 20px; text-align: center; color: var(--ok); font-weight: 700;
}
/* Reading measure and rhythm. A lesson body is prose somebody wrote out — up to 20,000 characters
   of it — so it gets the looser leading a page of text needs rather than a feed card's.
   `pre-wrap` is the convention every long body in this sheet already follows (.post-body,
   .event-blurb, .comment-body, .bubble-text) and it is load-bearing here rather than cosmetic: the
   body is a PLAIN STRING on the wire with no markup, so its paragraph breaks are literal newlines.
   Without this they collapse and a whole lesson runs together as one block. */
.lesson-body { line-height: 1.65; white-space: pre-wrap; }
.lesson-passages { display: flex; flex-direction: column; gap: 8px; }
.lesson-quiz { background: var(--surface); color: var(--text); }
.lesson-tick { display: flex; flex-direction: column; gap: 6px; align-items: flex-start; }

/* ---- F2: the player (views/quiz.js) ----------------------------------------------
 *
 * CAPTURE-SHAPED. One question per screen and BIG TARGETS: every option is a full-width row and the
 * whole row is the hit area, because a 12px radio on a moving bus is how somebody answers a
 * question they meant to skip. This is ui/rollcall.js's posture, applied to a quiz.
 */

.quiz-player {
  max-inline-size: 40rem; margin-inline: auto;
  display: flex; flex-direction: column; gap: 16px;
}
.quiz-head { display: flex; flex-direction: column; gap: 8px; }
.quiz-progress-bar {
  block-size: 4px; border-radius: var(--radius-pill);
  background: var(--surface-3); overflow: hidden;
}
.quiz-progress-fill {
  block-size: 100%; background: var(--primary); transition: width 200ms ease-out;
}
.quiz-stage { display: flex; flex-direction: column; gap: 12px; }
.quiz-counter { font-size: 13px; }
.quiz-question { font-size: 20px; line-height: 1.4; }
.quiz-options { display: flex; flex-direction: column; gap: 10px; }
/* The target. min-block-size rather than padding alone, so a one-word option ("True") is exactly as
   easy to hit as a full sentence — which is the case that matters, since true/false questions are
   the ones answered fastest and least carefully. */
.quiz-option {
  display: flex; align-items: center; gap: 12px;
  inline-size: 100%; min-block-size: 56px; padding: 12px 16px;
  border: 1px solid var(--border-strong); border-radius: var(--radius);
  background: var(--surface); color: var(--text);
  font: inherit; text-align: start; cursor: pointer;
}
.quiz-option:hover:not(:disabled) { border-color: var(--primary); }
.quiz-option:disabled { cursor: default; opacity: 0.75; }
.quiz-option-mark {
  inline-size: 22px; block-size: 22px; flex: none;
  border: 2px solid var(--border-strong); border-radius: 50%;
}
/* Chosen, NOT committed. Choosing an option does not advance and does not submit — see the header
   of views/quiz.js — so this state has to read as "picked" rather than as "answered". */
.quiz-option.chosen { border-color: var(--primary); background: var(--primary-soft); }
.quiz-option.chosen .quiz-option-mark {
  border-color: var(--primary); background: var(--primary); box-shadow: inset 0 0 0 3px var(--surface);
}
.quiz-text-answer {
  inline-size: 100%; min-block-size: 52px; padding: 12px 14px;
  border: 1px solid var(--border-strong); border-radius: var(--radius);
  background: var(--surface); color: var(--text); font: inherit;
}
.quiz-foot { display: flex; flex-direction: column; gap: 8px; }
.quiz-actions { justify-content: flex-end; }
.quiz-finished { display: flex; flex-direction: column; gap: 6px; align-items: center; text-align: center; }
.quiz-score { font-size: 32px; font-weight: 700; }
.quiz-finished .ok { color: var(--ok); }
.quiz-attempts, .quiz-answer-list { display: flex; flex-direction: column; gap: 10px; }
.quiz-attempt { display: flex; flex-direction: column; gap: 10px; }
.quiz-blocked { display: flex; flex-direction: column; gap: 10px; }

/* ---- F2: the leaderboard --------------------------------------------------------- */

.quiz-board-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.quiz-board-row {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 8px; border-radius: var(--radius-sm);
}
/* The viewer's own row, marked because finding it is what a member opened this for. Read off
   QUIZ_LEADERBOARD_FLAG_VIEWER — the server's answer, never a client-side id comparison, since a
   member who opted out of appearing is not on the board at all. */
.quiz-board-row.mine { background: var(--primary-soft); }
.quiz-rank { inline-size: 2rem; text-align: end; font-variant-numeric: tabular-nums; color: var(--muted); }
.quiz-your-rank { font-size: 18px; }

/* ---- F3: the memorization console (views/workspace_memorization.js) --------------- */

.memo-shelf, .memo-plan-list, .memo-roll-list, .memo-queue-list {
  display: flex; flex-direction: column; gap: 12px;
}
.memo-plan-row {
  display: flex; flex-direction: column; gap: 8px;
  color: inherit; text-decoration: none;
}
.memo-plan-row:hover { border-color: var(--primary); }
/* The reviewer's inbox at the head of the shelf — the only thing on the page somebody ELSE is
   waiting on, which is why it sits above the plans rather than among them. */
.memo-queue-card {
  display: flex; align-items: center; gap: 12px;
  color: inherit; text-decoration: none;
}
.memo-roll-row { display: flex; align-items: center; gap: 10px; }
.memo-queue-row { display: flex; flex-direction: column; gap: 10px; }
/* The recitation. The web PLAYS what Android recorded — this client records no audio, which is half
   the reason F3's member surface is Android's. */
.memo-recording { inline-size: 100%; }
/* The unit map: one cell per passage, in reading order. A grid rather than a list because thirty
   parts read as a map and a thirty-row list does not — and because the gap in an unfinished plan is
   what a leader is checking for. */
.memo-unit-map {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
}
.memo-unit-cell {
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
  background: var(--surface-2);
}
.memo-units, .memo-range { display: flex; flex-direction: column; gap: 6px; }
.memo-unit-list { display: flex; flex-direction: column; }
.memo-unit-row { display: flex; align-items: center; gap: 10px; }
.memo-unit-row .grow { display: flex; flex-direction: column; }
/* An endpoint is chosen through the passage picker, so it is a button that REPORTS a value rather
   than a field that takes one. Two of them, because a unit is a RANGE WITH TWO ENDPOINTS — a part
   of the Qur'an crosses surahs and a single reference could only say so by lying about one end. */
.memo-endpoint {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
  inline-size: 100%; text-align: start;
}

/* ---- shared form furniture, first needed by the teaching block ------------------- */

/* The label above a control that is not an <input>, so it matches the <span> inside .field. */
.field-label { font-size: 13px; font-weight: 500; color: var(--muted); }
/* forms.js text_area()'s hint: a sentence under the label and above the box, for what a composer
   has to say BEFORE somebody writes. Not an error, and it never moves. */
.field-hint { display: block; }
.editor-form { display: flex; flex-direction: column; gap: 12px; min-inline-size: min(28rem, 80vw); }

@media (max-width: 900px) {
  /* The reader loses its inset on a phone: at 390px the page IS the column, and a margin only
     narrows the measure it exists to protect. */
  .reader-page { border-radius: 0; padding: 16px 14px 28px; }
  .quiz-question { font-size: 18px; }
  /* Actions go full width and stack, so the Finish button on the last step is a deliberate press
     rather than one of three targets sharing a thumb's width. */
  .quiz-actions { justify-content: stretch; }
  .quiz-actions .btn { flex: 1; min-inline-size: 45%; }
  .memo-unit-map { grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); }
  .editor-form { min-inline-size: 0; }
}


/* ---------- the reading surface: ui/reader.js -------------------------------------
 *
 * `.reading` (further up this sheet) is the GROUND — paper, serif, a 62-character measure — and it
 * predates every caller. These are the page built on it: the masthead a document opens with, the
 * flow of the body, and the foot things hang off.
 *
 * Three screens share it and each is the same KIND of thing: a chapter (views/scripture.js), a
 * sermon (views/teaching.js) and a set of minutes (views/workspace_logbook.js). What they have in
 * common is not their subject but their posture — somebody is going to be looking at this for
 * minutes rather than seconds — and that is what decides how text is set.
 *
 * `.reader-page` (module F1's lesson reader) is the same idea, hand-rolled before this shape
 * existed, and it still owns its own 46rem measure. It should migrate onto `.reading`; converging
 * it is a visible change to F1 rather than a refactor, so it is left as it is and named here so the
 * divergence is a decision somebody can find rather than one nobody knows about.
 */

.reading-page {
  border-radius: var(--radius);
  padding: 26px 22px 34px;
  display: flex; flex-direction: column; gap: 16px;
}

/* The masthead and its controls: one row where there is width for it, two where there is not. The
   DOM order is the reading order — what this is, then what you can do about it — and the wrap does
   the rest, so nothing is reversed for a screen reader to get a layout. */
.reading-top { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 12px; }
.reading-top .reading-head { flex: 1 1 20rem; }
.reading-aside { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }

.reading-head { display: flex; flex-direction: column; gap: 6px; }
/* What KIND of thing this is. Small, upper, and in paper's own grey rather than the page's — the
   feed's --muted is tuned against blue-grey and goes cold on this ground. */
.reading-eyebrow {
  font-family: var(--sans); font-size: 12px; font-weight: 650; letter-spacing: 0.05em;
  text-transform: uppercase; color: var(--ink-muted);
}
/* The serif is inherited from `.reading` deliberately: a sans heading over a serif body is two
   voices, and the whole point of this surface is that the reader knows the voice before reading a
   word. `text-wrap: balance` keeps a two-line title from leaving one word on the second. */
.reading-title { margin: 0; font-size: 30px; line-height: 1.2; font-weight: 600; text-wrap: balance; }
.reading-byline { display: flex; align-items: center; gap: 8px; font-family: var(--sans); }
.reading-meta { font-family: var(--sans); }

/* The document itself. The leading is looser than a feed card's because this is a PAGE of text
   rather than a paragraph glanced at, and `pre-wrap` is load-bearing rather than cosmetic: every
   long body on this wire is a PLAIN STRING with no markup, so its paragraph breaks are literal
   newlines. The same convention .post-body, .event-blurb and .comment-body already follow. */
.reading-body { font-size: 18px; line-height: 1.75; white-space: pre-wrap; }
.reading-body p { margin: 0 0 0.9em; }
.reading-body a { color: var(--primary); }

/* A section heading INSIDE the document. An h2 rather than a styled div, because a reading page is
   the one place in this client where heading levels are load-bearing: somebody reading twenty
   thousand characters with a screen reader navigates by heading, and a page whose only landmark is
   its title is one unbroken block to them. */
.reading-h2 {
  margin: 0; font-family: var(--sans); font-size: 13px; font-weight: 650;
  letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-muted);
}
.reading-rule { border: 0; border-block-start: 1px solid var(--paper-2); margin: 4px 0; }

/* What hangs off the end — the passages a sermon worked, the actions out of a meeting. OUTSIDE the
   measure's flow but inside the paper: a row of chips is not prose, and 62 characters is not its
   width. `--sans` because none of it is read continuously. */
.reading-footer {
  display: flex; flex-direction: column; gap: 10px;
  font-family: var(--sans); font-size: 14px;
}

/* ---------- the verse-action sheet: ui/verse_actions.js ---------------------------
 *
 * The plain dialog, which style.css already lands as a bottom sheet below 620px — one DOM, two
 * skins, ui/composer.js's rule. Rows carry a glyph and a count, which is why this is not
 * `action_sheet`'s flat list: "3 posts" beside a row is the whole reason somebody taps it.
 */
.verse-actions { display: flex; flex-direction: column; }
.verse-action {
  display: flex; align-items: center; gap: 12px; inline-size: 100%;
  padding: 13px 4px; border: 0; border-block-end: 1px solid var(--border);
  background: transparent; color: inherit; text-align: start;
  font-size: 15px; cursor: pointer; text-decoration: none;
}
.verse-action:last-child { border-block-end: 0; }
.verse-action:hover { background: var(--surface-2); }

/* ---------- G3: lifecycle records & certificates ----------------------------------
 *
 * THE CERTIFICATE IS THE MODULE. Everything else in G3 is bookkeeping so that this can be produced
 * years later and be right — which is why it is drawn on the reading surface's paper rather than on
 * a card: it is a DOCUMENT, and a document set in the app's own chrome reads as a screen about a
 * document. It is also drawn twice, here and onto a canvas in ui/certificate.js, and the two are
 * built from one list of lines so they cannot drift.
 *
 * Centred, which is the one place in this sheet that is true. A certificate is a piece of paper
 * whose subject is a NAME, and everything else on it is arranged around that name.
 */
.certificate {
  background: var(--paper); color: var(--ink);
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 30px 24px; text-align: center;
  display: flex; flex-direction: column; gap: 6px; align-items: center;
}
.certificate-house {
  font-family: var(--sans); font-size: 12px; font-weight: 650; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--ink-muted);
}
.certificate-kind { font-family: var(--serif); font-size: 21px; margin-block-start: 8px; }
.certificate-subjects { display: flex; flex-direction: column; gap: 2px; margin-block: 6px 14px; }
/* The names, largest on the page. `text-wrap: balance` because a long name breaking with one word
   on the second line looks like a mistake on a document. */
.certificate-subject { font-family: var(--serif); font-size: 28px; line-height: 1.25; text-wrap: balance; }
/* Label above value rather than beside it: the card is centred, and a two-column table inside a
   centred document is two alignments arguing. */
.certificate-rows {
  margin: 0; display: flex; flex-direction: column; gap: 12px;
  border-block-start: 1px solid var(--paper-2); padding-block-start: 16px; inline-size: 100%;
}
.certificate-rows dt {
  font-family: var(--sans); font-size: 11px; font-weight: 650; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--ink-muted);
}
.certificate-rows dd { margin: 2px 0 0; font-family: var(--serif); font-size: 17px; }
/* The NUMBER, at the foot where a document's identity goes. It is the one field that cannot be
   reconstructed from anywhere else — a certificate with no number is a picture of a certificate. */
.certificate-number {
  margin-block-start: 18px; display: flex; flex-direction: column; gap: 2px;
  font-family: var(--sans);
}
.certificate-number-value { font-size: 15px; font-weight: 650; letter-spacing: 0.04em; }
.certificate-number .muted { color: var(--ink-muted); }

.records-page { display: flex; flex-direction: column; gap: 12px; }
.record-detail { display: flex; flex-direction: column; gap: 12px; }
/* The week's names, for whoever is building Saturday's order of service. Its heading carries the
   range the SERVER answered — the method clamps at eight weeks rather than refusing — so the row is
   a title and a range side by side rather than a title alone. */
.anniversaries .row { align-items: baseline; gap: 10px; }
.anniversaries h2 { margin: 0; font-size: 15px; }

.my-records { display: flex; flex-direction: column; gap: 10px; }
.my-record { inline-size: 100%; text-align: start; border: 0; background: transparent; cursor: pointer; }
.my-record .grow { display: flex; flex-direction: column; gap: 2px; }
.my-record .chip { display: inline-flex; align-items: center; }

/* ---------- C4: the leadership logbook --------------------------------------------
 *
 * Reader-shaped, and each entry is a whole reading surface rather than a card: a set of minutes is
 * up to twenty thousand characters of prose somebody reads at length. A logbook is also SHORT —
 * twenty entries a page, a meeting a month — which is what makes a stack of readers the right
 * shape here where a register of two thousand rites wants a searchable table.
 *
 * There is deliberately NO count anywhere outside this page, no badge, and no tile. See the header
 * of views/workspace_logbook.js before adding one.
 */
.logbook-page { display: flex; flex-direction: column; gap: 12px; }
.logbook-entries { display: flex; flex-direction: column; gap: 18px; }
.logbook-attendees { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.logbook-actions { display: flex; flex-direction: column; gap: 8px; }
.logbook-composer { min-inline-size: min(30rem, 82vw); }

/* Overlapping faces, oldest behind. The negative margin is the whole trick and it needs the
   stacking to be explicit, or the first face sits on top of the second. */
.avatar-stack { display: flex; align-items: center; }
.avatar-stack > * { margin-inline-start: -6px; box-shadow: 0 0 0 2px var(--paper); border-radius: 50%; }
.avatar-stack > *:first-child { margin-inline-start: 0; }

.action-item { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
.action-item .grow { display: flex; flex-direction: column; gap: 2px; }
/* Struck through and dimmed, still listed. A ticked action is a record of something that WAS
   agreed, and hiding it would leave a board unable to answer "did we ever do that?". */
.action-item.done .strong { color: var(--muted); text-decoration: line-through; }
/* Overdue shouts, and ONLY while the item is still open — see logbook.js's is_overdue. A ticked
   item finished a week after its date is done, not late, and painting it red forever would turn a
   board's completed list into a wall of failures. */
.action-item.overdue:not(.done) .muted { color: var(--bad); }
.action-draft {
  display: flex; flex-direction: column; gap: 6px;
  padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm);
}
.my-actions { display: flex; flex-direction: column; gap: 10px; }

/* ---------- G4: the sermon & teaching library -------------------------------------
 *
 * Two postures, and the address decides which: shelves with no query, and a registry-shaped browse
 * with one. Only the shelves are here — the browse is ui/registry.js's own furniture, unchanged,
 * which is the point of having the shape.
 */
.library { display: flex; flex-direction: column; gap: 14px; }
.library-section { display: flex; flex-direction: column; gap: 8px; }
.library-section .row { align-items: flex-start; gap: 10px; }
.teaching-row .grow { display: flex; flex-direction: column; gap: 2px; }
.teaching-row .chip { flex: 0 0 auto; }

.teaching-reader .reading-body { display: flex; flex-direction: column; gap: 14px; }
/* The recording sits above the manuscript: somebody who came to listen should not scroll a page of
   text to find the control, and somebody who came to read scrolls past one element. */
.teaching-player audio, .teaching-player video { inline-size: 100%; border-radius: var(--radius-sm); }
.teaching-player video { max-block-size: 60vh; background: #000; }

/* ---------- the scripture reader, on the shared surface ---------------------------
 *
 * `.reader` (further up) is the VERSE layer and it is unchanged; what moved is the page it is set
 * on, from a card to `.reading`. The two now agree with the sermon reader by construction rather
 * than by resemblance.
 *
 * A verse is a <button> now rather than a <span>, because EVERY verse opens the action sheet — not
 * only the ones the community has posted about. A reader who found that some verses responded and
 * others did not would have learnt a rule about the feed rather than about scripture. So the button
 * has to be un-styled back into running text: a button is a block-ish inline box with a border, a
 * background, its own font and centred text, and every one of those has to go.
 */
.scripture-reader .reading-body { white-space: normal; }
.reader button.verse {
  inline-size: 100%; text-align: start; border: 0; background: transparent;
  font: inherit; color: inherit; cursor: pointer;
}
.reader button.verse.has-posts { background: color-mix(in srgb, var(--gold) 12%, transparent); }
.reader button.verse:hover { background: var(--paper-2); }
/* The verse a link arrived at. Ringed rather than filled, so it still reads as itself next to a
   `has-posts` neighbour instead of looking like a third kind of verse. */
.reader button.verse.focused { outline: 2px solid var(--primary); }

@media (max-width: 900px) {
  /* The reading page loses its inset for `.reader-page`'s reason: at 390px the page IS the column,
     and a margin only narrows the measure it exists to protect. */
  .reading-page { border-radius: 0; padding: 18px 14px 30px; }
  .reading-title { font-size: 25px; }
  .reading-body { font-size: 17px; }
  /* The controls go UNDER the title where a thumb is, which is what the DOM order already says —
     the wrap is doing it, and this only stops them sharing a cramped row on the way. */
  .reading-aside { inline-size: 100%; }
  .certificate { padding: 22px 16px; }
  .certificate-subject { font-size: 24px; }
  .logbook-composer { min-inline-size: 0; }
}

/* ---------- the Today card (ui/today.js) -------------------------------------------
 *
 * Three slots in one card, divided rather than boxed: they answer three questions about the same
 * day, and three cards would read as three unrelated surfaces stacked above the feed.
 *
 * The card is the FIRST thing in the member column and the thing a reader's eye lands on, so slot
 * one carries the only large type on it — the next prayer, the next Shabbat marker — and the other
 * two stay at body weight. A card where all three slots shouted would have no head at all. */

.today-card { padding: 0; overflow: hidden; }

.today-slot { padding: 12px 14px; border-block-start: 1px solid var(--border); }
.today-slot:first-child { border-block-start: 0; }
.today-slot[hidden] { display: none; }

.today-slot-head { display: flex; align-items: baseline; gap: 8px; margin-block-end: 6px; }
.today-slot-title {
  color: var(--faint); font-size: 11px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .06em;
}

.today-slot-body { display: flex; flex-direction: column; gap: 8px; }

/* The one line the eye is meant to land on. Baseline-aligned so the name, the clock time and the
   countdown read as one sentence rather than as three stacked facts. */
.today-headline { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.today-headline-name { font-size: 20px; font-weight: 650; letter-spacing: -.01em; }
.today-headline-time { font-size: 20px; font-weight: 500; font-variant-numeric: tabular-nums; }
.today-headline-countdown { color: var(--muted); font-size: 13px; }

.today-alt-date { color: var(--muted); font-size: 13px; }
.today-parashah { color: var(--muted); font-size: 13px; font-style: italic; }
.today-season { align-self: flex-start; }

/* The five-prayer strip. A grid rather than a flex row so the five columns are equal whatever the
   names in them are — Maghrib is twice the width of Asr, and a flex row would put the five times at
   five different offsets and stop the strip reading as a ladder of the day. */
.today-strip {
  display: grid; grid-template-columns: repeat(5, 1fr);
  gap: 2px; margin: 2px 0 0; padding: 0; list-style: none;
}
.today-strip-item {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 7px 2px; border-radius: var(--radius-sm); text-align: center;
}
/* Two states, and they say different things: PAST is dimmed because it has gone, NEXT is tinted
   because it is the one being waited for. A strip carrying only one of the two would leave a reader
   counting forward from the first name to work out where in the day they are. */
.today-strip-item.is-past { opacity: .45; }
.today-strip-item.is-next { background: var(--primary-soft); }
.today-strip-name { font-size: 11px; font-weight: 600; color: var(--muted); }
.today-strip-item.is-next .today-strip-name { color: var(--primary); }
.today-strip-time { font-size: 13px; font-variant-numeric: tabular-nums; }

/* The Shabbat pair, which is two rows rather than five columns: candle lighting and havdalah are
   usually days apart, and squeezing two of them into a five-track grid would give each a fifth of
   the width for no reason.
   A class on the strip rather than `:has(.wide)` on it. The selector would work in every browser
   this app targets, but it makes the container's layout depend on a child being matched — and the
   variant is a fact the caller already knows when it builds the list, so it may as well say so. */
.today-strip.is-pair { grid-template-columns: 1fr; gap: 4px; }
.today-strip-item.wide { flex-direction: row; justify-content: space-between; padding: 6px 10px; }
/* The day and the clock time as one group, so "Fri 8:06 PM" reads as one answer. The day is what
   stops two evenings a day apart from reading as one — see shabbat_row() in ui/today.js. */
.today-strip-when { display: flex; align-items: baseline; gap: 6px; }
.today-strip-day { font-size: 11px; font-weight: 600; color: var(--faint); }

.today-observances {
  margin: 0; padding: 0; list-style: none;
  display: flex; flex-direction: column; gap: 6px;
}
.today-observance { display: flex; align-items: center; gap: 8px; }
.today-observance-name { font-weight: 550; }

.today-upcoming { display: flex; align-items: baseline; gap: 8px; }
.today-upcoming-name { font-weight: 550; }
.today-upcoming-when { color: var(--muted); font-size: 13px; }

.today-verse-text { margin: 0; font-size: 15px; }

.today-next-event { display: flex; align-items: center; gap: 10px; color: inherit; }
.today-next-event .icon { inline-size: 18px; block-size: 18px; background: var(--muted); flex: none; }
.today-next-main { display: flex; flex-direction: column; min-inline-size: 0; }
.today-next-title { font-weight: 550; }
.today-next-when { color: var(--muted); font-size: 13px; }

.today-link { font-size: 13px; align-self: flex-start; }
.today-needs-city { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; }

/* ---------- module G1: the sacred clock (views/sacred_clock.js) ---------------------
 *
 * The full ladder of the day, where the Today card carries only the five obligatory prayers. Three
 * registers, and the distinction between them is the whole reason `notifiable` survives the trip
 * from the engine into the view: a prayer is a call, a rung of the zmanim is a marker of the day,
 * and one drawn like the other says something untrue about both. */

.sacred-ladder { margin: 0; padding: 0; list-style: none; }
.sacred-rung {
  display: flex; align-items: baseline; gap: 10px;
  padding: 10px 14px; border-block-start: 1px solid var(--border);
}
.sacred-rung:first-child { border-block-start: 0; }
.sacred-rung-name { flex: 1; min-inline-size: 0; font-weight: 550; }
.sacred-rung-time { font-variant-numeric: tabular-nums; font-size: 15px; }
.sacred-rung-countdown { color: var(--primary); font-size: 12px; }

.sacred-rung.is-past { opacity: .5; }
.sacred-rung.is-next { background: var(--primary-soft); }
.sacred-rung.is-next .sacred-rung-name { color: var(--primary); font-weight: 650; }
/* Sunrise, chatzot, tzeit: shown because somebody came here for them, set back because they are not
   a call to prayer. */
.sacred-rung.is-informational .sacred-rung-name { font-weight: 400; color: var(--muted); }
.sacred-rung.is-informational .sacred-rung-time { font-size: 14px; color: var(--muted); }

.sacred-note { margin: 10px 2px 0; }

/* ---------- the qibla (ui/qibla.js) --------------------------------------------------
 *
 * A FIXED dial, drawn as a picture of a bearing. It looks like a compass, which is exactly why the
 * note under it is not optional — see the header of ui/qibla.js. The rotations are the only computed
 * values here and they arrive from js/style.js; nothing in this block may set one. */

.qibla { display: flex; flex-wrap: wrap; align-items: center; gap: 18px; padding: 14px; }

.qibla-dial {
  position: relative; flex: none;
  inline-size: 148px; block-size: 148px;
  border: 2px solid var(--border); border-radius: 50%;
  background: var(--surface-2);
}

/* Each cardinal is a full-size layer ROTATED about the dial's centre, with its letter pushed to the
   top of it. One rotation places the mark and the letter itself stays upright — which a rotated,
   absolutely-positioned label would not. */
.qibla-cardinal { position: absolute; inset: 0; display: flex; justify-content: center; }
.qibla-cardinal-label {
  margin-block-start: 6px;
  color: var(--faint); font-size: 11px; font-weight: 700; letter-spacing: .04em;
}

/* The needle, on the same trick: a full-size layer rotated to the bearing, carrying a line from the
   centre out to the rim. The default transform-origin IS the dial's centre, which is what makes the
   rotation a bearing rather than a wobble. */
.qibla-needle { position: absolute; inset: 0; }
.qibla-needle::before {
  content: ""; position: absolute;
  inset-block-start: 12px; inset-inline-start: 50%;
  inline-size: 3px; block-size: 62px; margin-inline-start: -1.5px;
  border-radius: 2px; background: var(--primary);
}
/* The tip, so the needle reads as pointing rather than as a spoke. */
.qibla-needle::after {
  content: ""; position: absolute;
  inset-block-start: 4px; inset-inline-start: 50%;
  inline-size: 0; block-size: 0; margin-inline-start: -6px;
  border-inline: 6px solid transparent;
  border-block-end: 10px solid var(--primary);
}

.qibla-reading { flex: 1; min-inline-size: 12rem; }
.qibla-bearing { font-size: 22px; font-weight: 650; font-variant-numeric: tabular-nums; }
.qibla-note { flex-basis: 100%; margin: 0; }

/* ---------- module G2: the faith calendar (views/calendar.js) -----------------------
 *
 * A month grid with two layers on it: the member's own accepted events, and the observances of
 * their faith. They are marked DIFFERENTLY on purpose — a count for events, a dot for an observance
 * — because one is a commitment somebody made and the other is what the day IS, and a single mark
 * for both would let a busy Tuesday look like a festival. */

.calendar-card { padding: 12px; }
.calendar-month-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.calendar-month { margin: 0; font-size: 17px; font-weight: 650; }
.calendar-alt {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin-block-start: 6px; padding-block-end: 8px;
}
.calendar-alt-date { color: var(--muted); font-size: 13px; }

.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.calendar-weekdays {
  display: grid; grid-template-columns: subgrid; grid-column: 1 / -1;
  color: var(--faint); font-size: 11px; font-weight: 700; text-align: center;
  padding-block-end: 4px;
}

.calendar-cell {
  position: relative;
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1px;
  min-block-size: 44px; padding: 4px 2px;
  border: 0; border-radius: var(--radius-sm);
  background: transparent; color: inherit;
  font: inherit; cursor: pointer;
}
.calendar-cell:hover { background: var(--surface-3); }
/* The days either side of the month. Kept rather than blanked, because a member looking at the
   first week wants to see that the 31st was a Sunday. */
.calendar-cell.is-outside { opacity: .35; }
.calendar-cell-number { font-size: 14px; font-variant-numeric: tabular-nums; }

/* A day carrying accepted events. On the TILE and not on the number, so it survives a day being
   today and selected at the same time. */
.calendar-cell.is-busy { background: var(--surface-3); }
.calendar-cell.is-busy .calendar-cell-number { font-weight: 650; }
.calendar-cell-count { font-size: 10px; font-weight: 700; color: var(--primary); line-height: 1; }

.calendar-cell.is-today { box-shadow: inset 0 0 0 1.5px var(--primary); }
.calendar-cell.is-today .calendar-cell-number { color: var(--primary); font-weight: 750; }
.calendar-cell.is-selected { background: var(--primary); }
.calendar-cell.is-selected .calendar-cell-number,
.calendar-cell.is-selected .calendar-cell-count { color: #fff; }

/* The observance mark. A FEAST takes the accent and a FAST the muted tone — the same distinction
   the explainer leads with, and the reason `kind` is on the row at all. */
.calendar-cell-dot { inline-size: 4px; block-size: 4px; border-radius: 50%; background: var(--primary); }
.calendar-cell.has-fast .calendar-cell-dot { background: var(--muted); }
.calendar-cell.is-selected .calendar-cell-dot { background: #fff; }

.calendar-agenda { margin-block-start: 14px; }
.calendar-holiday {
  inline-size: 100%; text-align: start;
  border: 0; background: transparent; font: inherit; cursor: pointer;
}
.calendar-holiday .list-row-title { font-weight: 600; }
.calendar-holiday.is-fast .list-row-title { font-weight: 550; color: var(--muted); }
/* Module C1's rule, drawn: a cancelled date stays listed and is struck through, because the whole
   job of the announcement is to be seen by somebody who would otherwise turn up. */
.calendar-agenda .list-row.is-cancelled .list-row-title {
  text-decoration: line-through; color: var(--muted);
}

/* ---------- module G6: the giving calculators (ui/calculators.js) --------------------
 *
 * A form inside the giving modal. Nothing here is a screen of its own — see the head of
 * ui/calculators.js for why a calculator is a step beside the give flow and not a destination. */

.calculator { display: block; }
.calculator-privacy {
  margin: 0 0 12px; padding: 8px 10px;
  border-radius: var(--radius-sm); background: var(--surface-2);
}
.calculator-modes { display: flex; flex-wrap: wrap; gap: 6px; margin-block-end: 10px; }
.calculator-modes .chip { cursor: pointer; border: 0; font: inherit; }

/* An input with its unit welded to the end of it — a percent sign, a "g". Part of the FIELD rather
   than a word in the label, because "37.5" in a box marked Gold is ambiguous and "37.5 g" is not. */
.calculator-rate { display: flex; align-items: stretch; }
.calculator-rate input {
  flex: 1; min-inline-size: 0;
  border-start-end-radius: 0; border-end-end-radius: 0;
}
.calculator-unit {
  display: flex; align-items: center; padding-inline: 10px;
  border: 1px solid var(--border); border-inline-start: 0;
  border-start-end-radius: var(--radius-sm); border-end-end-radius: var(--radius-sm);
  background: var(--surface-2); color: var(--muted); font-size: 13px;
}

/* The echo under an amount field: what the parser actually understood, before the figure becomes
   one anybody acts on. money.js's rule — the one ambiguity no parser can settle is made VISIBLE
   rather than guessed at. Reserving its height keeps the form from jumping as it fills. */
.calculator-echo { margin-block-start: 4px; min-block-size: 1.1em; }

.calculator-result {
  margin-block-start: 14px; padding-block-start: 12px;
  border-block-start: 1px solid var(--border);
  display: flex; flex-direction: column; gap: 8px;
}
.calculator-total { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.calculator-total-label { font-weight: 550; }
.calculator-total-value { font-size: 19px; font-weight: 650; font-variant-numeric: tabular-nums; }
/* The working — net wealth, the threshold — set back from the ANSWER, so the figure a member acts
   on is the one carrying the weight. */
.calculator-total.is-quiet .calculator-total-label,
.calculator-total.is-quiet .calculator-total-value {
  color: var(--muted); font-size: 14px; font-weight: 500;
}
.calculator-give { align-self: flex-start; }

.calculator-row {
  inline-size: 100%; text-align: start;
  border: 0; background: transparent; font: inherit; cursor: pointer;
}
.give-calculator { margin-block-start: -6px; margin-block-end: 12px; }

/* ---------- low-bandwidth mode + offline scripture ------------------------------
 *
 * Two features, one section, because both are about what this app COSTS a member — one in megabytes
 * over the air, one in megabytes on the phone — and both of them are drawn as ordinary furniture
 * rather than as warnings. Nothing in here uses an error tone: the mode is working as asked, and a
 * page that apologises for doing what it was told teaches somebody to turn it off.
 *
 * All of it is logical properties and existing custom properties, so dark mode and a future RTL
 * scripture page follow for free — see the sheet header. */

/* A held-back photograph. It is a REAL CONTROL, so it gets a control's affordances: a hover state,
   a focus ring from the shared button rules, and words rather than a broken-image glyph. The tone is
   --surface-3 (the same grey a loading tile already uses) and not a dashed placeholder box, because
   the tile is not missing anything — it is waiting to be asked. */
.post-media-item.held {
  display: grid; place-items: center; gap: 6px; align-content: center;
  background: var(--surface-3); color: var(--muted);
  padding: 12px; text-align: center;
}
.post-media-item.held:hover { background: var(--tint); color: var(--tint-text); }
/* The three bucketed shapes. Only the single-image case needs them: a multi-image grid already
   pins every tile to 4/3, and count-1 is the one that relaxes to `auto` for the real picture. See
   shape_class() in js/ui/cards.js for why these are three classes and not a computed ratio. */
.post-media.count-1 .post-media-item.held.shape-tall { aspect-ratio: 3 / 4; }
.post-media.count-1 .post-media-item.held.shape-square { aspect-ratio: 1 / 1; }
.post-media.count-1 .post-media-item.held.shape-wide { aspect-ratio: 16 / 9; }
.media-held { display: contents; }
.media-held-icon { font-size: 20px; line-height: 1; opacity: .75; }
.media-held-label { font-size: 13px; font-weight: 500; }
/* The dimensions are the one hard fact the wire gives about a picture nobody has fetched, and they
   are what a member judges "is that worth a tap" from. Set back, because they are evidence and not
   the instruction. */
.media-held-dims { font-size: 12px; color: var(--faint); font-variant-numeric: tabular-nums; }

/* The standing note at the head of the feed. Quiet, one line, and it carries a button rather than a
   dismiss — see js/ui/saver_note.js on why this states rather than warns. */
.saver-note {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 14px; margin-block-end: 10px;
  background: var(--surface); border-radius: var(--radius);
  color: var(--muted); font-size: 13px;
}
.saver-note .icon { flex: none; opacity: .7; }
.saver-note-text { flex: 1 1 auto; min-inline-size: 0; }
/* The figure is what makes the trade visible, so it is the part of the line that is not grey. */
.saver-note-figure { color: var(--text); font-weight: 500; }

.saver-usage { padding-block-start: 0; }
.saver-usage .stat-grid { margin-block-start: 8px; }

/* The save/remove row on a book page, and inside a chapter that could not be fetched. Given the
   card's own ground so it reads as a thing you can act on rather than as a footnote to the grid. */
.offline-book { margin-block-start: 14px; }
.offline-progress { display: flex; flex-direction: column; gap: 6px; margin-block-start: 4px; }
.offline-progress progress {
  inline-size: 100%; block-size: 4px; border: 0; border-radius: 2px;
  background: var(--surface-3); color: var(--primary);
}
.offline-progress progress::-webkit-progress-bar { background: var(--surface-3); border-radius: 2px; }
.offline-progress progress::-webkit-progress-value { background: var(--primary); border-radius: 2px; }
.offline-progress progress::-moz-progress-bar { background: var(--primary); border-radius: 2px; }

/* The chapter that is not on the device and could not be fetched. Inside the reading measure, set
   like the page it interrupts rather than like an error panel — a member on a bus is being told a
   fact about their phone, not shown a failure. */
.reader-offline {
  padding: 18px 0; display: flex; flex-direction: column; gap: 8px;
}
.reader-offline p { margin: 0; }

/* A book already on this phone, marked in the table of contents. A dot rather than a word: the grid
   is sixty-six tiles of book names, and sixty-six "Saved" labels would drown the names. */
.book-grid .btn.saved-offline { position: relative; }
.book-grid .btn.saved-offline::after {
  content: ""; position: absolute; inset-block-start: 6px; inset-inline-end: 6px;
  inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--primary);
}

/* ---------- text off the wire picks its own direction ---------------------
 *
 * The sheet above is direction-agnostic: every rule in it is logical, so the whole app mirrors when
 * <html dir> flips. That handles the CHROME. It does nothing for the CONTENT, and the content is
 * the half this product is converting for — Arabic and Hebrew scripture, and the communities that
 * read it.
 *
 * The failure is not that Arabic looks wrong in an English page. It is that Arabic looks ALMOST
 * right. A paragraph of Arabic in an `ltr` block still runs right-to-left word by word — the bidi
 * algorithm does that much unasked — but the block's own base direction is still left-to-right, so
 * the paragraph is aligned to the left, the last line hangs off the wrong end, and every neutral
 * character that ends a sentence (the full stop, the comma, the closing bracket, the digits in a
 * verse reference) is resolved against the BLOCK's direction and lands at the far side of the line
 * from the words it belongs to. Measured on a real post body in the harness: four bidi runs instead
 * of three, two of them four-pixel slivers holding the stranded punctuation.
 *
 * `unicode-bidi: plaintext` is the fix, and it is exactly `dir="auto"` expressed in CSS: the base
 * direction of each bidi paragraph is taken from its own first strong character, the way a plain
 * text file is read. Verified against `dir="auto"` on the same string in the same container — the
 * two produce identical line boxes, and neither changes a Latin string by so much as a pixel.
 *
 * IN CSS RATHER THAN AS AN ATTRIBUTE, DELIBERATELY. `dir="auto"` would be correct too and is what
 * Android does, but here it would be a key on something like a hundred `el()` calls, every one of
 * them a place to forget it — and forgetting it is invisible to everybody who cannot read the
 * script. As a rule it is declared once, beside the `white-space: pre-wrap` that every one of these
 * classes already carries for the same reason: they are the blocks that hold prose somebody else
 * wrote.
 *
 * SO THE LIST IS THE CONVENTION, AND A NEW MODULE ADDS ITSELF TO IT. The test is the one dom.js
 * already applies for escaping: did this string come off the wire, or is it markup written in this
 * repo? Wire prose belongs here. Interface chrome does not — a button that says "Send" must follow
 * the page, not the language of the word on it.
 *
 * Note there is no `text-align` here. Browsers resolve `start` per paragraph under `plaintext`, so
 * alignment follows for free — and setting it would flatten `.post-canvas`, whose centring is the
 * whole point of a canvas post.
 */
.prewrap,
/* The feed. */
.post-body, .post-canvas, .event-blurb,
/* Conversation: a comment, a message, and the message being replied to. */
.comment-body, .bubble-text, .bubble-quote,
/* What a leader is about to send, shown back to them before they send it. These three matter more
   than most: the preview exists so somebody can check the wording, and a preview that renders the
   wording differently from the phone it lands on is not a preview. */
.announce-preview-body, .sms-preview-body, .email-preview-body,
/* Teaching (F1) and the reading surfaces (G4, C4). */
.course-blurb, .lesson-body, .reading-body, .reading-title, .reading-byline,
/* Songs (D2). One paragraph per line, so each line resolves its own direction — which is what a
   bilingual hymn sheet with a transliteration beside it actually needs. */
.song-line, .song-translit,
/* Scripture. The reason the rest of this block exists: a verse is one bidi paragraph, and its
   number is a weak character, so the heuristic looks past it to the first letter of the verse and
   sets an Arabic or Hebrew chapter right-to-left inside an app the member is running in French. */
.reader, .reader .verse,
/* The rota's note to whoever is serving. */
.serving-note {
  unicode-bidi: plaintext;
}

/* ---- and so does a formatted VALUE, for a different and worse reason -------
 *
 * The block above is about prose. This one is about the short strings that pair digits with a Latin
 * token — a time, an amount with its ISO code, a percentage with its sign, a range with its dash.
 * They are interface chrome rather than content, so the instinct is that they should simply follow
 * the page. They cannot, and the reason is that the bidi algorithm does not treat them as one
 * thing: digits are WEAK, the surrounding direction resolves them, and the Latin token beside them
 * is a strong left-to-right run that gets reordered around it.
 *
 * Measured in an `rtl` container, on the strings this app actually builds:
 *
 *     "1,200,000 XOF"   renders   "XOF 1,200,000"      the code jumps in front of the figure
 *     "8:00 AM"         renders   "AM 8:00"
 *     "+12%"            renders   "12%+"               THE SIGN DETACHES FROM ITS NUMBER
 *     "09:00 – 10:30"   renders   "10:30 – 09:00"      THE RANGE READS BACKWARDS
 *
 * The last two are not cosmetic. A trend of +12% drawn as 12%+ has lost the only character that
 * said which way attendance moved, and a meeting from nine to half past ten drawn as "10:30 – 09:00"
 * tells a member the wrong thing about their own morning in a way that looks entirely deliberate.
 * `attendance.js`'s trend_label and `scheduler.js`'s range_label are where those two are built.
 *
 * `plaintext` again, and it is the right tool rather than a reused one: it hands the string its own
 * base direction from its own first strong character, so "8:00 AM" stays "8:00 AM" and an
 * Arabic-locale "٨:٠٠ ص" stays right-to-left. Pinning `direction: ltr` would have fixed today's
 * English formatting by breaking tomorrow's Arabic.
 *
 * The test for adding a class here: does the string put digits next to a letter or a separator
 * whose ORDER carries meaning? If so it belongs here. A bare count does not — "148" is weak all the
 * way through and renders identically either way.
 */
/* Times and ranges: the scheduler's gutter, its blocks, its agenda rows, and the week heading. */
.sched-hour, .sched-block-time, .sched-row-time, .sched-agenda-day, .events-range,
/* The attendance trend — the sign case above. */
.trend,
/* Amounts, which always travel with the code that denominates them (see money.js). */
.money-amount, .money-detail-amount, .fund-amount, .budget-amount, .calculator-total-value,
.ledger-totals, .counter-tile .v,
/* Dates and times shown against a thing: an event, a notice, a gift, a booking. */
.event-when, .announce-when, .money-when, .list-row-sub,
/* A receipt's and a certificate's number, which are read back to somebody over a telephone. */
.certificate-number-value, .receipt .v {
  unicode-bidi: plaintext;
}
