/* ==========================================================================
   R5 SYSTEMS - the signed-in areas: /client and /admin

   Loaded AFTER css/styles.css, never instead of it. Every colour, radius, shadow
   and typeface comes from the tokens declared in that file's :root, so these
   screens are the same product as the public site rather than a lookalike, and
   a change to the brand there lands here without anybody remembering to copy it.

   ---------------------------------------------------------------------------
   1. LOAD ORDER IS A CONTRACT
   ---------------------------------------------------------------------------
   styles.css first, app.css second, both with ?v=R5_ASSET_VERSION. app.css may
   only ADD to styles.css or restate what it needs to undo. It never assumes it
   is the only stylesheet on the page: eleven pages still carry their own inline
   <style> block, which lands after this file and therefore wins on a tie.
   Because .htaccess keeps CSS for a week, R5_ASSET_VERSION in api/lib/env.php
   is bumped in the same change as this file, never afterwards. Ship the two
   apart and a returning client runs new markup against last week's stylesheet.

   ---------------------------------------------------------------------------
   2. THE .btn TRAP (do not "tidy" this)
   ---------------------------------------------------------------------------
   styles.css gives .btn its shape but no background, so on a bare <button> the
   browser's own grey shows through. The default look is supplied ONLY through
   .btn:not(.btn--volt):not(.btn--ghost):not(.btn--danger). A plain
   .btn { background } here would sit at equal specificity to .btn--volt and,
   loading later, would beat it: every primary button in both portals would turn
   white. The chain is NOT extended with a fourth modifier either. Where a
   button has to read dark it gets that from its surroundings instead:
   .ticket .btn--ghost, .strip .btn--ghost, .window__in .btn--ghost.

   ---------------------------------------------------------------------------
   3. .field IS RESTATED IN FULL
   ---------------------------------------------------------------------------
   styles.css dresses .field for the dark contact band on the public site:
   near-white labels, a translucent input, white text. A partial override in
   here leaves white labels on a white card, which is how a form becomes
   invisible. So the forms block below restates every property that block sets,
   not only the colours. The input rules are written as ELEMENT selectors as
   well as .field ones, because admin/login.php and the admin table filters use
   raw inputs with no .field wrapper. Narrow them to classes and those forms
   lose their 16px size and their 48px height. Under 16px, iOS zooms the page on
   focus, which is the single most reported phone annoyance in this codebase.

   ---------------------------------------------------------------------------
   4. .sr AND .sr-only ARE BOTH DEFINED, ON PURPOSE
   ---------------------------------------------------------------------------
   The signed-in pages once relied on a .sr class from a stylesheet they carried
   inline. Dropping it put a honeypot field on screen, browsers cheerfully
   autofilled it, and every real signup was thrown away as a bot (88b4519).
   styles.css calls it .sr-only. admin/clients.php calls it .sr. Both spellings
   live here so that cannot happen a second time.

   ---------------------------------------------------------------------------
   5. PHONE FIRST
   ---------------------------------------------------------------------------
   About nine in ten visitors are on a phone, and a client zone is read standing
   in a shop far more often than sitting at a desk. The base rules ARE the phone
   layout; every wider screen is an addition. The breakpoints, and nothing else:
   359 (.steps type), 420 (.brand__area), 520 (.pair), 560 (.kpis/.g4/.seg__a),
   639/640 (.tbl--collapse, .btns, .g2, .g3, .receipt, sticky thead, .kv),
   720 (.ph aside), 820 (.acts__row), 900 (.g4/.kpis four across, .tk-grid,
   .hm, .strip__kpis), 960 (the menu becomes a column), 1000 (.rec two columns).

   THE 960 CONTRACT: the 960px in this file and the matchMedia('(min-width:
   960px)') in chrome_script() are the same number twice. Change one and you
   must change the other, or the drawer will not close itself when the layout
   turns into a column. The drawer class is menu-open on <html>, not on body.
   The off-canvas rules are gated on html.js so that with JavaScript off the
   menu is a horizontal chip strip in the flow, which is a usable page rather
   than a trapped one. Nothing arrives with motion unless html.is-in is set,
   which is the same script saying "the page is alive".

   ---------------------------------------------------------------------------
   6. THE Z LADDER (nothing new may be inserted)
   ---------------------------------------------------------------------------
   styles.css owns 80 .wa-fab, 100 .nav, 200 .skip-link, 300 .cookie-consent.
   app.css owns: .tag 2 (inside its own object), .tbl sticky thead 2,
   .actbar 5, .top 60, .scrim 90, .side 95.
   The open drawer covers the top bar, which is why the burger's X is hidden
   behind it. That is today's behaviour and it stays.

   ---------------------------------------------------------------------------
   7. WHERE THE METAPHOR STOPS
   ---------------------------------------------------------------------------
   Allowed to be a physical object (obsidian, tear, notches, sticker):
     the ticket (plan, offer, identity, client record), the till roll head,
     the admin board (.strip), the admin rack row, the ticket window
     (admin sign-in), the phone bezel on the heatmap stage, and a sticker
     on an empty state.
   Never a physical object:
     the top bar, side menu items, tables, charts, range pickers, filters,
     inputs, buttons, notes and flashes, KPI tiles on light, the heatmap
     stage itself, the exclusions page, the Payfast settings form.
   Counting rule at 390px: one obsidian object per page (the admin rail is
     chrome and does not count), one sticker per viewport, one volt primary
     button per screen, no tear line on anything that is not a ticket, roll
     or window.

   Colour follows from that. Volt is a FILL on light and TEXT only on obsidian:
   #CCFF00 on white is about 1.9:1 and disappears. Links on light are --link
   #4A6B00 (6.4:1). Negative text on obsidian is #FF6B6B (6.9:1), never the
   brand --red (4.0:1). Fine print on obsidian never goes below .85 alpha slate.

   ---------------------------------------------------------------------------
   8. RULE M (also repeated above .stubs, where it is enforced)
   ---------------------------------------------------------------------------
   A .stubs rail is lit from exactly one integer, and the caption or column
   header directly beside it must name what that integer counts.
     Client pages (index, subscription, invoices): the integer is
       months_in($sub['started_at']) = whole months elapsed on the plan, +1,
       clamped 0..12. The caption is always "Month N of 12". The word "paid"
       never appears next to a client rail.
     Admin pages (clients list, client record): the integer is the count of
       successful payments already in the query (paid_months / the payments
       count). The column header and the caption say "Months paid".
   Never light a rail from one and caption it as the other. A failed debit
   must never light a cell that claims to be paid.

   ---------------------------------------------------------------------------
   9. THE STICKY THEAD CAVEAT
   ---------------------------------------------------------------------------
   position: sticky only works while no ancestor is its own scroll box. A table
   wrapped in .scroll (overflow-x: auto) therefore cannot have a sticky head.
   That is why phones get the collapsed rows instead (no scroll box needed) and
   only from 640px does .tbl--collapse .scroll go back to overflow: visible so
   the head can stick. Never add overflow to .tbl, and never wrap a
   .tbl--collapse table in a second scroll box at 640px or wider, or the head
   silently stops sticking with no error anywhere.

   ---------------------------------------------------------------------------
   10. FILE ORDER
   ---------------------------------------------------------------------------
   1 this comment - 2 :root - 3 shell - 4 type and links - 5 surfaces and cards
   - 6 ticket family - 7 steps and stubs - 8 stats, strip, delta, spark
   - 9 pills, chips, mono, geo - 10 buttons - 11 .sr - 12 forms - 13 notes
   - 14 lists and tiles - 15 tables - 16 charts - 17 empty states - 18 secure
   - 19 seg - 20 confirm, danger, acts - 21 timeline - 22 misc
   - 23 side menu, burger, scrim, no-JS strip, area variants
   - 24 wider screens - 25 keyframes and reduced motion.
   ========================================================================== */


/* ==========================================================================
   2. TOKENS
   Only what styles.css does not already own. Nothing here redeclares a brand
   token; these are the roles this product needs on top of the brand.
   ========================================================================== */

:root {
  /* --- state colours on light --- */
  --link:        #4A6B00;   /* link text on light, 6.4:1 */
  --pos:         #3F7A16;   /* .up, positive text on light */
  --neg:         #B4341C;   /* .down, .btn--danger, negative text on light */
  --invalid:     #C2410C;   /* aria-invalid border */
  --ph-col:      #98A2AE;   /* placeholder */
  --amber:       #FFB020;   /* warning fill; obsidian text on it is 10.5:1.
                               profile.php already references var(--amber). */
  --amber-ink:   #7A5A00;   /* amber text on light (pairs with .pill--warn) */

  /* --- the obsidian surface ("tk" = ticket) --- */
  --tk-bg:          var(--obsidian);
  --tk-glow:        radial-gradient(560px 320px at 100% 0%, rgba(204,255,0,.16), transparent 62%),
                    radial-gradient(420px 260px at 0% 100%, rgba(204,255,0,.06), transparent 60%);
  --tk-text:        var(--white);            /* body and values on obsidian, 17.5:1 */
  --tk-soft:        var(--slate);            /* labels on obsidian, 6.3:1 */
  --tk-fine:        rgba(138,149,165,.85);   /* fine print floor on obsidian, 4.9:1 */
  --tk-bad:         #FF6B6B;                 /* negative text on obsidian, 6.9:1 */
  --tk-warn:        var(--amber);            /* warning text on obsidian, 10.5:1 */
  --tk-line:        rgba(245,246,248,.08);
  --tk-line-strong: rgba(245,246,248,.14);
  --tk-tear:        rgba(245,246,248,.20);   /* perforation on obsidian */
  --tk-tear-light:  rgba(20,23,26,.18);      /* perforation on paper */

  /* --- quiet fills on light --- */
  --tint:    rgba(20,23,26,.05);
  --tint-2:  rgba(20,23,26,.10);

  /* --- chrome --- */
  --top-h: 62px;   /* .top__in min-height; .side sticky offset and height;
                      sticky thead offset. THIS NUMBER LIVES HERE ONLY. */

  /* --- spacing (4px base) --- */
  --s1:.25rem; --s2:.5rem; --s3:.75rem; --s4:1rem; --s5:1.35rem; --s6:2rem; --s7:3rem;
  --gap:.85rem;                 /* the card gap, unchanged from today */
  --pad-card: clamp(1rem, 2vw, 1.35rem);

  /* --- radii --- */
  --r-tk:   22px;  /* ticket, roll, strip, window  (canon: register.php) */
  --r-tag:  11px;  /* sticker                       (canon) */
  --r-chip: 8px;   /* chips, code, mono--sm */
  --r-stub: 3px;   /* stub cells, meters */

  /* --- shadows --- */
  --shadow-tk:  0 0 0 1px rgba(245,246,248,.08), 0 28px 60px -28px rgba(14,16,18,.6);  /* canon */
  --shadow-tag: 0 14px 30px -10px rgba(204,255,0,.6), 0 4px 10px rgba(0,0,0,.35);      /* canon */
  --shadow-drawer: 0 0 60px -10px rgba(20,23,26,.3);

  /* --- motion --- */
  --ease-out:    cubic-bezier(.2,.8,.25,1);
  --ease-slap:   cubic-bezier(.2,.85,.3,1);
  --ease-shine:  cubic-bezier(.3,.6,.4,1);
  --ease-nudge:  cubic-bezier(.2,.8,.3,1.3);
  --dur-1:.16s; --dur-2:.28s; --dur-3:.52s; --dur-4:.6s;
}


/* ==========================================================================
   3. SHELL
   ========================================================================== */

body { min-height: 100svh; display: flex; flex-direction: column; }

.wrap {
  width: 100%;
  max-width: 1180px;
  margin-inline: auto;
  padding-inline: clamp(1rem, 4vw, 2rem);
}
.wrap--narrow { max-width: 760px; }

