/* ============================================================================
   Doorbell design system
   House standard: named tokens, no Inter, no emoji-as-UI, 8px scale, one radius
   system, AA contrast, explicit shadows. See ~/.claude/skills/house-design.

   FONTS ARE SELF-HOSTED, and that is a POPIA decision as much as a speed one:
   pulling a webfont from a CDN on the VISITOR page would send a stranger's IP
   address to a third party before they have agreed to anything. Everything the
   visitor page loads comes from us.
   ============================================================================ */

@font-face {
  font-family: 'Manrope';
  src: url('/static/fonts/manrope.woff2') format('woff2');
  font-weight: 200 800;
  font-display: swap;   /* never block the RING button on a font */
  font-style: normal;
}
@font-face {
  font-family: 'Bricolage Grotesque';
  src: url('/static/fonts/bricolage.woff2') format('woff2');
  font-weight: 200 800;
  font-display: swap;
  font-style: normal;
}

:root {
  color-scheme: light;

  --font-display: 'Bricolage Grotesque', Georgia, serif;
  --font-body: 'Manrope', 'Segoe UI', system-ui, sans-serif;

  /* Colour roles. Deliberately warm + deep teal - not slate, not violet, not cyan. */
  --foreground: #1c1a17;
  --surface: #f7f5f1;
  --surface-raised: #ffffff;
  --muted: #6b6259;          /* 5.1:1 on --surface - AA body */
  --border: #e4ded4;
  --primary: #0f5f52;        /* white on this = 7.6:1 */
  --primary-fg: #ffffff;
  --primary-hover: #0b4a40;
  --accent: #b8632a;         /* brass. white on this = 4.3:1 -> LARGE TEXT / UI ONLY */
  /* Brass dark enough to be BODY-SIZE TEXT on --surface. Added 2026-07-30 after
     measuring the marketing page: --accent as 15px bold text scored 3.97:1
     against the 4.5:1 it needed. The comment beside --accent had said "LARGE
     TEXT / UI ONLY" and was right; the new rule using it had a comment claiming
     it passed, written without measuring. Do not use --accent for small text —
     use this. */
  --accent-text: #96491b;    /* on --surface = measured below, AA small text */
  --danger: #a3261d;
  --danger-fg: #ffffff;
  --ok: #1d6b3f;

  /* 8px spacing scale - nothing off it */
  --space-1: 4px;  --space-2: 8px;  --space-3: 12px; --space-4: 16px;
  --space-6: 24px; --space-8: 32px; --space-12: 48px; --space-16: 64px;

  /* one radius system */
  --radius-sm: 6px; --radius-md: 12px; --radius-lg: 20px; --radius-full: 999px;

  /* named elevation */
  --shadow-sm: 0 1px 2px rgb(28 26 23 / .06);
  --shadow-md: 0 4px 16px rgb(28 26 23 / .09);
  --shadow-lg: 0 12px 40px rgb(28 26 23 / .14);
}

/* A doorbell gets used at night, outdoors, with the screen lighting someone's face.
   Dark mode is a real use case here, not decoration. color-scheme is set explicitly
   so Chrome-Android's force-dark cannot lighten these surfaces and collapse contrast. */
@media (prefers-color-scheme: dark) {
  :root {
    color-scheme: dark;
    --foreground: #f2efe9;
    --surface: #15140f;
    --surface-raised: #1f1d18;
    --muted: #a89e92;        /* 6.4:1 on --surface */
    --border: #34302a;
    --primary: #2f9d87;
    --primary-fg: #08130f;
    --primary-hover: #3fb69d;
    --accent: #d98748;
    /* On the dark surface the LIGHT brass is already the high-contrast choice,
       so accent-text goes lighter here, not darker - the same token flips
       direction with the theme. */
    --accent-text: #e39a61;
    --danger: #e8695c;
    --danger-fg: #1a0a08;
    --ok: #56c98a;
  }
}

*, *::before, *::after { box-sizing: border-box; }

/* ⚠ THE `hidden` ATTRIBUTE DID NOT HIDE ANYTHING ON HALF THIS APP. Found 2026-07-28
   (lane doorbell-ux) by READING A SCREENSHOT that disagreed with the probe: the element
   reported el.hidden === true and was plainly on screen.

   The browser's own rule is `[hidden] { display: none }` at USER-AGENT origin, so ANY
   author rule that sets display beats it. .notice{display:flex}, .btn{display:inline-flex}
   and .row{display:flex} all did. MEASURED — hidden attribute true, computed display flex,
   rendered height > 0 — on:
     - the quiet-hours times panel, so unticking "use quiet hours" left the times on screen
     - #btn-confirm-rotation, so a BRAND-NEW door showed "The new code is up", a dead button
       (confirmRotation() returns early with no pending rotation)
     - #btn-again on the visitor page, so a stranger always saw "Try again"
   No test could see it: `expect(el.hidden).toBe(true)` reads the ATTRIBUTE and passes while
   the thing is visible. Only rendering knows.

   One rule fixes the class, including every element added later. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.55;
  color: var(--foreground);
  background: var(--surface);
  -webkit-font-smoothing: antialiased;
}

h1, h2, h3 {
  font-family: var(--font-display);
  line-height: 1.15;
  letter-spacing: -0.02em;
  margin: 0 0 var(--space-3);
  font-weight: 700;
}
h1 { font-size: 2rem; }
h2 { font-size: 1.5rem; }
h3 { font-size: 1.25rem; }
p { margin: 0 0 var(--space-4); }
a { color: var(--primary); }

.muted { color: var(--muted); }
.small { font-size: 0.9375rem; }

/* ---------------------------------------------------------------- layout */
.wrap {
  max-width: 720px;
  margin: 0 auto;
  padding: var(--space-8) var(--space-4) var(--space-16);
}
.wrap-wide { max-width: 960px; }

.stack > * + * { margin-top: var(--space-4); }
.row { display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.row-between { display: flex; gap: var(--space-3); align-items: center; justify-content: space-between; flex-wrap: wrap; }
.grow { flex: 1 1 auto; min-width: 0; }

/* ---------------------------------------------------------------- surfaces */
.card {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
  box-shadow: var(--shadow-sm);
}
.card + .card { margin-top: var(--space-4); }

.notice {
  border-radius: var(--radius-md);
  padding: var(--space-4);
  border: 1px solid var(--border);
  background: color-mix(in srgb, var(--primary) 7%, var(--surface-raised));
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
}
.notice svg { flex: none; margin-top: 2px; color: var(--primary); }
.notice.warn { background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised)); }
.notice.warn svg { color: var(--accent); }
.notice.bad { background: color-mix(in srgb, var(--danger) 9%, var(--surface-raised)); }
.notice.bad svg { color: var(--danger); }

