/* ══════════════════════════════════════════════════════════════════════
   Nivasik — Components
   Rules of the house:
     · Motion is transform + opacity only, 120–280ms, ease-out on enter.
     · Every interactive thing has hover, active, focus-visible and disabled.
     · Nothing below 4.5:1 for text.
   ══════════════════════════════════════════════════════════════════════ */

/* ── Card ────────────────────────────────────────────────────────────── */
/* ── One material was 318 surfaces ───────────────────────────────────────
   `.card` carried a fill, a hairline and a radius, and no shadow — 318
   times, against 3 uses of `.card-elev`, the only variant that had one. The
   sidebar painted the same `--surface` as the content it frames, so
   structural chrome and content were literally the same material and the
   interface had no depth to say which of forty things mattered. In light
   that fill is #FFFFFF on a #F4F6F5 ground: 1.05:1, held apart by a 1.16:1
   hairline and nothing else, while tokens.css:201 promised "elevation from
   shadow, not fill".

   Three rungs, and a card picks one:
     .card         a thing on the page      — sits
     .card-elev    a thing above the page   — floats
     .card-sunken  a thing set into it      — recedes
   ──────────────────────────────────────────────────────────────────────── */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: var(--sp-5);
  box-shadow: var(--shadow-1);
}
.card-sunken { background: var(--sunken); box-shadow: none; }
.card-elev {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--sp-5);
  box-shadow: var(--shadow-1);
}
.card h3 { margin-bottom: var(--sp-3); }
.card-title {
  font-family: var(--font-display);
  font-size: var(--fs-tiny);
  font-weight: var(--fw-medium);
  text-transform: uppercase;
  letter-spacing: var(--tr-micro);
  color: var(--muted);
  margin-bottom: var(--sp-3);
}

/* ── KPI tile ────────────────────────────────────────────────────────── */
.kpi {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 20px;
  min-height: 110px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--sp-2);
}
/* No .kpi:hover. A stat tile is not interactive — nothing happens if you click
   it — so a 200ms border fade every time the pointer crosses a four-up row
   communicates nothing, and it was ungated, so a tap left one stuck lit on a
   phone. nv-bump, which fires when the VALUE changes, is the signal that means
   something; it is an animation, not this transition, and is untouched. */
.kpi .label {
  font-size: var(--fs-micro);
  text-transform: uppercase;
  letter-spacing: var(--tr-micro);
  color: var(--muted);
}
.kpi .value {
  font-family: var(--font-display);
  font-size: 32px;
  font-weight: var(--fw-medium);
  line-height: 1;
  letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
.kpi .delta { font-family: var(--font-mono); font-size: var(--fs-tiny); color: var(--muted); }
.kpi .delta.up { color: var(--accent); }
.kpi .delta.down { color: var(--danger-text); }

/* ── Command Center tiles ──────────────────────────────────────────────────
   A tile is a LINK. The whole point of the screen is that a number you can
   see is a number you can go to, so the affordance has to be the tile itself
   and not a chevron in the corner.

   Motion: transform and border-colour only, --dur-1, --ease-out, and the lift
   is gated behind a real pointer — on touch :hover latches after a tap and
   every tile you have pressed stays raised. */
.kpi-link {
  display: block;
  text-decoration: none;
  color: inherit;
  transition: transform var(--dur-1) var(--ease-out),
              border-color var(--dur-1) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .kpi-link:hover { transform: translateY(-2px); border-color: var(--border-strong); }
}
/* Press feedback, and it has to be a scale rather than "cancel the hover
   lift". On touch there IS no hover — the previous rule left a tapped tile
   completely inert until the next page painted, which on a slow connection is
   a second of wondering whether the tap registered. 70ms because a press
   response people notice as an animation is already too slow. */
.kpi-link:active { transform: scale(0.985); transition-duration: 70ms; }
@media (hover: hover) and (pointer: fine) {
  .kpi-link:hover:active { transform: translateY(-2px) scale(0.985); }
}
.kpi-link:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
  border-color: var(--primary);
}
/* No "needs attention" state on these tiles, deliberately. Every candidate
   condition — unpaid balance above zero, arrivals not yet checked in, dirty
   rooms before noon — is the NORMAL state of a working hotel, so a flag on it
   would be lit every hour of every day and would teach people to stop seeing
   it. The one real exception state here is SLA under target, and that already
   shows as a warn bar rather than as a shout. */
.kpi-sub {
  margin-top: 4px;
  font-size: var(--fs-tiny);
  color: var(--muted);
  line-height: 1.45;
}

/* ── Where the work is ─────────────────────────────────────────────────── */
.cc-cats { display: flex; flex-direction: column; gap: 2px; }
.cc-cat {
  display: grid;
  grid-template-columns: minmax(90px, 1fr) minmax(60px, 3fr) auto auto;
  align-items: center;
  gap: var(--sp-3);
  padding: 8px 6px;
  border-radius: var(--radius-sm);
  text-decoration: none;
  color: var(--text);
  transition: background-color var(--dur-1) var(--ease-out),
              transform var(--dur-1) var(--ease-out);
}
@media (hover: hover) and (pointer: fine) {
  .cc-cat:hover { background: var(--surface-2); }
}
.cc-cat:active { background: var(--surface-2); transform: scale(0.995); transition-duration: 70ms; }
.cc-cat:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; }
.cc-cat-name { font-size: var(--fs-small); text-transform: capitalize; overflow-wrap: anywhere; }
.cc-cat-bar {
  height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden;
}
.cc-cat-fill { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
.cc-cat-n { font-size: var(--fs-small); text-align: right; min-width: 2.5ch; }
.cc-cat-open { color: var(--muted); white-space: nowrap; min-width: 6ch; text-align: right; }

/* Below the fold of a phone the four-column label/bar/n/open grid is too much
   for 360px — the name gets one character per line. Stack the meta under it. */
@media (max-width: 560px) {
  .cc-cat { grid-template-columns: 1fr auto; grid-template-areas: 'name n' 'bar open'; gap: 4px var(--sp-2); }
  .cc-cat-name { grid-area: name; }
  .cc-cat-n    { grid-area: n; }
  .cc-cat-bar  { grid-area: bar; }
  .cc-cat-open { grid-area: open; }
}


/* ── Button ──────────────────────────────────────────────────────────── */
.btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 14px;
  min-height: 34px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--border-strong);
  background: var(--surface-2);
  color: var(--text);
  font-size: var(--fs-sm);
  font-weight: 500;
  line-height: 1.2;
  white-space: nowrap;
  user-select: none;
  transition: background-color var(--dur-1) var(--ease-out),
              border-color var(--dur-1) var(--ease-out),
              color var(--dur-1) var(--ease-out),
              transform var(--dur-1) var(--ease-out),
              opacity var(--dur-1) var(--ease-out);
}
.btn:hover { border-color: var(--primary); background: var(--surface-3); }
.btn:active { transform: scale(0.975); transition-duration: 70ms; }

/* --accent-fill / --on-accent, NOT --accent + a literal. --accent is the ink
   role and is #006E54 in light, so the primary CTA rendered as dark green
   carrying near-black ink at 3.07:1. --accent-fill is the same #00C896 in
   both themes on purpose and is the one fill that owns --on-accent (8.87:1). */
