/*!*************************************************************************************************************************************************************************************************************************************************************************!*\
  !*** css ../../node_modules/next/dist/build/webpack/loaders/css-loader/src/index.js??ruleSet[1].rules[14].oneOf[10].use[2]!../../node_modules/next/dist/build/webpack/loaders/postcss-loader/src/index.js??ruleSet[1].rules[14].oneOf[10].use[3]!./src/app/globals.css ***!
  \*************************************************************************************************************************************************************************************************************************************************************************/
/*
 * globals — the Manor web surface, in the same paper/ink/forest palette the
 * native app uses (apps/mobile/src/theme.ts, mirrored in
 * src/lib/estate-theme.ts).
 *
 * MOBILE FIRST, AND NOT AS A SLOGAN. The stated primary reader opens this on a
 * phone. So: base styles are the phone layout, every wider rule is a
 * min-width query, tap targets are >=44px, and `-webkit-text-size-adjust` and
 * a 16px input font-size are set because iOS Safari zooms the whole page when
 * it focuses an input smaller than that — which on a sign-in form reads as the
 * page breaking the moment you touch it.
 *
 * NO WEB FONT. Devanagari and Latin both have to render immediately on a slow
 * Indian mobile connection, and a webfont round-trip that fails leaves the
 * Hindi copy in tofu boxes. The system stack has real Devanagari coverage on
 * both Android and iOS.
 */

:root {
  --paper: #f5f1e8;
  --paper-raised: #fffdf8;
  --paper-sunk: #ebe5d6;

  --ink: #1c2321;
  --ink-muted: #5b6660;
  --ink-faint: #8b948e;

  --forest: #2e4a3a;
  --forest-deep: #1b2e23;
  --forest-soft: #e4ebe4;

  --brass: #a8823c;
  --brass-soft: #ede2c9;
  /*
   * THE WORDMARK'S OWN BROWN, lifted straight out of the logo file rather
   * than guessed at — every path in manor-buildcon-logo.svg is this colour.
   * Brass is the metal accent; this is the ink the brand signs its name in,
   * and using it for sheet headings and the confirmation wash makes those
   * surfaces read as part of the identity instead of generic chrome
   * (operator, 2026-08-12: "brown text? like from the original logo").
   */
  --umber: #5a381d;
  --umber-soft: #efe4d7;

  --line: #ded6c4;
  --line-strong: #c6bca4;

  --danger: #8c3b2e;
  --danger-soft: #f2e2de;

  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;

  --shell-header: 56px;
  --shell-tabs: 62px;
}

* {
  box-sizing: border-box;
}

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

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans",
    "Noto Sans Devanagari", sans-serif;
  font-size: 16px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

a {
  color: var(--forest);
}

h1,
h2,
h3 {
  margin: 0 0 0.4em;
  line-height: 1.25;
  font-weight: 600;
}

/* ------------------------------------------------------------- landing -- */

.landing {
  position: relative;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--forest-deep);
}

/* The hero fills the viewport and sits behind everything. `object-fit: cover`
   plus a poster means a slow or blocked video degrades to the still, never to
   a black rectangle. */
.landing__media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  z-index: 0;
}

/* Carries the legibility the retired paper plate used to: a deeper band
   through the middle third, where the wordmark and the field live. */
.landing__scrim {
  position: absolute;
  inset: 0;
  z-index: 1;
  background:
    /* The middle band — a soft pool of shade under the wordmark and the
       field, with no edge anywhere. This is what replaced the plate. */
    radial-gradient(
      ellipse 120% 45% at 50% 45%,
      rgba(27, 46, 35, 0.62) 0%,
      rgba(27, 46, 35, 0) 70%
    ),
    linear-gradient(
      to bottom,
      rgba(27, 46, 35, 0.55) 0%,
      rgba(27, 46, 35, 0.3) 40%,
      rgba(27, 46, 35, 0.78) 100%
    );
}

.landing__content {
  position: relative;
  z-index: 2;
  flex: 1 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
  padding: 2rem 1.25rem;
  text-align: center;
  color: #fffdf8;
}

/*
 * THE BRAND PLATE (v3 polish). A wordmark floating directly on the moving
 * hero fought the animation on every bright frame — Sol's finding — so the
 * title block now sits on a stable, translucent off-white plate that never
 * changes brightness under it. `backdrop-filter: blur` is what keeps the
 * plate reading as glass rather than a flat card, and the hairline border
 * plus soft shadow separate it from the video without a hard edge.
 */
.landing__plate {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  padding: 24px 28px;
  /*
   * NO CARD (operator, 2026-08-12: "take out the white background entirely
   * and have it blend"). The paper plate was added so the wordmark would
   * stop fighting bright video frames — but a hard-edged box parked over the
   * film is exactly the thing that interrupts it. The contrast job moves to
   * `.landing__scrim`, which darkens the film itself: same legibility, no
   * seam, and the property stays the whole picture instead of the backdrop
   * to a rectangle.
   */
  background: none;
  border: none;
  box-shadow: none;
  max-width: min(90vw, 22rem);
  /* The plate settles onto the film rather than being there from frame one,
     and RESIZES smoothly when the sign-in field opens inside it — the
     transition is what makes the form feel like it grew out of the plate
     instead of replacing it. */
  animation: landing-plate-in 900ms cubic-bezier(0.16, 0.84, 0.32, 1) both;
  transition: max-width 320ms ease, padding 320ms ease;
}

@keyframes landing-plate-in {
  from {
    opacity: 0;
    transform: translateY(14px) scale(0.98);
  }
}

/* Opened, it earns a little more room for a field and a button. */
.landing__plate--open {
  max-width: min(94vw, 24rem);
  padding: 26px 24px 22px;
}

/*
 * The form's LABELS and helper lines sit on film now, so they take the
 * light ink; the input and the button keep solid fills, because a field you
 * type into and a button you commit with must never be guesswork.
 */
.landing__form .field__label,
.landing__form .field__hint,
.landing__form > p,
.landing__form > .muted {
  color: rgba(255, 253, 248, 0.9);
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}

/*
 * ANYTHING WITH ITS OWN SURFACE KEEPS ITS OWN INK.
 *
 * The rule above paints bare text light so it reads on the film. Applied
 * blindly it also hit the code step's notices and its quiet buttons — which
 * carry pale backgrounds of their own — so light ink landed on light chrome
 * and the whole second step rendered as ghost boxes: "we sent a 6-digit
 * code", the resend row, the dev-provider line, all barely there.
 *
 * The fix is the boundary, not the colour: a descendant selector reaches
 * into components that were never meant to be on film. These put them back,
 * opaque, exactly as they read on the sign-in page.
 */
.landing__form .notice,
.landing__form .btn,
.landing__form .otp-input,
.landing__form input {
  text-shadow: none;
}

.landing__form .notice {
  color: var(--ink);
  background: var(--paper-raised);
}

.landing__form .notice--error {
  color: var(--danger);
  background: var(--danger-soft);
}

.landing__form .btn--quiet,
.landing__form .btn--inline {
  color: var(--forest-deep);
  background: var(--paper-raised);
  border-color: var(--line-strong);
}

/*
 * …EXCEPT THE WAY-OUT LINE, which has no surface of its own.
 *
 * The restoration above is for chrome that carries a pale background — a
 * notice, a bordered quiet button. The two words under the submit are bare
 * text on film, and painting them as buttons put a white slab around
 * "Different number" while its neighbour stayed unpainted, so one way out of
 * the screen looked like a control and the other like a caption. They are
 * the same thing and now read the same: light ink, no fill, no border.
 */
.landing__form .actions--links,
.landing__form .actions--links .btn--quiet,
.landing__form .actions--links .btn--inline,
.landing__form .actions--links button {
  color: rgba(255, 253, 248, 0.86);
  background: none;
  border-color: transparent;
  box-shadow: none;
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
}

.landing__form .actions--links button:hover,
.landing__form .actions--links button:focus-visible {
  color: #fff;
  background: none;
}

/* Two icon buttons sharing one line — square targets, centred, side by side
   rather than two full-width sentences stacked. */
/*
 * THE WAY OUT OF A SCREEN, not a third and fourth button on it. Words, muted,
 * on one line under the submit — the shape a code screen converges on. Two
 * round icon buttons sat here first and read as equals of the green button
 * above them, which inverts what the screen is for.
 */
.actions--links {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.45rem;
  margin: 0.15rem 0 0;
  font-size: 0.86rem;
  color: var(--ink-muted);
}

.actions--links .linkish,
.actions--links button {
  flex: none;
  width: auto;
  min-width: 0;
  min-height: 32px;
  padding: 0.1rem 0.2rem;
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  box-shadow: none;
  text-decoration: none;
  cursor: pointer;
}

.actions--links .linkish:hover,
.actions--links button:hover {
  color: var(--forest);
  background: none;
}

/*
 * Tucked under the button it belongs to. The form spaces its rows evenly,
 * which is right for a label and a field but too much air between a
 * primary button and the quiet line that trails it — they read as two
 * separate things instead of one control and its escape hatch.
 */
.actions--tight {
  margin-top: -0.55rem;
}

/*
 * THE FIELD'S OWN FRAME. It exists to give the mark below something to be
 * positioned against; without it an absolutely-positioned child would escape
 * to the page.
 */
.field__box {
  position: relative;
  display: block;
}

.field__box > input {
  width: 100%;
}

.field__box > input[aria-invalid="true"] {
  /* Room for the mark, so a long number never runs under it. */
  padding-right: 2.6rem;
  border-color: var(--danger, #b4442f);
}

/* The country code, shown inside the field it belongs to. */
.field__prefix {
  position: absolute;
  top: 50%;
  left: 0.95rem;
  transform: translateY(-50%);
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink-muted);
  pointer-events: none;
}

/*
 * THE ATTRIBUTE IS NOT DECORATION — it is what makes this rule WIN.
 *
 * The base field rule further down this file is `input[type="tel"], …` with
 * a padding SHORTHAND, which resets `padding-left` to 0.85rem. A plain
 * `.field__box--prefixed > input` scores exactly the same specificity as
 * that selector (one class-level token plus one type), so the later rule
 * won on source order alone and the typed number landed on top of the
 * prefix (operator, 2026-08-12). Naming the attribute here outranks it
 * outright, which is stable no matter where either rule moves.
 */
.field__box--prefixed > input[type="tel"] {
  padding-left: 3.2rem;
}

.field__flag-wrap {
  position: absolute;
  top: 50%;
  right: 0.55rem;
  transform: translateY(-50%);
  line-height: 0;
}

.field__flag {
  width: 26px;
  height: 26px;
  min-height: 0;
  padding: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 0.95rem;
  font-weight: 700;
  line-height: 1;
  color: #fff;
  background: var(--danger, #b4442f);
  border: 0;
  border-radius: 50%;
  box-shadow: none;
  cursor: pointer;
}

/* The sentence, on demand. Anchored to the mark's right edge so it opens
   INTO the field rather than off the screen. */
.field__bubble {
  position: absolute;
  top: calc(100% + 0.4rem);
  right: 0;
  z-index: 5;
  width: max-content;
  max-width: min(17rem, 74vw);
  padding: 0.5rem 0.65rem;
  font-size: 0.82rem;
  font-weight: 500;
  line-height: 1.35;
  color: var(--ink);
  text-align: left;
  background: var(--paper);
  border: 1px solid var(--danger, #b4442f);
  border-radius: var(--radius-sm, 8px);
  box-shadow: 0 6px 18px rgba(27, 46, 35, 0.18);
  animation: bubble-in 140ms ease-out both;
}

@keyframes bubble-in {
  from {
    opacity: 0;
    transform: translateY(-3px);
  }
}

/*
 * A MESSAGE ARRIVES, it does not blink into being. Appearing instantly under
 * the thumb that just pressed the button is what made an answer read as a
 * glitch (operator, 2026-08-12: "fade it nicely in not so ugly").
 */
.notice--arrive {
  animation: notice-in 200ms ease-out both;
}

@keyframes notice-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .field__bubble,
  .notice--arrive {
    animation: none;
  }
}

/* The way back to the film — quiet, below everything, never competing with
   the button that actually signs you in. */
.landing__dismiss {
  /* Centred by its own margins, not by `align-self` — the form is not
     necessarily a flex container, so align-self silently did nothing and
     the link sat hard against the left edge under a centred form. */
  display: block;
  align-self: center;
  margin: 0.15rem auto 0;
  text-align: center;
  padding: 0.35rem 0.5rem;
  min-height: 32px;
  font-size: 0.86rem;
  color: rgba(255, 253, 248, 0.78);
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.55);
  background: none;
  border: 0;
  box-shadow: none;
  cursor: pointer;
}

.landing__dismiss:hover,
.landing__dismiss:focus-visible {
  color: #fff;
}

/* The sign-in rises into the space the prompt just left. */
.landing__form {
  width: 100%;
  margin-top: 0.2rem;
  text-align: left;
  animation: landing-form-in 380ms cubic-bezier(0.16, 0.84, 0.32, 1) both;
}

@keyframes landing-form-in {
  from {
    opacity: 0;
    transform: translateY(12px);
  }
}

/* The wordmark keeps its OWN ink now, rather than being inverted to white —
   that inversion existed only because the logo used to float directly on the
   dark video scrim. On the paper-coloured plate the asset's own brown reads
   perfectly well, and forcing white here would wash it out to invisible. */
.landing__logo {
  width: min(58vw, 190px);
  height: auto;
  /* Paper-white on the film. The asset's own brown was right ON the paper
     plate; with the plate gone it would sit brown-on-photograph, which is
     the legibility problem the plate was invented to solve. A soft shadow
     keeps it off a bright frame. */
  filter: brightness(0) invert(1) drop-shadow(0 2px 10px rgba(0, 0, 0, 0.55));
}

/* The Hindi line beneath the wordmark — roughly a title's supporting-line
   scale, in the same muted ink the rest of the plate's chrome uses. */
.landing__tagline {
  margin: 0;
  font-size: 20px;
  line-height: 1.3;
  color: rgba(255, 253, 248, 0.92);
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
}

/* The primary call to action on the plate — a real 52px-tall control by
   size, even though the whole page is the actual tap target underneath it
   (the full-bleed overlay link further down). Brand brown, kept per the
   operator's note. */
.landing__enter {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /*
   * SET DOWN, AWAY FROM THE WORDMARK. At the plate's own 0.6rem gap it read
   * as part of the logo lock-up rather than as an instruction about the
   * screen (operator, 2026-08-12: "it's feeling like a part of the logo,
   * this close"). The brand stays optically centred; the invitation sits
   * clearly below it, in its own air.
   */
  margin-top: 2.4rem;
  /* Thinner and tighter than the pill it replaced — a hairline that says
     "control" without the weight that made the first one read fat. */
  min-height: 42px;
  padding: 0 1.15rem;
  font-size: 0.95rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  /* NO PILL. The outline and its tinted fill were the last box on a screen
     whose whole point is that nothing boxes the film (operator, 2026-08-12:
     "takes away from the minimalism"). What remains is the line itself —
     letter-spaced, light, with a shadow to hold it against a bright frame.
     The 52px height stays: it is still the size of a control even when it
     no longer looks like one. */
  color: #F0D9A0;
  border: 1px solid rgba(240, 217, 160, 0.38);
  border-radius: 999px;
  background: none;
  text-shadow: 0 1px 8px rgba(0, 0, 0, 0.65);
  /*
   * IT ARRIVES WITH THE PLATE, then BREATHES — a slow opacity swell, not a
   * blink. It was briefly held back for five seconds so the hero could play
   * alone; that was wrong in practice, because appearing later changed the
   * plate's height and the whole logo jumped ("it actually flashes the
   * entire logo, weird"). A thing that arrives late has to push something,
   * and here there was nothing to push but the brand. So it is present from
   * the first frame and simply stays quiet.
   */
  animation: landing-enter-breathe 3.6s ease-in-out 700ms infinite;
}

@keyframes landing-enter-breathe {
  50% {
    opacity: 0.62;
  }
}

/*
 * Reduced motion: the plate and the invitation are simply THERE. Nothing is
 * lost — every one of these animations is decoration on information that is
 * already fully rendered.
 */
@media (prefers-reduced-motion: reduce) {
  .landing__plate,
  .landing__form,
  .landing__enter {
    animation: none;
  }

  .landing__plate {
    transition: none;
  }
}

/* The whole page is the tap target — a full-bleed overlay link, so a tap
   anywhere goes to sign-in. It carries real link text for screen readers
   rather than being a bare clickable div. */
.landing__hit {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: block;
  text-indent: -9999px;
  overflow: hidden;
}

.landing__footer {
  position: relative;
  z-index: 2;
  padding: 0 1.25rem 1.5rem;
  text-align: center;
}

.landing__locked {
  position: relative;
  z-index: 4;
  margin: 0 1rem 1rem;
  padding: 0.9rem 1rem;
  border-radius: var(--radius-md);
  background: var(--danger-soft);
  color: #5e281f;
  border: 1px solid rgba(140, 59, 46, 0.35);
  text-align: left;
  font-size: 0.92rem;
}

/* ------------------------------------------------------- attribution --- */
/* docs/BRAND.md §3: smallest legible size, muted, small-caps, never accented,
   never larger than caption text, never above or beside the Manor logo. */
.attribution {
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  font-feature-settings: "smcp";
  font-variant: small-caps;
  color: var(--ink-faint);
}