/* ---------------------------------------------------------------- controls */
.btn {
  font: inherit;
  font-weight: 600;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  padding: var(--space-3) var(--space-6);
  min-height: 48px;                 /* thumb target */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  cursor: pointer;
  background: var(--primary);
  color: var(--primary-fg);
  text-decoration: none;
  transition: background .15s ease, transform .05s ease;
}
.btn:hover { background: var(--primary-hover); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-secondary {
  background: transparent;
  color: var(--foreground);
  border-color: var(--border);
}
.btn-secondary:hover { background: color-mix(in srgb, var(--foreground) 6%, transparent); }
.btn-danger { background: var(--danger); color: var(--danger-fg); }
.btn-danger:hover { background: color-mix(in srgb, var(--danger) 85%, black); }
.btn-block { width: 100%; }

:where(a, button, input, select, textarea):focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 2px;
}

label { display: block; font-weight: 600; margin-bottom: var(--space-2); font-size: 0.9375rem; }
input[type=text], input[type=email], input[type=time], input[type=number], select {
  font: inherit;
  width: 100%;
  padding: var(--space-3);
  min-height: 48px;
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  color: var(--foreground);
}
.field + .field { margin-top: var(--space-4); }

.pill {
  display: inline-flex; align-items: center; gap: var(--space-1);
  font-size: 0.8125rem; font-weight: 700; letter-spacing: .02em;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-full);
  border: 1px solid var(--border);
  color: var(--muted);
  text-transform: uppercase;
}
.pill-live { color: var(--primary-fg); background: var(--primary); border-color: transparent; }
.pill-quiet { color: var(--primary); border-color: var(--primary); }
/* color uses --accent-text, NOT --accent: this pill is 13px BOLD, so WCAG wants
   4.5:1 and plain brass measures 4.3:1 on white by this file's own note beside
   the token. The BORDER may stay --accent, because a border is UI and needs 3:1. */
.pill-paused { color: var(--accent-text); border-color: var(--accent); }

.code {
  font-family: 'Geist Mono', ui-monospace, 'Cascadia Mono', monospace;
  font-size: 1.75rem;
  font-weight: 700;
  letter-spacing: .18em;
  padding: var(--space-4);
  background: var(--surface);
  border: 1px dashed var(--border);
  border-radius: var(--radius-md);
  text-align: center;
  word-break: break-all;
}

/* ------------------------------------------------------- the visitor page */
/* Deliberately NOT a card grid. One page, one job, one enormous button. */
.door-shell {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: var(--space-6) var(--space-4);
  max-width: 520px;
  margin: 0 auto;
  gap: var(--space-6);
}
/* ⚠ THE VISITOR PAGE HAD ZERO SPACING BETWEEN EVERY SIBLING, AND NOT BY DESIGN.
   `.door-shell` is a flex column with `gap: var(--space-6)`, but visitor.html wraps
   each of its four states in a <section> for show/hide. So the gap separated the
   SECTIONS — of which only one is ever visible — and inside a section nothing had a
   margin at all (`.door-name` is margin:0, the buttons and .notice have none).
   MEASURED in a real render, every sibling gap in all three states was 0px: the
   title welded to the 168px ring slab, two tap targets physically touching, the
   privacy notice glued to the button above it.
   door-invalid.html is the proof of intent — its children sit DIRECTLY in
   .door-shell, inherit the 24px gap, and look composed.
   This restores the rhythm for the wrapped states without touching the unwrapped
   ones, and stays on the 8px scale. */
.door-shell > section {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.door-name {
  font-family: var(--font-display);
  font-size: clamp(1.75rem, 7vw, 2.5rem);
  font-weight: 700;
  text-align: center;
  margin: 0;
}

.ring-btn {
  width: 100%;
  min-height: 168px;
  border: none;
  border-radius: var(--radius-lg);
  background: var(--primary);
  color: var(--primary-fg);
  font-family: var(--font-display);
  font-size: 1.75rem;
  font-weight: 700;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-3);
  cursor: pointer;
  box-shadow: var(--shadow-lg);
  transition: transform .06s ease, background .15s ease;
}
.ring-btn:hover { background: var(--primary-hover); }
.ring-btn:active { transform: scale(.985); }
.ring-btn:disabled { opacity: .55; cursor: not-allowed; box-shadow: none; }
.ring-btn svg { width: 48px; height: 48px; }

/* Audio-only is a first-class choice, not a fallback link. */
.ring-alt {
  width: 100%;
  min-height: 56px;
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  background: var(--surface-raised);
  color: var(--foreground);
  font: inherit; font-weight: 600;
  display: flex; align-items: center; justify-content: center; gap: var(--space-2);
  cursor: pointer;
}
.ring-alt:hover { background: color-mix(in srgb, var(--foreground) 5%, var(--surface-raised)); }

@keyframes pulse { 0%,100% { opacity: 1 } 50% { opacity: .45 } }
.pulsing { animation: pulse 1.4s ease-in-out infinite; }

/* --------------------------------------------------------------- the call */
.stage {
  position: relative;
  background: #0d0c0a;
  border-radius: var(--radius-lg);
  overflow: hidden;
  aspect-ratio: 3 / 4;
  display: grid;
  place-items: center;
}
.stage video { width: 100%; height: 100%; object-fit: cover; display: block; }
.stage .self {
  position: absolute; right: var(--space-3); bottom: var(--space-3);
  width: 27%; max-width: 132px; aspect-ratio: 3/4;
  border-radius: var(--radius-md); overflow: hidden;
  border: 2px solid rgb(255 255 255 / .5);
  box-shadow: var(--shadow-md);
  background: #000;
}
.stage .self video { object-fit: cover; }
.stage-note {
  position: absolute; inset-inline: 0; bottom: 0;
  padding: var(--space-3);
  background: linear-gradient(to top, rgb(0 0 0 / .75), transparent);
  color: #fff; font-size: 0.9375rem; text-align: center;
}
.call-controls { display: flex; gap: var(--space-3); margin-top: var(--space-4); }
.call-controls .btn { flex: 1; }

/* ------------------------------------------------------------- dashboard */
.door-row {
  display: flex; gap: var(--space-4); align-items: flex-start;
  padding: var(--space-4) 0;
  border-top: 1px solid var(--border);
}
.door-row:first-of-type { border-top: none; }

.ring-list { list-style: none; padding: 0; margin: 0; }
.ring-list li {
  display: flex; gap: var(--space-3); align-items: center;
  padding: var(--space-3) 0;
  border-top: 1px solid var(--border);
  font-size: 0.9375rem;
}
.ring-list li:first-child { border-top: none; }
.ring-list .when { color: var(--muted); font-variant-numeric: tabular-nums; }

.qr-holder {
  background: #fff;                 /* a QR must be dark-on-light to scan, in any theme */
  padding: var(--space-4);
  border-radius: var(--radius-md);
  border: 1px solid var(--border);
  display: grid; place-items: center;
}
.qr-holder svg { width: 100%; max-width: 260px; height: auto; display: block; }

