/* Platform widget styles — shared verbatim by every app.
 *
 * These style the components auth-client.js RENDERS (toasts, modal
 * dialogs, dropdowns, the account menu, buttons, the coach-mark tour, the
 * footer), so every app was necessarily trying to style the same DOM.
 * They were maintained as four independent copies, and had drifted: nine
 * rules disagreed, including a stale toast in admin and hardcoded hex
 * colours where the rest used tokens.
 *
 * Deliberately NOT here: body, header, main, table, and the print rules.
 * Those genuinely differ per app and should — trash-analysis is a dense
 * print-oriented data view, hub is a 900px landing page. Only the widget
 * layer is shared.
 *
 * :root tokens are no longer here EITHER, but for the opposite reason:
 * they now live in tokens.css, which every app loads BEFORE this file.
 * They used to be duplicated into four app stylesheets and had drifted
 * badly; this file resolves var() against tokens.css and must never
 * redefine a token itself.
 *
 * Deployed into every app's bucket by cdn_stack.py as an extra
 * BucketDeployment source (see libs/frontend_common/README.md). Loaded
 * BEFORE each app's own styles.css, so an app can still override a rule
 * locally when it genuinely needs to.
 *
 * Drift was resolved by majority, and every choice is recorded in
 * docs/superpowers/plans/2026-07-25-hardening-and-migration-plan.md.
 */

/* ── Form controls inherit the type system ────────────────────────────
 * A UA stylesheet gives button, input, select and textarea their OWN font
 * — Chromium's is `400 13.333px Arial` — and that font does NOT inherit
 * from the page. So every control rendered in Arial while the text around
 * it was IBM Plex Sans.
 *
 * MEASURED on the deployed staging insurance page, 2026-09-08, not
 * inferred: 54 elements computed a font-family whose first family was
 * `Arial` — the account menu button, Log out, "+ Add another set of
 * details", "Remove this coverage line", "Download", every "Move to…"
 * select and its options, and all 21 file inputs — against 507 on IBM Plex
 * Sans. tokens.css's own header calls this "three families, three jobs,
 * one type system"; it was four families, and the fourth was nobody's
 * choice.
 *
 * This is a BASE rule rather than another per-widget declaration because
 * per-widget is what failed: `font-family: inherit` already appears
 * eleven times in this file, once per rule somebody remembered, and the
 * controls that reached Arial are the ones nobody wrote a rule for at all
 * — including every control an APP defines, which this file cannot
 * enumerate. Setting it once on the element types the UA sheet overrides
 * closes the class instead of the instances.
 *
 * font-family only. font-size is deliberately left to the UA and to each
 * rule: changing the size of every control at once moves layout on five
 * apps, which is a different change with a different risk.
 */
button, input, select, textarea, optgroup {
  font-family: inherit;
}

/* ── The app header bar ───────────────────────────────────────────────
 * This file's docstring says headers are deliberately per-app, and the
 * LAYOUT still is — what goes in the middle differs completely (rent-
 * analysis puts a rent-roll date picker and an export menu there, hub puts
 * nothing). What must not differ is the frame and the brand, and it did:
 *
 *   - hub/admin      full-bleed bar, padding 20px 24px
 *   - trash-analysis full-bleed bar, .header-inner, fixed 100px tall
 *   - rent-analysis  a ROUNDED CARD floating inside .page, with a
 *                    margin-bottom — not a bar at all
 *
 * So moving between apps on one domain visibly changed the furniture. All
 * four now use .app-header > .app-header-inner, and rent-analysis's header
 * moved out of .page to become a real <header> element rather than a
 * <div class="header">.
 *
 * The gradient is gone too. It was linear-gradient(135deg, --blue-900,
 * --blue-700) in all four; flat --navy reads as deliberate at this size,
 * and a gradient across a 1400px bar bands visibly on 8-bit displays.
 */
.app-header {background: var(--navy); color: var(--white); padding: 0 24px;
  box-shadow: 0 1px 0 rgba(0,0,0,.15), 0 2px 10px rgba(33,29,23,.10);}
.app-header-inner {max-width: 1400px; margin: 0 auto; display: flex; align-items: center;
  gap: 20px; min-height: 64px; padding: 12px 0; flex-wrap: wrap;}

/* Brand lockup: the platform wordmark, then this app's name.
 *
 * The wordmark is the first real platform identity in the UI — hub's <h1>
 * said "My Apps" and nothing anywhere named the product. It is also a LINK
 * TO "/", which was the navigation fix: the only way back to the launcher
 * used to be an "← All Apps" row inside the account-menu dropdown, i.e.
 * behind a click, in a menu whose other entries are identity and session
 * actions. A persistent home affordance is the convention every
 * multi-app suite uses, and it costs one element.
 *
 * That row has since retired outright, replaced by the app switcher below.
 * The wordmark carries the current org/property home rather than dropping
 * it: it declares data-scope-link="/" and auth-client.js's
 * refreshScopedLinks() owns its href from load onward. */
/* Two rows: the org name (added 2026-09-17, see [data-brand-org] below),
 * then everything the brand lockup already had -- the wordmark/property
 * name plus this app's own name. `.app-brand-line` carries the layout
 * `.app-brand` itself used to (baseline-aligned, wrapping row); `.app-brand`
 * now only stacks its two rows and lets each size itself, so a org-less
 * platform surface (hub, admin -- no [data-brand-org] slot declared) still
 * lays out identically to before this existed. */
.app-brand {display: flex; flex-direction: column; gap: 1px; min-width: 0;}
.app-brand-line {display: flex; align-items: baseline; gap: 10px; min-width: 0; flex-wrap: wrap;}
.app-wordmark {font-family: var(--font-display); font-weight: 700; font-size: 1.25rem;
  letter-spacing: -.01em; color: var(--white); text-decoration: none; flex: none;}
a.app-wordmark:hover {text-decoration: underline; text-underline-offset: 3px;}
/* The separator is generated, not typed into four index.html files. */
.app-brand-line .app-name::before {content: ''; display: inline-block; width: 1px; height: 15px;
  background: rgba(255,255,255,.35); margin-right: 10px; vertical-align: -2px;}

/* The org name, above the property+app row. Dan, 2026-09-17: "didn't we
 * say we should put the org name somewhere else? like maybe above the
 * property name?" -- filled by renderBrand() from [data-brand-org], same
 * empty-while-unknown contract as [data-brand-property] below. Quieter
 * than both rows under it (smaller, uppercase, more transparent): it is
 * context for the property name, not competing with it -- the property is
 * still the answer to "whose figures are these", which is the thing that
 * actually has to stay legible at a glance. */
[data-brand-org]:empty {display: none;}
.app-org {font-family: var(--font-sans); font-size: .68rem; font-weight: 600;
  letter-spacing: .05em; text-transform: uppercase; color: rgba(255,255,255,.55);
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}

/* The client's own brand, where our wordmark used to be.
 *
 * An app whose numbers belong to ONE property declares its wordmark element
 * as a SLOT (`data-brand-property`, empty in the markup); auth-client.js's
 * renderBrand() writes the active property's name into it and empties it
 * again when there is none. So the two rules below are what the header looks
 * like in the window before that name is known, and in an org that has no
 * property at all.
 *
 * HIDDEN WHILE EMPTY rather than left as a zero-width link: an invisible
 * clickable 10px of flex gap is worse than no control. The way home in that
 * window is the app switcher's "My Apps", which initAppSwitcher() renders
 * with no tiles before any fetch resolves and leaves rendered if none ever
 * does.
 *
 * THE DIVIDER GOES WITH OUR WORDMARK. It existed to separate two brands and
 * there is only one now: "Hutchins Insurance" is one phrase, which is how
 * the client wrote it. Same specificity as the rule above (one attribute +
 * one class + the pseudo-element, against two classes + the pseudo-element),
 * so it has to sit after it -- not before. */