.attribution--onDark {
  color: rgba(255, 253, 248, 0.62);
}

.attribution a {
  color: inherit;
}

/* ------------------------------------------------------------- forms --- */

.sheet {
  max-width: 26rem;
  margin: 0 auto;
  padding: 1.5rem 1.25rem 2rem;
}

/*
 * THE SIGN-IN VIEWPORT (v3 polish). `100dvh` (not `100vh`) is what keeps the
 * wrapper honest on mobile Safari, where `vh` includes the address bar that
 * is not actually there; the vertical padding is the floor that stops the
 * card touching the edges at a short (e.g. 720px, keyboard-open) height.
 */
.signin-wrap {
  min-height: 100dvh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 0;
}

.signin-wrap .sheet {
  max-width: 420px;
  width: 100%;
  padding: 1.5rem 1.25rem 2rem;
  margin: 0 1rem;
}

/* Centre the identity block only — see the comment in signin/page.tsx for
   why the form itself stays left-aligned. */
.signin-head {
  text-align: center;
}

.signin-logo {
  height: 64px;
  width: auto;
  margin-bottom: 0.75rem;
}

.signin-head .signin-logo {
  display: block;
  margin-inline: auto;
}

.field {
  display: block;
  margin-bottom: 1rem;
}

.field__label {
  display: block;
  font-size: 0.72rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  font-weight: 600;
  color: var(--ink-muted);
}

.field__hint {
  font-size: 0.82rem;
  color: var(--ink-muted);
  margin-top: 0.3rem;
}

/*
 * ONE box for every field control — text, phone, email, search, numbers, the
 * visit date+time picker, dropdowns and multi-line notes alike. A form where
 * the notes box and the name box wear different chrome reads as two
 * products.
 *
 * w10 E3 — `input[type="email"]` (the new register-client email field, w10
 * B5) and `input[type="number"]` (the price-approval request's proposed
 * price) were both missing from this list, so they fell back to the
 * browser's bare default control — no shared padding, no shared 48px floor,
 * a visibly different box on the same form. Floor bumped from 46px to 48px
 * to match the rest of the app's touch-target spec exactly (a 48px control
 * with 16px font already clears the "no iOS zoom" minimum).
 */
input[type="tel"],
input[type="text"],
input[type="search"],
input[type="email"],
input[type="number"],
input[type="datetime-local"],
select,
textarea {
  width: 100%;
  /* 16px minimum — anything smaller makes iOS Safari zoom on focus. */
  font-size: 16px;
  font-family: inherit;
  padding: 0.75rem 0.85rem;
  min-height: 48px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-md);
  background: var(--paper-raised);
  color: var(--ink);
}

/* The visit date+time picker: appearance-none lets the 46px touch floor and
   the shared box actually apply on iOS, where the native control is a small
   bordered button that ignores padding. The platform picker still opens. */
input[type="datetime-local"] {
  -moz-appearance: none;
       appearance: none;
  -webkit-appearance: none;
}

textarea {
  min-height: 72px;
  resize: vertical;
}

/* Every dropdown wears the same quiet chevron. The native arrow differs per
   OS and disappears against paper; appearance-none plus an inline ink-muted
   SVG makes "this opens a list" legible everywhere, including the header. */
select {
  -moz-appearance: none;
       appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%235b6660' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 0.85rem center;
  background-size: 12px 8px;
  padding-right: 2.5rem;
}

input:focus-visible,
select:focus-visible,
button:focus-visible,
a:focus-visible,
[tabindex]:focus-visible {
  outline: 2px solid var(--forest);
  outline-offset: 2px;
}

/*
 * The warm light on the confirmation step — see StepWash in SignInForm.
 * Deepest at the bottom, where the form and its button sit, and thinnest at
 * the top so the wordmark keeps the picture behind it. Sepia rather than
 * grey: the palette is forest and paper, and warming the film reads as a
 * lamp coming on rather than as a dimmer being turned down.
 */
.step-wash {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  /* The same brown the wordmark is drawn in, thinned to light. */
  background:
    linear-gradient(
      180deg,
      rgba(90, 56, 29, 0.34) 0%,
      rgba(110, 72, 40, 0.46) 46%,
      rgba(66, 40, 20, 0.6) 100%
    );
  animation: step-wash-in 620ms ease-out both;
}

@keyframes step-wash-in {
  from {
    opacity: 0;
  }
}

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

/*
 * THE SCREEN PARTING as a code is submitted — see SignInForm's submit
 * handler. The wordmark leaves upward, the form leaves downward, and the
 * property dims between them, so the last frame of the sign-in is the same
 * gesture as the first frame of the gates opening on /app. Reversible: the
 * class comes off if the verify fails, and each property transitions back.
 */
.gate-exit .landing__logo {
  /*
   * FADES IN PLACE. It used to travel upward and out — but the curtain it
   * flies into is dark, and a dark wordmark sliding onto dark cloth just
   * disappears into it (operator, 2026-08-12: "you did it in black so it
   * blended with the curtains"). Motion that ends inside something the same
   * colour is motion nobody sees.
   *
   * Staying put and dimming is both calmer and more legible: the mark is
   * where the reader last looked, and the only thing that changes is that
   * it is no longer there. A hair of scale keeps it from reading as a
   * hard cut.
   */
  transform: scale(0.985);
  opacity: 0;
  transition: transform 700ms ease-out, opacity 560ms ease-in 60ms;
}

.gate-exit .landing__form {
  transform: translateY(26vh);
  opacity: 0;
  transition: transform 760ms cubic-bezier(0.4, 0, 0.5, 1), opacity 620ms ease-in 100ms;
}

.gate-exit .landing__media,
.gate-exit .step-wash,
.gate-exit .landing__footer {
  opacity: 0;
  transition: opacity 560ms ease-in;
}

.landing__logo,
.landing__form {
  transition: transform 380ms ease-out, opacity 300ms ease-out;
}

@media (prefers-reduced-motion: reduce) {
  .gate-exit .landing__logo,
  .gate-exit .landing__form {
    transform: none;
  }
}

/*
 * The sign-in's own draw of the curtain waits for the screen to finish
 * parting — see ExitCurtain. Same panels, same animation, later start.
 */

/*
 * The confetti over the opening gates — see Confetti in GateProvider. Each
 * piece falls once, drifting slightly, and fades before it lands; nothing
 * spins, nothing bounces, nothing repeats.
 */
.gate-confetti {
  position: absolute;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}

.gate-confetti__bit {
  position: absolute;
  top: -6vh;
  left: var(--x);
  width: 7px;
  height: 11px;
  border-radius: 1px;
  opacity: 0;
  /* Timed against the hold: the first pieces are already falling while the
     curtain is still closed, and the last are still in the air as it parts,
     so the confetti belongs to the pause rather than racing it. */
  animation: confetti-fall var(--fall) cubic-bezier(0.28, 0.14, 0.5, 1) var(--delay) both;
}

.gate-confetti__bit--0 {
  background: var(--brass);
}
.gate-confetti__bit--1 {
  background: var(--umber);
}
.gate-confetti__bit--2 {
  background: var(--paper);
}
.gate-confetti__bit--3 {
  background: var(--forest);
}

@keyframes confetti-fall {
  0% {
    opacity: 0;
    transform: translate3d(0, 0, 0);
  }
  12% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    transform: translate3d(var(--drift), 58vh, 0);
  }
}

.otp-input {
  letter-spacing: 0.5em;
  font-size: 1.4rem;
  text-align: center;
  font-feature-settings: "tnum";
  font-variant-numeric: tabular-nums;
}

/*
 * SIX SLOTS, NOT A BLANK BOX. The dashes are set lighter and tighter than
 * the digits that replace them, so the field reads as "six things go here"
 * at a glance and never competes with what is typed into it.
 */
.otp-input::placeholder {
  letter-spacing: 0.12em;
  font-size: 1.15rem;
  color: var(--ink-muted);
  opacity: 0.55;
}

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  width: 100%;
  min-height: 48px;
  padding: 0.75rem 1.1rem;
  font-size: 1rem;
  font-family: inherit;
  font-weight: 600;
  border-radius: var(--radius-md);
  border: 1px solid var(--forest);
  background: var(--forest);
  color: #fffdf8;
  cursor: pointer;
  /*
   * v3 item 7 — the underline leak, fixed at the stylesheet level. `.btn`
   * started life as a <button>-only class; every one of the 9+ places that
   * later reused it on a <Link> (a real anchor) inherited the browser's
   * default underline, because nothing here ever reset it. One line here
   * fixes every current use AND every future one — the whole point of
   * fixing a leak at its source instead of patching each call site.
   * Underline stays reserved for genuine inline-prose links (plain <a>/
   * <Link> with no .btn/.card/.tab/.menu-sheet__link/.breadcrumb__link
   * role class) — see tests/no-underline-leak.test.mjs.
   */
  text-decoration: none;
}

.btn[disabled] {
  opacity: 0.55;
  cursor: not-allowed;
}

.btn--quiet {
  background: transparent;
  color: var(--forest);
}

.btn--inline {
  width: auto;
  min-height: 40px;
  padding: 0.4rem 0.9rem;
  font-size: 0.88rem;
}

.btn--danger {
  background: var(--danger);
  border-color: var(--danger);
}

.notice {
  padding: 0.85rem 1rem;
  border-radius: var(--radius-md);
  font-size: 0.92rem;
  margin-bottom: 1rem;
}

.notice--error {
  background: var(--danger-soft);
  color: #5e281f;
  border: 1px solid rgba(140, 59, 46, 0.35);
}

.notice--ok {
  background: var(--forest-soft);
  color: var(--forest-deep);
  border: 1px solid rgba(46, 74, 58, 0.3);
}

.notice--info {
  background: var(--brass-soft);
  color: #6e541f;
  border: 1px solid rgba(168, 130, 60, 0.35);
}

/* A smaller, quieter notice — informational asides (like the dev-provider
   note below the sign-in actions) rather than something needing a full-size
   banner (v3 polish). */
.notice--compact {
  padding: 0.5rem 0.75rem;
  font-size: 0.82rem;
  margin-bottom: 0;
  margin-top: 0.75rem;
}

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

.shell {
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  background: var(--paper);
}

.shell__header {
  height: var(--shell-header);
  flex: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0 0.9rem;
  background: var(--paper);
  color: var(--ink);
  border-bottom: 1px solid var(--line);
}

.shell__logo {
  height: calc(var(--shell-header) - 1.15rem);
  width: auto;
  display: block;
}

.shell__brand {
  font-weight: 600;
  font-size: 0.98rem;
  letter-spacing: 0.02em;
}

.shell__who {
  font-size: 0.78rem;
  opacity: 0.85;
  text-align: right;
  line-height: 1.3;
}

/* The township switcher — the header's one prominent control. With a single
   township it degrades to a static label (TownshipSwitcher.tsx). */
.shell__township {
  flex: 1 1;
  min-width: 0;
  max-width: 18rem;
  display: flex;
  align-items: center;
}

