/* ============================================================================
   Hank — shared design system  (BuildPass-grade)
   Loaded last on every page so its tokens & base styles are authoritative.
   Owns: the palette, typography (Inter), radii, shadows, and refined base
   interactions (focus rings, scrollbars, selection, controls). Page-specific
   component layouts stay in each page's own <style>.
   ============================================================================ */

/* ----------------------------------------------------------------------------
   Inter, served from here rather than from Google
   ----------------------------------------------------------------------------
   Every page used to open with a render-blocking <link> to
   fonts.googleapis.com. That is a stylesheet, so the browser will not paint
   ANYTHING until it arrives — and when it does not arrive, it waits for the
   connection to time out first. Measured in a sandbox where that host is
   unreachable, first paint on all nineteen pages was 12.7 seconds of blank
   screen. An agency network that blocks or throttles Google, or simply a bad
   morning for DNS, produces the same page for the same reason.

   It also cost two DNS lookups and two TLS handshakes to two extra origins
   (googleapis for the CSS, gstatic for the font itself) before the first byte
   of a font could be requested, because the second host is not even known
   until the first one answers.

   One variable file replaces the five static weights the app asked for: the
   wght axis covers 100-900, so 400/500/600/700/800 all come from these
   48 KB. latin-ext is a second file behind a unicode-range, so it is only
   fetched by a page that actually contains one of those characters.

   The family is named `Inter` deliberately — every page's font stack already
   says `'Inter', ...`, so nothing else had to change. Files carry the version
   in their name so they can be cached hard and forever; upgrading means a new
   filename, which is the only safe way to say `immutable`.

   Provenance: @fontsource-variable/inter@5.3.0, files/inter-latin-wght-normal
   and files/inter-latin-ext-wght-normal, unicode-ranges copied from its
   wght.css. Refresh with `npm run fonts:refresh`.
   -------------------------------------------------------------------------- */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  /* Show the text immediately in the fallback and swap when Inter lands. The
     alternative is a page that is deliberately blank while a font downloads,
     which is the failure this whole section exists to remove. */
  font-display: swap;
  src: url("/fonts/inter-5.3.0-latin-wght-normal.woff2") format("woff2-variations");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("/fonts/inter-5.3.0-latin-ext-wght-normal.woff2") format("woff2-variations");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
    U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020,
    U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  /* ── Surfaces & ink ─────────────────────────────────────────────────────
     Every page's own :root sets a --bg too, but harris.css is linked after
     every page's inline <style>, so this one value governs the platform.

     WHY THIS GROUND, measured rather than chosen by eye. The previous ground
     was #EAE2E0 — in OKLCH, L=91.9 C=0.92 at hue 34°. Grounds that read as
     up-market operations software sit at L≈97-98.5 with C≈0-0.3. So it was
     six points of lightness too dark and roughly three times too chromatic,
     and both showed:

       - too dark: white cards no longer read as gently raised panels but as
         high-contrast stickers laid on a wall. The elevation was doing no
         work, because the ground was doing all of it;
       - too chromatic: a visible pink cast at hue 34°, which reads as a
         lifestyle brand rather than as a place to look at money.

     This is L=97.6, C=0.29 at hue 265 — light enough that a 1px border and a
     1px shadow become the thing that separates a card from the page, which is
     what elevation is supposed to be, and neutral enough to disappear.

     The neutrals move together. A cool ground with warm borders is the same
     mismatch the old comment here described in reverse: the chrome reads as
     belonging to a different product than the page it sits on. */
  --bg: #F5F6F8;
  --surface: #FFFFFF;
  --surface-2: #FAFBFC;
  --surface-hover: #F1F3F6;
  --surface-border: #E4E7EC;
  --border: #E4E7EC;
  --border-strong: #D2D6DE;
  --surface-border-strong: #D2D6DE;

  --ink: #15171A;
  --text: #15171A;
  --primary: #15171A;
  --primary-light: #33373E;
  --text-secondary: #565C66;
  --ink-2: #565C66;
  /* AA on every surface it is used on, which it has never been before.
     The note this replaces recorded that the old #6F6F69 cleared AA on a
     white card (5.06:1) but measured only 3.97:1 on the ground, and called
     the real fix a layout change rather than a token one. Lightening the
     ground makes it a token one after all: this is 5.27:1 on white, 5.09:1
     on surface-2 and 4.88:1 on the ground.

     #666C78 specifically because it is the LIGHTEST cool neutral that clears
     4.5:1 on all three. Going darker would pass too, and would stop the token
     doing its job — muted text has to read as muted or the hierarchy it
     exists to express collapses. Checked: #767D88, the value this ground was
     first prototyped with, measures 3.84:1 on the ground and would have
     shipped a regression against the 5.06:1 we already had. */
  --text-muted: #666C78;
  --ink-3: #666C78;
  --muted: #666C78;

  /* ── Accents (BuildPass) ── */
  --blue: #4F7FE8;       --blue-soft: #EEF3FE;
  --info: #4F7FE8;       --info-subtle: #EEF3FE;
  --accent: #4F7FE8;     --accent-light: #EEF3FE;
  --green: #0FA968;      --green-soft: #E9F8F1;
  --success: #0FA968;    --success-subtle: #E9F8F1;
  --amber: #E0912F;      --amber-soft: #FDF3E4;
  --warning: #E0912F;    --warning-subtle: #FDF3E4;
  --red: #E5484D;        --red-soft: #FDECEC;
  --danger: #E5484D;     --danger-subtle: #FDECEC;
  --violet: #7C5CE6;     --violet-soft: #F0ECFC;
  --gold: #B08A3E;      --gold-soft: #F8F0DF;

  /* ── Ink for text on the matching soft tint ──
     The accents above are built for icons, borders and chart fills. They are
     NOT dark enough to carry text on their own soft background, and the app
     had been using them for exactly that on every status pill:

       green  on green-soft   2.78:1
       amber  on amber-soft   2.31:1
       blue   on blue-soft    3.41:1
       red    on red-soft     3.43:1
       violet on violet-soft  4.00:1

     All five short of the 4.5:1 AA floor for text this size, and the amber one
     is the worst of them — measured, not guessed. Nothing caught it because
     the a11y verifier checks names, roles and focus, not contrast.

     These are the LIGHTEST value that clears the floor in each hue, so the
     pill still reads as green or amber rather than as a dark smudge. Derived
     by walking each accent down toward black against its own tint and stopping
     at the first pass; ratios noted so the next person can check rather than
     trust. Solid-fill-with-white-text was measured too and is worse: it fails
     on four of the five, because these accents are light. */
  --green-ink:  #0B7F4E;   /* 4.61:1 on --green-soft  */
  --amber-ink:  #986320;   /* 4.62:1 on --amber-soft  */
  --blue-ink:   #4269C1;   /* 4.70:1 on --blue-soft   */
  --red-ink:    #C03C41;   /* 4.64:1 on --red-soft    */
  --violet-ink: #7154D1;   /* 4.65:1 on --violet-soft */

  /* Loading scrim — the veil over the whole screen while a page loads.

     WAS rgba(217,158,172,0.97): a strong pink, on the reasoning that a
     loading state is one of the few moments the whole screen belongs to the
     product. That reasoning survives; the colour does not. It is the FIRST
     thing anyone sees on every navigation, and against the cool near-neutral
     ground it now resolves to, a saturated pink flash is the loudest
     off-brief moment left in the app — the load reads as a different product
     than the page that follows it.

     A veil of the ground itself. The transition from loading to loaded is now
     a fade between two near-identical neutrals rather than a colour change,
     which is what makes a load feel like part of the page rather than an
     interruption in front of it.

     The scrim is light, so everything on it is INK, not white — white here is
     about 1.1:1. That is why the ink token exists rather than the overlay
     hard-coding #fff. Ink measures 15.6:1 on this. Alpha stays high for the
     same reason it always did: content showing through eats the contrast the
     text depends on. */
  --hb-scrim: rgba(245, 246, 248, 0.97);
  --hb-scrim-ink: #15171A;

  /* ── AI purple ──
     Named for its job, not its hue: this is the colour that marks something as
     AI — the sparkle mark, AI actions, generated content. Kept distinct from
     --violet (a generic status accent) so AI can be restyled without dragging
     unrelated chips along with it. Matches the brand sparkle asset. */
  --ai: #8B5CF6;
  --ai-strong: #7C3AED;
  --ai-soft: #F1ECFE;
  --ai-border: #DDD1FB;

  /* ── Radii ── */
  /* ── One content column ──────────────────────────────────────────────────
     Measured across the app before this existed, the content container was
     1180px on PM Operations, 1200px on Home and Reports, 1240px on Admin and
     1360px on Marketing and PM EOM — with several pages having no max-width
     at all. Five widths meant five different places the page began, so moving
     between modules shifted the whole layout sideways.

     The gutter is a token too, and that is what makes the header align: a
     centred column's left edge is (100% - max)/2 + gutter, so a header using
     the same expression lands on the same vertical without any markup change.
     Measured on PM Operations: brand at 28px and heading at 186px before,
     both at 104px after. */
  --content-max: 1360px;
  --content-gutter: 28px;

  --radius: 16px;
  --radius-lg: 20px;
  --radius-2: 12px;
  --radius-sm: 9px;
  --radius-3: 9px;

  /* ── Shadows (soft, layered) ──
     Tinted rgba(16,24,40) rather than rgba(16,15,14): a shadow is the ground
     seen through the card, so a warm-black shadow on a cool ground reads as a
     smudge rather than as depth. Same reason the borders moved.

     Slightly stronger than before, because they now have a job. Against
     #EAE2E0 the beige/white step separated a card on its own and the shadow
     was decoration; against #F5F6F8 the border and the shadow ARE the
     separation. */
  --shadow-sm: 0 1px 2px rgba(16,24,40,0.05);
  --shadow: 0 1px 2px rgba(16,24,40,0.05), 0 4px 16px rgba(16,24,40,0.06);
  --shadow-md: 0 1px 3px rgba(16,24,40,0.07), 0 6px 20px rgba(16,24,40,0.06);
  --shadow-lg: 0 8px 30px rgba(16,24,40,0.12);

  /* ── Type ──────────────────────────────────────────────────────────────
     A scale, because there was not one. Measured across ten pages: 21
     distinct sizes and 54 size/weight pairs, of which 13/400 (19.6% of all
     text), 12.5/400 (8.6%) and 12/400 (15.8%) were three different sizes
     doing one job. Nobody reads a half-pixel difference as meaning; they
     read it as the page being slightly out of tune everywhere.

     Seven steps, each far enough from the next to be legible as a decision:

       micro    uppercase labels, table headers, pill text
       small    secondary and supporting copy
       body     the default
       title    card and widget titles
       section  a heading over a group of cards
       page     the name of the screen you are on
       hero     a number that is the whole point of its tile

     Sizes are quarter-integers at most; 13.5 and 12.5 exist because the app
     already leaned on them and rounding them would move a lot of text for
     no gain. What is gone is the difference between 12 and 12.5 in the same
     role, which is the part that was noise. */
  --font: 'Inter', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;

  --t-micro: 10.5px;
  --t-small: 12px;
  --t-body: 13px;
  --t-title: 14px;
  --t-section: 18px;
  --t-page: 22px;
  --t-hero: 28px;
  /* One step above hero, for the single number that IS the screen — the
     valuation figure on the reporting dashboard. It kept its own size
     because it is genuinely a level up from a KPI tile, and naming it stops
     the next one being invented at 34px. */
  --t-display: 36px;

  /* Three weights. There were eleven — 400, 500, 550, 600, 620, 640, 650,
     680, 700, 750, 800 — and the five in between are variable-font values
     that no reader can tell from their neighbours. Regular carries text,
     medium marks something out, bold is a heading or a number. */
  --w-regular: 400;
  --w-medium: 600;
  --w-bold: 700;

  /* ── Space ──────────────────────────────────────────────────────────────
     Also absent, which is why padding across the app read 12, 14, 16, 18,
     20, 22, 24, 28 with no relationship between any of them. A tile whose
     padding is not on the scale is the reason a grid of them looks hand-
     placed rather than laid out.

     Roughly a 1.4 ratio, rounded to even numbers so half-values never
     appear in a computed style. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 22px;
  --space-6: 32px;
  --space-7: 44px;
}

/* ── Base ── */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }

body {
  font-family: var(--font);
  color: var(--text);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  letter-spacing: -0.006em;
}

/* `bolder` is the browser's default for these, and against a variable font
   that resolves to 900 — a weight the scale does not have and nothing else in
   the app uses. One <strong> on the intercom page was rendering at it. */
strong, b { font-weight: var(--w-bold); }

/* ── `hidden` actually hides ─────────────────────────────────────────────────
   `hidden` is a UA rule — `[hidden] { display: none }` — and the UA sheet is
   the WEAKEST origin there is. Any author rule that sets `display` beats it,
   so `.btn { display: inline-flex }` means a button hidden with `el.hidden =
   true` stays on the screen. Nothing errors, nothing logs, and the JS reading
   `el.hidden` back gets `true` — the attribute and the pixels disagree, and
   only one of them is what somebody sees.

   THIS IS THE THIRD TIME. `.mc-hint[hidden]` and `.hm-section-body[hidden]`
   further down are the same fix written per component, which is a pattern that
   only ends when the next component is written. Declared once here instead.

   MEASURED BEFORE IT WAS ADDED, across the twenty-one pages in the running
   app: exactly two elements carried `hidden` and were drawn anyway — a "Move
   to a new device" button on the account page and admin's error badge showing
   "0" — and no element anywhere relied on `hidden` failing. `!important` is
   what it takes to outrank an author `display`, and it is safe here for the
   same reason it is usually not: the whole point of the attribute is that it
   wins. */
[hidden] { display: none !important; }

/* ── The heading ladder ──
   A page's own name was rendering at four different sizes depending on which
   page you were on — 28, 22, 18 and, on the API explorer, 14 — and on the
   admin landing a section heading and a card heading were both 14/700, which
   is to say neither was above the other. Three steps, declared once:

     h1   the page's name          22 / bold
     h2   a section within it      18 / medium
     h3   a card or panel inside   14 / medium

   Element selectors on purpose. A page that wants a different shape for one
   particular heading can still have it, but it has to ask with a class —
   nothing here falls back to the browser's idea of how big a heading is. */