[data-brand-property]:empty {display: none;}
[data-brand-property] + .app-name::before {display: none;}
.app-name {font-family: var(--font-sans); font-size: .95rem; font-weight: 600;
  color: rgba(255,255,255,.82); margin: 0; letter-spacing: .01em; min-width: 0;}
/* Same phrase, same typeface. Ellen Rourke, 2026-09-16, looking at the
   header: "do you think [the property name] Insurance should be on the
   same font-looking?" -- ".app-wordmark" (the property name) is serif
   (--font-display); ".app-name" ("Insurance") was sans (--font-sans), so
   two halves of one phrase (see the divider rule above) were set in two
   different typefaces. Font-FAMILY only -- weight/size/color stay so the
   property name (primary identity) still reads more prominent than the
   app label (secondary), same hierarchy as before, just one typeface. */
[data-brand-property]:not(:empty) + .app-name {font-family: var(--font-display);}
@media (max-width: 620px) {
  .app-header-inner {min-height: 0; gap: 12px;}
  .app-brand .app-name::before {display: none;}
  .app-name {flex-basis: 100%;}
}

/* ── The scope bar ───────────────────────────────────────────────────
   Which org — and, where the app has one, which property — every number
   on the page belongs to. Lives in the header's centre zone, in the
   [data-app-context] host every app already declares (it used to hold
   renderActiveContext's read-only chip, which reported scope and could
   not change it). Same translucent-white treatment that chip used, since
   it sits on the same dark header. */
.scope-bar {display: flex; align-items: center; gap: 8px; min-width: 0;}
.scope-control {position: relative;}
.scope-btn {display: inline-flex; align-items: baseline; gap: 7px;
  background: rgba(255,255,255,.10); border: 1px solid rgba(255,255,255,.28);
  border-radius: 100px; padding: 5px 13px; cursor: pointer; font-family: inherit;
  max-width: 320px; transition: background .15s, border-color .15s;}
.scope-btn:hover {background: rgba(255,255,255,.18); border-color: rgba(255,255,255,.45);}
/* A one-option picker is readable and inert — a dead-end list teaches
   people the control does nothing. Explicit, so it does not inherit the
   hover above and look clickable. */
.scope-btn[disabled] {cursor: default; opacity: 1;}
.scope-btn[disabled]:hover {background: rgba(255,255,255,.10); border-color: rgba(255,255,255,.28);}
.scope-label {font-size: .62rem; font-weight: 700; text-transform: uppercase;
  letter-spacing: .07em; color: rgba(255,255,255,.62); flex: none;}
/* When .scope-label is the ONLY visible text on its button (the value is
   .visually-hidden -- see buildScopePicker's hideValueOnButton), it must
   NOT read as a small-caps prefix meant to sit beside a value. Matches
   .app-switcher-btn's own figures exactly, since these two buttons share
   one header cluster and should look like the same kind of control. */
.scope-label-solo {font-size: .8rem; font-weight: 600; text-transform: none;
  letter-spacing: normal; color: var(--white);}
.scope-value {font-size: .82rem; font-weight: 600; color: var(--white);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
/* In the accessibility tree, out of the visual layout -- NOT display:none,
   which removes it from both. Used to keep a control's accessible name
   (e.g. the property picker's current value, still read to a screen
   reader) while a sighted reader sees only the generic label, because the
   value is already stated elsewhere on screen. clip over clip-path for the
   same reason every standard visually-hidden recipe uses it: broader
   browser support, and nothing here needs the shape clip-path offers. */
.visually-hidden {position: absolute; width: 1px; height: 1px; padding: 0;
  margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap;
  border: 0;}
.scope-panel {position: absolute; top: calc(100% + 8px); left: 0; width: 280px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); z-index: 50;
  opacity: 0; transform: translateY(-6px); pointer-events: none;
  transition: opacity .15s, transform .15s; overflow: hidden;}
.scope-control.open .scope-panel {opacity: 1; transform: translateY(0); pointer-events: all;}
.scope-search {width: 100%; box-sizing: border-box; border: none;
  border-bottom: 1px solid var(--surface-sunken); padding: 10px 14px;
  font-family: inherit; font-size: .85rem; color: var(--ink);}
.scope-list {max-height: 280px; overflow-y: auto;}
.scope-option {display: flex; align-items: baseline; gap: 6px; width: 100%;
  text-align: left; padding: 10px 14px; background: none; border: none;
  font-family: inherit; font-size: .86rem; font-weight: 600; color: var(--ink-body);
  cursor: pointer; box-sizing: border-box;}
.scope-option:hover {background: var(--surface-sunken);}
.scope-option[aria-current="true"] {color: var(--accent);}
.scope-option-note {font-size: .74rem; font-weight: 600; color: var(--ink-soft);}
.scope-empty {padding: 12px 14px; font-size: .82rem; color: var(--ink-soft);}
.scope-panel-footer {display: flex; gap: 14px; padding: 10px 14px;
  border-top: 1px solid var(--surface-sunken);}
.scope-panel-footer:empty {display: none;}
/* The property picker's own panel opens RIGHT-anchored, not left, like
   .app-switcher-panel just above -- its control moved into that same
   far-right, margin-left:auto cluster on 2026-09-17
   (arrangeHeaderControls()), and .scope-panel's shared left:0 assumed a
   control with room to ITS right, which is true of the org picker (still
   left-positioned, in the scope bar) but no longer true here. Found as a
   real, reproducible horizontal-overflow failure
   (tests/browser/assertions.py's assert_no_horizontal_overflow), not
   guessed at: the panel measured 1313px wide in a 1280px viewport.
   ID-scoped rather than a second shared class, matching #scopeOrgBtn's
   own narrow-viewport override just below -- there is exactly one
   property picker per page. */
#scopePropertyPanel {left: auto; right: 0;}
/* A link that named an org/property this account cannot reach. Sits in the
   bar itself rather than on a page of its own: the bar is already the one
   surface about which org/property this is, and the failure is that the
   answer is not the one the link asked for. Loud on the dark header on
   purpose — the alternative it replaces is silently showing somebody a
   different organization's figures. */
/* --danger, not the old rgba(220,38,38,.92)/#b91c1c pair: those were the
   only bare colour literals in the shared widget layer, a different red
   from the palette's, on the loudest element on the platform. The .92
   alpha bought nothing over the solid token (spec §4 item 4). */
.scope-alert {display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  background: var(--danger); border-radius: 100px; padding: 5px 8px 5px 14px;
  max-width: 46vw;}
.scope-alert-text {font-size: .78rem; font-weight: 600; color: var(--white);}
.scope-alert-action {flex: none; background: var(--white); border: none;
  border-radius: 100px; padding: 4px 12px; font-family: inherit; font-size: .76rem;
  font-weight: 700; color: var(--danger); cursor: pointer;}
/* Narrow screens collapse to ONE control showing the property, with the
   org as secondary text beneath it. Both stay reachable; only the
   emphasis changes. */