.shell__township-select {
  /*
   * Overrides the full-width form default: this select lives in the header
   * row, keeps the 16px/46px touch floor, and must never look like a page
   * field — it is the estate's name, made pressable.
   *
   * LOUD ON PURPOSE (operator, 2026-08-12: "feels too hidden … doesn't feel
   * like the whole thing changes based on that selection"). Every screen
   * below is scoped by this one control, and as a pale outline on pale paper
   * it read as a title rather than a switch. So: the app's own green, a 2px
   * outline, a tinted fill, bold green text, and a house mark stamped inside
   * the pill — the only element in the header wearing the accent, because it
   * is the only element in the header that changes what you are looking at.
   *
   * The mark is a background IMAGE, not a sibling element, so it cannot be
   * squeezed out of the header row on a narrow phone. Both it and the
   * chevron are drawn here (background-position/size), which is why the
   * shared chevron rule is fully replaced rather than tinted.
   */
  width: 100%;
  padding-left: 2.1rem;
  font-weight: 700;
  color: var(--forest-deep);
  background-color: var(--forest-soft);
  background-image:
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232e4a3a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 10.5 12 3l9 7.5'/%3E%3Cpath d='M5.5 9.5V21h13V9.5'/%3E%3Cpath d='M10 21v-6h4v6'/%3E%3C/svg%3E"),
    url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%232e4a3a' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat, no-repeat;
  background-position: left 0.65rem center, right 0.6rem center;
  background-size: 1.05rem 1.05rem, 0.85rem 0.85rem;
  border: 2px solid var(--forest);
  border-radius: 999px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Mid-switch the pill dims — the shell is re-rendering on the new township. */
.shell__township[data-pending] .shell__township-select {
  opacity: 0.55;
}

.shell__township-label {
  flex: 1 1;
  min-width: 0;
  font-weight: 600;
  font-size: 0.95rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.shell__main {
  flex: 1 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/*
 * Bottom tabs on a phone — thumb-reachable, the same order as the native
 * app's tabs so the two do not teach different muscle memory.
 *
 * ITEM 20 (v3): the bar caps at 4 slots — at most 3 real destinations
 * (`primaryTabsForRole`) plus the trailing `Menu` trigger NavTabs always
 * draws — and each is a SINGLE line in the reader's chosen language (item
 * 21), not the old English-over-Hindi stack. That is also what fixed item
 * 22's dot-wrap bug on the tour card: a stacked pairing needs a separator
 * with somewhere to go; a single language never does.
 *
 * <900px is "mobile widths" for this bar (matches the operator's spec);
 * >=900px promotes it to a top strip via the `order` flip below.
 */
.shell__tabs {
  flex: none;
  display: flex;
  border-top: 1px solid var(--line);
  background: var(--paper-raised);
  padding-bottom: env(safe-area-inset-bottom);
}

.tab {
  /*
   * `min-width: 0` is what stops tabs overflowing a 375px phone. Without it a
   * flex item refuses to shrink below its content's intrinsic width, so the
   * longest label pushed the row wider than the screen and the last tab ran
   * off the edge — caught on a real phone-sized viewport, not in a wide
   * window where it looks fine.
   */
  flex: 1 1;
  min-width: 0;
  padding: 0.3rem 0.15rem;
  min-height: var(--shell-tabs);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1px;
  text-align: center;
  text-decoration: none;
  color: var(--ink-muted);
}

/* The `Menu` trigger is a <button>, not a <Link> — it opens a sheet, it does
   not navigate — so it needs the anchor's chrome reset onto it explicitly. */
.tab--menu {
  border: none;
  background: none;
  font: inherit;
  cursor: pointer;
}

/* Single-language label — 14px, weight 600 (v3 polish: no more uppercase +
   heavy letter-spacing, which read as shouting at a non-technical,
   Hindi-first reader). One line now, whichever language it's in. */
.tab__label {
  font-size: 14px;
  font-weight: 600;
  line-height: 1.15;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Active state: a pale-green fill plus a 3px baseline bar. */
.tab[aria-current="page"] {
  color: var(--forest);
  box-shadow: inset 0 3px 0 var(--forest);
  background: var(--forest-soft);
}

.tab__glyph {
  font-size: 1.1rem;
  line-height: 1;
}

/* Batch 5 item 1 — the colorful icon family's ONE non-emoji glyph: no rupee
   emoji exists, so Finance's ₹ gets its color from a small brass badge on
   the forest family instead, sized to sit level with the emoji row. */
.tab__glyph--inr {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  border-radius: 999px;
  background: var(--brass-soft);
  color: #6e541f;
  border: 1px solid rgba(168, 130, 60, 0.5);
  font-size: 0.85rem;
  font-weight: 700;
}

/* Mobile widths: the bar is a FIXED BOTTOM bar (item 20 as specified — the
   comment above described this but the rule was missing; the DOM order alone
   left it stranded under the header). Content gets matching bottom padding
   so the last rows never hide behind the bar. */
@media (max-width: 899px) {
  .shell__tabs {
    position: fixed;
    inset: auto 0 0 0;
    z-index: 30;
    box-shadow: 0 -1px 8px rgba(27, 46, 35, 0.08);
  }
  /*
   * w10 A2/A3 — the reserved space below every page's content bumped from
   * 12px to 26px (shell-tabs is 62px, so this floors total clearance at
   * 88px + the safe area) so a phone-width form's own sticky submit bar
   * (`.reg-form__submit`, `.subflow-footer`) never sits closer than 88px to
   * the bottom of the viewport — the finding was the sticky bar colliding
   * with a phone browser's own bottom chrome on the tunneled demo, not just
   * this app's nav.
   */
  .shell__main {
    padding-bottom: calc(var(--shell-tabs) + env(safe-area-inset-bottom) + 26px);
  }

  /*
   * …EXCEPT ON A FULL-HEIGHT PAGE, which reserves the bar and nothing more.
   *
   * The +26px above buys clearance for a SCROLLING page's own sticky submit
   * bar. A flush page (only the map) has no such bar: it is a flex column
   * that ends where its container ends, so the surplus is not clearance, it
   * is a 26px band of dead paper between the plot-number row and the nav —
   * which is exactly what the operator saw (2026-08-12).
   *
   * (It also reconciled two numbers that described one bar: the docked
   * plot-number row pinned itself with `--shell-tabs + safe-area` while
   * claiming to match what `.shell__main` reserved. It did not — they
   * disagreed by exactly the 26px. That row has since moved into the map's
   * own control cluster, so only this rule holds the number now.)
   */
  .shell__main:has(> .page--flush) {
    padding-bottom: calc(var(--shell-tabs) + env(safe-area-inset-bottom));
  }
}

@media (min-width: 900px) {
  .shell__tabs {
    order: -1;
    border-top: none;
    border-bottom: 1px solid var(--line);
  }
  .tab {
    min-height: 60px;
    flex-direction: row;
    gap: 0.5rem;
  }
}

/* -------------------------------------------------------------- page --- */

.page {
  padding: 1rem;
  flex: 1 1;
  min-height: 0;
}

.page--flush {
  padding: 0;
  display: flex;
  flex-direction: column;
}

.page__title {
  font-size: 1.25rem;
  margin-bottom: 0.15rem;
}

.page__lede {
  color: var(--ink-muted);
  font-size: 0.92rem;
  margin: 0 0 1rem;
}

.card {
  background: var(--paper-raised);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: 1rem;
  margin-bottom: 0.85rem;
  /* v3 item 7 — same leak as `.btn` above: a `.card` that is really a
     whole-tile <Link> (the Home dashboard's Kpi tiles) inherited the
     browser default underline until this reset. */
  text-decoration: none;
}

.demo-caption {
  display: block;
  background: var(--brass-soft);
  border: 1px solid rgba(168, 130, 60, 0.4);
  color: #6e541f;
  border-radius: var(--radius-md);
  padding: 0.6rem 0.75rem;
  font-size: 0.82rem;
  margin-bottom: 0.8rem;
}

.demo-caption__hi {
  display: block;
  margin-top: 0.15rem;
  opacity: 0.9;
}

.demo-badge {
  display: inline-block;
  background: var(--brass);
  color: #fffdf8;
  border-radius: 999px;
  padding: 0.05rem 0.5rem;
  font-size: 0.62rem;
  letter-spacing: 0.1em;
  font-weight: 700;
  vertical-align: middle;
}

.pill {
  display: inline-block;
  border-radius: 999px;
  padding: 0.12rem 0.6rem;
  font-size: 0.74rem;
  font-weight: 600;
}

/*
 * w9-item-1 — the ONLY modifier that puts `.pill` on a real anchor (the
 * profile's Partner-Associates/Clients/Site visits stat chips,
 * people/[id]/page.tsx). Every other `.pill` call site is a status BADGE —
 * a plain <span> that sets its own background/color inline via a paint
 * object (PlotPanel.tsx, VisitRows.tsx) — so bare `.pill` never needed a
 * text-decoration reset before. A tappable chip does, and is listed in
 * tests/no-underline-leak.test.mjs's CONTROL_ROLE_CLASSES so a future
 * link-role pill cannot leak the same way silently.
 */
.pill--quiet-link {
  background: var(--paper-sunk);
  color: var(--ink-muted);
  text-decoration: none;
}

.pill--quiet-link:hover,
.pill--quiet-link:focus-visible {
  background: var(--line);
  color: var(--ink);
}

.kv {
  display: grid;
  grid-template-columns: minmax(6.5rem, 40%) 1fr;
  grid-gap: 0.3rem 0.75rem;
  gap: 0.3rem 0.75rem;
  font-size: 0.92rem;
  margin: 0;
}

.kv dt {
  color: var(--ink-muted);
}

.kv dd {
  margin: 0;
}

/* --------------------------------------------------------------- map --- */

.map {
  flex: 1 1;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/*
 * Item 3 (v3 operator pass) — moved BELOW the map (last child of `.map` in
 * EstateMap.tsx), not above it: the search is what a person reaches for
 * AFTER they have already looked at the map, so it no longer squats above
 * the fold demanding a thumb stretch to the top of the phone for the one
 * thing most visits never touch. `border-top` (not `-bottom`) now separates
 * it from the legend sitting above it.
 */
/*
 * The plot-number field — a CARD that pops out of the search button at the
 * foot of the map's control cluster (operator, 2026-08-12: "the search
 * button being at bottom … would render the popup of the search over
 * itself and turn green after toggling").
 *
 * It used to be a bar docked above the tab bar, which meant the control that
 * opened it and the thing it opened were at opposite ends of the screen, and
 * the docked bar crowded the cluster it belonged to. Now it sits directly
 * above its own button, in the same column, and the button goes green while
 * it is out — one gesture, one place.
 */
/*
 * TYPE-AHEAD on the map. While the search box has text, the plots that match
 * wear a heavy accent outline and everything else falls back — so the answer
 * is visible on the drawing, not just reachable by pressing Enter.
 *
 * The outline is drawn with `stroke` + `paint-order: stroke`, so it thickens
 * OUTWARD from the polygon edge and never eats into the fill or the plot
 * number printed on top.
 */
/*
 * The match is marked by CONTRAST, calmly — a firm dark edge and one soft
 * ring outside it, with everything else stepped back. No pulse: a plot that
 * throbs is the map shouting, and the operator's read was right that it was
 * "way too intense". Stepping the rest back is what does the work; the
 * outline only has to be the thing your eye lands on, not an alarm.
 *
 * `paint-order: stroke` keeps the edge under the fill, so neither the wash
 * nor the plot number printed on top is eaten by it.
 */
/*
 * A MATCH IS DEEPER, NOT JUST OUTLINED (operator, 2026-08-12: "the plots
 * being filtered on are not popping out more — make it more deep to show
 * yes these are the ones; either lighten the rest or darken the filter
 * state"). Both, as asked: the match gains a brass wash OVER its status
 * fill — so a matched Available plot stops being a 0.34 whisper — and the
 * rest step further back below.
 *
 * The wash sits on the polygon itself rather than replacing `fill`, so the
 * status colour is still readable underneath: a search narrows the map, it
 * does not repaint it.
 */
.map__poly--match {
  stroke: var(--forest-deep) !important;
  stroke-width: 3.5;
  paint-order: stroke;
  filter: saturate(1.35) brightness(0.92)
    drop-shadow(0 0 4px rgba(27, 46, 35, 0.65));
}

/* Stepped back, still legible AS A MAP — roads, numbers and the estate's
   shape all survive, so you keep your bearings while you narrow. */
.map__poly--dimmed {
  /* Further back than the old 0.35 — enough that the matches read at a
     glance on a phone outdoors, not so far that the estate's shape and its
     roads stop giving you your bearings while you narrow. */
  opacity: 0.18;
}

/*
 * The plot-number row — DOCKED, in the map's own flex column, not floating.
 * Opening it shortens the map above and the row appears beneath: the view
 * shifts up, which is what makes it feel like the map made room rather than
 * something landing on it. `flex: none` is the whole trick — the map has
 * `flex: 1` and gives up exactly this row's height.
 */
.map__bar {
  flex: none;
  display: flex;
  gap: 0.5rem;
  align-items: center;
  padding: 0.6rem 0.75rem;
  border-top: 1px solid var(--line);
  background: var(--paper-raised);
}

.map__bar input[type="search"] {
  flex: 1 1;
  min-width: 0;
  min-height: 42px;
}

/* The field lives inside `.map__zoom`, which is already positioned against
   the map's bottom-right corner — so there is nothing left to pin. The old
   sticky rule (and its offset that had to agree with `.shell__main`'s
   reservation) is retired with the docked bar it positioned. */
/* The type picker on "File a request" (v3 item 4) — a row of pill toggles,
   never a plain <select>: the whole point of a picker is seeing every
   choice at once, which a closed dropdown does not give a non-technical
   reader. */
.chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

/*
 * Item 3's thumb-reach pass, generalised: a screen whose ONE primary action
 * sat at the top for no reason (a reading order the list itself does not
 * need — nothing above the list depends on the button firing first) gets
 * that action biased toward the bottom half on mobile — the same thumb-reach
 * reasoning the map's control cluster answers to. One shared utility so it stays one
 * decision, not one per screen. (First used by the associate's old
 * ClientsScreen.tsx, retired by the w8-28 two-section PeopleSections
 * rework; kept here as a shared utility for any future single-CTA screen.)
 */
.thumb-action {
  margin-top: 0.85rem;
}

@media (max-width: 899px) {
  .thumb-action {
    position: -webkit-sticky;
    position: sticky;
    bottom: calc(var(--shell-tabs) + env(safe-area-inset-bottom) + 0.6rem);
    z-index: 4;
    padding-top: 0.6rem;
    background: var(--paper);
  }
}

.map__canvas {
  position: relative;
  flex: 1 1;
  /* The layout drawing keeps a comfortable minimum height even once the
     search bar, the holds strip and the legend have each taken their own
     slice of the viewport — never squeezed down to a sliver on a 375px
     phone. */
  min-height: 55dvh;
  overflow: hidden;
  background: var(--paper-sunk);
  touch-action: none;
  cursor: -webkit-grab;
  cursor: grab;
}

.map__canvas--dragging {
  cursor: -webkit-grabbing;
  cursor: grabbing;
}

.map__layer {
  position: absolute;
  top: 50%;
  left: 50%;
  transform-origin: center center;
  will-change: transform;
}

/* Batch 4 item 8 — selecting a plot GLIDES the view to it (the same
   focus computation the search box uses); the class is transient: pointer
   input removes it so pinch/pan never fight a tween. */
.map__layer--gliding {
  transition: transform 460ms cubic-bezier(0.22, 1, 0.36, 1);
}

@media (prefers-reduced-motion: reduce) {
  .map__layer--gliding {
    transition: none;
  }
}

.map__plan {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.map__poly {
  stroke-width: 0.6;
  vector-effect: non-scaling-stroke;
  cursor: pointer;
}

/* Batch 4 item 8 — SELECTED reads as a highlight, not a dark shadow: a
   brass outline with a soft glow (the old near-black forest-deep stroke
   read as "shaded out"). The slight FILL LIFT rides a separate overlay
   polygon (`.map__poly-highlight`, EstateMap) so the plot's own status
   fill stays visible underneath. */
.map__poly--selected {
  stroke: var(--brass);
  stroke-width: 2.6;
  filter: drop-shadow(0 0 3px rgba(168, 130, 60, 0.9));
}

.map__poly-highlight {
  fill: rgba(168, 130, 60, 0.16);
  stroke: none;
  pointer-events: none;
}

.map__poly--inert {
  cursor: default;
}

.map__label {
  font-size: 5px;
  fill: var(--ink);
  pointer-events: none;
  text-anchor: middle;
  font-weight: 600;
  /* Contrast check (v3 item 2): a paper-toned halo behind the ink so the
     plot number stays legible over EVERY fill it can land on — a fully
     transparent Available plot (which can expose a busy patch of the
     photographed plan underneath), or the translucent Hold/Sold tints.
     `paint-order: stroke` draws the halo BEHIND the glyph's own fill rather
     than on top of it. */
  paint-order: stroke;
  stroke: rgba(255, 253, 248, 0.88);
  stroke-width: 1.1px;
  stroke-linejoin: round;
}

/* A cluster item that pairs a control with the panel it opens, side by side:
   the field sits to the LEFT of its button, both on the thumb's line. */
/* The key button wears the accent while its card is open — the control and
   the thing it controls say the same thing. */
.map__zoom .is-on {
  border-color: var(--forest);
  color: var(--forest-deep);
  background: var(--forest-soft);
}

.map__zoom {
  position: absolute;
  right: 0.7rem;
  bottom: 0.7rem;
  display: flex;
  flex-direction: column;
  /* Every child HUGS THE RIGHT EDGE. The column also holds cards wider than
     a button (the colour key, the plot-number field), and the default
     `stretch` let those cards set the column's width and drag the square
     buttons left with them. */
  align-items: flex-end;
  gap: 0.35rem;
  z-index: 5;
}

.map__zoom button {
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.2rem;
  font-weight: 600;
  border-radius: var(--radius-md);
  border: 1px solid var(--line-strong);
  background: var(--paper-raised);
  color: var(--ink);
  cursor: pointer;
}

/* The gear reads OPEN while its panel is — an icon-only toggle with no text
   has nothing else to say which state it is in. */
.map__zoom button[aria-expanded="true"] {
  border-color: var(--forest);
  background: var(--forest-soft);
  color: var(--forest-deep);
}

/*
 * The visits FAB and its expanded kind menu.
 *
 * `position: fixed` and NOT sticky: it must stay put while the list behind
 * it scrolls, which is the whole point of putting it in the thumb's arc. It
 * clears the fixed tab bar and the phone's home indicator by sitting above
 * BOTH (`--shell-tabs` + the safe-area inset), the same sum every other
 * bottom-anchored element in this file uses — a FAB that covers the nav is
 * worse than no FAB.
 */
.fab-dock {
  position: fixed;
  right: 1rem;
  bottom: calc(var(--shell-tabs) + env(safe-area-inset-bottom) + 1rem);
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.6rem;
}

.fab {
  width: 56px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 50%;
  background: var(--forest);
  color: var(--paper-raised);
  box-shadow: 0 6px 18px rgba(27, 46, 35, 0.28);
  cursor: pointer;
}

.fab:hover {
  background: var(--forest-deep);
}

.fab__glyph {
  font-size: 1.9rem;
  line-height: 1;
  /* Optical centring: the glyph's own bearing sits it low in the box. */
  margin-top: -0.12rem;
  transition: transform 140ms ease;
}

/* The + becomes an × — one control, two states, so the way out is the same
   target as the way in. */
.fab__glyph--open {
  transform: rotate(45deg);
}

.fab-menu {
  animation: fab-menu-in 150ms ease-out both;
  transform-origin: bottom right;
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  padding: 0.35rem;
  min-width: 11rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--paper-raised);
  box-shadow: 0 10px 24px rgba(27, 46, 35, 0.18);
}

@keyframes fab-menu-in {
  from {
    transform: translateY(8px) scale(0.96);
    opacity: 0;
  }
}

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

.fab-menu__item {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  width: 100%;
  min-height: 44px;
  padding: 0 0.7rem;
  font-size: 0.95rem;
  text-align: left;
  border: none;
  border-radius: 8px;
  background: transparent;
  color: var(--ink);
  cursor: pointer;
}

.fab-menu__item:hover {
  background: var(--paper-sunk);
}

.fab-menu__glyph {
  font-size: 1.05rem;
  line-height: 1;
}

/*
 * .pullup — the modal overlay a multi-step flow runs inside.
 *
 * NAMED `pullup`, NOT `sheet`: `.sheet` was already taken, by the sign-in
 * page's own form card (see the rule far above — max-width 26rem, centred).
 * Redefining it here as a fixed, dimming overlay turned that card into a
 * full-screen curtain and broke sign-in outright (2026-08-12). A global
 * stylesheet has ONE namespace; a new component checks it before claiming a
 * word this general.
 *
 * A flow that expands INSIDE the page pushes everything below it down: the
 * operator watched the month picker and the whole visit list get shoved off
 * screen the moment they pressed +. An overlay leaves the page exactly where
 * it was and says plainly "you are inside something temporary": the backdrop
 * dims and blurs, and the round ✕ at the top right is the way out.
 *
 * Full-bleed on a phone (there is no "beside" on a 375px screen) and a
 * centred card from tablet up.
 */
.pullup {
  position: fixed;
  /* STOPS ABOVE THE TAB BAR (operator, 2026-08-12: "this modal hides the
     menu buttons below"). Covering the app's own navigation is what turns a
     sheet into a trap — Home / Map / Visits / Finance / Menu stay visible and
     tappable, and leaving by one of them closes the flow the same as ✕. */
  inset: 0 0 calc(var(--shell-tabs) + env(safe-area-inset-bottom)) 0;
  z-index: 60;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  /* The panel FLOATS — inset on every side, rounded on all four corners.
     Anchored flush to the screen's bottom it cut off square against the tab
     bar, and the last green button landed hard on that edge (operator:
     "jagged"). A little air all round is what makes it read as a card
     resting on the page rather than a torn-off strip. */
  padding: 0.85rem;
  background: rgba(27, 46, 35, 0.42);
  /* -webkit- first: iOS Safari still needs the prefix, and without the blur
     the dim alone reads as a page that merely went grey. */
  -webkit-backdrop-filter: blur(3px);
  backdrop-filter: blur(3px);
  /* The dim and blur ARRIVE rather than snap on, so the page appears to
     recede behind the sheet instead of being replaced by it. */
  animation: pullup-dim 200ms ease-out both;
}

@keyframes pullup-dim {
  from {
    opacity: 0;
  }
}

/*
 * The UNDIMMED variant — the plot-panel behaviour. No curtain at all, so the
 * map above stays sharp and every tap outside the panel reaches it; only the
 * panel itself catches pointers.
 */
.pullup--bare {
  background: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  animation: none;
  pointer-events: none;
}

.pullup--bare /* No tab bar to clear — see Sheet's `floorless`. */
.pullup--floorless {
  inset: 0;
  padding-bottom: calc(0.85rem + env(safe-area-inset-bottom));
}

.pullup__panel {
  pointer-events: auto;
}

/* No tab bar to clear — see Sheet's `floorless`. */
.pullup--floorless {
  inset: 0;
  padding-bottom: calc(0.85rem + env(safe-area-inset-bottom));
}

.pullup__panel {
  display: flex;
  flex-direction: column;
  width: 100%;
  max-height: 88vh;
  background: var(--paper);
  border-radius: var(--radius-lg);
  /* Clips the body's scroll to the rounded corners — without it a list
     scrolled to its end paints square over them again. */
  overflow: hidden;
  box-shadow: 0 8px 34px rgba(27, 46, 35, 0.32);
  /* Rises from the thumb's edge — the direction the press came from. Faster
     than the backdrop's fade and very slightly overshooting, which is what
     makes a panel feel like an object arriving rather than a layer being
     switched on. */
  animation: pullup-rise 240ms cubic-bezier(0.16, 0.84, 0.32, 1.06) both;
}

@keyframes pullup-rise {
  from {
    transform: translateY(14%);
    opacity: 0;
  }
}

/* The whole effect is decoration: with reduced motion the sheet is simply
   THERE, which is the same information without the movement. */
@media (prefers-reduced-motion: reduce) {
  .pullup,
  /* No tab bar to clear — see Sheet's `floorless`. */
.pullup--floorless {
  inset: 0;
  padding-bottom: calc(0.85rem + env(safe-area-inset-bottom));
}

.pullup__panel {
    animation: none;
  }
}

.pullup__head {
  flex: none;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding: 1.1rem 0.9rem 0.7rem;
  border-bottom: 1px solid var(--line);
  /* The drag surface. `touch-action: none` is what stops the browser from
     claiming the vertical gesture as a page scroll before the handler sees
     it — without it the sheet simply never moves on a real phone. */
  touch-action: none;
  cursor: -webkit-grab;
  cursor: grab;
}

.pullup__head:active {
  cursor: -webkit-grabbing;
  cursor: grabbing;
}

/* The grab bar: centred on the head, above the title. */
.pullup__grip {
  position: absolute;
  top: 0.45rem;
  left: 50%;
  transform: translateX(-50%);
  width: 2.4rem;
  height: 4px;
  border-radius: 999px;
  background: var(--line-strong);
}

.pullup__title {
  /* The brand's own ink — see --umber. */
  color: var(--umber);
  margin: 0;
  font-size: 1rem;
  font-weight: 700;
}

/* The one way out — round, outlined, and in the warning colour so it reads
   as "leave" rather than as another step. */
.pullup__close {
  flex: none;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.4rem;
  line-height: 1;
  border: 2px solid var(--danger);
  border-radius: 50%;
  background: var(--paper-raised);
  color: var(--danger);
  cursor: pointer;
}

.pullup__close:hover {
  background: var(--danger-soft);
}

/* Whatever the sheet is asking ABOUT — a number, a plot, a name — carries
   the same brand ink as the heading, so the eye lands on the fact. */
.pullup__body strong {
  color: var(--umber);
}

.pullup__body {
  flex: 1 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  /* The safe-area pad keeps the last control clear of the home indicator —
     the sheet covers the tab bar, so this is the only thing below it. */
  /* The safe-area sits on the OVERLAY now (the panel floats above it), so
     this is plain breathing room under the last control. */
  padding: 0.8rem 0.9rem 1rem;
}

/*
 * Inside a sheet the step footer is just the last row, not a docked bar.
 * `.subflow-footer` earns its sunk band and tall padding when it sticks to
 * the bottom of a long page; here it painted a dead grey block below the
 * final button, on a panel that is already the right height.
 */
.pullup__body .subflow-footer {
  position: static;
  min-height: 0;
  margin: 0;
  padding: 0.6rem 0 0;
  background: none;
  border: none;
  box-shadow: none;
}

@media (min-width: 700px) {
  .pullup {
    align-items: center;
  }

  /* No tab bar to clear — see Sheet's `floorless`. */
.pullup--floorless {
  inset: 0;
  padding-bottom: calc(0.85rem + env(safe-area-inset-bottom));
}

.pullup__panel {
    max-width: 32rem;
  }
}

@media (min-width: 900px) {
  .fab-dock {
    bottom: 1.5rem;
    right: 1.5rem;
  }
}

/*
 * The colour key — a CARD in the map's bottom-right control column, not a
 * strip under the map. It stacks above the fit/settings/key buttons because
 * it is `.map__zoom`'s first child, so there is no offset to keep in sync
 * with anything. One column of words, right-aligned with its buttons, and
 * capped in width so a long label wraps inside the card instead of stretching
 * it across the drawing.
 */
.map__legend {
  /*
   * BESIDE the buttons, not stacked on them. As the first child of the
   * bottom-right control column it pushed the buttons down and covered the
   * drawing on arrival (operator, 2026-08-12: "the legend is toggled open
   * and it blocks the view — render it right to the left of the 3
   * buttons"). `.map__zoom` is already positioned, so anchoring to its left
   * edge needs no offset arithmetic: the key sits next to the controls and
   * the map keeps its middle.
   */
  position: absolute;
  right: 100%;
  bottom: 0;
  margin-right: 0.4rem;
  flex: none;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.3rem;
  max-width: 9.75rem;
  margin-bottom: 0.35rem;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-md);
  box-shadow: 0 6px 18px rgba(27, 46, 35, 0.16);
  background: var(--paper-raised);
  /* 13px: below the 14px body floor because this is a KEY read once, not
     copy read continuously, and every extra pixel here is a pixel of the
     drawing it covers. Still well above the 12px that read too small at the
     site gate (w10 E2). */
  font-size: 0.8125rem;
  line-height: 1.25;
  color: var(--ink);
}

.map__legend span {
  display: flex;
  align-items: center;
  gap: 0.4rem;
}


.legend__swatch {
  display: inline-block;
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 3px;
  margin-right: 0.3rem;
  vertical-align: -1px;
  border: 1px solid rgba(27, 35, 33, 0.3);
}

/*
 * Plot panel: a bottom sheet on a phone, a side panel on a wide screen.
 *
 * THREE FIXED REGIONS, NOT ONE SCROLLING BLOB (v3 polish). `.panel` is a flex
 * column capped at 72dvh on a phone; `.panel__head` (title + close) and
 * `.panel__tray` (the action buttons) are `flex: none` — always on screen —
 * and only `.panel__scroll` between them takes the remaining height and
 * scrolls. That is what makes the header and the tray "sticky" without any
 * position:sticky trickery: they are simply outside the part that scrolls.
 */
/*
 * THE PHONE-CHROME ALLOWANCE BELONGS TO THE PANEL, NOT TO THINGS INSIDE IT.
 *
 * The panel is the element that actually touches the bottom of the viewport,
 * so it is the element that must hold the 88px + safe-area clearance a phone
 * browser's own bottom bar needs. `.panel__tray` used to carry it as its own
 * padding, which worked only because the tray is OUTSIDE `.panel__scroll` —
 * padding there grows the panel and pushes the buttons up.
 *
 * `.subflow-footer` copied that same 88px, but it lives INSIDE the scroller,
 * where padding cannot grow anything: it made the sticky footer ~197px tall
 * and its opaque background then covered ~190px of the form still in flow
 * behind it — the booking form's client, deposit slider and proof step all
 * vanished under a beige block (operator finding, 2026-08-09). Holding the
 * allowance HERE, once, keeps the tray exactly where it was and lets the
 * subflow footer be only as tall as its buttons.
 *
 * `box-sizing: border-box` is global, so this padding eats into the 72dvh
 * cap rather than adding to it — `.panel__scroll` simply gets less room.
 */
.panel {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 40;
  max-height: 72dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--paper-raised);
  border-top: 1px solid var(--line-strong);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  box-shadow: 0 -8px 28px rgba(27, 46, 35, 0.18);
}

@media (min-width: 820px) {
  .panel {
    top: var(--shell-header);
    left: auto;
    right: 0;
    bottom: 0;
    width: 24rem;
    max-height: none;
    border-radius: 0;
    border-top: none;
    border-left: 1px solid var(--line-strong);
  }
}

/*
 * THE PANEL STOPS ABOVE THE NAV. NEVER OVER IT.
 *
 * It used to sit at `bottom: 0` with z-index 40 against the nav's 30, and
 * pay for that with `padding-bottom: 88px` — its own paper extended BEHIND
 * the tab bar and the padding pushed the tray back into view. That is a
 * disguise, not a layout: the nav was covered the whole time, and on a plot
 * with no actions (an Available or Rehan plot) the padding was simply a
 * blank block where Home / Map / Visits / Finance / Menu should have been
 * (operator, 2026-08-12: "we can never lose the nav").
 *
 * So the panel's floor is now the nav's ceiling, the padding is gone, and
 * 72dvh is 72dvh of real content instead of 72dvh minus 88px. Anything
 * taller scrolls inside `.panel__scroll` — which is what keeps the nav
 * visible when Historical Data expands.
 *
 * Bounded at 899px, exactly where `.shell__tabs` is fixed-bottom; at 900px
 * and up the nav is a top strip and `bottom: 0` is correct again. This block
 * follows the 820px rail rule deliberately, so in the 820–899px band — a
 * right-hand rail WITH a bottom nav — the later rule wins and the rail stops
 * above the nav too.
 */
@media (max-width: 899px) {
  .panel {
    bottom: calc(var(--shell-tabs) + env(safe-area-inset-bottom));
  }
}

.panel__head {
  flex: none;
  position: relative;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 1.15rem 1rem 0.6rem;
  border-bottom: 1px solid var(--line);
  background: var(--paper-raised);
  /* The drag surface. Without `touch-action: none` the browser claims the
     vertical gesture as a scroll before the handler ever sees it. */
  touch-action: none;
  cursor: -webkit-grab;
  cursor: grab;
}

.panel__head:active {
  cursor: -webkit-grabbing;
  cursor: grabbing;
}

/* Only the phone-width sheet is draggable; the wide-screen rail is a docked
   column, and dragging a docked column down means nothing. */
@media (min-width: 820px) {
  .panel__head {
    touch-action: auto;
    cursor: default;
  }

  .panel__grip {
    display: none;
  }
}

/* The same close treatment as the visit sheet — one app, one way to leave a
   temporary surface: small, round, outlined in the warning colour so it
   reads as "leave" rather than as another control. */
.panel__close {
  flex: none;
  width: 36px;
  height: 36px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  border: 2px solid var(--danger);
  background: var(--paper-raised);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
  color: var(--danger);
}

.panel__close:hover {
  background: var(--danger-soft);
}

/* The grab bar — the swipe-to-dismiss gesture's only advertisement, same
   as the sheet's. */
.panel__grip {
  position: absolute;
  top: 0.4rem;
  left: 50%;
  transform: translateX(-50%);
  width: 2.4rem;
  height: 4px;
  border-radius: 999px;
  background: var(--line-strong);
}

/* THE one small pencil treatment (operator review batch 4, item 9 — "the
   edit pencil is huge; make it small and quiet"): a 30px round target
   (deliberately under the 44px icon floor — this is a secondary affordance
   beside a title, not a primary control), quiet muted ink, borderless
   until engaged. EVERY pencil renders through this class so the treatment
   cannot drift per surface (the profile photo badge stays a badge — same
   visual family, its own geometry). `aria-pressed` (not a class) carries
   the open state. */
.panel__edit-icon {
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 999px;
  border: 1px solid transparent;
  background: transparent;
  font-size: 0.85rem;
  line-height: 1;
  cursor: pointer;
  color: var(--ink-faint);
}

.panel__edit-icon:hover,
.panel__edit-icon:focus-visible,
.panel__edit-icon[aria-pressed="true"] {
  color: var(--forest);
  border-color: var(--line-strong);
  background: var(--paper);
}

/* The scrolling middle — everything between the sticky header and the
   sticky tray: the status pill, the hold countdown, the parties, the naming
   and edit forms, the visit trail, the details expander. */
.panel__scroll {
  flex: 1 1;
  min-height: 0;
  overflow-y: auto;
  padding: 0.75rem 1rem 1rem;
}

/* The sticky action tray — Record a visit (52px filled primary), Hold (48px
   outlined), Sold (48px quiet/low-frequency), Release/Revert (48px
   secondaries), Edit (a quiet text action). `flex: none` plus living outside
   `.panel__scroll` is what pins it in place.
   w10 A2's 88px phone-chrome clearance now lives on `.panel` itself (see
   the doc there) rather than being repeated as this element's own bottom
   padding — same result for the tray, and it stops the subflow footer from
   inheriting a padding that only works outside the scroller. w10 A2 also
   hides this tray's BASE stack (Hold/Sold/Release/Revert) whenever any
   subflow owns the screen — see PlotPanel's `anyFlowOpen` — so only one set
   of actions is ever on screen. */
/*
 * AN EMPTY TRAY DRAWS NOTHING.
 *
 * Every button in the tray is status-gated, so a plot can reach a state
 * where none of them apply — a Rehan plot whose off-market word came from
 * the imported workbook is the live example. The wrapper still rendered:
 * 1px of border plus 1.5rem of padding, i.e. a 25px grey strip under the
 * panel with nothing in it (operator, 2026-08-12: "I still see this weird
 * space"). Measured on /app/map: tray height 25px, zero children.
 *
 * `:empty` is the fix rather than a second copy of the gate conditions in a
 * `hasAnyAction` boolean — a duplicated gate is a gate that drifts the first
 * time someone adds a button. This one cannot: it asks the rendered DOM.
 */
.panel__tray:empty {
  display: none;
}

.panel__tray {
  flex: none;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
  border-top: 1px solid var(--line-strong);
  background: var(--paper-raised);
}

.btn--tray-primary {
  width: 100%;
  min-height: 52px;
  font-size: 1rem;
}

.btn--tray-secondary {
  width: 100%;
  min-height: 48px;
  font-size: 0.95rem;
}

/* w10 A5 — Hold is the OUTLINED secondary: same footprint as
   `.btn--tray-secondary`, but transparent with a forest border/text so it
   reads as one step down from Record a visit's filled primary. */
.btn--tray-outline {
  width: 100%;
  min-height: 48px;
  font-size: 0.95rem;
  background: transparent;
  color: var(--forest);
  border: 1px solid var(--forest);
}

/* w10 A5 — Sold is the QUIET, low-frequency action: a muted outline rather
   than the forest-green treatment Hold gets, so the tray's visual order
   reads Record a visit > Hold > Sold even though all three are full-width
   48-52px controls. Recording a sale is rarer than placing a hold, and the
   hierarchy now says so. */
.btn--tray-quiet {
  width: 100%;
  min-height: 48px;
  font-size: 0.95rem;
  font-weight: 600;
  background: transparent;
  color: var(--ink-muted);
  border: 1px solid var(--line-strong);
}

/* Edit is deliberately the quietest control in the tray — a text action, not
   a button competing with Record a visit / Hold / Sold for the eye. */
.btn--tray-text {
  width: auto;
  align-self: center;
  min-height: 44px;
  min-width: unset;
  padding: 0.3rem 0.6rem;
  background: transparent;
  border: none;
  color: var(--forest);
  font-weight: 600;
  font-size: 0.88rem;
}

/*
 * w10 A2 — the subflow footer. While a subflow (naming/edit/record-visit) is
 * open, `.panel__tray`'s BASE stack is hidden and the flow's own Confirm/
 * Cancel row becomes the one sticky footer on screen instead — sticky
 * within `.panel__scroll` (the same "sticks to whichever surface scrolls"
 * pattern `.reg-form__submit` already uses), so it never scrolls out of
 * reach no matter how long the form above it runs.
 *
 * THE 88px BOTTOM PADDING IS FOR THE PAGE-LEVEL CASE ONLY. On the Visits
 * tab this footer is sticky within the DOCUMENT scroll, so `bottom: 0` pins
 * it to the viewport's own bottom edge and it needs its own clearance from
 * a phone browser's bottom chrome — page padding cannot move a pinned
 * sticky element. See the `.panel__scroll` override immediately below for
 * why the same padding is wrong inside the plot panel.
 */
.subflow-footer {
  position: -webkit-sticky;
  position: sticky;
  bottom: 0;
  z-index: 2;
  margin-top: 0.6rem;
  padding: 0.75rem 0 calc(88px + env(safe-area-inset-bottom));
  background: var(--paper-sunk);
  border-top: 1px solid var(--line);
}

/*
 * INSIDE THE PLOT PANEL THE ALLOWANCE IS ALREADY PAID — by `.panel` itself
 * (see its doc). Repeating it here made this footer ~197px tall inside a
 * ~536px scrollport, and because a sticky-bottom element is pinned OVER the
 * content that is still in normal flow behind it, that opaque beige box
 * swallowed the booking form whole: heading, client, deposit slider and
 * proof step all hidden (operator finding, 2026-08-09). With the padding
 * dropped the footer is only as tall as its own buttons.
 *
 * `bottom: -1rem` CANCELS `.panel__scroll`'s own 1rem bottom padding. A
 * sticky element parks against the scrollport's padding edge, which left a
 * 16px strip below the bar where the form kept showing through — a line of
 * hint text peeking out under the Cancel button, which reads as breakage
 * rather than as a bar with content behind it. Pulling the bar down by
 * exactly that padding makes it flush with the scroller's visible bottom;
 * `margin-bottom` takes the same 1rem back so the bar adds no height once
 * it comes to rest at the end of the form.
 */
.panel__scroll .subflow-footer {
  padding-bottom: 0.75rem;
  bottom: -1rem;
  margin-bottom: -1rem;
}

.actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.75rem;
}

/* One expander for the facts — actions first, details on demand. */
.disclosure {
  margin-top: 1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--paper);
}

.disclosure > summary {
  list-style: none;
  cursor: pointer;
  min-height: 48px;
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.7rem 0.9rem;
  font-weight: 600;
  color: var(--forest-deep);
}

.disclosure > summary::-webkit-details-marker {
  display: none;
}

.disclosure > summary::after {
  content: "▾";
  margin-left: auto;
  color: var(--ink-muted);
  transition: transform 120ms ease;
}

.disclosure[open] > summary::after {
  transform: rotate(180deg);
}

.disclosure > :not(summary) {
  margin-left: 0.9rem;
  margin-right: 0.9rem;
}

.disclosure > :last-child:not(summary) {
  padding-bottom: 0.9rem;
}

/*
 * A DISCLOSURE INSIDE A DISCLOSURE is a box inside a box, and a box needs
 * room BELOW it, not padding within it. The `:last-child` rule above puts
 * 0.9rem inside the nested box, so its own border landed flush on the
 * parent's border with a dead strip above it — "Historical Data … is flush
 * with the bottom, looks bad" (operator, 2026-08-12). Swap the padding for a
 * margin and the nested card sits INSIDE its parent, evenly, on every side.
 */
.disclosure > .disclosure:last-child {
  padding-bottom: 0;
  margin-bottom: 0.9rem;
}

.disclosure .disclosure {
  background: var(--paper-raised);
}

/*
 * THE UPLOAD AREA (v3 polish) — a styled 52px drop-zone-looking control
 * standing in for the bare native file input. The REAL `<input type="file">`
 * is still there, stretched over the whole area at opacity 0
 * (`.upload-area__input`), so every native behaviour — keyboard focus, the
 * OS file picker, drag-and-drop — still works exactly as it always did; only
 * the paint changed.
 */
.upload-area {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 52px;
  padding: 0 0.9rem;
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius-md);
  background: var(--paper-raised);
  overflow: hidden;
}

