/* Work Permit e-Sign — design system
   Semantic, brand-driven CSS variables. Colors are injected per-request from
   branding config into :root (see layout head). Mobile-first, responsive. */

/* ---------- The Saudi riyal sign (U+20C1) ----------
   U+20C1 SAUDI RIYAL SIGN entered Unicode only in 17.0 (9 September 2025), and no font in
   the --font stack below carries it. Without this face every amount on screen renders as a
   box or vanishes, so the glyph arrives as a served asset rather than a system font.
   Provenance and licence: assets/fonts/saudi-riyal-PROVENANCE.txt (SIL OFL 1.1).

   THE unicode-range IS LOAD-BEARING — IT IS NOT TIDYING.
   Parsing the upstream font's cmap shows it maps more than the riyal sign: U+0041 A,
   U+0042 B, U+0043 C, U+0044 D, U+20C0, U+20C1, U+E001, U+E002 and U+E900. The Latin
   letters A to D are drawn as riyal-shaped glyphs, a hangover from the icon-font approach
   the same upstream project supports. Because this family is FIRST in --font, dropping the
   unicode-range would redraw every capital A, B, C and D on the platform as a currency
   sign. Restricting the face to U+20C1 is what makes putting it first safe: the browser
   asks this font for exactly one character and falls through to the real body font for
   everything else — and it does not even download the file on a page with no amount on it.

   Declared at the top level, never inside @media screen: the only invoice "PDF" this
   platform produces for a customer is the browser's own print of /i/{token}
   (app/Views/public/invoice.php), and a print context has to be able to embed this face. */
@font-face {
    font-family: "Saudi Riyal";
    src: url("/assets/fonts/saudi-riyal.woff2") format("woff2");
    font-weight: 400;
    font-style: normal;
    font-display: swap;
    unicode-range: U+20C1;
}
@font-face {
    font-family: "Saudi Riyal";
    src: url("/assets/fonts/saudi-riyal-bold.woff2") format("woff2");
    font-weight: 700;
    font-style: normal;
    font-display: swap;
    unicode-range: U+20C1;
}

:root {
    /* ---------- SignMyDoc brand palette ----------
       Gold #D4AF37 is 2.10 against white, so it is a SURFACE here and never ink, and the ink
       ON it is near-black rather than the white the brand mockups show — white on gold is the
       same failing 2.10. --ui-text is the darkened gold that text and links use instead
       (NOT --c-ui-ink, which already existed and means the ink that goes ON the accent —
       reading it gave white text on the pale gold pill at 1.11:1),
       solved against the darkest light ground it meets (its own tint) at 4.68.
       Every value here was computed; see the contrast harness that ships with this change. */
    --c-primary: #D4AF37;
    --c-primary-ink: #1A1A1A;   /* 8.28 on gold. NOT white — that is 2.10. */
    --c-accent: #9A9A9A;        /* the document outline from the mark */
    --c-bg: #F3F2EF;
    --c-surface: #ffffff;
    --c-text: #3D3D3D;          /* the wordmark grey, a shade deeper for body copy */
    --c-muted: #616161;         /* 6.19. NOT #9A9A9A, which is 2.81 and fails as text. */
    --c-border: #DCDAD5;
    --c-surface-2: #F7F6F3;   /* subtle raised surface: table headers, row hovers, tracks, chips */
    --c-input-bg: #ffffff;    /* form-control background */

    /* semantic status colors (permit lifecycle) — warm-shifted to sit beside the gold */
    --c-success: #1E7F4F;
    --c-warning: #8A6D12;
    --c-danger: #C0392B;
    --c-info: #4A6572;
    /* A categorical violet. Not a brand colour and not injected from cfg_branding:
       the KPI tints are a fixed six-way coding and must stay distinguishable. */
    --c-violet: #7c3aed;

    /* ---------- Type scale ----------
       The token group this sheet was missing. Before this, font size was the ONLY
       part of the design system with no tokens behind it: twenty distinct px
       literals (11, 11.5, 12, 12.5, 13, 13.5, 14, 14.5, 15, 16, 17, 18, 19, 20,
       22, 24, 26, 27, 30, 34) across ninety-odd declarations, with no scale to
       pick from and nothing to change if the platform ever needed to read larger.

       The steps are RATIOS of --fs-base, not fixed pixels, which is the whole
       point: move --fs-base and the entire platform resizes in proportion. A Root
       can do exactly that from /owner/branding. The ratios reproduce the scale
       11.5 / 12.5 / 13.5 / 15 / 18 / 24 / 30 at the 15px default. */
    --fs-base: 15px;
    --fs-2xs: calc(var(--fs-base) * 0.7667);
    --fs-xs:  calc(var(--fs-base) * 0.8333);
    --fs-sm:  calc(var(--fs-base) * 0.9);
    --fs-lg:  calc(var(--fs-base) * 1.2);
    --fs-xl:  calc(var(--fs-base) * 1.6);
    --fs-2xl: calc(var(--fs-base) * 2);

    /* Controls size from these, never from --fs-base directly. iOS Safari zooms the whole
       page when a focused control sets text under 16px and does not zoom back out, so a
       signatory who taps one field spends the rest of the signing flow magnified. Flooring
       it on a TOKEN rather than on a selector is what makes it stick: a later, more specific
       rule like `.validator-form input` silently defeated a media query that listed selectors,
       and the next such rule would have too. */
    --fs-control: var(--fs-base);
    --fs-control-sm: var(--fs-sm);

    /* TailAdmin's card radius is 16 and its control radius 8 — measured, not guessed.
       The whole platform reads from these two, so the shape change lands everywhere. */
    --radius: 16px;
    --radius-sm: 8px;
    /* The step between the two. Five separate surfaces independently wanted a 12px tile and
       there was nothing between --radius 16 and --radius-sm 8 to put them on, so each would
       have shipped its own literal. Expressed as a RATIO so it follows a Root who changes
       the platform radius instead of drifting away from it. Unlike --radius and --radius-sm
       this one is NOT in BrandTokens::LENGTHS, so it needs no cfg_branding row — the
       stylesheet is the only place it lives. */
    --radius-md: calc(var(--radius) * .75);
    /* TailAdmin's page title is 20px and this scale has no 20: the steps land on 18
       (--fs-lg) and 24 (--fs-xl), and both are consumed too widely to retune — card
       titles, modal heads, stat numbers, tab counts. A dedicated step, expressed as a
       ratio so it follows a Root who changes --fs-base. Not in BrandTokens::LENGTHS, so
       no cfg_branding row. */
    --fs-h1: calc(var(--fs-base) * 1.3333);
    /* Card padding and table cell padding as separate tokens, because TailAdmin's table
       cell is NOT square (12 vertical, 24 horizontal) and --table-pad could only say one
       number. --table-pad is kept and still resolves, so nothing that reads it breaks. */
    --card-pad: 24px;
    --table-pad-y: 12px;
    --table-pad-x: 24px;
    --shadow-sm: 0 1px 2px rgba(15, 23, 42, .06), 0 1px 3px rgba(15, 23, 42, .08);
    --shadow: 0 10px 30px -12px rgba(15, 23, 42, .18);
    --shadow-lg: 0 24px 60px -20px rgba(15, 23, 42, .28);
    --space: 16px;
    /* The table block's column padding. The toolbar and the footer inherit it so
       every edge inside a table card lines up with the first column's text. */
    /* The card-header, the toolbar, the footer and the pager all inset by THIS, while the
       cells inset by --table-pad-x. Splitting the cell padding into -y/-x moved the cells
       to 24 and left this at the old single value of 16, so a card title sat 8px inboard
       of the column header beneath it on every list page in the product. Every one of the
       four rules that reads this uses it HORIZONTALLY only (the vertical figure is stated
       literally beside it each time), so pointing it at --table-pad-x aligns the chrome
       with the cells everywhere and changes nothing vertical. */
    --table-pad: var(--table-pad-x);
    /* TailAdmin draws a control border one step stronger than a card hairline so inputs
       read as interactive against a card. Warm equivalent of its #d0d5dd. */
    --c-border-control: #cfccc4;
    /* Row rules are LIGHTER than the card hairline, which is what stops a dense table
       reading as a grid of boxes. TailAdmin uses #f2f4f7 against a #e4e7ec border. */
    --c-rule: #ebe9e4;
    --sidebar-w: 290px;
    /* THE SIGNING FRAME. What an external signatory sees — usually on a phone, usually
       once. It is deliberately dark: the contrast is what makes the document the only
       bright thing on the screen. The colour was written inline in layouts/sign.php three
       times as #0f172a, so Root could not brand the one page their customers actually
       see.
       THIS COMMENT USED TO CLAIM Root edits these at /owner/branding like every other
       token. That is false and it cost a reviewer time: BrandTokens::TOKENS is exactly 17
       colour keys (primary, primary_ink, accent, bg, surface, surface_2, input_bg, border,
       text, muted, success, warning, danger, info, ui, ui_2, ui_soft) and head.php emits
       --c-<key> only for those. sign_bg / sign_ink / sign_muted / pdf_surround are NOT in
       it, so these four live here and only here — changing them is a stylesheet edit with
       no cfg_branding row, unlike --radius, --fs-base or --sidebar-w. */
    /* WARM-SHIFTED off the pre-gold Tailwind slate ramp. Lightness is held so every
       measured contrast on the frame survives — ink 14.48 -> 14.08, muted 6.96 -> 6.56 in
       light, and 15.14 -> 15.83 / 7.28 -> 7.38 in dark, where the arms redeclare only the
       background and these two serve both themes. */
    --c-sign-bg: #211E1A;     /* was #0f172a — slate-900 */
    --c-sign-ink: #EFECE6;    /* was #e2e8f0 — slate-200 */
    --c-sign-muted: #A8A29A;  /* was #94a3b8 — slate-400 */
    /* The PAPER a document is drawn on, and the ink written onto it. NOT --c-surface /
       --c-text: those flip in dark mode, and a PDF page stays white in dark mode, so the
       text typed into a field on that page must stay dark or it vanishes. */
    --c-page: #ffffff;
    --c-page-ink: #0a1f4d;
    --c-page-line: #cbd5e1;
    /* The surround a document page floats on while fields are being placed. Written as
       #e2e8f0 in two private stylesheets, so the builder stayed light-grey in dark mode. */
    /* The mat a white page floats on. It has to stay visibly darker than the page: 1.23
       against #ffffff before, 1.26 after, so the separation improves slightly. */
    --c-pdf-surround: #E8E5DE; /* was #e2e8f0 — slate-200 */
    /* The validation-sticker field, which has to read as a different KIND of field from a
       signature at a glance. Was #0d9488/#0f766e inline in three rules. */
    --c-field-sticker: #0d9488;
    /* INK ON A STATUS FILL. `color:#fff` on a --c-danger button is correct in light mode
       and wrong in dark, where --c-danger is redefined to a pale #f87171: white on that is
       about 2.2:1, and it was sitting on the control that DELETES a placed field. */
    --c-danger-ink: #ffffff;
    /* Text drawn on the chrome accent. Root can set --c-ui to anything at /owner/branding,
       so the ink that goes on it has to be nameable too. */
    /* Ink that sits ON the chrome accent. Near-black now the accent is gold — white on
       #D4AF37 is 2.10. Nothing consumes this today; it is correct if anything ever does. */
    --c-ui-ink: #1A1A1A;
    /* The signatory colour ramp. Six literals lived in template_builder.js, driving every
       field border, field label and slot dot, and so could not follow the theme at all. */
    --c-field-1: #1d4ed8; --c-field-2: #0891b2; --c-field-3: #7c3aed;
    --c-field-4: #c026d3; --c-field-5: #ea580c; --c-field-6: #0d9488;
    --c-field-7: #4338ca; --c-field-8: #b45309;
    /* A single centred card — the verification result, the challenge gate. */
    --measure-card: 560px;
    /* A menu icon drawn as a CHARACTER (see .ic-glyph), trimmed against the stroke icons
       it sits beside. MEASURED, not guessed: the line icons put 15x15px of ink inside their
       20px slot, and the riyal reaches the same 15x15 at 23px/700 — at a nominal 19px it
       renders only 12x13 and reads visibly smaller, because a font's em box carries its own
       internal leading while an SVG viewBox does not. Re-measure before changing it. */
    --ic-glyph-size: 23px;
    --ic-glyph-weight: 700;
    /* "Saudi Riyal" leads the stack so U+20C1 resolves from the webfont above; its
       unicode-range means every other character falls straight through to the next family,
       so this is not a change of typeface — see the @font-face comment. */
    --font: "Saudi Riyal", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", sans-serif;
    --font-mono: ui-monospace, "SFMono-Regular", "Cascadia Code", Menlo, Consolas, monospace;
}

* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
    margin: 0;
    font-family: var(--font);
    /* The scale has to start somewhere. Without this, text with no explicit size fell
       through to the browser default and --fs-base moved everything EXCEPT it. */
    font-size: var(--fs-base);
    color: var(--c-text);
    background: var(--c-bg);
    line-height: 1.55;
    -webkit-font-smoothing: antialiased;
    text-rendering: optimizeLegibility;
}
h1, h2, h3, h4 { line-height: 1.25; margin: 0 0 .5em; font-weight: 700; letter-spacing: -.01em; }
/* THE SECTION HEADING HAS A SIZE NOW. Four rules already asserted one — .card h2,
   .validator-head h2, .section-head h2 and .ops-h2 all say --fs-lg — but an h2 outside
   those contexts had no size rule at all and fell to the browser's ~22px. Meanwhile 124
   headings pinned themselves to 16px with an inline style, which beats every class, so
   .card h2 never actually rendered anywhere it mattered. One step, stated once, and the
   four rules above become redundant rather than wrong. .verify-status h2 (--fs-xl) and
   .legal-body h2 (20px) still override it deliberately. */
h2 { font-size: var(--fs-lg); }
/* AND h3, for the same reason h2 needed one. Three rules in this file already say an h3 is
   --fs-base (.step h3, .legal-body h3, .stage-card h3) and two say --fs-lg in a larger
   context (.feature h3, .modal-head h3) — but nothing stated the default, so 76 of the 98
   h3 in the product fell to the browser's 1.17em, about 17.55px, which sits almost on top
   of h2's 18 and reads as the same level. --fs-base is the majority answer and gives a
   clear step down from the section title. */
h3 { font-size: var(--fs-base); }
a { color: var(--ui-text); text-decoration: none; }
a:hover { text-decoration: underline; }
p { margin: 0 0 1em; }
img { max-width: 100%; }

@media (pointer: coarse) {
  :root { --fs-control: max(16px, var(--fs-base)); --fs-control-sm: max(16px, var(--fs-sm)); }
}

/* ---------- Layout primitives ---------- */
.container { width: 100%; max-width: 1140px; margin: 0 auto; padding: 0 20px; }
.stack > * + * { margin-top: var(--space); }
.row { display: flex; gap: 16px; flex-wrap: wrap; }
.grid { display: grid; gap: 18px; }
.muted { color: var(--c-muted); }
.small { font-size: var(--fs-sm); }
.mono { font-family: var(--font-mono); }
/* The currency sign emitted by App\Core\Support\Money::html() as
   <span class="cur" title="SAR">…</span>. It names the families again rather than relying
   on --font for two reasons: an amount printed inside a .mono cell inherits --font-mono,
   which has no riyal coverage at all; and a machine that genuinely HAS a Unicode-17 system
   font should use it instead of downloading ours. Ordered newest-first, mirroring
   Money::SIGN_FONT_STACK (whose trailing `inherit` is a PHP-side note — a CSS-wide keyword
   cannot appear as one item in a font list, so the generic sans-serif closes it here).
   No unicode-bidi here on purpose: isolating the sign on its own would cut it loose from
   the digits it belongs to. Money::html() wraps the whole amount in <bdi> instead. */
.cur { font-family: "Saudi Riyal", "Noto Sans Saudi Riyal", "Noto Sans Symbols 2", "Segoe UI Symbol", "Noto Sans", sans-serif; }
/* The amount AROUND that sign. Money::html() is the only thing in the codebase that
   emits a <bdi>, so this reaches every printed amount — dashboards, invoices, modals
   and KPI tiles — without a class on each of the twelve views that show money, and
   without :has(). td.num already had tabular-nums; money OUTSIDE a numeric table cell
   had neither, so digits jittered between rows and a total could wrap away from its
   sign. Both live on the parent because the digits are siblings of .cur, not inside it. */
bdi { font-variant-numeric: tabular-nums; white-space: nowrap; }
.center { text-align: center; }
.nowrap { white-space: nowrap; }
.hide { display: none !important; }
/* Readable to assistive tech, drawn nowhere. An empty column header — the row
   actions — still has to have a name, and a visible one would be noise. */
.sr-only {
    position: absolute; 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;
}

/* ---------- Buttons ---------- */
.btn {
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
/* --fs-control AND 10px, or the fix is half a fix. A control is padding 10px/16px at
   --fs-control; this was 11px/18px at --fs-base, so a button stood 47.25px beside a 45.25px
   field and overshot by 2px wherever they share a row (/owner/numbering, measured).
   Dropping to 10px alone would not settle it: line 241 raises --fs-control to
   max(16px, --fs-base) on a coarse pointer, where a control is 46.8px — so a 15px button
   would then be 1.55px SHORT on every phone. On --fs-control the two tiers agree exactly at
   both pointer types: 45.25 fine, 46.8 coarse. A button IS a control; that is what the
   token is for. min-height:44px still floors the touch target, and .btn-sm sets its own
   size and padding so it is unaffected. Same fix the sheet already made one tier down. */
    font: inherit; font-weight: 600; font-size: var(--fs-control);
    padding: 10px 18px; border-radius: var(--radius-sm);
    border: 1px solid transparent; cursor: pointer; text-decoration: none;
    transition: transform .04s ease, box-shadow .15s ease, background .15s ease, border-color .15s;
    min-height: 44px; /* touch target */
    /* A <label class="btn"> IS STILL A <label>, so it inherited the 6px margin-bottom from
       the base label rule in Forms. In a centred flex row that 46px margin box centres 3px
       high, so every file picker in the product sat 3px above the real button beside it -
       6 sites in 4 views (app/account.php, admin/flow_template.php x2,
       app/document_build.php x2, sign/sign.php). margin-block-end, not margin-bottom, so
       RTL is untouched; <a> and <button> carry no UA margin, so nothing else moves; and the
       .mb-* utilities in the correcting block at the end of this file still win from below,
       as does .empty .btn's top margin. */
    margin-block-end: 0;
}
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--c-primary); color: var(--c-primary-ink); }
.btn-primary:hover { text-decoration: none; filter: brightness(1.05); }
/* TailAdmin's secondary is a WHITE CHIP with a visible control border, not a transparent
   outline — on the cream canvas a transparent ghost reads as a hairline rectangle rather
   than a button. --c-border-control is the control-strength border and is already
   redefined in BOTH dark arms (#4A4852), so the chip needs no new dark rule. */
.btn-ghost { background: var(--c-surface); color: var(--c-text); border-color: var(--c-border-control); }
.btn-ghost:hover { background: var(--c-surface-2); text-decoration: none; }
.btn-danger { background: var(--c-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.05); text-decoration: none; }
/* ONE COMPACT HEIGHT FOR THE WHOLE SMALL TIER — 40px, not 36. The toolbar already argues
   for 40 in this file ('the base form control is 44px tall ... which made the bar 72px'),
   and .tt-bar's filter and search input are both 40. Leaving .btn-sm at 36 put a 36px
   button beside a 40px filter beside a 34px pager select — three heights in one row.
   .btn stays 44 so the default and the compact tier stay distinct. */
.btn-sm { padding: 7px 12px; font-size: var(--fs-sm); min-height: 40px; border-radius: var(--radius-sm); }
.btn-block { width: 100%; }
.btn[disabled] { opacity: .55; cursor: not-allowed; }

/* ---------- Cards ---------- */
.card {
    background: var(--c-surface); border: 1px solid var(--c-border);
    border-radius: var(--radius);
    /* NO SHADOW. TailAdmin's cards are flat and rely on the hairline alone — measured
       box-shadow: none on every card in its dashboard. A shadow under a hairline reads
       as two competing edges. --shadow-sm stays in the sheet for things that float. */
    box-shadow: none;
    padding: var(--card-pad);
}
.card-pad-lg { padding: 30px; }
/* A CARD NESTED IN A CARD is a grouping, not a second raised surface — six views turn the
   shadow off inline. Stated once here instead, which is also the only version that keeps
   working when the shadow token changes. */
.card .card { box-shadow: none; }
.card h2 { font-size: var(--fs-lg); }
/* AND 600 ON A BARE CARD TITLE. The rule at 359 already states the intent — the page title
   has to win on weight as well as size — but it is scoped to .card-header, and 25 card titles
   across 9 views are a bare <h2> that never enters one. Those rendered 18px/700 against an
   <h1> at 20px/600, so the SMALLER heading read heavier; /admin/billing in its signpost state
   is where it is most visible, because that h2 is the only other text on the page.
   THE CHILD COMBINATOR IS LOAD-BEARING: .card-header h2 is a GRANDCHILD of .card, so this
   cannot reach it and the two rules can never disagree. (0,1,1) against the element rule's
   (0,0,1) at 219, and 219/359 are the only font-weight declarations in the sheet that touch
   an h2, so this wins wherever it is declared. */
.card > h2 { font-weight: 600; }
/* A RULE UNDER THE TITLE. .card-table > .card-header has always drawn one and the plain
   header never did, so 132 of the product's 181 card titles floated above their content
   with nothing between. INSET rather than full-bleed: TailAdmin's divider spans the whole
   card, which here would need a negative margin equal to the card's padding — and there is
   more than one of those. FOUR classes now carry a card inset and they hold three distinct
   values: .card and .card-pad both var(--card-pad)/24px (316, 2645), .card-pad-sm 14px 16px
   (2635), .card-pad-lg 30px (318). So one bleed value would still be wrong on most of them
   across 132 cards. (This comment said "three of those (.card, .card-pad 22px, ...)" — the
   22 stopped being true when stage29 moved .card-pad onto 24, and .card-pad-sm arrived
   after it was written.) .card-table > .card-header is (0,2,0) and
   keeps its own padded, full-width rule without needing a :not() here. */
.card-header {
    display: flex; align-items: center; justify-content: space-between; gap: 12px;
    padding-bottom: 14px; margin-bottom: 16px;
    /* ONE HEADER HEIGHT, BADGE OR NO BADGE. align-items:center with no floor means the row
       is as tall as its tallest item, and a badge is taller than a title: the <h2> is
       --fs-lg 18px at the heading line-height 1.25 (line 219) = 22.5px, while .badge (line
       425) is --fs-xs 12.5px at the inherited body 1.55 (line 215) plus 3px of padding and
       1px of border top and bottom = 27.37px. So in any 2-up row where one card carries a
       badge and its neighbour does not, the two titles sit about 2px apart and the two
       hairlines 5px apart - measured exactly that on /owner/security, y=293 against y=298.
       A FLOOR ON THE CONTAINER, not a line-height on the heading. 28px of content + 14px
       padding + 1px border = 43, border-box being global at line 205, so both cases land on
       43 and a title that wraps keeps its 1.25 leading. A line-height would also have reached
       .card-table > .card-header (line 681), which is every list page in the product and was
       never misaligned, and would still have left an h3 header 4px out of step with its
       neighbour. This is inert where it should be: that table header pads 14px on BOTH sides
       and already clears 43, and any header holding a .btn or a control is floored at 44 by
       that control. Logical and block-axis, so RTL is untouched. */
    min-block-size: 43px;
    border-bottom: 1px solid var(--c-border);
    /* A HEADER RESERVES A BADGE. Measured on /owner/dashboard: a .badge is --fs-xs at the
       body line-height plus 3px block padding and a 1px border = 27.375px, while a bare
       title is 22.5px (h2) or 18.75px (h3) at line-height 1.25. With align-items:center and
       nothing else, the chip grew its own header and took the rule down with it, so Backup
       and Migrations sat side by side in one .dash-grid with their hairlines 10px apart.
       42 = 27.375 badge + 14 padding-bottom + 1 border, box-sizing: border-box, which puts
       both hairlines on the same device row. .card-table > .card-header is padded 14px on
       BOTH sides and is already 45.875px, so this floor never reaches it. */
    min-block-size: 42px;
}
/* 600, so the page title wins on weight as well as size. An <h1> at --fs-h1/700 against a
   card title at --fs-lg/700 is two pixels of hierarchy and nothing else.
   ON THE HEADING, NOT ON .card-header: the review asked for font-weight on the container
   so the title would inherit it, and it cannot — `h1, h2, h3, h4` sets font-weight
   explicitly at line 219, and inheritance only reaches a property nothing has set. This
   selector is (0,1,1) and does out-specify the element rule. */
/* margin: 0 as well as the weight. h1..h4 carry `margin: 0 0 .5em`, so a card title added
   9px of its own under itself on top of .card-header's 14px padding — the title floated
   23px above its own rule instead of the 14 the rule was written for, and the badge
   beside it was dragged down with it. h3 is included because 17 card headers use one. */
.card-header h2, .card-header h3 { font-weight: 600; margin: 0; }

/* ---------- Forms ---------- */
label { display: block; font-weight: 500; font-size: var(--fs-sm); margin-bottom: 6px; }
.input, input:not([type]), input[type=text], input[type=email], input[type=password], input[type=number],
input[type=tel], input[type=search], input[type=url], input[type=date], input[type=time], input[type=datetime-local], select, textarea {
    width: 100%; font: inherit; font-size: var(--fs-control); color: var(--c-text);
    padding: 10px 16px; border: 1px solid var(--c-border-control); border-radius: var(--radius-sm);
    background: var(--c-input-bg); transition: border-color .15s, box-shadow .15s; min-height: 44px;
}
textarea { min-height: 90px; resize: vertical; }
/* --c-muted, NOT the slate-400 #94a3b8 this inherited from the pre-gold indigo build.
   That value is 2.56 on the input surface — worse than the #9A9A9A this file's :root rejects
   by name as failing as text — and it is the only COOL neutral left in a warm palette, so it
   read blue-lavender on every card. Line 160 already retired the same hex when it became
   --c-sign-muted; this global rule was the copy that missed.
   One token covers both themes: --c-muted is 6.19 on white and #B4B1AB in both dark blocks,
   which is 8.08 on --c-input-bg. It is also the ink .field-hint already uses, so a placeholder
   stays a step below the --c-text value that will replace it. */
input::placeholder, textarea::placeholder { color: var(--c-muted); }
/* Consistent custom chevron for selects across browsers. */
select {
    -webkit-appearance: none; -moz-appearance: none; appearance: none;
    /* THE CHEVRON SIDE IS A PROPERTY, because background-position has no logical keyword.
       This was `padding-right: 40px` + `background-position: right 16px center` — both
       physical, so in Arabic the label was pushed 40px off the TRAILING edge and the
       chevron was painted before the text instead of after it, on every dropdown in the
       product. A second [dir="rtl"] arm per rule would work but does not scale: each new
       select rule has its own chevron offset and would need its own mirror or silently
       regress. With the side in --sel-chev, the single arm below covers all of them. */
    --sel-chev: right;
    padding-inline-end: 40px; cursor: pointer;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat; background-position: var(--sel-chev) 16px center;
}
/* THE ONE ARM THAT MIRRORS EVERY CHEVRON IN THE SHEET. (0,1,1) against select's (0,0,1).
   Any rule that offsets the chevron writes `var(--sel-chev) <offset> center` and is
   mirrored by this, with no arm of its own. */
[dir="rtl"] select,
[dir="rtl"] .combo > input { --sel-chev: left; }
input:focus, select:focus, textarea:focus {
    outline: none; border-color: var(--c-focus, var(--c-primary));
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-focus, var(--c-primary)) 25%, transparent);
}
/* Keyboard-only focus ring for interactive controls (accessibility). */
.btn:focus-visible, a:focus-visible, summary:focus-visible, [tabindex]:focus-visible {
    outline: 2px solid var(--c-focus, var(--c-primary)); outline-offset: 2px; border-radius: var(--radius-sm);
}
/* The swatch had no border, radius or background, so Chrome's own widget chrome showed
   through: a hard #767676 square against the rounded fields beside it, and a white frame on a
   dark card in dark mode. The treatment already existed in this sheet but was scoped to one
   component — these are .tag-add input[type=color]'s three declarations, moved onto the base
   rule and put on the CONTROL-strength tokens so the swatch matches the FIELDS it sits with
   rather than the card edge. Height does not move: box-sizing is border-box globally, so
   min-height:44px still measures 44 with the border, and the coarse-pointer floor holds.
   .tag-add's own rule is (0,2,1) against this (0,1,1) and keeps its 42x38 sizing. */
input[type=color] { min-height: 44px; padding: 4px; cursor: pointer; border: 1px solid var(--c-border-control); border-radius: var(--radius-sm); background: var(--c-input-bg); }
/* THE FILE INPUT WAS NEVER ADOPTED. The base control rule above enumerates its types and
   `file` is not among them, so eight views drew a raw UA widget — square corners, the
   browser's own font, a 21px bevelled button — between 45px rounded fields. max-width, NOT
   width: /admin/users puts the input itself in a `.row` (flex-wrap: wrap) beside two
   buttons, and width:100% would have taken the whole first line and pushed both buttons
   onto a second one. Measured 44.9px against the text field's 45.3. The chip models
   .btn-ghost (surface + control border) and .btn-sm (fs-sm, radius-sm), and its gap is
   margin-inline-end because the product ships Arabic. The pseudo-element gets its OWN rule:
   comma-joining a vendor alias would invalidate the whole selector list in every browser
   that does not know it. Sets no `display`, so the six `hidden` file inputs behind styled
   labels (sign/sign.php, document_build, flow_template, account) stay hidden. */
input[type=file] {
    max-width: 100%; font: inherit; font-size: var(--fs-control); color: var(--c-text);
    padding: 5px; border: 1px solid var(--c-border-control); border-radius: var(--radius-sm);
    background: var(--c-input-bg); min-height: 44px; cursor: pointer;
}
input[type=file]::file-selector-button {
    font: inherit; font-size: var(--fs-sm); font-weight: 600; color: var(--c-text);
    padding: 5px 12px; margin-inline-end: 12px; cursor: pointer;
    background: var(--c-surface); border: 1px solid var(--c-border-control);
    border-radius: var(--radius-sm);
}
input[type=checkbox], input[type=radio] { width: auto; min-height: 0; accent-color: var(--c-primary); }
.field + .field { margin-top: 16px; }
/* display:block because twelve views wrote style="display:block" on a <span> carrying this
   class — a component that does not own its own box, twelve times over. .inherit-note below
   is byte-identical and gets the same. */
.field-hint { display: block; font-size: var(--fs-xs); color: var(--c-muted); margin-top: 5px; font-weight: 400; }
/* A hint that explains a CHECKBOX is a SIBLING of its label, never a child of it: moving the
   paragraph inside the <label> would make two lines of prose the checkbox's accessible NAME
   and its click target. So it indents from here instead - 20px box + .checkbox-row's own 10px
   gap, a number that is DERIVABLE rather than measured, now that .checkbox-row input below
   zeroes the UA's 4px/3px physical inline margins. (0,2,0) against .field-hint's (0,1,0) so it
   wins wherever it lands; it is the only rule in the sheet that touches .field-hint at all; it
   is logical, so RTL mirrors for free; and it carries no colour, so dark mode is untouched.
   The </label> + .field-hint adjacency after a .checkbox-row exists 8 times across 7 views -
   owner/helpdesk.php x2, owner/footer.php, owner/seo.php, owner/sticker.php, owner/demo.php,
   admin/sticker.php and app/document_new.php - two rules for 8 sites, rather than 8 labels
   restructured by hand. (owner/discounts.php:172 looks adjacent in a grep and is NOT: its hint
   is a sibling of .dc-checks, not of the .checkbox-row inside it, so the rule does not reach
   it - which is correct, that hint explains the whole group.) */
.checkbox-row + .field-hint { margin-inline-start: 30px; }
.form-error { color: var(--c-danger); font-size: var(--fs-sm); font-weight: 600; }
.checkbox-row { display: flex; align-items: flex-start; gap: 10px; }
.checkbox-row input { width: 20px; height: 20px; min-height: 0; margin-top: 2px; margin-inline: 0; }
.checkbox-row label { font-weight: 500; margin: 0; }
/* AND THE ROW ITSELF, WHERE A GAP IS ALREADY DOING THE WORK. The rule above is for a
   .checkbox-row that CONTAINS a <label> - app/account.php is the one site in the tree built
   that way, and it still needs it. It cannot reach the other 59, where the row IS the label
   and an element is not its own descendant, so those keep the base label rule's 6px
   margin-bottom (line 406). A flex item's margin ADDS to its container's gap rather than
   collapsing into it, so .dc-checks' declared 8px row gap rendered at ~14 and the .field-hint
   below it at ~11 instead of its own 5 - measured on /owner/discounts, where the two rows of
   plan checkboxes sit at a 39px pitch on a 23.25px row.
   SCOPED TO THE GAPPED CONTAINER, not to .checkbox-row at large: several of the 59 sites
   stack rows straight inside a .field with no gap, where that 6px is the only separator.
   Logical property, so RTL is untouched. */
.dc-checks > .checkbox-row { margin-block-end: 0; }

/* ---------- Badges / status ---------- */
.badge {
    /* 500 and a tighter pill, per TailAdmin's 12px/500 at 2px 10px. The DOT stays: colour
       must not be the only indicator of a status, and the dot is what carries it for a
       reader who cannot separate the tints. */
    display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-xs); font-weight: 500;
    padding: 3px 10px; border-radius: 999px; letter-spacing: .01em;
    background: #e8f2ff; color: #003d8c; border: 1px solid transparent;
}
.badge::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .9; }
.badge-success { background: color-mix(in srgb, var(--c-success) 12%, var(--c-surface)); color: var(--c-success); }
.badge-warning { background: color-mix(in srgb, var(--c-warning) 14%, var(--c-surface)); color: var(--c-warning); }
.badge-danger  { background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface));  color: var(--c-danger); }
.badge-info    { background: color-mix(in srgb, var(--c-info) 12%, var(--c-surface));    color: var(--c-info); }
.badge-muted   { background: var(--c-surface-2); color: var(--c-muted); }
/* Outcome text. A badge is a chip with its own background; sometimes what is wanted is one line
   of text in the outcome's colour — a settled delivery timestamp, an error under a subject. There
   was no utility for it, so callers wrote the colour inline WITH A HEX FALLBACK, which quietly
   duplicated the palette in markup. Both themes are handled already, because the tokens are. */
.t-ok  { color: var(--c-success); }
.t-bad { color: var(--c-danger); }
/* A status pill in a TABLE CELL must not wrap — a two-line pill stops reading as a
   pill and drags the row height with it. Scoped to the table on purpose: the same
   class is used for prose eyebrows on the landing page, and 43 characters of nowrap
   in a 320px viewport is a horizontal scrollbar. Fix the column, not the element. */
table.data .badge { white-space: nowrap; }
/* REPEATED BADGES IN A CELL. A roles column emits one pill per role, and the spacing was
   hand-written inline at every site — three different values (3px, 4px, 6px) for one job,
   and up to 300 style attributes in the HTML of a 100-row page. Stated once, at the 4px
   step. margin-inline-start, so it mirrors in Arabic, and on the FOLLOWING badge so the
   last pill in a cell no longer carries a trailing margin into the column gutter.
   SCOPED TO THE CELL ON PURPOSE. A bare `.badge + .badge` would also reach
   admin/roles.php's `.cluster`, a flex row of up to nine permission pills that already
   supplies its own 5px gap — adding a margin on top of a gap is how the three values
   above happened in the first place. No <td> in the tree holds a flex container with more
   than one badge in it, so nothing inside this scope is double-spaced. */
table.data td .badge + .badge { margin-inline-start: 4px; }