/* ------------------------------------------------------------------------
   THE ALWAYS-ON QR PANEL ON EACH DOOR CARD - added 2026-07-29.
   The owner: "make sure that the QR code is ALWAYS DISPLAYED in your dashboard
   for the gate for the specific door ... I can't find mine now."
   Until today the dashboard rendered no QR at any time; it lived only in a
   modal shown at the instant a code was minted. This is the panel that keeps
   it on screen. #doors carries .no-print, so this copy never reaches paper -
   printing stays driven by the print sheet, which is laid out for it.
   ------------------------------------------------------------------------ */
.qr-card {
  display: flex; gap: var(--space-4); align-items: flex-start; flex-wrap: wrap;
  padding: var(--space-4);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--primary) 5%, var(--surface-raised));
}
.qr-card-text { flex: 1 1 220px; min-width: 0; }
.qr-card-image {
  background: #fff;                 /* a QR must be dark-on-light to scan, in ANY theme */
  padding: var(--space-2);
  border-radius: var(--radius-sm);
  border: 1px solid var(--border);
  flex: none;
}
.qr-card-image svg { width: 148px; height: 148px; display: block; }
.btn-sm { min-height: 38px; padding: var(--space-2) var(--space-4); font-size: 0.9375rem; }

/* Incoming call banner - the one place that should grab the eye. */
.incoming {
  border: 2px solid var(--primary);
  background: color-mix(in srgb, var(--primary) 10%, var(--surface-raised));
}

.icon { width: 20px; height: 20px; flex: none; }
.icon-lg { width: 28px; height: 28px; }

/* ============================================================================
   CSP-SAFE LAYOUT CLASSES — added 2026-07-28 (lane doorbell-ux)

   ⚠ WHY THIS BLOCK EXISTS. The CSP is `style-src 'self'` with NO 'unsafe-inline'.
   A browser therefore REFUSES every inline style="..." ATTRIBUTE, silently, the
   same way it refused the inline <script> that killed signup. MEASURED on the real
   dashboard at 375x812: 32 elements carried a style attribute and ZERO of them had
   ever applied (el.style.length === 0 on all 32). Across all of web/ it was 56.

   Nothing errored in the server log and no test could see it, because the suite
   talks to the API and never renders a page. The damage was real and visible:
   "Recent rings" sat flush against the row above it, the printable QR had no
   max-width so it filled the card, and on the VISITOR page the "Waiting for someone
   to answer" text lost its light colour and rendered near-black on the near-black
   .stage — invisible to a stranger standing at the door.

   THE FIX IS TO REMOVE THE INLINE STYLES, NOT TO LOOSEN THE CSP. Adding
   'unsafe-inline' would also re-open the inline-script hole on a camera/mic
   surface; no-inline-script.test.js asserts the CSP never contains that string.

   Utility order is load-bearing: .flush zeroes, then a directional class re-adds
   ONE edge. Keep .flush above them.
   ============================================================================ */
.flush { margin: 0; }
.mt-1 { margin-top: var(--space-1); }
.mt-2 { margin-top: var(--space-2); }
.mt-4 { margin-top: var(--space-4); }
.mt-6 { margin-top: var(--space-6); }
.mt-8 { margin-top: var(--space-8); }
.mt-12 { margin-top: var(--space-12); }
.mb-2 { margin-bottom: var(--space-2); }
.mb-4 { margin-bottom: var(--space-4); }
.mb-6 { margin-bottom: var(--space-6); }
.mb-8 { margin-bottom: var(--space-8); }
.my-6 { margin-top: var(--space-6); margin-bottom: var(--space-6); }
.my-8 { margin-top: var(--space-8); margin-bottom: var(--space-8); }
.center { text-align: center; }

/* dashboard call surface */
.stage-dash { aspect-ratio: 4 / 3; max-width: 560px; }
.call-max { max-width: 560px; }

