/* ============================================================================
   VAROLYN SECURE TRUST PLATFORM — design system
   Built on the two colours sampled from the logo itself:
       #48B448  green   (31.9% of logo pixels)
       #665A72  plum    (49.6% of logo pixels)

   PERFORMANCE CONTRACT — this is a healthcare tool that runs during a live
   video call, so the UI must never steal frames from the media pipeline:

     · Animations touch ONLY transform and opacity. Nothing animates width,
       height, top, left, margin or box-shadow — those force layout or paint
       on every frame.
     · backdrop-filter is used on small surfaces only, and dropped entirely
       below 768px where it is the single most expensive effect on mobile GPUs.
     · will-change is applied on hover intent, not permanently — a permanent
       will-change holds a compositor layer for every element forever.
     · content-visibility + contain isolate off-screen work.
     · Everything meaningful is disabled under prefers-reduced-motion.
   ========================================================================= */

/* ----------------------------------------------------------------- tokens */
:root {
  /* --- brand, straight from the logo --- */
  --green-500: #48b448;              /* logo green, measured */
  --plum-600:  #665a72;              /* logo plum, measured */

  /* Green scale. 600 is the UI primary: the raw logo green is slightly too
     light to carry white text at AA, so interactive surfaces sit one step
     darker while the logo itself stays untouched. */
  --green-50:  #eef9ee;
  --green-100: #d6f0d6;
  --green-200: #aee1ae;
  --green-300: #82d182;
  --green-400: #62c162;
  --green-600: #35933a;
  --green-700: #2a7530;
  --green-800: #205a26;
  --green-900: #17421d;

  /* Plum scale — also the source of every neutral, which is what makes the
     greys feel related to the brand instead of generically cold. */
  --plum-50:  #f6f4f7;
  --plum-100: #ece9ee;
  --plum-200: #d8d3dc;
  --plum-300: #bdb5c3;
  --plum-400: #9a90a2;
  --plum-500: #7d7186;
  --plum-700: #524759;
  --plum-800: #3d3543;
  --plum-900: #29232d;
  --plum-950: #1a1620;

  /* --- semantic --- */
  /* FILLS. Measured, not assumed: white on green-600 (#35933a) is only 3.9:1 and
     fails AA for normal text. green-700 carries white at ~6:1. Both the fill and
     the foreground therefore sit at 700 — a mid-green simply cannot hold white
     text accessibly at this text size. */
  --brand:        var(--green-700);
  --brand-hover:  var(--green-800);
  --brand-soft:   var(--green-50);
  /* TEXT: brand green as a foreground on a light surface, ~6:1 on white. */
  --brand-text:   var(--green-700);
  --accent:       var(--plum-600);
  --accent-soft:  var(--plum-50);

  --ink:      var(--plum-950);
  --ink-soft: var(--plum-700);
  /* plum-500 measured 4.37:1 on the page background — just under AA. Secondary text
     is still text, so it steps down to plum-600 (~6:1) rather than staying pretty. */
  --muted:    var(--plum-600);
  --line:     var(--plum-200);
  --line-soft: var(--plum-100);

  --bg:      #faf9fb;                /* warm off-white, tinted by the plum */
  --surface: #ffffff;
  --surface-raised: #ffffff;

  --ok:   var(--green-700);
  /* Measured on the .pill.Waiting background, where this is used as text:
     #a8620c is 4.43:1 and misses AA by a hair. #9a5809 is 5.19:1. Close enough to
     look identical, far enough to actually pass. */
  --warn: #9a5809;
  --bad:  #b3261e;
  --info: var(--plum-600);
  /* Document blue and transcript violet. They were written as raw hex at three call
     sites, which is why the timeline legend was unreadable in dark mode — see the
     tints below. */
  --doc:  #1d4ed8;
  --tx:   #6d28d9;

  /* --- SOFT TINTS: the pale wash behind a pill, a notice, a status tile ---
   *
   * THIS BLOCK EXISTS BECAUSE OF A REAL BUG. Every one of these used to be a hard-coded
   * near-white hex (#fdf6ec, #f2f5f4, #eff4ff…) written for the light theme and never
   * revisited. Under `prefers-color-scheme: dark` the page text becomes near-white while
   * those panels stayed near-white — so the audit timeline's expanded `detail` blocks were
   * white text on a white box: present, selectable, and completely invisible. A coordinator
   * looking at the record of a consultation saw an empty rectangle.
   *
   * The fix is not "add dark values for eleven more hexes", which would rot the same way the
   * moment somebody adds a twelfth. Each tint is DERIVED from the semantic colour it belongs
   * to, mixed into the current surface. Both ingredients already flip with the theme, so the
   * tint flips with them and a new one is one line that cannot be forgotten.
   *
   * The plain hex on the first line is a fallback for a browser without color-mix(): an
   * unsupported value makes the whole declaration invalid and the previous one stands. No
   * @supports needed, and no page renders without a background.
   */
  --tint-warn: #fdf6ec;   --tint-warn: color-mix(in srgb, var(--warn) 11%, var(--surface));
  --tint-bad:  #fdf2f1;   --tint-bad:  color-mix(in srgb, var(--bad)  10%, var(--surface));
  --tint-ok:   #f1fbf4;   --tint-ok:   color-mix(in srgb, var(--ok)   10%, var(--surface));
  --tint-info: #f6f8fa;   --tint-info: color-mix(in srgb, var(--info)  8%, var(--surface));
  --tint-brand:#f1faf9;   --tint-brand:color-mix(in srgb, var(--brand) 10%, var(--surface));
  --tint-doc:  #eff4ff;   --tint-doc:  color-mix(in srgb, var(--doc)  10%, var(--surface));
  --tint-tx:   #f8f5ff;   --tint-tx:   color-mix(in srgb, var(--tx)   10%, var(--surface));

  /* Borders for the same set — the same mix, stronger, so the edge reads at any theme. */
  --line-warn: #f0dcc0;   --line-warn: color-mix(in srgb, var(--warn) 30%, var(--surface));
  --line-bad:  #f3d4d1;   --line-bad:  color-mix(in srgb, var(--bad)  28%, var(--surface));
  --line-ok:   #c3e6cd;   --line-ok:   color-mix(in srgb, var(--ok)   28%, var(--surface));
  --line-info: #d8dfe6;   --line-info: color-mix(in srgb, var(--info) 22%, var(--surface));
  --line-brand:#bfe0dc;   --line-brand:color-mix(in srgb, var(--brand) 28%, var(--surface));
  --line-doc:  #c7d7fb;   --line-doc:  color-mix(in srgb, var(--doc)  26%, var(--surface));
  --line-tx:   #ddd0f7;   --line-tx:   color-mix(in srgb, var(--tx)   26%, var(--surface));

  /* Preformatted blocks: JSON payloads, hashes, audit detail. A distinct surface rather
     than a tint, because the point is "this is data, not prose", in either theme. */
  --code-bg:  #f2f5f4;    --code-bg:  color-mix(in srgb, var(--ink)  5%, var(--surface));
  --code-ink: var(--ink);

  /* --- the fusion: green and plum in one gradient, used sparingly --- */
  --fusion: linear-gradient(135deg, var(--green-500) 0%, var(--green-600) 38%, var(--plum-600) 100%);
  /* Derived, for the same reason the tints are. This was two near-white hexes from the
     palette scales, which are fixed by definition — so the gradient stayed near-white in
     dark mode while the text on it went near-white too. The earlier sweep that caught the
     flat backgrounds grepped for `background: #hex` and walked straight past a gradient
     built out of variables; this is the same bug wearing different clothes. */
  --fusion-soft: linear-gradient(135deg, var(--green-50) 0%, var(--plum-50) 100%);
  --fusion-soft: linear-gradient(135deg,
      color-mix(in srgb, var(--green-500) 10%, var(--surface)) 0%,
      color-mix(in srgb, var(--accent)    12%, var(--surface)) 100%);
  --fusion-line: linear-gradient(90deg, var(--green-500), var(--plum-600));

  /* --- elevation, tinted plum so shadows belong to the palette --- */
  --sh-1: 0 1px 2px rgba(41, 35, 45, .05), 0 1px 3px rgba(41, 35, 45, .04);
  --sh-2: 0 2px 4px rgba(41, 35, 45, .05), 0 4px 12px rgba(41, 35, 45, .06);
  --sh-3: 0 4px 8px rgba(41, 35, 45, .06), 0 12px 28px rgba(41, 35, 45, .10);
  --sh-brand: 0 4px 14px rgba(42, 117, 48, .30);
  --sh-brand-lg: 0 8px 26px rgba(42, 117, 48, .36);

  --r-sm: 8px;
  --r:    12px;
  --r-lg: 18px;
  --r-full: 999px;

  /* One easing for everything — a consistent curve is most of what reads as
     "designed". Slightly overshooting cubic for interactive feedback. */
  --ease: cubic-bezier(.22, .61, .36, 1);
  --ease-out: cubic-bezier(.16, 1, .3, 1);
  --t-fast: 140ms;
  --t: 220ms;
  --t-slow: 420ms;
}