@media (max-width: 620px) {
  .scope-bar {flex-direction: column; align-items: flex-start; gap: 0;}
  #scopePropertyControl {order: 1;}
  #scopeOrgControl {order: 2;}
  .scope-btn {background: none; border: none; border-radius: 0; padding: 1px 0; max-width: 62vw;}
  #scopeOrgBtn .scope-label {display: none;}
  #scopeOrgBtn .scope-value {font-size: .7rem; font-weight: 600; color: rgba(255,255,255,.7);}
  .scope-panel {width: 240px;}
}

/* ── The app switcher ────────────────────────────────────────────────
   Sits in the same corner container as the account menu, BEFORE it:
   switching app is navigation and happens often, logging out is session
   and happens once a day. Styled as an on-dark pill like .scope-btn
   rather than a second avatar — it is a different kind of control and
   should not read as one, and the header now holds two dropdowns whose
   only distinguishing feature would otherwise be their glyph.

   The panel is right-anchored: the button sits at the right edge of a
   1400px header, so a left-anchored panel would hang off the viewport. */
.app-switcher {position: relative;}
.app-switcher-btn {display: inline-flex; align-items: center; gap: 6px;
  background: rgba(255,255,255,.10); border: 1px solid rgba(255,255,255,.28);
  border-radius: 100px; padding: 6px 14px; cursor: pointer; font-family: inherit;
  font-size: .8rem; font-weight: 600; color: var(--white); flex: none;
  transition: background .15s, border-color .15s;}
.app-switcher-btn:hover {background: rgba(255,255,255,.18); border-color: rgba(255,255,255,.45);}
.app-switcher-panel {position: absolute; top: calc(100% + 10px); right: 0; width: 260px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); z-index: 50;
  opacity: 0; transform: translateY(-6px); pointer-events: none;
  transition: opacity .15s, transform .15s; overflow: hidden;}
.app-switcher.open .app-switcher-panel {opacity: 1; transform: translateY(0); pointer-events: all;}
.app-switcher-item {display: block; width: 100%; text-align: left; padding: 12px 16px;
  background: none; border: none; font-size: .88rem; font-weight: 600;
  cursor: pointer; color: var(--ink-body); text-decoration: none; box-sizing: border-box;}
.app-switcher-item:hover {background: var(--surface-sunken);}
/* "My Apps" is the way home, not a peer of the apps under it. */
#appSwitcherHome {border-bottom: 1px solid var(--surface-sunken);}

/* Show/hide, at the foot of the panel and visually below the apps it acts
   on — management, not navigation, so it must not read as another place to
   go. It is ALWAYS rendered when there is anything hideable (never behind a
   disclosure) because it is the only way back for someone who hid every
   app, and a control you have to find first is not a way back. */
.app-switcher-manage {border-top: 1px solid var(--line); background: var(--surface-sunken);}
.app-switcher-section-title,
.app-switcher-manage-title {padding: 10px 16px 4px; font-size: .68rem; font-weight: 700;
  letter-spacing: .06em; text-transform: uppercase; color: var(--ink-soft);}
.app-switcher-manage-item {display: block; width: 100%; text-align: left;
  padding: 8px 16px; background: none; border: none; font-family: inherit;
  font-size: .82rem; cursor: pointer; color: var(--ink-body); box-sizing: border-box;}
.app-switcher-manage-item:hover {background: var(--surface);}
/* The panel can now be taller than a short viewport (five apps plus a row
   each), so it scrolls rather than running off the bottom of the screen —
   the row that scrolls out of reach would be the restore control. */
.app-switcher-panel {max-height: min(70vh, 520px); overflow-y: auto;}

#toast {position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 200; display: flex; flex-direction: column; gap: .5rem;}
.account-menu {position: relative;}
/* Was a fixed 38px circle holding a single-letter initial. Dan, 2026-09-17:
   "instead of just my initial maybe my whole name please?" -- now an
   auto-width pill, the same shape/figures as .app-switcher-btn beside it
   (same header cluster, same kind of control), sized to whatever name it
   holds instead of a fixed diameter. */
.account-menu-btn {
  border-radius: 100px;
  border: 1px solid rgba(255,255,255,.4);
  background: rgba(255,255,255,.14);
  color: var(--white);
  font-weight: 600;
  font-size: .8rem;
  padding: 6px 14px;
  cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  flex-shrink: 0;
  transition: background .15s;}
.account-menu-btn:hover {background: rgba(255,255,255,.24);}
.account-menu-email {font-size: .78rem; color: var(--ink-soft); margin-top: 2px;}
/* The account menu's corner container. auth-client.js RENDERS the menu
   into whichever element declares data-account-menu, so per this file's
   own rule the container's styling belongs here rather than in four
   app stylesheets that had already drifted (hub/admin called it
   .header-right, trash-analysis .header-actions, and rent-analysis had
   none at all -- which is how its menu ended up inline with the Print
   and Export buttons). 2026-07-28. */
[data-account-menu] {display: flex; align-items: center; gap: 14px; margin-left: auto;}

/* The active org's client-branding logo, rendered into [data-account-menu]
 * by auth-client.js's renderHeaderLogo() -- NOT hardcoded per-app markup.
 * Ellen Rourke asked for "the NHA Solutions logo on the right side of the
 * app" (2026-09-07); the owner then made it a per-org CONFIG value
 * (identity.set_org_logo, carried on /api/me as active_org_logo_url)
 * because this platform is multi-tenant and a hardcoded <img> in every
 * index.html would brand every future client's view with NHA's mark. See
 * docs/superpowers/plans/2026-09-07-ellen-feedback.md, Task 21 (REVISED).
 * An org with no logo configured -- every org today except NHA's -- gets
 * no element at all, not a broken-image icon.
 *
 * This rule itself is still generic (a sized, padded, light-backdrop
 * frame around whatever image src the active org's config resolves to),
 * same as it would be for any future second client's own mark -- nothing
 * here names NHA. Only the DATA is NHA-specific, and only NHA's org
 * record actually carries a value.
 *
 * Previously duplicated, near-identically, between rent-analysis's and
 * trash-analysis's own stylesheets (the two apps that had hand-written the
 * `<img>` before this). The two copies agreed on the base rule; they
 * disagreed on two things, reconciled here:
 *   - the responsive collapse breakpoint (780px vs 700px) -- kept at
 *     780px, rent-analysis's value, since it already collapses its own
 *     header at that width for the same reason (see that app's old
 *     kpi-split breakpoint) and shrinking the logo a little earlier is
 *     the safer direction for legibility on every other app too.
 *   - the print size (rent-analysis kept it full-size in print and forced
 *     print-color-adjust so the pale backdrop pill actually reproduces;
 *     trash-analysis shrank it to match its own mobile breakpoint but
 *     never forced color-adjust). Kept trash-analysis's smaller print
 *     size -- a printed report has less use for a large corner logo than
 *     a screen does -- PLUS rent-analysis's print-color-adjust guarantee,
 *     so the backdrop is not left to a browser's default of dropping
 *     background colours in print. */
/* Sized by HEIGHT with width:auto, so the pill hugs whatever aspect the org's
   own mark happens to have. That stopped being theoretical on 2026-09-08: the
   NHA wordmark it was designed around is 5.97:1, and the NHA Solutions mark
   that replaced it is 1.49:1 — a near-square badge. The horizontal padding
   came down from 10px to 6px in the same change, because padding tuned to sit
   either side of a long wordmark reads as a wide empty margin around a badge. */
/* 44px -> 52px, 2026-09-16: "national housing solutions, if I could have
   this a little bit bigger, please." Mobile and print stay proportionally
   sized up too (38px -> 44px each) -- see the print rule below, which is
   deliberately pinned to the mobile size, not a third independent number.
   52px -> 104px, 2026-09-17: 52px still read as "exactly the same" to her --
   a real user report, not a design guess, so this doubles rather than
   nudges. Mobile/print doubled the same way (44px -> 88px), same pinning. */