/* ---------- Alerts ---------- */
.alert { padding: 13px 16px; border-radius: var(--radius-sm); border: 1px solid transparent; font-size: var(--fs-base); }
.alert-success { background: color-mix(in srgb, var(--c-success) 8%, var(--c-surface)); border-color: color-mix(in srgb, var(--c-success) 25%, var(--c-surface)); color: #14532d; }
.alert-danger  { background: color-mix(in srgb, var(--c-danger) 8%, var(--c-surface));  border-color: color-mix(in srgb, var(--c-danger) 25%, var(--c-surface));  color: #7f1d1d; }
.alert-info    { background: color-mix(in srgb, var(--c-info) 8%, var(--c-surface));    border-color: color-mix(in srgb, var(--c-info) 25%, var(--c-surface));    color: #0c4a6e; }
.alert-warning { background: color-mix(in srgb, var(--c-warning) 10%, var(--c-surface)); border-color: color-mix(in srgb, var(--c-warning) 30%, var(--c-surface)); color: #78350f; }

/* ---------- Tables ---------- */
.table-wrap { overflow-x: auto; border-radius: var(--radius); border: 1px solid var(--c-border); }
/* A TABLE THAT SCROLLS HAS TO LOOK LIKE ONE. overflow-x:auto on its own gives a table that
   stops at the card edge with nothing to say there is more — the Root dashboard's
   11-column grid runs 322px past this wrap. Overlay scrollbars are invisible until the
   user is already scrolling, which is too late to learn that they can.
   A persistent thin bar, the same idiom .sidebar already uses for its vertical overflow.
   A gradient edge-fade would be the prettier trick and does not work here: table.data
   paints background: var(--c-surface) across the whole wrap, so anything drawn on the
   wrap's own background sits underneath the table and is never seen.
   Only renders where there IS overflow — a table that fits shows no bar and no gutter. */
.table-wrap { scrollbar-width: thin; scrollbar-color: var(--c-border) transparent; }
.table-wrap::-webkit-scrollbar { height: 9px; }
.table-wrap::-webkit-scrollbar-track { background: transparent; }
.table-wrap::-webkit-scrollbar-thumb { background: var(--c-border); border-radius: 5px; }
.table-wrap:hover::-webkit-scrollbar-thumb { background: var(--c-muted); }
/* TailAdmin's cell is 14px against a 16px body — 0.875 of base. This table was 1.0 of base,
   which is why the product's densest surface read looser than the reference. --fs-sm is the
   nearest EXISTING step (0.9, so 13.5px at the 15px default) and is still a ratio of
   --fs-base, so a Root resizing the platform from /owner/branding still scales it. */
table.data { width: 100%; border-collapse: collapse; background: var(--c-surface); font-size: var(--fs-sm); }
/* Not optional. The second line of a cell is written `.muted small` across the product, and
   .small IS --fs-sm — so without this the sub-line would become the SAME size as the value
   above it and every two-line cell would lose its hierarchy. --fs-xs is what .cell-sub
   already uses and is TailAdmin's 12px secondary. Scoped to a cell, so prose .small is
   untouched. */
table.data td .small,
table.data td .cell-sub { font-size: var(--fs-xs); }
/* The header band is gone: TailAdmin's thead is transparent and leans on the rule under it.
   text-align stays LEFT rather than becoming logical here — the data-table section below
   already handles RTL per column, and changing it in two places would fight. */
/* TailAdmin's table header is sentence case at 12px/500 — not a tracked capital. Stage 1
   removed the header BAND but left the shouting, and tracked uppercase is most of what
   still reads as a 2015 admin template. font-weight was never declared, so the UA's
   th{font-weight:bold} was supplying 700.
   text-align moves off the physical `left` as well, and that one is a real bug: <td> sets
   no text-align, so a cell already starts on the inline edge — in Arabic the header sat on
   the OPPOSITE side of the column from the data underneath it.
   Colour is untouched: this platform's rule is that every table title is one colour in
   every state, and .sort-h inherits it. The :lang(ar) letter-spacing guard further down
   becomes a no-op but stays — it costs nothing and stops a future re-tracking reaching
   Arabic. The view markup already writes these labels in sentence case ('Document ID',
   'Status', 'Created'), so removing the transform reveals the words as written. */
table.data th {
    text-align: start; font-size: var(--fs-xs); font-weight: 500;
    text-transform: none; letter-spacing: 0; color: var(--c-muted);
    padding: var(--table-pad-y) var(--table-pad-x);
    border-bottom: 1px solid var(--c-border); background: transparent;
}
table.data td { padding: var(--table-pad-y) var(--table-pad-x); border-bottom: 1px solid var(--c-rule); vertical-align: middle; }
/* A TABLE WHOSE ROWS ARE NOT ALL THE SAME HEIGHT. vertical-align:middle above is correct for the
   one-to-three-line rows every other list page has. /owner/crons wraps a task note to nineteen
   lines, the row runs 435px, and the badge, the metrics, the schedule and the actions all sit
   190px below the task name they describe — six disconnected fragments instead of one record.
   vertical-align is a no-op when a row's cells are already the same height, so this modifier
   changes nothing except where one cell is dramatically taller than its neighbours. (0,2,2)
   against the (0,1,2) above, so it wins on specificity and does not depend on where it sits.
   A modifier, not a flip of the base rule: that base rule reaches 93 tables and would nudge a
   badge up a few px on every two-line row in the product. Third member of the data-* family,
   beside .data-plain and .data-cards, and inert on a phone where .data-cards makes every td
   display:block. It does NOT shorten the row — the table is still at min-content after the On
   column goes, so Task keeps its 224px and the tall row keeps its height. */
table.data.data-top td { vertical-align: top; }
/* A row for a document Root has deleted. It was an inline style written through a
   ternary in the <tr>, which is the shape the inline-style sweep cannot fold away in
   bulk — the condition has to come with it. */
/* A table.data used as a key/value list INSIDE a card already on a surface — four of these
   turn the background off inline so the card shows through. */
table.data.data-plain { background: transparent; }
/* THE FADE STOPS AT THE PINNED ACTION CELL. opacity on the <tr> composites the WHOLE row, so
   it took td.rowact with it — and the only control a parked row offers is Restore. At .6 the
   gold button renders #E5CF87 with a #767676 glyph: 2.94:1, under the 3:1 floor for a control
   glyph, while the live rows beside it carry a full-strength red Delete. The weakest-looking
   control on the page was the recovery one and the strongest was the destructive one. The
   gold-contrast work validated ink ON gold; it never covered a faded copy of it.
   td.rowact is the natural seam because it already paints its own background, hairline and
   sticky position below. The row still reads as parked: the data is faded and the red Deleted
   badge is still there, so the fade is never the only signal. */
table.data tr.is-deleted td:not(.rowact) { opacity: .6; }
table.data tr:last-child td { border-bottom: none; }
table.data tbody tr:hover { background: var(--c-surface-2); }

/* ---------- Data-table cell patterns ----------
   The shared row shape: a primary value with a quiet second line under it, a
   letter tile for a named entity, and numeric columns that line up. They are
   utilities on table.data rather than one page's CSS so every list can wear the
   same row — app/Views/owner/subscriptions.php is the reference use. */
.cell-main { font-weight: 600; line-height: 1.3; }
.cell-sub { font-size: var(--fs-xs); color: var(--c-muted); line-height: 1.35; margin-top: 1px; }
/* NO font-size here, deliberately. It was var(--fs-2xs) and it never rendered once:
   every .cell-sub in the tree sits inside a cell, where `table.data td .cell-sub`
   above is (0,2,2) against this selector's (0,2,0) — a specificity loss, not an
   ordering one, so no amount of source order could have saved it. The sheet states
   its position on a cell's second line twice (the comment above this block, and the
   `table.data td.small` rule near the end of the file): --fs-xs. A view that really
   wants 11.5px asks for it by name with .tiny. Letter-spacing is the live half of
   this rule — mono tracks tighter than the text face — and stays. */
.cell-sub.mono { letter-spacing: .02em; }
/* One wrapper per cell, so the two lines stay a single flex item when the row
   turns into a card below — see table.data.data-cards. */
.cell { min-width: 0; }

/* Entity identity: letter tile, name, and the handle underneath it. */
.ident { display: flex; align-items: center; gap: 11px; min-width: 0; }
.ident-av {
    flex: 0 0 auto; width: 34px; height: 34px; border-radius: var(--radius-sm);
    display: grid; place-items: center; font-weight: 700; font-size: var(--fs-sm); line-height: 1;
    text-transform: uppercase; user-select: none;
    /* --ui-text, not --c-primary: the surface gold on its own 12% tint is 1.93:1. */
    background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); color: var(--ui-text);
}
/* A record that has ended reads as history rather than as a live row. The badge
   still carries the actual state — this only stops six identical blue tiles
   implying six live things, so it is never the only signal. */
.ident.is-past .ident-av { background: var(--c-surface-2); color: var(--c-muted); }
/* A long name truncates instead of widening the column and pushing the row
   actions off the far edge. A table cell sizes to its content, so ellipsis needs
   an actual ceiling to work against — ch, so it tracks the font rather than a
   pixel guess. The full name stays in the DOM for a screen reader, and the view
   puts it in a title for the mouse. */
.ident-tx { min-width: 0; max-width: 30ch; }
.ident-tx .cell-main, .ident-tx .cell-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* A link that reads as plain text until it is pointed at. */
a.rowlink { color: inherit; text-decoration: none; }
a.rowlink:hover { color: var(--ui-text); text-decoration: underline; }

/* Money and counts: on the column's trailing edge, with digits that do not
   jitter from row to row. `end`, not `right`, so rtl.css does not have to. */
table.data th.num, table.data td.num { text-align: end; font-variant-numeric: tabular-nums; }
/* THE SAME TWO-ELEMENT SHAPE, FOR A CENTRED COLUMN. `table.data th` sets text-align:start
   at (0,1,2) and .center is (0,1,0), so a <th class="center"> has never once centred —
   while `table.data td` sets no text-align, so the cells under it always did. 13 headers
   across four views (owner/aliphia.php, owner/aliphia_reconcile.php, owner/paymob.php and
   partials/aliphia_map_entity.php) therefore sat one --table-pad to the START of their own
   centred checkbox, count and badge columns. Qualified on both elements, exactly as the
   .num pair above is. */
table.data th.center { text-align: center; }
/* A figure and its column title both break badly: "Total paid" over two lines and
   an amount split from its currency both read as a layout accident. Let the column
   claim the width it needs instead. */
table.data th.num, table.data td.num .cell-main { white-space: nowrap; }
/* On a trailing-aligned column the arrow belongs on the inside, beside the
   figures it orders, not hanging off the edge of the card. */
table.data th.num .sort-h { flex-direction: row-reverse; }
/* A CENTRED column's TITLE. The same (0,1,0)-against-(0,1,2) trap th.num above and
   th.ops-num further down each already carry an arm for: `table.data th { text-align:
   start }` beats a bare .center, so 13 headers across four views (owner/aliphia,
   owner/aliphia_reconcile, owner/paymob, partials/aliphia_map_entity) sat on the start
   edge above figures their own <td class="center"> had centred. Scoped exactly like its
   two neighbours. `center` is direction-neutral, so this one needs no RTL mirror. */
table.data th.center { text-align: center; }
table.data th.rowact, table.data td.rowact { text-align: end; white-space: nowrap; }
/* STICKY ACTIONS. A wide table scrolls inside .table-wrap, and the column that disappears
   first is the last one — which is the actions column on every list in the product. The
   data scrolling out of view is the point of a scroller; the BUTTONS scrolling out of view
   is a defect, and on /owner/documents (nine columns) it is the normal state of the page.
   Sticky binds to the nearest scrolling ancestor, which is .table-wrap — .card-table's own
   overflow:hidden is outside it and does not capture this.
   The cell has to paint its own background or the scrolling columns show through it, and it
   has to repeat the row hover for the same reason. Both are tokens, so both flip in dark.
   inset-inline-end, so it pins to the trailing edge in Arabic too. On a table that does not
   overflow, a sticky cell simply sits where it already was. */
table.data th.rowact,
table.data td.rowact {
    position: sticky; inset-inline-end: 0; z-index: 1;
    background: var(--c-surface);
}
table.data tbody tr:hover td.rowact { background: var(--c-surface-2); }
/* A ROW THAT PAINTS ITSELF, AND A CELL THAT PAINTS ITSELF OVER IT. td.rowact must be opaque
   or the columns it is pinned over show through it, and --c-surface is right on every list
   in the product but one: /app/notifications tints its UNREAD rows with an inline background
   on the <tr>, the one shape the inline-style sweep cannot fold away because the condition
   has to come with it. Without this the pinned action cell prints a plain-surface patch at
   the end of every unread row, and the hairline beside it makes the patch read as deliberate.
   The row carries the tint; the cell takes it from the row, and an untinted row is untouched.
   THE :hover ARM IS NOT OPTIONAL. `table.data tbody tr:hover td.rowact` above is (0,3,4), the
   same weight as this rule's first selector, and an inline background on the <tr> outranks
   the row's own hover rule, so without the second arm the patch returns the moment the row is
   pointed at. background-COLOR, not the shorthand, so nothing else the pinned cell sets
   moves; .data-cards keeps its own position and box-shadow reset either way. */
table.data tbody tr[style*="background"] td.rowact,
table.data tbody tr[style*="background"]:hover td.rowact { background-color: inherit; }
/* A hairline so the pinned cell reads as sitting OVER the row rather than as part of it. */
table.data th.rowact, table.data td.rowact { box-shadow: inset 1px 0 0 0 var(--c-rule); }
[dir="rtl"] table.data th.rowact,
[dir="rtl"] table.data td.rowact { box-shadow: inset -1px 0 0 0 var(--c-rule); }
/* A SENTENCE printed where an action button would be. The action column is nowrap so that a
   button label never breaks across lines; prose has to opt back out of it, or the cell becomes
   one unbreakable line and the table grows wider than the card holding it. Capped in ch so the
   note wraps inside the column instead of setting the column's width. */
table.data td.rowact .cell-note { display: inline-block; white-space: normal; text-align: end; max-width: 30ch; }

/* ---- column widths that are part of a table's meaning ----
   A checkbox cell, a two-digit counter, a timestamp: columns whose size is a property of
   what they hold rather than of the page they are on. These were written as
   style="width:70px" on the <th>, and the same kind of column had four different numbers
   across the console. Named once, here.

   On <th> only, and deliberately: a table lays out from its header row, and the widths
   stop applying the moment .data-cards collapses a row into a card on a phone — which is
   exactly when a fixed width would be wrong. */
table.data th.w-xs { width: 76px; }
table.data th.w-sm { width: 104px; }
table.data th.w-md { width: 168px; }
/* The row-actions column on a table that lives inside a fixed two-column grid, where the
   usual w-* steps are either too narrow or push the grid. */
table.data th.w-act { width: 132px; }
/* .empty IN A DATA TABLE. `.empty { padding: 26px 16px }` is (0,1,0) and the cell rule
   `table.data td` is (0,1,2), so an empty-state <td class="empty"> silently kept the
   ordinary cell padding and collapsed to 12px. Eleven empty rows across eight list pages
   depend on this arm. Same trap as .empty-lg losing to .empty. */
table.data td.empty { padding: 26px 16px; }
table.data th.w-lg { width: 208px; }
/* A FLOOR, not a hint. th.w-* sets `width`, which a table is free to ignore when it needs
   the space elsewhere; these are prose columns that must not be squeezed below a legible
   measure. Separate axis, separate scale. */
table.data th.mw-sm { min-width: 150px; }
table.data th.mw-md { min-width: 200px; }
table.data th.mw-lg { min-width: 230px; }

/* An id, a hash, a token or a URL has no spaces to break at, so a cell holding one pushes
   the whole table wider than the screen. Every console table that shows a ULID was saying
   style="word-break:break-all" to stop it. */
/* overflow-wrap:anywhere, NOT word-break:break-all. break-all splits inside a word even when
   a space was available, so /owner/crons rendered its space-separated key=value metrics as
   "attempted / =13 pushe / d=0 adopt / ed=0" — six lines per row. `anywhere` still breaks
   the 255-character spaceless JSON this class was added for, and still lets the column go
   narrow (unlike break-word it affects min-content sizing), but it takes a real soft-wrap
   opportunity first. */
.break-all { overflow-wrap: anywhere; }
table.data td.rowact .btn + .btn { margin-inline-start: 6px; }
/* THE MISSING ARM. .inline-form is inline-block, so an action cell that mixes one-button forms
   with bare buttons has four possible orders and the sheet covered three: form-then-form and
   button-then-form on .inline-form's own rules, button-then-button here. Form-then-button had
   nothing — and that is exactly the order /owner/crons writes (the run form, then the copy and
   log buttons), so its three row actions measured 3.7px and 9.7px: the first two read as glued
   and the third as detached. With this they measure 9.7 / 9.7 / 9.7 — not the 6px the token
   names, because ~3.4px of collapsed source whitespace is still in the inline-block flow, but
   equal, which is what the eye reads. A scan of every td.rowact in the tree finds exactly one
   cell with a bare button after a form, so nothing else grows. Logical margin, so it mirrors in
   Arabic with the rule above it. */
table.data td.rowact .inline-form + .btn { margin-inline-start: 6px; }
/* An icon-only row action, kept a real 36px target beside a btn-sm. */
/* An icon button is SQUARE, and its width is independent of the height it inherits — so it
   needs one width per tier or it stops being square at one of them. It was 36 wide against
   .btn's 44 height, i.e. 36x44, at the default size.
   AND IT IS A FLEX ITEM WHEREVER A ROW PUTS ONE, so `width` is only a flex BASIS: at the
   default flex-shrink:1 the item collapses to its min-content - 16px glyph + 7px padding x2
   + 2px border = exactly 32 - and /owner/tenants shipped a 32x40 Move button beside a 45px
   select. Measured against this sheet in a 1102px harness: 32.00x40.00 -> 40.00x40.00, with
   td.rowact unchanged at 234.42, the table unchanged at 1100 and no new overflow; the select
   beside it gives the 8px back (107 -> 99, 139.89 -> 132) and is not clipped. The .data-cards
   phone arm below already had to re-assert this shape - it belongs on the component, and that
   arm is (0,4,2) so it still wins. */
.btn-ic { padding: 7px; width: 44px; flex: 0 0 auto; }
.btn-ic.btn-sm { width: 40px; }
.btn-ic svg { width: 16px; height: 16px; }
/* An action button showing an icon AND its label (the "both" mode a Root can pick in
   /owner/actions). .btn is already inline-flex with gap:8px, so the only thing missing
   was stopping the glyph from being squashed by a long or translated label. In RTL the
   flex row reverses on its own, so the icon lands on the correct side with no rule. */
.btn > svg { flex: 0 0 auto; }

/* ============================================================
   THE STANDARD DATA-TABLE BLOCK — one shape for every table
   ------------------------------------------------------------
   There were three shapes on the platform before this. The pager sat inside the
   card on 1 page and floated on the page background under the card on 13; the
   client-built toolbar was jammed flush against the edge of a padding:0 card
   with no padding of its own; and 35 views hand-wrote
   `style="padding:0;overflow:hidden"` instead of naming the card class that
   already existed. The markup is now always these parts, in this order:

     <div class="card card-table">        -- the whole card IS the table block
       <div class="card-header">  optional: title + primary action
       <div class="tt-bar">       optional: search + filters
       <div class="table-wrap"><table class="data">
       <nav class="pager">        THE FOOTER: count, page links, rows-per-page
     </div>

   When a table is only PART of a card (prose above it, another form below) the
   same three parts are wrapped in a bordered .tabletools box instead, so the
   block still reads as one object. Those are the only two arrangements.

   The footer rule is shared on purpose: partials/list_pager.php (server paging)
   and assets/js/tabletools.js (client paging) emit the SAME class names, so one
   rule styles both and they cannot drift apart again.

   --table-pad is the table's own column padding. The toolbar and the footer use
   it, so the search box lines up with the first column's text and the count
   lines up under it instead of each picking its own inset.
   ============================================================ */
.card-table { padding: 0; overflow: hidden; }
/* AND THROUGH A <form>. The bulk translation editor opens its form INSIDE the card — it
   has to, because wrapping the card would break the `.list-toolbar + .card` join above it
   and the `.card + .pager` join below it — so .table-wrap is a GRANDCHILD there and this
   reset never reached it: the table kept its own 1px border and 16px radius INSIDE the
   card, which is a DOUBLED side border and a rounded corner arc in the middle of a square
   card. Two views in the tree put a form here, /owner/translations and /owner/aliphia,
   and both show it. */
.card-table > .table-wrap,
.card-table > form > .table-wrap { border: 0; border-radius: 0; }
/* .card-table zeroes the card's own padding, so an empty-state note written as a direct
   child sat flush against the edge — three views each added their own inline inset to
   cover it. Stated once, at the same inset the cells use, so "no requests yet" lines up
   with the column it replaces. */
.card-table > p { margin: 0; padding: 16px var(--table-pad); }
/* A header inside the block is padded like the block, not like a card. */
.card-table > .card-header { margin: 0; padding: 14px var(--table-pad); border-bottom: 1px solid var(--c-border); }
/* A SECTION inside the block — prose, or a small form, between the header and the table.
   `.card-table > p` covers a bare paragraph and .tt-bar / .pager cover the toolbar and the
   footer, but a <div> had no name, so 23 of them carry an inline inset.
   EVERY ONE OF THOSE WRITES 18px while --table-pad is 24px, so each is currently six
   pixels out of line with the table columns beside it — the inset was copied when the
   token was 18 and never followed it. Using the token is the point of the component. */
.card-table > .card-sect { padding-block: 14px; padding-inline: var(--table-pad); }

/* The standalone variant, for a table that shares its card with other content. */
.tabletools { border: 1px solid var(--c-border); border-radius: var(--radius); overflow: hidden; }
/* ...BUT NOT WHEN THE CARD IS ALREADY THE BLOCK. .card-table means "the card IS the table
   block" and zeroes the card's padding, so on a card-table card that ALSO holds prose
   tabletools.js correctly computes flush=false and builds this box — whose border then
   lands flush against the card's own. The result is a doubled hairline down both edges and
   a rounded notch inside the card's corner, on ten cards across eight views
   (/owner/aliphia-mapping, /owner/aliphia, /owner/aliphia/reconcile, /owner/actions,
   /owner/numbering, /owner/paymob/link, /owner/paymob/links, /owner/translations).
   HERE AND NOT IN THE VIEWS: dropping card-table from the markup instead would give the
   card its 24px gutter back on each side and cost the table 48px — measured on
   /owner/aliphia-mapping that is 1100 -> 1052, which takes the Source <select> from 197px
   to 191px, i.e. NARROWER than it is today, on the one page whose worst defect is a select
   too narrow to read. (0,2,0) against .tabletools' (0,1,0), so order does not matter. */
.card-table > .tabletools { border: 0; border-radius: 0; }
.tabletools > .table-wrap { border: 0; border-radius: 0; }

/* ---- the toolbar ---- */
.tt-bar {
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 0;
    padding: 12px var(--table-pad); border-bottom: 1px solid var(--c-border);
    background: var(--c-surface);
}
/* Toolbar controls are COMPACT: the base form control is 44px tall with 11px of
   vertical padding, which made the bar 72px — taller than two table rows, for one
   search box. On a coarse pointer they go back to full size, because 40px is below
   the touch-target minimum. Same shape as the --fs-control rule further up. */
/* A COMPACT FORM INPUT. The base control is 44px tall, which is right for a sign-up form
   and too loose for a dense settings card — ten inputs in owner/database.php,
   owner/pages.php and owner/page_edit.php say so inline. Same shape as the .tt-bar rule
   below, including the coarse-pointer arm that puts the touch target back: 40px is under
   the minimum for a finger.
   NOT for the two micro-selects inside a table ROW (crons.php, users.php) — those are
   padding:4px 6px and about 26px tall, and giving them 40px would change the height of
   every row in the table. Two sites do not earn a class. */
/* ELEMENT-QUALIFIED, or it never applies. The base control rule is
   `.input, input:not([type]), input[type=text], …` and an attribute selector weighs the
   same as a class, so that rule is (0,1,1) and a bare .input-sm at (0,1,0) loses every
   time. This sheet already records the trap at line 1776; the class was added anyway and
   has never once applied. */
input.input-sm, select.input-sm, textarea.input-sm { min-height: 40px; padding: 8px 10px; }
/* ELEMENT-QUALIFIED TOO, or the touch minimum never applies. A MEDIA QUERY CONTRIBUTES NO
   SPECIFICITY: this arm was `.input-sm` at (0,1,0) against the line above it at (0,1,1),
   so the 40px lost nothing to it and a compact control on a phone stayed at 40px instead
   of rising to the 44px touch minimum. Harmless until now only because .input-sm had no
   users anywhere in the Views tree; /owner/aliphia is its first, which is exactly when it
   would have shipped. Qualified to match, so the two tie and the media query decides. */
@media (pointer: coarse) { input.input-sm, select.input-sm, textarea.input-sm { min-height: 44px; } }
.tt-bar select.tt-filter { width: auto; max-width: 220px; margin: 0; }
.tt-bar select.tt-filter,
.tt-field input { min-height: 40px; padding-block: 7px; }
@media (pointer: coarse) {
    .tt-bar select.tt-filter,
    .tt-field input { min-height: 44px; padding-block: 11px; }
}

/* Search field: icon in front, clear button behind — the same idiom .list-toolbar
   already uses for .lt-search, so the page filter and the table filter read as
   one control used twice rather than two unrelated boxes. */
.tt-field { position: relative; display: flex; align-items: center; flex: 1 1 260px; max-width: 360px; }
.tt-field > svg { position: absolute; inset-inline-start: 11px; width: 16px; height: 16px; color: var(--c-muted); pointer-events: none; }
.tt-field input { width: 100%; margin: 0; min-height: 40px; padding-inline-start: 34px; padding-inline-end: 36px; }
/* The browser's own clear affordance is Chrome-only, cannot be styled, and is
   invisible on a dark surface — so it is removed and .tt-clear replaces it. */
.tt-field input::-webkit-search-cancel-button,
.tt-field input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
.tt-clear {
    position: absolute; inset-inline-end: 6px; width: 26px; height: 26px; padding: 0;
    display: grid; place-items: center; border: 0; background: transparent; cursor: pointer;
    border-radius: 50%; color: var(--c-muted); transition: background .15s, color .15s;
}
.tt-clear:hover { background: var(--c-surface-2); color: var(--c-text); }
.tt-clear:focus-visible { background: var(--c-surface-2); color: var(--c-text); }
.tt-clear svg { width: 14px; height: 14px; }
/* display:grid would beat the UA's [hidden]{display:none}, so restate it. */
.tt-clear[hidden] { display: none; }

/* An ACTION bar instead of a pager — a card whose last row is buttons (the bulk
   translation editor's "Save this page"). Same divider and same inset, so the two
   kinds of card bottom are the same object with different contents. It was written
   inline as `style="padding:12px 16px;border-top:..."`, which is how the two drifted
   apart by two pixels in the first place. */
/* The form arm, for the same reason .table-wrap above needs one — and this is the rule
   that needed it most: the one page it was written for, /owner/translations, is the one
   page the child combinator missed, so "Save this page" sat flush in the card corner with
   no inset and no divider of its own. THE TWO ARMS SHIP TOGETHER: until now the only line
   above that save bar was .table-wrap stray bottom border, so zeroing one without giving
   the other its border-top would leave the bar floating. */
.card-table > .card-foot,
.card-table > form > .card-foot,
.tabletools > .card-foot {
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    padding: 12px var(--table-pad); border-top: 1px solid var(--c-border);
}

/* A glyph shown beside its own picker, so the choice is visible without reading the
   name. Sized and boxed like a row action so the preview matches the real button. */
.ic-swatch {
    flex: 0 0 auto; display: grid; place-items: center; width: 40px; height: 40px;
    border: 1px solid var(--c-border); border-radius: var(--radius-sm);
    background: var(--c-surface-2); color: var(--c-text);
}

/* The read/download/validate counters on a document row. The layout used to be written
   inline in Ui::activityChips(), which put a fragment that renders in every document list
   out of reach of the stylesheet. NOT named .chip: that is already a bordered, clickable
   pill, and these are plain inline counters — reusing the name would have given every
   counter a border and a pointer cursor. */
.activity-chips {
    display: inline-flex; gap: 10px; white-space: nowrap;
    font-size: var(--fs-2xs); font-variant-numeric: tabular-nums;
}
/* A zero is present but quiet; a count that is not zero is the thing worth reading. */
/* INLINE-FLEX BECAUSE THE GLYPH IS NOW AN <svg>. An inline svg sits on the text baseline,
   so a 14px icon beside 11.5px tabular digits rendered visibly high. Centring them in a
   flex line fixes it; the 4px gap replaces the &nbsp; that used to separate them, which
   had to come out of Ui::activityChips() or every chip would double-gap. */
.activity-chips .actchip { color: var(--c-muted); display: inline-flex; align-items: center; gap: 4px; }
.activity-chips .actchip.is-on { color: var(--c-text); }

/* A swatch and its picker, side by side — the icon column on the Root pages where a mark
   is chosen. Named here rather than written into the page, so the action rows and the
   menu rows cannot drift apart. nowrap because the swatch belongs beside its select, not
   above it, however narrow the column gets. */
.pick { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
.pick select { width: auto; min-width: 0; }
/* A list that is a list semantically but should not look like prose bullets — used for
   "where is this used" file lists inside a table cell. */
.plain-list { list-style: none; margin: 6px 0 0; padding: 0; }
.plain-list > li + li { margin-top: 2px; }

/* ---- the footer: ONE rule, both pagers ---- */
.card-table > .pager,
.tabletools > .pager {
    margin: 0; padding: 11px var(--table-pad);
    border-top: 1px solid var(--c-border); background: var(--c-surface-2);
}

/* ---------- table.data.data-cards: a row becomes a card on a phone ----------
   A six-column list cannot be read through a horizontal scrollbar on a 375px
   screen. Below 780px the table's box model is dropped and each row is laid out
   as a card, taking its field names from each cell's data-label. A cell with NO
   data-label is the row's headline (or its actions) and spans the full width.
   NOT OPT-IN ANY MORE, whatever this comment used to say: behaviours.js's
   cardifyTables() copies the labels out of each table's own thead and switches
   .data-cards on for EVERY table.data:not([data-no-cards]) at load — it was written
   because 78 of the platform's 79 tables carried no data-label and turning the class
   on by hand would have produced cards of unlabelled values. So anything declared
   here reaches every table in the product on a phone, not a handful of opted-in ones.
   That is why the pinned actions cell has to be unpinned below. */
@media (max-width: 780px) {
  table.data.data-cards,
  table.data.data-cards tbody,
  table.data.data-cards tr,
  table.data.data-cards td { display: block; width: 100%; }
  table.data.data-cards thead { display: none; }
  table.data.data-cards tr { padding: 14px 16px; border-bottom: 1px solid var(--c-border); }
  table.data.data-cards tr:last-child { border-bottom: 0; }
  table.data.data-cards tbody tr:hover { background: transparent; }
  table.data.data-cards td { padding: 0; border: 0; }
  table.data.data-cards td[data-label] {
      display: flex; align-items: baseline; justify-content: space-between;
      gap: 14px; margin-top: 9px;
  }
  table.data.data-cards td[data-label]::before {
      content: attr(data-label); flex: 0 0 auto;
      font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .04em;
      color: var(--c-muted); font-weight: 600;
  }
  table.data.data-cards td[data-label] .cell,
  table.data.data-cards td[data-label] .cell-main,
  table.data.data-cards td[data-label] .cell-sub { text-align: end; }
  /* THE PINNED ACTIONS CELL IS UNPINNED HERE. td.rowact is position:sticky so that a wide
     table keeps its buttons reachable while it scrolls — but this layout has no scroller
     and no columns, so the sticky offset, the painted background and the hairline would
     all be left drawing a stray rule across the card. */
  table.data.data-cards td.rowact {
      position: static; background: none; box-shadow: none;
      display: flex; gap: 8px; margin-top: 13px;
  }
  /* btn-sm is 36px everywhere on the platform and stays 36px for a mouse. This is
     the one layout that is only ever touched, so the row actions take the full 44px
     here rather than forking the button scale for every page. */
  table.data.data-cards td.rowact .btn { flex: 1 1 auto; min-height: 44px; }
  table.data.data-cards td.rowact .btn-ic { flex: 0 0 44px; width: 44px; }
}

/* ============================================================
   THE STAT CARD — one component, every page that counts something.

   WHAT WAS WRONG WITH THE OLD ONE, in the order it hurt:

   1. THE ACCENT WAS A PHYSICAL EDGE. `border-left: 4px` stays on the LEFT when the
      page turns RTL, so on every Arabic page the coloured bar sat on the far side of
      the card from the words it belonged to. A component cannot carry a side any
      more; it carries a colour, and the colour is shown by a dot that sits in the
      text flow and therefore mirrors for free.

   2. THE COLUMN WAS TOO NARROW. `minmax(150px, …)` is narrower than the values these
      cards actually hold, so "Connected" broke to "Connecte / d" and "29.00 MB" to
      two lines. The track is wider now, and a long value shrinks instead of wrapping.

   3. THE COMPONENT FOUGHT ITSELF. `.l` forced uppercase and letter-spacing, so a card
      with a sentence under the caption had to undo both from the page. The
      description is part of the component now (`.stat .d`), not an exception to it.

   4. ROWS WERE RAGGED. One longer caption made its tile taller than its neighbours.
      Tiles stretch and the description is pushed to the bottom, so a row reads as one
      band.

   Colour still means what it meant — the semantic status tokens, unchanged. Only the
   SHAPE of the signal changed: a dot and a tinted hairline instead of a slab on one
   edge.
   ============================================================ */
.stats {
    display: grid;
    /* auto-FILL, not auto-fit. auto-fit collapses the empty tracks, which turns a
       two-tile row (owner/sessions) into two half-page slabs. auto-fill keeps the
       empty tracks, so two tiles stay tile-sized and six still fill the row. */
    grid-template-columns: repeat(auto-fill, minmax(var(--stat-min, 190px), 1fr));
    gap: 12px;
    align-items: stretch;
}
/* A FIXED SET OF FOUR figures — the storage widget. Four tiles in a half-width card
   fall to 3+1 on the default track, which reads as a mistake rather than as a wrap; a
   wider floor forces 2x2 there and a single row wherever the card is wide enough. */
.stats.stats-quad { --stat-min: 200px; }
/* A VARIABLE-LENGTH set of compact figures. /admin's Plan limits renders one tile per
   entry in Plans::LIMITS — six today — and had borrowed stats-quad, whose 200px floor is
   for the storage widget's fixed four in a HALF-width card. In the full-width card those
   six fell to 4+2. Measured on the live page: the grid is 1039px at a 12px gap, where six
   tracks needs a floor between 139 and 163. The same class serving two very different
   widths is what made one number impossible, so this is a second class rather than a
   retune of the first. */
.stats.stats-compact { --stat-min: 150px; }
/* A FIXED, KNOWN COUNT. auto-fill reads the WIDTH, so a row whose children never vary
   still picks up an empty track once the column is wide enough — measured at v115 as
   5 tracks for 3 tiles on /owner/invoices and 5 for 4 on /app/support, which renders as
   a row of cards that stops short of its own card. Stating the count is the same answer
   .kpi-5 and .kpi-7 already use, and for the same reason: no single floor is correct at
   every column width. A row whose length is DATA (one tile per plan limit) must keep
   auto-fill and is not a candidate for these. */
.stats.stats-3 { grid-template-columns: repeat(3, 1fr); }
.stats.stats-4 { grid-template-columns: repeat(4, 1fr); }
/* Below the point where four across stops being readable, fold to two and then to one.
   The counts are stated here rather than left to the --stat-min floor because an
   explicit grid-template has already taken that floor out of play. */
@media (max-width: 760px) {
  .stats.stats-3, .stats.stats-4 { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 430px) {
  .stats.stats-3, .stats.stats-4 { grid-template-columns: minmax(0, 1fr); }
}

.stat {
    --stat-accent: var(--c-muted);
    display: flex;
    flex-direction: column;
    gap: 5px;
    min-height: 98px;
    padding: 14px 16px;
    background: var(--c-surface);
    /* The accent tints the whole hairline instead of thickening one edge. */
    border: 1px solid color-mix(in srgb, var(--stat-accent) 40%, var(--c-border));
    border-radius: var(--radius);
    /* The value sizes itself against THIS CARD, not the viewport: the card lives in a
       grid track, and no vw unit can see how wide that track is. Eight tiles in a row
       are narrow while the window is wide, which is exactly when a vw-driven number
       would be at its largest. */
    container-type: inline-size;
    /* No resting shadow on purpose: a grid of drop-shadowed tiles reads as noise, and
       the page sits one step behind the card surface already. */
}

.stat .l {
    display: flex;
    /* start, not center: a caption that runs to two lines would otherwise centre the
       dot against the whole block instead of the first line. */
    align-items: start;
    gap: 7px;
    min-width: 0;
    overflow-wrap: break-word;
    font-size: var(--fs-2xs);
    font-weight: 600;
    line-height: 1.35;
    color: var(--c-muted);
    text-transform: uppercase;
    letter-spacing: .04em;
}
.stat .l::before {
    content: "";
    flex: 0 0 auto;
    width: 7px;
    height: 7px;
    /* Sit on the first line's x-height rather than its top edge. */
    margin-block-start: .42em;
    border-radius: 50%;
    background: var(--stat-accent);
    /* A soft ring rather than a bigger dot: legible at a glance without competing
       with the number for weight. */
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--stat-accent) 18%, transparent);
}

.stat .n {
    /* Fallback first: where container queries are unavailable the cqi line below is
       dropped whole, and a stat with no font-size at all would inherit body text. */
    font-size: var(--fs-xl);
    font-size: clamp(var(--fs-lg), 13cqi, var(--fs-2xl));
    font-weight: 700;
    line-height: 1.15;
    letter-spacing: -.02em;
    color: var(--c-text);
    /* Digits line up column-wise across a row, which is the point of a row. */
    font-variant-numeric: tabular-nums;
    /* break-word, not anywhere: `anywhere` will split inside a thousands group. */
    overflow-wrap: break-word;
}
/* A value that is words rather than a figure — a version string, a status phrase. */
.stat .n.is-text,
.kpi-val.is-text {
    font-size: var(--fs-lg);
    font-weight: 700;
    letter-spacing: normal;
}

/* The sentence under some cards. `margin-block-start: auto` pushes it to the bottom so
   a row of tiles shares one baseline; with no description the rule simply never runs. */
.stat .d,
.stat > p {
    margin: auto 0 0;
    font-size: var(--fs-2xs);
    line-height: 1.45;
    color: var(--c-muted);
    text-transform: none;
    letter-spacing: normal;
}

/* The usage bar under a plan-limit tile. It is NOT <progress>: that element cannot be
   coloured per-state across browsers without vendor pseudo-elements, and this one has to go
   red when a workspace is over its cap. aria-hidden, because the figure above it already says
   "13 of 10" in text — the bar is a second rendering of the same fact, not a second fact. */
.meter { height: 6px; border-radius: 3px; background: var(--c-surface-2); overflow: hidden; margin-top: 8px; }
.meter > span { display: block; height: 100%; border-radius: 3px; background: var(--stat-accent, var(--c-primary)); }
/* Over the cap the bar is full AND red, so it does not read as "nearly there". */
.stat.accent-danger .meter > span { background: var(--c-danger); }
.stat.accent-warning .meter > span { background: var(--c-warning); }
.stat .lim { font-size: var(--fs-xs); color: var(--c-muted); font-weight: 600; }
.stat.accent-primary { --stat-accent: var(--c-primary); }
.stat.accent-success { --stat-accent: var(--c-success); }
.stat.accent-warning { --stat-accent: var(--c-warning); }
.stat.accent-danger  { --stat-accent: var(--c-danger); }
.stat.accent-info    { --stat-accent: var(--c-info); }
.stat.accent-muted   { --stat-accent: var(--c-muted); }

/* A tile that wants attention rather than just an identity — an outage, a failure. */
.stat.is-alert {
    background: color-mix(in srgb, var(--stat-accent) 7%, var(--c-surface));
    border-color: color-mix(in srgb, var(--stat-accent) 55%, var(--c-border));
}
.stat.is-alert .n { color: var(--stat-accent); }

/* In forced-colors the system replaces every author colour and DROPS box-shadow, so
   the dot — now the only carrier of status — would vanish into the card. Opt it out;
   this is the case the property exists for. */
@media (forced-colors: active) {
    .stat .l::before { forced-color-adjust: none; box-shadow: none; outline: 1px solid; }
}

@media (max-width: 560px) {
    .stats { --stat-min: 144px; gap: 10px; }
    .stat { min-height: 84px; padding: 12px 13px; }
}

/* Paper: the print block below already redefines --c-surface and --c-border, so these
   read from tokens rather than restating a palette that would then drift. */
@media print {
    .stat { border-color: var(--c-border); background: var(--c-surface); min-height: 0; }
    .stat .l::before { box-shadow: none; }
}

/* ---------- App shell (sidebar) ---------- */
.shell { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 100vh; }
/* LAYOUT ONLY. The sidebar's colours — ground, ink and scrollbar track — are stated once in
   the token block further down this sheet. They were ALSO stated here, in a dark-navy palette
   the product no longer uses, and that copy was dead: the later one matches at the same
   specificity and wins. The ten layout properties below are not restated there, which is why
   this rule stays behind after its three colours leave. */
.sidebar {
    padding: calc(20px + env(safe-area-inset-top)) 14px 20px; position: sticky; top: 0; height: 100vh;
    display: flex; flex-direction: column; gap: 6px;
    overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin;
}
.sidebar::-webkit-scrollbar { width: 7px; }
.sidebar::-webkit-scrollbar-thumb { border-radius: 4px; }
.sidebar .brand { display: flex; align-items: center; gap: 10px; padding: 6px 10px 18px; font-weight: 800; font-size: var(--fs-lg); }
.sidebar .brand .logo { width: 34px; height: 34px; border-radius: var(--radius-sm); background: var(--c-primary); display: grid; place-items: center; color: var(--c-primary-ink); font-weight: 800; }
.sidebar nav { display: flex; flex-direction: column; gap: 2px; }
.sidebar a.nav-link {
    display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: var(--radius-sm);
    font-size: var(--fs-sm);
}
.sidebar a.nav-link:hover { text-decoration: none; }
/* The active item's colours are NOT here any more. This rule declared background:
   var(--c-primary) with color:#fff — white on the surface gold, 2.10:1 — and both are
   restated later as the --ui-soft / --ui-text pair. Nothing else was in it, so unlike its
   eight neighbours it goes entirely rather than being emptied of colour. (The weight and
   the marker on the active item live in their own rule further down and are untouched.) */
.sidebar a.nav-link .ic { width: 20px; height: 20px; display: inline-grid; place-items: center; }

/* ---------- A MENU ICON THAT IS A CHARACTER, NOT A DRAWING ----------
   The Payments group is marked with the Saudi riyal sign. Drawing it as an SVG path would
   put a SECOND copy of that symbol in the codebase, and App\Core\Support\Money is
   deliberately the one place this platform decides how the riyal is written — so the icon
   is the character itself, U+20C1, taken from Money::SAR_SIGN.

   It needs no font-family of its own. "Saudi Riyal" already leads --font with
   `unicode-range: U+20C1`, so this one codepoint resolves from the webfont while every
   other character falls straight through to the next family. Naming a family here would be
   a second place to keep that in step.

   SIZED FROM TOKENS, because a drawn icon and a typeset one never match at the same nominal
   size: a font's em box carries its own internal leading, so a 20px glyph reads smaller than
   a 20px stroke icon beside it. The two knobs below are how that is trimmed, in one place,
   without hunting for this rule. */
.ic-glyph {
    display: inline-grid;
    place-items: center;
    width: 100%;
    height: 100%;
    font-size: var(--ic-glyph-size);
    font-weight: var(--ic-glyph-weight);
    line-height: 1;
    /* The sign is a mark, not a measurement — keep it from being tracked or wrapped. */
    letter-spacing: normal;
    white-space: nowrap;
}

.sidebar .spacer { flex: 1; }
.sidebar .userbox { padding-top: 14px; margin-top: 8px; }
.sidebar .userbox .name { font-weight: 700; font-size: var(--fs-sm); }
.sidebar .userbox .role { font-size: var(--fs-xs); }

.main { min-width: 0; display: flex; flex-direction: column; }
.topbar {
    background: var(--c-surface); border-bottom: 1px solid var(--c-border);
    /* Safe-area top padding so the hamburger clears the status bar/notch when
       the platform runs as an installed PWA (viewport-fit=cover). */
    padding: calc(14px + env(safe-area-inset-top)) 24px 14px; display: flex; align-items: center; justify-content: space-between; gap: 16px;
    position: sticky; top: 0; z-index: 20;
}
/* The topbar is a UTILITY bar now, the way TailAdmin's is — it carries the hamburger, the
   installed-app back button, the language switch and whatever actions a page supplies. It
   no longer carries the page NAME: that was printed here at 18px and again in the view's
   own .page-head at 24px about forty pixels below, on every page in the product. The name
   now appears once, in .page-head, with the breadcrumb above it.
   This rule is kept because nothing emits a topbar h1 any more and a stray one would be
   unstyled; min-height and never height, because these controls wrap at narrow widths and
   a fixed box clips the wrapped row. */
/* THE BAR CARRIES THE PAGE NAME AGAIN — but not a copy of it. The earlier removal was right:
   the name was printed here at 18px AND in the view's own .page-head at 24px, the same words
   twice. The shell now LIFTS the view's .page-head out of the body and prints those same
   nodes here, so there is one title, in the header, where the request put it. */
.topbar h1 { font-size: var(--fs-lg); margin: 0; }

/* THE LEFT REGION MUST BE ABLE TO GROW. It used to hold two 42px buttons, so its flex base
   was content and .topbar's space-between did the rest. It now holds a title block that must
   take the slack and, critically, be allowed to SHRINK: min-width:0 is what lets the lede
   ellipsis instead of forcing the bar wider than the viewport.
   :not(.topbar-cluster) rather than :first-child — both children are .row, and a positional
   selector would silently retarget if anything is ever inserted before the hamburger. */
.topbar > .row:not(.topbar-cluster) { flex: 1 1 auto; min-width: 0; }
.topbar-head { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }

/* The lifted .page-head keeps its own flex layout — title block left, the page's actions
   right — but loses the body margin it carried as a block in the content column. */
.topbar-head > .page-head { margin-bottom: 0; width: 100%; }
.topbar-head > .crumbs { margin-bottom: 0; }

/* A HEADER TITLE IS NOT A PAGE TITLE. In the body the h1 was --fs-h1 (20px) with room above
   and below it; in a sticky bar that reads as oversized and pushes the bar past 100px tall.
   (0,2,1) beats both .topbar h1 and .page-head h1, which are each (0,1,1) and would otherwise
   be settled by declaration order — a tie this should not be relying on. */
.topbar .page-head h1 { font-size: var(--fs-lg); line-height: 1.25; }

/* THE LEDE STAYS ONE LINE. A sticky bar that changes height from page to page makes the
   content jump as you navigate, and some ledes run to two sentences. Clamped with the logical
   property so Arabic truncates on the correct side. */
.topbar .page-head p { font-size: var(--fs-xs); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }

/* Below the sidebar breakpoint the bar is already carrying a hamburger, a trail and a title;
   the lede is the first thing to go rather than the thing that makes the bar three rows.
   MEASURED AT 375px BEFORE AND AFTER: the bar was 188px — 28% of the viewport, on every
   page — because the trail took a row and the page's action button wrapped below the title.
   Both are removed here. The bar is still ALLOWED to wrap its two regions onto two rows at
   this width: a hamburger, a title, a page action, a language switch, a theme toggle and an
   identity chip do not fit on one 375px line, and forcing them would either overflow or
   push the controls under the 44px touch floor. */
@media (max-width: 900px) {   /* the SIDEBAR breakpoint — below it the layout is already mobile */
    .topbar .page-head p { display: none; }
    /* the trail repeats the title one line below it at this width */
    .topbar-head > .crumbs { display: none; }
    /* the action must not take a row of its own; the TITLE is what truncates, because a
       shrunken button is unreadable while a shortened title is still a title */
    .topbar-head > .page-head { flex-wrap: nowrap; align-items: center; }
    .topbar .page-head h1 { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
    .topbar-head > .page-head > :not(:first-child) { flex: 0 0 auto; }
}
/* No :not(.is-empty) guard any more. The bar now ALWAYS carries the theme toggle and the
   identity chip, on every plane, so there is no empty state left to hide — and the rule
   that used to hide it was exactly what made the Root console lose its header when the
   duplicated page title was removed from the test that fed it. */
.topbar { min-height: 60px; }
.topbar-back { display: inline-grid; place-items: center; width: 42px; height: 42px; border-radius: var(--radius-sm); border: 1px solid var(--c-border); background: var(--c-surface); cursor: pointer; color: var(--c-text); flex: 0 0 auto; }
.topbar-back[hidden] { display: none; }

/* THE APP FOOTER. Sticky to the bottom of the viewport, carrying the platform version and the
   Root-owned copyright — both edited at /owner/dashboard, either may be blank.
   BACKGROUND IS THE BODY'S OWN TOKEN, NOT `transparent`. The request was for it to match the
   body rather than read as a separate surface, and --c-bg is literally what body carries. A
   truly transparent sticky bar is a different thing: content would scroll visibly underneath
   the text and the two would overlap.
   margin-top:auto because .main is a column flex — on a short page this pins the footer to the
   bottom of the column instead of leaving it floating under the content.
   No border and no shadow: it is meant to be quiet. */
.app-foot {
    position: sticky; bottom: 0; z-index: 5; margin-top: auto;
    background: var(--c-bg);
    padding: 10px 24px calc(10px + env(safe-area-inset-bottom));
    display: flex; align-items: center; justify-content: center; gap: 8px; flex-wrap: wrap;
    font-size: var(--fs-xs); color: var(--c-muted);
}
.app-foot-sep { opacity: .55; }
/* The version is the one part anyone looks for deliberately, so it keeps tabular figures and
   does not wrap mid-string. */
.app-foot-ver { font-variant-numeric: tabular-nums; white-space: nowrap; }
@media (max-width: 760px) { .app-foot { padding-inline: 16px; } }
.topbar-back:active { background: var(--c-surface-2); }
.content { padding: 26px 24px 60px; }
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 22px; flex-wrap: wrap; }
/* THE TITLE BLOCK MUST SHRINK, OR THE BUTTON WRAPS OFF THE HEADER ROW. .page-head above
   constrains no child, so this div's flex base size is its max-content width — the lede on
   one line — and flex wraps BEFORE it shrinks. /owner/plans measured a 135px header
   instead of 72px with the primary action sitting at the start edge and 984px of empty
   canvas beside it; which pages suffer is pure luck of lede length.
   I patched this twice per page with .cap-narrow before understanding it. A cap does work
   at 1440 (a max-width on the <p> clamps the div's max-content contribution, which clamps
   its flex base), but it fails at 900px and below where 820 + gap + button no longer fit.
   This works at 1102 / 900 / 760 / 600 and still wraps at 420, which is right on a phone.
   SAFE BY MEASUREMENT: all 98 .page-head occurrences in the tree open with a BARE <div>
   that contains the <h1> — no class, no inline style on any of them — so this can never
   land on an action cluster and nothing competes with it. */
.page-head > div:first-child { flex: 1 1 320px; min-width: 0; }
.page-head h1 { font-size: var(--fs-h1); font-weight: 600; margin: 0; }

/* ---------- The page header's breadcrumb trail ----------
   Measured from TailAdmin's own PageBreadCrumb component rather than remembered: 14px muted
   links at a 6px gap, a 17x16 chevron at stroke-width 1.2 INSIDE each link, and the current
   page in the heading ink rather than the link ink. No border, no background, no icon.
   ------------------------------------------------------------------------------------- */
.crumbs { margin: 0 0 6px; }
.crumbs ol {
    display: flex; align-items: center; flex-wrap: wrap;
    gap: 6px; margin: 0; padding: 0; list-style: none;
}
.crumbs li { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); }
.crumbs a { display: inline-flex; align-items: center; gap: 6px; color: var(--c-muted); text-decoration: none; }
.crumbs a:hover { color: var(--c-text); text-decoration: none; }
/* The chevron is the SEPARATOR and it lives inside the link, exactly as TailAdmin ships it,
   so a trail never ends on a dangling arrow after the current page. */
.crumbs svg { width: 17px; height: 16px; flex: 0 0 auto; stroke: currentColor; }
/* The page you are on is not a link, and it takes the title's ink. */
.crumbs [aria-current="page"] { color: var(--c-text); font-weight: 500; }
/* An arrow points the way the reader is travelling, so in Arabic it points the other way.
   TailAdmin writes rtl:rotate-180 on this very chevron, so mirroring is upstream behaviour,
   not an invention. Keyed on dir and not :lang(ar): this is direction, not language. */
[dir="rtl"] .crumbs svg { transform: scaleX(-1); }
/* Opt-in: the exact TailAdmin layout for a view whose header has a free right slot — put the
   same <nav class="crumbs"> INSIDE .page-head and it moves opposite the title.
   margin-inline-start, so it lands at the row's END in both directions. */
.page-head > .crumbs { margin: 0; margin-inline-start: auto; }


/* Mobile bottom nav + off-canvas scrim (hidden on desktop; shown on mobile when open) */
.mobile-nav { display: none; }
.hamburger { display: none; }
.scrim { display: none; }

@media (max-width: 900px) {
    .shell { grid-template-columns: 1fr; }
    .sidebar { display: none; }
    .sidebar.open {
        display: flex; position: fixed; inset: 0 auto 0 0; width: 82vw; max-width: 300px; z-index: 60;
        box-shadow: var(--shadow-lg);
    }
    .scrim { display: none; }
    .scrim.open { display: block; position: fixed; inset: 0; background: rgba(2,6,23,.5); z-index: 55; }
    .hamburger { display: inline-grid; place-items: center; width: 42px; height: 42px; border-radius: var(--radius-sm); border: 1px solid var(--c-border); background: var(--c-surface); cursor: pointer; }
    .content { padding: 18px 16px 90px; }
    .mobile-nav {
        display: flex; position: fixed; bottom: 0; left: 0; right: 0; z-index: 40;
        background: var(--c-surface); border-top: 1px solid var(--c-border);
        padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
        justify-content: space-around;
    }
    .mobile-nav a { display: flex; flex-direction: column; align-items: center; gap: 3px; font-size: var(--fs-2xs); color: var(--c-muted); font-weight: 600; padding: 6px 10px; border-radius: var(--radius-sm); flex: 1; }
    .mobile-nav a.active { color: var(--c-primary); }
}

/* ---------- Auth / public ---------- */
.auth-wrap { min-height: 100vh; display: grid; grid-template-columns: 1.1fr 1fr; }
.auth-hero {
    /* THE FAR STOP IS GOLD, NOT GREY. This was color-mix(in srgb, var(--c-accent) 70%,
       var(--c-primary)) — and --c-accent is #9A9A9A, which the :root comment above calls
       "the document outline from the mark": a glyph colour, not a gradient partner. The mix
       computes to #ABA07C, so the lower half of the product's largest brand surface read as
       khaki rather than as a tone of the brand gold.
       --ui-2 is the token that already means exactly this — BrandTokens calls it "The far end
       of the button and logo gradient" — which is why .btn-primary, sitting on this very page,
       stays gold end to end while the panel behind it did not.
       Contrast does not move: #1A1A1A is 6.68 on #C69A27 against the 6.67 the comment below
       already accepts for the far stop, and 10.2 on the dark-mode --c-ui-2 #E3C45F.
       NOT var(--ui) at the near stop as well: BrandTokens keeps --ui* independent of
       --c-primary on purpose, because --c-primary also goes onto signed documents and into
       emails, so an instance that branded `primary` alone must keep its gold here. */
    background: linear-gradient(150deg, var(--c-primary), var(--ui-2));
    /* NOT #fff. White on this gradient is 2.10:1 at the gold end and 2.61:1 at the other —
       the same failing pairing this file's :root bans, and the Sign in button sitting on
       top of it already uses --c-primary-ink correctly. 8.28:1 / 6.67:1. */
    color: var(--c-primary-ink); padding: 56px 52px; display: flex; flex-direction: column; justify-content: center; position: relative; overflow: hidden;
}
.auth-hero h1 { font-size: var(--fs-2xl); max-width: 460px; }
.auth-hero p { color: color-mix(in srgb, var(--c-primary-ink) 85%, transparent); max-width: 440px; font-size: var(--fs-base); }
.auth-hero ul { list-style: none; padding: 0; margin: 22px 0 0; display: grid; gap: 12px; max-width: 440px; }
.auth-hero li { display: flex; gap: 11px; align-items: flex-start; color: color-mix(in srgb, var(--c-primary-ink) 88%, transparent); font-size: var(--fs-base); }
.auth-hero li .tick { flex: 0 0 22px; height: 22px; border-radius: 50%; background: color-mix(in srgb, var(--c-primary-ink) 16%, transparent); display: grid; place-items: center; font-size: var(--fs-sm); }
.auth-panel { display: grid; place-items: center; padding: 40px 24px; background: var(--c-bg); }
.auth-card { width: 100%; max-width: 400px; }
/* The language control above the sign-in form. flex-end is the MAIN-AXIS end, so this sits on
   the right in English and the left in Arabic with no RTL override — which is why the control
   is laid out in flow rather than pinned to a page corner. */
.auth-card .auth-lang { display: flex; justify-content: flex-end; margin-bottom: 14px; }
.auth-logo { display: flex; align-items: center; gap: 11px; font-weight: 800; font-size: var(--fs-lg); margin-bottom: 26px; }
.auth-logo .logo { width: 40px; height: 40px; border-radius: var(--radius-sm); background: var(--c-primary); display: grid; place-items: center; color: var(--c-primary-ink); }
@media (max-width: 860px) {
    .auth-wrap { grid-template-columns: 1fr; }
    .auth-hero { display: none; }
}

/* ---------- Public marketing ---------- */
/* Safe-area top padding so the public header clears the status bar/notch in PWA. */
.pub-header { background: var(--c-surface); border-bottom: 1px solid var(--c-border); padding-top: env(safe-area-inset-top); }
/* min-height, NOT height. The nav inside wraps at phone width, and a fixed 68px box let the
   wrapped rows overflow it — centred, so they spilled above the header and the first control
   was cut off at the top. This was true of the icon button that used to sit here too (93px of
   nav in a 68px box); it is simply visible now that the control is wider than it is tall.
   The padding is 4px and not more ON PURPOSE: the tallest thing on this row is the 58px logo
   plate, so 58 + 8 still sits under the 68px floor and the desktop header does not move by a
   single pixel. At 6px it grew by two. */
.pub-header .container { display: flex; align-items: center; justify-content: space-between; min-height: 68px; padding-block: 4px; }
.pub-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: var(--fs-lg); }
.pub-brand .logo { width: 36px; height: 36px; border-radius: var(--radius-sm); background: var(--c-primary); display: grid; place-items: center; color: var(--c-primary-ink); }
.hero {
    padding: 80px 0 70px; text-align: center;
    background: radial-gradient(1200px 400px at 50% -10%, color-mix(in srgb, var(--c-primary) 12%, transparent), transparent);
}
.hero h1 { font-size: clamp(30px, 5vw, 48px); max-width: 760px; margin: 0 auto .4em; }
.hero p { font-size: var(--fs-lg); color: var(--c-muted); max-width: 620px; margin: 0 auto 28px; }
.hero .cta { display: inline-flex; gap: 12px; flex-wrap: wrap; justify-content: center; }
.features { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 20px; padding: 20px 0 80px; }
/* --ui-text, not --c-primary: the surface gold on its own 12% tint is 1.93:1. */
.feature .ic { width: 46px; height: 46px; border-radius: var(--radius); background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); color: var(--ui-text); display: grid; place-items: center; margin-bottom: 12px; }
.feature h3 { font-size: var(--fs-lg); }
.feature p { font-size: var(--fs-base); color: var(--c-muted); margin: 0; }
/* The padding that used to be here (28px 0) never rendered: the rebuilt footer further down
   states 40px 0 0 at the same specificity. `color` and `text-align` are NOT restated there,
   so they stay and this rule stays with them. */
