/* ============================================================================
   SwifLock — Components
   ----------------------------------------------------------------------------
   Reusable classes built ONLY from tokens.css custom properties. The only raw
   literals permitted are true one-offs a token can't express: 1px hairlines,
   0/100%/50% geometry, and physics constants inside @keyframes. No palette
   color, spacing step, radius, or duration is ever written as a literal here.

   Depends on: tokens.css (load it first).
   ========================================================================== */

/* ---- App-shell basics ---------------------------------------------------- */

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

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

body {
  margin: 0;
  min-height: 100vh;
  min-height: 100dvh;
  font: var(--text-body);
  color: var(--color-text-primary);
  background: var(--color-bg-0);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* Suppress the mobile-webview tap flash everywhere; interactive elements
     supply their own deliberate :active feedback. */
  -webkit-tap-highlight-color: transparent;
}

/* Safe-area padding helper for edge-to-edge bars/sheets/CTAs. */
.safe-bottom { padding-bottom: max(var(--space-4), env(safe-area-inset-bottom)); }
.safe-top    { padding-top: max(var(--space-4), env(safe-area-inset-top)); }

/* ---- THE PAGE CONTAINER (DECISIONS ruling 19) ---------------------------- */
/* [E15] Alex: "I want all pages to have the same container sizes." Before this
   existed there was no page or container class here, so all NINE pages
   invented their own — measured content columns of 358 / 358 / 350 / 350 / 350
   / 350 / 350 / 342 / 294 at 390px, across FOUR different phone gutters (16,
   20, 24, 24+24). Nobody could name the drift; everybody felt it.

   [E15-FIX] NINE, not eight. The first pass said eight and meant the eight
   pages a signed-in walk-through reaches. `public/styleguide.html` is a real
   page served from `public/` — it is only invisible because it 302s anyone
   signed out — and it sat outside the ruling with its own 1080px cap and its
   own 20px gutter until this fix. The count is stated here because a comment
   that names a set is a claim about that set, and `test/dom-integrity.test.js`
   now DISCOVERS the pages rather than listing them, so the next page is inside
   the ruling the day it exists.

   ONE GUTTER, MANY MAXES. `--page-gutter` is 16px on every page at every
   width — it is not a tier and not responsive. Only `--page-max` varies, and
   it names the CONTENT column (the max-width below adds the gutters back), so
   the number in the tier matches the number a measurement reports.

   AFTER THIS, NO PAGE FILE WRITES ITS OWN max-width FOR ITS CONTAINER. Page
   files may still constrain a COMPONENT (a modal, a card) or a text measure
   (`max-width: 40ch` on a paragraph) — those are not page containers. Pinned by
   `[DOM] no page file writes its own container max-width`. */
.page {
  --page-gutter: var(--space-4);
  --page-max: 1100px;
  width: 100%;
  /* border-box is global, so the gutters must be added back for --page-max to
     mean the content column rather than the border box. */
  max-width: calc(var(--page-max) + 2 * var(--page-gutter));
  margin-inline: auto;
  /* The safe-area floor is part of the ONE gutter, not a per-page decision —
     three pages had hand-rolled their own `max(space, env(...))` pair and the
     other five had none. On a viewport with no inset env() resolves to 0, so
     this is exactly --page-gutter everywhere it does not matter, and never
     less than the notch where it does. */
  padding-inline:
    max(var(--page-gutter), env(safe-area-inset-left))
    max(var(--page-gutter), env(safe-area-inset-right));
}
/* One thing at a time, phone-first, read by one person: the guest key, the
   public booking flow, the sign-in card. */
.page--narrow { --page-max: 480px; }
/* A narrative you read top to bottom — setup, not a workspace. */
.page--prose  { --page-max: 620px; }
/* A surface with lists and grids to scan: the board, the registry, the wall,
   the hub. 1100 is what doors.html had already settled on.

   1100 IS A CAP, NOT A MEASUREMENT, and on two of the four wide pages it never
   binds. Measured at 1280 in both themes: doors 1100 and index 1100 — those two
   are the whole row — but frontdesk 996 and admin 1000, because each sits beside
   a rail that takes its width out of the viewport before `.page` ever sees it.
   So "wide = 1100" is only true where `.page` is the sole column. The two
   workspaces also still disagree with each other by 4px at desk width; that is
   recorded, not fixed, because ruling 19 is about the phone widths and there the
   number really is one number (288 / 343 / 358) on all nine pages. */