@media (prefers-color-scheme: dark) {
  :root {
    --ink: #f4f2f6;
    --ink-soft: var(--plum-200);
    --muted: var(--plum-400);
    --line: #3a3340;
    --line-soft: #2e2833;
    --bg: #141118;
    --surface: #1c1822;
    --surface-raised: #241f2b;
    --brand: var(--green-400);
    --brand-hover: var(--green-300);
    /* Inverted for dark: a dark green on a dark surface would be unreadable. */
    --brand-text: var(--green-300);
    --brand-soft: rgba(72, 180, 72, .12);
    --accent-soft: rgba(102, 90, 114, .22);
    --ok: var(--green-300);
    --warn: #e0a33e;
    --bad: #f08a80;
    /* A #1d4ed8 blue on a #1c1822 surface is 1.5:1 — legible to nobody. Both step up to
       the light end of their scale, exactly as --brand-text does above. */
    --doc: #93b4fb;
    --tx:  #c4b5fd;
    /* --info was the one semantic colour with no dark value at all. It is plum-600, a DARK
       plum, and it was surviving only because the one place it appeared as text sat on a
       hard-coded light panel — a light island in a dark page. Deriving that panel from the
       theme removed the island and left dark-on-dark at 2.6:1, so the colour itself now
       flips like every other one. */
    --info: var(--plum-300);
    /* Every --tint-* and --line-* above is color-mix(semantic, --surface), and both sides
       have just changed, so they re-derive here with nothing further to declare. That is
       the whole reason they are computed rather than listed. */
    --code-ink: var(--ink);
    --sh-1: 0 1px 2px rgba(0,0,0,.4);
    --sh-2: 0 2px 6px rgba(0,0,0,.45);
    --sh-3: 0 12px 32px rgba(0,0,0,.55);
  }
}

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

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
  font-size: 15px;
  line-height: 1.6;
  color: var(--ink);
  background: var(--bg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* A single fixed, GPU-cheap brand wash. Fixed attachment is avoided on
     mobile below, where it forces a repaint on every scroll frame. */
  background-image:
    radial-gradient(1100px 520px at 12% -8%, rgba(72,180,72,.07), transparent 60%),
    radial-gradient(900px 460px at 92% 4%, rgba(102,90,114,.08), transparent 62%);
  background-repeat: no-repeat;
  background-attachment: fixed;
}

@media (max-width: 767px) {
  body { background-attachment: scroll; }
}

/* ------------------------------------------------------------------ header */
header.bar {
  position: sticky;
  top: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 24px;
  background: color-mix(in srgb, var(--surface) 82%, transparent);
  border-bottom: 1px solid var(--line-soft);
  /* Small surface, so backdrop-filter is affordable here. */
  backdrop-filter: saturate(180%) blur(14px);
  -webkit-backdrop-filter: saturate(180%) blur(14px);
}

/* The brand hairline: the two logo colours, fused. */
header.bar::after {
  content: "";
  position: absolute;
  inset: auto 0 -1px 0;
  height: 2px;
  background: var(--fusion-line);
  opacity: .85;
}

.brand-lockup { display: flex; align-items: center; gap: 11px; min-width: 0; }

.brand-mark {
  width: 34px;
  height: 34px;
  flex: none;
  object-fit: contain;
  /* Lifts the mark off the header without a wrapper element. */
  filter: drop-shadow(0 1px 2px rgba(41,35,45,.18));
  transition: transform var(--t) var(--ease);
}
.brand-lockup:hover .brand-mark { transform: scale(1.06) rotate(-3deg); }

header.bar h1 {
  margin: 0;
  font-size: 15.5px;
  font-weight: 650;
  letter-spacing: -.015em;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

header.bar .tag {
  flex: none;
  font-size: 10.5px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .09em;
  padding: 3px 10px;
  border-radius: var(--r-full);
  color: var(--brand-text);
  background: var(--brand-soft);
  border: 1px solid color-mix(in srgb, var(--brand) 24%, transparent);
}

header.bar .spacer { flex: 1; }

/* ------------------------------------------------------------------ layout */
main { max-width: 1120px; margin: 0 auto; padding: 26px 24px 72px; }
main.narrow { max-width: 760px; }

/* Small screens: the header has four competing items and 375px of room. Rather than
   letting the product name ellipsis into "Varo…", drop the least important things
   first — the signed-in name is already discoverable from the console itself. */
@media (max-width: 640px) {
  header.bar { padding: 10px 14px; gap: 9px; }
  header.bar h1 { font-size: 14px; }
  header.bar .small { display: none; }        /* signed-in name */
  main { padding: 18px 16px 56px; }
  .card { padding: 18px 16px; border-radius: var(--r); }
  button { padding: 11px 16px; }              /* >=44px tall touch target */
}

@media (max-width: 420px) {
  /* Below this the tag and the name cannot both fit legibly. The mark carries the
     brand, so the wordmark yields. */
  header.bar h1 { font-size: 13px; letter-spacing: -.02em; }
  header.bar .tag { font-size: 9.5px; padding: 3px 7px; letter-spacing: .06em; }
  .brand-mark { width: 28px; height: 28px; }
}

/* Nothing may scroll the page sideways — a table wide enough to do so must scroll
   inside its own container instead. */
html, body { max-width: 100%; overflow-x: hidden; }

/* ------------------------------------------------------------------- cards */
.card {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--line-soft);
  border-radius: var(--r-lg);
  padding: 22px;
  margin-bottom: 18px;
  box-shadow: var(--sh-1);
  /* Isolates layout/paint work inside the card from the rest of the page. */
  contain: layout paint style;
  transition: box-shadow var(--t) var(--ease), transform var(--t) var(--ease),
              border-color var(--t) var(--ease);
}

/* Top edge catches the brand gradient on hover — opacity only, no repaint. */
.card::before {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: 3px;
  border-radius: var(--r-lg) var(--r-lg) 0 0;
  background: var(--fusion-line);
  opacity: 0;
  transition: opacity var(--t) var(--ease);
  pointer-events: none;
}
.card:hover {
  box-shadow: var(--sh-3);
  transform: translateY(-2px);
  border-color: var(--line);
}
.card:hover::before { opacity: 1; }

.card h2 {
  margin: 0 0 4px;
  font-size: 15px;
  font-weight: 680;
  letter-spacing: -.012em;
}
.card .hint { margin: 0 0 16px; font-size: 13px; color: var(--muted); }

/* ------------------------------------------------------------------ inputs */
label {
  display: block;
  font-size: 12.5px;
  font-weight: 620;
  letter-spacing: .005em;
  color: var(--ink-soft);
  margin: 14px 0 6px;
}

input, select, textarea {
  width: 100%;
  padding: 10px 12px;
  font: inherit;
  font-size: 14px;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r);
  transition: border-color var(--t-fast) var(--ease),
              box-shadow var(--t-fast) var(--ease);
}
input::placeholder { color: var(--plum-300); }

input:hover, select:hover, textarea:hover { border-color: var(--plum-300); }

input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--brand);
  box-shadow: 0 0 0 3.5px color-mix(in srgb, var(--brand) 16%, transparent);
}

/* ----------------------------------------------------------------- buttons */
/* `a.btn` is folded into every button rule below rather than given its own copy: two
   near-identical blocks drift, and the link-underline rule in the motion layer already
   excludes .btn on the assumption that this class exists and looks like a button. */