.pub-footer { border-top: 1px solid var(--c-border); color: var(--c-muted); font-size: var(--fs-sm); text-align: center; }

/* ---------- Verify result ---------- */
.verify-card { max-width: var(--measure-card); margin: 40px auto; }
.verify-status { display: flex; align-items: center; gap: 14px; padding: 22px; border-radius: var(--radius); color: #fff; }
.verify-status .vs-ic { width: 52px; height: 52px; border-radius: 50%; background: rgba(255,255,255,.25); display: grid; place-items: center; font-size: var(--fs-xl); flex: 0 0 auto; }
.verify-status h2 { color: #fff; margin: 0; font-size: var(--fs-xl); }
.vs-valid { background: var(--c-success); }
.vs-void, .vs-invalid { background: var(--c-danger); }
.vs-expired, .vs-superseded { background: var(--c-warning); }
.vs-notfound { background: var(--c-muted); }
.kv { display: grid; grid-template-columns: 160px 1fr; gap: 8px 16px; font-size: var(--fs-base); margin: 0; }
.kv dt { color: var(--c-muted); font-weight: 600; }
.kv dd { margin: 0; word-break: break-word; }

/* ---------- A label/value TABLE ----------
   .kv above is a <dl> grid and is the right answer when the markup is a description
   list. Three views needed the same shape as a TABLE and hand-rolled it inline instead:
   public/order_review.php (both its tables), owner/tenant.php and owner/application.php.
   Every one of them writes the cell padding as `padding: 6px 14px 6px 0` — a four-value
   PHYSICAL shorthand, so the gap after the label lands on the wrong side in Arabic. It
   contains neither "left" nor "right", which is how it survived the RTL sweeps.
   ---------------------------------------------------------------------------------- */
table.kv-table { width: 100%; border-collapse: collapse; font-size: var(--fs-sm); }
table.kv-table td { padding-block: 6px; vertical-align: baseline; }
table.kv-table td:first-child { padding-inline-end: 14px; color: var(--c-muted); }
table.kv-table td:last-child { text-align: end; }
/* A label that carries its own content — the discount row holds a code chip — keeps the
   full ink rather than the muted label colour. */
table.kv-table tr.od-row td:first-child { color: inherit; }
/* The closing total: heavier, ruled off, and the figure a step up. */
table.kv-table tr.is-total td { padding-block: 10px; border-top: 1px solid var(--c-border); font-weight: 700; }
table.kv-table tr.is-total td:first-child { color: var(--c-text); }
table.kv-table tr.is-total td:last-child { font-size: var(--fs-lg); }
/* The money summary sits narrow at the END of its column. margin-inline-start: auto,
   never `margin: 22px 0 0 auto` — that pushes it to the physical right in Arabic too. */
table.kv-table.kv-sum { width: auto; min-width: 260px; margin-block-start: 22px; margin-inline-start: auto; }
table.kv-table.kv-sum td:first-child { padding-inline-end: 16px; }
/* A DETAIL list rather than figures. The base aligns the value to the END because the
   checkout's values are money; these are prose — a contact address, a country, a date —
   and read from the start edge like their labels. The label does not wrap (it is one or
   two words and wrapping it strands the value), and both cells align to the top because
   a value here can run to several lines while its label never does. */
table.kv-table.kv-detail td { padding-block: 8px; vertical-align: top; }
table.kv-table.kv-detail td:first-child { white-space: nowrap; }
table.kv-table.kv-detail td:last-child { text-align: start; }

/* ---------- Utilities ---------- */
.flex-between { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
/* THE ONE ROW IN THE TREE WHERE A <label> SHARES THIS UTILITY. The base `label` rule in
   Forms carries margin-bottom: 6px, and align-items:center centres the MARGIN box - so the
   label's box, 6px taller than the link's, lands the link 3px LOWER than the label beside
   it. That is the "Forgot password?" step on /login, the product's front door, and it is
   the same mechanism and the same 3px the .btn rule above already records for a
   <label class="btn"> file picker. That round fixed .btn and left this row behind.
   baseline rather than .m-0 on the label: zeroing the margin would also close the 6px
   between the label row and the password input while the email field above kept its 6px,
   so one card would carry two rhythms, and there is no 6px step to put it back (.mb-xs is
   4px, .mb-1 is 8px). baseline aligns the two text baselines exactly and leaves the 6px
   extending the row below it, so nothing but the link moves.
   (0,2,0) against the utility's (0,1,0), so the seven other files that use .flex-between
   are untouched - none of them puts one under a .field - and align-items is a cross-axis
   keyword, so Arabic needs no override. Views/auth/login.php:26 is the only `flex-between`
   in the tree holding a <label>. */
.field > .flex-between { align-items: baseline; }
/* .gap-sm USED TO BE HERE and is now at the very end of this file, with .gap-md. It had to
   move: 92 component rules that set their own gap are declared below this point and all of
   them weigh (0,1,0), so `class="cluster gap-sm"` kept .cluster's gap and the utility did
   nothing precisely where it was most likely to be used. */
/* THE MARGIN SCALE IS NOT HERE ANY MORE. It is in the correcting block at the end of this
   file, because a margin utility corrects a property components also set: .kpi-row and
   .dash-grid set margin-bottom, .inherit-note sets margin-top, and all of them are
   declared below this point at the same (0,1,0) — so from here `class="kpi-row mb-0"`
   did nothing at all. Found by ui/deadutil.php, not by reading. */
/* THESE FIVE MOVED TOO. stage32 lifted the margin scale into the correcting block and took
   only the eight names it listed, leaving .mt-xs/.mb-xs/.mb-1 here and .mt-sm/.mb-sm just
   below — above every component that sets a margin at the same (0,1,0). The design pass
   re-ran the scan and found three of the five live no-ops. */
/* THE 12px STEP. The scale ran 4 / 8 / 16 / 24, so every 12px nudge was 4px from its
   nearest neighbour — twice the rounding allowed anywhere else — and 30 of them stayed
   inline for that reason alone. With 12 present the scale is 4/8/12/16/24 and every value
   the views actually write (6, 10, 12, 14, 18, 22) lands within 2px of a step.
   The name is a compromise: -xs and the numbers are two idioms that already met here, and
   renaming .mt-1/2/3 is not worth it when they are applied 140+ times. */
/* The alignment, fill, weight and size utilities were HERE and are now in the correcting
   block at the very end of this file. They had to move for the same reason .gap-sm did:
   each one corrects a property that components also declare at (0,1,0), so a copy above
   those components is a silent no-op. Putting them here was that exact mistake, made in
   the same change that fixed it for gap. */
/* The one-line subtitle under a page title. The negative margin is not a preference: h1..h4
   carry margin: 0 0 .5em, which is right between a heading and a paragraph and too much
   between a title and its own subtitle. Twelve views wrote -6px inline. If that heading
   margin is ever revisited, this is the single place to correct. */
.page-sub { margin-top: -6px; }
/* The same correction one level down: the subtitle under a CARD title. What is cancelled
   here is .card-header's margin-bottom (line 327), not h1..h4's .5em — two different
   offsets that happen to want the same -6px today, kept apart so that revising one does
   not move the other. */
.card-sub { margin-top: -6px; }
/* Two more, because between them they were written inline 160 times. Plain classes, not
   !important: the four utilities beside them carry no !important and are already applied
   140 times across the product without trouble, and a utility that cannot be overridden
   is the same trap as the inline style it replaces. */
.flex-1 { flex: 1; }
.w-full { width: 100%; }
.divider { height: 1px; background: var(--c-border); margin: 20px 0; border: 0; }

/* ============================================================
   Redesign — Phase 1: light sidebar shell + indigo UI accent
   The --ui* tokens are independent of the branding-injected
   --c-primary (which still drives signed documents & emails), so
   the app chrome adopts the reference indigo look without changing
   the brand colour. Appended so it overrides earlier rules by cascade.
   ============================================================ */
:root{
  /* Read through the branded palette, falling back to the values this block has always
     had. head.php injects --c-ui* only when a Root has set them, so an untouched instance
     resolves to exactly the indigo below and nothing moves. A custom property resolves
     against the element's full computed set, not against source order, so the injected
     <style> later in <head> is visible to these var() calls. */
  --ui: var(--c-ui, #D4AF37); --ui-2: var(--c-ui-2, #C69A27); --ui-soft: var(--c-ui-soft, #FBF3DC);
  /* GOLD USED AS TEXT. Deliberately NOT --c-ui-ink: that token already exists and means the
     opposite thing — the ink that sits ON the accent — so reading it gave white text on the
     pale gold pill at 1.11:1. Two meanings cannot share one name. No var() indirection here
     because branding has no key for it; inventing one is what caused the collision. */
  --ui-text: #846A15;
  /* THE FOCUS RING. --c-focus was declared in the two dark blocks and nowhere else, and
     every rule reads it as `var(--c-focus, var(--c-primary))` — so in light mode the
     fallback fired and every focus ring on the platform was drawn in the SURFACE gold at
     2.10:1, under the 3:1 a focus indicator has to clear. A var() fallback only applies
     while the property is unset, which is why it never looked like a missing token. Same
     value as --ui-text (5.18:1 on a card, 4.63:1 on the canvas, 5.09:1 on an input) and
     the dark blocks already set their own. */
  --c-focus: #846A15;
  --side-bg:#ffffff; --side-ink:#3d4453; --side-hover:#f4f5fb; --side-line:#ecedf5;
  /* Secondary line inside the workspace switcher. It was #8b90a0 / #9aa0b0 in a private
     <style> in shell.php — on every authenticated page, and unreachable from branding. */
  --side-muted:#8b90a0;
}
/* ── The sidebar's colour, and the only statement of it ───────────────────────────────
   Every declaration here used to be shadowing a dark-navy copy earlier in the sheet; that
   copy is gone, so these are now the sole source. Each value is a --side-* token, and
   every one of those is redefined in BOTH dark arms — so this block is unconditional on
   purpose and must stay that way. A literal added here would paint one mode only. */
.sidebar{ background:var(--side-bg); color:var(--side-ink); border-right:1px solid var(--side-line); scrollbar-color:#d5d8e4 transparent; }
.sidebar::-webkit-scrollbar-thumb{ background:#d5d8e4; }
.sidebar .brand{ color:var(--c-text); }
.sidebar .brand .logo{ background: var(--c-primary); }
.sidebar a.nav-link{ color:var(--side-ink); font-weight:600; }
.sidebar a.nav-link:hover{ background:var(--side-hover); color:var(--c-text); }
.sidebar a.nav-link.active{ background:var(--ui-soft); color:var(--ui-text); }
.sidebar .userbox{ border-top:1px solid var(--side-line); }
.sidebar .userbox .name{ color:var(--c-text); }
.sidebar .userbox .role{ color: var(--c-muted); }
/* New-document CTA (rendered above the nav) */
/* The CTA at the top of the sidebar is the first thing on every authenticated page, and it
   carried a gradient plus a gold halo that bled about 20px onto the cream canvas. Flat, and
   the same weight as .btn-primary, so the two read as one button. */
.new-cta{ display:flex; align-items:center; justify-content:center; gap:8px; margin:2px 0 12px; padding:11px 12px;
  border-radius: var(--radius-sm); background: var(--c-primary); color:var(--c-primary-ink); font-weight:600; font-size: var(--fs-base);
  text-decoration:none; }
.new-cta:hover{ filter:brightness(1.05); text-decoration:none; color:var(--c-primary-ink); }
.new-cta .ic{ width:18px; height:18px; display:inline-grid; place-items:center; }
.new-cta .ic svg{ width:16px; height:16px; }
/* .btn-primary lives at the top of this file, FLAT, as the TailAdmin pass set it. The two
   rules that stood here re-declared it at equal specificity with a gradient and a gold glow
   and, being later, won — which is why flattening the cards did not make the platform look
   flat. Deleted rather than edited: one selector, declared once. (The ink note they carried
   is kept at the canonical rule, and the palette docblock says it too.) */
/* Mobile bottom-nav active → indigo */
.mobile-nav a.active{ color:var(--ui-text); }

/* ============================================================
   Redesign — Phase 2: dashboard (icon-chip KPI cards + drop-zone)
   ============================================================ */
.kpi-ic{ width:34px; height:34px; border-radius: var(--radius-sm); display:grid; place-items:center; margin-bottom:11px; }
.kpi-ic svg{ width:18px; height:18px; }
.kpi-ic.tint-i{ background:var(--ui-soft); color:var(--ui-text); }
/* Each tint used to hard-code a light hex PAIR here and a second dark pair in the
   dark block below — twelve literals per tint, and two places to forget. Mixing the
   semantic token against --c-surface is one rule that resolves correctly in BOTH
   themes, because --c-surface is what changes between them. */
.kpi-ic.tint-b{ background:color-mix(in srgb, var(--c-info) 13%, var(--c-surface));    color:var(--c-info); }
.kpi-ic.tint-g{ background:color-mix(in srgb, var(--c-success) 13%, var(--c-surface)); color:var(--c-success); }
.kpi-ic.tint-v{ background:color-mix(in srgb, var(--c-violet) 13%, var(--c-surface)); color:var(--c-violet); }
.kpi-ic.tint-a{ background:color-mix(in srgb, var(--c-warning) 14%, var(--c-surface)); color:var(--c-warning); }
.kpi-ic.tint-r{ background:color-mix(in srgb, var(--c-danger) 13%, var(--c-surface));  color:var(--c-danger); }
.kpi-alert .kpi-ic.tint-r{ background:color-mix(in srgb, var(--c-danger) 18%, var(--c-surface)); }
/* Drop-zone hero */
.dash-hero{ margin-bottom:18px; }
.dz{ display:flex; align-items:center; gap:18px; border:2px dashed var(--c-border); border-radius:16px;
  background:linear-gradient(180deg,var(--c-surface-2),var(--c-surface)); padding:18px 22px; text-decoration:none; color:inherit; }
.dz:hover{ border-color:color-mix(in srgb,var(--ui) 40%,var(--c-border)); background:linear-gradient(180deg,#FEFCF6,#FAF2DE); text-decoration:none; }
.dz-ic{ width:52px; height:52px; border-radius: var(--radius); background:var(--ui-soft); color:var(--ui-text); display:grid; place-items:center; flex:0 0 auto; }
.dz-ic svg{ width:26px; height:26px; }
.dz-txt{ flex:1; min-width:0; }
.dz-txt .t{ display:block; font-weight:700; font-size: var(--fs-base); letter-spacing:-.01em; color:var(--c-text); }
.dz-txt .s{ display:block; color:var(--c-muted); font-size: var(--fs-sm); margin-top:2px; }
@media(max-width:600px){ .dz{ flex-direction:column; text-align:center; } }

/* ============================================================
   Redesign — Phase 3: list pages (status/filter chips + empty states)
   ============================================================ */
.filter-bar{ display:flex; flex-wrap:wrap; gap:8px; margin-bottom:12px; }
.fchip{ display:inline-flex; align-items:center; gap:7px; padding:6px 13px; border-radius:999px; background:var(--c-surface);
  border:1px solid var(--c-border); color:var(--c-text); font-size: var(--fs-sm); font-weight:600; text-decoration:none; line-height:1; white-space:nowrap; }
.fchip:hover{ border-color:color-mix(in srgb,var(--ui) 40%,var(--c-border)); color:var(--ui-text); text-decoration:none; }
.fchip.is-on{ background:var(--ui-soft); border-color:color-mix(in srgb,var(--ui) 45%,transparent); color:var(--ui-text); }
/* Polished empty state — extends the base .empty (also used by contacts) */
/* .empty.empty-lg, not .empty-lg: the base .empty is declared 176 lines BELOW this and at
   the same (0,1,0), so a plain .empty-lg lost its own padding to the thing it modifies and
   the "polished" empty state has always rendered at the plain one's size. Same trap the
   comment two lines down records for .empty-ic.tint-i. */
.empty.empty-lg{ padding:54px 20px; }
/* PAIRED WITH .empty-ic BELOW. This is (0,2,0) against that rule's (0,1,0), so it wins on
   radius wherever both apply regardless of order — one component was rendering as a circle
   on most pages and a 16px squircle on list pages. Both are --radius-md now, which is also
   the .icon-tile shape, so the two converge instead of drifting. */
.empty-ic.tint-i{ border-radius: var(--radius-md); background:var(--ui-soft); color:var(--ui-text); }
.empty-title{ font-size: var(--fs-base); font-weight:700; color:var(--c-text); letter-spacing:-.01em; margin:2px 0 5px; }
.empty p{ max-width:420px; margin:0 auto; }
.empty .btn{ margin-top:14px; }
.tag-or { display: flex; align-items: center; gap: 12px; color: var(--c-muted); font-size: var(--fs-sm); margin: 18px 0; }
.tag-or::before, .tag-or::after { content: ""; flex: 1; height: 1px; background: var(--c-border); }

/* ---------- Layout helpers ---------- */
.split-2 { display: grid; grid-template-columns: minmax(0,1fr) 360px; gap: 20px; align-items: start; }
.stack-lg > * + * { margin-top: 20px; }
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
/* Collapse 2-column form/content grids on phones. Inline grid-template-columns
   needs !important to be overridden; the substring selectors catch the inline
   ones used across the wizard, SMTP, users, permit-types and Microsoft config. */
@media (max-width: 760px) {
  .row2 { grid-template-columns: 1fr; }
  /* ONE SELECTOR, NOT A LIST OF SUBSTRINGS. This used to enumerate "fr 1fr" plus six fixed
     pixel widths and call itself robust; it was not. Measured against the tree, 7 inline
     grids escaped it and stayed multi-column on a phone — repeat(3,1fr) on /admin/reports,
     minmax(0,1fr) minmax(0,1.2fr) on /owner/database, minmax(0,1fr) 420px on
     /owner/error-pages, and four 120px 1fr label grids.
     The rule is simply: an inline grid that is not auto-fit/auto-fill cannot reflow by
     itself, so at phone width it goes single-column. That is a strict superset of the list
     it replaces, and nothing new has to be remembered when the next inline grid is written.
     !important is required and only here: an inline declaration outranks every class. */
  [style*="grid-template-columns"]:not([style*="auto-fit"]):not([style*="auto-fill"]) {
      grid-template-columns: 1fr !important;
  }
}
/* Wider two-column work layouts (form + side panel) collapse earlier. */
@media (max-width: 900px) {
  /* These stay an explicit list because they must collapse EARLIER than the phone rule
     above — a fixed side panel is unusable long before 760px. 420px was missing and is
     what /owner/error-pages uses. */
  [style*="minmax(0,1fr) 420px"], [style*="minmax(0, 1fr) 420px"],
  [style*="minmax(0,1fr) 380px"], [style*="minmax(0, 1fr) 380px"],
  [style*="minmax(0,1fr) 320px"], [style*="minmax(0, 1fr) 320px"],
  [style*="grid-template-columns:1fr 380px"], [style*="grid-template-columns: 1fr 380px"] { grid-template-columns: 1fr !important; }
}
.inline-search { display: flex; gap: 8px; align-items: center; }
.inline-search input { min-height: 40px; padding: 8px 12px; border: 1px solid var(--c-border); border-radius: var(--radius-sm); font: inherit; min-width: 240px; background: var(--c-surface); }
@media (max-width: 900px) { .split-2 { grid-template-columns: 1fr; } .inline-search input { min-width: 0; } }

/* ---------- Chips (tag filter) ---------- */
.chips { display: flex; flex-wrap: wrap; gap: 8px; }
/* THE POINTER AND THE HOVER BELONG TO THE LINK, NOT TO THE PILL. .chip is used both ways:
   4 <a class="chip"> that filter a list (/owner/documents, /owner/paymob and the entity
   tabs on /owner/aliphia-mapping) and 7 <span class="chip"> that are read-only counters
   (/owner/aliphia-mapping, /owner/aliphia/reconcile). On the bare class, every counter got
   a hand cursor and lit up gold under the mouse while doing nothing — the same trap the
   .activity-chips note further up records. An <a href> takes its pointer from the UA, so
   nothing is added back; a.chip:hover is (0,2,1) and still reaches all four links. */
.chip { display: inline-flex; align-items: center; gap: 7px; padding: 6px 12px; border-radius: 999px; background: var(--c-surface); border: 1px solid var(--c-border); color: var(--c-text); font-size: var(--fs-sm); font-weight: 500; }
a.chip:hover { text-decoration: none; border-color: color-mix(in srgb, var(--c-primary) 40%, var(--c-border)); }
.chip-on { background: var(--c-primary); border-color: var(--c-primary); color: var(--c-primary-ink); }
.chip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--chip, var(--c-muted)); }
/* The lozenge is solid --c-primary and its own text is --c-primary-ink, so a WHITE dot on
   it was the banned pairing at 2.10:1 — and a 9px dot is exactly the kind of small
   non-text indicator the 3:1 rule exists for. Matching the ink beside it: 8.28:1. */
.chip-on .chip-dot { background: var(--c-primary-ink); }
.chip-n { font-size: var(--fs-2xs); opacity: .7; font-variant-numeric: tabular-nums; }

/* ---------- Tag pills + picker ---------- */
.tag-pill { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: 999px; font-size: var(--fs-xs); font-weight: 600; margin: 1px 2px 1px 0; color: var(--chip, #64748b); background: color-mix(in srgb, var(--chip, #64748b) 12%, var(--c-surface)); border: 1px solid color-mix(in srgb, var(--chip, #64748b) 28%, var(--c-surface)); }
.tag-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--chip, #64748b); }
.tag-picker { display: flex; flex-wrap: wrap; gap: 8px; }
.tag-check { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border-radius: 999px; border: 1px solid var(--c-border); font-size: var(--fs-sm); cursor: pointer; user-select: none; }
.tag-check input { accent-color: var(--chip, var(--c-primary)); margin: 0; }
.tag-check:has(input:checked) { background: color-mix(in srgb, var(--chip, var(--c-primary)) 12%, var(--c-surface)); border-color: var(--chip, var(--c-primary)); }
.tag-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 14px; }
.tag-row { display: flex; align-items: center; gap: 10px; }
.tag-add { display: flex; gap: 8px; align-items: center; border-top: 1px solid var(--c-border); padding-top: 14px; }
.tag-add input[name=name] { flex: 1; min-height: 38px; padding: 7px 11px; border: 1px solid var(--c-border); border-radius: var(--radius-sm); font: inherit; }
.tag-add input[type=color] { width: 42px; height: 38px; padding: 3px; border: 1px solid var(--c-border); border-radius: var(--radius-sm); background: var(--c-surface); }

/* ---------- CRM toolbar (chips + search) ---------- */
.crm-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }

/* ---------- Modal ---------- */
.modal-backdrop { position: fixed; inset: 0; z-index: 80; display: none; align-items: flex-start; justify-content: center; padding: 6vh 16px 40px; background: rgba(2, 6, 23, .55); overflow-y: auto; }
.modal-backdrop.show { display: flex; }
.modal { width: 100%; max-width: 560px; background: var(--c-surface); border-radius: var(--radius); box-shadow: var(--shadow-lg); animation: modalIn .18s ease; }
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 17px 22px; border-bottom: 1px solid var(--c-border); }
.modal-head h3 { margin: 0; font-size: var(--fs-lg); }
.modal-close { display: grid; place-items: center; width: 34px; height: 34px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--c-muted); font-size: var(--fs-xl); line-height: 1; cursor: pointer; text-decoration: none; }
.modal-close:hover { background: var(--c-surface-2); color: var(--c-text); text-decoration: none; }
.modal-body { padding: 22px; }
@keyframes modalIn { from { transform: translateY(-10px); opacity: 0; } to { transform: none; opacity: 1; } }

/* ---------- Backup options ---------- */
.backup-opts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }
.backup-opts form { display: flex; flex-direction: column; gap: 7px; padding: 14px; border: 1px solid var(--c-border); border-radius: var(--radius-sm); background: var(--c-surface-2); }
.backup-opts .small { line-height: 1.4; }
@media (max-width: 820px) { .backup-opts { grid-template-columns: 1fr; } }

/* ---------- Dashboard: KPIs + charts ---------- */
/* The tile count here is NOT fixed — every .kpi-row on the Root dashboard is assembled by
   PHP conditionals (the Security row has five independent ifs and renders 0 to 7 tiles),
   so repeat(6, 1fr) dropped the seventh onto its own line at one sixth of the width with
   five empty tracks beside it. auto-FILL, not auto-fit, for the reason .stats records:
   auto-fit collapses the empty tracks, so a 3-tile row's tiles would come out two and a
   half times the size of a 7-tile row's. Keeping the tracks keeps every tile on the page
   one size and lets a short row simply end early. 140px so that seven still fit on one
   line at this console's content width. */
/* THE FLOOR IS A VARIABLE, because one number cannot be right for every row. auto-fill
   picks its track count from the WIDTH, so a 140px floor gave SEVEN tracks at this
   console's content width — which fixed the Root console's seven-tile Security row and
   gave /admin's always-six row an empty track at the end.
   160 WAS THEN SOLVED FOR THE WRONG WIDTH: /admin's row is directly in .content at
   ~1104px, but the Root dashboard's rows are inside a section card at ~1039px, where 160
   gives FIVE tracks and six tiles wrapped 5+1. Measured on both pages, six tracks needs a
   floor of 138..153 at either width; 148 is the middle of that band. The row that renders
   seven lowers its own floor below. Still auto-FILL: auto-fit would collapse
   the empty tracks and make a three-tile row's tiles two and a half times a seven-tile
   row's, which is the reasoning .stats already records. */
.kpi-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--kpi-min, 148px), 1fr)); gap: 24px; margin-bottom: 24px; }
/* The one row on the Root dashboard that can render seven tiles. Per-count modifier, the
   way .kpi-5 already is, rather than a floor that has to suit every page at once. */