.btn-primary { background: var(--accent-fill); color: var(--on-accent); border-color: var(--accent-fill); font-weight: var(--fw-medium); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
/* A button is a control read at a glance: --text, not the body-copy role. */
.btn-ghost { background: transparent; border-color: transparent; color: var(--text); }
.btn-ghost:hover { background: var(--surface-2); border-color: transparent; color: var(--text); }
.btn-danger { background: var(--danger-soft); color: var(--danger-text); border-color: var(--danger); }
/* #2E1B1A was a maroon that stayed dark in light mode: 2.01:1 against
   --danger-text. --panel-danger is the tinted-panel token and inverts. */
.btn-danger:hover { background: var(--panel-danger); border-color: var(--danger); color: var(--danger-text); }
.btn-sm { padding: 5px 10px; min-height: 30px; font-size: var(--fs-tiny); }
.btn-lg { padding: 12px 20px; min-height: 44px; font-size: var(--fs-body); }
.btn-block { display: flex; width: 100%; }

/* Disabled — deliberate, not just "same button that ignores you" */
.btn:disabled,
.btn[disabled],
.btn.is-disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
  filter: grayscale(0.35);
}
.btn:disabled:hover,
.btn[disabled]:hover,
.btn.is-disabled:hover { background: var(--surface-2); border-color: var(--border-strong); }
.btn-primary:disabled:hover, .btn-primary[disabled]:hover { background: var(--accent-fill); border-color: var(--accent-fill); }

/* Loading — add class `is-loading` (or aria-busy="true"). The label goes
   transparent (so the button keeps its width and nothing reflows) and a
   spinner takes its place; clicks are blocked. No markup change needed. */
.btn.is-loading, .btn[aria-busy="true"] { pointer-events: none; color: transparent !important; }
.btn.is-loading::after,
.btn[aria-busy="true"]::after {
  content: '';
  position: absolute;
  width: 14px; height: 14px;
  border-radius: 50%;
  border: 2px solid currentColor;
  border-top-color: transparent;
  color: var(--text);
  animation: nvSpin 640ms linear infinite;
}
.btn-primary.is-loading::after, .btn-primary[aria-busy="true"]::after { color: var(--on-accent); }
@keyframes nvSpin { to { transform: rotate(360deg); } }

/* ── Badge ───────────────────────────────────────────────────────────── */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  border-radius: var(--radius-pill);
  font-size: var(--fs-micro);
  font-weight: 500;
  line-height: 1.5;
  white-space: nowrap;
  background: var(--surface-2);
  color: var(--text-soft);
  border: 1px solid var(--border);
}
/* ── Status is a mark, not a fill ────────────────────────────────────────
   Four filled, outlined lozenges in four semantic colours, and a ticket row
   wearing four of them at once — priority, category, status, exception —
   plus an avatar and an SLA ring. Six containerised objects per row, none
   subordinate to any other, so the eye cannot find the priority because the
   category is exactly as loud. Three of the six auditors named this
   independently; a fourth named its consequence, which is that a filled
   success chip and the page's primary button were the same green and
   therefore "done" and "do this" looked alike.

   A status is now a dot and a word. No fill, no border, no pill — the
   colour survives where it carries the meaning (the dot) and stops
   competing with the one filled thing on the screen that is meant to be
   pressed. This is why a 500-row queue stays scannable.

   `.badge.solid` keeps the old treatment for the rare case that genuinely
   needs to shout — a breach, a shortfall — and is deliberately awkward to
   reach for. */
.badge.success,
.badge.warn,
.badge.danger,
.badge.info { background: none; border-color: transparent; padding-left: 0; padding-right: 8px; }
.badge.success::before,
.badge.warn::before,
.badge.danger::before,
.badge.info::before {
  content: ''; width: 6px; height: 6px; border-radius: 50%; flex: none;
}
.badge.success { color: var(--success-text); }
.badge.warn    { color: var(--warn-text); }
.badge.danger  { color: var(--danger-text); }
.badge.info    { color: var(--info-text); }
.badge.success::before { background: var(--success); }
.badge.warn::before    { background: var(--warn); }
.badge.danger::before  { background: var(--danger); }
.badge.info::before    { background: var(--info); }

/* ── THE SHAPE AXIS  (2026-09-19) ────────────────────────────────────────
   The dot above fixed the LOUDNESS problem — four filled lozenges became
   four marks — but all four marks were the same 6px circle telling itself
   apart by hue alone, and hue alone is not an axis:

     · --success is BYTE-IDENTICAL to --accent in this palette. tokens.css
       says so and says why: every alternative green measured 1.01–1.58:1
       against it, because the difference is hue and not lightness. So a
       "done" mark and an "available action" are the same colour, and the
       only thing that could have separated them was shape.
     · roughly one man in twelve cannot separate this green from this red,
       and success/danger is the pair the whole queue is scanned on.

   So each state now carries a FORM as well as a colour, and the set is
   readable with the colour removed entirely:

     success  a square    — closed, settled, filed
     danger   a diamond   — the only rotated mark, because breach is the
                            one state that must be findable by shape down
                            a column of two hundred rows
     warn     a bar       — wider than tall, so it reads as a LEVEL rather
                            than an event
     info     a circle    — unchanged; it is the neutral one

   Zero markup changes: 217 existing call sites across 42 dashboards get
   this for free, and it matches ds.css's `.status`, so the two vocabularies
   cannot disagree about what "done" looks like.

   The WORD always stays. The shape exists so colour is not load-bearing;
   a mark with no label puts it straight back. */
.badge.success::before { border-radius: 2px; }
.badge.danger::before  { border-radius: 1px; transform: rotate(45deg); }
.badge.warn::before    { width: 9px; height: 4px; border-radius: 1px; }

/* The escape hatch, for the few places a status must carry weight. */
.badge.solid            { padding-left: 8px; border-width: 1px; }
.badge.solid::before    { display: none; }
.badge.solid.success    { background: var(--accent-soft); border-color: var(--accent); }
.badge.solid.warn       { background: var(--warn-soft);   border-color: var(--warn); }
.badge.solid.danger     { background: var(--danger-soft); border-color: var(--danger); }
.badge.solid.info       { background: var(--primary-soft);border-color: var(--primary); }
.badge-dot::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; display: inline-block; flex-shrink: 0; }