.page--wide   { --page-max: 1100px; }

/* ---- WHEN TO CENTRE (DECISIONS ruling 20) -------------------------------- */
/* [E20-RULE] Alex asked for a centering sweep. A read-only critic ran it over
   every page in both themes and found ZERO violations — the rule was already
   being followed by instinct. It is written down here so it survives the next
   screen somebody adds, because an eyeballed pass does not.

   CENTRE only when the surface is ONE THING the eye must land on and there is
   nothing to scan: an empty state, the guest hero, a sign-in card, a confirm
   modal. The centre of the screen is where you look when there is no list.

   LEFT-ALIGN the moment there is a list, a form, or a number to compare. Stat
   tiles are left-aligned because you read them as a column of figures, not as
   five separate posters. Ragged left edges are what make a dashboard feel
   unfinished, and centred text in a row of cards makes every value start in a
   different place.

   The test: could the reader ever want to compare this to the thing under it?
   Then it is left-aligned. Do not centre anything that is not centred today. */

/* Every interactive control clears the tap flash + gets a comfortable
   min hit-area (44px, Apple HIG) even when the visual glyph is smaller. */
button,
.btn,
.keypad__key,
.pill,
a.link {
  -webkit-tap-highlight-color: transparent;
}

/* Shared, consistent focus ring for keyboard users across all controls.
   [E21] This declaration was copy-pasted into TWELVE places across six page
   files. Nine of them were byte-identical to this rule and are now deleted;
   the three survivors are DELIBERATE and each carries a comment saying so, so
   the next sweep does not "consolidate" them into a regression:
     - frontdesk.html `.nav__item` (phone bottom tab bar, -2px)
     - frontdesk.html `.nav__item` inside the desktop media query (+2px)
     - guest.html `.keycard` (3px ring on the hero, not 2px)
   A page-local `.foo:focus-visible` rule outranks this one on specificity
   alone (`:where()` contributes zero), so an exception only has to be written
   down — it does not need `!important` and never should.

   NB: `.door-tile` is deliberately NOT in this list. It is a <div> on both the
   front desk and admin's registry — a surface, not a control — and listing it
   would advertise a keyboard affordance it does not have. The controls INSIDE
   a tile carry their own.

   NB: `.input` is NOT in this list either, and used to be. Being listed here
   never did anything for it: `.input:focus` below is (0,2,0) and this rule is
   (0,1,0) because `:where()` contributes zero, so the outline was overridden
   every single time and every text field in the product measured
   `outline-style: none`. That is not a bug in `.input:focus` — it draws its own
   ring on purpose, an INSET one plus an accent border, because an outward ring
   on a 48px field would sit outside the border it already moves, and it keys off
   `:focus` rather than `:focus-visible` so a field that has the caret always
   says so, mouse or keyboard. The defect was only ever the advertisement.
   Making the list win would have needed `!important` — which the paragraph
   above forbids for exactly the right reason — or a specificity bump, and both
   would have replaced a deliberate, visible focus state with the generic one.
   So the name goes and the ring stays: after this, every name in this list
   measurably produces the outline it promises. */
:where(
  .btn, .keypad__key, .card--interactive, a.link, .cal__day, .cal__nav,
  .door-card, .tap-card-btn, .nav__item, .prop-item,
  .attention__link, .guest-row, .icon-btn, .room, .steps__item
):focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}
/* [E8] The calendar's cells sit edge to edge (`gap: 0`, so a range reads as
   one band), so a ring pushed OUTWARD would be drawn over the neighbours and
   clipped by them. Inset keeps the whole ring visible on the focused day. This
   is the same reason frontdesk's phone tab bar uses -2px. */
:where(.cal__day, .cal__nav):focus-visible { outline-offset: -2px; }