/* From 960px .has-side main .wrap gives up its cap so tables can use the whole
   width beside the menu. A single-column form does not want that, so it opts
   back in with .wrap--cap on the block itself. */
.wrap--cap { max-width: 1180px; margin-inline: auto; }

main { flex: 1; min-width: 0; padding-block: clamp(1.25rem, 3vw, 2rem) 3rem; }

.shell { flex: 1; display: flex; align-items: flex-start; width: 100%; }

/* Every page wraps its content in one .stack. That is what replaces the inline
   style="margin-top" attributes that used to set the rhythm page by page. */
.stack > * + * { margin-top: var(--gap); }

/* ---------- the top bar ----------
   White in both areas, so the two portals share one chrome and every obsidian
   object below reads as an object placed on a page. theme-color stays #F5F6F8. */

.top {
  position: sticky; top: 0; z-index: 60;
  background: rgba(255, 255, 255, .88);
  backdrop-filter: saturate(1.4) blur(12px);
  -webkit-backdrop-filter: saturate(1.4) blur(12px);
  border-bottom: 1px solid var(--line);
  padding-top: env(safe-area-inset-top, 0px);
}
.top__in { display: flex; align-items: center; gap: 1rem; min-height: var(--top-h); }

/* The same mark and wordmark the public site uses, from the same file. It is
   drawn in currentColor, so as an <img> it comes out the near black the site
   renders it in, and there is one copy of the artwork rather than a second one
   pasted in here to drift. On obsidian we draw .geo instead; filter: invert(1)
   on the mark is forbidden, because it breaks silently the day the artwork
   gains a colour. */
.brand {
  display: inline-flex; align-items: center; gap: .55rem;
  text-decoration: none; color: var(--ink);
}
.brand__mark { width: 34px; height: 34px; flex: none; display: block; }
.brand__word {
  font-family: var(--font-display);
  font-weight: 800; font-size: 1.2rem; letter-spacing: -.01em;
}
/* Which of the two areas you are in. Quiet, because it is a label on the logo
   and not part of it. */
.brand__area {
  font-size: .82rem; font-weight: 600; color: var(--ink-soft);
  padding-left: .55rem; margin-left: .1rem;
  border-left: 1px solid var(--line-strong);
}
@media (max-width: 420px) { .brand__area { display: none; } }

.top__t {
  margin-left: auto; min-width: 0; text-align: right;
  font-size: .84rem; color: var(--ink-soft);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The client's monogram beside their name. The name is allowed to run out of
   room; the monogram is not, because it is the thing that says "this is your
   account and not somebody else's". */
.who { display: inline-flex; align-items: center; gap: .5rem; min-width: 0; }

/* Only client/account.php still prints this, and only until that page lands.
   Sign out otherwise lives at the bottom of the menu. */
.out { color: var(--ink-soft); font-size: .84rem; text-decoration: none; font-weight: 550; white-space: nowrap; }
.out:hover { color: var(--red); }


/* ==========================================================================
   4. TYPE AND LINKS
   ========================================================================== */

h1 {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3.2vw, 2rem);
  font-weight: 800; letter-spacing: -.03em; line-height: 1.1;
}
h2 {
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 1.6vw, 1.2rem);
  font-weight: 750; letter-spacing: -.02em;
}
.sub { color: var(--ink-soft); font-size: .95rem; margin-top: .35rem; line-height: 1.55; }
.muted { color: var(--ink-soft); }
/* A date, a reference or an amount is one thing and reads as one thing. Wrapped
   over two lines in a table row it stops being a value and starts being a
   paragraph, and a column of them stops lining up. */
.nw { white-space: nowrap; }
.up { color: var(--pos); font-weight: 650; }
.down { color: var(--neg); font-weight: 650; }

/* Small print OUTSIDE a field. .hint stays scoped to .field on purpose: a
   global .hint rule changes the size of text on seven pages at once, because
   most <p class="hint"> instances are not inside a field. .fine is the opt-in,
   and .hint--std is the same rule under the other spelling so either is safe. */
.fine, .hint--std { font-size: .82rem; color: var(--ink-soft); line-height: 1.5; }

/* ---------- the page header ----------
   Every page in both areas opens the same way: section, title, then one plain
   sentence saying where things stand and what happens next. The state line is
   what answers the reader's actual question; the sticker only gives it a word. */

.ph { display: grid; gap: .3rem; margin-bottom: var(--s5); }
.ph__eye {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--ink-soft);
  display: inline-flex; align-items: center; gap: .55em;
}
.ph__state { font-size: .98rem; font-weight: 600; color: var(--ink); line-height: 1.45; margin-top: .15rem; }
.ph__sub { color: var(--ink-soft); font-size: .95rem; line-height: 1.55; max-width: 60ch; }

/* Colour is never the only signal: the dot always sits beside a word. */
.dot {
  display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  margin-right: .45rem; vertical-align: .05em; background: var(--line-strong);
}
.dot--ok   { background: var(--pos); }
.dot--warn { background: var(--amber); }
.dot--bad  { background: var(--neg); }
.dot--mute { background: var(--ink-soft); }

/* Below 720px the aside drops under the header, where the next thing on the
   page is often a ticket whose sticker hangs over its own top edge. Without
   this the sticker lands on top of the button. */
.ph__aside { margin-top: .2rem; margin-bottom: .55rem; }

@media (min-width: 720px) {
  .ph {
    grid-template-columns: minmax(0, 1fr) auto;
    grid-template-areas: "eye aside" "h1 aside" "state aside" "sub aside";
    align-items: start;
  }
  .ph__aside { margin: 0; }
  .ph__eye { grid-area: eye; }
  .ph h1 { grid-area: h1; }
  .ph__state { grid-area: state; }
  .ph__sub { grid-area: sub; }
  .ph__aside { grid-area: aside; justify-self: end; }
}

/* the same header, sitting on the admin board */
.strip .ph h1 { color: var(--tk-text); }
.strip .ph__eye, .strip .ph__sub { color: var(--tk-soft); }
.strip .ph__state { color: var(--tk-text); }
.strip .dot--ok { background: var(--volt); }
.strip .dot--bad { background: var(--tk-bad); }

/* ---------- links ----------
   :where() gives this zero specificity, so a component rule wins on merit
   instead of fighting a :not() chain. .tabs stays in the escape list even
   though range_picker() no longer emits it. If an old engine drops the rule,
   links go inherit-coloured and plain, which is exactly today's failure mode. */

a { color: inherit; }
main a:where(:not(.btn, .seg__a, .tabs, .brand, .plain, .chip, .tile, .side__a, .list__a)) {
  color: var(--link); text-decoration: underline; text-underline-offset: 2px;
}
main a:where(:not(.btn, .seg__a, .tabs, .brand, .plain, .chip, .tile, .side__a, .list__a)):hover {
  color: var(--ink);
}
/* On obsidian a link is volt and underlined, because --link at 6.4:1 on white
   is 1.6:1 on #0E1012. */
.ticket a, .strip a, .roll__head a, .window__in a { color: var(--volt); }


/* ==========================================================================
   5. SURFACES AND CARDS
   ========================================================================== */

.card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: var(--pad-card);
  box-shadow: var(--shadow-soft);
}
/* clip, not hidden: overflow: hidden makes the card its own scroll box and a
   sticky <thead> inside it silently stops sticking. clip does the same visual
   job without establishing a scroll container. The hidden line is the fallback
   for engines without clip; those lose the sticky head and nothing else. */
.card--flush { padding: 0; overflow: hidden; overflow: clip; }

/* The single volt-tinted card. There is one per screen at most, and it is
   always the thing we are asking the client to do. */
.card--volt {
  background: linear-gradient(180deg, rgba(204, 255, 0, .13), rgba(204, 255, 0, .02));
  border-color: rgba(140, 180, 0, .35);
}
/* "Sort out my payment": a red-tinted card, not a note, because it carries an
   action and notes do not. */
.card--alert { background: #FDE7E2; border-color: #F3C6BB; color: var(--ink); }
/* Obsidian card. The heatmap phone bezel is the only permitted use. */
.card--dark {
  background: var(--tk-glow), var(--tk-bg);
  color: var(--tk-text);
  border: 0;
  border-radius: var(--r-tk);
  box-shadow: var(--shadow-tk);
}
.card + .card { margin-top: var(--gap); }

/* Twin families. .card__* is the client spelling, .kpi__* the admin one; both
   are in live markup on both sides and both stay. */
.card__k, .kpi__k {
  font-size: .68rem; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--ink-soft);
}
.card__v, .kpi__v {
  font-family: var(--font-display);
  font-size: clamp(1.55rem, 3.4vw, 2.05rem);
  font-weight: 800; letter-spacing: -.035em; line-height: 1.05;
  margin-top: .3rem; font-variant-numeric: tabular-nums;
}
.card__d, .kpi__d { font-size: .84rem; color: var(--ink-soft); margin-top: .25rem; line-height: 1.5; }

/* The hook voice on a light card. Never volt text here: volt on white is
   1.9:1. The middle line simply goes quiet instead. */
.hook-lite {
  font-family: var(--font-display);
  font-size: 1.35rem; font-weight: 800; letter-spacing: -.03em; line-height: 1.15;
  color: var(--ink);
}
.hook-lite span { display: block; }
.hook-lite .is-dim { color: var(--ink-soft); }


/* ==========================================================================
   6. THE TICKET FAMILY
   The canon. Every value here is lifted from client/register.php, which is the
   page this whole language came from and the regression check for the pass.
   ========================================================================== */

.ticket { position: relative; margin-top: 1rem; }   /* room for the sticker overhang */

.ticket__in {
  position: relative; overflow: hidden;
  padding: 2.2rem 1.35rem 1.15rem;                  /* the top value is the sticker's room */
  color: var(--tk-text);
  background: var(--tk-glow), var(--tk-bg);
  border-radius: var(--r-tk);
  box-shadow: var(--shadow-tk);                     /* the 1px ring is inside this; no border */
}

.ticket__hook {
  font-family: var(--font-display);
  font-size: clamp(1.7rem, 6.6vw, 2.2rem);
  font-weight: 800; letter-spacing: -.04em; line-height: 1.04;
  margin: 0 0 .8rem; color: var(--tk-text);
}
.ticket__hook span { display: block; }
.ticket__hook .is-volt { color: var(--volt); }
.ticket__hook .is-dim  { color: var(--tk-soft); }
.ticket__hook .is-bad  { color: var(--tk-bad); }   /* the torn variant's second line */

.ticket__d { font-size: .9rem; line-height: 1.5; color: var(--tk-soft); max-width: 46ch; }

/* The tear. Two page-coloured circles straddle the edges so the perforation
   reads as a notch bitten out of the sheet, exactly as on the public site. */
.ticket__tear {
  position: relative; height: 0;
  margin: 1.15rem -1.35rem 1rem;
  border-top: 2px dashed var(--tk-tear);
}
.ticket__tear::before, .ticket__tear::after {
  content: ''; position: absolute; top: -12px;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--bg); box-shadow: inset 0 0 0 1px var(--tk-line);
}
.ticket__tear::before { left: -11px; }
.ticket__tear::after  { right: -11px; }

.ticket__rows { margin: 0; display: grid; }
.ticket__rows > div {
  display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
  padding: .5rem 0; border-top: 1px solid var(--tk-line); font-size: .9rem;
}
.ticket__rows > div:first-child { border-top: 0; }
.ticket__rows dt { margin: 0; color: var(--tk-soft); }
.ticket__rows dd {
  margin: 0; font-weight: 700; text-align: right;
  font-variant-numeric: tabular-nums; overflow-wrap: anywhere;
}
.ticket__rows dd.is-volt { color: var(--volt); }   /* the one highlighted value per object */
.ticket__rows dd.is-empty { color: var(--tk-soft); font-weight: 500; }

