/*
 * Expenset — base stylesheet. The single home of design tokens and the ex- class prefix.
 * Renaming the framework is a find-replace of `ex-` and `--ex-` here and in templates/
 * (docs/css.md, STANDARDS.md 10.4). Our own CSS — no framework, no CDN (on-prem, offline).
 *
 * Ported from our payroll product's design system, prefix-renamed, with its payroll-specific
 * components removed (decisions.md 2026-08-04: port what is generic and visible, write what is
 * domain-shaped). A comment here naming a payslip or a pay run is a leftover — fix it or delete it.
 */

:root {
  /* Neutral slate palette — a calm back-office tool, not a billboard. */
  /* THE PARTS OF THE SCREEN THE HARDWARE OWNS, behind one name each.
   *
   * env(safe-area-inset-*) reports 0 in every desktop browser and 34px at the bottom of an installed
   * iPhone, so anything that depends on it is correct on a laptop and wrong on a phone, invisibly.
   * Two faults shipped that way: the tab bar's clearance counted the bottom inset twice, and the
   * page-loading bar sat at top 0, which on an installed app is behind the status bar.
   *
   * Named rather than called inline so a test can set them to an iPhone's numbers and measure what
   * happens. env() cannot be faked; a custom property can, and that is the whole reason these exist. */
  --ex-safe-top: env(safe-area-inset-top, 0px);
  --ex-safe-bottom: env(safe-area-inset-bottom, 0px);
  --ex-safe-left: env(safe-area-inset-left, 0px);
  --ex-safe-right: env(safe-area-inset-right, 0px);

  --ex-color-bg: #f6f7f9;
  --ex-color-surface: #ffffff;
  /* THE FOURTH STEP, and the palette's own rules implied it before anything defined it.
     Rule 2 below says depth in dark comes from the fill — "page, then card, then control" — but a
     dialog is none of those. It is the frontmost thing on the screen and it was borrowing the card's
     colour, separated only by a shadow, which rule 2 says in the same breath does almost nothing on a
     dark ground. So a modal sat at exactly the lightness of the card behind it. Anything that floats
     above the page takes this: the dialog, the dropdown panel, the menus, the toast.
     In light it is white, the same as a card — there, depth comes from the shadow, which works. */
  --ex-color-raised: #ffffff;
  --ex-color-border: #e2e5ea;
  /* What sits behind a dialog. Light dims a bright page and that is most of the work; dark cannot dim
     a dark page, so its separation comes from the blur and from the raised fill above. */
  --ex-color-scrim: rgba(16, 24, 40, 0.5);
  --ex-scrim-blur: 3px;
  --ex-color-text: #1c2530;
  --ex-color-text-muted: #5b6675;
  --ex-color-text-faint: #9aa3b0;
  --ex-color-accent: #1f6feb;
  --ex-color-accent-hover: #1a5fd0;
  --ex-color-accent-contrast: #ffffff;
  --ex-color-accent-soft: #eaf1fd;

  /* Form controls — the three states a field moves through. At rest a control sits back on a tinted
     fill so a form reads as a calm block of labels; hover lifts the fill toward white and firms the
     border; focus takes it all the way to the surface colour with a border darkened to the text
     colour. A progression carried by fill and border alone — no halo, no coloured bar. */
  --ex-color-control: #eef0f4;
  --ex-color-control-hover: #f8f9fb;
  --ex-color-control-border: #dde1e8;
  --ex-color-control-border-hover: #b4bcc8;

  /* Status — used by flashes and field errors; defined once, not sprinkled as raw hex. */

  /* THE TWO FILLS BEHIND A FILLED STATUS BUTTON, and they are separate tokens because a colour cannot
   * do both of this palette's jobs in dark mode. It is arithmetic rather than taste.
   *
   * --ex-color-gain and --ex-color-danger are TEXT colours: a gain figure in a table, an error under a
   * field, both read against the page's own ground. On a dark page that means they have to be light --
   * to clear 4.5:1 against #0f1218 a colour needs a relative luminance of at least 0.203. A FILL behind
   * white text has to go the other way: to clear 4.5:1 against white it needs a luminance of at most
   * 0.183. The two ranges do not touch, so one value cannot serve both, and in light mode the question
   * never comes up because the same dark green and red satisfy both at once.
   *
   * Hence: these ARE gain and danger in light mode, and their own deeper values in dark. Each is picked
   * so white reads on it and it still stands clear of the page rather than melting into it -- 4.53:1 and
   * 4.14:1 for the green, 4.54:1 and 4.13:1 for the red, which is deliberate: the two sit side by side
   * on a blotter row and a pair that weighed differently would read as one being louder than the other. */
  --ex-color-approve-fill: var(--ex-color-gain);
  --ex-color-danger-fill: var(--ex-color-danger);

  --ex-color-success: #16a34a;
  --ex-color-success-bg: #eef8f0;
  --ex-color-danger: #dc2626;
  --ex-color-danger-hover: #b91c1c;
  --ex-color-danger-bg: #fdeeec;
  --ex-color-warning: #d97706;
  --ex-color-warning-bg: #fef3e2;
  --ex-color-info: #1f6feb;
  --ex-color-info-bg: #eaf1fd;

  /* The financial semantic tokens (docs/css.md). These carry meaning, not decoration, so they are
     tokens before the first screen needs one — otherwise the first valuation screen invents a green
     and the second invents a different one.

     Gain and loss are deliberately NOT the success and danger colours. A loss is not an error and a
     gain is not a confirmation; a book that fell this month is reporting correctly, and colouring
     that with the same red as a failed save teaches people to read a normal market as a fault.

     Colour is never the only signal (docs/css.md): a loss is in parentheses as well as in the loss
     colour, a stale price shows its age in text as well. Roughly one man in twelve has a colour
     vision deficiency, and a board pack gets photocopied in black and white. */
  --ex-color-gain: #0f7a4d;
  --ex-color-loss: #b4232a;
  --ex-color-stale: #8a6d1f;
  /* Darker than loss, and deliberately not the same value. A loss is the market; a breach is a
     statutory limit exceeded, with a three-month notification clock running (SI 50 First Schedule
     ¶6(3)). Two tokens holding the same colour would merge them the first time somebody scans a
     page for the thing that needs reporting. */
  --ex-color-breach: #7f1218;
  --ex-color-pending: #5a6270;

  /* Alert triangle, as a mask so it inherits the danger colour. Used by .ex-form__error's icon. */
  --ex-icon-alert: 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' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z'/%3E%3Cline x1='12' y1='9' x2='12' y2='13'/%3E%3Cline x1='12' y1='17' x2='12.01' y2='17'/%3E%3C/svg%3E");

  /* Every control is this tall — inputs, selects, date fields, buttons. One token, because a button
     standing next to an input at a different height is the most visible sloppiness in a form and the
     hardest to fix later, once each control has grown its own padding. */
  --ex-control-height: 2.375rem;
  --ex-control-height-sm: 2rem;

  /* A BUTTON INSIDE A TABLE ROW, which is the one control that does not take the 44px touch floor.
     Declared as a token rather than a literal so the size and the test that measures it read the same
     number. The reasoning is where the rule is, in the coarse block near .ex-table td .ex-btn. */
  --ex-table-action-size: 2.125rem;

  /* Control padding — the inset inside something you type in or press.
     Deliberately NOT on the --ex-space scale. That scale is a 4px layout rhythm for the gaps between
     things; these are optical values picked so a control's text sits centred inside --ex-control-height.
     Rounding them onto the 4px grid would move every control's height, which is the one measurement the
     whole form layout hangs off.

     Only the values used in more than one place are tokens. A genuine one-off — a calendar cell, a pill,
     a stepper — keeps its literal, because tokenising a one-off is how a token set stops meaning
     anything (docs/css.md: add a token when a value is used more than once or carries meaning). */
  --ex-pad-control: 0.5rem 0.75rem;              /* a text control: input, textarea, select, datepicker */
  --ex-pad-search: 0.45rem 0.6rem 0.45rem 2rem;  /* a search input; the left inset clears its icon */

  /* Spacing — a 4px scale. */
  --ex-space-1: 0.25rem;
  --ex-space-2: 0.5rem;
  --ex-space-3: 0.75rem;
  --ex-space-4: 1rem;
  --ex-space-5: 1.25rem;
  --ex-space-6: 1.5rem;
  --ex-space-8: 2rem;

  /* Typography — system stack; no web fonts to fetch on an offline box.
     Two sizes, on purpose. --ex-font-size is the rem *base*: it sets what every rem in this file means,
     so the spacing scale, the sidebar width and the radii all hang off it. --ex-font-body is the size
     text is actually set in. Keeping them apart is what lets the type come down without the whole app
     shrinking with it — change the body size to retune the reading, the base to rescale the layout. */
  --ex-font-sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  /* THE REM BASE, AND THE ONE THING A TEXT-SIZE SETTING HAS TO CHANGE.
   *
   * Every size in this stylesheet is a rem read from this, so moving it moves the whole product
   * together: type, spacing, control heights and the tap targets with them. A setting that scaled
   * only the type would leave a larger word in a button that had not grown.
   *
   * The stored preference overrides it before the first paint, the same way the theme does, because
   * a page that renders at one size and jumps to another is worse than one that was always wrong. */
  --ex-font-size: var(--ex-text-scale, 16px);
  --ex-font-body: 0.875rem;
  --ex-line-height: 1.5;

  /* THE TYPE SCALE, and it did not exist.
     Colour, spacing and radius were tokens from the first commit; type was not, so every component
     picked its own number and the font sizes in this file drifted into eighteen different values. Four
     of them were a real scale (11, 12, 13, 14px) and the rest was drift: 15px and 17px both meaning
     "a bit bigger than body", a 12.5px pill, a 0.95rem heading, three different em sizes for inline
     code. Nobody chose that; it accumulated, one component at a time, exactly the way a colour palette
     would have if the colours had not been named.

     Every absolute font-size in this file now reads a token, so the drift cannot start again quietly: a
     literal is visible in a diff in a way that "one more component picking 15px" never was. Relative
     sizes are the deliberate exception, and there is one of them below.

     Nine steps, named by size rather than by role. Role names (--ex-text-title) read better until two
     things that are not titles need the same size, and then they lie. */
  --ex-text-2xs: 0.6875rem;  /* 11px — a timestamp, a field label, the smallest thing we set */
  --ex-text-xs: 0.75rem;     /* 12px — hints, badges, table headers */
  --ex-text-sm: 0.8125rem;   /* 13px — dense body: table cells, notes, list rows */
  --ex-text-md: 0.875rem;    /* 14px — body. The same value as --ex-font-body, deliberately */
  --ex-text-lg: 1rem;        /* 16px — a card or modal title */
  --ex-text-xl: 1.125rem;    /* 18px — a page title */
  --ex-text-2xl: 1.375rem;   /* 22px — a standalone heading: sign-in, an error page */
  --ex-text-3xl: 2rem;       /* 32px — a number that is the whole point of its screen */
  --ex-text-4xl: 3.5rem;     /* 56px — an error code, and nothing else */

  /* Monospace inside flowing text, and relative ON PURPOSE where the rest of the scale is absolute.
     A code span sits inside whatever is around it — a 13px table cell or a 16px card title — and a
     fixed size would be too big in one and too small in the other. One value, where there were three
     (0.88em, 0.9em, 0.72em) doing the same job by accident. */
  --ex-text-inline: 0.9em;

  --ex-radius: 6px;
  --ex-radius-lg: 10px;
  --ex-border: 1px solid var(--ex-color-border);
  --ex-shadow: 0 1px 2px rgba(16, 24, 40, 0.06);
  --ex-shadow-lg: 0 8px 24px rgba(16, 24, 40, 0.12);

  --ex-sidebar-width: 15rem;
  --ex-sidebar-collapsed: 3.75rem;
  --ex-topbar-height: 3.75rem;
  --ex-content-max: 60rem;
  --ex-transition: 0.16s ease;
}

/* Dark ------------------------------------------------------------------------

   Only the tokens move. Not one component below this point knows which theme is on, which is the
   whole return on having had tokens from the first line of CSS.

   Three rules the palette follows:

   1. **Not inverted.** Pure white text on pure black is the classic mistake: the contrast is so hard
      that text blooms and long reading hurts. The darkest surface is #0f1218 and the brightest text
      #e7eaf0 — a strong contrast, short of the maximum.
   2. **Surfaces get lighter as they come forward**, the opposite of the light theme where they get
      whiter and gain shadow. Shadow does almost nothing on a dark ground, so depth has to come from
      the fill: page, then card, then control.
   3. **The status colours are lifted, not reused.** A 16a34a green that reads well on white is muddy
      on charcoal. Each one moves up in lightness until it carries its meaning again.

   Two selectors, deliberately. The media query is the default — somebody whose machine is in dark
   mode gets a dark application without being asked. The attribute is an explicit choice and beats it
   both ways, so a person who wants light on a dark laptop can have it. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ex-color-bg: #0f1218;
    --ex-color-surface: #171b23;
    /* A real step above the card, because this is the only thing separating a dialog from the page in
       dark — a shadow does not read here, and a scrim cannot dim what is already dark. */
    --ex-color-raised: #1f2531;
    --ex-color-border: #272d39;
    /* Nearly black and much more opaque than the light scrim, plus a heavier blur. Dimming a dark page
       by half with a dark navy changes almost nothing, which is why a modal read as floating on nothing
       in dark mode; going to black at 0.66 and blurring properly puts the page visibly behind glass. */
    --ex-color-scrim: rgba(0, 0, 0, 0.66);
    --ex-scrim-blur: 8px;
    --ex-color-text: #e7eaf0;
    --ex-color-text-muted: #a3adbd;
    --ex-color-text-faint: #6d7788;
    --ex-color-accent-contrast: #ffffff;
    --ex-color-accent-soft: #1a2537;
    --ex-color-control: #1d222c;
    --ex-color-control-hover: #232936;
    --ex-color-control-border: #303747;
    --ex-color-control-border-hover: #465063;
    /* Not gain and danger here: on this ground those are the mint and the salmon that read as TEXT, and
       a button filled with either is a button white cannot sit on. Deep enough for white, light enough
       to keep an edge against the page. Weighted to match each other. */
    --ex-color-approve-fill: #12875a;
    --ex-color-danger-fill: #d24343;
    --ex-color-success: #3dd68c;
    --ex-color-success-bg: #10261c;
    --ex-color-danger: #f87171;
    --ex-color-danger-hover: #ef5350;
    --ex-color-danger-bg: #2a1519;
    --ex-color-warning: #fbbf24;
    --ex-color-warning-bg: #2a2010;
    --ex-color-info: #60a5fa;
    --ex-color-info-bg: #14213a;
    --ex-color-gain: #3dd68c;
    --ex-color-loss: #f87171;
    --ex-color-stale: #d9b44a;
    --ex-color-breach: #ff8f8f;
    --ex-color-pending: #94a3b8;
    --ex-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
    --ex-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.55);
    color-scheme: dark;
  }
}

:root[data-theme="dark"] {
  --ex-color-bg: #0f1218;
  --ex-color-surface: #171b23;
  --ex-color-raised: #1f2531;
  --ex-color-border: #272d39;
  --ex-color-scrim: rgba(0, 0, 0, 0.66);
  --ex-scrim-blur: 8px;
  --ex-color-text: #e7eaf0;
  --ex-color-text-muted: #a3adbd;
  --ex-color-text-faint: #6d7788;
  --ex-color-accent-contrast: #ffffff;
  --ex-color-accent-soft: #1a2537;
  --ex-color-control: #1d222c;
  --ex-color-control-hover: #232936;
  --ex-color-control-border: #303747;
  --ex-color-control-border-hover: #465063;
  --ex-color-approve-fill: #12875a;
  --ex-color-danger-fill: #d24343;
  --ex-color-success: #3dd68c;
  --ex-color-success-bg: #10261c;
  --ex-color-danger: #f87171;
  --ex-color-danger-hover: #ef5350;
  --ex-color-danger-bg: #2a1519;
  --ex-color-warning: #fbbf24;
  --ex-color-warning-bg: #2a2010;
  --ex-color-info: #60a5fa;
  --ex-color-info-bg: #14213a;
  --ex-color-gain: #3dd68c;
  --ex-color-loss: #f87171;
  --ex-color-stale: #d9b44a;
  --ex-color-breach: #ff8f8f;
  --ex-color-pending: #94a3b8;
  --ex-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
  --ex-shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.55);
  color-scheme: dark;
}

/* THE BREAKPOINT SCALE ---------------------------------------------------------
 *
 * Two steps, and they are written out as literals rather than tokens because a token cannot work here.
 * `@media (max-width: var(--ex-bp-md))` is not unsupported, it is meaningless: a media query is
 * evaluated against the viewport before there is any element to resolve a custom property against, so
 * there is nothing for var() to read. Every other scale in this file is a token; this one cannot be, and
 * that is exactly why it needs something else keeping it honest. BreakpointTest fails the build if a
 * width query appears that is not on this list.
 *
 *   34rem   544px   Below this a two-column form gives each column less width than the labels standing
 *                   in it, so the grids drop to one column.
 *   48rem   768px   Below this a sidebar and a readable content column cannot both fit, so the rail
 *                   becomes an off-canvas drawer and the top bar sheds everything that is not telling
 *                   you where you are.
 *   60rem   960px   Below this a report's filter cannot put its action beside the fields it filters:
 *                   a portfolio, a period and a button need about that much before the button stops
 *                   squeezing the period into eight characters a side. Above it the filter is three
 *                   columns and the button sits on the control line; below it, one column and the
 *                   button takes its own row, which is right on a tablet.
 *
 * A fourth step is a decision, not a convenience. It goes on this list with the sentence saying what
 * stops working below it, and the test refuses the build until it does — which is the point, because
 * the way a stylesheet ends up with nine breakpoints is one at a time, each obvious to whoever added it.
 * The transaction grid and the maturity ladder will probably want one; they should have to argue for it.
 *
 * REM HERE, AND WHAT IT MEASURES — the part that surprises people.
 *
 * In a media query `rem` is the BROWSER'S default text size, not this document's. It is not
 * --ex-font-size and it is not a hardcoded 16. That is deliberate and it is the accessibility win:
 * somebody who has set their browser to 20px because they cannot read 16 gets every breakpoint 25%
 * further out, because their whole world is bigger and the rail should give up sooner for them.
 *
 * It does NOT move with the in-app text size, and it should not. That setting is a zoom applied inside a
 * viewport whose physical width has not changed — the screen is the same screen — so it must not shift
 * the point at which a sidebar stops fitting on it. What it does change is how much fits before that
 * point, and that is a real consequence rather than a theoretical one: at Large, the same window holds
 * about an eighth less. ResponsiveLayoutIT sweeps the narrow widths at every text size for exactly this
 * reason, so the combination is measured rather than assumed. */

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

/* The hidden attribute must always win, even over display:flex/inline-flex components. */
[hidden] { display: none !important; }

html {
  font-size: var(--ex-font-size);
  /* iOS inflates text in a column it decides is too narrow, which fires on rotating a phone: the type
     grows, nothing else does, and a table that fitted a moment ago no longer does. 100% keeps the size
     we asked for. Not a zoom lock — pinch still works, and the text size setting still applies. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  /* No pull-to-refresh. The shell does not scroll — .ex-scroll does — so a downward drag at the top of
     a list would reload the page instead, discarding a part-filled form to no purpose. */
  overscroll-behavior-y: none;
}

body {
  margin: 0;
  font-family: var(--ex-font-sans);
  font-size: var(--ex-font-body);
  line-height: var(--ex-line-height);
  color: var(--ex-color-text);
  background: var(--ex-color-bg);
}

h1, h2, h3, p { margin: 0; }

.ex-icon {
  width: 1.15em;
  height: 1.15em;
  flex: none;
}

/* App shell — sidebar + main column -------------------------------------

   The shell is exactly one screen tall and never scrolls. The sidebar and the top bar stay put
   because they are simply always on screen, not because they are stuck to something that moves under
   them, and the content region does the scrolling. That is what makes this read as an application
   rather than a document: the page furniture doesn't drift, and a long table can't push the top bar
   off the top of the screen.

   dvh, not vh: on a phone, 100vh is the height with the browser chrome *hidden*, so a vh-tall shell
   is always taller than what you can actually see and the bottom of the sidebar sits under the URL
   bar. dvh tracks the real viewport. */

html { height: 100%; }

.ex-app {
  display: grid;
  grid-template-columns: var(--ex-sidebar-width) 1fr;
  height: 100dvh;
  overflow: hidden;
  transition: grid-template-columns var(--ex-transition);
}

.ex-collapsed .ex-app {
  grid-template-columns: var(--ex-sidebar-collapsed) 1fr;
}

/* Full height of the shell, so it needs no stickiness of its own. `overflow: visible` is deliberate:
   the collapsed rail's submenus fly out sideways and must escape the sidebar's box. The nav list
   inside it scrolls instead (below), which keeps the flyouts free. */
.ex-sidebar {
  height: 100%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: var(--ex-space-3);
  background: var(--ex-color-surface);
  border-right: var(--ex-border);
  overflow: visible;
}

.ex-sidebar__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ex-space-2);
  padding: var(--ex-space-2) var(--ex-space-2) var(--ex-space-4);
}

.ex-sidebar__brand {
  font-weight: 700;
  font-size: var(--ex-text-xl);
  letter-spacing: 0.01em;
  white-space: nowrap;
  overflow: hidden;
}

.ex-sidebar__toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 2rem;
  height: 2rem;
  padding: 0;
  color: var(--ex-color-text-muted);
  background: none;
  border: none;
  border-radius: var(--ex-radius);
  cursor: pointer;
  font-size: var(--ex-text-md);
  transition: background var(--ex-transition), color var(--ex-transition), transform 0.06s ease;
}

.ex-sidebar__toggle:hover {
  background: var(--ex-color-bg);
  color: var(--ex-color-text);
}

.ex-sidebar__toggle:active,
.ex-topbar__menu:active { transform: translateY(1px); }

/* Nav ------------------------------------------------------------------- */

.ex-nav {
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-1);
}

/* The sections scroll if they outgrow the sidebar; Admin stays pinned to the bottom regardless. When
   collapsed the submenus are absolute flyouts, and a scroll container would clip them — so the rail
   doesn't scroll. It holds icons only, which fit. */
.ex-nav--sections {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
}

.ex-collapsed .ex-nav--sections { overflow: visible; }

.ex-nav--bottom {
  margin-top: auto;
  padding-top: var(--ex-space-3);
  border-top: var(--ex-border);
}

.ex-nav__item {
  display: flex;
  align-items: center;
  gap: var(--ex-space-3);
  width: 100%;
  padding: var(--ex-space-2) var(--ex-space-3);
  border: none;
  border-radius: var(--ex-radius);
  background: none;
  color: var(--ex-color-text-muted);
  font: inherit;
  font-size: var(--ex-text-md);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  white-space: nowrap;
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-nav__item:hover {
  background: var(--ex-color-bg);
  color: var(--ex-color-text);
}

.ex-nav__item:active {
  background: var(--ex-color-border);
}

.ex-nav__item--active {
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
  font-weight: 600;
}

.ex-nav__label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Collapsed (desktop icon rail) ----------------------------------------- */

.ex-collapsed .ex-sidebar__brand,
.ex-collapsed .ex-nav__label {
  display: none;
}

.ex-collapsed .ex-sidebar__head {
  justify-content: center;
}

.ex-collapsed .ex-nav__item {
  justify-content: center;
  padding-left: 0;
  padding-right: 0;
}

/* Held still across the collapse/expand switch, so the sidebar's width animates but the items in it
   do not slide about while it does. JS sets .ex-anim-off for one frame. */
.ex-anim-off .ex-nav__item { transition: none; }

/* Main column ----------------------------------------------------------- */

.ex-main {
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  overflow: hidden;
}

/* Not sticky — it is a fixed row of a shell that never scrolls, so it cannot go anywhere. z-index
   keeps its popovers (notifications, the account menu) above the scrolling content. */
.ex-topbar {
  position: relative;
  z-index: 20;
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--ex-space-4);
  min-height: var(--ex-topbar-height);
  padding: var(--ex-space-3) var(--ex-space-6);
  background: var(--ex-color-surface);
  border-bottom: var(--ex-border);
}

.ex-topbar__title {
  font-size: var(--ex-text-xl);
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Everything on the right, on one line — bell and user never wrap onto a second row. */
.ex-topbar__meta {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
  flex: none;
}

.ex-topbar__menu {
  display: none;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  color: var(--ex-color-text-muted);
  background: none;
  border: none;
  border-radius: var(--ex-radius);
  cursor: pointer;
}

.ex-topbar__menu:hover { background: var(--ex-color-bg); }

/* The build stamp. Quiet, and the first thing to go when the bar gets narrow — it answers "which build
   is that?" on a support call, which is not a question anybody is asking on a phone. */
.ex-topbar__version {
  font-size: var(--ex-text-2xs);
  color: var(--ex-color-text-faint);
  white-space: nowrap;
}

/* The one scrolling region in the app. It is a separate element from .ex-content so the scrollbar
   sits at the edge of the window where a scrollbar belongs, not at the edge of the centred column. */
.ex-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* THE CONTAINING BLOCK FOR THE PULL-TO-REFRESH INDICATOR, which sits absolutely at the top of this
     scroller. Without it the indicator is laid out against whatever is positioned further up and
     appears somewhere else entirely — the same fault that put a clipped table label against the
     document and made six screens scroll sideways. Learned once, applied here before it happened. */
  position: relative;
}

.ex-content {
  width: 100%;
  max-width: var(--ex-content-max);
  margin: 0 auto;
  padding: var(--ex-space-6);
}


.ex-scrim {
  position: fixed;
  inset: 0;
  background: rgba(16, 24, 40, 0.4);
  z-index: 15;
}

/* Page ------------------------------------------------------------------- */

.ex-page__lead {
  margin-bottom: var(--ex-space-6);
  color: var(--ex-color-text-muted);
}

/* Cards ------------------------------------------------------------------ */

.ex-cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr));
  gap: var(--ex-space-5);
}

.ex-card {
  display: block;
  background: var(--ex-color-surface);
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  box-shadow: var(--ex-shadow);
  padding: var(--ex-space-6);
}

.ex-card__title { font-size: var(--ex-text-lg); font-weight: 600; margin-bottom: var(--ex-space-2); }
/* A card's description is a step up from a field hint, deliberately. Both were --ex-text-xs, which is
   right for the line under an input — glanced at, next to the thing it describes — and too small for a
   paragraph somebody is expected to read before deciding what to do on the whole card. */
.ex-card__text { color: var(--ex-color-text-muted); font-size: var(--ex-text-sm); line-height: 1.5; }

/* A card's header row: the title, and any action belonging to the card rather than to the page.
   Divided from the body the way a table divides its head, so a card carrying several sections still
   reads as titled rather than as one run of text.

   These two were written on four screens before they existed here, matching nothing — the card's own
   padding happened to carry the layout, so it looked deliberate (docs/lessons-learned.md 2026-08-05).
   The divider is the reason to have a head element at all. */
.ex-card__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--ex-space-4);
  padding-bottom: var(--ex-space-3);
  margin-bottom: var(--ex-space-4);
  border-bottom: var(--ex-border);
}
.ex-card__head .ex-card__title { margin-bottom: 0; }

/* THE TITLE NEVER SHRINKS TO MAKE ROOM FOR ITS OWN DESCRIPTION, and the description takes the whole
   next line.

   The head is a flex row so a card-level action can sit opposite the title, which is right. But a
   paragraph of explanation is a flex item too, and left to itself it negotiates with the title as an
   equal — so a long write-up beside a short heading squeezes the HEADING until it wraps, which reads
   as broken rather than as full. `flex: 1 0 100%` on the description pushes it onto its own line at
   full width, leaving the title and any action on the first line where they belong. */
.ex-card__head > .ex-card__title { flex: 0 0 auto; }
.ex-card__head > .ex-card__text { flex: 1 0 100%; }

/* The body's job is to make the card's padding exact: a heading or paragraph arriving with its own
   margin would otherwise add to it, and cards would sit at slightly different heights across a row. */
.ex-card__body > :first-child { margin-top: 0; }
.ex-card__body > :last-child { margin-bottom: 0; }

/* A static colour swatch — a token being shown, not a colour being chosen. The picker's swatch is a
   button (.ex-swatches__item) and carries a pointer cursor and a hover ring with it, which is wrong
   for something nobody can click. */
.ex-swatch {
  display: block;
  width: 100%;
  height: 2.5rem;
  border: 1px solid rgba(16, 24, 40, 0.12);
  border-radius: var(--ex-radius);
}

/* A card in the grid stands as its own tile — full height of its row, content pinned top and bottom. */
.ex-cards > .ex-card {
  display: flex;
  flex-direction: column;
  min-height: 9rem;
}

/* Clickable cards (navigation, settings) feel interactive without moving: the accent border and an
   inset accent rail arrive, the shadow deepens, the title colours, and an arrow reveals. No shift. */
a.ex-card {
  color: inherit;
  text-decoration: none;
  transition: box-shadow var(--ex-transition), border-color var(--ex-transition);
}

/* The arrow lives after the title, holding its space transparently so nothing reflows on hover. */
a.ex-card .ex-card__title::after {
  content: "\2192";
  margin-left: var(--ex-space-2);
  color: transparent;
  transition: color var(--ex-transition);
}

a.ex-card:hover {
  border-color: var(--ex-color-accent);
  box-shadow: var(--ex-shadow-lg), inset 3px 0 0 var(--ex-color-accent);
}

a.ex-card:hover .ex-card__title,
a.ex-card:hover .ex-card__title::after { color: var(--ex-color-accent); }
a.ex-card:active { box-shadow: var(--ex-shadow), inset 3px 0 0 var(--ex-color-accent); }

.ex-card--muted { background: var(--ex-color-bg); box-shadow: none; }
.ex-card--muted .ex-card__title { color: var(--ex-color-text-muted); }

/* Summary stats: a dashboard-style card per headline figure — a soft round icon badge, the value at a calm
   size (a touch above body, not a billboard), and a small label under it. Used where a screen leads with a
   few totals — a portfolio's value, its income to date, its headroom against a limit. */
/* THREE ACROSS AT MOST, and the cap is the point rather than the minimum width.

   It was repeat(auto-fit, minmax(12rem, 1fr)), which on a laptop fits four and on a wide monitor five.
   At that width a label is about eighteen characters before it wraps, so "Coupons and face value
   altogether" became three lines and the card grew taller than the ones beside it. The row then reads
   as one broken card rather than four tidy ones.

   Capped by explicit breakpoints rather than by a cleverer auto-fit expression, because the honest
   version of "no more than three" is to say three. A fourth figure goes on its own row or, better,
   turns out not to have been worth a card. */
.ex-stats {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--ex-space-4);
}
@media (min-width: 34rem) {
  .ex-stats {
    grid-template-columns: repeat(2, 1fr);
  }
}
@media (min-width: 60rem) {
  .ex-stats {
    grid-template-columns: repeat(3, 1fr);
  }
}
.ex-stat {
  display: flex;
  align-items: center;
  gap: var(--ex-space-3);
  background: var(--ex-color-surface);
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  box-shadow: var(--ex-shadow);
  padding: var(--ex-space-4) var(--ex-space-5);
}
.ex-stat__icon {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 50%;
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
}
.ex-stat__icon .ex-icon { width: 1.15rem; height: 1.15rem; }

/* The figure sits directly over its label, the two centred on each other. Left-aligned, a short number
   above a long label hangs off the left edge and the pair reads as two separate lines rather than as
   one fact. The icon keeps its place. */
.ex-stat > div { text-align: center; }

/* THE CHIP IS REQUIRED RATHER THAN COMPENSATED FOR, and StatTileTest is what says so.
 *
 * .ex-stat is a flex row built around the chip: the chip is one item and the figure with its label is
 * the other. Where there is no chip the pair is the only thing in the row, so it hugs its own content
 * and the whole tile reads as pushed against the left edge of a card it is supposed to sit on. The
 * styleguide's example has always carried one; six screens were built without and all six had it
 * (2026-08-27).
 *
 * The first fix here was a rule giving the pair the row where no chip was present, which would have
 * made a tile with no chip look deliberate — and a component that renders two ways is a component that
 * drifts. Every tile carries a chip now, so that rule had no selector left to match, and a rule
 * matching nothing reads as coverage. The requirement lives in a test instead, where it fails on the
 * screen that breaks it rather than quietly correcting it. */

.ex-stat__value {
  font-size: var(--ex-text-lg);
  font-weight: 600;
  line-height: 1.25;
  white-space: nowrap; /* a money total stays on one line; the card widens, it does not wrap */
  font-variant-numeric: tabular-nums;
}
.ex-stat__label { font-size: var(--ex-text-sm); color: var(--ex-color-text-muted); margin-top: 0.1rem; }

/* The tile whose figure is a movement: an unrealised gain, a period's return. Its badge takes the gain
   colour, so the one tile in a row that is a direction rather than a level reads as one.
 *
 * This was .ex-stat--net, "a green badge marks the take-home", and it painted from --ex-color-success. Two
 * things wrong with that here. The name is a payslip's, and the colour said "that worked" where the figure
 * means "the book went up" — which is the distinction --ex-color-gain was defined for and the reason the
 * palette keeps gain apart from success at all: a book that fell this month is reporting correctly, not
 * failing.
 *
 * THE --loss COUNTERPART ARRIVED THE DAY THE VARIANT FIRST DID ANYTHING (2026-08-27). It painted only
 * .ex-stat__icon, and until that day the only tile in the product carrying a chip was the styleguide's
 * own example — so on every real screen this was a class that selected nothing, and nobody could tell.
 * Giving the tiles their chips is what made it visible, and immediately made the gap obvious: an
 * unrealised gain, a period's net and a reconciliation difference all go either way, and a green chip
 * over a negative figure is worse than a grey one.
 *
 * SO THEY ARE APPLIED ON THE SIGN AND NEITHER IS APPLIED AT NIL. A tile that has not moved is not a
 * small gain; it is a level, and it takes the ordinary chip. */
.ex-stat--gain .ex-stat__icon {
  background: color-mix(in srgb, var(--ex-color-gain) 12%, var(--ex-color-surface));
  color: var(--ex-color-gain);
}

.ex-stat--loss .ex-stat__icon {
  background: color-mix(in srgb, var(--ex-color-loss) 12%, var(--ex-color-surface));
  color: var(--ex-color-loss);
}

/* SPENT AGAINST A LIMIT IS ONE RATIO, SO IT IS A METER.
 *
 * Not a pie of two slices, which takes more room and says less: a pie asks the eye to compare two
 * angles, a bar asks it to read one length against a line. docs/css.md has carried this section since
 * before the first feature screen; the component itself did not exist until the budget report needed
 * it (2026-08-29), which is its own small lesson about writing the rule before the thing.
 *
 * THE UNFILLED TRACK IS A LIGHTER STEP OF THE FILL'S OWN COLOUR, not grey. A grey remainder makes the
 * state read only across the filled part, so a bar that is a quarter red looks like a mostly-fine bar
 * with a warning at the start; tinting the whole track makes the row read as one thing at a glance.
 *
 * ROUNDED AT THE DATA END ONLY. Rounding both ends floats the fill off its baseline and makes a short
 * one read shorter than it is — at ten per cent, two round caps leave almost nothing straight.
 *
 * AND SEVERITY IS NEVER CARRIED BY COLOUR ALONE. Every meter states its figures beside it, and one
 * that has gone past its limit says so in words. Colour is the thing you notice; the words are the
 * thing you can act on, and about one man in twelve cannot see the difference between the fill
 * colours at all. */
/* MONTH BY MONTH — a chart in CSS, because this product has no charting library and no CDN
   (STANDARDS.md §1) and does not need either for two bars per month. A grid, a fixed plot height and
   a percentage on each bar is the whole of it.

   THE BARS ARE aria-hidden AND THE FIGURES ARE NOT. What a screen reader gets is the month and its
   two amounts, which is the content; the bars are how the same thing looks to somebody who can see
   it. A chart that is only bars is a row of empty spans to anybody who cannot.

   The scale is passed in by the controller — the tallest figure across the whole run — so every bar
   is drawn against the same number. Scaled per month they would all be full height, which is a
   pattern rather than a chart. */
.ex-runchart {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 1fr;
  gap: var(--ex-space-2);
  align-items: end;
}

.ex-runchart__month {
  min-width: 0;
  text-align: center;
}

/* A FIXED PLOT HEIGHT, so the percentages have something to be percentages of. Without it every bar
   resolves against a height of nothing and the row collapses. */
.ex-runchart__bars {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 0.15rem;
  height: 5rem;
  margin-bottom: var(--ex-space-2);
}

.ex-runchart__bar {
  display: block;
  width: 0.6rem;
  /* A month with nothing in it still shows a mark, so an empty month reads as empty rather than as a
     month that failed to render. */
  min-height: 2px;
  border-radius: 2px 2px 0 0;
}

.ex-runchart__bar--in { background: var(--ex-color-gain); }
.ex-runchart__bar--out { background: var(--ex-color-loss); }

