/*
 * KikundiDesk — the group's desk. One stylesheet for the public door and the app.
 *
 * THE DESIGN LANGUAGE IS DASHER'S — a Bootstrap admin dashboard (themewagon.github.io/dasher). It is
 * the first reference this product has taken that is the same SHAPE as this product: a left sidebar, a
 * KPI row, cards, data tables, status chips and modals. Every earlier one was a marketing site, so its
 * language only ever fitted the door and the desk had to be argued into it.
 *
 * The language, not the template. Dasher ships Bootstrap 5, SimpleBar, Swiper, jsVectorMap, ApexCharts,
 * Choices.js and a Tabler icon webfont off a CDN — half a megabyte of JavaScript for a katibu on mobile
 * data, and against every convention this repo has ("no build step, no framework, vanilla JS in the
 * browser", "No CDN, ever"). So it is EXPRESSED here: emerald on a cool near-white ramp, 8px buttons,
 * 16px cards with no border and a soft grey-blue lift, 4px status chips, and 14px body copy, because a
 * dashboard is dense. Public Sans is self-hosted beside Inter for exactly the reason
 * lib/report-charts.js draws its own SVG: this runs on a box whose outbound access we do not control,
 * and a katibu in Dar es Salaam should not wait on fonts.googleapis.com to read what their group owes.
 *
 * The greys still carry the page and the accent still appears only where a katibu can ACT, because this
 * is a book a group's money is kept in and a dense table of shillings should read as something that
 * counts rather than something that sells. A group's money is never read off a gradient.
 *
 * NO DARK MODE, and this is a decision rather than an omission: a katibu works outdoors, in a meeting,
 * on a cheap Android with a dim screen, where a dark UI is harder to read in glare and not easier.
 *
 * Mobile first. The katibu records a loan standing in a meeting, on a phone, so the phone layout is the
 * base and the desk layout is the media query — not the other way round.
 */

/* Public Sans (SIL OFL, licence beside it in public/fonts/) is Dasher's face and the product's now.
   Inter stays: the report template and the sign-in mails still run the previous palette and inline it
   by hand from this same directory (lib/report-html.js:fontFace), so removing the file breaks a PDF. */
@font-face {
  font-family: 'Public Sans';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('./fonts/PublicSans-Variable.woff2') format('woff2');
}
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url('./fonts/Inter-Variable.woff2') format('woff2');
}

/* ══ Tokens ════════════════════════════════════════════════════════════════════════════════════════ */
:root {
  /* Colour. The accent is the ONE hue a katibu can act on — a link, a button, a focus ring, the screen
     they are on. It is never spent on decoration, which is why there is no second brand hue to reach for.
     THE ACCENT IS TWO VALUES AND THE SPLIT IS LOAD-BEARING. White on #00A76F is 3.1:1 — below AA even at
     weight 700, since the large-text exemption wants 18.66px bold and a button label is 14px. Dasher
     ships it anyway; we do not. --accent is for what sits ON light (an icon, a border, a focus ring, a
     link, a chart mark) and --accent-ink is the FILL under white text, where it reaches 5.4:1. A button
     that reads at arm's length in a meeting is not a matter of taste. */
  --accent: #00a76f;
  --accent-hover: #007867;
  --accent-ink: #007867;
  /* A real colour, not an alpha: an 8%-alpha accent goes grey on a tinted canvas, and this tint has to
     hold the active sidebar item, a chosen answer and the drop zone equally on white and on --bg-alt. */
  --accent-light: #c8fad6;
  /* Dasher's neutrals — cool, near-white, and eight steps where we use five. --muted is 4.6:1 on white
     and holds on --bg-alt; it sets every caption, sub-figure, table note and chart axis in the product,
     which is why it is checked rather than picked. (3.5:1 shipped here once and had to be fixed.) */
  --heading: #1c252e;
  --body: #454f5b;
  --muted: #637381;
  --disabled: #919eab;
  --border: #dfe3e8;
  --border-light: #f4f6f8;
  --white: #ffffff;
  --bg: #ffffff;
  --bg-alt: #f9fafb;
  --bg-dark: #1c252e;
  /* THE FILL UNDER SOMETHING THAT SITS ON A SURFACE — a plain button, a chip, a switch track, a
     banner. It is an ALPHA and not a flat grey, and that is the whole point: --bg-alt is the canvas
     the cards sit on and it is one step off white, so a flat neutral fill is invisible on a white card
     and invisible again on the canvas. The first pass of this re-skin shipped exactly that, and the
     rules screen's "Change" came out as bare text with no button under it. 16% of the grey ramp reads
     on white, on --bg-alt and on a tinted card alike. */
  --fill: rgba(145, 158, 171, 0.16);
  --fill-strong: rgba(145, 158, 171, 0.28);

  /* Meaning. Money is never decorative, so these are the only non-neutral hues in the app.
     THESE DID NOT MOVE WITH THE REST, AND THAT IS DELIBERATE. Dasher's own success is #22C55E, which is
     green — and so is its primary. A "Paid" chip beside a Save button would then be the same colour as
     the button, so the one thing on the row that is a STATE reads as a thing to press. Holding them
     still also keeps lib/report-charts.js's TINT byte-identical to the chip backgrounds below, which is
     what makes the report's coverage grid and the members table say "covered" the same way. */
  --good: #1a7a44;
  --good-bg: #e8f6ee;
  --warn: #8a5a00;
  --warn-bg: #fdf3e0;
  --bad: #c8232c;
  --bad-bg: #fdeced;

  /* Type */
  --font: 'Public Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --mono: 'SF Mono', SFMono-Regular, Consolas, Menlo, monospace;
  /* 14px BODY, WHICH IS THE DENSITY AND NOT A SHRINKING. A dashboard's job is to put a group's whole
     position in front of the katibu at once; at 17px a nine-column members table scrolls inside its own
     box on a laptop, and a table that scrolls is a table nobody reads across. Only the reading sizes
     come down — the headings and the door's display sizes below are untouched, and the form inputs keep
     their own 17px floor (see .field), because below that iOS Safari zooms the page on focus. */
  --text-xs:   clamp(0.6875rem, 0.66rem + 0.14vw, 0.75rem);
  --text-sm:   clamp(0.75rem,   0.72rem + 0.14vw, 0.8125rem);
  --text-base: clamp(0.875rem,  0.85rem + 0.12vw, 0.9375rem);
  --text-lg:   clamp(1.125rem,  1.05rem + 0.38vw, 1.25rem);
  --text-xl:   clamp(1.25rem,   1.10rem + 0.75vw, 1.5rem);
  --text-2xl:  clamp(1.5rem,    1.20rem + 1.50vw, 2rem);
  --text-3xl:  clamp(1.875rem,  1.40rem + 2.40vw, 2.75rem);
  --text-4xl:  clamp(2.25rem,   1.50rem + 3.75vw, 3.5rem);
  --text-5xl:  clamp(2.75rem,   1.80rem + 4.75vw, 4.5rem);
  --weight-regular: 400; --weight-medium: 500; --weight-semibold: 600; --weight-bold: 700;
  --leading-tight: 1.1; --leading-snug: 1.25; --leading-normal: 1.5; --leading-relaxed: 1.7;
  --tracking-tight: -0.02em; --tracking-wide: 0.02em; --tracking-wider: 0.06em;

  /* Space */
  --space-xs: 0.5rem; --space-sm: 0.75rem; --space-md: 1rem; --space-lg: 1.5rem;
  --space-xl: 2rem;   --space-2xl: 3rem;   --space-3xl: 4rem; --space-4xl: 6rem;
  /* The rhythm between the door's sections. Sized for the copy it actually carries: four one-line cards
     and three one-line steps. The old 7rem was set when each of those was a paragraph, and against short
     copy it reads as a gap rather than as breathing room. */
  --space-section: clamp(2.5rem, 1.5rem + 3vw, 4.5rem);

  /* Shape. --radius-lg is the card (Dasher's 16px) and --radius-sm is the button and the nav item
     (Dasher's 8px). --radius-full survives for the things that are genuinely round — a count badge, a
     radio, a status dot, an icon button — and for the two switch TRACKS. It is no longer what a button
     is: the pills are what made every earlier palette read as a marketing page wearing a table. */
  --radius-sm: 0.5rem; --radius-md: 0.75rem; --radius-lg: 1rem; --radius-xl: 1.5rem; --radius-full: 9999px;
  /* Dasher's lift: a 2px halo that draws the card's own edge — which is why the hairline border could
     go — over a wide, soft, downward drop. Tinted grey-blue rather than black: a neutral-black shadow
     under a cool page reads as grey plastic; the same alpha in the page's own hue reads as depth. */
  --shadow-sm: 0 1px 2px rgba(145, 158, 171, 0.16);
  --shadow-md: 0 0 2px rgba(145, 158, 171, 0.2), 0 4px 8px -2px rgba(145, 158, 171, 0.16);
  --shadow-lg: 0 0 2px rgba(145, 158, 171, 0.2), 0 20px 40px -4px rgba(145, 158, 171, 0.24);
  --shadow-card: 0 0 2px rgba(145, 158, 171, 0.2), 0 12px 24px -4px rgba(145, 158, 171, 0.12);
  --shadow-card-hover: 0 0 2px rgba(145, 158, 171, 0.2), 0 16px 32px -4px rgba(145, 158, 171, 0.24);

  /* Motion */
  --transition-fast: 150ms ease;
  --transition-base: 250ms ease;
  --transition-spring: 500ms cubic-bezier(0.22, 1, 0.36, 1);

  /* Frame */
  --header-height: 3.25rem;
  --header-bg: rgba(255, 255, 255, 0.72);
  --header-bg-scrolled: rgba(255, 255, 255, 0.92);
  --nav-width: 15rem;
  --container: 1100px;
  /* The measure for a band that is a LAYOUT rather than a column of prose. 1100px is right for reading
     down a page and far too narrow for two things standing side by side: the hero at 1100 left the copy
     ending a third of the way across the screen with the picture pinned to the viewport's own edge, and
     the eye read the space between them as a mistake. Only the hero uses this. */
  --container-wide: 1480px;
  --container-narrow: 720px;
  --pad: clamp(1rem, 0.75rem + 1.5vw, 2rem);

  --z-sticky: 200; --z-overlay: 300; --z-modal: 400; --z-toast: 500;
}

/* ══ Base ══════════════════════════════════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }

body {
  font-family: var(--font);
  font-size: var(--text-base);
  line-height: var(--leading-normal);
  color: var(--body);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

h1, h2, h3, h4, h5, h6 {
  margin: 0;
  color: var(--heading);
  font-weight: var(--weight-semibold);
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}
h1 { font-size: var(--text-3xl); }
h2 { font-size: var(--text-2xl); }
h3 { font-size: var(--text-lg); }
h4 { font-size: var(--text-base); }
p  { margin: 0 0 var(--space-md); }
p:last-child { margin-bottom: 0; }

a { color: var(--accent); text-decoration: none; transition: color var(--transition-fast); }
a:hover { color: var(--accent-hover); }

button, input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
button { cursor: pointer; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-sm); }

.muted     { color: var(--muted); }
.small     { font-size: var(--text-sm); }
.mono      { font-family: var(--mono); font-size: var(--text-sm); letter-spacing: 0; }
.num       { font-variant-numeric: tabular-nums; }   /* money lines up in a column */
.center    { text-align: center; }
.right     { text-align: right; }
.hide      { display: none !important; }
/* `.btn` sets an explicit `display: inline-flex`, and ANY author `display` beats the UA stylesheet's
   rule for the `hidden` attribute — so `signout.hidden = true` left a Sign out button sitting on the
   page. It was fixed once, locally, in the console's own <style>; the bug belongs to every `.btn` in
   the product, so the fix belongs here. */
[hidden]   { display: none !important; }
/* A footnote under a table or a card: why a screen does what it does, or what it deliberately will not.
   Prose, so it keeps a measure even where the table above it does not. Promoted out of the console's
   own block, where it had four call sites and the same declarations as `.card > .sub` beside it. */
.note      { color: var(--muted); font-size: var(--text-sm); margin-top: var(--space-md); max-width: 78ch; }
.stack > * + * { margin-top: var(--space-md); }
.row       { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.row .spacer { flex: 1 1 auto; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ══ Buttons ═══════════════════════════════════════════════════════════════════════════════════════ */
/* A BUTTON IS A RECTANGLE WITH SOFT CORNERS, NOT A PILL — 8px, weight 700, and the padding a dashboard
   uses rather than the padding a hero uses. This is the single biggest change of the re-skin and it is
   on every screen: a pill says "press me, this is the one thing on the page", which is true of a landing
   page's one call to action and false of a members table with a button on every row. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--space-xs);
  padding: 0.5rem 0.9rem;
  font-size: var(--text-sm);
  font-weight: var(--weight-bold);
  line-height: 1.4;
  color: var(--heading);
  /* --fill, never --bg-alt: see the token. On a white card a flat near-white button is not a button. */
  background: var(--fill);
  border: none;
  border-radius: var(--radius-sm);
  transition: background var(--transition-base), color var(--transition-base),
              transform var(--transition-base), box-shadow var(--transition-base);
  white-space: nowrap;
}
.btn:hover { background: var(--fill-strong); }
.btn:active { transform: scale(0.98); }
/* --disabled rather than an opacity: a 45%-opacity label over a tinted card is a different grey on each
   card it sits on, and one of those greys is always the wrong one. */
.btn:disabled { background: var(--border-light); color: var(--disabled); pointer-events: none; box-shadow: none; }

/* --accent-ink, NOT --accent: white on #00A76F is 3.1:1. See the token block. */
.btn.primary { background: var(--accent-ink); color: var(--white); }
.btn.primary:hover { background: #006c5a; box-shadow: var(--shadow-md); }
.btn.secondary { background: transparent; color: var(--accent-ink); box-shadow: inset 0 0 0 1.5px var(--accent); }
.btn.secondary:hover { background: var(--accent-ink); color: var(--white); box-shadow: none; }
/* GHOST IS FOR DISMISSING, AND ONLY FOR DISMISSING (2026-08-12). It is --body on transparent, which is
   the same colour as `.help`, `.sub` and an ordinary table cell — so at rest it is not distinguishable
   from the text around it. That is exactly right for Cancel, which sits in a `.form-actions` row beside
   a filled primary: the row itself says these are controls, and the one that throws work away should be
   the quieter of the two. It is exactly wrong anywhere the button has to be FOUND. Ghost was carrying
   both jobs, so "Change" read as a table cell, "Cancel this entry" read as data in a row of data, and
   "Forgot your password?" read as a sentence. Use `.btn` (the grey fill) for an action on a card or in a
   row, and `.btn.link` below for a link in prose. */
.btn.ghost { background: transparent; color: var(--body); }
.btn.ghost:hover { background: var(--fill); color: var(--heading); }
/* A BUTTON THAT IS REALLY A LINK — "Forgot your password?", "Back", "Send it again". Not a duplicate of
   ghost: it is the half of ghost's old job that ghost was failing at. The colour is the one `a` already
   uses two rules above, so a link that happens to be a <button> because it runs script instead of
   navigating does not have to look like a different thing. --accent-ink, not --accent: this is text, and
   white-background text wants the 5.4:1 of the darker token. Underlined on hover rather than at rest,
   because these sit under a form where a permanent underline competes with the field borders. */
.btn.link { background: transparent; color: var(--accent-ink); font-weight: var(--weight-semibold); }
.btn.link:hover { background: transparent; color: var(--accent-hover); text-decoration: underline; }
.btn.danger { background: transparent; color: var(--bad); box-shadow: inset 0 0 0 1.5px rgba(200, 35, 44, 0.35); }
.btn.danger:hover { background: var(--bad); color: var(--white); box-shadow: none; }
.btn.lg { padding: 0.7rem 1.35rem; font-size: var(--text-base); }
.btn.sm { padding: 0.35rem 0.7rem; font-size: var(--text-xs); }
.btn.block { display: flex; width: 100%; }

/* ══ The shell ═════════════════════════════════════════════════════════════════════════════════════ */
.topbar {
  position: fixed; top: 0; left: 0; right: 0; z-index: var(--z-sticky);
  height: var(--header-height);
  display: flex; align-items: center; gap: var(--space-sm);
  padding: 0 var(--pad);
  background: var(--header-bg);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  box-shadow: 0 1px 0 rgba(0, 0, 0, 0.07);
}
/* There is no mark beside the name: the wordmark IS the brand, so it carries the weight on its own. */
.topbar .brand { display: flex; align-items: center; gap: 0.55rem; min-width: 0; }
.topbar .brand b {
  font-size: var(--text-base); font-weight: var(--weight-bold); color: var(--heading);
  letter-spacing: -0.02em; white-space: nowrap;
  overflow: hidden; text-overflow: ellipsis;
}
/* The second half carries the accent, which is what makes the name a MARK rather than a line of bold
   text — and it is the same two-tone the film's end card signs off with, so the video and the site are
   recognisably one thing. --accent-ink, not --accent: this is ink on a near-white bar, and the darker of
   the two accent values is the one that holds its contrast at 14px. */
.topbar .brand b .mark { color: var(--accent-ink); }
.topbar .spacer { flex: 1 1 auto; }

/* The hamburger only exists on a phone; the nav is always present on a desk. */
.navtoggle {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.1rem; height: 2.1rem; padding: 0;
  background: transparent; border: none; border-radius: var(--radius-full);
  color: var(--heading);
}
.navtoggle:hover { background: rgba(0, 0, 0, 0.05); }

/* Language is a two-state switch, not a dropdown: there are exactly two languages and both are
   first-class. The track keeps 3px inside a 12px corner so the chip's 8px sits concentrically in it —
   the same geometry as .tabs and .choices.pair.switch, which are the same idiom and move together. */
.langpick { display: inline-flex; background: var(--fill); border-radius: var(--radius-md); padding: 3px; }
.langpick button {
  border: none; background: transparent; color: var(--muted);
  font-size: var(--text-xs); font-weight: var(--weight-semibold);
  padding: 0.3rem 0.7rem; border-radius: var(--radius-sm);
  transition: background var(--transition-fast), color var(--transition-fast);
}
.langpick button.on { background: var(--white); color: var(--heading); box-shadow: var(--shadow-sm); }

/* WHO IS SIGNED IN, beside the way out — the desk and the console both, which is why it is here rather
   than in the console's own <style> where it used to live alone. Styled by class and found by id, the
   same pair `.langpick` uses.
   Hidden on a phone. That bar already carries the menu button, the group's name, the language pair and
   Sign out, and of those the name is the one that can go: Sign out already says somebody is signed in,
   and the desk only ever has one person in it at a time. */
.whoami { display: none; max-width: 14rem; font-size: var(--text-sm); color: var(--muted);
          overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* THE DESK IS A CANVAS WITH CARDS ON IT; THE DOOR IS A PAGE AND KEEPS ITS WHITE.
   White cards on a white page are not cards — they are a sheet with hairlines ruled across it, and a
   katibu scanning for "where does this question end" has nothing to find. The tint goes HERE and not on
   `body`, because the public door shares that body and its sections are painted white on purpose. */
.app { display: block; padding-top: var(--header-height); background: var(--bg-alt); min-height: 100vh; }

/* READING A MONTH GONE BY. The banner says which month and how to get back, but a banner lives inside
   `#view` and scrolls away — a katibu halfway down a forty-row members table would then have no sign at
   all that the figures are from June 2025. A whole-page state belongs on the shell, so the page keeps a
   strip under the topbar for as long as the desk is standing in the past. Both, not one. */
body.kd-past .app { box-shadow: inset 0 3px 0 var(--accent); }

/* A COLUMN, so the footer can be pushed to the bottom by `margin-top: auto` rather than by whatever
   happens to be above it. `overflow-y` stays on this box: a flex column scrolls exactly as a block does,
   and the two ends of the menu are what a katibu reaches for. */
.nav {
  position: fixed; top: var(--header-height); bottom: 0; left: 0; width: min(84vw, var(--nav-width));
  z-index: var(--z-modal);
  display: flex; flex-direction: column;
  padding: var(--space-sm) var(--space-sm) var(--space-lg);
  background: var(--white);
  border-right: 1px solid var(--border-light);
  overflow-y: auto;
  transform: translateX(-100%);
  transition: transform var(--transition-spring);
}
.nav.open { transform: translateX(0); box-shadow: 8px 0 30px rgba(0, 0, 0, 0.15); }
/* THE GLYPH LEADS AND THE WORD CARRIES. Three columns — icon, label, badge — with the icon a fixed
   size so ten labels start on one vertical line however long the words are; Kiswahili's are half again
   as long as English's and used to sit at ten different indents behind nothing at all.
   The relative position is for the active rail below, which is drawn INSIDE the row's own box. */
.nav a, .nav .nav-action {
  position: relative;
  display: flex; align-items: center; gap: 0.65rem;
  width: 100%; padding: 0.55rem 0.7rem; margin-bottom: 1px;
  background: none; border: none; text-align: left;
  color: var(--body);
  font-size: var(--text-sm); font-weight: var(--weight-medium);
  border-radius: var(--radius-sm);
  transition: background var(--transition-fast), color var(--transition-fast);
}
/* The icon recedes: it is a marker, not the meaning, and a menu of ten glyphs at full text colour reads
   as ten things shouting. `flex: none` because an SVG in a flex row is otherwise squashed by a long label. */
.nav a .icon, .nav .nav-action .icon { flex: none; color: var(--disabled); transition: color var(--transition-fast); }
/* One line, cut rather than wrapped. A menu item that is two lines tall breaks the rhythm of every item
   under it, and the full word is already the link's own text for anything that reads it aloud. */
.nav a .lbl, .nav .nav-action .lbl {
  flex: 1 1 auto; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav a:hover, .nav .nav-action:hover { background: var(--border-light); color: var(--heading); }
.nav a:hover .icon, .nav .nav-action:hover .icon { color: var(--body); }
/* Where the katibu IS. The tint is solid and the text is the darker accent, so the item reads at a
   glance and still passes AA — the reverse (white on the accent) is the 3.1:1 case the tokens warn about.
   The rail is an INSET SHADOW and not a border: a border on the active state alone would shift the row's
   text by its own width the moment a link became current, so every navigation would nudge the menu. */
.nav a.active {
  background: var(--accent-light); color: var(--accent-ink); font-weight: var(--weight-bold);
  box-shadow: inset 3px 0 0 var(--accent);
}
.nav a.active .icon { color: var(--accent-ink); }
.nav a .count {
  flex: none; min-width: 1.3rem; padding: 0 0.35rem; height: 1.3rem;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--bad); color: var(--white);
  font-size: var(--text-xs); font-weight: var(--weight-bold);
  border-radius: var(--radius-full);
}
.nav a .count:empty, .nav a .count.zero { display: none; }
/* The desk is grouped in the order the work happens — the two screens opened most, then the monthly job,
   then what a group agrees once. The headings are captions doing a caption's job; only the spacing around
   them is their own. Stored in sentence case: the shouting is a formatting decision and belongs here,
   not in the language bundles.
   A FOLDING ONE IS A BUTTON, so it needs the whole reset — nothing in `.nav` styled a button before this,
   because until now the sidebar was links only. */
.nav .nav-heading {
  display: flex; align-items: center; gap: var(--space-xs);
  width: 100%; padding: var(--space-md) 0.7rem 0.3rem;
  margin-bottom: 0.15rem;
  background: none; border: none; text-align: left;
  font-size: var(--text-xs); font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--muted);
}
button.nav-heading:hover { color: var(--heading); }
/* Pushed to the far edge so the two chevrons line up with each other and with the badges above them. */
.nav .nav-heading .chev {
  display: inline-flex; margin-left: auto;
  transition: transform var(--transition-fast);
}
/* Open points down, shut points along the way it would open. The state is carried by `hidden` on the
   group; this is the only thing that moves, so reduced motion costs nothing but the turn. */
.nav .nav-heading.shut .chev { transform: rotate(-90deg); }
/* An ungrouped run carries no heading of its own, so the gap is what separates it — without this the
   Ask button reads as the last item of Settings. */
.nav .nav-group + .nav-group { margin-top: var(--space-md); }
/* The one control in the menu that goes nowhere: it opens a sheet over whatever screen you are on. It is
   drawn as a link is, because a katibu should not have to learn that this one is a different kind of
   thing — but it is a <button>, because it does not have an address. It shares the whole of the link's
   own rule above; only the seam that says "this one leaves the menu" is its own. */
.nav .nav-actions {
  margin-top: var(--space-md); padding-top: var(--space-md);
  border-top: 1px solid var(--border-light);
}
/* Pushed to the bottom of the column, so a short menu does not leave the footer floating in the middle
   of an empty sidebar. `auto` and not a fixed gap: the menu is eleven items on one group and two on
   another, and the footer belongs at the end of the box either way. */
.nav .foot { margin-top: auto; padding: var(--space-lg) 0.7rem 0; font-size: var(--text-xs); color: var(--muted); line-height: var(--leading-snug); }

.scrim {
  position: fixed; inset: 0; z-index: var(--z-overlay);
  background: rgba(0, 0, 0, 0.3);
  opacity: 0; pointer-events: none;
  transition: opacity var(--transition-base);
}
.scrim.show { opacity: 1; pointer-events: auto; }

.content { padding: var(--space-lg) var(--pad) var(--space-4xl); max-width: var(--container); }
.content > header { margin-bottom: var(--space-lg); }
.content > header h1 { font-size: var(--text-2xl); }
.content > header .sub { color: var(--muted); font-size: var(--text-sm); margin-top: 0.25rem; }

/* ══ Cards, KPIs, panels ═══════════════════════════════════════════════════════════════════════════ */
/* NO BORDER. The hairline was there because a soft drop shadow alone leaves a card's top edge undrawn
   on a flattening screen; Dasher's lift solves that inside the shadow itself — a 2px halo at 20% draws
   the whole edge, and the wide 24px drop does the lifting. One thing doing two jobs, so the border goes. */
.card {
  background: var(--white);
  border: none;
  border-radius: var(--radius-lg);
  padding: var(--space-lg);
  box-shadow: var(--shadow-card);
}
.card + .card { margin-top: var(--space-md); }
.card > h3 { margin-bottom: 0.25rem; }
.card > .sub { color: var(--muted); font-size: var(--text-sm); margin-bottom: var(--space-md); }
.card.tap { transition: transform var(--transition-spring), box-shadow var(--transition-spring); }
.card.tap:hover { transform: translateY(-3px); box-shadow: var(--shadow-card-hover); }

.kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-sm); }
/* FIVE TILES NEVER DIVIDE INTO FOUR COLUMNS. Home's strip was six with the lead figure among them; "The
   group is worth" is now the donut card above it, so five is what is left — and five in the four-column
   grid the other screens use comes out 4 + 1, the widest piece of dead space on the page. Two, three and
   five are each exact at their own width.
   The class is set from the COUNT, not from the screen, so Members' strip of four keeps the ordinary
   grid and needs to know nothing about this. */