button, a.btn {
  --btn-bg: var(--brand);
  --btn-fg: #fff;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  font: inherit;
  font-size: 14px;
  font-weight: 620;
  letter-spacing: -.005em;
  line-height: 1.2;
  padding: 10px 18px;
  color: var(--btn-fg);
  background: var(--btn-bg);
  border: 1px solid transparent;
  border-radius: var(--r);
  cursor: pointer;
  isolation: isolate;
  overflow: hidden;
  box-shadow: var(--sh-brand);
  transform: translateZ(0);           /* own layer, so hover never repaints siblings */
  transition: transform var(--t-fast) var(--ease),
              box-shadow var(--t) var(--ease),
              background-color var(--t-fast) var(--ease),
              color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease);
}

/* Sheen sweep. A single translated pseudo-element — no gradient re-render. */
button::after, a.btn::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: linear-gradient(100deg, transparent 20%,
              rgba(255,255,255,.28) 50%, transparent 80%);
  transform: translateX(-130%);
  transition: transform var(--t-slow) var(--ease-out);
}
button:hover::after, a.btn:hover::after { transform: translateX(130%); }

button:hover, a.btn:hover { background: var(--brand-hover); box-shadow: var(--sh-brand-lg); transform: translateY(-1px); }
button:active, a.btn:active { transform: translateY(0) scale(.985); box-shadow: var(--sh-brand); transition-duration: 60ms; }

button:focus-visible, a.btn:focus-visible {
  outline: none;
  box-shadow: var(--sh-brand), 0 0 0 3.5px color-mix(in srgb, var(--brand) 30%, transparent);
}

a.btn {
  text-decoration: none;
  background-image: none;          /* cancels the animated underline for links */
  width: auto;
}

button:disabled {
  opacity: .45;
  cursor: not-allowed;
  box-shadow: none;
  transform: none;
}
button:disabled::after { display: none; }

button.ghost {
  --btn-bg: transparent;
  --btn-fg: var(--brand-text);
  border-color: color-mix(in srgb, var(--brand) 34%, transparent);
  box-shadow: none;
}
button.ghost:hover {
  background: var(--brand-soft);
  border-color: var(--brand);
  box-shadow: 0 2px 10px rgba(53,147,58,.14);
}
button.ghost::after { background: linear-gradient(100deg, transparent 20%, rgba(53,147,58,.10) 50%, transparent 80%); }

button.danger {
  --btn-bg: transparent;
  --btn-fg: var(--bad);
  border-color: color-mix(in srgb, var(--bad) 32%, transparent);
  box-shadow: none;
}
button.danger:hover {
  background: color-mix(in srgb, var(--bad) 8%, transparent);
  border-color: var(--bad);
  box-shadow: 0 2px 10px rgba(179,38,30,.16);
}
button.danger::after { display: none; }

.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.row.end { justify-content: flex-end; }

/* ------------------------------------------------------------------ tables */
.scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }

table { width: 100%; border-collapse: collapse; font-size: 13.5px; }

th {
  position: sticky;
  top: 0;
  z-index: 1;
  text-align: left;
  padding: 10px 10px;
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .075em;
  color: var(--muted);
  background: var(--surface);
  border-bottom: 1px solid var(--line);
}

td {
  padding: 11px 10px;
  border-bottom: 1px solid var(--line-soft);
  vertical-align: top;
}

tbody tr { transition: background-color var(--t-fast) var(--ease); }
tbody tr:hover { background: var(--accent-soft); }
tbody tr:last-child td { border-bottom: none; }

/* ------------------------------------------------------------------- pills */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 11.5px;
  font-weight: 650;
  letter-spacing: .01em;
  padding: 3px 10px;
  border-radius: var(--r-full);
  color: var(--muted);
  background: var(--plum-50);
  border: 1px solid var(--line);
  white-space: nowrap;
}
.pill::before {
  content: "";
  width: 6px; height: 6px;
  border-radius: 50%;
  background: currentColor;
  opacity: .8;
  flex: none;
}

.pill.Scheduled, .pill.issued { color: var(--plum-600); background: var(--plum-50);  border-color: var(--plum-200); }
.pill.Waiting             { color: var(--warn);  background: var(--tint-warn); border-color: var(--line-warn); }
.pill.Active              { color: var(--ok);    background: var(--green-50); border-color: var(--green-200); }
.pill.Completed           { color: var(--muted); }
.pill.Cancelled, .pill.Expired,
.pill.consumed, .pill.revoked, .pill.expired { color: var(--bad); background: var(--tint-bad); border-color: var(--line-bad); }

/* Live states get a soft pulse — opacity only, cheap, and it stops on
   reduced-motion. */
.pill.Active::before { animation: pulse 2.4s var(--ease) infinite; }
@keyframes pulse {
  0%, 100% { opacity: 1;  transform: scale(1); }
  50%      { opacity: .45; transform: scale(.82); }
}

code, .mono { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace; font-size: 12.5px; }

/* A LINK, A SECRET, OR A COMMAND — something to be read exactly and then copied.
 *
 * This was a pale brand gradient, which in dark mode put near-white monospace on a near-white
 * wash: the session links were rendered, selectable, and unreadable, so a coordinator could
 * not send a patient their link. It uses the same surface as the audit `pre` blocks now,
 * because it is the same kind of thing — data, not prose — and that surface is derived from
 * the theme rather than declared per-mode.
 */
.linkbox {
  position: relative;
  background: var(--code-bg);
  color: var(--code-ink);
  border: 1px dashed var(--line);
  border-radius: var(--r);
  padding: 11px 40px 11px 13px;      /* room on the right for the copy affordance */
  word-break: break-all;
  margin: 7px 0;
  font-size: 12.5px;
  cursor: pointer;
  user-select: all;                   /* one click selects the whole value, if JS is gone */
  transition: border-color var(--t) var(--ease), background var(--t) var(--ease);
}
.linkbox:hover { border-color: var(--brand); }
.linkbox:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
}

/* The affordance. Present at rest so it is discoverable, because nothing else on the box
   says "you can click this" — and a copy action nobody knows about is not a feature. */
.linkbox::after {
  content: "COPY";
  position: absolute;
  top: 50%;
  right: 10px;
  transform: translateY(-50%);
  font-size: 9.5px;
  font-weight: 700;
  letter-spacing: .06em;
  color: var(--muted);
  opacity: .55;
  transition: opacity var(--t) var(--ease), color var(--t) var(--ease);
  pointer-events: none;
}
.linkbox:hover::after { opacity: 1; color: var(--brand-text); }

/* COPIED. A brief green wash sweeping across, then the label confirms and fades back.
   Deliberately short and non-blocking: the point is to answer "did that work?" in the
   quarter-second the eye is still on the box, not to put on a show. */
.linkbox--copied {
  border-color: var(--brand);
  border-style: solid;
  animation: linkbox-flash var(--t-slow) var(--ease-out);
}
.linkbox--copied::after {
  content: "COPIED";
  opacity: 1;
  color: var(--brand-text);
  animation: linkbox-tick var(--t) var(--ease-out);
}
@keyframes linkbox-flash {
  0%   { background: color-mix(in srgb, var(--brand) 26%, var(--surface)); }
  100% { background: var(--code-bg); }
}
@keyframes linkbox-tick {
  0%   { transform: translateY(-50%) scale(.7); opacity: 0; }
  60%  { transform: translateY(-50%) scale(1.12); opacity: 1; }
  100% { transform: translateY(-50%) scale(1); opacity: 1; }
}
.linkbox--failed { border-color: var(--bad); border-style: solid; }
.linkbox--failed::after { content: "SELECT AND COPY"; color: var(--bad); opacity: 1; }

/* Someone who has asked the system not to animate has asked for a reason — vestibular
   disorders are the usual one. They still get the state change, just without the movement. */
@media (prefers-reduced-motion: reduce) {
  .linkbox--copied { animation: none; }
  .linkbox--copied::after { animation: none; }
}

/* ----------------------------------------------------------------- notices */
.notice {
  position: relative;
  padding: 13px 16px 13px 18px;
  border-radius: 0 var(--r) var(--r) 0;
  font-size: 13.5px;
  margin: 13px 0;
  background: var(--brand-soft);
  border: 1px solid var(--line-soft);
  border-left: 3px solid var(--brand);
  animation: notice-in var(--t-slow) var(--ease-out) both;
}
.notice.warn { background: var(--tint-warn); border-left-color: var(--warn); }
.notice.bad  { background: var(--tint-bad); border-left-color: var(--bad); }