/* ---- Typographic helpers ------------------------------------------------- */

.text-display   { font: var(--text-display); }
.text-title     { font: var(--text-title); }
.text-heading   { font: var(--text-heading); }
.text-body      { font: var(--text-body); }
.text-label     { font: var(--text-label); }
.text-caption   { font: var(--text-caption); color: var(--color-text-tertiary); }
.text-secondary { color: var(--color-text-secondary); }
.text-tertiary  { color: var(--color-text-tertiary); }
.text-accent    { color: var(--color-accent); }

a.link {
  color: var(--color-accent);
  text-decoration: none;
  border-radius: var(--radius-xs);
  transition: opacity var(--dur-fast) var(--ease-standard);
}
a.link:hover { opacity: 0.78; }
a.link:active { opacity: 0.6; }

/* ---- Buttons ------------------------------------------------------------- */
/* Geometry contract (design-system.md §8): filled/outline share a 52px height
   and 12px radius; ghost is 44px min with no border ever. Press motion is the
   asymmetric quick-down / spring-up from §7 (button-press). */

.btn {
  --_btn-h: 52px;
  appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: var(--_btn-h);
  min-width: 120px;
  padding: 0 var(--space-6);
  border: 0;
  border-radius: var(--radius-md);
  font: var(--text-label);
  letter-spacing: 0.01em;
  cursor: pointer;
  user-select: none;
  text-decoration: none;
  background: transparent;
  color: var(--color-text-primary);
  transition:
    transform var(--dur-medium) var(--ease-spring-press),
    background-color var(--dur-fast) var(--ease-standard),
    border-color var(--dur-fast) var(--ease-standard),
    opacity var(--dur-fast) var(--ease-standard);
}
.btn:active { transform: scale(0.96); transition-duration: var(--dur-instant); }
.btn:disabled,
.btn[aria-disabled="true"] {
  opacity: 0.4;
  pointer-events: none;
}
.btn--block { display: flex; width: 100%; }
/* [ST7-FIX F1] THE PAIRED RULE FOR THE ATTRIBUTE, AND IT BELONGS HERE RATHER
   THAN ON A PAGE. The UA sheet's `[hidden] { display: none }` is one rule of
   the lowest specificity there is, so `.btn`'s `display: inline-flex` above —
   and `.btn--block`'s `display: flex` — beat it, and a button marked `hidden`
   keeps its full 120x52 box, its place in a `space-between` row, and its
   pointer target. This product has shipped that defect on SEVEN surfaces.

   IT WAS FIXED PAGE BY PAGE SIX TIMES AND THE SEVENTH WAS STILL LIVE. Two
   pages declared their own copy of this rule (public/frontdesk.html, whose
   comment claimed it was page-local "because this page is the one that hides
   buttons", and public/welcome.html); two more hid a `.btn` by attribute with
   no rule at all. public/guest.html's `#end-retry` — the "Try again" control
   on the broken-link screen every guest can reach — measured 120.00 x 52.00,
   `display: flex`, inside the viewport, WEARING `hidden`, so a guest whose
   link was dead read "…or ask whoever sent it to you. Try again" and could
   press a button the page believed was gone. public/book.html's
   `#checkout-restart` is the same shape.

   One rule, in the sheet every page loads, next to the declarations it has to
   beat. The two page-local copies are deleted in the same change so a future
   edit cannot make them drift. Proved by BOX MEASUREMENT (getClientRects) on
   guest.html's broken-link screen and on frontdesk's four action buttons, in
   test/st7-add-steps.test.js — never by reading the attribute back, which is
   exactly what let this through six times. */
.btn[hidden] { display: none; }

.btn--primary {
  background: var(--color-accent);
  color: var(--color-accent-fg);
}
.btn--primary:hover { background: color-mix(in srgb, var(--color-accent) 88%, var(--color-text-primary)); }

.btn--secondary {
  background: transparent;
  color: var(--color-text-primary);
  border: 1px solid var(--color-border-strong);
}
.btn--secondary:hover { background: var(--color-bg-2); }
.btn--secondary:active { background: var(--color-bg-2); }