h1 { font-size: var(--t-page);    font-weight: var(--w-bold);   letter-spacing: -0.02em; }
h2 { font-size: var(--t-section); font-weight: var(--w-medium); letter-spacing: -0.01em; }
h3 { font-size: var(--t-title);   font-weight: var(--w-medium); letter-spacing: -0.01em; }
h4 { font-size: var(--t-title);   font-weight: var(--w-medium); }

/* Tabular figures for anything money/metric-y that opts in */
.num, .tabular { font-variant-numeric: tabular-nums; letter-spacing: -0.01em; }

/* ── Controls actually join the type system ──────────────────────────────
   This rule set the FAMILY and nothing else, and form controls do not inherit
   font-size from their parent — so every one of them fell back to the user
   agent's default. Measured across eight pages: 48 controls rendering at
   13.3333px, a size that appears nowhere on the scale, sitting beside 13px
   body text they were meant to match.

   It was invisible because 13.333 and 13 look the same in isolation, and
   because the family WAS being inherited, so nothing looked foreign. The
   mobile block further down even documents the intent — "Every form control
   in the app was 13px" — describing a value the stylesheet never set.

   Height too. Text controls measured 25, 26, 27, 30, 31, 32, 33, 34, 36, 38
   and 39px across the same eight pages, which is what a toolbar of five
   controls at four different heights is made of. One height, one radius.
   Buttons are deliberately NOT given a min-height here: the app has legitimate
   16px icon affordances and 30px section toggles, and a blanket rule would
   break both. */
:root { --control-h: 36px; }

button, input, select, textarea {
  font-family: var(--font);
  font-size: var(--t-body);
  font-weight: var(--w-regular);
}

/* The :not() set here MATCHES the coarse-pointer rule further down exactly,
   and that is load-bearing rather than tidiness.

   The first version excluded four types (adding range and file) where the
   touch rule excludes three. Four :not()s is specificity (0,4,1) against the
   touch rule's (0,3,1), so this desktop rule outranked the 44px minimum
   regardless of source order, and 54 controls across seven pages dropped
   below the touch target on a phone. verify:mobile caught it; nothing in the
   stylesheet reads as wrong.

   Equal specificity means source order decides, and the media block is later,
   so a coarse pointer still gets 44px. Keep these two selectors identical. */
input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
select {
  min-height: var(--control-h);
  border-radius: var(--radius-3);
}
button { cursor: pointer; transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease, transform .06s ease, filter .15s ease; }
button:active { transform: translateY(0.5px); }
a { transition: color .15s ease; }

/* Consistent, accessible focus ring (keyboard) without clobbering layouts */
:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
button:focus:not(:focus-visible), a:focus:not(:focus-visible) { outline: none; }

/* Refined focus for text controls (mouse + keyboard) */
input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--blue);
  box-shadow: 0 0 0 3px var(--blue-soft);
}

/* Selection */
::selection { background: rgba(79,127,232,0.18); }
::-moz-selection { background: rgba(79,127,232,0.18); }

/* Quiet, modern scrollbars */
* { scrollbar-width: thin; scrollbar-color: #D6D6CF transparent; }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #D6D6CF; border-radius: 999px; border: 3px solid transparent; background-clip: content-box; }
::-webkit-scrollbar-thumb:hover { background: #C2C2B9; background-clip: content-box; border: 3px solid transparent; }

/* Media niceties */
img { max-width: 100%; }
svg { display: inline-block; vertical-align: middle; }

/* Placeholder ink */
::placeholder { color: var(--ink-3); opacity: 1; }
::-webkit-input-placeholder { color: var(--ink-3); }

/* ── Micro-interactions ──────────────────────────────────────────────────
   Tasteful, global, and safe: an easing curve with a hint of spring for
   interactive elements, and a lift utility pages/components can opt into. */
:root { --ease: cubic-bezier(0.22, 0.61, 0.36, 1); --ease-out: cubic-bezier(0.16, 1, 0.3, 1); }
button, a, input, select, textarea, summary, [role="button"] {
  transition-timing-function: var(--ease);
}
/* Cards/tiles that opt in get a smooth hover lift */
.lift, .hover-lift { transition: transform .18s var(--ease-out), box-shadow .18s var(--ease-out), border-color .18s var(--ease-out); }
.lift:hover, .hover-lift:hover { transform: translateY(-2px); box-shadow: var(--shadow-lg); }

/* Respect users who prefer less motion */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; scroll-behavior: auto !important; }
}

/* ============================================================================
   Modals — keep tall forms inside the viewport
   ----------------------------------------------------------------------------
   The modal pattern (.modal-backdrop > .modal) centres a fixed-position panel
   that had no height limit and no scrolling. Once a form grew taller than the
   viewport — Admin > Add User, with seven offices and eight module toggles —
   it overflowed in BOTH directions at once, putting the heading off the top
   and the Save/Cancel buttons off the bottom with no way to scroll to either.

   Cap the panel to the viewport and let it scroll internally, then pin the
   heading and the action row so they stay reachable at any content height.
   Linked after each page's inline <style>, so this governs all of them.

   THE REST OF THE COMPONENT NOW LIVES HERE TOO.
   There were 31 modal overlays across eight pages, wearing FOUR class names
   for the backdrop (.modal-backdrop, .backdrop, .cfg-backdrop,
   .task-drawer-backdrop) and rendering THIRTEEN panel geometries — ten
   max-widths from 400px to 780px, two radii, two shadows. The scrim was the
   same warm rgba(16,15,14,0.32) with a 2px blur on 26 of the 31, so that is
   what everything gets; the two that were not (marketing at 0.45, and the
   sales dashboard at pure black with no blur) join them.

   CLOSED IS THE DEFAULT, AND THE PAGE SAYS WHEN IT IS OPEN.
   Five open/close protocols were in use — `.hidden` on admin and staff (which
   were visible by DEFAULT and hidden by class), `.visible` on documents,
   `.open` on email-blast, intercom and pm-eom, `.show` on marketing — and
   several pages used more than one. No shared rule can infer which protocol an
   element follows, so `display` stays with the page. What did change is the
   default: hidden here, and each page keeps exactly one selector saying when
   its modal is shown. The two pages that were open-by-default now say
   `.modal-backdrop:not(.hidden)`, which is the same behaviour with a safer
   failure mode — dropping the class used to stack all eight of admin's modals
   open on top of the page.

   shared/modal.js reads visibility from the computed style rather than from
   any of these class names, because that is the one signal that means the same
   thing under all five protocols.
   ========================================================================= */

/* .task-drawer-backdrop is excluded on purpose: it is the scrim behind a
   full-height right-hand drawer, and centring it or giving it 24px of padding
   would move the drawer off the edge it is anchored to. It still gets the
   accessibility treatment, which is name-agnostic. */
.modal-backdrop,
.backdrop,
.cfg-backdrop {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 24px;
  /* WARMER AND DEEPER THAN IT WAS (rgba(16,15,14,.32), blur 2px). The app now
     sits on a warm ground rather than a cool grey one, and a near-neutral
     scrim over peach reads as a grey film laid on top of the page instead of
     the page receding. Same hue family as the ground, further blur, so what is
     behind the panel is legibly still there and unmistakably not the subject. */
  background: rgba(26, 21, 17, 0.40);
  backdrop-filter: blur(6px) saturate(105%);
  -webkit-backdrop-filter: blur(6px) saturate(105%);
  /* Fallback for very short viewports, where even the capped panel won't fit. */
  overflow-y: auto;
}

.modal,
.cfg-modal {
  background: var(--surface);
  /* A LIT EDGE, NOT A DRAWN ONE — the same trade .hb-surface makes on the
     canvas. A grey hairline plus a flat shadow reads as a box sitting on the
     page; a white hairline with the lift in the shadow reads as a panel
     floating above it. The third layer is a hair of grey at the very edge, so
     the panel still has a boundary against a light background rather than
     dissolving into it. */
  border: 1px solid rgba(255, 255, 255, 0.9);
  border-radius: var(--radius);
  box-shadow:
    0 0 0 1px rgba(21, 23, 26, 0.04),
    0 1px 2px rgba(21, 23, 26, 0.06),
    0 28px 64px -28px rgba(21, 23, 26, 0.45);
  padding: 24px;
  width: 100%;
  max-width: 480px;
  max-height: calc(100vh - 48px);
  overflow-y: auto;
}

/* Four steps instead of ten arbitrary widths. A modal picks the one that fits
   its content: sm for a single field or a confirmation, the default for a
   short form, lg for a two-column form, xl for a table. */
.modal-sm { max-width: 400px; }
.modal-lg { max-width: 600px; }
.modal-xl { max-width: 780px; }

/* Edge to edge, for a panel whose content brings its own padding — the
   document viewer and the task drawer both fill themselves. */
.modal-flush { padding: 0; }

/* The heading, said once. Five weights were in use at this one size — 600,
   640, 650, 660 and 700 — plus marketing at 17px. 16px/700 is what thirteen of
   the thirty-one already rendered. */
.modal > h3,
.cfg-modal > h2 {
  font-size: var(--t-section);
  font-weight: var(--w-medium);
  letter-spacing: -0.01em;
  color: var(--ink);
}

/* The negative offsets cancel the panel's own 24px padding so the pinned rows
   sit flush against its edges instead of floating 24px inside them. */
.modal > h3 {
  position: sticky;
  top: -24px;
  z-index: 2;
  background: var(--surface);
  padding-top: 24px;
  margin-top: -24px;
  /* Hairlines make it read as pinned chrome rather than content that happens
     to be there, and mark where the form scrolls underneath. */
  border-bottom: 1px solid var(--border);
  padding-bottom: 12px;
}

/* ── One of these, shown as one of these ─────────────────────────────────────
   A radio group rendered as a segmented track: the choices sit in one pill,
   the selected one is a raised white chip, and there are no dots.

   WHY THE DOTS HAD TO GO. The nudge modal asked four questions in a row — send
   to, who called, send by, plus a tick — and `accent-color` painted every
   selected answer as a FILLED GREEN DISC WITH A WHITE TICK IN IT. At a glance
   that is the vocabulary of "done", not of "chosen": three completed steps and
   a form still asking. It was also the loudest colour in a modal whose actual
   loudest thing should be the send button, and it repeated four times.

   WHY A NEW NAME RATHER THAN `.seg`. `.seg` already exists on three pages and
   already means two different components: marketing and PM EOM Review use it
   for a strip of BUTTONS with exactly this pill treatment, while PM Lookup
   uses it for a group of radio LABELS. One class, two structures, and any
   shared rule written for either breaks the other. This is the radio flavour,
   named for what it is, so the two can stop colliding.

   THE INPUT STAYS A REAL RADIO. It is visually hidden, not replaced: keyboard
   arrows, the tab stop, `:checked`, `:disabled`, form serialisation and every
   screen reader keep working exactly as they did. Only the paint changes.
   ============================================================================ */
.hb-choice {
  display: inline-flex;
  align-items: stretch;
  gap: 2px;
  /* 2px of track + 1px of border on each side puts a 30px chip in a 36px
     control — `--control-h`, the same height as every input and select in the
     app. It was 3px/34px, which made a 42px pill: measured in the nudge
     modal's Template / Send-by row, that pushed the Send-by LABEL six pixels
     above the Template label beside it, so two headings on one row sat at two
     different heights. Their controls were already bottom-aligned by the grid,
     which is exactly why the mismatch showed up in the labels instead. */
  padding: 2px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 999px;
  /* A long group on a narrow screen wraps rather than pushing the panel wide. */
  flex-wrap: wrap;
  max-width: 100%;
}

.hb-choice label {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  /* 34px, not the 44px control height: this is a chip inside a track, and a
     44px chip inside a 3px-padded pill makes a 50px-tall control for a choice
     between two words. The whole label is the target, so the hit area is the
     chip's full width — comfortably past the 24px CSS-pixel minimum for a
     control that sits inside a form somebody is already reading. */
  min-height: 30px;
  padding: 0 var(--space-4);
  border-radius: 999px;
  font-size: var(--t-body);
  color: var(--ink-2);
  cursor: pointer;
  white-space: nowrap;
  transition: background .12s ease, color .12s ease, box-shadow .12s ease;
}

/* Hidden from sight, not from the accessibility tree or the tab order. */
.hb-choice input[type="radio"],
.hb-choice input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

.hb-choice label:hover { color: var(--ink); }

/* RAISED, ON THE PAGE'S OWN SURFACE. The same treatment marketing's button
   version already uses, so the two components look like one idea even while
   they remain two implementations. */
.hb-choice label:has(> input:checked) {
  background: var(--surface);
  color: var(--ink);
  font-weight: var(--w-medium);
  box-shadow: 0 1px 2px rgba(25, 25, 23, 0.12);
}

/* The focus ring belongs to the chip, because the input it belongs to is a
   pixel wide and clipped. Without this a keyboard user tabbing into the group
   sees nothing at all. */
.hb-choice label:has(> input:focus-visible) {
  outline: 2px solid var(--blue);
  outline-offset: 2px;
}

/* A choice that cannot be made. Dimmed and LEFT IN PLACE: removing it would
   reflow the row under the pointer as somebody switches, and hiding a channel
   silently is how "why can I not text them" becomes a support question. */
.hb-choice label:has(> input:disabled) {
  opacity: .45;
  cursor: not-allowed;
  background: transparent;
  box-shadow: none;
}

/* A FINGER NEEDS MORE THAN A POINTER DOES. 30px is right beside a 36px input
   on a desktop; on a touch screen the whole control grows to 44, which is what
   verify-mobile enforces for everything else somebody taps. */
@media (pointer: coarse) {
  .hb-choice label { min-height: 38px; }
}

@media (max-width: 560px) {
  /* Full width on a phone, so two chips split the row instead of huddling in
     the left third of it. */
  .hb-choice { display: flex; width: 100%; }
  .hb-choice label { flex: 1 1 auto; justify-content: center; padding: 0 var(--space-3); }
}

/* ── The way out of a modal, said quietly ────────────────────────────────────
   A modal's close control was a full `.btn` — an outlined pill reading "Close"
   in the top-right corner, at the same visual weight as the panel's own title
   and, on every modal that also has a Cancel button, the second of two
   controls that do the same thing. Two equally loud ways to leave, and the
   loudest thing in the header is the exit.

   An icon at the size of a tap target, ink only until it is pointed at. The
   word goes to `aria-label`, so it is still named for anybody who needs the
   name — what changes is that it stops competing with the heading for the
   first thing you read.

   OPT-IN BY CLASS, because the markup is per-page: a modal adopts it by
   swapping `class="btn"` for `class="modal-x"` on the button it already has.
   ============================================================================ */