.ex-runchart__label {
  font-size: var(--ex-text-sm);
  font-weight: 600;
  color: var(--ex-color-text);
  /* The month name alone on a narrow column, clipped rather than wrapped: six wrapped names make six
     columns of different heights and the bars stop lining up. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* CENTRED UNDER THE BAR, WHICH MEANS BEATING .ex-num. Money is right-aligned everywhere else in the
   product and rightly so — a column of figures is read down its decimal point. Here there is no
   column: each figure belongs to the bar above it, and right-aligned they drifted to the edge of
   their cell and read as though they belonged to the next month along. Two classes in the selector so
   it outranks .ex-num whatever the order. */
.ex-runchart__figure.ex-num {
  text-align: center;
  font-size: var(--ex-text-xs);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ex-runchart__note {
  margin-top: var(--ex-space-1);
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
}

/* THE LEGEND. A chart with two series has one, always — the sentence underneath that used to do this
   job was a legend written as prose, which has to be read in full and says nothing to somebody who
   cannot separate the two colours. A swatch beside a word puts the same fact where the eye is.

   It carries more weight here than usual: the figures under each bar are hidden on a phone, so this
   is the only thing naming the two series at the width the product is actually used at. */
.ex-legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ex-legend__item {
  display: inline-flex;
  align-items: center;
  gap: var(--ex-space-2);
  font-size: var(--ex-text-sm);
  /* The label is text-coloured, never the series colour. A word in the same green as its bar is the
     colour carrying the meaning twice and the text contrast paying for it. */
  color: var(--ex-color-text-muted);
}

.ex-legend__swatch {
  display: inline-block;
  width: 0.75rem;
  height: 0.75rem;
  border-radius: 3px;
  flex-shrink: 0;
}

.ex-legend__swatch--in { background: var(--ex-color-gain); }
.ex-legend__swatch--out { background: var(--ex-color-loss); }

/* On a phone six columns of figures do not fit, so the amounts go and the bars stay. The month names
   shorten to three letters by the same rule the rest of the product uses. */
@media (max-width: 48rem) {
  .ex-runchart__figure { display: none; }
  .ex-runchart__label { font-size: var(--ex-text-xs); }
}

.ex-meter {
  display: block;
  width: 100%;
  height: 0.5rem;
  border-radius: 999px;
  overflow: hidden;
  background: color-mix(in srgb, var(--ex-color-accent) 16%, var(--ex-color-surface));
}

.ex-meter__fill {
  display: block;
  height: 100%;
  min-width: 0.25rem; /* anything above nothing is visible; nothing shows as an empty track */
  border-radius: 0 999px 999px 0;
  background: var(--ex-color-accent);
  transition: width 0.2s ease;
}

/* The escalation: accent while there is room, warning as it runs out, danger once it is gone. The
   track follows the fill, which is what keeps the whole row reading as one state. */
.ex-meter--warning { background: color-mix(in srgb, var(--ex-color-warning) 18%, var(--ex-color-surface)); }
.ex-meter--warning .ex-meter__fill { background: var(--ex-color-warning); }

.ex-meter--danger { background: color-mix(in srgb, var(--ex-color-danger) 16%, var(--ex-color-surface)); }
.ex-meter--danger .ex-meter__fill { background: var(--ex-color-danger); }

/* A meter for money coming in reads the other way round: full is good, and short of the plan is the
   thing worth noticing. It takes the gain colour rather than the accent so the two halves of a budget
   table are not saying the same thing with the same paint. */
.ex-meter--gain { background: color-mix(in srgb, var(--ex-color-gain) 16%, var(--ex-color-surface)); }
.ex-meter--gain .ex-meter__fill { background: var(--ex-color-gain); }

/* The figures that go beside it. Never instead of it, and never the other way round. */
.ex-meter-row { display: flex; flex-direction: column; gap: var(--ex-space-1); }
.ex-meter-row__figures {
  display: flex;
  justify-content: space-between;
  gap: var(--ex-space-3);
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-muted);
}

/* The approval chain: each step on its own line with its state badge. */
.ex-steps {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-2);
}
.ex-steps__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ex-space-3);
  padding: var(--ex-space-3) var(--ex-space-4);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  background: var(--ex-color-bg);
}
.ex-steps__name { font-weight: 500; }
.ex-steps__meta {
  display: block;
  margin-top: 0.1rem;
  font-weight: 400;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
}

/* The step's state, on the vertical chain.

   A step that is done recedes; the step waiting on somebody right now is the only one that carries
   weight, because the single question this panel answers is "whose desk is this on?". A later step
   stays visible but quiet — knowing what happens after you approve is part of deciding whether to.

   The accent edge is on the left here rather than a wash, because these are rows in a sequence and a
   filled row would read as selected rather than as current. */
.ex-steps__item--done {
  color: var(--ex-color-text-muted);
  background: transparent;
}
.ex-steps__item--current {
  border-color: var(--ex-color-accent);
  border-left: 3px solid var(--ex-color-accent);
  background: var(--ex-color-surface);
}
.ex-steps__item--waiting {
  color: var(--ex-color-text-muted);
  border-style: dashed;
  background: transparent;
}

/* A wizard strip: where you are in a form broken into steps, laid out left to right and numbered.
   Distinct from the vertical .ex-steps above, which is an approval chain — that one is a record of
   what happened, this one is a map of what is left. A form that shows no end is a form people abandon
   halfway, and on a transaction screen half-finished means re-keyed.

   It must come after the base rule, not before: both are one class deep, so the later one wins and a
   modifier written above the thing it modifies silently does nothing.

   Done steps are marked with more than colour — the number stays — and the current step is the only
   one carrying weight and the accent. */
.ex-steps--wizard {
  flex-direction: row;
  flex-wrap: wrap;
  gap: var(--ex-space-2);
  counter-reset: ex-step;
  margin-bottom: var(--ex-space-5);
}
.ex-steps--wizard .ex-steps__item {
  flex: 1 1 8rem;
  justify-content: flex-start;
  gap: var(--ex-space-2);
  color: var(--ex-color-text-muted);
}
.ex-steps--wizard .ex-steps__item::before {
  counter-increment: ex-step;
  content: counter(ex-step);
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  font-size: var(--ex-text-xs);
  font-weight: 600;
  border-radius: 50%;
  background: var(--ex-color-control);
  border: 1px solid var(--ex-color-control-border);
}
.ex-steps--wizard .ex-steps__item--done { color: var(--ex-color-text); }
.ex-steps--wizard .ex-steps__item--done::before {
  background: var(--ex-color-success-bg);
  border-color: var(--ex-color-success);
  color: var(--ex-color-success);
}
.ex-steps--wizard .ex-steps__item--current {
  color: var(--ex-color-text);
  font-weight: 600;
  border-color: var(--ex-color-accent);
  background: var(--ex-color-accent-soft);
}
.ex-steps--wizard .ex-steps__item--current::before {
  background: var(--ex-color-accent);
  border-color: var(--ex-color-accent);
  color: var(--ex-color-accent-contrast);
}

/* Forms ------------------------------------------------------------------ */

.ex-form { display: flex; flex-direction: column; gap: var(--ex-space-4); max-width: 32rem; }

/* 32rem is right for a column of fields somebody reads down and wrong for a control whose content is
   wide by nature: a pasted CSV, where every line is a row and wrapping them destroys the shape the
   person is checking. Opt in, so the default stays the readable measure. */
.ex-form--wide { max-width: none; }

/* A control holding data rather than prose. Pasted rows line up column by column in a monospaced face
   and do not in a proportional one, which is the difference between seeing a misplaced comma and not.
   Wrapping off for the same reason: a wrapped row stops looking like a row. */
.ex-field__textarea.ex-mono {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: var(--ex-text-sm);
  line-height: 1.6;
  white-space: pre;
  overflow-x: auto;
}

.ex-field { display: flex; flex-direction: column; gap: var(--ex-space-1); }

/* The label follows its control: quiet while the field is at rest, full strength once the reader is
   in it. Hover counts as well as focus — pointing at a field is already intent. */
.ex-field__label {
  font-size: var(--ex-text-xs);
  font-weight: 600;
  color: var(--ex-color-text-muted);
  letter-spacing: 0.01em;
  transition: color var(--ex-transition);
}
/* Both wrappers a label can sit in. A single control lives in .ex-field; a group of them (radios, check
   boxes) lives in .ex-fieldset with its label as the <legend>, and the rule missed that one, so a group was
   the one kind of field whose label did not follow it. Any new wrapper belongs on this list. */
.ex-field:hover .ex-field__label,
.ex-field:focus-within .ex-field__label,
.ex-fieldset:hover .ex-field__label,
.ex-fieldset:focus-within .ex-field__label { color: var(--ex-color-text); }
/* A quiet "(optional)" hint appended to a label. */
.ex-field__optional {
  margin-left: var(--ex-space-1);
  font-weight: 400;
  font-size: var(--ex-text-2xs);
  color: var(--ex-color-text-faint);
}

.ex-field__input {
  padding: var(--ex-pad-control);
  border-radius: var(--ex-radius);
}

/* ONE FACE AND ONE SIZE FOR EVERY BOX A PERSON TYPES IN, wherever on a screen it happens to sit.

   A form control does not inherit the page's typeface, so each of these has always had to say `font:
   inherit` for itself — and each of them said it separately, which is how the two search boxes ended up
   a size smaller than every other input in the product. Nothing compares them: a search box lives on a
   list toolbar and a field lives on a form, and the only screens holding both are the ones where a
   filter row sits above a table. Stated once, in the same list shape the fill, the hover and the focus
   below already use, so a new control is added to a list rather than given a private copy of the block.

   Padding and radius stay with each control: a search box's left padding clears its icon, a textarea's
   is its own, and those are genuinely different. Face, size and colour are not. (2026-08-19) */
.ex-field__input,
.ex-field__textarea,
.ex-datatable__search input,
.ex-listbar__search input {
  font: inherit;
  font-size: var(--ex-text-md);
  color: inherit;
}

/* One height for everything a person can click or type into, so a button beside an input lines up
   exactly rather than nearly. box-sizing is stated because these carry a border.

   LINE-HEIGHT IS PART OF THE HEIGHT, and leaving it out is what made this token a near-miss for a year.
   min-height is a floor: it decides the height only while the content fits inside it. .ex-btn states
   line-height 1.2, so its content came to about 33px and the floor governed — every button was exactly
   2.375rem. A text input stated nothing and inherited the body's 1.5, so 14px text made a 21px line, and
   21 + 16 of padding + 2 of border is 39px. It sailed past the floor and sized itself.

   So every input in this product has been one pixel taller than the button beside it. One pixel is
   under the threshold at which anybody files a bug and over the threshold at which a form looks very
   slightly wrong, which is the worst place for a defect to sit. Stating the line-height here puts the
   content back under the floor and hands the height to the token, for both.

   A textarea is deliberately exempt from all of it: its height is its number of rows, and it wants the
   body's 1.5 because somebody is reading a paragraph in it. */
.ex-field__input,
.ex-select__control,
.ex-datepicker__control,
.ex-datatable__search input,
.ex-listbar__search input,
.ex-btn {
  box-sizing: border-box;
  min-height: var(--ex-control-height);
  line-height: 1.2;
}

/* THE DATEPICKER'S INNER FIELD IS NOT ON THAT LIST, and it used to be — which made every date on
   every screen two pixels taller than the input beside it.

   It is the same shape as the one-pixel bug described above, one level down. .ex-datepicker__field is
   the text input *inside* .ex-datepicker__control, and the control carries the border while holding
   zero vertical padding. Give the inner field the floor as well and it becomes 38px of content; the
   control then has to fit 38px plus its own 2px of border, so it lands at 40px and sails past the
   floor exactly as the outer control did before.

   The floor belongs to whichever element draws the edge, and only that one. The field is sized by its
   own padding and line-height, comes to about 33px, and fits inside the control's floor with room to
   spare — so the token governs the height of the date control, and a date lines up with the text input
   and the select beside it and with the button underneath.

   Its line-height is NOT set here, because it would not survive: the field's own block declares
   `font: inherit`, which is a shorthand that resets line-height, and it sits later in the file. The
   1.2 lives there, stated after the shorthand. Setting it here as well would look like belt and braces
   and be neither. */
.ex-datepicker__field {
  box-sizing: border-box;
}
.ex-btn--sm { min-height: var(--ex-control-height-sm); }

/* The same faint grey in every box that has a prompt in it — a search box's placeholder is doing exactly
   the same job as a field's and was reading a shade darker for no reason anybody chose. */
.ex-field__input::placeholder,
.ex-field__textarea::placeholder,
.ex-datatable__search input::placeholder,
.ex-listbar__search input::placeholder { color: var(--ex-color-text-faint); }

/* Control states — one place, every control -------------------------------

   A field moves through three states and the reader should be able to tell them apart without
   looking twice:

     rest    tinted fill, hairline border. The control sits back, so a form reads as a column of
             labels rather than a grid of white boxes.
     hover   the fill lifts toward white and the border firms. Nothing moves.
     focus   full surface colour, and the border darkens all the way to the text colour.

   Focus is carried by the fill and the border alone. Nothing is added on top: no bar, no ring, and no
   outline either. An outline here draws a *second* edge outside the border — a text input matches
   :focus-visible on a plain mouse click, so it is not a keyboard-only addition, it is a double ring on
   every field. The border going from hairline grey to full text colour, against a fill that goes to
   white, is the indicator. */

/* This block owns the fill and the border for every control listed. A control's own rule sets its
   shape (padding, radius, layout) and nothing else — declaring `border` there would land later in
   the file and quietly win. A new control is added to these three lists; there is deliberately no
   generic `.ex-control` hook, because one existed with no users and an extension point nobody has
   extended is a guess, not a seam. */

.ex-field__input,
.ex-field__textarea,
.ex-select__control,
.ex-datepicker__control,
.ex-cal__select,
.ex-datatable__search input,
.ex-listbar__search input {
  background-color: var(--ex-color-control);
  border: 1px solid var(--ex-color-control-border);
  transition: background-color var(--ex-transition), border-color var(--ex-transition),
              color var(--ex-transition);
}

.ex-field__input:hover,
.ex-field__textarea:hover,
.ex-select__control:hover,
.ex-datepicker__control:hover,
.ex-cal__select:hover,
.ex-datatable__search input:hover,
.ex-listbar__search input:hover {
  background-color: var(--ex-color-control-hover);
  border-color: var(--ex-color-control-border-hover);
}

.ex-field__input:focus,
.ex-field__textarea:focus,
.ex-cal__select:focus,
.ex-datatable__search input:focus,
.ex-listbar__search input:focus,
.ex-select__control:focus-visible,
/* The datepicker's control wraps the text input, so focus lands inside it, not on it. */
.ex-datepicker__control:focus-within,
.ex-select.is-open .ex-select__control,
.ex-datepicker.is-open .ex-datepicker__control {
  outline: none;
  background-color: var(--ex-color-surface);
  /* The focused edge takes the brand accent, so the field a person is typing in belongs to the same
     colour as the button they will press to save it. Buttons already focus in the accent; a field
     stopping at neutral grey was the one control that ignored the company's own theme.

     Still the border and not an added outline: an outline would draw a second edge outside this one,
     and a text input matches :focus-visible on an ordinary mouse click, so it would be a double ring
     on every field rather than a keyboard-only affordance. */
  border-color: var(--ex-color-accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--ex-color-accent) 15%, transparent);
}

/* Invalid runs the same three states in danger tones. The edge must stay red *through* hover and focus:
   the reader is in the field precisely because something is wrong with it, and a neutral border there
   leaves the message below as the only sign of it. These need the :hover/:focus compounds to outrank
   the neutral state rules above — a plain .ex-field__input--invalid loses to .ex-field__input:hover on
   specificity, wherever it sits in the file. The fill still lifts, so the progression survives. */
.ex-field__input--invalid,
.ex-field__textarea--invalid,
.ex-field__input--invalid:hover,
.ex-field__textarea--invalid:hover { border-color: var(--ex-color-danger); }

.ex-field__input--invalid:focus,
.ex-field__textarea--invalid:focus { border-color: var(--ex-color-danger-hover); }

/* An enhanced select and an enhanced date field are DIVS and INPUTS we build, not the native control
   the browser validates. The native one is 1px wide and transparent, so marking it red marks nothing a
   person can see — these are the elements that have to carry the state instead. */
.ex-select__control--invalid,
.ex-select__control--invalid:hover { border-color: var(--ex-color-danger); }
.ex-select__control--invalid:focus { border-color: var(--ex-color-danger-hover); }

/* A SELECT THAT IS NOT ANSWERABLE YET. The control is a div, so the browser's own disabled look does
   not reach it and the field would otherwise be indistinguishable from one somebody simply has not got
   to. Left in place rather than hidden: on a ladder of four, seeing the rungs still to come is what
   tells somebody the form has more in it.

   FADED RATHER THAN RECOLOURED, and that is a correction. The first attempt flattened the fill to the
   page background, which is a hair off this control's own resting fill -- so on the screen where three
   of four steps were shut, all four looked the same. Found in a screenshot, which is the only place it
   was ever going to be found. Opacity moves the border, the label and the caret together, so the whole
   field recedes rather than one part of it changing shade. */
.ex-select.is-disabled {
  opacity: 0.5;
}
.ex-select.is-disabled .ex-select__control,
.ex-select.is-disabled .ex-select__control:hover {
  background-color: var(--ex-color-bg);
  border-color: var(--ex-color-border);
  border-style: dashed;
  color: var(--ex-color-text-muted);
  cursor: not-allowed;
}
.ex-datepicker__field--invalid,
.ex-datepicker__field--invalid:hover { border-color: var(--ex-color-danger); }
.ex-datepicker__field--invalid:focus { border-color: var(--ex-color-danger-hover); }

/* A control that cannot be typed in never lights up: the fill flattens into the page background so it
   reads as furniture. */
.ex-field__input:disabled,
.ex-field__textarea:disabled,
.ex-field__input[readonly],
.ex-field__textarea[readonly] {
  background-color: var(--ex-color-bg);
  border-color: var(--ex-color-border);
  color: var(--ex-color-text-muted);
}
.ex-field__input:disabled,
.ex-field__textarea:disabled { cursor: not-allowed; }
.ex-field:has(:disabled) .ex-field__label { color: var(--ex-color-text-faint); }

.ex-field__error { font-size: var(--ex-text-xs); color: var(--ex-color-danger); }

/* THE SERVICE'S "NO", ON THE FORM THAT ASKED.
 *
 * A refusal raised while posting used to render the shared problem page: the heading and the
 * sentence were right, and everything else about it was wrong. The typed entry was gone, the two
 * buttons offered the dashboard and the book list to somebody halfway through recording a
 * transaction, and Back re-served an empty form. Reported as a very big gap, and it was: the person
 * who can fix "that category goes the other way" is standing at the form with the wrong category
 * selected, and the product had just walked them away from it.
 *
 * The problem page is still right for a refusal met by navigating -- a URL into another book, a
 * record that is not yours. This is for the other kind: a "no" about the content of a form, shown
 * beside the fields that produced it, with everything still typed in.
 *
 * role="alert" in the markup, not decoration: the re-rendered page looks almost identical to the one
 * just submitted, and a screen reader is told what changed. */
.ex-form__refusal {
  grid-column: 1 / -1;
  display: flex;
  gap: var(--ex-space-3);
  align-items: flex-start;
  padding: var(--ex-space-3) var(--ex-space-4);
  border: 1px solid var(--ex-color-danger);
  border-radius: var(--ex-radius);
  background: var(--ex-color-danger-bg);
}

.ex-form__refusal > .ex-icon {
  flex: none;
  width: 1.25rem;
  height: 1.25rem;
  margin-top: 0.1rem;
  color: var(--ex-color-danger);
}

.ex-form__refusal-title {
  font-weight: 600;
  color: var(--ex-color-danger);
}

.ex-form__refusal-why {
  margin-top: 0.15rem;
  font-size: var(--ex-text-sm);
}

/* Buttons ---------------------------------------------------------------- */

/* Links are neutral by default — they take the colour of whatever they sit in (nav items, buttons,
   breadcrumbs style their own). Only an explicit .ex-link is a coloured, hover-underlined text link. */
a {
  color: inherit;
  text-decoration: none;
}

/* A text link, behaving like the two places links already work.

   At rest it is the table link: accent, dotted underline. Dotted rather than solid so a paragraph
   carrying several does not read as ruled, but still visibly a link without relying on colour — one
   man in twelve cannot use colour as a signal.

   On hover it is the breadcrumb: the underline goes solid and a soft accent pill appears behind it.
   The pill is padded and pulled back by the same amount, so nothing on the line moves when it
   appears — a link that shifts its own paragraph on hover is worse than one with no hover at all.

   Focus gets the identical treatment. A hover state that a keyboard cannot reach is half a state. */
.ex-link {
  color: var(--ex-color-accent);
  font-weight: 500;
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 0.2em;
  text-decoration-thickness: 1px;
  padding: 0.1rem 0.3rem;
  margin: -0.1rem -0.3rem;
  border-radius: var(--ex-radius);
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-link:hover,
.ex-link:focus-visible {
  color: var(--ex-color-accent-hover);
  background: var(--ex-color-accent-soft);
  text-decoration-style: solid;
}

.ex-btn {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--ex-space-2);
  font: inherit;
  font-size: var(--ex-text-sm);
  font-weight: 500;
  line-height: 1.2;
  padding: 0.45rem 0.85rem;
  color: var(--ex-color-text);
  background: var(--ex-color-surface);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  cursor: pointer;
  white-space: nowrap;
  text-decoration: none;
  transition: background var(--ex-transition), border-color var(--ex-transition),
    color var(--ex-transition), transform 0.06s ease;
}

.ex-btn .ex-icon { width: 1.05em; height: 1.05em; }
.ex-btn:hover { background: var(--ex-color-bg); }
.ex-btn:active { transform: translateY(1px); }

.ex-btn:focus-visible {
  outline: 2px solid var(--ex-color-accent);
  outline-offset: 1px;
}

.ex-btn--primary {
  color: var(--ex-color-accent-contrast);
  background: var(--ex-color-accent);
  border-color: var(--ex-color-accent);
}

.ex-btn--primary:hover {
  background: var(--ex-color-accent-hover);
  border-color: var(--ex-color-accent-hover);
}

/* Quiet button for toolbars and secondary actions. */
.ex-btn--ghost {
  background: none;
  border-color: transparent;
  color: var(--ex-color-text-muted);
}

.ex-btn--ghost:hover { background: var(--ex-color-bg); color: var(--ex-color-text); }

.ex-btn--sm { padding: 0.3rem 0.6rem; font-size: var(--ex-text-xs); }

/* A button at the end of its range: visibly inert but kept in place, so a pager holds its shape. */
.ex-btn--disabled { opacity: 0.5; pointer-events: none; }

/* AND THE SAME FOR A BUTTON THE BROWSER ITSELF HAS DISABLED, which had no styling at all until
   2026-08-24.

   The class above is for a link: an <a> cannot carry the disabled attribute, so a pager at its last page
   says so with a class. A real <button disabled> is a different thing and was getting nothing — full
   colour, full contrast, indistinguishable from a live one, and silently inert when pressed. Found on
   the auction screen, whose create button is disabled until something is ticked and looked exactly as it
   does when it will work.

   NOT pointer-events: none here, deliberately. The browser already refuses the click; suppressing
   pointer events as well would take the not-allowed cursor with it, which is the one bit of feedback a
   person gets from pressing something that does nothing. */
.ex-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Server-side pager — previous / page-of / next — below a table paged in the DB, where the row count is
   too large to send at once: a transaction listing, an accrual history, an audit trail. */
.ex-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--ex-space-4);
  margin-top: var(--ex-space-5);
}

/* A server-paged list's toolbar: search and filters on the left, the download on the right, wrapping on a
   narrow card. The search field and the sortable headers mirror the client data-table's look, so a paged list
   and a small config list read as the same thing even though only one of them is a data-table.

   Named for the job, not the first screen to want it: any list whose rows come a page at a time from the
   database takes it — a transaction listing, an accrual history, the audit trail. */
.ex-listbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--ex-space-3);
  margin-bottom: var(--ex-space-3);
}
.ex-listbar__filters {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ex-space-2);
}
.ex-listbar__export { display: flex; align-items: center; gap: 0.4rem; }
/* THE FILTER DROPDOWN IS THE SAME WIDTH BEFORE AND AFTER main.js RUNS, and the second selector is what
   makes that true.

   The "blip on a page with a select" block further down gives the native control the height, border and
   fill of the one about to replace it — and gives it `width: 100%`, which is right in a .ex-field, where
   a select is the only thing on its row and fills it. On this toolbar it is wrong: the row is a flex
   line holding a search box, this dropdown and a Search button, and a flex item asking for the full
   width of the line pushes the button onto a second row. So the toolbar was drawn two rows tall, and
   then main.js clamped the control to 13rem and it snapped back to one.

   That is the reflow reported as "the table loading is very glitchy" (2026-08-19). Nothing was loading
   slowly. The page was drawing one layout and then a different one, a few dozen milliseconds apart, and
   everything under the toolbar jumped up by a control's height.

   Both selectors, not one: .ex-select is the enhanced control's wrapper and select[data-ex-select] is
   the native element standing in its place. They are never on screen at the same time.

   A FLEX BASIS RATHER THAN A MIN AND A MAX, and the first attempt at this fix used min/max and was not
   enough — ControlSwapIT measured 208px held against 144px enhanced and said so. The two clamps were
   the same and the two controls still arrived at different widths inside them, because they were
   computing from different starting points: the native select from `width: 100%`, so it hit the maximum,
   and our wrapper from its content, so it hit the minimum. Stating the basis takes the starting point
   out of it. `width` is ignored for a flex item whose basis is a length, so the holding rule's
   `width: 100%` no longer has anything to say here. */
.ex-listbar__filters .ex-select,
.ex-js .ex-listbar__filters select[data-ex-select]:not(.is-enhanced) { flex: 0 1 13rem; min-width: 9rem; }

.ex-listbar__search { position: relative; min-width: 16rem; }
.ex-listbar__search .ex-icon {
  position: absolute;
  left: 0.6rem;
  top: 50%;
  transform: translateY(-50%);
  width: 1rem;
  height: 1rem;
  color: var(--ex-color-text-faint);
}
/* THE SAME HEIGHT AS THE BUTTON BESIDE IT. .ex-btn--sm carries min-height: var(--ex-control-height-sm)
   and this carried only padding, so a search box and its Search button sat at two different heights on
   the one row they share. Nothing showed it until a screen put them side by side (2026-08-17). */
/* NO HEIGHT HERE, DELIBERATELY. This input is already on the one-height list above with every other
   control a person can click or type into — that is the rule, and it is stated there. A height was
   added here on 2026-08-17 to match a .ex-btn--sm sitting beside it, which fixed the symptom the wrong
   way round: the button was the odd one out, not the input. Removed, and the button made full size. */
/* SHAPE ONLY, AND THAT IS THE RULE THE BLOCK ABOVE STATES. This carried its own fill, its own border,
   its own hover and its own focus — a private copy of the control-states block, made before that block
   existed and never folded back into it. Two consequences, both visible on the exchange-rates screen and
   neither on any single page you could compare it against:

     the focused edge went to --ex-color-text, plain grey, where every other control in the product goes
     to the brand accent with a soft ring around it (that changed on the shared block and this copy did
     not follow);
     the text sat at --ex-text-sm while every other control is --ex-text-md, so the words in the search
     box were a size smaller than the words in the dropdown beside it.

   Both are now the shared block's business. What is left here is what is genuinely this control's own:
   it fills its label, and its left padding clears the magnifying glass sitting on top of it.

   NO `font: inherit` HERE, and its absence is load-bearing. `font` is a shorthand: it resets font-size
   along with everything else, and this rule sits later in the file than the shared one at the same
   specificity — so restating it here would silently undo the size the shared block just set. The face is
   handed to every control in one place above. (2026-08-19) */
.ex-listbar__search input {
  width: 100%;
  padding: var(--ex-pad-search);
  border-radius: var(--ex-radius);
}

/* Popover menu — a toggle button and a panel that drops below it (the run review's Download menu and Filters).
   Keeps a crowded toolbar to one line by folding several controls behind one button. */
.ex-menu { position: relative; display: inline-flex; }
/* The chevron icon points right, so a caret needs rotating to read as one: down when closed, up when
   open. Rotating by 180 from its own right-pointing rest state — which is what this did — gives a
   caret that points right, then left, and never at the panel it controls. The same fix is already
   commented on .ex-user__caret; this component missed it. */
.ex-menu__toggle .ex-menu__caret {
  transition: transform var(--ex-transition);
  transform: rotate(90deg);
}
.ex-menu.is-open .ex-menu__toggle .ex-menu__caret { transform: rotate(-90deg); }
.ex-menu__panel {
  position: absolute;
  top: calc(100% + 0.35rem);
  right: 0;
  z-index: 20;
  min-width: 12rem;
  padding: 0.35rem;
  background: var(--ex-color-raised);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  box-shadow: var(--ex-shadow);
}
.ex-menu__panel[hidden] { display: none; }
/* The Filters panel holds a form, so it is wider and lays its controls out in a column. It sits on the left of
   the toolbar, so it opens to the RIGHT (left:0), not right-aligned like the Download menu — otherwise it would
   extend left past the content and be clipped by the shell (.ex-main overflow:hidden), reading as "behind the
   sidebar". */
.ex-menu__item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 0.6rem;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text);
  text-decoration: none;
  font-size: var(--ex-text-sm);
  white-space: nowrap;
}
.ex-menu__item:hover { background: var(--ex-color-bg); }
.ex-menu__item .ex-icon { width: 1rem; height: 1rem; color: var(--ex-color-text-muted); }

/* A rule between two groups of items, for a menu holding two kinds of thing.
 *
 * The blotter's is the first: three things a dealer records this week, and one that loads a book which
 * already existed, once, when a client is taken on. Without the rule a reader has to tell them apart by
 * reading all four; with it, the shape of the menu says there are two groups before anything is read.
 *
 * A HAIRLINE AND NOT A HEADING. A labelled group inside a menu of four items is more furniture than the
 * menu has content, and the labels would be longer than the items. */
.ex-menu__sep {
  height: 1px;
  margin: var(--ex-space-1) 0;
  background: var(--ex-color-border);
}

/* The Filters button and the chips describing what is narrowed to, side by side on the left of the toolbar. */
.ex-listbar__filterset { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 0.5rem; }

/* Active-filter chips shown beside the Filters button, so what a run is narrowed to reads at a glance. */
.ex-listbar__active { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 0.4rem; }
.ex-chip--filter {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.2rem 0.55rem;
  font-size: var(--ex-text-xs);
  background: var(--ex-color-bg);
  border: var(--ex-border);
  border-radius: 999px;
  color: var(--ex-color-text-muted);
}

/* EACH CHIP DROPS ITSELF. Clearing everything to remove one of two narrowings is what makes people stop
   using filters: they narrow twice, realise the first was wrong, and start the whole thing again. The
   cross sits inside the chip it removes, so there is nothing to aim at and nothing to read.

   It is faint at rest — the chip is a statement, not a row of buttons — and comes up to full strength on
   hover so it reads as something to press once the pointer is on it. */
.ex-chip__remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin-right: -0.2rem;
  border-radius: 999px;
  color: var(--ex-color-text-faint);
  transition: color var(--ex-transition), background-color var(--ex-transition);
}
.ex-chip__remove .ex-icon { width: 0.75rem; height: 0.75rem; }
.ex-chip__remove:hover { color: var(--ex-color-text); background: var(--ex-color-control-hover); }

/* A sortable column header: the label is the link; an arrow (a caret, flipped for descending) shows when it
   is the active sort. The same caret the data-table headers use, so sorting reads the same across the app. */
.ex-sortlink {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  color: inherit;
  text-decoration: none;
  cursor: pointer;
}
/* A sortable header has to say so before it is touched. The arrow appeared only once a column was
   already sorted, so on arrival every header looked identical to the ones that cannot be sorted at
   all, and the only way to discover the feature was to click something and see what happened.

   At rest the arrow is faint but present — enough to read as an affordance, not enough to compete
   with the heading. Hover brings it up; the sorted column takes the accent and full strength, so
   which column the table is ordered by is legible from across the desk. */
.ex-sortlink:hover,
.ex-sortlink:focus-visible { color: var(--ex-color-text); }
.ex-sortlink:hover::after,
.ex-sortlink:focus-visible::after { opacity: 0.7; }

.ex-sortlink.is-asc,
.ex-sortlink.is-desc { color: var(--ex-color-accent); }

.ex-sortlink::after {
  content: "";
  width: 0.7em;
  height: 0.7em;
  opacity: 0.28;
  transition: opacity var(--ex-transition), transform var(--ex-transition);
  background: currentColor;
  -webkit-mask: no-repeat center / contain;
  mask: no-repeat center / contain;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 15 12 9 18 15'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 15 12 9 18 15'/%3E%3C/svg%3E");
}
.ex-sortlink.is-asc::after { opacity: 1; }
.ex-sortlink.is-desc::after { opacity: 1; transform: rotate(180deg); }

.ex-table__empty {
  text-align: center;
  color: var(--ex-color-text-faint);
  padding: var(--ex-space-6);
}
.ex-pager__status {
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-muted);
  font-variant-numeric: tabular-nums;
}

/* Square icon-only button — for genuinely universal actions in dense rows (STANDARDS.md 10.5). */
.ex-btn--icon { padding: 0.4rem; }

/* Toasts — light, coloured by type, with a status chip and a draining bar ---- */

.ex-toasts {
  position: fixed;
  top: var(--ex-space-5);
  right: var(--ex-space-5);
  z-index: 30;
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-3);
  pointer-events: none;
}

.ex-toast {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--ex-space-3);
  min-width: 19rem;
  max-width: 26rem;
  padding: var(--ex-space-4);
  background: var(--ex-color-raised);
  color: var(--ex-color-text);
  /* No coloured bar down the left. Same objection as the status band: it is every framework's alert
     and it says "generic notification" before it says anything about this system. The icon badge
     carries the state, and the progress bar along the bottom already picks up the same hue. */
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  box-shadow: var(--ex-shadow-lg);
  pointer-events: auto;
  overflow: hidden;
  animation: ex-toast-in 0.28s cubic-bezier(0.2, 0.85, 0.25, 1);
  transition: opacity var(--ex-transition), transform var(--ex-transition);
}

.ex-toast--leaving {
  opacity: 0;
  transform: translateX(0.75rem) scale(0.98);
}

@keyframes ex-toast-in {
  from { opacity: 0; transform: translateX(1.5rem) scale(0.96); }
  to { opacity: 1; transform: translateX(0) scale(1); }
}

.ex-toast__icon {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 999px;
}

.ex-toast__icon .ex-icon { width: 1rem; height: 1rem; stroke-width: 2.6; }
.ex-toast__i { display: none; align-items: center; justify-content: center; }

.ex-toast__msg { flex: 1; font-size: var(--ex-text-sm); line-height: 1.45; }

.ex-toast__close {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  color: var(--ex-color-text-faint);
  background: none;
  border: none;
  border-radius: var(--ex-radius);
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-toast__close:hover { background: var(--ex-color-bg); color: var(--ex-color-text); }

.ex-toast__progress {
  position: absolute;
  left: 0;
  bottom: 0;
  height: 3px;
  width: 100%;
  transform-origin: left;
  opacity: 0.9;
  background: var(--ex-color-text-muted);
  animation: ex-toast-progress 5s linear forwards;
}

@keyframes ex-toast-progress {
  from { transform: scaleX(1); }
  to { transform: scaleX(0); }
}

/* Per-type colour: the left border, the icon chip, and the progress bar. */
.ex-toast--success { border-color: color-mix(in srgb, var(--ex-color-success) 30%, transparent); }
.ex-toast--success .ex-toast__icon { background: var(--ex-color-success-bg); color: var(--ex-color-success); }
.ex-toast--success .ex-toast__i--success { display: inline-flex; }
.ex-toast--success .ex-toast__progress { background: var(--ex-color-success); }

.ex-toast--error { border-color: color-mix(in srgb, var(--ex-color-danger) 30%, transparent); }
.ex-toast--error .ex-toast__icon { background: var(--ex-color-danger-bg); color: var(--ex-color-danger); }
.ex-toast--error .ex-toast__i--error { display: inline-flex; }
.ex-toast--error .ex-toast__progress { background: var(--ex-color-danger); }

.ex-toast--warning { border-color: color-mix(in srgb, var(--ex-color-warning) 32%, transparent); }
.ex-toast--warning .ex-toast__icon { background: var(--ex-color-warning-bg); color: var(--ex-color-warning); }
.ex-toast--warning .ex-toast__i--warning { display: inline-flex; }
.ex-toast--warning .ex-toast__progress { background: var(--ex-color-warning); }

.ex-toast--info { border-color: color-mix(in srgb, var(--ex-color-info) 30%, transparent); }
.ex-toast--info .ex-toast__icon { background: var(--ex-color-info-bg); color: var(--ex-color-info); }
.ex-toast--info .ex-toast__i--info { display: inline-flex; }
.ex-toast--info .ex-toast__progress { background: var(--ex-color-info); }

/* Notifications ---------------------------------------------------------- */

.ex-notify { position: relative; }

.ex-notify__button {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  color: var(--ex-color-text-muted);
  background: none;
  border: none;
  border-radius: var(--ex-radius);
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition), transform 0.06s ease;
}

.ex-notify__button:hover { background: var(--ex-color-bg); color: var(--ex-color-text); }
.ex-notify__button:active { transform: translateY(1px); }

.ex-notify__badge[hidden] { display: none; }

/* The unread count on the bell.

   Accent, not danger, and the foreground gives the reason it was ever wrong: the text colour was
   already --ex-color-accent-contrast while the background was --ex-color-danger. Two colour families
   in one control, which is why it was the one thing on the page that did not retint when the brand
   colour changed — and why in dark mode it put white text on a light red (--ex-color-danger is
   #f87171 there), which is the worse half of the same mistake. There is no --ex-color-danger-contrast
   to reach for, deliberately: the status colours carry meaning on text and borders, not as fills
   behind text.

   Accent is also what it means. A count is "there are three", not "something is wrong" — and most of
   what lands here is ordinary (a trade awaiting authorisation). Red is spent on the breach band and
   the destructive buttons, and it is worth less everywhere once it is used for a number. */
.ex-notify__badge {
  position: absolute;
  top: 0.15rem;
  right: 0.15rem;
  min-width: 1.05rem;
  height: 1.05rem;
  padding: 0 0.25rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: var(--ex-text-2xs);
  font-weight: 600;
  line-height: 1;
  color: var(--ex-color-accent-contrast);
  background: var(--ex-color-accent);
  border-radius: 999px;
}

.ex-notify__panel {
  position: absolute;
  top: calc(100% + var(--ex-space-2));
  right: 0;
  width: 20rem;
  max-width: calc(100vw - var(--ex-space-8));
  background: var(--ex-color-raised);
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  box-shadow: var(--ex-shadow-lg);
  z-index: 25;
  overflow: hidden;
  animation: ex-toast-in 0.16s ease;
}

.ex-notify__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--ex-space-3) var(--ex-space-4);
  border-bottom: var(--ex-border);
}

.ex-notify__title { font-weight: 600; font-size: var(--ex-text-md); }

/* The way out of the panel and onto the page that holds the write-ups. Mirrors __head: the same
   padding and a border on the other side, so the list sits between two rules of equal weight rather
   than trailing off at the bottom of a box. */
.ex-notify__foot {
  padding: var(--ex-space-3) var(--ex-space-4);
  border-top: var(--ex-border);
}

.ex-notify__all {
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
  font-size: var(--ex-text-xs);
  color: var(--ex-color-accent);
  text-decoration: none;
}

.ex-notify__all:hover { text-decoration: underline; }

.ex-notify__all svg { width: 1rem; height: 1rem; }