.ticket__fine { font-size: .76rem; line-height: 1.5; color: var(--tk-fine); margin-top: .85rem; }
.ticket__d s { color: var(--tk-fine); text-decoration-thickness: 1px; }

/* The no lock in promise, on the funnel ticket.
   It is the objection that sends people away to go and check, so it is given a
   volt rail and a tick and put above the tear where the eye already stops. It
   is a band and not a sticker on purpose: one sticker per screen, and the
   sticker is already spent on the offer itself. */
.ticket__free {
  display: grid; grid-template-columns: 20px minmax(0, 1fr); gap: .6rem;
  align-items: start; margin-top: .95rem; padding: .7rem .8rem .7rem .7rem;
  border-left: 3px solid var(--volt);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  background: rgba(204, 255, 0, .09);
  font-size: .86rem; line-height: 1.5; color: var(--tk-text);
}
.ticket__free svg { width: 20px; height: 20px; color: var(--volt); margin-top: .05rem; }
.ticket__free b { font-weight: 750; }

/* The grid form of the rows: for the admin client ticket and the roll head,
   where the values are numbers to compare rather than a list to read. */
.ticket__rows--grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem 1rem; }
.ticket__rows--grid > div { display: grid; border: 0; padding: 0; }
.ticket__rows--grid dt {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--tk-soft);
}
.ticket__rows--grid dd {
  font-family: var(--font-display); font-size: 1.35rem; font-weight: 800;
  letter-spacing: -.03em; text-align: left;
}
@media (min-width: 900px) {
  .ticket__rows--grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
}

.ticket a { text-decoration: underline; text-underline-offset: 2px; }
.ticket :focus-visible { outline-color: var(--volt); }
.ticket .pill--mute { background: rgba(245,246,248,.1); color: var(--tk-soft); border-color: var(--tk-line-strong); }

/* Who the ticket belongs to. Identity is not a state, so .ticket--id carries
   no sticker. */
.ticket__who { display: flex; gap: .9rem; align-items: center; margin-bottom: .7rem; }
.ticket__who h1, .ticket__who .ticket__name {
  font-family: var(--font-display); font-weight: 800; letter-spacing: -.03em; color: var(--tk-text);
}

/* ---------- variants ---------- */

/* the short ticket: home, profile, the sign-in sheet, the 503 page */
.ticket--stub .ticket__in { padding: 1.9rem 1.2rem 1rem; }
.ticket--stub .ticket__hook { font-size: clamp(1.45rem, 5.4vw, 1.8rem); line-height: 1.06; }
.ticket--stub .ticket__tear { margin-block: .9rem .75rem; }

/* the torn ticket: the cancellation confirm view. The months you are giving up
   go dashed and faint, and the first of them carries the cut. */
.ticket--torn .stubs li.is-gone { opacity: .35; border-style: dashed; }
.ticket--torn .stubs li.is-cut  { box-shadow: inset 2px 0 0 var(--red); }

/* ---------- the ticket layout ----------
   register.php's .reg grid, renamed and promoted so login, checkout, the unpaid
   home, profile and subscription all get the same sheet-beside-form shape. */

.tk-grid {
  display: grid; gap: 1rem;
  grid-template-areas: "head" "ticket" "steps" "main";
  margin-top: .25rem;
}
.tk-grid__head  { grid-area: head; }
.tk-grid__side  { grid-area: ticket; }
.tk-grid__steps { grid-area: steps; }
.tk-grid__main  { grid-area: main; min-width: 0; }

@media (min-width: 900px) {
  .tk-grid {
    grid-template-columns: minmax(300px, 5fr) minmax(0, 7fr);
    grid-template-areas: "ticket head" "ticket steps" "ticket main";
    grid-template-rows: auto auto 1fr;
    column-gap: clamp(1.75rem, 4.5vw, 4rem);
    row-gap: .9rem;
    align-items: start;
  }
  .tk-grid__side { position: sticky; top: 84px; align-self: start; }
}

/* ---------- the sticker ----------
   Crooked, with the logo's offset outline behind it. The vocabulary is closed:
   Free website, Active, Paid up, Ten questions, Sent, Client area, Admin,
   Confirming, Cancelling, Sandbox, Payment failed, Cancelled, Not started,
   No plan, Nothing yet, Back shortly. Nothing else is a sticker.
   Text is obsidian on all four fills, so every one clears 5:1 or better. */

.tag {
  display: inline-flex; align-items: center; gap: .5rem;
  height: 38px; padding: 0 1rem 0 .85rem;
  border-radius: var(--r-tag);
  background: var(--volt); color: var(--obsidian);
  font-size: .76rem; font-weight: 800; letter-spacing: .15em;
  text-transform: uppercase; white-space: nowrap;
  transform: rotate(-4deg); transform-origin: 20% 60%;
  box-shadow: var(--shadow-tag);
  position: relative;
}
.tag::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  border-radius: inherit; border: 2px solid var(--volt);
  opacity: .55; transform: translate(7px, -7px);
}
.tag svg { width: 15px; height: 15px; flex: none; }

/* The sticker overhangs the top edge of whatever it labels. z-index 2 keeps it
   above the object's own gradient without introducing a new layer. */
.ticket__tag, .roll > .tag, .strip > .tag, .window > .tag {
  position: absolute; top: -17px; left: 1.15rem; z-index: 2;
}
.strip > .tag { left: 1.25rem; }

.tag--amber { background: var(--amber); box-shadow: 0 14px 30px -10px rgba(255,176,32,.55), 0 4px 10px rgba(0,0,0,.35); }
.tag--amber::before { border-color: var(--amber); }
.tag--red   { background: var(--red);   box-shadow: 0 14px 30px -10px rgba(255,45,45,.5), 0 4px 10px rgba(0,0,0,.35); }
.tag--red::before { border-color: var(--red); }
.tag--slate { background: var(--slate); box-shadow: 0 4px 10px rgba(0,0,0,.35); }
.tag--slate::before { border-color: var(--slate); }

/* ---------- the ticket window (admin sign-in) ----------
   The one place a form appears anywhere near obsidian, solved by making the
   form the tear-off part, on paper. No input, select or textarea sits on
   obsidian anywhere in either portal: that is the surface where the worst
   documented bug in this codebase lives. */

.window { position: relative; width: min(440px, 100%); margin: 8vh auto 0; }
@media (min-width: 640px) { .window { margin-top: 10vh; } }

.window__in {
  position: relative; overflow: hidden;
  padding: 2.2rem 1.35rem 1.35rem;
  color: var(--tk-text);
  background: var(--tk-glow), var(--tk-bg);
  border-radius: var(--r-tk);
  box-shadow: var(--shadow-tk);
}
.window__geo { position: absolute; top: 1.2rem; right: 1.4rem; }
.window__counter {
  background: var(--surface); color: var(--ink);
  border-radius: 14px; padding: 1.1rem 1.1rem 1.2rem; margin: 0 -.2rem;
}
.window__in :focus-visible { outline-color: var(--volt); }
/* the counter is paper, so it takes the light-surface ring back */
.window__counter :focus-visible { outline-color: var(--focus); }

/* ---------- the till roll (client payments) ----------
   Obsidian head, paper list, one dashed seam between them. The head is the
   thing you keep; the receipts are the thing you read. */

.roll { position: relative; margin-top: 1rem; }
.roll__in {
  border-radius: var(--r-tk); overflow: hidden;
  background: var(--surface); border: 1px solid var(--line); box-shadow: var(--shadow-soft);
}
.roll__head {
  position: relative; padding: 2.1rem 1.35rem 1.1rem;
  color: var(--tk-text); background: var(--tk-glow), var(--tk-bg);
}
.roll__k {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--tk-soft);
}
.roll__v {
  font-family: var(--font-display); font-size: clamp(1.9rem, 7vw, 2.4rem);
  font-weight: 800; letter-spacing: -.04em; line-height: 1.05;
  color: var(--volt); margin: .15rem 0 .8rem; font-variant-numeric: tabular-nums;
}
.roll__head :focus-visible { outline-color: var(--volt); }

/* The seam is drawn on the paper side, in the paper perforation colour, with
   the two notches straddling it. */
.roll__tear { position: relative; height: 0; border-top: 2px dashed var(--tk-tear-light); }
.roll__tear::before, .roll__tear::after {
  content: ''; position: absolute; top: -12px;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--bg); box-shadow: inset 0 0 0 1px var(--line);
}
.roll__tear::before { left: -11px; }
.roll__tear::after  { right: -11px; }

.roll__list { list-style: none; margin: 0; padding: .25rem 0 0; }

.receipt {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .15rem 1rem;
  padding: .85rem 1.35rem; border-top: 1px solid var(--line); align-items: start;
}
.receipt:first-child { border-top: 0; }
.receipt__when { font-weight: 700; font-size: .95rem; }
.receipt__what { display: block; font-size: .84rem; color: var(--ink-soft); }
.receipt__r { display: grid; justify-items: end; gap: .3rem; }
.receipt__amt { font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; font-variant-numeric: tabular-nums; }
.receipt__ref { grid-column: 1 / -1; font-size: .78rem; color: var(--ink-soft); margin-top: .25rem; }
.ref {
  font: 500 .78rem/1.5 ui-monospace, Consolas, Menlo, monospace;
  background: var(--tint); padding: .15rem .4rem; border-radius: 6px; overflow-wrap: anywhere;
}
/* A failed row says so in words, carries a red rail and explains itself. The
   old pale pink (#FFD9D9) was a dark-theme colour left on a white card and was
   very close to invisible. */
.receipt.is-failed { box-shadow: inset 4px 0 0 var(--neg); }
.receipt__why {
  grid-column: 1 / -1; margin-top: .35rem; padding: .55rem .7rem;
  font-size: .82rem; line-height: 1.5;
  color: #7C2213; background: #FDE7E2; border: 1px solid #F3C6BB; border-radius: var(--r-sm);
}
@media (min-width: 640px) {
  .receipt { grid-template-columns: 9rem minmax(0, 1fr) auto; align-items: baseline; }
  /* The date and the description are one element on a phone so they stack, and
     three columns from here up. display:contents lifts the two children into
     the receipt's own grid rather than leaving both of them stacked inside the
     narrow date column, which is what put "Monthly debit, Website plan" into a
     9rem box while the amount took the wide one. */
  .receipt__l { display: contents; }
  .receipt__ref { grid-column: auto; margin-top: 0; }
}


/* ==========================================================================
   7. THE STEP TRACK AND THE MONTHS RAIL
   ========================================================================== */

/* The four steps are always the same four words, and a track with nothing lit
   is forbidden: every instance has exactly one .on, or all four .done. */

.steps {
  list-style: none; margin: .35rem 0 0; padding: 0; position: relative;
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .5rem;
}
/* width, bottom and border-radius are restated, not because this track wants
   them, but because styles.css already owns a .steps and a .steps::before for
   the public site's process section: under 620px it turns that one into a
   vertical timeline with width: 2px. Same specificity, so app.css wins on
   source order only for the properties it actually names. Leave width out and
   the connector on a phone collapses to a 2px stub with no error anywhere. */