.modal-x {
  flex-shrink: 0;
  width: 34px;
  height: 34px;
  display: inline-grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-3);
  cursor: pointer;
  transition: background .12s ease, color .12s ease;
}
.modal-x:hover { background: var(--surface-2); color: var(--ink); }
.modal-x:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
/* Drawn rather than typed: a multiplication sign, an ex and a times character
   are three different glyphs at three different weights depending on the face
   that resolves, and one of them is a letter. */
.modal-x::before {
  content: "";
  width: 15px;
  height: 15px;
  background: currentColor;
  -webkit-mask: var(--hb-x) center / contain no-repeat;
  mask: var(--hb-x) center / contain no-repeat;
}
:root {
  --hb-x: 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.2' stroke-linecap='round'%3E%3Cpath d='M18 6 6 18M6 6l12 12'/%3E%3C/svg%3E");
}

/* ── What is stopping the send, beside the thing it is stopping ──────────────
   A readiness line — "Nearly there. Fill in what the call was about." — was a
   full-width bordered box of its own, in red, immediately above the action
   row. Three problems in one element: it took a whole row to say one short
   thing, red made guidance read as an error, and on a phone it sat off the
   bottom of the scroll along with the button it was about.

   It belongs in the footer, on the same line as the action, aligned left
   against the buttons on the right — so the reason you cannot press the button
   is in the same glance as the button. Calm ink, not red: nothing has gone
   wrong yet. */
.modal-actions .modal-why {
  margin-right: auto;
  text-align: left;
  font-size: var(--t-small);
  line-height: 1.45;
  color: var(--ink-2);
  /* It shares a sticky row with the buttons, so it must never push them off
     the end of it. */
  min-width: 0;
}
.modal-actions .modal-why b,
.modal-actions .modal-why strong { font-weight: var(--w-medium); color: var(--ink); }

/* Brand mark, bottom-right of the modal.
   It lives on the action row rather than the panel, because .modal scrolls its
   own content — a mark on the panel would slide out of view on a tall form,
   while the action row is already sticky and stays put.
   Behind the buttons (z-index 0 against their 1) so it can never collide with
   them, which is what lets it sit in the same corner as Close. */
.modal > .modal-actions::after {
  content: "";
  position: absolute;
  right: 0;
  bottom: 20px;
  width: 26px;
  height: 26px;
  z-index: 0;
  pointer-events: none;
  opacity: 0.30;
  background-image: var(--hb-mark, url("/img/hank-icon.svg"));
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}

.modal > .modal-actions > * { position: relative; z-index: 1; }

.modal > .modal-actions {
  /* Right-aligned here rather than left to each page. Several modals inherit a
     generic flex rule and end up centring their buttons; the footer is the one
     place the action must be predictable. Also the positioning context for the
     brand mark above. */
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 9px;
  /* 16px was the majority of the four values in use (16, 18, 20 and none). */
  margin-top: 16px;
  position: sticky;
  bottom: -24px;
  z-index: 2;
  background: var(--surface);
  padding-bottom: 24px;
  margin-bottom: -24px;
  border-top: 1px solid var(--border);
  padding-top: 14px;
}

@media (max-width: 560px) {
  .modal-backdrop { padding: 12px; }
  .modal { max-height: calc(100vh - 24px); }
}

/* ============================================================================
   Brand & AI marks
   ----------------------------------------------------------------------------
   One brand lockup for every page header. The wordmark is live text rather
   than an image so it renders in Inter (matching the brand face), scales
   crisply, and inverts correctly in dark mode. When a logo has been uploaded
   via Admin > Branding, harris.js swaps that image in instead — so uploading
   the real asset once updates every page, with the text as the fallback.
   ========================================================================= */

/* ── Icon links in a page header ─────────────────────────────────────────────
   Destinations recognisable from their icon alone — Documents, Admin — which
   appear on nearly every page, where the words were spending width to say
   something the icon says faster.

   Icon-only is only safe when the control has an accessible name, so every use
   carries an aria-label, with `title` for the desktop tooltip on top of it.
   That is deliberately not a licence to do the same to row actions like
   Delete: a tooltip that never fires on touch is a poor sole label for a
   destructive control, which is a different proposition to a nav link.

   36px square, growing to 44px on a touch pointer. Note the direction —
   several pages currently make header controls *smaller* below 480px, down to
   around 22px, which is backwards. A finger needs more room than a cursor. */
.hb-icon-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  flex-shrink: 0;
  border-radius: var(--radius-3);
  color: var(--ink-2);
  text-decoration: none;
  background: none;
  border: none;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease;
}
.hb-icon-link svg { width: 18px; height: 18px; display: block; }
.hb-icon-link:hover { background: var(--surface-2); color: var(--ink); }
.hb-icon-link:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }

@media (pointer: coarse) {
  .hb-icon-link { width: 44px; height: 44px; }
  .hb-icon-link svg { width: 20px; height: 20px; }
}

/* ── Touch targets ──
   Measured on a phone, 231 of the app's controls rendered smaller than 44px in
   one direction or both — the shared button at 48x24, the dashboard nav links
   at 43x30, the theme toggle two pixels short of the line on all nineteen
   pages. 44px is the figure both platform guidelines land on and it is roughly
   the pad of an adult finger; below it, hitting the right thing is luck.

   Scoped to coarse pointers, so a mouse-driven desktop keeps its density —
   this is about what a finger can hit, not about making everything bigger.

   A checkbox is the one exclusion: it stays the size a checkbox is, because it
   is hit by its <label>, which is already wider than a thumb. */
@media (pointer: coarse) {
  button,
  select,
  textarea,
  input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]) {
    min-height: 44px;
  }

  /* Width too. A row of 28px icon buttons — rename, move, delete on the
     documents page — is three targets inside the width of one finger. */
  button { min-width: 44px; }

  /* The label is the checkbox's target, so the label is what has to be big
     enough. Sixteen pixels tall is a line of text, not a control. */
  label:has(> input[type="checkbox"]),
  label:has(> input[type="radio"]) { min-height: 44px; }

  /* Buttons hold centred text, so the extra height has to go above and below
     it rather than piling up at the top.

     Not .hb-unbutton, though: those are whole cards and table rows that became
     buttons for accessibility, and centring their contents would undo the
     layout they were converted from. They keep the min-height — a converted
     card is taller than 44px anyway, so it costs nothing — but not the flex.
     The first version of this rule excluded them from both, on the assumption
     that anything wearing the class was large. Intercom's department dropdown
     wears it and is 36px tall. */
  button:not(.hb-unbutton) {
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /* Links that behave as controls — navigation, tabs, the brand going home,
     and the plain <a>s that top bars use as buttons. Links inside prose are
     deliberately left alone: a 44px-tall sentence is not an improvement. */
  .hb-brand,
  .nav-link,
  .sidebar-item,
  .header-link,
  .tb-link,
  a.btn,
  .back-link,
  .viewbar a,
  .topbar a,
  .page-header a,
  .page-topbar a,
  .header a {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  /* If it has a click handler it is a target, whatever element it happens to
     be. On a large container this does nothing; on the documents page's file
     name — a 32px div that opens the file — it is the difference between a
     tap and a near miss. */
  [onclick] { min-height: 44px; }

  /* A link or a drill-down inside a table cell. Nine of these on the finance
     page rendered 16px tall — a line of text with a click handler on it. They
     were invisible to every earlier census because the page had no offices to
     draw rows for until the vendor stub existed. */
  td a,
  td [data-drill],
  .cell-link {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  /* Width matters as much as height, and these are the ones a finger meets
     edge-on: icon-only navigation, the collapsed brand, the theme toggle. */
  /* a.hb-brand, not .hb-brand: the lockup's belt-and-braces rule further down
     this file sets min-width:0, and at equal specificity the later rule wins.
     One element name is the cheapest way to outrank it from up here. */
  a.hb-brand,
  .nav-link,
  .sidebar-item,
  .sidebar-toggle { min-width: 44px; justify-content: center; }

  /* 16px is not a design decision, it is a platform threshold: Safari on iOS
     zooms the whole page when you focus a field whose text is smaller, and the
     reader then has to pinch their way back out. Every form control in the app
     was 13px. This is the one number in the app deliberately off the scale,
     and __tests__/type-scale.test.js names it as such. */
  input, select, textarea { font-size: 16px; }

  /* A 56px rail with 12px of padding leaves 32px, so a 44px item would push
     out of it. Six each side leaves exactly 44. */
  .sidebar-nav { padding-left: 6px; padding-right: 6px; }
  .sidebar .sidebar-item { justify-content: flex-start; }
}

.hb-brand {
  /* One knob for the whole lockup. Set it on a page (or on the .hb-brand
     itself) to tune a header that is tighter or looser than the norm.

     It is deliberately generous, because an <img> box is not the same as the
     mark inside it: the Hank lockup's artwork occupies roughly two thirds of
     its own viewBox height, so a 22px box was drawing a ~15px mark and reading
     far smaller than the space around it. Sizing to the visible mark rather
     than to the file is what makes it sit in the header properly. */
  --hb-logo-h: 42px;

  display: inline-flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  color: var(--ink);
  flex-shrink: 0;
}

.hb-brand:hover { opacity: 0.75; }

.hb-brand .hb-wordmark {
  font-family: var(--font);
  font-weight: var(--w-bold);
  /* Tracks the logo height so the text fallback and the real mark read at the
     same weight in the bar, rather than the fallback looking like a caption. */
  font-size: calc(var(--hb-logo-h) * 0.5);
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--ink);
  white-space: nowrap;
}

/* The uploaded logo, when one exists. */
.hb-brand img.hb-logo {
  height: var(--hb-logo-h);
  width: auto;
  max-width: 220px;
  object-fit: contain;
  display: block;
}

/* Short headers cannot hold the full-size lockup, so step it down rather than
   letting it push the bar out of shape on a laptop or a phone. */
@media (max-width: 900px) {
  .hb-brand { --hb-logo-h: 34px; }
}

/* Dark mode. The Hank marks are solid black on transparent, so on the dark
   theme they would be a black shape on a near-black bar — invisible.
   An <img> is opaque to CSS (we cannot restyle paths inside it), so the
   lightness has to be flipped with a filter.

   invert(1) alone would turn a *coloured* logo into its photographic negative;
   pairing it with hue-rotate(180deg) puts the hues back where they started, so
   the net effect is "flip light and dark, keep the colours". On a pure black
   mark that is simply white, and on a colour mark it degrades sensibly rather
   than going lurid. Alpha is untouched, so transparent stays transparent. */
/* The icon slot — for headers with no room for the wordmark. Square-capped so
   a logo standing in for a missing icon cannot stretch the header, and the
   text fallback is a single initial in a rounded tile rather than a lone
   floating letter. */
.hb-brand img.hb-logo-icon {
  height: calc(var(--hb-logo-h) * 0.72);
  width: calc(var(--hb-logo-h) * 0.72);
  object-fit: contain;
}

/* Responsive pair: the full lockup on anything with room, the icon on a phone.
   Both are in the DOM when an icon exists, so the swap is a media query rather
   than a resize listener re-rendering the header. */
.hb-brand img.hb-logo-compact { display: none; }

@media (max-width: 560px) {
  /* Only swap when the compact mark is actually present — a full logo with no
     icon uploaded must stay visible rather than hide into nothing. */
  .hb-brand:has(img.hb-logo-compact) img.hb-logo-full { display: none; }
  .hb-brand img.hb-logo-compact { display: block; }
}

/* The sidebar pages collapse their rail to 56px at 768px, not at the 560px the
   header swap uses. That 200px band left a 127px lockup inside a 56px rail —
   the swap has to follow the container that actually collapsed, not a global
   viewport width. */
@media (max-width: 768px) {
  .sidebar .hb-brand { --hb-logo-h: 34px; }
  .sidebar .hb-brand:has(img.hb-logo-compact) img.hb-logo-full { display: none; }
  .sidebar .hb-brand img.hb-logo-compact { display: block; }
}

/* Belt and braces. Every swap above is a breakpoint someone can get wrong
   again; this makes the failure a squeezed mark rather than one spilling
   across the page, wherever the lockup ends up. */
.hb-brand { max-width: 100%; min-width: 0; }
.hb-brand img.hb-logo { max-width: 100%; }

.hb-brand .hb-wordmark-short {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: calc(var(--hb-logo-h) * 0.72);
  height: calc(var(--hb-logo-h) * 0.72);
  border-radius: 8px;
  background: var(--ink);
  color: var(--bg);
  font-size: var(--t-title);
  font-weight: var(--w-bold);
  letter-spacing: 0;
}

/* The text lockup's compact form, and it has to be declared AFTER
   .hb-wordmark-short: the compact mark carries both classes, and at equal
   specificity the later rule wins. Written above that block instead, it lost,
   and every desktop header rendered HANK followed by a black H. */
.hb-brand .hb-wordmark-compact { display: none; }

/* Never shown once a real mark has been uploaded — an uploaded logo, full or
   icon, is always a better brand than a letter in a box. */
@media (max-width: 560px) {
  .hb-brand:not(:has(img.hb-logo)) .hb-wordmark:not(.hb-wordmark-compact) { display: none; }
  .hb-brand:not(:has(img.hb-logo)) .hb-wordmark-compact { display: inline-flex; }
}

/* Follows the rail, not the viewport — same reasoning as the logo swap above:
   the sidebar collapses to 56px at 768px, and that is the container the mark
   actually has to fit inside. */
@media (max-width: 768px) {
  .sidebar .hb-brand:not(:has(img.hb-logo)) .hb-wordmark:not(.hb-wordmark-compact) { display: none; }
  .sidebar .hb-brand:not(:has(img.hb-logo)) .hb-wordmark-compact { display: inline-flex; }
}

/* Divider between the brand and a page title sitting beside it. */
.hb-brand + .hb-brand-sep {
  width: 1px;
  height: 18px;
  background: var(--border-strong);
  flex-shrink: 0;
}

/* ── AI sparkle ──
   currentColor throughout, so it takes the AI purple from whatever it sits in
   rather than hard-coding a fill that dark mode would have to fight. */
.ai-mark {
  display: inline-block;
  width: 1em;
  height: 1em;
  color: var(--ai);
  vertical-align: -0.125em;
  flex-shrink: 0;
}

.ai-mark svg { width: 100%; height: 100%; display: block; }

/* Small "AI" pill for labelling generated content or AI-backed actions. */
.ai-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 9px 3px 7px;
  border-radius: 999px;
  background: var(--ai-soft);
  border: 1px solid var(--ai-border);
  color: var(--ai-strong);
  font-size: var(--t-small);
  font-weight: var(--w-medium);
  letter-spacing: 0.02em;
  line-height: 1.5;
}