.kpi-row.kpi-7 { --kpi-min: 130px; gap: 12px; }
@media (max-width: 1100px) { .kpi-row { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 560px) { .kpi-row { grid-template-columns: repeat(2, 1fr); } }
/* A five-tile row (the Email delivery card: sent / delivered / queued / bounced / not sent).
   Five is prime, so no intermediate column count divides it — three columns would leave a hole
   in the second row. It therefore holds five across, including inside a half-width dash-grid
   card, and at phone width drops straight to two with the last tile spanning the row. The
   count lives here rather than inline on the element because an inline grid-template beats
   every media query and the row could never then respond to anything. */
.kpi-row.kpi-5 { grid-template-columns: repeat(5, 1fr); gap: 12px; }
@media (max-width: 560px) {
  .kpi-row.kpi-5 { grid-template-columns: repeat(2, 1fr); }
  .kpi-row.kpi-5 > .kpi:last-child { grid-column: 1 / -1; }
}
/* A THREE-TILE ROW (the Document activity card: validation checks / downloads / views).
   The count was written INLINE on the element, which beats every media query, so the row
   held three across at phone width where the arms above would have taken over. Stated
   here for the reason .kpi-5 is: the count is FIXED — no PHP conditional varies it — and
   a floor cannot hold three across the whole band. auto-fill reads the WIDTH, and the
   card's inner width runs from 716px at a 1101px viewport to 1150px at .content's cap:
   a floor small enough to give three tracks at 716 gives four at 1150, and one large
   enough to stop the fourth gives two at 716. The 24px gap is inherited from .kpi-row,
   which is what the inline version rendered with, so the desktop row is unchanged.
   Its OWN 560 arm because this selector is (0,2,0) and out-specifies the generic arms it
   would otherwise fall back to; the last tile spans, the way .kpi-5's does, because
   three is odd and the third tile would otherwise sit beside a hole. */
.kpi-row.kpi-3 { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 560px) {
  .kpi-row.kpi-3 { grid-template-columns: repeat(2, 1fr); }
  .kpi-row.kpi-3 > .kpi:last-child { grid-column: 1 / -1; }
}
/* The TailAdmin card shape, which stage 1 set on the earlier .kpi rule where it could not
   render: 16px radius, 16px padding, and NO shadow — TailAdmin's cards are flat and this one
   was quietly putting --shadow-sm back after the rest of the platform had dropped it. */
/* THE TILE IS A COLUMN WITH AN EXPLICIT ORDER. Two markup orders exist in the product:
   partials/dashboard_analytics.php and admin/audit.php write ic -> lab -> val, which is
   TailAdmin's stat card; owner/dashboard.php (25 tiles), admin/reports.php (8) and
   admin/mail_log.php (6) write val -> lab, so 39 tiles read upside-down against the rest.
   An `order` declaration used to exist for exactly this and was deleted as dead in an
   earlier pass — it was dead because .kpi was never a flex container, so the property had
   nothing to act on. Both halves are needed, and with them the markup order stops
   mattering on all 39. */
.kpi {
    background: var(--c-surface); border: 1px solid var(--c-border);
    border-radius: var(--radius); padding: 16px; box-shadow: none;
    display: flex; flex-direction: column;
}
.kpi-ic  { order: -1; }
.kpi-lab { order: 0; }
.kpi-val { order: 1; }
/* The stat NUMBER is the point of a stat card; TailAdmin sets it at its 2xl step. --fs-2xl is
   30px against --fs-xl's 24px, and -.03em tracks with the size instead of a fixed -.5px. */
/* margin-block-start: AUTO, not 2px. The tile is a flex column and the value is its last
   child, so with a fixed margin the value is glued to the bottom of the label — and a
   two-line label therefore pushes it 20px below every other value in the row. auto eats
   the slack instead, so a row of tiles shares one baseline however its labels wrap. The
   sentence in .stat is pinned the same way for the same reason, one component over. */
.kpi-val { font-size: var(--fs-2xl); font-weight: 800; letter-spacing: -.03em; color: var(--c-text); line-height: 1.1; font-variant-numeric: tabular-nums; margin-block-start: auto; }
.kpi-lab { font-size: var(--fs-xs); font-weight: 600; color: var(--c-muted); margin: 0; }
.kpi-alert { border-color: color-mix(in srgb, var(--c-danger) 35%, var(--c-surface)); background: color-mix(in srgb, var(--c-danger) 5%, var(--c-surface)); }
.kpi-alert .kpi-val { color: var(--c-danger); }

.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin-bottom: 24px; }
/* NO LONGER THE ARM .mb-0 NEEDS — KEPT AS A STATEMENT OF INTENT. When this was written .mb-0
   sat at 1180, above this rule and at the same (0,1,0), so `class="dash-grid mb-0"`
   silently kept the 24px and this (0,2,0) arm was the only thing that worked. stage32 moved
   the whole margin scale into the correcting block at the end (.mb-0 is now at 3977, below
   this rule), so the plain utility wins on declaration order by itself. The arm stays
   because it says out loud that this pairing is expected, but it is no longer load-bearing
   — do not cite it as proof that a utility needs a component-specific arm. */
.dash-grid.mb-0 { margin-bottom: 0; }
@media (max-width: 860px) { .dash-grid { grid-template-columns: 1fr; } }

.donut-wrap { display: flex; align-items: center; gap: 18px; flex-wrap: wrap; }
.chart-donut { flex: 0 0 auto; max-width: 190px; height: auto; }
.chart-donut-num { font: 700 26px var(--font); fill: var(--c-text); }
.chart-donut-lab { font: 500 11px var(--font); fill: var(--c-muted); text-transform: uppercase; letter-spacing: .5px; }
.chart-legend { list-style: none; margin: 0; padding: 0; flex: 1; min-width: 160px; }
.chart-legend li { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-sm); }
.chart-legend .dot { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.chart-legend .lg-label { color: var(--c-text); }
/* LOGICAL, NOT PHYSICAL. The auto margin is what pushes the count away from the label.
   .chart-legend li is display:flex, so in Arabic the main axis runs right-to-left and a
   LEFT margin is the item's END side: the auto margin then collects the free space the
   value already sits next to instead of pushing it across, and the value column collapses
   onto thirteen different-length labels. Identical rendering in LTR. */
.chart-legend .lg-val { margin-inline-start: auto; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--c-muted); }
/* 300, NOT 220 — THE CAP WAS LETTERBOXING THE SIX-MONTH CHART INTO TWO THIRDS OF ITS CARD.
   groupedBars() emits viewBox="0 0 336 200" width="100%" preserveAspectRatio="xMidYMid meet",
   so the drawing fills its box only while the box keeps 336:200. At the dash-grid's 490px
   column the intrinsic height is 490 * 200/336 = 291.7px; a 220px cap made the box 490x220,
   meet scaled by min(490/336, 220/200) = 1.1, and the drawing rendered 370px wide — dead card
   either side plus a blank band beneath. At 300 the cap stops binding there.
   MEASURED, NOT JUST DERIVED (2026-10-12). No 336-viewBox chart renders on this instance —
   /admin/dashboard has no documents, so the throughput card takes the .chart-empty path — so
   the RULE was measured instead of waiting for the content: a synthetic
   <svg class="chart-bars" viewBox="0 0 336 200"> injected into a real .dash-grid card,
   read under both caps and then removed. At 1440 the box is 490px and the intrinsic height
   292px; under the old cap the drawing came out 370px wide with 60px of dead card EACH SIDE
   (not the 75 the finding claimed — (490-370)/2 = 60), and under 300 it comes out 490px with
   none. 120px of drawing width recovered.
   AND THE CAP IS STILL LOAD-BEARING, which is why it was raised and not deleted: at an 800px
   viewport .dash-grid is one column, the box is 718px, the intrinsic height is 427px, and 300
   still binds. A chart 427px tall on a narrow screen is the thing the cap exists to prevent;
   the 107px of dead margin each side there is the accepted price of that.
   The 14-day charts have a 784-unit viewBox and never reached 220, so they do not move. */
.chart-bars { display: block; height: auto; max-height: 300px; overflow: visible; }

/* AND THE OTHER HALF OF THE SAME COMPLAINT, which is a different chart. A 784-viewBox chart
   is ~125px tall at that column and the cap above never applied to it; its card is stretched
   by .dash-grid (a grid at the default align-items:stretch) to whatever its neighbour needs
   — 190px of donut on /admin/audit — and every pixel of the leftover fell BELOW the x-axis
   labels, so the card read as cut off beside a full one.
   AUTO MARGINS ON THE BLOCK AXIS, not flex:1 + min-height:0. An auto margin absorbs free
   space when there is some and computes to ZERO when there is none, so a card that is the
   taller of its pair — and every card once .dash-grid collapses to one column — renders
   exactly as it does today. flex:1 with min-height:0 would have made the SVG's contribution
   to an UNSTRETCHED card's height indeterminate, which is a chart that collapses on a phone.
   Only matches where groupedBars actually drew: its no-data path returns .chart-empty, which
   :has(> .chart-bars) does not select, so an empty card stays a plain block. */
.card:has(> .chart-bars) { display: flex; flex-direction: column; }
.card:has(> .chart-bars) > .chart-bars { margin-block: auto; }
.chart-donut-track { stroke: var(--c-surface-2); }
.chart-axis { stroke: var(--c-border); }
.chart-xlab { font: 500 11px var(--font); fill: var(--c-muted); }
.chart-blab { font: 600 10px var(--font); fill: var(--c-muted); }
/* THOSE TWO SIZES ARE USER UNITS, NOT CSS PIXELS, AND THE MULTIPLIER IS PER CALLER.
   Charts::groupedBars() writes viewBox="0 0 {max(320, n*56)} 200" and renders the <svg>
   at width:100% with preserveAspectRatio="xMidYMid meet", so every label is scaled by
   (rendered width / viewBox width) -- and n is not the same on every page.
     14 days  (/owner/error-pages, /admin/audit): viewBox 784 in a 490px half-width
              dash-grid card, scale 0.625, so 11px/10px arrive as 6.88px/6.25px. The
              smallest step in the system, --fs-2xs, is 11.50px: these are barely half of
              it and are genuinely hard to read at 1:1.
     6 months (partials/dashboard_analytics, on /admin/dashboard, /admin/reports and /app):
              viewBox 336 in the SAME 490px card, scale 1.4583, so the identical
              declarations already arrive at 16.04px/14.58px -- larger than --fs-base.
   Raising the base would therefore ship 27px month labels with OVERLAPPING bar figures on
   three dashboards (measured: adjacent Created/Completed numbers collide by 3.9px). So the
   correction is scoped to the 784-unit shape: 18.4 * 0.625 = 11.50px, exactly --fs-2xs.
   A computed probe cannot see any of this -- getComputedStyle reads 11px and 10px.
   THE DURABLE FIX is for Charts::groupedBars() to derive the size from $w. Until it does,
   this selector stops matching if the range ever leaves 14 days, and those two charts fall
   silently back to 6.88px. */
svg.chart-bars[viewBox^="0 0 784 "] .chart-xlab,
svg.chart-bars[viewBox^="0 0 784 "] .chart-blab { font-size: 18.4px; }
.chart-key { display: flex; gap: 14px; font-size: var(--fs-xs); color: var(--c-muted); }
/* margin-inline-end: the swatch sits before its word, and in Arabic before is the
   right-hand side, so a physical margin-right opened the gap on the wrong side of the
   swatch and jammed it against the word it labels. Identical rendering in LTR. */
.chart-key i { display: inline-block; width: 10px; height: 10px; border-radius: 3px; margin-inline-end: 5px; vertical-align: middle; }
.chart-empty { padding: 40px; text-align: center; color: var(--c-muted); font-size: var(--fs-sm); }

.hbars { display: flex; flex-direction: column; gap: 10px; margin-top: 4px; }
/* THE LABEL TRACK IS A SHARE, NOT A FIXED 96. hBars()'s labels are email addresses on
   /admin/audit — six of the seven in "Most active actors" were ellipsed mid-address, beside a
   track over 90% empty, and hBars emits no title= so the clipped text was unrecoverable.
   minmax() keeps the 96px floor for a phone-width card and lets the label take 42% where
   there is room (~206px on a half-width .dash-grid card at 1440, leaving ~230px of track).
   A PERCENTAGE AND NOT max-content: each .hbar-row is its OWN grid, so a content-sized track
   would start every row's bar at a different x and the chart would stop being comparable. A
   percentage resolves against the row's own width, which is identical for every row in the
   card, so one axis is preserved. (fit-content() degenerates here anyway — .hbar-label is
   white-space:nowrap, so its min-content equals its max-content and the cap never binds.)
   REJECTED THE FLAT 128px: it still clips an email address, which is the actual content.
   COST: charts whose labels are genuinely short — the aging buckets, the status codes — now
   have a wider gap before the bar starts. hBars is a relative scale, so the comparison those
   charts exist for is untouched. */