/* ── Input ───────────────────────────────────────────────────────────── */
.input, .select, .textarea {
  width: 100%;
  padding: 8px 12px;
  min-height: 34px;
  background: var(--bg);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm);
  color: var(--text);
  font-size: var(--fs-sm);
  transition: border-color var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out);
}
.input:hover, .select:hover, .textarea:hover { border-color: var(--muted-2); }
.input:focus, .select:focus, .textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.input:disabled, .select:disabled, .textarea:disabled {
  opacity: 0.5; cursor: not-allowed; background: var(--surface);
}
.input.is-invalid, .select.is-invalid, .textarea.is-invalid {
  border-color: var(--danger);
  box-shadow: 0 0 0 3px var(--danger-soft);
}
.textarea { min-height: 80px; resize: vertical; }
.label {
  font-size: var(--fs-micro);
  color: var(--text-soft);
  text-transform: uppercase;
  letter-spacing: var(--tr-micro);
  margin-bottom: var(--sp-1);
  display: block;
}
.input::placeholder, .select::placeholder, .textarea::placeholder,
input::placeholder, textarea::placeholder {
  color: var(--muted-2);   /* the token's stated job */
  opacity: 1;              /* Firefox otherwise multiplies it by .54 */
}
.field-hint { font-size: var(--fs-tiny); color: var(--muted); margin-top: var(--sp-1); }
.field-error { font-size: var(--fs-tiny); color: var(--danger-text); margin-top: var(--sp-1); }

/* ── Table ───────────────────────────────────────────────────────────── */
.table { width: 100%; border-collapse: separate; border-spacing: 0; }
.table th {
  text-align: left;
  font-size: var(--fs-micro);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: var(--tr-micro);
  color: var(--text-soft);
  padding: 10px 12px;
  border-bottom: 1px solid var(--border-strong);
  white-space: nowrap;
}
.table td {
  padding: 12px;
  border-bottom: 1px solid var(--border);
  font-size: var(--fs-sm);
  vertical-align: middle;
}
.table td .mono, .table td.num { font-variant-numeric: tabular-nums; }

/* A wide table must scroll INSIDE its own box, never take the page with it.
   ────────────────────────────────────────────────────────────────────────
   Measured at 360px: Tickets renders a 916px table inside a 334px card,
   Staff 590px, SLA 444px — all clipped, with overflow-x computed as `visible`
   on the parent, so the STATUS and ASSIGNEE columns simply were not reachable
   and a receptionist read a truncated total with nothing saying to swipe.
   CLAUDE.md's own rule is that the body never scrolls sideways; this is how a
   table keeps that promise instead of breaking it. */
/* `min-width: max-content` and `overflow-x: auto` were on the SAME element.
   A box sized to its own content can never scroll — it can only overflow, and
   `body { overflow-x: clip }` then swallows the scrollbar that would have shown
   it. Measured across 228 captures: 24 of 30 clipped elements in the whole
   product were this one rule, and on Tickets at 390px the table rendered
   1077px wide, so Status, SLA and Assignee were simply gone.
   layout.css:602 was written to fix exactly this and lost the specificity war
   (0,1,1 against 0,2,0), which is why it never took.

   The scroller and the thing being scrolled must be two different boxes.
   `.table-wrap` already did it correctly — payments.html is the one table page
   that passed — so the direct-child case now matches it: the table is the
   content, the block is the port. */
.card > .table-wrap > .table { min-width: max-content; }
.card > .table { display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }
@supports (scrollbar-width: thin) { .card > .table { scrollbar-width: thin; } }

/* And SAY that it scrolls. A touch scrollbar is invisible until you already
   know to swipe, so a receptionist reads a truncated total and takes it as the
   number. The classic two-layer trick: `local` layers scroll with the content
   and `scroll` layers stay put, so the shadows appear only on the side there
   is more to see, and fade out at each end. Pure paint — no script, nothing to
   keep in sync with the scroll position. */
.card > .table {
  background-image:
    linear-gradient(to right, var(--surface), transparent 12px),
    linear-gradient(to left,  var(--surface), transparent 12px),
    linear-gradient(to right, rgb(0 0 0 / 0.18), transparent 10px),
    linear-gradient(to left,  rgb(0 0 0 / 0.18), transparent 10px);
  background-position: left center, right center, left center, right center;
  background-repeat: no-repeat;
  background-size: 28px 100%, 28px 100%, 12px 100%, 12px 100%;
  background-attachment: local, local, scroll, scroll;
}
.table tbody tr { transition: background-color var(--dur-1) var(--ease-out); }
.table tbody tr:hover td { background: var(--surface-2); }
.table tbody tr:last-child td { border-bottom: none; }
/* Clickable rows read as clickable: a hairline rail appears on the left */
.table tbody tr[data-id] td:first-child,
.table tbody tr.ticket-row td:first-child { position: relative; }
.table tbody tr[data-id]:hover td:first-child::before,
.table tbody tr.ticket-row:hover td:first-child::before {
  content: '';
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 2px;
  background: var(--accent);
}
.table tbody tr:focus-within td { background: var(--surface-2); }
.table tbody tr[data-id]:active td { background: var(--surface-3); }
.table.compact td { padding: 8px 12px; }
.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }

/* ── Tabs ────────────────────────────────────────────────────────────── */
.tabs { display: flex; gap: var(--sp-1); border-bottom: 1px solid var(--border); margin-bottom: var(--sp-4); }
.tab {
  padding: 8px 14px;
  min-height: 34px;
  display: inline-flex;
  align-items: center;
  font-size: var(--fs-sm);
  /* A tab is a control, so the token is --text; the resting step down is
     opacity, not a weaker ink role. --muted here measured 5.23:1 and read
     as disabled next to the active tab. */
  color: var(--text);
  opacity: .72;
  cursor: pointer;
  border-bottom: 2px solid transparent;
  transition: opacity var(--dur-1) var(--ease-out), border-color var(--dur-2) var(--ease-out);
}
/* The Rooms tabs are real links (two documents, no SPA router), so the
   underline has to be turned off explicitly. */
a.tab { text-decoration: none; }
.tab:hover { opacity: 1; }
.tab.active { opacity: 1; border-bottom-color: var(--accent); }

/* ── Modal ───────────────────────────────────────────────────────────── */
.modal-backdrop {
  position: fixed; inset: 0;
  background: var(--veil);
  /* No backdrop blur here. It is the most expensive composite you can ask of
     a cheap GPU and it re-rasterises for the whole nvFade. motion.css adds it
     at >=721px only, and says so — declaring it here meant every phone paid
     for it anyway, which is exactly what that gate exists to prevent. */
  z-index: 100;
  display: flex; align-items: center; justify-content: center;
  animation: nvFade var(--dur-3) var(--ease-out);
}
.modal {
  background: var(--bg-elevated);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  padding: var(--sp-6);
  max-width: 560px;
  width: 92vw;
  max-height: 86vh;
  overflow-y: auto;
  box-shadow: var(--shadow-2);
  animation: nvModalIn var(--dur-3) var(--ease-out);
}
.modal h2 { margin-bottom: var(--sp-4); }
.modal-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-5); }

/* On a phone the modal runs to the bottom of the viewport and Cancel / Save
   sat flush against the Android gesture bar and the iOS home indicator — a
   44px target with nothing under it, which is a mis-tap that dismisses the
   form. The modal scrolls, so the room has to come from the modal's own
   padding rather than from the backdrop. */
@media (max-width: 767px) {
  .modal { padding-bottom: max(var(--sp-6), calc(var(--sp-5) + env(safe-area-inset-bottom, 0px))); }
}
@keyframes nvFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes nvModalIn {
  from { opacity: 0; transform: translateY(8px) scale(0.985); }
  to   { opacity: 1; transform: none; }
}