.btn--ghost {
  --_btn-h: 44px;
  min-width: 0;
  padding: 0 var(--space-4);
  background: transparent;
  color: var(--color-accent);
}
.btn--ghost:hover { background: var(--tint-accent); }
.btn--ghost:active { background: var(--tint-accent); }

.btn--danger {
  background: var(--color-danger);
  color: var(--color-danger-fg);
}
.btn--danger:hover { background: color-mix(in srgb, var(--color-danger) 88%, var(--color-text-primary)); }

/* Loading state swaps the label for a spinner without changing size. */
.btn--loading { color: transparent !important; pointer-events: none; position: relative; }
.btn--loading::after {
  content: "";
  position: absolute;
  width: 18px; height: 18px;
  border-radius: var(--radius-full);
  border: 2px solid currentColor;
  border-top-color: transparent;
  color: var(--color-accent-fg);
  animation: dp-spin 0.7s linear infinite;
}
@keyframes dp-spin { to { transform: rotate(360deg); } }

/* ---- Cards --------------------------------------------------------------- */

.card {
  background: var(--color-bg-1);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-5);
  box-shadow: var(--elevation-1);
  /* [E37] THE ONE PLACE IN THIS SHEET A REAL UNDERLINE COULD STILL COME FROM.
     `.btn` and `a.link` both kill the browser default; `.card` did not, and a
     card IS used as an anchor — public/index.html's three launcher tiles are
     `<a class="app card card--interactive">`. They render clean today only
     because that page happens to patch it locally (`a.app { text-decoration:
     none }`), which means the next `<a class="card">` written anywhere else
     ships underlined and nobody finds out until someone looks at it.
     Alex reported "underlines" on the account page; measured, there are none
     there — but this is the gap that produces them, so it is closed at the
     component rather than left for the page that trips over it. index.html's
     local rule is now redundant and is deliberately left alone: deleting it
     changes nothing on screen and is not this task. */
  text-decoration: none;
}
.card--interactive {
  cursor: pointer;
  transition:
    transform var(--dur-base) var(--ease-standard),
    border-color var(--dur-base) var(--ease-standard),
    box-shadow var(--dur-base) var(--ease-standard);
}
.card--interactive:hover {
  border-color: var(--color-border-strong);
  box-shadow: var(--elevation-2);
  transform: translateY(-1px);
}
.card--interactive:active { transform: translateY(0); }

/* Card-enter stagger (design-system.md §7 card-enter). Add .is-entering to a
   freshly-inserted card; JS sets --enter-i for the per-item delay. */
.card-enter {
  animation: dp-card-enter var(--dur-medium) var(--ease-standard-decel) both;
  animation-delay: calc(var(--enter-i, 0) * 35ms);
}
@keyframes dp-card-enter {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* ---- The lock tile ------------------------------------------------------- */
/* [E17] A lock used to render as THREE different components: frontdesk's
   `.door-tile` (16px pad, 12px radius, border + elevation), admin's
   `.door-row` (16px 0, no radius, no border, no shadow, a bottom hairline) and
   doors' `.door-card`. Ruling: THE TILE WINS. This is its shell — surface,
   frame and press affordance — hoisted here so the pages share one definition
   instead of restating it. Each page still composes its own INSIDES
   (`.door-tile__*` on frontdesk, `.door-row__*` on admin's registry); what is
   shared is what a lock LOOKS LIKE, which is the thing that was drifting.
   Do not invent a fourth. */
.door-tile {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-4);
  border-radius: var(--radius-md);
  background: var(--color-bg-1);
  border: 1px solid var(--color-border);
  box-shadow: var(--elevation-1);
  transition:
    border-color var(--dur-base) var(--ease-standard),
    box-shadow var(--dur-base) var(--ease-standard);
}

/* ---- Pills / badges ------------------------------------------------------ */
/* The one place a flat tint background is allowed (never a blur). */