@keyframes notice-in {
  from { opacity: 0; transform: translateY(-5px); }
  to   { opacity: 1; transform: none; }
}

/* ------------------------------------------------------------------- stage */
/* The session page's layout moved to session.css when /join became a video-call layout
   rather than a page of cards. An id selector here would outrank every class there, so the
   old two-column grid is removed rather than left to fight it — and `main`'s max-width and
   bottom padding are lifted for that page too, because a call fills the viewport. */
main#stage {
  max-width: none;
  padding: 0;
}

.video-wrap {
  position: relative;
  background: var(--plum-950);
  border-radius: var(--r-lg);
  overflow: hidden;
  /* 16:9 to MATCH THE SOURCE. This was 4/3, which with object-fit:cover meant a 1920x1080
     camera had a third of every frame cropped away and the remainder upscaled to fill —
     throwing away real pixels and then inventing some. Matching the container to the capture
     aspect ratio is most of what "crystal clear" actually means in practice. */
  aspect-ratio: 16 / 9;
  box-shadow: var(--sh-3);
  contain: paint;
}
/* `object-fit: contain` rather than cover: with the container now matching the source, they
   are the same for a 16:9 camera, but a 4:3 webcam is letterboxed intact instead of having
   its top and bottom cropped off — which on a verification call is someone's forehead.
   `image-rendering` is deliberately left at the default: smooth scaling is correct for
   video, and `pixelated`/`crisp-edges` would make it worse, not sharper. */
.video-wrap video {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  background: var(--plum-950);
}

.video-wrap .self {
  position: absolute;
  right: 12px; bottom: 12px;
  width: 27%;
  /* Matches the source too — see the note on .video-wrap. */
  aspect-ratio: 16 / 9;
  border-radius: var(--r);
  border: 2px solid rgba(255,255,255,.7);
  overflow: hidden;
  background: #000;
  box-shadow: 0 6px 18px rgba(0,0,0,.4);
  transition: transform var(--t) var(--ease);
}
.video-wrap .self video { object-fit: cover; }   /* a thumbnail of yourself may crop */
.video-wrap .self:hover { transform: scale(1.04); }

.video-wrap .overlay {
  position: absolute;
  inset: 0;
  display: grid;
  place-content: center;
  text-align: center;
  padding: 24px;
  font-size: 14px;
  color: var(--plum-200);
  background: var(--plum-950);
}

.doc-frame {
  width: 100%;
  height: 460px;
  border: 1px solid var(--line);
  border-radius: var(--r);
  /* Stays white in both themes ON PURPOSE: this frame holds a scanned certificate, which is
     a photograph of a white page. Tinting it would alter the document being examined. */
  background: #fff;
  box-shadow: var(--sh-1);
}

.waiting-item {
  display: flex;
  align-items: center;
  gap: 10px;
  justify-content: space-between;
  border: 1px solid var(--line-warn);
  border-radius: var(--r);
  padding: 11px 13px;
  margin-bottom: 8px;
  background: var(--tint-warn);
  animation: notice-in var(--t) var(--ease-out) both;
}

.credential-head { display: flex; gap: 14px; align-items: center; margin-bottom: 8px; }
.credential-head img {
  width: 56px; height: 56px;
  border-radius: 50%;
  border: 2px solid var(--surface);
  box-shadow: 0 0 0 2px var(--green-200), var(--sh-1);
}

.hidden { display: none !important; }
.small { font-size: 12.5px; color: var(--muted); }

/* ------------------------------------------------------- reduced motion */
/* Everything above degrades to instant. Hover colour changes remain, because
   they are affordance rather than decoration. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
  button:hover, .card:hover, .brand-lockup:hover .brand-mark,
  .video-wrap .self:hover { transform: none !important; }
  button::after { display: none; }
}

/* Responsive product name. Two spans rather than a CSS-injected string, so the full
   name stays in the document for assistive technology and page titles. */
.t-short { display: none; }
@media (max-width: 560px) {
  .t-full  { display: none; }
  .t-short { display: inline; }
}

/* Compact in-table action. On a pointer device 24px is fine; on touch the tap area
   is padded out to the 44px Apple/WCAG target WITHOUT growing the visual button,
   using a transparent ::before overlay. Inline styles used to force 20px here,
   which is unusable on a phone. */
button.btn-xs {
  position: relative;
  padding: 3px 9px;
  font-size: 12px;
  border-radius: var(--r-sm);
}
@media (pointer: coarse) {
  button.btn-xs::before {
    content: "";
    position: absolute;
    left: 0; right: 0;
    top: 50%;
    height: 44px;
    transform: translateY(-50%);
    min-width: 44px;
  }
}

/* ------------------------------------------------ third-party module surfaces
   transcript-client injects its own stylesheet at runtime, so it lands AFTER this
   file in the cascade. It sets a light background on its buttons but no colour,
   which combined with the global white button text produced WHITE ON #f6f8fb —
   a contrast ratio of about 1.05:1, i.e. genuinely unreadable. It also ships an
   off-palette blue. Re-specified here with an id selector so these win on
   specificity rather than !important. */
#transcriptMount .vstp-tx__controls button {
  color: var(--brand-text);
  background: var(--surface);
  border: 1px solid color-mix(in srgb, var(--brand) 32%, transparent);
  border-radius: var(--r-sm);
  box-shadow: none;
  transition: background-color var(--t-fast) var(--ease),
              border-color var(--t-fast) var(--ease),
              transform var(--t-fast) var(--ease);
}
#transcriptMount .vstp-tx__controls button:hover {
  background: var(--brand-soft);
  border-color: var(--brand);
  transform: translateY(-1px);
}
#transcriptMount .vstp-tx__controls button:disabled {
  color: var(--muted);
  border-color: var(--line);
  background: color-mix(in srgb, var(--ink) 6%, var(--surface));
  transform: none;
}

/* Its send button ships as #1f4f82. Bring it onto the brand. */
#transcriptMount button[class*="send"],
#transcriptMount .vstp-tx__send button,
#transcriptMount form button[type="submit"] {
  color: #fff;
  background: var(--brand);
  border-color: transparent;
  box-shadow: var(--sh-brand);
}
#transcriptMount button[class*="send"]:hover,
#transcriptMount .vstp-tx__send button:hover,
#transcriptMount form button[type="submit"]:hover { background: var(--brand-hover); }

/* Keep its panels on the palette too. */
#transcriptMount .vstp-tx { border-color: var(--line-soft); border-radius: var(--r); }
#transcriptMount input, #transcriptMount textarea { border-radius: var(--r-sm); }


/* ==========================================================================
   MOTION LAYER
   Added to the same performance contract as everything above: only transform
   and opacity are animated, nothing here forces layout or paint mid-frame,
   and the whole layer collapses under prefers-reduced-motion.

   The rule applied throughout: motion earns its place by telling the user
   something. A waiting patient learns they have not been forgotten; a
   caregiver's eye is pulled to the one control that matters; a state change
   is felt rather than merely rendered. Decoration that says nothing was left
   out — this runs during a live video call on a mid-range Android phone.
   ========================================================================== */

/* ------------------------------------------------------- entrance stagger */
/* Cards arrive in sequence rather than all at once. The delay is capped at six
   steps: beyond that a list stops feeling responsive and starts feeling slow. */
@keyframes rise-in {
  from { opacity: 0; transform: translate3d(0, 14px, 0) scale(.985); }
  to   { opacity: 1; transform: none; }
}

/* Every card container in the product: the sign-in page, the coordinator console,
   and the session stage. The delay list below must cover the SAME set — an
   animation without its matching delay rule renders the whole group at once,
   which is exactly the stagger not happening. */
main > .card,
main > section.card,
#console > .card,
#stage > .card {
  animation: rise-in 520ms var(--ease-out) both;
}
main > .card:nth-child(1),  #console > .card:nth-child(1),  #stage > .card:nth-child(1)   { animation-delay: 0ms; }
main > .card:nth-child(2),  #console > .card:nth-child(2),  #stage > .card:nth-child(2)   { animation-delay: 60ms; }
main > .card:nth-child(3),  #console > .card:nth-child(3),  #stage > .card:nth-child(3)   { animation-delay: 120ms; }
main > .card:nth-child(4),  #console > .card:nth-child(4),  #stage > .card:nth-child(4)   { animation-delay: 180ms; }
main > .card:nth-child(5),  #console > .card:nth-child(5),  #stage > .card:nth-child(5)   { animation-delay: 240ms; }
main > .card:nth-child(n+6),#console > .card:nth-child(n+6),#stage > .card:nth-child(n+6) { animation-delay: 300ms; }