.ai-badge .ai-mark { width: 12px; height: 12px; color: var(--ai); }

/* ── Hank ──
   Hank is who the assistant IS, not a label for a feature. So the panel is
   signed with a name and a mark, the way a person's contribution would be —
   never "AI Summary", which describes a mechanism and belongs to nobody.

   Shared rather than per-page because that identity has to be the same
   everywhere it appears. A module inventing its own purple, or its own
   four-pointed star, makes Hank read as several different things.

   Built from the --ai tokens throughout, so both themes come free and a change
   to the brand purple moves every module at once. */
.hank-panel {
  background: var(--ai-soft);
  border: 1px solid var(--ai-border);
  border-radius: var(--radius);
  padding: 18px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.hank-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
}

/* The signature: mark and name locked together, so they read as one thing. */
.hank-sig {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  letter-spacing: -0.01em;
  color: var(--ai-strong);
  line-height: 1.2;
}

.hank-sig .ai-mark { width: 18px; height: 18px; color: var(--ai); }

/* What Hank is doing here — a line of context, not a second heading. */
.hank-sub {
  font-size: var(--t-body);
  color: var(--ink-2);
  line-height: 1.5;
  margin-top: 3px;
}

.btn-hank {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  background: var(--ai-strong);
  color: #fff;
  border: 1px solid var(--ai-strong);
  border-radius: 999px;
  padding: 9px 16px;
  font-size: var(--t-body);
  font-weight: var(--w-medium);
  font-family: inherit;
  cursor: pointer;
  white-space: nowrap;
  transition: filter 0.15s ease, opacity 0.15s ease;
}

.btn-hank:hover:not(:disabled) { filter: brightness(1.1); }
.btn-hank:disabled { opacity: 0.62; cursor: default; }
.btn-hank .ai-mark { width: 14px; height: 14px; color: currentColor; }
.btn-hank:focus-visible { outline: 2px solid var(--ai-strong); outline-offset: 2px; }

/* The state before Hank has been asked anything. Addressed to the reader, and
   specific about what will come back — a vague invitation gets ignored. */
.hank-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  text-align: center;
  padding: 22px 16px;
  color: var(--ink-2);
  font-size: var(--t-body);
  line-height: 1.6;
}

.hank-empty .ai-mark { width: 30px; height: 30px; color: var(--ai); opacity: 0.85; }
.hank-empty p { max-width: 46ch; }

/* Generated prose, sitting on the panel's own tint. */
.hank-body { font-size: var(--t-title); line-height: 1.65; color: var(--ink); }
.hank-body > * + * { margin-top: 10px; }

/* ============================================================================
   Charts — house style
   ----------------------------------------------------------------------------
   Companion to harris-charts.js. Covers the chrome that lives in the DOM
   rather than on the canvas: the callout tooltip, the caption strip under a
   chart, and a legend whose swatches show the actual fill textures so the key
   matches what is drawn.
   ========================================================================= */

/* Positioning context for the tooltip, which is absolutely placed against the
   canvas wrapper. harris-charts.js also promotes the wrapper at runtime, so an
   unlisted class still works; these are here so the layout is correct on first
   paint rather than after the first hover. */
.chart-body, .chart-box, .chart-wrap, .chart-container, .kpi-spark { position: relative; }

.hc-tooltip {
  position: absolute;
  pointer-events: none;
  z-index: 20;
  min-width: 150px;
  padding: 10px 12px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-lg);
  opacity: 0;
  transition: opacity 0.12s ease;
}

.hc-tt-title {
  font-size: var(--t-small);
  font-weight: var(--w-medium);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 7px;
}

.hc-tt-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 18px;
  font-size: var(--t-small);
  line-height: 1.9;
}

.hc-tt-key {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  color: var(--text-secondary);
  white-space: nowrap;
}

.hc-tt-key i {
  width: 9px;
  height: 9px;
  border-radius: 2px;
  flex-shrink: 0;
}

/* Tabular figures so values stay column-aligned row to row. */
.hc-tt-val {
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-medium);
  color: var(--ink);
  white-space: nowrap;
}

/* Caption strip beneath a chart — the one-line takeaway, set in mono caps. */
.chart-caption {
  display: block;
  margin-top: 10px;
  padding: 9px 14px;
  border-radius: var(--radius-3);
  background: var(--green-soft);
  color: var(--green);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: var(--t-small);
  font-weight: var(--w-medium);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* Legend whose swatches carry the same textures the chart draws, so the key is
   readable without relying on hue. */
.chart-legend {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 22px;
  margin-top: 12px;
  font-size: var(--t-small);
  color: var(--text-secondary);
}

.chart-legend span { display: inline-flex; align-items: center; gap: 8px; }

.chart-legend i {
  width: 14px;
  height: 14px;
  border-radius: 3px;
  border: 1px solid var(--border-strong);
  flex-shrink: 0;
}

.chart-legend i.solid { background: var(--green); border-color: var(--green); }

.chart-legend i.dots {
  background-color: var(--surface);
  background-image: radial-gradient(currentColor 1px, transparent 1px);
  background-size: 4px 4px;
  color: var(--text-muted);
}

.chart-legend i.hatch {
  background-color: var(--surface);
  background-image: repeating-linear-gradient(90deg, currentColor 0 1px, transparent 1px 4px);
  color: var(--text-muted);
}

/* ============================================================================
   Radio buttons, and the checkboxes that opt into looking like them
   ----------------------------------------------------------------------------
   A filled circle with a white tick, rather than the browser's blue dot.

   `.tick-circle input[type="checkbox"]` OPTS A CHECKBOX INTO THE SAME LOOK, and
   it is deliberately opt-in rather than applied to every checkbox in the app.
   A square means "any number of these" and a circle means "one of these", and
   that distinction is worth keeping where a form has both. But it is not worth
   keeping inside ONE group of controls that answer one question: the nudge
   modal asks "Send to" with radios and "About" with tick boxes, stacked, and
   `accent-color` gave the second row a green SQUARE beside the first row's
   green circle — two shapes, two shades of green, for what reads as one set of
   choices. Reported as the rows not matching, which is exactly what it was.

   Opting in keeps the multi-select behaviour: these are still checkboxes, both
   can be ticked, and only the paint changes.

   THE COLOUR IS NOT --green, AND THE REASON IS MEASURED. The brand green
   (#0FA968) puts a white tick at 3.05:1 and the filled circle at 3.05:1
   against a white surface — a graphical object needs 3:1, so both sit exactly
   on the line, and a 2px tick stroke on the line is a legibility problem
   rather than a technicality. #0E9A5F is the same hue two steps down: tick
   3.62:1, circle 3.34:1 against the page, both with margin.

   Restyled here rather than per page so the app has one radio. There were
   three on two pages using the platform default, which is a different blue on
   every operating system and matches nothing else in Hank.

   `appearance: none` REMOVES the control entirely, including its focus ring,
   so :focus-visible has to put one back — without it a keyboard user loses the
   only indication of where they are. */
:root { --radio-on: #0E9A5F; }

input[type="radio"],
.tick-circle input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  margin: 0;
  border: 1.5px solid var(--border-strong, #D2D6DE);
  border-radius: 50%;
  background: var(--surface, #fff);
  cursor: pointer;
  transition: background-color 0.12s ease, border-color 0.12s ease;
}

input[type="radio"]:hover:not(:disabled),
.tick-circle input[type="checkbox"]:hover:not(:disabled) {
  border-color: var(--ink-3, #666C78);
}

/* The tick is a background image rather than a pseudo-element: ::after on a
   replaced element is well supported now but not universally, and a radio that
   fills with green and shows nothing inside reads as a bug. */
input[type="radio"]:checked,
.tick-circle input[type="checkbox"]:checked {
  background-color: var(--radio-on);
  border-color: var(--radio-on);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round' d='M4 8.4l2.6 2.6L12 5.6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: center;
  background-size: 13px 13px;
}

input[type="radio"]:focus-visible,
.tick-circle input[type="checkbox"]:focus-visible {
  outline: 2px solid var(--blue, #4F7FE8);
  outline-offset: 2px;
}

input[type="radio"]:disabled,
.tick-circle input[type="checkbox"]:disabled { opacity: 0.5; cursor: not-allowed; }

/* The label beside it, so a row of options reads as one line rather than as a
   control with text near it. */
label:has(> input[type="radio"]),
.tick-circle label:has(> input[type="checkbox"]) {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}

/* ============================================================================
   Loading
   ----------------------------------------------------------------------------
   One loading language for the whole platform, in three sizes:

     .hb-loading    a full-cover scrim for a blocking wait — a page booting, a
                    save that must finish before anything else makes sense.
     .hb-spinner    an inline ring for a button or a field.
     .hb-skeleton   a shimmering placeholder for content whose shape is already
                    known, which beats a spinner because the page stops jumping
                    when the data lands.

   Driven by HankLoading in harris.js; no page needs its own markup.
   ========================================================================= */
.hb-loading {
  position: fixed;
  inset: 0;
  z-index: 2000;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 20px;
  padding: 24px;
  text-align: center;
  background: var(--hb-scrim);
  /* The blur is what keeps the page readable-but-inert behind the scrim, so
     the wait reads as "this screen is busy" rather than "this screen is gone". */
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.18s ease, visibility 0s linear 0.18s;
}

.hb-loading.is-on {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.18s ease, visibility 0s;
}

.hb-loading-ring {
  width: 62px;
  height: 62px;
  border-radius: 50%;
  border: 3px solid rgba(27, 22, 24, 0.22);
  border-top-color: var(--hb-scrim-ink);
  animation: hb-spin 0.9s linear infinite;
}

/* The title line makes Hank the subject: "[Hank] is loading your dashboard".
   The name is the uploaded wordmark where one exists, so the brand does the
   talking rather than a label describing it. A flex row rather than an inline
   <img> so the mark's optical baseline can be nudged without dragging the
   text with it, and so it wraps as two lines on a phone instead of
   overflowing. */
.hb-loading-title {
  display: flex;
  /* Centred, not baseline-aligned. An image has no baseline of its own, so the
     browser uses its bottom edge — which lands wherever the uploaded file's
     internal whitespace happens to put it. Centring is the only alignment that
     holds for an asset whose padding we do not control. */
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: 0 12px;
  color: var(--hb-scrim-ink);
  font-family: var(--font, 'Inter', system-ui, sans-serif);
  font-size: var(--t-hero);
  font-weight: var(--w-medium);
  letter-spacing: -0.02em;
  line-height: 1.25;
  margin: 0;
}

.hb-loading-name {
  display: inline-flex;
  align-items: center;
}

/* Showing only the name out of the full lockup. The image keeps its natural
   width and the box hides the rest, so the glyphs are never squashed — an
   object-fit crop would distort them. */
.hb-loading-name.hb-name-cropped {
  overflow: hidden;
  justify-content: flex-start;
}

.hb-loading-name.hb-name-cropped img {
  max-width: none;
  flex-shrink: 0;
}

.hb-loading-name img {
  height: 42px;
  width: auto;
  max-width: 190px;
  object-fit: contain;
  display: block;
  /* The marks are black artwork; the scrim is light, so unlike the header
     these need no inversion. */
}

/* The bob is the whole "someone is doing this" gesture — small enough to read
   as breathing rather than bouncing. */
.hb-loading-name { animation: hb-bob 2.4s ease-in-out infinite; }

@keyframes hb-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-0.16em); }
}

/* Trailing dots, so the sentence is visibly still being spoken. */
.hb-loading-dots i {
  display: inline-block;
  animation: hb-dot 1.4s ease-in-out infinite;
  opacity: 0.25;
}
.hb-loading-dots i:nth-child(2) { animation-delay: 0.18s; }
.hb-loading-dots i:nth-child(3) { animation-delay: 0.36s; }

@keyframes hb-dot {
  0%, 60%, 100% { opacity: 0.25; }
  30% { opacity: 1; }
}

.hb-loading-sub {
  color: rgba(27, 22, 24, 0.72);
  font-family: var(--font, 'Inter', system-ui, sans-serif);
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  line-height: 1.5;
  margin: -12px 0 0;
  max-width: 44ch;
}

/* Step list. Rows are left-aligned inside a block that is itself centred, so
   the ticks line up in a column instead of ragging with the label lengths.

   Everything here is scoped inside .hb-loading and set explicitly rather than
   inherited: the markup is produced by page code (dashboard.js builds
   .prog-item rows), and inheriting the page's colours onto the scrim is what
   made these unreadable in the first place. */
.hb-loading-steps {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 9px;
  margin-top: 4px;
  max-height: 46vh;
  overflow-y: auto;
}

.hb-loading .prog-item {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font, 'Inter', system-ui, sans-serif);
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  line-height: 1.4;
  color: rgba(27, 22, 24, 0.82);
  white-space: nowrap;
}

.hb-loading .prog-icon {
  width: 17px;
  height: 17px;
  flex-shrink: 0;
}

/* Done and failed are the two states worth colouring, and both are darker than
   the palette's green and red. An icon is a non-text UI component, so it needs
   3:1 against its background; the normal tokens measure ~2.4:1 on this scrim
   and these measure ~3.5:1 and ~3.9:1. */
.hb-loading .prog-icon-done { color: #05603A; }
.hb-loading .prog-icon-fail { color: #93151A; }

/* The pending marker is a div, not an SVG — give it the ring treatment. */
.hb-loading .prog-icon-loading {
  border: 2px solid rgba(27, 22, 24, 0.25);
  border-top-color: rgba(27, 22, 24, 0.7);
  border-radius: 50%;
  animation: hb-spin 0.8s linear infinite;
}

/* A finished step steps back so the eye lands on what is still running. */
.hb-loading .prog-item:has(.prog-icon-done) { color: rgba(27, 22, 24, 0.55); }

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

/* Inline ring, for a button or beside a field. Takes currentColor so it works
   on a dark button and a light one without a variant. */
.hb-spinner {
  display: inline-block;
  width: 1em;
  height: 1em;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: hb-spin 0.7s linear infinite;
  vertical-align: -0.125em;
  flex-shrink: 0;
}

.hb-spinner.lg { width: 22px; height: 22px; border-width: 2.5px; }

/* Placeholder blocks. The sweep runs across the element rather than pulsing
   its opacity, so a column of them reads as one surface loading instead of a
   row of independently blinking boxes. */
.hb-skeleton {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-3, 8px);
  background: var(--surface-2);
  color: transparent;
  user-select: none;
}

.hb-skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.45), transparent);
  animation: hb-sweep 1.3s ease-in-out infinite;
}