.upload-area:focus-within {
  outline: 2px solid var(--forest);
  outline-offset: 2px;
}

.upload-area__label {
  font-size: 0.92rem;
  color: var(--ink-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.upload-area__input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  cursor: pointer;
}

/* One office detail: field name, value, and a QUIET remove. The rows are the
   content; the remove button stays chromeless until asked for, so a sheet of
   ten details reads as a table, not a wall of buttons. */
.detail-row {
  display: grid;
  grid-template-columns: minmax(0, 2fr) minmax(0, 3fr) 44px;
  grid-gap: 0.5rem;
  gap: 0.5rem;
  align-items: center;
  margin-bottom: 0.5rem;
}

.detail-row input {
  min-width: 0;
}

.detail-row__remove {
  min-height: 44px;
  border-radius: var(--radius-md);
  border: none;
  background: transparent;
  color: var(--ink-faint);
  font-size: 1rem;
  cursor: pointer;
}

.detail-row__remove:hover,
.detail-row__remove:focus-visible {
  color: var(--danger);
  background: var(--danger-soft);
}

/* ------------------------------------------------------------ people --- */

.person {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.55rem 0;
}

.avatar {
  flex: none;
  width: 38px;
  height: 38px;
  border-radius: 999px;
  background: var(--forest-soft);
  color: var(--forest-deep);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.82rem;
  font-weight: 700;
  border: 1px solid rgba(46, 74, 58, 0.25);
}

/* Block, so a name and the phone beneath it are two lines rather than one
   run-together string ("Priya Verma+919876500123") when the row is rendered
   somewhere without its own column layout. It WRAPS rather than truncates —
   a name running onto a second line is never worse than a manually-typed
   ".." (the copy bug Sol flagged), and this codebase does not truncate names
   anywhere else either (see tests/web-surface.test.mjs sweeps). */
.person__name {
  display: block;
  font-weight: 600;
  font-size: 0.95rem;
}

.person__meta {
  display: block;
  font-size: 0.8rem;
  color: var(--ink-muted);
}

/* w10 B4 — the admin-only "Introduced by / Managed by" provenance line on a
   client card: one notch quieter and smaller than the ordinary meta line
   (phone · counts) so it reads as a secondary fact, not a second identity
   line competing with the name. */
.person__meta--provenance {
  font-size: 0.75rem;
  opacity: 0.85;
}

/* The chain is drawn with a rule down the left so depth is visible at a
   glance rather than inferred from indentation alone. Exactly 24px PER
   LEVEL (v3 polish) — a nested `.branch` inside another compounds to 48px
   at the second level, matching the operator's spec. */
.branch {
  margin-left: 0;
  padding-left: 24px;
  /* Green, not paper-grey: the rail is the visible half of "who reports to
     whom", and against a cream card the old --line rule read as a rendering
     artifact rather than a chain. */
  border-left: 2px solid rgba(46, 74, 58, 0.4);
}

/* The chevron + child-count control on an expandable row — sits before the
   avatar, so "this row has children, N of them, tap to fold" reads before
   the person's own name does. */
.branch-toggle {
  /*
   * A PILL, not a grey tick. As a muted chevron plus a small grey badge it
   * disappeared into the row's own meta line — the operator's "the little 1
   * gets hidden" — which meant the app's whole reporting chain was invisible
   * on the tab that exists to show it. Now it is an outlined green pill
   * carrying a two-person mark, the count, and the chevron: "this person has
   * N under them, open it".
   */
  flex: none;
  display: flex;
  align-items: center;
  gap: 0.25rem;
  min-height: 34px;
  padding: 0 0.5rem;
  border: 2px solid var(--forest);
  border-radius: 999px;
  background: var(--forest-soft);
  color: var(--forest-deep);
  cursor: pointer;
}

.branch-toggle:hover,
.branch-toggle:focus-visible {
  background: var(--forest);
  color: var(--paper-raised);
}

/* Currentcolor, so the mark inverts with the pill on hover. */
.branch-toggle__people {
  flex: none;
  width: 1rem;
  height: 1rem;
  display: block;
  fill: none;
  stroke: currentcolor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.branch-toggle__chevron {
  font-size: 0.8rem;
  line-height: 1;
}

.branch-toggle__count {
  font-size: 0.85rem;
  font-weight: 700;
  line-height: 1;
  text-align: center;
}

/* ------------------------------------------------ people/clients (w8-28) -- */

/* ONE fixed row grid every person — avatar | name+chip+you | meta | trailing
   chevron. `.person` already gives the flex row + left alignment (there is
   no centered variant anywhere in this stylesheet); this modifier only
   pushes a trailing control (the branch chevron) to the row's END instead
   of the old leading position. */
.people-row {
  justify-content: flex-start;
  text-align: left;
}

.people-row .branch-toggle {
  margin-left: auto;
}

/* The person-row name link — v3 item 7's underline-leak convention (see
   tests/no-underline-leak.test.mjs): every control-role class on a real
   <Link> resets its own text-decoration rather than relying on a global
   anchor rule the base `a` selector deliberately does not carry. */
.people-row__link {
  text-decoration: none;
  color: inherit;
}

.people-section {
  padding: 0;
}

/*
 * SYMMETRIC padding, top and bottom.
 *
 * It used to be `1rem 1rem 0`, borrowing its bottom breathing room from the
 * body below it. Collapse the section and that body stops existing, so the
 * [+ Add] pill sat flush on the card's floor (operator, 2026-08-12: "check
 * how the add is touching the bottom"). A header must not depend on a
 * sibling that a toggle can remove; the body's own top pad is trimmed to
 * match so the expanded spacing is unchanged.
 */
.people-section__head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
}

.people-section__toggle {
  flex: 1 1;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 44px;
  margin: 0 -0.3rem;
  padding: 0 0.3rem;
  border: none;
  background: none;
  font: inherit;
  text-align: left;
  color: var(--ink);
  cursor: pointer;
}

.people-section__chevron {
  flex: none;
  font-size: 0.85rem;
  color: var(--ink-muted);
}

.people-section__title {
  font-size: 1rem;
  font-weight: 600;
}

/* The quiet [+ Add] pill — a real button (not `.pill`, which is a small
   inline LABEL with no tap-target floor) so it clears the 44px minimum
   every control in this app answers to. */
.people-section__add {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
  min-height: 44px;
  padding: 0.35rem 0.85rem;
  border-radius: 999px;
  border: 1px solid var(--line-strong);
  background: var(--paper-raised);
  color: var(--forest);
  font-size: 0.85rem;
  font-weight: 600;
  cursor: pointer;
}

.people-section__add:hover,
.people-section__add:focus-visible {
  background: var(--forest-soft);
}

.people-section__body {
  padding: 0 1rem 1rem;
}

/*
 * The profile page's "where am I" trail — People / Ancestor / this person
 * (v3 item 6: quiet, single line). Smaller than body text, muted, and
 * NEVER WRAPS — `flex-wrap: nowrap` plus `overflow: hidden` on the row, with
 * every middle crumb (`.breadcrumb__crumb`) individually clipped to an
 * ellipsis so a long ancestor name shortens instead of pushing the row
 * (or the final "you are here" name) off a narrow phone screen.
 */
.breadcrumb {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: 0.2rem;
  overflow: hidden;
  font-size: 0.78rem;
  color: var(--ink-muted);
  white-space: nowrap;
}

/* A middle crumb (an ancestor, not the root and not the current person) —
   the one kind allowed to shrink and ellipsis, per the spec. `min-width: 0`
   is what lets a flex child shrink below its own text's intrinsic width at
   all (see NavTabs.tsx's `.tab` for the same fix, same reason). */
.breadcrumb__crumb {
  display: flex;
  align-items: center;
  gap: 0.2rem;
  min-width: 0;
  max-width: 7rem;
  overflow: hidden;
}

.breadcrumb__link {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: inherit;
  text-decoration: none;
}

.breadcrumb__link:hover,
.breadcrumb__link:focus-visible {
  color: var(--forest);
}

/* ONE separator style throughout the trail — no mixed "·"/"/"/">" glyphs. */
.breadcrumb__sep {
  flex: none;
  opacity: 0.6;
}

/* The last crumb — "you are here" — gets first claim on whatever room is
   left (the ancestors above it are what give ground), but still clips
   rather than wrapping if the name is genuinely long. */
.breadcrumb__current {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--ink);
  font-weight: 600;
}