/* Table rows: the same idea, shorter, and only for the first screenful. A
   hundred-row stagger would be an animation the user waits out. */
@keyframes row-in {
  from { opacity: 0; transform: translate3d(-6px, 0, 0); }
  to   { opacity: 1; transform: none; }
}
tbody tr { animation: row-in 340ms var(--ease-out) both; }
tbody tr:nth-child(1) { animation-delay: 20ms; }
tbody tr:nth-child(2) { animation-delay: 45ms; }
tbody tr:nth-child(3) { animation-delay: 70ms; }
tbody tr:nth-child(4) { animation-delay: 95ms; }
tbody tr:nth-child(5) { animation-delay: 120ms; }
tbody tr:nth-child(6) { animation-delay: 145ms; }
tbody tr:nth-child(n+7) { animation-delay: 165ms; }

/* --------------------------------------------------------- the brand mark */
/* One quiet arrival on load. Not a loop: a logo that animates forever is a
   logo the user learns to ignore. */
@keyframes mark-in {
  0%   { opacity: 0; transform: translate3d(0,-6px,0) scale(.7) rotate(-12deg); }
  60%  { opacity: 1; transform: translate3d(0,0,0) scale(1.06) rotate(2deg); }
  100% { opacity: 1; transform: none; }
}
.brand-mark { animation: mark-in 700ms var(--ease-out) both; }

/* The fused hairline under the header draws itself once, left to right. */
@keyframes hairline-draw { from { transform: scaleX(0); } to { transform: scaleX(1); } }
header.bar::after {
  transform-origin: left center;
  animation: hairline-draw 900ms var(--ease-out) both;
  animation-delay: 120ms;
}

/* ----------------------------------------------------- the waiting screen */
/* The patient's most anxious moment in the whole product: admitted or not,
   they are looking at a still frame wondering whether anything is happening.
   A slow, calm breath — closer to a resting pulse than a spinner, which reads
   as urgency. Opacity and transform only, so it costs nothing during media
   negotiation. */
@keyframes breathe {
  0%, 100% { opacity: .55; transform: scale(1); }
  50%      { opacity: 1;   transform: scale(1.5); }
}
.video-wrap .overlay { position: relative; }
.video-wrap .overlay::before {
  content: "";
  position: absolute;
  top: calc(50% - 46px);
  left: 50%;
  width: 10px; height: 10px;
  margin-left: -5px;
  border-radius: 50%;
  background: var(--green-400);
  box-shadow: 0 0 0 6px rgba(72,180,72,.16), 0 0 22px rgba(72,180,72,.5);
  animation: breathe 3.4s var(--ease) infinite;
}

/* ------------------------------------------------- a patient has arrived */
/* The waiting list is where a caregiver must notice something new. The row
   arrives with a brief warm sweep, then settles and stays still — a row that
   pulses forever becomes wallpaper. */
@keyframes arrive {
  0%   { opacity: 0; transform: translate3d(0,-10px,0) scale(.98); }
  55%  { opacity: 1; transform: translate3d(0,2px,0) scale(1.005); }
  100% { opacity: 1; transform: none; }
}
@keyframes sweep-once {
  from { transform: translateX(-110%); }
  to   { transform: translateX(210%); }
}
.waiting-item {
  position: relative;
  overflow: hidden;
  animation: arrive 460ms var(--ease-out) both;
}
.waiting-item::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(100deg, transparent 25%, rgba(255,255,255,.7) 50%, transparent 75%);
  animation: sweep-once 1100ms var(--ease-out) 260ms both;
}

/* The admit control is the single most consequential button on the screen
   (SEC-0002 — nothing reaches the patient until it is pressed). It gets a
   halo that fades out after three beats: attention first, then silence. */
/* Expressed as a scaling ring on a pseudo-element rather than an animated
   box-shadow. box-shadow is explicitly excluded by the performance contract at
   the top of this file: every frame repaints the element and its backdrop.
   transform + opacity on an overlay composites instead, and costs nothing. */
@keyframes halo {
  0%   { opacity: .55; transform: scale(1); }
  70%  { opacity: 0;   transform: scale(1.5); }
  100% { opacity: 0;   transform: scale(1.5); }
}
.waiting-item button:not(.ghost):not(.danger) { position: relative; }
.waiting-item button:not(.ghost):not(.danger)::before {
  content: "";
  position: absolute;
  inset: -2px;
  border-radius: inherit;
  border: 2px solid var(--green-400);
  pointer-events: none;
  opacity: 0;
  animation: halo 2s var(--ease-out) 3;
}

/* --------------------------------------------------------- state changes */
/* When a pill's state actually changes, the JS re-renders it; the entrance
   animation makes the change felt instead of silently swapped. */
@keyframes pill-in {
  from { opacity: 0; transform: scale(.86); }
  to   { opacity: 1; transform: none; }
}
.pill { animation: pill-in 300ms var(--ease-out) both; }

/* The Active dot pulse (defined with the pills above) is the live-session signal.
   A second tint on the pill body was tried and removed: an ::after at z-index:-1
   sits behind the pill's own opaque background, so it animated a layer nobody
   could see. Two signals for one state was redundant anyway. */

/* ---------------------------------------------------------- test-instance */
/* Deliberately the one restless element in the interface. If this is showing,
   the operator is not on the live system and should not mistake it for one. */
@keyframes warn-glow {
  0%, 100% { opacity: 0;   transform: scale(1); }
  50%      { opacity: .45; transform: scale(1.12); }
}
header.bar .tag.tag-warn {
  position: relative;
  color: var(--warn);
  background: var(--tint-warn);
  border-color: var(--line-warn);
}
/* Infinite, so it must not touch box-shadow — that would repaint the badge and
   the blurred header behind it for the entire session. */
header.bar .tag.tag-warn::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: inherit;
  background: rgba(190,120,20,.35);
  z-index: -1;
  animation: warn-glow 2.6s var(--ease) infinite;
}

/* ------------------------------------------------------------- buttons */
/* The shine sweep already exists on hover. This adds the press: a short,
   physical settle rather than a jump back. */
button { will-change: auto; }
button:active { transition-duration: 70ms; }

/* Keyboard focus gets its own arrival so tabbing feels tracked, not teleported. */
/* A one-shot scale settle on focus. The ring itself is the existing
   box-shadow rule above; this only makes the arrival feel tracked rather than
   teleported, without animating the shadow. */
@keyframes focus-settle {
  from { transform: scale(.985); }
  to   { transform: none; }
}
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible { animation: focus-settle 160ms var(--ease-out) both; }

/* --------------------------------------------------------------- inputs */
/* A field that has content sits slightly forward of one that is empty —
   a quiet progress signal while filling a form. */
input, select, textarea { transition: transform var(--t-fast) var(--ease), border-color var(--t-fast) var(--ease), box-shadow var(--t-fast) var(--ease); }
input:focus, select:focus, textarea:focus { transform: translateY(-1px); }

/* ---------------------------------------------------------- credentials */
/* The credential photo is the trust moment — the patient is deciding whether
   to believe this person is who the platform says. It arrives, once. */
@keyframes credential-in {
  0%   { opacity: 0; transform: scale(.9); }
  100% { opacity: 1; transform: none; }
}
.credential-head img {
  animation: credential-in 520ms var(--ease-out) both;
  transition: transform var(--t) var(--ease), box-shadow var(--t) var(--ease);
}
.credential-head img:hover {
  transform: scale(1.06);
  box-shadow: 0 0 0 2px var(--green-300), var(--sh-2);
}

/* The document itself fades up rather than snapping in — it is rendered
   server-side and can take a moment. */
.doc-frame { animation: rise-in 480ms var(--ease-out) both; }

/* ------------------------------------------------------------- loading */
/* A shimmer placeholder for anything fetched. background-position is animated
   here rather than a transform because there is no element to move — it is a
   single compositor-friendly gradient and it is never on screen for long. */