/* the printable sheet */
.qr-print { max-width: 300px; margin-inline: auto; }
.qr-fallback { color: #1c1a17; }   /* .qr-holder is always white, in any theme */

/*
 * 🔴 THE DOOR ADDRESS IN TYPEABLE TEXT, UNDER THE QR — owner-asked 2026-07-31, for the visitor
 * who cannot or will not scan a square.
 *
 * ⚠ `overflow-wrap: anywhere` IS THE LOAD-BEARING LINE. The address carries a ~30-character
 * random token with no spaces or hyphens, so a normal wrap rule cannot break it: it overflows the
 * card on a phone, or the print engine clips the tail. A CLIPPED URL IS WORSE THAN NO URL — it
 * still looks typeable and cannot possibly work, which is the dead-end shape this product keeps
 * having to remove. `anywhere` lets it break mid-token.
 *
 * ⚠ A MONOSPACE FACE, because this exists to be COPIED BY HAND off a printed sticker. In a
 * proportional face l/1/I and 0/O are the same glyph, and the token is case-sensitive base62 —
 * one misread character is a door that will not open and no way to tell why. (This product has
 * already lost an hour to an O/0 mix-up in a Paystack plan code.)
 */
/*
 * 🔴 THE RECOVERY CODE LIST. Monospace for the same reason as the door address: these get copied
 * off a screen onto paper and then typed back in months later, and in a proportional face l/1/I and
 * 0/O are the same glyph. (The 30-character alphabet already excludes the worst offenders, but the
 * face is free insurance and this is the one credential nobody can ask us to resend.)
 *
 * ⚠ AND IT PRINTS. "Write these down" is the instruction, so the Print button has to produce
 * something legible in black on white.
 */
.recovery-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--space-2);
}
.recovery-list li {
  font-family: 'Geist Mono', ui-monospace, 'Cascadia Mono', Menlo, Consolas, monospace;
  font-size: 1rem;
  letter-spacing: 0.02em;
  padding: var(--space-2) var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.recovery-list li.spent { opacity: 0.45; text-decoration: line-through; }

.qr-url {
  margin: 0.75rem auto 0;
  max-width: 22rem;
  font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;
  font-size: 0.78rem;
  line-height: 1.5;
  overflow-wrap: anywhere;
  word-break: break-word;
  color: var(--ink-2, #5b544c);
}

/* form rows inside the quiet-hours panel */
.row-fields { align-items: flex-end; gap: var(--space-4); }
.field-narrow { max-width: 320px; }
.field-sm { min-width: 130px; }
.field-md { min-width: 170px; }
.check-row {
  display: flex; align-items: center; gap: var(--space-3);
  cursor: pointer; font-weight: 600;
}
.check-row input { width: auto; margin: 0; min-height: 0; }
.summary-toggle { cursor: pointer; font-weight: 600; margin-bottom: var(--space-4); }
.summary-note { font-weight: 400; }

/* marketing page */
.hero-title { font-size: clamp(2rem, 7vw, 3.25rem); max-width: 14ch; }
.hero-sub { font-size: 1.125rem; max-width: 56ch; }
.bullets { padding-left: var(--space-6); }

/* visitor page */
.door-name-sm { font-size: 1.5rem; }
.waiting-block {
  color: #f2efe9;                  /* the .stage behind this is #0d0c0a in BOTH themes */
  display: grid; place-items: center;
  gap: var(--space-4); padding: var(--space-8);
}
.waiting-label { font-size: 1rem; }

@media print {
  /* The rotation page is meant to be printed and stuck to a wall. */
  .no-print { display: none !important; }
  body { background: #fff; color: #000; }
  .card { border: none; box-shadow: none; padding: 0; }
  .qr-holder { border: 1px solid #000; }
  /* The address is the whole point of printing it — force it black and keep it on the sheet. */
  .qr-url { color: #000 !important; font-size: 0.72rem; }
  /* Recovery codes are meant to be printed and put somewhere safe. */
  .recovery-list li { background: #fff; border-color: #000; color: #000; }
}

/* ============================================================================
   THE MARKETING PAGE — rebuilt 2026-07-30.

   WHAT WAS WRONG. index.html was a 720px centred column: pill, H1, sub, two
   buttons, then THREE IDENTICAL STACKED .card BLOCKS each holding an H2 and a
   muted paragraph. That is the single most copy-pasted layout on the web and it
   is exactly the shape house-design calls the template tell. Meanwhile the four
   real photographs commissioned for this page (plate-qr, scan-at-gate,
   house-night, gate-dusk — each at two widths) were sitting in /static/img
   REFERENCED BY NOTHING. The previous lane generated them at 16:43 and was
   killed at 16:45 before wiring a single one in.

   THE APPROACH. The plate photograph IS the product, so it takes half the fold
   instead of sitting under a wall of text. The three steps are ALTERNATING ROWS,
   not a three-card grid. The safeguards keep real Lucide icons — never emoji.
   The privacy and limitations blocks are deliberately NOT cards: that is the
   small print, and dressing it up as a feature would be the dishonest move on a
   page whose whole voice is "here is the catch".

   EVERY VALUE BELOW COMES FROM THE TOKENS ABOVE. No raw hex except inside the
   CTA band, which sits on a dark photograph in BOTH themes and therefore cannot
   use theme-flipping tokens — the same reason .stage and .waiting-block already
   hard-code their colours. Each of those is commented at the point of use.

   ⚠ NOTHING HERE MAY BE AN INLINE style="" ATTRIBUTE. The CSP is
   `style-src 'self'` with no 'unsafe-inline', so the browser silently refuses
   inline style attributes — 56 of them across web/ had never once applied. All
   layout for this page lives in this file.
   ============================================================================ */

.site-head { border-bottom: 1px solid var(--border); }
.site-head-inner,
.section-inner,
.cta-inner,
.site-foot-inner,
.hero-inner {
  max-width: 1120px;
  margin-inline: auto;
  padding-inline: var(--space-4);
}
.site-head-inner {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-4);
  padding-block: var(--space-4);
}
.brand {
  display: inline-flex; align-items: center; gap: var(--space-2);
  color: var(--foreground); text-decoration: none;
}
.brand svg { color: var(--primary); }
.brand-name {
  font-family: var(--font-display);
  font-weight: 700; font-size: 1.25rem; letter-spacing: -0.02em;
}
.site-nav { display: flex; align-items: center; gap: var(--space-4); }
.site-nav-link { color: var(--foreground); text-decoration: none; font-weight: 600; }
.site-nav-link:hover { color: var(--primary); }

/* ---------------------------------------------------------------- the hero */
.hero { padding-block: var(--space-12) var(--space-16); }
.hero-inner { display: grid; gap: var(--space-8); }
.hero-copy > .pill { margin-bottom: var(--space-6); }
/* Overrides the older narrow marketing rule above: in a two-column hero the
   headline has half the width, so 14ch broke it into a ragged stack. */
.hero-title { font-size: clamp(2.25rem, 5.5vw, 3.5rem); max-width: 18ch; margin-bottom: var(--space-4); }
.hero-sub { font-size: 1.125rem; max-width: 54ch; }
.hero-cta { margin-block: var(--space-8) var(--space-4); }
.hero-foot { max-width: 46ch; }

.hero-figure { margin: 0; }
.hero-img {
  width: 100%; height: auto; display: block;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
}

@media (min-width: 900px) {
  .hero-inner {
    grid-template-columns: 1.05fr 0.95fr;
    gap: var(--space-16);
    align-items: center;
  }
}

/* ------------------------------------------------------------- section shell */
.section { padding-block: var(--space-16); }
.section-tint {
  background: color-mix(in srgb, var(--primary) 5%, var(--surface));
  border-block: 1px solid var(--border);
}
.eyebrow {
  font-size: 0.8125rem; font-weight: 700; letter-spacing: .08em;
  text-transform: uppercase;
  color: var(--primary);          /* AA on --surface in both themes */
  margin-bottom: var(--space-2);
}
.section-title {
  font-size: clamp(1.75rem, 3.5vw, 2.5rem);
  max-width: 22ch;
  margin-bottom: var(--space-4);
}
.section-lead { font-size: 1.0625rem; max-width: 62ch; margin-bottom: var(--space-12); }

/* ------------------------------------------------------- how it works: rows */
.steps { list-style: none; margin: var(--space-12) 0 0; padding: 0; }
.step { display: grid; gap: var(--space-6); }
.step + .step { margin-top: var(--space-16); }
.step-n {
  font-family: var(--font-display);
  font-size: 1rem; font-weight: 700; letter-spacing: .1em;
  /* --accent-text, NOT --accent: at this size WCAG wants 4.5:1 and plain brass
     measured 3.97:1. Verified by measurement, not by assumption. */
  color: var(--accent-text);
  margin-bottom: var(--space-2);
}
/* Scoped to .step-copy h3 rather than a bare descendant rule: a broad
   `.steps h3` would reach into any future markup nested in a step. */
.step-copy h3 { font-size: 1.5rem; margin-bottom: var(--space-3); }
.step-copy p:last-child { margin-bottom: 0; }
.step-figure { margin: 0; }
/* Radius and shadow are IDENTICAL across every photo on the page; only the crop
   varies, and that variation is deliberate art direction — the vertical shot is
   a person reaching up, the wide ones are establishing shots. */
.step-img {
  width: 100%;
  /* ⚠ `height: auto` IS LOAD-BEARING, NOT TIDINESS. These <img> tags carry
     width/height ATTRIBUTES (needed to reserve space and stop layout shift).
     Those attributes make the height DEFINITE, so `aspect-ratio` is ignored
     and the image renders at its full intrinsic height. Measured before this
     line existed: computed aspect-ratio read "16 / 10" — exactly as authored —
     while the element rendered 512x893. The declaration looked correct in
     devtools and the geometry was wrong; only the rendered box showed it. */
  height: auto;
  display: block;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
  aspect-ratio: 16 / 10;
  object-fit: cover;
}
/* Portrait-LEANING, not portrait. The source is 2/3 and a person reaching
   upward, so some verticality is right — but at 3/4 it rendered 512x683 beside
   a 200px paragraph, and the centred copy then floated with ~250px of dead
   space above it. That reads as a broken row, not as whitespace. 4/3 keeps the
   crop on the hand and phone while landing near the 320px of its siblings, so
   the three rows share a rhythm. */
.step-img-tall { aspect-ratio: 4 / 3; object-position: center 38%; }

@media (min-width: 860px) {
  .step { grid-template-columns: 1fr 1fr; gap: var(--space-16); align-items: center; }
  /* The alternating row: figure to the left, copy to the right. Set on BOTH
     children so neither falls back to source order. */
  .step-alt .step-copy   { grid-column: 2; grid-row: 1; }
  .step-alt .step-figure { grid-column: 1; grid-row: 1; }
}

/* ---------------------------------------------------------------- safeguards */
.guards {
  list-style: none; padding: 0; margin: 0;
  display: grid; gap: var(--space-8);
}
.guard { display: grid; grid-template-columns: auto 1fr; gap: var(--space-4); align-items: start; }
.guard-icon { color: var(--primary); margin-top: 2px; }
.guard h3 { font-size: 1.125rem; margin-bottom: var(--space-2); }
.guard p { margin-bottom: 0; }

@media (min-width: 760px) {
  .guards { grid-template-columns: 1fr 1fr; gap: var(--space-12) var(--space-16); }
}

/* --------------------------------------------------- privacy + limitations */
.plain { display: grid; gap: var(--space-12); }
.plain-col h2 { font-size: 1.375rem; margin-block: var(--space-4) var(--space-3); }
.plain-icon { color: var(--primary); }
.plain-icon-warn { color: var(--accent); }
/* The one sentence in each column that must not be skimmed past. */
.plain-strong { font-weight: 700; }
.plain-col p:last-child { margin-bottom: 0; }

@media (min-width: 760px) {
  .plain { grid-template-columns: 1fr 1fr; gap: var(--space-16); }
  .plain-col + .plain-col {
    border-left: 1px solid var(--border);
    padding-left: var(--space-16);
  }
}

/* ---------------------------------------------------------------- CTA band
   ⚠ HARD-CODED COLOURS ARE CORRECT HERE. This band sits on a dark photograph
   in BOTH light and dark themes, so a token that flips (e.g. --surface, which
   is #f7f5f1 light and #15140f dark) would put near-white text on a near-white
   button over a dark photo in one of the two themes. .stage and .waiting-block
   hard-code for exactly this reason. The scrim is opaque enough that white text
   clears AA against the lightest part of the image (the lit doorway). */
.cta {
  background-image:
    linear-gradient(rgb(12 14 13 / .78), rgb(12 14 13 / .82)),
    url('/static/img/gate-dusk-1376.webp');
  background-size: cover;
  background-position: center 60%;
  color: #f4f1ec;
}
.cta-inner { padding-block: var(--space-16); text-align: center; }
.cta-title {
  font-size: clamp(1.875rem, 4vw, 2.75rem);
  color: #ffffff;
  margin-bottom: var(--space-3);
}
.cta-sub { color: #d9d3cb; max-width: 46ch; margin-inline: auto; font-size: 1.0625rem; }
.cta-row { justify-content: center; margin-top: var(--space-8); }
.btn-on-dark { background: #f4f1ec; color: #14201d; }
.btn-on-dark:hover { background: #ffffff; }
.btn-ghost-dark { background: transparent; color: #f4f1ec; border-color: rgb(244 241 236 / .55); }
.btn-ghost-dark:hover { background: rgb(244 241 236 / .12); }

/* ------------------------------------------------------- legal pages
   /terms and /privacy. A measured reading column — legal text at the marketing
   page's full width is unreadable, so this caps the measure rather than the
   section. Same tokens, same radius, same icon set: these are part of the
   product, not a bolted-on document. */
/* The marketing container is 1120px. Capping only the TEXT measure inside it
   left the whole document stranded against the left edge with ~40% of the page
   empty — technically correct, visibly odd. Narrow the CONTAINER instead so the
   column is centred and the page reads as a deliberate document. */
.section-narrow .section-inner { max-width: 800px; }
.legal-title { font-size: clamp(1.875rem, 4vw, 2.75rem); max-width: 24ch; margin-bottom: var(--space-4); }
.legal { max-width: 68ch; }
.legal h2 {
  font-size: 1.25rem;
  margin-block: var(--space-12) var(--space-3);
  padding-top: var(--space-6);
  border-top: 1px solid var(--border);
}
.legal h2:first-of-type { margin-top: var(--space-8); }
.legal .notice { margin-bottom: var(--space-8); }
.legal-list { padding-left: var(--space-6); margin-bottom: var(--space-4); }
.legal-list li { margin-bottom: var(--space-3); color: var(--muted); }
/* Scoped to the list item, NOT `.legal strong` — a broad descendant rule would
   turn every bolded phrase mid-sentence into a block and orphan its punctuation.
   That exact mistake made a whole page unreadable on another product while every
   test stayed green; only a real screenshot read caught it. */
.legal-list li strong { color: var(--foreground); }
.legal-foot { margin-top: var(--space-16); font-size: 0.9375rem; }

/* -------------------------------------------------------------------- foot */
.site-foot { border-top: 1px solid var(--border); }
.site-foot-inner {
  padding-block: var(--space-8);
  display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6);
  justify-content: space-between;
}

/* ------------------------------------------------------------------ household
   The household screen (dashboard.html #household), 2026-07-30, lane
   doorbell-household-ui. APPENDED, never inserted: the marketing and legal blocks
   above are live in production and were walked with measured contrast, so nothing
   above this line is touched.

   Every rule here exists because the CSP is style-src 'self' with no
   unsafe-inline - an inline style="" attribute on any of these elements would be
   refused SILENTLY and the element would render unstyled with nothing in the
   console. 56 dead style attributes shipped that way once on this very page. */

.member-list { list-style: none; padding: 0; margin: 0 0 var(--space-4); }
.member-list li {
  display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap;
  padding-block: var(--space-3);
  border-top: 1px solid var(--border);
}
.member-list li:first-child { border-top: none; }
/* ⚠ FOUND BY READING THE 390px SCREENSHOT, not by a test. Without these two rules the row's
   icon was pushed onto a LINE OF ITS OWN above the name, because the text span (.grow, which
   is flex:1 1 auto) claimed the whole first line and the wrap did the rest. Nothing was
   unreadable, so no assertion would ever have caught it - it just looked wrong. `flex:none`
   keeps the icon at its natural size beside the text, and a small flex-basis lets the text
   share that first line and wrap INSIDE itself rather than below the icon. */
.member-list li svg { flex: none; color: var(--muted); }
.member-list li .grow { flex: 1 1 12ch; }
.member-list .member-name { font-weight: 600; }
/* tabular-nums so a column of join dates does not jitter, same as .ring-list .when */
.member-list .member-when { color: var(--muted); font-variant-numeric: tabular-nums; }
.member-list .member-empty { color: var(--muted); display: block; }

/* The shown-once invite code. Monospace and large because it is read aloud, typed
   on a phone, or copied - and a code that is misread is a support conversation.
   `user-select: all` makes one tap select the whole thing on mobile, where the
   Copy button may be the harder gesture. */
.code-show {
  display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap;
}
.code-value {
  font-family: ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
  font-size: 1.375rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  /* ⚠ NOT --accent. Measured on the marketing page: --accent as bold text scored
     3.97:1 against the 4.5:1 it needs. --accent-text is the token that exists for
     exactly this, and it flips direction with the theme. */
  color: var(--accent-text);
  background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised));
  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
  border-radius: var(--radius-sm);
  padding: var(--space-3) var(--space-4);
  /* A long code must wrap inside its box rather than push the card sideways -
     the page body must never scroll horizontally at 375px. */
  overflow-wrap: anywhere;
  user-select: all;
}

/* Status lines that must read as a verdict, not as body copy. `.small` sets the
   size; these only set the colour, so they compose with it rather than fight it. */
.result-ok { color: var(--ok); }
.result-bad { color: var(--danger); }

/* A door somebody else owns. The card is deliberately QUIETER than an owned door:
   there is nothing to configure on it, and a card full of controls that all 404
   is worse than a card with none. */
.door-shared { border-style: dashed; }
.pill-shared { color: var(--muted); border-color: var(--border); }
.shared-note { display: flex; gap: var(--space-2); align-items: flex-start; }
.shared-note svg { flex: none; margin-top: 2px; color: var(--muted); }

/* ============================================================================
   MARKETING PAGE — DENSITY + RHYTHM PASS, 2026-07-30 (orchestrator NJ2Q)

   Owner, verbatim: the site looks like "AI slop / infinite scrolly shit".

   MEASURED before touching anything, in a real Chromium:
     375px  -> 4986px tall = 6.14 screens of scrolling
     768px  -> 5224px      = 5.10 screens
     1440px -> 3557px      = 3.95 screens
   and the how-it-works section alone was 1688px on mobile — 34% of the page.

   The token layer was NOT the problem and is untouched here: fonts, colours,
   the 8px scale, the radius system and the icon set already comply. The problem
   was STRUCTURAL — three hero-sized photographs stacked in one section, four
   sections sharing an identical eyebrow-title-lead-grid shape, and 128px of
   padding between every one of them.

   APPENDED, never edited in place: every rule below overrides an earlier one by
   source order at equal specificity, so this whole block can be deleted to get
   the previous design back exactly.
   ============================================================================ */

/* ---- density -------------------------------------------------------------
   The old 64px top AND bottom on every section put 128px between each pair.
   On an 812px phone that is a sixth of a screen spent on nothing, four times
   over. All values stay on the 8px scale. */
.section { padding-block: var(--space-12); }
.section-lead { margin-bottom: var(--space-6); }
.hero { padding-block: var(--space-8) var(--space-12); }
.hero-cta { margin-block: var(--space-6) var(--space-3); }
.plain { gap: var(--space-8); }
.cta-inner { padding-block: var(--space-12); }

@media (min-width: 760px) {
  .section { padding-block: var(--space-16); }
  .cta-inner { padding-block: var(--space-16); }
}

/* ---- how it works: one photograph, one sequence --------------------------
   Replaces the three stacked .step rows. The alternating left/right treatment
   was thoughtful on a wide screen and INVISIBLE on a phone, where it collapses
   to a plain column — so on the width most visitors use, it was simply three
   big pictures in a row. One photo carries the idea; the steps become text. */
.flow { display: grid; gap: var(--space-8); }
.flow-figure { margin: 0; }
.flow-img {
  width: 100%;
  /* ⚠ height:auto is LOAD-BEARING, same trap as .step-img: the <img> carries
     width/height ATTRIBUTES (they reserve space and stop layout shift), which
     make the height DEFINITE and cause aspect-ratio to be ignored entirely. */
  height: auto;
  display: block;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  object-position: center 38%;   /* keeps the crop on the hand and the phone */
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
}
.flow-title { margin-bottom: var(--space-6); }
.flow-steps { list-style: none; margin: 0; padding: 0; }
.flow-step {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-4);
}
.flow-step + .flow-step { margin-top: var(--space-6); }
.flow-n {
  display: grid;
  place-items: center;
  width: var(--space-8);
  height: var(--space-8);
  border: 1px solid var(--border);
  border-radius: var(--radius-full);
  font-family: var(--font-display);
  font-size: 0.8125rem;
  font-weight: 700;
  letter-spacing: .04em;
  /* --accent-text, NOT --accent. Plain brass measures 3.97:1 at this size and
     needs 4.5:1. The badge is deliberately UNFILLED so the text sits on
     --surface and inherits that already-measured pair rather than creating a
     new, unmeasured one. */
  color: var(--accent-text);
}
/* The connector: three rows should read as ONE sequence, not three list items.
   Drawn on the row (not the badge) so it spans a row of any height, and run
   into the gap below so it meets the next badge. */
.flow-step:not(:last-child)::before {
  content: "";
  position: absolute;
  left: calc(var(--space-8) / 2);
  top: var(--space-8);
  bottom: calc(-1 * var(--space-6));
  width: 1px;
  background: var(--border);
  transform: translateX(-50%);
}
.flow-body h3 { font-size: 1.125rem; margin-bottom: var(--space-1); }
.flow-body p { margin-bottom: 0; }

@media (min-width: 900px) {
  .flow {
    grid-template-columns: 0.8fr 1fr;
    gap: var(--space-16);
    align-items: center;
  }
  /* Portrait on the wide layout: the shot is a person reaching upward, and a
     tall frame beside a three-item list balances the row. */
  .flow-img { aspect-ratio: 3 / 4; }
}

/* ---- safeguards: a spec sheet, not a card grid ---------------------------
   Was a 2x2 of icon + h3 + one line, which is the exact features-grid the house
   standard names as the most copy-pasted layout on the web. Hairline rules and
   tight leading make it read as a list of guarantees — which is what it is.
   The h3s are KEPT (they carry the document outline for a screen reader); only
   their weight on the page changes. */
.guards-list { row-gap: 0; column-gap: var(--space-16); border-bottom: 1px solid var(--border); }
.guards-list .guard {
  padding-block: var(--space-4);
  border-top: 1px solid var(--border);
  align-items: start;
}
.guards-list .guard-icon { width: 20px; height: 20px; margin-top: 3px; }
.guards-list .guard h3 { font-size: 1rem; margin-bottom: var(--space-1); }

/* ---- second density pass, same session -----------------------------------
   Measured after the first pass: mobile 6.14 -> 5.01 screens, but TABLET had
   quietly become the worst case at 1250px of hero alone. Cause: the two-column
   hero starts at 900px, so at 768px the square plate photograph renders full
   width — 768px wide and therefore ~700px TALL, most of a screen for one image.
   A square source is fine on a phone and ruinous at tablet width. Crop it. */
@media (max-width: 599px) {
  .hero-img { aspect-ratio: 4 / 3; object-fit: cover; }
}
@media (min-width: 600px) and (max-width: 899px) {
  .hero-img { aspect-ratio: 16 / 10; object-fit: cover; }
}

/* The portrait crop at 3/4 was taller than the list it sits beside, so the row
   was sized by the photo rather than by its content. 4/5 keeps it clearly
   portrait while letting the copy set the height. */
@media (min-width: 900px) {
  .flow { grid-template-columns: 0.72fr 1fr; }
  .flow-img { aspect-ratio: 4 / 5; }
}

/* The honest-part columns are prose, and prose at 16px paragraph gaps stacks up
   over eight paragraphs. Tighten the rhythm WITHOUT touching the words — this
   section is the product's honesty and none of it gets cut. */
.plain-col p { margin-bottom: var(--space-3); }
.plain-col h2 { margin-block: var(--space-3) var(--space-2); }
.guards-list .guard { padding-block: var(--space-3); }

/* Same square-source-at-tablet-width trap as the hero, one section down: the
   two-column .flow starts at 900px, so at 768px the portrait-leaning scan photo
   rendered 768 wide by 576 tall and made how-it-works TALLER on tablet (1109px)
   than on mobile (966px). Crop it in the same band, for the same reason. */
@media (min-width: 600px) and (max-width: 899px) {
  .flow-img { aspect-ratio: 16 / 10; }
}

/* Section header that pairs the title with its lead instead of stacking them.
   Stacked inside a 1120px container, a 22ch title over a 62ch lead left the
   whole right half of the tinted band empty — dead space, not whitespace. It
   also means this section no longer repeats the vertical eyebrow-title-lead
   shape used elsewhere on the page. */
.section-head { display: grid; gap: var(--space-4); margin-bottom: var(--space-8); }
.section-head .section-lead { margin-bottom: 0; }

@media (min-width: 900px) {
  .section-head {
    grid-template-columns: 1fr 1fr;
    gap: var(--space-16);
    align-items: end;
  }
  .section-head .section-title { margin-bottom: 0; }
}

/* ------------------------------------------------- the sign-in confirmation interstitial */
/* The account address on /auth/:code. This string is the ONLY thing on that page that lets a
   victim of the login-CSRF attack notice they are about to be signed into somebody else's
   account, so it has to be READ, not glanced at.

   ⚠ IT IS NOT `.code-value`, AND THE REASON WAS ONLY VISIBLE IN A SCREENSHOT. `.code-value` is
   1.375rem with 0.08em letter-spacing and `overflow-wrap: anywhere` - correct for an 8-character
   invite code, wrong for a 20+ character email: at 390px it wrapped mid-token as
   "attacker@example.co / m". Every assertion still passed, because the text WAS present and the
   test only checked that. A split address is exactly what a lookalike domain hides behind.

   Same accent treatment, sized to fit an ordinary address on one line. `anywhere` is kept only
   as the last resort for an absurd address, because a page body that scrolls sideways at 390px
   is a worse failure than a break. */
.account-email {
  font-family: ui-monospace, 'Cascadia Mono', 'SF Mono', Menlo, Consolas, monospace;
  font-size: 1rem;
  font-weight: 600;
  /* --accent-text, not --accent: measured elsewhere in this file, --accent as bold text scores
     3.97:1 against the 4.5:1 it needs, and it flips direction with the theme. */
  color: var(--accent-text);
  background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised));
  border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border));
  border-radius: var(--radius-sm);
  padding: var(--space-3) var(--space-4);
  overflow-wrap: anywhere;
  /* ⚠ AN ADDRESS MUST NOT BE ABLE TO REORDER ITSELF. This element is the only control on that
     page: the reader is being asked to decide whether the account is theirs. An address
     containing a bidi override (U+202E and friends) would otherwise render in a different order
     than it is stored, so what you read is not what you would be signed into. `isolate` confines
     any directional run to this element and stops it reaching the surrounding sentence.
     The characters are also rejected at registration (DECEPTIVE_CHARS in app.js) - this is the
     second half, because accounts registered BEFORE that check existed are still in the database.
     Raised by the crazy-8. */
  unicode-bidi: isolate;
}

/* ============================================================================
   THE DASHBOARD SHELL — views, a fixed tab bar, and a doors LIST.
   Added 2026-07-30, lane doorbell-dashboard-pages.

   WHAT WAS WRONG, MEASURED IN A REAL BROWSER AT 390x844 SIGNED IN WITH 8 DOORS:
   the dashboard was 10,659px tall — 12.63 SCREENS — and
   `document.querySelectorAll('nav a, [role=tab], .tab').length` returned 0.
   There was no navigation of any kind. Every door painted its QR panel, its
   pause button, its quiet-hours panel and its last 20 rings into one column,
   so the page grew about a screen and a half PER DOOR, and the household and
   account screens sat below all of it. The owner asked twice: "I want a page
   for each fucking thing", then "make sure that the user dashbord is not just
   fuking infinite scroll".

   ⚠ THE TOKENS WERE ALREADY RIGHT AND ARE NOT TOUCHED HERE. This is layout,
   not paint: no new colour, no new font, no new radius, no new shadow. Every
   value below is a var() from the block at the top of this file, and every
   spacing value is on the 8px scale. The one place that would have been a
   temptation — the tab bar's safe-area padding — composes with the scale via
   calc() rather than inventing a number.

   ⚠ NOTHING HERE MAY BECOME AN INLINE style attribute. The CSP is
   style-src 'self' with no unsafe-inline, so the browser refuses inline style
   attributes SILENTLY — 56 of them across web/ had never once applied.
   ============================================================================ */

/* Room for the fixed tab bar, so the last control on a view is never sitting
   underneath it. The .wrap bottom padding is replaced, not added to. */
.app-shell { padding-bottom: calc(var(--space-16) + var(--space-12)); }

.app-header {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-4);
  margin-bottom: var(--space-6);
}