/* The role chip — one canonical label per row, visually distinct per kind so
   Associate / Partner-Associate / Client read apart at a glance. */
.role-chip {
  display: inline-block;
  margin: 0.1rem 0 0.2rem;
  padding: 0.05rem 0.5rem;
  border-radius: 999px;
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.02em;
}

.role-chip--office {
  background: var(--brass-soft);
  color: #6e541f;
}

.role-chip--associate {
  background: var(--forest-soft);
  color: var(--forest-deep);
}

.role-chip--partner-associate {
  background: rgba(46, 74, 58, 0.14);
  color: var(--forest-deep);
}

.role-chip--client {
  background: var(--paper-sunk);
  color: var(--ink-muted);
  border: 1px solid var(--line-strong);
}

/* The Finance Officer (role `staff`, spec §11.1) — its OWN chip paint
   (operator review batch 3: staff used to fall through to the Client chip).
   Brass-outlined on paper: office-adjacent authority, distinct from the
   solid-brass Office chip and from every selling-green variant. */
.role-chip--finance-officer {
  background: var(--paper-raised);
  color: #6e541f;
  border: 1px solid var(--brass);
}

.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
}

table {
  border-collapse: collapse;
  width: 100%;
  font-size: 0.88rem;
}

th,
td {
  text-align: left;
  padding: 0.5rem 0.65rem;
  border-bottom: 1px solid var(--line);
  white-space: nowrap;
}