.header-nha-logo {height: 104px; width: auto; flex-shrink: 0; border-radius: 8px;
  padding: 4px 6px; background: rgba(255,255,255,.92);}
@media (max-width: 780px) {
  .header-nha-logo {height: 88px;}
}

.account-menu-header {padding: 14px 16px; border-bottom: 1px solid var(--surface-sunken);}
.account-menu-item {display: block; width: 100%; text-align: left; padding: 12px 16px;
  background: none; border: none; font-size: .88rem; font-weight: 600;
  cursor: pointer; color: var(--ink-body);
  text-decoration: none; 
  box-sizing: border-box;}
.account-menu-item:hover {background: var(--surface-sunken);}
/* .account-menu-label / .account-menu-section / .account-menu-select were
   deleted here on 2026-08-10. They styled the retired <select> switchers
   (Organization/Property) that the scope bar replaced; nothing under apps/
   or libs/ emits any of the three any more. Task 4 swept hub's own
   styles.css and missed the SHARED sheet, which is the more expensive miss:
   dead rules here ship to every app and read like a live component. */
.account-menu-logout {color: var(--danger);}
/* Secondary, inline action inside a menu SECTION (as opposed to
   .account-menu-item, which is a full-width row) — "Rename property" and
   "+ Add property" sit side by side under the property switcher.
   auth-client.js's ACCOUNT_MENU_ITEMS.property has generated elements with
   this class since the declarative menu landed, but the rule only ever
   existed in rent-analysis's and trash-analysis's OWN stylesheets — the
   two apps that happened to declare `property` in their data-menu. So a
   class emitted by SHARED code was styled in 2 of 4 apps: exactly the
   drift this file exists to prevent, and it would have shipped unstyled
   the moment any other app added `property` to its menu. Moved here,
   deleted from both app stylesheets. */
.account-menu-link {background: none; border: none; padding: 0; font-family: inherit;
  font-size: .78rem; font-weight: 600; color: var(--accent); cursor: pointer;
  text-decoration: underline; text-underline-offset: 2px;}
.account-menu-link:hover {color: var(--accent-hover);}
.account-menu-name {font-weight: 700; font-size: .92rem; color: var(--ink);}
.account-menu-panel {position: absolute;
  top: calc(100% + 10px);
  right: 0;
  width: 260px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lg);
  z-index: 50;
  opacity: 0;
  transform: translateY(-6px);
  pointer-events: none;
  transition: opacity .15s, transform .15s;
  overflow: hidden;}
.account-menu.open .account-menu-panel {opacity: 1; transform: translateY(0); pointer-events: all;}
/* Buttons. Each variant declares its OWN hover colour rather than the
   blanket `.btn:hover {opacity:.88}` this replaces — fading the whole
   element also faded its label, which dropped .btn-secondary's text from
   10.5:1 to roughly 9:1 and .btn-danger's outline to near-invisible, for
   no reason beyond it being one line to write. Explicit hovers also mean a
   disabled button (opacity .55) no longer changes appearance on hover, so
   "this does nothing" now reads correctly. */
.btn {display: inline-flex;
  align-items: center;
  gap: .4rem;
  padding: .55rem 1.25rem;
  border-radius: var(--radius-sm);
  font-size: .875rem;
  font-weight: 600;
  border: 1px solid transparent;
  cursor: pointer;
  transition: background-color .15s, border-color .15s, color .15s, transform .1s;
  font-family: inherit;}
.btn-danger {background: transparent; color: var(--danger); border-color: var(--danger); padding: .3rem .7rem; font-size: .78rem;}
.btn-danger:hover {background: var(--danger-soft);}
.btn-on-dark {background: rgba(255,255,255,.14); color: var(--white); border-color: rgba(255,255,255,.4);}
.btn-on-dark:hover {background: rgba(255,255,255,.26);}
.btn-primary {background: var(--accent); color: var(--white);}
.btn-primary:hover {background: var(--accent-hover);}
.btn-secondary {background: var(--surface); color: var(--ink-body); border-color: var(--line-strong);}
.btn-secondary:hover {background: var(--surface-sunken); border-color: var(--ink-faint);}
.btn-sm {padding: .35rem .85rem; font-size: .82rem;}
.btn-success {background: var(--success); color: var(--white);}
.btn-success:hover {background: var(--success-hover);}
.btn:active {transform: scale(.97);}
/* Disabled must win over every variant's :hover above, hence the higher
   specificity — a disabled .btn-primary was still repainting on hover. */
.btn:disabled, .btn[disabled] {opacity: .55; cursor: default;}
.btn:disabled:hover, .btn[disabled]:hover {background: inherit;}

/* ── Keyboard focus, defined once for every shared control ────────────
   There was previously NO focus styling anywhere in this file, and
   .modal-input:focus actively did `outline: none` while replacing it with
   only a border-colour change — a 1px hue shift as the sole indicator of
   where keyboard focus is. :focus-visible (not :focus) so a mouse click
   does not leave a ring behind on every button it touches. */
.btn:focus-visible,
.account-menu-btn:focus-visible,
.app-switcher-btn:focus-visible,
.app-switcher-item:focus-visible,
.account-menu-item:focus-visible,
.account-menu-link:focus-visible,
.dropdown-item:focus-visible,
/* The scope pickers get the same ring as everything else. They replaced
   two native <select>s, which got a focus indicator for free; a <div> and
   a <button> do not, and the spec (§9) makes keeping the keyboard
   affordance an explicit non-regression rather than a nice-to-have. The
   JS half — roving focus, aria-expanded, aria-current, Escape — shipped
   with the pickers; only the visible ring was missing, which is the half
   nothing in tests/js/ can see. */
.scope-btn:focus-visible,
.scope-search:focus-visible,
.scope-option:focus-visible,
.scope-alert-action:focus-visible,
.modal-input:focus-visible,
.tour-skip:focus-visible,
.tour-back:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  box-shadow: var(--focus-ring);
}
/* On the navy header the accent ring has too little contrast against the
   dark ground, so on-dark controls get a white ring instead. .scope-btn
   sits on that same navy bar; .scope-option/.scope-search are inside the
   white panel below it and keep the accent ring above.
   This matters MORE since 2026-09-08, not less: the accent used to be a
   terracotta, which at least differed in hue from the navy ground. It is
   now a blue, so an accent ring on navy would be near-invisible. The
   white-ring rule below is what makes that safe, and it predates the
   palette change -- it was already right for the wrong colour. */
.btn-on-dark:focus-visible,
.app-switcher-btn:focus-visible,
.scope-btn:focus-visible,
.account-menu-btn:focus-visible {
  outline-color: var(--white);
  box-shadow: 0 0 0 3px rgba(255,255,255,.35);
}
.dropdown {position: relative; display: inline-flex;}
.dropdown-item {display: block;
  width: 100%;
  text-align: left;
  padding: 10px 14px;
  background: none;
  border: none;
  font-size: .85rem;
  font-weight: 600;
  cursor: pointer;
  color: var(--ink-body);
  font-family: inherit;}
.dropdown-item:hover {background: var(--surface-sunken);}
.dropdown-panel {position: absolute;
  top: calc(100% + 6px);
  right: 0;
  min-width: 200px;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lg);
  z-index: 50;
  opacity: 0;
  transform: translateY(-6px);
  pointer-events: none;
  transition: opacity .15s, transform .15s;
  overflow: hidden;
  text-align: left;}