/* ⚠ STICKY, BECAUSE A RING MUST INTERRUPT WHATEVER YOU ARE DOING. The views are
   short now, but the doors list still scrolls on a big household, and a doorbell
   that announces itself only at the top of one particular tab is a doorbell that
   does not work. It sits ABOVE the tab bar z-index band on purpose. */
.incoming-slot {
  position: sticky;
  top: 0;
  z-index: 50;
  padding-block: var(--space-2);
  background: var(--surface);
}

/* A view is shown or hidden by the router via the hidden attribute, which
   [hidden] { display: none !important } at the top of this file already
   enforces against class-set display values. Nothing else is needed. */
.page-title {
  font-size: 1.625rem;          /* the shell has its own scale: a 32px h1 spent a
                                   fifth of a phone screen naming the page you
                                   had just signed in to */
  margin-bottom: var(--space-6);
}
.view-head {
  font-size: 1.125rem;
  margin-block: var(--space-8) var(--space-3);
}
.view > .view-head:first-child { margin-top: 0; }

/* ---------------------------------------------------------- the doors list */

/* ⚠ A ROW IS A LINK, NOT A CARD. The card shape is what made the old page 12
   screens long; a row is one line of information and one tap. 64px minimum is
   the thumb target, matching .btn. */
.door-row {
  display: flex; align-items: center; gap: var(--space-4);
  padding: var(--space-4);
  min-height: 64px;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  color: var(--foreground);
  text-decoration: none;
  box-shadow: var(--shadow-sm);
}
.door-row + .door-row { margin-top: var(--space-2); }
.door-row:hover { border-color: var(--primary); }
.door-row-icon { color: var(--primary); }
.door-row-main { display: flex; flex-direction: column; min-width: 0; flex: 1 1 auto; }
.door-row-name { font-weight: 700; overflow-wrap: anywhere; }
.door-row-sub { color: var(--muted); }
.door-row-chevron { color: var(--muted); }
.door-row-more { justify-content: space-between; }
.door-row-more .door-row-name { color: var(--primary); }
/* The add-a-door card follows the rows, and .card + .card does not reach it. */
.door-row + .card { margin-top: var(--space-4); }