.hb-skeleton.line { height: 12px; margin: 8px 0; }
.hb-skeleton.line.short { width: 45%; }
.hb-skeleton.block { height: 120px; }

@keyframes hb-sweep { to { transform: translateX(100%); } }

/* ---------------------------------------------------------------------------
   .hb-working — the fourth member of the family
   ---------------------------------------------------------------------------
   The other three cover: the whole screen is blocked (.hb-loading), a control
   is busy (.hb-spinner), and the shape of what is coming is already known
   (.hb-skeleton). Nothing covered the commonest case in Hank — ONE REGION of
   the page is doing real work and the shape of the answer is not yet known.
   A search, a report being assembled, a month being reconciled.

   Reached for a spinner, every page put a 16px ring somewhere near the control
   and called it done. On PM lookup that ring sat in the search field at the
   top-left of a 1360px-wide bar, roughly 1200px from the results the person
   was actually watching, and said nothing about what was being searched.

   WHAT IT IS: A SENTENCE AND A BAR
   -------------------------------
   The sentence is the component. "Searching 4,812 properties across 6 offices"
   is a specific claim about work in progress, and it is the thing a spinner
   can never say. The bar underneath is an indeterminate progress line — no
   percentage, because there is no percentage to report, and a fake one is a
   lie that people learn to distrust.

   IT USED TO BE MORE THAN THIS, and less good for it. The first build put a
   flowing stack of tinted record cards above the caption — a nice idea about
   depicting the book being searched, and in place it read as a widget
   competing with the sentence for attention. A wait is not a moment that
   deserves a hero illustration; it is a moment that should say what it is
   doing and get out of the way. The cards went; the sentence got bigger.

   WHY IT IS ALL transform
   -----------------------
   The runner moves and stretches; nothing changes width, left or margin.
   Transform is composited off the main thread, and a wait animation that
   itself causes layout is a wait animation that makes the wait longer — this
   one runs precisely while the page is busy parsing a response.

   THE TIMING IS THE DESIGN
   ------------------------
   See HankLoading.region() in harris.js: this is armed on a delay and held for
   a floor once painted. A cache-warm lookup answers in about 15ms, and an
   animation that appears for 15ms is not an animation, it is a flash. The CSS
   here has no opinion about when it shows; that is deliberate and lives in one
   place rather than in every caller.
   ------------------------------------------------------------------------ */
.hb-working {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-4, 16px);
  /* Enough that the sentence lands near the optical centre of a first screen
     of results rather than clinging to the top of an empty box — and no more.
     With the card stack gone there is much less to hold up, and the 300px the
     stack needed left a bottom-heavy void. */
  min-height: 190px;
  padding: var(--space-6, 32px) var(--space-4, 16px);
  text-align: center;
}

/* The caption is the component now, so it carries the weight the stack used to.
   Callers put the real numbers in <b> — see workingCaption() in pm-lookup. */
.hb-working-cap {
  margin: 0;
  max-width: 44ch;
  font-size: var(--t-section, 18px);
  font-weight: var(--w-medium, 600);
  letter-spacing: -0.01em;
  line-height: 1.45;
  color: var(--ink-2, #565C66);
}

.hb-working-cap b { color: var(--ink, #15171A); font-weight: var(--w-medium, 600); }

/* The track. Fixed width rather than fluid: a rule that stretches to the
   content width of a 1360px page stops reading as a progress bar and starts
   reading as a horizontal rule under a heading. */
.hb-working-bar {
  position: relative;
  width: 100%;
  max-width: 260px;
  height: 3px;
  border-radius: 2px;
  background: var(--border, #E4E7EC);
  overflow: hidden;
}

/* TWO runners, half a cycle apart, and the second one is not decoration.

   With one, the track is empty at the wrap: at 0% the runner spans -38%..-2%
   and at 100% it spans 100%..136%, both entirely outside. Screenshotting the
   cycle at six fixed phases caught it — t=0 was a plain grey line under the
   sentence, which on a real search is a bar that dies for a moment every one
   and a half seconds and reads as the page having given up. Offsetting a
   second runner by half the cycle means one is always in the track.

   Both are positioned and sized entirely by transform: translateX moves in
   percentages of the TRACK, scaleX sets how much of the track is covered, and
   the two compose because scale is applied about the left edge first. */
.hb-working-bar::before,
.hb-working-bar::after {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  border-radius: 2px;
  transform-origin: 0 50%;
  background: var(--ink, #15171A);
  animation: hb-working-run 1.5s cubic-bezier(0.62, 0.02, 0.34, 1) infinite;
}

/* Negative, so it is already mid-track on the first frame rather than
   arriving 750ms in. */
.hb-working-bar::before { animation-delay: -0.75s; }

/* Narrow entering, longer through the middle, narrow leaving. The stretch is
   what stops an indeterminate bar reading as a single block sliding past on a
   loop — it gives the travel a beginning and an end without claiming to know
   how much work is left. Both ends of the cycle sit fully outside the track,
   so the runner enters and exits rather than popping. */
@keyframes hb-working-run {
  0%   { transform: translateX(-38%) scaleX(0.36); }
  50%  { transform: translateX(28%)  scaleX(0.48); }
  100% { transform: translateX(100%) scaleX(0.36); }
}

/* Motion is decoration here; the text still says what is happening. */
@media (prefers-reduced-motion: reduce) {
  .hb-loading-ring { animation: none; border-top-color: rgba(27, 22, 24, 0.55); }
  .hb-loading .prog-icon-loading { animation: none; }
  .hb-loading-name { animation: none; transform: none; }
  .hb-loading-dots i { animation: none; opacity: 0.7; }
  .hb-spinner { animation: none; opacity: 0.6; }
  .hb-skeleton::after { animation: none; }

  /* THE BAR HAS TO BE GIVEN A RESTING SHAPE, not merely stopped.
     harris.css opens with a global reduced-motion reset that runs any
     animation in a millisecond and parks the element on its LAST keyframe —
     here translateX(100%), which is entirely outside the track. Somebody who
     asked their operating system for less motion would get an empty grey line
     under the sentence. This is a still of the mid-cycle frame: a segment
     sitting in the track, which reads as work in progress without moving. */
  .hb-working-bar::after {
    animation: none;
    transform: translateX(28%) scaleX(0.48);
    background: var(--ink-3, #666C78);
  }
  /* One segment, not two: the pair only makes sense as a cycle, and frozen
     they read as a broken bar with a gap in it. Stopped as well as hidden —
     display:none does not stop an animation, it only stops you seeing it, and
     leaving a compositor animation running behind a hidden element is exactly
     the work somebody asking for reduced motion is trying to avoid. */
  .hb-working-bar::before { display: none; animation: none; }
}

/* ───────────────────────────────────────────────────────────────────────────
   View bar — one module, several views

   A module whose views live on separate pages needs somewhere to say so.
   Before this, PM end-of-month had three pages that each linked to the others
   from the top-right corner, alongside "Home" and "Sign out" — three different
   sets of links, no indication of which page you were on, and no way to tell
   that the three were one module rather than three.

   This is the affordance for that: a strip directly under the header listing
   every view, with the current one marked. Deliberately separate from the
   header's own links, because "which view of this module" and "leave this
   module" are different questions and should not sit in the same cluster.

   Underline tabs rather than a segmented control or a dropdown: it matches the
   detail tabs already used elsewhere in the platform, it shows every option
   without a click, and it degrades to a horizontally scrollable row on a phone
   rather than collapsing into a menu.
   ─────────────────────────────────────────────────────────────────────────── */
.viewbar {
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}
.viewbar-inner {
  max-width: 1360px;
  margin: 0 auto;
  padding: 0 28px;
  display: flex;
  align-items: stretch;
  gap: 2px;
  overflow-x: auto;
  /* A cut-off tab is the only clue there are more; keep the scrollbar out of
     the way but never hide the overflow. */
  scrollbar-width: none;
}
.viewbar-inner::-webkit-scrollbar { display: none; }

.viewbar a {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 13px 15px;
  font-size: var(--t-body);
  font-weight: var(--w-medium);
  color: var(--ink-3);
  text-decoration: none;
  white-space: nowrap;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
  transition: color 0.12s var(--ease, ease), border-color 0.12s var(--ease, ease);
}
.viewbar a:hover { color: var(--ink); }
.viewbar a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -3px;
  border-radius: var(--radius-2, 6px);
}
.viewbar a[aria-current="page"] {
  color: var(--ink);
  font-weight: var(--w-medium);
  border-bottom-color: var(--ink);
}
.viewbar a svg { width: 15px; height: 15px; flex: none; }

/* The personal view sits apart from the whole-portfolio ones: same module,
   but "my task" rather than "the business". */
.viewbar a.viewbar-own { margin-left: auto; }
@media (max-width: 768px) {
  .viewbar-inner { padding: 0 16px; }
  /* auto margins and horizontal scrolling fight each other — the pushed item
     ends up unreachable past the scroll extent. */
  .viewbar a.viewbar-own { margin-left: 0; }
}

/* The module mark — the coloured icon a module wears on its dashboard tile and
   again in its own header and page title. One rule, because it is one thing
   appearing at three sizes; the size and colours come from modules.js so the
   tile and the page cannot drift apart. */
.hb-module-mark {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  flex-shrink: 0;
  vertical-align: middle;
}
.hb-module-mark svg {
  width: 60%;
  height: 60%;
  stroke-width: 2;
}
/* Room to breathe, ON THE MARK ITSELF rather than on one header class.
   This was `.page-title .hb-module-mark`, and the app has SEVEN header
   container conventions and four in-body title containers — so every page
   whose title is not literally `.page-title` rendered its icon flush against
   the first letter. Measured at 0px on PM Operations, whose title block is
   `.page-h`. A gap that depends on which of seven names a page happened to
   pick is not a gap, it is a coincidence.

   12px rather than the old 10: 10 is on no scale, arrived at by nobody. */
.hb-module-mark { margin-right: var(--space-3); }
/* Beside the brand in the header bar: smaller gap, matches the divider rhythm. */
.header-module .hb-module-mark { margin-right: var(--space-2); border-radius: 6px; }
/* Sized here rather than left to each page. Two pages declared it and the rest
   inherited the browser's 16px default — a size that is on no scale, arrived at
   by nobody, and looked like a decision. */
.header-module {
  display: inline-flex;
  align-items: center;
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  color: var(--ink-2);
}

/* Making the title a flex row is what lets the mark sit on the text's centre
   line. It also changes the box: the row is now as tall as the 32px mark
   rather than as tall as the text, so the half-leading that used to sit under
   the baseline is gone and any margin below it reads tighter than it did.
   The pages set `.page-subtitle { margin-top: 2px }`, which was right for a
   text-only heading and is cramped under this one.
   line-height:1 keeps the row exactly as tall as the mark instead of adding
   the font's leading on top, so the gap below is a number somebody chose
   rather than whatever the font contributed. */
.page-title { display: flex; align-items: center; line-height: 1; }

/* Set here rather than per page: the mark is injected by modules.js on every
   module page, so the spacing it needs belongs with it and not copied into
   each page's own <style>. */
.page-title + .page-subtitle,
.page-title ~ .page-subtitle { margin-top: 8px; }

/* ============================================================================
   .hb-unbutton — a real button that does not look like one
   ----------------------------------------------------------------------------
   Thirty-three things in Hank were `<div onclick="...">`: the section headers
   on the home page, the endpoint rows in the Agentbox explorer, the office and
   department dropdowns, the counters on the staff board, a checkbox and a
   folder in a list. Every one of them was invisible to the keyboard — not
   awkward to reach, unreachable — and announced to a screen reader as nothing
   at all, because a div with a click handler is still a div.

   The fix is to make them the buttons they always were, which means stripping
   the chrome a <button> brings so the layout class that was already there
   still governs. Nothing here changes how any of them look; it changes what
   they are.

   `text-align: inherit` matters more than it looks: a button centres its
   contents by default, and several of these are left-aligned rows.

   IN A LAYER, AND THIS TIME THAT IS RIGHT.
   The canonical .btn deliberately is NOT layered — see the note further down;
   it has to beat every page's `* { padding: 0 }` reset, and a layered rule
   loses to an unlayered one whatever the source order. Here the requirement is
   the exact opposite. These elements already carry a layout class that says
   what they look like — .counter-widget has its own background, border, radius
   and padding; .dept-dropdown-toggle has a border that turns blue when open —
   and an unlayered reset in this file would load last and strip all of it.

   Layered, the reset neutralises only what a <button> ADDS and nothing else
   claims: appearance, the UA font and centring, shrink-to-fit width. Every
   property the element's own class sets keeps winning. Author styles still
   beat the UA stylesheet whether they are layered or not, so the neutralising
   half works regardless.
   ========================================================================= */
@layer hb-unbutton {
  .hb-unbutton {
    appearance: none;
    -webkit-appearance: none;
    background: none;
    border: 0;
    margin: 0;
    padding: 0;
    font: inherit;
    color: inherit;
    text-align: inherit;
    /* The UA stylesheet gives every button `letter-spacing: normal`, and a UA
       declaration beats plain inheritance — so with Inter's -0.006em tracking
       on <body>, every converted control drew its text a hair wider than the
       div it replaced. One pixel per pill, and invisible until you measure two
       renderings side by side, which is how this was found. */
    letter-spacing: inherit;
    word-spacing: inherit;
    text-transform: inherit;
    text-indent: inherit;
    /* A div is block-level and a button is inline-block. Everything here that
       needs a different display — the flex rows, the inline-flex pills — says
       so in its own class, and an unlayered page rule beats this layer. */
    display: block;
    /* A button is shrink-to-fit; these were block-level rows. */
    width: 100%;
    /* The one thing worth keeping from the button default. */
    cursor: pointer;
  }

  /* Some of them were not rows but inline pills and cell labels, where a
     full-width block is very wrong. Said here rather than as an unexplained
     `width: auto` in a page's pill rule. */
  .hb-unbutton.hb-unbutton-inline {
    display: inline-block;
    width: auto;
  }

  /* For a control that STRETCHES — a grid cell whose row is sized by a taller
     sibling, a flex item on a stretch cross-axis.
     A button lays its contents out in an anonymous box that it centres
     vertically, and `display: block` does not stop it. That is invisible until
     something makes the button taller than its contents, and then the text
     drifts down while the div beside it stays put: five and a half pixels on
     the staff board's counter row, the day one counter's label wrapped to two
     lines. Laying the contents out as a real column is what stops it.
     Opt-in rather than the default, because the default would have to set
     `flex-direction: column`, and that lands on every consumer whose own class
     says `display: flex` and means a row — which broke three of them. */
  /* NO CONSUMER RIGHT NOW. The staff board's counter row was the only one, and
     it became links when that row moved onto the shared card. Kept rather than
     deleted because it is a shared utility, not page leftovers: the trap above
     is real and the next stacked control to be unbuttoned will hit it. Nobody
     should read this rule as load-bearing for a page that exists today. */
  .hb-unbutton.hb-unbutton-stack {
    display: flex;
    flex-direction: column;
    justify-content: flex-start;
  }
}

/* ============================================================================
   Tables — one row rhythm, said on the elements themselves
   ----------------------------------------------------------------------------
   Thirteen definitions under eight names: bare `table`/`th`/`td` on admin,
   marketing and pm-eom-operations; `.doc-table`, `.history-table` (twice),
   `.data-table` (THREE times — people-analytics, staff and css/dashboard.css,
   all different), `.grid-table`, `.sub-table`, `.marketing-table`, `table.t`
   and `table.list`. Across 32 rendered tables that came to seven header
   paddings, five body paddings, two header weights and two header colours.

   WRITTEN ON THE ELEMENTS, NOT ON A CLASS.
   `th` and `td` are the lowest specificity there is, so this is a floor: any
   page rule that genuinely differs — the right-aligned money columns on
   pm-eom, the sticky sortable header on the staff board, the deliberately
   tight cells in the process wizard — still wins without needing a class or an
   !important. It also means a table nobody remembered to style now looks like
   the rest, which is the opposite of what a shared `.table` class does.

   THE TWO HOUSE STYLES, AND WHY THIS ONE.
   Hank's pages render a 10.5px/700 uppercase header on --surface-2; the
   reporting and sales dashboards render 11px/600 on --bg. By element it was
   almost a tie, 16 to 15 — but fourteen of those fifteen are on a single page.
   By page it is six to two, so the header here is Hank's. The assessment
   engine has a third treatment (sentence case at 500, no uppercase) which is
   arguably the nicer one; adopting it would be a redesign rather than a
   consolidation, so it is deliberately not what this does.
   ========================================================================= */
table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--t-body);
}

th {
  text-align: left;
  /* 14px horizontal is the median, and it is what email-blast, intercom and
     the staff board already used. Every one of the thirteen definitions gave
     th and td the same horizontal padding as each other; this keeps that. */
  padding: 10px 14px;
  font-size: var(--t-micro);
  font-weight: var(--w-bold);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--ink-3);
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
}

td {
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}

/* The last hairline sits directly on the container's own edge and reads as a
   double border. Six of the thirteen definitions already removed it. */
tbody tr:last-child td { border-bottom: none; }

tbody tr:hover td { background: var(--surface-2); }

/* Totals row: heavier, on the header's ground, with a rule above it that is
   stronger than the row hairlines so it reads as a summary rather than one
   more row. */
tfoot td {
  font-weight: var(--w-bold);
  background: var(--surface-2);
  border-top: 1px solid var(--border-strong);
}

/* Figures line up only if they are right-aligned AND tabular; either alone
   still gives a ragged column. */
th.num,
td.num { text-align: right; font-variant-numeric: tabular-nums; }

/* The horizontal scroller, under both names the app gave it.
   shared/table.js makes one of these focusable when — and only when — it
   actually overflows, so it is reachable by keyboard; the focus ring below is
   what makes that visible. A focusable element with nothing to scroll would
   just be a dead tab stop, which is why it is conditional. */
.table-scroll,
.table-wrapper {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.table-scroll:focus-visible,
.table-wrapper:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }

/* ============================================================================
   Cards — one container, one title, one subtitle
   ----------------------------------------------------------------------------
   The same box was defined seven times: `.card` on admin, email-blast,
   intercom, marketing, pm-eom and process, and `.panel` on people-analytics.
   All seven already agreed on the surface, the border, the radius and the
   shadow. The disagreement was padding — 20, 22 and 28, plus marketing and
   pm-eom's asymmetric `20px 22px` — and the heading, which came in three sizes
   and two weights under five names.

   22px is what 22 of the 57 rendered cards already had, and it is the value on
   the two pages with the most of them. The heading follows the same rule:
   14px/700 was on 19 of the 27 card titles.

   `.panel` is the same component under people-analytics' name for it, so it is
   listed rather than aliased — twelve elements is too many to rewrite for a
   rename that changes nothing a reader sees.
   ========================================================================= */
.card,
.panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 22px;
  /* A grid or flex item defaults to `min-width: auto`, which means "never
     narrower than my contents". A card holding a header row that will not
     shrink therefore grows the item, the item grows the grid, and the page
     scrolls sideways. On the analytics page one card was 604px wide inside a
     310px column for exactly this reason. Cards fit their column; what is
     inside them is the next rule's problem. */
  min-width: 0;
}

/* A card whose content brings its own edges — a full-bleed table, an image.
   This was `style="padding:0;overflow:hidden"` inline on five cards; the
   overflow clip is what keeps the table's corners inside the radius. */
.card-flush { padding: 0; overflow: hidden; }

/* Title on the left, action on the right. `.card-h` joins for the layout but
   keeps its own margin on pm-eom: the content under it there carries a
   `margin-top` of its own, so the shared 16px would land on top of that and
   double the gap. */
.card-header,
.panel-header,
.card-h {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  /* A title on the left and its controls on the right is a row until the two
     stop fitting, and then it is two rows. Without this the row simply refused
     to shrink and pushed the card out from the inside. It never wraps at a
     width where it fits, so nothing on a desktop moves. */
  flex-wrap: wrap;
}

/* Same reasoning one level down: a long title should wrap or ellipsise inside
   its half of the row rather than set the row's minimum width. */
.card-header > *,
.panel-header > *,
.card-h > * { min-width: 0; }

.card-header,
.panel-header { margin-bottom: 16px; }

.card-header h2,
.card-title,
.panel-title {
  font-size: var(--t-title);
  font-weight: var(--w-bold);
  letter-spacing: -0.01em;
  color: var(--ink);
}

/* 12px is the majority (12 of the 19 subtitles); the line-height is not — no
   page set one, so these inherited whatever the page body gave them and sat at
   about 1.2. That is fine for the one-line case and tight the moment a
   subtitle wraps, which several do at narrow widths. 1.5 is the body value
   used everywhere else here. It makes each of these boxes 4px taller, which is
   the only reason the panels on people-analytics moved at all. */
.card-sub,
.panel-subtitle {
  font-size: var(--t-small);
  font-weight: var(--w-regular);
  color: var(--ink-3);
  line-height: 1.5;
  margin-top: 2px;
}

/* ============================================================================
   Buttons — one definition
   ----------------------------------------------------------------------------
   `.btn` was defined eleven times: once in each of ten page <style> blocks and
   once more in css/dashboard.css. They agreed on radius and weight and on
   almost nothing else. Measured in a browser, across every page and both
   themes, the app rendered TWENTY-NINE distinct button geometries — the
   padding alone came in ten values from `4px 10px` to `12px 24px`.

   The numbers here are not new. They are the ones the app already renders
   most: `8px 14px` at 13px/600 was the majority base (48 of 71 rendered
   buttons), and `4px 10px` at 12px was the majority small (69 of 113). The
   outliers move; nothing is invented.

   TWO VOCABULARIES, BOTH KEPT.
   Half the app writes `.btn-primary` / `.btn-danger` and half writes
   `.btn.dark` / `.btn.danger`. Supporting both is three extra selectors and
   keeps this a stylesheet change: no markup had to be rewritten to land it,
   so nothing could break in the rewriting.

   `.btn-sm` IS SELF-SUFFICIENT.
   On admin and staff it is a size modifier (`class="btn btn-sm"`); on
   email-blast and intercom it is the whole button (`class="btn-sm"`, 32 of
   them). Defining it as size-only would have stripped the background, border
   and colour off those 32. It carries the full recipe and overrides the size.

   NOT IN A CASCADE LAYER, AND THAT WAS TESTED.
   harris.css loads LAST on every page — deliberately, since that is what lets
   :root here beat a page's own :root. The obvious consequence is that an
   unlayered `.btn` here outranks a page's `.btn-export` at equal specificity,
   so the first attempt wrapped all of this in `@layer components`: layered
   rules lose to unlayered ones whatever the source order, which would have
   left pages free to override with ordinary CSS.

   It collapsed every button in the app. Layer order beats specificity, and
   every page's <style> opens with `*, *::before, *::after { margin: 0;
   padding: 0 }`. That universal reset is unlayered, so it outranked a layered
   `.btn { padding: 8px 14px }` and the buttons rendered at zero padding —
   measured across 466 of them, every single one shrank to the size of its
   label. A layer is only usable here if the reset is in one too, and the reset
   is copied into eighteen page <style> blocks.

   So these are plain rules, and a page that needs a genuine local override
   raises its own specificity to get it (see .btn-export in css/dashboard.css).
   ========================================================================= */

/* .btn-logout is here rather than in four page <style> blocks because that is
   all it ever was: the base button under another name, copied to home,
   reports, email-blast and intercom with the same padding, type and colours
   each time. The pages keep only their own responsive shrink. */
.btn,
.btn-sm,
.btn-logout {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 8px 14px;
  font-family: inherit;
  font-size: var(--t-body);
  font-weight: var(--w-medium);
  line-height: 1.25;
  color: var(--ink-2);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-3);
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease,
              color .15s ease, box-shadow .15s ease, transform .06s ease,
              filter .15s ease;
}