.kpis.five { grid-template-columns: repeat(2, minmax(0, 1fr)); }
/* A KPI is rendered as a direct child of the view on Home, Reports, Rules and the wizard's own check —
   that is, straight onto the canvas rather than inside a card. It was tinted to stand out against a
   white page; on a tinted canvas that same fill makes it disappear, so it lifts instead of sinking.
   `.accent` and `.alert` carry their own fill and are already loud enough to need neither. */
/* ── THE TILE'S OWN HUE ──
   `--tile` is what the whole strip is coloured off, and it arrives from the SERVER: report-model.js puts
   a validated SERIES colour on each KPI and kikundi-portal.js forwards it onto this custom property
   without reading it. So the browser still chooses no colour — every USE of it is one of the rules below.

   The default is the accent, which is what a tile with no hue of its own gets: the Members strip, the
   Rules summary, the console's four counts, and any tile the model ships without a `fill`. Nothing
   needs a branch, and a tile that predates this change draws correctly.

   WHAT IT MAY AND MAY NOT COLOUR. It marks the glyph, the leading edge and the trail — the tile's
   furniture. It is deliberately nowhere near `.n`: `SERIES[1]` is about 3.3:1 on white and these figures
   are 20-24px at weight 600, which is UNDER the large-text exemption (18.66px bold). A group's money is
   read off ink, never off a decoration; that rule has not moved, it has only stopped being read as "a
   tile may not say what it is about". */
.kpi {
  --tile: var(--accent);
  background: var(--white);
  border: none;
  border-radius: var(--radius-lg);
  padding: var(--space-md);
  /* The leading edge is INSET rather than a border, so it costs the tile no width and cannot push the
     padding off-centre — the same reason `.card` draws its own edge inside the shadow instead of adding
     a hairline. It rides on the existing lift, which must stay second or it paints over it. */
  box-shadow: inset 3px 0 0 0 var(--tile), var(--shadow-card);
}
/* The label and its glyph are one row, so the tile opens with what it is ABOUT and the figure lands
   under a mark rather than under a lone grey caption. A tile with no glyph collapses to the label alone
   and looks exactly as it did before — which is what let the three strips take theirs one at a time. */
/* THE MIN-HEIGHT IS WHAT KEEPS THE FIGURES ON ONE LINE ACROSS THE STRIP. "Total saved by members" wraps
   to two lines where "Members" does not, and without a floor here that one tile's figure sits 15px below
   its four neighbours' — which in a row of KPIs reads as a mistake rather than as a longer label. 1.9rem
   is two lines of --text-xs at --leading-snug, which is also just over the 1.75rem chip, so a one-line
   label and a two-line one both land on the same baseline and every tile below them starts level.
   Kiswahili is the case this is actually for: its labels run half again as long. */
.kpi .head { display: flex; align-items: center; gap: 0.45rem; min-width: 0; min-height: 1.9rem; }
/* A WASH DERIVED FROM THE HUE, NEVER A SECOND HEX. There is no light companion to SERIES anywhere —
   TINT is the coverage grid's and is validated for a bare square with nothing written in it, which this
   is not — and inventing one here would be a colour that was never checked against the other three.
   `color-mix` makes it out of the hue the server already sent. The `--fill` line above it is the
   fallback a browser without color-mix keeps, and a grey chip with a coloured glyph is still a chip. */
.kpi .ic {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 1.75rem; height: 1.75rem;
  border-radius: var(--radius-md);
  color: var(--tile);
  background: var(--fill);
  background: color-mix(in srgb, var(--tile) 14%, var(--white));
}
.kpi .l {
  font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--muted);
  letter-spacing: var(--tracking-wide);
  /* It shares the row with the glyph now, so it has to be allowed to give ground: "Total saved by
     members" in Kiswahili is longer again, and a label that cannot wrap would push the glyph out. */
  min-width: 0;
}
/* NO tabular-nums, and the absence is deliberate. Tabular gives every digit the width of a `0`, which is
   what makes a COLUMN of figures line up — and what makes a standalone one read loose at display size:
   "121" comes out spaced like a serial number. These tiles are not a column; each figure has its own
   label above it and nothing beneath it to align with. It stays where it earns its keep: td.num (nine
   columns that must line up), .c-value (a column down a chart's right edge), and .kpi .delta b.
   --tracking-tight stays, and is what keeps proportional digits from looking airy. */
.kpi .n {
  margin-top: 0.35rem;
  font-size: var(--text-xl); font-weight: var(--weight-semibold); color: var(--heading);
  letter-spacing: var(--tracking-tight);
}
.kpi .s { margin-top: 0.2rem; font-size: var(--text-xs); color: var(--muted); }
/* THE HEADLINE TILE IS NOT A DIFFERENT OBJECT, IT IS THE SAME OBJECT WITH THE FIGURE THAT MATTERS.
   It used to be filled with --heading — a black slab in a row of white cards, which read as a separate
   component that had wandered into the strip rather than as the first among six equals. What the group
   is worth is still marked, and marked where the marking belongs: on the number. */
.kpi.accent .n { color: var(--accent-ink); }
/* The alert tile KEEPS its tint, and that is not an inconsistency. This one is not decoration — it is
   the status vocabulary the chips and the banners already use, saying there is something here for the
   meeting to act on. Colour that carries meaning stays; colour that carried only emphasis went.
   A STATUS OUTRANKS A SUBJECT. `--tile` is what a tile is ABOUT and this is what it is telling the
   meeting to DO, so the status takes the property over rather than fighting it in three separate rules:
   the edge, the glyph chip and the trail all follow in one line, and there is only ever one mechanism
   deciding what colour a tile is. report-model.js ships no `fill` on `behind` for the same reason. */
.kpi.alert { --tile: var(--bad); background: var(--bad-bg); }
.kpi.alert .n { color: var(--bad); }
/* On the tinted alert ground the derived wash is a red on a red and reads as a smudge; the white the
   other tiles' chips are mixed with is not the ground here. It takes the page's own neutral fill, which
   is an alpha and therefore correct on any ground — which is exactly what `--fill` exists for. */
.kpi.alert .ic { background: var(--fill); }

/* ══ Charts ════════════════════════════════════════════════════════════════════════════════════════ */
/* Drawn in the browser from the numbers the desk ships (public/kikundi/kikundi-charts.js), never as an
   image and never by a library.
   THIS USED TO SAY there is no resize listener anywhere in the product, and it was true — because the
   charts did not fit. Four of them were drawn at constants of 760 to 820 units and left to scroll, which
   on the phone a katibu holds in a meeting showed about 40% of each one. They are now painted at the
   width their box actually has (chartCard + one shared ResizeObserver in kikundi-portal.js), at a scale
   of exactly 1, so an 11px label is still 11px — which is the thing frame() has always refused to give
   up and the reason stretching was never the answer. */
/* Its OWN size, never the card's. An SVG scales its text along with everything else, so a 320-unit chart
   stretched across a 1000px card renders 11px axis labels at 33px. See frame() in kikundi-charts.js. */
.chart-svg { display: block; overflow: visible; max-width: none; }
.chart-svg.fluid { width: 100%; height: auto; }
.chart-svg:not(.fluid) { margin-inline: auto; }
/* So a wide chart scrolls inside its own box, exactly as a wide table does — and the page never does.
   With charts painted at their box's own width this is now the LAST RESORT rather than the ordinary
   case: it catches two years of months and forty members, not an ordinary year on a phone. */
.chart-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin-top: var(--space-sm); }
/* AND WHEN IT IS THE LAST RESORT, IT SAYS SO. A card that simply ended mid-chart with nothing suggesting
   a swipe would do anything is how a katibu concluded the chart was broken rather than continued. The
   fade is painted on the wrap itself and not with a pseudo-element, because the wrap SCROLLS and a
   pseudo-element inside it would scroll away with the content it is marking. `.end` is set by
   markChartScroll once the reader reaches the right-hand edge — a fade still promising more when there
   is none is the same lie in the other direction.
   The mask, not a gradient overlay: a chart card is white and the canvas behind it is tinted, so an
   overlay would need to know which ground it is on and a mask does not. */
.chart-wrap.more {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2.5rem), transparent 100%);
  mask-image: linear-gradient(to right, #000 calc(100% - 2.5rem), transparent 100%);
}
.chart-wrap.more.end { -webkit-mask-image: none; mask-image: none; }
.chart-svg .c-axis  { font-size: 11px; fill: var(--muted); font-family: var(--font); }
.chart-svg .c-label { font-size: 12px; fill: var(--heading); font-family: var(--font); }
.chart-svg .c-value { font-size: 12px; fill: var(--heading); font-family: var(--font); font-weight: 600; font-variant-numeric: tabular-nums; }

/* The legend is the chart's text alternative as much as its key, so it is never smaller than a caption
   and never lighter than --body. Reading the figures instead of the picture must be a real option. */
.chart-legend { display: flex; flex-wrap: wrap; gap: var(--space-sm) var(--space-md); margin-top: var(--space-sm); font-size: var(--text-sm); color: var(--body); }
.chart-legend .key { display: inline-flex; align-items: center; gap: 0.4rem; }
/* The hairline is the same argument the KPI split keys make: a near-white swatch on a white card is
   simply not there, and the coverage legend now carries three of them. */
.chart-legend .swatch {
  width: 0.7rem; height: 0.7rem; border-radius: 3px; flex: none;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.07);
}
/* A TEXTURE CANNOT BE A BACKGROUND COLOUR, and `url(#kd-hatch)` is not a background-image either — an
   SVG fragment reference resolves to nothing in HTML CSS, so the swatch rendered as an empty box. It has
   never been seen because the only hatched chart on this desk (the reasons bars) carries no legend at
   all; a donut names its slices ONLY in the legend, so this is where it would first have been read as
   nothing at all.
   Built from the same two TOKENS hatchDefs uses, at the same 45deg — copying report-charts.js's hexes
   here would hard-code a validated print colour in the browser, which the palette test refuses. */
.chart-legend .swatch.hatch {
  background: repeating-linear-gradient(45deg, var(--bg-alt) 0 3px, var(--muted) 3px 5px);
}
.chart-legend b { color: var(--heading); font-variant-numeric: tabular-nums; }

/* They draw themselves once, on the same easing as everything else, and then hold still.
   ONCE MEANS ONCE. A chart is repainted whenever its box changes width, which during a window drag is
   every frame — and every repaint is new nodes, so the entrance would run again and again while the
   reader is trying to read it. The entrance belongs to the chart ARRIVING, not to the window moving, so
   `repainted` is set by the second paint onwards (see repaintChart) and stops it. */