/* The cross and the word sit on one line and share a centre.
 *
 * It was a plain button with no display of its own, so the svg was an inline box on the text baseline
 * and the word sat beside it a couple of pixels low — the same misalignment .ex-btn solves with a flex
 * row and does not inherit here, because this is deliberately not a .ex-btn: it is a quiet control in a
 * panel head, and a bordered button there would compete with the notices underneath it. */
.ex-notify__clear {
  display: inline-flex;
  align-items: center;
  gap: var(--ex-space-1);
  font: inherit;
  font-size: var(--ex-text-xs);
  line-height: 1;
  color: var(--ex-color-accent);
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
}

.ex-notify__list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-height: 22rem;
  overflow-y: auto;
}

.ex-notify__item {
  border-left: 3px solid var(--ex-color-accent);
  border-bottom: var(--ex-border);
}
.ex-notify__item:last-child { border-bottom: none; }

/* The whole row is the link to where it happened; hover shifts the background, never the position. */
.ex-notify__link {
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-1);
  padding: var(--ex-space-3) var(--ex-space-4);
  color: inherit;
  text-decoration: none;
}
.ex-notify__link:hover { background: var(--ex-color-bg); }
.ex-notify__link--plain { cursor: default; }
.ex-notify__link--plain:hover { background: none; }

.ex-notify__msg { font-size: var(--ex-text-sm); }
.ex-notify__time { font-size: var(--ex-text-2xs); color: var(--ex-color-text-faint); }

.ex-notify__empty {
  padding: var(--ex-space-6) var(--ex-space-4);
  text-align: center;
  color: var(--ex-color-text-faint);
  font-size: var(--ex-text-sm);
}

/* Textarea --------------------------------------------------------------- */

.ex-field__textarea {
  padding: var(--ex-pad-control);
  border-radius: var(--ex-radius);
  resize: vertical;
  min-height: 5rem;
}

/* Table ------------------------------------------------------------------ */

.ex-table-wrap {
  overflow-x: auto;
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
}

/* 0.8125rem is the house table size — what --compact used to be, now the default everywhere.

   A table is read by scanning down a column, not by reading a sentence, and at 14px a six-column
   holdings row runs wider than the eye tracks comfortably. Dropping to 13px fits more of the record on
   one line without straining, and it is the size the rest of this product's furniture already uses. */
.ex-table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--ex-text-sm);
}

.ex-table th,
.ex-table td {
  padding: 0.55rem 0.8rem;
  text-align: left;
}

/* A link in a cell.

   At rest it is the accent colour with a dotted underline — dotted rather than solid so a column of
   them does not read as a ruled block, but still visibly a link without relying on colour, which one
   man in twelve cannot use as a signal.

   On hover it goes solid and darkens. The cursor is not a hover state: the pointer only appears once
   you are already on the target, so it tells you nothing about where the targets are, and it never
   appears at all for someone navigating by keyboard. Focus gets the same treatment as hover.

   :NOT(.NK-BTN), AND IT SHOULD HAVE SAID SO FROM THE START. Half the row actions in this product are
   links wearing .ex-btn — Reverse, Part settled, Reject — and this rule was beating them on specificity
   (0,1,1 against .ex-btn's 0,1,0). So every one of them has been drawing in accent ink with a dotted
   underline instead of its own colours, which the styleguide forbids in as many words: "Buttons are
   never underlined; links are."

   It went unseen because on a plain .ex-btn the result is merely odd. Putting a red one in a table made
   it loud — accent-coloured text and an underline on a red ground — and a person reported the colour
   combination rather than the rule, which is how a specificity bug usually surfaces. */
.ex-table a:not(.ex-btn) {
  color: var(--ex-color-accent);
  text-decoration: underline;
  text-decoration-style: dotted;
  text-underline-offset: 0.2em;
  text-decoration-thickness: 1px;
  border-radius: 2px;
  transition: color var(--ex-transition), text-decoration-color var(--ex-transition);
}
/* Solid on hover, but the same hairline weight — going to 2px made the underline heavier than the
   text sitting on it, and a column of those reads as a ruled block. The change from dotted to solid,
   plus the darker ink, is enough of a signal on its own. */
.ex-table a:not(.ex-btn):hover,
.ex-table a:not(.ex-btn):focus-visible {
  color: var(--ex-color-accent-hover);
  text-decoration-style: solid;
}

/* A table held to a fixed height, scrolling inside itself, with the header staying put. For a list
   long enough to need it but not long enough to deserve paging — and so the page's own actions stay
   reachable rather than being pushed a screen and a half down. */
.ex-table-wrap--scroll {
  overflow: auto;
  max-height: 22rem;
}

/* THE SAME CAP ON A DATATABLE, ON A PAGE AND NOT ONLY IN A MODAL.

   .ex-datatable__scroll had a max-height under .ex-modal and nowhere else, so a page-level datatable
   grew to whatever its page size allowed: fifteen rows on the categories list, twelve on investments,
   twenty-five on a statement. Paging bounds the row count and does nothing about the height, and a
   table that runs past the fold pushes the pager itself off the screen -- so the control that would
   have let somebody see less of it is the first thing they cannot reach.

   Deliberately taller than the plain wrapper's 22rem. This one has a toolbar above it and a pager
   below, both of which have to stay visible, and a page size of fifteen wants more room than a modal
   does. 32rem is about twelve rows at the house table size. */
.ex-datatable__scroll {
  overflow: auto;
  max-height: 32rem;
}
.ex-datatable__scroll thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--ex-color-bg);
}
.ex-table-wrap--scroll thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--ex-color-bg);
}

.ex-table thead th {
  font-size: var(--ex-text-xs);
  font-weight: 600;
  color: var(--ex-color-text-muted);
  background: var(--ex-color-bg);
  border-bottom: var(--ex-border);
  white-space: nowrap;
}

.ex-table tbody td { border-bottom: var(--ex-border); }
.ex-table tbody tr:last-child td { border-bottom: none; }
.ex-table tbody tr:hover { background: var(--ex-color-bg); }

/* A ROW THAT OPENS SOMETHING SAYS SO UNDER THE POINTER.
 *
 * Every table in this product highlights on hover, whether or not anything happens when you click, so
 * the highlight is not the affordance — the cursor is. The client data table had this from the start
 * and the server-paged one never did, so on the limits register, the exchange rates and the transaction
 * listing a row opened a modal that nothing on the page suggested was there. Reported by a person
 * looking at the screen, 2026-08-27.
 *
 * KEYED ON role="button", WHICH IS THE THING THAT IS ACTUALLY TRUE. main.js sets the role and the
 * tabindex on exactly the rows it wires, so this cannot drift from what is clickable: a table whose
 * JavaScript has not run, or an empty-state row the wiring skips, does not get a pointer promising
 * something that will not happen.
 *
 * The focus ring comes with it, because a row reachable by Tab and invisible when it gets there is half
 * a control. */
.ex-table tbody tr[role="button"] { cursor: pointer; }
.ex-table tbody tr[role="button"]:focus-visible {
  outline: 2px solid var(--ex-color-accent);
  outline-offset: -2px;
}

/* Searchable select ------------------------------------------------------ */

.ex-select { position: relative; }

.ex-select__native.is-enhanced { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }

/* THE BLIP ON A PAGE WITH A SELECT, and this is what removes it.

   Reported from a real screen: a page with a select looks glitchy on first load and on refresh. The
   cause is not slowness. Between the first paint and main.js running, a select[data-ex-select] is
   still the BROWSER'S OWN control — its font, its height, its arrow, the operating system's — and then
   it is swapped for ours. Two different controls in the same place, a few dozen milliseconds apart.
   Nothing was loading slowly; the page was showing its working.

   So the native control is given the shape of the one about to replace it: our height, our border, our
   radius, our background, and no text — because the text is about to be re-rendered in our control
   anyway and showing it twice in two fonts is the flicker in miniature. Same trick, same reason, as
   the data table holding its shape above.

   .ex-js SCOPES ALL OF IT, and that is not decoration. With JavaScript off none of this applies, the
   native select keeps the browser's own styling, and it stays usable — which is the contract
   enhanceSelect() is written to honour: the native element is the source of truth and submits on its
   own. A rule outside .ex-js would leave a JS-off browser with an invisible, unusable field.

   :not(.is-enhanced) rather than a timer: the moment main.js wraps it, the class lands and this stops
   applying. There is nothing to unwind and nothing to time. */
.ex-js select[data-ex-select]:not(.is-enhanced) {
  box-sizing: border-box;
  appearance: none;
  width: 100%;
  min-height: var(--ex-control-height);
  padding: var(--ex-pad-control);
  border: 1px solid var(--ex-color-control-border);
  border-radius: var(--ex-radius);
  background-color: var(--ex-color-control);
  /* The value is hidden rather than the element, so the field keeps its place in the layout and
     nothing below it moves when the real control arrives. */
  color: transparent;
  /* The caret, sketched where the real one lands, so the shape reads as a select rather than as an
     empty box. Two gradients rather than an element, because there is no element to put it in yet —
     the same constraint the data table's skeleton works under. */
  background-image: linear-gradient(45deg, transparent 50%, var(--ex-color-text-faint) 50%),
    linear-gradient(135deg, var(--ex-color-text-faint) 50%, transparent 50%);
  background-position: calc(100% - 1.05rem) 50%, calc(100% - 0.7rem) 50%;
  background-size: 0.35rem 0.35rem, 0.35rem 0.35rem;
  background-repeat: no-repeat;
}

/* The date field has the same swap and needed the same answer: before main.js runs it is the
   browser's own date control, complete with an operating-system calendar button that is not ours and
   is about to disappear. Held at our height, our border, our radius, and quiet. */
.ex-js input[data-ex-datepicker]:not(.is-enhanced) {
  box-sizing: border-box;
  appearance: none;
  width: 100%;
  min-height: var(--ex-control-height);
  padding: var(--ex-pad-control);
  border: 1px solid var(--ex-color-control-border);
  border-radius: var(--ex-radius);
  background-color: var(--ex-color-control);
  color: transparent;
}

.ex-js input[data-ex-datepicker]:not(.is-enhanced)::-webkit-calendar-picker-indicator { opacity: 0; }

.ex-select__control {
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
  padding: var(--ex-pad-control);
  border-radius: var(--ex-radius);
  cursor: pointer;
  font-size: var(--ex-text-md);
}

.ex-select__value { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ex-select__value--placeholder { color: var(--ex-color-text-faint); }

.ex-select__caret {
  flex: none;
  width: 1.1rem;
  height: 1.1rem;
  color: var(--ex-color-text-faint);
  transition: transform var(--ex-transition);
}

.ex-select.is-open .ex-select__caret { transform: rotate(180deg); }

.ex-select__panel {
  position: absolute;
  top: calc(100% + 0.25rem);
  left: 0;
  right: 0;
  z-index: 20;
  background: var(--ex-color-raised);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  box-shadow: var(--ex-shadow-lg);
  overflow: hidden;
}

/* The search box inside an open dropdown.
 *
 * IT NAMES ITS OWN COLOURS, and it is the only control in this file that used not to. Every other one
 * takes its fill and its ink from the shared control-states block; this sat outside it with a padding
 * and a border and nothing else, so the browser's default field styling showed through — a white box on
 * a dark panel the moment anybody opened a select in dark mode. Reported from a real screen rather than
 * found here, which is the point: it is invisible in light mode, where the default happens to agree.
 *
 * Named rather than added to the shared block, because it is not one of those controls. It has no
 * resting fill and no border of its own — it is a strip at the top of a panel that already has both, so
 * giving it the tinted control background would draw a box inside a box. It takes the panel's own
 * surface and the ordinary text colour, which is what "part of the panel" looks like. */
.ex-select__search {
  width: 100%;
  padding: 0.55rem 0.75rem;
  border: none;
  border-bottom: var(--ex-border);
  background: var(--ex-color-raised);
  color: var(--ex-color-text);
  font: inherit;
  font-size: var(--ex-text-md);
  outline: none;
}

.ex-select__search::placeholder { color: var(--ex-color-text-faint); }

.ex-select__options { max-height: 14rem; overflow-y: auto; padding: 0.25rem; }

.ex-select__option {
  padding: 0.45rem 0.6rem;
  border-radius: var(--ex-radius);
  cursor: pointer;
  font-size: var(--ex-text-md);
}

.ex-select__option:hover,
.ex-select__option.is-active { background: var(--ex-color-bg); }
.ex-select__option.is-selected { color: var(--ex-color-accent); font-weight: 500; }

.ex-select__empty {
  padding: 0.6rem;
  text-align: center;
  color: var(--ex-color-text-faint);
  font-size: var(--ex-text-sm);
}

/* Multi-select: the same control, holding a chip per choice ---------------- */

/* The control has to be allowed to grow. Every other control in this file is exactly one row tall and
   the shared sizing block gives them all a min-height for it; this is the one that legitimately gets
   taller, because the alternative is a row of chips scrolling sideways inside a fixed box — which hides
   the choices somebody opened the control to see. So min-height stands and height does not. */
.ex-select--multi .ex-select__control {
  height: auto;
  padding-top: 0.3rem;
  padding-bottom: 0.3rem;
}

.ex-select--multi .ex-select__value {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-1);
  /* Overridden from the single select, which truncates one long label with an ellipsis. Chips wrap. */
  overflow: visible;
  white-space: normal;
  text-overflow: clip;
}

.ex-select__chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.1rem 0.2rem 0.1rem 0.45rem;
  border: 1px solid var(--ex-color-border);
  border-radius: var(--ex-radius);
  background: var(--ex-color-bg);
  font-size: var(--ex-text-sm);
  line-height: 1.4;
}

/* A real button, not a span with a click handler: it is an action, it is reached by Tab, and somebody
   working the keyboard has to be able to take a choice back without opening the panel to do it. */
.ex-select__chip-remove {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.05rem;
  height: 1.05rem;
  padding: 0;
  border: none;
  border-radius: 3px;
  background: none;
  color: var(--ex-color-text-faint);
  font-size: var(--ex-text-md);
  line-height: 1;
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-select__chip-remove:hover {
  background: var(--ex-color-control-hover);
  color: var(--ex-color-text);
}

.ex-select__chip-remove:focus-visible {
  outline: 2px solid var(--ex-color-text);
  outline-offset: 1px;
}

/* The row is the control. The box shows the state and never takes the click on its own — two hit
   targets doing one job means half of them miss the row highlight that goes with it. */
.ex-select--multi .ex-select__option {
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
}

.ex-select--multi .ex-select__option input[type="checkbox"] {
  pointer-events: none;
}

/* A chosen row keeps its normal weight here, unlike the single select. There the accent colour is the
   only way to see which one is current; here the ticked box says it, and colouring every chosen row as
   well leaves a panel with six loud lines and no way to see the one you are on. */
.ex-select--multi .ex-select__option.is-selected {
  color: inherit;
  font-weight: 400;
}

/* Date picker ------------------------------------------------------------ */

.ex-datepicker { position: relative; }

.ex-datepicker__native {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

/* The control is a shell around a real text input, so a date can be typed. It takes the control states
   from the shared block; the input inside is transparent and borderless, and the calendar sits on its
   own trigger at the end. */
.ex-datepicker__control {
  display: flex;
  align-items: center;
  gap: var(--ex-space-1);
  width: 100%;
  padding: 0 0.25rem 0 0.75rem;
  border-radius: var(--ex-radius);
  font: inherit;
  font-size: var(--ex-text-md);
  text-align: left;
}

.ex-datepicker__field {
  flex: 1;
  min-width: 0;
  padding: 0.5rem 0;
  border: none;
  background: none;
  font: inherit;
  font-size: var(--ex-text-md);
  /* AFTER `font: inherit`, and that order is the whole point. `font` is a shorthand and it resets
     line-height along with everything else, so a line-height declared anywhere above this — even in
     the block that owns control heights — is thrown away here and the field silently keeps the body's
     1.5. That made a 21px line instead of 17, so the field came to 37px, and the bordered control
     around it to 39px against the 38px of every input beside it.

     The same trap the comment on the fill-and-border block describes, arriving through a shorthand
     rather than through source order. A property that a later shorthand can reset has to be restated
     after it. */
  line-height: 1.2;
  color: inherit;
}

.ex-datepicker__field:focus { outline: none; }
.ex-datepicker__field::placeholder { color: var(--ex-color-text-faint); }

/* Typed something the field couldn't read. Not an error state — nothing has been submitted yet — so
   it marks the text rather than shouting in red around the whole control. */
.ex-datepicker__field.is-unreadable {
  color: var(--ex-color-danger);
  text-decoration: underline wavy var(--ex-color-danger);
  text-underline-offset: 0.2em;
}

/* The reason a typed date was rejected, under the field. Empty (and so invisible) until there is one to show. */
.ex-datepicker__message {
  display: block;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-danger);
}
.ex-datepicker__message:empty { display: none; }

.ex-datepicker__trigger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: none;
  background: none;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text-faint);
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-datepicker__trigger:hover,
.ex-datepicker.is-open .ex-datepicker__trigger {
  background: var(--ex-color-surface);
  color: var(--ex-color-text);
}

.ex-datepicker__panel {
  position: absolute;
  top: calc(100% + 0.35rem);
  left: 0;
  z-index: 25;
  width: 19rem;
  padding: var(--ex-space-3);
  background: var(--ex-color-raised);
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  box-shadow: var(--ex-shadow-lg);
}

/* ABOVE THE FIELD, WHEN THERE IS NO ROOM BELOW IT.
 *
 * The panel opened downwards always, which is right for a field near the top of a form and wrong for
 * one near the bottom -- and on a phone most fields are near the bottom. Six pickers on four forms
 * ran off a 390x844 screen, the worst reaching 1126px, which puts two rows of the calendar somewhere
 * nobody can tap and nothing can scroll to.
 *
 * Reported as the month grid having no twelfth month. December was always rendered; it was below the
 * fold. That is why the fix is placement and not the grid.
 *
 * main.js adds this class only when the panel actually fits above, measured after it has rendered,
 * because its height depends on whether it is showing days, months or years. */
.ex-datepicker__panel--above {
  top: auto;
  bottom: calc(100% + 0.35rem);
}

/* The month sits on the left where reading starts, with the two steppers together on the right — so
   moving month to month is one small target area rather than a reach across the panel. */
.ex-cal__head {
  display: flex;
  align-items: center;
  gap: var(--ex-space-1);
  margin-bottom: var(--ex-space-2);
}

.ex-cal__title {
  margin-right: auto;
  border: none;
  background: none;
  font: inherit;
  font-weight: 600;
  font-size: var(--ex-text-md);
  color: var(--ex-color-text);
  padding: 0.3rem 0.5rem;
  border-radius: var(--ex-radius);
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition);
}

/* The month name is the way into the month and year pickers. It carries no marker of that — a caret
   next to it was tried and looked tacked on. The hover fill is the affordance. */
.ex-cal__title:hover { background: var(--ex-color-control); }

.ex-cal__cells {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 0.35rem;
  padding: 0.25rem 0;
}

.ex-cal__cell {
  padding: 0.65rem 0.4rem;
  border: none;
  background: none;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text);
  font: inherit;
  font-size: var(--ex-text-sm);
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-cal__cell:hover { background: var(--ex-color-control); }
.ex-cal__cell.is-outside { color: var(--ex-color-text-faint); }
.ex-cal__cell.is-selected,
.ex-cal__cell.is-selected:hover { background: var(--ex-color-accent); color: var(--ex-color-accent-contrast); }

.ex-cal__nav {
  width: 1.9rem;
  height: 1.9rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-md);
  cursor: pointer;
}

.ex-cal__nav:hover { background: var(--ex-color-control); color: var(--ex-color-text); }

.ex-cal__weekdays,
.ex-cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); }

.ex-cal__grid { gap: 1px; }

.ex-cal__weekdays {
  margin-bottom: var(--ex-space-1);
  padding-bottom: var(--ex-space-1);
  border-bottom: var(--ex-border);
}

.ex-cal__weekdays span {
  text-align: center;
  padding: 0.25rem 0;
  font-size: var(--ex-text-2xs);
  font-weight: 600;
  color: var(--ex-color-text-faint);
}

.ex-cal__day {
  position: relative;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  /* Round, so the selected day reads as a marked date rather than a filled table cell. */
  border-radius: 999px;
  color: var(--ex-color-text);
  font: inherit;
  font-size: var(--ex-text-sm);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background var(--ex-transition), color var(--ex-transition);
}

.ex-cal__day:hover { background: var(--ex-color-control); }
.ex-cal__day.is-outside { color: var(--ex-color-text-faint); }

/* Today is a dot under the number, not a colour on it: colour here competes with the selected day,
   and the two are often the same square. */
.ex-cal__day.is-today { font-weight: 600; }
.ex-cal__day.is-today::after {
  content: "";
  position: absolute;
  bottom: 0.28em;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--ex-color-accent);
}

.ex-cal__day.is-selected,
.ex-cal__day.is-selected:hover {
  background: var(--ex-color-accent);
  color: var(--ex-color-accent-contrast);
  font-weight: 600;
}

.ex-cal__day.is-selected.is-today::after { background: var(--ex-color-accent-contrast); }

.ex-cal__day.is-disabled {
  color: var(--ex-color-text-faint);
  opacity: 0.4;
  cursor: not-allowed;
}

.ex-cal__day.is-disabled:hover { background: none; }
.ex-cal__day.is-disabled.is-today::after { display: none; }

/* The weekend reads a shade quieter — a trade, settlement or coupon date lands on a working day far more
   often than not, so it helps to see the shape of the week at a glance. */
.ex-cal__day:nth-child(7n),
.ex-cal__day:nth-child(7n - 1) { color: var(--ex-color-text-muted); }
.ex-cal__day.is-outside:nth-child(7n),
.ex-cal__day.is-outside:nth-child(7n - 1) { color: var(--ex-color-text-faint); }
.ex-cal__day.is-selected:nth-child(7n),
.ex-cal__day.is-selected:nth-child(7n - 1) { color: var(--ex-color-accent-contrast); }

.ex-cal__time {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  margin-top: 0.6rem;
  padding-top: 0.6rem;
  border-top: var(--ex-border);
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-muted);
}

.ex-cal__time-label { margin-right: auto; }

/* The calendar's month and year pickers — shape only; the "Control states" block gives them the fill,
   the border and the three states, same as every other control. */
.ex-cal__select {
  padding: 0.3rem 0.4rem;
  border-radius: var(--ex-radius);
  font: inherit;
  font-size: var(--ex-text-sm);
}

.ex-cal__foot {
  display: flex;
  justify-content: space-between;
  margin-top: var(--ex-space-2);
  padding-top: var(--ex-space-2);
  border-top: var(--ex-border);
}

/* Modal ------------------------------------------------------------------ */

.ex-modal {
  width: calc(100vw - 2rem);
  /* Never taller than the screen: the head and foot hold their place and only the body scrolls. */
  max-height: calc(100dvh - 2rem);
  padding: 0;
  /* A hairline edge, not "none". In light the shadow draws the dialog's boundary; in dark it does not,
     so without this the raised fill just fades into the blurred page at the corners. */
  border: 1px solid var(--ex-color-border);
  border-radius: var(--ex-radius-lg);
  background: var(--ex-color-raised);
  color: var(--ex-color-text);
  box-shadow: var(--ex-shadow-lg);
}

/* The page behind, put behind glass.
 *
 * Both values are tokens because both have to change with the theme, and only one of them was doing any
 * work before. A scrim dims — which is most of the separation on a bright page and almost none of it on
 * a dark one, where dimming charcoal by half gives you slightly darker charcoal. The blur is what
 * survives the theme: it says "this is not the layer you are working in" regardless of how light the
 * page under it happens to be. So dark leans on the blur and takes the scrim nearly to black. */
.ex-modal::backdrop {
  background: var(--ex-color-scrim);
  backdrop-filter: blur(var(--ex-scrim-blur));
  -webkit-backdrop-filter: blur(var(--ex-scrim-blur));
}

/* Nothing behind a modal scrolls.

   A dialog is on the top layer, so a wheel over the backdrop still scrolls whatever is underneath —
   the page slides around behind the thing you are reading, and on a touch screen the modal appears to
   drag the page with it. .ex-scroll is this application's only scrolling region, so freezing that and
   the document is the whole job.

   :has() rather than a class toggled from JavaScript: the state is already in the DOM as the dialog's
   open attribute, and a second source of truth for the same fact is one that gets left behind when a
   dialog closes by a route nobody wired up. */
html:has(dialog[open]) {
  overflow: hidden;
}
html:has(dialog[open]) .ex-scroll {
  overflow: hidden;
}

/* Flex column only when open: an author display:flex on the base would beat the UA's
   dialog:not([open]){display:none} — origin trumps specificity — and show a closed dialog inline. */
.ex-modal[open] {
  display: flex;
  flex-direction: column;
  animation: ex-modal-in 0.16s ease;
}

@keyframes ex-modal-in {
  from { opacity: 0; transform: translateY(0.5rem) scale(0.98); }
  to { opacity: 1; transform: none; }
}

.ex-modal--sm { max-width: 24rem; }
.ex-modal--md { max-width: 34rem; }

/* THE READING SIZE, AND IT IS NOT THE FORM SIZE.
 *
 * --sm and --md are for a decision and a form: a measure a person reads down, where wider is worse.
 * --lg is what the row-detail dialog uses, and that is a different job — it holds a dozen labelled
 * fields in a three-column grid plus a row of figures over them, and every one of those columns is a
 * value that must not wrap. At 52rem the three tracks came to about 15rem apiece, which is under the
 * 13rem the detail grid asks for once the padding is taken off, so a bank's registered name and a
 * sentence saying why there is no figure both ran to three lines.
 *
 * 60rem is two more columns' worth of slack, and it is still short of --ex-content-max: a dialog wider
 * than the page behind it stops reading as something on top of the page (2026-08-28). */
.ex-modal--lg { max-width: 60rem; }

/* A DIALOG WHOSE WHOLE CONTENT IS ONE FORM, which is the shape a decision taken in a modal needs: the
 * field and the button that commits it have to be inside the same <form>, so the form has to wrap the
 * head, the body and the foot rather than sit inside the body.
 *
 * That puts a box between the dialog and the three parts that were written to be its own children, and
 * every one of their rules is about being a flex item of it — .ex-modal__head holds its place with
 * flex: 0 0 auto, .ex-modal__body takes the middle and is the only part that scrolls. Wrapped in a
 * plain block they simply stack, so the body does not scroll and the foot does not hold the bottom.
 *
 * And .ex-form is 32rem, which is the reading measure for a column of fields and the right default
 * everywhere else. Inside a 34rem dialog it left the head's rule and the foot's rule stopping short of
 * the edges — which is what "it doesn't look like our usual modal" was, reported before anybody looked
 * at the markup.
 *
 * So the form takes the layout the dialog expected of its own child, and gives up the measure: the
 * dialog's width class is already the decision about how wide this is. */
.ex-modal__form {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  /* Both, and neither is redundant: max-width drops .ex-form's reading measure, and width takes the
     dialog's own. A flex item's stretch is a default that several things quietly override. */
  max-width: none;
  width: 100%;
  /* .ex-form's own gap would space the three parts apart; their borders are what separates them. */
  gap: 0;
}

.ex-modal__head {
  flex: 0 0 auto; /* holds its place at the top while the body scrolls */
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ex-space-4);
  padding: var(--ex-space-4) var(--ex-space-5);
  border-bottom: var(--ex-border);
}

.ex-modal__title { font-size: var(--ex-text-lg); font-weight: 600; }

.ex-modal__close {
  display: inline-flex;
  flex: none;
  padding: 0.3rem;
  color: var(--ex-color-text-faint);
  background: none;
  border: none;
  border-radius: var(--ex-radius);
  cursor: pointer;
}

.ex-modal__close:hover { background: var(--ex-color-bg); color: var(--ex-color-text); }

.ex-modal__body {
  flex: 1 1 auto; /* takes the middle, and is the only part that scrolls */
  min-height: 0;
  overflow-y: auto;
  padding: var(--ex-space-5);
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-4);
}
.ex-modal__body p { margin: 0; color: var(--ex-color-text-muted); font-size: var(--ex-font-body); line-height: 1.5; }
.ex-modal__body > :first-child { margin-top: 0; }
.ex-modal__body > :last-child { margin-bottom: 0; }

/* Anything scrollable inside a modal is bounded by the modal, never by its own content. A table that
   sets its own 22rem or 100vh height inside a dialog fights the dialog's max-height and wins, which
   is how a modal ends up taller than the window and takes the page's scrollbar with it. */
.ex-modal .ex-table-wrap,
.ex-modal .ex-table-wrap--scroll,
.ex-modal .ex-datatable__scroll {
  max-height: min(22rem, 45dvh);
  overflow: auto;
}
.ex-modal .ex-table-wrap thead th,
.ex-modal .ex-table-wrap--scroll thead th,
.ex-modal .ex-datatable__scroll thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--ex-color-surface);
}

.ex-modal__foot {
  flex: 0 0 auto; /* holds its place at the bottom */
  display: flex;
  justify-content: flex-end;
  gap: var(--ex-space-2);
  padding: var(--ex-space-4) var(--ex-space-5);
  border-top: var(--ex-border);
}

/* The Close button sits on its own on the left; any other actions stay on the right. */
.ex-modal__foot-close { margin-right: auto; }

/* Data table ------------------------------------------------------------- */

.ex-datatable {
  /* The two strips main.js adds around the table. Their heights are pinned here so that the
     placeholders further down can hold exactly the right room open before either one exists. */
  --ex-datatable-toolbar-h: 4rem;
  --ex-datatable-foot-h: 3.2rem;
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  overflow: hidden;
  background: var(--ex-color-surface);
}

.ex-datatable__toolbar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ex-space-3);
  padding: var(--ex-space-3) var(--ex-space-4);
  border-bottom: var(--ex-border);
  min-height: var(--ex-datatable-toolbar-h);
}

/* Column filters: one of the project's enhanced dropdowns (ex-select) per data-ex-filter column, sitting
   between the search box and the export buttons. Sized compact so several fit on the toolbar. */
.ex-datatable__filters { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.ex-datatable__filters .ex-select { min-width: 9rem; max-width: 13rem; }

.ex-datatable__search {
  position: relative;
  flex: 1;
  max-width: 22rem;
}

.ex-datatable__search .ex-icon {
  position: absolute;
  left: 0.6rem;
  top: 50%;
  transform: translateY(-50%);
  width: 1rem;
  height: 1rem;
  color: var(--ex-color-text-faint);
}

/* Shape only, for the same reason .ex-listbar__search input is shape only: the face, the size and the
   colour are handed to every control at once, further up. `font` is a shorthand, so restating it here
   would reset the size the shared rule set. */
.ex-datatable__search input {
  width: 100%;
  padding: var(--ex-pad-search);
  border-radius: var(--ex-radius);
}

.ex-datatable__actions { margin-left: auto; display: flex; gap: 0.4rem; }

/* Fill the working area on big screens rather than capping short; the sticky header (below) keeps the
   column names in view as the rows scroll inside this box. */
/* No min-height. It was 12rem, which reserved most of a screenful whatever the table held — so a list
   of two users drew two rows and then a hand's width of nothing before the pager, and read as though
   the rest had failed to load. The floor was there to stop the pager jumping as pages of differing
   length load, but the fixed page size already makes every full page the same height, and the only
   page that differs is the last one; steadying that at the cost of every short list is the wrong
   trade. The max-height stays: that is what keeps a long table inside the viewport. */
.ex-datatable__scroll { overflow: auto; max-height: calc(100vh - 20rem); }

/* Inside a modal the same wrapper is only there to let a wide table scroll sideways. The minimum height is a
   grid's, meant to stop a short list looking collapsed on a page of its own, and in a two-row help table it
   just leaves a hole under the last row. */
.ex-modal .ex-datatable__scroll { min-height: 0; }

.ex-datatable .ex-table { border: none; }

.ex-datatable thead th {
  position: sticky;
  top: 0;
  z-index: 1;
}

.ex-datatable thead th.is-sortable { cursor: pointer; user-select: none; }

.ex-datatable__sort {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.ex-datatable__sort::after {
  content: "";
  width: 0.75em;
  height: 0.75em;
  opacity: 0;
  background: currentColor;
  transition: opacity var(--ex-transition);
  -webkit-mask: no-repeat center / contain;
  mask: no-repeat center / contain;
  -webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 15 12 9 18 15'/%3E%3C/svg%3E");
  mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 15 12 9 18 15'/%3E%3C/svg%3E");
}

.ex-datatable th.is-sortable:hover { color: var(--ex-color-text); }
.ex-datatable th.is-sortable:hover .ex-datatable__sort::after { opacity: 0.4; }
.ex-datatable th.is-asc .ex-datatable__sort::after { opacity: 1; }
.ex-datatable th.is-desc .ex-datatable__sort::after { opacity: 1; transform: rotate(180deg); }

.ex-datatable tbody tr { cursor: pointer; }
.ex-datatable tbody tr:focus-visible {
  outline: 2px solid var(--ex-color-accent);
  outline-offset: -2px;
}
.ex-datatable__num { text-align: right; font-variant-numeric: tabular-nums; }

.ex-datatable__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ex-space-3);
  padding: var(--ex-space-3) var(--ex-space-4);
  border-top: var(--ex-border);
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-muted);
  min-height: var(--ex-datatable-foot-h);
}

/* Before enhancement -------------------------------------------------------
   The table in the HTML is the whole table: every row the server sent, no toolbar, no pager. Left
   alone the browser paints it that way — and on a long list it paints it a screenful at a time as the
   response streams in — then main.js arrives and it all snaps: rows vanish down to one page, two strips
   appear and push everything down. These rules give the table that final shape from the first paint, so
   the first page of rows lands where it will stay and the enhancement is invisible when it lands.

   Gated on .ex-js, set in the head before any paint. With JavaScript off the class never appears, none
   of this applies, and the full table stands on its own. */

/* The rows past the first page, held back until there is a pager to reach them.

   One rule per page size in use, and DataTablePageSizeTest now enforces that in both directions: a
   template asking for a size with no rule here fails, and a rule for a size nothing asks for fails too.
   The test was named in this comment long before it existed, and in the meantime the guard it promised
   silently lapsed — every real listing moved to 12 while the rules still covered 6, 10 and 15, so the
   five screens that matter painted their whole table and then snapped down to one page. The comment was
   the only thing holding the invariant, and a comment cannot fail a build. */
.ex-js .ex-datatable:not(.is-ready)[data-page-size="6"] tbody tr:nth-child(n + 7),
.ex-js .ex-datatable:not(.is-ready)[data-page-size="12"] tbody tr:nth-child(n + 13),
/* 30 is the Bank of Zambia auction, which is ticked in a batch rather than read a page at a time: a
   pager between the rows would have somebody confirming a page and coming back for the next. */
.ex-js .ex-datatable:not(.is-ready)[data-page-size="30"] tbody tr:nth-child(n + 31) {
  display: none;
}

/* Room kept for the toolbar and the pager, so arriving costs them nothing. Held open with min-height,
   the same way the real strips hold their own: on a fill page the table is a column flex box, and a
   plain height on a flex item is a starting offer — it gets shrunk, and the rows slide up under a
   placeholder shorter than the toolbar replacing it. A minimum is not negotiable.

   The room is also drawn, as the outline of what is coming: a search box and two export buttons above,
   a count and a row of pager buttons below. Held-open blank strips read as a page that failed to
   finish; the same strips sketched and breathing read as a page still arriving, which is the truth.
   The rows between them are real and already legible, so nothing covers them.

   Shapes are background layers rather than elements because there is nothing to put an element in yet:
   main.js has not run, and this has to be right on the first paint. Sized in the same units as the real
   controls, so the sketch lands where the thing it stands for will. */
.ex-js .ex-datatable:not(.is-ready)::before,
.ex-js .ex-datatable:not(.is-ready)::after {
  content: "";
  display: block;
  background-repeat: no-repeat;
  /* Nothing for the first quarter-second: a local page is enhanced long before then, and a skeleton
     that flashes up and vanishes is the very stutter this whole block exists to remove. Only a load
     slow enough to be worth explaining ever gets explained. */
  animation:
    ex-skeleton-in 200ms ease-out 250ms both,
    ex-skeleton-pulse 1.6s ease-in-out 450ms infinite;
}

.ex-js .ex-datatable:not(.is-ready)::before {
  min-height: var(--ex-datatable-toolbar-h);
  border-bottom: var(--ex-border);
  background-image:
    linear-gradient(var(--ex-color-control) 0 0),  /* search box */
    linear-gradient(var(--ex-color-control) 0 0),  /* CSV */
    linear-gradient(var(--ex-color-control) 0 0);  /* TXT */
  background-size: min(22rem, 40%) 2.15rem, 3.6rem 1.9rem, 3.6rem 1.9rem;
  /* A position offset is measured to the far edge of the block, so each one has to clear the whole width
     of the block outside it plus the gap between them, or the two buttons meet and read as a single bar. */
  background-position: left 1rem center, right 5rem center, right 1rem center;
}

.ex-js .ex-datatable:not(.is-ready)::after {
  min-height: var(--ex-datatable-foot-h);
  border-top: var(--ex-border);
  background-image:
    linear-gradient(var(--ex-color-control) 0 0),  /* "Showing 1-10 of 360" */
    linear-gradient(var(--ex-color-control) 0 0),  /* five pager buttons */
    linear-gradient(var(--ex-color-control) 0 0),
    linear-gradient(var(--ex-color-control) 0 0),
    linear-gradient(var(--ex-color-control) 0 0),
    linear-gradient(var(--ex-color-control) 0 0);
  background-size: min(10rem, 30%) 0.85rem, 2.4rem 1.75rem, 2.4rem 1.75rem, 2.4rem 1.75rem,
    2.4rem 1.75rem, 2.4rem 1.75rem;
  background-position: left 1rem center, right 1rem center, right 3.75rem center, right 6.5rem center,
    right 9.25rem center, right 12rem center;
}

@keyframes ex-skeleton-in {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes ex-skeleton-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}

/* Still say the page is working, just without the breathing. */
@media (prefers-reduced-motion: reduce) {
  .ex-js .ex-datatable:not(.is-ready)::before,
  .ex-js .ex-datatable:not(.is-ready)::after {
    animation: ex-skeleton-in 1ms linear 250ms both;
  }
}

.ex-datatable__pages { display: flex; align-items: center; gap: 0.35rem; }
.ex-datatable__gap { padding: 0 0.25rem; color: var(--ex-color-text-faint); }

/* Freeze the first column so the row's identifier (a portfolio code, a trade date) stays in view when the table is
   scrolled sideways. Sticky in both axes on the top-left header cell; opaque backgrounds so scrolled
   cells pass underneath, not through. Header sits above the frozen column, which sits above the body. */