.pill {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  min-height: 24px;
  padding: var(--space-1) var(--space-3);
  border-radius: var(--radius-full);
  font: var(--text-caption);
  font-weight: 500;
  white-space: nowrap;
  background: var(--tint-neutral);
  color: var(--color-text-secondary);
  border: 1px solid transparent;
}
.pill__dot {
  width: 6px; height: 6px;
  border-radius: var(--radius-full);
  background: currentColor;
  flex: none;
}
.pill--accent  { background: var(--tint-accent);  color: var(--color-accent); }
.pill--success { background: var(--tint-success); color: var(--color-success); }
.pill--warning { background: var(--tint-warning); color: var(--color-warning); }
.pill--danger  { background: var(--tint-danger);  color: var(--color-danger); }
.pill--neutral { background: var(--tint-neutral); color: var(--color-text-tertiary); }

/* ---- Inputs -------------------------------------------------------------- */

.input {
  width: 100%;
  min-height: 48px;
  padding: 0 var(--space-4);
  font: var(--text-body);
  color: var(--color-text-primary);
  background: var(--color-bg-1);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  transition: border-color var(--dur-fast) var(--ease-standard);
}
.input::placeholder { color: var(--color-text-tertiary); }
/* THE FIELD'S OWN FOCUS STATE, and the reason `.input` is not in the shared
   `:focus-visible` list above. The accent border plus the 1px inset ring IS the
   focus indicator — deliberately inset, and deliberately on `:focus` rather than
   `:focus-visible` so a field holding the caret shows it however the user got
   there. Do not "consolidate" this into the shared rule: it would have to win on
   specificity to do so, and what it replaces is a designed state, not a
   fallback. */
.input:focus {
  outline: none;
  border-color: var(--color-accent);
  box-shadow: inset 0 0 0 1px var(--color-accent);
}
.input:disabled { opacity: 0.4; cursor: not-allowed; }
.input--error { border-color: var(--color-danger); }
.input--error:focus { border-color: var(--color-danger); box-shadow: inset 0 0 0 1px var(--color-danger); }

.field { display: flex; flex-direction: column; gap: var(--space-2); }
.field__label { font: var(--text-label); color: var(--color-text-secondary); }
.field__help  { font: var(--text-caption); color: var(--color-text-tertiary); }
.field__help--error { color: var(--color-danger); }

/* ---- Numeric keypad ------------------------------------------------------ */
/* 3-col grid, ≥64×56px tiles, digit-tap motion from §7. */

.keypad {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-2);
}
.keypad__key {
  appearance: none;
  min-height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  font: var(--text-title);
  color: var(--color-text-primary);
  background: var(--color-bg-1);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  cursor: pointer;
  user-select: none;
  transition:
    transform var(--dur-fast) var(--ease-standard),
    background-color var(--dur-fast) var(--ease-standard);
}
.keypad__key:hover { background: var(--color-bg-2); }
.keypad__key:active {
  transform: scale(0.94);
  background: var(--color-bg-2);
  transition-duration: var(--dur-instant);
}
.keypad__key--action { font: var(--text-label); color: var(--color-text-secondary); }
.keypad__key--accent { color: var(--color-accent); }

/* ---- Range calendar ------------------------------------------------------ */
/* [E8] DECISIONS ruling 17: tap the first day, tap the last day. Dates only —
   the times come from the property and are stated in words underneath, never
   asked for.

   THE TAP TARGET IS THE POINT, so the geometry is stated rather than hoped for.
   `gap: 0` on the grid is deliberate: seven columns across the frontdesk panel
   at 390px (358px of content, less 2×8px of card padding) leaves 48.8px per
   cell, and a gap would spend the margin over the 44px HIG minimum on
   whitespace. It also lets a selected range render as one continuous band
   instead of seven detached tiles — cells touch, which is what a range IS.
   Rows are pinned at 44px so a five-row month cannot shrink the target.

   [E15] THE ARITHMETIC AT 320px, AND WHY IT DOES NOT CLOSE INSIDE THE
   CONTAINER. The width of a cell is
     (viewport - 2*--page-gutter - 2*(.cal padding) - 2*(.cal border)) / 7
   so at 320px, seven 44px columns need 308px and the calendar's own frame
   already spends 18 of the 320. Solving for the gutter gives -3px: measured
   live, a gutter of ZERO still only reaches 43.14px. THERE IS NO PAGE GUTTER
   THAT SATISFIES 44px WIDTH AT 320px. [E15] therefore accepted 38.56 x 44 and
   wrote "DO NOT FIX IT BY BREAKING THE CONTAINER" here; ruling 36 has since
   overruled that half, and the paragraph below is what replaces it. The
   arithmetic above is unchanged and is still the reason.

   No new colours, no new motion: accent for the two chosen days, the flat
   accent TINT for the nights between them (§ north star: tint rings and flat
   fills, never a glow), and the same press-scale the keypad uses. */