/* A translating highlight rather than an animated background-position: the
   usual shimmer recipe repaints the whole block every frame, and a skeleton is
   on screen precisely when the device is already busy fetching. */
@keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
.skeleton {
  position: relative;
  overflow: hidden;
  /* A loading placeholder built from a fixed near-white was a bright rectangle flashing on a
     dark page — the opposite of the calm a skeleton exists to convey. */
  background: color-mix(in srgb, var(--ink) 9%, var(--surface));
  border-radius: var(--r-sm);
  color: transparent !important;
  user-select: none;
  min-height: 1em;
}
.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--surface) 70%, transparent), transparent);
  animation: shimmer 1.4s var(--ease) infinite;
}

/* --------------------------------------------------------------- links */
/* An underline that grows from the left rather than appearing whole.
   Excludes .nv-item: this rule is for links in PROSE. At (0,1,1) it outranks
   .nv-item at (0,1,0), so without the exclusion it captured the six sidebar
   items that point at the other page — they lost the transform lift, kept a
   `transition: background-size` instead, and grew a green underline on hover
   while the seven same-page items did not. One menu, two behaviours, decided
   by which page an item happens to link to. */
a:not(.btn):not(.nv-item) {
  background-image: linear-gradient(var(--brand), var(--brand));
  background-size: 0% 1.5px;
  background-position: 0 100%;
  background-repeat: no-repeat;
  transition: background-size var(--t) var(--ease);
  text-decoration: none;
}
a:not(.btn):not(.nv-item):hover, a:not(.btn):not(.nv-item):focus-visible { background-size: 100% 1.5px; }

/* ---------------------------------------------------------- reduced motion */
/* The block above already flattens durations globally. These are the loops it
   cannot reach by duration alone — an infinite animation with a 0.001ms
   duration still repeats forever and still holds a compositor layer. */
@media (prefers-reduced-motion: reduce) {
  .video-wrap .overlay::before,
  .pill.Active::before,
  header.bar .tag.tag-warn::after,
  .waiting-item::after,
  .waiting-item button:not(.ghost):not(.danger)::before,
  .skeleton::after {
    animation: none !important;
  }
  /* The breathing dot still needs to communicate "waiting", so it stays —
     simply static and visible rather than pulsing. */
  .video-wrap .overlay::before { opacity: .9; }
  .skeleton::after { display: none; }
  input:focus, select:focus, textarea:focus { transform: none !important; }
  a:not(.btn):not(.nv-item) { background-size: 100% 1.5px; transition: none; }
}

/* ------------------------------------------------------------ small screens */
/* Phones do the video call. Anything ornamental that costs a compositor layer
   comes off — the media pipeline gets the frames instead. */
@media (max-width: 767.98px) {
  .waiting-item::after { display: none; }
  main > .card, #console > .card, #stage > .card, tbody tr { animation-delay: 0ms !important; }
}


/* ==========================================================================
   DARK MODE CORRECTIONS

   Found by measuring, not by looking: 36 text nodes failed WCAG AA in dark mode
   while the light theme passed cleanly. Two independent causes, both of the same
   shape — half a component flipped for dark and the other half did not.

   1. `--brand` flips to a LIGHT green in dark mode (green-400), but button text
      stayed hardcoded white. White on #62c162 is 2.25:1. A filled button needs
      its foreground chosen against the fill, so in dark mode that foreground has
      to be dark — the same reason --brand-text flips the other way.

   2. Pills, notices and the waiting row carried hardcoded light backgrounds
      (#fdf6ec, #fdf2f1, …) that never changed, while their text colours were
      tokens that DO change. The result was a light-mode background under a
      dark-mode foreground: light-on-light, 1.7:1 at worst.

   The fix keeps every hue and simply completes each flip.

   NOTE (2026-08-01): the second problem is now also solved one level up — every one of
   those washes is a --tint-* derived from its semantic colour and the current surface,
   so it flips on its own and this block is no longer load-bearing for correctness. What
   remains here is hand-tuning: translucent fills read better over a dark card than an
   opaque mix, and these hues were chosen by eye. Deleting this block would leave the page
   correct but slightly flatter. Anything ADDED below must still be a deliberate refinement,
   never a fix for a missing flip — if a flip is missing, fix the variable.
   ========================================================================== */
@media (prefers-color-scheme: dark) {

  /* --- filled controls: dark ink on the light-green fill --- */
  button {
    color: var(--plum-950);
  }
  button:disabled { color: var(--plum-800); }

  /* Ghost and danger buttons are outlined, not filled, so they keep a light
     foreground — overriding them with dark ink would break them the other way. */
  button.ghost { color: var(--brand-text); }
  button.danger { color: #fff; }

  /* --- pills: dark, translucent tints instead of the light-mode washes --- */
  .pill {
    color: var(--ink-soft);
    background: rgba(255, 255, 255, .07);
    border-color: var(--line);
  }
  .pill.Scheduled, .pill.issued {
    color: var(--plum-200);
    background: rgba(157, 145, 166, .16);
    border-color: rgba(157, 145, 166, .32);
  }
  .pill.Waiting {
    color: #f2c579;
    background: rgba(224, 163, 62, .16);
    border-color: rgba(224, 163, 62, .34);
  }
  .pill.Active {
    color: var(--green-300);
    background: rgba(72, 180, 72, .16);
    border-color: rgba(72, 180, 72, .34);
  }
  .pill.Completed { color: var(--plum-300); }
  .pill.Cancelled, .pill.Expired,
  .pill.consumed, .pill.revoked, .pill.expired {
    color: #f5a79e;
    background: rgba(240, 138, 128, .15);
    border-color: rgba(240, 138, 128, .33);
  }

  /* --- notices --- */
  .notice      { background: var(--brand-soft); }
  .notice.warn { background: rgba(224, 163, 62, .13); }
  .notice.bad  { background: rgba(240, 138, 128, .13); }

  /* --- the waiting row: a patient arriving must read as attention, not alarm --- */
  .waiting-item {
    background: rgba(224, 163, 62, .13);
    border-color: rgba(224, 163, 62, .3);
  }
  /* Its sweep highlight is white by default, which is far too bright on a dark row. */
  .waiting-item::after {
    background: linear-gradient(100deg, transparent 25%, rgba(255,255,255,.14) 50%, transparent 75%);
  }

  /* --- the test-instance badge --- */
  header.bar .tag.tag-warn {
    color: #f2c579;
    background: rgba(224, 163, 62, .16);
    border-color: rgba(224, 163, 62, .38);
  }

  /* --- the credential ring reads as a halo on light, a rim on dark --- */
  .credential-head img { border-color: var(--surface-raised); }
}

/* ==========================================================================
   CAPTURE ALERT + QUALITY NOTE
   Same performance contract as everything above: transform and opacity only,
   no layout thrash, nothing looping forever, all of it inert under
   prefers-reduced-motion.

   The design problem here is unusual and worth stating. This alert appears
   during a live medical conversation, to tell someone that a protection
   fired — and detection is PROBABILISTIC, so it is often a false alarm from
   a patient simply leaning in to read small text.

   That rules out the obvious visual language. Red, a shake, a siren icon or
   anything that reads as an accusation would be wrong: the software cannot
   support the claim, and a frightened patient in a healthcare setting is a
   real cost. So it is amber, it settles rather than flashes, and it pulses
   exactly ONCE. Serious, not alarming.
   ========================================================================== */

.capture-alert {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  margin-top: 14px;
  padding: 13px 15px;
  border: 1px solid rgba(224, 163, 62, .42);
  border-left: 3px solid var(--warn);
  border-radius: var(--r);
  background: rgba(224, 163, 62, .10);
  animation: alert-in 420ms var(--ease-out) both;
}

@keyframes alert-in {
  from { opacity: 0; transform: translate3d(0, -8px, 0) scale(.99); }
  to   { opacity: 1; transform: none; }
}

.capture-alert__icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 32px; height: 32px;
  border-radius: 50%;
  color: var(--warn);
  background: rgba(224, 163, 62, .17);
}