.dropdown.open .dropdown-panel {opacity: 1; transform: translateY(0); pointer-events: all;}
.modal-actions {display: flex; justify-content: flex-end; gap: 10px;}
.modal-box {background: var(--surface);
  border-radius: var(--radius);
  box-shadow: var(--shadow-lg);
  padding: 22px 24px;
  width: 90%; max-width: 380px;}
/* Large-form variant. .modal-box is a 380px confirm dialog; an app whose
   modal is a real form (trash-analysis's invoice review: ~14 fields) needs
   width, its own scroll region and a sticky header/footer. One shared
   implementation, because trash-analysis carrying its own .modal-backdrop/
   .modal stack was the two-modal drift the design-refresh spec's Surface-4
   defect 2 calls out — the same class of copy the account menu was
   consolidated to prevent. Metrics copied from the app's implementation
   (some differ from the original spec sentence for this task — see the
   handoff doc's fixed entry) so the consolidation is invisible. */
.modal-box--form { max-width: 720px; max-height: 90vh; overflow-y: auto;
  padding: 0; width: 100%; }
.modal-head { display: flex; align-items: center; justify-content: space-between;
  padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--surface-sunken);
  position: sticky; top: 0; background: var(--surface); z-index: 1; }
.modal-head h2 { font-size: 1.05rem; font-weight: 700; margin: 0; }
.modal-body { padding: 1.5rem; }
.modal-foot { display: flex; justify-content: flex-end; gap: .75rem;
  padding: 1rem 1.5rem; border-top: 1px solid var(--surface-sunken);
  position: sticky; bottom: 0; background: var(--surface); }
.modal-close { background: none; border: none; font-size: 1.4rem;
  line-height: 1; cursor: pointer; color: var(--ink-soft); padding: .2rem; }
.modal-close:hover { color: var(--ink); }
/* .modal-toggle: opt-in show/hide behaviour for a modal that is mounted
   once and toggled via .open (trash-analysis's invoice-review modal),
   rather than created/removed from the DOM like every other app's confirm
   dialogs (auth-client.js's session/invite prompts). Scoped to this
   modifier alone — .modal-overlay itself must stay visible-by-default, or
   every dialog that never adds .open would render blank. Carries the same
   fade (opacity/pointer-events) trash-analysis's own .modal-backdrop used,
   plus the translateY(16px)->0 slide its .modal used, now scoped to
   .modal-box.
   Also carries that same .modal-backdrop's `padding: 1rem` — its viewport
   gutter. Restoring it here rather than on .modal-overlay is deliberate:
   the 720px .modal-box--form form modal is exactly what .modal-toggle
   gates, and without the gutter it sits flush against the screen edges on
   any viewport ≤720px wide. .modal-overlay's own dialogs (every other
   app's confirm prompts) don't need it — they self-gutter via their
   380px .modal-box's `width: 90%`, which already keeps 5% clear on each
   side at any width. Putting the padding on .modal-overlay instead would
   double-gutter those and was never the bug being fixed. */
.modal-toggle { opacity: 0; pointer-events: none; transition: opacity .2s;
  padding: 1rem; }
.modal-toggle.open { opacity: 1; pointer-events: all; }
.modal-toggle .modal-box { transform: translateY(16px); transition: transform .2s; }
.modal-toggle.open .modal-box { transform: translateY(0); }
.modal-input {width: 100%; padding: 8px 10px; border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm); font-size: .9rem; margin-bottom: 16px;
  font-family: inherit; color: var(--ink-body); background: var(--surface);}
/* The `outline: none` that used to be here is gone — see the shared
   :focus-visible block above, which gives this a real ring. */
.modal-input:focus {border-color: var(--accent);}
.modal-message {font-size: .92rem; color: var(--ink); margin-bottom: 14px; line-height: 1.4;}
.modal-overlay {position: fixed; inset: 0; background: rgba(33,29,23,.45);
  display: flex; align-items: center; justify-content: center;
  z-index: 200;}
/* The client copyright footer. The BLOCK rule below was copied verbatim
   into all four app stylesheets (only these two child rules were shared),
   so the same 6-property declaration existed five times. Consolidated
   here, where the rest of it already lived.
   Its colour was #8595ac — 3.05:1 on white, failing WCAG AA for normal
   text, in the one element that appears on every page of every app and
   also gets printed. Now --ink-soft (5.9:1). */
.nha-copyright {max-width: var(--content-max); margin: 40px auto 0; padding: 16px 22px 20px;
  border-top: 1px solid var(--line); font-family: var(--font-sans);
  font-size: 10.5px; line-height: 1.55; color: var(--ink-soft); text-align: left;}
.nha-copyright p {margin:0}
.nha-copyright strong {font-weight:700}
/* Defined HERE, next to its only consumer. It used to live in each app's
   own styles.css, which meant this shared rule animated with a keyframe
   four per-app files were each responsible for supplying — a scaffolded
   app that omitted it got a silently un-animated toast. */
@keyframes slideIn {
  from { transform: translateX(100%); opacity: 0; }
  to { transform: translateX(0); opacity: 1; }
}
.toast-item {padding: .7rem 1.1rem; border-radius: var(--radius-sm); font-size: .875rem; font-weight: 600;
  color: var(--white); box-shadow: var(--shadow-lg); animation: slideIn .25s ease; max-width: 340px;}
/* Someone who has asked for less motion still needs to SEE the toast —
   it carries the result of the action they just took. Only the slide is
   dropped, never the element. */
@media (prefers-reduced-motion: reduce) {
  .toast-item {animation: none;}
  /* .scope-panel has the identical `transition: opacity .15s, transform
     .15s` as the three panels beside it and was simply missed when the
     scope bar landed. */
  .account-menu-panel, .app-switcher-panel, .dropdown-panel, .scope-panel,
  .tour-spotlight {transition: none;}
  .btn:active {transform: none;}
  /* .modal-toggle (trash-analysis's invoice-review modal) fades and slides
     on open/close the same as the panels above — same accommodation. */
  .modal-toggle, .modal-toggle .modal-box {transition: none;}
}
.toast-item.error {background: var(--danger);}
.toast-item.info {background: var(--navy);}
.toast-item.success {background: var(--success);}
.tour-clickcatcher {position: fixed; inset: 0; z-index: 400; background: transparent;}
.tour-skip, .tour-back {background: none; border: none; color: var(--ink-soft); font-size: .8rem;
  font-weight: 600; cursor: pointer; padding: 0; font-family: inherit;}
.tour-skip:hover, .tour-back:hover {color: var(--ink);}
.tour-spotlight {position: fixed;
  z-index: 401;
  border-radius: 8px;
  box-shadow: 0 0 0 9999px rgba(33,29,23,.6);
  pointer-events: none;
  transition: top .2s, left .2s, width .2s, height .2s;}
.tour-step-count {font-size: .72rem; color: var(--ink-soft); white-space: nowrap;}
.tour-tooltip {position: fixed;
  z-index: 402;
  width: 300px;
  max-width: calc(100vw - 28px);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lg);
  padding: 16px 18px;}
.tour-tooltip-actions {display: flex; align-items: center; gap: 10px;}
.tour-tooltip-footer {display: flex; align-items: center; justify-content: space-between;
  margin-top: 14px; gap: 10px;}
.tour-tooltip-text {font-size: .85rem; color: var(--ink-body); line-height: 1.45;}
.tour-tooltip-title {font-family: var(--font-display); font-weight: 600; font-size: 1rem;
  color: var(--ink); margin-bottom: 6px;}