.steps::before {
  content: ''; position: absolute; top: 13px; left: 14px; right: 14px; bottom: auto;
  width: auto; height: 2px; border-radius: 0; background: var(--line-strong);
}
.steps li {
  position: relative; display: grid; gap: .45rem; justify-items: start;
  font-size: .76rem; line-height: 1.25; font-weight: 600; color: var(--ink-soft);
}
.steps b {
  position: relative; z-index: 1; display: grid; place-items: center;
  width: 28px; height: 28px; border-radius: 50%;
  background: var(--surface); border: 2px solid var(--line-strong); color: var(--ink-soft);
  font-size: .76rem; font-weight: 800;
  transition: border-color .3s ease, color .3s ease;
}
.steps .on { color: var(--ink); font-weight: 700; }
.steps .on b {
  background: var(--obsidian); border-color: var(--obsidian); color: var(--volt);
  animation: r5Ping 2.6s ease-out 1s infinite;
}
/* A done step is a tick as well as a fill, and the markup adds a .sr "done", so
   the state never depends on colour alone. */
.steps .done b { background: var(--volt-dim); border-color: var(--volt-dim); color: var(--obsidian); }
.steps b svg {
  width: 12px; height: 12px; stroke: currentColor; stroke-width: 3; fill: none;
  stroke-linecap: round; stroke-linejoin: round;
}
@media (max-width: 359px) { .steps li { font-size: .7rem; } }

/* on obsidian */
.steps--dark::before { background: var(--tk-line-strong); }
.steps--dark li { color: var(--tk-soft); }
.steps--dark b { background: var(--charcoal); border-color: var(--tk-line-strong); color: var(--tk-soft); }
.steps--dark .on { color: var(--tk-text); }
.steps--dark .on b { background: var(--volt); border-color: var(--volt); color: var(--obsidian); }
.steps--dark .done b { background: rgba(204,255,0,.18); border-color: var(--volt-dim); color: var(--volt); }

/* compact, no labels, for a table row. The <ol> carries role="img" and an
   aria-label, so a screen reader hears "Step 3 of 4, ten questions" rather
   than four empty list items. */
.steps--mini { grid-template-columns: repeat(4, 10px); gap: 5px; margin: 0; width: max-content; }
/* On the admin client ticket the mini track follows the contact chips, so it
   needs air above it; in a table cell it must stay flush. */
.ticket .steps--mini { margin-top: .7rem; }
.steps--mini::before { display: none; }
.steps--mini li span {
  position: absolute !important; width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
}
.steps--mini b { width: 10px; height: 10px; border-width: 2px; font-size: 0; animation: none; }
.steps--mini .done b { background: var(--volt-dim); border-color: var(--volt-dim); }
.steps--mini .on b { background: var(--obsidian); border-color: var(--obsidian); box-shadow: 0 0 0 2px rgba(204,255,0,.7); }
.steps--mini b svg { display: none; }

/* ---------- the twelve-stub rail ----------

   RULE M
   A .stubs rail is lit from exactly one integer, and the caption or column
   header directly beside it must name what that integer counts.
     Client pages (index, subscription, invoices): the integer is
       months_in($sub['started_at']) = whole months elapsed on the plan, +1,
       clamped 0..12. The caption is always "Month N of 12". The word "paid"
       never appears next to a client rail.
     Admin pages (clients list, client record): the integer is the count of
       successful payments already in the query (paid_months / the payments
       count). The column header and the caption say "Months paid".
   Never light a rail from one and caption it as the other. A failed debit
   must never light a cell that claims to be paid.

   The rail appears on exactly five surfaces: the client plan ticket, the client
   home stub, the till roll head, the admin client ticket and the admin rack row
   (mini). Nowhere else. At 320px it is about 288px wide, 24px a cell, which is
   enough for the .62rem numerals including "12". */

.stubs {
  display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 0;
  margin-top: .9rem; list-style: none; padding: 0;
  border-block: 1px solid var(--tk-line);
}
.stubs li {
  height: 26px; display: grid; place-items: center;
  font-size: .62rem; font-weight: 700; font-variant-numeric: tabular-nums;
  color: var(--tk-soft);
  border-left: 2px dashed var(--tk-tear); background: transparent;
}
.stubs li:first-child { border-left: 0; }
.stubs li.is-paid { background: var(--volt); color: var(--obsidian); }
.stubs li.is-next { box-shadow: inset 0 0 0 2px var(--volt-dim); color: var(--tk-text); }
.stubs li.is-gone { opacity: .35; border-left-style: dashed; }
.stubs li.is-cut  { box-shadow: inset 2px 0 0 var(--red); }

/* 6px, numberless, for a table cell. This one lives on paper, so its unlit
   cells are the light hairline and its lit cells are volt-dim: volt on white
   would be a 1.9:1 smear. */
.stubs--mini { height: 6px; margin-top: .4rem; border-block: 0; gap: 2px; width: 96px; }
.stubs--mini li { height: 6px; font-size: 0; border-left: 0; border-radius: var(--r-stub); background: var(--line-strong); }
.stubs--mini li.is-paid { background: var(--volt-dim); }
.stubs--mini li.is-next { box-shadow: none; }
.ticket .stubs--mini li, .strip .stubs--mini li { background: var(--tk-line-strong); }
.ticket .stubs--mini li.is-paid, .strip .stubs--mini li.is-paid { background: var(--volt); }


/* ==========================================================================
   8. STATS, THE ADMIN BOARD, DELTAS, SPARKLINES
   ========================================================================== */

/* A KPI tile on light is just .card with .kpi__k / .kpi__v / .kpi__d. Nothing
   extra is needed for it, and it never goes obsidian. */

/* ---------- .strip, the admin board ----------
   One obsidian board per admin page, at the top, carrying the page header and
   the numbers. No tear and no notches: it is a board behind the counter, not a
   ticket somebody keeps. overflow stays visible so the sticker can overhang. */

.strip {
  position: relative; margin-top: 1rem;
  padding: 1.15rem 1.25rem 1.25rem;
  color: var(--tk-text);
  background: var(--tk-glow), var(--tk-bg);
  border-radius: var(--r-tk); box-shadow: var(--shadow-tk);
  margin-bottom: var(--gap);
  overflow: visible;
}
.strip .ph { margin-bottom: .9rem; }
.strip__kpis {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .9rem 1rem;
  border-top: 1px solid var(--tk-line); padding-top: .9rem;
}
@media (min-width: 900px) { .strip__kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); } }
.strip__kpis--5 { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }

.strip .kpi__k { color: var(--tk-soft); }
.strip .kpi__v { color: var(--tk-text); font-size: clamp(1.75rem, 3.6vw, 2.4rem); }
.strip .kpi__v.is-volt { color: var(--volt); }     /* the one highlighted number */
.strip .kpi__d { color: var(--tk-fine); font-size: .78rem; }
.strip .up { color: var(--volt); }
.strip .down, .strip .kpi__v.down { color: var(--tk-bad); }
.strip .muted { color: var(--tk-soft); }
.strip code { color: var(--tk-text); background: rgba(245,246,248,.08); }
.strip a:not(.btn):not(.seg__a) { color: var(--volt); }
.strip :focus-visible { outline-color: var(--volt); }

/* The home for a status that is a status and not a subtitle (the own-visits
   diagnostics on the overview). Never used for prose. */
.strip__foot {
  margin-top: .7rem; padding-top: .7rem; border-top: 1px solid var(--tk-line);
  display: flex; flex-wrap: wrap; gap: .35rem 1rem; font-size: .8rem; color: var(--tk-soft);
}

/* ---------- deltas ----------
   The arrow is a CSS triangle, so a delta is never colour-only and never
   depends on a glyph rendering. delta() keeps .up / .down / .muted on the same
   span, so anything still reading those classes keeps working. */