/* The way back up from the page for one door. */
.backlink {
  font: inherit; font-weight: 600;
  display: inline-flex; align-items: center; gap: var(--space-2);
  background: none; border: none; cursor: pointer;
  color: var(--primary);
  padding: var(--space-2) 0;
  margin-bottom: var(--space-4);
  min-height: 44px;
}
.backlink:hover { color: var(--primary-hover); }

/* -------------------------------------------------------------------- home */
.home-status { margin-bottom: var(--space-4); }
.home-status svg { color: var(--primary); }
/* Tinted from the same tokens the .notice variants already use, so a warning on
   the home screen and a warning inside a card are the same colour. */
.home-status-warn { background: color-mix(in srgb, var(--accent) 10%, var(--surface-raised)); }
.home-status-warn svg { color: var(--accent); }
.home-status-bad { background: color-mix(in srgb, var(--danger) 9%, var(--surface-raised)); }
.home-status-bad svg { color: var(--danger); }

/* -------------------------------------------------------------------- plan */
.limit-list { list-style: none; padding: 0; margin: 0; }
.limit-list li {
  display: flex; gap: var(--space-4); align-items: baseline;
  padding: var(--space-3) 0;
  border-top: 1px solid var(--border);
}
.limit-list li:first-child { border-top: none; }
.limit-list .when { color: var(--muted); font-variant-numeric: tabular-nums; }