.ex-datatable thead th:first-child,
.ex-datatable tbody td:first-child {
  position: sticky;
  left: 0;
}
.ex-datatable tbody td:first-child {
  z-index: 1;
  background: var(--ex-color-surface);
}
.ex-datatable tbody tr:hover td:first-child { background: var(--ex-color-bg); }
.ex-datatable thead th:first-child { z-index: 3; }

/* Fill listing: the page never scrolls — the data grid inside the card does — so the toolbar (search,
   filters, export) and the pagination footer stay pinned in view. The page becomes a flex column that
   fills the shell's scroll region; the card, the datatable and its scroll area each grow to take what's
   left, and the scroll area alone owns the overflow (replacing the max-height guess used elsewhere). */
.ex-content:has(> .ex-page--fill) {
  height: 100%;
  display: flex;
  flex-direction: column;
  /* AND IF THE FURNITURE IS TALLER THAN THE SCREEN, THE PAGE SCROLLS RATHER THAN LOSING IT.
   *
   * The promise of a fill page is that it never scrolls and the table inside it does. That holds while
   * everything above the table is short. Where it is not — a teaching card, a picker and a search over
   * a register — the flex column simply overflowed this box, and with no overflow set the surplus was
   * unreachable by any means: measured at 196px on the limits screen, with the table below it
   * collapsed to its own 8rem floor.
   *
   * Losing the pinned toolbar on a screen too short to pin it is a far smaller cost than hiding a
   * fifth of a page, and it costs nothing at all on a page that fits. */
  overflow-y: auto;
  /* NO WIDTH OF ITS OWN, AND THAT IS THE DECISION RATHER THAN AN OMISSION.
   *
   * This carried max-width: 80rem for a day, to stop the blotter's Action column being sliced by the
   * card's edge. It fixed that and broke something worth more: a listing at 1200px beside every other
   * screen at 960px means the page jumps width as somebody moves between them, and one product with
   * two content widths has no standard. --ex-content-max applies to every screen in this product,
   * listings included.
   *
   * A table with more columns than 60rem holds says so with .ex-table--wide, which is the decision
   * that was already made here: an explicit min-width, a frozen first column and a scrollbar forced
   * visible, so the scroll reads as deliberate instead of as a cut column. */
}
/* THE GAP THIS COLUMN NEVER HAD. Every fill page until now held one card, so a flex column with no gap
   looked identical to one with a gap and the omission could not be seen. The first page to put a picker
   card above its table (the valuation runs list, 2026-08-17) drew the two flush against each other with
   no seam. Same spacing as .ex-stack, which is what a page laid out any other way uses. */
.ex-page--fill {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-5);
}
/* The head brings its own margin-bottom, so it would otherwise pay the gap twice. */
.ex-page--fill > .ex-page__head { margin-bottom: 0; }
/* The region that grows to take the leftover height and owns the internal scroll. A listing page marks its
   one data card; a page with cards above the table (the run review) marks just the table's card, so the
   status and totals above it keep their natural height and only the grid scrolls. */
/* SHRINK TO FIT, NEVER GROW TO FILL — 0 1 auto, not 1 1 auto, and the difference is the whole component.
 *
 * Grow-to-fill was the obvious reading of "the card fills the page", and it is wrong for a short list. A
 * two-row list drew its two rows and then a full screen of white before the pager, which reads as a page
 * that failed to finish loading. That is the same defect the max-height guess here used to have as a
 * min-height of 12rem, written down a few lines from here and removed for exactly this reason; growing to
 * fill reintroduced it at four times the size.
 *
 * Shrink-to-fit gives both behaviours from one rule. A list that fits sits at its natural height with the
 * pager directly under the last row, as it always did. A list that does not fit shrinks into the space
 * there is and scrolls inside it, which is what pins the toolbar and the pager. Nothing has to know in
 * advance which case it is in. */
.ex-page--fill > .ex-fill {
  flex: 0 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.ex-page--fill .ex-fill > .ex-datatable {
  flex: 0 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}
.ex-page--fill .ex-fill .ex-datatable__scroll,
.ex-page--fill .ex-fill > .ex-table-wrap {
  flex: 0 1 auto;
  /* A floor, not 0: on a short screen the grid keeps a usable height and the page scrolls to reach the
     pagination, rather than the grid collapsing to nothing and the pager landing under the toolbar. */
  min-height: 8rem;
  max-height: none;
  overflow: auto;
}
/* A FADE AT THE BOTTOM WHILE THERE IS MORE BELOW, and it is not decoration.
 *
 * The scroll region ends wherever the height runs out, which is nearly always part-way through a row. A
 * row sliced horizontally with the card's own border directly under it reads as a layout that has gone
 * wrong, not as a list that continues — and it was reported as one. macOS hides scrollbars until
 * something is scrolled, so on the machine most of this is looked at there is no other signal at all.
 *
 * ONLY WHILE THERE IS MORE BELOW, WHICH IS THE SECOND ATTEMPT. This was painted on every one of these
 * boxes unconditionally, and the comment here said so: it claimed "this box scrolls" rather than "there
 * is more below", and called that the lesser of two wrongs. It was not. A list that ends on screen had
 * its final row washed out for no reason, which reads as data that failed to load — reported on the
 * holdings screen and the valuation runs, in the same breath as the sliced row this was meant to fix.
 *
 * initScrollFades in main.js puts .ex-scroll--more on and takes it off, on scroll and on resize. It is
 * nine lines of script and it is the whole difference between a hint that is true and one that is not.
 * background-attachment: local would have done it without script and cannot: the table's own rows carry
 * an opaque background and paint straight over it, which is why this is a sticky pseudo-element.
 *
 * 1.5rem rather than 2: enough to say the row continues, not so much that the row underneath is
 * unreadable. Washing out the thing it is drawing attention to is what made it read as a fault. */
.ex-page--fill .ex-fill > .ex-table-wrap.ex-scroll--more::after,
.ex-page--fill .ex-fill .ex-datatable__scroll.ex-scroll--more::after {
  content: "";
  display: block;
  position: sticky;
  bottom: 0;
  height: 1.5rem;
  /* Pulled back over the last rows rather than added under them, so the fade overlays the content
     instead of adding a rem and a half of empty box to every list. */
  margin-top: -1.5rem;
  background: linear-gradient(to top, var(--ex-color-surface), transparent);
  pointer-events: none;
}

/* A pseudo-element rather than a background on the box, which was tried first and did nothing: the
   table's own rows carry an opaque background and painted straight over it. Sticky rather than
   background-attachment: local for the same reason.
   *
   * The limitation this used to carry -- "does not disappear when the last row is reached, because
   * nothing in CSS can tell it to" -- was true of the CSS and not of the product. Script can tell, and
   * a reader could tell the difference long before anybody wrote it down. */

/* Keep the column names in view as the rows scroll inside the box. The header cell already carries an opaque
   background (.ex-table thead th), so scrolled rows pass under it, not through. (The data-table sets its own.) */
.ex-page--fill .ex-fill > .ex-table-wrap thead th {
  position: sticky;
  top: 0;
  z-index: 1;
}

/* Detail — stacked label/value in a grid, for the row modal --------------- */

.ex-detail {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  gap: var(--ex-space-5) var(--ex-space-6);
}

/* Short entries — a token and its name, an icon and its name — where the 13rem column leaves most of
   the row empty and the eye has to travel between neighbours that belong together. */
.ex-detail--tight {
  grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
  gap: var(--ex-space-4);
}

/* Entries whose values are NAMES rather than figures, so two to a row rather than three.
 *
 * "Zambia National Commercial Bank Plc" and "Growth Fund current account · 0100123456" are the width of
 * a sentence, and at the default 13rem they wrap to three and four lines while a date beside them uses
 * a third of its column. Widening the track to 20rem gives those two per row on a laptop and one on a
 * phone, and it is still auto-fit — so the number of columns follows the space rather than being
 * declared, which is what keeps a fixed three-column grid from becoming a horizontal scroll at 380px.
 *
 * Use it for a block of names. Keep the default for a block of dates, codes and figures. */
.ex-detail--pairs {
  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
}

/* THREE ACROSS IN A DIALOG, WHATEVER THE DIALOG IS WIDE.
 *
 * The track is 13rem because a page's detail list holds dates, codes and figures. A dialog's holds the
 * same fields plus the ones a table had no room for — a registered name, a rate series, a sentence
 * saying why there is no figure — and when --lg went from 52rem to 60rem the auto-fit arithmetic tipped
 * from three columns to four. Four tracks of 13rem in 60rem of dialog is squeezed rather than roomy:
 * the extra width went into a fourth column instead of into the three that were already tight, which
 * is the opposite of what widening it was for (2026-08-28).
 *
 * 17rem holds it at three on a --lg dialog and drops to two and then one as the window narrows, so it
 * is still the room deciding and not a declared count. */
.ex-modal .ex-detail {
  grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
}

.ex-detail__item { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
.ex-detail__item--wide { grid-column: 1 / -1; }

/* A figure and its label line up on the same edge.

   .ex-num right-aligns the value, and the label stayed left — so an amount sat at the right of its
   column with its own caption stranded at the left, reading as two unrelated things rather than as
   one labelled figure. The whole item takes the alignment of the value it holds.

   :has() so it applies from the value's own class and nobody has to remember a matching modifier on
   the parent. */
.ex-detail__item:has(.ex-num) { text-align: right; }

/* A label/value list is semantically a description list, so the block may be marked up as
   <dl>/<dt>/<dd>. Browsers indent <dd> by 40px, which puts every value out of line with its own
   label — reset it here rather than making pages avoid the correct element. */
.ex-detail dd { margin: 0; }

/* A summary detail block that sits above a form in the same card: a hairline and space set it apart from the
   inputs below, so the read-only figures and the fields you fill do not run together. */
.ex-detail--divided {
  padding-bottom: var(--ex-space-6);
  border-bottom: 1px solid var(--ex-color-border);
  margin-bottom: var(--ex-space-6);
}
.ex-detail__label { font-size: var(--ex-text-2xs); line-height: 1.3; color: var(--ex-color-text-muted); }
.ex-detail__value { line-height: 1.3; color: var(--ex-color-text); overflow-wrap: anywhere; white-space: pre-line; }

/* A VALUE MADE OF MARKUP KEEPS ITS OWN WHITESPACE RULES, and this is what closes a trap the line above
   sets. `pre-line` collapses runs of spaces and PRESERVES newlines, which is right for a stored value
   that carries its own line breaks — an address, a reason somebody typed over two lines — and every one
   of those arrives through th:text as a single text node with no source formatting in it.

   A value written as markup instead does carry source formatting: `<dd class="ex-detail__value">` on one
   line and the `<a>` on the next puts a real newline at the head of the box, so pre-line renders a blank
   first line and the value sits a line below its own label. It reads as a gap somebody put there on
   purpose. Found on the instrument record's Issuer, where every neighbouring row is th:text and only that
   one holds a link (2026-08-19); the same shape is on the user record, the cash reversal and the
   subscription.

   Keyed on having an element child rather than on a modifier class, so nobody has to remember it — and
   because the two cases genuinely differ: a value built from elements says where its lines break by
   putting them in elements. */
.ex-detail__value:has(> *) { white-space: normal; }

/* Confirm — a little SweetAlert: centred, a typed icon chip on top --------- */

.ex-modal--confirm { max-width: 30rem; }
.ex-modal--confirm .ex-modal__body { text-align: center; padding-top: var(--ex-space-6); }
.ex-modal--confirm .ex-modal__foot { justify-content: center; border-top: none; padding-top: 0; padding-bottom: var(--ex-space-6); }
/* The confirm's OK button carries an icon: a check to proceed, swapped for a trash when the action is
   destructive (the button gets ex-btn--danger). Only the matching one shows. */
/* Cancel in the confirm dialog is a real button, quietly.

   It was a ghost — no border, no fill — so on a centred two-button footer it read as a caption beside
   the primary rather than as the other choice, and on a destructive confirm the safe option was the
   one that looked least like something you could press. It keeps a soft fill and a hairline edge:
   clearly secondary to the primary, clearly a button. */
[data-ex-confirm-cancel] {
  background: var(--ex-color-control);
  border-color: var(--ex-color-control-border);
  color: var(--ex-color-text);
}
[data-ex-confirm-cancel]:hover {
  background: var(--ex-color-bg);
  border-color: var(--ex-color-control-border-hover);
}

.ex-confirm-ok__go, .ex-confirm-ok__danger { display: inline-flex; }
[data-ex-confirm-ok] .ex-confirm-ok__danger { display: none; }
[data-ex-confirm-ok].ex-btn--danger .ex-confirm-ok__go { display: none; }
[data-ex-confirm-ok].ex-btn--danger .ex-confirm-ok__danger { display: inline-flex; }

.ex-confirm__icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 3.5rem;
  height: 3.5rem;
  margin: 0 auto var(--ex-space-4);
  border-radius: 999px;
  background: var(--ex-color-warning-bg);
  color: var(--ex-color-warning);
}

.ex-confirm__icon .ex-icon { width: 1.9rem; height: 1.9rem; }
.ex-confirm__i { display: none; }
.ex-confirm__title { font-size: var(--ex-text-lg); font-weight: 600; margin-bottom: var(--ex-space-2); }

/* Type variants set the chip colour and reveal the matching icon. */
.ex-confirm--success .ex-confirm__icon { background: var(--ex-color-success-bg); color: var(--ex-color-success); }
.ex-confirm--error   .ex-confirm__icon { background: var(--ex-color-danger-bg);  color: var(--ex-color-danger); }
.ex-confirm--warning .ex-confirm__icon { background: var(--ex-color-warning-bg); color: var(--ex-color-warning); }
.ex-confirm--info    .ex-confirm__icon { background: var(--ex-color-info-bg);    color: var(--ex-color-info); }
.ex-confirm--question .ex-confirm__icon { background: var(--ex-color-info-bg);   color: var(--ex-color-info); }

.ex-confirm--success  .ex-confirm__i--success,
.ex-confirm--error    .ex-confirm__i--error,
.ex-confirm--warning  .ex-confirm__i--warning,
.ex-confirm--info     .ex-confirm__i--info,
.ex-confirm--question .ex-confirm__i--question { display: inline-flex; }

/* The affirmative half of a decision whose other half is a refusal.
 *
 * NOT A FIFTH WAY OF SAYING "IMPORTANT", which is the way a variant like this usually spreads. It exists
 * for one shape: a row offering two answers to the same question, read several rows at a time, where the
 * pair is scanned by colour before either label is read. Authorise beside Reject on the blotter is that
 * shape, and maker-checker is the reason it recurs.
 *
 * Never for an ordinary Save. --primary is "the one thing this screen is for" and there is one of those
 * per screen; this is not that, and reaching for green to make a button stand out is the same mistake
 * the styleguide already forbids in red.
 *
 * THE GREEN IS GAIN'S AND NOT SUCCESS'S. The two are kept apart on purpose (see .ex-stat--gain):
 * success is "that worked", the green of a flash message confirming something happened. This is neither
 * a confirmation nor a report of an outcome — it is a control offering a decision — and gain is the
 * steadier green this product uses where a colour has to sit still and be looked at.
 *
 * IT PAINTS FROM --ex-color-approve-fill RATHER THAN FROM GAIN DIRECTLY, and that is not indirection
 * for its own sake: in dark mode gain is a bright mint chosen to be read as text against near-black, and
 * a button filled with it is a button white cannot sit on. The token is gain in light mode and a deeper
 * green in dark; the arithmetic that forces two values is where it is declared.
 *
 * The hover is mixed from the same token rather than a second one being invented for one button. */
.ex-btn--approve {
  color: #fff;
  background: var(--ex-color-approve-fill);
  border-color: var(--ex-color-approve-fill);
}

/* Its own hover, or the generic .ex-btn:hover grey wins and washes the green out. Same reason as red. */
.ex-btn--approve:hover {
  background: color-mix(in srgb, var(--ex-color-approve-fill) 85%, #000);
  border-color: color-mix(in srgb, var(--ex-color-approve-fill) 85%, #000);
}

/* The same treatment as --approve, and for the same reason. In dark mode --ex-color-danger is the salmon
 * that reads as TEXT against near-black, so a button filled with it left white at 2.77:1 and looked
 * washed out beside the green. --ex-color-danger-fill is danger in light mode and a deeper red in dark.
 *
 * IT CHANGES EVERY RED BUTTON IN DARK MODE, not only the blotter's: every Delete, every Remove, every
 * destructive confirmation. That is the point rather than a side effect — they were all the same washed
 * salmon — but it is worth knowing which screens moved. */
.ex-btn--danger {
  color: #fff;
  background: var(--ex-color-danger-fill);
  border-color: var(--ex-color-danger-fill);
}

/* Set its own hover background — otherwise the generic .ex-btn:hover grey wins and washes the red out.
   Mixed from the fill rather than taking --ex-color-danger-hover, which is the hover for danger as TEXT
   and in dark mode is lighter than the fill this now sits on. */
.ex-btn--danger:hover {
  background: color-mix(in srgb, var(--ex-color-danger-fill) 85%, #000);
  border-color: color-mix(in srgb, var(--ex-color-danger-fill) 85%, #000);
}

/* Form layouts ----------------------------------------------------------- */

.ex-form--grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--ex-space-4) var(--ex-space-5);
  max-width: none;
}

.ex-field--full { grid-column: 1 / -1; }

/* The bar above a report: what to report on, over what period, and the button.
 *
 * SIX SCREENS ASK THE SAME QUESTION and each was asking it in three equal-width fields, so a portfolio
 * name of forty characters was given exactly as much room as a date of eleven. The two things are not
 * of one size and the layout should not pretend they are: the subject takes two thirds and the period
 * takes one, which is roughly the ratio of what a person has to read in each.
 *
 * A SUBJECT AND A PERIOD, not "two thirds and one third" — the ratio is what the split looks like, not
 * what it means. Anything else on a filter bar (a template, a counterparty) belongs in the wide column
 * beside the subject, never in the narrow one, which is sized for two dates and nothing else.
 *
 * minmax(0, …) on the wide track, for the reason .ex-form--grid has it: a select with a long option in
 * it has an intrinsic minimum width and will push a 2fr track wider than 2fr rather than truncate.
 *
 * THE NARROW TRACK HAS A FLOOR AND THAT IS THE ONLY REASON THIS WORKS. A clean 2fr/1fr came out three
 * pixels short of two dates at 1440 — the period stacked, on the one width the whole change was being
 * judged at. 18rem is what the pair needs (two controls at 8.5rem and the gap between them), so the
 * ratio is what the split looks like at a laptop and the floor is what makes it true. A third of the
 * room is not a fact about a date; the width of "01 Jan 2026" and a calendar button is.
 *
 * Below 34rem the base rule collapses every grid form to one column and this goes with it, which is why
 * there is no width in here. */
.ex-form--filter { grid-template-columns: minmax(0, 2fr) minmax(18rem, 1fr); }

/* THE BUTTON JOINS THE ROW ONCE THERE IS ROOM FOR IT, and the period stops being the squeezed half.
 *
 * Two things were wrong on a laptop and both were the same mistake: the columns were shared out as if
 * the portfolio picker were the important one. It is a select that fits its longest option and never
 * needs more; the period is two date fields that each want a full date in them, and at 1fr split in
 * two they sat at about eight characters apiece. Meanwhile "Show" took an entire row of its own under
 * a form with two rows in it, which is where the exposure screen's filter reads better than every
 * report's: it puts the action on the same line as what it acts on.
 *
 * So above sixty rems the period gets the larger share and the actions take a third column sized to
 * the button. Below it, nothing changes: the base rule still collapses to one column on a phone, and
 * .ex-field--full still does what it says everywhere else.
 *
 * THE BUTTON SITS ON THE CONTROL LINE, which took two goes. align-self: end was wrong for the reason
 * it was chosen: it matches the button to the bottom of the tallest cell, and on every one of these
 * screens the tallest cell is a period with a two-line hint under it. So the button ended up a full
 * hint below the inputs it belongs beside — level with the bottom of a sentence rather than with
 * anything. The screen where it looked right (income) is the one screen whose period carries no hint,
 * which is what made the mistake hard to see from any single page.
 *
 * What it wants is the line the controls are on, and every control in this form is at the same offset
 * from the top of its cell: one label, then the field's own row gap. That is said here in the tokens
 * the label is made of rather than as a measured number, so it follows if either ever changes. */
@media (min-width: 60rem) {
  .ex-form--filter {
    grid-template-columns: minmax(0, 1.1fr) minmax(22rem, 1.6fr) auto;
  }
  .ex-form--filter > .ex-form__actions.ex-field--full {
    grid-column: auto;
    align-self: start;
    padding-top: calc(var(--ex-text-xs) * var(--ex-line-height) + var(--ex-space-1));
  }
}

/* A FILTER WITH ONE FIELD BESIDE THE PORTFOLIO, which is the other shape these screens come in: a
   valuation, a concentration and a liquidity report are all read as at one day rather than over a
   period. Two fields and an action, so the same three columns with a narrower floor under the middle
   one — 22rem is the width of two dates and a calendar button each, and a single date in it would be
   a field with a hand's width of empty box after the text. */
.ex-form--filter--asat { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); }
@media (min-width: 60rem) {
  .ex-form--filter--asat { grid-template-columns: minmax(0, 1.1fr) minmax(14rem, 1fr) auto; }
}

/* A FILTER THAT ALSO ASKS WHICH WAY THE MONEY WENT, which is the shape a statement's filter has:
 * what it was, in or out, between when and when, and the button. Four things, and .ex-form--filter
 * has three columns.
 *
 * IT HAD BEEN THE ORDINARY TWO-COLUMN GRID and it cost a third of the screen. Search and Money took
 * the first row, the period took a full-width row of its own because it is .ex-field--full, and the
 * button took a third — three rows of controls above a table, on a monitor with room for all of it on
 * one line. The filter is not the page; it is how you get to the page.
 *
 * THE DIRECTION COLUMN HAS A FLOOR AND NOTHING ELSE. It is a select holding "Money out", so 9rem is
 * the whole of what it will ever need, and giving it a share of the row would take that share off the
 * period — which is the mistake .ex-form--filter's own comment records making twice. Search gets the
 * elastic share because a narrative is the only field here whose content has no length limit worth
 * naming.
 *
 * BELOW 60rem THIS IS .ex-form--filter UNCHANGED: two columns, the period on its own row under them,
 * the button under that. There is not room for four, and the base rule still collapses the lot to one
 * column below 34rem. The period keeps .ex-daterange, so From and To stack inside their own column
 * rather than being pulled apart by the grid. */
@media (min-width: 60rem) {
  .ex-form--filter--direction {
    grid-template-columns: minmax(0, 1.2fr) minmax(9rem, 0.6fr) minmax(22rem, 1.4fr) auto;
  }
  /* The period is one column here, not the full row it is everywhere narrower. */
  .ex-form--filter--direction > .ex-daterange.ex-field--full { grid-column: auto; }
}

/* A period: two dates that are one answer, sitting in one field's worth of a form.
 *
 * THEY ARE NOT TWO FIELDS AND HAVE NEVER BEHAVED LIKE TWO. Neither one means anything alone, they are
 * always submitted together, and a validation failure ("a period cannot end before it starts") belongs
 * to the pair rather than to either half. Laying them out as two independent fields put a whole form
 * column between From and To on a wide screen, which is a long way to move your eye between two halves
 * of one sentence.
 *
 * THE PAIR SPLITS ON ROOM RATHER THAN ON THE VIEWPORT. auto-fit against a minimum wide enough for
 * "01 Jan 2027" and the calendar button beside it means the two sit side by side wherever both fit and
 * stack where they do not — in a narrow column on a wide screen just as much as on a phone. A media
 * query cannot see that, because the column is narrow for reasons the viewport width does not know
 * about (BreakpointTest, and the scale it is protecting).
 *
 * THE HINT HANGS OFF THE PAIR, NOT OFF ONE OF THE DATES. "By trade date, being the day the decision was
 * taken" is a sentence about the period; hung under From it is both misplaced and three lines deep in a
 * column sized for a date, which is what drags the row out of line with the field beside it. */
.ex-daterange { display: flex; flex-direction: column; }

.ex-daterange__pair {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8.5rem, 1fr));
  gap: var(--ex-space-2) var(--ex-space-3);
}

/* Between 34rem and 48rem there is no sidebar but there is not much else either, and 18rem of floor
   under the period leaves the portfolio less room than it had before any of this. Give the ratio back
   the decision; the pair stacks inside its own column, which is the whole point of .ex-daterange. */
@media (max-width: 48rem) {
  .ex-form--filter { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
}

.ex-form__section { border: none; padding: 0; margin: 0; }
.ex-form__hint { margin-top: 0.1rem; font-size: var(--ex-text-xs); color: var(--ex-color-text-muted); }

/* A heading that splits a form into groups (e.g. a grade's identity, then its work duration): a quiet
   rule above it and a note beneath, so the group reads as its own thing without shouting like a card title. */
.ex-form__subhead {
  margin: var(--ex-space-2) 0 0;
  padding-top: var(--ex-space-4);
  border-top: var(--ex-border);
  font-size: var(--ex-text-md);
  font-weight: 600;
  color: var(--ex-color-text);
}
.ex-form__subnote {
  margin: 0.25rem 0 0;
  font-size: var(--ex-text-xs);
  line-height: 1.5;
  color: var(--ex-color-text-muted);
}

.ex-form__actions {
  display: flex;
  gap: var(--ex-space-2);
  padding-top: var(--ex-space-2);
}

/* Form-card header: what this screen does, and a helper line. */
.ex-formhead { margin-bottom: var(--ex-space-2); }
/* A page heading, so it takes the page-title step rather than the 17px it had grown, which sat
   between the card title above it and the topbar title beside it and matched neither. */
.ex-formhead__title { margin: 0; font-size: var(--ex-text-xl); font-weight: 600; }
.ex-formhead__lead { margin: var(--ex-space-1) 0 0; color: var(--ex-color-text-muted); font-size: var(--ex-text-md); }

@media (max-width: 34rem) {
  .ex-form--grid { grid-template-columns: 1fr; }
}

/* Breadcrumb trail ------------------------------------------------------- */
.ex-breadcrumb {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ex-space-2);
  margin-bottom: var(--ex-space-4);
  font-size: var(--ex-text-xs);
}

/* A breadcrumb is a trail you can walk back up, so the links have to look walkable. They were plain
   muted text that only changed colour on hover — indistinguishable from a caption until the pointer
   was already on them, and invisible as links to anyone using a keyboard.

   Each link now sits in its own quiet hit area that fills on hover, which also gives the row a
   comfortable target on a touch screen. The current page stays flat: it is where you already are,
   and nothing about it should invite a press. */
.ex-breadcrumb__link {
  color: var(--ex-color-text-muted);
  text-decoration: none;
  padding: 0.15rem 0.4rem;
  margin: -0.15rem -0.1rem;
  border-radius: var(--ex-radius);
  transition: background var(--ex-transition), color var(--ex-transition);
}
.ex-breadcrumb__link:hover,
.ex-breadcrumb__link:focus-visible {
  color: var(--ex-color-accent);
  background: var(--ex-color-accent-soft);
}
.ex-breadcrumb__current { color: var(--ex-color-text); font-weight: 600; }

/* A chevron rather than a slash: it points the way the trail runs, where a slash is just punctuation.
   Sized down and dimmed so the separators recede and the names carry the line. */
.ex-breadcrumb__sep {
  color: var(--ex-color-text-faint);
  font-size: var(--ex-text-inline);
  line-height: 1;
  user-select: none;
}

/* Error pages ----------------------------------------------------------- */
.ex-errorpage {
  max-width: 30rem;
  margin: 4rem auto;
  text-align: center;
}

.ex-errorpage__code {
  margin: 0;
  font-size: var(--ex-text-4xl);
  font-weight: 800;
  line-height: 1;
  color: var(--ex-color-accent);
}

.ex-errorpage__title { margin: var(--ex-space-4) 0 var(--ex-space-2); font-size: var(--ex-text-2xl); font-weight: 600; }
.ex-errorpage__text { margin: 0 0 var(--ex-space-6); color: var(--ex-color-text-muted); }
.ex-errorpage .ex-btn { align-self: center; }

/* What the reader hands to whoever fixes it: the address, the permission, the reference. Quiet — it is
   for the one visit in ten that becomes a support call — but a definition list rather than a sentence,
   because it is read out loud down a telephone and a label per value is what makes that possible. */
.ex-errorpage__detail {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--ex-space-1) var(--ex-space-3);
  margin: var(--ex-space-6) 0 0;
  padding: var(--ex-space-4) 0 0;
  border-top: 1px solid var(--ex-color-border);
  font-size: var(--ex-text-sm);
  text-align: left;
}

.ex-errorpage__detail dt {
  color: var(--ex-color-text-muted);
  white-space: nowrap;
}

.ex-errorpage__detail dd {
  margin: 0;
}

/* Selectable in one gesture: these are the two values somebody has to copy exactly. */
.ex-errorpage__detail code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  word-break: break-all;
  user-select: all;
}

/* Stacked on a phone. Two columns with a nowrap label pushes the value off the side of a 320px screen,
   and the value is the half that matters. */
@media (max-width: 34rem) {
  .ex-errorpage__detail {
    grid-template-columns: 1fr;
  }

  .ex-errorpage__detail dd {
    margin-bottom: var(--ex-space-2);
  }
}

/* Spinner and loaders ---------------------------------------------------- */

/* A tapered arc that sweeps, rather than a ring with a bite out of it.

   Built from a conic gradient masked to a ring, so the stroke fades from full colour to nothing round
   the circle and the leading end is bright while the tail dissolves. That reads as motion even in a
   still screenshot, which a plain broken ring does not.

   Deliberately unhurried at 1.15s. A fast spinner reads as agitation and makes a two-second wait feel
   like a fault; this is a system where a valuation run over a whole book is legitimately slow, and the
   loader should say "working" rather than "panicking". */
.ex-spinner {
  display: inline-block;
  width: 1.15em;
  height: 1.15em;
  border-radius: 50%;
  background: conic-gradient(from 0deg, transparent 0deg, currentColor 300deg, transparent 360deg);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
  animation: ex-spin 1.15s linear infinite;
}

.ex-spinner--lg {
  width: 2.25rem;
  height: 2.25rem;
  color: var(--ex-color-accent);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 3px), #000 calc(100% - 3px));
}

/* Reduced motion: keep the shape, drop the sweep to a slow pulse. Someone who has asked the operating
   system for less movement should not be handed a spinning element for the length of a valuation run. */
@media (prefers-reduced-motion: reduce) {
  .ex-spinner { animation: ex-spinner-pulse 1.6s ease-in-out infinite; }
}
@keyframes ex-spinner-pulse {
  0%, 100% { opacity: 0.35; }
  50% { opacity: 1; }
}

@keyframes ex-spin { to { transform: rotate(360deg); } }

/* Spinner with words. */
.ex-loader {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-sm);
}

.ex-btn.is-loading-text { pointer-events: none; }
.ex-btn.is-loading-text .ex-spinner { width: 1em; height: 1em; }

/* Button loading: keep the button's size, hide its content, show a spinner. */
.ex-btn.is-loading { position: relative; color: transparent; pointer-events: none; }
.ex-btn.is-loading > * { visibility: hidden; }

/* The same spinner as everywhere else. This one was still the old broken ring at 0.6s, so a button
   spun differently — and faster — than the spinner sitting next to it. A loading indicator that
   changes shape depending on where it appears reads as two different systems. */
.ex-btn.is-loading::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: 1.15em;
  height: 1.15em;
  margin: -0.575em 0 0 -0.575em;
  border-radius: 50%;
  color: var(--ex-color-accent-contrast);
  background: conic-gradient(from 0deg, transparent 0deg, currentColor 300deg, transparent 360deg);
  -webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
  mask: radial-gradient(farthest-side, transparent calc(100% - 2px), #000 calc(100% - 2px));
  animation: ex-spin 1.15s linear infinite;
}

/* The spinner takes the button's own text colour, which is white on a filled button and would be an
   invisible grey ring on it. --success is on this list for the same reason the other two are: it is
   filled, and a variant added without being named here gets a grey spinner nobody can see. */
.ex-btn.is-loading:not(.ex-btn--primary):not(.ex-btn--danger):not(.ex-btn--approve)::after {
  color: var(--ex-color-text-muted);
}

/* Top page-loading bar — activates on navigation, resets on the new page. */
/* PULL DOWN TO REFRESH — the indicator only. The gesture is in main.js.
 *
 * It sits at the top of .ex-scroll and is pulled into view by the drag, so it appears to come out
 * from under the top bar the way the platform's own does. Nothing here runs unless a finger is on the
 * screen: main.js does not even attach the listeners on a fine pointer.
 *
 * The height is fixed and the translate does the moving, rather than growing the element — a height
 * animation reflows the whole list underneath on every frame of a drag, on the hardware where this is
 * least affordable. */
.ex-pull {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 3rem;
  margin-top: -3rem;
  pointer-events: none;
  z-index: 5;
}

.ex-pull__dial {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 999px;
  background: var(--ex-color-raised);
  border: var(--ex-border);
  box-shadow: var(--ex-shadow-lg);
  color: var(--ex-color-text-muted);
}

.ex-pull__dial .ex-icon {
  width: 1.1rem;
  height: 1.1rem;
}

/* Snapping back is animated; following the finger is not. A transition on the drag itself makes the
   indicator lag behind the thumb, which reads as the page being slow rather than as weight. */
.ex-pull:not(.is-pulling) {
  transition: transform 0.2s ease-out;
}

/* FAR ENOUGH, SAID BEFORE LETTING GO. Without this somebody has to guess whether they have pulled
   enough, and guessing wrong means the list jumps back and nothing happens. */
.ex-pull.is-ready .ex-pull__dial {
  color: var(--ex-color-accent);
  border-color: var(--ex-color-accent);
}

.ex-pull.is-refreshing .ex-pull__dial .ex-icon {
  animation: ex-pull-spin 0.7s linear infinite;
}

@keyframes ex-pull-spin {
  to {
    transform: rotate(360deg);
  }
}

/* The spin is decoration; the reload happens either way. */
@media (prefers-reduced-motion: reduce) {
  .ex-pull.is-refreshing .ex-pull__dial .ex-icon {
    animation: none;
  }

  .ex-pull:not(.is-pulling) {
    transition: none;
  }
}

.ex-pageloader {
  position: fixed;
  /* BELOW THE STATUS BAR, NOT UNDER IT. Installed on an iPhone the page paints into the notch area --
     that is what viewport-fit=cover buys -- so a 3px bar at top 0 is drawn behind the clock and is
     invisible on the one device where a slow page is most likely. Reported as "there is no loader". */
  top: var(--ex-safe-top);
  left: 0;
  height: 4px;
  width: 100%;
  transform: scaleX(0);
  transform-origin: left;
  background: var(--ex-color-accent);
  opacity: 0;
  z-index: 100;
}

.ex-pageloader.is-active {
  opacity: 1;
  transform: scaleX(0.9);
  transition: transform 8s cubic-bezier(0.1, 0.85, 0.2, 1);
}

/* ON A PHONE IT IS A REAL BAR, not a hairline.
 *
 * 4px of accent at the top of a laptop screen is plenty, because the pointer is already up there and
 * the window is close. On a phone the top of the screen is the far corner from the thumb, under the
 * clock, and 4px of blue on a light ground is something you find only if you know to look. The
 * report was "it just goes quiet, so I kept clicking and clicking", which is the exact cost.
 *
 * Taller, and it pulses, because an indeterminate wait wants movement rather than a shape: the eye
 * catches a change where it does not catch a colour. */
/* ON TOUCH IT TRAVELS INSTEAD OF GROWING, and this is the third attempt at it.
 *
 * The desktop bar starts at scaleX(0) and grows to 0.9 over EIGHT SECONDS. That is a good shape for a
 * page that might take ten: the bar creeps, and its position is a rough claim about progress. It is
 * the wrong shape for a phone, where a navigation is under a second -- at 300ms the bar is about four
 * percent of the width, a 6px stub in the corner of the screen, and by the time it is wide enough to
 * notice the page has already arrived.
 *
 * So making it taller and making it pulse changed nothing, because there was almost nothing there to
 * make taller. Reported twice, correctly, as there being no loader.
 *
 * A segment that travels across the full width instead. It is the same size in the first frame as in
 * the last, so it is visible however long the wait turns out to be, and movement across the whole top
 * of the screen is caught by the eye in a way a growing stub is not. transform and transition are
 * cleared because the desktop rule sets both and they would otherwise still be squeezing it. */
@media (pointer: coarse) {
  .ex-pageloader { height: 5px; }

  .ex-pageloader.is-active {
    transform: none;
    transition: none;
    background: linear-gradient(
      90deg,
      transparent 0%,
      var(--ex-color-accent) 35%,
      var(--ex-color-accent) 65%,
      transparent 100%);
    background-size: 45% 100%;
    background-repeat: no-repeat;
    animation: ex-pageloader-travel 0.85s linear infinite;
  }

  @keyframes ex-pageloader-travel {
    from { background-position: -45% 0; }
    to { background-position: 145% 0; }
  }
}

@media (prefers-reduced-motion: reduce) and (pointer: coarse) {
  /* Still visible, and still. Somebody who has asked for less movement gets a bar that is simply
     there rather than one that slides, which is the honest version of the same signal. */
  .ex-pageloader.is-active {
    animation: none;
    background: var(--ex-color-accent);
    background-size: auto;
  }
}

/* :active IS THE ONLY FEEDBACK THAT CANNOT MISS, and it is why the last three attempts failed.
 *
 * The class below is added synchronously by the click handler -- that was measured, it works, the tab
 * really does carry .is-navigating before click() returns. What was never true is that the browser
 * PAINTS it: between the handler returning and the navigation starting there may be no frame at all,
 * and on a fast connection there certainly is not. So three fixes in a row made an invisible thing
 * bigger, taller and brighter, and every one of them was still invisible.
 *
 * :active is painted by the browser as part of handling the press itself, before any script runs and
 * without waiting for a frame that a navigation may cancel. It is the one signal that arrives while
 * the finger is still down.
 *
 * It cannot be the whole answer -- it ends when the finger lifts, and the wait is only starting -- so
 * the class and the bar still do the rest. But it is what makes the first quarter-second honest. */
.ex-tabbar__item:active,
.ex-nav__item:active,
.ex-choice__item:active {
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
}