.delta {
  display: inline-flex; align-items: center; gap: .3rem;
  font-size: .78rem; font-weight: 700; line-height: 1;
  padding: .2rem .5rem .2rem .4rem; border-radius: 999px;
  font-variant-numeric: tabular-nums;
}
.delta::before { content: ''; width: 0; height: 0; border: 4px solid transparent; }
.delta--up   { color: var(--pos); background: #EEF9E3; }
.delta--up::before   { border-bottom-color: currentColor; margin-bottom: 3px; }
.delta--down { color: var(--neg); background: #FDE7E2; }
.delta--down::before { border-top-color: currentColor; margin-top: 3px; }
.delta--flat { color: var(--ink-soft); background: var(--tint); }
.delta--flat::before { display: none; }
.strip .delta--up   { color: var(--volt);   background: rgba(204,255,0,.12); }
.strip .delta--down { color: var(--tk-bad); background: rgba(255,107,107,.14); }
.strip .delta--flat { color: var(--tk-soft); background: rgba(245,246,248,.08); }

/* ---------- sparklines ----------
   One chart primitive that renders on a light card and on the obsidian board.
   The line is currentColor, so the colour swap is one rule and the gradient in
   the markup carries a unique id per call. #7FA300 is the darkest useful volt
   on white; volt itself would vanish. */

.spark { width: 100%; height: auto; display: block; color: #7FA300; }
.strip .spark, .card--dark .spark { color: var(--volt); }
.spark__line {
  fill: none; stroke: currentColor; stroke-width: 2.5; stroke-linejoin: round;
  vector-effect: non-scaling-stroke;
}
.spark__base { stroke: var(--line-strong); }
.strip .spark__base { stroke: var(--tk-line-strong); }
.spark__t { font: 600 11px var(--font-body); fill: var(--ink-soft); }
.strip .spark__t { fill: var(--tk-soft); }
.spark__t--r { text-anchor: end; }


/* ==========================================================================
   9. PILLS, CHIPS, MONOGRAMS, THE LOGO MOTIF
   ========================================================================== */

/* Every pill carries a word. The palettes are literal pairs, not roles, so they
   are not tokenised: changing one half without the other breaks the contrast. */
.pill {
  display: inline-flex; align-items: center; min-height: 22px;
  font-size: .7rem; font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
  padding: .28rem .65rem; border-radius: 999px; white-space: nowrap;
}
.pill--ok   { background: var(--volt); color: var(--obsidian); }
.pill--warn { background: #FFF4CC; color: #7A5A00; border: 1px solid #F0DC9A; }
.pill--bad  { background: #FDE7E2; color: #B4341C; border: 1px solid #F3C6BB; }
.pill--mute { background: var(--tint); color: var(--ink-soft); border: 1px solid var(--line); }
.pill--dark { background: rgba(245,246,248,.1); color: var(--tk-text); border: 1px solid var(--tk-line-strong); }

.chips { display: flex; flex-wrap: wrap; gap: .4rem; }
.chip {
  display: inline-flex; align-items: center; gap: .35rem;
  min-height: 30px; padding: 0 .65rem; border-radius: var(--r-chip);
  font-size: .8rem; font-weight: 600; color: var(--ink);
  background: var(--tint); text-decoration: none;
}
.chip b { font-weight: 700; }
a.chip:hover { background: var(--tint-2); }
.ticket .chip, .strip .chip { background: rgba(245,246,248,.08); color: var(--tk-text); }

/* The monogram is the brand's offset-square motif with two letters in it.
   z-index: 0 makes .mono its own stacking context so the offset outline lands
   above its own tile and below the letters; without it the outline falls behind
   whichever ancestor happens to be a stacking context (the blurred top bar) and
   disappears. */
.mono {
  display: inline-grid; place-items: center;
  width: 36px; height: 36px; border-radius: 10px;
  position: relative; z-index: 0; flex: none;
  background: var(--obsidian); color: var(--volt);
  font-family: var(--font-display); font-weight: 800; font-size: .82rem;
}
.mono::before {
  content: ''; position: absolute; inset: 0; z-index: -1; border-radius: inherit;
  border: 1.5px solid var(--volt); opacity: .55; transform: translate(3px, -3px);
}
.mono--sm { width: 26px; height: 26px; border-radius: var(--r-chip); font-size: .66rem; }
.mono--sm::before { transform: translate(2px, -2px); }
.mono--lg { width: 56px; height: 56px; border-radius: 14px; font-size: 1.25rem; }
.mono--lg::before { transform: translate(5px, -5px); }
.ticket .mono { background: var(--charcoal); }

/* The logo's offset-layer geometry, drawn in CSS, for use on obsidian.
   filter: invert(1) on /assets/logo/r5-mark.svg is forbidden: the artwork is
   currentColor today and the invert would break silently the day it is not. */
.geo {
  display: inline-block; width: 14px; height: 14px; border-radius: 4px;
  background: var(--volt); position: relative;
}
.geo::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  border: 2px solid var(--volt); opacity: .55; transform: translate(5px, -5px);
}
.geo--lg { width: 28px; height: 28px; border-radius: 7px; }
.geo--lg::after { transform: translate(9px, -9px); }


/* ==========================================================================
   10. BUTTONS
   .btn, .btn--volt, .btn--ghost, .btn--sm and .btn--full come from styles.css.
   Only what is missing there is added, plus a floor of 48px so every control is
   comfortably bigger than a thumb.
   ========================================================================== */

.btn { min-height: 48px; }

/* Only when no modifier is present. Written with :not() rather than as a plain
   .btn rule, because app.css loads after styles.css and a bare .btn { background }
   at equal specificity would beat .btn--volt and turn every primary button on
   both signed-in areas white. DO NOT ADD A FOURTH MODIFIER TO THIS CHAIN. */
.btn:not(.btn--volt):not(.btn--ghost):not(.btn--danger) {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line-strong);
}
.btn:not(.btn--volt):not(.btn--ghost):not(.btn--danger):hover {
  border-color: rgba(20, 23, 26, .3);
}
.btn--sm { min-height: 40px; }
.btn--danger {
  background: transparent; color: var(--neg);
  border: 1px solid rgba(180, 52, 28, .35);
}
.btn--danger:hover { background: #FDE7E2; border-color: rgba(180, 52, 28, .5); }
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; transform: none; }
.btn[disabled]:hover { transform: none; }

/* The dark secondary comes from context, not from a .btn--dark modifier, so the
   :not() chain above never has to know about it. */
.ticket .btn--ghost, .strip .btn--ghost, .window__in .btn--ghost {
  background: transparent; color: var(--tk-text); border: 1px solid var(--tk-line-strong);
}
.ticket .btn--ghost:hover, .strip .btn--ghost:hover, .window__in .btn--ghost:hover {
  background: rgba(245,246,248,.08);
}

.btns { display: flex; flex-direction: column; gap: .6rem; margin-top: 1.1rem; }
.btns--end { justify-content: flex-end; }
.btns--end .btn { flex: 0 0 auto; }

/* A submit sitting directly under the last field reads as part of it. The gap is
   what says the form is finished and this is the thing that sends it. */
form > .btn, form > .btns { margin-top: 1.4rem; }

.btn--icon { width: 44px; padding: 0; }   /* always with an aria-label */

/* The one-pass shine on the button that takes money.
   RULE: .btn--go is only ever used together with .btn--volt, so the :not()
   chain above never needs to know about it either. */
.btn--go { position: relative; overflow: hidden; gap: .55em; }
.btn--go svg { width: 18px; height: 18px; flex: none; transition: transform .25s var(--ease-nudge); }
.btn--go:hover svg { transform: translateX(4px); }
.btn--go::after {
  content: ''; position: absolute; top: 0; bottom: 0; left: 0; width: 30%;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 0%, rgba(255,255,255,.55) 50%, transparent 100%);
  transform: skewX(-20deg) translateX(-180%);
}
.is-ready .btn--go::after { animation: r5Shine .9s var(--ease-shine) .15s 1 both; }
.is-in .btn--go::after    { animation: r5Shine .9s var(--ease-shine) 1.05s 1 both; }


/* ==========================================================================
   11. SCREEN READER ONLY
   styles.css calls this .sr-only. The signed-in pages inherited .sr from the
   stylesheet they used to carry inline, and dropping it put a honeypot on screen
   where browsers happily autofilled it and every real signup was thrown away as
   a bot. Both spellings are defined here so that cannot happen again.
   ========================================================================== */

.sr, .sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}


/* ==========================================================================
   12. FORMS
   Restated in full, because styles.css dresses .field for the dark contact
   section and a partial override would leave white labels on a white card.
   Every input in both portals sits on paper. There is no dark restatement,
   and there is not going to be one.
   ========================================================================== */

.field { display: grid; gap: .4rem; margin-bottom: 1rem; }
.field label { font-size: .89rem; font-weight: 650; color: var(--ink); }
.field .opt, .field__opt { font-weight: 500; color: var(--ink-soft); }
.field .hint { font-size: .82rem; color: var(--ink-soft); line-height: 1.5; }

.field input, .field select, .field textarea,
input[type=text], input[type=email], input[type=tel],
input[type=password], input[type=date], input[type=search], select, textarea {
  width: 100%;
  font: inherit;
  font-size: 16px;                 /* under 16px and iOS zooms the page on focus.
                                      Element selectors, not classes, because
                                      admin/login.php and the admin filters use
                                      raw inputs with no .field wrapper. */
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--line-strong);
  border-radius: var(--r-sm);
  padding: .72rem .85rem;
  min-height: 48px;
  transition: border-color .2s ease, box-shadow .2s ease;
}
textarea, .field textarea { min-height: 104px; resize: vertical; line-height: 1.55; padding-top: .65rem; }

/* The arrow is drawn in --ink-soft in the data URI. It cannot be a token, so if
   --ink-soft ever changes, change it here too. */
.field select, select {
  appearance: none; -webkit-appearance: none; padding-right: 2.4rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23525E6C' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right .8rem center; background-size: 18px;
}

/* styles.css puts a 3px --focus outline on everything. On a field that outline
   sits outside a rounded box and reads badly, so it is swapped for a volt-dim
   border plus a ring. Remove the ring without restoring the outline and
   keyboard focus becomes invisible on every form in both portals. */
input:focus-visible, select:focus-visible, textarea:focus-visible,
.field input:focus-visible, .field select:focus-visible, .field textarea:focus-visible {
  outline: none;
  border-color: var(--volt-dim);
  box-shadow: 0 0 0 3px rgba(204, 255, 0, .3);
}
input[aria-invalid=true], textarea[aria-invalid=true] {
  border-color: var(--invalid); box-shadow: 0 0 0 3px rgba(194, 65, 12, .15);
}
::placeholder { color: var(--ph-col); }

@media (min-width: 520px) {
  .pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: .8rem; }
}

/* Show / hide password. The button is only rendered visible once the script has
   said it is there, so with JavaScript off nothing sits over the input doing
   nothing. */
.pw { position: relative; }
.pw input { padding-right: 4.4rem; }
.pw__t {
  position: absolute; top: 50%; right: .4rem; transform: translateY(-50%);
  display: none; align-items: center;
  min-height: 36px; padding: 0 .7rem;
  border: 0; border-radius: var(--r-chip); background: var(--tint);
  color: var(--ink-soft); font: inherit; font-size: .8rem; font-weight: 650;
  cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.js .pw__t { display: inline-flex; }
.pw__t:hover { background: var(--tint-2); color: var(--ink); }

/* "Needed" becomes a chip, and the four needed questions get a volt rail so the
   eye finds them without anything moving. The brief's eleven fields keep their
   positions, names and ids exactly as they are posted today. */
.req {
  display: inline-flex; align-items: center; height: 18px; padding: 0 .45rem;
  margin-left: .35rem; border-radius: 6px;
  background: var(--volt); color: var(--obsidian);
  font: 800 .62rem/1 var(--font-body); letter-spacing: .1em;
  text-transform: uppercase; vertical-align: middle;
}
.field--need { border-left: 3px solid var(--volt); padding-left: .8rem; margin-left: -.8rem; }

.check { display: flex; gap: .65rem; align-items: flex-start; cursor: pointer; font-size: .92rem; }
.check input[type=checkbox] {
  width: 22px; height: 22px; min-height: 0; flex: none; margin-top: .1rem; accent-color: var(--volt-dim);
}
.check small { display: block; color: var(--ink-soft); font-size: .84rem; margin-top: .15rem; line-height: 1.5; }

/* The terms box is the one thing on the form somebody has to read before
   ticking, so it gets a little room of its own rather than sitting flush
   against the field above it. */
.check.terms {
  margin: 1.4rem 0 .2rem; padding: .9rem 1rem; background: var(--bg);
  border: 1px solid var(--line); border-radius: var(--r-sm);
}
.check.terms a { color: var(--link); text-decoration: underline; text-underline-offset: 2px; }

/* The live / sandbox switch on admin payments. Amber when on, because sandbox
   is a warning state and not a success. */
.switch { display: flex; align-items: center; gap: .7rem; cursor: pointer; font-size: .92rem; }
.switch input {
  appearance: none; -webkit-appearance: none;
  width: 44px; height: 26px; min-height: 0; flex: none;
  border-radius: 999px; background: var(--line-strong); border: 0; padding: 0;
  position: relative; transition: background .2s ease;
}
.switch input::after {
  content: ''; position: absolute; top: 3px; left: 3px; width: 20px; height: 20px;
  border-radius: 50%; background: var(--paper); transition: transform .2s ease;
}
.switch input:checked { background: var(--amber); }
.switch input:checked::after { transform: translateX(18px); }

/* A deliberately read-only field should read as a fact, not as a field that has
   broken. The label says so in words as well. */
.is-ro input[disabled] {
  opacity: 1; color: var(--ink-soft); background: var(--bg);
  border-style: dashed; cursor: default;
}
.is-ro label::after {
  content: "read only"; margin-left: .5rem;
  font: 700 .62rem/1 var(--font-body); letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-soft);
}

/* The photo drop zone, in the tear-line idiom. */
.drop {
  border: 2px dashed var(--tk-tear-light); border-radius: var(--r-md);
  padding: 1rem; background: var(--bg); display: grid; gap: .6rem; justify-items: start;
}
.drop input[type=file] { min-height: 48px; font-size: 16px; background: transparent; border: 0; padding: 0; }
.drop input[type=file]::file-selector-button {
  font: inherit; font-size: .92rem; font-weight: 650; min-height: 44px;
  padding: 0 1.1rem; border: 0; border-radius: 999px;
  background: var(--volt); color: var(--obsidian); margin-right: .8rem; cursor: pointer;
}

/* Save and Send at the bottom of a very long form, kept in reach on a phone.
   On a desktop the form is short enough to see, so the bar goes back into the
   flow rather than hovering over nothing. */
.actbar {
  position: sticky; bottom: 0; z-index: 5; margin-top: 1.1rem;
  padding: .75rem 0 calc(.75rem + env(safe-area-inset-bottom, 0px));
  background: linear-gradient(180deg, rgba(255,255,255,0), var(--surface) 35%);
}
@media (min-width: 960px) { .actbar { position: static; padding: 0; background: none; } }

/* The cancellation acknowledgement. The danger button dims until the box is
   ticked, with no script involved, so it still guards with scripts off. */
.ack {
  display: flex; gap: .65rem; align-items: flex-start; font-size: .85rem;
  padding: .8rem .9rem; background: rgba(255,107,107,.07);
  border: 1px solid rgba(255,107,107,.35); border-radius: var(--r-sm);
}
.ack input { width: 24px; height: 24px; min-height: 0; flex: none; accent-color: var(--red); }
.ack label { font-size: .88rem; font-weight: 600; }
form:has(.ack input:not(:checked)) .btn--danger { opacity: .5; }

/* Filters and search rows above a table. */
.toolbar { display: flex; flex-wrap: wrap; gap: .6rem; align-items: center; }
.toolbar input[type=text], .toolbar input[type=search] { flex: 1 1 220px; }