.btn-sm { padding: 4px 10px; font-size: var(--t-small); gap: 6px; }

.btn:hover,
.btn-sm:hover,
.btn-logout:hover { background: var(--surface-2); color: var(--ink); }

/* :disabled covers <button>; aria-disabled covers the anchors and divs that
   are dressed as buttons and cannot carry the real attribute. */
.btn:disabled,
.btn-sm:disabled,
.btn[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }

.btn:disabled:hover,
.btn-sm:disabled:hover { background: var(--surface); color: var(--ink-2); }

/* Icons sized here so a button with an icon and one without line up. Direct
   children only: an SVG deeper in (a badge, a spinner inside a slot) keeps
   whatever size its own component gave it. */
.btn > svg { width: 15px; height: 15px; flex-shrink: 0; }
.btn-sm > svg { width: 13px; height: 13px; flex-shrink: 0; }

/* The filled, dark, one-per-view action. `.dark` and `.primary` are the other
   two names the app already used for this exact treatment. */
.btn-primary,
.btn.primary,
.btn.dark,
.btn-sm.primary {
  background: var(--ink);
  color: var(--surface);
  border-color: var(--ink);
}

.btn-primary:hover,
.btn.primary:hover,
.btn.dark:hover,
.btn-sm.primary:hover {
  background: var(--primary-light);
  border-color: var(--primary-light);
  color: var(--surface);
}

/* Destructive, and quiet until hovered — it is usually sitting next to the
   action somebody actually meant to press. */
.btn-danger,
.btn.danger,
.btn-sm.danger {
  background: var(--surface);
  color: var(--red);
  border-color: var(--red-soft);
}

.btn-danger:hover,
.btn.danger:hover,
.btn-sm.danger:hover {
  background: var(--red-soft);
  color: var(--red);
  border-color: var(--red);
}

/* `.btn-outline` was a no-op alias for the base on four of the five pages that
   defined it. Kept as a name — 68 elements say it — but it is the base, stated
   so nobody has to go looking for the difference. */
.btn-outline {
  background: var(--surface);
  color: var(--ink-2);
  border-color: var(--border);
}

.btn-outline:hover { background: var(--surface-2); color: var(--ink); }

/* Filled colour variants. Confirm-and-go green, informational blue. */
.btn-success,
.btn.success {
  background: var(--green);
  color: #fff;
  border-color: var(--green);
}

.btn-info,
.btn.blue,
.btn-sm.info {
  background: var(--blue);
  color: #fff;
  border-color: var(--blue);
}

.btn-success:hover,
.btn.success:hover,
.btn-info:hover,
.btn.blue:hover,
.btn-sm.info:hover { filter: brightness(0.96); }

/* Anything that hands the work to Hank. Same purple as .btn-hank and the
   assistant panels, so the affordance reads the same wherever it appears. */
.btn-ai,
.btn.ai {
  background: var(--ai-soft);
  color: var(--ai-strong);
  border-color: var(--ai-border);
}

.btn-ai:hover,
.btn.ai:hover {
  background: var(--ai-soft);
  color: var(--ai-strong);
  filter: brightness(0.97);
}

/* The filled buttons say `color: var(--surface)` rather than `#fff` so they
   invert with the theme: in dark mode --ink is near-white and a white label on
   it would be invisible. --surface is the ground in both, which is what the
   ink is always drawn against. */

/* ============================================================================
   Empty states — say what is missing, then offer the way out
   ----------------------------------------------------------------------------
   Hank had thirteen empty-state class names across ten paddings — 20px, 26px,
   30px, `34px 18px`, `38px 16px`, 40px, `40px 20px`, `44px 16px`, `48px 20px`,
   `60px 20px` — and almost all of them rendered a single grey sentence.

   The assessment engine already had the better pattern, so this is its recipe
   in Hank's tokens: an icon in a tinted square, a headline naming what is
   absent, a line explaining why, and the button that fixes it. The last part
   is the point. Seven of Hank's states said things like "No users yet. Click
   '+ Add User' to create one" — a sentence whose whole job was to send the
   reader off to find a control. If the state knows which button to press it
   can be the button.

   NO FLEX ON THE CONTAINER, DELIBERATELY.
   Several of these render into `<td colspan="6">`, and `display:flex` on a
   table cell stops it being a cell: the colspan is dropped and the row
   collapses. Centring with `text-align` and `margin:auto` instead means the
   same class works in a table, a card and a drawer without a variant for each.
   ========================================================================= */