/* WHAT WAS TAPPED SAYS SO IMMEDIATELY, and this is the half that actually answers "did that work".
 *
 * The bar is honest about the page and says nothing about the tap: it waits 200ms by design, sits at
 * the opposite end of the screen from the thumb, and on a slow connection the gap between pressing a
 * tab and anything at all changing is long enough to press it again. Marking the pressed control is
 * instant, local, needs no network, and is where the person is already looking.
 *
 * Not a spinner. A spinner replaces the label with a shape and loses which one was pressed, which is
 * the one fact worth keeping while the next page loads. */
.ex-tabbar__item.is-navigating,
.ex-nav__item.is-navigating {
  color: var(--ex-color-accent);
  background: var(--ex-color-accent-soft);
}

.ex-tabbar__item.is-navigating .ex-icon,
.ex-nav__item.is-navigating .ex-icon {
  animation: ex-nav-tapped 1s ease-in-out infinite;
}

@keyframes ex-nav-tapped {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

@media (prefers-reduced-motion: reduce) {
  .ex-pageloader.is-active { animation: none; }
  .ex-tabbar__item.is-navigating .ex-icon,
  .ex-nav__item.is-navigating .ex-icon { animation: none; }
}

/* Section / div overlay loader — put an overlay in a .ex-loadable, toggle .is-loading. */
.ex-loadable { position: relative; }

.ex-loadable__overlay {
  display: none;
  position: absolute;
  inset: 0;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  background: rgba(255, 255, 255, 0.66);
  border-radius: inherit;
  color: var(--ex-color-text-muted);
  z-index: 5;
}

.ex-loadable__text { font-size: var(--ex-text-sm); }
.ex-loadable.is-loading > .ex-loadable__overlay { display: flex; }

/* Responsive — the sidebar becomes an off-canvas drawer, and the phone gains a tab bar. Desktop-first
   tool: both of these are additions below a breakpoint rather than the shape everything else bends to. */

.ex-scrim { display: none; }

/* Shown only below 48rem, where the rail has become a drawer and its destinations need another way in.
   Rendered on every page so the markup is one thing rather than a conditional. */
.ex-tabbar { display: none; }

@media (max-width: 48rem) {
  .ex-app { grid-template-columns: 1fr; }
  .ex-collapsed .ex-app { grid-template-columns: 1fr; }

  .ex-sidebar {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 20;
    width: var(--ex-sidebar-width);
    height: 100dvh;
    transform: translateX(-100%);
    transition: transform var(--ex-transition);
    border-right: var(--ex-border);
  }

  /* On mobile, ignore the desktop collapse — always show full labels in the drawer. */
  .ex-collapsed .ex-sidebar__brand,
  .ex-collapsed .ex-nav__label { display: revert; }

  .ex-collapsed .ex-nav__item { justify-content: flex-start; padding-left: var(--ex-space-3); }

  /* The drawer shows the full sidebar, so the flat nav scrolls freely at this width. */
  .ex-collapsed .ex-nav--sections { overflow-y: auto; }

  .ex-sidebar-open .ex-sidebar { transform: translateX(0); box-shadow: var(--ex-shadow-lg); }
  .ex-sidebar-open .ex-scrim { display: block; }

  .ex-topbar__menu { display: inline-flex; }
  .ex-topbar { padding: var(--ex-space-3) var(--ex-space-4); }

  /* EIGHTY PIXELS OF A 390 PIXEL SCREEN WERE PADDING, before a single figure.
   *
   * The page padded 16 each side and every card padded another 24 inside that, so a fifth of the
   * width was gone to whitespace on the narrowest screen in the product -- which is also the one where
   * a table has the least room to show a narrative and a stat tile the least room for a balance.
   * Reported as the cards eating the space the information needed.
   *
   * 12 and 16 now, which is 56 rather than 80: 24 more pixels of content on every screen, and the
   * cards still read as cards. The desktop keeps 24 inside 24, where there is room for it and where
   * the same generosity is what makes a long page readable. */
  .ex-content { padding: var(--ex-space-3); }
  .ex-card { padding: var(--ex-space-4); }

  /* The head's rule below the title, which at 24 was a bigger gap than the space between two cards. */
  .ex-card__head {
    padding-bottom: var(--ex-space-2);
    margin-bottom: var(--ex-space-3);
  }

  /* And the gap between cards, which was set for a page that had room to breathe. */
  .ex-stack > * + * { margin-top: var(--ex-space-3); }

  /* ---- A QUEUE ROW SPENT A THIRD OF THE SCREEN BEFORE ITS FIRST WORD ---------------------------
   *
   * Measured on the notifications page at 375px, which is an iPhone SE or a 13 mini: the message
   * began roughly 100 pixels in. Page padding 12, card padding 16, the row's own padding 16, a 36px
   * icon in a circle and a 16px gap beside it -- none of it wrong on a desktop, all of it stacked on
   * a screen that has 375 to spend. "A transaction of 12,976.20 ZMW was reversed on Zanaco current
   * account" wrapped to three lines inside a column 240 wide. Reported as the padding on the left
   * being huge, and it was.
   *
   * THE LIST RUNS TO THE CARD'S EDGES. A bordered box inside a padded card, both on the same surface,
   * is an inset drawn twice; letting the list break out of the padding is what a phone list does
   * everywhere else and gives back 32 pixels for nothing. The card's own padding still holds the
   * heading above it, which is what makes the card still read as a card.
   *
   * And the icon comes down to 1.75rem with a smaller gap. It marks what kind of notice this is; it
   * does not need to be the width of two characters of the sentence it is marking. */
  .ex-card .ex-queue {
    margin-inline: calc(-1 * var(--ex-space-4));
    border-left: 0;
    border-right: 0;
    border-radius: 0;
  }

  /* The row's own padding and the icon used to be shrunk here too, and none of it ever applied:
     the .ex-queue component is defined 2,600 lines below this media query, so its base rules came
     later in the file at the same specificity and won at every width -- the third rule in this
     stylesheet found dead for exactly that reason. Those rules now live in the component's own
     phone media query, where the cascade cannot unsay them. Only this breakout stays, because two
     classes outrank one and it was the only line here that was actually working. */

  /* THE ROW'S ACTION IS NARROWER AND EXACTLY AS TALL, and the height is the part that is not mine
     to change.
   *
   * Reported as the buttons wanting to be smaller on a small screen, which they do -- but
   * EveryScreenOnAPhoneIT holds every control to 44px and exempts precisely two things, each with a
   * written argument: a button inside a table row, because the row's name cell is already a
   * full-height link to the same place, and the search button, because the palette is a shortcut to
   * screens the tab bar also reaches. A notice's Open is neither. It is the only route to the thing
   * the notice is about, and a 32px target for the one control on a row is what that floor exists to
   * prevent.
   *
   * So the padding comes in and the height stays. The button gets visibly smaller without becoming
   * harder to hit, which is the half of "too big" that can be honoured. */
  .ex-queue__act .ex-btn {
    padding-left: var(--ex-space-3);
    padding-right: var(--ex-space-3);
  }

  /* The same for a card's own actions, which on the notifications page are Mark all read and Clear
     sitting side by side on a 375px row. */
  .ex-card__head .ex-actions-wrap .ex-btn {
    padding-left: var(--ex-space-3);
    padding-right: var(--ex-space-3);
  }

  /* ---- THE TITLE IN THE TOP BAR HAD BECOME "Noti..." -------------------------------------------
   *
   * At 375 the bar holds a menu button, the page title, the search button, the book chip, the bell
   * and the avatar. Everything but the title is a fixed size, so the title is what gives -- and it
   * gave until it was four letters and an ellipsis. "Reco...", "Noti...", "Acc...".
   *
   * The breadcrumb directly beneath it says "Dashboard > Notifications", in full, on every screen.
   * So the top bar was spending the last of its width on a worse copy of the line under it.
   *
   * WHAT STAYS IS THE BOOK. This product's likeliest serious mistake is a grocery bill entered into
   * the company accounts, and the chip is the only thing that prevents it -- so at the width where
   * one of them has to go, the one that goes is the one repeated ten pixels lower. It comes back at
   * 26rem, where there is room for both.
   *
   * The h1 is hidden rather than removed: a screen reader still reads it, and the page still has one
   * heading that names it. */
  .ex-topbar__title--repeated { display: none; }

  /* THE BOOK TAKES THE SPACE THE TITLE GAVE UP. It is the first thing on the bar at this width and
     the widest, which is the right order: everything else here is a control, and this is the fact
     that stops a grocery bill reaching the company accounts.
   *
   * .ex-topbar .ex-topbar__book, not .ex-topbar__book, and the extra selector is load-bearing.
   * .ex-badge sets `flex: none` two thousand lines further down, which is right for a badge in a
   * table cell and wrong for the one flexible item on this bar -- same specificity, later in the
   * file, so it won. The chip stayed at its full 197px and pushed the user menu 16px off the right
   * edge of a 375px screen. Measured rather than guessed: the bar was 375 wide with a scrollWidth
   * of 391. */
  .ex-topbar .ex-topbar__book {
    flex: 1 1 auto;
    min-width: 0;
    margin-right: auto;
  }

  /* THE PAGER IS FURNITURE AND WAS DRESSED LIKE A DECISION.
   *
   * "Showing 1–3 of 3" with Prev, 1 and Next takes a whole band under a three-row table, and on touch
   * every one of those buttons is 44px tall because that is the tap-target rule -- so the thing that
   * says how little there is to see is bigger than a row of what there is.
   *
   * THE HEIGHT STAYS AND THE WEIGHT GOES. Turning them down to 32px would be quietly repealing the
   * rule for the control somebody misses most often when a list is long. What actually made them look
   * heavy is the width and the ink: full horizontal padding and body-sized text on three buttons in a
   * row. Narrower and quieter, still 44 tall. */
  .ex-pager {
    gap: var(--ex-space-2);
    margin-top: var(--ex-space-3);
    flex-wrap: wrap;
  }

  .ex-pager .ex-btn {
    padding-left: var(--ex-space-2);
    padding-right: var(--ex-space-2);
    font-size: var(--ex-text-sm);
  }

  .ex-pager__status,
  .ex-datatable__foot {
    font-size: var(--ex-text-xs);
    color: var(--ex-color-text-muted);
  }

  /* WHAT THE TOP BAR GIVES UP FIRST, and it was giving up the wrong thing.
   *
   * Four things competed for 390px: a menu button, the page title, the build stamp, a bell and the
   * signed-in name. The title is the only one that shrinks — it carries the ellipsis — so it lost every
   * argument and collapsed to a single letter and a full stop. "Clients" rendered as "C." while
   * "Jane Banda" sat beside it at full width, which is precisely backwards: you know who you are, and
   * the one thing a small screen has to tell you is where you are.
   *
   * So the two that are not answering a question go. The build stamp is for a support call. The name is
   * for a shared desk, and the avatar still says whose session this is — the name itself is one tap away
   * in the menu it sits on. */
  .ex-topbar__version,
  .ex-user__name { display: none; }
  .ex-topbar__title { min-width: 0; }

  /* AN EMPTY LIST HAS TO EXPLAIN ITSELF WITHOUT BEING DRAGGED SIDEWAYS.
   *
   * The "no clients yet" row is a colspan across a table whose five column names held it 112px wider
   * than the phone, so the sentence saying why the list was empty wrapped to that width and ran off the
   * edge of the scroll box. You had to scroll a table with no rows in it to read the one row it did
   * have — and at that moment the sentence is the entire content of the screen.
   *
   * The header goes, rather than being allowed to wrap. Wrapping was tried first and does nothing here:
   * every column name is a single word, so "Registration" and "Portfolios" cannot break, and with the
   * sort caret beside each one the row has a floor about 110px past the screen. Nothing to wrap at.
   *
   * Hiding it is also the more honest answer. Column headings label columns of values, and there are no
   * values — an empty grid's headings are furniture around a message, and on the one screen size where
   * the furniture costs the message its legibility, the message wins. The moment there is a row to
   * label, the headings are back.
   *
   * Pinned by ResponsiveLayoutIT, which measures the overflow rather than trusting this comment. */
  .ex-table:has(.ex-table__empty) thead { display: none; }
}

/* ---- Touch, and the small screen it usually comes with ----------------------------------------
 *
 * TWO DIFFERENT QUESTIONS, ASKED SEPARATELY, and keeping them apart is what lets this product be a
 * dense back-office tool and a usable phone application at the same time.
 *
 *   (pointer: coarse)   is this being driven by a finger? Decides how big a thing you press has to be.
 *   (max-width: 48rem)  is there room for the shell? Decides the drawer, the tab bar, the layout.
 *
 * They are not the same question and conflating them gets both wrong. A touchscreen laptop at 1440 is
 * coarse and roomy: it wants big targets and the full rail. A narrow desktop window is fine-pointered
 * and cramped: it wants the drawer and 38px controls, because a mouse hits 38px exactly and
 * `docs/css.md` is explicit that forty rows beat twelve beautifully. Sizing off width would have taken
 * the density from the second to help the first.
 *
 * Everything in the coarse block below was measured rather than guessed: every interactive control in
 * the product came in under 44px on a phone, the smallest being a 27px pager button. 44 is Apple's
 * number, 48 is Google's, and both come from the same fact — the pad of an adult index finger is about
 * 10mm. Under that you aim rather than press, and here the control beside the one you missed is the one
 * that authorises something. ResponsiveLayoutIT measures it in a real touch context. */

@media (pointer: coarse) {
  /* One number, and the small variant gives up being small. A pager button that is visually compact is
     a fine idea for a mouse and a bad one for a thumb, and there is no version of "compact" that is
     also 44px. Density on touch is worth less than being able to hit the thing. */
  :root {
    --ex-control-height: 2.75rem;
    --ex-control-height-sm: 2.75rem;
  }

  /* THE ONE CONTROL THE COARSE BLOCK MISSED, found by sweeping every screen in a touch context.
   *
   * A chip's remove button is 17px, which is a third of a fingertip. It is not on the style guide
   * only: the multi-select is on ten real forms, including recording a transaction and closing an
   * account, so this is a person on a phone trying to take back a category they picked by mistake and
   * hitting the chip instead.
   *
   * THE TARGET GROWS AND THE CHIP DOES NOT, which is why this is padding and a negative margin rather
   * than a width. A 44px glyph inside a chip would make the chip 44px tall and every chip row on
   * every form would grow with it; the pseudo-element takes the taps instead and nothing moves.
   * `position: relative` on the button is what the ::after is measured against.
   *
   * ResponsiveLayoutIT is named in the comment above as measuring all of this. It has never existed.
   * EveryScreenOnAPhoneIT now does it, in a context with hasTouch set, which is the only way this
   * block is even active -- a 390px window with a mouse is not a phone and is deliberately styled
   * like the narrow desktop window it is. */
  .ex-select__chip-remove {
    width: 2.75rem;
    height: 2.75rem;
    /* THE BOX IS 44px AND OCCUPIES 17px OF THE ROW, which is what the negative margin buys: exactly
       half the difference, pulled back on every side, so the chip and the row it sits in do not grow
       by a pixel while the thing a thumb has to land on is full size.

       A ::after overlay was tried first and rejected. It works, and it cannot be measured: the button
       still reports 17px to getBoundingClientRect, so the test could not tell a real fix from a
       comment claiming one. On this project that distinction is the whole point. */
    margin: calc((1.05rem - 2.75rem) / 2);
  }

  /* THE SINGLE MOST VISIBLE MOBILE DEFECT IN THE PRODUCT, and it is one line.
   *
   * iOS Safari zooms the entire page when you focus an input whose text is under 16px. Ours are 14px,
   * so every tap on every field on every form threw the layout up to 114% and left it there — the
   * user then pinches back out, on every field, for ever. It is not subtle and it is not rare; it is
   * the thing that makes a web form feel like a web form.
   *
   * max() rather than 16px flat, so somebody on the Large text size keeps their larger type instead of
   * being pushed back down to 16 — and somebody on Small still clears the zoom threshold, which a
   * plain 1rem would not, since 1rem is 14px for them. */
  .ex-field__input,
  .ex-field__textarea,
  .ex-select__control,
  .ex-select__search,
  .ex-datepicker__field,
  .ex-datatable__search input,
  .ex-listbar__search input {
    font-size: max(1rem, 16px);
  }

  /* The icon-only controls, which were the worst of it: the two things a person taps most on a phone —
     the menu and the bell — were 36px, and the sidebar toggle 32px. */
  .ex-topbar__menu,
  .ex-notify__button,
  .ex-sidebar__toggle,
  .ex-modal__close,
  .ex-toast__close,
  .ex-datepicker__trigger,
  .ex-field__reveal {
    min-width: 2.75rem;
    min-height: 2.75rem;
  }

  /* Navigation rows. Comfortable rather than merely legal — these are the full width of the drawer, so
     the height is the only dimension that was ever in question. */
  .ex-nav__item,
  .ex-user__menuitem,
  .ex-menu__item,
  .ex-user__trigger {
    min-height: 2.75rem;
  }

  /* Both dimensions on a button, not just the height. The pager's page-number buttons were 27px wide
     and the height fix did nothing for them: a 27x44 target is still a miss waiting to happen, and a
     pager is a row of them side by side, so the one you hit by mistake is the wrong page. */
  .ex-btn { min-width: 2.75rem; }

  /* A tick box and its words are one control — the label is what you press, and pressing the words has
     toggled it since it was written. So the label is the target, and the 18px box inside it is a mark
     rather than something to aim at. Sized here so the pair clears the minimum as a unit. */
  .ex-check { min-height: 2.75rem; }

  /* A breadcrumb is a trail of inline text links, and the 44px rule is written for controls: WCAG's own
     target-size criterion exempts a link sitting in a run of text, because padding one to 44px either
     breaks the line it is in or turns a trail into a stack of buttons. It still has to be reachable
     though, and 23px was under even the 24px floor that does apply. */
  .ex-breadcrumb__link {
    padding-top: 0.4rem;
    padding-bottom: 0.4rem;
    margin-top: -0.4rem;
    margin-bottom: -0.4rem;
  }

  /* THE GREY FLASH, which is the clearest single tell that something is a web page.
   *
   * Removed only because there is something real underneath it: .ex-btn already presses down on
   * :active, the nav rows already fill. Taking the highlight away without leaving feedback behind makes
   * an interface that feels dead rather than native, which is the more common way this goes wrong. */
  a,
  button,
  [role="button"],
  input,
  select,
  textarea,
  label {
    -webkit-tap-highlight-color: transparent;
  }
}

@media (max-width: 48rem) {
  /* THE BOTTOM TAB BAR — the primary destinations, where a thumb already is.
   *
   * Four is the ceiling somebody can hit reliably at this width, and the fourth is More, which opens
   * the drawer this replaces rather than duplicating it. Administration, the operator's institutions
   * and everything still marked "soon" live behind it. As Phase 2 lands real sections they get promoted
   * out of More and into a tab; until then a bar of five mostly-disabled items would be a worse answer
   * than a bar of three that work.
   *
   * The drawer is not gone — More is its handle, and it still holds the whole navigation. What has gone
   * is having to open it to reach the two screens anybody uses. */
  .ex-tabbar {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 20;
    display: flex;
    background: var(--ex-color-surface);
    border-top: var(--ex-border);
    /* The home indicator's strip on a modern iPhone. Without it the last row of a tab bar sits under
       the bar the operating system draws, and the tab you are aiming at is the one you cannot press. */
    padding-bottom: var(--ex-safe-bottom);
  }

  .ex-tabbar__item {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.15rem;
    min-height: 3.25rem;
    padding: var(--ex-space-2) var(--ex-space-1);
    border: none;
    background: none;
    color: var(--ex-color-text-muted);
    font: inherit;
    font-size: var(--ex-text-2xs);
    text-decoration: none;
    cursor: pointer;
  }

  .ex-tabbar__item .ex-icon { width: 1.35rem; height: 1.35rem; }
  .ex-tabbar__item:active { background: var(--ex-color-bg); }

  /* The current section, marked by weight and colour rather than by a filled pill — a filled tab reads
     as a button somebody has pressed and is still holding down. */
  .ex-tabbar__item--active {
    color: var(--ex-color-accent);
    font-weight: 600;
  }

  /* Room for it. The shell is a fixed-height grid, so the bar overlays the scrolling region rather than
     taking a row of its own — the content has to end above it or the last row of every list is
     permanently under the navigation.

     MEASURED, NOT ASSUMED. This said 3.25rem, matching the item's min-height, and the bar renders at
     57.5px because its contents are taller than that minimum: a 1.35rem icon, a gap and a label do not
     fit in 3.25rem. So the clearance was five pixels short and the last row of every list sat under
     the bar — which is the "bottom menu is sitting weird" that got reported.

     Picking a bigger number would fix today and break again for anybody using a larger system text
     size, where the label grows and the bar with it. main.js measures the bar and writes
     --ex-tabbar-height, so the clearance follows whatever it actually is; the literal here is only the
     value before that runs, and before it the page has not painted. */
  .ex-scroll {
    /* THE MEASURED HEIGHT ALREADY CONTAINS THE INSET, so adding it here counted it twice.
     *
     * .ex-tabbar pads its own bottom by the inset, and getBoundingClientRect returns the border box,
     * so the number main.js writes into --ex-tabbar-height is bar plus inset. Adding the inset again
     * put 34px of dead space under every list on an installed iPhone -- and nothing at all on a
     * laptop, where the inset is 0, which is why resizing a window never showed it.
     *
     * The fallback still adds it, because it is used only before main.js has measured anything. */
    padding-bottom: var(--ex-tabbar-height, calc(3.75rem + var(--ex-safe-bottom)));

    /* THE ACTIONS BAR MUST NOT COVER THE FIELD BEING TYPED INTO.
     *
     * Reported as the sticky button covering the very input you are trying to fill in, and it did:
     * 118px of the narrative field on the record-a-transaction form was underneath it.
     *
     * The browser scrolls a focused field into view by itself, and does it correctly -- "into view"
     * means inside the scrollport. It has no way to know that the last 70-odd pixels of that
     * scrollport are painted over by a position: sticky bar, so it stops as soon as the field's
     * bottom edge clears the frame, which is exactly where the bar is.
     *
     * scroll-padding-bottom is the browser's own answer to that: it shrinks the box that
     * scroll-into-view aims at, without moving anything on the page. So this is not a nudge or a
     * scroll listener, it is telling the browser where the usable bottom actually is -- and it works
     * for anything that scrolls something into view, including a keyboard opening, tabbing between
     * fields and jumping to a field that failed validation.
     *
     * The keyboard is added because the bar lifts by it, so the covered strip grows with it. */
    scroll-padding-bottom: calc(
      var(--ex-control-height) + var(--ex-space-3) * 2 + var(--ex-safe-bottom)
        + var(--ex-keyboard-height, 0px) + var(--ex-space-4)
    );
  }

  /* The hamburger goes. With a tab bar carrying More, two controls opened the same drawer from opposite
     corners of the same bar, and the one in the top left is the one a thumb cannot reach. */
  .ex-topbar__menu { display: none; }

  /* The notch, and the rounded corners beside it. viewport-fit=cover in the document head is what lets
     the page paint into those areas at all; these are what keep anything readable out of them. */
  .ex-topbar {
    padding-top: calc(var(--ex-space-3) + var(--ex-safe-top));
    padding-left: calc(var(--ex-space-4) + var(--ex-safe-left));
    padding-right: calc(var(--ex-space-4) + var(--ex-safe-right));
  }

  /* AND THE DRAWER ENDS ABOVE THE TAB BAR, which it did not.
   *
   * The drawer is 100dvh and the tab bar is fixed to the bottom of the same viewport at the same
   * z-index, so the last thing in the drawer sat underneath it. .ex-nav--bottom is pinned there by
   * `margin-top: auto` and holds My account and Sign out -- which are the things somebody opens the
   * drawer to reach. Measured at 390: the last item overlapped the bar by 37px, most of its height.
   *
   * The same clearance the scrolling content uses. main.js measures the bar and writes
   * --ex-tabbar-height, so this follows whatever the bar actually is rather than a literal that is
   * right until somebody turns their system text size up and the labels grow.
   *
   * THE MEASURED VALUE ALREADY CONTAINS THE SAFE-AREA INSET, so it is not added again here -- the
   * same double-count .ex-scroll's own note describes, which put 34px of dead space under every list
   * on an installed iPhone and nothing at all on a laptop. The fallback adds it, because the fallback
   * is only in force before main.js has measured anything.
   *
   * Written in this block rather than beside the drawer's other phone rules because this one is
   * later in the file: the earlier `padding-bottom` was simply overridden here, which is why the
   * first attempt at this fix changed nothing at all. */
  .ex-sidebar {
    padding-top: calc(var(--ex-space-3) + var(--ex-safe-top));
    padding-bottom: calc(
      var(--ex-space-3) + var(--ex-tabbar-height, calc(3.75rem + var(--ex-safe-bottom)))
    );
    padding-left: calc(var(--ex-space-3) + var(--ex-safe-left));
  }

  /* A MODAL BECOMES A SHEET.
   *
   * A centred dialog is a desktop shape: it assumes a pointer that can reach the middle of the screen
   * and a window big enough for the dialog to sit inside rather than fill. On a phone it lands under
   * the thumb's reach with its buttons at the top, which is the wrong end.
   *
   * Anchored to the bottom instead, full width, square along the bottom edge because there is no gap
   * under it to round against, and rising rather than fading in. Its foot is the first thing the thumb
   * reaches, which is where the buttons already are. */
  .ex-modal {
    width: 100%;
    max-width: none;
    margin: 0;
    margin-top: auto;
    max-height: 88dvh;
    border-radius: var(--ex-radius-lg) var(--ex-radius-lg) 0 0;
  }

  .ex-modal--sm,
  .ex-modal--md,
  .ex-modal--lg,
  .ex-modal--confirm { max-width: none; }

  .ex-modal[open] { animation: ex-sheet-up 0.22s cubic-bezier(0.2, 0.85, 0.25, 1); }

  .ex-modal__foot {
    padding-bottom: calc(var(--ex-space-4) + var(--ex-safe-bottom));
  }

  /* A sheet's actions share the width rather than stacking.
   *
   * THIS SAID column-reverse, on the reasoning that a row of buttons at this size is a row of targets
   * too narrow to hit. That is true of a row of five. A confirm has two, and two across 390px is
   * about 165px each -- four times a fingertip, and taller than the 44px minimum because they are
   * .ex-btn. What stacking actually bought was a dialog whose two answers sit one above the other,
   * which reads as a list to choose from rather than as yes and no, and takes twice the height of a
   * sheet already limited to 88dvh.
   *
   * flex: 1 1 0 rather than auto, so Cancel and Delete come out the same width instead of sized to
   * their own words -- a wide destructive button beside a narrow safe one is a nudge. */
  .ex-modal__foot .ex-btn { flex: 1 1 0; justify-content: center; min-width: 0; }

  /* FORM ACTIONS COME TO THE THUMB, INSIDE THE APPLICATION SHELL AND NOWHERE ELSE.
   *
   * Save at the bottom of a long form means scrolling to the end to commit, and on a phone that is a
   * lot of scrolling to reach the one control the whole screen exists for. Stuck to the foot of the
   * scrolling region instead, on its own ground so it does not read as floating over the last field.
   *
   * position: sticky rather than fixed, deliberately: sticky stays inside the document flow, so it
   * cannot cover the field above it and it goes away when the form does.
   *
   * .ex-scroll IS PART OF THE SELECTOR AND IS THE FIX FOR A REPORTED BUG. This applied to every form
   * in the product, and the sign-in page is not in the shell: it is a short centred card with nothing
   * to scroll. So there was no keyboard to hide from, and the keyboard offset simply lifted the
   * button off the bottom of its card -- measured at 27.5px, mid-air, and it stayed there for as long
   * as the value did. Reported as the button floating after the keyboard closed, with its loading
   * state still on.
   *
   * A form that cannot scroll has nothing to stick to. The auth cards, the offline page and the
   * public page all keep their buttons in the flow, which is where a short form wants them. */
  .ex-scroll .ex-form__actions {
    position: sticky;
    /* ABOVE THE KEYBOARD, NOT BEHIND IT. bottom: 0 is the bottom of the layout viewport, which does
       not move when a keyboard opens -- the keyboard is drawn over the page rather than resizing it,
       so the buttons stayed exactly where they were and the keyboard covered them. main.js measures
       the difference between the visual and layout viewports and writes it here; it is 0 whenever
       there is no keyboard, so this is the same rule as before at rest. */
    bottom: var(--ex-keyboard-height, 0px);
    z-index: 10;
    margin: var(--ex-space-4) calc(-1 * var(--ex-space-4)) 0;
    padding: var(--ex-space-3) var(--ex-space-4);
    padding-bottom: calc(var(--ex-space-3) + var(--ex-safe-bottom));
    background: var(--ex-color-surface);
    border-top: var(--ex-border);
  }

  /* The buttons themselves fill the width on any form, sticky or not: a submit button is the one
     control a form exists for and there is no reason for it to be half the width of a phone. */
  .ex-form__actions {
    gap: var(--ex-space-2);
  }

  .ex-form__actions .ex-btn { flex: 1 1 auto; justify-content: center; }

  /* THE TOAST COMES DOWN TO THE THUMB TOO — and only its placement changes.
   *
   * The design itself is sound and stays exactly as it is: the icon chip carrying the state, the
   * draining progress bar, the border tinted from the same token. What was wrong was where it sat. The
   * top right is the furthest corner of a phone from the hand holding it, it is the corner the notch is
   * in, and the dismiss control was in the furthest corner of that.
   *
   * Above the tab bar, full width inside the insets, so the message and its close button are both under
   * the thumb that is already there. */
  .ex-toasts {
    top: auto;
    left: var(--ex-space-3);
    right: var(--ex-space-3);
    bottom: calc(3.25rem + var(--ex-space-3) + var(--ex-safe-bottom));
  }

  .ex-toast {
    min-width: 0;
    max-width: none;
  }
}

@keyframes ex-sheet-up {
  from { opacity: 0; transform: translateY(1.5rem); }
  to { opacity: 1; transform: none; }
}

/* Sign-in page ---------------------------------------------------------- */
/* Standalone: a centred card on the app background, no shell. */
.ex-auth {
  /* 100vh FIRST AS THE FALLBACK, THEN THE HEIGHT WE ACTUALLY WANT. A browser that does not know
     dvh keeps the vh line; one that does takes the second and forgets the first. */
  min-height: 100vh;
  /* dvh IS THE VIEWPORT AS IT IS RIGHT NOW, not as it is with the browser's chrome hidden. vh does
     not move when the address bar slides away or a keyboard opens, so the card was being centred in
     a taller box than the one being looked at -- on a phone with the keyboard up that put it behind
     the keyboard, and you had to scroll to find the password field you were typing into.

     The keyboard is subtracted on top of that: iOS does not shrink even dvh for it, so main.js
     measures it (visual viewport against layout viewport) and this centres the card in what is
     left. It is 0px whenever there is no keyboard, so this is plain centring at rest. */
  min-height: calc(100dvh - var(--ex-keyboard-height, 0px));
  display: flex;
  justify-content: center;
  /* NOT align-items: center. Centring a flex item that is taller than its container clips the top
     of it and puts it out of reach -- there is nothing to scroll back to, because the overflow is
     above the scroll origin. margin: auto on the card centres it exactly the same way when it fits
     and simply stops centring when it does not, which is the behaviour wanted on a small phone with
     a keyboard open and an error message showing. */
  align-items: flex-start;
  padding: var(--ex-space-6);
  padding-top: calc(var(--ex-space-6) + var(--ex-safe-top));
  padding-bottom: calc(var(--ex-space-6) + var(--ex-safe-bottom));
  background: var(--ex-color-bg);
}

.ex-auth__card {
  width: 100%;
  max-width: 22rem;
  margin: auto;
  padding: var(--ex-space-8);
  background: var(--ex-color-surface);
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  box-shadow: var(--ex-shadow-lg);
}

/* A phone is not a desk. The card is the whole screen there, so the deep padding a floating card
   wants on a monitor just pushes the fields it holds off the bottom of a short one. */
@media (max-width: 30rem) {
  .ex-auth { padding: var(--ex-space-4); }
  .ex-auth__card { padding: var(--ex-space-6); }
}

.ex-auth__brand {
  margin: 0;
  font-size: var(--ex-text-2xl);
  font-weight: 700;
  color: var(--ex-color-text);
}

.ex-auth__lead {
  margin: var(--ex-space-1) 0 var(--ex-space-6);
  color: var(--ex-color-text-muted);
}

.ex-auth__note,
.ex-auth__error {
  margin: 0 0 var(--ex-space-4);
  padding: var(--ex-space-3);
  border-radius: var(--ex-radius);
  font-size: var(--ex-text-sm);
}

.ex-auth__note {
  background: var(--ex-color-info-bg);
  color: var(--ex-color-info);
}

.ex-auth__error {
  background: var(--ex-color-danger-bg);
  color: var(--ex-color-danger);
}

.ex-auth__submit {
  width: 100%;
  justify-content: center;
  margin-top: var(--ex-space-2);
}


/* Admin lists & forms --------------------------------------------------- */

/* The page head: what this screen is, and what you can do on it.
 *
 * IT WRAPS RATHER THAN SQUEEZING THE PROSE, and it did the opposite until 2026-08-19. The rule was a
 * rigid row — `align-items: center`, `justify-content: space-between`, and `flex: none` on the actions —
 * which means the button cluster takes whatever width it needs and the paragraph absorbs every bit of
 * the shrinking. One button and a sentence look perfect. Five buttons and three sentences give you a
 * ragged column of text about twenty characters wide beside a wide row of buttons, which is what was
 * reported on the working-day calendar.
 *
 * THE THREE PROPERTIES THAT FIX IT, and each is doing a different job:
 *
 *   flex-wrap on the head, so that when the two cannot sit side by side the actions drop to their own
 *   full-width line underneath instead of the sentence giving up its room. A wrap decision is made on
 *   the lead's flex BASIS, not on how small it could be squeezed, which is what makes the basis below
 *   the whole mechanism.
 *
 *   A readable measure as that basis. 28rem is a little under the 32rem .ex-form calls the readable
 *   width for a column somebody reads down; a lead narrower than this has stopped being a paragraph and
 *   become a list of words. It is a floor and not a cap: the lead still grows into whatever room is left.
 *
 *   flex-wrap on the actions, which is the mobile half. Every .ex-btn is `white-space: nowrap`, so five
 *   of them in a non-wrapping row on a phone is a row wider than the phone. They now flow onto as many
 *   rows as they need.
 *
 * WHAT THIS DOES NOT CHANGE is the ordinary page, and that is deliberate. A screen with one action —
 * which is most of them — has a lead basis of 28rem plus a 7rem button, so it sits side by side at every
 * width it ever did. The behaviour only differs where the head was already too full to read.
 *
 * The row gap is tighter than the head's own bottom margin on purpose: when the actions do wrap, they
 * have to read as belonging to the head above them rather than to the content below. */
.ex-page__head {
  display: flex;
  flex-wrap: wrap;
  /* The actions belong to the page, not to the middle of a sentence, so they sit level with the first
     line of it. Identical to `center` on the one-line leads that are most of the product; it is the
     multi-line ones where a centred button row floats. */
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--ex-space-3) var(--ex-space-4);
  margin-bottom: var(--ex-space-4);
}

.ex-page__head .ex-page__lead {
  margin: 0;
  flex: 1 1 28rem;
}

/* THE LEAD ALWAYS TAKES ITS OWN ROW AND THE BUTTONS SIT UNDER IT.
 *
 * The rule above wraps the actions only when they no longer fit beside a 28rem lead, which makes the
 * arrangement of a page head depend on how many buttons happen to be on it. Counterparties has four and
 * reads well — a full-width sentence, then a row of actions. Clients has one, so the same sentence is
 * squeezed into two-thirds of the page with a button floating beside it, and adding a second button
 * later squeezes it again. Same markup, different layout, decided by arithmetic nobody intended.
 *
 * Reported by a person comparing the two (2026-08-23), and the ask was to make the counterparties
 * arrangement the standard rather than the accident.
 *
 * IT WAS SCOPED TO .ex-page--fill FIRST, AND THAT WAS TOO NARROW (2026-08-24). The listing marker was a
 * convenient way to mean "a page with a growing row of actions", and the reasoning went that a form or a
 * detail page, with one button beside a short lead, was fine as it was. Then the Bank of Zambia register
 * turned up: not a listing, one button, and a three-line explanation squeezed into two-thirds of the
 * page beside it — the exact defect, on a page the rule did not reach.
 *
 * The lead is not always short, and that is what the first version got wrong. So it applies to every
 * page head now, which is also what was asked for the first time: the arrangement should be the
 * product's, not a category's.
 *
 * flex-basis rather than the whole shorthand, so the grow and shrink above still apply. */
.ex-page__head .ex-page__lead {
  flex-basis: 100%;
}

/* AND THE ACTIONS KEEP THE RIGHT-HAND EDGE THEY HAD.
 *
 * justify-content: space-between above is applied per flex line, and a line holding one item puts it
 * at the start — so the moment the lead took a whole row on its own, the buttons dropped to the left
 * margin. That moves the primary action from where it has always been on every other screen, and puts
 * it under the beginning of a sentence rather than at the end of the head.
 *
 * margin-left: auto rather than justify-content: flex-end, because the actions are the only thing on
 * their line and an auto margin says "take the space before me" without changing how the first line
 * lays out. */
.ex-page__head .ex-page__actions {
  margin-left: auto;
}

/* The action cluster on the right of a page head — one or more buttons kept together. */
.ex-page__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-3);
  flex: 0 0 auto;
}

/* A vertical run of cards/sections with even breathing room between them. Opt-in; .ex-page itself sets
   no gap, so a page that stacks full-width cards reaches for this instead of per-card margins. */
.ex-stack {
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-5);
}

/* Status band — a card that states where something stands (the rate pack in force, the licence). The icon
   sits in a soft round badge (same treatment as the empty-state icon) so it reads at a glance instead of as
   a tiny stroke. Default is calm/positive (green); the variants below shout when they need to. Shared by
   Settings › Statutory and Settings › Licence. */
/* Not a coloured bar down the left-hand side. That is every framework's alert box, it says "generic
   warning" before it says anything about this system, and four of them stacked read as a wall of
   stripes.

   Instead the whole band takes a wash of its own tint, edged in the same hue at low opacity, and the
   state is carried by the icon badge — which is the thing the eye lands on first anyway. The result
   sits inside a card without fighting it, and a run of them reads as a list of conditions rather than
   as a column of warnings.

   colour-mix keeps every tone derived from one token, so a state stays coherent when the token moves
   and there are no hand-picked pastels to maintain. */