/* ==========================================================================
   13. NOTES AND FLASHES
   A note never carries a sticker, at most two stack on a page, and a flash
   renders under the page header, never above the h1.
   ========================================================================== */

.note, .warn {
  border-radius: var(--r-md);
  padding: .9rem 1.05rem;
  font-size: .92rem;
  line-height: 1.55;
  margin-bottom: 1.1rem;
  border: 1px solid;
}
/* .note--err is the client spelling and .warn the admin one. Both stay. */
.note--err, .warn { background: #FDE7E2; border-color: #F3C6BB; color: #7C2213; }
.note--ok        { background: #EEF9E3; border-color: #CDE7B0; color: #33600F; }
.note--warn      { background: #FFF7DF; border-color: #F0DC9A; color: #6B4E00; }
.note--test      { background: #EAF2FF; border-color: #C3D8F5; color: #21456E; }
.note b, .warn b { font-weight: 750; }

/* the single-line flash that sits under a page header */
.note--line { padding: .65rem .9rem; font-size: .88rem; }
.note .btn--sm { margin-top: .5rem; }


/* ==========================================================================
   14. LISTS, TILES, KEY AND VALUE GRIDS
   Chevrons are inline SVG in --ink-soft. Never the &rsaquo; character, never
   volt and never volt-dim: #9FCC00 on white is about 1.9:1.
   ========================================================================== */

.rows { list-style: none; margin: 0; padding: 0; }
.rows li {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem; padding: .75rem 0; border-top: 1px solid var(--line); font-size: .92rem;
}
.rows li:first-child { border-top: 0; }
.rows .k { color: var(--ink-soft); }
.rows .v { font-weight: 650; text-align: right; font-variant-numeric: tabular-nums; }

.rows--links li { padding: 0; }
.rows--links li a {
  display: flex; align-items: center; justify-content: space-between; gap: .8rem;
  min-height: 52px; font-weight: 650; color: var(--ink); text-decoration: none;
}
.go {
  width: 18px; height: 18px; flex: none; color: var(--ink-soft);
  fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
  transition: transform .25s var(--ease-nudge);
}
.rows--links li a:hover .go, .tile:hover .go { transform: translateX(4px); color: var(--ink); }

.tiles { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem; }
/* grid-column, box-shadow, the transition and the hover lift are restated
   because styles.css already owns a .tile for the public site's service cards:
   it sets grid-column: span 3 for a twelve column grid, a card shadow and a
   4px hover rise. Dropped into this two column grid that span makes every tile
   take a row of its own, and the rise is marketing motion on a menu. */
.tile {
  display: flex; align-items: center; gap: .65rem; min-height: 64px; padding: .8rem .9rem;
  grid-column: auto;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--r-md);
  box-shadow: none; transition: border-color .16s ease;
  color: var(--ink); font-weight: 650; font-size: .92rem; text-decoration: none;
}
.tile svg:first-child { width: 20px; height: 20px; color: var(--ink-soft); flex: none; }
.tile .go { margin-left: auto; }
.tile:hover { border-color: rgba(20, 23, 26, .3); transform: none; box-shadow: none; }

.kv { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem 1rem; }
.kv dt {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--ink-soft);
}
.kv dd { font-size: .95rem; font-weight: 650; font-variant-numeric: tabular-nums; margin: 0; }
@media (min-width: 640px) { .kv { grid-template-columns: repeat(3, minmax(0, 1fr)); } }


/* ==========================================================================
   15. TABLES
   ========================================================================== */

table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th {
  text-align: left; font-size: .68rem; letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-soft); font-weight: 700; padding: .65rem .7rem; white-space: nowrap;
}
td { padding: .7rem; border-top: 1px solid var(--line); vertical-align: top; }
td.n, th.n { text-align: right; font-variant-numeric: tabular-nums; }
tbody tr:hover { background: rgba(20, 23, 26, .022); }

/* The admin detail key column, moved out of clients.php. */
td.k { color: var(--ink-soft); width: 15rem; white-space: nowrap; }

.scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }

.tbl { position: relative; }
.tbl .scroll { border-radius: inherit; }
.tbl__foot { padding: .7rem 1rem 1rem; }
.tbl__act { display: flex; gap: .35rem; justify-content: flex-end; }
.tbl__group td {
  background: var(--bg); font-size: .68rem; line-height: 1.4; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase; color: var(--ink-soft); padding: .5rem .7rem;
}
/* The whole row is the affordance, the first cell's link is the hit area, so
   there is no second focusable copy of the same destination. */
tr.is-link { cursor: pointer; }
tr.is-link td:first-child { position: relative; }
tr.is-link td:first-child a::after { content: ''; position: absolute; inset: 0; }

/* .rack is the clients list. It has no rule of its own; it is a name for the
   markup so the page can hang page-specific rules on it if it ever needs to. */
.rack__who { display: flex; gap: .7rem; align-items: center; min-width: 0; }

/* Sticky heads only work when the table is NOT inside its own overflow box.
   So: phones get the collapsed rows (no scroll box needed) and desktops get
   the sticky head. Never wrap a .tbl--collapse table in .scroll at >=640px,
   and never add overflow to .tbl, or the sticky head silently stops sticking. */
@media (min-width: 640px) {
  .tbl--collapse .scroll { overflow: visible; }
  .tbl thead th {
    position: sticky; top: var(--top-h); z-index: 2;
    background: var(--surface); box-shadow: inset 0 -1px 0 var(--line);
  }
}

/* Phone: every row becomes a labelled stub card. This is half of the fix for
   the three admin pages that scroll sideways at 390px today. Every cell except
   the first needs data-k, or a phone shows a value with no label. */
@media (max-width: 639px) {
  .tbl--collapse thead {
    position: absolute !important; width: 1px; height: 1px;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  }
  .tbl--collapse tr {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .15rem .75rem;
    padding: .8rem 1rem; border-top: 1px solid var(--line);
  }
  .tbl--collapse tr:first-child { border-top: 0; }
  .tbl--collapse td { display: block; border: 0; padding: 0; }
  .tbl--collapse td[data-k]::before {
    content: attr(data-k); display: block;
    font-size: .66rem; line-height: 1.4; font-weight: 700; letter-spacing: .1em;
    text-transform: uppercase; color: var(--ink-soft);
  }
  .tbl--collapse td.n { text-align: left; }
  .tbl--collapse td.span2 { grid-column: 1 / -1; }
}

/* Ranked bars, aligned across rows: bar() emits the track as well as the fill
   so a 12% row and a 90% row start in the same place. */
.bar { height: 6px; border-radius: var(--r-stub); background: var(--volt-dim); opacity: .8; min-width: 2px; }
.meter { height: 6px; border-radius: var(--r-stub); background: var(--line); overflow: hidden; margin-top: .35rem; }
.meter > .bar { height: 100%; }


/* ==========================================================================
   16. CHARTS: THE FUNNEL
   Ships on funnel.php only, where reached / opened / sent are already counted.
   This replaces an inline track drawn in rgba(245,246,248,.06), which was a
   dark-surface colour and therefore invisible on a white card.
   ========================================================================== */

.pipe { list-style: none; margin: 0; padding: 0; display: grid; gap: .5rem; }
.pipe__k {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--ink-soft);
}
.pipe__n {
  font-family: var(--font-display); font-size: clamp(1.4rem, 3.2vw, 1.8rem);
  font-weight: 800; letter-spacing: -.03em; font-variant-numeric: tabular-nums;
  margin: .1rem 0 .35rem;
}
.pipe__n i { font: 400 .84rem/1 var(--font-body); font-style: normal; color: var(--ink-soft); margin-left: .4rem; }
/* The one number in the funnel that answers the question the page was opened
   with. It is the same size as its neighbours, because a funnel is a
   comparison and resizing one stage would lie about the shape; it is the ink
   that changes, plus a volt underline the width of the digits. */
.pipe__n.is-key { color: var(--ink); box-shadow: inset 0 -.28em 0 rgba(204, 255, 0, .55); }
.strip .pipe__n.is-key { color: var(--tk-text); box-shadow: inset 0 -.28em 0 rgba(204, 255, 0, .3); }
.pipe__track { height: 30px; border-radius: var(--r-chip); background: var(--bg); border: 1px solid var(--line); overflow: hidden; }
.pipe__fill { height: 100%; min-width: 3%; border-radius: 7px; background: linear-gradient(90deg, var(--volt), var(--volt-dim)); }
.pipe__drop { font-size: .82rem; font-weight: 650; color: var(--neg); padding-left: .2rem; }
.pipe__drop::before {
  content: ''; display: inline-block; width: 0; height: 0; margin-right: .4rem;
  border: 4px solid transparent; border-top-color: currentColor; vertical-align: .1em;
}


/* ==========================================================================
   17. EMPTY STATES
   Never the word "nothing" on its own. Two lines: the fact, then what will
   change it, with a date where one exists. The sticker on a light empty card is
   the one permitted piece of theatre on an otherwise blank surface, and it does
   not count against the one-obsidian-object rule because it is not on obsidian.
   ========================================================================== */

.empty { color: var(--ink-soft); font-size: .95rem; padding: 2.5rem 1rem; text-align: center; }
.empty--tag { display: grid; justify-items: center; gap: .6rem; padding: 2.25rem 1rem; text-align: center; }
.empty--tag .tag { position: static; transform: rotate(-4deg); }
.empty__t {
  font: 750 1.05rem/1.25 var(--font-display); letter-spacing: -.02em; color: var(--ink);
}
.empty--tag .fine { max-width: 42ch; }
.card--dark .empty__t, .strip .empty__t { color: var(--tk-text); }


/* ==========================================================================
   18. THE PAYFAST STRIP
   The official mark never sits on obsidian, because its own type is dark. There
   is no .secure--dark and there is not going to be one.
   ========================================================================== */

.secure {
  display: flex; align-items: center; justify-content: center; flex-wrap: wrap;
  gap: .45rem .75rem; margin-top: 1.05rem;
}
.secure__t {
  display: inline-flex; align-items: center; gap: .4rem;
  font-size: .82rem; font-weight: 600; color: var(--ink-soft);
}
.secure__t svg { width: 15px; height: 15px; flex: none; color: var(--pos); }
.secure__logo { display: block; height: 38px; width: auto; }
.secure__fine { margin-top: .5rem; text-align: center; font-size: .8rem; line-height: 1.5; color: var(--ink-soft); }

/* The line under a form that offers the other door: sign in instead of
   register, or the way back to the public site. Shared, because register and
   sign in both carried their own copy of these three properties. */
.alt { text-align: center; margin-top: 1.2rem; font-size: .9rem; }
.alt--away { margin-top: 2rem; font-size: .84rem; color: var(--ink-soft); }

/* ---------- the questions asked before signing up ----------
   Closed by default, so they cost nothing to somebody already convinced, and
   built out of <details> so they work with no JavaScript. */
.faq { margin-top: 2.25rem; }
.faq__t {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--ink-soft); margin-bottom: .5rem;
}
.faq__q { border-top: 1px solid var(--line); }
.faq__q:last-of-type { border-bottom: 1px solid var(--line); }
.faq__q > summary {
  list-style: none; cursor: pointer; display: flex; align-items: center; gap: .75rem;
  min-height: 52px; padding: .55rem 0; font-size: .95rem; font-weight: 650;
  -webkit-tap-highlight-color: transparent;
}
.faq__q > summary::-webkit-details-marker { display: none; }
/* A plus that becomes a minus. Drawn in CSS so there is no glyph to lose and
   nothing to load. */