.hbar-row { display: grid; grid-template-columns: minmax(96px, 42%) 1fr 34px; align-items: center; gap: 10px; }
.hbar-label { font-size: var(--fs-sm); color: var(--c-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hbar-track { height: 12px; background: var(--c-surface-2); border-radius: 999px; overflow: hidden; }
.hbar-fill { display: block; height: 100%; border-radius: 999px; min-width: 2px; transition: width .3s; }
/* A BUCKET THAT READS 0 PAINTS NOTHING. The min-width above is a floor so a bar whose
   percentage rounds down to nothing is still visible -- but Charts::hBars() rounds, and a
   row whose value IS zero gets that floor too, so /app/dashboard drew a coloured 2px tick
   beside four rows that each say 0. hBars() writes the width into the element's own style
   attribute and emits exactly `width:0%` for those rows; "width:10%" and "width:100%" do
   not contain that substring, so the attribute names the zero rows and only those. Same
   shape as the tr[style*="background"] arm the pinned action cell already uses, and (0,2,0)
   against the (0,1,0) above it, declared after, so it reaches.
   WHAT IT COSTS: a row whose real percentage rounds to 0 (a value under 1/200th of the
   largest) loses its floor too. Its number still states it, and on these four charts that
   needs a 200:1 spread. Reading the value in PHP is the cleaner cut; this is the arm that
   fits in the stylesheet. */
.hbar-fill[style*="width:0%"] { min-width: 0; }
/* text-align: end. .hbar-row is a grid, so its three tracks already mirror in Arabic; a
   physical right left the number pinned to the physical right edge of a track that had
   moved to the other side of the bar. Identical rendering in LTR. */
.hbar-val { font-size: var(--fs-sm); font-weight: 600; text-align: end; color: var(--c-muted); font-variant-numeric: tabular-nums; }

.quick-stats { display: flex; gap: 8px; flex-wrap: wrap; }
.quick-stat { padding: 7px 13px; border: 1px solid var(--c-border); border-radius: 999px; background: var(--c-surface); font-size: var(--fs-sm); color: var(--c-muted); }
.quick-stat:hover { text-decoration: none; border-color: color-mix(in srgb, var(--c-primary) 40%, var(--c-border)); }
.quick-stat strong { color: var(--c-text); }

/* ---------- Empty states ---------- */
.empty { text-align: center; padding: 26px 16px; color: var(--c-muted); }
.empty-ic { display: inline-flex; padding: 14px; border-radius: var(--radius-md); background: var(--c-surface-2); color: var(--c-muted); margin-bottom: 10px; }
.empty-ic svg { width: 26px; height: 26px; }
.empty p { margin: 0 auto; font-size: var(--fs-base); }
.empty-cell { padding: 8px !important; }

/* ---------- Accessibility: honour reduced-motion ---------- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
}
.card-header { flex-wrap: wrap; }

/* ---------- System health strip ---------- */
.health-strip { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex-wrap: wrap; padding: 14px 18px; border-radius: var(--radius); border: 1px solid var(--c-border); background: var(--c-surface); margin-bottom: 18px; border-inline-start: 4px solid var(--c-muted); }
.health-strip.health-ok   { border-inline-start-color: var(--c-success); }
.health-strip.health-warn { border-inline-start-color: var(--c-warning); }
.health-strip.health-down { border-inline-start-color: var(--c-danger); background: color-mix(in srgb, var(--c-danger) 4%, var(--c-surface)); }
.health-main { display: flex; align-items: center; gap: 13px; }
.health-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--c-muted); flex: 0 0 auto; box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-muted) 18%, transparent); }
.health-ok .health-dot   { background: var(--c-success); box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-success) 18%, transparent); }
.health-warn .health-dot { background: var(--c-warning); box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-warning) 18%, transparent); }
.health-down .health-dot { background: var(--c-danger); box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-danger) 18%, transparent); animation: healthPulse 1.6s ease-in-out infinite; }
@keyframes healthPulse { 0%,100% { box-shadow: 0 0 0 4px color-mix(in srgb, var(--c-danger) 22%, transparent); } 50% { box-shadow: 0 0 0 8px color-mix(in srgb, var(--c-danger) 6%, transparent); } }
.health-title { font-weight: 700; font-size: var(--fs-base); }
.health-desc { font-size: var(--fs-sm); color: var(--c-muted); }
.health-metrics { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.hm { display: flex; flex-direction: column; line-height: 1.25; }
.hm-label { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .04em; color: var(--c-muted); font-weight: 600; }
.hm-val { font-size: var(--fs-lg); font-weight: 700; font-variant-numeric: tabular-nums; }
.hm-sub { font-size: var(--fs-2xs); color: var(--c-muted); }
.hm-alert .hm-val { color: var(--c-danger); }

/* ---------- Pager ---------- */
.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
.pager-info { font-size: var(--fs-sm); color: var(--c-muted); }
.pager-links { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.pager-gap { color: var(--c-muted); padding: 0 2px; }
/* Rows-per-page control. A bare <select> is width:100%/min-height:44px above, which
   would swallow the pager row — so it is sized to its content here, as .list-toolbar
   does for the filter selects. */
.pager-per { display: flex; align-items: center; gap: 8px; margin: 0; }
.pager-per label { display: flex; align-items: center; gap: 7px; margin: 0; font-weight: 500; font-size: var(--fs-sm); color: var(--c-muted); }
/* 34 was the third height in the footer row, beside the pager's own .btn-sm tokens. */
/* THE SAME TWO PHYSICAL FAULTS AS THE BASE RULE, on the rows-per-page control of six list
   pages. `padding: 5px 34px 5px 11px` is a 4-value shorthand — physical even though it
   contains neither "left" nor "right" — so in Arabic the 34px chevron gutter was on the
   leading side and the 11px text inset on the trailing one, with the chevron then painted
   over the text. The design pass named only the base `select`; this rule has it too. */
.pager-per select { width: auto; min-width: 74px; min-height: 40px; padding-block: 5px;
    padding-inline: 11px 34px; font-size: var(--fs-control-sm);
    background-position: var(--sel-chev) 10px center; }
.btn.is-disabled { opacity: .45; pointer-events: none; }

/* ---------- Shared pieces of the two PDF builders ----------
   .pdf-toolbar was defined ONLY inside app/document_build.php's private <style>, while
   admin/flow_template.php also used the class — so on that page it matched nothing and a
   seventeen-declaration inline copy was doing the work. .upz / .upload-zone were the same
   drop zone written twice. One definition each. */
.pdf-toolbar {
    display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
    background: var(--c-surface); border: 1px solid var(--c-border);
    border-radius: var(--radius); padding: 10px 12px; margin-bottom: 12px;
    position: sticky; top: 70px; z-index: 10;
}
.upload-zone, .upz {
    border: 2px dashed var(--c-border); border-radius: var(--radius);
    padding: 40px; text-align: center; background: var(--c-surface);
    transition: border-color .15s, background .15s;
}
.upload-zone:hover, .upz:hover { border-color: color-mix(in srgb, var(--ui) 40%, var(--c-border)); }
/* The zone LOOKED droppable -- a dashed border is the universal sign for it -- and was
   not: only the hidden <input type=file> behind the button accepted anything. The
   handlers are in builder.js; this is the feedback half, and without it a drop target
   that gives no sign it is armed reads as broken even when it works.
   .is-dragover is set on dragenter/dragover and cleared on dragleave/drop. */
.upload-zone.is-dragover, .upz.is-dragover {
    border-color: var(--ui); border-style: solid;
    background: var(--ui-soft);
}
/* Nothing inside the zone may swallow the drag events -- a child fires dragleave on the
   parent as the pointer crosses onto it, which makes the highlight flicker and, on some
   browsers, loses the drop. */
.upload-zone.is-dragover > *, .upz.is-dragover > * { pointer-events: none; }

/* A segmented control — Draw / Upload / Saved on the signing page. It was built inline in
   sign/sign.php, which is why its buttons sat under the touch floor on the one page that is
   mostly used on a phone. */
/* The PAINT here is right and stays — a --c-surface-2 track with a --c-surface active chip
   and --shadow-sm is exactly TailAdmin's pill-tab. The GEOMETRY was the problem: this file
   already has that pattern in .tabs, which wraps the track in a border, rounds it at
   --radius and rounds its items at calc(--radius - 4px) so the chip sits CONCENTRICALLY
   inside. .seg had no border and used --radius-sm for both track and item, so with 4px of
   padding the active chip's corners bulged against the track's. Same component, two shapes.
   The 4px padding is what makes (--radius - 4px) exact here. */
.seg { display: flex; gap: 6px; background: var(--c-surface-2); border: 1px solid var(--c-border); padding: 4px; border-radius: var(--radius); margin-bottom: 12px; }
.seg button {
    flex: 1; border: 0; background: transparent; padding: 8px; border-radius: calc(var(--radius) - 4px);
    font: inherit; font-weight: 600; color: inherit; cursor: pointer; min-height: 40px;
}
.seg button.active { background: var(--c-surface); box-shadow: var(--shadow-sm); }
@media (pointer: coarse) { .seg button { min-height: 44px; } }

/* Public verification: the status sub-line and the challenge input, both written inline in
   three places with three different pixel sizes. */
/* The invoice totals block. It was an unclassed <table> positioned with margin-left:auto —
   a physical property, so in Arabic the totals stayed pinned to the wrong edge of a
   document a customer is asked to pay. */
.invoice-totals {
    margin-block: 16px 0; margin-inline-start: auto;
    border-collapse: collapse; font-size: var(--fs-sm); min-width: 280px;
}

/* ---------- Page tabs ----------
   A group of related pages presented as one tabbed surface, so the Root sidebar can be ten
   entries instead of thirty-seven.

   THEY ARE LINKS, NOT ARIA TABS. Each one loads a page, so the markup is a <nav> of <a>
   with aria-current="page" — not role="tablist"/role="tab". Using the ARIA tab pattern
   here would promise a screen-reader user a panel that switches in place, and then move
   the whole page out from under them. This is navigation and it says so.

   The strip scrolls rather than wraps: a second row of tabs reads as a second group, and
   on a phone seven tabs would otherwise become three lines of chrome above the content. */
.tabs {
    display: flex; gap: 2px; align-items: stretch;
    margin: 0 0 18px; padding: 3px;
    background: var(--c-surface-2); border: 1px solid var(--c-border);
    border-radius: var(--radius);
    overflow-x: auto; overscroll-behavior-inline: contain;
    scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs > a {
    flex: 0 0 auto; display: inline-flex; align-items: center; gap: 7px;
    padding: 8px 14px; min-height: 38px; border-radius: calc(var(--radius) - 4px);
    font-size: var(--fs-sm); font-weight: 600; color: var(--c-muted);
    text-decoration: none; white-space: nowrap;
    transition: background .15s, color .15s;
}
.tabs > a:hover { background: var(--c-surface); color: var(--c-text); text-decoration: none; }
.tabs > a[aria-current="page"] {
    background: var(--c-surface); color: var(--c-text); box-shadow: var(--shadow-sm);
}
.tabs > a svg { width: 16px; height: 16px; flex: 0 0 auto; }
/* A count or status beside a tab's name. */
.tabs > a .tab-n {
    font-size: var(--fs-2xs); font-weight: 700; color: var(--c-muted);
    background: var(--c-surface-2); border-radius: 999px; padding: 1px 7px;
}
.tabs > a[aria-current="page"] .tab-n { background: var(--ui-soft); color: var(--ui-text); }
@media (pointer: coarse) { .tabs > a { min-height: 44px; } }

/* The context strip above a scoped group — "you are inside THIS workspace". Without it a
   tab row that changes what it edits depending on a query parameter is a trap. */
.tab-context {
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    margin: 0 0 12px; padding: 10px 14px;
    background: var(--ui-soft); border: 1px solid color-mix(in srgb, var(--ui) 25%, var(--c-surface));
    border-radius: var(--radius); color: var(--c-text);
}
.tab-context .tc-label { font-size: var(--fs-2xs); font-weight: 700; text-transform: uppercase; letter-spacing: .05em; color: var(--ui-text); }
.tab-context .tc-name { font-weight: 700; }
.tab-context .tc-spacer { flex: 1 1 auto; }

/* ---------- The bottom sheet ----------
   sign/sign.php redefined .modal in its own <style> to mean a bottom sheet — a DIFFERENT
   component wearing the name app.css already uses for a centred dialog. It won on that
   page only because a private <style> comes after the stylesheet, which is the kind of
   thing that holds until someone reorders a partial. It has its own name now. */
.sheet-back {
    position: fixed; inset: 0; background: rgba(2, 6, 23, .6);
    display: none; align-items: flex-end; justify-content: center; z-index: 50;
}
.sheet-back.open { display: flex; }
.sheet {
    background: var(--c-surface); width: 100%; max-width: var(--measure-tight);
    border-radius: 16px 16px 0 0; padding: 20px;
    padding-bottom: calc(20px + env(safe-area-inset-bottom));
}
/* Above phone width there is no bottom edge to rise from, so it becomes a dialog. */
@media (min-width: 600px) {
    .sheet-back { align-items: center; }
    .sheet { border-radius: var(--radius); padding-bottom: 20px; }
}

/* A logo shown for review against a surface that is NOT the page — a white plate for a
   light logo, an inverted one for the dark variant. Written inline as background:#fff in
   the workspace site editor, which previewed the DARK logo on white. */
.logo-preview {
    display: block; margin-bottom: 6px; padding: 3px;
    background: var(--c-page); border: 1px solid var(--c-border); border-radius: var(--radius-sm);
}
.logo-preview-dark { background: var(--c-sign-bg); }

.vs-sub { opacity: .92; font-size: var(--fs-sm); }
/* input.challenge-input, not .challenge-input: the base control rule matches via
   input:not([type]) / input[type=text], which is (0,1,1) and outranks a bare class. The
   element selector ties the specificity and wins on order. */
input.challenge-input { text-align: center; font-size: calc(var(--fs-control) * 1.35); letter-spacing: 2px; }

/* A small button is still a touch target. .btn-sm is 36px, which is fine for a mouse and
   under the 44px floor everything else on the platform respects. */
@media (pointer: coarse) { .btn-sm { min-height: 44px; padding-block: 11px; } }

/* A <select> beside the PDF toolbar. It used to wear .btn-sm — a BUTTON class on a select,
   which pinned it to 36px and below the touch floor. It is a control, so it takes the
   platform's control sizing and grows on a coarse pointer like every other one. */
.slot-select { width: auto; margin: 0; }

/* ---------- The PDF viewer: placing fields on a document ----------
   Two surfaces do exactly this job — app/document_build.php (.pdf-viewer/.pdf-page/
   .sig-field) and admin/flow_template.php (.tb-viewer/.t-page/.t-field) — and both wrote
   the same #e2e8f0 surround and the same #fff page into their own private <style>. Same
   component, two copies, neither reachable from the stylesheet and neither surviving dark
   mode. Defined once here; the two sets of class names are kept so the JS that references
   them does not have to change. */
.pdf-viewer, .tb-viewer {
    background: var(--c-pdf-surround); border-radius: var(--radius); padding: 14px; overflow: auto;
}
.pdf-viewer { padding: 16px; min-height: 300px; }
/* The PAGE is paper: white in dark mode too, because the PDF inside it is. */
.pdf-page, .t-page { position: relative; margin: 0 auto 16px; box-shadow: var(--shadow); background: var(--c-page); }
.t-page { margin-bottom: 14px; }
.pdf-page canvas, .t-page canvas { display: block; width: 100%; height: auto; }
/* flow_template's own half of that pair, moved out of its private <style>. The 6px radius it
   carried was off the scale and so sat out the shape change; the OFFSETS stay physical,
   because a field is positioned on a PDF and a PDF's coordinate space does not mirror. */
.tb { display: grid; grid-template-columns: 1fr 300px; gap: 18px; align-items: start; }
.t-field {
    position: absolute; border: 2px solid var(--c-primary);
    background: color-mix(in srgb, var(--c-primary) 14%, transparent);
    border-radius: var(--radius-sm); cursor: move; display: flex; align-items: center;
    justify-content: center; font-size: var(--fs-2xs); font-weight: 700; color: var(--c-primary);
    overflow: hidden; user-select: none; touch-action: none;
}
.t-field .d {
    position: absolute; top: -10px; right: -10px; width: 20px; height: 20px; border-radius: 50%;
    background: var(--c-danger); color: var(--c-danger-ink); border: 0; font-size: var(--fs-xs);
    cursor: pointer; display: grid; place-items: center;
}
.t-field .rz {
    position: absolute; right: -6px; bottom: -6px; width: 14px; height: 14px;
    border: 2px solid var(--c-primary); border-radius: 3px; cursor: nwse-resize;
}
@media (max-width: 820px) { .tb { grid-template-columns: 1fr; } }
/* document_build's half of the pair. Its 6px radius was off the scale, exactly as
   flow_template's was; the offsets stay physical for the same reason — a field is placed on a
   PDF, whose coordinate space does not mirror. The tiny type on the badges (9-11px) stays
   too: these labels sit INSIDE a field box drawn at the document's own scale, and growing
   them pushes them outside the thing they label. */
.sig-field {
    position: absolute; box-sizing: border-box; border: 2px solid var(--c-primary);
    background: color-mix(in srgb, var(--c-primary) 14%, transparent);
    border-radius: var(--radius-sm); cursor: move; display: flex; align-items: center;
    justify-content: center; font-size: var(--fs-2xs); font-weight: 700; color: var(--c-primary);
    overflow: hidden; user-select: none; touch-action: none;
}
.sig-field .fx-del {
    position: absolute; top: -10px; right: -10px; width: 22px; height: 22px; border-radius: 50%;
    background: var(--c-danger); color: var(--c-danger-ink); border: 0; font-size: 13px;
    line-height: 1; cursor: pointer; display: grid; place-items: center;
}
.sig-field .fx-resize {
    position: absolute; right: -7px; bottom: -7px; width: 16px; height: 16px;
    border: 2px solid var(--c-primary); border-radius: 4px; cursor: nwse-resize;
}
.sig-field.sticker-field {
    border-color: var(--c-field-sticker); color: var(--c-field-sticker);
    background: color-mix(in srgb, var(--c-field-sticker) 12%, transparent); border-style: dashed;
}
.sticker-field .sticker-body {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 1px; line-height: 1.15; text-align: center; padding: 2px; font-size: 11px;
}
.sticker-field .sticker-qr { font-size: 22px; line-height: 1; }
.sticker-field small { font-weight: 600; opacity: .65; font-size: 9px; }
.sticker-field .fx-resize { border-color: var(--c-field-sticker); }
/* text-align: start, not left — a text field on an Arabic document fills from the right. */
.sig-field.text-field .tf-body {
    display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
    line-height: 1.1; padding: 2px 5px; font-size: var(--fs-2xs); overflow: hidden;
    text-align: start; width: 100%; box-sizing: border-box;
}
.sig-field.text-field .tf-body small { font-weight: 600; opacity: .72; font-size: 9px; }
.sig-field .tf-req {
    position: absolute; top: -11px; left: -6px; height: 20px; padding: 0 8px;
    border-radius: 10px; background: var(--c-surface); border: 1.5px solid currentColor;
    color: inherit; font-size: 10px; font-weight: 800; cursor: pointer; display: grid;
    place-items: center; z-index: 3;
}
.sig-field .tf-edit {
    position: absolute; top: -11px; left: 46px; width: 22px; height: 20px; border-radius: 10px;
    background: var(--c-surface); border: 1.5px solid currentColor; color: inherit;
    font-size: 11px; cursor: pointer; display: grid; place-items: center; z-index: 3;
}
/* Step 1 shows the PDF as a plain viewer: anything already placed is visible but inert. */
.builder.wiz.wiz-view-only .sig-field { pointer-events: none; }
.builder.wiz.wiz-view-only .sig-field .fx-del,
.builder.wiz.wiz-view-only .sig-field .fx-resize { display: none; }
.sig-field .fx-resize, .t-field .rz { background: var(--c-page); }

/* A placed field's little controls are deliberately small — they sit on top of the
   document and must not hide it. Small is fine for a mouse and useless for a finger, so
   on a touch device the HIT AREA grows past the badge without the badge growing with it. */
@media (pointer: coarse) {
    .sig-field .fx-del, .sig-field .tf-req, .sig-field .tf-edit,
    .sig-field .fx-resize, .t-field .d, .t-field .rz { position: absolute; }
    .sig-field .fx-del::after, .sig-field .tf-req::after, .sig-field .tf-edit::after,
    .sig-field .fx-resize::after, .t-field .d::after, .t-field .rz::after {
        content: ""; position: absolute; inset: 50% auto auto 50%;
        width: 44px; height: 44px; transform: translate(-50%, -50%);
    }
}

/* ---------- The workspace switcher (client workspaces) ----------
   Lived in a private <style> inside partials/shell.php, which renders on EVERY
   authenticated page — so it was shipped platform-wide while being invisible to the
   stylesheet, and three of its colours (#8b90a0, #9aa0b0, #7c81d6) answered to nothing.
   Its type was 10px and 10.5px, below the scale and below comfortable reading. */
.wsswitch { padding: 8px 10px 10px; margin: 2px 8px 8px; border-bottom: 1px solid var(--side-line); }
.wsswitch-label {
    font-size: var(--fs-2xs); font-weight: 700; letter-spacing: .09em; text-transform: uppercase;
    color: var(--side-muted); margin: 0 0 6px 6px;
}
.wsswitch-item {
    display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-sm);
    text-decoration: none; color: var(--side-ink); margin-bottom: 2px;
}
.wsswitch-item:hover { background: var(--side-hover); }
.wsswitch-item.is-current { background: var(--ui-soft); color: var(--ui-text); }
.wsswitch-item .ic { display: flex; flex: 0 0 18px; }
.wsswitch-item .ic svg { width: 18px; height: 18px; }
.wsswitch-txt { display: flex; flex-direction: column; line-height: 1.15; min-width: 0; }
.wsswitch-name { font-size: var(--fs-sm); font-weight: 600; }
.wsswitch-sub { font-size: var(--fs-2xs); color: var(--side-muted); }
/* Derived from the chrome accent rather than frozen at #7c81d6, so the current workspace
   still reads as "the selected one" after Root changes the brand. */
/* NEUTRAL, not a gold mix. Mixing --ui (the pale SURFACE gold) toward the muted grey landed
   at 2.31 on the cream pill — the "Root control" line in the report that prompted this fix.
   --c-muted is 5.59 there and keeps the hierarchy: gold name, grey sub. */
.wsswitch-item.is-current .wsswitch-sub { color: var(--c-muted); }
.wsswitch-ext { opacity: .7; font-weight: 400; }

/* The sign-in workspace chooser. A <button> per workspace, because the choice IS the action —
   a radio list plus a confirm button would only add a click. Styled as a card row rather than
   .btn so it does not read as three competing primary actions; it sits on the auth card, which
   is --c-surface in both themes, so it takes the ordinary surface ink. */
.ws-pick {
    display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
    width: 100%; text-align: start; cursor: pointer; font: inherit;
    padding: 12px 14px; border-radius: var(--radius-sm);
    border: 1px solid var(--c-border); background: var(--c-surface); color: var(--c-text);
    min-height: 44px; transition: border-color .15s, background .15s;
}
.ws-pick:hover { border-color: var(--ui); background: var(--ui-soft); }
.ws-pick:focus-visible { outline: 2px solid var(--c-focus, var(--c-primary)); outline-offset: 2px; }
.ws-pick-name { font-weight: 700; font-size: var(--fs-base); }
.ws-pick-sub  { font-size: var(--fs-xs); color: var(--c-muted); }
/* On hover the row takes the pale gold tint, where --c-muted measures 4.6:1 — above AA, but
   checked rather than assumed, because --ui-soft is a SURFACE in light mode and inks that
   pass on white do not automatically pass on it. */
.ws-pick:hover .ws-pick-sub { color: var(--c-text); }

/* ---------- The signing frame ----------
   layouts/sign.php used to write this inline: body background, header background, header
   text colour and the caption colour, all hardcoded. That made the single page an external
   signatory sees the one page Root could not brand, and left its caption untranslated. */
/* The colour matters as much as the background: text drawn straight onto this frame needs an
   ink chosen FOR the frame, which is what --c-sign-ink is.
   (This comment used to say --c-sign-bg and --c-text were "BOTH #0f172a, so anything inheriting
   --c-text renders at 1:1". That stopped being true at the gold rebrand — --c-text is #3D3D3D
   now — and the real measured figure for app-chrome ink on this frame is 1.53, not 1:1 and
   no longer the 1.64 it was before the frame warm-shifted to #211E1A. Left
   uncorrected it sends the next reader after the wrong number.)

   THE LINE BELOW USED TO SAY "the cards inside set their own colours". They do not: .card
   sets a background and no color, and so do .sheet, .sign-summary, .sign-viewer, .sign-bar,
   .sig-opt, .seg and .sigpad-wrap. That assumption was the bug — see the block after this
   rule. Measured in a browser before the fix: 12 of 32 text elements on this plane failed. */
.sign-shell { background: var(--c-sign-bg); color: var(--c-sign-ink); min-height: 100vh; min-height: 100dvh; }

/* ---------- The signing plane has TWO grounds, and both were getting the other's ink ----

   Ground 1 — the dark PAGE. .sign-shell sets --c-sign-ink to suit it, correctly.
   Ground 2 — the LIGHT PANELS on top of it. Each paints itself --c-surface / --c-surface-2
   / --c-page and none of them reset the ink, so everything inside with no colour of its
   own — every heading, every <strong>, and .seg button, which is literally
   `color: inherit` — kept the page's near-white ink ON WHITE:

       document title in .sign-summary   1.23      "Add your signature" in .sheet   1.23
       "Already signed" in .card          1.23      Draw / Upload / Type             1.14

   The body text underneath read perfectly, because .muted carries its own colour. That is
   exactly why this reached a signatory as "some words are invisible" rather than as an
   obviously broken card, and why reading the stylesheet did not find it — only measuring
   getComputedStyle against the first ancestor that actually PAINTS a background did.

   The boundary fails the other way too, for the few things that genuinely sit on the dark
   page: the page pager uses .btn-ghost (--c-text) and .muted (--c-muted), both inks for a
   LIGHT surface — 1.64 and 2.88 on the navy.

   So: a panel restores the surface ink; the pager takes the page ink. Listing the panels
   by name is deliberate — there is no CSS selector for "an element that paints a
   background", so a new panel on this plane MUST be added here. */
.sign-shell .card,
.sign-shell .sheet,
.sign-shell .sign-summary,
.sign-shell .sign-viewer,
.sign-shell .sign-bar,
.sign-shell .sig-opt,
.sign-shell .seg { color: var(--c-text); }

/* PAPER IS NOT A THEME SURFACE. --c-page stays #ffffff in dark mode on purpose — a PDF is
   paper — so the signature pad and the rendered page must take --c-page-ink, not --c-text.
   Giving them --c-text with the panels above put #F2F1EF on white at 1.13 in dark mode;
   caught by measuring in dark before this shipped, not by reading it. */
.sign-shell .sigpad-wrap,
.sign-shell .s-page { color: var(--c-page-ink); }

/* The pager signing.js injects directly onto the dark page. --c-sign-ink/--c-sign-muted are
   not redeclared in either dark block, and do not need to be: they are already light. */
/* THIS PAIRS WITH .btn-ghost BECOMING A WHITE CHIP. This rule sets the ink and the border
   and has never set a background, so it used to inherit `transparent` — correct on the dark
   signing frame. The moment the base rule paints itself --c-surface, that white lands under
   this near-white ink at about 1.1:1, on the Prev/Next controls signing.js injects onto the
   frame. The background has to be stated here. */
.sign-shell .sign-nav .btn-ghost {
    background: transparent;
    color: var(--c-sign-ink);
    border-color: color-mix(in srgb, var(--c-sign-ink) 38%, transparent);
}
.sign-shell .sign-nav .btn-ghost:hover {
    background: color-mix(in srgb, var(--c-sign-ink) 14%, transparent);
}
.sign-shell .sign-nav .muted { color: var(--c-sign-muted); }
.sign-head {
    display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
    padding: 12px 18px; padding-top: calc(12px + env(safe-area-inset-top));
    background: var(--c-sign-bg); color: var(--c-sign-ink);
    border-bottom: 1px solid color-mix(in srgb, var(--c-sign-ink) 14%, transparent);
}
.sign-head .logo-mark {
    /* Same gold gradient as .btn-primary and .sidebar .brand .logo, which both already use
       --c-primary-ink. In dark --c-ui-2 goes LIGHTER (#E3C45F), so near-black holds there too. */
    flex: 0 0 auto; width: 32px; height: 32px; border-radius: var(--radius-sm); color: var(--c-primary-ink);
    background: linear-gradient(135deg, var(--ui), var(--ui-2));
    display: grid; place-items: center; font-weight: 800;
}
.sign-head .sign-note { color: var(--c-sign-muted); margin-inline-start: auto; }
@media (max-width: 460px) {
    /* The platform name and the caption cannot both fit on a narrow phone. The caption
       takes its own line rather than crushing the name it sits beside. */
    .sign-head .sign-note { margin-inline-start: 0; flex: 1 0 100%; }
}

/* ---------- Logo plate (guarantees a white/transparent logo is visible) ---------- */
.logo-plate { display: inline-flex; align-items: center; justify-content: center; background: var(--c-primary); border-radius: var(--radius-sm); padding: 6px 11px; }
.logo-plate-light { background: #fff; border: 1px solid var(--c-border); }

/* ── The brand lock-up ─────────────────────────────────────────────────────
   Sized HERE instead of inline in six separate views, and sized for the artwork people
   actually upload. A STACKED logo — mark above wordmark — is the common case, and at 32px
   tall its wordmark renders about 10.7px high across 39px of width: present in the DOM,
   unreadable on the screen. 44px puts the wordmark near 15px, which reads, and still clears
   the 69px header with room to spare. max-width is generous so a WIDE lock-up is limited by
   its height rather than cropped by a width that was guessed. */
.brand-logo { max-height: 52px; max-width: 200px; object-fit: contain; display: block; }

/* The real charge under a yearly plan's monthly headline. Quiet, but on the card rather than in
   a footnote — the figure the order will carry is not small print. */
.plan-billed { margin: 4px 0 0; font-size: var(--fs-sm); color: var(--c-muted); }
.plan-per { font-size: var(--fs-sm); color: var(--c-muted); margin-inline-start: 2px; }

/* ── The money acknowledgement ────────────────────────────────────────────
   The sentence a customer is least likely to read and most likely to be surprised by, so it is
   given room rather than shrunk into a field hint. Full contrast ink, not --c-muted: a term
   somebody is agreeing to is not secondary text. */
.ack-row { display: flex; gap: 10px; align-items: flex-start; text-align: start;
    margin: 0 0 12px; font-size: var(--fs-sm); line-height: 1.5; color: var(--c-text); }
.ack-row input[type="checkbox"] { flex: 0 0 auto; margin-top: 2px; }
.ack-row-sm { font-size: var(--fs-xs); margin: 8px 0; }
.ack-note { font-size: var(--fs-sm); line-height: 1.55; color: var(--c-muted); margin: 0 0 12px; }
.ack-note-block { margin-block: 12px; }
.ack-note-block p { margin: 0 0 8px; }
.ack-note-block p:last-child { margin-bottom: 0; }
.ack-details > summary { cursor: pointer; color: var(--c-muted); font-size: var(--fs-sm);
    margin-top: 14px; }

/* ── The two-column settings form ─────────────────────────────────────────
   A two-column grid that collapses to one on a narrow screen. Fields that hold a LIST rather
   than a value span both columns, because a row of checkboxes squeezed into half the width
   wraps into something nobody can scan.

   THE .dc-* NAMES ARE THE OLD ONES. This arrived as `.dc-grid`, for Root's discount codes — the
   first page that wanted it. It is now also the shape of the mail-transport form, and a class
   named after one feature that silently styles another is how a stylesheet stops being readable.
   The real names lead; the originals stay on the same declarations so that page is untouched, and
   can be dropped the next time it is edited for its own reasons. */
/* 24px ROW GAP, raised WITH the margin cancellation above and not separately. Until now the
   row rhythm in these forms was the grid's 14px PLUS .field + .field's uncancelled 16px on
   the right column — about 30px in practice, and staggered. Cancelling the margin alone
   would have dropped every row to 14px, which is not on the documented 4/8/12/16/24 scale.
   24 is on the scale and holds the ~30px feel these forms already have, so the visible
   change is "row one is straight now" rather than a reflow. */
.form-grid, .dc-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 18px; }
.form-wide, .dc-wide { grid-column: 1 / -1; }
.chip-row, .dc-checks { display: flex; flex-wrap: wrap; gap: 8px 18px; }
/* 24, THE SAME ROW GAP AS THE GRID THREE LINES ABOVE. This was 14 - the exact value the
   comment on that gap rejects by name as off the documented 4/8/12/16/24 scale. The gap was
   raised for the rows and the action row beneath them was left behind, so a form's real
   submit was the one element in the card off the rhythm: measured on /owner/discounts, 14px
   between the Notes box and "Create code" against 24 everywhere else in it. ONE view carries
   this class (owner/discounts.php) - the .dc-grid / .chip-row forms on admin/mail.php,
   owner/exchange.php and owner/microsoft.php do not - so the change is local to it. */
.dc-actions { display: flex; justify-content: flex-end; margin-top: 24px; }
/* BOTH NAMES. This arm was written when the class was only .dc-grid and never grew when
   .form-grid was added as the general name, so admin/mail.php, owner/exchange.php and
   owner/microsoft.php stayed TWO COLUMNS on a phone — against this project's PWA-first
   rule, and invisible to every desktop screenshot. Found while verifying the stagger above,
   not by looking for it. */
@media (max-width: 760px) { .form-grid, .dc-grid { grid-template-columns: 1fr; } }

/* Several one-button forms share an action cell. A form is block-level, so without this they
   stack and the row grows three rows tall. */
.inline-form { display: inline-block; }
.inline-form + .inline-form, .rowact .btn + .inline-form { margin-inline-start: 6px; }

/* ── The discount code box on the order review page ───────────────────────
   Sits under the money table and aligns with it, so the code and the figure it changes read
   as one block. Logical properties throughout: this page is public and renders RTL. */
.od-discount { margin: 14px 0 0 auto; max-width: 320px; }
.od-label { display: block; font-size: var(--fs-sm); color: var(--c-muted); margin-bottom: 6px; }
.od-field { display: flex; gap: 8px; align-items: center; }
.od-field input { flex: 1 1 auto; min-width: 0; text-transform: uppercase; letter-spacing: .06em; }
/* The applied state is a quiet confirmation, not a second call to action — the primary button
   on this page is the acceptance, and a loud green panel here competes with it. */
.od-applied { display: flex; gap: 8px; align-items: center; font-size: var(--fs-sm);
    padding: 8px 10px; border-radius: var(--radius-sm);
    background: color-mix(in srgb, var(--c-success) 8%, var(--c-surface));
    border: 1px solid color-mix(in srgb, var(--c-success) 25%, var(--c-surface)); }
.od-applied > span:nth-of-type(2) { flex: 1 1 auto; min-width: 0; }
.od-ok { color: var(--c-success); font-weight: 800; flex: 0 0 auto; }
.od-chip { display: inline-block; margin-inline-start: 6px; padding: 1px 7px;
    border-radius: 999px; font-size: var(--fs-xs); font-weight: 700; letter-spacing: .04em;
    background: var(--ui-soft); color: var(--ui-text); }
/* The sidebar rail is narrower than the public header, so the mark keeps a tighter WIDTH
   limit there — that is what protects a wide lock-up from the 264px rail. The height no longer
   differs: a stacked mark at 52px is 64px wide, which the rail has room for, and giving the
   two surfaces different heights only made the logo look smaller in the app than on the site
   people arrive from. */
.brand .brand-logo { max-height: 52px; max-width: 176px; }

/* A LOGO WITH NO DARK VARIANT IS LIGHT-MODE ARTWORK — dark ink on transparency. On the dark
   sidebar its wordmark measured 1.51:1, which is not "subtle", it is absent. Give it a light
   carrier until a dark logo exists. The two-variant classes are excluded deliberately: the dark
   one is already drawn for a dark ground. */
:root[data-theme="dark"] .brand-logo:not(.brand-logo-dark):not(.brand-logo-light) {
    background: #fff; border-radius: var(--radius-sm); padding: 4px 8px;
}
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .brand-logo:not(.brand-logo-dark):not(.brand-logo-light) {
        background: #fff; border-radius: var(--radius-sm); padding: 4px 8px;
    }
}

/* THE PLATE BEHIND THE PUBLIC HEADER'S LOGO. It painted var(--c-primary) — brand gold — under
   artwork whose nib and flourish are gold, so the mark's own strokes had nothing to sit on.
   A surface plate gives any uploaded logo a neutral ground in both themes. Scoped to the public
   header on purpose: `.logo-plate` elsewhere is the branding screen's swatch, and showing the
   logo ON the brand colour is exactly that swatch's job. */
/* THEME-AWARE AGAIN, and the reason is the whole point: a DARK-MODE LOGO NOW EXISTS, so
   the correct variant renders per theme and the plate must follow the page rather than
   carry light artwork. It was briefly hard-coded #fff because there was no dark variant
   and the light one's dark wordmark measured 1.51:1 on a dark plate; generating the dark
   logo is what made that crutch wrong — on a white plate the dark logo's LIGHT wordmark
   would have been just as invisible, the same bug mirrored. */
/* AND THEN IT HAD NOTHING LEFT TO DO. Painting --c-surface here paints the header its own
   colour — .pub-header is --c-surface too — so all the plate still contributed was a 1px
   outline and 22x12px of padding around a mark that was already small. A box drawn around a
   logo, in the logo background colour, reads as a box. The carrier that genuinely matters is
   the one on .brand-logo below, which only fires for a logo with NO dark variant, on a dark
   page — the case the plate was invented for. */
.pub-header .logo-plate { background: transparent; border: 0; padding: 0; }
/* The header's verify link is a DUPLICATE of two other entry points (footer -> Product,
   and the landing page's own "How verification works" section). Below 640px the nav
   already wraps to a second row at 271px wide; the signup button would force a third and
   the chrome would take 122px of a phone's first screen. So the duplicate goes, and only
   here — at every width above this, all five items sit on one line. */
@media (max-width: 640px) { .pub-nav-verify { display: none; } }
/* Dark-mode logo swap. Both <img> render only when a dark logo is uploaded (a lone
   default logo stays class-less, so these rules never hide it). Mirrors the theme-toggle
   icon idiom exactly: default here, explicit-dark below, system-dark inside the @media. */
.brand-logo-dark { display: none; }

/* ---------- Landing: inline document validator ---------- */
.validator { max-width: 680px; margin: 6px auto 0; background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 24px; text-align: left; }
.validator-head { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 16px; }
/* --ui-text, not --c-primary: the surface gold on its own 12% tint is 1.93:1. */
/* 13px was a literal between the two radius steps; --radius-md is the step. */
.validator-ic { flex: 0 0 auto; width: 48px; height: 48px; border-radius: var(--radius-md); display: grid; place-items: center; background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface)); color: var(--ui-text); }
.validator-head h2 { font-size: var(--fs-lg); margin: 0 0 3px; }
.validator-form { display: flex; gap: 10px; }
.validator-form input { flex: 1; min-height: 50px; font-size: var(--fs-control); }
.validator-form .btn { min-height: 50px; padding-left: 24px; padding-right: 24px; }
@media (max-width: 560px) { .validator-form { flex-direction: column; } }

.vresult { display: none; }
.vresult.show { display: block; margin-top: 18px; }
.vres-loading { display: flex; align-items: center; gap: 10px; color: var(--c-muted); font-size: var(--fs-base); padding: 8px 2px; }
.spinner { width: 18px; height: 18px; border: 2.5px solid var(--c-border); border-top-color: var(--c-primary); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.vres { border: 1px solid var(--c-border); border-radius: var(--radius-sm); overflow: hidden; }
.vres-head { display: flex; align-items: center; gap: 14px; padding: 16px 18px; color: #fff; }
.vres-head.vs-valid { background: var(--c-success); }
.vres-head.vs-void, .vres-head.vs-invalid { background: var(--c-danger); }
.vres-head.vs-expired, .vres-head.vs-superseded { background: var(--c-warning); }
.vres-head.vs-notfound { background: var(--c-muted); }
.vres-ic { flex: 0 0 auto; width: 40px; height: 40px; border-radius: 50%; background: rgba(255,255,255,.22); display: grid; place-items: center; font-size: var(--fs-lg); font-weight: 700; }
.vres-title { font-weight: 700; font-size: var(--fs-base); }
.vres-desc { font-size: var(--fs-sm); opacity: .95; }
.vres-body { padding: 16px 18px; }
.vres-kv { display: grid; grid-template-columns: 120px 1fr; gap: 7px 14px; margin: 0; font-size: var(--fs-sm); }
.vres-kv dt { color: var(--c-muted); font-weight: 600; }
.vres-kv dd { margin: 0; word-break: break-word; }
.vres-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 15px; }

/* ---------- Landing: how-it-works ---------- */
/* The six accent chips (.i-blue … .i-rose) were removed here together with $pal in
   public/landing.php. They only ever worked in light: the dark override at
   `:root[data-theme="dark"] .feature .ic` is (0,4,0) against their (0,3,0) and it
   restates `color` without `background`, so each chip became a gold glyph on a
   blue/cyan/green/violet/amber/rose tile. Keeping the hues in dark instead was measured
   and is worse: #2563eb on its own 12% tint over --c-surface is 2.87:1. The base
   `.feature .ic` rule above is theme-correct in both modes and is what now applies. */
.how { padding: 20px 0 80px; }
.how-title { text-align: center; font-size: var(--fs-xl); margin-bottom: 28px; }
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 760px) { .steps { grid-template-columns: 1fr; } }
.step { position: relative; padding: 22px 20px; background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius); box-shadow: var(--shadow-sm); }
.step-n { display: inline-grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--c-primary); color: var(--c-primary-ink); font-weight: 700; margin-bottom: 12px; }
.step h3 { font-size: var(--fs-base); margin: 0 0 6px; }
.step p { margin: 0; font-size: var(--fs-sm); }