/* ------------------------------------------------------------- ring history */

/* ⚠ THE CLAMP IS A CLASS, NOT A SHORTER RESPONSE. All 20 rows are rendered and
   the extra ones are hidden here, so "Show all rings" needs no second request
   and cannot fail on a bad connection. RINGS_VISIBLE in dashboard.js is 6 and
   must match the nth-child below — changing one means changing both. */
.ring-list-clamped li:nth-child(n+7) { display: none; }

/* ------------------------------------------------- the confirmation dialog

   🔴 FIXED TO THE VIEWPORT, for the same reason the tab bar below is: a control
   you have to scroll to reach is a control that does not exist. Measured on the
   live site 2026-07-31 — tapping "Generate recovery codes" ~590px down the page
   put the confirmation at top:-494px, off-screen, and the smooth scrollIntoView
   that was supposed to reveal it never ran. The button read as dead.

   ⚠ z-index 60 sits ABOVE the tab bar's 40. A confirmation partly covered by the
   navigation is a confirmation somebody answers wrong. */
#ask { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center;
       padding: var(--space-4); }
#ask[hidden] { display: none; }   /* [hidden] loses to `display: grid` without this */
.ask-backdrop { position: absolute; inset: 0; background: rgb(0 0 0 / .55); }
.ask-panel {
  position: relative;             /* above the backdrop */
  width: 100%; max-width: 30rem;
  max-height: 85vh; overflow-y: auto;   /* a long question must not push its own buttons away */
  box-shadow: var(--shadow-md);
}