.chart-svg .bar { transform-box: fill-box; animation: kd-grow 600ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.chart-wrap.repainted .chart-svg .bar,
.chart-wrap.repainted .chart-svg .spark-line { animation: none; }
.chart-svg .bar-up   { transform-origin: 50% 100%; }
.chart-svg .bar-down { transform-origin: 50% 0%; }
.chart-svg .bar-row  { transform-origin: 0% 50%; animation-name: kd-grow-x; }
/* A stack's segments FADE rather than growing. `.bar-row`'s scaleX is anchored to the row's left edge, so
   applied per segment every one of them would grow out of the member's name and slide over its
   neighbours on the way to its place. What a stack has to look like is one bar arriving. */
.chart-svg .bar-seg  { animation-name: kd-fade; }
/* A band ladder fades in for the same reason a stack does — the rungs sit side by side along one axis, so
   growing each from its own left edge would slide them over their neighbours on the way to their places.
   The gap marker fades with them: it is a rung of the ladder, not an annotation over it. */
.chart-svg .band-seg { animation: kd-fade 400ms ease-out both; }
.chart-svg .band-gap { animation: kd-fade 400ms ease-out both; }
.chart-svg .cell { animation: kd-fade 400ms ease-out both; }
/* A month nobody owes for yet is quiet, not empty: it still has to read as part of the rail. */
/* FOUR WAYS A CELL CAN BE EMPTY, AND THE EDGE IS WHAT TELLS THEM APART. The fills are three near-whites
   about 1.05:1 from each other, which on a phone in sunlight is no difference at all — so `not_collected`
   (a month the group's own rules skip, which is an ANSWER) carries a visible edge and a dash drawn by
   `coverageRail`, `not_due` a faint one, and `not_member` none whatever, so absence reads as absence
   rather than as a paler shade. Renamed from `.cell-future` with the state it styles. */
/* The edges moved to the server with the fills (see `coverageYear`'s `ink`), because two places deciding
   one cell's colour is how the rail and the printed grid drift apart. */
.chart-svg .cell-mark          { opacity: 0.75; }
.chart-svg .rail-now { opacity: 0.7; }
.chart-svg .spark-line { stroke-dasharray: 1; animation: kd-draw 900ms cubic-bezier(0.33, 0, 0.2, 1) both; }
@keyframes kd-grow-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes kd-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes kd-draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@keyframes kd-grow { from { transform: scaleY(0); } to { transform: scaleY(1); } }

/* ── the KPI tile's trail ── */
/* Stacked: six tiles across a laptop are about 150px wide, which is not enough for a sparkline AND a
   sentence side by side. The tile also has to CLIP — a chart is the one child that can be wider than
   its parent, and one that escapes takes the whole page sideways with it. */
.kpi { overflow: hidden; }
/* `--tile` rather than a flat accent: `sparkline()` strokes in `currentColor` by design and is handed no
   fill, so this one line is what tints every trail in the strip — the trail under "Out on loan" is drawn
   in the same blue as the slice of the donut above it that IS that figure. */
.kpi .trend { margin-top: var(--space-sm); color: var(--tile); }
.kpi .trend .chart-svg { margin-top: 0.15rem; }
/* --muted and no status hue, on purpose. A fall in money in hand because the group agreed a loan is not
   bad news, and a red figure against it would tell a meeting something untrue about its own decision. */
/* It WRAPS rather than ellipsizing. "ikilinganishwa na mwezi uliopita" is three times the length of
   "on last month" and was being cut mid-word in every Kiswahili tile; a second line costs nothing here,
   and the grid keeps the row of tiles the same height by itself. */
.kpi .delta { display: block; font-size: var(--text-xs); color: var(--muted); line-height: var(--leading-snug); }
.kpi .delta b { font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; color: var(--body); }
/* THE FIGURE GETS `.tag`'S SHAPE, AND DELIBERATELY NOT `.tag` ITSELF. It is the same object — a small
   fact on a ground of its own — and the standing chips wearing that shape sit three inches below this
   strip, so borrowing the shape is what stops the two reading as two different products. What it must
   NOT borrow is the class, because `.tag`'s tones are a status vocabulary and this carries no verdict:
   the arrow is a direction, and money in hand falling because the group agreed a loan is not bad news.
   Neutral ground, ordinary ink, in every tile including the red one. */
.kpi .delta b.chip,
.card.worth .delta b.chip {
  display: inline-block;
  padding: 0.1rem 0.35rem;
  border-radius: 4px;
  background: var(--fill);
  color: var(--body);
}
/* The line is coloured by its SUBJECT, not by its direction. Arrears are drawn in the arrears colour
   whichever way they moved — a group that went from five behind to four sees a red line FALLING, which
   is good news drawn in the colour of the thing it is about. Colouring by direction is the trap: it
   would paint that same fall green here and paint a loan the meeting agreed red on the tile beside it.
   That is now what `--tile` means for every tile in the strip and not a special case for this one, so
   the rule that used to stand here is `.kpi.alert { --tile: var(--bad); }` above. */
/* On a phone the tiles are two across and there is even less room: the words go, the figure stays. */
@media (max-width: 34rem) {
  .kpi .delta .since { display: none; }
  /* The coverage legend names five states. At a phone's width, on the design system's ordinary gaps, it
     wraps to three lines under a chart it is nearly as tall as — and it is the chart's TEXT ALTERNATIVE,
     so it can neither be shortened nor dropped. Tighter gaps take it to two. */
  .chart-legend { gap: var(--space-xs) var(--space-sm); }
}

/* ── this month's collection ── */
/* HOME'S CARDS, TWO ACROSS WHERE THERE IS ROOM — one column here, see the 64rem rule.
   `.card + .card`'s top margin is the single-column rhythm and would become a stray gap between grid
   rows, so it is cancelled inside and the gap does the spacing for both axes. */
.home-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-md); margin-top: var(--space-md); }
.home-grid > .card + .card { margin-top: 0; }
/* ── the bands ──
   Home was seven cards of identical weight in one column: a list rather than a document, with nowhere
   for the eye to enter and nothing saying what it was looking at. They are grouped now, in the order a
   meeting asks its questions — how is the group doing, where has the money gone, where does each member
   stand — and the heading is the MENU'S own, character for character. "Here is a group of things" is
   something this product already knows how to say (`.nav .nav-heading`), and a second way of saying it
   would be a private vocabulary beside an existing one — the bug this codebase keeps finding in its own
   console. No new token: text-xs, semibold, tracking-wider, uppercase, muted. */
.home-band + .home-band { margin-top: var(--space-xl); }
.home-band > h2 {
  font-size: var(--text-xs); font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--muted);
  display: flex; align-items: center; gap: 0.5rem;
}
/* A SHORT RULE, NOT A GLYPH, and the difference is the point. Every other heading on this desk that
   carries a mark carries the ICON of the thing it names — `header()` takes the route's own glyph, a
   card head takes its section's — and these three name nothing that has a glyph: they are groupings of
   cards, not subjects. A borrowed icon would say the band was about whatever it was borrowed from.
   A rule is the same weight of signal with nothing to misread, and it is what gives the eye somewhere
   to enter a screen that was seven cards of identical weight in a column.
   It is `::before` on the heading rather than a node in the tree, because it is decoration in the exact
   sense the accessibility rules mean: nothing announces it, and `band()` stays three lines long. */
.home-band > h2::before {
  content: ''; flex: none;
  width: 1.25rem; height: 2px; border-radius: 1px;
  background: var(--accent);
}
/* The statements note is a `.card` that is also a band — it sits under all of them and belongs to none,
   so it takes the band's spacing above it and nothing else. `.home-band > h2` cannot reach its `h3`. */
.home-band.card { margin-top: var(--space-xl); }

/* The six-month worth chart draws in `currentColor` — it chooses no hue, like everything else in
   kikundi-charts.js — so the card says which one, and it is the same accent the headline tile's own
   figure is set in. The two are the same series. */
.card.worth .chart-wrap { color: var(--accent-ink); }
.card.worth .delta { display: block; margin-top: var(--space-sm); font-size: var(--text-sm); color: var(--muted); }
.card.worth .delta b { font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; color: var(--body); }

/* ── the two donuts ──
   Where the group's money is, and what came in this month. They LEAD the screen, above the KPI strip:
   between them they answer the two questions a meeting opens with, and everything under them is detail.
   A pair, so they are one rule — the ring centred over its own legend, which is the only place the
   slices are named, because no arc carries a label. */
.home-grid.lead { margin-top: 0; margin-bottom: var(--space-lg); }
.card.chart.donut .chart-wrap { display: flex; justify-content: center; }
/* The figure goes in the hole, which is the whole reason this is a donut and not a pie: the reader is
   already looking there, so the picture answers "how much" before it answers "made of what".
   Proportional digits by `.kpi .n`'s own rule — a standalone figure with nothing beneath it to line up
   with reads loose in tabular. */
.chart-svg .donut-figure {
  font-family: var(--font); font-size: 17px; font-weight: 700; fill: var(--heading);
  letter-spacing: var(--tracking-tight);
}
.chart-svg .donut-cap { font-family: var(--font); font-size: 10px; fill: var(--muted); }
/* Centred under a centred ring: a legend running the full width of the card under a 168px ring reads as
   belonging to the card rather than to the chart. */
.card.chart.donut .chart-legend { justify-content: center; }

.collection-body { display: flex; align-items: center; gap: var(--space-lg); flex-wrap: wrap; }
.collection .ring { flex: none; width: 8rem; color: var(--accent-ink); }
.collection .says { flex: 1 1 14rem; min-width: 0; }
/* Proportional figures, by the same rule as `.kpi .n` above: this is a standalone figure with a sentence
   under it, not a column with anything to line up against. */
.collection .says .big { font-size: var(--text-xl); font-weight: var(--weight-bold); color: var(--heading); margin-bottom: 0.2rem; }
.collection .says .sub { color: var(--body); font-size: var(--text-sm); margin-bottom: 0.2rem; }
.collection .says .help { color: var(--muted); font-size: var(--text-sm); margin: 0; }
.ring-figure { font-family: var(--font); font-size: 26px; font-weight: 700; fill: var(--heading); }
.ring-arc { animation: kd-sweep 900ms cubic-bezier(0.22, 1, 0.36, 1) both; }
/* 377 is the ring's own circumference (2πr, r=60) — the arc sweeps from empty to whatever offset the
   chart set inline. It has to be a literal because a keyframe cannot read an element's own attribute,
   so it moves by hand with the ring's size in kikundi-charts.js. */
@keyframes kd-sweep { from { stroke-dashoffset: 377; } }

/* ══ Status chips ══════════════════════════════════════════════════════════════════════════════════ */
/* 4px, much tighter than the pill it was. A chip is a LABEL on a row, not a control, and the tighter
   corner is what stops a column of them reading as a column of small buttons. */
.tag {
  display: inline-flex; align-items: center; gap: 0.3rem;
  padding: 0.2rem 0.5rem;
  font-size: var(--text-xs); font-weight: var(--weight-bold);
  border-radius: 4px;
  background: var(--fill); color: var(--body);
  white-space: nowrap;
}
.tag.good { background: var(--good-bg); color: var(--good); }
.tag.warn { background: var(--warn-bg); color: var(--warn); }
.tag.bad  { background: var(--bad-bg);  color: var(--bad); }
/* A member who has left. The model has asked for this tone since it was written — app.css only ever had
   good/warn/bad, so `dim` fell back to the base grey and said nothing about why. */
.tag.dim  { background: var(--fill);  color: var(--muted); }
/* A tag carrying a SENTENCE rather than a status word — the readiness checks, which are findings and not
   states. It keeps the colour, which is the whole signal, and gives up the one-line promise, which a
   phone cannot keep: a nowrap sentence pushed the whole page sideways at 390px. */
.tag.wrap { white-space: normal; text-align: left; }
/* A run of tags reading as one line — what is already fine, said once and briefly, so a card about what
   is WRONG is not mostly green. */
/* ══ The page heading ══════════════════════════════════════════════════════════════════════════════ */
/* A SCREEN SAYS WHAT IT IS WITH THE MARK THE MENU GAVE IT. The glyph is `NAV_ICON[route]` — the same one
   the sidebar draws beside that screen's link — in `.cardhead .ic`'s own vocabulary, because that tile is
   already what this product means by "here is the subject of this block". A second treatment for one idea
   is the `.pill`-beside-`.tag` bug wearing a different coat. */
.pagehead { display: flex; align-items: flex-start; gap: var(--space-md); margin-bottom: var(--space-lg); }
.pagehead .ic {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem;
  color: var(--accent-ink); background: var(--accent-light);
  border-radius: var(--radius-lg);
}
.pagehead .ht { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; position: relative; }
/* SHRINK TO THE WORDS, NEVER GROW: the mark has to hug the end of the heading TEXT. Letting the h1
   take the free space would push the mark to the far edge of the screen, where it stands next to
   nothing and reads as belonging to the page rather than to the sentence it explains. */
.pagehead .ht > h1 { flex: 0 1 auto; min-width: 0; }
/* THE DATE THE FIGURES BELOW ARE FOR, said INSIDE the heading rather than under it. It was a small line
   beneath the title first, and at that size it read as a footnote to the sentence below it rather than
   as part of the heading — which is the opposite of the point, since the date is half of what the
   heading claims. So it is the h1's own size, and subordinated by WEIGHT and INK instead: the eye lands
   on what the screen is, then on which day it is about, without either being a different object. */
.pagehead h1 .d { font-weight: var(--weight-regular); color: var(--muted); }

/* ══ Which date the figures are for ════════════════════════════════════════════════════════════════ */
/* AT TODAY it is a TILE, not a button loose on the canvas: white, the cards' own radius and shadow, so it
   reads as a sibling of the KPI strip immediately under it. `.kpis` has no margin of its own — every bit
   of space around the strip belongs to its neighbours — so the gap beneath is this bar's to own, or the
   tiles weld to it. */
.asat { display: flex; justify-content: flex-end; margin-bottom: var(--space-md); }

.periodpick {
  display: inline-flex; align-items: center; gap: var(--space-sm);
  padding: 0.5rem 0.75rem;
  background: var(--white); border: none; border-radius: var(--radius-lg);
  box-shadow: var(--shadow-card);
  cursor: pointer; text-align: left;
  transition: box-shadow var(--transition-fast), transform var(--transition-fast);
}
.periodpick:hover { box-shadow: var(--shadow-card-hover); transform: translateY(-1px); }
.periodpick:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-light), var(--shadow-card); }
.periodpick .ic {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem;
  color: var(--accent-ink); background: var(--accent-light);
  border-radius: var(--radius-md);
}
.periodpick .txt { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
.periodpick .l {
  font-size: var(--text-xs); font-weight: var(--weight-semibold);
  color: var(--muted); letter-spacing: var(--tracking-wide);
}
/* The VALUE is the period, in heading ink — what you are looking at and how to change it are one object,
   so the state has to be the loudest thing in the control. */
.periodpick .v {
  margin-top: 0.1rem;
  font-size: var(--text-sm); font-weight: var(--weight-bold);
  color: var(--heading); white-space: nowrap;
}
.periodpick .chev { display: flex; color: var(--muted); }

/* LOOKING BACK the same object grows into the notice, in the same place and the same shape. Tinted with
   the product's own accent and NOT with `warn`: nothing is wrong, and the three status tints already mean
   three things a mode is not. The edge runs down the leading side, so it reads at a glance on a phone. */
.asat.past {
  justify-content: flex-start; align-items: flex-start; gap: var(--space-md);
  padding: var(--space-md);
  background: var(--accent-light);
  border-inline-start: 4px solid var(--accent);
  border-radius: var(--radius-lg);
}
.asat.past .ic {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem;
  color: var(--accent-ink); background: rgba(255, 255, 255, 0.65);
  border-radius: var(--radius-md);
}
.asat.past .say { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.asat.past .say b { font-size: var(--text-base); font-weight: var(--weight-bold); color: var(--accent-ink); }
.asat.past .say span { font-size: var(--text-sm); color: var(--accent-ink); opacity: 0.85; }
.asat.past .acts { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-xs); }
/* Inside the tint the picker keeps its shape but drops the lift — a raised white tile on a coloured band
   reads as a second card sitting on top of the first. */
.asat.past .periodpick { box-shadow: none; }
.asat.past .periodpick:hover { box-shadow: var(--shadow-sm); transform: none; }
.asat.past .periodpick .ic { background: var(--accent-light); }

/* A phone has no room for a sentence and three buttons on one line, so the actions drop under the words
   and take the full width rather than being squeezed into a column of one-word wraps. */
@media (max-width: 46rem) {
  .asat.past { flex-wrap: wrap; }
  /* The glyph and the sentence stay on ONE row and only the actions drop. `flex: 1 1 auto` with no basis
     let the sentence wrap first, which put the icon alone on a line of its own with nothing beside it —
     a mark for a sentence that is no longer there. A basis narrower than any phone keeps them paired. */
  .asat.past .say { flex: 1 1 10rem; }
  .asat.past .acts { width: 100%; }
  /* And the actions share the width rather than stacking one-and-a-half per line. */
  .asat.past .acts .btn { flex: 1 1 auto; justify-content: center; }
}

/* HOW FAR THE PROOF GOES, in the same object that names the period — see `periodBar`.

   `warn` AND NOT THE ACCENT. The first pass tinted this with the accent green on the reasoning that
   nothing has gone WRONG — the group has not fallen behind and nobody has made a mistake. That is true
   of the group and false of the screen: green is the reassurance colour, it is what `good` means three
   inches away on the standing chips, and a katibu reading a green band concludes the desk is fine and
   moves on. This band is telling them their figures stop short of the date above them and that they
   should go and upload a statement — which is the definition of something wanting attention. `warn` and
   not `bad`, because nothing is broken and no money is missing: there is simply a stretch nobody has
   looked at.

   At today `.asat` is a right-aligned row; with a caveat it becomes `.stack`, a column, and the caveat
   takes the full width above the picker. Looking back it sits inside the accent band on its own line,
   where it keeps its own warm tint rather than borrowing the notice's — two different things are being
   said and one of them wants acting on. */
.asat.stack { flex-direction: column; align-items: stretch; gap: var(--space-sm); }
.asat.stack .periodpick { align-self: flex-end; }

.evidence {
  display: flex; align-items: flex-start; gap: var(--space-sm);
  padding: var(--space-md);
  background: var(--warn-bg);
  border-inline-start: 4px solid var(--warn);
  border-radius: var(--radius-lg);
}
.evidence .ic { flex: none; display: flex; color: var(--warn); padding-top: 0.1rem; }
.evidence .txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
.evidence .txt b { font-size: var(--text-base); font-weight: var(--weight-bold); color: var(--warn); }
.evidence .txt span { font-size: var(--text-sm); color: var(--warn); opacity: 0.9; }
/* Who it is actually in doubt about, set apart from the general sentence: the line above says the
   figures are short, this one names how many people that leaves unaccounted for. */
.evidence .txt .who { font-weight: var(--weight-bold); opacity: 1; }
.evidence .txt a {
  align-self: flex-start; margin-top: 0.5rem;
  font-size: var(--text-sm); font-weight: var(--weight-semibold);
  color: var(--warn); text-decoration: underline;
}

/* Looking back, the accent band and this one are saying two different things — "you are reading the
   past" and "these figures stop short" — so it keeps its own tint instead of dissolving into the
   notice around it. Only the radius softens, so it reads as nested rather than stacked. */
.asat.past { flex-wrap: wrap; }
.asat.past .evidence { flex: 1 1 100%; margin-top: var(--space-sm); }

.pills { display: flex; flex-wrap: wrap; gap: var(--space-xs); margin-top: var(--space-sm); }
/* Home's standing strip: how many members are in each state, on the line under the KPI tiles. Every other
   `.pills` sits inside a card under something, so this is the one that has to find its own space. The
   count is set apart from the word rather than run on: "Behind 2" reads as a chip with a number on it,
   "Behind2" reads as a typo. */
.pills.standing { margin: var(--space-md) 0; }
.pills.standing .tag b { margin-left: 0.35rem; font-variant-numeric: tabular-nums; font-weight: var(--weight-bold); }

.tag .led { width: 0.45rem; height: 0.45rem; border-radius: 50%; background: currentColor; }

/* ══ Banners — one sentence the katibu must read ═══════════════════════════════════════════════════ */
.banner {
  display: flex; gap: var(--space-sm); align-items: flex-start;
  padding: var(--space-md) var(--space-md);
  border-radius: var(--radius-md);
  font-size: var(--text-sm);
  background: var(--fill); color: var(--heading);
  margin-bottom: var(--space-md);
}
/* A banner is a row — an icon beside a sentence. `.stack` is for the ones that carry several lines: a
   heading, one line per thing to say, and a note. Without it those become flex ITEMS side by side and a
   three-line warning renders as three squashed columns on a phone. */
.banner.stack { flex-direction: column; gap: 0.35rem; }
.banner.good { background: var(--good-bg); color: var(--good); }
.banner.warn { background: var(--warn-bg); color: var(--warn); }
.banner.bad  { background: var(--bad-bg);  color: var(--bad); }
.banner b { font-weight: var(--weight-semibold); }

/* ══ Forms ═════════════════════════════════════════════════════════════════════════════════════════ */
.field { margin-bottom: var(--space-md); }
.field > label {
  display: block; margin-bottom: 0.35rem;
  font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--heading);
}
/* Radios and checkboxes are deliberately excluded: they live inside .choice, where they are a control
   beside a sentence, not a box the katibu types into. */