/* ---------- Announcement top bar ---------- */
.ann-bars { display: flex; flex-direction: column; }
.ann-bar { display: flex; align-items: center; gap: 12px; padding: 9px 16px; font-size: var(--fs-sm); border-bottom: 1px solid rgba(0,0,0,.06); }
.ann-bar .ann-in { flex: 1; min-width: 0; display: flex; gap: 8px; flex-wrap: wrap; align-items: baseline; }
.ann-bar .ann-in strong { font-weight: 700; }
.ann-bar a { color: inherit; text-decoration: underline; font-weight: 600; }
.ann-bar .ann-x { border: 0; background: transparent; color: inherit; opacity: .6; cursor: pointer; font-size: var(--fs-base); line-height: 1; padding: 2px 6px; border-radius: 6px; }
.ann-bar .ann-x:hover { opacity: 1; background: rgba(0,0,0,.06); }
.ann-info { background: #eff6ff; color: #1e40af; }
.ann-success { background: #ecfdf5; color: #065f46; }
.ann-warning { background: #fffbeb; color: #92400e; }
.ann-danger { background: #fef2f2; color: #991b1b; }

/* Platform version chip next to the sidebar role */
.sidebar .userbox .role .ver { color: var(--c-muted); opacity: .85; }

/* Private signatory note field (builder) — visually distinct so it's noticed */
.signer-note { min-height: 38px; padding: 8px 11px; font-size: var(--fs-sm); background: #fffbeb; border-color: #fcd9a5; border-radius: var(--radius-sm); }
.signer-note:focus { border-color: var(--c-warning); box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-warning) 22%, transparent); }
.signer-note::placeholder { color: #b45309; opacity: .55; }
.signer-note-lab { font-size: var(--fs-2xs); color: var(--c-warning); font-weight: 600; margin-bottom: 4px; }

/* ---------- PWA install button ---------- */
.pwa-install-btn { position: fixed; right: 18px; bottom: 18px; z-index: 70; display: none; align-items: center; gap: 8px; background: var(--c-primary); color: var(--c-primary-ink); border: none; border-radius: 999px; padding: 12px 20px; font: inherit; font-weight: 600; box-shadow: var(--shadow-lg); cursor: pointer; }
.pwa-install-btn:hover { filter: brightness(1.06); }
.pwa-install-btn svg { flex: 0 0 auto; }
@media (max-width: 640px) { .pwa-install-btn { left: 16px; right: 16px; bottom: 78px; justify-content: center; } }

/* ---------- Install advisory banner ---------- */
.install-banner { position: fixed; left: 12px; right: 12px; bottom: 14px; z-index: 75; display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 12px 14px; animation: ibIn .25s ease; }
.install-banner[hidden] { display: none; }
.install-banner .ib-body { display: flex; align-items: center; gap: 12px; min-width: 0; }
.install-banner .ib-ic { font-size: var(--fs-xl); flex: 0 0 auto; }
.install-banner .ib-txt { display: flex; flex-direction: column; min-width: 0; }
.install-banner .ib-txt strong { font-size: var(--fs-base); }
.install-banner .ib-txt span { font-size: var(--fs-xs); color: var(--c-muted); }
.install-banner .ib-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.install-banner .ib-x { border: 0; background: transparent; color: var(--c-muted); font-size: var(--fs-xl); line-height: 1; cursor: pointer; padding: 4px 6px; border-radius: var(--radius-sm); }
.install-banner .ib-x:hover { background: var(--c-surface-2); color: var(--c-text); }
@keyframes ibIn { from { transform: translateY(20px); opacity: 0; } to { transform: none; opacity: 1; } }
@media (max-width: 460px) { .install-banner .ib-txt span { display: none; } }

/* ---------- list pages: one-line toolbar, sortable headers ---------- */
/* Search, filters and actions share a single horizontal row on every index page.
   The search box is the only element allowed to grow, so the row stays one line
   until the viewport genuinely cannot hold it. */
.list-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:nowrap;margin-bottom:14px;
  background:var(--c-surface);border:1px solid var(--c-border);border-radius: var(--radius);padding:10px 12px}
.list-toolbar>*{flex:0 0 auto;margin:0}
.list-toolbar .lt-search{position:relative;flex:1 1 260px;min-width:170px;display:flex;align-items:center}
.list-toolbar .lt-search svg{position:absolute;inset-inline-start:11px;width:16px;height:16px;color:var(--c-muted);pointer-events:none}
.list-toolbar .lt-search input{width:100%;padding-inline-start:34px;margin:0}
.list-toolbar select,.list-toolbar input[type=date]{margin:0;min-width:120px;max-width:210px;flex:0 1 auto}
/* ONE COMPACT HEIGHT IN THE FILTER STRIP TOO. The base control is 44px while the toolbar's
   own Apply is .btn-sm at 40, so every index page shipped a 44px field beside a 40px button.
   .tt-bar was harmonised and .pager-per select was harmonised; .list-toolbar never was.
   All THREE controls the partial can render are named here - input[type=date] comes from its
   $dates argument and would otherwise be the one left behind at 44 on the pages that use it,
   which is the three-heights-in-one-row defect this tier was written to kill.
   padding-block, never the shorthand: `.list-toolbar .lt-search input` sets
   padding-inline-start:34px to clear the magnifier glyph and a shorthand would wipe it. */
.list-toolbar input[type=search],
.list-toolbar select,
.list-toolbar input[type=date] { min-height: 40px; padding-block: 7px; }
@media (pointer: coarse) {
  .list-toolbar input[type=search],
  .list-toolbar select,
  .list-toolbar input[type=date] { min-height: 44px; padding-block: 11px; }
}
.list-toolbar .lt-date{display:flex;align-items:center;gap:6px;font-size: var(--fs-xs);color:var(--c-muted);white-space:nowrap}
.list-toolbar .lt-date input{min-width:140px}
.list-toolbar .lt-spacer{flex:1 1 auto}
/* THE TOOLBAR'S OWN CONTROLS ARE THE COMPACT TIER, exactly as .tt-bar's are at 706-710.
   partials/list_toolbar.php emits the submit as .btn-sm (40px, line 313) while the search
   box and the selects fall through to the base control rule at 363-368 (min-height:44px,
   padding:10px 16px at --fs-control = 45.25px). Measured on /owner/subscriptions at 1440:
   the gold Apply block runs y297-336 against y294-339 for the select beside it, inset 3px
   top and bottom, and the bar stands 71px -- taller than a 65px data row, for one search
   box. The sheet already argued this for the OTHER toolbar ('made the bar 72px'); but the
   fix at 706-707 names only .tt-bar/.tt-field, and .list-toolbar is the partial every
   index page actually renders.
   7px of block padding at --fs-control is 39.25px, which the 40px floor rounds up to
   exactly .btn-sm's height. Measured against the live sheet: bar 71.25 -> 66.00, all three
   controls 45.25 -> 40.00, Apply unchanged at 40.00.
   ELEMENT-QUALIFIED, or it never applies. The base arm for the search box is
   input[type=search] and an attribute selector weighs the same as a class, so that rule is
   (0,1,1) and a bare `.list-toolbar .input` at (0,1,0) would never reach -- the trap
   recorded at 698-702. (0,1,1) ties it and declaration order (here > 363) settles it; for a
   <select> the base is the bare element at (0,0,1) and this wins outright. Only the block
   axis is touched, so the select's --sel-chev inset and .lt-search's 34px icon gutter are
   both left alone (computed padding-inline and background-position verified unchanged).
   The coarse arm is MIN-HEIGHT ONLY -- the shape .input-sm uses at 704, NOT .tt-bar's extra
   padding-block:11px. Line 241 raises --fs-control to 16px on touch, so 11px computes to
   48.8px against .btn-sm's 44.925px at 1844: a WIDER gap than the 1.9px touch already has.
   The 44px floor alone lands on 44.00 and still clears the touch minimum.
   AND THE ROW-ACTIONS SELECT JOINS THE SAME TIER. /owner/tenants puts a <select> in a
   td.rowact beside a .btn-sm submit; it fell through to the base control rule in Forms and
   stood 45.25px against the 40px button next to it, the 40px filter controls in the toolbar
   welded above and the 40px pager select welded below - three heights in what reads as one
   card. It is the ONLY <select> in a td.rowact in the tree, so the arm costs one element.
   (0,2,3) against the base rule's (0,0,1), block axis only, so the --sel-chev inset and the
   chevron's mirror in Arabic are untouched - measured 40.00 in both directions. */
.list-toolbar input, .list-toolbar select, table.data td.rowact select { min-height: 40px; padding-block: 7px; }
@media (pointer: coarse) {
  .list-toolbar input, .list-toolbar select, table.data td.rowact select { min-height: 44px; }
}
th .sort-h{display:inline-flex;align-items:center;gap:4px;color:inherit;text-decoration:none;white-space:nowrap}
th .sort-h svg{width:13px;height:13px;flex:0 0 13px}
th .sort-h:hover{color:var(--c-muted)}
th .sort-h.is-active{color:var(--c-muted)}
/* The sort affordance, without the noise. An unsorted column's faded double
   chevron is revealed on hover or keyboard focus; the sorted column's single
   arrow is always visible, and stays the only indicator of which column is
   sorted. OPACITY only — the header's COLOUR is identical on every column in
   every state, which is the platform's table-title rule, and the svg keeps its
   13px box so revealing it shifts nothing. Where there is no hover to give
   (touch), the chevrons stay visible: a hidden affordance that can never be
   revealed is worse than a busy one. */
th .sort-h svg{opacity:0;transition:opacity .15s ease}
th .sort-h.is-active svg,
th .sort-h:hover svg,
th .sort-h:focus-visible svg{opacity:1}
@media (hover:none){th .sort-h svg{opacity:1}}
/* The sidebar eats ~330px, so the toolbar box is far narrower than the viewport:
   wrap at 1100px, not 900px, or three filters + search overflow the card. */
@media (max-width:1100px){
  .list-toolbar{flex-wrap:wrap}
  .list-toolbar .lt-search{flex:1 1 100%}
  .list-toolbar select,.list-toolbar input[type=date]{flex:1 1 140px;max-width:none}
}

/* ============================================================
   Theme toggle control (theme-agnostic — shown in sidebar + public header)
   ============================================================ */
.theme-toggle{
  /* ONE number drives the geometry. Every other figure is the source design's ratio of it:
     width 2h, knob h/2, inset (h - knob)/2, travel w - knob - 2 inset. Change --sw-h and the
     whole control scales without a single other number moving. */
  --sw-h: 30px;
  --sw-w: calc(var(--sw-h) * 2);
  --sw-knob: calc(var(--sw-h) / 2);
  --sw-pad: calc((var(--sw-h) - var(--sw-knob)) / 2);
  --sw-travel: calc(var(--sw-w) - var(--sw-knob) - var(--sw-pad) * 2);
  /* The knob travels toward the END of the line, so in RTL it must go the other way and the
     crescent's bite has to mirror with it. [dir="rtl"] and not :lang(ar) — this is a question
     about direction, not language — and the objection recorded further down this file does not
     apply: that was about `[dir="rtl"] *` at (0,1,0) losing to a two-class rule. This is (0,2,0)
     against (0,1,0) and wins. */
  --sw-dir: 1;
  /* DAY. A pale gold sky with a dark disc on it. The disc is INK and not gold on purpose: the
     brand gold is #D4AF37, which scores 1.9 against a pale ground, and the moving part of a
     control has to clear 3:1 against what it sits on. Gold on gold never will. */
  --sw-track: var(--ui-soft);
  --sw-knob-bg: var(--c-text);
  --sw-crescent: transparent;

  position:relative;display:inline-block;flex:0 0 auto;vertical-align:middle;
  width:var(--sw-w);height:var(--sw-h);padding:0;
  /* --c-muted, not --c-border: this is the edge of a CONTROL, and the pale track is 1.07
     against a white page — with nothing but a hairline there is nothing to see. */
  border:1px solid var(--c-muted);border-radius:999px;
  background:var(--sw-track);color:var(--c-text);cursor:pointer;
  transition:background-color .3s,border-color .3s}
[dir="rtl"] .theme-toggle{--sw-dir:-1}
.theme-toggle::before{
  content:"";position:absolute;top:50%;inset-inline-start:var(--sw-pad);
  width:var(--sw-knob);height:var(--sw-knob);border-radius:50%;
  background:var(--sw-knob-bg);
  /* THE CRESCENT IS THE MOON: an inset shadow in the sky's own colour, bitten out of one side
     of the disc. Transparent in light, which leaves the disc whole — a sun. */
  box-shadow:inset calc(var(--sw-knob) * .48 * var(--sw-dir)) calc(var(--sw-knob) * -.16) 0 0 var(--sw-crescent);
  transform:translate(calc(var(--sw-travel) * var(--sw-dir)), -50%);
  transition:transform .3s,background-color .3s,box-shadow .3s}
.theme-toggle:hover{border-color:var(--c-focus, var(--c-primary))}
/* A bare <button> is not matched by the global keyboard ring, which lists .btn, a, summary and
   [tabindex] — so this control states its own rather than leaving it to the browser default. */
.theme-toggle:focus-visible{outline:2px solid var(--c-focus, var(--c-primary));outline-offset:2px}
/* The glyphs are not drawn: the knob is the icon. They stay in the DOM for the forced-colors
   case, where the system replaces every colour and DROPS box-shadow — which would leave a bare
   pill saying nothing at all. There the pill becomes the icon button it used to be. */
.theme-toggle .ic-sun,.theme-toggle .ic-moon{display:none}
.theme-toggle svg{width:18px;height:18px}
@media (forced-colors: active) {
  .theme-toggle{width:var(--sw-h);border-radius:var(--radius-sm);display:inline-grid;place-items:center}
  .theme-toggle::before{display:none}
  .theme-toggle .ic-moon{display:inline-flex}
}
/* .userbox-theme is gone with the sidebar's theme row — the toggle moved to the topbar.
   Deleted rather than left behind: it was markup-only (one site, in partials/shell.php,
   removed in the same change), not one of the classes JavaScript adds at runtime, so
   nothing can bring it back. .theme-toggle itself STAYS — it is shared with
   layouts/public.php and was never sidebar-scoped. */

/* ============================================================
   Dark mode
   Token overrides on two triggers, both higher-specificity than the
   branding :root injected in <head> (specificity 0,2,0 > 0,1,0), so they
   win regardless of source order:
     • system default → @media(dark) :root:not([data-theme="light"])
     • explicit choice → :root[data-theme="dark"]
   Component rules already consume the tokens (surface / border / text /
   muted, and tints mixed against --c-surface which is #fff in light), so
   most of the UI re-themes by swapping tokens; the rules after the token
   blocks are the handful of bespoke fixes (hardcoded light values, and
   solid status fills that must stay dark enough for white text).
   ============================================================ */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --c-bg:#17161A; --c-surface:#201F24; --c-surface-2:#2A282F; --c-border:#3C3A44; --c-border-control:#4A4852; --c-rule:#302E37;
    --c-text:#F2F1EF; --c-muted:#B4B1AB; --c-input-bg:#1B1A1F;
    --c-success:#56C98A; --c-warning:#E3B94A; --c-danger:#F08A80; --c-info:#9BB4C2; --c-focus:#E3C45F; --c-violet:#a78bfa;
    /* THE BRAND GOLD NEEDS NO ADJUSTMENT ON DARK — #D4AF37 scores 8.56 on this page, the one
       colour in the set that is already right. It becomes ink here, and the gradient's second
       stop lightens instead of darkening so a gold button still reads as raised. */
    --c-ui:#D4AF37; --c-ui-2:#E3C45F; --ui-text:#E3C45F;
    --c-primary:#D4AF37; --c-primary-ink:#1A1A1A;
    /* Dark ink on the pale dark-mode danger fill, and a brighter signatory ramp so the
       field borders keep their separation against a dark card. */
    --c-danger-ink:#450a0a;
    --c-field-1:#60a5fa; --c-field-2:#22d3ee; --c-field-3:#a78bfa;
    --c-field-4:#e879f9; --c-field-5:#fb923c; --c-field-6:#2dd4bf;
    --c-field-7:#818cf8; --c-field-8:#fcd34d;
    --ui-soft:#332D18; --side-bg:#1A191D; --side-ink:#C9C6C0; --side-hover:#262429; --side-line:#34323B; --side-muted:#8E8B85;
    /* The builder surround follows the theme; the PAGE deliberately does not — a PDF is
       white whatever the reader has chosen, and its ink has to stay dark to be visible. */
    --c-pdf-surround:#2A282F; --c-sign-bg:#131215;
    --shadow-sm:0 1px 2px rgba(0,0,0,.4),0 1px 3px rgba(0,0,0,.5);
    --shadow:0 12px 34px -12px rgba(0,0,0,.6);
    --shadow-lg:0 26px 64px -20px rgba(0,0,0,.7);
    color-scheme:dark;
  }
}
:root[data-theme="dark"] {
  --c-bg:#17161A; --c-surface:#201F24; --c-surface-2:#2A282F; --c-border:#3C3A44; --c-border-control:#4A4852; --c-rule:#302E37;
  --c-text:#F2F1EF; --c-muted:#B4B1AB; --c-input-bg:#1B1A1F;
  --c-success:#56C98A; --c-warning:#E3B94A; --c-danger:#F08A80; --c-info:#9BB4C2; --c-focus:#E3C45F; --c-violet:#a78bfa;
  /* THE BRAND GOLD NEEDS NO ADJUSTMENT ON DARK — #D4AF37 scores 8.56 on this page, the one
     colour in the set that is already right. It becomes ink here, and the gradient's second
     stop lightens instead of darkening so a gold button still reads as raised. */
  --c-ui:#D4AF37; --c-ui-2:#E3C45F; --ui-text:#E3C45F;
  --c-primary:#D4AF37; --c-primary-ink:#1A1A1A;
    /* Dark ink on the pale dark-mode danger fill, and a brighter signatory ramp so the
       field borders keep their separation against a dark card. */
    --c-danger-ink:#450a0a;
    --c-field-1:#60a5fa; --c-field-2:#22d3ee; --c-field-3:#a78bfa;
    --c-field-4:#e879f9; --c-field-5:#fb923c; --c-field-6:#2dd4bf;
    --c-field-7:#818cf8; --c-field-8:#fcd34d;
  --ui-soft:#332D18; --side-bg:#1A191D; --side-ink:#C9C6C0; --side-hover:#262429; --side-line:#34323B; --side-muted:#8E8B85;
    /* The builder surround follows the theme; the PAGE deliberately does not — a PDF is
       white whatever the reader has chosen, and its ink has to stay dark to be visible. */
    --c-pdf-surround:#2A282F; --c-sign-bg:#131215;
  --shadow-sm:0 1px 2px rgba(0,0,0,.4),0 1px 3px rgba(0,0,0,.5);
  --shadow:0 12px 34px -12px rgba(0,0,0,.6);
  --shadow-lg:0 26px 64px -20px rgba(0,0,0,.7);
  color-scheme:dark;
}

/* --- Bespoke dark fixes: explicit-choice trigger --- */
:root[data-theme="dark"] .alert-success{color:#9BE3BC}
:root[data-theme="dark"] .alert-danger{color:#F6B8B1}
:root[data-theme="dark"] .alert-info{color:#C2D4DE}
:root[data-theme="dark"] .alert-warning{color:#EFD38B}
:root[data-theme="dark"] .badge:not([class*="badge-"]){background:color-mix(in srgb,var(--ui) 22%,var(--c-surface));color:#EBD79A}
:root[data-theme="dark"] .sidebar a.nav-link.active,
:root[data-theme="dark"] .fchip.is-on,
:root[data-theme="dark"] .fchip:hover,
:root[data-theme="dark"] .kpi-ic.tint-i,
:root[data-theme="dark"] .empty-ic.tint-i,
:root[data-theme="dark"] .wsswitch-item.is-current,
:root[data-theme="dark"] .mobile-nav a.active{color:#EBD79A}
:root[data-theme="dark"] a:not([class]){color:#E3C45F}
:root[data-theme="dark"] .feature .ic,
:root[data-theme="dark"] .validator-ic{color:#E3C45F}
:root[data-theme="dark"] .ann-info{background:#132a4d;color:#bfdbfe}
:root[data-theme="dark"] .ann-success{background:#0f2e22;color:#a7f3d0}
:root[data-theme="dark"] .ann-warning{background:#33260a;color:#fde68a}
:root[data-theme="dark"] .ann-danger{background:#3a1620;color:#fecaca}
:root[data-theme="dark"] .ann-bar{border-bottom-color:rgba(255,255,255,.08)}
:root[data-theme="dark"] .ann-bar .ann-x:hover{background:rgba(255,255,255,.08)}
:root[data-theme="dark"] .dz:hover{background:linear-gradient(180deg,var(--c-surface-2),#182136)}
/* tint-b/g/v/a/r had a dark override each; the color-mix rules above resolve in both
   themes from --c-surface, so ten literals and two blocks are gone. */
:root[data-theme="dark"] .signer-note{background:#2a2410;border-color:#5c4a1e;color:var(--c-text)}
:root[data-theme="dark"] .signer-note::placeholder{color:#d9b878;opacity:1}
:root[data-theme="dark"] .btn-danger{background:#dc2626}
:root[data-theme="dark"] .vs-valid{background:#15803d}
:root[data-theme="dark"] .vs-void,
:root[data-theme="dark"] .vs-invalid{background:#b91c1c}
:root[data-theme="dark"] .vs-expired,
:root[data-theme="dark"] .vs-superseded{background:#b45309}
:root[data-theme="dark"] .vs-notfound{background:#475569}
:root[data-theme="dark"] select,
:root[data-theme="dark"] .combo > input{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2397a6bf' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")}
:root[data-theme="dark"] .dz-ic{color:#EBD79A}
:root[data-theme="dark"] .sidebar{scrollbar-color:#2a3654 transparent}
:root[data-theme="dark"] .sidebar::-webkit-scrollbar-thumb{background:#2a3654}
/* NIGHT. The knob is the same colour as the track, so the only thing actually visible on it is
   the gold crescent — which is exactly what the source design does with its inset shadow. Gold
   is ink on dark here and scores 8.56 against this surface. */
:root[data-theme="dark"] .theme-toggle{--sw-track:var(--c-surface-2);--sw-knob-bg:var(--c-surface-2);--sw-crescent:var(--ui)}
:root[data-theme="dark"] .theme-toggle::before{transform:translate(0, -50%)}
:root[data-theme="dark"] .brand-logo-light{display:none}
:root[data-theme="dark"] .brand-logo-dark{display:inline-block}

/* --- Bespoke dark fixes: system-preference trigger (mirror of above) --- */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .alert-success{color:#9BE3BC}
  :root:not([data-theme="light"]) .alert-danger{color:#F6B8B1}
  :root:not([data-theme="light"]) .alert-info{color:#C2D4DE}
  :root:not([data-theme="light"]) .alert-warning{color:#EFD38B}
  :root:not([data-theme="light"]) .badge:not([class*="badge-"]){background:color-mix(in srgb,var(--ui) 22%,var(--c-surface));color:#EBD79A}
  :root:not([data-theme="light"]) .sidebar a.nav-link.active,
  :root:not([data-theme="light"]) .fchip.is-on,
  :root:not([data-theme="light"]) .fchip:hover,
  :root:not([data-theme="light"]) .kpi-ic.tint-i,
  :root:not([data-theme="light"]) .empty-ic.tint-i,
  :root:not([data-theme="light"]) .wsswitch-item.is-current,
  :root:not([data-theme="light"]) .mobile-nav a.active{color:#EBD79A}
  :root:not([data-theme="light"]) a:not([class]){color:#E3C45F}
  :root:not([data-theme="light"]) .feature .ic,
  :root:not([data-theme="light"]) .validator-ic{color:#E3C45F}
  :root:not([data-theme="light"]) .ann-info{background:#132a4d;color:#bfdbfe}
  :root:not([data-theme="light"]) .ann-success{background:#0f2e22;color:#a7f3d0}
  :root:not([data-theme="light"]) .ann-warning{background:#33260a;color:#fde68a}
  :root:not([data-theme="light"]) .ann-danger{background:#3a1620;color:#fecaca}
  :root:not([data-theme="light"]) .ann-bar{border-bottom-color:rgba(255,255,255,.08)}
  :root:not([data-theme="light"]) .ann-bar .ann-x:hover{background:rgba(255,255,255,.08)}
  :root:not([data-theme="light"]) .dz:hover{background:linear-gradient(180deg,var(--c-surface-2),#182136)}
  :root:not([data-theme="light"]) .signer-note{background:#2a2410;border-color:#5c4a1e;color:var(--c-text)}
  :root:not([data-theme="light"]) .signer-note::placeholder{color:#d9b878;opacity:1}
  :root:not([data-theme="light"]) .btn-danger{background:#dc2626}
  :root:not([data-theme="light"]) .vs-valid{background:#15803d}
  :root:not([data-theme="light"]) .vs-void,
  :root:not([data-theme="light"]) .vs-invalid{background:#b91c1c}
  :root:not([data-theme="light"]) .vs-expired,
  :root:not([data-theme="light"]) .vs-superseded{background:#b45309}
  :root:not([data-theme="light"]) .vs-notfound{background:#475569}
  :root:not([data-theme="light"]) select,
  :root:not([data-theme="light"]) .combo > input{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2397a6bf' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E")}
  :root:not([data-theme="light"]) .dz-ic{color:#EBD79A}
  :root:not([data-theme="light"]) .sidebar{scrollbar-color:#2a3654 transparent}
  :root:not([data-theme="light"]) .sidebar::-webkit-scrollbar-thumb{background:#2a3654}
  :root:not([data-theme="light"]) .theme-toggle{--sw-track:var(--c-surface-2);--sw-knob-bg:var(--c-surface-2);--sw-crescent:var(--ui)}
  :root:not([data-theme="light"]) .theme-toggle::before{transform:translate(0, -50%)}
  :root:not([data-theme="light"]) .brand-logo-light{display:none}
  :root:not([data-theme="light"]) .brand-logo-dark{display:inline-block}
}

/* ============================================================
   GLOBAL COVERAGE LAYER
   Added 2026-09-18 after auditing all 123 views against this stylesheet:
   every `class=` token and every element in the markup was diffed against
   every selector defined here. What follows is what that diff found with no
   rule anywhere — elements and classes that were rendering at the browser's
   default, and views that were compensating with hard-coded inline CSS.

   Everything below is token-driven, so it themes with the rest of the
   platform instead of having to be fixed twice. Nothing here changes a
   surface that was already rendering correctly: the element rules land only
   where there was no rule at all, and an inline style on a view still wins.
   ============================================================ */

/* ---------- The z-index scale, written down ----------
   These are the values already in use above, not new ones — they were simply
   never stated in one place, so the next fixed element had nothing to pick
   from. Use a token from here rather than inventing a number.
     topbar 20 < mobile-nav 40 < scrim 55 < sidebar (open) 60
     < pwa-install 70 < install-banner 75 < modal 80                       */
:root {
    --z-topbar: 20;
    --z-mobile-nav: 40;
    --z-scrim: 55;
    --z-drawer: 60;
    --z-float: 70;
    --z-banner: 75;
    --z-modal: 80;
}

/* ---------- Platform-wide text behaviour ---------- */
/* This platform prints ULIDs, document ids, API tokens, slugs and email
   addresses into ordinary prose and table cells. None of those contain a
   space, so without this one long value pushes its container wider than the
   viewport and the whole page scrolls sideways — the same defect the row
   actions hit on the subscriptions table, but reachable from any data. */
body { overflow-wrap: break-word; }
/* The grey flash Android paints over a tapped link; the app draws its own
   pressed and focus states, so the double feedback is just noise. */
html { -webkit-tap-highlight-color: transparent; }
::selection { background: color-mix(in srgb, var(--c-primary) 26%, transparent); }

/* ---------- Native elements that had no rule at all ----------
   Each of these was found in the markup with nothing styling it. Where a view
   already hard-coded the look inline, the values here are that same look, so
   the inline copy can be deleted without anything moving. */

/* Three of the four <hr> in the platform carried an inline border, two of them
   written as a literal rgba() that does not follow the dark-mode tokens. */
hr { border: 0; border-top: 1px solid var(--c-border); margin: 18px 0; }

/* Both <fieldset> call sites carried the identical inline string
   'border:1px solid var(--c-border);border-radius: var(--radius-sm);padding:12px 14px;margin:0'
   — one of them held in a PHP variable. This is that string, once. */
fieldset { border: 1px solid var(--c-border); border-radius: var(--radius-sm); padding: 12px 14px; margin: 0; }
legend { padding: 0 6px; font-size: var(--fs-sm); color: var(--c-muted); font-weight: 600; }

/* <summary> is a click target and was rendering with the default arrow cursor.
   The native disclosure marker is deliberately kept — removing it would be a
   visible change to the three <details> already shipping. */
summary { cursor: pointer; }/* NOT a summary that is dressed as a button. Three views use `<summary class="btn …">` as
   a disclosure control, and this rule is (0,1,1) against .btn-ghost's (0,1,0), so it
   repainted the button's ink on hover. */

summary:not(.btn):hover { color: var(--ui-text); }

/* A <dl> arrives with `margin: 1em 0` from the UA. .vres-kv zeroed it, .kv did not,
   and two views papered over the difference with an inline margin — so the same
   component had two spacings depending on which view drew it. */
dl { margin: 0 0 1em; }
/* Every <dl> on the platform is a .kv or .vres-kv grid that already zeroes this;
   the reset is here so the next one does not inherit the browser's 40px indent. */
dd { margin-inline-start: 0; }

/* Browsers draw an <optgroup> label in wildly different weights and colours. */
optgroup { font-weight: 700; color: var(--c-text); }

/* An embedded frame arrives with a 2px inset border in every browser. */
iframe { border: 0; }

/* A table caption inherits nothing and reads as stray text above the table. */
caption { text-align: start; font-size: var(--fs-sm); color: var(--c-muted); padding: 0 0 8px; }

/* Nineteen bare <code> sit inside prose — /healthz, ops_cron_runs, COUNT(*),
   CIDR ranges — with no separation from the sentence around them. The six that
   carry an inline colour (one-time passwords, recovery codes) keep it: an
   inline style still wins, and those are meant to stand out more than this. */
code {
    font-family: var(--font-mono); font-size: .92em;
    background: var(--c-surface-2); color: var(--c-text);
    border: 1px solid var(--c-border); border-radius: 5px;
    padding: .1em .38em;
}
pre {
    font-family: var(--font-mono); font-size: var(--fs-sm); line-height: 1.5;
    background: var(--c-surface-2); border: 1px solid var(--c-border);
    border-radius: var(--radius-sm); padding: 12px 14px; margin: 0 0 1em;
    overflow-x: auto;
}
/* A <code> inside a <pre> is the block's own text, not an inline chip. */
pre code { background: none; border: 0; padding: 0; font-size: inherit; }

/* Not yet used anywhere, and deliberately included: these are the text-level
   elements a new view reaches for next, and each one has a browser default
   that ignores the token system. Defining them here is what stops the next
   page from arriving with another inline style. */
kbd {
    font-family: var(--font-mono); font-size: .85em; padding: .15em .45em;
    background: var(--c-surface); color: var(--c-text);
    border: 1px solid var(--c-border); border-bottom-width: 2px; border-radius: 5px;
}
mark { background: color-mix(in srgb, var(--c-warning) 26%, var(--c-surface)); color: var(--c-text); padding: 0 .15em; border-radius: 3px; }
abbr[title] { text-decoration: underline dotted; cursor: help; text-underline-offset: 2px; }
blockquote { margin: 0 0 1em; padding: 2px 0 2px 14px; border-inline-start: 3px solid var(--c-border); color: var(--c-muted); }
figure { margin: 0 0 1em; }
figcaption { font-size: var(--fs-sm); color: var(--c-muted); margin-top: 6px; }
del { text-decoration: line-through; color: var(--c-muted); }
ins { text-decoration: none; background: color-mix(in srgb, var(--c-success) 16%, var(--c-surface)); }
/* accent-color alone does NOT colour this once a background is set — Chrome drops
   the native widget and falls back to its own green. The pseudo-elements are the
   only way to reach the track and the fill. */
progress { inline-size: 100%; block-size: 8px; border: 0; border-radius: 999px; -webkit-appearance: none; appearance: none; background: var(--c-surface-2); }
progress::-webkit-progress-bar { background: var(--c-surface-2); border-radius: 999px; }
progress::-webkit-progress-value { background: var(--c-primary); border-radius: 999px; }
progress::-moz-progress-bar { background: var(--c-primary); border-radius: 999px; }

/* ---------- Classes the markup uses that nothing defined ---------- */

/* Used by 21 views as `class="card card-pad"`. It resolved to nothing, so the
   padding those pages get is .card's own — which is exactly 22px. Stated here
   so the class is real rather than a silent no-op; no page moves by a pixel. */
/* The compact step. .card-pad is 22px and .card-pad-lg is 30px, with nothing below — so a
   small in-card panel wrote its own 14px/16px inset 15 times. */
.card-pad-sm { padding: 14px 16px; }
/* 24px, NOT 22px, and the two have to stay equal. --table-pad-x is 24px, so every
   child of a .card-table — its header, its .card-sect, its cells — insets at 24. A
   padded card at 22 put its text edge 2px inboard of the table card stacked directly
   below it, in every column in the product that mixes the two.
   CORRECTION to what this comment first said: .card DOES carry padding — var(--card-pad),
   24px, at line 316 — so .card-pad at 22 was overriding the card's own inset DOWNWARD,
   and a card wearing the class was tighter than one without it. At 24 the two agree and
   the class is now redundant for every use but one: owner/actions.php:169 puts .card-pad
   on a bare <p>, so the rule stays. */
.card-pad { padding: 24px; }

/* .stat.accent-muted moved into the stat-tile block with its five siblings — it set
   border-left, which is a DIFFERENT property from the block's --stat-accent, so left
   here it would have survived the rewrite and kept the physical bar on exactly the
   tiles the integration pages use most. */

/* ---------- Print ----------
   Only public/invoice.php had any print rules, scoped to itself. Every other
   page printed its sidebar, topbar, mobile nav, filter toolbar and every
   button onto the paper. A signing platform gets printed; this is the floor.
   invoice.php's own block is inline and uses !important, so it still wins. */
@media print {
    @page { margin: 14mm; }
    html, body { background: #fff !important; }
    /* DARK MODE DOES NOT END AT THE PRINTER. Forcing the paper white is only half of it:
       the TEXT tokens are still whatever the theme set, and browsers drop backgrounds for
       print while keeping colours — so a Root who prints a permit at night got #e6edf7 ink
       on white paper, which is a blank-looking page with a ghost on it. Restate the reading
       palette for paper. Status colours are deliberately NOT reset: a signed or rejected
       badge is information, not decoration, and it has to survive the printer. */
    :root, :root[data-theme="dark"], :root:not([data-theme="light"]) {
        --c-bg: #ffffff; --c-surface: #ffffff; --c-surface-2: #f8fafc;
        --c-text: #0f172a; --c-muted: #475569; --c-border: #cbd5e1;
        /* The frame's print values are restated here because paper is its own ground. The
           two INKS warm with the rest of the frame so the token keeps one meaning across
           screen and paper; 17.85 -> 16.60 and 7.58 -> 7.25 on white, both far above any
           bar. The surrounding --c-text / --c-muted / --c-border in this block are the
           general print palette and are still slate — a separate call, deliberately not
           made here. */
        --c-sign-bg: #ffffff; --c-sign-ink: #211E1A; --c-sign-muted: #5C564E;
        --c-pdf-surround: #ffffff; --side-muted: #475569;
    }
    /* Application chrome: navigation and controls are not part of a document.
       .sign-head is here because the signing frame is chrome too — without it, printing a
       signed permit laid a full-width near-black bar across the top of the paper. */
    .sidebar, .topbar, .mobile-nav, .scrim, .hamburger, .topbar-back, .sign-head,
    .pwa-install-btn, .install-banner, .ann-bar, .modal-backdrop, .tt-bar,
    .list-toolbar, .pager, .theme-toggle, .btn, [data-print-bar] { display: none !important; }
    /* The list card is welded to a toolbar above it and a pager below it, and both are
       hidden on this line — so the squared corners that make the join would print as a
       card with two flat edges and nothing against them. Restore the radius for paper.
       The logical longhands cover both ends in either direction. */
    .list-toolbar + .card, .list-toolbar + .tabletools,
    .card:has(table.data), .tabletools {
        border-start-start-radius: var(--radius); border-start-end-radius: var(--radius);
        border-end-start-radius: var(--radius);   border-end-end-radius: var(--radius);
        border-top: 1px solid var(--c-border);
    }
    /* .shell is a grid built around a sidebar that is no longer there. */
    .shell { display: block !important; }
    .content, .main, .container { padding: 0 !important; margin: 0 !important; max-width: none !important; }
    /* Lift is a screen idea; on paper it is a grey smudge. */
    .card, .table-wrap, .stat, .health-strip { box-shadow: none !important; }
    /* A table that breaks mid-row is unreadable; keep rows whole. */
    table.data { page-break-inside: auto; }
    table.data tr { page-break-inside: avoid; page-break-after: auto; }
    thead { display: table-header-group; }
    /* A phone-width card layout must not survive into a paper-width page. */
    table.data.data-cards, table.data.data-cards tbody, table.data.data-cards tr,
    table.data.data-cards td { display: revert !important; }
    table.data.data-cards thead { display: table-header-group !important; }
    table.data.data-cards td[data-label]::before { content: none !important; }
}

/* ---------- Table tools: client-side search, sort and paging ----------
   These lived in a <style> block inside admin/users.php, which meant the one view that
   opted into the enhancer was also the only view that could be styled by it. The script
   is now global and builds its own controls for any table that has none, so the rules
   have to be global too. Restated in platform tokens rather than the literals they were
   written with, and the sort affordance is the chevron idiom .sort-h already uses —
   opacity only, so revealing it shifts nothing, and the header's colour never changes
   between states, which is the platform's table-title rule. */
/* .tabletools, .tt-bar, .tt-field and the footer are all defined once, with THE
   STANDARD DATA-TABLE BLOCK further up. Only the sort affordance is left here. */
.tt-count { color: var(--c-muted); font-size: var(--fs-sm); white-space: nowrap; font-variant-numeric: tabular-nums; }

table.data th.tt-sortable { cursor: pointer; user-select: none; white-space: nowrap; }
table.data th.tt-sortable::after {
    content: ""; display: inline-block; width: 13px; height: 13px; margin-inline-start: 5px;
    vertical-align: -2px; opacity: 0; transition: opacity .15s ease;
    background: currentColor;
    -webkit-mask: var(--tt-chev) center/13px 13px no-repeat;
            mask: var(--tt-chev) center/13px 13px no-repeat;
}
/* The same three states as .sort-h: hidden until pointed at, always on once sorted. */
table.data th.tt-sortable:hover::after,
table.data th.tt-sortable:focus-visible::after,
table.data th.tt-asc::after,
table.data th.tt-desc::after { opacity: 1; }
@media (hover: none) { table.data th.tt-sortable::after { opacity: 1; } }

/* ON A TRAILING-ALIGNED COLUMN THE CARET MUST NOT PUSH THE TITLE INBOARD. The caret is
   opacity:0 until the header is pointed at, but it still occupies 13px plus a 5px start
   margin in the inline flow — and on th.num (text-align: end) those 18px land BETWEEN the
   label and the column's trailing edge, while td.num sits flush. So "Version" printed 17px
   left of its own digits on /owner/email, and "Rows" and "Size" 18px left of theirs on
   /owner/database, with the start-aligned columns beside them correct.
   The sheet already makes exactly this move twice — table.data th.num .sort-h, and
   th.ops-num .ops-sort, both with flex-direction: row-reverse. .tt-sortable was the one that
   was missed, and it is the affordance every tabletools table wears; a ::after cannot be
   reordered by flex, so the reservation is cancelled instead.
   13px box + 5px start margin - 18px end margin = zero net advance, so the label's trailing
   edge lands on the content edge with the figures, and the caret still draws into the cell's
   own 24px --table-pad-x, clear of the column boundary. The same 18px also leaves the
   column's intrinsic width, which is what had been sizing it.
   A NEGATIVE MARGIN AND NOT A FLEX CONTAINER: making the th display:flex would stop it being
   a table-cell and detach the header's own bottom rule — the defect this round found on
   <td class="ident">. Logical property, so Arabic needs nothing. (0,3,3) against the base
   rule's (0,2,3), so it wins wherever it is declared. */
table.data th.num.tt-sortable::after { margin-inline-end: -18px; }
:root {
    --tt-chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 9l4-4 4 4M8 15l4 4 4-4'/%3E%3C/svg%3E");
    --tt-up: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 14l5-5 5 5'/%3E%3C/svg%3E");
    --tt-down: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 10l5 5 5-5'/%3E%3C/svg%3E");
}
table.data th.tt-asc::after  { -webkit-mask-image: var(--tt-up);   mask-image: var(--tt-up); }
table.data th.tt-desc::after { -webkit-mask-image: var(--tt-down); mask-image: var(--tt-down); }

/* There is no separate client pager any more. tabletools.js renders the SAME
   <nav class="pager"> markup partials/list_pager.php does — .pager-info,
   .pager-links, .pager-per — so a JS-paged table and a server-paged table are
   the same footer, drawn by the same rules, and a change to one is a change to
   both. .tt-page / .tt-pager were the second implementation and are gone. */

/* ---------- Layout primitives ----------
   Views were reaching for inline display:flex / display:grid 119 times, because the
   vocabulary stopped at .row, .grid, .stack and .split-2. These are the shapes those
   119 were actually spelling out, named once. Logical properties throughout, so a
   mirrored page needs no per-view fix. */
.cluster { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }   /* buttons, chips */
.cluster-end { justify-content: flex-end; }
.cluster-between { justify-content: space-between; }
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid-auto { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
.grid-auto-sm { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
.span-all { grid-column: 1 / -1; }
/* A section is a titled block of a page; a block is one slab inside it. */
.section + .section { margin-top: 26px; }
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.section-head h2 { font-size: var(--fs-lg); font-weight: 600; margin: 0; }
.section-head .muted { font-size: var(--fs-sm); }
/* The circular icon plate several views draw by hand at 64px. */
.ic-plate { width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center; margin-inline: auto; flex: 0 0 auto; }
@media (max-width: 640px) { .grid-2 { grid-template-columns: 1fr; } }

/* ---------- Section measure ----------
   Page sections were being sized one at a time: 269 inline max-width declarations across
   35 distinct values, and 21 views set MORE THAN ONE section width on a single page —
   owner/security.php alone drew sections at 520, 620, 680 and 900px. That is the whole of
   the "sections come in different sizes" complaint, and it cannot be fixed a view at a time.

   Four steps, chosen from what the views were already asking for rather than invented, so
   the sweep moved most declarations by 100px or less:
     tight   620  one-purpose centred pages — sign-in, signup, a status result
     narrow  820  single-column settings and forms
     measure 1000 the default page section (the plurality today, 66 uses)
     wide    1200 dense tables that genuinely need the room

   Change a number here and every section on the platform moves with it. That is the point:
   a section's width is now a platform decision, not something each view answers again. */
:root {
    --measure-tight: 620px;
    --measure-narrow: 820px;
    --measure: 1000px;
    --measure-wide: 1200px;
}
.measure-tight  { max-width: var(--measure-tight);  margin-inline: auto; }
.measure-narrow { max-width: var(--measure-narrow); margin-inline: auto; }
.measure        { max-width: var(--measure);        margin-inline: auto; }
.measure-wide   { max-width: var(--measure-wide);   margin-inline: auto; }

/* THE PAGE AREA FILLS ITS COLUMN. This used to cap at the WIDE measure and centre itself,
   which kept a card the same width on a laptop and a 34-inch monitor — a real argument, and
   the reason the cap existed. The cost was two bands of empty page either side of every
   screen at desktop widths, which is what this change removes by request: the content area
   is now as wide as the space the sidebar leaves it.
   THE MEASURE RULES THAT MATTER ARE STILL IN PLACE and are the reason this is safe. Prose
   still wraps at a readable column: .measure-* cap the text blocks, .card .field > .input
   caps form controls at --measure-narrow, and tables were never measure-bound. What stretches
   is the CONTAINER, not the sentences inside it.
   width:100% is kept so the column still fills when a parent sizes it; only the cap and the
   auto side margins go. */
.content { width: 100%; }



/* An eyebrow — the small capitalised caption above a value or a section, on the
   invoice, the order review and the signup pages. It was written inline seventeen
   times, which put it out of reach of the stylesheet AND out of reach of the Arabic
   guard below, because an inline declaration beats both. Type treatment only: .muted
   and .small already carry the colour and the size at every call site. */
.eyebrow { text-transform: uppercase; letter-spacing: .08em; }
:lang(ar) .eyebrow { letter-spacing: normal; }

/* ============================================================
   LETTER-SPACING BREAKS ARABIC.

   Arabic is cursive: the letters of a word join, and their shapes change depending on
   what they join to. letter-spacing inserts a gap between every pair, which severs
   those joins — the word stops being a word and becomes a row of disconnected shapes
   that a reader has to reassemble. It is a Latin small-caps convention that does not
   survive the change of script, and it is invisible to anyone reviewing the English.

   Negative tracking is the mirror problem: it crowds the joins and lets a letter and
   its neighbour's diacritic overlap.

   WHY :lang(ar) AND NOT `[dir="rtl"] *`, AND NOT !important:
   `[dir="rtl"] *` has specificity (0,1,0) and loses outright to `.stat .l` (0,2,0) —
   it would look correct in the stylesheet and do nothing on the page. Prefixing the
   REAL selector with :lang(ar) adds exactly (0,1,0) to whatever that selector already
   weighs, so it always wins, and it stays visible next to the rule it corrects.
   !important would win too, and would also silently reach input.challenge-input below,
   whose 2px tracking is deliberate — it is a six-digit numeric code, never Arabic, and
   the spacing is what makes it readable as separate digits.

   Anything whose content cannot be Arabic is deliberately absent: the .mono cells, the
   stat and KPI numerals, the SVG chart labels, and the challenge input.
   ============================================================ */
:lang(ar) h1,
:lang(ar) h2,
:lang(ar) h3,
:lang(ar) h4,
:lang(ar) .badge,
:lang(ar) table.data th,
:lang(ar) table.data.data-cards td[data-label]::before,
:lang(ar) .stat .l,
:lang(ar) .stat .n,
:lang(ar) .kpi-lab,
:lang(ar) .kpi-val,
:lang(ar) .dz-txt .t,
:lang(ar) .empty-title,
:lang(ar) .hm-label,
:lang(ar) .tab-context .tc-label,
:lang(ar) .wsswitch-label,
:lang(ar) .tabs > a { letter-spacing: normal; }

/* Same reasoning, one level up: a value that is an Arabic word must never be broken
   mid-word to fit a narrow tile. A forced break leaves the fragment wearing a final
   letter form it has not earned, which reads as a spelling mistake rather than as a
   wrap. Let it overflow its line instead — the tile grows. */
:lang(ar) .stat .n { overflow-wrap: normal; }

/* ---------- ONE PAGE COLUMN ----------
   Top-level sections do NOT carry their own max-width any more. They fill .content,
   so the page header, the filter card, the table and the alert banners share one
   left and one right edge — on every page, not only within a page. Five widths were
   in use before (620 / 820 / 1000 / 1200 / 1248) and .page-head never carried one at
   all, which is why a page title could be visibly wider than the cards beneath it.

   A CONTROL does not widen with them. 1200px of text input is not more usable than
   820, it is less: the CARD is what lines up with the rest of the page, and the
   reading column lives inside it. This is a maximum, not a width, so a field already
   in a narrow column or a two-column grid is untouched.

   The measure tokens themselves are NOT deprecated — .measure/.measure-narrow are
   still right for a centred single-purpose page (signup, verify, an invoice). What
   is gone is a console page picking a width per section. */
.card .field > input,
.card .field > select,
.card .field > textarea,
.card .field > .input { max-width: var(--control-max, var(--measure-narrow)); }

/* --control-max is the ONE knob for the rule above. It is a custom property, so it
   INHERITS: a section opts its whole subtree in or out by setting it on a container,
   and no rule has to outrank another to do it. Beware the usual var() trap -- the
   fallback applies only while the property is UNSET, so never give --control-max a
   value on :root.

   .wiz-form / the builder side panel: the document wizard runs its cards at the full
   page column, so an 820px control left a ~300px dead gutter on every row -- while the
   searchable document-type combo escaped the cap entirely (its input is a GRANDCHILD of
   .field, and the rule above is a child selector), so one field lined up with the card
   edge and five did not. Fixed by widening the controls to the card, not by narrowing
   the card: .content already caps the page at --measure-wide, so nothing here can
   stretch across a large monitor -- which is what the per-control cap this replaces,
   written inline in document_build.php, was guarding against.

   .field-narrow: a field whose answer is two or three characters. A full-width box for
   "14" advertises that something long is expected; the box IS the affordance, so it is
   sized to the answer. It sits BELOW .wiz-form in the tree, so it wins there too
   without a specificity fight -- which is the whole reason this is a property. */
/* .fields-fill is the GENERAL opt-in; .wiz-form is the document wizard's own. They are
   listed together so there is one mechanism rather than a class per page — the next form
   that wants to fill its card adds the class, it does not add a rule. */
.fields-fill,
.wiz-form,
.builder.wiz .builder-side { --control-max: none; }
.field-narrow { --control-max: 140px; }

/* The combo's input is a grandchild of .field, so the child-combinator cap above never
   reached it. THIS WAS SCOPED TO THE WIZARD because the comment here said that was "the only
   page known to build one". That stopped being true when behaviours.js turned data-searchable
   into a platform component: six views build a combo now — app/document_new (the wizard) plus
   owner/departments, owner/licence_new, owner/numbering, owner/permit_types and owner/roots.
   TWO of those five were genuinely uncapped and are what this fixes: measured at 1440 after
   the change, /owner/licences/new and /owner/roots now stop at 820px like every other
   control, where before the cap could not reach them at all.
   THE OTHER THREE ARE WIDE ON PURPOSE AND MUST STAY THAT WAY. /owner/catalogue (969px),
   /owner/departments (969px) and /owner/numbering (971px) compute max-width:none because
   their own <form> carries .fields-fill, the general opt-in declared above. This rule does
   match them; it resolves to none through the knob, which is the mechanism working. Do not
   read a wide combo on those three as a regression — check for .fields-fill first.
   Generalised to .card, which is where the cap it mirrors already lives.
   THE OLD .wiz-form ARM IS GONE RATHER THAN KEPT ALONGSIDE: .wiz-form is one of the selectors
   setting --control-max:none above, so inside the wizard both arms evaluate to
   max-width:none and the arm existed only to produce no cap. Forms that are MEANT to fill
   their card still say so by setting that knob, not by outranking this rule. */
.card .field > .combo > input { max-width: var(--control-max, var(--measure-narrow)); }

/* The builder's side-panel controls sit in a .stack / .row, not in a .field, so the rule
   above never governed them -- document_build.php capped them inline instead. Named here
   so they read the same knob as everything else and the width lives in one file. */
.builder.wiz .builder-side .stack > input,
.builder.wiz .builder-side .stack > select,
.builder.wiz .builder-side .stack > textarea,
.builder.wiz .builder-side .row > input { max-width: var(--control-max, var(--measure-narrow)); }

/* One trailing-action row, used by BOTH "Add" buttons in the Participants step. They had
   been written twice -- one with a flex:1 spacer <span>, one with the button inline beside
   the last input -- which put two identical buttons 250px apart in cards of identical
   width. The spacer span is gone with it: justify-content does that job. */
.row-end { display: flex; justify-content: flex-end; align-items: center; gap: 8px; flex-wrap: wrap; }

/* ============================================================
   The breakpoint scale — PICK FROM THIS LIST, don't add to it.
   Ten distinct widths accumulated here because each component was
   given the width that happened to suit it. They are written down
   rather than merged: a custom property cannot be used inside
   @media, so a "scale" is documentation plus discipline and
   nothing else, and moving any of these shifts where a real
   component reflows without fixing any defect.

     460  install-banner text
     560  kpi-row -> 2 columns, validator-form
     600  drop-zone stacks                     (merge candidate: 640)
     640  pwa install button
     760  steps, row2                          (merge candidate: 780)
     780  table.data.data-cards -> phone cards
     820  backup-opts                          (merge candidate: 860)
     860  dash-grid, auth-wrap
     900  THE LAYOUT SWITCH — .shell drops the sidebar,
          mobile-nav and scrim appear. Treat as load-bearing.
     1100 kpi-row -> 3 columns, list-toolbar wraps
          (1100 not 900: the sidebar eats ~330px, so the toolbar
           box is far narrower than the viewport.)

   A new rule needing "about 800px" takes 780 or 820, not 800.
   ============================================================ */

/* ── Searchable select (combobox) ─────────────────────────────────────────────
   Enhancement over a real <select>, which stays in the DOM and keeps submitting --
   so with JavaScript off this never applies and the native control is what shows. */
.combo { position: relative; }
.combo > select { display: none; }
/* THE COMBO IS A DROPDOWN AND HAS TO LOOK LIKE ONE. The input that stands in for the
   <select> inherited only the base text-field rule, so on app/document_new.php the chooser
   was byte-identical to the free-text title box above it while the native select two fields
   below carried a chevron — three affordances in one card, and the dropdown was the one
   that looked like free text. Same declarations as `select` at the top of this file.
   NOT FOLDED INTO THAT SELECTOR: it also sets cursor:pointer, which is wrong on a box you
   type in. (0,1,1), the same weight as input[type=text] in the base control rule, and
   declared far below it, so padding-inline-end beats that rule's padding shorthand by order.
   THE CHEVRON COLOUR IS SWAPPED PER SELECTOR, NOT PER TOKEN, so this needs an arm in the
   RTL mirror beside `[dir="rtl"] select` AND in BOTH dark blocks — a copy that skips them
   keeps the light #64748b stroke on a dark field. All three are extended. */
.combo > input {
    --sel-chev: right;
    padding-inline-end: 40px;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat; background-position: var(--sel-chev) 16px center;
}
.combo-list {
    position: absolute; inset-inline: 0; top: calc(100% + 4px); z-index: 30;
    margin: 0; padding: 4px; list-style: none; max-height: 240px; overflow-y: auto;
    background: var(--c-surface); border: 1px solid var(--c-border);
    border-radius: var(--radius-sm); box-shadow: var(--shadow);
}
.combo-list[hidden] { display: none; }
.combo-opt { padding: 8px 10px; border-radius: 7px; cursor: pointer; font-size: 14px; color: var(--c-text); }
.combo-opt.is-none { color: var(--c-muted); }
/* --ui is the SURFACE gold and this put it on --ui-soft, which is pale gold: 1.90:1, the
   worst text pairing left in the file. --ui-text is the ink for a gold ground and is what
   every other selected/active state here already uses (.nav-link.active, .fchip.is-on,
   .wsswitch-item.is-current, .tabs [aria-current]). 4.68:1, and mirrored in both dark arms. */
.combo-opt:hover, .combo-opt.is-active { background: var(--ui-soft); color: var(--ui-text); }
.combo-empty { padding: 8px 10px; font-size: 13px; color: var(--c-muted); }

/* ── Sticky announcement bar ──────────────────────────────────────────────────
   The bar and the topbar stick together as one block, so the topbar never has to
   know how tall the bar is. .topbar carries its own position:sticky for the case
   where this wrapper is not present, so it is neutralised inside it -- two nested
   sticky elements with the same top would fight. */
.stickhead { position: sticky; top: 0; z-index: 20; background: var(--c-surface); }
.stickhead .topbar { position: static; z-index: auto; }

/* ── Active sidebar link ──────────────────────────────────────────────────────
   The pale tint alone did not read as "you are here" -- and on most pages it was not
   even shown, because the layouts defaulted the nav key to the Dashboard.

   currentColor, not var(--ui): the dark token block redeclares --ui-soft but NOT --ui,
   and a separate rule recolours this link to #EBD79A in dark. currentColor follows that;
   var(--ui) would stay light indigo on the dark tint.

   inset-inline-start and a symmetric radius, never border-left: Arabic is a first-class
   locale here and a physical-side border puts the marker on the wrong edge in RTL. */
.sidebar a.nav-link.active { font-weight: 700; position: relative; }
.sidebar a.nav-link.active::before {
    content: ""; position: absolute; inset-inline-start: 3px; top: 50%;
    transform: translateY(-50%); width: 3px; height: 58%;
    border-radius: 3px; background: currentColor;
}

/* The "New document" CTA is lifted out of the nav, so it needs its own current state
   or its page is the one page with nothing highlighted. */
.new-cta.is-current { outline: 2px solid var(--ui); outline-offset: 2px; }

/* Forced colours drop background and shadow; the marker would simply vanish. Same
   opt-out the stat-card accent already uses. */
@media (forced-colors: active) {
    .sidebar a.nav-link.active::before { forced-color-adjust: none; background: currentColor; }
    .sidebar a.nav-link.active { outline: 1px solid; }
}


/* ── Side-by-side fields, page rhythm, and the reveal disclosure ───────────────
   Added together because the first two are defects in rules that already exist
   here, not in any one page's markup. */

/* `.field + .field { margin-top: 16px }` above is right for a STACK. But two fields
   sitting side by side in a .row or a .grid are adjacent siblings too, and there that
   margin is not spacing -- it is a stagger: the second field alone drops ~16px, which
   is why "Accent color" sat lower than "Primary color" on /admin/branding and "Height"
   lower than "Width" on /admin/sticker. A flex or grid parent already supplies the gap
   on BOTH axes, so when those rows wrap on a narrow screen the vertical spacing is
   still there; this removes a duplicate, not the spacing. Child combinator on purpose:
   fields stacked inside a column of a .row keep their margin. */
.row > .field + .field,
.grid > .field + .field,
.row2 > .field + .field,
/* .form-grid AND .dc-grid WERE MISSING, and the symptom was specific: in a 2-column grid only
   field 2 has a preceding .field sibling, so .field + .field's 16px landed on the
   right-hand item of ROW ONE ONLY — its label as well as its box — and every row below sat
   level. Three forms shipped with a staggered first row. Child combinator like its
   neighbours, so a .field stacked inside a grid COLUMN keeps its margin. */
.form-grid > .field + .field,
.dc-grid > .field + .field { margin-top: 0; }

/* Page sections carry their own bottom rhythm -- .kpi-row and .dash-grid are both 18px
   -- but a bare .card used as a top-level section has none, so two cards in a row sat
   flush while every other pair on the page sat 18px apart. Visible between "Document
   activity" and "Top senders" on /admin/reports.
   A TOP margin on the second card, not a bottom margin on the first, so a page never
   ends with a trailing gap. Adjacent sibling margins collapse, so a card already
   carrying .mb-3 keeps its 24px instead of adding to it. */
/* One page rhythm: 24px between stacked cards, the same gutter the grids use. */
.content > .card + .card { margin-top: 24px; }

/* A disclosure whose <summary> IS the button. Native <details> rather than a JS toggle:
   it works with scripting off and brings its own keyboard and screen-reader semantics.
   The three marker resets between them remove the default triangle in every engine --
   a .btn with a disclosure arrow inside it reads as a dropdown that does not exist. */
.reveal > summary { list-style: none; cursor: pointer; }
.reveal > summary::-webkit-details-marker { display: none; }
.reveal > summary::marker { content: ""; }
.reveal[open] > summary { margin-bottom: 18px; }

/* A CLOSED DISCLOSURE IS NOT A PANEL. .card paints a surface, a hairline and 24px of inset;
   while the <details> is shut its only content is the summary button, so the card rendered as
   a ~90px empty box with ~950px of blank surface beside one button and read as a panel that
   had failed to load (/app/support at 1440, measured). The chrome returns the moment it opens,
   which is when there is a form to contain. (0,3,0) — :not([open]) weighs as an attribute —
   so it beats .card and .card-pad, both (0,1,0), and is declared below both. Reaches the four
   top-level card disclosures: app/support, owner/discounts, owner/exchange, owner/microsoft.
   The two chromeless .reveal sites (admin/mail, owner/tenant) carry no .card and do not move. */
.card.reveal:not([open]) { padding: 0; border: 0; background: transparent; }

/* AND THE OPEN STATE, which is the same card and the opposite problem. The summary and the
   form's submit were both .btn-primary, so an opened disclosure showed a gold button at the
   top and a second gold button 1,250px below it — and the loud one only folded the form away.
   Nothing above said OPEN: the three marker resets and the margin were the whole component, so
   open and closed were pixel-identical apart from what appeared underneath. Open, the summary
   drops to the ghost chip — .btn-ghost's three declarations written out rather than inherited,
   so no other rule of that class rides along — and the card has exactly one gold button, the
   submit. (0,3,1) against .btn-primary's (0,1,0). CLOSED IS UNCHANGED and keeps its gold
   create affordance.
   THESE TWO RULES ARE A PAIR, from two findings that read as rivals because they describe one
   card. :not([open]) and [open] cannot both match; applying either alone fixes half a
   component. */
.reveal[open] > summary.btn-primary { background: var(--c-surface); color: var(--c-text); border-color: var(--c-border-control); }


/* ── Language switcher ─────────────────────────────────────────────────────
   It had no rules at all: two bare anchors, unspaced, with nothing marking which
   language was active. The mark is inline SVG so it inherits the topbar ink. */
/* ONE CONTROL, not a pill of two links. It names the language being READ and moves to the next
   one when pressed. The word and the mark are the SAME ink — --c-text, not the gold — because in
   this design they are one label, and colouring half of a two-part label reads as a decoration
   that has drifted. Contrast is then whatever body text has, in both themes, by construction.
   The keyboard ring needs no rule of its own: the global a:focus-visible already covers it. */
.lang-switch { display: inline-flex; align-items: center; gap: 8px; flex: 0 0 auto;
    min-height: 32px; padding: 0 11px;
    border: 1px solid var(--c-border); border-radius: var(--radius-sm);
    background: var(--c-surface); color: var(--c-text);
    font-size: var(--fs-sm); font-weight: 600; line-height: 1;
    text-decoration: none; white-space: nowrap;
    transition: background-color .15s, border-color .15s; }
.lang-switch:hover { background: var(--c-surface-2); border-color: var(--c-focus, var(--c-primary)); }
.lang-switch .lang-word { display: inline-block; }
/* 20x16 keeps the glyph own 640x512 ratio. The rounded frame people read as a border around the
   characters is part of the Font Awesome artwork, not a box this stylesheet draws. */
.lang-switch .lang-ic { width: 20px; height: 16px; flex: 0 0 auto; color: inherit; }
/* In forced colours every author colour goes, including the border that makes this read as a
   control at all. */
@media (forced-colors: active) { .lang-switch { border: 1px solid; } }


/* ── Public pricing ────────────────────────────────────────────────────────
   auto-fit, so three plans fill the row and two do not leave a hole; minmax floors the
   card at a width the limit rows stay readable in. Same lesson as /admin/roles. */
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(272px, 1fr));
    gap: 18px; margin-top: 26px; align-items: stretch; }
.plan { display: flex; flex-direction: column; background: var(--c-surface);
    border: 1px solid var(--c-border); border-radius: var(--radius); padding: 24px 22px;
    box-shadow: var(--shadow-sm); }
.plan-name { margin: 0; font-size: var(--fs-base); font-weight: 700; color: var(--c-text); }
.plan-price { margin: 10px 0 0; font-size: 28px; font-weight: 800; letter-spacing: -.5px;
    color: var(--c-text); font-variant-numeric: tabular-nums; }
.plan-desc { margin: 10px 0 0; font-size: var(--fs-sm); color: var(--c-muted); }
/* The limit list is the comparison. Rows line up across cards because every card lists
   every key in the same order, including the ones a plan does not include. */
.plan-limits { list-style: none; margin: 18px 0 0; padding: 18px 0 0; border-top: 1px solid var(--c-border);
    display: flex; flex-direction: column; gap: 9px; flex: 1 1 auto; }
.plan-limits li { display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
    font-size: var(--fs-sm); color: var(--c-muted); }
.plan-limits li strong { color: var(--c-text); font-weight: 700; font-variant-numeric: tabular-nums; }
/* A feature that is NOT included is dimmed, not hidden — the gap is the information. */
.plan-limits li.hasnt strong { color: var(--c-muted); font-weight: 600; }
.plan-cta { margin-top: 20px; width: 100%; }
@media (max-width: 560px) { .plan-price { font-size: 25px; } }

/* ── /admin/billing ────────────────────────────────────────────────────────
   That page is built from the components above — .plan-grid for the catalogue, .kv for the
   facts, .card-table for the history — so all that is left to state here is what is particular
   to it. It used to state all of it inline, in two unequal columns, and the plan cards queued
   one per row down the narrower of them. */
/* A notice that something is waiting on the customer. The accent is on the INLINE start, so it
   lands on the right in Arabic; border-left would have pinned it to the wrong edge. The measure
   is here because these two blocks are the only prose on the page. */
.bill-flag { max-width: var(--measure-narrow); border-inline-start: 4px solid var(--c-warning); }
.bill-flag-h { font-size: var(--fs-lg); margin: 4px 0 10px; }
/* The subscription's facts beside what it actually grants. auto-fit rather than a breakpoint:
   two columns where there is room for two, one where there is not. min-width:0 because a grid
   track whose floor is auto will not shrink for a long plan name — it pushes the column out of
   the card instead. */
.bill-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(258px, 1fr));
    gap: 4px 36px; align-items: start; }
.bill-cols > * { min-width: 0; }
/* The money terms are the only long prose on the page, and the page column is 1117px on a
   laptop — 160 characters to a line. Capped to the same measure the notices use, which is what
   that token is for. */
.bill-terms { max-width: var(--measure-narrow); }
/* A date is written "03 Oct 2026", and the bidi algorithm reorders that to "Oct 2026 03" when
   it sits in an Arabic line — which is how every date on this page read in Arabic. plaintext
   gives each value its own direction, decided by its own first strong character, which is what a
   date, a reference number and an email address all need. The dates INSIDE a translated sentence
   cannot be reached this way and carry their own <bdi>. */
.kv dd, table.data td.date { unicode-bidi: plaintext; }
/* The plan the workspace is already on. The badge inside the card carries the meaning; this is
   only so the eye finds it in a grid of five. The shadow is restated, not replaced — dropping
   it would lift this one card out of the row it belongs to. */
.plan.is-current { border-color: var(--ui); box-shadow: inset 0 0 0 1px var(--ui), var(--shadow-sm); }


/* ── Public footer ─────────────────────────────────────────────────────────
   Two link columns and a contact column. auto-fit so it collapses to one column on a
   phone without a breakpoint, and so a footer configured with only one link column does
   not leave a hole where the second would be. */
.pub-footer { border-top: 1px solid var(--c-border); background: var(--c-surface);
    margin-top: 56px; padding: 40px 0 0; font-size: var(--fs-sm); }
.foot-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
    gap: 30px 28px; padding-bottom: 32px; }
.foot-col { display: flex; flex-direction: column; gap: 9px; align-items: flex-start; }
.foot-h { margin: 0 0 3px; font-size: var(--fs-sm); font-weight: 700; color: var(--c-text); }
.foot-col a { color: var(--c-muted); text-decoration: none; }
.foot-col a:hover { color: var(--ui-text); text-decoration: underline; }
.foot-body { margin: 0; color: var(--c-muted); line-height: 1.65; }
.foot-contact { font-weight: 600; }
.foot-social { display: flex; gap: 10px; margin-top: 6px; flex-wrap: wrap; }
/* A tap target, not just a glyph: 36px square clears the 24px minimum comfortably. */
.foot-social a { display: grid; place-items: center; width: 36px; height: 36px;
    border-radius: 50%; border: 1px solid var(--c-border); color: var(--c-muted); }
.foot-social a:hover { color: var(--c-primary-ink, #fff); background: var(--ui); border-color: var(--ui); }
.foot-base { border-top: 1px solid var(--c-border); padding: 16px 0 22px; color: var(--c-muted); }
/* The footer has no columns configured yet: keep the legal line off the top border. */
.pub-footer .foot-base:first-child { border-top: 0; padding-top: 0; }


/* ── Client logo strip ─────────────────────────────────────────────────────
   Two identical copies of the set slide left by exactly 50% of the track, at which point
   copy 2 occupies the position copy 1 started from — so the loop has no visible seam and
   needs no JavaScript.
   Logos are greyed and lift to full colour on hover: a wall of competing brand colours
   fights the page, and these are social proof rather than decoration. */
.logostrip { padding: 42px 0; }
.logostrip-h { text-align: center; font-size: var(--fs-sm); font-weight: 700; letter-spacing: .08em;
    text-transform: uppercase; color: var(--c-muted); margin: 0 0 26px; }
/* The mask hides the track overflow and fades both edges, so logos enter and leave rather
   than being chopped off at the viewport edge. */
.logostrip-mask { overflow: hidden; position: relative;
    -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
    mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent); }
.logostrip-track { display: flex; width: max-content; animation: logoslide 42s linear infinite; }
.logostrip-mask:hover .logostrip-track { animation-play-state: paused; }
.logostrip-set { display: flex; align-items: center; gap: 56px; margin: 0; padding: 0 28px; list-style: none; }
.logostrip-set li { flex: 0 0 auto; }
.logostrip-set img { height: 38px; width: auto; max-width: 160px; object-fit: contain;
    filter: grayscale(1); opacity: .62; transition: filter .2s, opacity .2s; }
.logostrip-set img:hover { filter: grayscale(0); opacity: 1; }
@keyframes logoslide { from { transform: translateX(0); } to { transform: translateX(-50%); } }
/* Arabic reads the other way, so the strip travels the other way. */
:dir(rtl) .logostrip-track { animation-name: logoslide-rtl; }
@keyframes logoslide-rtl { from { transform: translateX(0); } to { transform: translateX(50%); } }
/* Motion that never stops is a real accessibility problem, not a preference — vestibular
   disorders, and anything moving forever competes with the text beside it. Honour the
   setting by making the strip a static wrapped row, rather than hiding it. */
@media (prefers-reduced-motion: reduce) {
    .logostrip-track { animation: none; width: 100%; }
    .logostrip-set { flex-wrap: wrap; justify-content: center; gap: 34px 46px; }
    .logostrip-set[aria-hidden="true"] { display: none; }
    .logostrip-mask { -webkit-mask-image: none; mask-image: none; }
}
@media (max-width: 560px) { .logostrip-set { gap: 34px; } .logostrip-set img { height: 30px; } }


/* ── Root-authored content pages ───────────────────────────────────────────
   Lifted out of a <style> block private to views/public/page.php. The rules are the
   same; what changes is that they are now visible to the markup-vs-CSS audit, and that
   a second page reusing .legal-body inherits them. Details/summary is included because
   a security FAQ is the obvious shape for one of these pages. */
.legal-page { max-width: var(--measure-narrow); margin-inline: auto; }
.legal-body { color: var(--c-text); font-size: var(--fs-base); }
.legal-body h2 { font-size: 20px; margin: 26px 0 8px; color: var(--c-text); }
.legal-body h3 { font-size: var(--fs-base); margin: 18px 0 6px; }
.legal-body p { margin: 0 0 12px; line-height: 1.7; }
.legal-body ul, .legal-body ol { margin: 0 0 14px; margin-inline-start: 20px; }
.legal-body li { margin: 6px 0; line-height: 1.6; }
.legal-body a { color: var(--ui-text); }
.legal-body .lead { font-size: 16px; color: var(--c-muted); margin-bottom: 18px; }
/* An FAQ written as <details> gets a real disclosure for free — keyboard operable and
   searchable by the browser, which a div-and-script accordion is not. */
.legal-body details { border: 1px solid var(--c-border); border-radius: var(--radius-sm);
    padding: 14px 16px; margin: 0 0 10px; background: var(--c-surface); }
.legal-body details[open] { box-shadow: var(--shadow-sm); }
.legal-body summary { font-weight: 650; cursor: pointer; color: var(--c-text); }
.legal-body summary:hover { color: var(--ui-text); }
.legal-body details > *:last-child { margin-bottom: 0; }
/* Tables appear in comparison-style pages; keep them from overflowing on a phone. */
.legal-body table { width: 100%; border-collapse: collapse; margin: 0 0 16px; display: block; overflow-x: auto; }
.legal-body th, .legal-body td { border: 1px solid var(--c-border); padding: 9px 11px; text-align: start; }
.legal-body th { background: var(--c-surface-2, #f8fafc); font-weight: 700; }


/* ── Demonstration booking ─────────────────────────────────────────────────
   The day strip scrolls horizontally rather than wrapping: a wrapped calendar reads as a
   month grid and invites clicking dates that were never offered. */
.demo-wrap { max-width: var(--measure-narrow); margin-inline: auto; padding: 34px 20px 64px; }
.demo-h { margin: 0 0 6px; }
.demo-intro { margin: 0 0 22px; }
.demo-legend { font-size: var(--fs-sm); font-weight: 700; margin-bottom: 10px; padding: 0; }
.demo-legend .muted { font-weight: 400; }
/* min-inline-size:0 undoes the UA's `fieldset { min-inline-size: min-content }`. The day
   strip is a non-wrapping flex row of fixed-width pills, so its min-content is the sum of
   every date offered; the fieldset refused to go below that, grew past the card's rounded
   corner, took the slot grid with it and scrolled the whole page sideways. It also meant
   the strip's own overflow-x:auto could never engage. Logical property, so RTL is safe. */
.demo-when { border: 0; padding: 0; margin: 0 0 18px; min-inline-size: 0; }
.demo-days { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; }
.demo-day { flex: 0 0 auto; display: flex; flex-direction: column; align-items: center; gap: 2px;
    min-width: 72px; padding: 9px 10px; border: 1px solid var(--c-border); border-radius: var(--radius-sm);
    background: var(--c-surface); cursor: pointer; font: inherit; color: var(--c-muted); }
.demo-day:hover { border-color: color-mix(in srgb, var(--ui) 45%, var(--c-border)); color: var(--c-text); }
/* --ui-text, not --ui: the identical pairing already corrected on .combo-opt above. Raw
   gold on --ui-soft is 1.90:1, so the ONE date that has to read was three times less
   legible than the unselected ones beside it at 6.19:1. --ui-text is 4.68:1 here and is
   themed in both dark arms. Border and fill unchanged, so the pill keeps its gold. */
.demo-day.is-on { border-color: var(--ui); background: var(--ui-soft); color: var(--ui-text); }
.demo-dow { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .06em; }
.demo-dnum { font-weight: 700; font-size: var(--fs-sm); color: inherit; }
/* The count is the useful part: it says which days are worth opening. */
.demo-dn { font-size: var(--fs-2xs); opacity: .75; }
.demo-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
    gap: 8px; margin-top: 14px; }
/* display:grid would beat the UA's [hidden]{display:none}, so restate it -- the same line
   .tt-clear, .topbar-back, .install-banner and .combo-list already carry. Without it the
   server-rendered `hidden` and the script's p.hidden both did nothing, so EVERY day's grid
   rendered at once and four fifths of the page was duplicate buttons. */
.demo-slots[hidden] { display: none; }
/* The radio itself is visually hidden but NOT display:none — it stays focusable, so the
   grid is operable by keyboard and the focus ring lands on the label. */
.demo-slot { position: relative; display: block; }
.demo-slot input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.demo-slot span { display: grid; place-items: center; min-height: 42px; border-radius: var(--radius-sm);
    border: 1px solid var(--c-border); background: var(--c-surface); font-size: var(--fs-sm);
    font-weight: 600; font-variant-numeric: tabular-nums; color: var(--c-text); }
.demo-slot input:hover + span { border-color: color-mix(in srgb, var(--ui) 45%, var(--c-border)); }
.demo-slot input:checked + span { background: var(--ui); border-color: var(--ui); color: var(--c-primary-ink); }
.demo-slot input:focus-visible + span { outline: 2px solid var(--ui); outline-offset: 2px; }
.demo-band { padding-block: 10px; }
/* The form stacks .row / .row / .field / button, so `.field + .field` above never matches
   and each group sat 0px below the one before it while its own label sat 6px above its box
   -- the separator smaller than the thing separated, so every label read as belonging to
   the box above. 16px is the existing step. Scoped to .demo-form on purpose: the global
   .row carries no margin and 26 other views depend on that. */
.demo-form > .row + .row,
.demo-form > .row + .field,
.demo-form > .field + .btn { margin-top: 16px; }
@media (max-width: 560px) { .demo-slots { grid-template-columns: repeat(auto-fill, minmax(78px, 1fr)); } }

/* ── Helpdesk ───────────────────────────────────────────────────────────────────────────
   Public intake, the requester's thread, the agent desk and the Root console all draw from
   this one block, so the four surfaces cannot drift apart. Every colour is a token: these
   pages are rendered in light and dark, and in both directions. */

/* The reference. Tabular monospace because HD-00012 and HD-00021 must be distinguishable
   at a glance in a column of them. */
.hd-ref { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--fs-sm); font-variant-numeric: tabular-nums; letter-spacing: .02em;
    background: var(--c-surface-2); border: 1px solid var(--c-border);
    border-radius: var(--radius-sm); padding: 1px 6px; color: var(--c-text); }
a .hd-ref { color: var(--ui-text); border-color: color-mix(in srgb, var(--ui) 35%, var(--c-border)); }

.hd-head { display: flex; align-items: flex-start; justify-content: space-between;
    gap: 16px; flex-wrap: wrap; margin-bottom: 18px; }

/* The thread. A list, because that is what it is. */
.hd-thread { list-style: none; margin: 0 0 22px; padding: 0; display: grid; gap: 14px; }
/* --c-surface-2, NOT --c-surface: the card that holds the thread is itself --c-surface, so a
   requester's message was a 1px border drawn on exactly its own background — the only message
   in the thread with no ground of its own. Both other kinds already tint (.is-agent just below,
   and the internal note), which is why this was the one that disappeared. */
.hd-msg { border: 1px solid var(--c-border); border-radius: var(--radius);
    background: var(--c-surface-2); padding: 13px 15px; }
/* The agent's side is tinted rather than pushed to one edge: a chat-bubble layout breaks
   the moment the page flips to RTL, and these threads are read in both directions. */
.hd-msg.is-agent { background: var(--ui-soft);
    border-color: color-mix(in srgb, var(--ui) 28%, var(--c-border)); }
/* An internal note must be unmistakable. A dashed warning border AND the words on the
   label: colour alone fails for anyone who cannot see it, and this is the one distinction
   on the page whose cost of being missed is a private note pasted to a customer. */
.hd-msg.is-internal { background: color-mix(in srgb, var(--c-warning) 9%, var(--c-surface));
    border-style: dashed; border-color: color-mix(in srgb, var(--c-warning) 55%, var(--c-border)); }
.hd-meta { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
    font-size: var(--fs-sm); margin-bottom: 6px; }
.hd-meta time { font-size: var(--fs-xs); margin-inline-start: auto; }
.hd-note-tag { font-size: var(--fs-2xs); text-transform: uppercase; letter-spacing: .05em;
    font-weight: 700; color: color-mix(in srgb, var(--c-warning) 78%, var(--c-text));
    border: 1px solid color-mix(in srgb, var(--c-warning) 50%, var(--c-border));
    border-radius: var(--radius-sm); padding: 1px 6px; }
.hd-body { font-size: var(--fs-base); line-height: 1.65; color: var(--c-text);
    overflow-wrap: anywhere; }

.hd-reply { border-top: 1px solid var(--c-border); padding-top: 16px; }
/* Two submit buttons, side by side and visibly unequal — see partials/hd_agent_reply.php
   for why this is not a tick box. */
.hd-send { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.hd-note-btn { border-color: color-mix(in srgb, var(--c-warning) 55%, var(--c-border));
    color: color-mix(in srgb, var(--c-warning) 80%, var(--c-text)); }
.hd-note-btn:hover { background: color-mix(in srgb, var(--c-warning) 12%, transparent); }

/* Thread beside a sidebar, stacking on narrow screens. */
.hd-cols { display: grid; grid-template-columns: minmax(0, 1fr) 288px; gap: 18px;
    align-items: start; }
@media (max-width: 900px) { .hd-cols { grid-template-columns: minmax(0, 1fr); } }

.hd-filter { display: flex; gap: 8px; flex-wrap: wrap; }
.hd-stat { margin: 0 0 6px; }
.hd-dl { display: grid; grid-template-columns: auto 1fr; gap: 6px 12px; margin: 0;
    font-size: var(--fs-sm); }
.hd-dl dt { color: var(--c-muted); }
.hd-dl dd { margin: 0; color: var(--c-text); overflow-wrap: anywhere; }

/* The one-time agent link. */
.hd-issued { border-color: color-mix(in srgb, var(--ui) 45%, var(--c-border)); }
.hd-token-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.hd-token { flex: 1 1 320px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
    font-size: var(--fs-sm); }

/* ── The agent desk chrome (layouts/desk.php) ─────────────────────────────────────────── */
/* The safe-area inset every other sticky header in the product has. head.php sets
   viewport-fit=cover on every page, so without this the brand and the agent's name sit
   under the status bar on a notched phone — and the desk is the surface most likely to be
   opened on one, from a link in an email. */
.desk-top { border-bottom: 1px solid var(--c-border); background: var(--c-surface);
    padding-top: env(safe-area-inset-top);
    position: sticky; top: 0; z-index: var(--z-topbar); }
.desk-wrap { max-width: var(--measure-wide); margin-inline: auto; padding-inline: 20px; }
/* 68px, the same bar height layouts/public uses — a 52px logo does not sit in 58. */
.desk-top .desk-wrap { display: flex; align-items: center; justify-content: space-between;
    gap: 16px; min-height: 68px; }
.desk-brand { display: inline-flex; align-items: center; gap: 10px; text-decoration: none;
    color: var(--c-text); }
/* NO SIZING HERE. The desk used to cap its logo at 28px, and because `.desk-brand img` is
   (0,1,1) it beat the platform's own `.brand-logo` (0,1,0) at 52px — so this was the one
   header in the product showing the brand at half size, with the wordmark under the mark
   reduced to about eight pixels. Deleted rather than retuned: every <img> the layout can
   emit carries .brand-logo, so there is already a figure to defer to, and object-fit is
   stated there too. */
.desk-title { font-weight: 700; font-size: var(--fs-sm); }
.desk-who { text-align: end; display: grid; gap: 1px; font-size: var(--fs-sm); }
.desk-main { padding: 26px 0 64px; }

/* ---------- Moved out of page-local <style> blocks ----------
   These lived in a `<style>` inside the view, which put them beyond the reach of the
   stylesheet, of branding, and of any platform-wide restyle — the thing the house rule
   exists to prevent. Appended at the end because that is the cascade position they held.
   ---------------------------------------------------------------------------------- */

/* Numbering token chips — WAS DUPLICATED BYTE FOR BYTE in admin/numbering.php and
   owner/numbering.php. Two copies of one component is one copy more than can be kept in
   step; the radius was also a literal 6px, so it sat out the shape change. */
.num-prev { font-family: var(--font-mono); font-weight: 700; }
.tok {
    display: inline-block; font-family: var(--font-mono); font-size: var(--fs-xs);
    background: var(--c-surface-2); border: 1px solid var(--c-border);
    /* LOGICAL, not a 4-value shorthand: `margin: 2px 2px 2px 0` was physical, and under RTL
       the trailing margin moved to the chip's leading side and pointed at the PREVIOUS name.
       THE NUMBERS, measured off the rendered page: a chip sat 2px from the word it labels and
       about 3px (one collapsed newline in the markup) from the next chip — so the row read as
       ten loose items rather than five pairs, and the chip row is where most of the teaching
       happens on that card. 14px leading makes the gap BETWEEN pairs roughly five times the
       gap inside one. */
    border-radius: var(--radius-sm); padding: 2px 7px; cursor: pointer;
    margin-block: 2px; margin-inline: 14px 3px;
}
/* The first chip has no word before it, so it keeps the inline edge of the input above it. */
.tok:first-child { margin-inline-start: 0; }
.tok:hover { border-color: var(--c-primary); color: var(--ui-text); }
/* PAIRING, MEASURED. Each chip is followed by the word it means and then by the next
   chip, and on /admin/numbering at 1440 those two gaps are 2px and 3px: the chip's own
   trailing margin on one side, and on the other nothing but the collapsed newline
   between the spans. A reader cannot tell which word belongs to which chip from a
   one-pixel difference, so the row reads as ten unpaired items instead of five pairs.
   12px is a margin step. It has to weigh (0,2,0) to reach, because .tok's own
   margin-inline shorthand above already declares the start side as 0; logical, so
   Arabic pairs the same way; and :not(:first-child) keeps the first chip flush under
   the input it fills. The row grows 48px, from x=770 to x=818 in a 1052px card column,
   so it still cannot wrap at 1440. Fixes both numbering views at once. */
.tok:not(:first-child) { margin-inline-start: 12px; }
input.num-in { font-family: var(--font-mono); }

/* The Root operations dashboard: collapsible section headers, sortable columns, notes. */
.ops-h2 { margin: 0; font-size: var(--fs-lg); }
.ops-toggle {
    display: inline-flex; align-items: center; gap: 9px;
    background: none; border: 0; padding: 0; margin: 0;
    /* 18/600 rather than the old 16/700: these ARE card titles, and that is the size and
       weight the rest of the platform gives one. */
    font: inherit; font-size: var(--fs-lg); font-weight: 600;
    color: var(--c-text); cursor: pointer; text-align: start; min-height: 0; width: auto;
}
/* A CSS triangle, so the borders have to stay PHYSICAL — border-inline-start does not draw
   one. The mirroring is done on the transform instead, which is the part that carries the
   direction. [dir="rtl"] and not :lang(ar): this is a question of writing direction, not of
   language. */
.ops-chev {
    display: inline-block; width: 0; height: 0;
    border-left: 6px solid currentColor;
    border-top: 5px solid transparent; border-bottom: 5px solid transparent;
    transform: rotate(90deg); transition: transform .15s ease;
    color: var(--c-muted); flex: 0 0 auto;
}
.ops-toggle.is-collapsed .ops-chev { transform: rotate(0deg); }
[dir="rtl"] .ops-toggle.is-collapsed .ops-chev { transform: rotate(180deg); }
.ops-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ops-note { font-size: var(--fs-xs); color: var(--c-muted); }
.ops-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.ops-tools input[type=search] { flex: 1 1 200px; max-width: 280px; min-width: 0; }
.ops-sort {
    background: none; border: 0; padding: 0; margin: 0; font: inherit; color: inherit;
    cursor: pointer; display: inline-flex; align-items: center; gap: 4px;
    white-space: nowrap; min-height: 0; width: auto;
    text-transform: inherit; letter-spacing: inherit;
}
/* 9px is deliberate and stays: this is a caret GLYPH, not prose, and the 10.5px floor is
   about readable text. */
.ops-sort .ops-caret { font-size: 9px; opacity: 0; }
.ops-sort.is-active .ops-caret { opacity: 1; }
/* REGRESSION FIX. This is the Root console's numeric column, and it lost its HEADER when
   `table.data th { text-align: start }` shipped: that rule is (0,1,2) and a bare class is
   (0,1,0), so ten column titles on /owner/dashboard ended up on the opposite edge from
   their own figures. It showed nowhere else because .ops-num appears in one file.
   Scoped exactly like th.num/td.num further up — the same column in the shared vocabulary
   — and `end` instead of `right`, so the figures stay on the trailing edge in Arabic too. */
table.data th.ops-num,
table.data td.ops-num { text-align: end; font-variant-numeric: tabular-nums; }
/* On a trailing-aligned column the sort caret belongs beside the figures rather than
   hanging off the card edge — the same move table.data th.num .sort-h already makes. */
table.data th.ops-num .ops-sort { flex-direction: row-reverse; }
/* Kept for any .ops-num used outside a table; logical this time. */
.ops-num { text-align: end; font-variant-numeric: tabular-nums; }
@media (max-width: 820px) { .ops-tools input[type=search] { max-width: 100%; width: 100%; } }

/* ---------- More page-local blocks, moved ----------
   Second batch, same reasoning as the first: a `<style>` in the view is beyond the reach of
   the stylesheet, of branding, and of any platform-wide restyle. Appended because that is the
   cascade position these rules already held. Where a block carried a defect, the defect is
   named at the rule that had it.
   ------------------------------------------------------------------------------------- */

/* The "waiting on N signatories" strip on a document page. */
.pending-panel {
    /* WAS var(--ui-soft, #eff6ff) — a pre-gold BLUE fallback. The token always resolves, so
       the fallback never fired and nobody noticed it had been left behind. */
    background: var(--ui-soft); border: 1px solid var(--c-border);
    border-radius: var(--radius-sm); padding: 9px 12px; margin-bottom: 10px;
    font-size: var(--fs-sm);
}
/* WAS 13px here against 13.5px above — two states of ONE panel that disagreed by half a
   pixel. Both are --fs-sm now. */
.pending-panel.is-done { background: transparent; color: var(--c-muted); font-size: var(--fs-sm); }

/* A <details> used as a disclosure: the restore step-up has to look and size like the icon
   buttons on every other list. BOTH marker rules are needed — list-style covers Firefox,
   the pseudo-element covers WebKit. */
.bk-restore > summary { list-style: none; cursor: pointer; }
.bk-restore > summary::-webkit-details-marker { display: none; }
.bk-restore > summary::marker { content: ""; }

/* Numbered install steps on the public how-to. */
.howto-steps { margin: 0; padding-inline-start: 0; list-style: none; counter-reset: step; display: grid; gap: 12px; }
.howto-steps li {
    position: relative; padding-inline-start: 40px; font-size: 14.5px; line-height: 1.5;
    counter-increment: step;
}
.howto-steps li::before {
    content: counter(step); position: absolute; inset-inline-start: 0; top: -1px;
    width: 26px; height: 26px; border-radius: 50%;
    /* WAS color-mix(… var(--c-primary) 12%, #fff) — a literal white mix base, so in dark mode
       the step disc stayed pale on a dark card. --c-surface flips; #fff does not. padding and
       inset are logical for the reason the rest of the platform's are: Arabic. */
    background: color-mix(in srgb, var(--c-primary) 12%, var(--c-surface));
    /* --ui-text, not --c-primary: the surface gold on its own 12% tint is 1.93:1. */
    color: var(--ui-text); font-weight: 700; font-size: 13px;
    display: grid; place-items: center;
}

/* Approval-flow stage editor. The 12px radius is KEPT deliberately: this is a card nested in
   a card, and matching the outer --radius would read as one bulbous box inside another. */
.stage-card { border: 1px solid var(--c-border); border-radius: 12px; padding: 14px; margin-bottom: 12px; background: var(--c-surface); }
.stage-card h3 { font-size: var(--fs-base); margin: 0; }
.slot-row { display: grid; grid-template-columns: 1.3fr 1fr 1fr auto auto; gap: 8px; align-items: center; padding: 8px 0; border-top: 1px solid var(--c-border); }
.slot-row .fixedonly { display: none; }
.slot-row.is-fixed .fixedonly { display: block; }
@media (max-width: 820px) { .slot-row { grid-template-columns: 1fr 1fr; } }

/* ---------- The create-document wizard's step rail ----------
   TWO copies of this existed: app/document_new.php carried a seven-rule subset and
   app/document_build.php the full twenty, and they disagreed on the rail's bottom margin
   (18 against 16) and on the connector's minimum length (20 against 24). Merged here as the
   superset. The build page's longer connector is kept, because that page runs four steps and
   the short one collapses; the new page's spacing is kept. Every gold used as INK is
   --ui-text, per the rule this file's own :root states — the subset had inherited --ui and
   was painting the current step at 2.10:1. */
.wiz-steps { display: flex; list-style: none; margin: 0 0 18px; padding: 0; overflow-x: auto; }
.wiz-step {
    display: flex; align-items: center; gap: 8px; flex: 0 1 auto; min-width: max-content;
    padding: 6px 4px; font-size: 13px; font-weight: 600; color: var(--c-muted);
    cursor: pointer; border-radius: var(--radius-sm); transition: color .12s;
}
.wiz-step:not(:last-child)::after {
    content: ""; flex: 1; height: 2px; min-width: 24px; margin: 0 8px;
    background: var(--c-border); border-radius: 2px;
}
.wiz-num {
    width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center;
    flex: 0 0 auto; font-size: var(--fs-xs); background: var(--c-surface);
    border: 2px solid var(--c-border);
}
.wiz-step.is-current, .wiz-step.is-done, .wiz-step:hover { color: var(--ui-text); }
.wiz-step.is-current .wiz-num, .wiz-step:hover .wiz-num { border-color: var(--ui-text); color: var(--ui-text); }
/* A COMPLETED step is a filled gold disc, and the ink on it is the near-black --c-ui-ink.
   This is the one shape where gold is correct — as the surface, not the text. */
.wiz-step.is-done .wiz-num { background: var(--ui); border-color: var(--ui); color: var(--c-ui-ink); }
.wiz-step.is-done:not(:last-child)::after { background: var(--ui); }
.wiz-step:focus-visible { outline: 2px solid var(--c-focus); outline-offset: 2px; }
/* A step that cannot be reached yet is not a link, and says so without colour alone. */
.wiz-step:not(.wiz-reachable) { opacity: .5; cursor: default; }
.wiz-step:not(.wiz-reachable):hover { color: var(--c-muted); }
.wiz-step:not(.wiz-reachable):hover .wiz-num { border-color: var(--c-border); }
/* ...AND THE STEP YOU ARE STANDING ON IS NOT ONE OF THEM. `wiz-reachable` is added by
   script and only document_build.php has that script (:300) — app/document_new.php ships
   five STATIC <li>s, so every one of them matched the :not() above and the whole rail
   rendered at 50%, .is-current included. Measured on the live shot: the current step came
   out at 1.98:1 against the page, FAINTER than a step you cannot reach yet (2.10:1), so
   the rail read as switched off and nothing in it said "you are here".
   SAME (0,2,0) AS THE RULE IT CORRECTS, so it only works from BELOW it — moving this above
   the block makes it a silent no-op. The four pending steps keep the deliberate dim.
   No-op on the build page, where a done or current step already carries wiz-reachable. */
.wiz-step.is-current, .wiz-step.is-done { opacity: 1; }
.wiz-flash { animation: wizFlash .9s ease-out; }
@keyframes wizFlash {
    from { box-shadow: 0 0 0 3px color-mix(in srgb, var(--ui) 45%, transparent); }
    to   { box-shadow: 0 0 0 3px transparent; }
}
@media (prefers-reduced-motion: reduce) { .wiz-flash { animation: none; } .wiz-step { transition: none; } }
@media (max-width: 640px) { .wiz-step:not(.is-current) .wiz-label { display: none; } }

/* ---------- Account: stored signature slots ---------- */
/* .acct-stack IS GONE, not retuned. It wrapped the three account cards in a flex column
   with an 18px gap, and that did two things: it stopped them being children of .content,
   so `.content > .card + .card` never reached them, and it substituted a gap the page
   rhythm does not use. The cards are direct children of .content now and stack at 24px,
   the same as every other section on every other page. */
.sig-slot {
    display: flex; gap: 14px; align-items: flex-start; padding: 12px 0;
    border-bottom: 1px solid var(--c-border);
}
.sig-slot:last-of-type { border-bottom: 0; }
/* The transparency checkerboard behind a stored signature. It was drawn in a literal #e2e8f0
   — a light blue-grey with no dark counterpart, so in dark mode a pale checkerboard sat on a
   dark card. --c-border is the one token that reads as a quiet grid against the surface in
   BOTH modes (#DCDAD5 on white, #3C3A44 on #201F24), and it flips on its own. */
.sig-slot .sig-shot {
    flex: 0 0 150px; min-height: 64px; padding: 6px;
    border: 1px solid var(--c-border); border-radius: var(--radius-sm);
    display: grid; place-items: center;
    background-image:
        linear-gradient( 45deg, var(--c-border) 25%, transparent 25%),
        linear-gradient(-45deg, var(--c-border) 25%, transparent 25%),
        linear-gradient( 45deg, transparent 75%, var(--c-border) 75%),
        linear-gradient(-45deg, transparent 75%, var(--c-border) 75%);
    background-size: 12px 12px;
    background-position: 0 0, 0 6px, 6px -6px, -6px 0;
}
.sig-slot .sig-shot img { max-width: 100%; max-height: 74px; object-fit: contain; }
/* FULL INK, NOT --c-muted. This is the one place a muted string is not printed on white:
   #616161 on the checkerboard's --c-border tint (#DCDAD5) is 4.43:1, under the 4.5:1 AA
   floor at this size, and the glyphs cross a 12px check, so about half of every letter
   lands on the failing square. --c-text is 7.8:1 on the tint and 10.9:1 on the white
   squares. Dark mode was already clear (#F2F1EF on #3C3A44, 9.4:1) and stays so. */
.sig-slot .sig-shot .muted { font-size: var(--fs-xs); color: var(--c-text); }
.sig-slot-body { flex: 1; min-width: 0; }

/* ---------- Root branding: colour swatches and the token strip ---------- */
/* margin-inline-end, not margin-right: this swatch precedes its label, and in Arabic the gap
   belongs on the other side. */
.swatch {
    display: inline-block; width: 20px; height: 20px; border-radius: 5px;
    border: 1px solid var(--c-border); vertical-align: -5px; margin-inline-end: 7px;
}
.inherit-note { display: block; font-size: var(--fs-xs); color: var(--c-muted); margin-top: 5px; font-weight: 400; }
.inherit-note .mono { color: var(--c-text); }
/* WAS border-left + padding-left. A left border is the one thing this project has written down
   as breaking RTL — it pins the rule to the start edge in English and to the END edge in
   Arabic, where it reads as a stray line down the wrong side of the panel. */
.gate {
    border-inline-start: 3px solid var(--c-warning);
    padding-inline-start: 12px; margin: 2px 0 14px;
}

/* Token swatch strip. An <iframe> preview was built first and then removed: this host sends
   `Header always set X-Frame-Options "DENY"` and `frame-ancestors 'none'` from .htaccess, and
   `always set` replaces whatever PHP sends, so a same-origin frame is blocked no matter what
   the response asks for. Verified on the live site rather than assumed. Weakening a
   clickjacking control to preview a colour would be a bad trade, so the full sheet opens in a
   tab of its own instead, where the real viewport gives the real media queries. */
.tokstrip { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 9px; margin: 0; }
.tokchip { border: 1px solid var(--c-border); border-radius: var(--radius-sm); overflow: hidden; background: var(--c-surface); }
.tokchip i { display: block; height: 34px; }
.tokchip b { display: block; font-size: var(--fs-2xs); font-weight: 600; padding: 5px 7px 2px; color: var(--c-text); }
.tokchip span { display: block; font-size: var(--fs-2xs); padding: 0 7px 6px; color: var(--c-muted); font-family: var(--font-mono); }

/* ---------- The document builder ----------
   Moved out of a 94-line <style>. Its second <style> stays in the view: that one is inside
   <noscript>, and no stylesheet can express "only when scripting is off".
   ------------------------------------------------------------------------------------- */
.builder { display: grid; grid-template-columns: 1fr 340px; gap: 18px; align-items: start; }
.builder-side { position: sticky; top: 70px; max-height: calc(100vh - 86px); overflow-y: auto; }
.signer-row {
    display: flex; align-items: center; gap: 8px; padding: 8px;
    border: 1px solid var(--c-border); border-radius: var(--radius-sm); margin-bottom: 8px;
}
.signer-dot { width: 12px; height: 12px; border-radius: 50%; flex: 0 0 auto; }
.signer-move { display: flex; flex-direction: column; gap: 2px; }
.signer-move button { line-height: 1; padding: 1px 6px; font-size: 11px; }
.routing-box { border: 1px solid var(--c-border); border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 10px; }
.routing-box .checkbox-row { margin: 0; }

/* ONE LINE. The picker and all four field buttons stay on a single row: the group neither
   wraps internally nor shrinks below its content. The TOOLBAR still wraps, so when the window
   is narrow the zoom / Next / Replace cluster drops to a second row rather than squeezing
   these. Narrower still, the group scrolls sideways — overflow is visible and recoverable,
   whereas a wrapped-away button reads as missing. */
.pdf-place-tools,
/* The four field-type buttons travel as one block, and now that nothing in this row wraps the
   group is what keeps them from being separated by the horizontal scroll. */
.pdf-place-group { display: flex; gap: 8px; row-gap: 8px; align-items: center; flex-wrap: wrap; flex: 0 1 auto; min-width: 0; }
/* flex: 0 0 auto on each control is what actually holds the line together: without it the
   buttons shrink and their labels wrap to two lines instead of the row scrolling. */
.pdf-place-tools > .btn, .pdf-place-group > .btn, .pdf-place-tools > select { min-height: 36px; flex: 0 0 auto; }
.pdf-place-tools > .small { flex: 0 0 auto; white-space: nowrap; }

/* The toolbar Next and its gate hint, mirroring #wizNav at the foot of the page. The WRAPPER
   is what gets hidden when the wizard bails out, for the same reason #wizNav is: refresh()
   writes style.display on the BUTTON, so hiding the button itself would be undone by the next
   refresh. :empty keeps the gap from showing when there is no hint to give. */
.wiz-nav-top { display: flex; align-items: center; gap: 8px; }
.wiz-nav-top .wiz-hint:empty { display: none; }
/* Below 640px the footer hint already becomes its own full-width row, so the toolbar copy is
   redundant and only costs height in a sticky bar on a phone. */
@media (max-width: 640px) { .wiz-nav-top .wiz-hint { display: none; } }

/* WAS var(--c-surface-2, #f1f5f9) — another pre-gold blue-grey fallback that never fired. */
.view-note {
    background: var(--c-surface-2); border: 1px solid var(--c-border); color: var(--c-muted);
    padding: 6px 10px; border-radius: var(--radius-sm); font-size: var(--fs-xs);
}
/* The "click the page to place a field" prompt. Its ink was --ui on --ui-soft, which is gold
   on pale gold: 1.59:1, the worst pairing in the file. --ui-text is the ink for a gold
   surface and this is exactly the case its docblock was written for. */
.place-hint {
    background: var(--ui-soft); border: 1px solid color-mix(in srgb, var(--ui) 30%, var(--c-surface));
    color: var(--ui-text); padding: 8px 12px; border-radius: var(--radius-sm);
    font-size: var(--fs-sm); font-weight: 600;
}

/* Phase 3 — one step at a time. */
.builder.wiz { display: block; }
.builder.wiz .builder-main { width: 100%; }
.builder.wiz .builder-side { position: static; max-height: none; overflow: visible; width: 100%; }
/* The side panel's control width lives in app.css with every other control measure, as
   --control-max. It was capped HERE at --measure-narrow, which was right while the panel was
   a 340px column and wrong the moment the wizard started running it at the full page width:
   it left a ~300px dead gutter beside every field. Do not re-add a cap — a width that lives
   in two places drifts, and this one did. */
.wiz-hidden { display: none !important; }
.wiz-nav {
    display: flex; align-items: center; gap: 12px; margin: 22px 0 0; padding-top: 16px;
    border-top: 1px solid var(--c-border);
}
.wiz-nav .wiz-spacer { flex: 1; }
.wiz-hint { color: var(--c-muted); font-size: var(--fs-sm); }
.wiz-review-row {
    display: flex; justify-content: space-between; gap: 12px; padding: 7px 0;
    border-bottom: 1px solid var(--c-border);
}
.wiz-review-row:last-child { border-bottom: 0; }
.wiz-review-row .k { color: var(--c-muted); }
.wiz-review-row .v { font-weight: 600; text-align: end; }
@media (max-width: 820px) {
    .builder { grid-template-columns: 1fr; }
    .builder-side { position: static; max-height: none; overflow: visible; }
}
@media (max-width: 640px) {
    .wiz-nav { flex-wrap: wrap; }
    .wiz-nav .wiz-hint { order: 3; flex: 0 0 100%; text-align: center; }
}

/* ---------- The signing surface ----------
   The page where someone actually signs. Its <style> was the best-written of the fourteen —
   tokenised, logical properties, its reasoning recorded at the rules that needed it — and all
   of that is kept. What changed is the gold: every affordance a signatory has to FIND and READ
   was painted in --ui, which on paper measures 1.90:1 for the field label and 2.10:1 for its
   dashed border. On --ui-text they are 4.67:1 and 5.18:1.
   ------------------------------------------------------------------------------------- */
.sign-wrap {
    max-width: var(--measure-narrow); margin: 0 auto;
    padding: 12px 12px calc(120px + env(safe-area-inset-bottom));
}
.sign-summary {
    background: var(--c-surface); border-radius: var(--radius); padding: 14px 16px;
    margin-bottom: 12px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap;
}
.sign-viewer { background: var(--c-surface-2); border-radius: var(--radius); padding: 12px; }
.s-page { position: relative; margin: 0 auto 14px; box-shadow: var(--shadow); background: var(--c-page); }
.s-page canvas { display: block; width: 100%; height: auto; }
.s-field {
    position: absolute; border: 2px dashed var(--ui-text);
    background: color-mix(in srgb, var(--ui) 15%, transparent);
    border-radius: var(--radius-sm); display: flex; align-items: center; justify-content: center;
    color: var(--ui-text); font-size: var(--fs-xs); font-weight: 700; cursor: pointer;
}
.s-field.done { border-style: solid; border-color: var(--c-success); background: transparent; }
/* The field whose turn it is. Colour alone would not carry it for a colour-blind signatory,
   so the ring is paired with a scale change; both are dropped under reduced-motion, where the
   solid ring still marks the spot. */
.s-field.s-now {
    border-style: solid; border-width: 3px;
    box-shadow: 0 0 0 4px color-mix(in srgb, var(--ui) 35%, transparent);
    animation: sNowPulse 1.6s ease-out 2;
}
@keyframes sNowPulse { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.03); } }
@media (prefers-reduced-motion: reduce) { .s-field.s-now { animation: none; } }
/* --c-field-2, NOT var(--c-accent, #0891b2). That fallback has never fired: --c-accent is a
   declared token (#9A9A9A, the grey document outline from the mark), and a var() fallback
   applies only while the property is UNSET — so initials fields have been drawing grey since
   the rebrand instead of the cyan written here. --c-field-2 IS #0891b2 and is redeclared for
   dark mode; --c-accent is not. */
.s-field.s-initial {
    border-style: dotted; color: var(--c-field-2); border-color: var(--c-field-2);
    background: color-mix(in srgb, var(--c-field-2) 12%, transparent); font-size: 11px;
}
.s-field img { max-width: 100%; max-height: 100%; object-fit: contain; }
.s-field.s-textfield {
    border-style: solid; background: color-mix(in srgb, var(--ui) 8%, var(--c-page));
    cursor: text; padding: 0;
}
.s-textinput {
    width: 100%; height: 100%; border: 0; background: transparent;
    font: 600 var(--fs-control-sm)/1.2 inherit; color: var(--c-page-ink);
    padding: 2px 7px; box-sizing: border-box; outline: none; border-radius: var(--radius-sm);
}
.s-textinput:focus { background: var(--c-page); box-shadow: inset 0 0 0 2px var(--c-focus); }
/* WAS color-mix(--ui 55%, --c-sign-muted), which landed on 2.64:1 — a hint is still text.
   --ui-text clears 4.5:1 on the paper AND on the field's own tint, and stays distinct from
   the typed value, which is navy --c-page-ink at weight 600. */
.s-textinput::placeholder { color: var(--ui-text); font-weight: 500; }
.sign-bar {
    position: fixed; left: 0; right: 0; bottom: 0; background: var(--c-surface);
    border-top: 1px solid var(--c-border); padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
    display: flex; gap: 10px; align-items: center; z-index: 30;
    /* An UPWARD shadow for a bottom bar; the --shadow tokens all cast downward. */
    box-shadow: 0 -6px 20px -12px rgba(0, 0, 0, .3);
}
.sign-bar .prog { flex: 1; font-size: var(--fs-sm); font-weight: 600; }
.sigpad-wrap {
    border: 1px solid var(--c-border); border-radius: var(--radius); background: var(--c-page);
    touch-action: none; position: relative;
}
.sigpad-wrap canvas { display: block; width: 100%; height: 200px; border-radius: var(--radius); }
.sig-baseline {
    position: absolute; inset-inline: 16px; bottom: 44px;
    border-bottom: 1px dashed var(--c-page-line); pointer-events: none;
}
/* Upload: side-by-side choice between the cleaned and the original signature. The ACTIVE
   border marks which one is selected, so it is a meaningful indicator and owes 3:1 — --ui
   gave it 2.10. The two tiles also disagreed on their radius (10 against 12) while doing the
   same job; both are --radius-sm now. */
.saved-opt, .sig-opt {
    display: flex; flex-direction: column; gap: 6px; padding: 10px;
    border: 2px solid var(--c-border); border-radius: var(--radius-sm); cursor: pointer;
}
.saved-opt { position: relative; flex: 1; text-align: center; }
.sig-opt { background: var(--c-surface); }
.saved-opt.is-active { border-color: var(--ui-text); background: color-mix(in srgb, var(--c-primary) 6%, transparent); }
.sig-opt.is-active { border-color: var(--ui-text); box-shadow: var(--shadow-sm); }
.saved-opt .saved-lab { font-size: var(--fs-xs); font-weight: 700; color: var(--c-muted); }
.saved-opt img { max-height: 96px; max-width: 100%; object-fit: contain; align-self: center; }
.sig-opt-title { font-weight: 600; font-size: var(--fs-sm); }
.sig-opt-note { font-size: var(--fs-2xs); color: var(--c-muted); }
/* A chequerboard makes real transparency visible — on plain white the two options would look
   identical, which is exactly the confusion this choice exists to remove. */
.sig-chk {
    display: block; border-radius: var(--radius-sm); padding: 6px; min-height: 70px;
    background-image:
        linear-gradient( 45deg, var(--c-border) 25%, transparent 25%),
        linear-gradient(-45deg, var(--c-border) 25%, transparent 25%),
        linear-gradient( 45deg, transparent 75%, var(--c-border) 75%),
        linear-gradient(-45deg, transparent 75%, var(--c-border) 75%);
    background-size: 14px 14px; background-position: 0 0, 0 7px, 7px -7px, -7px 0;
}
.sig-chk img { display: block; max-width: 100%; max-height: 80px; margin: 0 auto; }

/* ---------- A list page is ONE card ----------
   TailAdmin's list is a single card: toolbar, divider, table, divider, pagination, with
   nothing floating on the canvas. On this platform partials/list_toolbar.php renders a
   SIBLING BEFORE the card and partials/list_pager.php a SIBLING AFTER it, so a list page was
   three separate boxes with canvas showing between them. Exactly one page already looks
   right — /admin/users, which uses .tabletools.

   Done with :has() so that no view changes. The logical radius longhands cover both bottom
   (or both top) corners in either direction, so the join is symmetric with no [dir="rtl"]
   arm. Every value is an existing token that both dark arms already redefine.
   ------------------------------------------------------------------------------------- */
.list-toolbar:has(+ .card),
.list-toolbar:has(+ .tabletools) {
    margin-bottom: 0;
    padding: 12px var(--table-pad-x);
    border-end-start-radius: 0;
    border-end-end-radius: 0;
}
.list-toolbar + .card,
.list-toolbar + .tabletools {
    border-top: 0;
    border-start-start-radius: 0;
    border-start-end-radius: 0;
}
/* A WELDED STRIP IN A SPLIT COLUMN IS NARROWER THAN THE PAGE. The toolbar's own wrap rule is
   a @media at 1100px, written for a strip that spans the whole content area. Inside .split-2
   the strip only gets (viewport less 290 sidebar, 48 gutters, 320 side panel and the 20 gap),
   while its contents have a hard floor of about 547px: 170 search + 120 + 120 selects + Apply
   + 24 gaps + 48 padding. Between 1101px and about 1222px of viewport that column is below
   the floor and the @media has not fired yet, so Apply would spill past the card's border.
   (0,2,0) beats the base nowrap at (0,1,0) from anywhere in the file, and above that band
   nothing moves: at 1440 the column is 763px and the row still fits on one line. */
.split-2 .list-toolbar { flex-wrap: wrap; }
/* :has(table.data) keeps the footer join to the card that actually holds the table, so an
   unrelated card that merely precedes a pager is never welded to it.
   `+` IS ADJACENCY, AND A <script> BETWEEN THE TWO BREAKS IT. owner/documents.php put a
   65-line script between its card and its pager and the join silently did not apply. The
   fix is to keep the script out of the way, NOT to widen this to `~`: `~` would also weld
   the first of two table cards on a page that has two. list_pager.php returns
   before any output when there is nothing to page, so an empty table has no .pager sibling
   and keeps its rounded bottom — the rule gates itself. */
.card:has(table.data):has(+ .pager),
.tabletools:has(+ .pager) {
    border-end-start-radius: 0;
    border-end-end-radius: 0;
}
.card:has(table.data) + .pager,
.tabletools + .pager {
    margin-top: 0;
    padding: 12px var(--table-pad-x);
    background: var(--c-surface-2);
    border: 1px solid var(--c-border);
    border-top: 0;
    border-end-start-radius: var(--radius);
    border-end-end-radius: var(--radius);
}

/* ---------- The icon tile ----------
   One of the eight primitives in TailAdmin's vocabulary, and this platform had SIX unreconciled
   versions of it: .validator-ic at 48/13px literal, .feature .ic at 46/--radius, .kpi-ic at
   34/--radius-sm, .dz-ic at 52/--radius, .empty-ic at padding-14/50%, .vs-ic at 52/50%. Five
   more were about to be invented by five separate passes.

   Defined once here. It is deliberately NOT applied to the six existing selectors in this edit:
   each of them is load-bearing on a different page and three of them are sized by surrounding
   layout, so they are aliased one at a time with the page in front of me. What this does is
   give the next five findings something to land on instead of a sixth literal.
   ------------------------------------------------------------------------------------- */
.icon-tile {
    width: 48px; height: 48px; flex: 0 0 auto;
    border-radius: var(--radius-md);
    background: var(--c-surface-2); color: var(--c-muted);
    display: grid; place-items: center;
}
.icon-tile svg { width: 24px; height: 24px; }
/* The tinted variants read the same tokens the badges and alerts do, so each one flips with the
   theme and none of them needs a dark arm. */
.icon-tile.tint-brand   { background: var(--ui-soft); color: var(--ui-text); }
.icon-tile.tint-success { background: color-mix(in srgb, var(--c-success) 12%, var(--c-surface)); color: var(--c-success); }
.icon-tile.tint-warning { background: color-mix(in srgb, var(--c-warning) 14%, var(--c-surface)); color: var(--c-warning); }
.icon-tile.tint-danger  { background: color-mix(in srgb, var(--c-danger) 12%, var(--c-surface));  color: var(--c-danger); }
.icon-tile.tint-info    { background: color-mix(in srgb, var(--c-info) 12%, var(--c-surface));    color: var(--c-info); }
.icon-tile.tint-violet  { background: color-mix(in srgb, var(--c-violet) 13%, var(--c-surface));  color: var(--c-violet); }
/* A smaller step for dense rows, on the same shape. */
.icon-tile.is-sm { width: 36px; height: 36px; border-radius: var(--radius-sm); }
.icon-tile.is-sm svg { width: 18px; height: 18px; }

/* ---------- The topbar's trailing cluster ----------
   TailAdmin's header carries its utilities on the inline-end: theme, notifications, avatar.
   This platform has no notification surface in the header, so the cluster is the theme toggle
   and an identity chip. The chip is the first avatar the product has had — three separate
   proposals in the design audit already assumed one existed.
   ------------------------------------------------------------------------------------- */
.topbar-cluster { display: flex; align-items: center; gap: 10px; }
.topbar-user {
    display: inline-flex; align-items: center; gap: 9px;
    padding: 4px; padding-inline-end: 12px;
    border: 1px solid var(--c-border); border-radius: 999px;
    background: var(--c-surface); min-width: 0;
}
/* The initials tile. --radius-md would be wrong here: an avatar is round, and the pill it sits
   in is round, so the two are concentric at 50%. */
.topbar-av {
    flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%;
    display: grid; place-items: center;
    background: var(--ui-soft); color: var(--ui-text);
    font-size: var(--fs-xs); font-weight: 700; line-height: 1;
    text-transform: uppercase; user-select: none;
}
.topbar-who { display: flex; flex-direction: column; min-width: 0; }
.topbar-who .nm {
    font-size: var(--fs-sm); font-weight: 600; color: var(--c-text); line-height: 1.15;
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 18ch;
}
.topbar-who .rl { font-size: var(--fs-2xs); color: var(--c-muted); line-height: 1.15; }
/* On a phone the bar belongs to the hamburger and the page, so the name collapses to the
   avatar alone rather than competing for the row. */
@media (max-width: 640px) {
    .topbar-who { display: none; }
    .topbar-user { padding-inline-end: 4px; }
}

/* ---------- Directional arrows in link text ----------
   .flip-arrow is written in five places — including partials/list_pager.php, which renders
   under every paged list in the product — and had NO rule anywhere. U+2190 and U+2192 are
   Bidi_Mirrored=No, so the bidi algorithm leaves them pointing the same way whatever the
   paragraph direction: in Arabic "Prev ←" pointed forward and "Next →" pointed back.
   scaleX, not a different glyph, so one span works for both directions and no view changes.
   display:inline-block because a transform does not apply to an inline box. */
.flip-arrow { display: inline-block; }
[dir="rtl"] .flip-arrow { transform: scaleX(-1); }

/* ---------- Gap scale — THIS BLOCK STAYS LAST IN THE FILE ----------
   .gap-sm is moved here from the utilities block, declaration unchanged. It has to be
   below every component that sets its own gap — there are 92 of them — because they all
   weigh (0,1,0) and the later copy wins. A gap utility declared above the components it
   corrects is a no-op, which is exactly what it was.
   There is deliberately NO .gap-lg: 16px is .row's own gap, 18px is .grid's and 20px is
   .split-2's, so wanting a large gap means picking the right container, not adding a
   class. Nothing between 4px and 7px either — those round to .gap-sm.
   ANYTHING APPENDED TO THIS FILE GOES ABOVE THIS BLOCK. */
.gap-sm { gap: 8px; }
.gap-md { gap: 10px; }

/* ---------- Alignment, fill, weight and size — THE REST OF THE CORRECTING BLOCK ----
   Same rule as the gap scale above: every utility here overrides a property that
   components also set at (0,1,0), so it only works from below them. align-items is set
   by ~100 component rules, flex by a dozen, font-weight and font-size by scores.
   .row and .cluster name the direction and gap; align-items had no name at all, so 114
   inline declarations spelled it out, 66 on an element ALREADY carrying class="row".
   Composable on purpose: a .row keeps its own child rules (.row > .field + .field)
   rather than being swapped for a .cluster that does not have them. flex-start/flex-end
   rather than start/end, because in grid layout the flex-* keywords are defined to
   behave as start/end, so one pair serves flex and grid both. */
.items-center { align-items: center; }
.items-start  { align-items: flex-start; }
.items-end    { align-items: flex-end; }
/* A flexible form column: grow, but wrap before it is narrower than this. Written inline
   40 times with six different bases — six wrap points for one shape. */
.flex-fill    { flex: 1 1 220px; }
.flex-fill-lg { flex: 1 1 340px; }
/* A .muted label inside a bold line inherits the weight it is there to step back from.
   27 of the 28 inline font-weight:400 declarations sit on exactly <span class="muted">. */
.unbold { font-weight: 400; }
/* One step below .small, for the dense muted captions written inline at 11px and 12px. */
.tiny { font-size: var(--fs-2xs); }
/* A bare <h1> outside .page-head has no size anywhere in this sheet and falls back to the
   browser's 2em — which is why thirteen card pages wrote font-size:22px inline. */
.card-title { font-size: var(--fs-h1); }

/* A MEASURE CAP WITHOUT THE CENTRING. .measure-tight/-narrow/.measure/-wide all bundle
   margin-inline:auto, so they cannot be used on the thing that most often wants a
   reading measure: a paragraph of explanation under a form or a table, which must stay
   on the START edge. Same four widths, cap only. */
.cap-tight  { max-width: var(--measure-tight); }
.cap-narrow { max-width: var(--measure-narrow); }
.cap        { max-width: var(--measure); }
.cap-wide   { max-width: var(--measure-wide); }
/* A list's own indent. 15 <ul>/<ol> set 18px or 20px inline; one value, and a LOGICAL
   one — several of those were written padding-left and indented from the wrong edge in
   Arabic. */
.list-indent { padding-inline-start: 20px; }
/* The air above and below a full-width page band. Eight write `padding: 40px 0`, which
   is padding-block with an explicit zero the element already had. */
.pad-block { padding-block: 40px; }
/* HIDDEN, BUT OVERRIDABLE. .hide is display:none !important, which is correct for a
   thing that must stay hidden and useless for the four panels a script later reveals —
   !important cannot be undone by setting element.style.display. This one can. */
.is-hidden { display: none; }
/* A column that must NOT grow. .flex-fill and .flex-fill-lg both grow; five sites want
   the opposite and say flex:0 0 auto inline. A basis other than auto stays inline —
   that is a layout decision, not a utility. */
.flex-none { flex: 0 0 auto; }
/* A .cluster THAT MUST NOT WRAP, and the reason the vocabulary needed it: .cluster
   carries flex-wrap:wrap, and on a row holding a <select> — which takes width:100% from
   the base control rule and so claims the whole line as its flex base — wrapping pushes
   the button after it onto a second line in EVERY row of the table. That made one
   conversion a critical in the first sweep with no way to express the fix. Last in the
   file, because .cluster sets flex-wrap at the same (0,1,0). */
.cluster-nowrap { flex-wrap: nowrap; }
/* A SELECT INSIDE A NOWRAP CLUSTER MUST NOT WANT THE WHOLE LINE. The base control rule
   gives every select width:100%, so in a nowrap cluster its flex base size fills the row
   and whatever follows is squashed — on /owner/crons the Set button was crushed to 32px
   and the select's label clipped to one letter under the chevron. width:auto lets it size
   to its longest option; the inline-end inset gives the chevron its gutter back.
   THE CHEVRON OFFSET READS --sel-chev rather than naming a side. That is the whole point
   of the property: this rule is mirrored in Arabic by the one [dir="rtl"] select arm,
   with no arm of its own to forget. (0,2,1) and last in the file. */
.cluster-nowrap > select {
    width: auto; min-height: 40px; padding-block: 5px; padding-inline: 11px 30px;
    font-size: var(--fs-xs); background-position: var(--sel-chev) 10px center;
}
/* ---------- The margin scale, moved here from the middle of the file ----------
   THE PRICE OF BEING LAST, which this comment did not state and should have.
   `.m-0` is a SHORTHAND. A shorthand resets every longhand in its family whether or not
   it names one, so from down here `.m-0` also wipes margin-inline and margin-block set by
   anything declared above — and `.measure-tight`, `.measure-narrow` and `.measure-wide`
   (2859-2862) all carry `margin-inline: auto` for their centring. `class="m-0
   measure-tight"` therefore renders capped but NOT centred, which is exactly what
   shipped on public/install_howto.php from v124 until ui/proporder.php found it.
   So: pair a MEASURE class with `.mb-0` (or `.mt-0`), never with `.m-0`. The longhands in
   this block are safe to stack with anything; only the shorthand is not. Moving the
   scale back up is not the alternative — that is what was silently beating
   `class="kpi-row mb-0"` on every dashboard before stage32.
   Same rule as the gap scale and the alignment utilities above: a utility that
   corrects a property components also declare only works from BELOW them. .kpi-row,
   .dash-grid and .inherit-note all set a margin at (0,1,0) and all sit above this
   point, so from the old position `class="kpi-row mb-0"` and
   `class="inherit-note mt-0"` were silently doing nothing.
   .m-0 STAYS FIRST within the group: it is the shorthand the longhands refine, so it
   is the one utility here that must precede its neighbours rather than follow them. */
.m-0 { margin: 0; }
.mt-0 { margin-top: 0; } .mt-1 { margin-top: 8px; } .mt-2 { margin-top: 16px; } .mt-3 { margin-top: 24px; }
.mb-0 { margin-bottom: 0; } .mb-2 { margin-bottom: 16px; } .mb-3 { margin-bottom: 24px; }
/* A TYPE UTILITY ON A FORM CONTROL NEEDS THE ELEMENT. The base control rule is
   `.input, input:not([type]), input[type=text], …` — (0,1,1), because an attribute
   selector weighs the same as a class — so a bare .small never reached the nine
   inputs, selects and textareas that wear it. Same trap as .input-sm, and the sheet
   records it at line 1776. */
input.small, select.small, textarea.small { font-size: var(--fs-sm); }
/* AND .mono ON AN <input>, the same trap one property over. The base control rule sets
   `font: inherit` — the SHORTHAND, so it resets font-family to the body face as well as
   the size — and .mono at (0,1,0) lost to its (0,1,1) on all 19 inputs that wear it in
   13 views, the licence key and the write-once secret among them. select and textarea are
   matched only by their bare element arms at (0,0,1), so .mono already reaches those and
   no arm is needed for them here. */
input.mono { font-family: var(--font-mono); }
/* AND .mono, THE SAME TRAP AND THE SAME ARM. `.mono` at line 251 is (0,1,0); the base
   control rule declares `font: inherit` — and the `font` shorthand resets font-family —
   at (0,1,1), later in the sheet. So the class was a no-op on every input, select and
   textarea in the tree: 19 inputs and 5 textareas across 15 views, holding activation
   URLs, licence keys, API secrets and a cron expression — exactly the strings where
   1/l/I and 0/O have to be told apart. input.num-in below already element-qualified it
   for ONE field; this is the general arm, and it needs no markup change anywhere. */
input.mono, select.mono, textarea.mono { font-family: var(--font-mono); }
/* AND .small ON THE CELL ITSELF, which is the case that was never covered. `table.data`
   sets font-size: var(--fs-sm) on every cell, and .small IS --fs-sm — so a
   `<td class="muted small">` resolved to exactly the size it already had: 296 cells in
   this tree writing a class that changes nothing. The rule meant to do it,
   `table.data td .small` above, is a DESCENDANT combinator — it reaches a <span> inside
   the cell (12.5px) and cannot reach the cell, which is why /owner/plans renders its
   nested description SMALLER than the `small` cell beside it. (0,2,2) and nothing else
   declares font-size on a td. No th arm: `table.data th` is (0,1,2) and already sets
   --fs-xs, so the two `<th>` carrying `small` are unaffected. */
table.data td.small { font-size: var(--fs-xs); }
/* THE FIVE STEPS stage32 LEFT BEHIND, now beside the eight it moved. Same reason: a
   margin utility corrects a property components also declare, so it only wins from
   below them. */
.mt-xs { margin-top: 4px; } .mb-xs { margin-bottom: 4px; } .mb-1 { margin-bottom: 8px; }
.mt-sm { margin-top: 12px; } .mb-sm { margin-bottom: 12px; }
/* The helpdesk token field, same cause: a font-size on an <input> at (0,1,0). */
input.hd-token { font-size: var(--fs-sm); }
/* A SECONDARY CARD TITLE. Measured first: 141 card headers carry a bare <h2> at --fs-lg
   and that is the primary size. The step below it is real rather than drift — a repeated
   item header (one per landing section, one per client logo) and the "Add …" form card
   beside a primary list — and it was being said three ways: a bare <h3> (17, which the
   element rule already renders at exactly this size), an <h3 class="small"> (6, a third
   step down inside nested cards), and five <h2> with an inline font-size.
   Those five exist because heading LEVEL and SIZE got conflated. The level is an outline
   decision — they are correct as <h2> directly under the page <h1> — and the size is a
   visual one. This keeps the level and changes the size.
   Element-qualified because `.card h2` is (0,1,1); a plain utility would lose to it. */
h1.card-title-sm, h2.card-title-sm, h3.card-title-sm { font-size: var(--fs-base); }

/* ---------- table.data.data-form: a SETTINGS GRID, not a list ----------
   /owner/aliphia's Stage flow is nine rows of form controls across eight columns, and every
   cell is inset by the table's own --table-pad-x, 24px a side. On the 1101px card the seven
   non-prose columns are fixed (104 + 168 + 168 + 76 + 104 + 104 + 168 = 892), so a w-md
   column hands its input 168 - 48 = 120px, and `Client registered`, `ACCOUNT_ACTIVATED` and
   `ALIPHIA_INVOICE_RAISED` each showed about half of themselves.
   NOTHING COULD BE BORROWED FROM A NEIGHBOUR: measured, w-lg on two columns still left ten
   values clipped AND made the table 97px taller, because the 80px came straight out of the
   stage description. The gutter is the only pool left: 8 columns at 48px is 384px, 35% of
   the table. Halving the INTERIOR of it costs no width at all -- the th widths do not move,
   so the table stays at its wrapper's width and nothing scrolls.
   THE FIRST AND LAST CELL KEEP --table-pad-x. That is the inset the card header, the toolbar
   and the footer are aligned to, and it is the only alignment the design system governs; the
   interior gutters line up with nothing.
   MIN-WIDTH 781 IS THE COMPLEMENT OF table.data.data-cards' max-width:780, not a new
   breakpoint. Below it behaviours.js has already turned every row into a card and
   `table.data.data-cards td { padding: 0 }` is the shape that must win. That rule is (0,2,2)
   and so is this one, so without the bound this one would out-ORDER it from the end of the
   file and inset every field inside the phone card.
   padding-inline and the logical longhands, so the two outer insets stay on the table's
   outer edges in Arabic. */
@media (min-width: 781px) {
  table.data.data-form th,
  table.data.data-form td { padding-inline: calc(var(--table-pad-x) / 2); }
  table.data.data-form th:first-child,
  table.data.data-form td:first-child { padding-inline-start: var(--table-pad-x); }
  table.data.data-form th:last-child,
  table.data.data-form td:last-child { padding-inline-end: var(--table-pad-x); }
}