.hb-empty {
  padding: 44px 24px;
  text-align: center;
  color: var(--ink-2);
}

/* The tinted square. It gives the icon a shape and a ground, which is what
   stops a lone 28px outline glyph reading as a loading artefact. */
.hb-empty-icon {
  width: 56px;
  height: 56px;
  margin: 0 auto 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-3);
  background: var(--surface-2);
  color: var(--ink-3);
}

.hb-empty-icon svg { width: 28px; height: 28px; stroke-width: 1.5; }

/* h3 as well as the class: these render from JS template literals that mostly
   already reach for a heading tag, and matching both means a call site can be
   migrated without rewriting its markup. */
.hb-empty h3,
.hb-empty-title {
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  color: var(--ink);
  margin: 0 0 4px;
  line-height: 1.35;
}

.hb-empty p {
  font-size: var(--t-body);
  line-height: 1.6;
  color: var(--ink-2);
  margin: 0 auto;
  max-width: 46ch;
}

.hb-empty .btn,
.hb-empty .btn-sm { margin-top: 18px; }

/* The cell HankEmpty.row() puts the state in. It contributes no padding of its
   own: the state already has 44px, and adding the table's usual 10px cell
   padding on top pushes the whole thing off-centre. */
.hb-empty-cell { padding: 0; border: none; background: none; }

/* For the ones that live inside something small — a kanban column, a chart
   well, a notifications dropdown — where 44px of padding would be most of the
   container. */
.hb-empty.is-compact { padding: 24px 16px; }
.hb-empty.is-compact .hb-empty-icon { width: 40px; height: 40px; margin-bottom: 10px; }
.hb-empty.is-compact .hb-empty-icon svg { width: 20px; height: 20px; }
.hb-empty.is-compact h3,
.hb-empty.is-compact .hb-empty-title { font-size: var(--t-body); }
.hb-empty.is-compact p { font-size: var(--t-small); }

/* Something went wrong is not the same as there is nothing here, and it should
   not look like it. Same layout, warmer square, so a failure is legible as a
   failure at a glance. */
.hb-empty.is-error .hb-empty-icon { background: var(--red-soft); color: var(--red); }

/* The legacy names, converged.
   ----------------------------------------------------------------------------
   Fifty-eight call sites still write .empty / .empty-state / .empty-docs and
   friends, most of them from JS template literals. Rather than rewrite all of
   them at once, the names keep working and now share one padding, one colour
   and one type size — ten scales down to one. Only scalar properties are set
   here: several of these classes sit on a <td>, so nothing may touch display.
   Migrate a call site to .hb-empty when it earns an icon and an action. */
.empty-state,
.empty-docs,
.err-empty,
.brain-empty,
.notif-empty,
.cw-history-empty {
  padding: 44px 24px;
  text-align: center;
  color: var(--ink-2);
  font-size: var(--t-body);
  line-height: 1.6;
}

/* .empty is the PM EOM family's name for the same thing, and it is nearly
   always a table row — half the padding, because the table has its own. */
.empty {
  padding: 28px 20px;
  text-align: center;
  color: var(--ink-2);
  font-size: var(--t-body);
  line-height: 1.6;
}

.empty-state p,
.empty-docs p,
.cw-history-empty p { font-size: var(--t-body); margin: 0 auto; max-width: 46ch; }

.empty-state h3,
.cw-history-empty h3 {
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  color: var(--ink);
  margin: 0 0 4px;
}

/* Icons that were already there keep their size but stop being nearly
   invisible: 0.4 opacity on a --ink-3 grey is a 1.9:1 shape. */
.empty-state svg,
.empty-docs svg,
.empty > svg { width: 28px; height: 28px; opacity: 1; color: var(--ink-3); margin-bottom: 10px; }

/* ============================================================================
   Narrow viewports
   ----------------------------------------------------------------------------
   Every measurement in this design programme was taken at 1440px until it was
   taken at 390px, and then eight of the nineteen pages scrolled sideways —
   which is not a cosmetic fault, it is content the reader cannot reach.

   The rules here are the ones that belong to shared components. A page keeps
   its own narrow-width block for its own layout; what it should not keep is a
   private copy of a fix for a component five other pages also use.

   Breakpoints. The app had fourteen distinct ones, no two pages agreeing.
   Three are canonical:

     560px   a phone. One column, bars give up their padding.
     768px   the sidebar rail collapses to 56px on the two pages that have one.
     1100px  wide grids drop to a single column.

   Anything else is a component collapsing at its own natural width, and has to
   say why in a comment where it is written. `__tests__/breakpoints.test.js`
   holds the line.
   ========================================================================= */

/* The micro step folds into the small one wherever the pointer is a finger.
   10.5px uppercase is a label at arm's length on a monitor and a squint on a
   handset, and it was 107 of the app's 212 runs of sub-12px text — every table
   header, every badge, every stat caption. One line moves all of them, which
   is what the scale was for.

   Keyed on the pointer rather than the width on purpose: legibility at arm's
   length is a property of the device, not of how wide the window happens to
   be. A tablet at 900px needs the larger label; a desktop browser dragged
   narrow does not.

   The step does not disappear, it just stops being a size: these are still
   uppercase, still letter-spaced, still bold, and still read as labels. */
@media (pointer: coarse) {
  :root { --t-micro: 12px; }
}

@media (max-width: 560px) {
  /* 28px of padding either side is 56px of a 390px screen. The bar's contents
     were 364px wide inside 334px of room on three pages. */
  .topbar { padding-left: 12px; padding-right: 12px; }
  /* The left group carries the brand and the page's name, and is the half that
     can give; without this it sets the bar's minimum width and nothing fits. */
  .topbar > :first-child { min-width: 0; }

  /* The sidebar pages' bar. Its title and its controls are a row while they
     fit and two rows when they do not — the staff board's view toggle and
     "New Staff Card" button together are 226px, against 302px of bar with the
     rail already collapsed to 56px. */
  .page-topbar { flex-wrap: wrap; gap: var(--space-2); }
  .page-topbar > * { min-width: 0; }
}

/* A row of tabs that does not fit scrolls, it does not wrap and it does not
   squash. Wrapping puts a second row of tabs under a border that was drawn for
   one; squashing gives you four unreadable slivers. This is the same choice
   the PM view bar already makes, for the same reason: a cut-off tab is the
   only clue there are more, so the scrollbar goes away but the overflow never
   does. Declared for every width — the row scrolls whenever it is too narrow,
   which is not a property of phones.

   The 2px is the active tab's underline, which sits on a negative margin and
   would otherwise be clipped by the scroll container it now lives in. */
.tab-bar {
  overflow-x: auto;
  padding-bottom: 2px;
  scrollbar-width: none;
}
.tab-bar::-webkit-scrollbar { display: none; }
.tab-bar > * { flex-shrink: 0; }

/* ============================================================================
   Metric cards & sections
   ----------------------------------------------------------------------------
   One card, defined once, because the alternative is what this file already
   had to undo. Before the type scale there were 21 font sizes doing seven
   jobs; every one of them arrived the same way — a page needed a number tile,
   nothing shared existed, and somebody wrote a good one locally. Six pages did
   that six times.

   So this is the shared answer, and `harris-metrics.js` renders it, so that
   adopting the look is a call rather than a copied template literal. A page
   that hand-rolls the markup gets no help when the card changes.

   WHAT A CARD SHOWS, in order of how the eye lands:
     label      what this number is
     value      the number, at hero size, tabular
     delta      how it moved, with an explicit TONE (see below)
     footer     the comparison the delta is against, in words

   THE DELTA'S TONE IS NOT ITS SIGN. Down is good for arrears, vacancies and
   properties lost; up is good for portfolio size and properties gained. A
   component that colours by sign gets half of them backwards, and a red
   number nobody can act on is worse than no colour at all — so the caller
   states `tone` and the component never guesses.
   ========================================================================= */

.hm-grid {
  display: grid;
  /* auto-fit rather than a fixed count: the same declaration gives four
     across on a desktop and one on a phone, with nothing at a width where a
     card is too narrow to read its own number.

     240px, not 210px, and the reason is the ORPHAN ROW. At 210 the desktop
     width fitted five tracks, so People Analytics — which has six metrics —
     laid out as five across and one alone beside four cards' worth of empty
     page. Six does not divide by five. At 240 the same width fits four, so
     six becomes 4 + 2 and the second row reads as a row.

     Every other grid in the app carries four or eight cards and was already
     resolving to four tracks, so this changes nothing for them: measured at
     1512px, pm-eom-operations, sales-dashboard and intercom all held at four
     across before and after. The visible change is confined to the page that
     had the defect.

     It is not a free choice — a wider minimum means fewer, wider cards at
     tablet widths too, where this drops 3 across to 2. That is the trade, it
     is deliberate, and verify:mobile sweeps 16 widths to prove nothing
     overflows because of it. */
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--space-3);
}

.hm-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding: var(--space-5);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow-sm);
  text-align: left;
  font: inherit;
  color: inherit;
  width: 100%;
}

/* Only the ones that go somewhere get the affordance. A hover state on a card
   that does nothing is a promise the card cannot keep. */
a.hm-card,
button.hm-card {
  cursor: pointer;
  /* A card is a surface, not a sentence. Without this the first page to give
     one an href gets every word inside it underlined — label, value, footer.
     Found by that page, which then had to undo it locally; that local rule
     should go now this one exists. */
  text-decoration: none;
  transition: border-color .15s ease, box-shadow .15s ease, transform .15s ease;
}
a.hm-card:hover,
button.hm-card:hover {
  border-color: var(--border-strong);
  box-shadow: var(--shadow);
}
a.hm-card:active,
button.hm-card:active { transform: translateY(1px); }

/* The icon. 42px box, 20px glyph — the geometry the reporting dashboard's 58
   tiles already use, kept so adopting it is not also a resize.

   TINTED BY MEANING, from the same six names the pills use. The dashboard
   currently names nine tints by colour (teal, indigo, rose, charcoal...) and
   uses them for severity coding, which is real work — but nine private colour
   names is the divergence this block exists to end, and mapping them onto
   meanings is a decision about what those tiles MEAN, not a styling choice.
   Untinted is the default so a page can adopt the layout before it has made
   that decision. */
.hm-card .mc-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 42px; height: 42px;
  border-radius: var(--radius-2);
  background: var(--surface-2);
  color: var(--ink-2);
  flex-shrink: 0;
}
.hm-card .mc-icon svg { width: 20px; height: 20px; }
.hm-card .mc-icon.done    { background: var(--green-soft);  color: var(--green-ink); }
.hm-card .mc-icon.waiting { background: var(--amber-soft);  color: var(--amber-ink); }
.hm-card .mc-icon.working { background: var(--blue-soft);   color: var(--blue-ink); }
.hm-card .mc-icon.problem { background: var(--red-soft);    color: var(--red-ink); }
.hm-card .mc-icon.info    { background: var(--violet-soft); color: var(--violet-ink); }

/* A sparkline sits at the right of the footer, on the sub-line's baseline —
   where it reads as an annotation on the figure rather than as content. */
.hm-card .mc-spark { display: inline-flex; align-items: center; margin-left: auto; }
.hm-card .mc-spark canvas,
.hm-card .mc-spark svg { display: block; max-width: 80px; height: 32px; }

.hm-card .mc-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 20px;
}
/* THE LABEL RECEDES SO THE NUMBER CAN LEAD.
   Was 14px/700 in full ink against a 28px/700 value — both bold, both at full
   strength, so the eye had to choose between them. On the card in the
   reference the label is quiet and small and the figure carries the weight,
   which is the whole reason a metric card works at a glance.

   13px/600 in secondary ink: 6.73:1 on white, so receding costs no
   readability. */
.hm-card .mc-label {
  font-size: var(--t-body);
  font-weight: var(--w-medium);
  color: var(--ink-2);
  letter-spacing: 0;
}
/* Pushes the chevron to the right edge whether or not an info button exists. */
/* When a card carries a hint button it cannot itself be a link (see
   harris-metrics.js), so the destination sits on the label instead. Styled to
   read as the same affordance: same weight, same chevron, no underline. */
.hm-card .mc-label-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  text-decoration: none;
  color: inherit;
}
.hm-card .mc-label-link:hover { text-decoration: underline; }
.hm-card .mc-label-link .mc-go { margin-left: 0; }

.hm-card .mc-head .mc-go { margin-left: auto; color: var(--ink-3); display: flex; }
.hm-card .mc-head .mc-go svg { width: 16px; height: 16px; }

.hm-card .mc-info {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px; height: 16px;
  padding: 0;
  border: none;
  background: none;
  color: var(--ink-3);
  cursor: help;
  flex-shrink: 0;
}
.hm-card .mc-info svg { width: 14px; height: 14px; }
.hm-card .mc-info:hover { color: var(--ink-2); }
.hm-card .mc-info[aria-expanded="true"] { color: var(--ink); }

/* A real 44px button on touch, that does not push anything around.
   The complaint was that the global coarse-pointer minimum inflates this
   16px glyph's box and shoves the icon away from the label it belongs to.
   The first fix shrank the BUTTON and put the hit area on a ::after — which
   gave thumbs their target but made the element itself 16px, and the mobile
   verifier measures the element. 42 checks failed, correctly: an expanded
   pseudo-element is invisible to anything auditing control size, and a
   control that only LOOKS 44px to the finger is not a standard you can hold.
   Negative margins instead: the box really is 44x44, and its layout footprint
   is 44 - 28 = 16, so the centred glyph sits exactly where it always did. */
@media (pointer: coarse) {
  .hm-card .mc-info { width: 44px; height: 44px; margin: -14px; }
}

/* What the info button reveals. Hidden with the attribute rather than a class,
   so it is out of the accessibility tree and out of Tab order when closed. */