.cal {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-bg-1);
  padding: var(--space-2);
}
.cal__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  margin-bottom: var(--space-1);
}
.cal__month { font: var(--text-heading); color: var(--color-text-primary); }
.cal__nav {
  appearance: none;
  width: 44px;
  height: 44px;
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--color-text-secondary);
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease-standard);
}
.cal__nav:hover { background: var(--color-bg-2); color: var(--color-text-primary); }
.cal__nav svg { width: 20px; height: 20px; }

.cal__dow,
.cal__grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 0; }
.cal__dow { margin-bottom: var(--space-1); }
.cal__dow span {
  font: var(--text-caption);
  color: var(--color-text-tertiary);
  text-align: center;
  padding: var(--space-1) 0;
}

.cal__day {
  appearance: none;
  min-height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  font: var(--text-body);
  font-variant-numeric: tabular-nums;
  color: var(--color-text-primary);
  background: transparent;
  border: 0;
  border-radius: var(--radius-sm);
  cursor: pointer;
  user-select: none;
  transition:
    background-color var(--dur-fast) var(--ease-standard),
    transform var(--dur-fast) var(--ease-standard);
}
.cal__day:hover { background: var(--color-bg-2); }
.cal__day:active { transform: scale(0.92); transition-duration: var(--dur-instant); }
/* A leading pad cell. Present so the columns line up, but it is not a control:
   no text, no hit area, and never focusable (the renderer marks it `disabled`). */
.cal__day--blank { visibility: hidden; pointer-events: none; }
/* Today, when today is not itself chosen — a ring, not a fill, so it cannot be
   mistaken for a selection. */
.cal__day--today { box-shadow: inset 0 0 0 1px var(--color-border-strong); }
/* [ST7] A DAY THAT HAS ALREADY GONE. The renderer marks it `disabled`, so it is
   out of the tab order and cannot be activated at all; this rule is what makes
   it LOOK unavailable, which is the other half of the same requirement. A
   control that silently refuses a tap is the "glitchy" complaint in miniature.
   The contrast is deliberately below the body floor: WCAG 1.4.3 exempts an
   inactive control, and "not available" is the whole message. The hover fill
   and the press-scale go with it, because a surface that lights up and shrinks
   under a finger has promised something it will not do. */
.cal__day--past { color: var(--color-text-tertiary); opacity: 0.45; cursor: default; }
.cal__day--past:hover { background: transparent; }
.cal__day--past:active { transform: none; }
/* The nights BETWEEN the two chosen days. Square, so the band is continuous. */
.cal__day--between { background: var(--tint-accent); border-radius: 0; }
/* The two chosen days themselves. */
.cal__day--edge {
  background: var(--color-accent);
  color: var(--color-accent-fg);
  font-weight: 600;
  box-shadow: none;
}
/* The band starts at the arrival day and ends at the departure day, so only the
   OUTER corners are round — the inner pair square off against the tint. */
.cal__day--start { border-radius: var(--radius-sm) 0 0 var(--radius-sm); }
.cal__day--end   { border-radius: 0 var(--radius-sm) var(--radius-sm) 0; }
/* One day chosen and no departure yet: it is both ends of nothing, so it keeps
   its full radius. */
.cal__day--start.cal__day--end { border-radius: var(--radius-sm); }