.field input:not([type="radio"]):not([type="checkbox"]), .field select, .field textarea {
  display: block; width: 100%;
  /* 17px minimum: below that iOS Safari zooms the whole page on focus, which loses the katibu's place. */
  font-size: max(1.0625rem, var(--text-base));
  padding: 0.7rem 0.9rem;
  color: var(--heading); background: var(--white);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
  -webkit-appearance: none; appearance: none;
}
.field select {
  /* %23637381 is --muted. A data-URI cannot read a custom property, so this one literal has to be moved
     by hand with the token — it was still the Apple grey two palettes after that grey was retired. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%23637381' stroke-width='1.6' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 0.9rem center; padding-right: 2.2rem;
  /* A SELECT MUST NEVER CLIP ITS OWN ANSWER — it is the one control whose value the katibu cannot
     scroll, select or widen to read. `appearance: none` above is what makes this necessary: it removes
     the browser's own shrink-to-fit, so a long option in a narrow grid track was hard-cut with no
     ellipsis and no hint anything was missing. "the payment they missed" came out as "the payment
     they", in a 215px track, on the screen where a group decides what a late member is charged.
     The ellipsis is the SAFETY NET, not the fix — `widthFor` gives a select whose answer is a sentence
     a track wide enough to hold it. This is what happens when even that is not enough. */
  text-overflow: ellipsis;
  /* And a floor, so the ellipsis is reached by a genuinely long answer rather than by a narrow column.
     11rem is `.cols`'s own track floor: a select may never be narrower than the grid it sits in. */
  min-width: min(11rem, 100%);
}
.field input:focus, .field select:focus, .field textarea:focus {
  outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light);
}
.field input[aria-invalid="true"] { border-color: var(--bad); box-shadow: 0 0 0 3px rgba(200, 35, 44, 0.1); }

/* A DATE OR A MONTH IS A FIGURE, so it is set in the same tabular numerals every other figure on the desk
   is — an unstyled month box renders its placeholder in the browser's own proportional face, at a
   different size from the field above it, and reads as something the page did not draw. The native
   calendar button is the one control here we do not own, so it is given the accent rather than left as
   whatever grey the platform chose, and a comfortable target rather than the 12px default. */
.field input[type="date"], .field input[type="month"] {
  font-variant-numeric: tabular-nums; letter-spacing: var(--tracking-wide);
}
.field input[type="date"]::-webkit-calendar-picker-indicator,
.field input[type="month"]::-webkit-calendar-picker-indicator {
  padding: 0.25rem; margin-inline-start: 0.25rem;
  border-radius: var(--radius-sm);
  opacity: 0.55; cursor: pointer;
  transition: opacity var(--transition-fast), background var(--transition-fast);
}
.field input[type="date"]::-webkit-calendar-picker-indicator:hover,
.field input[type="month"]::-webkit-calendar-picker-indicator:hover {
  opacity: 1; background: var(--accent-light);
}
/* The one-line explanation under a choice. Section B: the plain language lives HERE, not in the label. */
/* Held to a measure of its own. Once the desk stopped capping its column at 64rem so that tables could
   use the whole screen, an explanation under a field became a single 900px line — which is not easier to
   read for being longer, it is harder. The words are never shortened; only the column they sit in. */
/* UNSCOPED, because a help line is a help line wherever it is written. This rule read `.field .help`
   for as long as it has existed, and there was no bare `.help` anywhere in this file — so the fifteen
   or so places that write one as a SIBLING rather than inside a field got no styling whatever: body
   size, near-black, no measure. A caption meant to sit quietly under a heading was rendering heavier
   than the sentence it was explaining, most visibly as a loan-cost method's description in the rules
   sheet, where it sat directly above the same words' worth of properly-grey field help and read as two
   different kinds of thing. `.collection .says .help` and `.plan-q .help` are both more specific and
   are unaffected. This belongs in app.css for the reason `[hidden]` and `.note` do: its bugs always
   belonged here. */
.help { margin-top: 0.35rem; max-width: 42ch; font-size: var(--text-sm); color: var(--muted); line-height: var(--leading-snug); }

/* ══ WHAT THIS MEANS ═══════════════════════════════════════════════════════════════════════════════
   A heading's explanation, behind the mark beside it. Every screen on this desk used to carry its
   prose at full length for ever, so a katibu who had read it once still met four grey paragraphs on
   the way to the first control. Not one word is removed — hard rule 4 says they must exist and must be
   plain, not that all of them must be on screen at once. */
.why {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 1.5rem; height: 1.5rem; padding: 0;
  border: none; border-radius: 50%;
  background: transparent; color: var(--muted); cursor: pointer;
  transition: background var(--transition-base), color var(--transition-base);
}
/* NO AUTO MARGIN, EVER. `.cardhead .btn` and `.cardhead .state` both already claim `margin-left: auto`,
   and in a flex row the FIRST auto margin takes the free space — a third claimant here would stop
   pushing the Change button and the state chip to the far end of every rules card. */
.why:hover, .why[aria-expanded="true"] { background: var(--accent-light); color: var(--accent-ink); }
.why:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* IN FLOW, AT EVERY WIDTH — and it was floating for exactly one round of looking at it.
   `flex-basis: 100%` drops the panel onto its own line inside the heading row, which is what lets it
   stay the mark's immediate DOM sibling: reading order is already right, so there is no focus trap
   and nothing to move when the screen is rebuilt.

   An absolutely positioned panel is cheaper still — no scroll listener, no resize listener — and it
   covers the card beneath it, which on this desk is exactly where the NEXT mark is. Driven in a
   browser, the open page lead sat on top of the book-start card's own mark and swallowed the click:
   the panel took the press and the katibu got nothing at all. At rest the two are identical, since
   the panel is hidden either way — so the compactness this was built for costs nothing here — and
   open, in flow is strictly better: nothing is covered, nothing is unclickable, no z-index to lose. */
.whypanel {
  flex-basis: 100%; order: 9;
  margin-top: 0.5rem; padding: var(--space-md);
  background: var(--bg-alt); border-radius: var(--radius-md);
  border-inline-start: 3px solid var(--accent);
  color: var(--body); font-size: var(--text-sm); line-height: var(--leading-normal);
  max-width: 78ch;
}
.whypanel p { margin: 0; }

/* ── A FIELD'S HELP, SHOWN ONLY WHILE THE FIELD IS FOCUSED ──────────────────────────────────────
   OUT OF FLOW IN BOTH STATES, which is the whole of why this costs nothing. Reserving the space
   always would hand back exactly the whitespace being reclaimed; letting it push the page down means
   tabbing a five-field card walks the layout under the katibu's thumb. Absolute in both states, so
   focusing a field moves NOTHING.

   Clipped rather than `display: none` at rest, because a `display: none` help line is announced to
   nobody — the input names it in `aria-describedby` and a screen reader reads it on entry either way.

   `.field` is the containing block, so this can never widen an ancestor: unlike a bare `.sr-only`,
   which anchors to the document and drags a sideways-scrolling table wide, this one is safe anywhere.

   SCOPED WITH `>`, deliberately. There are a dozen or so standalone `.help` paragraphs on this desk
   that are not field help at all, several carrying live figures — an unscoped rule would silently
   hide those too. */
.field, .plan-q { position: relative; }
.field > .help, .plan-q > .help {
  position: absolute; top: 100%; inset-inline-start: 0; z-index: 2;
  width: 1px; height: 1px; margin: 0; padding: 0;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.field:focus-within > .help, .plan-q:focus-within > .help {
  width: max-content; max-width: min(42ch, 100%); height: auto;
  margin-top: 0.35rem; padding: 0.5rem 0.7rem;
  overflow: visible; clip: auto; white-space: normal;
  background: var(--white); box-shadow: var(--shadow-card);
  border-radius: var(--radius-md);
}
/* An empty one draws nothing at all — `yesNo` emits the node whether or not that answer has a
   sentence, so that the form's SHAPE does not change across an answer. */
.field > .help:empty, .plan-q > .help:empty { display: none; }

/* THE WAY BACK. With the preference on, every explanation is the paragraph this desk always drew,
   in the same slot, with today's spacing — so there is nothing for the two readings to disagree
   about. The mark itself is not drawn at all in that state; see `why()`. */
body.kd-explain .field > .help, body.kd-explain .plan-q > .help {
  position: static; width: auto; height: auto;
  margin-top: 0.35rem; padding: 0; max-width: 42ch;
  overflow: visible; clip: auto; white-space: normal;
  background: none; box-shadow: none; border-radius: 0;
}

.field .err  { margin-top: 0.35rem; font-size: var(--text-sm); color: var(--bad); }
.field .unit { display: flex; align-items: center; gap: 0.55rem; }
.field .unit input { flex: 0 1 9rem; }
.field .unit span { font-size: var(--text-sm); color: var(--muted); }
/* A number with its dialling code in front of it. The code is shown, not typed — the country the katibu
   picked decides it — so it is styled as part of the box rather than as a second field. */
.field .withcode { display: flex; align-items: stretch; }
.field .withcode .code {
  display: flex; align-items: center; flex: none;
  padding: 0 0.75rem;
  font-size: max(1.0625rem, var(--text-base)); color: var(--muted);
  background: var(--fill);
  border: 1px solid var(--border); border-right: none;
  border-radius: var(--radius-md) 0 0 var(--radius-md);
}
.field .withcode input { border-radius: 0 var(--radius-md) var(--radius-md) 0; }
.field.inline { display: flex; align-items: center; gap: var(--space-sm); }
.field.inline > label { margin: 0; }

/* AN INPUT IS AS WIDE AS ITS ANSWER, NOT AS WIDE AS THE COLUMN IT SITS IN.
   A box 340px long for a one-digit share count is not generous, it is a question about how much the
   katibu is expected to type — and four boxes of identical width say the four answers are the same
   size of thing when a name, a phone number, a date and a count are nothing alike. The cap is a
   MAX-width, so a phone (where the column is already narrower than every cap) is unchanged.
   `.field .unit input` already does this for money, at 9rem; these are the same idea for the rest. */
.field.w-xs input { max-width: 6rem; }    /* a count — shares held */
.field.w-sm input { max-width: 11rem; }   /* a date, a month */
.field.w-md input { max-width: 16rem; }   /* a phone number */

fieldset { margin: 0 0 var(--space-lg); padding: 0; border: none; }
fieldset > legend {
  padding: 0; margin-bottom: var(--space-sm);
  font-size: var(--text-base); font-weight: var(--weight-semibold); color: var(--heading);
}
.choices { display: grid; gap: 0.5rem; }
.choice {
  display: flex; gap: 0.7rem; align-items: flex-start;
  padding: 0.8rem 0.9rem;
  border: 1px solid var(--border); border-radius: var(--radius-md);
  cursor: pointer;
  transition: border-color var(--transition-fast), background var(--transition-fast);
}
.choice:hover { border-color: var(--muted); }
.choice input { width: auto; margin: 0.15rem 0 0; accent-color: var(--accent); flex: none; }
.choice > span { min-width: 0; }
.choice.on { border-color: var(--accent); background: var(--accent-light); }

/* A yes/no pair: two answers side by side from 30rem, because reading them as a pair is the whole point
   of the question. The radio is visually hidden (.sr-only) and the CARD is the tap target — but it is
   still a real radio, so arrow keys and a screen reader behave exactly as they did before.
   The tick is a SECOND signal, never the only one: the accent border and tint say "chosen" too. */
.choices.pair { grid-template-columns: 1fr; }
@media (min-width: 30rem) { .choices.pair { grid-template-columns: 1fr 1fr; } }
.choices.pair .choice { align-items: center; min-height: 3rem; padding: 0.75rem 0.9rem; }

/* A SWITCH: a pair whose two answers are single WORDS — yes and no.
   `.pair` above is right where the two answers are sentences to be read against each other ("one rule
   for all loans" / "it depends on the amount borrowed"): they need the room and the katibu needs to
   read both. A yes/no needs neither. Drawn as two slabs it was a question answerable in one syllable
   taking 1400px of a desk and shouting itself twice, so it becomes the pill-track-and-chip the system
   already uses for the language picker and the console's tabs — an existing idiom applied where it
   belongs, not a third shape doing the same job.
   Still real radios in a real radiogroup, so a keyboard and a screen reader are unchanged. Two signals
   for "chosen" and NEITHER is colour alone: the raised white chip, and the weight of the word. */
.choices.pair.switch {
  display: inline-flex; width: auto; max-width: 22rem; gap: 0;
  padding: 3px;
  background: var(--fill);
  border-radius: var(--radius-md);
}
.choices.pair.switch .choice {
  flex: 1 1 0; justify-content: center; align-items: center;
  min-width: 5.5rem; min-height: 2.25rem;
  padding: 0.4rem 1rem;
  background: transparent; border: none; border-radius: var(--radius-sm);
  transition: background var(--transition-fast), box-shadow var(--transition-fast);
}
.choices.pair.switch .choice:hover { border: none; background: rgba(0, 0, 0, 0.03); }
.choices.pair.switch .choice .box { display: none; }        /* here the chip IS the tick */
.choices.pair.switch .choice.on { background: var(--white); box-shadow: var(--shadow-sm); }
.choices.pair.switch .choice.on .t { color: var(--accent-ink); font-weight: var(--weight-bold); }
.choices.pair.switch .choice:focus-within { box-shadow: 0 0 0 3px var(--accent-light); }
/* THE TWELVE MONTHS a group collects in — the desk's one multi-select, and the only `.choices` whose
   answer is not one of a closed set. It keeps the plain `.choice` card (the tick box is the signal, so
   `.switch`'s "the chip IS the tick" cannot apply: twelve chips with nine raised reads as nothing) and
   only changes the track it sits on. Auto-fill rather than a fixed twelve across: three columns on a
   phone, six on a tablet, twelve on a desk, from one rule and no breakpoints of its own. */
.choices.months { grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr)); gap: 0.4rem; }
.choices.months .choice { justify-content: center; align-items: center; gap: 0.4rem; padding: 0.5rem 0.4rem; }
.choices.months .choice .box { width: 1.05rem; height: 1.05rem; border-radius: var(--radius-sm); }

.choice .box {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 1.35rem; height: 1.35rem;
  color: transparent; background: var(--white);
  border: 1px solid var(--border); border-radius: var(--radius-full);
  transition: color var(--transition-fast), background var(--transition-fast), border-color var(--transition-fast);
}
.choice.on .box { color: var(--white); background: var(--accent); border-color: var(--accent); }
/* The hidden radio still takes focus, so the ring must be drawn on something the katibu can see. */
.choice:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
.choice .t { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--heading); }
.choice .h { font-size: var(--text-sm); color: var(--muted); line-height: var(--leading-snug); }
/* Chosen, so the card is filled with the accent tint — see .wizard-head .step-label. */
.choice.on .h { color: var(--accent-ink); }

/* A form laid out across the page rather than down it. One column on a phone, two on a desk (the 46rem
   block below), because eight stacked fields on a laptop is a thin ribbon down the middle of an empty
   page and the katibu scrolls past half the form before they see the button. `.full` is for the field
   whose answer is long — a group's name — and for anything that must not be read as one of a pair. */
/* A NAMED PART OF A FORM, seamed off from the part before it.
   A rules sheet asks two unrelated things and used to run them together: WHEN this version takes effect
   (a date and the meeting that agreed it — administration) and WHAT THE RULE IS (the constitution
   itself). Both landed in one flow, so on the loan-cost sheet the choice "one rule for all loans / the
   amount borrowed" sat in the same two-column grid as the date, directly under "Which meeting agreed
   them", and read as belonging to it. A katibu could not see where the paperwork stopped and their
   group's rule started.
   The seam is `margin-top + padding-top + border-top`, which is not a new look: it is the same hairline
   `.form-actions`, `.nav .foot` and `.auth-card .alt-action` already use to say "a different kind of
   thing follows". Only BETWEEN parts — never above the first, which would draw a line under the
   heading. */
.form-part + .form-part {
  margin-top: var(--space-lg); padding-top: var(--space-md);
  border-top: 1px solid var(--border-light);
}
.form-part > .tcap:first-child { margin-top: 0; }

.form-grid { display: grid; gap: var(--space-md) var(--space-lg); align-items: start; }
.form-grid .field { margin-bottom: 0; }        /* the grid owns the spacing, same as .rows .r .field */
.form-grid .full { grid-column: 1 / -1; }
/* `.wide` IS DELIBERATELY NOT DECLARED HERE, and that is the whole difference between the two grids.
   It means "one `.cols` track is not enough", and a `.cols` track is 215px in the rules sheet while a
   column of THIS grid is around 440px — already more than twice as much. Giving it `1 / -1` here, which
   is what the first pass did, put "The group's common fund" in an 844px box: 203px of answer and 640px
   of nothing, which is exactly the over-wide dropdown `.w-lg` was added to stop one screen away. A
   field that needs more room than a narrow track does not need more room than a wide one. `.full` is
   declared, because 45-plus characters genuinely does outgrow a column of this grid at 46rem. */

/* A ROW OF FIELDS THAT BELONG TO ONE THING — one member's name, number, date and share count.
   `auto-fit` rather than a fixed count because the rows that use this do not all have the same number
   of fields: the roster has four, a member's opening position has three, and a fixed template is wrong
   for one of them. It is declared HERE and not as `.rows .r .cols`, which is where it lived and which
   is why `openingPositionEditor` — the same function, reused by the Members screen's dialog — got no
   grid at all inside a modal and stacked its three fields down the sheet. */
/* AUTO-FILL, NEVER AUTO-FIT, and the difference is the whole of why a band read as two boxes at
   opposite ends of the sheet. `auto-fit` COLLAPSES the tracks nothing is sitting in and hands their
   room to the ones left over — so a row holding exactly two fields became two half-width columns, and
   a band's From and To ended up 350px apart with nothing whatever between them. `auto-fill` keeps the
   empty tracks, so two fields sit in the first two and stay beside each other however wide the sheet
   is. The `1fr` still shares the room out inside a NARROW container, which is what makes a phone work. */
.cols { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: var(--space-sm); align-items: start; }
.cols .field { margin-bottom: 0; }
/* A TRACK IS ONE SIZE, AND NOT EVERY ANSWER IS. `.form-grid` has had `.full` since it was written;
   `.cols` had no way to say this at all, so a one-digit percentage box and a dropdown whose answer is
   a whole phrase were given identical 215px tracks — the first mostly empty, the second cutting its
   own value off. `.wide` is two tracks, which is what a sentence needs; `.full` is the row, for the
   rare field that must not be read as one of a set.
   The span is claimed by the FIELD, exactly as `kikundi-auth.js` does it (`field.className +=
   ' full'`), never by wrapping it in a spacer div — a wrapper is a second element with the same job
   and the two drift. And it is `span 2`, not `1 / 3`: `auto-fit` collapses to fewer tracks on a narrow
   sheet, and a fixed line number would put a field in a column that is no longer there. */
.cols .full { grid-column: 1 / -1; }
/* `.wide` IS HELD BACK UNTIL THERE IS A SECOND TRACK TO SPAN INTO. Spanning 2 in a grid that `auto-fit`
   has collapsed to ONE track does not wrap — it forces an implicit second column and takes the sheet
   sideways, which is the bug this whole change is about, arriving on the smallest screen instead of the
   largest. Below 46rem the narrowest `.cols` in the desk (a rules sheet on a 390px phone, ~294px inside
   the card) is exactly one track wide, and one track is already the full row. At 46rem and above the
   narrowest possible case — a plain 32rem modal inside `.rules-form` — still comes to two, so there is
   always something to span into. A phone stacks; nothing is lost by staying out of its way. */