.hm-card .mc-hint {
  font-size: var(--t-small);
  color: var(--ink-2);
  line-height: 1.5;
  background: var(--surface-2);
  border-radius: var(--radius-3);
  padding: var(--space-2) var(--space-3);
}
.hm-card .mc-hint[hidden] { display: none; }

.hm-card .mc-figure {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  flex-wrap: wrap;
}
.hm-card .mc-value {
  font-size: var(--t-hero);
  font-weight: var(--w-bold);
  line-height: 1.05;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
.hm-card .mc-foot {
  font-size: var(--t-small);
  color: var(--ink-3);
  display: flex;
  gap: var(--space-3);
  justify-content: space-between;
  align-items: baseline;
}

/* Text for a screen reader and nobody else.
   The delta's arrow is aria-hidden — it is a picture of a direction — so
   "47%" on its own would be read out with no indication of which way it
   moved. This carries the direction in words. Clip rather than display:none
   or visibility:hidden, both of which take it out of the accessibility tree
   as well, which is the opposite of the point. */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── The delta ──
   Tone is given, never inferred — see the note at the top of this block. The
   arrow says which way it moved; the colour says whether that is good. They
   are different facts and the card shows both, which is why a falling number
   can legitimately be green. */
.hm-delta {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  font-size: var(--t-small);
  font-weight: var(--w-medium);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.hm-delta svg { width: 11px; height: 11px; }
.hm-delta.good { color: var(--green); }
.hm-delta.bad  { color: var(--red); }
/* No movement is information too, and it should not read as either. */
.hm-delta.flat { color: var(--ink-3); }

/* ── Section ──
   A titled group of cards, collapsible. The title is on the page's own scale
   (--t-page), one step above a card's label, so the grouping reads as
   structure rather than as another card. */
.hm-section-h {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: var(--space-6) 0 var(--space-4);
}
.hm-section-h:first-child { margin-top: 0; }
/* A SECTION HEADING IS NOT A PAGE TITLE.
   This was --t-page / --w-bold: 22px at 700, the exact size and weight of the
   h1 above it. Measured in a browser on PM Operations, "This month" and
   "By property manager" rendered identically to "PM Operations", so the page
   had three things all shouting at the same volume and no hierarchy at all —
   the single loudest reason it did not read as up-market.

   14px/600 in the secondary ink instead: a 1.57x step down from the 22px
   title, which is a clear rank, while staying large enough to scan as
   structure rather than as a caption. */
.hm-section-h h2 {
  font-size: var(--t-title);
  font-weight: var(--w-medium);
  letter-spacing: 0;
  color: var(--ink-2);
}
.hm-section-h .sh-actions { margin-left: auto; display: flex; align-items: center; gap: var(--space-1); }
.hm-section-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 30px; height: 30px;
  border: none;
  background: none;
  border-radius: var(--radius-3);
  color: var(--ink-2);
  cursor: pointer;
}
.hm-section-toggle:hover { background: var(--surface-2); color: var(--ink); }
.hm-section-toggle svg { width: 17px; height: 17px; transition: transform .18s ease; }
.hm-section-toggle[aria-expanded="false"] svg { transform: rotate(180deg); }
/* Driven by hidden rather than a class, so the collapsed content is out of the
   accessibility tree and out of Tab order — a panel you cannot see but can
   still tab into is the bug the modal work already fixed once. */
.hm-section-body[hidden] { display: none; }
/* The gap to whatever follows. Three pages adopted a section and all three
   supplied this themselves, inline — which is the same private decision made
   three times, and the thing this block exists to stop. */
/* Measured at 0px between the last card and the next heading, because the
   `:last-child` reset that used to follow this line ALWAYS matched: pages
   render each section into its own container div, so every section is the
   only child of its parent. A reset meant to trim a trailing gap removed
   every gap instead. */
.hm-section { display: block; margin-bottom: var(--space-6); }

/* ── The composite card ──
   Two or three figures that belong to one subject, then a breakdown beneath a
   rule. Same shell as .hm-card so a row can mix the two without the seam
   showing. */
.hm-card .mc-cols {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: var(--space-4);
}
.hm-card .mc-col .mc-col-label {
  font-size: var(--t-small);
  color: var(--ink-3);
  margin-bottom: var(--space-1);
}
.hm-card .mc-rule {
  height: 1px;
  background: var(--border);
  margin: var(--space-1) 0;
}
.hm-card .mc-break-label {
  font-size: var(--t-small);
  color: var(--ink-3);
}
.hm-breakdown { display: flex; flex-direction: column; gap: var(--space-2); }
.hm-breakdown .mb-row {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--t-body);
}
.hm-breakdown .mb-dot {
  width: 7px; height: 7px;
  border-radius: 50%;
  flex-shrink: 0;
  background: var(--ink-3);
}
.hm-breakdown .mb-name { color: var(--ink); }
.hm-breakdown .mb-count {
  margin-left: auto;
  font-variant-numeric: tabular-nums;
  font-weight: var(--w-medium);
  color: var(--ink);
}
.hm-breakdown .mb-value {
  font-variant-numeric: tabular-nums;
  color: var(--ink-3);
  min-width: 62px;
  text-align: right;
}

@media (max-width: 560px) {
  .hm-card { padding: var(--space-4); }
  .hm-section-h { margin: var(--space-5) 0 var(--space-3); }
}

/* ============================================================================
   Status pills
   ----------------------------------------------------------------------------
   NAMED FOR WHAT THEY MEAN, not for what colour they are. That distinction is
   the whole point of this block.

   Ten pages carry pills and every one of them declares its own, with variants
   called `.pill.gray`, `.pill.green`, `.pill.amber`. Writing
   `class="pill gray"` is choosing a COLOUR, and a colour is not a decision
   anyone can check: nothing in the system says what "pending" ought to look
   like, so the next person picks again, and a status ends up grey because grey
   was next to hand.

   That is exactly what happened on PM Operations: every property manager who
   had not signed off yet showed a grey "Pending", and grey reads as nothing —
   disabled, empty, not applicable. It is not nothing. It means the month is
   waiting on that person. `class="hm-pill waiting"` cannot be written without
   saying so, and the colour then follows from the meaning rather than the
   other way round.

   THE VOCABULARY, and it is deliberately short. Six states people actually act
   on differently:

     done      finished and needs nobody — signed off, sent, settled, locked
     waiting   a PERSON has to do something — pending, unsubmitted, to review
     working   the system is mid-flight — sending, syncing, generating
     problem   it failed or it is overdue and somebody must look
     info      a neutral fact worth marking — a type, a category, a count
     idle      genuinely no state yet, and nothing to do about it

   GREY IS THE RARE ONE. `idle` exists for "this has not started and that is
   fine" — a draft nobody has touched. If a pill means somebody is being waited
   on, it is `waiting`, not `idle`. A status list where everything is grey
   tells a reader nothing, which is the same as having no pill at all.

   Namespaced `hm-` because harris.css is linked AFTER every page's inline
   <style>: a shared rule called `.pill` would silently overwrite all ten
   pages' own. That already happened once with `.metric-card`, and nothing
   failed — see the note in the metric-card block.
   ========================================================================= */

.hm-pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 9px;
  border-radius: 20px;
  font-size: var(--t-small);
  font-weight: var(--w-medium);
  white-space: nowrap;
  /* Every variant sets all three together. A pill that inherits one of them
     from the page around it changes meaning depending on where it is put. */
  background: var(--surface-2);
  color: var(--ink-2);
  border: 1px solid transparent;
}
.hm-pill svg { width: 12px; height: 12px; flex-shrink: 0; }

/* The dot carries the status for anyone who cannot separate the hues, and
   reads as deliberate rather than decorative next to a word. */
.hm-pill .hm-pill-dot {
  width: 6px; height: 6px;
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
}

.hm-pill.done      { background: var(--green-soft);  color: var(--green-ink); }
.hm-pill.waiting   { background: var(--amber-soft);  color: var(--amber-ink); }
.hm-pill.working   { background: var(--blue-soft);   color: var(--blue-ink); }
.hm-pill.problem   { background: var(--red-soft);    color: var(--red-ink); }
.hm-pill.info      { background: var(--violet-soft); color: var(--violet-ink); }
/* The only grey one, and the only one that takes a border — without it a grey
   pill on a grey surface has no edge at all. */
.hm-pill.idle      { background: var(--surface-2);   color: var(--ink-2); border-color: var(--border); }

/* Marks something as AI-touched. Kept off the six above because it says WHO
   did it, not what state it is in — an AI-generated draft is still a draft. */
.hm-pill.ai        { background: var(--ai-soft);     color: var(--ai-strong); border-color: var(--ai-border); }

/* ── Identity pills ──────────────────────────────────────────────────────────
   The vocabulary above names MEANINGS — done, waiting, problem. These name
   nothing: they exist so a reader can tell one office from another down a list
   of results, which is identity rather than status.

   TINTED LIKE A STATUS PILL, which took three attempts and one thing genuinely
   learned each time.

   1. Six pale hues beside five pale status hues. Rendered, "Adelaide" in pale
      olive sat directly above "Active" in pale green and read as the same
      colour. Measured after the fact: three of the six were within 25° of a
      status hue in OKLCH. The check that had let them through compared
      LUMINANCE distance — the wrong axis, since hue is what makes two pastels
      indistinguishable.

   2. So the fill went neutral and the colour moved into a saturated dot. That
      removed the confusion by removing the competition.

   3. Tinted again, on request — but the fix for (1) is no longer neutrality,
      because the office pill now sits UNDER THE ADDRESS while status sits on
      the right. They are not adjacent, so they no longer have to be different
      KINDS of thing; they only have to be different colours.

   Which they now measurably are. Reusing the old dot hues would have been the
   easy read of "tint it from the circle colour" and would have reintroduced
   exactly (1): those six land 2°, 5°, 14°, 20°, 29° and 35° from a status hue
   — blue on blue, purple on violet, magenta on the red that Inactive now uses.

   WHICH STATUSES THEY HAVE TO CLEAR IS A MEASURED FACT, not all five. These
   pills render on one page, and the statuses that appear beside them there are
   done (green 166°), problem (red 17°), waiting (amber 78°) and idle, which has
   no hue at all. Constraining against blue and violet was paying separation to
   avoid a collision that cannot happen; dropping them frees the whole 200-340
   arc.

   RED IS A HARD EXCLUSION, and a screenshot decided that rather than an
   average. The first arrangement maximised the smallest separation and reached
   29° from everything — and rendered, an ochre office pill sat on the same card
   as a red Inactive pill and the two read as the same alarm. Red now means
   "this is not a live property", the one thing on a card that must not be
   ambiguous, so it gets 55° and the rest is packed into what remains:

       135° 196° 228° 259° 291° 322°

   Measured on the rendered hex rather than on the design intent — the first
   generator's in-gamut test compared a value to itself and so never rejected
   anything, and two colours outside sRGB were clamped channel-by-channel,
   which moves the hue. Asked for 228° and 256°, sRGB gave 248° and 258°: ten
   degrees apart instead of twenty-eight, and no warning anywhere. Every hue
   below is now round-tripped, and drifts by at most 0.4°.

   The result: every identity ink is at least 43° from any status hue it can
   appear beside, and at least 27° from every other identity ink — against 2°
   at the start. Chroma is capped at what the status inks themselves reach
   (C=0.184); left to maximise, the search produced #6200FF and #AE01ED, which
   are electric and nothing like the muted greens and ambers they sit beside.
   These are the same family, differing in hue, not in volume. Each clears
   4.5:1 on its own fill (4.61-4.68) — text contrast, not the 3:1 a dot would
   need, because the colour is now carrying a word.

   The dot inherits `currentColor` like every status pill, so an office pill is
   built the same way as an Active one. That is what was asked for and it is
   also one fewer rule.

   Six, cycled by a hash of the name, so an office keeps its colour between
   renders and across sessions with nothing configured. Beyond six they repeat,
   which is honest for identity in a way it was not for the chart ramp: two
   offices sharing a tint is a mild inconvenience, two chart series sharing a
   fill is a wrong reading. */
.hm-pill.id-1 { background: #E4F6F7; color: #04797E; }   /* teal,   200° */
.hm-pill.id-2 { background: #FCEDF6; color: #B53B94; }   /* plum,   340° */
.hm-pill.id-3 { background: #EAF3FF; color: #006BD6; }   /* azure,  256° */
.hm-pill.id-4 { background: #E6F5FD; color: #037699; }   /* cyan,   228° */
.hm-pill.id-5 { background: #F0F1FF; color: #6959D6; }   /* indigo, 284° */
.hm-pill.id-6 { background: #F6EFFC; color: #974ABF; }   /* violet, 312° */

/* ── Stacked pills ───────────────────────────────────────────────────────────
   Several pills on one row wrapped into a ragged block that read as one long
   label rather than as separate facts. Stacked, each is its own line and the
   right edge lines up. */
.hm-pill-stack {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-1);
}

/* ============================================================================
   One content column, shared by the header
   ----------------------------------------------------------------------------
   SCOPED TO THE PAGES THAT NEED IT, and the exclusion is load-bearing.

   Hank has two layouts. Most pages are a full-bleed header over a centred
   content column — those are the ones with a `.topbar` or a `.page-header`,
   and they are what this fixes. The reporting dashboard, the sales dashboard,
   People Analytics and the staff board are the other kind: a fixed sidebar
   with content filling whatever is left. Their header and their content
   already start at the same x, because both begin where the sidebar ends —
   measured, staff reads brand@272 / content@272, already aligned.

   So a blanket rule would break the four pages that were right in order to fix
   the ten that were not. `body:not(:has(.sidebar))` is how the two layouts are
   told apart, because that is the actual difference between them rather than a
   class somebody remembered to add.

   The header padding repeats the container's own centring arithmetic — the
   `+ var(--content-gutter)` term is what closes the last 20px, and leaving it
   out lands the header one gutter short of the heading. `max()` keeps the
   28px minimum below --content-max, where the column is edge-to-edge and the
   two collapse to the same value anyway.
   ========================================================================= */
body:not(:has(.sidebar)) .wrap,
body:not(:has(.sidebar)) .container {
  max-width: var(--content-max);
  margin-inline: auto;
  padding-inline: var(--content-gutter);
}

body:not(:has(.sidebar)) .topbar,
body:not(:has(.sidebar)) .page-header {
  padding-inline: max(
    var(--content-gutter),
    calc((100% - var(--content-max)) / 2 + var(--content-gutter))
  );
}