/* ── Toast ───────────────────────────────────────────────────────────── */
.toast-stack { position: fixed; bottom: var(--sp-5); right: var(--sp-5); z-index: 200; display: flex; flex-direction: column; gap: var(--sp-2); }
.toast {
  background: var(--bg-elevated);
  border: 1px solid var(--border-strong);
  border-left: 3px solid var(--accent);
  padding: 12px 16px;
  border-radius: var(--radius);
  box-shadow: var(--shadow-2);
  min-width: 260px;
  max-width: 380px;
  font-size: var(--fs-sm);
  animation: nvToastIn var(--dur-3) var(--ease-out);
}
.toast.error { border-left-color: var(--danger); }
.toast.warn  { border-left-color: var(--warn); }
.toast.leaving { animation: nvToastOut var(--dur-2) var(--ease-out) forwards; }
@keyframes nvToastIn  { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
@keyframes nvToastOut { to   { opacity: 0; transform: translateY(4px) scale(0.98); } }
/* Legacy name kept: some pages still animate with slideIn */
@keyframes slideIn { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }

/* ── SLA ring ────────────────────────────────────────────────────────── */
.sla-ring {
  /* --gauge-track, not --surface-3. The track is a deliberate value that
     has to hold 3:1 against every arc colour in both themes; --surface-3
     is "whatever a row happens to look like on hover", which is not the
     same requirement and does not survive the light palette. */
  --p: 50; --c: var(--gauge-ok);
  width: 32px; height: 32px;
  border-radius: 50%;
  background: conic-gradient(var(--c) calc(var(--p) * 1%), var(--gauge-track) 0);
  display: inline-flex; align-items: center; justify-content: center;
  position: relative;
  flex-shrink: 0;
}
.sla-ring::before { content: ''; position: absolute; inset: 4px; background: var(--surface); border-radius: 50%; }
.sla-ring span { font-family: var(--font-mono); font-size: 10px; font-weight: 600; position: relative; z-index: 1; }

/* ── Chart helpers ───────────────────────────────────────────────────── */
.bar-track { height: 6px; background: var(--surface-3); border-radius: var(--radius-pill); overflow: hidden; }
.bar-fill { height: 100%; background: var(--accent); border-radius: var(--radius-pill); }
.bar-fill.warn { background: var(--warn); }
.bar-fill.danger { background: var(--danger); }
.bar-fill.info { background: var(--primary); }

/* Heatmap cell */
.heat { display: inline-block; width: 22px; height: 22px; border-radius: 4px; background: var(--surface-2); }

/* ── DEV ribbon ──────────────────────────────────────────────────────── */
/* NOT --grad-risk. That gradient is built from --warn and --danger, which
   are light in dark mode and DARK in light mode, so no single ink clears
   both: the old #0E1110 measured 8.46 dark but 3.20 / 2.90 light. An opaque
   --surface-2 bar with --warn-text and a --warn rule is unmistakable and
   correct in both. It must stay opaque — content scrolls under it.

   The --warn rule is an INSET SHADOW, not a border-bottom. A border adds a
   real px to a `position: fixed` bar whose height the layout reserves with
   `body.has-ribbon { padding-top }`; a shadow paints the identical hairline
   and reserves nothing. */
.dev-ribbon { position: fixed; top: 0; left: 0; right: 0; background: var(--surface-2); color: var(--warn-text); box-shadow: inset 0 -1px 0 var(--warn); text-align: center; padding: 4px; font-size: var(--fs-micro); font-weight: 700; letter-spacing: var(--tr-micro); z-index: 300; text-transform: uppercase; }
body.has-ribbon { padding-top: 24px; }

/* ── Empty state ─────────────────────────────────────────────────────────
   An empty state without a next action is a dead end. Pages should render
   <div class="empty"><h3>…</h3><p>…</p><button class="btn">…</button></div> */
.empty {
  text-align: center;
  padding: 56px 20px;
  color: var(--text-soft);
  max-width: 420px;
  margin: 0 auto;
}
.empty h3 { color: var(--text); margin-bottom: 6px; font-size: 16px; }
.empty p { color: var(--text-soft); font-size: var(--fs-body); line-height: var(--lh-relaxed); margin: 0; }
.empty .btn, .empty .row { margin-top: var(--sp-4); }
.empty .row { justify-content: center; }
.empty-icon {
  width: 40px; height: 40px;
  margin: 0 auto var(--sp-3);
  border-radius: var(--radius);
  display: grid; place-items: center;
  background: var(--surface-2);
  border: 1px solid var(--border);
  color: var(--muted);
}

/* ── Skeletons ───────────────────────────────────────────────────────────
   Use these instead of the word "Loading…". Shapes should match what will
   land there, so nothing jumps when the data arrives:
     <div class="skeleton skeleton-line" style="width:60%"></div>
     <div class="skeleton skeleton-line sm"></div>
     <div class="skeleton skeleton-avatar"></div>
     <div class="skeleton-row">…avatar + lines…</div>
     <div class="skeleton skeleton-card"></div>
     <div class="skeleton skeleton-kpi"></div>                           */
.skeleton {
  position: relative;
  overflow: hidden;
  background: var(--surface-2);
  border-radius: var(--radius-sm);
}
.skeleton::after {
  content: '';
  position: absolute; inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, rgba(255,255,255,0.045), transparent);
  animation: nvShimmer 1.25s linear infinite;   /* constant motion -> linear */
}
/* A white sweep over the light skeleton (#EAEEEC) is 1.01:1 — the animation
   was simply gone. Light needs a DARK sweep. Pure-black alpha, no hue, so
   there is no palette value hiding here: .08 measures 1.19:1, matched to the
   1.15:1 the white sweep gives in dark. */
:root[data-theme="light"] .skeleton::after {
  background: linear-gradient(90deg, transparent, rgba(0,0,0,0.08), transparent);
}
@keyframes nvShimmer { to { transform: translateX(100%); } }
.skeleton-line { height: 12px; width: 100%; }
.skeleton-line.sm { height: 9px; }
.skeleton-line.lg { height: 18px; }
.skeleton-avatar { width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0; }
.skeleton-card { height: 120px; border-radius: var(--radius); }
.skeleton-kpi  { height: 110px; border-radius: var(--radius); }
.skeleton-row {
  display: flex; align-items: center; gap: var(--sp-3);
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
}
.skeleton-row:last-child { border-bottom: none; }
/* Whole-region loading: dim it, block clicks, keep the layout still */
.is-loading-region { opacity: 0.55; pointer-events: none; transition: opacity var(--dur-2) var(--ease-out); }

/* ── Divider ─────────────────────────────────────────────────────────── */
.hr { height: 1px; background: var(--border); border: 0; margin: var(--sp-4) 0; }