th {
  background: var(--paper-sunk);
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.muted {
  color: var(--ink-muted);
}

.stack > * + * {
  margin-top: 0.75rem;
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

/* ---------------------------------------------------------------------------
   People, parties and the hold countdown
   ---------------------------------------------------------------------------
   Added with the status/holds/avatars work. The rules below are laid out for a
   PHONE first — this app's readers are standing at a gate office, not sitting
   at a desk — so everything wraps rather than truncates, and every tap target
   clears the 44px minimum.
   ------------------------------------------------------------------------- */

.person {
  flex-wrap: wrap;
}

/* A real photograph, cropped square and circular like the initials it
   replaces, so a directory of mixed rows does not look ragged.

   `display: block` (batch 5 item 5): `.avatar` sets `display: flex` for
   centring its INITIALS text, but an <img> is a replaced element — leaving
   the flex display on it invites engine-specific paint quirks on top of the
   pale placeholder background (the owner's seeded avatar rendered as an
   empty pale circle on the operator's phone). A photograph needs no inner
   layout at all, so it opts back to a plain block box; `object-fit: cover`
   alone crops it. (The other half of that fix is the seed writing an 8x8
   PNG — see prisma/seed.mjs: WebKit can rasterize a 1x1 upscaled ~40x as a
   washed-out smear.) */
.avatar--photo {
  display: block;
  object-fit: cover;
  background: var(--paper-sunk);
}

.person__meta--error {
  color: var(--danger);
  display: block;
}

.person__tools {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.3rem;
}

.tag {
  display: inline-block;
  margin-left: 0.4rem;
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
  font-size: 0.65rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  background: var(--paper-sunk);
  color: var(--ink-muted);
  border: 1px solid var(--line);
  vertical-align: middle;
}

.tag--owner {
  background: var(--brass-soft);
  color: #6e541f;
  border-color: rgba(168, 130, 60, 0.5);
}

/* --- the hold ------------------------------------------------------------ */

.hold-box {
  margin-top: 0.8rem;
  padding: 0.7rem 0.8rem;
  border-radius: var(--radius-md);
  background: var(--brass-soft);
  border: 1px solid rgba(168, 130, 60, 0.45);
}

.countdown {
  font-size: 0.95rem;
  color: #5c440f;
}

.countdown--compact {
  font-size: 0.8rem;
}

/* A strip of the signed-in person's own live holds, above the plan. */
.holds-strip {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  padding: 0.5rem 0.75rem 0;
}

.holds-strip__label {
  font-size: 0.75rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.holds-strip__chip {
  font: inherit;
  font-size: 0.8rem;
  padding: 0.3rem 0.6rem;
  border-radius: 999px;
  border: 1px solid rgba(168, 130, 60, 0.5);
  background: var(--brass-soft);
  color: #5c440f;
  cursor: pointer;
}

/* --- who holds or bought a plot ----------------------------------------- */

.parties {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  margin-top: 0.75rem;
}

.party {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
}

.party__label {
  font-size: 0.72rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.party__person {
  display: flex;
  align-items: center;
  gap: 0.55rem;
}

.party__person > span {
  display: flex;
  flex-direction: column;
}

/* "There is someone here and you may not see who" — deliberately visible
   rather than an empty space that would read as "nobody". */
.party__hidden {
  font-size: 0.88rem;
  color: var(--ink-muted);
  font-style: italic;
}

/* --- the sell / add-client / plot-settings sub-panel --------------------- */

.sell-box {
  margin-top: 0.8rem;
  padding: 0.75rem;
  border-radius: var(--radius-md);
  background: var(--paper-sunk);
  border: 1px solid var(--line);
}

.sell-box .field:first-of-type {
  margin-top: 0;
}

/*
 * THE REGISTRATION FORM LAYOUT (v3 polish) — a modifier on `.sell-box`, not
 * a change to the base rule. `.sell-box` is also the sell/naming flow and
 * the office-details editor, which stay single-column short forms wherever
 * they render (a narrow 24rem map panel never crosses the 640px breakpoint
 * anyway); `.reg-form` is opted into only by AddClientForm and
 * AddAssociateForm, the two actual "fill in a person" registration forms.
 */
.reg-form {
  max-width: 720px;
}

.reg-form__grid {
  display: grid;
  grid-template-columns: 1fr;
  grid-gap: 16px;
  gap: 16px;
}

@media (min-width: 640px) {
  .reg-form__grid {
    grid-template-columns: 1fr 1fr;
    gap: 18px 20px;
  }
  .reg-form__grid .field {
    margin-bottom: 0;
  }
  .reg-form__grid .field--full {
    grid-column: 1 / -1;
  }
}

/* The bilingual submit bar. Sticky within whichever scrolling surface hosts
   the form — the map panel's own `.panel__scroll`, or (lacking one) the
   page/viewport itself — so Register/Cancel never scroll out of view. */
.reg-form__submit {
  position: -webkit-sticky;
  position: sticky;
  bottom: 0;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 1rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--line);
  background: var(--paper-sunk);
}

/* Register AND Cancel both clear 52px (v3 polish — the bilingual pattern
   spec's control floor), including Cancel, which the shared `.btn--inline`
   rule alone would leave at 40px. */
.reg-form__submit .btn {
  min-height: 52px;
}

.field__hint--error {
  color: var(--danger);
}

/* Batch 5 item 3 — the finance form's inline shortfall note ("less than the
   full price"). CAUTION, not error: the entry is legal, it just cannot close
   the step — so the brass caution ink, not danger red. */
.field__hint--caution {
  color: #6e541f;
  font-weight: 600;
}

/* (textarea styling lives with the shared field controls in the forms block —
   one box for every field, notes included.) */

/* ---------------------------------------------------------------------------
   The App Menu, LocaleGate, empty states and the ledger
   ---------------------------------------------------------------------------
   Added with the role-scoped navigation work; reworked for item 20 (one menu
   sheet, two entry points) and item 21 (language as a setting). Same
   phone-first posture as the rest of the sheet: every control clears 44px,
   and nothing here introduces a new colour — paper, ink, forest, brass,
   danger only.
   ------------------------------------------------------------------------- */

.settings {
  position: relative;
}

/* The person's own face is one of the App Menu's two triggers — the other is
   the `Menu` nav tab (NavTabs' `.tab--menu`). The most recognisable icon
   there is. */
.settings__trigger {
  min-width: 44px;
  min-height: 44px;
  padding: 2px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: var(--paper-raised);
  cursor: pointer;
}

/* A faint veil so "tap anywhere to close" is discoverable by accident. */
.settings__backdrop {
  position: fixed;
  inset: 0;
  z-index: 55;
  border: none;
  padding: 0;
  background: rgba(27, 46, 35, 0.18);
  cursor: default;
}

/*
 * The menu sheet itself (AppMenu). FULL-SCREEN by default (a phone, <900px —
 * item 20) so it reads as its own screen rather than a sliver over the app;
 * becomes an ANCHORED DROPDOWN at the same 900px breakpoint the nav bar
 * promotes at (globals.css `.shell__tabs`), since a wide window has room for
 * one. `position: fixed` throughout — it does not care which of the two
 * triggers (the avatar or the `Menu` tab) opened it, because both live in
 * different branches of the shell and only ONE sheet ever exists (see
 * MenuProvider).
 */
.menu-sheet {
  position: fixed;
  inset: 0;
  z-index: 60;
  overflow-y: auto;
  /* Batch 5 item 6 — the sheet's own scroll never chains to the page
     behind it, so the covered content cannot be seen moving under the
     sheet's edges mid-gesture. */
  overscroll-behavior: contain;
  background: var(--paper-raised);
  padding: 1rem;
  padding-bottom: calc(1rem + env(safe-area-inset-bottom));
  /* Batch 5 item 6 — a clean OPAQUE entrance: the old settings-pop faded
     the whole sheet up from opacity 0, so for its first frames the page
     text showed THROUGH the menu's own text (the operator's "translucent
     mid-fade mess" screenshot). The slide animates transform ONLY — the
     sheet is fully opaque on every frame, so nothing ever interleaves —
     and stays quick (200ms ≤ the 250ms budget). Reduced motion: instant
     (the existing override below). */
  animation: menu-sheet-in 200ms ease-out;
}

@keyframes menu-sheet-in {
  from {
    transform: translateY(3%);
  }
  to {
    transform: none;
  }
}

.menu-sheet__head {
  display: flex;
  justify-content: flex-end;
}

.menu-sheet__close {
  min-width: 44px;
  min-height: 44px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: var(--paper);
  font-size: 1.1rem;
  cursor: pointer;
}

/* The destinations `nav.ts` moved OUT of the bar (People/Import/Townships/
   Requests for an admin, Visits for an associate) — big, obvious rows, the
   same touch floor as everything else in this sheet. */
.menu-sheet__destinations {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}

.menu-sheet__link {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  min-height: 48px;
  padding: 0.4rem 0.5rem;
  border-radius: var(--radius-md);
  text-decoration: none;
  color: var(--ink);
  font-weight: 600;
}

.menu-sheet__link:hover,
.menu-sheet__link:focus-visible {
  background: var(--forest-soft);
}

/*
 * THE LANGUAGE TOGGLE (w8-27 item 27.4) — a compact on-theme SLIDER on the
 * Settings page, replacing the two big buttons that used to live in the App
 * Menu itself. Both names always render (see LocaleToggle.tsx) — the ONE
 * control that always shows both languages, so a reader can recognise the
 * other one to switch into it.
 */
.locale-toggle {
  position: relative;
  display: flex;
  align-items: stretch;
  width: 8.5rem;
  min-height: 44px;
  padding: 3px;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: var(--paper-sunk);
  cursor: pointer;
}

.locale-toggle:disabled {
  opacity: 0.6;
  cursor: default;
}

.locale-toggle__option {
  position: relative;
  z-index: 1;
  flex: 1 1;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.82rem;
  font-weight: 700;
  color: var(--ink-muted);
  transition: color 160ms ease;
}

.locale-toggle__option[data-active="true"] {
  color: var(--paper);
}

.locale-toggle__thumb {
  position: absolute;
  top: 3px;
  bottom: 3px;
  left: 3px;
  width: calc(50% - 4.5px);
  border-radius: 999px;
  background: var(--forest);
  transition: transform 160ms ease;
}

.locale-toggle__thumb[data-pos="right"] {
  transform: translateX(100%);
}

@media (prefers-reduced-motion: reduce) {
  .locale-toggle__option,
  .locale-toggle__thumb {
    transition: none;
  }
}

/* settings-pop (opacity fade) retired with batch 5 item 6 — the menu sheet
   was its only consumer and an opacity entrance is exactly what let page
   text show through the sheet mid-fade. */

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

@media (min-width: 900px) {
  /* A wide window has room to spare — the sheet becomes an anchored panel
     under the header instead of covering the whole viewport. */
  .menu-sheet {
    inset: auto;
    top: calc(var(--shell-header) + 8px);
    right: 0.9rem;
    width: min(22rem, calc(100vw - 1.5rem));
    max-height: calc(100vh - var(--shell-header) - 16px);
    border: 1px solid var(--line-strong);
    border-radius: var(--radius-lg);
    box-shadow:
      0 18px 44px rgba(27, 46, 35, 0.26),
      0 3px 10px rgba(27, 46, 35, 0.14);
  }

  .menu-sheet__head {
    display: none;
  }
}

.settings__section + .settings__section {
  border-top: 1px solid var(--line);
  margin-top: 0.85rem;
  padding-top: 0.85rem;
}

/*
 * w9-item-3 — "Request data deletion" is a real BUTTON now (danger-outline:
 * quiet until pressed, but unmistakably a control), replacing the bare red
 * text link `.settings__danger` used to be. Same shape as `.btn--danger`
 * (filled) — this is its outline counterpart, for a destructive-ish action
 * that is not yet the confirmed, filled-red step (that is what the confirm
 * card's own `.btn--danger` is for).
 */
.btn--danger-outline {
  background: transparent;
  color: var(--danger);
  border-color: var(--danger);
}

.btn--danger-outline:hover,
.btn--danger-outline:focus-visible {
  background: var(--danger-soft);
}

/*
 * LocaleGate — the first-login language chooser (item 21). A calm, centred
 * card over the paper background, same radius/shadow language as the menu
 * sheet; DELIBERATELY shows both languages (see the component) since there
 * is no chosen locale yet for it to render in.
 */
.locale-gate {
  position: fixed;
  inset: 0;
  z-index: 80;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.25rem;
  background: var(--forest-deep);
}

.locale-gate__card {
  width: min(24rem, 100%);
  background: var(--paper-raised);
  border-radius: var(--radius-lg);
  padding: 1.75rem 1.5rem;
  text-align: center;
  box-shadow: 0 18px 44px rgba(27, 46, 35, 0.26);
}

.locale-gate__title {
  font-size: 1.15rem;
  line-height: 1.4;
  margin: 0 0 0.6rem;
}

.locale-gate__hint {
  color: var(--ink-muted);
  font-size: 0.88rem;
  line-height: 1.45;
  margin: 0 0 1.25rem;
}

.locale-gate__options {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.locale-gate__option {
  min-height: 56px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-md);
  background: var(--paper);
  font: inherit;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--ink);
  cursor: pointer;
}

.locale-gate__option:hover,
.locale-gate__option:focus-visible {
  background: var(--forest-soft);
  border-color: var(--forest);
}

/* One-line guidance where a list has nothing yet — always EN + HI. */
.empty {
  text-align: center;
  padding: 1.5rem 1rem;
  color: var(--ink-muted);
  font-size: 0.92rem;
}

/*
 * THE LEDGER AS A STATEMENT (v3 polish). `.ledger-page` centers the whole
 * surface at a bank-statement width; `.ledger-row` is a STABLE three-column
 * grid — label / value / status — so the price and the status pill land in
 * the same column on every row instead of floating wherever that row's own
 * text happens to end.
 */
.ledger-page {
  max-width: 760px;
  margin: 0 auto;
}

.ledger-row {
  display: grid;
  grid-template-columns: 140px 1fr auto;
  align-items: center;
  grid-gap: 0.75rem;
  gap: 0.75rem;
  min-height: 120px;
  padding: 0.6rem 0;
  border-bottom: 1px solid var(--line);
}

@media (max-width: 480px) {
  /* A phone this narrow cannot hold three real columns without crushing the
     value column — the label folds above the value/status pair instead of
     disappearing. */
  .ledger-row {
    grid-template-columns: 1fr auto;
    min-height: unset;
  }
  .ledger-row__label {
    grid-column: 1 / -1;
  }
}

.ledger-row__label {
  font-weight: 600;
  font-size: 0.9rem;
}

.ledger-row__status {
  text-align: right;
  justify-self: end;
}

.ledger-row:last-child {
  border-bottom: none;
}

/* --- request / visit states ---------------------------------------------- */

.pill--open,
.pill--scheduled {
  background: var(--brass-soft);
  color: #6e541f;
}

.pill--completed,
.pill--actioned {
  background: var(--forest-soft);
  color: var(--forest-deep);
}

.pill--cancelled,
.pill--dismissed {
  background: var(--paper-sunk);
  color: var(--ink-muted);
}

/* Photo still owed on a deferred visit — quiet, but unmissable. */
.pill--photo-pending {
  background: var(--brass-soft);
  color: #6e541f;
}

/* --- the at-visit photograph --------------------------------------------- */

/* The proof photo in a history row: small, square-cropped, softly framed. */
.visit-photo {
  display: block;
  width: 72px;
  height: 72px;
  object-fit: cover;
  border-radius: 8px;
  background: var(--paper-sunk);
  border: 1px solid var(--line);
}

/* The capture/confirm preview — big enough to judge the picture by. */
.visit-photo--preview,
.visit-photo--live {
  width: 100%;
  max-width: 320px;
  height: auto;
  aspect-ratio: 4 / 3;
}

/* --- the map ------------------------------------------------------------- */

/* A plot in the signed-in person's own book. A MARKER, not a permission: every
   polygon is selectable, because under the current status rules any staff
   member may act on any available plot. */
.map__poly--mine {
  stroke-width: 2.5;
  stroke-dasharray: 4 2;
}

.legend__swatch--mine {
  background: transparent;
  border: 2px dashed var(--forest);
}

/* The remaining time on a held plot, drawn under its number. */
.map__label--hold {
  font-size: 7px;
  fill: #5c440f;
  font-weight: 600;
}

/* ---------------------------------------------------------------------------
   The welcome entrance + guided tour (item 19; entrance rebuilt w7-item-4)
   ---------------------------------------------------------------------------
   Both in-house, no animation library, same paper/ink/forest/brass palette.
   Sit above everything else the shell draws (the settings menu tops out at
   z-index 60) but the welcome gate always wins over the tour, since the
   provider never shows both at once anyway.

   THE ENTRANCE (w7-item-4) is a full-screen photograph of the estate's own
   gate that pushes in toward the gateway, then dissolves into the home
   screen underneath — "walking in", replacing the earlier SVG gates that
   swung open. Two independent animations, on two separate layers, so they
   never fight over one CSS property:
     - `.welcome-gate__photo` SCALES UP (the push-in) — `transform`.
     - `.welcome-gate` itself FADES OUT (the dissolve) — `opacity`, timed to
       start only once the push-in has finished (`animation-delay`).
   `.welcome-gate--reduced` swaps both for one brief, honest fade with no
   photo movement at all — reduced motion no longer skips the moment
   outright, it shortens it.
   ------------------------------------------------------------------------- */

.welcome-gate {
  all: unset;
  position: fixed;
  inset: 0;
  z-index: 500;
  display: block;
  overflow: hidden;
  cursor: pointer;
  box-sizing: border-box;
  background: var(--forest-deep);
}

.welcome-gate__photo {
  position: absolute;
  inset: -8%; /* headroom for the scale — the push-in must never expose an edge */
  background-image: url("/media/manor-gate-portal.jpg");
  background-size: cover;
  /* Biased toward the gateway's opening rather than dead-center — tune
     against the final traced photograph once art directs it. */
  background-position: 50% 42%;
  transform-origin: 50% 42%;
  will-change: transform;
}

.welcome-gate__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(20, 15, 5, 0.65), rgba(20, 15, 5, 0) 48%);
}

.welcome-gate__content {
  position: absolute;
  left: 0;
  right: 0;
  bottom: max(2.25rem, env(safe-area-inset-bottom));
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.4rem;
  padding: 0 1.5rem;
}

.welcome-gate__caption {
  color: var(--paper-raised);
  font-size: 1.05rem;
  font-weight: 600;
  text-align: center;
}

/* --- the full experience: dissolve IN, dive, dissolve OUT ------------- */

/*
 * ONE animation for the whole overlay, not a delayed fade-out (operator,
 * 2026-07-29: "it should dissolve that login page and fade in the gates and
 * then quickly fade back out ... right now it just shows the image of the
 * gate and holds"). The previous shape snapped the photo on at full opacity
 * and then parked it for a second after the push-in ended — a still image
 * sitting on screen, which is what read as tacky. Now:
 *   0–220ms    fade up over the page it is replacing (the dissolve IN)
 *   220–1150ms fully present, photo still travelling
 *   1150–1600ms fade back out while the photo is STILL moving
 * The photo's push-in runs the entire 1600ms, so the gate is never
 * motionless on screen. Total = WELCOME_GATE_FULL_MS in WelcomeGate.tsx;
 * the two must always match or the overlay unmounts mid-fade.
 */
.welcome-gate--full {
  animation: welcome-gate-veil 1600ms ease-out both;
}

.welcome-gate--full .welcome-gate__photo {
  /* A shorter throw than the old 1.0→1.3: over 1.6s a 30% zoom reads as a
     lurch. This is a slow drift toward the gateway that never lands, which
     is what "walking in" actually looks like. */
  animation: welcome-gate-push-in 1600ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

@keyframes welcome-gate-veil {
  0% {
    opacity: 0;
  }
  14% {
    opacity: 1;
  }
  72% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}

@keyframes welcome-gate-push-in {
  from {
    transform: scale(1.04);
  }
  to {
    transform: scale(1.18);
  }
}

@keyframes welcome-gate-dissolve {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

/* --- reduced motion: no push-in, a brief honest fade ------------------ */

.welcome-gate--reduced {
  animation: welcome-gate-dissolve 260ms ease-in both;
  animation-delay: 220ms;
}

/* Belt-and-braces: even if `prefersReducedMotion` were somehow wrong, the
   photo itself must never visibly move for a reader who asked for less
   motion — the shortened fade above is the real gate; this is the backstop. */
@media (prefers-reduced-motion: reduce) {
  .welcome-gate__photo {
    animation: none !important;
  }
}

/* --- the guided tour ------------------------------------------------------ */

.tour__mask {
  position: fixed;
  z-index: 400;
  background: rgba(27, 46, 35, 0.55);
  pointer-events: auto;
}

/* The last-step Done fallback (never a dead overlay): no target rect to dim
   around, so the mask covers the whole viewport and the card centers itself
   instead of anchoring off `rect`. */
.tour__mask--full {
  inset: 0;
}

.tour__card--center {
  top: 50%;
  transform: translateY(-50%);
}

.tour__ring {
  position: fixed;
  z-index: 401;
  border-radius: var(--radius-md);
  box-shadow:
    0 0 0 3px var(--brass),
    0 0 26px rgba(168, 130, 60, 0.5);
  pointer-events: auto;
  transition: top 160ms ease, left 160ms ease, width 160ms ease, height 160ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .tour__ring {
    transition: none;
  }
}

/*
 * INTERACTIVE steps (batch 3; widened 2026-08-12 to every LIVE step): the
 * ring over a usable target drops its tap shield — pointer events pass
 * through the cutout to the real control (the four masks still block
 * everywhere else) — and breathes so the "press HERE" invitation reads
 * without any copy. Reduced motion: the pulse stops; the static brass halo
 * alone carries the invitation.
 */
.tour__ring--interactive {
  pointer-events: none;
  animation: tour-ring-pulse 1.6s ease-in-out infinite;
}

@keyframes tour-ring-pulse {
  0%,
  100% {
    box-shadow:
      0 0 0 3px var(--brass),
      0 0 22px rgba(168, 130, 60, 0.45);
  }
  50% {
    box-shadow:
      0 0 0 5px var(--brass),
      0 0 34px rgba(168, 130, 60, 0.7);
  }
}

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

/*
 * THE BEACON — operator review, 2026-08-12: "you're not enticing the user
 * to click … make it flash blink highlight the tab to click or dropdown".
 * The ring alone outlined the target; this WASHES it, blinking between
 * nothing and a brass tint so the eye is pulled to the control itself
 * rather than to a rectangle drawn near it. Never eats the tap it is
 * advertising — it is decoration laid over a live cutout.
 */
.tour__beacon {
  position: fixed;
  z-index: 400;
  border-radius: var(--radius-md);
  pointer-events: none;
  background: rgba(168, 130, 60, 0.34);
  animation: tour-beacon-flash 1.6s ease-in-out infinite;
}

@keyframes tour-beacon-flash {
  0%,
  100% {
    background: rgba(168, 130, 60, 0.08);
  }
  50% {
    background: rgba(168, 130, 60, 0.4);
  }
}

/*
 * THE NUDGE — a small chip that bounces at the target, placed by the pure
 * layout (tour-spotlight.ts) on the opposite side of the ring from the
 * card, and omitted entirely when the target is a whole region rather than
 * a control. It says one word, in the reader's own language.
 */
.tour__nudge {
  position: fixed;
  z-index: 403;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  padding: 0.2rem 0.45rem;
  border-radius: 999px;
  background: var(--brass);
  color: #fffdf8;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.04em;
  line-height: 1.1;
  pointer-events: none;
  box-shadow: 0 4px 12px rgba(27, 46, 35, 0.28);
}

/* The arrow sits on the side facing the target, so the chip reads as one
   object pointing rather than two stacked marks — and the bob moves TOWARD
   the target rather than away from it. */
.tour__nudge--down {
  flex-direction: column;
  animation: tour-nudge-bob-down 1.1s ease-in-out infinite;
}

.tour__nudge--up {
  flex-direction: column-reverse;
  animation: tour-nudge-bob-up 1.1s ease-in-out infinite;
}

.tour__nudge-arrow {
  font-size: 0.6rem;
  line-height: 1;
}

@keyframes tour-nudge-bob-down {
  0%,
  100% {
    transform: translateX(-50%) translateY(0);
  }
  50% {
    transform: translateX(-50%) translateY(4px);
  }
}

@keyframes tour-nudge-bob-up {
  0%,
  100% {
    transform: translateX(-50%) translateY(0);
  }
  50% {
    transform: translateX(-50%) translateY(-4px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .tour__beacon {
    animation: none;
    background: rgba(168, 130, 60, 0.24);
  }

  .tour__nudge,
  .tour__nudge--up,
  .tour__nudge--down {
    animation: none;
  }
}

/*
 * ONE RHYTHM, SET BY THE LAYOUT (operator review, 2026-08-12: "the spacing
 * is off too"). The card used to space itself with a different bottom
 * margin on every child — dots 0.6, title 0.35, body 0.85 — so the gaps
 * never agreed and a step with a short body read differently from a long
 * one. It is a flex column with ONE gap now; children carry no margins of
 * their own, so every card in the tour has the same interior rhythm no
 * matter what is in it.
 */
.tour__card {
  position: fixed;
  z-index: 402;
  left: 1rem;
  right: 1rem;
  max-width: 26rem;
  max-height: calc(100dvh - 24px);
  overflow-y: auto;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  background: var(--paper-raised);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  box-shadow:
    0 18px 44px rgba(27, 46, 35, 0.3),
    0 3px 10px rgba(27, 46, 35, 0.16);
  padding: 0.95rem 1.05rem 0.8rem;
}

/*
 * WHERE AM I / HOW MUCH IS LEFT. This replaces the row of step dots: the
 * admin walk-through is 24 stops, and 24 dots is noise nobody counts. A
 * section name says where you are; a filled bar says how far along.
 */
.tour__progress {
  display: flex;
  flex-direction: column;
  gap: 0.32rem;
}

.tour__eyebrow {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem;
  margin: 0;
}

.tour__section {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--forest);
}

.tour__count {
  font-size: 0.72rem;
  color: var(--ink-faint);
  font-feature-settings: "tnum";
  font-variant-numeric: tabular-nums;
}

.tour__bar {
  display: block;
  height: 3px;
  border-radius: 999px;
  background: var(--line);
  overflow: hidden;
}

.tour__bar-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--forest);
  transition: width 220ms ease;
}

@media (prefers-reduced-motion: reduce) {
  .tour__bar-fill {
    transition: none;
  }
}

.tour__title {
  margin: 0;
  font-size: 1.05rem;
}

.tour__body {
  margin: 0;
  font-size: 0.92rem;
  color: var(--ink-muted);
}

.tour__actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
}