/* The resolved stay, in words, live under the grid. */
.cal__resolved {
  margin: 0;
  font: var(--text-body);
  color: var(--color-text-secondary);
}

/* ---- [E33] DECISIONS RULING 36: FULL-BLEED ON THE NARROWEST PHONES ------- */
/* THE ONE DELIBERATE, SCOPED EXCEPTION TO RULING 19'S CONTAINER, AND IT IS
   EXACTLY ONE COMPONENT AT EXACTLY ONE END OF THE WIDTH RANGE.

   Ruling 36: *"Below ~360px the stay calendar drops the page gutter and its own
   card frame and runs edge to edge, so a day cell reaches 44px in both axes.
   Above that breakpoint nothing changes."*

   WHY, IN ONE LINE, because the arithmetic is already stated above and does not
   close: these cells are CONTIGUOUS (`gap: 0`, which is what makes a selected
   range one band), so a cell under the 44px floor does not cost a missed tap —
   it costs TAPPING THE 12th WHEN YOU MEANT THE 13th, booking a guest the wrong
   dates and cutting a door code for the wrong window. A guest locked out is
   worse than a calendar without a side border on a 2016 phone.

   THE ARITHMETIC IT BUYS, at the 320px viewport that is the whole point:
     before  320 - 2*16 (page gutter) - 2*8 (.cal padding) - 2*1 (.cal border)
             = 270 / 7 = 38.5625  — UNDER the floor in one axis
     after   320 - 0 - 0 - 0 = 320 / 7 = 45.7143  — over it in both
   Height is untouched: `.cal__day` already declares `min-height: 44px`, and
   this rule changes nothing on the block axis.

   THE NEGATIVE MARGIN IS THE PAGE GUTTER ITSELF, `var(--page-gutter)`, not a
   re-typed 16 — so the day ruling 19's one gutter is retuned, this exception
   moves with it instead of silently over- or under-shooting the viewport edge.
   On a notched device `.page` pads by `max(--page-gutter, env(safe-area-inset-*))`,
   which is WIDER than what is pulled back here, so the calendar stops at the
   safe area rather than running under the notch. That is the correct direction:
   this may fall short of the physical edge, it can never pass it.

   `border-radius: 0` COMES WITH `border-inline: 0` and is not a separate taste
   call. A box with its side borders removed but its corner radius kept renders
   the surviving top and bottom hairlines curling into nothing at each end — a
   frame that is half there. Ruling 36 says the card frame is DROPPED, so it is
   dropped whole. It has no effect on any measurement above.

   THE BREAKPOINT IS MIRRORED FROM CONFIG.frontdesk.calendarFullBleedBelowPx.
   A media query cannot read a custom property — `@media (width < var(--x))` is
   not expressible in CSS — so the number is a literal here and the canonical
   copy lives in src/config.js, exactly as CONFIG.frontdesk.unitTilesMaxCount is
   mirrored into public/frontdesk.html's CONFIG_UI block. test/e8-stay-dates.js
   reads this literal out of this sheet and asserts it equals the config value,
   so the two cannot drift.

   360 IS THE RULING'S NUMBER AND IT HAS SLACK IN IT ON PURPOSE. The width at
   which the plain container first reaches 44 is 358 (358 - 50 = 308 = 7*44), so
   358 and 359 get the exception without strictly needing it. The pin below
   asserts the direction that matters: at the breakpoint and above, the ordinary
   container already clears 44 on its own, so no width is left under the floor
   and no width above the breakpoint is changed. */
@media (width < 360px) {
  /* ONLY `.cal`. Nothing else in this sheet or any page file escapes the
     container at any width — pinned by test/e8-stay-dates.test.js, which
     asserts this block's selector list is exactly this one. */
  .cal {
    margin-inline: calc(-1 * var(--page-gutter));
    border-inline: 0;
    padding-inline: 0;
    border-radius: 0;
  }
}

/* ---- Scrim + bottom sheet + modal ---------------------------------------- */

.scrim {
  position: fixed;
  inset: 0;
  z-index: 10;
  background: color-mix(in srgb, var(--color-bg-0) 55%, transparent);
  display: none;
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease-standard);
  overscroll-behavior: contain;
}
.scrim.is-open { display: flex; opacity: 1; }