/* ── Mobile component fixes ──────────────────────────────────────────── */
@media (max-width: 720px) {
  /* Toast: keep above bottom nav, away from screen edges */
  .toast-stack { bottom: 72px; right: 12px; left: 12px; align-items: stretch; }
  .toast { min-width: unset; max-width: unset; width: 100%; }

  /* SVG charts: make them fill container width */
  svg { max-width: 100%; height: auto; }

  /* Tabs: allow wrapping so they don't overflow */
  .tabs { flex-wrap: wrap; overflow-x: auto; }
  .tab { flex-shrink: 0; }

  /* Heatmap wrapper inside productivity */
  .heatmap-wrap, [style*="overflow-x:auto"] { max-width: 100%; }

  /* Table inside card wrapper scrollable */
  .card > table.table,
  .card > div > table.table { overflow-x: auto; display: block; }

  /* KPI value scale down for 2-col layout */
  .kpi .value { font-size: 24px; }

  /* Topbar actions: hide non-essential items (topbar only — an unscoped
     rule here was hiding badges inside tables and cards too) */
  .user-chip .tiny { display: none; }
  .topbar .badge.info:not(.badge-dot) { display: none; }

  /* The mobile placement of the account menu lives in layout.css, anchored
     under the chip that opens it. This rule pinned it to bottom:72px — about
     600pt away from the chip on a tall phone — and it survived only because
     layout.css happens to load after this file and overrides every line of it
     with !important. Two competing copies decided by link order is how the
     next person reorders the <head> and moves the menu back to the floor. */
}

/* ── Touch: thumbs are 44px wide, not 30 ─────────────────────────────── */
@media (pointer: coarse) {
  /* 16px, or iOS zooms the whole page in on focus and never zooms back.
     ──────────────────────────────────────────────────────────────────────
     Safari auto-magnifies any focused field whose font-size is under 16px.
     These are var(--fs-sm) = 14px, so tapping the sign-in ID field scaled the
     page ~1.07× and left the right edge of every form clipped for the rest of
     that page — measured on an iPhone 17 Pro. The earlier touch pass raised
     min-height to 44px and left font-size alone, which fixed the target and
     not the zoom. */
  .input, .select, .textarea, input[type="text"], input[type="tel"],
  input[type="email"], input[type="number"], input[type="password"],
  input[type="search"], input[type="date"], input[type="time"], textarea, select {
    font-size: 16px;
  }
  /* A date input reports a wider intrinsic size than its box on iOS and ran
     26pt past the edge of the New booking modal. */
  input[type="date"], input[type="time"] { max-width: 100%; box-sizing: border-box; }
  .btn { min-height: 44px; padding-inline: 16px; }
  .btn-sm { min-height: 44px; padding-inline: 14px; }
  .tab { min-height: 44px; }
  .user-chip { min-height: 44px; padding: 4px 12px 4px 6px; }
  .user-avatar { width: 30px; height: 30px; font-size: 12px; }
  .nav-link { min-height: 44px; }
  .input, .select, .textarea { min-height: 44px; }
  .table td { padding: 14px 12px; }
  /* No hover rail on touch — it sticks after a tap */
  .table tbody tr[data-id]:hover td:first-child::before,
  .table tbody tr.ticket-row:hover td:first-child::before { content: none; }
}

/* ══ Theme switch ══════════════════════════════════════════════════════
   Three targets at 40px inside a 132px pill. A binary toggle cannot say
   "follow my phone", and that is the state that matters most for staff
   whose shift crosses sunset.

   The thumb slides rather than the background swapping, because a moving
   object tells you the control is one thing with three positions; three
   independently-highlighting buttons read as three separate buttons. */
.theme-switch {
  display: inline-flex; position: relative; isolation: isolate;
  /* --border measured 1.24:1 against the topbar ground: in light the switch
     had no visible outline at all. A control boundary needs 3:1. */
  background: var(--surface-2); border: 1px solid var(--border-control);
  border-radius: var(--r-pill); padding: 3px; flex-shrink: 0;
}
.theme-switch__thumb {
  position: absolute; z-index: 0; inset: 3px auto 3px 3px;
  width: 34px; border-radius: var(--r-pill);
  /* The thumb IS the state indicator, and --surface on --surface-2 is
     1.17:1 light / 1.11:1 dark — invisible in both. The inset ring lifts it
     to 3.25:1 light / 3.37:1 dark against the track. */
  background: var(--surface);
  box-shadow: var(--shadow-1), inset 0 0 0 1px var(--border-control);
  transition: transform var(--dur-2) var(--ease-out);
}
.theme-switch[data-pref="light"] .theme-switch__thumb { transform: translateX(34px); }
.theme-switch[data-pref="dark"]  .theme-switch__thumb { transform: translateX(68px); }
.theme-switch button {
  position: relative; z-index: 1;
  width: 34px; height: 28px; display: grid; place-items: center;
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--text-soft); border-radius: var(--r-pill);
  transition: color var(--dur-1) var(--ease-out), transform var(--dur-1) var(--ease-out);
}
.theme-switch button:hover { color: var(--text); }
/* These are real pressable controls sitting among buttons that all answer a
   press; without this they were the only ones that did not. */
.theme-switch button:active { transform: scale(0.96); transition-duration: 70ms; }
.theme-switch button[aria-checked="true"] { color: var(--text); }
.theme-switch button:focus-visible {
  outline: 2px solid var(--focus); outline-offset: 2px;
}
.theme-switch svg {
  width: 15px; height: 15px; fill: none; stroke: currentColor;
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}
/* On a phone the topbar is tight and the search box already collapses.
   The switch stays — it is the one control people look for. */
@media (max-width: 720px) {
  .theme-switch button { width: 30px; }
  .theme-switch__thumb { width: 30px; }
  .theme-switch[data-pref="light"] .theme-switch__thumb { transform: translateX(30px); }
  .theme-switch[data-pref="dark"]  .theme-switch__thumb { transform: translateX(60px); }
}

/* ── Heading icons ──────────────────────────────────────────────────────
   32 headings across the product opened with an operating-system emoji.
   That is not a style choice, it is the absence of one: it says nobody
   drew anything, so the OS's clip-art was borrowed — and it renders as
   different artwork on every device, so the product had no fixed
   appearance. A prospect deciding whether to pay reads 🧠 next to "AI
   insights" and correctly files it as a demo.

   One stroked set at 1.7px in currentColor, sized in `em` so it tracks
   whatever type step the heading is on. */
.h-ico {
  width: 1em; height: 1em; flex-shrink: 0;
  fill: none; stroke: currentColor; stroke-width: 1.7;
  stroke-linecap: round; stroke-linejoin: round;
  vertical-align: -0.13em; opacity: .72;
  margin-right: .12em;
}
h1 .h-ico, h2 .h-ico, h3 .h-ico { opacity: .6; }

/* ── nvIcon — window.nvIcon(name) in utils.js ───────────────────────────
   The shared line-icon helper every dashboard calls where a colour emoji
   used to sit. Sized in em so it follows the type beside it; colour is
   currentColor, never the glyph's own. `flex: none` because most sites are
   inside .btn / .badge / .row, all flex, and an icon must not shrink. */
.nv-ico { width: 1em; height: 1em; vertical-align: -0.15em; flex: none; }
.empty-icon .nv-ico { width: 22px; height: 22px; }

/* ── Tinted panels ──────────────────────────────────────────────────────
   33 hardcoded `var(--panel-accent)` inline styles
   lived in markup where no stylesheet could reach them. Every one was a
   near-black box, so the first time light mode rendered, the "value-back"
   card on the home page was a black rectangle with invisible text on a
   white page. Tokens, so a theme swap carries them. */