@media (min-width: 46rem) {
  .cols .wide { grid-column: span 2; }
}

/* Two short sections beat one long list: the katibu answers about the GROUP, then about THEMSELVES. */
.form-sec { margin-bottom: var(--space-lg); }
.form-sec > .legend {
  display: flex; align-items: center; gap: var(--space-sm);
  margin-bottom: var(--space-sm);
  font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.06em;
}
.form-sec > .legend::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--border-light); }

/* WRAPS, because a row of buttons that does not fit must go onto a second line rather than off the side
   of the page. The Members screen carries three ways in — read them off a statement, fill the figures in
   as a block, add one person — and in Kiswahili, whose labels run half again as long as the English,
   those three ran 250px past the edge of a phone. A sideways-scrolling PAGE is the one thing the layout
   rules here forbid outright, and `.spacer` still does its job on whichever line it lands on. */
.form-actions {
  display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center;
  margin-top: var(--space-lg); padding-top: var(--space-md);
  border-top: 1px solid var(--border-light);
}
.form-actions .spacer { flex: 1 1 auto; }

/* Back and Next stay in reach. The setup form is the longest page in the product and is answered once,
   standing up; "Next" being something you scroll to find is how a katibu loses their place. Frosted the
   same way as the topbar, so the two edges of the page match. */
/* Padded, not negatively margined: .content's own padding differs between the phone and the 64rem desk,
   so a bleed-to-the-edge bar would be wrong at one of them. A rounded bar sitting inside the column is
   right at every width and needs no breakpoint of its own. */
.form-actions.sticky {
  position: sticky; bottom: var(--space-sm); z-index: var(--z-sticky);
  margin-top: var(--space-lg);
  padding: var(--space-sm) var(--space-md);
  background: var(--header-bg);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  backdrop-filter: saturate(180%) blur(20px);
  border: 1px solid var(--border-light); border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
}
/* A field appearing after an answer, rather than a block of inputs materialising out of nothing. */
.reveal { animation: reveal var(--transition-base) both; }
@keyframes reveal { from { opacity: 0; transform: translateY(-0.25rem); } to { opacity: 1; transform: none; } }

/* A row of choices rather than a save/cancel pair: it wraps, because the labels are sentences and
   Kiswahili's run half again as long as English's. Without the wrap, "Hizi hazikuwa fedha za kikundi"
   is clipped at the card's edge on a phone — which is exactly where this is read. */
.choice-actions {
  display: flex; flex-wrap: wrap; gap: var(--space-sm); align-items: center;
  margin-top: var(--space-md); padding-top: var(--space-md);
  border-top: 1px solid var(--border-light);
}
.choice-actions .btn { white-space: normal; text-align: center; }

/* ══ Tables ════════════════════════════════════════════════════════════════════════════════════════ */
/* A phone cannot widen, so a wide table scrolls inside its own box and the page never does. */
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; margin: 0 calc(-1 * var(--space-lg)); padding: 0 var(--space-lg); }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
th {
  text-align: left; white-space: nowrap;
  padding: 0.5rem 0.7rem;
  font-size: var(--text-xs); font-weight: var(--weight-semibold); color: var(--muted);
  letter-spacing: var(--tracking-wide);
  border-bottom: 1px solid var(--border);
}
td { padding: 0.7rem; border-bottom: 1px solid var(--border-light); color: var(--heading); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
th.right, td.right { text-align: right; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
td.nowrap, th.nowrap { white-space: nowrap; }

/* A name identifies its row rather than being data in it: never right-aligned, and never wrapped. A name
   longer than the column is cut with an ellipsis and kept whole in the cell's title — one line, always,
   because a table where one row is two lines tall is a table nobody scans. */
td.who, th.who { text-align: left; white-space: nowrap; }
/* 10rem fits every ordinary two- and three-part name; the cap only bites on an outlier, and it is what
   lets nine columns fit a 1280px laptop without the table scrolling inside its own box. */
td.who b { display: inline-block; max-width: 10rem; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }

/* A member who has gone, and a figure that is a word rather than an amount. */
tbody tr.gone td { color: var(--muted); }
td .dim { color: var(--muted); }

/* A table with more columns than the card is wide. Only the HORIZONTAL padding gives — the row height is
   what a thumb aims at and is untouched. Money is the reason: one member's figure runs to tens of millions
   and the totals row sums the whole group, so four of the columns are sized by digits nothing can shorten. */
table.tight td, table.tight th { padding-left: 0.55rem; padding-right: 0.55rem; }

/* A totals row is a summary, not one more member: it is ruled off ABOVE rather than below, and it is the
   one row a katibu can read without reading any other. Ported from the report's own tfoot, so the screen
   and the printed page say the same thing the same way. */
tfoot td {
  padding-top: 0.85rem;
  font-weight: var(--weight-semibold); color: var(--heading);
  border-top: 1px solid var(--border); border-bottom: none;
}
tfoot td:first-child { color: var(--muted); }

/* ── A WIDE TABLE STANDS UP AS ONE CARD PER ROW ON A PHONE ──
   Nine columns, four of them money. `table.tight` above buys the difference between fitting and
   scrolling on a 1280px laptop and can do nothing at 390px, where the members table was about a third
   visible and every figure had to be swiped to — on the screen a katibu opens most, on the device they
   open it on.
   `.stacks` is set by kd-dom's `tableOf` from the COLUMN COUNT (five or more), not by a call site, and
   every cell carries its own column name in `data-label`. Two or three columns stay a table: standing
   those up would be taller and no clearer.
   The `<thead>` goes because every cell now prints its own heading — but it is only `display:none` on a
   table whose `th`s are still there and still associated, so nothing is taken away from a screen reader
   that a sighted reader keeps. */
@media (max-width: 46rem) {
  table.stacks, table.stacks tbody, table.stacks tfoot, table.stacks tr, table.stacks td { display: block; }
  table.stacks thead { display: none; }
  /* The row IS the card. Inside `.card` already, so it takes the canvas tint rather than another shadow:
     a raised white tile on a white card is the failure `--fill` exists to prevent. */
  table.stacks tr {
    padding: var(--space-md); margin-bottom: var(--space-sm);
    background: var(--bg-alt); border-radius: var(--radius-md);
  }
  table.stacks tr:last-child { margin-bottom: 0; }
  table.stacks td, table.stacks.tight td {
    display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-md);
    padding: 0.3rem 0; border: none; text-align: right;
  }
  table.stacks td::before {
    content: attr(data-label);
    flex: none; text-align: left;
    font-size: var(--text-xs); font-weight: var(--weight-semibold);
    letter-spacing: var(--tracking-wide); color: var(--muted);
  }
  /* THE NAME IS THE CARD'S HEAD, not a label/value line — it is what the card IS, and printing "Member"
     over it would be a heading for something already obvious. The name may also be the one thing here
     long enough to need two lines, so it is the one cell allowed them. */
  table.stacks td:first-child {
    display: block; text-align: left; white-space: normal;
    padding-bottom: var(--space-xs); margin-bottom: var(--space-xs);
    font-weight: var(--weight-semibold); color: var(--heading);
    border-bottom: 1px solid var(--border-light);
  }
  table.stacks td:first-child::before { content: none; }
  table.stacks td.who b { max-width: none; }
  /* THE TOTALS CARD CARRIES ONLY TOTALS: a column with no total returns '' and would otherwise print its
     heading over nothing. */
  table.stacks tfoot td:empty { display: none; }
  /* A MEMBER'S CARD CARRIES EVERY COLUMN THE TABLE WOULD, and that is not the same rule. The model sends
     '' for some cells and null for others — `r[k] == null ? '—'` catches only the second — so in the
     table one member's Owed reads '—' and another's is blank. Hiding the blank one made the cards ragged
     and, worse, made "nothing owed" indistinguishable from a column this group does not have. The dash
     is drawn rather than the row dropped: `:empty` ignores pseudo-elements, so the label still prints. */
  table.stacks tbody td:empty::after { content: '—'; color: var(--muted); }
  /* The action cell has no column and so no label. Its buttons go to the foot of the card, together. */
  table.stacks td:last-child:not([data-label]) {
    display: flex; justify-content: flex-end; gap: var(--space-xs);
    padding-top: var(--space-sm); margin-top: var(--space-xs);
    border-top: 1px solid var(--border-light);
  }
  table.stacks tfoot td { padding-top: 0.3rem; border-top: none; }
  table.stacks tfoot tr { background: var(--accent-light); }
  /* Nothing scrolls sideways any more, so the wrap stops reserving room to. */
  .table-wrap:has(table.stacks) { overflow-x: visible; margin: 0; padding: 0; }
}

/* A centred sentence with no measure is one long line across the widest sheet with acres either side —
   the `.help` fault one element over. The box still fills its card; only the WORDS are held in, and
   centred with `margin-inline: auto` so nothing moves off the middle. */
.empty { padding: var(--space-xl) var(--space-md); text-align: center; color: var(--muted); font-size: var(--text-sm); }
.empty > *, .well > p, .well > span { max-width: 44ch; margin-inline: auto; }

/* THE BAND LADDER IS NEVER SCALED UP, and it is the one `fluid` chart that needs saying so.
   `fluid` is for a chart holding NO text — a sparkline, an area trend, a split bar — because an SVG
   scales everything inside its viewBox, text included. The ladder carries each band's own NUMBER, so
   at 400 units stretched across a 62rem sheet it was drawn at 2.5x: a 60px-tall green slab with a 28px
   numeral in it, which is what a katibu saw where a quiet ruler was meant to be. Capped at its natural
   400 units it may still SHRINK on a phone, which is safe — the whole drawing gets smaller together —
   and it can no longer grow. */
.chart-wrap.ladder { max-width: 25rem; }

/* ══ Wizard ════════════════════════════════════════════════════════════════════════════════════════ */
/* THE WIZARD IS A FORM, AND A FORM KEEPS ITS MEASURE.
   `.content` deliberately drops its cap at 64rem so a nine-column table can use the whole screen. That
   is right for a table and wrong for a page of questions: it stretched a one-digit share count across a
   third of a 1440px monitor. Capped here, on the wizard's own container, so no table screen is touched
   and the router needs to know nothing about it. */
.wizard { max-width: 62rem; }

/* The step header says three things once each: which step, what it is called, how far along the group
   is. It used to say the first two twice, in two components stacked on top of each other. */
.wizard-head {
  display: grid; grid-template-columns: auto 1fr; align-items: center;
  gap: var(--space-sm) var(--space-md);
  margin-bottom: var(--space-lg); padding: var(--space-md);
  background: var(--accent-light);
  border-radius: var(--radius-lg);
}
.wizard-head .mark {
  display: flex; align-items: center; justify-content: center;
  width: 2.75rem; height: 2.75rem;
  color: var(--accent); background: var(--white);
  border-radius: var(--radius-full);
  box-shadow: var(--shadow-sm);
}
.wizard-head .said { min-width: 0; }
.wizard-head .said h2 {
  margin: 0;
  font-size: var(--text-lg); font-weight: var(--weight-semibold);
  letter-spacing: var(--tracking-tight); color: var(--heading);
}
.wizard-head .stepper { grid-column: 1 / -1; margin: 0; }
/* --muted on the accent tint is 4.2:1 — under AA, and this is a caption in 12px. The tint used to be an
   8% alpha over white, where --muted was fine; a solid #C8FAD6 is a darker ground and the caption has to
   move with it. --accent-ink reaches 4.7:1 on the same tint and is already the colour of everything else
   that sits on it. Same reason `.choice.on .h` is corrected below. */
.wizard-head .step-label { color: var(--accent-ink); }

.stepper { display: flex; gap: 0.4rem; margin-bottom: var(--space-lg); }
/* A bar on a phone; a bar with its step's icon AND ITS NAME from 46rem, where there is room for both.
   The name is the same word the heading above already uses, so the rail says where the katibu is going
   rather than making them decode four small pictures. */
.stepper .s {
  flex: 1 1 0; height: 0.25rem; border-radius: var(--radius-full);
  /* Not --border-light: the rail sits on the head's own accent tint, where a hairline-weight bar is
     invisible and a four-step rail reads as a two-step one. */
  background: rgba(0, 0, 0, 0.12);
  transition: background var(--transition-base), color var(--transition-base);
  color: var(--muted);
}
.stepper .s .icon, .stepper .s .nm { display: none; }
.stepper .s.done { background: var(--accent); }
.stepper .s.now  { background: var(--accent); opacity: 0.55; }
.step-label { font-size: var(--text-xs); font-weight: var(--weight-semibold); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--muted); margin-bottom: 0.4rem; }

/* An icon is decoration BESIDE a heading, never instead of it. Remove every one of these and the
   wizard still reads exactly the same — which is the test of whether an icon has been used honestly. */
.icon { flex: none; display: block; }
/* WRAPPING AND POSITIONED, so the explanation panel can be a flex sibling of the row's own children:
   `.whypanel` takes `flex-basis: 100%` and drops onto its own line under the heading, which keeps it
   the mark's immediate DOM sibling and so keeps reading order right with no ARIA gymnastics. The
   `position: relative` is what the wide-screen rule below anchors the floating panel to. */
.cardhead { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-sm); margin-bottom: 0.2rem; position: relative; }
.cardhead .ic {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem;
  color: var(--accent-ink); background: var(--accent-light);
  border-radius: var(--radius-md);
}
.cardhead h3 { margin: 0; min-width: 0; }
.cardhead + .sub { margin-top: 0; margin-bottom: var(--space-md); }
/* A card that can be changed carries its button in its own heading row, at the far end — so the katibu
   reads what a rule says and acts on THAT rule, without a row of identical buttons at the foot of the
   page to tell apart. */
.cardhead .btn { margin-left: auto; flex: none; }

/* A table's own caption inside a card — one band of a loan-cost ladder, one reason's cost stack. */
.tcap {
  margin: var(--space-md) 0 0.35rem;
  font-size: var(--text-sm); color: var(--muted);
  letter-spacing: var(--tracking-wide);
}

/* A label/value table. The left column is a heading rather than data, so it does not compete with the
   figure beside it, and it is held to one width so two stacked cards line up. */
table.pairs td:first-child { width: 45%; color: var(--muted); }
table.pairs td:last-child { color: var(--heading); font-weight: var(--weight-medium); }
/* A value here sits beside its own label, not in a column of figures to compare down — so it keeps the
   tabular digits and gives up the right edge. Aligning only the amounts right made one card read as two. */
table.pairs td.num { text-align: left; }

/* An actions cell: the buttons stay on one line and the table scrolls instead. */
td.nowrap { white-space: nowrap; }
/* --space-sm, not --space-xs. 8px was set when the button beside it was a ghost — bare text, whose
   ink stops well inside its own box, so the eye read a wider gap than the number said. Now that a row
   action carries the grey fill, 8px is measured from the FILL's edge to the icon's hit area and the two
   read as one crowded lump. 12px is the same gap `.row` uses between any two adjacent controls. */
td.nowrap .btn + .iconbtn,
/* Two buttons in one actions cell, at the SAME gap. The console had its own rule for this at 8px — the
   spacing the note above argues against — so the two surfaces spaced identical-looking cells
   differently. One declaration, one gap. */
td.nowrap .btn + .btn { margin-left: var(--space-sm); }
.btn .icon { margin-block: -0.15rem; }        /* an icon should not make a pill taller than its text */

/* The position of a row in a list the katibu can reorder — a number, so the order is readable and not
   only inferred from where the row happens to sit. */
.rank {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  min-width: 1.5rem; height: 1.5rem; padding: 0 0.35rem;
  font-size: var(--text-xs); font-weight: var(--weight-semibold);
  font-variant-numeric: tabular-nums;
  color: var(--body); background: var(--fill);
  border-radius: var(--radius-full);
}

/* Repeating rows — members, positions, bands, reasons money goes out. */
/* `minmax(0, 1fr)`, not the implicit `auto`: a grid track sized to its content is sized to the LONGEST
   line inside it, so one row carrying a full sentence made every row wider than a phone and took the
   whole page sideways with it. The track now takes the width it is given and the sentence wraps inside. */
.rows { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-sm); }
/* On the canvas a row IS a card — the same border, the same lift — because that is what it looks like
   and a second flatter box beside a card reads as a mistake rather than as a different kind of thing. */
.rows .r {
  display: grid; gap: var(--space-sm);
  padding: var(--space-md);
  border: none; border-radius: var(--radius-lg);
  background: var(--white);
  box-shadow: var(--shadow-card);
  transition: box-shadow var(--transition-fast);
}
/* The card has no border to darken now, so focus is shown by the lift the hover already uses. */
.rows .r:focus-within { box-shadow: var(--shadow-card-hover); }

/* A ROW INSIDE A ROW IS CONTAINED BY IT, AND MUST LOOK IT. The loan-cost sheet nests a cost inside a
   band — "How is the cost worked out?" lives in "Band 1" — and both were a white card with the same
   lift, so the inner one read as the outer one's SIBLING and the band appeared to end where its own
   cost began. A raised card on top of a raised card is also the one place this design system's depth
   stops meaning anything.
   Recessed instead of raised: the tinted ground the fields already sit on, a hairline, no shadow. The
   inner card now reads as a well cut into the outer one, which is what it is. Depth still means one
   thing — nearer the reader is nearer the top of the stack — and nesting is legible without adding a
   third card colour. */
.rows .r .rows .r {
  background: var(--bg-alt);
  box-shadow: inset 0 0 0 1px var(--border-light);
}
.rows .r .rows .r:focus-within { box-shadow: inset 0 0 0 1px var(--border); }
/* Wrapping so a long heading takes a second line instead of pushing the page sideways. It only ever
   fires where the row would otherwise overflow, so every existing row is unchanged. */
.rows .r .head { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.rows .r .head .who { font-weight: var(--weight-semibold); color: var(--heading); flex: 1 1 auto; min-width: 0; }
/* A head whose NAME is typed rather than read — the reasons list, where naming the thing is the first
   job and setting it up is the second. The field takes the room `.who` would have, and loses its own
   bottom margin because the flex row owns the spacing here exactly as `.form-grid` does. */
.rows .r .head .field { flex: 1 1 auto; min-width: 8rem; margin-bottom: 0; }
/* A row whose heading is a tag rather than a name still needs the action pushed to the far end. */
.rows .r .head .spacer { flex: 1 1 auto; }
.rows .r .field { margin-bottom: 0; }

/* A ROW INSIDE A CARD IS A LINE OF THAT CARD, NOT A SECOND CARD.
   The roster was a bordered white box for every member, inside a bordered white card, on a white page —
   three nested things of the same colour, so none of them read as anything. Inside a card the row keeps
   its grouping and gives up its box: a rule between members says the same thing more quietly.
   `.rows` used OUTSIDE a card is untouched and stays boxed. */
.card > .rows { gap: 0; }
.card > .rows > .r {
  padding: var(--space-md) 0;
  background: transparent; box-shadow: none;
  border: none; border-bottom: 1px solid var(--border-light); border-radius: 0;
}
.card > .rows > .r:first-child { padding-top: 0; }
.card > .rows > .r:last-child { border-bottom: none; padding-bottom: 0; }

/* A row that is a RECORD rather than a task. A closed rule request keeps its place in the list — what a
   group asked us and what we told them is theirs to keep, and making the card vanish was read as a bug —
   but it must not compete with the open ones above it for the eye. Dimmed, not hidden: the sentences
   inside stay at full contrast, because they are the reason it is still here. */
.rows .r.done > .head .who, .rows .r.done > p { color: var(--muted); }

/* ══ A conversation ════════════════════════════════════════════════════════════════════════════════
   Two sides, and which side said it has to be readable at a glance without colour doing the work alone —
   each message is NAMED. The tint is a second signal, not the only one.
   Not chat bubbles: these are a handful of careful sentences about a rule, read once, sometimes months
   apart. A left-and-right thread would make a support record look like a phone conversation and would
   waste half the width of a table-shaped screen on empty space. */
.thread { display: grid; gap: var(--space-sm); }
.thread .msg {
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-md);
  background: var(--bg-alt);
  border-left: 3px solid var(--border);
}
.thread .msg.betki { background: var(--accent-light); border-left-color: var(--accent); }
.thread .msghead { display: flex; gap: var(--space-sm); align-items: baseline; margin-bottom: 0.2rem; }
.thread .msghead b { font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--heading); }
/* The sentences a katibu came here to read. `pre-line` and not `pre`: newlines the writer typed are
   meaning, the accidental wrapping of a long line is not. */