/* A status band that belongs to the shell rather than to the page: the subscription strip, above the
   content. It needs the gap the page's own stack would have given it — without one it sits flush
   against the breadcrumb below and reads as part of it. */
.ex-shellband {
  margin-bottom: var(--ex-space-5);
}

.ex-statusband {
  display: flex;
  align-items: flex-start;
  gap: var(--ex-space-4);
  padding: var(--ex-space-4) var(--ex-space-5);
  border-radius: var(--ex-radius-lg);
  background: color-mix(in srgb, var(--ex-color-success) 7%, var(--ex-color-surface));
  border: 1px solid color-mix(in srgb, var(--ex-color-success) 22%, transparent);
}
.ex-statusband + .ex-statusband { margin-top: var(--ex-space-3); }

.ex-statusband--info {
  background: color-mix(in srgb, var(--ex-color-info) 7%, var(--ex-color-surface));
  border-color: color-mix(in srgb, var(--ex-color-info) 22%, transparent);
}
.ex-statusband--warn {
  background: color-mix(in srgb, var(--ex-color-warning) 8%, var(--ex-color-surface));
  border-color: color-mix(in srgb, var(--ex-color-warning) 26%, transparent);
}
.ex-statusband--danger {
  background: color-mix(in srgb, var(--ex-color-danger) 7%, var(--ex-color-surface));
  border-color: color-mix(in srgb, var(--ex-color-danger) 24%, transparent);
}
.ex-statusband__note:last-child { margin-bottom: 0; }
.ex-statusband__icon {
  flex: none;
  box-sizing: content-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.25rem;
  height: 1.25rem;
  padding: 0.5rem;
  border-radius: 50%;
  background: var(--ex-color-success-bg);
  color: var(--ex-color-success);
}
.ex-statusband__icon .ex-icon { width: 1.25rem; height: 1.25rem; }
.ex-statusband__title { font-weight: 600; margin-bottom: var(--ex-space-1); }
.ex-statusband__note {
  color: var(--ex-color-text-muted);
  font-size: var(--ex-font-body);
  line-height: 1.5;
}
/* An action under a status band (e.g. "go to the open run") sits a step below the note. */
.ex-statusband__actions { margin-top: var(--ex-space-3); }

/* The icon badge per state. One idiom for all four: the state's soft background with its own colour on
   the glyph, exactly as the default (success) sets it above.
 *
 * --warn and --danger used to be declared twice. The wash above was written first, with the long comment
 * arguing against coloured alert boxes; thirty lines below it a second pair of rules gave both a solid
 * fill, a solid border and a white glyph, and being later in the file they won. So the file described one
 * design and rendered another, and only --info and the default still looked like the thing the comment
 * describes. The styleguide's limits panel stacks a default, a --warn and a --danger in a row, which is
 * precisely the "wall of stripes" the comment set out to avoid, and that is what it showed.
 *
 * The duplicates are gone. If a state needs to shout louder than a wash, that is a change to the wash
 * percentages here, not a second block further down. */
.ex-statusband--warn .ex-statusband__icon {
  background: var(--ex-color-warning-bg);
  color: var(--ex-color-warning);
}
.ex-statusband--danger .ex-statusband__icon {
  background: var(--ex-color-danger-bg);
  color: var(--ex-color-danger);
}
/* Info (calm card, blue badge): nothing installed yet — reads as a next step, not an error.
 *
 * --ex-color-info, not --ex-color-accent. The two hold the same blue in the default theme, so the wrong
 * one was invisible — until a client picks a brand colour, at which point the accent moves and this badge
 * would have gone green inside a blue wash. A status colour is not the brand colour; they only look alike. */
.ex-statusband--info .ex-statusband__icon {
  background: var(--ex-color-info-bg);
  color: var(--ex-color-info);
}

.ex-note-muted { color: var(--ex-color-text-muted); font-size: var(--ex-text-sm); }

/* WHERE A NUMBER CAME FROM, said on the screen that shows it.

   A market convention is not something this software knows; it is something somebody looked up, and
   the person reading a coupon figure in a board pack is entitled to see which document it rests on
   without asking us. So a citation is a component rather than a sentence somebody remembers to write,
   and it looks the same everywhere it appears.

   Quiet by design — a rule above it, muted, small. It is a footnote, not a claim competing with the
   figure it supports. The icon is what makes it scannable as "this is provenance" at a glance. */
.ex-source {
  display: flex;
  align-items: flex-start;
  gap: var(--ex-space-2);
  margin-top: var(--ex-space-4);
  padding-top: var(--ex-space-3);
  border-top: var(--ex-border);
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-xs);
  line-height: 1.5;
}

/* The icon keeps its size while the text beside it is small, or it reads as a smudge. */
.ex-source > .ex-icon { flex: none; width: 1rem; height: 1rem; margin-top: 0.1rem; }

/* The document itself, so the eye lands on what to go and read. */
.ex-source__what { color: var(--ex-color-text); font-weight: 500; }

.ex-muted {
  color: var(--ex-color-text-faint);
}

.ex-form__error {
  display: flex;
  align-items: flex-start;
  gap: var(--ex-space-2);
  margin: 0 0 var(--ex-space-4);
  padding: var(--ex-space-3);
  border-radius: var(--ex-radius);
  border-left: 3px solid var(--ex-color-danger);
  background: var(--ex-color-danger-bg);
  color: var(--ex-color-danger);
  font-size: var(--ex-text-sm);
  line-height: 1.4;
}

/* A small alert glyph so an error reads as an error at a glance, not just red text. Inline SVG mask
   so it takes the danger colour like the text (no separate icon markup in every template). */
.ex-form__error::before {
  content: "";
  flex: none;
  width: 1.05rem;
  height: 1.05rem;
  margin-top: 0.05rem;
  background: currentColor;
  -webkit-mask: var(--ex-icon-alert) center / contain no-repeat;
  mask: var(--ex-icon-alert) center / contain no-repeat;
}

.ex-field__hint {
  display: block;
  margin-top: var(--ex-space-1);
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
}

/* How much of a capped field is used, under its right edge: "4/10". Sits opposite the hint rather than under
   it, because it is a running total rather than something to read. Any field with a maxlength can opt in with
   data-ex-count; it is not a code-only thing. It fills in at the limit so hitting the cap is felt, not just
   discovered when typing stops doing anything. */
.ex-field__count {
  display: block;
  margin-top: var(--ex-space-1);
  font-size: var(--ex-text-2xs);
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--ex-color-text-faint);
}
.ex-field__count--full {
  color: var(--ex-color-text-muted);
  font-weight: 600;
}

/* Readable first, decorative second. At 11px in a pill this was the smallest type in the product and
   the hardest to read, which is the wrong way round for something that carries a record's *state* —
   "Settled" against "Awaiting authorisation" is a distinction somebody has to make at a glance down a
   column. So it came up a step, with a little more room inside the pill, and the neutral one takes full
   text colour rather than muted grey.

   It came up to 12.5px, which was a number nobody else in the file used — the badge won its argument and
   invented a step to win it with. Now it takes --ex-text-xs, whose comment names badges as one of the
   things that step is for. The reading barely moves (12px against 12.5px), the badge keeps everything it
   gained over 11px, and the scale no longer has a private step in it for one component. */
.ex-badge {
  display: inline-block;
  flex: none;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  font-size: var(--ex-text-xs);
  font-weight: 600;
  line-height: 1.35;
  white-space: nowrap;
  background: var(--ex-color-control);
  color: var(--ex-color-text);
  vertical-align: middle;
}

.ex-badge--ok { background: var(--ex-color-success-bg); color: var(--ex-color-success); }
.ex-badge--info { background: var(--ex-color-info-bg); color: var(--ex-color-info); }
/* Three states a person's account can be in, and they should not look alike.
 *
 *   --ok    active, and nothing to do
 *   --warn  something is holding it up and somebody can clear it — a dormancy lock
 *   --off   switched off on purpose, by a person, and it stays off until a person says otherwise
 *
 * --off was amber, which read as an alarm. A disabled account is not an alarm; it is a decision that
 * was taken and is working as intended. Amber now belongs to the state that actually wants attention. */
.ex-badge--warn { background: var(--ex-color-warning-bg); color: var(--ex-color-warning); }
.ex-badge--off { background: var(--ex-color-control); color: var(--ex-color-text-muted); }

/* Waiting on somebody. Its own colour, not --off and not --warn: a request sitting in a chain is
 * neither switched off nor a problem, it is simply not yet decided, and a pending badge that looked
 * like an alarm would have every queue in the product reading as a fault. --ex-color-pending is the
 * same slate the financial tokens use for a figure that is awaited rather than wrong. */
/* WAITING ON A PERSON IS NOT A NEUTRAL STATE, and this was grey on grey, which is what the product
   uses for a thing that is over: cancelled, closed, withdrawn. A trade awaiting authorisation is the
   opposite — it is the only status on the blotter that requires somebody to do something, and it read
   as the quietest thing on the row.
   *
   * Amber, the same pair the warning badge uses. It deliberately shares that palette rather than
   inventing a seventh colour: both mean "a person has to look at this", and a status vocabulary that
   grows a shade per state stops being read at a glance, which is the only thing a badge is for. */
.ex-badge--pending { background: var(--ex-color-warning-bg); color: var(--ex-color-warning); }

/* A refusal. Moved here from the audit section, where it had been declared on its own five hundred
   lines below the badges it belongs with — the sort of thing that gets redefined by the next person
   who searches for it and finds nothing nearby. */
.ex-badge--danger { background: var(--ex-color-danger-bg); color: var(--ex-color-danger); }

/* A state as a pill: an icon that codes the state beside a normal-weight label, rather than the small
   .ex-badge. For a state worth reading from across a page head, where a badge is too quiet.
 *
 * Only --pending survives, and only the styleguide shows it. This was the payroll run's status pill and it
 * carried --approved and --posted alongside, for states this product has no concept of; those are gone.
 * When Phase 2 gives a transaction its own states, they are added here, named for what they are. */
.ex-runstatus {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.3rem 0.7rem;
  border-radius: 999px;
  font-size: var(--ex-font-body);
  font-weight: 500;
  background: var(--ex-color-bg);
  color: var(--ex-color-text-muted);
}
.ex-runstatus .ex-icon { width: 1rem; height: 1rem; }
.ex-runstatus--pending { background: var(--ex-color-warning-bg); color: var(--ex-color-warning); }

.ex-fieldset {
  border: none;
  padding: 0;
  margin: 0;
}

/* Checkboxes & radios — one control drawn ourselves. Works with plain markup:
   <label class="ex-check"><input type="checkbox"><span>Label</span></label>. The native input is
   restyled with appearance:none and the tick/dot is its ::before, so no extra wrapper is needed. */
.ex-checks {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
  gap: var(--ex-space-2) var(--ex-space-4);
}

/* The words of a choice are its label: they name it, and clicking them toggles it. So they follow their
   control exactly as a field's label follows its input, quiet at rest and full strength when the reader is
   on it. They were fixed at full strength before, which is the one state that says nothing: a column of
   choices read as a wall of equally loud text, and pointing at one gave no answer back.
   Ticked counts as well as hovered: a chosen option is not resting. */
.ex-check {
  display: inline-flex;
  align-items: center;
  gap: var(--ex-space-2);
  cursor: pointer;
  font-size: var(--ex-text-md);
  /* Stated rather than inherited. The body line-height gives the label a line box much taller than the
     1.1rem control, and centring a tall box against a short one leaves the words reading low against
     the tick — the text is optically centred inside its own line box, which sits below the box's
     middle. 1.35 brings the line box close to the control's height, so "centred" looks centred. */
  line-height: 1.35;
  color: var(--ex-color-text-muted);
  user-select: none;
  transition: color var(--ex-transition);
}

.ex-check:hover,
.ex-check:focus-within,
.ex-check:has(input:checked) { color: var(--ex-color-text); }

/* A CHOICE THAT CARRIES AN EXPLANATION.
 *
 * .ex-check above is one line: a box and its words, centred against each other. Put a description in
 * as a third child and all three land on the same row — so a role's name and its description sit side
 * by side, wrap independently, and collide. On the user screen "Investment Officer" broke over two
 * lines while "Captures trades and reads the book" broke over two more, beside it.
 *
 * A grid instead: the box in the first column spanning both rows, the name above its description in
 * the second. No extra markup, because the fix has to be reachable from the templates that already
 * have this shape.
 *
 * TWO CLASSES IN THE SELECTOR, DELIBERATELY. .ex-check--strong sets display: flex and is declared
 * further down this file, so at equal specificity it wins on order and a described choice that is also
 * strong silently reverts to one row — which is precisely how it looked on the user screen. Matching
 * on .ex-check as well outranks it whatever the order, so this cannot be undone by somebody moving a
 * block. The same trap took .ex-steps--wizard earlier and is worth only falling into once.
 */
.ex-check.ex-check--described {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: 0 var(--ex-space-2);
}

/* Centred on the FIRST LINE of the label, not on the block.
 *
 * align-items: start puts the control on the block's top edge, which is above the middle of the first
 * line — so the name reads low against its tick. The nudge is half the difference between the line box
 * (0.875rem x 1.35 = 1.18rem) and the control (1.1rem). Small, and it is the whole difference between
 * "aligned" and "very slightly wrong in a way nobody can name". */
.ex-check--described > input { margin-top: 0.04rem; }

/* Second column, second row — without this the hint would fall under the checkbox.
 *
 * Weight reset explicitly: .ex-check--strong bolds the whole label, and a description inherits it. A
 * bold hint under a bold name is two things shouting and no hierarchy between them — the point of the
 * hint is that it is the quieter half.
 *
 * Tighter leading than body text and a hair of space above it: a name and its explanation are one
 * thought, and they should sit closer to each other than to the next choice down. Loose leading here
 * made the pair read as two unrelated lines that happened to be adjacent. */
.ex-check--described > .ex-field__hint {
  grid-column: 2;
  margin-top: 0.15rem;
  font-weight: 400;
  line-height: 1.4;
}


/* Wider columns, for a list whose options carry descriptions.
 *
 * The default .ex-checks fills as many 14rem columns as fit, which is right for a wall of short labels
 * (a permission grid) and too narrow the moment each choice has a sentence under it — the description
 * wraps three times and the block stops reading as a pair.
 *
 * 17rem rather than a fixed column count, so the row count follows the space instead of being decided
 * here: three across a full-width card, two on a narrower one or a split screen, one on a phone. A
 * fixed "three" would be three squeezed columns on a laptop at 1280 and a wasted half-screen on a
 * wide monitor.
 *
 * Row gap is the larger one — each choice is two lines now, so the air between choices has to beat the
 * air between a name and its own description, or the list reads as one continuous block. */
.ex-checks--described {
  grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
  gap: var(--ex-space-4) var(--ex-space-5);
}

/* Fewer, wider columns — two across a full-width card instead of three.
 *
 * For a list whose descriptions are sentences rather than names. "Manage banks, brokers, issuers and
 * custodians" over a 17rem column breaks three times and the choice stops reading as one block; the
 * permission groups on the role screen are all like that. Portfolios are not, which is why this is a
 * second modifier rather than a change to the one above. */
.ex-checks--wide { grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr)); }

/* The multi-select's option rows draw from this same block rather than inventing a tick of their own —
   one control, one place it is drawn, so a change to the radius or the fill reaches both. */
.ex-check input[type="checkbox"],
.ex-check input[type="radio"],
.ex-select--multi .ex-select__option input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  flex: none;
  width: 1.1rem;
  height: 1.1rem;
  margin: 0;
  display: inline-grid;
  place-content: center;
  border: 1.5px solid var(--ex-color-control-border);
  border-radius: 4px;
  /* Same fill as the text controls: an unticked box is a control at rest, and a white one sitting in
     a column of filled fields reads as already-actioned. */
  background: var(--ex-color-control);
  cursor: pointer;
  transition: background var(--ex-transition), border-color var(--ex-transition);
}

.ex-check input[type="radio"] { border-radius: 50%; }

/* Hover the whole choice, not just the box. Clicking the words toggles it, so the words are part of the
   control, and lighting up only for the 1.1rem square meant pointing at a checkbox the way you point at a
   text input (anywhere on it) gave no answer at all. A text field lights wherever you point at it; so does
   this now. Checked boxes stay accent-filled, and a disabled one never lights. */
.ex-check:hover input:not(:checked):not(:disabled) {
  background: var(--ex-color-control-hover);
  border-color: var(--ex-color-control-border-hover);
}

.ex-check input:checked,
.ex-select--multi .ex-select__option input:checked {
  background: var(--ex-color-accent);
  border-color: var(--ex-color-accent);
}

.ex-check input[type="checkbox"]::before,
.ex-select--multi .ex-select__option input[type="checkbox"]::before {
  content: "";
  width: 0.32rem;
  height: 0.6rem;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) translateY(-8%);
  opacity: 0;
}

.ex-check input[type="radio"]::before {
  content: "";
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: #fff;
  opacity: 0;
}

.ex-check input:checked::before,
.ex-select--multi .ex-select__option input:checked::before { opacity: 1; }

/* Keyboard focus is an outline sitting just clear of the box — the same treatment the fields take, and
   the same "no blur" reading. */
.ex-check input:focus-visible {
  outline: 2px solid var(--ex-color-text);
  outline-offset: 2px;
}

.ex-check input:disabled { opacity: 0.5; cursor: not-allowed; }
.ex-check:has(input:disabled) { cursor: not-allowed; color: var(--ex-color-text-faint); }

/* The "all grades" master, on its own line above the finer grade choices. */
.ex-check--strong {
  display: flex;
  font-weight: 600;
  margin-bottom: var(--ex-space-3);
}

/* A muted "select all shown" convenience row, spanning the full width above the grade checkboxes. */
.ex-check--muted {
  grid-column: 1 / -1;
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-sm);
}

/* A grade list dimmed while "all grades" is on — its individual choices no longer apply. */
.ex-checks[data-ex-disabled] { opacity: 0.5; }

/* Password field with a reveal toggle (initPasswordToggles wraps the input). */
.ex-field__control { position: relative; display: flex; }
.ex-field__control .ex-field__input { flex: 1; padding-right: 2.5rem; }

.ex-field__reveal {
  position: absolute;
  right: 1px;
  top: 1px;
  bottom: 1px;
  width: 2.4rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  background: none;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text-faint);
  cursor: pointer;
}

.ex-field__reveal:hover { color: var(--ex-color-text); }
.ex-field__reveal:focus-visible { outline: 2px solid var(--ex-color-accent); outline-offset: -2px; }
.ex-field__reveal svg { width: 1.1rem; height: 1.1rem; }

/* Topbar user menu ------------------------------------------------------ */
.ex-user { position: relative; }

.ex-user__trigger {
  display: inline-flex;
  align-items: center;
  gap: var(--ex-space-2);
  flex: none;
  padding: 0.3rem 0.55rem 0.3rem 0.3rem;
  border: none;
  background: none;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text);
  font: inherit;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
}

.ex-user__trigger:hover,
.ex-user.is-open .ex-user__trigger { background: var(--ex-color-bg); }

.ex-user__avatar {
  flex: none;
  width: 1.85rem;
  height: 1.85rem;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
  font-size: var(--ex-text-xs);
  font-weight: 700;
}

.ex-user__name { max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* A dropdown caret: the chevron icon points right, so rotate it to point down when closed and up when
   open, the way a menu caret should read (not sideways). */
.ex-user__caret { display: inline-flex; color: var(--ex-color-text-faint); transition: transform var(--ex-transition); transform: rotate(90deg); }
.ex-user__caret svg { width: 0.9rem; height: 0.9rem; }
.ex-user.is-open .ex-user__caret { transform: rotate(-90deg); }

.ex-user__menu {
  position: absolute;
  right: 0;
  top: calc(100% + 0.4rem);
  min-width: 14rem;
  width: max-content;
  max-width: 20rem;
  padding: var(--ex-space-2);
  background: var(--ex-color-raised);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  box-shadow: var(--ex-shadow-lg);
  z-index: 30;
}

.ex-user__ident { padding: var(--ex-space-2) var(--ex-space-3) var(--ex-space-2); border-bottom: var(--ex-border); margin-bottom: var(--ex-space-1); }
.ex-user__ident-label { font-size: var(--ex-text-2xs); color: var(--ex-color-text-faint); }
.ex-user__ident-name { font-size: var(--ex-text-xs); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ex-user__ident-sub { font-size: var(--ex-text-2xs); color: var(--ex-color-text-muted); }

.ex-user__menuitem {
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
  width: 100%;
  padding: 0.4rem var(--ex-space-3);
  border: none;
  background: none;
  border-radius: var(--ex-radius);
  color: var(--ex-color-text);
  font: inherit;
  font-size: var(--ex-text-sm);
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.ex-user__menuitem:hover { background: var(--ex-color-bg); }
.ex-user__menuitem svg { width: 1rem; height: 1rem; color: var(--ex-color-text-muted); }
.ex-user__menu form { margin: 0; }


/* Appearance — brand colour picker ------------------------------------------
   The native colour input styled as a swatch, a hex field beside it, a row of suggested swatches, and
   a live preview whose components read the accent tokens so they follow the pick in real time. */

.ex-colour {
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
}

/* The native <input type=color>, stripped of its chrome and shown as a rounded swatch. */
.ex-colour__swatch {
  appearance: none;
  -webkit-appearance: none;
  width: 2.5rem;
  height: 2.5rem;
  padding: 0;
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  background: none;
  cursor: pointer;
}
.ex-colour__swatch::-webkit-color-swatch-wrapper { padding: 0; }
.ex-colour__swatch::-webkit-color-swatch { border: none; border-radius: calc(var(--ex-radius) - 2px); }
.ex-colour__swatch::-moz-color-swatch { border: none; border-radius: calc(var(--ex-radius) - 2px); }

.ex-colour__hex {
  width: 9rem;
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}

/* An inline identifier — a component key, a variable name a formula reads. Monospace so it reads as a
   token, not prose; tinted to sit apart from the surrounding text without shouting. */
.ex-code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: var(--ex-text-inline);
  padding: 0.1em 0.35em;
  border-radius: 4px;
  background: var(--ex-color-bg);
  border: 1px solid var(--ex-color-border);
  color: var(--ex-color-text);
}

/* A worked example somebody copies, rather than a term mentioned in a sentence. .ex-code alone is an
   inline span: on a <pre> it gives 0.1em of padding, no block spacing, and lines that run to the edge.
   Scrolls sideways rather than wrapping, because a wrapped CSV example stops looking like rows. */
.ex-code--block {
  display: block;
  margin: var(--ex-space-3) 0;
  padding: var(--ex-space-3) var(--ex-space-4);
  overflow-x: auto;
  line-height: 1.6;
  white-space: pre;
  /* Selectable in one gesture. The whole point of the block is that it is copied. */
  user-select: all;
}

/* A read-only value shown where an input would otherwise sit — a field that is fixed and cannot be
   edited (a component's family once chosen). Reads as settled, not as a disabled control. */
.ex-field__static {
  margin: 0;
  padding: var(--ex-space-2) 0;
  font-weight: 600;
  color: var(--ex-color-text);
}

/* A numeric table cell — right-aligned so figures line up on the decimal, the way money should read.
   Stated for a table cell too: `.ex-table th, .ex-table td` sets text-align on an element+class selector,
   which outweighs a bare class, so on its own this rule never reached the cells it was written for. */
.ex-num {
  text-align: right;
  /* A figure never wraps. "1,120.55" broken across two lines has stopped being a number, and on a
     statement this is the column somebody runs a finger down against the bank's own copy. */
  white-space: nowrap;
  /* TABULAR FIGURES, WHICH THIS CLASS WAS MISSING UNTIL 2026-08-21.

     docs/css.md has described .ex-num as "tabular figures and right alignment" since before the first
     component was written, and gave the reason: without them a column of amounts does not line up
     vertically, and a column that does not line up cannot be scanned. Only the alignment was ever
     implemented. Every money column in the product -- the income report, the valuation report, the
     cash statement, the blotter -- has been rendering in proportional digits, where a 1 is narrower
     than a 7 and the decimal points wander by a pixel or two per row.

     Nothing failed, which is why it lasted: the figures are correct, the columns are merely harder to
     read than they were designed to be. Found by asserting the computed style of a rendered cell
     rather than the presence of the class (ComplianceScreensIT), because the markup was right all
     along. */
  font-variant-numeric: tabular-nums;
}
.ex-table th.ex-num,
.ex-table td.ex-num {
  text-align: right;
}

/* A second line inside a table cell: the qualifier under the value it qualifies.

   "3 Apr 2026 / recorded 8 Jan 2027" is two facts about one movement, and they are not equals — the
   first is when the bank says the money moved and the second is when we heard about it. Beside each
   other they read as two columns crammed into one; underneath, in hint style, the second reads as what
   it is. Same treatment as .ex-field__hint under an input, for the same reason. */
.ex-cell__note {
  display: block;
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-xs);
  line-height: 1.3;
  font-style: normal;
}

/* A COLUMN THAT STANDS DOWN ON A PHONE.
 *
 * A table with more columns than a 390px screen holds scrolls sideways inside its card, which is the
 * right behaviour and is not free: the columns that go off the edge are the ones on the right, and
 * the actions column is always on the right. On the members list that put Role, Joined and both
 * buttons out of sight behind a horizontal scroll nobody thinks to try.
 *
 * THE RULE THAT COMES WITH THIS CLASS: whatever it hides must appear somewhere else on the row, as an
 * .ex-cell__note under the name. Hiding a column is only a layout decision if the value survives; if
 * it does not, it is deleting data on small screens and calling it responsive. /accounts has done it
 * this way since the bank column went off the edge — the note there carries the kind and the bank.
 *
 * The header goes with the cell, or the table has a column of headings with nothing under them. */
@media (max-width: 48rem) {
  .ex-table th.ex-cell--wide,
  .ex-table td.ex-cell--wide {
    display: none;
  }
}

/* And the note that carries a hidden column's value shows only where the column is gone, so a wide
   screen does not say the same thing twice. */
.ex-cell__note--onlynarrow {
  display: none;
}

@media (max-width: 48rem) {
  .ex-cell__note--onlynarrow {
    display: block;
  }
}

/* THE NOTE CUT TO ONE LINE, so the cell is two lines and never more.
 *
 * A note is normally a handful of words and needs nothing. The limits register is where that stops
 * being true: what a limit measures carries the reading that decides it — "Listed means registered
 * with the Securities and Exchange Commission AND admitted to trade (Securities Act, 2016 s. 2);
 * quoted means registered and not listed" — two hundred characters against a five-word label. Left to
 * wrap, one row is six lines deep and the register cannot be scanned at all.
 *
 * SO THE CELL SHOWS THE BEGINNING AND THE ROW OPENS THE REST. This is only ever right beside
 * data-ex-rowdetail: a note truncated with nowhere to read the whole of it is a fact the screen has
 * quietly withheld, which is worse than a tall row. The modal carries the field in full and unclamped.
 *
 * -webkit-line-clamp with display:-webkit-box is the prefixed form every current browser implements,
 * Firefox included; the unprefixed `line-clamp` is not yet safe to rely on alone. Clamping needs a
 * width to clamp against, so the column carrying this must be the one taking the slack — every other
 * column .ex-cell--fit — or the cell simply widens instead of cutting. */
.ex-cell__note--clamp {
  display: -webkit-box;
  -webkit-line-clamp: 1;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* THE CELL'S OWN VALUE, HELD TO TWO LINES, which is the other half of what --clamp does for the note.
 *
 * A note is a qualifier and one line of it is a fair trade. A value is the thing itself, so two: a
 * registered name — "Zambia National Building Society" — and two people's names — "Jane Chileshe-Banda,
 * authorised by Grace Tembo" — both land on two lines in a column of about twelve rem, and both went to
 * four when the column was left to take whatever three flexible columns had left over. Four-line rows
 * are what stops a listing being scannable at all, which is the whole job of a listing.
 *
 * TWO IS THE NUMBER BECAUSE OF WHAT IS IN THESE CELLS, not because two is tidy. One would cut most
 * registered names in Zambia, which run long by law rather than by choice.
 *
 * The same caveat as the note, and it matters more here: only ever beside data-ex-rowdetail. A note cut
 * short loses a qualifier; a value cut short loses the fact, and on this listing the fact is somebody's
 * name. The modal carries it whole. */
.ex-cell__value { display: block; }

.ex-cell__value--clamp {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* A TABLE ALLOWED TO BE WIDER THAN THE COLUMN THE APP IS READ IN.
 *
 * Everything here sits inside .ex-content, capped at --ex-content-max (60rem) because that is a
 * comfortable measure for reading. Most tables are content with it: a date, a code, a figure and an
 * action fit inside 60rem with room over.
 *
 * A FEW DO NOT, AND THEY ARE THE ONES WHOSE CELLS CARRY SENTENCES. The Bank of Zambia proposal screens
 * put a value and an explanation in the same cell, three or four columns across — what the regulator
 * calls it, what accepting it would do, what this book calls it instead. At 60rem every one of those
 * wraps, and the row that wraps worst is the row where the register disagrees, which is the row
 * somebody opened the page to read.
 *
 * So this asks for a little more and lets the wrapper scroll for it. It is deliberately modest: 64rem
 * against 60rem is about fifty pixels per column on the three that need it, which is the difference
 * between a name on two lines and a name on one. It must be used inside .ex-table-wrap--scroll, which
 * already has overflow:auto — a wide table with nowhere to scroll would push the whole page sideways,
 * and a page that scrolls horizontally is a bug rather than a layout. */
.ex-table--roomy { min-width: 64rem; }

/* AND ONE STEP FURTHER, FOR A TABLE THAT IS BOTH WIDE AND WORDY.
 *
 * 64rem is enough for four or five columns where two carry a sentence. It is not enough for the
 * instrument listing, which is nine — code, name, issuer, term, type, currency, jurisdiction, maturity,
 * day count — several of them nowrap because they hold a name or a date that must not break. At 64rem
 * every one of those took its minimum and the name column took what was left, which was a word.
 *
 * A second step rather than a bigger single one: widening --roomy would push the four-column proposal
 * screens sideways for nothing, and they are the ones read most often. */
.ex-table--wide { min-width: 80rem; }

/* AND THE SCROLLBAR STAYS VISIBLE WHERE ONE IS ROOMY.
 *
 * macOS hides overlay scrollbars until something is scrolled, which is fine for a list that scrolls
 * downward — a reader knows a long list continues. It is not fine sideways: a column cut off at the edge
 * of a card, with nothing to say more of it is there, reads as a broken table rather than a scrollable
 * one. Asking for a classic scrollbar is the whole fix, and it is scoped to the tables that can actually
 * overflow so that nothing else in the product grows a permanent bar it does not need.
 *
 * ON ANY TABLE WRAPPER, NOT ONLY A MARKED ONE (2026-08-28). This named .ex-table-wrap--scroll holding
 * a --roomy or --wide table, which is a guess at which tables overflow rather than a fact about them.
 * The blotter is neither: seven columns and a pair of buttons, in a plain wrapper, on the screen a desk
 * works from all day — and its Action column sat cut off at the card's edge with nothing to say the
 * rest was a scroll away.
 *
 * SAFE TO WIDEN, because a bar is not being added. Every one of these wrappers is already overflow-x:
 * auto, and a box with nothing to scroll shows no scrollbar however it is styled. What this asks for is
 * that where there IS a scroll it is a classic bar rather than an overlay one — which is the whole
 * point, since the overlay kind is invisible until somebody already knows to try. */
.ex-table-wrap,
.ex-datatable__scroll {
  scrollbar-width: thin;
  scrollbar-color: var(--ex-color-border) transparent;
}
.ex-table-wrap::-webkit-scrollbar,
.ex-datatable__scroll:has(> .ex-table--roomy)::-webkit-scrollbar,
.ex-datatable__scroll:has(> .ex-table--wide)::-webkit-scrollbar {
  width: 0.5rem;
  height: 0.5rem;
}
.ex-table-wrap::-webkit-scrollbar-thumb,
.ex-datatable__scroll:has(> .ex-table--roomy)::-webkit-scrollbar-thumb,
.ex-datatable__scroll:has(> .ex-table--wide)::-webkit-scrollbar-thumb {
  background: var(--ex-color-border);
  border-radius: 999px;
}

/* A column sized to its content, which never wraps — a date, a figure, a badge, a single action.

   `width: 1%` is the shrink-to-fit idiom: the browser gives such a column its minimum and the columns
   without it share what is left. That is the whole point. A table left to itself treats a date column
   and a narrative column as equals, so "3 Apr 2026" breaks onto two lines while the narrative sits in
   space it does not need. Put this on every column of predictable width and let the one genuinely
   variable column — the bank's own text — take the slack.

   On the th as well as the td, because a width on one row of a column decides it for all of them and
   the header is the row that is always present. */
.ex-table th.ex-cell--fit,
.ex-table td.ex-cell--fit {
  width: 1%;
  white-space: nowrap;
}

/* A COLUMN THAT MUST NOT BE SQUEEZED, WHICH IS THE OTHER HALF OF THE SAME PROBLEM.
 *
 * .ex-cell--fit says "take your minimum". On a table where several columns claim that, what is left for
 * the flexible ones can be almost nothing — and on the instrument listing it was: nine columns, seven of
 * them fit, and the instrument's NAME reduced to a word a line. The name is the column somebody reads
 * the row by.
 *
 * A floor rather than a width, because the column should still grow into space nobody else wants. 12rem
 * is about twenty-four characters, which is a company name on one line and most security names on two.
 * (2026-08-25, from a person looking at the screen.) */
.ex-table th.ex-cell--name,
.ex-table td.ex-cell--name {
  /* 10rem, not 12. The floor exists so the one elastic column is not squeezed to a word a line by
     neighbours that all take their minimum, and 12rem was chosen against a book of fourteen holdings.
     A book of forty, with figures in the tens of millions, has wider number columns — and the floor
     then stops being a floor and becomes the reason the table does not fit its card: the valuation
     report came out 22px over, entirely on this column refusing to give any of it back.
     10rem is about twenty characters, which still holds a security name on two lines, and it leaves
     the figures the room they now need. */
  min-width: 10rem;
}

/* NARROWER ON A PHONE, because the floor is a floor for a monitor.
 *
 * 10rem is 160px. A table with two of these — the members list has Name and "Signs in as" — spends
 * 320px of a 390px screen on them before Role, Joined and the buttons get a pixel, so everything
 * that is not a name sat off the right-hand edge and had to be scrolled to. Reported as the columns
 * not being set properly.
 *
 * 7rem is about fourteen characters, which still holds a first name or a short username on one line
 * and leaves room for the columns beside it. The elastic behaviour is unchanged: it is a minimum, so
 * a table with one name column and space to spare still gives it the slack. */
@media (max-width: 48rem) {
  .ex-table th.ex-cell--name,
  .ex-table td.ex-cell--name {
    min-width: 7rem;
  }
}

/* And smaller again on a phone held upright, which is 390px and the width this product is actually
   used at. 6rem is about twelve characters — a first name, or a name that wraps to two lines, which
   a cell is allowed to do.

   Cell padding was tightened here too and the rule was deleted again: a later max-width: 48rem block
   already sets it, and media queries carry equal specificity, so the later one wins whatever this
   said. It changed nothing and read as though it did. */
@media (max-width: 30rem) {
  .ex-table th.ex-cell--name,
  .ex-table td.ex-cell--name {
    min-width: 6rem;
  }
}

/* Movement, on a number. The colour is the second signal, never the first — the parentheses around a
   loss and the sign of the figure are what carry it. Applied to the number itself rather than the row,
   because one row can hold a gain and a loss at once. */
.ex-num--gain { color: var(--ex-color-gain); }
.ex-num--loss { color: var(--ex-color-loss); }

/* A mark older than the freshness threshold (domain-reference 6.3). The age is always shown in text
   beside it; this only makes it findable in a long column. */
.ex-num--stale { color: var(--ex-color-stale); }

/* A CONTROL AND THE BUTTON THAT ACTS ON IT, on one line.
 *
 * A search box and its Search, a date and its Show, an amount and its Apply. Controls sit in a row and
 * wrap on a narrow screen; the button lines up with the control, not with the top of the row.
 *
 * THIS IS WHERE THE "SAME HEIGHT" RULE IS ACTUALLY ENFORCED. --ex-control-height gives every control a
 * floor, and a floor is not a guarantee: a button can still end up taller than the input beside it, and
 * two separate components were already hand-patching this with a local height variable of their own
 * before they were deleted. Here the button takes that height exactly, and exactly is what "and never
 * bigger" means — a fixed height cannot be exceeded the way a minimum can.
 *
 * align-self is the other half, and it is the half that is visible. .ex-btn sets align-self: flex-start
 * so a lone button does not stretch down a tall container; inside a control row that is wrong, because
 * a .ex-field is taller than its own input (it carries a label above it), so the button pinned itself
 * to the top of the row and floated clear above the input it belonged to. Ending it on flex-end puts it
 * on the control's own line whether the field carries a label or not.
 *
 * Pinned by ControlPairingIT, which measures both in a real browser on the styleguide's own example.
 * A rule this easy to undo by adding one padding somewhere needs a test, not a comment. */
.ex-inline-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-3);
  align-items: flex-end;
}
.ex-inline-row .ex-field {
  margin: 0;
}
/* The control takes whatever room the buttons leave.

   Without this an input keeps the browser's own default width — about twenty characters — so a search
   box sits as a stub beside its buttons with the rest of the row empty, and a long narrative cannot be
   read back while it is being typed. `min-width: 0` is what lets it shrink below that default on a
   narrow screen instead of pushing the buttons onto their own line. */
.ex-inline-row > .ex-field__input,
.ex-inline-row > .ex-field__textarea,
.ex-inline-row > .ex-select {
  flex: 1 1 16rem;
  min-width: 0;
}

/* HOW WIDE A DROPDOWN IS, as a scale rather than a number invented per screen.
 *
 * .ex-select was missing from the rule above, so a dropdown in a control row sized to its own content
 * while an input beside it took 16rem — which is why every portfolio picker sat as a stub against its
 * button. Adding it is the fix; the two modifiers are the reason it is written as a scale, because a
 * dropdown holding a currency and one holding "GROWTH · Kariba Staff Pension Growth Fund" are not the
 * same control and the next screen will want to say which it has without picking a number.
 *
 * A flex-basis and not a width: the row still gets to shrink these on a narrow screen rather than
 * pushing the button onto its own line, which is the whole point of the row.
 *
 *   --sm   a code, a currency, a month, a year          10rem, and capped so it cannot sprawl
 *   (none) a name that fits comfortably on one line     16rem
 *   --lg   a code AND a name together, which is what a portfolio picker holds   26rem
 */
.ex-select--sm { flex-basis: 10rem; max-width: 12rem; }
.ex-select--lg { flex-basis: 26rem; }

.ex-inline-row > .ex-btn {
  align-self: flex-end;
  height: var(--ex-control-height);
}
.ex-inline-row > .ex-btn--sm { height: var(--ex-control-height-sm); }
/* A row of buttons. It carries no margin of its own, but it does need separating from whatever it
   follows — a paragraph explaining the actions, or a subheading introducing them. Without this the
   buttons sat directly under the last line of text with only the line-height between them, which read
   as part of the sentence. */
.ex-actions-wrap {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ex-space-2);
}