/* ── A card that carries a state wears it on its EDGE ────────────────────
   `style="border-color:var(--warn)"` was written 24 times across nine
   dashboards, and `background:var(--danger-soft);border-color:var(--danger)`
   seven more. Every one of them uses a token correctly, through the one
   mechanism no stylesheet can answer — so the colour discipline held and the
   component discipline did not: a card variant with no name, which is how a
   variant ends up with four slightly different versions of itself.

   .edge-* is the state as a line. .wash-* adds the tint for the cases that
   need to be found from across a room — a lockout, an overdue folio. They
   are not new colours; both are the status pairs tokens.css already measures.
   Neither is a material: they modify .card, they do not replace it.

   Doubled selectors, same reasoning as the block in tokens.css: `.card` sets
   a border and these have to beat it from a class of equal weight. */
.edge-accent.edge-accent { border-color: var(--accent); }
.edge-warn.edge-warn     { border-color: var(--warn); }
.edge-danger.edge-danger { border-color: var(--danger); }
.edge-info.edge-info     { border-color: var(--primary); }
.wash-accent.wash-accent { background: var(--accent-soft);  border-color: var(--accent); }
.wash-warn.wash-warn     { background: var(--warn-soft);    border-color: var(--warn); }
.wash-danger.wash-danger { background: var(--danger-soft);  border-color: var(--danger); }
.wash-info.wash-info     { background: var(--primary-soft); border-color: var(--primary); }
/* Weight, by name. `style="font-weight:600"` was the eighth most common
   inline declaration in the dashboards; 700 does not exist in this system,
   so there are only two worth naming. */
.semi.semi { font-weight: var(--fw-semi); }
.med.med   { font-weight: var(--fw-medium); }

.tint-accent { background: var(--panel-accent); }
.tint-warn   { background: var(--panel-warn); }
.tint-info   { background: var(--panel-info); }
.tint-danger { background: var(--panel-danger); }
.tint-violet { background: var(--panel-violet); }
.tint-brand  { background: var(--grad-brand); color: var(--on-accent); }
.tint-risk   { background: var(--grad-risk); }

/* Muted text inside a primary button takes the button's own ink. With the
   utility layer restored, .muted inside a selected choice (Settings →
   Vertical pack) became grey-green on bright green — unreadable. */
.btn-primary .muted, .btn-primary .soft { color: var(--on-accent); opacity: .78; }

/* .hidden, a second time on purpose. It also lives in the restored utility
   block in tokens.css, but a phone can hold a cached tokens.css from before
   the restore; this copy guarantees the guest portal, login and SLA panels
   hide whichever of the two files is fresh. Identical rule, so harmless. */
.hidden { display: none !important; }

/* The HTML `hidden` attribute, made to actually hide things.
   ─────────────────────────────────────────────────────────────────────────
   The UA stylesheet's `[hidden] { display: none }` is an author-sheet loss
   waiting to happen: ANY author rule that sets `display` on the same element
   beats it. So `.nav-link { display: flex }` (layout.css:168) and
   `#moreSheet .ms-row { display: flex }` (layout.css:714) silently won, and
   every `el.hidden = true` in nav.js did nothing at all.

   The cost was not cosmetic: wireNavFilter() and wireSheetFilter() are what
   CLAUDE.md names as the ANSWER to a 43-item sidebar — the reason the modules
   were never merged into vague parents. Typing in either box marked 41 of 42
   links hidden and hid none of them. Measured, not guessed.

   A global rule rather than two targeted ones, because `el.hidden = true` is
   used in fourteen places across nav.js and the dashboards and every one of
   them was one `display` declaration away from the same silence. */
[hidden] { display: none !important; }

/* Announced, never drawn. Used by the loading skeleton, whose shape is
   decorative and carries aria-hidden — a screen reader needs the word. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ── ID document (v2/js/guest-id.js) ──────────────────────────────────────
   Shared by the check-in modal and the guest profile, so it lives here
   rather than in either page. Tokens only.

   The native file input is hidden and a <label for> is styled as the button:
   `<input type=file>` cannot be restyled across browsers, and a label is the
   one element that triggers it without JavaScript — so the control still
   works if a script fails. It is NOT display:none — that removes it from the
   accessibility tree and from keyboard focus — it is clipped, like .sr-only. */
.nvid { margin-top: var(--sp-2); }
.nvid-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.nvid-type { max-width: 170px; }
.nvid-input {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
.nvid-pick { cursor: pointer; }
.nvid-input:focus-visible + .nvid-pick,
.nvid-pick:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.nvid-pending, .nvid-item {
  display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
  margin-top: var(--sp-2); padding: var(--sp-2);
  border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2);
}
.nvid-pending > span, .nvid-item > span { flex: 1 1 140px; min-width: 0; overflow-wrap: anywhere; }
.nvid-list:empty { display: none; }

/* ── Trial countdown ────────────────────────────────────────────────────────
   A quiet strip at the very top while a trial runs. It only escalates in the
   last week: a bar that shouts for forty-five days is a bar people stop
   seeing, which is the one thing a deadline notice cannot afford. */
.nv-trialbar { position: sticky; top: 0; z-index: 60;
  display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap;
  padding: 7px var(--sp-4); padding-top: max(7px, var(--safe-top));
  background: var(--surface-2); border-bottom: 1px solid var(--border);
  font-size: var(--fs-caption); }
.nv-trialbar-t { font-weight: var(--fw-semi); color: var(--text); }
.nv-trialbar-s { color: var(--muted); }
.nv-trialbar.is-urgent { background: var(--warn-soft, var(--surface-2));
                         border-bottom-color: var(--gauge-warn, var(--border)); }
.nv-trialbar.is-urgent .nv-trialbar-t { color: var(--gauge-warn, var(--text)); }
@media (max-width: 560px) { .nv-trialbar-s { display: none; } }

/* ── .stat ──────────────────────────────────────────────────────────────
   A figure inside a card: the money card's Taken/Spent/Left over, the ROI
   page's inputs, ai-brain's counters. Twelve of them across four pages, and
   the class was never DEFINED — no background, no border, no type scale — so
   a dl/dt/dd fell back to the browser's defaults and the Command Center's
   headline numbers read as loose text on a flat panel. Same failure as the
   utility classes this repo lost once before: the markup says what it wants
   and the stylesheet never answered.

   It is a SUNKEN tile, not another .kpi. A .stat always sits inside a .card,
   and giving it the raised surface + border of a card nests a card in a card,
   which flattens the hierarchy instead of building one. Recessed reads as
   "part of this panel"; raised would read as "a separate object that happens
   to be here". */