/*
 * w9-item-5 — renamed from `.tour__dont-show`: the button used to sit
 * ALONGSIDE an inline Skip button (two ways to dismiss on one card); it is
 * now the ONE quiet, centered "Skip the tour" link below Back/Next. Same
 * visual treatment (bare button, muted, centered) and the same onSkip
 * handler underneath — only the copy and its role on the card changed.
 */
.tour__skip-link {
  display: block;
  width: 100%;
  margin: 0;
  padding: 0.5rem 0 0;
  border: none;
  border-top: 1px solid var(--line);
  background: none;
  font: inherit;
  font-size: 0.8rem;
  color: var(--ink-faint);
  text-align: center;
  cursor: pointer;
}

/* --------------------------------------------- visit date+time (w8-30) -- */

/*
 * The on-theme month-grid calendar + time-slot picker (VisitDateTimePicker)
 * that replaces the bare native `datetime-local` input in both the
 * record-a-visit flow and a booking's reschedule control.
 */
.visit-datetime {
  margin-top: 0.4rem;
}

.visit-datetime__nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 0.5rem;
}

.visit-datetime__nav-btn {
  min-width: 44px;
  min-height: 44px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--paper);
  font-size: 1.1rem;
  cursor: pointer;
  color: var(--ink);
}

.visit-datetime__month {
  font-weight: 600;
  font-size: 0.95rem;
}

.visit-datetime__grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  grid-gap: 0.25rem;
  gap: 0.25rem;
}

.visit-datetime__weekday {
  text-align: center;
  font-size: 0.72rem;
  color: var(--ink-muted);
  padding: 0.2rem 0;
}

.visit-datetime__day {
  /* w10 E3 — was 40px, under the 44x44 touch-target floor every other icon
     control in this app answers to. */
  min-width: 44px;
  min-height: 44px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: none;
  font-size: 0.88rem;
  color: var(--ink);
  cursor: pointer;
}

.visit-datetime__day:disabled {
  color: var(--ink-faint);
  cursor: default;
}

.visit-datetime__day[data-today="true"] {
  border-color: var(--line-strong);
}

.visit-datetime__day[data-selected="true"] {
  background: var(--forest);
  color: var(--paper);
  border-color: var(--forest);
  font-weight: 700;
}

.visit-datetime__day:not(:disabled):hover,
.visit-datetime__day:not(:disabled):focus-visible {
  background: var(--forest-soft);
}

.visit-datetime__slots {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(4.2rem, 1fr));
  grid-gap: 0.4rem;
  gap: 0.4rem;
  margin-top: 0.7rem;
  max-height: 11rem;
  overflow-y: auto;
  padding-right: 0.15rem;
}

.visit-datetime__slot {
  min-height: 44px;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--paper);
  font-size: 0.85rem;
  color: var(--ink);
  cursor: pointer;
}

.visit-datetime__slot[data-selected="true"] {
  background: var(--forest);
  color: var(--paper);
  border-color: var(--forest);
  font-weight: 700;
}

.visit-datetime__slot:hover,
.visit-datetime__slot:focus-visible {
  border-color: var(--forest);
}

/* --- confirmation toasts (operator review batch 2, ask 3) ----------------
   ONE primitive, mounted once (ToastProvider in the /app shell). Bottom of
   the viewport, ABOVE the tab bar — the reader's thumb zone. Success wears
   forest-on-paper, failure wears the danger pair and STAYS until its ✕.
   Entry animation is a small rise+fade; reduced motion gets opacity only. */

.toast-stack {
  position: fixed;
  left: 0.75rem;
  right: 0.75rem;
  bottom: calc(var(--shell-tabs) + 0.75rem + env(safe-area-inset-bottom, 0px));
  z-index: 700; /* above the menu sheet (60) and the welcome gate (500) */
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  pointer-events: none; /* the stack never blocks taps; each toast re-enables */
}

@media (min-width: 700px) {
  .toast-stack {
    left: auto;
    right: 1.25rem;
    width: min(24rem, calc(100vw - 2.5rem));
  }
}

.toast {
  pointer-events: auto;
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  padding: 0.65rem 0.75rem;
  border-radius: var(--radius-md);
  background: var(--forest-deep);
  color: var(--paper-raised);
  border: 1px solid var(--forest);
  box-shadow: 0 6px 24px rgba(28, 35, 33, 0.28);
  font-size: 0.92rem;
  animation: toast-in 220ms ease-out both;
}

.toast--error {
  background: var(--danger);
  border-color: var(--danger);
  color: #fff;
}

.toast__message {
  flex: 1 1;
  min-width: 0;
  padding-top: 0.15rem;
}

.toast__dismiss {
  flex: none;
  width: 32px;
  height: 32px;
  margin: -0.15rem -0.25rem -0.15rem 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: inherit;
  font-size: 0.9rem;
  line-height: 1;
  cursor: pointer;
  opacity: 0.85;
}

.toast__dismiss:hover,
.toast__dismiss:focus-visible {
  opacity: 1;
  background: rgba(255, 255, 255, 0.12);
}

@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(0.5rem);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@media (prefers-reduced-motion: reduce) {
  .toast {
    animation: toast-fade-in 150ms ease-out both;
  }
}

@keyframes toast-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* --- the estate gates (operator review batch 2, ask 4) --------------------
   Two brand-green panels meet on the center seam and OPEN outward after a
   successful sign-in / language choice (GateProvider `open` phase), or
   CLOSE over the app on sign-out (`close` phase). Transform-only panel
   animation; the loading affordance rides above the seam. The durations
   here MUST equal GATE_OPEN_MS / GATE_CLOSE_MS / GATE_REDUCED_MS in
   src/lib/gate-transition.ts — the JS unmount timer and these animations
   may never drift apart.
   NOTE for reviewers: screenshots freeze compositor animations — verify by
   DOM-sampling classes/computed styles at intervals, not by screenshot. */

.estate-gate {
  position: fixed;
  inset: 0;
  z-index: 800; /* above everything, toasts included — it IS the transition */
  overflow: hidden;
  pointer-events: none; /* purely visual; never traps a tap */
}

.estate-gate__panel {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50.5%; /* a hair over half so the seam can never show a gap */
  /* Batch 4 item 1 — CLOTH, not sliding doors: vertical FOLD SHADING
     (irregular repeating stripes — a dark crease, a lit ridge — layered
     over batch 3's sheen + forest gradient; translucent overlays only, no
     new hexes), so each panel reads as hung fabric. The stripes ride the
     panel's own transform, so the folds sway with the drape below. */
  background:
    repeating-linear-gradient(
      90deg,
      rgba(0, 0, 0, 0.12) 0px,
      rgba(0, 0, 0, 0) 26px,
      rgba(255, 255, 255, 0.05) 40px,
      rgba(0, 0, 0, 0) 54px,
      rgba(0, 0, 0, 0.09) 86px,
      rgba(0, 0, 0, 0) 108px,
      rgba(255, 255, 255, 0.04) 122px,
      rgba(0, 0, 0, 0) 148px
    ),
    linear-gradient(155deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 38%, rgba(0, 0, 0, 0.14) 100%),
    linear-gradient(135deg, var(--forest-deep) 0%, var(--forest) 100%);
  will-change: transform;
}

/* The seam: a hairline brass edge, a faint INNER shadow so each panel turns
   softly into the meeting line, and a brass GLOW spilling just past it —
   while the gates stand closed the two glows overlap into one lit seam.
   (rgba(168, 130, 60, …) is var(--brass), same channels the tour ring
   already uses.) */
.estate-gate__panel--left {
  left: 0;
  /* The curtain is GATHERED toward its own outer edge as it draws — the
     skew/scale in the keyframes pivots there, like fabric pulled to the
     rail. */
  transform-origin: left center;
  border-right: 1px solid var(--brass);
  box-shadow:
    inset -22px 0 34px rgba(0, 0, 0, 0.22),
    4px 0 18px rgba(168, 130, 60, 0.3);
}

.estate-gate__panel--right {
  right: 0;
  transform-origin: right center;
  border-left: 1px solid var(--brass);
  box-shadow:
    inset 22px 0 34px rgba(0, 0, 0, 0.22),
    -4px 0 18px rgba(168, 130, 60, 0.3);
}

.estate-gate__content {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  color: var(--paper-raised);
  text-align: center;
  padding: 0 1.5rem;
}

/* Batch 4 item 1 — the LOGO on the seam, as a light-brass single-colour
   recolor of the dark-on-light asset: the SVG is a MASK (its paths supply
   the alpha), background-color supplies the paint — crisp at any size on
   the forest panels, one asset, no filter blur. Aspect ratio = the SVG's
   own 371x161 viewBox. */
/*
 * The greeting under the wordmark — brass on forest, spaced like the
 * label it sits beneath, and small enough that the mark stays the thing
 * you look at.
 */
.estate-gate__greeting {
  display: block;
  margin-top: 0.75rem;
  font-size: 0.95rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-align: center;
  color: var(--brass-soft);
  opacity: 0.92;
}

.estate-gate__logo {
  display: block;
  width: min(15rem, 62vw);
  aspect-ratio: 371 / 161;
  background-color: var(--brass-soft);
  -webkit-mask: url("/media/manor-buildcon-logo.svg") no-repeat center / contain;
  mask: url("/media/manor-buildcon-logo.svg") no-repeat center / contain;
}

.estate-gate__loading {
  font-size: 0.92rem;
  opacity: 0.85;
}

/* OPEN (batch 3 polish — one overlapping choreography, ~1s, nothing pops):
     0–35%    panels stand CLOSED — the honest loading beat, seam lit;
     28–62%   the wordmark/loading affordance fades + scales gently OUT
              (a slight stagger AHEAD of the panels, so the words leave
              first and the split reads as the reveal);
     35–100%  panels part on an ease-in-out curve (never linear);
     30–100%  the app underneath fades/scales IN (.estate-stage--entering
              below) — no flash of a fully-rendered app before the gates
              finish. Durations MUST equal the JS constants (see header). */
/* Batch 4 item 1 — the panels ACCELERATE apart like drawn fabric (an
   ease-IN pull, not a door's symmetric glide), with a gentle sway: a small
   skew leans the cloth into the pull, overshoots a touch, and settles as
   the panel leaves; a slight scaleX "gathers" the fabric toward its rail
   (the transform-origin above). The two sides deliberately ease on
   slightly different intermediate beats — real curtains never move in
   lockstep. Durations MUST equal the JS constants (see header). */
.estate-gate--open .estate-gate__panel--left {
  animation: estate-gate-open-left 2900ms cubic-bezier(0.4, 0, 0.6, 1) both;
}

.estate-gate--open .estate-gate__panel--right {
  animation: estate-gate-open-right 2900ms cubic-bezier(0.4, 0, 0.6, 1) both;
}

.estate-gate--open .estate-gate__content {
  animation: estate-gate-content-out 700ms cubic-bezier(0.65, 0, 0.35, 1) 600ms both;
}

/*
 * THE CURTAIN OPENING — rewritten 2026-08-12 because it read as jagged,
 * and three separate things were causing that:
 *
 *   1. A DEAD HOLD INSIDE THE KEYFRAMES — a third of the timeline spent
 *      perfectly still, then off. A stall followed by motion is a lurch.
 *      The pause is real and wanted, but it belongs to the SEQUENCE, not
 *      to this animation: it is an `animation-delay` of GATE_HOLD_MS now,
 *      during which the wordmark shows and the confetti falls. These
 *      keyframes just move.
 *   2. THE DISTANCE WAS BACK-LOADED under an ease-IN curve: 9% of the
 *      travel in the first half, then 59% in the last fifth, with a curve
 *      that was still ACCELERATING at 100%. It hit full speed and stopped
 *      dead — nothing decelerates, so the end snapped.
 *   3. THE SKEW CHANGED SIGN twice mid-flight (+0.8 → -0.5 → +0.2), so the
 *      cloth visibly jerked the other way twice on the way out.
 *
 * Now: it leaves almost immediately, travels at an even rate with the
 * easing done by the curve rather than by uneven keyframes, and the sway
 * rises once and settles back to flat. The fold shading and the drape are
 * unchanged — this is the same cloth, just no longer fighting itself.
 */
@keyframes estate-gate-open-left {
  /*
   * EACH BEAT CARRIES ITS OWN CURVE. Three motions in one animation cannot
   * share a single timing function — a curve that eases the draw-in would
   * be halfway through its own arc by the time the parting began. CSS lets
   * a keyframe set the easing for the segment that STARTS there, so the
   * draw-in settles, the hold holds, and the parting builds and releases.
   * (Never `linear`: a curtain moving at a constant rate reads as a
   * mechanism, not as cloth. A test enforces that.)
   */
  0% {
    transform: translateX(-103%) skewY(0deg) scaleX(0.962);
    animation-timing-function: cubic-bezier(0.32, 0.04, 0.2, 1);
  }
  /* Drawn in and shut — GATE_LEAD_MS. */
  20.7% {
    transform: translateX(0) skewY(0deg) scaleX(1);
    animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
  }
  /* Held, while the confetti falls — GATE_HOLD_MS. */
  44.8% {
    transform: translateX(0) skewY(0deg) scaleX(1);
    animation-timing-function: cubic-bezier(0.55, 0.02, 0.62, 0.42);
  }
  /* And away — GATE_PART_MS, building then settling. */
  76% {
    transform: translateX(-52%) skewY(0.55deg) scaleX(0.981);
    animation-timing-function: cubic-bezier(0.3, 0.2, 0.2, 1);
  }
  100% {
    transform: translateX(-103%) skewY(0deg) scaleX(0.962);
  }
}