.capture-alert__body   { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.capture-alert__title  { font-size: 14px; letter-spacing: -.01em; color: var(--ink); }
.capture-alert__text   { font-size: 12.8px; line-height: 1.55; color: var(--ink-soft); }

/* The caregiver's copy. They are watching a face, not this panel, and they are
   the one who has to decide something — so their version is drawn slightly
   stronger and pulses once to pull the eye away from the video.

   ONCE is the whole point. A loop becomes wallpaper within about ten seconds,
   and this needs to still mean something the fifth time it happens in a week. */
.capture-alert--staff {
  border-color: rgba(224, 163, 62, .55);
  background: rgba(224, 163, 62, .14);
}

@keyframes alert-pulse {
  0%   { transform: none; }
  30%  { transform: scale(1.014); }
  100% { transform: none; }
}
.capture-alert--pulse { animation: alert-in 420ms var(--ease-out) both, alert-pulse 900ms var(--ease) 420ms 1; }

/* The icon's ring expands outward once and dissolves. Drawn with a pseudo
   element and scaled — not an animated box-shadow, which would repaint the
   element on every frame of a live call. */
.capture-alert--pulse .capture-alert__icon { position: relative; }
.capture-alert--pulse .capture-alert__icon::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--warn);
  animation: ring-out 1100ms var(--ease-out) 300ms 1 both;
}
@keyframes ring-out {
  from { opacity: .55; transform: scale(1); }
  to   { opacity: 0;   transform: scale(1.85); }
}

/* --------------------------------------------------------- quality note */
/* Deliberately the quietest thing on the page. A change of bitrate is not
   news; it is a courtesy so nobody thinks the picture got worse by accident. */
.quality-note {
  margin: 10px 0 0;
  font-size: 12.2px;
  color: var(--muted);
  display: flex;
  align-items: center;
  gap: 7px;
  animation: fade-slide 340ms var(--ease-out) both;
}
.quality-note::before {
  content: '';
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--brand);
  flex: none;
}
@keyframes fade-slide {
  from { opacity: 0; transform: translate3d(0, 4px, 0); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .capture-alert--pulse .capture-alert__icon::after { display: none; }
}

/* --------------------------------------------------------------- dark */
@media (prefers-color-scheme: dark) {
  .capture-alert       { background: rgba(224, 163, 62, .12); }
  .capture-alert--staff{ background: rgba(224, 163, 62, .17); }
  .capture-alert__title{ color: #f4e6cd; }
  .capture-alert__text { color: var(--plum-200); }
}

/* ==========================================================================
   LINKS
   Found by measurement, not by eye: in-copy anchors had NO rule anywhere in
   this stylesheet, so they rendered at the browser default #0000EE. Against
   the dark surface that measures 1.89:1, where AA requires 4.5:1 — and the
   most prominent link in the product is "raise a grievance", the DPDP Act
   s.13 route a patient uses when something has gone wrong. An unreadable
   complaints link is the one link that must never be unreadable.

   Measured replacements: green-700 on the light background is 5.43:1,
   green-300 on the dark card is 9.66:1.
   ========================================================================== */

a {
  color: var(--brand-text);
  text-decoration: underline;
  /* Sits the rule clear of descenders instead of striking through them —
     the difference between a link that looks deliberate and one that looks
     like a rendering fault. */
  text-underline-offset: 2px;
  text-decoration-thickness: 1px;
  text-decoration-color: color-mix(in srgb, var(--brand-text) 42%, transparent);
  border-radius: 3px;
  transition: color var(--t) var(--ease), text-decoration-color var(--t) var(--ease);
}
a:hover {
  color: var(--brand-hover);
  text-decoration-color: currentColor;
}
/* Keyboard focus must be visible on both surfaces. `:focus-visible` rather
   than `:focus` so a mouse click does not leave a ring behind. */
a:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: 2px;
  text-decoration-color: currentColor;
}

@media (prefers-color-scheme: dark) {
  a { color: var(--green-300); }
  a:hover { color: var(--green-200); }
  a:focus-visible { outline-color: var(--green-300); }
}

/* ==========================================================================
   LIVENESS CAPTURE STAGE
   Same performance contract: transform and opacity only, nothing looping
   forever, inert under prefers-reduced-motion.

   The design brief is unusual: the person is being asked to sit still and be
   scanned, which is inherently uncomfortable, and they are a caregiver
   applying for work rather than a suspect. So the ring counts DOWN calmly in
   brand green — no red, no scanning-laser effect, no "VERIFYING IDENTITY"
   in capitals. It should feel like a photo booth, not a checkpoint.
   ========================================================================== */

.liveness-stage {
  position: relative;
  width: 100%;
  max-width: 380px;
  margin: 16px auto 0;
  aspect-ratio: 4 / 3;
  border-radius: var(--r);
  overflow: hidden;
  background: var(--plum-950);
  display: grid;
  place-items: center;
}

.liveness-stage video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Mirrored, because an un-mirrored self-view is subtly disconcerting and makes people
     adjust the wrong way. The frames sent for analysis are unaffected — the mirroring is
     a CSS transform on the element, not on the canvas the capture reads from. */
  transform: scaleX(-1);
}

.liveness-ring {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  pointer-events: none;
}

.liveness-ring svg {
  width: 128px;
  height: 128px;
  transform: rotate(-90deg);         /* start the arc at twelve o'clock */
}

.liveness-ring__track {
  fill: none;
  stroke: rgba(255, 255, 255, .22);
  stroke-width: 5;
}

.liveness-ring__fill {
  fill: none;
  stroke: var(--green-300);
  stroke-width: 5;
  stroke-linecap: round;
  transition: stroke-dashoffset .1s linear;
}

.liveness-ring__label {
  position: absolute;
  font: 600 30px/1 system-ui, -apple-system, sans-serif;
  color: #fff;
  text-shadow: 0 1px 6px rgba(0, 0, 0, .55);
  font-variant-numeric: tabular-nums;
}

/* A single gentle pulse while capturing, so it is obvious something is happening.
   Scaled, not shadowed — an animated box-shadow repaints every frame. */
@keyframes liveness-breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.035); }
}
.liveness-ring--active svg { animation: liveness-breathe 2s var(--ease) infinite; }

@media (prefers-reduced-motion: reduce) {
  .liveness-ring--active svg { animation: none; }
  .liveness-ring__fill { transition: none; }
}

@media (max-width: 480px) {
  .liveness-stage { max-width: 100%; }
  .liveness-ring svg { width: 108px; height: 108px; }
  .liveness-ring__label { font-size: 26px; }
}

/* ==========================================================================
   SIDEBAR NAVIGATION + PREMIUM SHELL
   public/nav-sidebar.js renders the markup; this styles it.

   THE PERFORMANCE CONTRACT AT THE TOP OF THIS FILE STILL HOLDS, and "3D depth"
   is not an exemption from it. The distinction that matters:

     · Layered box-shadows, gradients and inset highlights are STATIC here.
       Painting them once on load is cheap; animating them is what forces a
       repaint every frame, and this console is often open in a second tab
       while a video call runs.
     · Every hover and active transition below animates transform or opacity
       ONLY. The lift on a menu item is translateX, not a shadow change; the
       active accent bar is scaleY, not a width change.

   That gets the premium feel without spending a single frame of the media
   pipeline's budget.
   ========================================================================== */

/* ------------------------------------------------------------------ shell */

.shell {
  display: grid;
  grid-template-columns: 268px minmax(0, 1fr);
  gap: 26px;
  max-width: 1440px;
  margin: 0 auto;
  padding: 24px 24px 72px;
  align-items: start;
}

/* The sidebar tracks the page while the content scrolls. `max-height` + its own
   scroll means a long menu never pushes the viewport. */
.shell__nav {
  position: sticky;
  top: 22px;
  max-height: calc(100vh - 44px);
  display: flex;
  flex-direction: column;
}

.shell__main { min-width: 0; }   /* without this, wide tables blow the grid out */

/* ------------------------------------------------------------------ the nav */

.nv {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  /* Layered, static. Two shadows plus an inset top highlight is what reads as a
     raised physical panel rather than a flat rectangle with a drop shadow. */
  box-shadow:
    var(--sh-2),
    inset 0 1px 0 rgba(255, 255, 255, .85);
  overflow: hidden;
  display: flex;
  flex-direction: column;
  min-height: 0;
}

.nv__scroll {
  overflow-y: auto;
  padding: 10px 10px 14px;
  min-height: 0;
  /* A menu is a short list; hide the scrollbar chrome until it is needed. */
  scrollbar-width: thin;
}