.stat {
  margin: 0;
  padding: 14px 16px;
  background: var(--sunken);
  border: 1px solid var(--border);
  border-radius: var(--r-sm);
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}
.stat dt {
  font-size: var(--fs-micro);
  text-transform: uppercase;
  letter-spacing: var(--tr-micro);
  color: var(--muted);
}
.stat dd {
  margin: 0;
  font-family: var(--font-display);
  font-size: 26px;
  line-height: 1.15;
  color: var(--text);
  /* the figures sit in a row and are compared down the column */
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.stat > p { margin: 0; }
@media (max-width: 600px) { .stat dd { font-size: 22px; } }

/* ── Field checks ────────────────────────────────────────────────────────
   The note under a phone or ID field. It WARNS and never blocks — nothing
   here sets `required` or disables a button, because a guest at the desk
   with an unusual document must not be held up by software arguing with
   them. See public/v2/js/fieldcheck.js for the rest of that contract.
   ──────────────────────────────────────────────────────────────────────── */
.fc-note { margin-top: 5px; font-size: var(--fs-micro); line-height: 1.45;
           min-height: 1em; color: var(--muted); }
.fc-note.is-bad { color: var(--warn-text); }
/* A tick, not a sentence: "looks right" needs no words, and a green
   paragraph under every filled field is noise. */
.fc-note.is-ok  { color: var(--accent); }

/* The dial code sits INSIDE the field as a fixed prefix, so the person
   types the ten digits they know and the number that is stored is
   unambiguous about which country it belongs to. It is not part of the
   input's value — a validator that rewrites what somebody typed is how a
   correct unusual number becomes a wrong ordinary one. */
.fc-phone { display: flex; align-items: stretch; gap: 0; width: 100%; }
.fc-phone .fc-dial {
  display: inline-flex; align-items: center; padding: 0 10px;
  font-family: var(--font-mono); font-size: var(--fs-caption); font-style: normal;
  color: var(--muted); background: var(--sunken);
  border: 1px solid var(--border); border-right: 0;
  border-radius: var(--r-sm) 0 0 var(--r-sm); white-space: nowrap; }
.fc-phone .input { border-radius: 0 var(--r-sm) var(--r-sm) 0; flex: 1; min-width: 0; }
.fc-phone:focus-within .fc-dial { border-color: var(--accent); color: var(--accent); }

/* ── Segmented control ───────────────────────────────────────────────────
   Today / Last 7 / Last 30 were three separate buttons with the selected one
   set to .btn-primary — so a FILTER STATE was painted in the same filled
   accent as the page's actual call to action sitting beside it, and two
   greens competed for the same glance. A segment is one recessed track with
   the selected option raised out of it: the state reads as "where you are",
   which is what it is, and the accent goes back to meaning "do this".
   ──────────────────────────────────────────────────────────────────────── */
.seg { display: inline-flex; padding: 3px; gap: 2px; border-radius: var(--r-pill);
       background: var(--sunken); border: 1px solid var(--border); }
.seg button { border: 0; background: none; border-radius: var(--r-pill);
              padding: 6px 13px; font-size: var(--fs-caption);
              font-weight: var(--fw-medium); color: var(--muted);
              white-space: nowrap; line-height: 1.2;
              transition: color var(--dur-1) var(--ease-out),
                          background var(--dur-1) var(--ease-out); }
@media (hover: hover) and (pointer: fine) {
  .seg button:hover { color: var(--text); }
}
.seg button[aria-pressed="true"] {
  background: var(--surface); color: var(--text);
  box-shadow: var(--shadow-1); font-weight: var(--fw-semi); }
/* Response lives on the press, not the release — §1 of the house standards. */
.seg button:active { transform: scale(0.97); transition-duration: 70ms; }

/* ── The lead figure ─────────────────────────────────────────────────────
   A dashboard that renders its four numbers at identical weight has not
   decided which one the person came for. Taken and Spent are INPUTS; what
   is left is the ANSWER; occupancy is a different question and belongs with
   its own chart. One figure at display scale, its two inputs beside it at
   caption scale, and the arithmetic visible between them.
   ──────────────────────────────────────────────────────────────────────── */
.lead-money { display: grid; gap: clamp(14px, 2vw, 26px);
              grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
              align-items: center; }
@media (max-width: 720px) { .lead-money { grid-template-columns: 1fr; } }
.lead-k { font-size: var(--fs-caption); color: var(--muted); }
.lead-n { font-family: var(--font-display); font-weight: 500;
          font-size: clamp(38px, 5vw, 60px); line-height: 1.02;
          letter-spacing: -0.028em; margin: 4px 0 6px;
          font-variant-numeric: tabular-nums; }
.lead-sub { font-size: var(--fs-caption); color: var(--muted); }
.lead-in { display: grid; gap: 1px; background: var(--border);
           border: 1px solid var(--border); border-radius: var(--r-sm);
           overflow: hidden; }
.lead-in > div { display: flex; align-items: baseline; justify-content: space-between;
                 gap: 12px; padding: 11px 14px; background: var(--surface); }
.lead-in dt { font-size: var(--fs-caption); color: var(--muted); }
.lead-in dd { margin: 0; font-weight: var(--fw-semi); color: var(--text);
              font-variant-numeric: tabular-nums; }
.lead-in .lead-note { font-size: var(--fs-micro); color: var(--muted-2); }

/* ── Chart section labels ────────────────────────────────────────────────
   Seven uppercase letter-spaced labels inside one card is not hierarchy —
   when every label shouts, none of them does. One register, sentence case,
   and the weight does the separating.
   ──────────────────────────────────────────────────────────────────────── */
.chart-h { margin: 0 0 6px; font-size: var(--fs-caption);
           font-weight: var(--fw-semi); color: var(--text-soft);
           text-transform: none; letter-spacing: 0; }

/* ── Load strip ───────────────────────────────────────────────────────────
   mountView prepends this when a request failed during a paint. It is a
   status, not an error page: the rest of the screen loaded and stays. */
.load-strip { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-4);
  background: var(--warn-soft); border: 1px solid var(--warn); border-radius: var(--r-md);
  color: var(--warn-text); font-size: var(--fs-caption); }
.load-strip .btn { flex: none; }

/* ── Hover lift: cards that are links or buttons rise 2px and warm their
   border. Gated: on touch, :hover latches after a tap. ─────────────────── */
@media (hover: hover) and (pointer: fine) {
  a.card:hover, button.card:hover, .card[role="button"]:hover, .card.is-link:hover {
    transform: translateY(-2px); border-color: var(--border-control);
    transition: transform var(--dur-2) var(--ease-out), border-color var(--dur-2) var(--ease-out); }
}

/* ── Sheet — a panel that finishes where it started ─────────────────────────
   Opened by openSheet() (js/sheet.js), which builds on openModal: the node IS
   a .modal inside a .modal-backdrop, so the one close path, the phone's
   bottom-sheet treatment in layout.css (≤600px) and the reduced-motion reset
   all apply unchanged. What is added here is the desk: from 601px up the panel
   is pinned to the RIGHT edge, full height, and slides in from and back out to
   that edge — DIRECTION.md: "sheets slide from the edge they were summoned
   from and return there". Raised surface, so --surface-2 and --shadow-2.

   --dur-4 (400ms) is the one duration reserved for sheets and pages; the exit
   is one step shorter, as every panel's is. transform only. ──────────────── */