/* Bottom sheet: pinned to viewport bottom, top corners only, slides up. */
.scrim--sheet { align-items: flex-end; justify-content: center; }
.sheet {
  width: 100%;
  max-width: 480px;
  background: var(--color-bg-3);
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  padding: var(--space-5) var(--space-5) 0;
  box-shadow: var(--elevation-3);
  transform: translateY(100%);
  transition: transform var(--dur-slow) var(--ease-emphasized-decel);
  overscroll-behavior: contain;
}
.scrim.is-open .sheet { transform: translateY(0); }
.sheet__handle {
  width: 36px; height: 4px;
  border-radius: var(--radius-full);
  background: var(--color-border-strong);
  margin: 0 auto var(--space-4);
}

/* Modal: centered dialog, all corners rounded, scale/fade in. */
.scrim--modal { align-items: center; justify-content: center; padding: var(--space-6); }
.modal {
  width: 100%;
  max-width: 400px;
  background: var(--color-bg-3);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  padding: var(--space-6);
  box-shadow: var(--elevation-3);
  transform: scale(0.96);
  opacity: 0;
  transition:
    transform var(--dur-slow) var(--ease-emphasized-decel),
    opacity var(--dur-medium) var(--ease-standard);
  overscroll-behavior: contain;
}
.scrim.is-open .modal { transform: scale(1); opacity: 1; }

/* Wrong-PIN shake — single-axis translateX only, dropped under reduced-motion
   via tokens (the fade stays, the shake collapses). */
.modal.is-shaking,
.sheet.is-shaking { animation: dp-shake var(--dur-slow) var(--ease-standard); }
@keyframes dp-shake {
  10%, 90% { transform: translateX(-1px); }
  20%, 80% { transform: translateX(3px); }
  30%, 50%, 70% { transform: translateX(-6px); }
  40%, 60% { transform: translateX(6px); }
}
@media (prefers-reduced-motion: reduce) {
  .modal.is-shaking, .sheet.is-shaking { animation: none; }
}

/* ---- Live status dot (SSE connection / door offline) --------------------- */

.status-dot {
  width: 8px; height: 8px;
  border-radius: var(--radius-full);
  background: var(--color-text-tertiary);
  display: inline-block;
  flex: none;
  transition: background-color var(--dur-base) var(--ease-standard);
}
.status-dot--live { background: var(--color-success); }
.status-dot--error { background: var(--color-danger); }

/* ---- Brand lockup -------------------------------------------------------
   Alex's SwifLock artwork, sitting DIRECTLY ON THE SURFACE. There is no white
   plate behind it any more: the plate only ever existed because the navy
   artwork measured 1.10:1 on the dark ground and had to be given something to
   sit on. There are two variants now, tokens.css picks the one that suits the
   ground, and the reason for the plate is gone with it.

   A BACKGROUND IMAGE RATHER THAN AN <img>, deliberately. `src` cannot be
   switched from CSS, so an <img> would need either JS (a flash, and a missing
   logo when it fails) or two elements (both downloaded). A background reads
   --brand-lockup straight out of the theme cascade, fetches exactly the one
   file that matches, and needs no script at all. The element carries
   role="img" + aria-label so it still has a name in the accessibility tree,
   which the typed wordmark it replaces used to provide.

   Sized by WIDTH, with the height following the shared aspect ratio, so it can
   never overflow its column: `min(...)` caps it on desktop and 100% caps it at
   320px. `contain` means a clamp can never stretch the artwork. */
.brand-lockup {
  display: block;
  width: min(var(--_lockup-w, 240px), 100%);
  aspect-ratio: var(--brand-lockup-ar);
  background-image: var(--brand-lockup);
  background-repeat: no-repeat;
  background-position: center;
  background-size: contain;
}
/* The compact pair (smaller files, same artwork) for the places the lockup is
   a header element beside other content rather than the hero of the page. */
.brand-lockup--sm {
  background-image: var(--brand-lockup-sm);
}