.nv-group { padding: 3px 0; }
.nv-group + .nv-group {
  margin-top: 3px;
  border-top: 1px solid var(--line-soft);
  padding-top: 9px;
}

.nv-group__title {
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .10em;
  text-transform: uppercase;
  color: var(--muted);
  padding: 0 12px 6px;
}

/* ------------------------------------------------------------------ items */

.nv-item {
  /* Reset: these are <button> and <a> in the same list and must look identical.
     `box-shadow: none` is load-bearing, not defensive tidying — the global `button`
     rule carries --sh-brand, so without this the seven items that happen to live on
     the current page glowed green and the six cross-page links did not. Same menu,
     two different-looking halves, for no reason a reader could ever guess. */
  appearance: none;
  -webkit-appearance: none;
  font: inherit;
  text-align: left;
  text-decoration: none;
  width: 100%;
  cursor: pointer;
  box-shadow: none;

  position: relative;
  display: grid;
  grid-template-columns: 30px minmax(0, 1fr) auto;
  align-items: center;
  gap: 11px;
  padding: 7px 12px;
  margin: 1px 0;
  border: 0;
  border-radius: var(--r);
  background: transparent;
  color: var(--ink);

  /* transform + opacity + colour only. No shadow or size in the transition. */
  transition:
    transform var(--t) var(--ease),
    background-color var(--t) var(--ease),
    color var(--t) var(--ease);
}

/* The active accent bar. Present always, scaled to nothing until needed, so the
   transition is a transform rather than an element appearing and reflowing. */
.nv-item::before {
  content: '';
  position: absolute;
  left: 3px;
  top: 8px;
  bottom: 8px;
  width: 3px;
  border-radius: 2px;
  background: var(--brand);
  transform: scaleY(0);
  transform-origin: center;
  opacity: 0;
  transition: transform var(--t) var(--ease-out), opacity var(--t) var(--ease);
}

.nv-item:hover {
  background: var(--brand-soft);
  transform: translateX(2px);
}
.nv-item:hover::before { transform: scaleY(.45); opacity: .45; }

.nv-item:focus-visible {
  outline: 2px solid var(--brand);
  outline-offset: -2px;
}

.nv-item--active {
  background: var(--brand-soft);
  color: var(--brand-text);
  font-weight: 600;
}
.nv-item--active::before { transform: scaleY(1); opacity: 1; }
.nv-item--active:hover { transform: none; }   /* it is already where it belongs */

/* ---- icon tile: the small piece of depth that carries the whole look ---- */

.nv-item__icon {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 9px;
  color: var(--muted);
  background: linear-gradient(180deg, var(--plum-50), var(--plum-100));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .9),
    0 1px 2px rgba(41, 35, 45, .07);
  transition: color var(--t) var(--ease), transform var(--t) var(--ease-out);
}
.nv-item__icon svg { width: 16px; height: 16px; display: block; }

.nv-item:hover .nv-item__icon { transform: translateY(-1px); }

.nv-item--active .nv-item__icon {
  color: #fff;
  background: linear-gradient(180deg, var(--green-600), var(--green-700));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .35),
    var(--sh-brand);
}

.nv-item__text { min-width: 0; display: block; }

.nv-item__label {
  display: block;
  font-size: 13.5px;
  line-height: 1.3;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The one-line explanation under each label. This is the part that actually
   fixes "I could not find where to approve a credential" — the label says what
   it is, the hint says what it is for. */
.nv-item__hint {
  display: block;
  font-size: 11px;
  line-height: 1.35;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-top: 1px;
}
.nv-item--active .nv-item__hint { color: var(--green-700); opacity: .8; }

.nv-item__badge {
  min-width: 20px;
  height: 20px;
  padding: 0 6px;
  border-radius: 10px;
  display: grid;
  place-items: center;
  font-size: 11px;
  font-weight: 700;
  color: #fff;
  background: var(--warn);
  box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
}

/* ------------------------------------------------------------------ depth on cards */

/* A slightly richer card in the console: the same shape as elsewhere, with an
   inset highlight along the top edge so it reads as lit from above. Static. */
.shell__main .card {
  box-shadow: var(--sh-2), inset 0 1px 0 rgba(255, 255, 255, .8);
}

/* ------------------------------------------------------------------ mobile / tablet */

/* Below 1000px the sidebar stops being a column and becomes a block of chips above
   the content, WRAPPING rather than scrolling sideways.

   The first attempt here was a single horizontal scroller. Two things were wrong with
   it, both visible the moment it was measured at 768px: the groups were flex children
   with no `flex: none`, so they compressed below their content and the labels printed
   on top of each other; and even laid out correctly, thirteen labelled items make a
   ~3000px strip, so nine of them sit off-screen behind a sideways scroll nobody thinks
   to try. That is the same "feature you cannot find" problem the sidebar exists to fix.
   Wrapping to three rows shows all thirteen at once. Hints are dropped — helpful on a
   wide screen, clutter on a narrow one — but the `title` keeps them on long-press. */
@media (max-width: 1000px) {
  .shell {
    grid-template-columns: minmax(0, 1fr);
    gap: 16px;
    padding: 16px 16px 60px;
  }
  .shell__nav { position: static; max-height: none; }

  .nv__scroll {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    overflow: visible;      /* nothing to scroll once it wraps */
    padding: 10px;
  }
  .nv-group {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    /* Load-bearing: without it the group shrinks below its content and the items
       inside overlap. This is the bug described above. */
    flex: none;
    padding: 0;
    align-items: center;
  }
  /* The group dividers are a vertical-list idea. Wrapped across rows they land in
     arbitrary places, so they go. */
  .nv-group + .nv-group {
    margin-top: 0;
    padding-top: 0;
    border-top: 0;
  }
  .nv-group__title { display: none; }

  .nv-item {
    width: auto;
    flex: none;
    /* `auto` columns per child rather than a fixed three-column template: items have
       two children, the credentials item has three (its badge). Column auto-flow sizes
       whichever it gets. */
    grid-template-columns: none;
    grid-auto-flow: column;
    grid-auto-columns: auto;
    gap: 9px;
    padding: 8px 12px;
    margin: 0;
    border: 1px solid var(--line-soft);
  }
  .nv-item--active { border-color: transparent; }
  .nv-item::before { display: none; }
  .nv-item:hover { transform: none; }
  .nv-item__hint { display: none; }
  .nv-item__label { font-size: 13px; white-space: nowrap; }
}

@media (prefers-reduced-motion: reduce) {
  .nv-item, .nv-item::before, .nv-item__icon { transition: none; }
  .nv-item:hover { transform: none; }
  .nv-item:hover .nv-item__icon { transform: none; }
}

/* ------------------------------------------------------------------ dark */

@media (prefers-color-scheme: dark) {
  .nv {
    background: var(--surface);
    box-shadow: var(--sh-2), inset 0 1px 0 rgba(255, 255, 255, .06);
  }
  .nv-item__icon {
    background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, .09),
      0 1px 2px rgba(0, 0, 0, .35);
  }
  .nv-item--active .nv-item__icon {
    background: linear-gradient(180deg, var(--green-500), var(--green-700));
    color: #08140a;
  }
  .nv-item--active { color: var(--green-300); }
  .nv-item--active .nv-item__hint { color: var(--green-300); }
  .shell__main .card { box-shadow: var(--sh-2), inset 0 1px 0 rgba(255, 255, 255, .05); }
}

/* ------------------------------------------------------------------ app pages */

/* `body.app` is the sidebar layout (admin + dashboard). The normal centred `main`
   column is exactly wrong there: the shell needs the full width for its grid.
   Scoped to this class so every other page keeps the reading-width column. */
body.app > main {
  max-width: none;
  margin: 0;
  padding: 0;
}

/* The sign-in card is the one thing on these pages that still wants the narrow
   column — it is a form with four fields, not a console. */
body.app > main > #loginCard {
  max-width: 560px;
  margin: 30px auto;
}

/* One destination in the sidebar. Views are swapped with .hidden, which is
   display:none, so a hidden view costs nothing — no layout, no paint, and its
   tables are not laid out until it is shown. */
.view { animation: view-in .22s var(--ease-out) both; }

/* Enters by transform and opacity ONLY. A 4px rise reads as the page settling
   rather than as an animation, and neither property triggers layout. */
@keyframes view-in {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .view { animation: none; }
}