.faq__q > summary::after {
  content: ''; flex: none; margin-left: auto; width: 11px; height: 11px;
  background:
    linear-gradient(var(--ink-soft), var(--ink-soft)) center/11px 2px no-repeat,
    linear-gradient(var(--ink-soft), var(--ink-soft)) center/2px 11px no-repeat;
  transition: transform .2s var(--ease-out, ease);
}
.faq__q[open] > summary::after { background-size: 11px 2px, 0 0; }
.faq__q > summary:hover { color: var(--link); }
.faq__q p { padding: 0 0 .95rem; font-size: .9rem; line-height: 1.6; color: var(--ink-soft); max-width: 62ch; }

@media (prefers-reduced-motion: reduce) {
  .faq__q > summary::after { transition: none; }
}


/* ==========================================================================
   19. THE SEGMENTED CONTROL (the range picker)
   ========================================================================== */

.seg {
  display: inline-flex; padding: 3px; gap: 2px;
  border: 1px solid var(--line-strong); border-radius: 999px;
  background: var(--surface); max-width: 100%; overflow-x: auto;
}
.seg__a {
  display: inline-flex; align-items: center; min-height: 34px; padding: 0 .8rem;
  border-radius: 999px; font-size: .8rem; font-weight: 600;
  color: var(--ink-soft); text-decoration: none; white-space: nowrap;
}
.seg__a:hover { color: var(--ink); background: var(--tint); }
.seg__a.on { background: var(--obsidian); color: var(--volt); }
@media (max-width: 560px) { .seg__a { min-height: 40px; } }

.strip .seg { background: rgba(245,246,248,.06); border-color: var(--tk-line-strong); }
.strip .seg__a { color: var(--tk-soft); }
.strip .seg__a:hover { color: var(--tk-text); background: rgba(245,246,248,.08); }
.strip .seg__a.on { background: var(--volt); color: var(--obsidian); }


/* ==========================================================================
   20. CONFIRM, DANGER, ACTION ROWS
   No modals, no confirm(), no JavaScript in any guard. A real-money guard that
   does nothing with scripts off is not a guard, so it is a <details> reveal:
   markup only, works everywhere, and the inner button always starts with "Yes,"
   so a screen reader user cannot mistake the two buttons for each other.
   ========================================================================== */

.acts__row { display: grid; gap: .6rem; padding: .9rem 0; border-top: 1px solid var(--line); }
.acts__row:first-of-type { border-top: 0; }
.acts__t { font-weight: 700; font-size: .95rem; }
.acts__d { font-size: .84rem; color: var(--ink-soft); line-height: 1.5; }
.acts__set { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
.acts__set input { flex: 1 1 150px; }
/* The doing half of a row: a form, or a <details class="confirm"> whose summary
   is the button. align-content start keeps a one-button form from stretching to
   the height of a three-line explanation beside it. */
.acts__do { display: grid; gap: .5rem; align-content: start; }
.acts__del label { font-size: .82rem; }
.acts__del input { max-width: 340px; }
@media (min-width: 820px) {
  .acts__row { grid-template-columns: minmax(0, 1fr) auto; align-items: start; column-gap: 1.2rem; }
  .acts__do { justify-self: end; min-width: 240px; justify-items: end; }
  /* The confirm body is a paragraph and a form, so it reads left to right even
     when the button that opened it is pinned to the right. */
  .acts__do .confirm__body { justify-items: stretch; text-align: left; }
  .acts__del { justify-items: stretch; }
}

.danger { border-top: 2px solid rgba(180, 52, 28, .35); margin-top: .5rem; padding-top: .75rem; }
.danger__t {
  font-size: .68rem; line-height: 1.2; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--neg); margin-bottom: .3rem;
}

.confirm > summary {
  list-style: none; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;
}
.confirm > summary::-webkit-details-marker { display: none; }
.confirm[open] > summary { opacity: .5; }
.confirm__body {
  margin-top: .6rem; padding: .85rem;
  background: #FDE7E2; border: 1px solid #F3C6BB; border-radius: var(--r-sm);
  color: #7C2213; font-size: .9rem; display: grid; gap: .6rem;
}
/* The whole point of this component is that the real button is out of reach
   until somebody asks for it. A browser hides the closed content of a <details>
   for you, but declaring display on the child defeats that, and the guard then
   gates nothing while still looking like it does. Say it explicitly. */
.confirm:not([open]) > .confirm__body { display: none; }
.confirm__body .btn--danger { background: var(--paper); }


/* ==========================================================================
   21. THE ACTIVITY TIMELINE (admin client record)
   Assembled in PHP from timestamps the page already has. No new query.
   ========================================================================== */