.modal.nv-sheet { background: var(--surface-2); box-shadow: var(--shadow-2); min-width: 0; }
@media (min-width: 601px) {
  /* Inside the media query, not above it: layout.css docks every .modal to
     the BOTTOM of a phone with align-items:flex-end, and a bare rule here
     outranked it — the sheet slid up from the bottom and then sat at the top. */
  .modal-backdrop.nv-sheet-bd { justify-content: flex-end; align-items: stretch; }
  .modal.nv-sheet {
    width: min(460px, 92vw); max-width: none;
    height: 100%; max-height: 100%;
    border-radius: 0; border-width: 0 0 0 1px;
    padding: var(--sp-5) var(--sp-6) max(var(--sp-6), calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)));
    animation: nvSheetInX var(--dur-4) var(--ease-out);
  }
  .modal.nv-sheet.nv-sheet--wide { width: min(560px, 92vw); }
  .modal-backdrop.closing .modal.nv-sheet { animation: nvSheetOutX var(--dur-3) var(--ease-out) forwards; }
}
@keyframes nvSheetInX  { from { transform: translateX(100%); } to { transform: none; } }
@keyframes nvSheetOutX { to   { transform: translateX(100%); } }

/* Header: title on the left, the close control on the right. The title block
   is allowed to shrink and wrap; the button is not. */
.nv-sheet__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
.nv-sheet__titles { min-width: 0; flex: 1 1 auto; }
.nv-sheet__head h2 { margin: 0; overflow-wrap: anywhere; }
.nv-sheet__sub { font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--text-soft); margin-top: var(--sp-1); overflow-wrap: anywhere; }
.nv-sheet__close { flex: none; padding: 6px; min-width: 36px; }
.nv-sheet__close svg { display: block; }

/* The lead figure — the balance due, the price — is the only 600 on the page. */
.nv-sheet__lead { font: var(--fw-semi) var(--fs-title)/var(--lh-title) var(--font-display);
  letter-spacing: var(--tr-title); font-variant-numeric: tabular-nums; margin-top: var(--sp-3); }
.nv-sheet__lead small { display: block; font: var(--fw-medium) var(--fs-caption)/var(--lh-caption) var(--font-body);
  letter-spacing: var(--tr-caption); color: var(--text-soft); margin-top: 2px; }

/* Sections: a quiet heading with an optional link on its right. */
.nv-sheet__section { margin-top: var(--sp-5); min-width: 0; }
.nv-sheet__section > h3 { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
  margin: 0 0 var(--sp-2); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--text-soft);
  font-weight: var(--fw-medium); letter-spacing: 0; }
.nv-sheet__section > h3 a { font-weight: var(--fw-medium); }

/* Key / value lines — a bill summary, a unit's facts. Values right-aligned
   and tabular so amounts line up. */
.nv-sheet__kv { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px var(--sp-3); margin: 0;
  font-size: var(--fs-caption); line-height: var(--lh-caption); }
.nv-sheet__kv dt { color: var(--text-soft); min-width: 0; }
.nv-sheet__kv dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; color: var(--text); min-width: 0; overflow-wrap: anywhere; }
.nv-sheet__kv dd.is-total, .nv-sheet__kv dt.is-total { border-top: 1px solid var(--border); padding-top: var(--sp-2); font-weight: var(--fw-medium); color: var(--text); }

/* Rows — a ticket, an asset, a payment already taken. */
.nv-sheet__row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3);
  padding: var(--sp-2) 0; border-bottom: 1px solid var(--border); min-width: 0; font-size: var(--fs-caption); line-height: var(--lh-caption); }
.nv-sheet__row:last-child { border-bottom: none; }
.nv-sheet__row > * { min-width: 0; }
.nv-sheet__row .nv-sheet__main { flex: 1 1 auto; overflow-wrap: anywhere; }
.nv-sheet__row .nv-sheet__aside { flex: none; display: flex; align-items: center; gap: var(--sp-1); flex-wrap: wrap; justify-content: flex-end; }
.nv-sheet__meta { font-size: var(--fs-micro); line-height: var(--lh-micro); color: var(--muted); letter-spacing: 0; text-transform: none; }

/* Forms inside a sheet: one column, fields stacked with a steady rhythm. */
.nv-sheet__field { margin-top: var(--sp-3); min-width: 0; }
.nv-sheet__field > .label { margin-bottom: 4px; }
.nv-sheet__two { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: var(--sp-3); }
.nv-sheet__two > * { min-width: 0; }
@media (max-width: 400px) { .nv-sheet__two { grid-template-columns: minmax(0, 1fr); } }
.nv-sheet__chips { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-2); }
.nv-sheet__note { margin-top: var(--sp-3); font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--text-soft); }
.nv-sheet__err { margin-top: var(--sp-3); }
.nv-sheet__foot { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-5); flex-wrap: wrap; }
.nv-sheet__foot .btn { min-width: 0; }
@media (max-width: 600px) { .nv-sheet__foot .btn { flex: 1 1 auto; justify-content: center; } }

/* Picking a unit: a grid of tiles, one per free room. Selected, not committed
   — the footer button says what the tap will do ("Assign 204"). Hover is
   gated; on touch :hover latches after a tap. */
.nv-pick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: var(--sp-2); }
.nv-pick-grid > * { min-width: 0; }
.nv-pick { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-height: 56px; padding: 10px 12px;
  text-align: left; background: var(--surface); color: var(--text); border: 1px solid var(--border-control);
  border-radius: var(--radius-sm); font: inherit; cursor: pointer; min-width: 0; width: 100%;
  transition: border-color var(--dur-1) var(--ease-out), background-color var(--dur-1) var(--ease-out), box-shadow var(--dur-1) var(--ease-out); }
.nv-pick strong { font-family: var(--font-mono); font-weight: var(--fw-medium); font-size: var(--fs-body); line-height: var(--lh-body); overflow-wrap: anywhere; }
.nv-pick .nv-pick__sub { font-size: var(--fs-micro); line-height: var(--lh-micro); color: var(--text-soft); overflow-wrap: anywhere; }
.nv-pick:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.nv-pick:active { transform: scale(.98); transition-duration: 70ms; }
.nv-pick[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); box-shadow: inset 0 0 0 1px var(--accent); }
@media (hover: hover) and (pointer: fine) {
  .nv-pick:hover { border-color: var(--primary); background: var(--primary-soft); }
  .nv-pick[aria-pressed="true"]:hover { border-color: var(--accent); background: var(--accent-soft); }
}

/* The unit drawer's QR: the same printed card the QR modal draws, small. */
.nv-sheet__qr { display: grid; grid-template-columns: 116px minmax(0, 1fr); gap: var(--sp-3); align-items: start; }
.nv-sheet__qr canvas { display: block; width: 116px; height: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); }
.nv-sheet__qr > * { min-width: 0; }
.nv-sheet__url { font-family: var(--font-mono); font-size: var(--fs-micro); line-height: var(--lh-micro); color: var(--muted);
  overflow-wrap: anywhere; word-break: break-all; }
/* A checkbox with its sentence, inside a sheet. */
.nv-sheet__check { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-4);
  font-size: var(--fs-caption); line-height: var(--lh-caption); color: var(--text-soft); cursor: pointer; }
.nv-sheet__check input { width: 18px; height: 18px; flex: none; accent-color: var(--accent-fill); }