/* ----------------------------------------------------------- the tab bar */

/* ⚠ FIXED TO THE VIEWPORT. Navigation you have to scroll to reach is
   navigation that does not exist, which is what this page had. */
.tabbar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
  display: flex;
  background: var(--surface-raised);
  border-top: 1px solid var(--border);
  padding: var(--space-2) var(--space-2)
           calc(var(--space-2) + env(safe-area-inset-bottom, 0px));
  box-shadow: var(--shadow-md);
}
.tab {
  flex: 1 1 0; min-width: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-1);
  padding: var(--space-2) var(--space-1);
  min-height: 48px;                    /* thumb target, same as .btn */
  border-radius: var(--radius-sm);
  color: var(--muted);                 /* 5.1:1 on the raised surface - AA */
  text-decoration: none;
  font-weight: 600;
}
.tab-label {
  font-size: 0.75rem;                  /* UI chrome, not body copy */
  line-height: 1.2;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
}
.tab:hover { color: var(--foreground); }
/* ⚠ THE CURRENT TAB IS MARKED WITH aria-current, AND THE STYLE HANGS OFF THAT
   RATHER THAN OFF A CLASS. One attribute then means the same thing to a screen
   reader and to the eye, and they cannot drift apart. Colour is not the only
   signal — the weight changes too — because colour alone fails for anyone who
   cannot separate these two hues. */
.tab[aria-current="page"] { color: var(--primary); font-weight: 800; }
.tab[aria-current="page"] svg { stroke-width: 2.25; }

/* From tablet up the bar moves to the top of the shell and reads as a normal
   nav row: a bottom bar is a phone idiom and looks stranded on a desktop. */
@media (min-width: 720px) {
  .app-shell { padding-bottom: var(--space-16); }
  .tabbar {
    position: static;
    box-shadow: none;
    border-top: none;
    border-bottom: 1px solid var(--border);
    padding: 0 0 var(--space-2);
    margin-bottom: var(--space-8);
    gap: var(--space-2);
  }
  .tab {
    flex: 0 0 auto;
    flex-direction: row;
    gap: var(--space-2);
    padding: var(--space-2) var(--space-4);
  }
  .tab-label { font-size: 1rem; }
  .incoming-slot { top: var(--space-2); }
}

/* ⚠ SCOPED TO .app-shell, NOT GLOBAL. The marketing page and the visitor page
   use .card too and their generous padding is right for them; this only tightens
   the dashboard, where a phone screen is holding a control panel. 16px is on the
   8px scale — it is a step down, not a free-hand number. */
@media (max-width: 560px) {
  .app-shell .card { padding: var(--space-4); }
  .app-shell { padding-inline: var(--space-3); }
}

/* The printable sheet must not carry the tab-bar gutter into the print, and the
   tab bar itself is .no-print. */
@media print {
  .app-shell { padding-bottom: 0; }
}