.thread .msg p { white-space: pre-line; }

/* ══ Readiness — one verdict, then the work ════════════════════════════════════════════════════════ */
/* The whole answer on one line, tinted the way a banner is, and everything behind it.
   A FINDING IS A ROW, NEVER A PILL. `.tag.wrap` exists because a readiness check was put in a pill and
   then had to be let out of it — a status word's shape carrying a paragraph's content. Ten of those
   stacked is what made this card read as a list of alarms rather than as work to do. So the sentence
   goes in a row and the colour goes on the row's edge, which is the same signal in a shape that fits. */
/* The strip sits between the fields and the button, so it owns the gap to the field grid above it —
   `.form-grid` deliberately owns only its own spacing. */
.readyblock { margin-top: var(--space-md); }
.readystrip {
  display: flex; align-items: flex-start; gap: var(--space-sm);
  width: 100%;
  padding: var(--space-md);
  border: none; border-radius: var(--radius-md);
  font-size: var(--text-sm); font-weight: var(--weight-medium); text-align: left;
  background: var(--fill); color: var(--heading);
  transition: background var(--transition-fast);
}
.readystrip.good { background: var(--good-bg); color: var(--good); }
.readystrip.warn { background: var(--warn-bg); color: var(--warn); }
.readystrip.bad  { background: var(--bad-bg);  color: var(--bad); }
/* The dot the `.tag .led` rule has been describing since it was written, finally on something. */
.readystrip .dot { flex: none; width: 0.5rem; height: 0.5rem; margin-top: 0.4rem; border-radius: 50%; background: currentColor; }
/* Checking. The same node, re-styled rather than swapped: a strip that changed shape mid-check would
   move the sentence beside it every time the katibu touched the date. */
.readystrip .dot.busy {
  width: 0.85rem; height: 0.85rem; margin-top: 0.2rem;
  background: none; border: 2px solid currentColor; border-right-color: transparent;
  animation: spin 700ms linear infinite;
}
.readystrip .say { flex: 1 1 auto; min-width: 0; }
/* The counts are a second reading of the same verdict, so they never take the tone's colour: a green
   "6 in place" beside a red sentence reads as two findings. */
.readystrip .counts { flex: none; font-size: var(--text-xs); font-weight: var(--weight-semibold); opacity: 0.75; }
.readystrip .chev { flex: none; margin-top: 0.1rem; transition: transform var(--transition-fast); }
.readystrip[aria-expanded="true"] .chev { transform: rotate(180deg); }

/* What is wrong, in full, with the way to put it right — and what is already fine, said once. */
.readylist { margin-top: var(--space-sm); }
.readylist[hidden] { display: none; }
/* The severity lives on the edge, not on the words: the title is a heading and reads as one. */
.readylist .r { border-left: 3px solid var(--border-light); }
.readylist .r.wrong { border-left-color: var(--bad); }
.readylist .r.note { border-left-color: var(--warn); }
.readylist .r .head b { font-weight: var(--weight-semibold); color: var(--heading); flex: 1 1 auto; min-width: 0; }
.readylist .r p { font-size: var(--text-sm); }
.readylist .still { margin-top: var(--space-sm); }

/* 2.25rem drawn, 44px tappable: the katibu is using a thumb, standing up, in a meeting. */
.iconbtn {
  position: relative;
  flex: none; width: 2.25rem; height: 2.25rem; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  background: transparent; border: none; border-radius: var(--radius-full);
  color: var(--muted); line-height: 1; cursor: pointer;
  transition: background var(--transition-fast), color var(--transition-fast);
}
.iconbtn::after {
  content: ''; position: absolute; left: 50%; top: 50%;
  width: 44px; height: 44px; transform: translate(-50%, -50%);
}
.iconbtn:hover { background: var(--fill); color: var(--heading); }
.iconbtn.danger:hover { background: var(--bad-bg); color: var(--bad); }
.iconbtn:disabled { opacity: 0.3; cursor: default; }
.iconbtn:disabled:hover { background: transparent; color: var(--muted); }

/* The file drop / pick area on the statement steps. */
/* One row, not a slab. Centred text inside a 200px-tall dashed rectangle is the shape of a page that has
   nothing else on it; this one sits under a heading, above a roster, and is one of several things to do
   on the step. An icon, what it is, and what to give it — read left to right in a line. */
.drop {
  display: flex; align-items: center; gap: var(--space-md);
  padding: var(--space-md);
  text-align: left;
  border: 1.5px dashed var(--border); border-radius: var(--radius-md);
  background: var(--fill);
  cursor: pointer;
  transition: border-color var(--transition-base), background var(--transition-base);
}
.drop:hover, .drop.over { border-color: var(--accent); background: var(--accent-light); }
/* The same shape as .cardhead .ic — one idiom for "an icon in a tinted tile", not two. */
.drop .ic {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem;
  color: var(--accent); background: var(--white);
  border-radius: var(--radius-md);
}
.drop .say { min-width: 0; }
.drop b { display: block; color: var(--heading); font-weight: var(--weight-semibold); }
.drop span { display: block; margin-top: 0.15rem; font-size: var(--text-sm); color: var(--muted); }

/* ══ Modal — a centred card on a desk, a bottom sheet on a phone ═══════════════════════════════════ */
.modal-bg {
  position: fixed; inset: 0; z-index: var(--z-modal);
  display: none; align-items: flex-end; justify-content: center;
  background: rgba(0, 0, 0, 0.35);
  backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
}
.modal-bg.show { display: flex; }
.modal {
  width: 100%; max-height: 92vh; overflow-y: auto;
  padding: var(--space-lg) var(--space-lg) var(--space-xl);
  background: var(--white);
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  box-shadow: var(--shadow-lg);
  animation: sheet-up var(--transition-spring);
}
@keyframes sheet-up { from { transform: translateY(12px); opacity: 0.6; } to { transform: none; opacity: 1; } }
.modal > h3 { margin-bottom: 0.25rem; }
.modal > .sub { color: var(--muted); font-size: var(--text-sm); margin-bottom: var(--space-md); }
.modal:focus { outline: none; }

/* ── A FRAMED SHEET: the head and the foot are pinned, and only the middle scrolls ─────────────────
   The flat card above scrolls as one piece, which is right for a handful of fields and wrong for a
   whole section of a constitution. There the title went off the top, Save sat below the fold with
   nothing saying the sheet had one, and a refusal from the Save button could land off-screen too — a
   form saying it will not save while hiding the reason.
   The head and the foot are `flex: none` SIBLINGS of the scrolling box, so they are held by the layout.
   `position: sticky` would not do: a sticky footer inside a scrolling box still has to be scrolled to
   before it sticks, which is the whole complaint.
   `min-height: 0` on the body is what makes it work at all — a flex item's default floor is its content,
   so without it the body grows to its full height and pushes the foot out of the card. */
.modal.framed { padding: 0; display: flex; flex-direction: column; }
.modal.framed .modal-head {
  flex: none;
  display: flex; align-items: flex-start; gap: var(--space-sm);
  padding: var(--space-md) var(--space-lg);
  background: var(--bg-alt);
  border-bottom: 1px solid var(--border);
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
}
/* The section's own glyph, the same one its card carries on the first-run form — nudged down on to the
   heading's line rather than the block's top edge, which is where `flex-start` would leave it. */
.modal.framed .modal-head > .icon { flex: none; margin-top: 0.15rem; color: var(--accent); }
.modal.framed .modal-head .ttl { flex: 1 1 auto; min-width: 0; }
.modal.framed .modal-head h3 { margin: 0; }
/* CLAMPED ON A PHONE, WHOLE ON A DESK. The head is pinned, so every line it takes is a line the form
   below it never gets back — and this sentence ran to three of them at 390px, a fifth of the sheet
   spent on a caption before the katibu had reached the first field. Two lines on a phone; the rest is
   one tap away on the `title`, and the sentence is a reassurance ("no report you have already shared
   can change") rather than an instruction, so nothing is lost by reading it second.
   Unclamped from 46rem, where it fits in two lines anyway. */
.modal.framed .modal-head .sub {
  margin: 0.2rem 0 0; color: var(--muted); font-size: var(--text-sm); line-height: var(--leading-snug);
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2;
  overflow: hidden;
}
.modal.framed .modal-body {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overscroll-behavior: contain;
  padding: var(--space-lg);
}
/* The foot is already `.form-actions`; inside a framed sheet it stops being a rule under a form and
   becomes the card's own edge, so it takes the padding and gives up the top margin. */
.modal.framed > .form-actions {
  flex: none; margin: 0;
  padding: var(--space-md) var(--space-lg);
  background: var(--white);
  border-top: 1px solid var(--border);
}
/* Two fields of paperwork — WHEN a version starts and which meeting agreed it — against the section of
   the constitution the katibu actually opened the sheet for. The quiet ground is what says which is
   which; without it both parts were the same white and the seam alone had to carry it. */
.form-part.compact {
  padding: var(--space-md); margin-bottom: var(--space-md);
  background: var(--bg-alt); border-radius: var(--radius-md);
}
.form-part.compact + .form-part { border-top: none; padding-top: 0; margin-top: 0; }
/* And the way out of a section that cannot say what the group agreed is not part of the rule either. */
.form-part.aside { background: var(--bg-alt); border-radius: var(--radius-md); padding: var(--space-md); border-top: none; }
.form-part.aside .reqline { margin-top: 0; padding-top: 0; border-top: none; }

/* A loan type, a fine or a band inside a rules sheet is a whole small form, not a line — it carries a
   name, a price, a term, a schedule and sometimes a ladder. It gets the room to say so.
   Scoped to `.rules-form` deliberately: `.rows` is shared with the members roster, the requests list,
   the message thread and the console, and giving all of them this padding would turn every list on the
   desk into a much longer page. `.card > .rows > .r` on the first-run form is a line of a card and
   keeps its hairline — this is the sheet, where the row IS the card. */
.rules-form .rows > .r { padding: var(--space-lg); gap: var(--space-md); }
.rules-form .rows > .r > .head { gap: var(--space-md); }
/* The name is what the row is about, so it leads at the size of a heading rather than at the size of
   the chip beside it. */
.rules-form .rows > .r > .head > .who { font-size: var(--text-base); }
/* An open loan type reads as nested rather than as the next thing down the page. The hairline is the
   same idiom `.form-part` uses for a seam, turned on its side. */
.rules-form .rows > .r > .form-part {
  margin-top: var(--space-md); padding-top: var(--space-md);
  border-top: 1px solid var(--border-light);
}

/* The door's sign-in / register dialog. A bottom sheet on a phone like every other modal here, a
   centred card on a desk — and WIDER when registering, because that form is nine fields and a narrow
   column would make it a mile long on the one screen where there is room to lay it across. */
.modal.auth { position: relative; padding-top: var(--space-xl); }
/* Sign in still carries a line under its heading; registering no longer does. So the heading owns the
   gap, and gives most of it back when a sub-line is there to fill it. Where :has() is not supported the
   heading simply keeps the larger gap — roomier than intended, never broken. */
.modal.auth h2 { font-size: var(--text-xl); margin-bottom: var(--space-lg); }
.modal.auth h2:has(+ .sub) { margin-bottom: 0.3rem; }
.modal.auth .sub { color: var(--muted); font-size: var(--text-sm); margin: 0.3rem 0 var(--space-lg); }
.modal-close { position: absolute; top: var(--space-sm); right: var(--space-sm); z-index: 1; }
/* The page behind a sheet must not scroll with it — on a phone that reads as the form falling apart. */
body.modal-open { overflow: hidden; }