.tl { list-style: none; margin: 0; padding-left: 1.6rem; position: relative; }
.tl::before {
  content: ''; position: absolute; left: 5px; top: .6rem; bottom: .6rem;
  width: 2px; background: var(--line-strong);
}
.tl li {
  position: relative; display: grid; grid-template-columns: 6.5rem minmax(0, 1fr);
  gap: .15rem .8rem; padding: .4rem 0;
}
.tl li::before {
  content: ''; position: absolute; left: -1.6rem; top: .75rem;
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--volt-dim); box-shadow: 0 0 0 3px var(--surface);
}
.tl li.is-bad::before { background: var(--neg); }
.tl li.is-now::before { background: var(--obsidian); box-shadow: 0 0 0 2px var(--volt), 0 0 0 5px var(--surface); }
.tl__d { font-size: .78rem; color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.tl__t { font-size: .9rem; color: var(--ink); }
@media (max-width: 480px) { .tl li { grid-template-columns: minmax(0, 1fr); } }


/* ==========================================================================
   22. THE REST
   Small pieces that were scattered across page <style> blocks, gathered so a
   page never has to carry a copy that can drift.
   ========================================================================== */

.code {
  display: block; font: 500 .8rem/1.6 ui-monospace, Consolas, Menlo, monospace;
  background: var(--bg); border: 1px solid var(--line-strong); border-radius: var(--r-sm);
  padding: .7rem .85rem; user-select: all; overflow-x: auto;
}

.copyblock { margin-top: 1rem; }
.copyblock summary {
  cursor: pointer; font-size: .88rem; font-weight: 650; color: var(--link);
  padding: .45rem 0; list-style-position: inside;
}
.copyblock textarea {
  margin-top: .6rem;
  font: 12.5px/1.6 ui-monospace, Consolas, Menlo, monospace;
  background: var(--bg); color: var(--ink);
  border: 1px solid var(--line-strong); border-radius: var(--r-sm);
  white-space: pre; overflow: auto; min-height: 0;
}

.more summary { font-size: .8rem; color: var(--link); cursor: pointer; margin-top: .5rem; }
.more p { font-size: .82rem; color: var(--ink-soft); margin-top: .35rem; line-height: 1.5; }

.quote { font-size: .88rem; color: var(--ink-soft); border-left: 2px solid var(--line-strong); padding-left: .7rem; }

.state__t { display: flex; gap: .6rem; align-items: flex-start; font-weight: 650; font-size: 1rem; }
.state__t::before {
  content: ''; width: 12px; height: 12px; border-radius: 50%;
  margin-top: .35em; flex: none; background: var(--line-strong);
}
.state__t.up::before { background: var(--pos); }
.state__t.down::before { background: var(--neg); }

/* The waiting ring. Static it is still a three-quarter ring beside the word
   "Waiting", so it reads the same with motion turned off. */
.ring {
  display: inline-block; width: 28px; height: 28px; border-radius: 50%;
  border: 2px solid var(--tk-line-strong); border-top-color: var(--volt);
  vertical-align: -.35em; margin-right: .35rem;
}
.ring--wait { animation: r5Spin .9s linear infinite; }

.heat-legend { height: 8px; border-radius: 4px; background: linear-gradient(90deg, #466EFF, #00C8B4, #CCFF00, #FFAA00, #FF3C3C); }

.hm { display: grid; gap: var(--gap); }
@media (min-width: 900px) {
  .hm { grid-template-columns: minmax(0, 1fr) 300px; align-items: start; }
  .hm__rail { position: sticky; top: calc(var(--top-h) + 1rem); }
}
/* The one obsidian object on the heatmap page: a phone bezel around the stage.
   The stage itself stays flat paper, because it is a tool. */
.hm__stage--phone { background: var(--obsidian); border-radius: var(--r-xl); padding: 12px; box-shadow: var(--shadow-tk); }
.hm__stage--phone #hm-stage { border-radius: 26px; overflow: hidden; }

.thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: .6rem; }
.answer { white-space: pre-wrap; }

.shots { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: .6rem; }
.shot { border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--bg); overflow: hidden; }
.shot img { aspect-ratio: 1; object-fit: cover; width: 100%; }
.shot button {
  width: 100%; min-height: 44px; border: 0; border-top: 1px solid var(--line);
  background: transparent; color: var(--ink-soft); font-size: .8rem; font-weight: 650; cursor: pointer;
}
/* Two taps to delete: the first arms the tile, the second sends the form. */
.shot.is-armed button { color: var(--neg); background: #FDE7E2; }
/* Most desktop browsers will not draw a HEIC, so the tile says what it is
   instead of showing a broken image. */
.shot.is-heic img { display: none; }
.shot.is-heic::before {
  content: 'HEIC'; display: grid; place-items: center; aspect-ratio: 1;
  color: var(--ink-soft); font: 700 .7rem/1 var(--font-body); letter-spacing: .12em;
}

/* The admin client record: the owner on a phone is there to act, so the manage
   blocks come before the long tables. From 1000px they move into a sticky rail
   beside them. */
.rec { display: grid; gap: var(--gap); }
@media (min-width: 1000px) {
  .rec { grid-template-columns: minmax(0, 1fr) 340px; align-items: start; }
  .rec__rail { position: sticky; top: calc(var(--top-h) + 1rem); display: grid; gap: var(--gap); }
}


/* ==========================================================================
   23. THE SIDE MENU
   A drawer on a phone, a permanent column from 960px up. Grouped, because a
   flat list of seven links makes somebody read all seven to find one, and a
   heading turns that into a glance.

   The drawer slides rather than appears, and it sits above its own sheet of
   glass so the page behind is plainly still there and plainly not the thing you
   are using. The off-canvas rules are gated on html.js, so with JavaScript off
   the menu is a horizontal chip strip in the flow instead of a drawer nobody
   can open. The markup is identical in both areas; only .area-admin on <body>
   changes the colours.
   ========================================================================== */

.side {
  position: fixed;
  top: 0; bottom: 0; left: 0;
  z-index: 95;
  width: min(280px, 82vw);
  padding: 1rem .8rem calc(1rem + env(safe-area-inset-bottom, 0px));
  padding-top: calc(1rem + env(safe-area-inset-top, 0px));
  background: var(--surface);
  border-right: 1px solid var(--line);
  overflow-y: auto;
  overscroll-behavior: contain;
  transform: translateX(-100%);
  transition: transform .28s cubic-bezier(.2,.7,.3,1);
  display: flex;
  flex-direction: column;
  gap: .2rem;
}
.menu-open .side { transform: none; box-shadow: var(--shadow-drawer); }

.scrim {
  position: fixed; inset: 0; z-index: 90;
  background: rgba(20,23,26,.45);
  backdrop-filter: blur(2px);
  -webkit-backdrop-filter: blur(2px);
  opacity: 0; transition: opacity .28s ease;
}
.menu-open .scrim { opacity: 1; }
.scrim[hidden] { display: none; }
.menu-open { overflow: hidden; }

.side__group { padding-bottom: .35rem; }
.side__group + .side__group { margin-top: .5rem; padding-top: .5rem; border-top: 1px solid var(--line); }
.side__k {
  font-size: .66rem; font-weight: 700; letter-spacing: .13em; text-transform: uppercase;
  color: var(--ink-soft); padding: .5rem .8rem .35rem;
}

.side__a {
  display: flex; align-items: center; gap: .7rem;
  min-height: 46px; padding: .5rem .8rem;
  border-radius: 12px;
  color: var(--ink); text-decoration: none;
  font-size: .95rem; font-weight: 600;
  position: relative;
  transition: background-color .16s ease, color .16s ease;
  -webkit-tap-highlight-color: transparent;
}
.side__a svg { width: 20px; height: 20px; flex: none; color: var(--ink-soft); }
.side__a:hover { background: var(--tint); }
.side__a.on { background: var(--tint); color: var(--ink); font-weight: 700; }
.side__a.on svg { color: var(--obsidian); }
/* The old solid volt block was the loudest thing on every page and fought the
   one volt button the page is actually asking you to press. This is a
   ticket-book tab on the rail's outer edge instead. */
.side__a.on::before {
  content: ''; position: absolute; left: -.8rem; top: 50%; margin-top: -11px;
  width: 4px; height: 22px; border-radius: 0 3px 3px 0; background: var(--volt);
}

/* The client's plan at a glance, above sign out. Paper, not obsidian, so the
   one-obsidian-object rule is untouched. */
.side__stub {
  margin-top: auto; margin-bottom: .4rem; padding: .7rem .8rem;
  border: 1px solid var(--line); border-radius: var(--r-sm); background: var(--bg);
  display: grid; gap: .35rem; justify-items: start;
}
.side__stub p { font-size: .82rem; color: var(--ink-soft); }
.side__stub + .side__out { margin-top: 0; }

.side__out { margin-top: auto; color: var(--ink-soft); font-weight: 550; }
.side__out:hover { color: var(--red); background: rgba(255,45,45,.07); }

/* ---------- the button that opens it ---------- */

.burger {
  appearance: none; border: 0; background: transparent; cursor: pointer;
  width: 44px; height: 44px; margin-left: -.6rem; flex: none;
  display: grid; align-content: center; gap: 5px; padding: 0 10px;
  border-radius: 10px;
  -webkit-tap-highlight-color: transparent;
}
.burger:hover { background: var(--tint); }
.burger span {
  display: block; height: 2px; border-radius: 2px; background: var(--ink);
  transition: transform .25s ease, opacity .2s ease;
}
.menu-open .burger span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.menu-open .burger span:nth-child(2) { opacity: 0; }
.menu-open .burger span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }

/* ---------- the admin rail: obsidian, behind the counter ---------- */

.area-admin .side {
  background: radial-gradient(420px 260px at 0% 100%, rgba(204,255,0,.07), transparent 60%), var(--obsidian);
  border-right: 1px solid var(--tk-line);
}
.area-admin .side__group + .side__group { border-top-color: var(--tk-line); }
.area-admin .side__k { color: rgba(138,149,165,.7); }
.area-admin .side__a { color: var(--tk-soft); }
.area-admin .side__a svg { color: var(--tk-soft); }
.area-admin .side__a:hover { background: rgba(245,246,248,.06); color: var(--tk-text); }
.area-admin .side__a.on { background: rgba(245,246,248,.06); color: var(--tk-text); font-weight: 700; }
.area-admin .side__a.on svg { color: var(--volt); }
.area-admin .side__out { color: var(--tk-soft); }
.area-admin .side__out:hover { color: var(--tk-bad); background: rgba(255,45,45,.10); }
.area-admin .side :focus-visible { outline-color: var(--volt); }
.area-admin .scrim { background: rgba(14,16,18,.55); }

/* ---------- with no JavaScript ----------
   The drawer needs a script to open, so without one the menu becomes a
   horizontal strip of chips under the top bar and every link is reachable by
   tapping. Both layouts add 'js' to <html> as the first thing their head script
   does, which is what turns these rules back off. */

@media (max-width: 959px) {
  html:not(.js) .shell { flex-direction: column; }
  html:not(.js) .side {
    position: static; width: 100%; height: auto;
    flex-direction: row; flex-wrap: nowrap;
    overflow-x: auto; gap: .35rem; padding: .5rem 1rem; transform: none;
    border-right: 0; border-bottom: 1px solid var(--line);
  }
  html:not(.js) .side__group { display: contents; }
  html:not(.js) .side__k,
  html:not(.js) .side__stub,
  html:not(.js) .burger { display: none; }
  html:not(.js) .side__a { min-height: 40px; padding: .4rem .7rem; white-space: nowrap; }
  html:not(.js) .side__a.on::before { display: none; }
  html:not(.js) .side__a.on { background: var(--volt); color: var(--obsidian); }
  html:not(.js) .area-admin .side__a.on { background: var(--volt); color: var(--obsidian); }
  html:not(.js) .area-admin .side__a.on svg { color: var(--obsidian); }
  html:not(.js) .side__out { margin-top: 0; }
}


/* ==========================================================================
   24. WIDER SCREENS
   The grid families live here because the phone column count is the fix for the
   three admin pages that scroll sideways at 390px today, and it is easier to
   defend when the whole ladder is in one place.
   ========================================================================== */

.grid, .kpis { display: grid; gap: var(--gap); grid-template-columns: minmax(0, 1fr); }
.g2 { grid-template-columns: minmax(0, 1fr); }   /* one column on a phone: this is the fix */
.row { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; }

@media (min-width: 560px) {
  /* Four stat cards stacked one per row on a tablet is a lot of scrolling for
     four numbers, so they pair up well before the full four-across kicks in. */
  .g4, .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .kpis { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
}

@media (min-width: 640px) {
  .btns { flex-direction: row; }
  .btns .btn { flex: 1 1 0; }
  .g2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

@media (min-width: 900px) {
  .g4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .kpis { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
  .g3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}

/* ---------- from 960px the menu simply lives there ---------- */

@media (min-width: 960px) {
  .burger, .scrim { display: none; }
  .side {
    position: sticky;
    top: var(--top-h);
    height: calc(100svh - var(--top-h));
    transform: none;
    flex: none;
    box-shadow: none;
    border-right: 1px solid var(--line);
    padding-top: 1.1rem;
  }
  .area-admin .side { border-right: 1px solid var(--tk-line); }
  .has-side main { min-width: 0; flex: 1; }
  /* The menu already takes its column, so the rest of the width belongs to the
     data. Capping this at a reading measure made sense when the page was one
     centred column; beside a sidebar it just left a stripe of empty grey on a
     wide screen while the tables underneath scrolled sideways. A page that is
     one column of prose or one form opts the cap back in with .wrap--cap. */
  .has-side main .wrap { max-width: none; margin-inline: 0; padding-inline: clamp(1.25rem, 2.5vw, 2.25rem); }
  .has-side main .wrap.wrap--cap { max-width: 1180px; margin-inline: auto; }
  .menu-open { overflow: auto; }
}


/* ==========================================================================
   25. MOTION
   Everything runs once on arrival and is finished inside 1.2s. Two loops only:
   the current step's ping and the waiting ring. Arrival motion is gated on
   html.is-in, which the layouts add beside html.js, so with JavaScript off
   every ticket, sticker and rail renders complete and still.
   ========================================================================== */

@keyframes r5Rise  { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes r5Slap  { 0%   { opacity: 0; transform: rotate(9deg) scale(1.55); }
                     55%  { opacity: 1; transform: rotate(-6.5deg) scale(.95); }
                     100% { opacity: 1; transform: rotate(-4deg) scale(1); } }
@keyframes r5Fill  { from { background: transparent; color: var(--tk-soft); } to { background: var(--volt); color: var(--obsidian); } }
@keyframes r5Ping  { 0%, 100% { box-shadow: 0 0 0 4px rgba(204,255,0,.55); } 50% { box-shadow: 0 0 0 8px rgba(204,255,0,.12); } }
@keyframes r5Shine { to { transform: skewX(-20deg) translateX(480%); } }
@keyframes r5Spin  { to { transform: rotate(360deg); } }

/* The ticket assembles itself: the three hook lines, then the paragraph, the
   rows, the rail and the fine print. The same children inside .roll__head and
   .window__in are the same selectors, so they come along.

   .stubs--mini is excluded everywhere below. It lives in an admin table cell,
   six or three hundred to a page, and arrival motion on a tool the owner opens
   thirty times a day is a tax. Worse, r5Rise fills backwards: without the
   exclusion every mini rail sat at opacity 0 for half a second on load, which
   reads as a rail that is not there. */
.is-in .ticket__hook span,
.is-in .ticket__d,
.is-in .ticket__rows,
.is-in .stubs:not(.stubs--mini),
.is-in .ticket__fine,
.is-in .roll__k,
.is-in .roll__v { animation: r5Rise var(--dur-4) var(--ease-out) both; }

.is-in .ticket__hook span:nth-child(1) { animation-delay: .12s; }
.is-in .ticket__hook span:nth-child(2) { animation-delay: .2s; }
.is-in .ticket__hook span:nth-child(3) { animation-delay: .28s; }
.is-in .roll__k  { animation-delay: .12s; }
.is-in .roll__v  { animation-delay: .2s; }
.is-in .ticket__d { animation-delay: .36s; }
.is-in .ticket__rows { animation-delay: .44s; }
.is-in .stubs:not(.stubs--mini) { animation-delay: .5s; }
.is-in .ticket__fine { animation-delay: .52s; }

/* The sticker lands last, after the sheet it is stuck to. */
.is-in .tag { animation: r5Slap var(--dur-3) var(--ease-slap) .35s both; }

/* The paid months fill left to right once the ticket has settled. --i is set on
   each cell in the markup. The mini rail is excluded on purpose: seventy two
   cells sweeping in an admin table is a tax, not a flourish. */
.is-in .stubs:not(.stubs--mini) li.is-paid {
  animation: r5Fill .25s var(--ease-out) both;
  animation-delay: calc(.7s + var(--i, 0) * 40ms);
}

@media (prefers-reduced-motion: reduce) {
  .btn, .btn:hover, .btn:active { transition: none; transform: none; }
  .side, .scrim, .burger span { transition: none; }
  .tag, .ticket__hook span, .ticket__d, .ticket__rows, .ticket__fine,
  .roll__k, .roll__v, .stubs, .stubs li, .steps b, .steps .on b,
  .ring--wait, .go, .btn--go svg, .switch input::after,
  input, select, textarea { animation: none; transition: none; }

  /* The same list again, carrying the .is-in prefix. The arrival rules above
     are written as ".is-in .thing", so an unprefixed ".thing { animation: none }"
     here loses to them on specificity and every ticket would still rise and
     every sticker would still slap for a reader who asked for none of it. These
     twins match specificity and win on source order. If a new arrival animation
     is added above, its selector is added here too. */
  .is-in .tag,
  .is-in .ticket__hook span,
  .is-in .ticket__d,
  .is-in .ticket__rows,
  .is-in .stubs:not(.stubs--mini),
  .is-in .ticket__fine,
  .is-in .roll__k,
  .is-in .roll__v,
  .is-in .stubs:not(.stubs--mini) li.is-paid { animation: none; }

  /* The current step keeps a static halo, so "you are here" survives. */
  .steps .on b { box-shadow: 0 0 0 4px rgba(204,255,0,.55); }
  .btn--go::after { display: none; }

  /* Anything that was waiting on an animation to become visible has to be
     visible without it, and the sticker keeps its crooked resting angle. */
  .is-in .ticket__hook span, .is-in .ticket__d, .is-in .ticket__rows,
  .is-in .stubs:not(.stubs--mini), .is-in .ticket__fine,
  .is-in .roll__k, .is-in .roll__v {
    opacity: 1; transform: none;
  }
  .is-in .tag { opacity: 1; transform: rotate(-4deg); }
  .is-in .stubs:not(.stubs--mini) li.is-paid { background: var(--volt); color: var(--obsidian); }
}