/* A ROW'S ACTIONS SIT SIDE BY SIDE, AND THE COLUMN ENFORCES IT RATHER THAN EACH PAGE.

   A <form> is a block element, so an Edit link followed by an Archive form stacked one above the
   other and made every row twice as tall as it needed to be. On the categories list that halved how
   many rows fit inside the table's own height cap: eight instead of fifteen.

   The fix could have been an .ex-actions-wrap inside every such cell, and was, on the two screens
   that had already been noticed. That is a thing to remember on every future list, and this project
   has enough evidence about what happens to things people have to remember -- data-ex-row-actions
   hidden reached nine templates by being copied. So the column lays its own children out.

   Not display:flex on the <td> itself: that takes the cell out of the table layout algorithm and it
   stops lining up with its header. Making the children inline is enough and changes nothing else. */
td[data-ex-row-actions] {
  white-space: nowrap;
}
td[data-ex-row-actions] > form,
td[data-ex-row-actions] > a,
td[data-ex-row-actions] > button {
  display: inline-flex;
  vertical-align: middle;
}
td[data-ex-row-actions] > * + * {
  margin-left: var(--ex-space-2);
}

/* A ROW'S BUTTONS ARE SHORT, AND THEY KEEP THEIR WORDS.
 *
 * Reported three times, and I got it wrong twice. "Small" was read as narrow: the first attempt made
 * them small at a desk, the second dropped their labels on a phone so the actions column stopped
 * being as wide as the longest word in it. Neither was what was asked for, and the second was a
 * decision nobody asked me to make:
 *
 *   "removing the words on buttons in tables is not something i agreed to, you did it on your own.
 *    people wont know what the buttons are. please put the button names."
 *
 * Which is right. An icon alone is a guess with a picture on it — "Archive" and "Remove" and
 * "Statement" are not things to be inferred from a glyph on a screen somebody uses once a month, and
 * the style guide already forbids an unlabelled icon for anything that changes data (10.5).
 *
 * WHAT WAS ACTUALLY ASKED FOR IS HEIGHT AND PADDING: "reduced height length, and a slight decrease in
 * the padding... on big screens they look as they show right now, but on small screens they look too
 * big." So the label stays, and the button gets shorter, tighter and quieter instead.
 *
 * The column is wider than an icon-only one and that is the trade accepted here deliberately: a table
 * that scrolls sideways is a nuisance, and a button nobody can identify is a mistake. */
@media (pointer: coarse) {
  /* THREE SELECTORS, BECAUSE OF ONE RULE FURTHER DOWN THE FILE.
   *
   * `.ex-actions-wrap > form > .ex-btn { min-height: 2.75rem }` carries the same specificity as
   * `.ex-table td .ex-btn` — two classes and an element each — and sits later, so it won, and the two
   * screens whose row actions are wrapped in .ex-actions-wrap kept their 44px buttons while every
   * other screen shrank. /expected and /organizations/members, found by the test rather than by
   * looking. Naming the wrapper here outranks it whatever the order. */
  .ex-table td .ex-btn,
  .ex-table td .ex-actions-wrap > .ex-btn,
  .ex-table td .ex-actions-wrap > form > .ex-btn {
    /* SMALLER THAN THE 44px EVERY OTHER CONTROL GETS, AND THAT IS THE OWNER'S CALL. A 44px control in
       every row of a twelve-row table is a column of blocks down the side of the data, and on a phone
       the data is the scarce thing.
     *
     * What makes it defensible rather than a repeal of the tap-target rule: on every list carrying
     * one of these, the row's name cell is already a link to the same destination, at full row height
     * across most of the width. The small button is the second way to somewhere that already has a
     * large first way. EveryScreenOnAPhoneIT exempts row actions by name and quotes this reasoning;
     * TableActionsOnAPhoneIT bounds their height at both ends, because an exemption with no bound is
     * a hole rather than an exemption.
     *
     * min-width as well as height: the coarse block sets .ex-btn { min-width: 2.75rem }, and a
     * minimum beats a height -- without this line the button measured 44px whatever else was said. */
    min-height: var(--ex-table-action-size);
    height: var(--ex-table-action-size);
    min-width: 0;
    padding: 0 var(--ex-space-2);
    gap: 0.3rem;
    font-size: var(--ex-text-xs);
  }

  /* The icon comes down with the button. A 20px glyph in a 34px button reads as a full-size control
     somebody has squashed. */
  .ex-table td .ex-btn .ex-icon {
    width: 0.95rem;
    height: 0.95rem;
  }
}

/* STACKED, WHERE THE COLUMN'S WIDTH COSTS MORE THAN THE ROW'S HEIGHT.
 *
 * flex-wrap above lets two buttons drop to a second line when the column is tight — but in a table it
 * never is. Auto layout sizes a column to its content and the wrapper scrolls, so the cell always gets
 * the width for both side by side and the wrap never fires. On the blotter that was 262px, the widest
 * thing on the table and wider than any figure on it, and it was what pushed the row past its card.
 *
 * Declared rather than left to the layout, because the layout had no reason to choose. Only rows
 * offering two answers at once grow a line — Authorise beside Reject, Part settled beside Did not
 * settle — and those rows are already two lines deep from the date and its settlement note. */
.ex-actions-wrap--stack {
  flex-direction: column;
  align-items: stretch;
}
/* IN A TABLE CELL IT MUST NOT WRAP, and the reason is not taste.
 *
 * An actions column is .ex-cell--fit, which is width: 1% — the shrink-to-fit idiom, so the browser gives
 * the cell its MINIMUM content width. The minimum width of a wrapping flex row is its widest single
 * item, because it may put every one on its own line. So the two answers on a blotter row stacked, in a
 * cell exactly one button wide, and no amount of width on the table would have moved them.
 *
 * With nowrap the minimum becomes the sum of the buttons, so the cell asks for the room they need and
 * the wrapper scrolls if the table then outgrows the page — which is what an actions column should do
 * anyway, since a row's controls belong on one line with the row. */
.ex-table td > .ex-actions-wrap { flex-wrap: nowrap; }
/* Flow spacing inside a card's body.

   Components carry no margins of their own, deliberately — a table should not decide what sits under
   it, and a margin baked into a component is one that has to be fought everywhere it is wrong. The
   consequence is that a card body has to supply the rhythm, and until it did, paragraphs ran into
   button rows and lists started immediately under the sentence introducing them.

   Stated as "every child after the first", not as a list of pairs. Enumerating pairs looks precise and
   is quietly incomplete: the audit list was missed because nobody had thought of a paragraph followed
   by an ordered list, and the next component would have been missed too. */
.ex-card__body > * + * { margin-top: var(--ex-space-4); }

/* A subheading is starting a new thought, so it takes more air above than an ordinary block — and
   nothing extra below, because it belongs to what follows it. */
.ex-card__body > .ex-form__subhead,
.ex-card__body > .ex-form__subnote { margin-top: var(--ex-space-6); }
.ex-card__body > .ex-form__subhead + *,
.ex-card__body > .ex-form__subnote + * { margin-top: var(--ex-space-3); }
.ex-card__body > :first-child { margin-top: 0; }

/* A TABLE TAKES MORE AIR THAN A PARAGRAPH DOES, wherever the writing above it ends.
 *
 * The rhythm above is one gap for everything, which is right for prose following prose: a sentence and
 * the sentence after it are the same kind of thing. A table is not. It arrives with its own border, its
 * own heading row and its own fill, and set a paragraph's distance below the sentence introducing it
 * the two read as one block — the explanation looks like part of the table rather than the thing that
 * introduces it. Reported on the limits register and true of every screen shaped that way.
 *
 * THREE PLACES, BECAUSE A CARD PUTS THE TABLE IN ANY OF THEM — and the first version of this rule
 * covered only one, so it changed nothing on the screen it was written for. A listing card commonly has
 * no .ex-card__body at all: the head carries the title and the note, and the table is the card's own
 * next child. That is the shape the limits register uses, and both body rules missed it entirely.
 *
 * Checked by reading the computed margin off the element rather than by comparing screenshots, which is
 * how the miss got reported as fixed: two pictures at different scroll positions look like a change. */
.ex-card > .ex-card__head + .ex-table-wrap,
.ex-card > .ex-card__head + .ex-datatable,
.ex-card__body > .ex-table-wrap:first-child,
.ex-card__body > .ex-datatable:first-child { margin-top: var(--ex-space-4); }
.ex-card__body > * + .ex-table-wrap,
.ex-card__body > * + .ex-datatable { margin-top: var(--ex-space-6); }

/* A denser table for a panel too narrow for the default rhythm: smaller padding, so the columns and any
   row actions fit without wrapping and a code chip in the first column doesn't dominate. */
.ex-table--compact th,
.ex-table--compact td { padding: 0.45rem 0.55rem; }
/* Only the padding. The size override that used to be here (0.72em) was fighting the point of an
   em: .ex-code already scales to whatever cell it sits in, so a compact table shrank it twice and
   landed at about nine pixels. */
.ex-table--compact .ex-code { padding: 0.04em 0.3em; }

/* The live code check's verdict, under the field: green when the code is free, red when it is taken.
   It sits in its own hint beside the explanatory one rather than replacing it — initCodeChecks rewrites
   the element's whole className, which would take the explanation with it. */
.ex-field__hint--ok {
  color: var(--ex-color-success);
}
.ex-field__hint--warn {
  color: var(--ex-color-danger);
}
/* Nothing to say yet, so no gap where a sentence will go. */
.ex-field__hint:empty { display: none; }

/* A code field. Codes are stored upper-case (the services normalise on save), so the field shows what
   will actually be stored as it is typed rather than letting somebody type "growth" and find GROWTH on
   every report afterwards. A class, not a style attribute on the input — a page carrying its own rules
   is a page a token change stops reaching (docs/css.md). */
.ex-field__input--code {
  text-transform: uppercase;
}

/* A BOX SIZED TO WHAT GOES IN IT, FOR A TABLE ROW RATHER THAN A FORM.
 *
 * On a form an input fills its field and that is right — the label above it names the whole width. On a
 * table row it does not: the exchange screen's code box sits in a .ex-cell--fit column beside three
 * others, and left to its own intrinsic size (twenty characters) it made the widest column on the page
 * the one holding the shortest value.
 *
 * 9ch, in the character unit rather than in rem, so it stays the right size if the type does not: a
 * counterparty code is six or seven upper-case characters and this is that plus the padding. Anything
 * longer still types and still submits — the box scrolls — because maxlength is what limits a value and
 * a width never should. */
.ex-field__input--short {
  width: 9ch;
}

/* WORDS WHERE A FIGURE WOULD BE, inside a column sized for figures.
 *
 * The valuation report's value column is nowrap, correctly: "1,120.55" broken across two lines has
 * stopped being a number, and it is the column somebody runs a finger down against the bank's own copy.
 * But a holding that could not be valued shows a reason there instead — "No market: a deposit is a
 * contract, not a security" — and on one unbreakable line that sentence made the value column the widest
 * thing on the report.
 *
 * So the reason wraps and the figures do not. The cap is what keeps it from demanding the width back by
 * another route: inside a shrink-to-fit column the text would otherwise ask for its full single-line
 * width before agreeing to wrap. (2026-08-25)
 *
 * A WIDTH RATHER THAN A max-width, AND THAT IS THE HALF THAT WAS MISSING (2026-08-27). A cap stops the
 * column growing and asks nothing of it, which is enough on the four-column table this was written for
 * and not enough on the seven-column one beside it: there, six columns are .ex-cell--fit, the value
 * column collapsed to the width of a number, and "Nobody has said how its interest is counted" came out
 * one word per line. A shrink-to-fit column takes whatever its widest cell asks for, so the reason has
 * to ask. 14rem over two lines is about seventy characters, which covers all eight reasons with room
 * over — the clamp is a guard against a ninth rather than something that fires today.
 *
 * QUIET, AND NOT BECAUSE IT MATTERS LESS. It is the subject of this report and it is deliberately not
 * dressed as a figure: a reason set at the same weight as the money beside it is read as the money
 * beside it, which on the value column is the one misreading that costs something. It takes the note
 * treatment for the same reason .ex-cell__note does — it qualifies a column rather than filling it. */
.ex-cell__reason {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  white-space: normal;
  width: 14rem;
  margin-left: auto;
  font-size: var(--ex-text-xs);
  line-height: 1.3;
  color: var(--ex-color-text-muted);
}

/* A formula shown as a read-only value in the detail modal: monospace, its own line, wrapping instead of
   being clipped — so a long formula is never cut off the way a plain table cell would cut it. */
.ex-detail__value--code {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: var(--ex-text-inline);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  padding: var(--ex-space-2) var(--ex-space-3);
  border-radius: calc(var(--ex-radius) - 2px);
  background: var(--ex-color-bg);
  border: 1px solid var(--ex-color-border);
}

/* A QR code to scan, on the second-factor setup screen.

   Centred and given a white plate of its own rather than left to sit on the card. The plate is not
   decoration: a QR code needs light quiet space around it to be read at all, and on a dark theme the
   card behind it is not light. The SVG carries its own white ground for the same reason; this adds the
   padding around it and keeps the two the same colour so the join does not show.

   Sized in rem here rather than by attributes on the SVG, so one number decides how big it is and the
   vector stays sharp at whatever that number becomes. 12rem is about 190px, which every phone camera
   reads comfortably from a normal sitting distance. */
.ex-qr {
  display: flex;
  justify-content: center;
  margin: var(--ex-space-5) 0;
}

.ex-qr__code {
  width: 12rem;
  padding: var(--ex-space-3);
  background: #ffffff;
  border: 1px solid var(--ex-color-border);
  border-radius: var(--ex-radius);
}

.ex-qr__code svg {
  display: block;
  width: 100%;
  height: auto;
}

/* The countdown in the session dialog.

   Tabular figures, so the minutes and seconds do not shuffle sideways as the digits change — a number
   that jitters once a second reads as broken, and this one is on screen for five minutes.

   Text-coloured rather than red. It is a fact about the clock, not an error, and the dialog around it
   already carries the weight; a red number here would be the third thing shouting.

   Qualified with the element, and it has to be: `.ex-modal__body p` above sets a font size and a muted
   colour, and an element+class selector outweighs a bare class — so on its own this rule rendered the
   countdown at body size in grey, which looked deliberate and was not. The same trap `.ex-num` fell
   into against `.ex-table td`, written down there for the same reason. */
p.ex-session__countdown {
  margin-top: var(--ex-space-5);
  text-align: center;
  font-size: var(--ex-text-3xl);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--ex-color-text);
}

/* The same value carrying a control beside it — a Copy button next to a one-time password or an
   authenticator key.

   Flex, and the reason is the `pre-wrap` above. That is there for a long formula, which must keep its
   own line breaks; but it also preserves the newline and indentation the *template* uses between the
   value and the button, so the string rendered pushed in by however deep the markup happened to nest
   and the button dropped onto its own indented line. It looked like a centring bug and was whitespace.

   Flex containers drop whitespace-only text nodes between children, so the layout stops depending on
   how the HTML is formatted — which is the right fix, rather than putting both elements on one long
   line in every template that needs this and hoping nobody reformats it. */
.ex-detail__value--code:has(.ex-btn) {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ex-space-4);
}

/* Give a modal's own paragraphs breathing room from what follows (a table, a heading). */
.ex-modal__body > p {
  margin: 0 0 var(--ex-space-4);
}

.ex-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-2);
}

/* A suggested colour. Hover and the chosen state read via a ring, never a shift (design tokens). */
.ex-swatches__item {
  width: 1.75rem;
  height: 1.75rem;
  padding: 0;
  border: 1px solid rgba(16, 24, 40, 0.12);
  border-radius: 50%;
  cursor: pointer;
  box-shadow: 0 0 0 0 transparent;
  transition: box-shadow var(--ex-transition);
}
.ex-swatches__item:hover { box-shadow: 0 0 0 3px var(--ex-color-border); }
.ex-swatches__item--on { box-shadow: 0 0 0 3px var(--ex-color-text-faint); }

.ex-preview {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ex-space-4);
  padding: var(--ex-space-4);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  background: var(--ex-color-bg);
}

/* No .ex-preview__link. It existed, and it was a copy of the link style as it was before the link was
   redesigned — so the brand-colour preview showed a lookalike that had quietly stopped matching the
   real thing. A preview built from a copy is worse than no preview: it is confidently wrong.

   The preview uses .ex-link itself. That is the whole point of it. */

.ex-preview__nav {
  padding: var(--ex-space-2) var(--ex-space-3);
  border-radius: var(--ex-radius);
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
  font-weight: 500;
  font-size: var(--ex-text-md);
}

.ex-preview__chip {
  padding: var(--ex-space-1) var(--ex-space-3);
  border-radius: 999px;
  background: var(--ex-color-accent);
  color: var(--ex-color-accent-contrast);
  font-size: var(--ex-text-xs);
  font-weight: 600;
}

/* Audit — change-history section (fragments/audit.html) -----------------------
   A record's changes as a timeline: each entry has an action badge, actor and time, then a field-by-
   field old → new diff. Reuses the profile card and the badge idiom. */

.ex-audit__empty,
.ex-audit__nochange {
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-md);
}

/* WHY IT HAPPENED, on its own line above the diff.
 *
 * Given the same label treatment as a field name, because that is what it is: the entry's answer to a
 * question, sitting alongside its answers to "what changed". Full strength rather than muted — on a
 * limit override or a support reset the reason is the most important thing on the entry, and it should
 * not be quieter than the field names underneath it. */
.ex-audit__reason {
  display: flex;
  gap: var(--ex-space-3);
  margin-bottom: var(--ex-space-2);
  font-size: var(--ex-text-md);
  line-height: 1.45;
}

/* The audit trail reads as a record, not as a changelog.

   A flat list of rows separated by hairlines is what every application shows for "recent activity",
   and it invites the same glance: skim it, believe it, move on. This is not that. It is the answer to
   "who authorised this?" in front of a trustee board, and it is the thing an auditor is handed. It
   has to look like it will hold.

   Three things do that work, and none of them is decoration:

   1. **A rail.** Events sit on a continuous line running down the card, so the trail reads as
      unbroken and in order. A gap in a rail is visible in a way that a gap between rows is not.
   2. **The sequence number.** Audit rows are numbered per company and gapless (uq_audit_sequence), so
      the number is not ornament: a missing one is itself the finding. It is set in mono, at the head
      of every entry, where it can be quoted.
   3. **The chain.** Each entry shows its own hash and the one it follows. That is what turns
      "append-only, we promise" into something a reader can check — and it is the difference between
      this and a table anybody with database access could edit unnoticed (decisions.md, roadmap 1.6b).

   Nothing in here is interactive. An audit trail with an Edit button is a contradiction. */
.ex-audit__list {
  list-style: none;
  margin: 0;
  padding: 0;
  position: relative;
}

/* The rail. Drawn behind the markers and stopped short at each end so it does not run off into the
   card's padding — a line that reaches the edge reads as a border, not as a spine. */
.ex-audit__list::before {
  content: "";
  position: absolute;
  left: 0.9375rem;
  top: 0.75rem;
  bottom: 0.75rem;
  width: 2px;
  background: var(--ex-color-border);
}

.ex-audit__entry {
  position: relative;
  padding: 0 0 var(--ex-space-5) 3rem;
}
.ex-audit__entry:last-child { padding-bottom: 0; }

/* The marker sits on the rail with the card's own background behind it, so the line appears to pass
   underneath rather than stopping at each dot. Tinted by the action through a modifier on the entry. */
.ex-audit__marker {
  position: absolute;
  left: 0;
  top: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  border-radius: 50%;
  background: var(--ex-color-control);
  color: var(--ex-color-text-muted);
  box-shadow: 0 0 0 4px var(--ex-color-surface);
}
.ex-audit__marker .ex-icon { width: 1rem; height: 1rem; }

/* The four tones AuditChange.toneModifier() can actually return. It used to carry --authorised and
   --deleted too, which it never emits: APPROVAL resolves to "created" and DELETE to "reversed". The
   styleguide was hand-writing --authorised as an example, so the gallery demonstrated a marker the
   application cannot produce — the one thing a gallery must never do. */
.ex-audit__entry--created .ex-audit__marker {
  background: var(--ex-color-success-bg);
  color: var(--ex-color-success);
}
.ex-audit__entry--reversed .ex-audit__marker {
  background: var(--ex-color-danger-bg);
  color: var(--ex-color-danger);
}
.ex-audit__entry--edited .ex-audit__marker {
  background: var(--ex-color-info-bg);
  color: var(--ex-color-info);
}
/* Somebody looked at something. Deliberately the quietest marker on the page: a read is worth
   recording where the law asks for it, and it must not crowd out the entries where money moved. */
.ex-audit__entry--read .ex-audit__marker {
  background: var(--ex-color-control);
  color: var(--ex-color-text-faint);
}

.ex-audit__head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--ex-space-2) var(--ex-space-3);
  min-height: 2rem;
  margin-bottom: var(--ex-space-3);
}

/* Gapless and quotable. Mono because it is an identifier, not a quantity. */
.ex-audit__seq {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-faint);
  letter-spacing: 0.02em;
}

.ex-audit__actor { font-weight: 600; }

/* WHAT the entry was about, on the company-wide trail only.
 *
 * A record's own change history does not carry it — saying "User 2" under a heading that already names
 * Jane is noise. On the whole-company trail it is the first thing anybody looks for, so it sits before
 * the actor and is set in the same quiet monospace as an identifier elsewhere. */
.ex-audit__subject {
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
}

.ex-audit__when {
  margin-left: auto;
  color: var(--ex-color-text-faint);
  font-size: var(--ex-text-xs);
  white-space: nowrap;
}

/* The changed fields sit in their own inset panel. It separates *what moved* from who moved it, and
   it gives the strike-through old values a ground of their own so a long history does not read as one
   continuous wall of text. */
.ex-audit__body {
  padding: var(--ex-space-3) var(--ex-space-4);
  background: var(--ex-color-bg);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
}

/* The chain line. Deliberately quiet — it is evidence, not content, and it is read only when somebody
   is checking rather than browsing. */
.ex-audit__chain {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--ex-space-1) var(--ex-space-2);
  margin-top: var(--ex-space-2);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--ex-text-2xs);
  color: var(--ex-color-text-faint);
}
.ex-audit__chain .ex-icon { width: 0.85rem; height: 0.85rem; }
.ex-audit__hash { color: var(--ex-color-text-muted); }

/* label → value grid, the same two-column reading as ex-detail. */
.ex-audit__changes {
  margin: 0;
  display: grid;
  grid-template-columns: minmax(8rem, 12rem) 1fr;
  gap: var(--ex-space-1) var(--ex-space-4);
}

.ex-audit__change { display: contents; }

.ex-audit__field {
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-sm);
}

.ex-audit__move {
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--ex-space-2);
  font-size: var(--ex-text-md);
}

.ex-audit__from {
  color: var(--ex-color-text-muted);
  text-decoration: line-through;
}
.ex-audit__arrow { color: var(--ex-color-text-faint); }
.ex-audit__to { font-weight: 500; }

/* Empty state — teaching panel shown instead of a bare table (fragments/page :: emptyState) --------- */

.ex-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--ex-space-3);
  padding: var(--ex-space-8) var(--ex-space-4);
}

/* The icon sits in a soft circular badge, in the accent colour — an empty state should feel like an
   invitation, not a dead end. content-box so the padding grows the circle around a fixed-size glyph.

   Direct child only. As a descendant selector this also caught the icon inside the call-to-action
   button underneath, wrapping it in a 3rem circle and blowing the button up to twice its height. It
   went unnoticed while that button had no icon; the moment every button gained one, the component
   broke. A badge belongs to this element's own icon, not to every icon that happens to be inside it. */
.ex-empty > .ex-icon {
  box-sizing: content-box;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0.9rem;
  margin-bottom: var(--ex-space-2);
  border-radius: 50%;
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
}

/* Centred and set apart from the lead above it, so the call to action reads as the next step. */
.ex-empty .ex-btn {
  align-self: center;
  margin-top: var(--ex-space-3);
}

.ex-empty__title {
  font-size: var(--ex-text-lg);
  font-weight: 600;
}

.ex-empty__lead {
  max-width: 34rem;
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-md);
}

/* An explanatory block that reads the same wherever it is dropped: in a card on an empty screen, or in
   a dialog once that screen has rows on it.
   IT OWNS ITS OWN SPACING, and that is the point. The first version used .ex-card__text and looked
   right in the card and cramped in the modal — .ex-modal__body p zeroes the paragraph margin and only
   .ex-modal__body > p restores it, so paragraphs one div deep inside a fragment lost it entirely. A
   block meant to be reused cannot depend on how deep its container nests it. */
.ex-guide > p {
  margin: 0 0 var(--ex-space-4);
  color: var(--ex-color-text-muted);
  /* Reading text, not a hint. .ex-card__text is --ex-text-xs, which is right for a line under a title
     and too small for four paragraphs somebody is being asked to actually read. */
  font-size: var(--ex-text-md);
  line-height: 1.6;
}

.ex-guide > p:last-child { margin-bottom: 0; }

.ex-guide strong { color: var(--ex-color-text); }

/* Separated from the prose above it, because it changes subject: the paragraphs explain, this offers
   an action. */
.ex-guide__subhead {
  margin: var(--ex-space-6) 0 var(--ex-space-2);
  padding-top: var(--ex-space-5);
  border-top: 1px solid var(--ex-color-border);
  font-size: var(--ex-text-md);
  font-weight: 600;
  color: var(--ex-color-text);
}

.ex-guide__hint {
  margin: 0 0 var(--ex-space-2);
  color: var(--ex-color-text-muted);
  font-size: var(--ex-text-sm);
  line-height: 1.5;
}

/* A row of them, wrapping. The chip existed with no container and no display rule, so a run of them
   sat as inline text with the padding overlapping the line above — visible the first time more than
   one was rendered, which was the empty state on the counterparty screen. */
.ex-chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-2);
  margin-top: var(--ex-space-3);
}

.ex-chip {
  display: inline-flex;
  align-items: center;
  /* Matches the small button, so a chip and a button on the same row sit on one baseline rather than
     a pixel apart — the mismatch this stylesheet has already been through once. */
  min-height: var(--ex-control-height-sm);
  padding: var(--ex-space-1) var(--ex-space-3);
  border: var(--ex-border);
  border-radius: 999px;
  background: var(--ex-color-surface);
  color: var(--ex-color-text);
  font-size: var(--ex-text-sm);
  text-decoration: none;
  transition: border-color var(--ex-transition), background var(--ex-transition);
}
.ex-chip:hover {
  border-color: var(--ex-color-accent);
  background: var(--ex-color-accent-soft);
}

/* The one currently chosen, where a row of chips is a set of choices rather than a set of links.
 *
 * The accent border and its soft fill are the same pair :hover uses, so the chosen chip is what an
 * unchosen one looks like under the pointer — the connection a reader makes without being told. Weight
 * rather than a second colour carries the difference, because the row is read at a glance and two
 * accented backgrounds in it would be two things claiming to be current.
 *
 * aria-current="page" goes on the same element in the template. The colour is not the announcement. */
.ex-chip--on {
  border-color: var(--ex-color-accent);
  background: var(--ex-color-accent-soft);
  font-weight: 600;
}


/* ---- The profile head: who this is, above what they may do ----------------------------------
 *
 * The account screen was six stacked cards of equal weight, so the first thing a person met was a
 * heading called "My details" rather than their own name. This puts the identity above the detail: who
 * you are, how you sign in, and the two controls that belong to a person rather than to an
 * administrator.
 *
 * IT SITS ON THE CARD RATHER THAN IN IT. The head is flush with the card's own edge and the sections
 * below keep their padding, so the name reads as the card's subject and not as its first field. */
/* ---- A list of things somebody may do -------------------------------------------------------
 *
 * The account screen answered "what may I do?" with thirty permission labels joined by middots — a
 * paragraph eleven lines deep, in which the one somebody came to check was a phrase somewhere inside.
 * Nobody reads a paragraph to find an item; they scan a column, and a paragraph cannot be scanned.
 *
 * THE COLUMN COUNT FOLLOWS THE ROOM RATHER THAN BEING DECLARED. auto-fit against a 16rem track gives
 * three on a laptop, two on a narrow window and one on a phone — and the labels here are sentences
 * ("Confirm a posting batch for the client's ledger"), so a fixed three would wrap every one of them at
 * 380px rather than reflowing.
 *
 * A TICK RATHER THAN A BULLET, because the question is always "can I?" and a mark that means yes
 * answers it more plainly than a dot. It is the affirmative colour and not the accent: this is a list
 * of things that are true of somebody, not a list of links. */
.ex-tick-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: var(--ex-space-2) var(--ex-space-5);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ex-tick-list li {
  display: flex;
  align-items: flex-start;
  gap: var(--ex-space-2);
  font-size: var(--ex-text-md);
  line-height: 1.4;
}

.ex-tick-list .ex-icon {
  flex: none;
  width: 0.95rem;
  height: 0.95rem;
  /* Nudged to the cap height of the first line, so a two-line item has its tick against the words
     rather than floating above them. */
  margin-top: 0.2rem;
  color: var(--ex-color-success);
}


/* ---- The figures a dialog is about, above the fields it also carries ------------------------
 *
 * A row detail is a list of a dozen fields, and two or three of them are what somebody opened it for:
 * what the holding is worth, what a trade moves, what a line earned. In a flat detail list those sit at
 * the same weight as the reference and the day count, so the reader finds them by reading everything.
 *
 * THE TILE DROPS ITS BOX. .ex-stat carries a border, a fill and a shadow because it sits on a page; a
 * dialog is already a raised surface, and a bordered card inside a raised panel is a box in a box. What
 * is left is the thing that mattered — a label above a figure at a size the eye lands on first.
 *
 * NOT .ex-stat WITH A MODIFIER, because it is not one: no icon chip, no centring, no delta. The stat
 * tile is a flex row built around its chip and StatTileTest holds it to that. This is a label and a
 * number, which is what a dialog has room for. */
/* A ROW DETAIL'S CELL SUPPLIES ONE CHILD, AND THAT IS THE CONTRACT.
 *
 * main.js builds the modal body as <div class="ex-detail"> and clones the children of the row's hidden
 * cell into it, so every child becomes a grid ITEM. One child — the <dl class="ex-detail"> every one of
 * these cells already held — fills the row and lays out its own fields; two children become two columns
 * of a 13rem auto-fit grid, and adding this block put three figures in a stack a third of the dialog
 * wide with the fields flowing round them. Spanning it with grid-column: 1 / -1 collapsed the auto-fit
 * grid to a single column instead, which was worse.
 *
 * So the template wraps the figures and the list together in one plain div, and this block is laid out
 * inside that rather than against the modal's own grid. */
.ex-figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
  gap: var(--ex-space-4);
  margin-bottom: var(--ex-space-2);
  padding-bottom: var(--ex-space-4);
  border-bottom: var(--ex-border);
}

.ex-figure__label {
  display: block;
  font-size: var(--ex-text-2xs);
  color: var(--ex-color-text-muted);
}

.ex-figure__value {
  display: block;
  margin-top: 0.1rem;
  font-size: var(--ex-text-xl);
  font-weight: 600;
  line-height: 1.2;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* Where the figure is a sentence rather than a number — "Nobody has said how its interest is counted".
   It stops being a figure, so it stops being set like one. */
.ex-figure__value--said {
  font-size: var(--ex-text-md);
  font-weight: 400;
  white-space: normal;
  color: var(--ex-color-text-muted);
}

.ex-profile {
  display: flex;
  align-items: flex-start;
  gap: var(--ex-space-4);
  flex-wrap: wrap;
  padding: var(--ex-space-5);
  border-bottom: var(--ex-border);
}

/* The initials, at the size the top bar's are but larger. Not a photograph: this product holds no
   images of people, and a placeholder shaped like one is a promise of a feature that does not exist. */
.ex-profile__avatar {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 3.25rem;
  height: 3.25rem;
  border-radius: 50%;
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
  font-size: var(--ex-text-lg);
  font-weight: 600;
  letter-spacing: 0.02em;
}

.ex-profile__who { flex: 1; min-width: 12rem; }

.ex-profile__name {
  font-size: var(--ex-text-xl);
  font-weight: 600;
  line-height: 1.2;
}

.ex-profile__meta {
  margin-top: 0.15rem;
  font-size: var(--ex-text-md);
  color: var(--ex-color-text-muted);
  overflow-wrap: anywhere;
}

/* The controls that are the person's own. Everything else on this screen is an administrator's to
   change, which is the whole reason the page says so twice. */
.ex-profile__acts {
  flex: none;
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-2);
}

@media (max-width: 48rem) {
  /* The actions drop to their own full-width row rather than squeezing beside a wrapped name. */
  .ex-profile__acts { width: 100%; }
}


/* ---- The queue: what is waiting for one person ----------------------------------------------
 *
 * A LIST AND NOT A TABLE, which is the one decision this component is. Everything else waiting to be
 * read in this product is a table, correctly: a transaction listing and an audit trail are scanned down
 * a column, and columns are what make that possible. This is not scanned — it is worked through. Each
 * row is a decision somebody takes and then moves on from, so what a row needs is what a decision needs
 * beside it, which is a figure, an age and a control. In a table the control would be a column of
 * buttons and the age would be a date somebody has to subtract from today.
 *
 * IT IS NOT .ex-notify EITHER, which is the bell's panel in the top bar: a small overlay of things that
 * happened. This is a page of things that have not happened yet. The two look alike for one screen's
 * width and diverge on everything a row carries. */
.ex-queue {
  /* IT IS MOUNTED ON A <ul>, AND A ul ARRIVES WITH 40px OF ITS OWN. The notifications page is the
     only place this component is used and it is a list there, so every row carried the browser's
     default padding-inline-start before any rule of ours said anything -- measured at 40 of the
     112px between the screen's left edge and a notification's first word. Two phone-side fixes were
     written and argued over while the biggest single number in the stack was the user agent's.
     Zeroed here, where the component is defined, because a component that only works on a <div> is
     a component with an undocumented requirement. */
  margin: 0;
  padding: 0;
  list-style: none;
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  background: var(--ex-color-surface);
  overflow: hidden;
}

/* THE DAY, AS A HEADING INSIDE THE LIST. A queue is read as "what came in this morning, and what has
   been here since Tuesday", and the second is the finding. Sticky, because the list scrolls past its
   own headings and a run of rows with nothing saying when they arrived is a run of rows. */
.ex-queue__day {
  position: sticky;
  top: 0;
  z-index: 1;
  padding: var(--ex-space-2) var(--ex-space-4);
  border-bottom: var(--ex-border);
  background: var(--ex-color-bg);
  font-size: var(--ex-text-2xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ex-color-text-muted);
}

.ex-queue__item {
  display: flex;
  align-items: flex-start;
  gap: var(--ex-space-4);
  padding: var(--ex-space-4);
  border-bottom: var(--ex-border);
  transition: background-color 0.12s ease;
}
.ex-queue__item:last-child { border-bottom: none; }

/* THE ROW LIGHTS UP WHERE THERE IS SOMETHING TO DO WITH IT, and stays flat where there is not. On a
   list somebody works down rather than scans, the lit row is what marks the reader's place; a row
   nobody may act on lighting up would promise a click that is not there.

   HOVER AND FOCUS TOGETHER. The ring stays on the button, which is the thing actually focused; the row
   lights so it is clear which one Enter will open.

   NOT A WHOLE-ROW LINK, and that was tried. A stretched ::after over the row hit-tests as the anchor
   -- elementFromPoint says so -- and a real click on it did not follow the link, while the same click
   on the button's own box did. A target that works when a test inspects it and not when a person
   presses it is worse than no target, so the row stays a row and the button stays the way in. The
   whole-row behaviour belongs with the record drawer, where the row opens something rather than
   navigating away. */
.ex-queue__item:has(.ex-queue__act):hover,
.ex-queue__item:has(.ex-queue__act:focus-visible) { background: var(--ex-color-bg); }

/* A BAR AND NOT A WASH. Tinting a whole row makes a screen of waiting work one solid block of accent,
   which says everything is urgent and therefore says nothing. The bar marks each row on its own, and it
   is the same mark a required field and a status band already use in this product. */
.ex-queue__item--mine { border-left: 3px solid var(--ex-color-accent); }

/* What somebody keyed themselves. Faded rather than hidden: it is the row they will go looking for, and
   a queue that dropped it would have them hunting through a blotter for a trade the screen decided not
   to mention. Not a warning colour — nothing has gone wrong. */
.ex-queue__item--blocked { opacity: 0.72; }

.ex-queue__icon {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.25rem;
  height: 2.25rem;
  border-radius: 50%;
  background: var(--ex-color-control);
  color: var(--ex-color-text-muted);
}
.ex-queue__icon .ex-icon { width: 1.1rem; height: 1.1rem; }
.ex-queue__item--mine .ex-queue__icon {
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
}

.ex-queue__body { flex: 1; min-width: 0; }

.ex-queue__what {
  font-weight: 600;
  overflow-wrap: anywhere;
}

/* WHAT THE TRADE IS IN. Between the reference and the standing line, at body size rather than the
   meta line's, because it is the second thing read on the row and not a footnote to the first. */
.ex-queue__instrument {
  margin-top: 0.15rem;
  font-size: var(--ex-font-body);
  color: var(--ex-color-text);
  overflow-wrap: anywhere;
}

.ex-queue__meta {
  margin-top: 0.15rem;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
}

/* The figures a decision is taken on, beside the thing rather than in a column. Tabular, because two
   rows of them are compared down the page even though this is not a table. */
.ex-queue__figures {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-2) var(--ex-space-4);
  margin-top: var(--ex-space-2);
  font-variant-numeric: tabular-nums;
}