/* ══ Spinner ═══════════════════════════════════════════════════════════════════════════════════════ */
.spinner {
  display: inline-block; width: 1rem; height: 1rem;
  border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%;
  animation: spin 700ms linear infinite;
  vertical-align: -0.15rem;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ══ The public door ═══════════════════════════════════════════════════════════════════════════════ */
.door { min-height: 100vh; display: flex; flex-direction: column; }
/* A LIGHT HERO. The dark saturated band was the loudest thing in the product and it was the first thing
   a visitor saw; Dasher has no dark band anywhere, and neither does anything else this reference points
   at. Ink on a near-white wash, and the colour is spent on the two buttons and nothing else. */
.hero {
  position: relative; overflow: hidden;
  /* The bottom padding is deep on purpose: it is the room the mark stands in, below the buttons and
     clear of every sentence. On a desk the mark moves to the right of the copy and this goes back to an
     ordinary section's spacing — see the 64rem rule. */
  padding: calc(var(--header-height) + var(--space-2xl)) var(--pad) calc(var(--space-2xl) + 7rem);
  color: var(--heading);
  background: linear-gradient(170deg, var(--border-light) 0%, var(--bg-alt) 38%, var(--white) 100%);
}
/*
 * THE ART IS THE PRODUCT'S OWN MARK, USED AS THE HERO'S GROUND.
 *
 * Two records meeting on a green check: the book on the left, the statement on the right, and the tie
 * between them. It is the single picture this whole product is about, and it is the same image the promo
 * film signs off with — a visitor who watched the video and then opened the site should recognise that
 * they have arrived at the right place before reading a word.
 *
 * It replaced a hand-drawn SVG chart that traced itself on load. The chart was well made and said
 * something true, but it said the same thing every SaaS hero says; the mark says what THIS is.
 *
 * SIZED BY HEIGHT, ANCHORED RIGHT, AND THAT IS THE WHOLE RESPONSIVE STORY. `cover` would work on a
 * laptop, where the band is almost exactly the image's own 2.6:1, and destroy it on a phone, where it
 * would blow one document up to four times its size to fill a portrait box. Height-sizing keeps the two
 * records whole and legible at every width; the right anchor keeps them out of the copy, which is
 * always set from the left.
 *
 * IT IS WALLPAPER AND IT HAS TO KNOW IT. The mask clears the left of the band entirely, where the words
 * are, and fades along the bottom edge where the next section starts. A first pass had the mark at full
 * strength beside the headline and it read as a second headline — the difference between a background
 * and a thing in the way.
 *
 * The small file is the default and the large one is only fetched where there are pixels to want it:
 * 900×346 against 2400×924, the same picture, and a phone on a Tanzanian mobile network should not pull
 * the big one to draw it 300px wide.
 */
.hero-art {
  position: absolute; inset: 0; width: 100%; height: 100%;
  z-index: 0; pointer-events: none;
  opacity: 0.9;
  background-image: url('./hero-3d-sm.jpg');
  background-repeat: no-repeat;
  /*
   * ON A PHONE THE MARK GETS ITS OWN ROOM, IT DOES NOT SHARE THE COPY'S.
   *
   * On a desk the clearing is horizontal: the copy is set to 34rem and the mark stands to the right of
   * it. A phone has no right of it — the lede runs the full width — so the first pass put the mark
   * directly behind "checked against your own M-Koba statement", which is the difference between a
   * background and a thing in the way, and this stylesheet already says so twenty lines up.
   *
   * So on a narrow screen the clearing is VERTICAL instead: the band carries extra bottom padding, the
   * mark sits in it, and the mask fades away everything above the halfway line. Nothing is ever drawn
   * behind a sentence at any width — the axis the clearing runs along is the only thing that changes.
   */
  background-size: auto 24%;
  background-position: right -8% bottom 5%;
  -webkit-mask-image: linear-gradient(to bottom, transparent 48%, #000 70%), linear-gradient(to right, transparent 12%, #000 40%);
  mask-image: linear-gradient(to bottom, transparent 48%, #000 70%), linear-gradient(to right, transparent 12%, #000 40%);
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
  /*
   * IT ARRIVES ONCE, THEN DRIFTS. The mark fades up and settles from a hair below and a hair large —
   * about 900ms — and after that nothing but a drift too slow to catch. A hero that keeps moving keeps
   * taking the eye off the sentence beside it, and this page has one job: get a katibu to the register
   * button.
   *
   * The entrance is `both`, so the global reduced-motion rule flattens its duration to nothing and it
   * lands on its finished state. The drift is infinite and must be killed by NAME instead — an infinite
   * animation running in 0.01ms is not a stillness, it is the same loop as fast as the machine can go.
   */
  animation: kd-art-in 900ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both,
             kd-drift 22s ease-in-out 2s infinite alternate;
}
@keyframes kd-art-in { from { opacity: 0; transform: translate3d(0, 14px, 0) scale(1.04); } }
/* 22 seconds for 14 pixels: present, and never something a visitor can catch happening. */
@keyframes kd-drift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(0, -14px, 0); } }

/* The copy arrives with it — the same easing as the art, so the band settles as one thing. */
.hero .eyebrow { animation: kd-up 600ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.hero h1       { animation: kd-up 700ms cubic-bezier(0.22, 1, 0.36, 1) 80ms both; }
.hero .lede    { animation: kd-up 700ms cubic-bezier(0.22, 1, 0.36, 1) 180ms both; }
.hero .cta     { animation: kd-up 700ms cubic-bezier(0.22, 1, 0.36, 1) 280ms both; }
@keyframes kd-up { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }

/* NOT POSITIONED, deliberately. `.hero-art` is a child of this now and is `position:absolute; inset:0`
   below 64rem — it has to resolve against `.hero`, whose relative position is what gives the mark the
   whole band including the deep bottom padding it stands in. Positioning this box instead would shrink
   the mark's room to the height of the words. The stacking is done by `.hero-copy` instead. */
.hero .inner { max-width: var(--container); margin: 0 auto; }
/* The art is the LAST child, so it paints over the copy unless the copy is raised out of the flow's
   painting order. One line here rather than four on the individual elements. */
.hero-copy { position: relative; z-index: 1; }
.hero h1 { color: var(--heading); font-size: var(--text-4xl); font-weight: var(--weight-bold); letter-spacing: -0.03em; }
.hero .lede { margin-top: var(--space-md); font-size: var(--text-lg); color: var(--body); max-width: 34rem; }
.hero .cta { margin-top: var(--space-xl); display: flex; gap: var(--space-sm); flex-wrap: wrap; }
/* The line that makes the NAME work. "KikundiDesk" alone in the corner says nothing to somebody who
   arrived from a WhatsApp link; this says what a desk IS, right above the headline. */
.hero .eyebrow {
  margin-bottom: var(--space-sm);
  color: var(--accent-ink); font-size: var(--text-sm); font-weight: var(--weight-bold);
  letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
/* (`.btn.white` and `.btn.onDark` lived here. They were a white pill and a translucent one, and both
   existed only because the hero was a dark gradient. On a light hero one is invisible and the other is
   nearly so, and the door's two calls to action are simply .primary and .secondary now.) */

.section { padding: var(--space-section) var(--pad); }
.section.alt { background: var(--bg-alt); }
.section .inner { max-width: var(--container); margin: 0 auto; }
.section h2 { margin-bottom: var(--space-lg); font-size: var(--text-2xl); letter-spacing: var(--tracking-tight); }
/* ── What the visitor gets ───────────────────────────────────────────────────────────────────────── */
/* (.steps lived here — the numbered how-a-month-goes cards. The section went when the door stopped
   arguing about a monthly chore and started arguing about where the group's record lives; the rules
   went with it rather than sitting unused waiting for a list that is not coming back.)
   These four are not an order, they are four reasons, so they carry an icon and never a number. */
.section .lede { max-width: 42rem; margin: -0.5rem 0 var(--space-xl); color: var(--muted); font-size: var(--text-lg); }
.values { display: grid; gap: var(--space-md); list-style: none; margin: 0; padding: 0; }
.values li {
  padding: var(--space-lg);
  background: var(--white); border: none; border-radius: var(--radius-lg);
  box-shadow: var(--shadow-card);
  transition: transform var(--transition-spring), box-shadow var(--transition-spring);
}
.values li:hover { transform: translateY(-3px); box-shadow: var(--shadow-card-hover); }
.values b { display: block; color: var(--heading); font-size: var(--text-lg); font-weight: var(--weight-semibold); margin-bottom: 0.35rem; }
.values span { color: var(--body); line-height: var(--leading-normal); }
/* The icon is the card's mark, not its meaning: the title says what the card is and the icon agrees
   with it. Remove every one and the page still reads the same. */
.values .ic {
  display: flex; align-items: center; justify-content: center;
  width: 2.5rem; height: 2.5rem; margin-bottom: var(--space-sm);
  color: var(--accent-ink); background: var(--accent-light);
  border-radius: var(--radius-md);
}

/* One more invitation at the foot of the page, for the visitor who read to the end. It is centred at
   every width: one heading, one line and one button left-aligned against an empty right-hand side
   reads as a section whose content failed to load. */
/* A SECTION ARRIVES AS IT IS REACHED. Guarded by `body.reveal-ready`, which the door's own script only
   sets when it has an IntersectionObserver to unset it with — so with no JavaScript, or on a browser
   without the observer, nothing is ever hidden. A reveal that can strand the page blank is not an
   effect, it is a page that failed to load. Under prefers-reduced-motion the global rule flattens the
   transition and each section simply appears. */
body.reveal-ready .reveal-on-scroll {
  opacity: 0; transform: translateY(16px);
  transition: opacity 600ms cubic-bezier(0.22, 1, 0.36, 1), transform 600ms cubic-bezier(0.22, 1, 0.36, 1);
}
body.reveal-ready .reveal-on-scroll.in { opacity: 1; transform: none; }

.cta-end { margin-top: var(--space-xl); display: flex; justify-content: center; }
.section .inner.end { text-align: center; }
.section .inner.end .lede { margin-left: auto; margin-right: auto; }
.compare .then span { color: var(--muted); line-height: var(--leading-snug); }
.compare .now span  { color: var(--heading); font-weight: var(--weight-medium); line-height: var(--leading-snug); }

.door footer { margin-top: auto; padding: var(--space-xl) var(--pad); background: var(--bg-dark); color: rgba(255, 255, 255, 0.55); font-size: var(--text-sm); }
.door footer .inner { max-width: var(--container); margin: 0 auto; display: flex; gap: var(--space-md); flex-wrap: wrap; align-items: center; }
.door footer a { color: rgba(255, 255, 255, 0.8); }
/* "Ask us" lives down here, and the footer is the one dark ground on the door. `.btn.link` is
   --accent-ink — an emerald chosen to read on white, which on this bar is dark on dark. So the footer's
   button follows the footer's own links rather than the button vocabulary's colour, and the padding goes
   with it: beside a line of plain text, a button's inset reads as a misalignment. */
.door footer .btn.link { color: rgba(255, 255, 255, 0.8); padding: 0; min-height: 0; }
.door footer .btn.link:hover { color: var(--white); }

/* THE DOOR'S HEADER IS NOW THE DESK'S HEADER. It used to be a dark translucent bar because it sat on a
   dark gradient, and a light bar over that read as two pages stacked. The hero is light, so the whole
   override collapses to the base .topbar and only two things are left to say.
   The sign-in button must be FILLED: the base .btn is --bg-alt on --heading, which is a near-white chip
   on a near-white bar — the one place on the door where a visitor looks for a way in, and it would be
   invisible. Dasher solves the same problem the same way; its "Portal" is the one filled thing in the nav. */
.door .topbar .btn.sm { background: var(--accent-ink); color: var(--white); }
.door .topbar .btn.sm:hover { background: #006c5a; }
/* The link button beside Sign in. `.btn.link` carries no fill, so the one filled thing in the header is
   still the one thing the header wants pressed.
   NOT ON A PHONE. A 390px bar already holds the wordmark, the language pair and Sign in; a fourth
   control there is what cut "KikundiDesk" to "KikundiD…", and the name of the product losing its last
   three letters to a link is a bad trade. The footer carries it down there, and so does the foot of
   whichever form they are looking at — which is where the question usually gets asked anyway. */
.door .topbar .btn.link { display: none; padding: 0.35rem 0.5rem; background: transparent; color: var(--body); }
.door .topbar .btn.link:hover { background: transparent; color: var(--accent-ink); }

/* The sign-in / create-a-group card, used on the door and on the verify page. */
.auth-wrap { padding: var(--space-2xl) var(--pad) var(--space-2xl); background: var(--bg-alt); }
.auth-card {
  max-width: 26rem; margin: 0 auto;
  padding: var(--space-xl);
  background: var(--white);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-card);
}
/* Sign in is two fields and stays a narrow card. Creating a group is nine, laid out two across, and
   needs the room — so the width is a modifier the form asks for, not a change to the card.
   (This used to add "and the admin console's login uses it too". It does not: there is ONE door since
   2026-08-02, and public/admin/ has no sign-in form at all — it walks you back to ../kikundi/.) */
.auth-card.wide { max-width: 44rem; }
.auth-card h2 { font-size: var(--text-xl); }
.auth-card > .sub { color: var(--muted); font-size: var(--text-sm); margin: 0.3rem 0 var(--space-lg); }
.auth-card .alt-action { margin-top: var(--space-lg); padding-top: var(--space-md); border-top: 1px solid var(--border-light); font-size: var(--text-sm); color: var(--muted); text-align: center; }
.tabs { display: flex; background: var(--fill); border-radius: var(--radius-md); padding: 3px; margin-bottom: var(--space-lg); }
.tabs button {
  flex: 1 1 0; border: none; background: transparent; color: var(--muted);
  font-size: var(--text-sm); font-weight: var(--weight-semibold);
  padding: 0.5rem 0.75rem; border-radius: var(--radius-sm);
  transition: background var(--transition-fast), color var(--transition-fast);
}
.tabs button.on { background: var(--white); color: var(--heading); box-shadow: var(--shadow-sm); }

/* ══ Desk ══════════════════════════════════════════════════════════════════════════════════════════ */
@media (min-width: 46rem) {
  :root { --header-height: 3.5rem; }
  .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-md); }
  /* THE HERO TILE AND ITS SPAN HAVE GONE WITH "The group is worth". That figure is the donut's centre
     now, its two halves are the donut's slices with their own figures beside them, and where it has BEEN
     is the "What your group has been worth" card — so all three parts of the tile survive somewhere
     better, and the strip is five equal tiles with nothing leading it.
     Five into three is 3 + 2, which leaves one gap on the second row rather than the four-column grid's
     4 + 1. The donuts above are what the eye enters on. */
  .kpis.five { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .rows .r { grid-template-columns: 1fr; }
  .modal-bg { align-items: center; }
  .modal { max-width: 32rem; border-radius: var(--radius-xl); }
  .modal.auth { padding: var(--space-xl); }
  .modal.auth.wide { max-width: 44rem; }        /* nine fields, two across — same rule as .auth-card.wide */
  /* Room for the whole sentence: the clamp exists for the phone, where the pinned head was eating the form. */
  .modal.framed .modal-head .sub { -webkit-line-clamp: none; line-clamp: none; overflow: visible; }
  /* A rules section is a form, not a question: a loan type per card, each with its own price, term,
     schedule and band ladder. Same reasoning as .auth.wide — on a desk there is room to lay it across,
     and a narrow column turns it into a mile of scrolling.
     THIS USED TO BE `.modal:has(.rules-form)`, which is the one thing a sizing rule must not be. Where
     :has() is unsupported it did nothing at all, silently, so the sheet that most needed the width was
     the one whose width could vanish without a trace. It is a class the sheet asks for now. */
  .modal.lg { max-width: 48rem; }
  /* The band is TALL enough for the art to be a ground rather than a stripe: much below this the line
     and the bars sit on top of one another and the composition stops meaning anything. */
  .hero {
    padding-top: calc(var(--header-height) + var(--space-3xl));
    padding-bottom: calc(var(--space-3xl) + 5rem);
    min-height: 34rem; display: flex; align-items: center;
  }
  .hero .inner { width: 100%; }
  .hero h1, .hero .lede { max-width: 34rem; }
  /* THE NAME HAS TO READ AS A NAME — from here up, where there is room for it. `.topbar .brand b` is
     --text-base at every width, which is right on the desk and on the console because there it is the
     GROUP's name, a label on a tool somebody is already inside. On the door it is the product being
     introduced to a stranger. Scoped to `.door` for exactly that reason: unscoped, the same selector
     would inflate every group's name on every screen of the desk. */
  .door .topbar .brand b { font-size: var(--text-lg); letter-spacing: -0.03em; }
  .door .topbar .btn.link { display: inline-flex; }
  .whoami { display: inline; max-width: 18rem; }
  .values { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* TWO COLUMNS, AND A MEASURE. The columns were `1fr` against whatever the sheet happened to be, so
     in a 62rem loan-setup sheet each cell was ~470px: a date box sat alone at the left of one while
     the field beside it began 470px away, which is not two questions side by side, it is two questions
     at opposite ends of a room. A form is a column of questions and keeps a measure, for the reason
     `.wizard` and every paragraph on this desk do. The SHEET stays as wide as it is — the loan cards
     and their band ladders need that room — and only the questions are held in — at a measure
     where a box and the one beside it still read as a PAIR: at 44rem a 176px date sat in a
     340px cell and the field beside it began 188px away, which is a canyon, not a pair. */
  .form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); max-width: 36rem; }
  /* EXCEPT ON THE DOOR, where the card was already sized to its own grid:
     `.auth-card.wide` and `.modal.auth.wide` are 44rem BECAUSE the nine registration fields
     are, so capping the grid inside them just puts 128px of nothing down the right-hand side of
     the card. The cap is for a form sharing a sheet with something WIDER than itself, which is
     never true here. */
  .modal.auth .form-grid, .auth-card .form-grid { max-width: none; }
  /* Room for the names: the track becomes four labelled stops rather than four bars. It stays on its own
     line UNDER the head rather than being squeezed into 18rem beside it — four step names do not fit in
     18rem in English and are half again as long in Kiswahili. */
  .stepper { gap: var(--space-xs); }
  .stepper .s {
    display: flex; align-items: center; justify-content: center; gap: var(--space-xs);
    height: auto; min-height: 1.75rem; padding: 0.25rem var(--space-xs);
    background: transparent;
    border-bottom: 0.25rem solid rgba(0, 0, 0, 0.12); border-radius: 0;
  }
  .stepper .s .icon { display: block; }
  .stepper .s .nm {
    display: block; min-width: 0;
    font-size: var(--text-xs); font-weight: var(--weight-medium);
    line-height: var(--leading-snug);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }
  .stepper .s.done { background: transparent; border-bottom-color: var(--accent); color: var(--accent); }
  .stepper .s.now { background: transparent; opacity: 1; border-bottom-color: var(--accent); color: var(--heading); }
  .stepper .s.now .nm { font-weight: var(--weight-semibold); }
}

/* Its own step, and not folded into 64rem: 64rem is where the SIDEBAR becomes permanent, which takes
   15rem off the width a centred sheet has to sit in. A wide sheet may only grow once there is room for
   it beside a menu that is now always there. */
@media (min-width: 75rem) {
  /* 54rem, DOWN FROM 62. The width was set while a band ladder stretched to whatever room it was given
     and a row of fields spread to the edges of it, so the sheet was made wide enough to survive both.
     With the ladder capped at its natural size and `.cols` packing to the left, the widest thing in
     here is three fields and a gap — about 700px — and the rest was dead space that made two questions
     read as two unrelated halves of a room. A sheet is as wide as its content, not as wide as it can
     get away with. */
  .modal.lg { max-width: 54rem; }
}

@media (min-width: 64rem) {
  /*
   * THE MARK MOVES FROM UNDER THE COPY TO BESIDE IT, and this is the width where that becomes true.
   *
   * IT IS A COLUMN NOW, NOT WALLPAPER. It used to be `position:absolute; inset:0` across the whole
   * viewport while the copy sat in a 1100px centred box — two coordinate systems that never agreed, and
   * every complaint about this band came out of that one fact. On a 1920 screen it left ~380px of dead
   * gutter at the left, ~560px of empty container between the sentence and the picture, and then masked
   * away the picture's own left third to stop it running under words it was nowhere near. The mark this
   * whole product is about arrived cropped, small, and adrift.
   *
   * Two columns of ONE grid instead. The copy gets a real measure (about 40rem, set by its column rather
   * than clamped inside a box three times its width), the mark gets the larger share because it is the
   * thing being shown, and the gap between them is a stated 2rem instead of whatever was left over.
   *
   * THE LEFT MASK GOES WITH IT. It existed only to keep the art off the copy, and a grid keeps them off
   * each other by construction. Dropping it is most of "make the picture bigger" — a third of the image
   * was simply being erased. What is left is a soft bottom fade, because the band's gradient ends in
   * white and the JPG's own ground should not meet it at a ruled line.
   */
  .hero { padding-bottom: var(--space-3xl); }
  .hero .inner {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 0.9fr);
    align-items: center;
    gap: var(--space-2xl);
    max-width: var(--container-wide);
  }
  /* The column is the measure now — 34rem inside a column two thirds this wide would put the clamp back
     and re-open a gap in the middle of the copy's own column. 40rem is a line of prose; the headline is
     let out to the column, which is what makes it read as a headline rather than a paragraph in bold. */
  .hero h1 { max-width: none; }
  .hero .lede { max-width: 40rem; }
  /*
   * THE BOX IS CROPPED TO THE MARK, and the numbers below are measured off the file rather than chosen.
   *
   * hero-3d.jpg is 2400×924 and the two records occupy only x 71–97%, y 28–77% of it — the rest is
   * empty ground, which is why the old rule masked the left away instead of showing the picture. Drawn
   * whole (`contain`) the mark comes out about a quarter of its own column: the picture would be bigger
   * on the page and smaller to look at, which is the opposite of the point.
   *
   * So the box is cut to the MARK's own shape — 618×450 source pixels, 1.45:1 with a little air — and
   * the picture is oversized until the mark fills it. Both numbers are then forced, not judged:
   *
   *   box W = 1.45H.  `auto 190%` ⇒ image is 1.9H tall, 4.94H wide (the file is 2.6:1).
   *   the mark is 25.75% of that width and 48.7% of that height ⇒ 1.27H × 0.93H, i.e. 88% of the box
   *   across and 92% of it down.
   *   its centre sits at 4.14H from the image's left and 0.99H from its top; a percentage
   *   background-position aligns like with like, so p = 3.42/3.49 across and 0.49/0.90 down.
   *
   * Everything outside is clipped rather than masked. Height is clamped rather than taken from the
   * column, because a picture sized purely by a 1480px grid makes a 750px hero and pushes the reason
   * anybody should care off the bottom of the screen.
   */
  .hero-art {
    position: relative; inset: auto;
    height: clamp(17rem, 26vw, 24rem); width: auto; aspect-ratio: 1.45;
    margin-inline: auto;
    opacity: 1; overflow: hidden;
    background-image: url('./hero-3d.jpg');
    background-size: auto 190%;
    background-position: 98% 55%;
    /* The file's ground is pure white and the band's gradient is not, so the crop would otherwise show
       as a faint rectangle. A fade on all four edges rather than a ruled line — both layers intersect
       under the `mask-composite` the base rule already sets. */
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 12%, #000 88%, transparent 100%),
                        linear-gradient(to right, transparent 0, #000 10%, #000 92%, transparent 100%);
    mask-image: linear-gradient(to bottom, transparent 0, #000 12%, #000 88%, transparent 100%),
                linear-gradient(to right, transparent 0, #000 10%, #000 92%, transparent 100%);
  }
  /* The sections take the hero's measure so the page reads as ONE grid. A 1480px band sitting over a
     1100px one steps in and out down the page, which is the other half of why this looked unfinished.
     Prose keeps its own 42rem inside it — a 1480px line of explanation is not easier to read. */
  .section .inner { max-width: var(--container-wide); }
  .door footer .inner { max-width: var(--container-wide); }
  /* THE CARDS PAIR UP ONLY ONCE THE SIDEBAR IS PERMANENT, which is the same width that released
     `.content` to the full screen and so the width that created the emptiness in the first place.
     A chart that draws at 760–820px natural width takes the whole row: halving the column would put a
     scrollbar under every chart on the screen to save space beside two of them. */
  .home-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  /* THREE ACROSS WHERE THERE ARE THREE, and it is the COUNT that asks for it — `.kpis.five`'s own rule,
     never the screen's name. The rule above is about charts whose NATURAL width is 760-820px and which
     therefore cannot be halved without putting a scrollbar under every one of them. A donut is the one
     card on this desk whose natural width is 168px: `dnutCard` sizes the ring `min(168, max(96, w - 8))`,
     which is 168 in a third of the content at 1024, 1280 and 1440 alike, so it is never scaled and never
     scrolled. What gets tighter is the LEGEND, and a legend wraps rather than truncating. */
  .home-grid.three { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .home-grid > .wide { grid-column: 1 / -1; }
  /* A pair stretches to the taller of the two. Left to itself the collection card is half the height of
     the chart beside it and the row reads as a card and a gap rather than as a pair. */
  .home-grid > .collection .collection-body { height: 100%; }
  .navtoggle { display: none; }
  .app { padding-left: var(--nav-width); }
  .nav { transform: none; box-shadow: none; }
  .scrim { display: none; }
  .content { padding: var(--space-xl) var(--space-xl) var(--space-4xl); }
  /* The desk is a data application, not a page of prose: once the sidebar is permanent there is a fixed
     column of screen to its right and a table should have all of it. `--container` is the marketing
     site's measure and it left 100px of a 1440px screen doing nothing at all.
     Prose keeps its own measure below, because a 1100px line of explanation is not easier to read for
     being 1400px long — only the tables grow. */
  .content { max-width: none; }
  .content > header .sub, .card > .sub, .card > h3 + .sub { max-width: 78ch; }
  .table-wrap { margin: 0; padding: 0; }
}

/* ── WHERE FIVE TILES BECOME FIVE COLUMNS ──
   Five divides into one column or five and nothing else, so between the phone's stack and here the strip
   is 3 + 2 and carries one gap. That gap is the cost of the count, not a layout mistake — and it is paid
   for as early as it honestly can be.
   80rem is where five stop being cramped: each tile holds a figure, a sub-line and a six-month sparkline,
   which wants about 180px, and 5 x 180 plus the gaps is roughly the 970px of content a 1306px window has
   once the permanent sidebar and the page padding are taken off. Earlier than this and the sparkline is
   drawing a half-year into 140px; later and the strip sits on a hole for another 160px of screen. */
@media (min-width: 80rem) {
  .kpis.five { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}

/* A full desk has room the laptop did not: let a long name have it rather than cutting a name that fits. */
@media (min-width: 90rem) {
  /* Four values, four columns — but only here. At a laptop's width four of these is 230px each holding a
     four-line paragraph, which is a column of words one at a time. Two-up is right until there is room. */
  .values { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  td.who b { max-width: 15rem; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
  /* Killed by NAME, not by duration. Every other animation here runs once with `both`, so a duration of
     nothing lands it on its finished state — which is exactly what "no motion" should look like. The
     hero's drift is infinite, and an infinite animation flattened to 0.01ms is not a stillness, it is
     the same loop running as fast as the machine can manage. Naming the element drops its entrance too,
     which is right: the mark's base rule IS its finished state, so it simply arrives already there. */
  .hero-art { animation-name: none; }
}

/* ══ The constitution, being written ═══════════════════════════════════════════════════════════════
   THE LONGEST FORM IN THE PRODUCT WAS THE ONE FORM WITH NO MEASURE.

   `drawFirstRun` built a class-less div, `.content` drops its `max-width` at 64rem, and `.wizard`'s
   62rem cap is applied only by the wizard — so on a 1440px monitor a yes/no question was drawn as two
   730px slabs, a `select` whose answer is one phrase ran the full width of the card, and the payment
   order put a rank badge and the word "Fine" 1300px away from the two arrows that move it.

   `.wizard`'s own comment above already reasoned this out — "it stretched a one-digit share count
   across a third of a 1440px monitor". Then the constitution was moved OUT of the wizard, onto the
   Rules screen, and left the cap behind. This is that cap given back, and almost everything else in
   this block is a consequence of having one.

   The width that comes back is spent on a RAIL rather than on wider controls: where you are, what is
   still open, and the date the whole version takes effect. A katibu answers this standing up, in a
   meeting, once — losing your place in it is the failure worth designing against. */
.setup { display: grid; gap: var(--space-lg); }
.setup-main { min-width: 0; }
/* Below the permanent sidebar there is no room for a second column, and the plan card at the top of
   the form is already doing the rail's job of saying what this group has to answer. */
.setup-rail { display: none; }

@media (min-width: 66rem) {
  .setup { grid-template-columns: minmax(0, 46rem) minmax(13rem, 17rem); gap: var(--space-2xl); align-items: start; }
  .setup-rail { display: block; position: sticky; top: calc(var(--header-height) + var(--space-lg)); }
}

/* A NUMBERED PART, AND WHERE IT STANDS.
   Seven cards in one scroll read as a pile; numbered, they read as a constitution with parts — and it
   is the same numeral the rail shows, so the two cannot disagree about which section is which. */
.cardhead .n {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 1.5rem; height: 1.5rem;
  font-size: var(--text-xs); font-weight: var(--weight-bold); font-variant-numeric: tabular-nums;
  color: var(--muted); background: var(--fill);
  border-radius: var(--radius-full);
}
.cardhead h3 { min-width: 0; }
/* THREE STATES AND NOT TWO. A chip reading "Answered" over a section that merely carries a working
   default would tell a katibu their group has decided something it has not — the same failure
   `year.sharesOut` exists to prevent. "Using the standard" is `.tag.dim` on purpose: it is not a
   warning, and colouring it like one would train the katibu to ignore the ones that are. */
.cardhead .state { margin-left: auto; }

/* THE PLANNING CARD IS A DIFFERENT KIND OF THING FROM THE SECTIONS BELOW IT.
   It decides whether each of those cards is drawn at all, so it must not look like the sixth of
   seven. Recessed rather than raised: the white cards are the constitution, this is what selects
   which parts of it get written. Same well the nested rows already use, so it is not a new depth.
   And each question is ONE LINE — a label and a two-word answer do not need a stacked block, and
   five stacked blocks was most of a phone screen before anything had been decided. */
.card.plan { background: var(--bg-alt); box-shadow: inset 0 0 0 1px var(--border); }
.plan-q { padding: var(--space-sm) 0; border-top: 1px solid var(--border); }
.plan-q .line { display: flex; align-items: center; gap: var(--space-md); }
.plan-q .line > span { flex: 1 1 auto; min-width: 0; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--heading); }
.plan-q .line .choices { flex: none; }
/* The spacing this used to set now belongs to the focused state above: the node is out of flow in
   both states, so a top margin on the clipped one positioned nothing. Kept as the inline reading's
   rule, which is the one case where it is still a paragraph under a control. */
body.kd-explain .plan-q > .help { margin-top: 0.4rem; }

/* AN EMPTY STATE IS A PLACE, NOT A GAP.
   "This one costs nothing." was centred grey text floating in `.empty`'s 2rem of padding, with the
   button that answers it a separate full-width bar underneath — so the sentence and the only way out
   of it read as two unrelated things. They are one thing. Same recess as `.rows .r .rows .r`, which
   is the depth vocabulary this system already has for "cut into the card". */
.well {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-sm);
  padding: var(--space-lg) var(--space-md); text-align: center;
  background: var(--bg-alt); border-radius: var(--radius-md);
  box-shadow: inset 0 0 0 1px var(--border-light);
}
.well p { max-width: 46ch; font-size: var(--text-sm); color: var(--muted); }

/* AN ORDERED LIST OF SHORT LABELS DOES NOT NEED THE PAGE'S WIDTH.
   `.rows .r .head .who` is `flex: 1 1 auto`, so the word "Fine" absorbs every pixel of slack and
   shoves the two arrows to the far edge — for a control whose whole job is "move this up one".
   OPT-IN, and it has to be: `.rows` is shared with the members roster, the bands, the reasons, the
   requests list, the message thread and the console, and a global change reshapes all of them.
   The cap is on the ROW and not on its head, so the hairline between rows stops where the content
   does: a rule running on for another 250px past the last arrow reads as a table that lost its
   columns, rather than as a short list. */
.rows.ordered .r { max-width: 28rem; }

/* A FROM–TO PAIR IS ONE CONTROL, NOT TWO FIELDS.
   The sharing window was two independent fields in a two-column grid, the second labelled with the
   bare word "and" — which labels nothing on its own — and the sentence explaining BOTH dates hung
   under the right-hand one only, with the left column empty beneath it. */
.range { display: flex; align-items: center; gap: var(--space-sm); flex-wrap: wrap; }
.range .field { margin-bottom: 0; }
.range .sep { font-size: var(--text-sm); color: var(--muted); }

/* A CONTROL WHOSE ANSWER IS ONE PHRASE IS NOT AS WIDE AS THE CARD.
   The `.w-xs/.w-sm/.w-md` ladder above targets `input` only, so a `select` had no cap available to it
   at all — which is the whole of why the share-out method dropdown ran the full width of a monitor. */
.field.w-lg input, .field.w-lg select { max-width: 26rem; }
.field.w-sm select, .field.w-md select { max-width: 16rem; }

/* TWO SENTENCES READ AS A PAIR, NOT AS TWO BILLBOARDS.
   Safe without a scope: `.choices.pair` has exactly one non-switch call site, and `.switch` overrides
   `display` to inline-flex, so the template columns it would otherwise inherit are inert there. */
.choices.pair:not(.switch) { max-width: 44rem; }

/* THE WAY OUT IS NOT A THIRD ANSWER.
   "Our rule is not one of these" sat immediately under the last option with nothing between them, so
   in a card whose content IS a list of options it read as one more thing you could pick. The same
   hairline `.form-part`, `.form-actions` and `.choice-actions` already use to say "a different kind
   of thing follows". */
.reqline { margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--border-light); }

/* THE RAIL. Not decoration: it answers "how much of this is left" without scrolling, which on a form
   this long is the question a katibu actually has. The ring is `kikundi-charts.js`'s own `ring()`,
   hand-drawn SVG with no library, and its centre figure is a COUNT — "4/7" — never a percentage,
   because 57% of a constitution is not a thing anybody has ever needed to know. Numerals, so it reads
   the same in both languages, which is the reasoning `ring` already carries for its own figure. */
.setup-rail .card { padding: var(--space-md); }
/* `.ring-figure`'s 26px is sized for the 132px ring on Home. This one is 76px across with a 9px
   stroke, so the same figure would all but fill the hole it sits in. */
.setup-rail .ring-figure { font-size: 19px; }
.railtop { display: flex; align-items: center; gap: var(--space-sm); }
.railtop b { display: block; font-size: var(--text-sm); color: var(--heading); }
.railtop .small { display: block; margin-top: 0.15rem; line-height: var(--leading-snug); color: var(--muted); }
.raillist { list-style: none; margin: var(--space-md) 0 0; padding: 0; }
.raillist a { display: flex; align-items: center; gap: 0.55rem; padding: 0.4rem 0.45rem; border-radius: var(--radius-sm); font-size: var(--text-sm); color: var(--body); }
.raillist a:hover { background: var(--border-light); color: var(--heading); }
.raillist .rn { flex: none; min-width: 0.9rem; font-size: var(--text-xs); color: var(--muted); font-variant-numeric: tabular-nums; }
/* The dot repeats what the card's own chip says, in the same three states. Never colour alone: the
   list is ordered and numbered, and the chip on the card carries the words. */
.dot { flex: none; width: 0.5rem; height: 0.5rem; border-radius: 50%; box-shadow: inset 0 0 0 1.5px var(--border); }
.dot.done { background: var(--accent); box-shadow: none; }
.dot.std { background: var(--fill-strong); box-shadow: none; }
.railfoot { display: flex; flex-wrap: wrap; gap: 0.35rem 0.8rem; margin-top: var(--space-md); padding-top: var(--space-sm); border-top: 1px solid var(--border-light); font-size: var(--text-xs); color: var(--muted); }
.dotkey { display: inline-flex; align-items: center; gap: 0.35rem; }

/* A SHEET NAMES ITS SECTION THE WAY THE CARD DID.
   Same tile as `.cardhead .ic`, so the sheet and the card it was opened from are visibly one thing
   rather than two screens about the same subject. */
.sheethead { display: flex; align-items: center; gap: var(--space-sm); }
.sheethead .ic {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 2rem; height: 2rem;
  color: var(--accent-ink); background: var(--accent-light); border-radius: var(--radius-md);
}

/* ── SETTING A GROUP UP ────────────────────────────────────────────────────────────────────────────
   Signup is registration, so a katibu arrives at a desk with no rules, no book-start date and nobody's
   opening position on it. This card is what says so, on Home and on Members, and it stays until the
   records are complete AND the opening ties.

   TINTED `warn`, and the reasoning is `.evidence`'s. Nothing is broken and no money is missing, so it is
   not `bad`; but it is telling the katibu to go and do something, which is what wanting attention means.
   Never the accent green — green is the reassurance colour, and a katibu reading a green band concludes
   the desk is fine and moves on.

   It is a real card in the page, NOT `.setup-rail`: that one is a sticky column that `display: none`s
   below 66rem, which would take the whole of setting a group up away from the phone it is done on. */
.setupcard { background: var(--warn-bg); border-inline-start: 4px solid var(--warn); }
.setupcard .railtop b { color: var(--warn); }
.setupcard .railtop .small { color: var(--warn); opacity: 0.9; }
.setupcard .raillist { margin-top: var(--space-md); }
.setupcard .raillist li + li { margin-top: 0.35rem; }

/* A step: what it is, what is still missing, and the way to do it. The button is on the line because a
   list of what is wrong with no way to act on it is a complaint. */
.setupstep {
  display: flex; align-items: flex-start; gap: 0.6rem;
  padding: 0.5rem 0.55rem; border-radius: var(--radius-sm);
}
.setupstep .txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
.setupstep .txt b { font-size: var(--text-sm); font-weight: var(--weight-semibold); color: var(--heading); }
.setupstep .txt span { font-size: var(--text-sm); color: var(--warn); line-height: var(--leading-snug); }
.setupstep .btn { flex: none; align-self: center; }
/* Done is quiet: it is there to be counted, not read. The number and the ring carry the progress. */
.setupstep.done .txt b { color: var(--muted); font-weight: var(--weight-normal); }

/* THE NUMBER, AND NOT A DOT.
   These steps have an order now — each one is what the next is worked out from — and five identical
   dots say nothing whatever about which comes first. `.raillist .rn` is already the rail's tabular step
   number; this is the same element given a disc to sit in so it reads as a position rather than as a
   stray figure. Done fills solid, which is the state change the ring is counting. */
.setupstep .rn {
  flex: none; width: 1.35rem; height: 1.35rem; margin-top: 0.05rem;
  display: grid; place-items: center; border-radius: 50%;
  box-shadow: inset 0 0 0 1.5px var(--warn); color: var(--warn);
  font-size: var(--text-xs); font-variant-numeric: tabular-nums;
}
.setupstep.done .rn { background: var(--warn); color: var(--white); box-shadow: none; }
.setupstep .rn svg { width: 0.8rem; height: 0.8rem; }

/* DO THIS NEXT. A white ground on the tinted card, so the eye lands on one row of five without a second
   colour entering a vocabulary where three tints already mean three things. The card is already saying
   `warn`; this is not a different status, it is which row of that one status to read first. */
.setupstep.now { background: var(--card); box-shadow: var(--shadow-card); }

/* A step that genuinely cannot be done yet — the template with no members, the check with nothing to
   check against. Muted rather than `warn`, because nothing is wrong: it is simply not this one's turn,
   and colouring it like a problem would put four problems on the screen of a group doing nothing wrong.
   The button beside it carries a real `disabled`, which is what actually stops the tap. */
.setupstep.blocked .txt b { color: var(--muted); font-weight: var(--weight-normal); }
.setupstep.blocked .txt span { color: var(--muted); }
.setupstep.blocked .rn { box-shadow: inset 0 0 0 1.5px var(--border); color: var(--muted); }

/* THE TWO HALVES OF ONE JOB. Where the book starts and where each member stood are one step: the
   positions ARE as at that date, and the form is generated from it. Two lines, two ways in, one tick —
   drawn inside the step rather than as two rows, so the card cannot suggest they are separable. */
.setupparts { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.45rem; }
/* `flex-start` and not `center`, which is the same alignment the step row itself takes and for the same
   reason: a half is a title with a sentence under it, so centring the row put the dot beside the
   SENTENCE and made it look as though it marked that rather than the half it belongs to. The button
   still centres against the block, exactly as the step's does. */
.setuppart { display: flex; align-items: flex-start; gap: 0.5rem; }
.setuppart .txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 0.1rem; }
.setuppart b { font-size: var(--text-sm); color: var(--heading); }
.setuppart.done b { color: var(--muted); }
.setuppart .btn { flex: none; align-self: center; }
/* Small enough to read as a sub-item of the numbered step above rather than as a sixth step. */
.setuppart .dot { flex: none; margin-top: 0.4rem; }
.setupcard .setuppart .dot.done { background: var(--warn); }

/* Putting the statement aside. The row keeps its number and its title — a step that vanished would take
   the count with it and claim a statement exists — and only the body folds, with `hidden` and never a
   height transition: `prefers-reduced-motion` flattens every transition to 0.01ms, so a fold that
   depended on one would never shut for the people most likely to want it shut. */
.setupstep .later { flex: none; align-self: center; display: flex; align-items: center; gap: 0.4rem; }

/* On a phone the button drops under the words rather than squeezing them to two characters — and ONLY
   the button. Letting the row wrap freely put the number on a line of its own above the step it marks,
   because the words are wide enough to be pushed down past it. The basis is what pins that: the button
   takes a whole line and the number and the words stay together on the one above. */
@media (max-width: 30rem) {
  .setupstep { flex-wrap: wrap; }
  .setupstep .txt { flex: 1 1 60%; }
  .setupstep > .btn { flex: 0 0 100%; align-self: flex-start; margin-inline-start: 1.95rem; }
  .setupstep .later { flex: 0 0 100%; margin-inline-start: 1.95rem; }
  .setuppart { flex-wrap: wrap; }
  .setuppart .btn { flex: 0 0 100%; margin-inline-start: 1rem; }
}

/* ── THE OPENING FORM ──────────────────────────────────────────────────────────────────────────────
   Download it, fill it in, upload it. TWO NUMBERED STEPS STACKED, drawn with `.form-part` and the
   `cardHead` idiom the first-run rules cards already use — there is no layout of its own here, and that
   is the point: a two-column grid was tried and said "pick one" about a sequence whose middle happens in
   Excel, possibly on another day. `.form-part` already draws the rule between them.

   The control of a step needs air under the heading it belongs to, and `.form-part` alone does not give
   it any. */
.form-part > .cardhead + .sub + .btn,
.form-part > .cardhead + .sub + .drop { margin-top: var(--space-md); }

/* A STEP INSIDE A SHEET IS MARKED MORE LIGHTLY THAN A CARD ON A PAGE. On the Rules screen each block is
   its own card, so whitespace and a white surface on the tinted canvas do the separating; in a sheet they
   share one white body with a rule between them, and a full-size mark beside each is heavier than the
   nesting deserves.

   THE HEADING ITSELF IS LEFT ALONE, deliberately. It measures 20px/600 in a sheet — the same as the
   sheet's own title — and there is no token between `--text-base` (body text) and `--text-lg` (h3) to
   step it down to. Inventing one off the scale is the private-vocabulary bug the console keeps producing,
   and a `font-size: var(--text-lg)` rule here would be a line that reads like a correction and changes
   nothing at all, which is worse than no line. The numeral, the tile and the rule above do the work. */
.modal-body .cardhead .ic { width: 1.75rem; height: 1.75rem; }

/* Every problem at once, with the line number the katibu can find in their own spreadsheet. A list and
   not a sentence: one bad cell on row 38 must not hide a missing figure on row 2. */
.plainlist { margin: 0.4rem 0 0; padding-inline-start: 1.1rem; font-size: var(--text-sm); }
.plainlist li + li { margin-top: 0.2rem; }

/* Who to bring in off a statement. A tick per person, because the statement holds everyone who ever sent
   the group money — including people who have left, and the group's own spending numbers. */
.pickrows { display: flex; flex-direction: column; gap: 0.15rem; margin-top: var(--space-md); }
.pickrow {
  display: flex; align-items: center; gap: 0.6rem;
  padding: 0.45rem 0.5rem; border-radius: var(--radius-sm); cursor: pointer;
}
.pickrow:hover { background: var(--border-light); }
.pickrow .txt { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
.pickrow .txt b { font-size: var(--text-sm); color: var(--heading); }
.pickrow .txt span { font-size: var(--text-xs); color: var(--muted); }
/* Somebody the book already holds: shown, so the katibu can see the statement was understood, and not
   offered, because adding them again is refused by the name rule anyway. */
.pickrow.held { opacity: 0.65; cursor: default; }
.pickrow.held:hover { background: none; }

/* ── A NEW BUILD LANDED WHILE THIS TAB WAS OPEN ────────────────────────────────────────────────────
   The desk is a single-page app, so a tab left open all morning never re-requests its own JavaScript and
   no cache header can reach it. This says so and offers a reload; it never takes one, because the screen
   this matters most on is the one holding forty members' freshly typed figures.

   Fixed to the BOTTOM: the topbar is already frosted and sticky, and a second bar above the page would
   push every screen down by its own height the moment it appeared. */
.stalebar {
  position: fixed; z-index: 60; inset-inline: var(--space-md); bottom: var(--space-md);
  display: flex; align-items: center; gap: var(--space-sm);
  margin-inline: auto; max-width: 34rem;
  padding: 0.6rem 0.75rem;
  background: var(--heading); color: #fff;
  border-radius: var(--radius-lg); box-shadow: var(--shadow-lg, 0 8px 24px rgba(16, 24, 40, 0.18));
  font-size: var(--text-sm);
}
.stalebar span { flex: 1 1 auto; }
.stalebar .btn { flex: none; background: #fff; color: var(--heading); border-color: transparent; }