/* Housing-program glossary — see glossary.js's initGlossary().
   A dotted underline plus a help cursor is the long-standing convention
   for "there is a definition behind this"; without an affordance the
   native tooltip is undiscoverable, and undiscoverable is what the
   acronyms already were. */
.has-glossary {text-decoration: underline dotted var(--ink-faint);
  text-underline-offset: 3px;
  cursor: help;}

/* Hidden until renderGlossaryLegend() fills it, so a page with no
   glossary terms shows no empty box. */
.glossary-key:empty {display: none;}
.glossary-key {padding: 14px 18px;}

.glossary-legend {display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: 6px 22px;
  margin: 0;
  font-size: .78rem;
  line-height: 1.45;}
.glossary-legend > div {display: flex; gap: 7px; align-items: baseline;}
.glossary-legend dt {font-weight: 700; color: var(--ink); white-space: nowrap;}
.glossary-legend dd {margin: 0; color: var(--ink-soft);}

/* ── Within-app view tabs ─────────────────────────────────────────────
   The CSS contract for libs/frontend_common/view-tabs.js, and shared for
   the same reason the component is: it renders `.tab-btn` into a
   `[data-view-tabs]` host and toggles `active` on both the buttons and the
   app's own `<section class="view" id="view-{id}">`.

   `.view`/`.view.active` are NOT decoration — they ARE the switching
   mechanism, and this is the widget layer rather than an app's page
   structure by exactly that argument. These four rules lived only in
   rent-analysis/frontend/styles.css while it was the sole consumer, written
   against that app's private `--rd-*` names; the second app to adopt the
   component would have rendered every view stacked at once, from CSS that
   looked correct in the one file it was in. Gated by
   tests/js/test_view_tabs_css_is_shared.js.

   Retargeted onto the semantic tokens on the way here — `--rd-border` and
   friends are rent-analysis's own migration shim (see its styles.css) and
   resolve to nothing anywhere else. */
.view { display: none; }
.view.active { display: block; }

.tab-bar {
  display: flex;
  gap: 4px;
  margin-bottom: 18px;
  border-bottom: 2px solid var(--line);
  overflow-x: auto;
}
.tab-btn {
  background: transparent;
  border: none;
  padding: 11px 18px;
  font-size: 13.5px;
  font-weight: 600;
  color: var(--ink-soft);
  cursor: pointer;
  border-bottom: 3px solid transparent;
  margin-bottom: -2px;
  white-space: nowrap;
  font-family: inherit;
}
.tab-btn:hover { color: var(--ink); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }

@media print {
  /* Print keeps its own values deliberately: --ink-soft/--line are tuned
     for the parchment ground, and on white paper the border needs to be
     darker than a screen hairline to reproduce at all on a laser printer. */
  .nha-copyright {color:#4A4A4A;border-top:1px solid #9A9A9A;page-break-inside:avoid;font-size:8.5px;margin-top:20px}
  /* Navigation is not content: printing the page you are looking at should
     not print the control that chose it. rent-analysis already did this in
     its own print block; it belongs with the component now that three apps
     render the bar. Only the ACTIVE view prints, which follows from
     `.view { display: none }` above and is the intended reading — you print
     what is on screen. */
  .tab-bar { display: none !important; }
  /* The legend is worth MORE on paper: a printed rent roll circulates to
     people who never saw the app and cannot hover anything. */
  .glossary-legend {font-size: 8.5px;}
  /* Smaller in print than on screen (matching the 780px mobile size, not
     the full 104px) -- a printed report has less use for a large corner
     logo than a screen does -- but with print-color-adjust forced: without
     it, a browser that drops background colours by default in print would
     silently lose the pale backdrop pill behind the logo. See the rule's
     own comment above for the two apps this was reconciled from. */
  .header-nha-logo {height: 88px; background: rgba(255,255,255,.92);
    -webkit-print-color-adjust: exact; print-color-adjust: exact;}
}

/* The row a search deep link pointed at (?row=), set by view-tabs.js's
   highlightRequestedRow. Shared rather than per-app: both rent-analysis and
   trash-analysis are linked into this way, and a highlight that looked
   different in each would read as two different features.

   A left rule plus a tint, not a background swap — the row still has to be
   readable as one of the table's rows, and the point is "here it is", not
   "this row is special". */
tr.row-target > td,
.row-target {
  background: var(--surface-sunken);
}
tr.row-target > td:first-child {
  box-shadow: inset 3px 0 0 var(--accent);
}

/* ── Stat tiles ───────────────────────────────────────────────────────
   The read-only figure card — a label, a big number, an optional sub-
   line — built three separate times: admin's `.kpi`, trash-analysis's
   `.stat-card`, rent-analysis's `.kpi`. NOT hub's `.overview-card`: that
   is an interactive navigation card (hover lift, focus ring, click-
   through to another page), a different responsibility from a tile that
   only ever displays a number, and it is explicitly out of scope here —
   "five implementations" is one shared tile plus hub's interactive card,
   not an unfinished four.

   STEP 1 INVENTORY, taken before this block existed (admin: styles.css
   ~64-77 pre-port; trash-analysis: styles.css ~300-314 pre-port; rent-
   analysis: styles.css ~87-130 pre-port). The three did not agree on
   every property, so this is the MAJORITY shape (2 of 3 agreeing wins;
   where no two agreed, the median of the three, which minimises total
   drift across all three vs. picking either endpoint). Every per-app
   delta this produces is declared below — undeclared deltas are review
   defects, not this list being incomplete.

     accent edge:    admin top/3px, rent top/3px, trash LEFT/4px
                      -> top/3px (2/3 majority). Trash's accent moves
                      from the left edge to the top and narrows 4px->3px.
     base border:    admin `1px solid` its own gray-300 alias, rent
                      `1px solid` its own --rd-border alias, both all
                      sides plus the accent; trash had NO base border at
                      all, only the left accent. This is NOT a majority
                      pick among three different colours: admin's
                      gray-300 shim (tokens.css:262) and rent's
                      --rd-border (rent's own styles.css:37) are both
                      aliases of --line, so writing `var(--line)` here
                      is RENDERING-IDENTICAL to what admin and rent
                      already painted, not a choice between competing
                      values. Trash is the only real delta: it gains a
                      1px var(--line) border on the other three sides
                      where it previously had none.
     padding:         admin 16px 18px, trash 20px 24px, rent 18px 20px
                      -> 18px 20px, the per-axis median. Admin +2px/+2px,
                      trash -2px/-4px, rent +-0px.
     border-radius:   admin 10px, trash 12px (var(--radius)), rent 10px
                      -> 10px (2/3 majority). Trash -2px.
     shadow:          admin/trash both var(--shadow-sm); rent used the
                      literal, un-tokenised `0 1px 3px rgba(0,0,0,.06)`
                      -> var(--shadow-sm) (2/3 majority). Rent's shadow
                      becomes the platform's warm two-layer shadow —
                      very slightly deeper — and loses its last raw
                      colour literal.
     background:      admin/rent both the literal `white`, trash
                      `var(--white)` -> `var(--surface)`. All three
                      already rendered #FFFFFF; this only normalises the
                      source text onto the semantic token. No visible
                      change.
     label size:      admin 10.5px, trash .78rem (12.48px), rent 10.5px
                      -> 10.5px (2/3 majority). Trash -1.98px.
     label weight:    admin 700, trash 600, rent 700 -> 700 (2/3
                      majority). Trash +100.
     label letter-sp: admin 0.7px, trash .05em (~0.62px at trash's old
                      size), rent 0.7px -> 0.7px (2/3 majority, and an
                      em->px unit change for trash).
     label margin-b:  admin 8px, trash .35rem (5.6px), rent 8px -> 8px
                      (2/3 majority). Trash +2.4px.
     value size:      admin 22px, trash 1.7rem (27.2px), rent 24px ->
                      24px, the median (no two agreed). Admin +2px,
                      trash -3.2px, rent +-0px.
     value weight/line-height/color: 700 / 1.1 / var(--ink) on all
                      three already — no delta.
     sub size:        admin 11px, trash .78rem (12.48px), rent 11.5px
                      -> 11.5px, the median (no two agreed). Admin
                      +0.5px, trash -0.98px, rent +-0px.
     sub margin-top/color: 4px / var(--ink-soft) on all three already —
                      no delta.
     value tint per accent: STATE-ONLY — a controller design ruling,
                      not a majority vote, and it overrides the vote
                      count below. admin and rent both originally tinted
                      the value TEXT on 4 of their 5 variants (every one
                      except the structural/default one); trash tinted
                      none of its 5 (only the left border tinted). A
                      literal majority-vote consolidation would have
                      extended tinting to every non-structure variant,
                      including the new --info. That is NOT what this
                      does: the platform rule is that a number's colour
                      reports its STATE (good/bad/needs-attention),
                      never its KIND or CATEGORY — and --info/--alt are
                      categorical (which kind of thing this is), not
                      states, so their value text is NOT tinted.
                      --positive/--caution/--negative ARE states, and
                      keep the tint; --structure never had one, in any
                      of the three original implementations, and still
                      doesn't. Consequences, per app: trash's
                      accent-info/accent-alt tiles return to the plain
                      var(--ink) value colour they always had (trash
                      never tinted any value text pre-consolidation, so
                      this is a reversion to its own prior behaviour,
                      not a new change for it); admin's kpi--alt tiles —
                      there are two, payments-section.js's "Trial Orgs"
                      and usage-section.js's "% Activated" — LOSE the
                      teal value tint they had pre-consolidation — a
                      deliberate, rule-aligned change, not an oversight;
                      rent never rendered --alt/--info in live markup
                      (only --structure is used today), so no visible
                      change there. Trash's
                      YoY card keeps its accent-positive/-negative/
                      -caution tints, including the no-prior-year-data
                      case (`stat-tile--caution`, an em-dash with no
                      percentage) — that IS a state (a missing/uncertain
                      comparison), not a category, so it correctly
                      keeps its amber value tint under this rule.

   ACCENT TOKENS — each app's variant classes were unwound through their
   own alias chains back to tokens.css, and every variant the three
   shared already resolved to the identical underlying colour, so
   consolidating cost zero colour drift, only naming:
     --info      var(--accent)     trash's accent-info was the blue-500
                                  shim, itself === var(--accent). rent's
                                  "purple" was var(--rd-accent) ===
                                  var(--accent) too (the class name is
                                  misleading — it was never actually
                                  purple) — mapped here rather than to
                                  --alt because the TOKEN, not the old
                                  name, is what already matched.
     --alt       var(--alt)      admin kpi--alt and trash's accent-alt
                                  (the teal-500 shim, === var(--alt))
                                  agree.
     --positive  var(--success)  admin/trash (the green-500 shim) and
                                  rent's "green" (the same green-500
                                  shim) all === var(--success) already.
     --caution   var(--warn)     admin/trash (the amber-500 shim) and
                                  rent's "amber" (var(--rd-warning)) all
                                  === var(--warn) already.
     --negative  var(--danger)   admin/trash (the red-500 shim) and
                                  rent's "red" (var(--rd-danger)) all
                                  === var(--danger) already.
     --structure var(--navy)     admin's kpi--structure and rent's
                                  "navy" (var(--rd-navy)) both ===
                                  var(--navy); trash never had this
                                  variant.

   Grid wrappers stay APP-LOCAL and keep their own grid-template-columns
   — only the tile itself is shared. Each app renames its own grid class
   to this shared `.stat-tiles` name (admin's `.kpi-grid`, trash's
   `.stats-grid`, rent's `.kpi-grid`) but keeps defining grid-template-
   columns/gap/margin in its own styles.css. rent-analysis's
   `.kpi-split`/`.kpi-mix`/`.mix-title`/`.mix-line` (the occupancy tile's
   two-column mix breakdown) and `.k-extra`/`.x-lbl`/`.x-num` (its
   Vacant/NTV mini-stats) are LAYOUT helpers, not tile styling, and stay
   in rent-analysis's own styles.css, re-parented under `.stat-tile` —
   they use rent's private `--rd-*` tokens, which is fine: that file is
   app-local CSS the no-shim-tokens ratchet does not apply to. */
.stat-tiles { display: grid; }

.stat-tile {
  background: var(--surface);
  border-radius: 10px;
  padding: 18px 20px;
  box-shadow: var(--shadow-sm);
  border: 1px solid var(--line);
  border-top: 3px solid var(--navy);
}
.stat-tile--info      { border-top-color: var(--accent); }
.stat-tile--alt       { border-top-color: var(--alt); }
.stat-tile--positive  { border-top-color: var(--success); }
.stat-tile--caution   { border-top-color: var(--warn); }
.stat-tile--negative  { border-top-color: var(--danger); }
.stat-tile--structure { border-top-color: var(--navy); }

.stat-tile__label {
  font-size: 10.5px;
  text-transform: uppercase;
  letter-spacing: 0.7px;
  color: var(--ink-soft);
  font-weight: 700;
  margin-bottom: 8px;
}
.stat-tile__value {
  font-size: 24px;
  font-weight: 700;
  color: var(--ink);
  line-height: 1.1;
}
/* --info and --alt are CATEGORICAL, not states, so their value text is
   deliberately NOT tinted here — see the "value tint per accent" note
   in the comment above. Only genuine states tint the number. */
.stat-tile--positive .stat-tile__value { color: var(--success); }
.stat-tile--caution  .stat-tile__value { color: var(--warn); }
.stat-tile--negative .stat-tile__value { color: var(--danger); }

.stat-tile__sub {
  font-size: 11.5px;
  color: var(--ink-soft);
  margin-top: 4px;
}

/* Loading placeholder. A pulsing ground instead of a bordered "Loading…"
   box: the box made a cold start read as flash-then-notice (the perceived
   half of the slow-first-load report in docs/client-feedback-2026-07-28.md
   item A4 — the request-count half already shipped as /api/bootstrap).
   Pulse, not shimmer-sweep: the platform's motion rules allow colour
   tweens and nothing travels. Under prefers-reduced-motion the pulse
   stops but the blocks REMAIN — drop the movement, never the element. */
.skeleton { display: grid; gap: 10px; }
.skeleton-line { height: 14px; border-radius: var(--radius-xs);
  background: var(--surface-sunken); animation: skeleton-pulse 1.2s ease-in-out infinite; }
.skeleton-line--title { height: 24px; width: 40%; }
.skeleton-line--half { width: 55%; }
@keyframes skeleton-pulse { 0%,100% { opacity: 1; } 50% { opacity: .55; } }
@media (prefers-reduced-motion: reduce) { .skeleton-line { animation: none; } }

/* ── Audit / activity log ──────────────────────────────────────────────
   The presentation half of libs/frontend_common/audit-format.js. It ships
   here rather than in admin's own styles.css because that renderer is
   shared — a widget whose CSS lives in one app's stylesheet renders
   unstyled the moment a second app adopts it, which is the whole reason
   .badge could not be reused for the flags below.

   The category tag uses the CATEGORICAL tokens, not the semantic-state
   ones, and that is the design decision rather than a palette convenience:
   these six answer "what kind of thing is this", never "how bad is it".
   Colouring `access` as danger and `egress` as warn would assert a
   severity ranking the log deliberately does not have — whether a revoked
   grant outranks a downloaded export depends entirely on what you are
   investigating (see audit_log.py's CATEGORIES comment). Genuine states —
   an import that FAILED, an action taken under support access — are the
   only things that get a semantic colour, via .audit-flag. */
.audit-filters { display: flex; align-items: center; gap: 10px;
  flex-wrap: wrap; margin: 4px 0 12px; }
.audit-chips { display: flex; gap: 6px; flex-wrap: wrap; }
.audit-chip { font: inherit; font-size: .74rem; font-weight: 600;
  padding: 4px 11px; border-radius: 999px; cursor: pointer;
  background: var(--surface); color: var(--ink-soft);
  border: 1px solid var(--line-strong); }
.audit-chip:hover { background: var(--surface-sunken); color: var(--ink-body); }
/* Selected state carries BOTH a fill and a weight change, never colour
   alone — the platform's contrast rule, and the reason a colourblind
   reader can still tell which filters are on. aria-pressed carries it for
   a screen reader. */
.audit-chip.is-on { background: var(--accent); border-color: var(--accent);
  color: var(--surface); font-weight: 700; }
.audit-search { flex: 1; min-width: 180px; max-width: 320px; }

/* The category tag that opens every "What happened" cell. */
.audit-cat { display: inline-block; margin-right: 7px; padding: 1px 7px;
  border-radius: var(--radius-xs); font-size: 10px; font-weight: 700;
  letter-spacing: .04em; text-transform: uppercase; vertical-align: 1px; }
.audit-cat--access { background: var(--cat-indigo-soft); color: var(--cat-indigo); }
.audit-cat--money  { background: var(--cat-olive-soft);  color: var(--cat-olive); }
.audit-cat--data   { background: var(--cat-slate-soft);  color: var(--cat-slate); }
.audit-cat--import { background: var(--cat-teal-soft);   color: var(--cat-teal); }
.audit-cat--egress { background: var(--cat-rust-soft);   color: var(--cat-rust); }
.audit-cat--system { background: var(--cat-plum-soft);   color: var(--cat-plum); }
/* Support access is deliberately the QUIETEST tag on the page, which is the
   inverse of how it might first seem. It is written once per HTTP request
   (auth.py, un-deduped on purpose), so it is the highest-volume and
   lowest-per-row-signal thing in any org's log; giving the most numerous
   row type the loudest colour would make every other event harder to find,
   which is the problem this whole redesign exists to fix. The volume is
   what carries the signal here, and the roll-up states it in words. */
/* --ink-soft, not --ink-faint: on --surface-sunken the faint token measures
   4.28:1, under the AA floor for normal text. Fixed on this rule rather than
   by darkening --ink-faint, which would shift muted text in every app to fix
   one chip. (.btn-secondary:hover above uses --ink-faint as a BORDER on the
   same ground, which is a 3:1 non-text case and correctly left alone.) */
.audit-cat--support { background: var(--surface-sunken); color: var(--ink-soft); }
/* An action the server could not classify. Same quiet ground as support but
   outlined, so it is distinguishable at a glance from it and still reads as
   the exception it is rather than as another category. */
.audit-cat--unclassified { background: var(--surface-sunken); color: var(--ink-soft);
  box-shadow: inset 0 0 0 1px var(--line-strong); }

/* States, not kinds — see the section comment. */
.audit-flag { display: inline-block; margin-left: 6px; padding: 1px 7px;
  border-radius: 999px; font-size: 10px; font-weight: 700; }
.audit-flag--danger { background: var(--danger-soft); color: var(--danger); }
.audit-flag--warn   { background: var(--warn-soft);   color: var(--warn); }

.audit-when { white-space: nowrap; color: var(--ink-soft); }
.audit-who, .audit-where { color: var(--ink-body); word-break: break-word; }
/* The sentence column is the one that should absorb the width, so the
   three ahead of it are held narrow. */
.audit-when, .audit-where { width: 1%; }
.audit-what { line-height: 1.45; }
/* The rejection reason from audit_import — a parser message, so monospace,
   and bounded rather than allowed to set the row height on its own. */
.audit-note { margin-top: 3px; font-family: var(--font-mono); font-size: .74rem;
  color: var(--danger); word-break: break-word; }

/* The raw stored record, collapsed. Every humanized row keeps one: the
   sentence is a summary ON TOP OF the record, never a replacement for it.

   THE OVERFLOW RULES ARE LOAD-BEARING, not polish. rent-analysis's
   send_tenant_message deliberately stores the real subject and body
   (capped at 500 + 2000 chars) because the audit trail is that route's
   stated safeguard — and the previous UI JSON.stringify'd that into a bare
   <td>, where one such row set the column widths for the entire table. */
.audit-raw { margin-top: 5px; }
.audit-raw > summary { cursor: pointer; font-size: .74rem; font-weight: 600;
  color: var(--ink-soft); list-style-position: outside; }
.audit-raw > summary:hover { color: var(--accent); }
.audit-raw > pre { margin: 6px 0 0; padding: 8px 10px; max-height: 260px;
  overflow: auto; background: var(--surface-sunken); border-radius: var(--radius-xs);
  font-family: var(--font-mono); font-size: .72rem; color: var(--ink-body);
  white-space: pre-wrap; word-break: break-word; }

/* ---------------------------------------------------------------------------
   Environment banner (auth-client.js's renderEnvironmentBanner)

   Rendered ONLY when auth-config.js reports a non-prod environment, so
   production never carries it. Fixed to the very top of the viewport with
   the page padded to match: a banner that scrolls away answers "which
   environment am I in" only while you are at the top, and the moment it
   matters is when you are looking at figures further down.

   --warn rather than --danger: this is an orientation cue, not an error.
   Semantic tokens only (tests/js/test_platform_css_uses_semantic_tokens.js
   forbids new colour literals here, and --warn's own comment records the
   contrast measurement behind its value).
--------------------------------------------------------------------------- */
.env-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 9999;
  padding: 4px 12px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--warn-soft);
  border-bottom: 1px solid var(--warn);
  /* WRAPPING IS ALLOWED, and the space reserved for it is MEASURED.
     This used to be white-space:nowrap + ellipsis against a fixed 24px
     body padding-top, so a banner that wrapped would have been taller than
     its reservation and clipped the page. That made the one-line assumption
     an invariant -- but it also meant the longest message got truncated on a
     narrow phone, and the longest message is the one that appears when the
     environment could not be identified at all: the case where the text
     matters most is the case it was cut from.
     renderEnvironmentBanner() now sets body's padding-top from the banner's
     real offsetHeight (and again on resize), so any wording at any viewport
     reserves exactly its own height. The 24px below stays as the no-JS
     fallback for a single line. */
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 9px;
  flex-wrap: wrap;
}
/* The name as a chip: caps and letterspacing on the label only, so the
   sentence beside it stays ordinary readable prose rather than shouting. */
.env-banner strong {
  flex: none;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 10.5px;
  padding: 2px 7px;
  border-radius: 3px;
  background: var(--warn);
  color: var(--surface);
}
.env-banner span {
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
}
body.has-env-banner {
  padding-top: 24px;
}