/* HOW LONG IT HAS BEEN WAITING, which is the figure this screen is really about. Its own column at the
   end, right-aligned and never wrapping, so the ages line up and a fortnight stands out from an hour. */
.ex-queue__age {
  flex: none;
  align-self: flex-start;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
  white-space: nowrap;
}
/* Past a threshold the age stops being a fact and becomes the finding. */
.ex-queue__age--stale { color: var(--ex-color-warning); font-weight: 600; }

.ex-queue__act { flex: none; align-self: center; }

/* Why this one is not theirs to take. Kept to a measure, because it is a sentence and the column it
   sits in is sized for a button. */
.ex-queue__blocked {
  flex: none;
  align-self: center;
  max-width: 12rem;
  font-size: var(--ex-text-xs);
  line-height: 1.35;
  color: var(--ex-color-text-muted);
}

/* MORE, RATHER THAN PAGES. A queue is read from the top and abandoned partway; nobody navigates to page
   four of their own work. So the button says how many are left, which is the number that decides
   whether to press it. */
.ex-queue__more {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--ex-space-3);
  padding: var(--ex-space-3);
  border-top: var(--ex-border);
  background: var(--ex-color-bg);
}

@media (max-width: 48rem) {
  /* The control drops under the row rather than shrinking beside it: a decision is not a thing to
     aim at, and 44px of button matters more here than on any other screen in the product. */
  .ex-queue__item { flex-wrap: wrap; }
  .ex-queue__age { order: 3; }
  .ex-queue__act,
  .ex-queue__blocked { order: 4; width: 100%; max-width: none; align-self: stretch; }

  /* A QUEUE ROW SPENT A THIRD OF THE SCREEN BEFORE ITS FIRST WORD -- and the first fix changed
     nothing, which is the part worth recording. Measured on the notifications page at 375px: page
     padding, card padding, the row's own 16px, a 36px icon in a circle and a 16px gap put the
     message about 100px in, so "A transaction of 12,976.20 ZMW was reversed" wrapped three times in
     a column 240 wide. These rules were then written in the shared phone media query near line
     4300, which sits BEFORE this component in the file -- same specificity, earlier position, so
     the base rules above won at every width and the fix shipped dead. Reported a second time from
     production as not done at all, because it was not.

     They live here now, after the rules they override, where winning is a property of the file's
     shape rather than a hope. The icon marks what kind of notice a row is; it does not need to be
     the width of two characters of the sentence it marks. */
  .ex-queue__item {
    gap: var(--ex-space-3);
    padding: var(--ex-space-3) var(--ex-space-4);
  }

  .ex-queue__icon {
    width: 1.75rem;
    height: 1.75rem;
  }
  .ex-queue__icon .ex-icon { width: 0.95rem; height: 0.95rem; }
}


/* ---- The palette: one box over the whole book -----------------------------------------------
 *
 * Ctrl-K from anywhere. A <dialog> on the top layer, so it takes the modal's own scrim and its own
 * focus trap rather than inventing either — the difference from .ex-modal is that this one is pinned
 * near the top of the screen instead of centred. That is not decoration: the list grows downward as
 * somebody types, and a centred panel that grows would move the row under the cursor while they are
 * reading it.
 *
 * IT REUSES THE MODAL'S SHELL AND ITS OWN NOTHING. Same fill, same border, same shadow, same backdrop.
 * A second dialog styled from scratch is a second dialog that stops matching the first. */
.ex-palette {
  width: min(40rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 8rem);
  /* Pinned high rather than centred, and the margin is what does it: `auto` on the sides keeps it
     centred horizontally, and a fixed top leaves the growing room below. */
  margin: 4.5rem auto auto;
  padding: 0;
  border: 1px solid var(--ex-color-border);
  border-radius: var(--ex-radius-lg);
  background: var(--ex-color-raised);
  color: var(--ex-color-text);
  box-shadow: var(--ex-shadow-lg);
  overflow: hidden;
}

.ex-palette::backdrop {
  background: var(--ex-color-scrim);
  backdrop-filter: blur(var(--ex-scrim-blur));
  -webkit-backdrop-filter: blur(var(--ex-scrim-blur));
}

/* The input row. No border of its own at rest — the whole panel is the control, and a bordered box
   inside a bordered box reads as a form somebody has to submit rather than a thing that answers as
   you type. */
.ex-palette__bar {
  display: flex;
  align-items: center;
  gap: var(--ex-space-3);
  padding: var(--ex-space-3) var(--ex-space-4);
  border-bottom: var(--ex-border);
}

.ex-palette__bar .ex-icon {
  flex: none;
  width: 1.1rem;
  height: 1.1rem;
  color: var(--ex-color-text-faint);
}

.ex-palette__input {
  flex: 1;
  min-width: 0;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  font-size: var(--ex-text-lg);
  color: inherit;
}
.ex-palette__input:focus { outline: none; }
.ex-palette__input::placeholder { color: var(--ex-color-text-faint); }

.ex-palette__list {
  max-height: 24rem;
  overflow-y: auto;
  padding: var(--ex-space-2);
}

/* The heading over a run of hits of one kind. Sticky, because the list scrolls and a group whose
   heading has scrolled away is a run of rows with nothing saying what they are. */
.ex-palette__group {
  position: sticky;
  top: 0;
  z-index: 1;
  padding: var(--ex-space-2) var(--ex-space-3) var(--ex-space-1);
  background: var(--ex-color-raised);
  font-size: var(--ex-text-2xs);
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ex-color-text-muted);
}

/* A row. A <button> so it is a control and not a div somebody taught to be one; taken out of the tab
   order because the arrow keys drive this list and focus never leaves the input. Tab walking through
   the results would pull the real focus ring away from the is-active highlight and the two would
   drift apart — the same reasoning the elucive.co palette this is modelled on writes down. */
.ex-palette__item {
  display: flex;
  align-items: center;
  gap: var(--ex-space-3);
  width: 100%;
  padding: var(--ex-space-2) var(--ex-space-3);
  border: none;
  border-radius: var(--ex-radius);
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.ex-palette__item.is-active { background: var(--ex-color-accent-soft); }

/* A SCREEN THIS PERSON CANNOT OPEN, SHOWN RATHER THAN HIDDEN.
 *
 * Hiding it makes the palette lie about what the software has: somebody types "limits", gets nothing,
 * and concludes there is no limit register. Greyed, with the permission named underneath, they learn
 * both that it exists and whose door to knock on.
 *
 * Faded and not-allowed, never a red or a warning colour. Nothing has gone wrong — a fund accountant
 * who cannot open the limits register is a fund accountant, not an incident — and spending an alarm
 * colour on an ordinary boundary leaves nothing louder for a breach. */
.ex-palette__item.is-refused {
  opacity: 0.55;
  cursor: not-allowed;
}
.ex-palette__item.is-refused:hover { background: none; }

.ex-palette__icon {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: var(--ex-radius);
  background: var(--ex-color-control);
  color: var(--ex-color-text-muted);
}
.ex-palette__icon .ex-icon { width: 1rem; height: 1rem; }
.ex-palette__item.is-active .ex-palette__icon {
  background: var(--ex-color-surface);
  color: var(--ex-color-accent);
}

/* The label and its second line. min-width: 0 is what lets a long fund name ellipsis instead of
   pushing the row wider than the panel. */
.ex-palette__text { flex: 1; min-width: 0; }

.ex-palette__label {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ex-palette__detail {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--ex-text-xs);
  color: var(--ex-color-text-muted);
}

.ex-palette__note {
  padding: var(--ex-space-5) var(--ex-space-4);
  margin: 0;
  text-align: center;
  font-size: var(--ex-text-md);
  color: var(--ex-color-text-muted);
}

/* The footer that says which keys work. Nobody reads it twice and everybody reads it once. */
.ex-palette__keys {
  display: flex;
  gap: var(--ex-space-4);
  padding: var(--ex-space-2) var(--ex-space-4);
  border-top: var(--ex-border);
  font-size: var(--ex-text-2xs);
  color: var(--ex-color-text-faint);
}

/* The way in for somebody who does not know the shortcut, which is everybody on their first morning.
   It looks like the search box it opens rather than like a button, because that is what it is for. */
.ex-searchbutton {
  display: inline-flex;
  align-items: center;
  gap: var(--ex-space-2);
  min-width: 12rem;
  padding: 0.35rem var(--ex-space-3);
  border: var(--ex-border);
  border-radius: var(--ex-radius);
  background: var(--ex-color-bg);
  font: inherit;
  font-size: var(--ex-text-md);
  color: var(--ex-color-text-faint);
  cursor: pointer;
  transition: border-color var(--ex-transition), color var(--ex-transition);
}
.ex-searchbutton:hover {
  border-color: var(--ex-color-accent);
  color: var(--ex-color-text-muted);
}
.ex-searchbutton .ex-icon { width: 1rem; height: 1rem; }
.ex-searchbutton__label { flex: 1; text-align: left; }

/* The shortcut on the button, and the keys in the footer. */
.ex-kbd {
  padding: 0.05rem 0.3rem;
  border: var(--ex-border);
  border-radius: 3px;
  background: var(--ex-color-surface);
  font-family: inherit;
  font-size: var(--ex-text-2xs);
  color: var(--ex-color-text-muted);
}

/* Below the sidebar breakpoint the top bar is already fighting for room — the menu button, the title,
   the bell and the signed-in name — so the box gives up its words and keeps its icon. The shortcut is
   no use on a phone anyway; what is left is a tap target that opens the same palette.
 *
 * AND IT IS A TAP TARGET, WHICH TOOK A TEST TO NOTICE. Losing the words left it 42 by 29, under the
 * 44px floor ResponsiveLayoutIT holds every control to — a thing somebody aims at rather than presses,
 * on the bar where the control beside it opens the notifications. The padding that made it look like a
 * search box is what shrank it once there was nothing inside to hold it open, so at this width it stops
 * pretending to be a box and becomes a square button the size of the menu one it sits opposite. */
@media (max-width: 48rem) {
  .ex-searchbutton {
    /* NEVER GIVES WAY. The book chip beside it is the flexible item now, and a flex row shrinks
       everything with a shrink factor before it overflows -- so this became a two-pixel sliver the
       moment a book had a long name. A control that is squeezed to nothing is worse than one that is
       small: it reads as a rendering fault, which is exactly how it was reported. */
    flex: none;
    min-width: 0;
    /* SMALLER THAN THE 44px FLOOR, asked for and worth the trade.
     *
     * The top bar at 390px holds a page title, this button, the book chip, the bell and the avatar,
     * and it is crowded: the title truncates to "Insi..." on the Insight screen. Eight pixels back
     * does not fix that on its own -- the book chip is the larger consumer and the title is still cut
     * after this change, which is worth saying because the first version of this comment claimed
     * otherwise and the screenshot disagreed. What it does buy is a control sized in proportion to
     * what it does.
     *
     * What makes it defensible rather than a repeal: this button opens the command palette, which is
     * a shortcut to screens that are all reachable from the sidebar and the bottom bar — large
     * targets, both. Missing it costs a second attempt at a convenience, not access to anything.
     *
     * EveryScreenOnAPhoneIT exempts it by name with this reasoning, and nothing else on the bar
     * moves: the bell and the avatar stay 44, because a notification and the sign-out menu have no
     * second route. */
    width: 2.25rem;
    height: 2.25rem;
    justify-content: center;
    padding: 0;
  }
  .ex-searchbutton .ex-icon { width: 1.05rem; height: 1.05rem; }
  .ex-searchbutton__label,
  .ex-searchbutton .ex-kbd { display: none; }
}


/* ---- Print: drop the app chrome, print only the document ------------------------------------
   A valuation report, an income statement or a board pack gets printed and filed, so the printed
   page must be the document and not a screenshot of an application. Anything a reader cannot act on
   from paper — nav, breadcrumb, action buttons — is marked .ex-noprint and disappears. */
@media print {
  /* Named here rather than marked up on each page. These components are screen-only by their nature —
     a search box, an export button, a pager and a Save button are all things you cannot do to a sheet of
     paper — so the stylesheet states it once instead of five templates each remembering a class. That is
     also why nothing carried .ex-noprint for a year: the mechanism existed and every page that needed it
     would have had to opt in by hand. It stays, for the genuine one-off. */
  .ex-sidebar,
  .ex-topbar,
  .ex-breadcrumb,
  .ex-page__actions,
  .ex-form__actions,
  .ex-listbar,
  .ex-datatable__toolbar,
  .ex-datatable__foot,
  .ex-pager,
  .ex-noprint { display: none !important; }

  html, body, .ex-app, .ex-main { height: auto; overflow: visible; background: #fff !important; }
  .ex-app { display: block !important; }
  .ex-main { margin: 0 !important; }
  .ex-page { padding: 0 !important; }

  /* Unwind the fill layout. On screen a listing pins its toolbar and pager and scrolls the grid inside a
     bounded box; on paper a bounded box with overflow prints the first screenful of rows and silently
     drops the rest, which is the worst possible way for a report to be wrong. Everything goes back to its
     natural height, so nothing is clipped.

     A client-paged data table still prints the page you are looking at, not all of them — main.js hides
     the other pages with an inline display:none and that is the honest thing to print, since it is what
     the screen is showing. A report meant to be filed whole is a server-rendered report, not a screen
     with a pager on it; that is Phase 10's problem and it should not be solved by a print rule that
     quietly prints rows nobody chose. */
  .ex-content:has(> .ex-page--fill),
  .ex-page--fill,
  .ex-page--fill > .ex-fill,
  .ex-page--fill .ex-fill > .ex-datatable,
  .ex-page--fill .ex-fill .ex-datatable__scroll,
  .ex-page--fill .ex-fill > .ex-table-wrap,
  .ex-datatable__scroll,
  .ex-table-wrap,
  .ex-table-wrap--scroll {
    display: block !important;
    height: auto !important;
    max-height: none !important;
    min-height: 0 !important;
    overflow: visible !important;
  }

  /* A sticky header repeats itself down a printed page or lands on top of the rows. thead alone repeats
     per page, which is what a printed table actually wants. */
  thead th { position: static !important; }
  .ex-table thead { display: table-header-group; }
  .ex-table tr { break-inside: avoid; }
}




/* A BIGGER BUTTON, FOR THE ONE PLACE THAT HAS A SINGLE THING TO DO.
 *
 * The public page's call to action, and the offline page's only control. Both are a whole screen
 * whose entire purpose is one button, and a default-sized one floating in that space reads as an
 * afterthought rather than as the point.
 *
 * THIS IS WHY IT EXISTS AT ALL: the landing page shipped using .ex-btn--lg and the stylesheet did not
 * define it, so the call to action rendered at default size, in production, for a day. It is the
 * fifth class-that-selects-nothing on this project and the first one caught by a test rather than by
 * somebody looking -- StylesheetVocabularyTest was written to find exactly this and found it on its
 * first run, in code that was already deployed. */
.ex-btn--lg {
  min-height: 3rem;
  padding: 0 var(--ex-space-6);
  font-size: var(--ex-text-md);
}

/* ==============================================================================================
   THE BOTTOM BAR, THE PUBLIC PAGE, AND WHAT A PHONE GOT WRONG
   ============================================================================================== */

/* ---- Card actions on a phone ------------------------------------------------------------------
 *
 * THE BUG THIS FIXES, described the way it was reported: "the buttons in cards float and don't look
 * great".
 *
 * .ex-card__head is a flex row with space-between so a card-level action can sit opposite the title,
 * which is right on a desktop. On a phone the title wraps to two lines, the action wraps onto a third,
 * and space-between then pushes that lone button hard against the right edge with a gap above it. It
 * reads as a button that has come loose from its card -- floating, exactly.
 *
 * Below 48rem the head stops being a row: the action takes its own full-width line and its buttons
 * stretch to fill it, which is also the right target size for a thumb. */
@media (max-width: 48rem) {
  .ex-card__head {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--ex-space-3);
  }

  /* The actions take their own line, starting at the left edge of the card rather than being pushed
     to the right by the head's space-between. That was the whole "floating" complaint: a lone button
     hard against the right edge with a gap above it. */
  .ex-card__head > .ex-actions-wrap,
  .ex-card__body > .ex-actions-wrap,
  .ex-card__foot > .ex-actions-wrap {
    justify-content: flex-start;
  }

  /* THEY SIT SIDE BY SIDE AT THEIR OWN WIDTH, AND ONLY WRAP WHEN THEY MUST.
   *
   * The first version stretched each one to an equal share of the card, which made a single action
   * into a full-width slab and two actions into a pair of slabs. It removed the floating and replaced
   * it with something heavier: a card whose loudest element is a button nobody has pressed yet.
   *
   * A form is still made to participate, because a block-level form takes a line of its own and
   * stacks the buttons whatever the flex rules say -- that is the same fault the row-actions column
   * was fixed for, and it is the actual cause of "the buttons are stacked". */
  .ex-card__head > .ex-actions-wrap > form,
  .ex-card__body > .ex-actions-wrap > form,
  .ex-card__foot > .ex-actions-wrap > form,
  .ex-actions-wrap > form {
    display: contents;
  }

  /* A THUMB IS NOT A CURSOR. 44px is the smallest target Apple's and Google's own guidance will
     accept, and a control below it is one a person misses and then has to look at their hand. */
  .ex-actions-wrap > .ex-btn,
  .ex-actions-wrap > form > .ex-btn {
    min-height: 2.75rem;
  }
}

/* ---- The public page ---------------------------------------------------------------------------
 *
 * The only screen that is not the application shell. It borrows the shell's tokens and its buttons
 * and adds only what a page with no sidebar needs: a centred column and room to breathe. */
.ex-landing {
  min-height: 100vh;
  min-height: 100dvh;
  margin: 0;
  background: var(--ex-color-bg);
  color: var(--ex-color-text);
}

.ex-landing__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ex-space-4);
  max-width: 64rem;
  margin: 0 auto;
  padding: var(--ex-space-4) var(--ex-space-5);
}

.ex-landing__brand {
  display: inline-flex;
  align-items: center;
  gap: var(--ex-space-2);
  font-size: var(--ex-text-lg);
  font-weight: 700;
  color: var(--ex-color-text);
  text-decoration: none;
}

.ex-landing__mark { display: block; }

.ex-landing__hero,
.ex-landing__features,
.ex-landing__closing {
  max-width: 64rem;
  margin: 0 auto;
  padding: var(--ex-space-6) var(--ex-space-5);
}

.ex-landing__hero { padding-top: var(--ex-space-8); text-align: center; }

.ex-landing__title {
  margin: 0 0 var(--ex-space-4);
  font-size: var(--ex-text-3xl);
  line-height: 1.15;
  letter-spacing: -0.02em;
}

.ex-landing__lead {
  max-width: 42rem;
  margin: 0 auto var(--ex-space-5);
  font-size: var(--ex-text-lg);
  line-height: 1.6;
  color: var(--ex-color-text-muted);
}

.ex-landing__cta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--ex-space-3);
  justify-content: center;
}

.ex-landing__note {
  margin: var(--ex-space-4) 0 0;
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-faint);
}

.ex-landing__section-title {
  margin: 0 0 var(--ex-space-5);
  font-size: var(--ex-text-xl);
  text-align: center;
}

/* auto-fit rather than a fixed column count, so the six cards go three, two and one across without
   three separate breakpoints saying so. */
.ex-landing__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--ex-space-4);
}

.ex-landing__feature .ex-icon {
  width: 1.75rem;
  height: 1.75rem;
  margin-bottom: var(--ex-space-3);
  color: var(--ex-color-accent);
}

.ex-landing__feature-title {
  margin: 0 0 var(--ex-space-2);
  font-size: var(--ex-text-md);
}

.ex-landing__closing { text-align: center; }

.ex-landing__foot {
  max-width: 64rem;
  margin: 0 auto;
  padding: var(--ex-space-6) var(--ex-space-5) var(--ex-space-8);
  border-top: var(--ex-border);
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-faint);
  text-align: center;
}

@media (max-width: 48rem) {
  .ex-landing__title { font-size: var(--ex-text-2xl); }
  .ex-landing__cta > .ex-btn { width: 100%; }
}

/* ---- The public page's movement -----------------------------------------------------------------
 *
 * ENOUGH TO SUGGEST THE PAGE IS ALIVE AND NOT ENOUGH TO WAIT FOR. The hero settles once on arrival and
 * the six cards follow it in, staggered by 60ms, all of it over in about half a second. Nothing moves
 * on scroll, nothing moves twice, and nothing is animated that a person is trying to read: the cards
 * carry the product's claims, so they arrive and then hold still.
 *
 * TRANSFORM AND OPACITY ONLY. Both are composited, so this costs a phone nothing; animating height or
 * top would relayout the page six times on the slowest device anybody opens it with.
 *
 * The stagger is written out rather than generated, because six is not many and :nth-child with a
 * calc() delay is harder to read than six lines that say what they do. */

@keyframes ex-landing-rise {
  from { opacity: 0; transform: translateY(0.75rem); }
  to   { opacity: 1; transform: none; }
}

.ex-landing__title,
.ex-landing__lead,
.ex-landing__cta,
.ex-landing__note {
  animation: ex-landing-rise 0.45s ease-out both;
}

.ex-landing__lead  { animation-delay: 0.06s; }
.ex-landing__cta   { animation-delay: 0.12s; }
.ex-landing__note  { animation-delay: 0.18s; }

.ex-landing__reveal { animation: ex-landing-rise 0.45s ease-out both; }
.ex-landing__reveal:nth-child(1) { animation-delay: 0.10s; }
.ex-landing__reveal:nth-child(2) { animation-delay: 0.16s; }
.ex-landing__reveal:nth-child(3) { animation-delay: 0.22s; }
.ex-landing__reveal:nth-child(4) { animation-delay: 0.28s; }
.ex-landing__reveal:nth-child(5) { animation-delay: 0.34s; }
.ex-landing__reveal:nth-child(6) { animation-delay: 0.40s; }

/* A card lifts under the pointer, and only where there is a pointer to lift it: on a touchscreen a
   hover state is a state you cannot leave, so the card would stay lifted after a tap. */
@media (hover: hover) {
  .ex-landing__feature {
    transition: transform var(--ex-transition), box-shadow var(--ex-transition);
  }
  .ex-landing__feature:hover {
    transform: translateY(-2px);
    box-shadow: var(--ex-shadow-lg);
  }
}

/* SOMEBODY WHO HAS ASKED FOR LESS MOVEMENT GETS NONE. Not a shorter animation -- none. The setting is
   often a vestibular condition rather than a preference, and a page that still slides "just a little"
   is a page that still makes them ill. Everything lands in its final position immediately. */
@media (prefers-reduced-motion: reduce) {
  .ex-landing__title,
  .ex-landing__lead,
  .ex-landing__cta,
  .ex-landing__note,
  .ex-landing__reveal {
    animation: none;
  }
  .ex-landing__feature { transition: none; }
  .ex-landing__feature:hover { transform: none; }
}

/* ==============================================================================================
   WHAT A PHONE ACTUALLY LOOKS LIKE, after opening it in Chrome and reading the screen
   ==============================================================================================

   Everything below was measured in a real browser at 390 by 844 rather than asserted in a test. The
   tests said every screen fitted and every control was big enough, and both were true; what they
   cannot say is that a screen is mostly furniture. Three findings, all of them about density. */

@media (max-width: 48rem) {

  /* ---- The top bar was giving the page title three letters -----------------------------------
   *
   * "Accounts" rendered as "Acc...". The bar is a flex row of title, book badge, bell and user menu,
   * and the title is the only one that can shrink, so it shrank to nothing while a badge repeating
   * the book name -- which is not what the page is about -- kept its full width.
   *
   * The badge earns its place: knowing which book is open is what stops a grocery bill going into
   * the company accounts, and it is on every screen for that reason. But it does not have to be
   * whole. Capped and ellipsised, it still says which book, and the title gets to be a title. */
  .ex-topbar__title {
    flex: 1 1 auto;
    min-width: 0;
  }

  /* Ellipsised rather than capped. It is now the flexible item on the bar, so what bounds it is what
     is left after the controls -- which is the whole point of moving it out of the fixed-size group.
     It read "Mwenecho Bus..." at 7.5rem, which names a book but not which of two somebody keeps. */
  .ex-topbar .ex-topbar__book {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* The build number is for somebody reporting a fault, and they are not reporting it from a phone
     with the title cut in half. */
  .ex-topbar__version { display: none; }

  /* ---- Stat tiles were a third of the screen before any data ---------------------------------
   *
   * Three tiles, 75px each plus gaps, stacked full width: 250px of a 667px viewport spent on three
   * numbers before the list they summarise appears at all. Two across halves that, and the figures
   * are still the largest thing in each tile.
   *
   * Two rather than three, because a 390px screen divided three ways gives 120px a tile, and
   * "12,976.20" does not fit in 120px without shrinking to a size nobody reads across a room. */
  /* THE TILE IS SHORTER AT THIS WIDTH; HOW MANY FIT IN A ROW IS DECIDED BELOW.
   *
   * A stat tile does not need 75px to hold one line of figures and one line of label, and that is
   * true of a small tablet as much as a phone. */
  .ex-stat {
    min-height: 0;
    padding: var(--ex-space-3) var(--ex-space-4);
  }

  .ex-stat__icon {
    width: 2rem;
    height: 2rem;
    flex: 0 0 auto;
  }

  .ex-stat__icon .ex-icon { width: 1.1rem; height: 1.1rem; }
}

/* ONE PER ROW ON A PHONE, AND TWO FROM 34rem, WHICH IS WHAT THE PORTED RULES ALREADY SAID.
 *
 * The stylesheet has said this since Phase 0: one column, two from 34rem, three from 60rem. Putting
 * "one per row" in the 48rem block overrode the middle of that, so every width from 544px to 768px --
 * a tablet in portrait, a large phone on its side -- got a single column it had never asked for.
 *
 * Two across was the wrong trade on a phone, where 165px makes a balance a figure you squint at. At
 * 544px and up the halves are wide enough, which is precisely what the 34rem breakpoint was chosen
 * for, so this is a phone rule and not a small-screen rule.
 *
 * 33.9375rem rather than 34rem: max-width and min-width are both inclusive, so writing 34 here would
 * make both rules true at exactly 34rem and leave the winner to source order. */
@media (max-width: 33.9375rem) {
  .ex-stats {
    grid-template-columns: 1fr;
    gap: var(--ex-space-2);
  }

  /* ---- Table rows were 77px tall ---------------------------------------------------------------
   *
   * Reported as too much padding, and padding was half of it: 8.8px top and bottom on every cell, on
   * a screen where the same row on a laptop has room to breathe and here does not.
   *
   * The other half was wrapping. "Airtel Money · 0977000001" broke across three lines because the
   * separator and the number were free to wrap anywhere, so a two-line cell became a three-line one
   * and set the height of the row. */
  .ex-table td,
  .ex-table th,
  .ex-datatable td,
  .ex-datatable th {
    padding: var(--ex-space-2) var(--ex-space-3);
  }

  /* A ROW IS ONE LINE, AND THE TABLE SCROLLS INSTEAD.
   *
   * "Airtel Money · 0977000001" is a bare td with no inner element to target, 101px wide, so it broke
   * across three lines and set the row to 76px. Three accounts filled the screen.
   *
   * The first attempt at this styled .ex-cell-sub and small, neither of which is in the markup -- a
   * rule that selects nothing, which is the fault this project keeps shipping, written this time
   * while fixing something else.
   *
   * Cells do not wrap now and the table scrolls sideways inside its own container, which it already
   * had: .ex-datatable__scroll is overflow-x auto and capped in height. So the PAGE still does not
   * scroll sideways -- EveryScreenOnAPhoneIT measures that on every screen -- and a row is one line
   * at about 36px instead of three at 76px. Reading a long narrative means scrolling the table, which
   * is the trade every phone table makes, and the alternative is nine visible rows becoming four. */
  .ex-table td,
  .ex-table th,
  .ex-datatable td,
  .ex-datatable th {
    white-space: nowrap;
  }

  /* ---- The tab bar itself ----------------------------------------------------------------------
   *
   * 57px, which is not wrong and is more than it needs. The icon and the label both had room to
   * spare above and below, and on a 667px viewport every row of navigation is a row of content
   * somebody does not get. Roughly 50px now, and the clearance follows it because main.js measures
   * the bar rather than trusting a number here. */
  .ex-tabbar__item {
    min-height: 3rem;
    padding: var(--ex-space-1) var(--ex-space-1);
    gap: 0.1rem;
  }

  .ex-tabbar__item .ex-icon { width: 1.25rem; height: 1.25rem; }
}

/* ---- The two ways to record something -----------------------------------------------------------
 *
 * .ex-queue was the wrong component for this and looking at it is what showed it. Its row is a fact
 * with an action beside it, so on a phone the title and its description ran together on one line and
 * the button dropped underneath, detached and left aligned. What this actually is is a menu of two
 * alternatives, where the whole row is the choice.
 *
 * So the row IS the link. Nothing to aim at inside it, no button that can wrap away from what it
 * belongs to, and a target the width of the sheet. */
.ex-choice {
  display: flex;
  flex-direction: column;
  gap: var(--ex-space-2);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ex-choice__item {
  display: flex;
  align-items: center;
  gap: var(--ex-space-3);
  min-height: 3.5rem;
  padding: var(--ex-space-3) var(--ex-space-4);
  border: var(--ex-border);
  border-radius: var(--ex-radius-lg);
  background: var(--ex-color-surface);
  color: var(--ex-color-text);
  text-decoration: none;
  transition: background var(--ex-transition), border-color var(--ex-transition);
}

.ex-choice__item:hover,
.ex-choice__item:focus-visible {
  background: var(--ex-color-accent-soft);
  border-color: var(--ex-color-accent);
}

/* THE ROW'S OWN ICON, AND ONLY IT — the child combinator is the fix for a reported bug.
 *
 * This was a descendant selector, so it also caught the icons inside the row's buttons: it made them
 * 1.5rem and accent-coloured, the same size and colour as the row's leading icon. On the books list
 * that put a large blue folder inside a ghost "Archive" button beside the identical large blue folder
 * that belongs to the row, which reads as a rendering fault rather than as a button. Reported as the
 * icons not showing on the buttons, which is what an icon looks like when it is indistinguishable
 * from its neighbour. A button's icon is the button's business. */
.ex-choice__item > .ex-icon {
  flex: 0 0 auto;
  width: 1.5rem;
  height: 1.5rem;
  color: var(--ex-color-accent);
}

.ex-choice__body {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  min-width: 0;
}

.ex-choice__title { font-weight: 600; }

.ex-choice__meta {
  font-size: var(--ex-text-sm);
  color: var(--ex-color-text-muted);
}

/* The chevron is the affordance that says the row goes somewhere, and it sits at the far end. */
.ex-choice__item .ex-choice__go {
  margin-left: auto;
  width: 1.1rem;
  height: 1.1rem;
  /* Never squashed and never pushed off. A chevron is 17px; a flex item with no shrink floor gives
     those up first when the text beside it is long, and a chevron half a chevron wide reads as a
     rendering fault. The body carries min-width: 0 so it is the thing that gives way instead. */
  flex-shrink: 0;
  /* .ex-choice__item .ex-icon sets the accent colour and is two classes deep, so a single-class rule
     here lost to it and the chevron came out as loud as the leading icon. */
  color: var(--ex-color-text-faint);
}

/* WHERE A ROW'S ACTIONS GO. .ex-choice was built for a menu, where the whole row is the link and
   there is nothing else in it. The books list is the other shape: a row carrying a state and one or
   two things you might do to it, so the actions need somewhere that is not the end of the sentence. */
.ex-choice__act {
  display: flex;
  align-items: center;
  gap: var(--ex-space-2);
  margin-left: auto;
  flex: 0 0 auto;
}

/* A row that is a container rather than a link does not want the pointer or the hover. */
li.ex-choice__item { cursor: default; }
li.ex-choice__item:hover { background: var(--ex-color-surface); border-color: var(--ex-color-border); }

@media (max-width: 48rem) {
  /* On a phone the actions take their own line rather than squeezing beside a name that has wrapped.
   *
   * li, NOT EVERY .ex-choice__item, AND THAT IS THE FIX FOR A REPORTED BUG. This rule was written for
   * the books list and applied to the whole component, so it also wrapped the rows that are plain
   * links with a chevron — and there the chevron is the last flex item, so it came off the end of the
   * text and sat alone on a third line, pointing at nothing. "Your books · Everything you belong to,
   * archived ones too" was the row long enough to show it.
   *
   * The two shapes are already told apart a few lines above, by the same selector: a row that is an
   * <li> is a container carrying actions, and a row that is an <a> is itself the link. Only the first
   * has anything to wrap. */
  /* AND NOW IT DOES NOT WRAP AT ALL, which is the third answer to this question and the one that was
   * asked for: "buttons dont look so great on this page, they are not inline... the buttons here
   * should be small [on] small screens."
   *
   * Wrapping was meant to stop the actions being squeezed beside a long name. What it actually did
   * was let ANY child move to a new line, and the first to go is not the button but the body — so a
   * book called "Chihana household and small business trading" left its folder icon stranded alone on
   * the first line with the name beneath it, and the button below that again. Three lines, none of
   * them lined up with anything.
   *
   * A small button beside the words is what a row of this kind wants. The name wraps inside its own
   * cell, which is what min-width: 0 on the body is for, and everything stays on one line. */
  li.ex-choice__item { flex-wrap: nowrap; }
  .ex-choice__act { flex: 0 0 auto; }
}

/* A ROW'S BUTTONS ARE SMALL ON A PHONE, for the same reason a table row's are: a full-height control
   beside a name is a block of colour competing with the thing it belongs to. Same size as the table's
   row actions, from the same token, so the two lists agree. They keep their words. */
@media (pointer: coarse) {
  .ex-choice__act .ex-btn,
  .ex-choice__act > form > .ex-btn {
    min-height: var(--ex-table-action-size);
    height: var(--ex-table-action-size);
    min-width: 0;
    padding: 0 var(--ex-space-2);
    gap: 0.3rem;
    font-size: var(--ex-text-xs);
  }

  .ex-choice__act .ex-btn .ex-icon {
    width: 0.95rem;
    height: 0.95rem;
  }
}


/* The chosen appearance option, so a page of buttons says which one is in force. Not a disabled
   button: it is still pressable, and pressing the one already chosen should do nothing rather than
   look broken. */
.ex-btn.is-chosen {
  border-color: var(--ex-color-accent);
  background: var(--ex-color-accent-soft);
  color: var(--ex-color-accent);
  font-weight: 600;
}

/* ---- A table whose first column stays put ------------------------------------------------------
 *
 * FOR THE BUDGET PLAN, AND FOR ANY TABLE SOMEBODY TYPES INTO. The plan is a category, a direction and
 * a figure, which is wider than a phone -- so the table scrolls sideways, and somebody entering the
 * fourth figure had scrolled the names off the left edge and was typing an amount against a row they
 * could no longer identify. Reported as having to slide back and forth for every line.
 *
 * TWO COLUMNS PINNED, not one: the tick and the name. They are one thing -- the tick has no meaning
 * without the name beside it -- and pinning only the tick would leave the checkbox floating against
 * rows it does not belong to, which is worse than pinning nothing.
 *
 * The background is opaque and stated, because a sticky cell paints over the cells sliding under it
 * and a transparent one shows both at once. The header cell needs a higher z-index than the body's
 * for the same reason, where the sticky row and the sticky column cross. */
.ex-table--stickyfirst {
  /* ONE NUMBER, USED TWICE. The first column's width and the second column's offset have to be the
     same value or the two pinned cells do not share an edge -- and they were not the same value.
     The offset was a fixed 3.25rem and the width was whatever .ex-cell--fit shrank to, which on the
     plan screen is a 1.1rem checkbox in 0.55rem of compact padding: about 2.2rem. The 17px between
     them was a slot the scrolling row showed straight through, reported as seeing the background
     between the tick and the category while scrolled right. A guess about a width is wrong the day
     the padding changes; a token cannot disagree with itself. */
  --ex-pinned-first: 3.25rem;
}

.ex-table--stickyfirst th:nth-child(-n + 2),
.ex-table--stickyfirst td:nth-child(-n + 2) {
  position: sticky;
  background: var(--ex-color-surface);
  z-index: 1;
}

.ex-table--stickyfirst th:nth-child(1),
.ex-table--stickyfirst td:nth-child(1) {
  left: 0;
  width: var(--ex-pinned-first);
  min-width: var(--ex-pinned-first);
}

.ex-table--stickyfirst th:nth-child(2),
.ex-table--stickyfirst td:nth-child(2) { left: var(--ex-pinned-first); }

/* WHERE THE STICKY ROW AND THE STICKY COLUMN MEET, one of them has to win. The header, because a
   column heading scrolling under a cell reads as a rendering fault. */
.ex-table--stickyfirst thead th:nth-child(-n + 2) { z-index: 2; }

/* A hairline that says the rest of the row is elsewhere. Without it the pinned cells and the scrolled
   ones read as one continuous row and the join is invisible until something obviously does not line
   up. */
.ex-table--stickyfirst th:nth-child(2),
.ex-table--stickyfirst td:nth-child(2) {
  border-right: var(--ex-border);
}

/* Only where it is needed. On a laptop the table fits, nothing scrolls, and a sticky cell with a
   border down its right-hand side is a line through the middle of a table for no reason. */
@media (min-width: 60rem) {
  .ex-table--stickyfirst th:nth-child(-n + 2),
  .ex-table--stickyfirst td:nth-child(-n + 2) {
    position: static;
    border-right: 0;
  }
}

/* Where the columns ARE pinned -- a phone -- the pinned pair takes a budget and keeps to it.
 *
 * REPORTED FROM THE PLAN SCREEN: the category column was as wide as its longest description, so the
 * two pinned columns ate most of a 390px screen and the figures the table exists for scrolled in the
 * sliver left over. The name column now takes roughly the width of its own heading; a long name
 * wraps, and the description under it -- which is a reminder, not a fact anybody types against --
 * cuts to one line with an ellipsis rather than deciding the width of every row.
 *
 * And the rows come down: compact padding was set for a laptop's pointer, and on a phone it read as
 * too much air between one category and the next. */
@media (max-width: 60rem) {
  .ex-table--stickyfirst th:nth-child(2),
  .ex-table--stickyfirst td:nth-child(2) {
    width: 7.5rem;
    max-width: 7.5rem;
  }

  .ex-table--stickyfirst td:nth-child(2) .ex-cell__note {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .ex-table--compact.ex-table--stickyfirst th,
  .ex-table--compact.ex-table--stickyfirst td {
    padding-top: 0.35rem;
    padding-bottom: 0.35rem;
  }
}