@keyframes estate-gate-open-right {
  /*
   * EACH BEAT CARRIES ITS OWN CURVE. Three motions in one animation cannot
   * share a single timing function — a curve that eases the draw-in would
   * be halfway through its own arc by the time the parting began. CSS lets
   * a keyframe set the easing for the segment that STARTS there, so the
   * draw-in settles, the hold holds, and the parting builds and releases.
   * (Never `linear`: a curtain moving at a constant rate reads as a
   * mechanism, not as cloth. A test enforces that.)
   */
  0% {
    transform: translateX(103%) skewY(0deg) scaleX(0.962);
    animation-timing-function: cubic-bezier(0.32, 0.04, 0.2, 1);
  }
  /* Drawn in and shut — GATE_LEAD_MS. */
  20.7% {
    transform: translateX(0) skewY(0deg) scaleX(1);
    animation-timing-function: cubic-bezier(0.4, 0, 0.6, 1);
  }
  /* Held, while the confetti falls — GATE_HOLD_MS. */
  44.8% {
    transform: translateX(0) skewY(0deg) scaleX(1);
    animation-timing-function: cubic-bezier(0.55, 0.02, 0.62, 0.42);
  }
  /* And away — GATE_PART_MS, building then settling. */
  76% {
    transform: translateX(52%) skewY(-0.55deg) scaleX(0.981);
    animation-timing-function: cubic-bezier(0.3, 0.2, 0.2, 1);
  }
  100% {
    transform: translateX(103%) skewY(0deg) scaleX(0.962);
  }
}

@keyframes estate-gate-content-out {
  0%,
  34% {
    opacity: 1;
    transform: scale(1);
  }
  62%,
  100% {
    opacity: 0;
    transform: scale(1.05);
  }
}

/* CLOSE: the reverse choreography — panels sweep in from the edges on the
   same curve and STAY closed (fill-mode both holds the end state) while the
   sign-out redirect leaves; the wordmark arrives a beat AFTER the panels
   start (the same stagger, mirrored), and the app dims/settles underneath
   (.estate-stage--leaving). */
/* The reverse: cloth RELEASED — it sweeps in fast and settles softly
   (decelerating), with the same sway leaning the other way; fill-mode
   `both` holds the panels closed while the sign-out redirect leaves. */
.estate-gate--close .estate-gate__panel--left {
  animation: estate-gate-close-left 1600ms cubic-bezier(0.36, 0.03, 0.16, 1) both;
}

.estate-gate--close .estate-gate__panel--right {
  animation: estate-gate-close-right 1600ms cubic-bezier(0.38, 0.03, 0.16, 1) both;
}

.estate-gate--close .estate-gate__content {
  animation: estate-gate-content-in 1600ms cubic-bezier(0.65, 0, 0.35, 1) both;
}

/*
 * THE CURTAIN CLOSING — the exact mirror of the mistake the opening had.
 *
 * It ran 95% of its travel inside the first 55% of the timeline (-103% to
 * -8%) and then inched the last sliver across the remaining 45%, on a curve
 * that was already at full speed by a sixth of the way in. So it flew shut
 * and crawled home: the motion the reader actually sees is over almost
 * before it starts, which is why it never matched the opening no matter
 * what the duration said (operator, 2026-08-12: "the close doesn't have the
 * same effect, it's too fast").
 *
 * Even travel now, one sway, and it starts from exactly where the opening
 * left off — same skew, same scale — so the two are the same cloth moving
 * in opposite directions rather than two animations that happen to share a
 * name.
 */
@keyframes estate-gate-close-left {
  0% {
    transform: translateX(-103%) skewY(0deg) scaleX(0.962);
  }
  42% {
    transform: translateX(-58%) skewY(-0.5deg) scaleX(0.978);
  }
  86% {
    transform: translateX(-9%) skewY(-0.3deg) scaleX(0.995);
  }
  100% {
    transform: translateX(0) skewY(0deg) scaleX(1);
  }
}

@keyframes estate-gate-close-right {
  0% {
    transform: translateX(103%) skewY(0deg) scaleX(0.962);
  }
  42% {
    transform: translateX(58%) skewY(0.5deg) scaleX(0.978);
  }
  86% {
    transform: translateX(9%) skewY(0.3deg) scaleX(0.995);
  }
  100% {
    transform: translateX(0) skewY(0deg) scaleX(1);
  }
}

@keyframes estate-gate-content-in {
  0%,
  22% {
    opacity: 0;
    transform: scale(0.96);
  }
  78%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

/* THE STAGE — the app content's own half of the transition (batch 3): it
   fades/scales in AS the gates open and dims back as they close, driven by
   GateProvider's wrapper classes. Idle carries no animation and no
   transform at all. Durations mirror the overlay's so the two halves are
   one motion. */
.estate-stage {
  display: block;
}

.estate-stage--entering {
  animation: estate-stage-enter 1600ms cubic-bezier(0.65, 0, 0.35, 1) 1300ms both;
}

.estate-stage--leaving {
  animation: estate-stage-leave 1600ms cubic-bezier(0.65, 0, 0.35, 1) both;
}

@keyframes estate-stage-enter {
  0%,
  30% {
    opacity: 0;
    transform: scale(0.985);
  }
  100% {
    opacity: 1;
    transform: scale(1);
  }
}

@keyframes estate-stage-leave {
  0% {
    opacity: 1;
    transform: scale(1);
  }
  60%,
  100% {
    opacity: 0.55;
    transform: scale(0.985);
  }
}

/* Reduced motion: NO panel travel — one brief honest fade, both phases.
   The `--reduced` class is the real gate (decided per play from the live
   media query); the media query below is the belt-and-braces backstop. */
.estate-gate--reduced .estate-gate__panel--left,
.estate-gate--reduced .estate-gate__panel--right {
  animation: none;
  transform: translateX(0); /* no travel, no sway — the crossfade is all */
}

.estate-gate--reduced.estate-gate--open {
  animation: estate-gate-reduced-out 200ms ease-out both;
}

.estate-gate--reduced.estate-gate--close {
  animation: estate-gate-content-in 200ms ease-in both;
}

@keyframes estate-gate-reduced-out {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .estate-gate__panel {
    animation: none !important;
    transform: translateX(0) !important;
  }
  /* The stage never moves for a reduced-motion reader either — the
     provider already withholds these classes when reduced; this is the
     belt-and-braces backstop, same shape as the panel rule above. */
  .estate-stage--entering,
  .estate-stage--leaving {
    animation: none !important;
  }
}

/* --- the live money status bar (operator review batch 2, ask 1) -----------
   ONE class pair for every percent-paid bar (booking detail, plot popup,
   the finance form's preview) so the fill animates smoothly when the value
   changes — a status bar, not a static paint. Reduced motion: no tween. */

.money-bar {
  height: 8px;
  border-radius: 999px;
  background: var(--paper-sunk);
  overflow: hidden;
}

.money-bar__fill {
  height: 100%;
  background: var(--forest);
  transition: width 600ms cubic-bezier(0.22, 0.61, 0.36, 1);
}

.money-bar__fill--preview {
  background: var(--brass);
}

@media (prefers-reduced-motion: reduce) {
  .money-bar__fill {
    transition: none;
  }
}

/* --- the profile "My details" header (operator review batch 2, ask 2) -----
   Photograph BIG and centered with a pencil badge ON the photo (the
   change-photograph door); the name written large with a pencil beside it
   (the edit-details door); the phone plain below. One shared layout for
   admin, staff, associate and client — EditPersonForm's `profile`
   presentation. */

.profile-head {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 0.35rem;
  padding: 0.5rem 0 0.25rem;
}

.profile-head__avatar-wrap {
  position: relative;
  display: inline-block;
}

.profile-head__avatar-wrap .avatar {
  font-size: 2.2rem;
}

/* Batch 4 item 9 — stays a BADGE (photo affordance) but joins the small
   pencil family: 32px, same quiet scale as the header pencils. */
.profile-head__pencil {
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  border: 2px solid var(--paper-raised);
  background: var(--forest);
  color: var(--paper-raised);
  font-size: 0.85rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 8px rgba(28, 35, 33, 0.25);
}

.profile-head__pencil:hover,
.profile-head__pencil:focus-visible {
  background: var(--forest-deep);
}

.profile-head__name-row {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  margin-top: 0.4rem;
}

.profile-head__name {
  margin: 0;
  font-size: 1.45rem;
  font-weight: 700;
  line-height: 1.2;
  overflow-wrap: anywhere;
}

/* Batch 4 item 9 — the ONE small pencil treatment (see
   .panel__edit-icon): 30px, quiet, borderless until engaged. */
.profile-head__edit {
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  border: 1px solid transparent;
  background: transparent;
  color: var(--ink-faint);
  font-size: 0.85rem;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}

.profile-head__edit:hover,
.profile-head__edit:focus-visible {
  color: var(--forest);
  border-color: var(--line-strong);
  background: var(--paper);
}

.profile-head__edit:hover,
.profile-head__edit:focus-visible {
  border-color: var(--forest);
  background: var(--forest-soft);
}

.profile-head__phone {
  margin: 0;
  color: var(--ink-muted);
  font-size: 0.95rem;
}

/*
 * Finance-queue section headings.
 *
 * The COUNT carries the emphasis so the heading word never has to shout —
 * "OVERDUE" was the page's only all-caps label and it was loudest at zero,
 * while the section that actually had rows ("Recently completed") rendered
 * its title in muted grey and read as disabled. One treatment, and the
 * colour appears only when there is something to act on.
 */
.count {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.5rem;
  height: 1.5rem;
  padding: 0 0.4rem;
  font-size: 0.8rem;
  font-weight: 700;
  font-feature-settings: "tnum";
  font-variant-numeric: tabular-nums;
  vertical-align: 2px;
  border-radius: 999px;
  background: var(--paper-sunk);
  color: var(--ink-muted);
}

.count--danger {
  background: var(--danger-soft);
  color: var(--danger);
}

/*
 * A collapsed section's summary reads as the heading it replaces — same size
 * and weight as its siblings' <h2>.
 *
 * The caret is DRAWN, because `display: flex` on a <summary> suppresses the
 * native ::marker in WebKit and Blink: the section opened perfectly and gave
 * no sign it could be opened, which is how it read as "hidden" rather than
 * "collapsed". Sibling sections elsewhere in the app show ▸, so this matches
 * them rather than inventing a third affordance.
 */
.todo-section__summary {
  gap: 0.45rem;
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--ink);
  list-style: none;
}

.todo-section__summary::-webkit-details-marker {
  display: none;
}

.todo-section__summary::before {
  content: "\25B8";
  font-size: 0.9em;
  color: var(--ink-muted);
  transition: transform 120ms ease;
}

details[open] > .todo-section__summary::before {
  transform: rotate(90deg);
}

/*
 * A booking's next deadline, on the finance queue row.
 *
 * RED and not muted: the §11.4 windows (7-day finance, 30-day booking) are
 * the reason that queue exists, and a date rendered in the same tone as the
 * associate's name beside it does not get read. The OVERDUE pill stays the
 * louder state — this is the warning BEFORE it.
 */
.due {
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--danger);
}

/* The Visits heading and its month picker are one row — the picker changes
   what the heading counts, so they must never be separated on screen. */
.visits-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  margin: 0 0 0.5rem;
}

/*
 * The month picker — a PILL, not a bare select.
 *
 * As a plain select it read as a caption parked beside the heading; the one
 * control that decides what the whole list is showing looked like the least
 * pressable thing on screen. The wrapper draws the whole control (green
 * outline, calendar mark, its own chevron) and the select inside is stripped
 * bare, so what you see and what you press are the same rectangle.
 */
.month-pick {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  min-height: 42px;
  padding: 0 1.9rem 0 0.7rem;
  border: 2px solid var(--forest);
  border-radius: 999px;
  background: var(--forest-soft);
  color: var(--forest-deep);
  font-weight: 700;
  font-size: 0.9rem;
  white-space: nowrap;
}

.month-pick__glyph {
  font-size: 0.95rem;
  line-height: 1;
}

/* The chevron is drawn by the WRAPPER so it sits inside the pill's own
   padding — the select's native arrow is suppressed below. */
.month-pick::after {
  content: "";
  position: absolute;
  right: 0.75rem;
  width: 0.45rem;
  height: 0.45rem;
  border-right: 2px solid var(--forest);
  border-bottom: 2px solid var(--forest);
  transform: translateY(-0.12rem) rotate(45deg);
  pointer-events: none;
}

.month-pick select {
  /* The real control: invisible chrome, full hit area, inherits the pill's
     type so the value reads as the pill's own label. */
  -moz-appearance: none;
       appearance: none;
  -webkit-appearance: none;
  min-height: 0;
  width: auto;
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  font-weight: 700;
  cursor: pointer;
}

/*
 * THE FOCUS RING BELONGS TO THE PILL, NOT TO THE TEXT INSIDE IT.
 *
 * The `<select>` here is deliberately chrome-less and sized to its own value,
 * so a ring drawn on it traced a small rectangle around the date, floating
 * inside a rounded pill it did not match — a focus indicator that looked like
 * a rendering fault (operator, 2026-08-12: "an outline around the date itself
 * that doesn't line up with the component"). Whenever a control's hit area
 * and its visible shape are different elements, the ring goes on the shape.
 *
 * `:has()` carries it, and the fallback is deliberate: browsers without
 * `:has()` keep the old ring rather than losing focus visibility entirely —
 * a misaligned indicator is a blemish, no indicator is a barrier. That is why
 * the `outline: none` lives INSIDE the @supports block and not outside it.
 */
.month-pick select:focus-visible {
  outline: 2px solid var(--forest);
  outline-offset: 3px;
}

@supports selector(:has(*)) {
  .month-pick select:focus-visible {
    outline: none;
  }

  .month-pick:has(select:focus-visible) {
    outline: 2px solid var(--forest);
    outline-offset: 2px;
  }
}

/* Visually hidden, still announced — the picker's own label. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* ------------------------------- township + offices, fluid pass (0812) -- */

/*
 * THE HEADER RAIL — a back glyph and the page's identity on one line.
 *
 * Detail pages used to open with a full "← All townships" button stacked
 * above the title, which read as the page's primary action and restated in
 * words what the arrow already said (operator, 2026-08-12). The glyph keeps
 * its accessible name, so nothing is lost to a screen reader.
 */
.rail {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 0.9rem;
}

.rail__back {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--paper-raised);
  color: var(--ink-muted);
  text-decoration: none;
}

.rail__back:hover,
.rail__back:focus-visible {
  background: var(--forest-soft);
  color: var(--forest-deep);
}

.rail__back svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.rail__id {
  min-width: 0;
}

.rail__title {
  margin: 0;
  font-size: 1.25rem;
  line-height: 1.2;
}

/*
 * COUNTS AS FIGURES. Three pills in a form grid read as labels with numbers
 * attached; the office wants the NUMBER first and the word underneath it
 * (operator, 2026-08-12: "show that like little widgets"). The coloured
 * top edge is the same status vocabulary the map legend uses, so Available
 * is the same green in both places.
 */
.kpi-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-gap: 0.5rem;
  gap: 0.5rem;
}

.kpi {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.15rem;
  padding: 0.7rem 0.4rem;
  border: 1px solid var(--line);
  border-top: 3px solid var(--line-strong);
  border-radius: var(--radius-md);
  background: var(--paper-raised);
  text-align: center;
}

.kpi--available {
  border-top-color: #3e9c5b;
}

.kpi--hold {
  border-top-color: var(--brass);
}

.kpi--booked {
  border-top-color: var(--danger);
}

.kpi__num {
  font-size: 1.6rem;
  font-weight: 700;
  line-height: 1;
  color: var(--ink);
  font-feature-settings: "tnum";
  font-variant-numeric: tabular-nums;
}

.kpi__label {
  font-size: 0.78rem;
  color: var(--ink-muted);
}

.kpi-foot {
  margin: 0.6rem 0 0;
  font-size: 0.85rem;
}

/* A card that continues the one directly above it — the tracing statement
   belongs to the plan it is about, not to a section of its own. */
.card--attached {
  margin-top: 0.4rem;
}

.tw-traced {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
}

/* ---- the townships list row ---- */

.tw-row {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.tw-row__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.6rem;
}

.tw-row__id {
  min-width: 0;
}

.tw-row__name {
  margin: 0 0 0.15rem;
  font-size: 1.05rem;
  line-height: 1.25;
}

.tw-row__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--line);
}

.tw-flag {
  font-size: 0.8rem;
  color: var(--ink-faint);
}

.tw-flag--traced {
  color: var(--forest);
  font-weight: 600;
}

/* ---- an office / seminar row ---- */

.venue-row {
  padding: 0.55rem 0;
  border-bottom: 1px solid var(--line);
}

.venue-row:last-child {
  border-bottom: none;
}

.venue-row__head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.6rem;
}

.venue-row__name {
  min-width: 0;
}

/* The single per-row affordance. A 44px tap target, quiet until wanted. */
.venue-row__more {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: -0.5rem -0.4rem -0.5rem 0;
  border: none;
  border-radius: 999px;
  background: none;
  color: var(--ink-muted);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}

.venue-row__more:hover,
.venue-row__more:focus-visible {
  background: var(--forest-soft);
  color: var(--forest-deep);
}

/* The count never wraps to two lines. `.pill` is a shrinkable flex item, so
   in the narrow right-hand column of a township row "370 plots" broke across
   the word. A count is one token. */
.tw-row__head .pill {
  flex: none;
  white-space: nowrap;
}

