/* Plant Tender — common.css
   Drop-in replacement for site/assets/common.css.
   Every class name and CSS variable from the original is preserved, so no HTML
   or JS has to change. Only the values, type, shapes and a few additive rules differ.

   Design system: "Organic" — a light sage ground under near-cream cards, deep
   forest as ink AND as a surface (the top bar, filled buttons, the water
   verdict), sage as the second voice, tan and amber as the warm accents. The
   accent variables are still named --terracotta-* so the class-name contract
   from earlier passes survives; the values have been green for months and are
   now forest. Caprasimo display over Figtree body, over-rounded shapes, pill
   controls. Repainted to Stephen's mockup in #607 — see the token block. */

@import url('https://fonts.googleapis.com/css2?family=Caprasimo&family=Figtree:wght@400;500;600;700&display=swap');

:root {
  /* ── Organic tokens (source of truth) ───────────────────────────── */
  /* ── The mockup palette (#607) ───────────────────────────────────
     Stephen mocked every app screen against his four-colour seed and
     attached the render set plus a token map to #576. #576/#603 built
     against the ISSUE TEXT rather than against that mockup, and the
     result was the old warm-sand system wearing green accents: --ground
     moved #e1d2be → #e1d0bc, --surface stayed cream, --ink did not move
     at all. Those three carry the character, so nothing about the app
     actually looked different. #607 is the correction, and this time the
     ground, the surface and the ink all move.

     Every hex below was PIXEL-SAMPLED out of the mockup PNGs rather than
     transcribed from the prose beside them (CLAUDE.md §13 — the render
     is the artefact, the caption is a description of it). The four seeds,
     the five neutrals, the tints and the scrim all reproduce exactly.

       sage   #6D9773    forest #0C3B2E    tan #BB8A52    amber #FFBA00

     What each seed may CARRY, measured, is why the roles fall out the way
     they do rather than by preference:

       forest  → every ink role, the top bar, every filled button, the
                 water verdict. 9.60 on the ground, 11.71 on a card, and
                 it carries cream at 11.10. The only one of the four that
                 can do this.
       sage    → fills and decoration ONLY. Forest on raw sage is 4.31 and
                 cream on it 3.32 — no text, at any size. It is the
                 placeholder tile, the leaf disc, the room dot, and the
                 three page grounds are its tints (28% / 14% / 34%).
       tan     → rules, borders, the notice-card edge. Forest on tan is
                 4.07, cream on tan 3.07, tan as ink on the ground 2.35.
                 Nothing legible is made of it or sits on it.
       amber   → the warning fill, carrying FOREST at 7.30. Cream on amber
                 is 1.52 — it is a fill for dark ink permanently, in every
                 theme, and it is the most dangerous pairing in the seed
                 precisely because it looks like a confident bright fill
                 while carrying nothing at all.

     The neutrals are NOT in the seed strip; the mockup derives them, and
     these are its values. Three rungs of the same sage plus one near-cream
     card, so the card is the only thing that lifts off the page. */
  --ground: #dce4d8;         /* 28% sage over white — the page ground */
  --sand: #e0e8dc;           /* 14% sage — pill fill, input fill, thumbnails */
  /* 34% sage — the landing band and the table header/zebra. A THIRD ground,
     new in #607: the mockup separates the band from the pill fill, and the
     binding contrast constraint in the whole light theme lives here rather
     than on the ground everyone looks at — --text-dim on this band is 4.76:1.
     Do not darken this or lighten --text-dim without re-measuring that pair;
     cta-contrast-acceptance pins it. */
  --band: #d6e0d1;
  /* FOREST. --ink moved for the first time since the system was written, and
     it is the single change that makes the app look like the mockup rather
     than like the old system with a green button. Everything derived from it
     moves too — --text, --photo-scrim (86% of this), and --shadow. */
  --ink: #0c3b2e;
  /* Accent ramp — FOREST. Variable names still say "terracotta" so no markup
     has to move (§8: the names are a contract, and the values have been green
     for months).

     The ramp's invariant is unchanged: **700 is the accent-toned ink with the
     most contrast against the page ground**, in whichever direction that
     happens to be. In light it is the darkest step (12.88 on the ground,
     against the base's 9.60). 600 is the emphatic step — `a { color:
     var(--accent-strong) }` and `button:hover` — which has to be legible as
     INK on the card (8.52) and to carry cream as a FILL (8.07). Both pull the
     same way once the base is this dark, so 600 sits ABOVE the base rather than
     between it and 700 — the ramp is no longer monotonic in lightness, and that
     is a consequence of the base being forest. #601 is the live
     example of what ignoring this costs: an accent/accent-strong pair one step
     apart on the same ramp, painted as ink on fill, is an invisible control. */
  --terracotta: #0c3b2e;
  /* The tints. Re-derived as forest over the new near-cream card rather than
     over the old warm one, which is why they cool: they are the quiet fill
     behind an eyebrow or a feedback panel, and text sits on them. */
  --terracotta-100: #e7ecea;
  --terracotta-200: #cbd8d3;
  --terracotta-600: #145240;
  --terracotta-700: #07221a;
  /* The second voice, still DERIVED rather than the seed itself — because this
     token is an INK. It colours the 17px check icons on the landing page and
     /pricing's comparison ticks (non-text UI, 3.0:1) AND the admin surface's
     status lines via --water, which is body text at 4.5:1. Raw sage manages
     2.55 on the new ground. Darkened along the seed's own hue until the worst
     light ground clears with room: 5.65 / 6.89 / 5.87 / 5.41 over --bg /
     --surface / --surface-2 / --band.

     The RAW seed has its own token below. Splitting them is what #607 needed
     that #603 did not have: the mockup uses undiluted sage as a FILL under an
     icon, which is legal, and the same colour as an ink, which is not. */
  --sage: #2f5f49;
  /* THE RAW SEED, and the pairing the mockup puts it in: an icon, never a
     word. `.plant-photo.placeholder`, `.empty .leaf`, the chat empty state —
     a full-strength sage tile with a forest glyph on it at 4.31:1, which is
     over the 3.0 an icon owes and under the 4.5 a label would. Nothing that
     spells anything may be moved onto this pair. */
  --sage-fill: #6d9773;
  --sage-ink: #0c3b2e;
  /* Tan, named. It was only ever reachable through --border before, which is
     why the mockup's warm voice — the notice-card edge, the 3px warn rule —
     had nowhere to come from. Non-text by construction: see the seed table. */
  --tan: #bb8a52;
  /* The notice card (`.vd-ask`, first-check's `.ask`, add.html's carried/cold
     notes). 22% tan, the ONE warm surface in the system, and the reason the
     app does not read as monochrome green. Forest on it is 9.55, dim ink 4.94.
     Sampled from the mockup rather than computed — 22% tan over white is
     #f0e5d9 and the render is #eddfce, so the mock lays it over the ground. */
  --notice-fill: #eddfce;
  --notice-border: var(--tan);

  --sage-100: #ebf0eb;
  --sage-200: #cbd6c6;
  /* The labelled green fill — `.pill.water`, `.verdict.water`, admin's
     verified/priority pills. FOREST now, which is what the mockup shows: the
     "Water it" panel and the "Watered" pill are the same green as the primary
     button, because the verdict IS the primary thing on that screen. Carries
     the cream literal at 11.10, up from 8.61. */
  --sage-700: #0c3b2e;
  /* Ink on --sage-200's 34% tint: the healthy pill, the leave-it-be panel.
     Forest, 8.31:1. Same value as --sage-700 because measured, sage has no
     legible ink step of its own — both of its deep rungs are forest. */
  --sage-800: #0c3b2e;

  --font-heading: 'Caprasimo', Georgia, serif;
  --font-body: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;

  /* The body size, as a token because CLAUDE.md §1 makes it a FLOOR rather than just a
     default: "before shrinking anything below the body size … check that the meaning
     survives at a larger size". A rule that must not go below it can now SAY so —
     `font-size: var(--text-size-body)` — instead of repeating the number, which is the
     difference between a decision and a coincidence that a later type-scale change
     silently breaks. #584 is the worked example, and scripts/home-simulator-acceptance.js
     §G asserts the dial simulator's copy against this token rather than against 15.
     Named for the size: --text and --text-dim beside it are colours. */
  --text-size-body: 15px;

  /* ── App variables (same names as before, new values) ───────────── */
  /* ONE THEME (#607). Stephen removed dark mode from the product in this pass, so
     every `@media (prefers-color-scheme: dark)` block is gone from common.css and
     from all 28 pages' inline <style> blocks.

     THIS DECLARATION IS NOT COSMETIC AND IT IS NOT OPTIONAL. Deleting the dark CSS
     is only half the job: a browser whose OS is set to dark still paints UA WIDGETS
     dark — form inputs, <select> menus, date pickers, scrollbars — on a page that
     never asked for it, because the default `color-scheme: normal` lets the OS
     decide. The result is not "the light theme"; it is a hybrid with black input
     wells and dark scrollbars on a sage page, which is worse than either theme and
     is exactly what a user in dark mode would have landed in. Every page also
     carries <meta name="color-scheme" content="light"> so the UA knows before the
     stylesheet arrives and there is no dark flash on first paint. */
  color-scheme: light;

  --bg: var(--ground);
  --surface: #fbf7f1;
  --surface-2: var(--sand);
  /* Sage's own edge step. It was #c5a37d — a tan border, correct while the
     ground was sand and an intruder now that it is not. 1.42:1 against the
     ground, 1.72:1 against the card. Non-text, so no AA floor applies; this is
     a legibility choice, not a compliance one, and CLAUDE.md §1 is the reason
     it is not fainter: a border is what tells someone where a card stops. */
  --border: #c6d2c1;
  --text: var(--ink);
  /* Desaturated forest. THE binding constraint of the light theme: 4.76:1 on
     --band, which is the darkest ground it lands on. 4.97 on --bg, 6.06 on the
     card, 5.16 on the pill fill. Do not lighten it.

     #368 lifted this token in the dark theme because the binding constraint inverted
     there — dim ink had to be LIGHTER than the pill fill rather than darker than the
     ground. With dark removed (#607) that second value is gone and there is only one
     constraint left, the band. #368's finding is not wrong, it just no longer has a
     theme to apply to. */
  --text-dim: #4a6455;
  --accent: var(--terracotta);
  --accent-strong: var(--terracotta-600);
  /* Ink for text/icons sitting ON an --accent fill: forest, so cream, at 11.10:1.
     This token used to invert under the dark theme because --accent inverted with it
     (#262: cream on the pale dark accent was 1.88:1). With one theme it is simply a
     colour. Only for --accent/--accent-strong fills: --sage-700 carries its own cream
     literal; --danger and --water carry their own pairs below.

     The mockup's token map spells this #F4EDE3. We keep #f7f1e4, which is the cream
     already written as a literal at six --sage-700 fill sites (#599) — matching the
     mock would mean either changing all six or shipping two creams one step apart.
     #f7f1e4 measures marginally HIGHER (11.10 against 10.75) and the two are
     indistinguishable side by side. Listed as a deviation on the PR. */
  --text-on-accent: #f7f1e4;
  /* Kept in lockstep with --sage, which is what it has always been. Nothing sits
     on top of it, which is the contract that lets it be a colour rather than a
     pairing; it is also read as an ink by the admin surface's status lines, which
     is why --sage is the derived step and --sage-fill holds the raw seed. Any
     green fill that carries a LABEL uses --sage-700 + cream. */
  --water: var(--sage);
  --skip: #4a6455;
  /* The dark sibling of TAN — this is the ink/rule token, and in light it paints
     no text at all: only `.vd-note.warn`'s 18px glyph and that note's 3px left
     rule, which are non-text UI and owe 3.0:1.

     THE MOCKUP PAINTS THAT RULE IN RAW TAN, AND WE DO NOT. Raw tan is 2.35:1 on
     the page ground and 2.86:1 on the card — under the 3.0 floor, on the one
     marker that flags a caveat a reader has to notice. Darkened along tan's own
     hue (31°, unchanged) until both clear: 3.50 / 4.27 / 3.64 over --bg /
     --surface / --surface-2. At 3px and 18px the difference from the mock is
     nearly invisible; the difference in whether you can see the rule at all is
     not. Listed as a deviation on the PR. */
  --warn: #966e42;
  /* AMBER, and the ink it carries permanently. Fill + ink for a warning-status
     chip (.pill.overdue, .pill.concerning, .verdict.unknown), a themed PAIR for
     the same reason --danger-fill/--text-on-danger is one.

     The ink is FOREST, never cream: amber under white is 1.71:1 and under our
     cream 1.52:1. Forest on amber is 7.30:1. Amber is a fill for dark ink,
     permanently — that is a property of the colour, not of a theme.

     NOT var(--warn): --warn is tuned against the page ground, which is a
     different job from carrying text on top of its own wash — see #392. */
  --warn-fill: #ffba00;
  --text-on-warn: #0c3b2e;
  /* KEPT, and now for a second reason.

     The seed has no danger colour and the mockup's own spec leaves it open. The
     nearest in-palette candidate — a darkened tan — would put destructive actions
     in the same family as the notice card, which is the one warm surface in the
     system: "we have a tip for you" and "this deletes every photo you own" would
     be the same colour. Danger works by being unlike the rest of the palette.

     It is also the anchor of a chain four issues tuned by hand (#367 split fill
     from ink, #369 lifted the dark ink, #392 lifted it again against the wash
     rather than the bare ground, #341 found the cream-on-mid-tone failure), and
     none of that is improved by a hue change nobody asked for.

     5.96 on the new ground, 7.27 on the card, cream on it 6.90 — all up, because
     the grounds it sits on got lighter. */
  --danger: #8a3a1f;
  /* The deep end of the danger ramp — the BASE OF THE FILL, never ink. It is --danger
     itself, because a deep rust is both a readable ink and a fill that carries cream.
     The split existed so the dark theme could lift the INK (#369) without dragging the
     .pill.sick wash lighter underneath --text-on-danger; with one theme the two values
     coincide. Kept as separate names rather than collapsed, because the two jobs are
     still different jobs and #341/#367/#369/#392 are the record of what happens when
     one token tries to do both. */
  --danger-deep: var(--danger);
  /* Fill + ink for a danger-status chip, kept as an explicit PAIR: a deep rust carrying
     cream at 6.9:1. --text-on-accent cannot stand in for the cream here — it is tied to
     the accent, and #341 is what happens when a chip's ink is assumed rather than
     measured against the fill it actually sits on. */
  --danger-fill: var(--danger-deep);
  --text-on-danger: #f7f1e4;
  /* ── The top bar (#607) ──────────────────────────────────────────
     The mockup's one structural move, and the thing that gives forest weight on
     every screen rather than only on whichever button happens to be in view: the
     sticky bar is a forest slab with a cream wordmark. It is done entirely with
     these tokens plus local overrides on `header.topbar` — no markup moves, and
     the eleven admin pages inherit it for free, `.admin-exit` included, because
     the overrides land on --text/--text-dim/--border/--surface-2/--accent rather
     than on any one component.

     The wordmark is 11.10:1 on the slab and the account name 7.26:1; both are
     pinned by cta-contrast-acceptance, because a bar whose ink comes from token
     overrides is exactly the kind of surface a later token move breaks silently. */
  --topbar-fill: var(--terracotta);
  --topbar-ink: #f7f1e4;
  --topbar-ink-dim: #b8cabb;   /* 7.26:1 on forest — secondary, still comfortably legible */
  --topbar-line: color-mix(in srgb, var(--topbar-ink) 22%, transparent);
  --topbar-raise: color-mix(in srgb, var(--topbar-ink) 14%, transparent);
  /* ── On-photo ink (#534) ─────────────────────────────────────────
     Text sitting on a plant PHOTOGRAPH has no page ground behind it, so it has to bring
     its own. That was true when this system had two themes — a photo somebody took has no
     theme — and it stays true now that it has one: these are measured against the
     photograph, never against a page.

     The scrim is a UNIFORM alpha and the caption text sits only on the uniform part; the
     gradient in `.ps-caption::before` feathers into the photo above and carries no text.
     That is the whole contrast argument: a composite in sRGB is `a*scrim + (1-a)*photo`
     per channel, and relative luminance rises monotonically with every channel, so across
     ALL possible photographs the composite's luminance is bounded by its value over pure
     black and over pure white. Both ends are darker than the cream, so nothing in between
     can dip.

     #607 MOVES --ink, so this bound is re-derived rather than assumed: 12.34:1 on a black
     photo and 7.28:1 on a white one, against 14.0 / 8.35 when the ink was bark. Both ends
     fall — forest is lighter than bark — and both stay well clear of 4.5, and crucially
     both sit on the SAME side of the cream, which is what makes the interval argument hold
     at all. Two endpoints can each clear 4.5:1 with the midpoint at 1:1 if the ink is
     between them; cta-contrast-acceptance asserts the bracket, not just the two numbers.
     Checked, not claimed — `npm run room-carousel-acceptance` re-derives it too. */
  --photo-scrim: color-mix(in srgb, var(--ink) 86%, transparent);
  --text-on-photo: #f7f1e4;
  /* Opaque status-chip pairs, for a chip that lands on a photograph rather than on a page.
     THESE ARE NOW PLAIN ALIASES, and that is the one simplification removing dark actually
     buys (#607).

     They existed because the dark theme painted the three status chips as
     `color-mix(…, transparent)` washes — exactly right over a known --surface, and
     undefined over somebody's sunlit window, where 22% of anything on a white orchid is
     not a measured pairing but a coin flip. So a chip on a photo pinned the LIGHT pair,
     and the pin had to be a LITERAL rather than `var(--warn-fill)`, because a var()
     re-resolves against the same element's cascade and on a dark :root would have handed
     back the very wash it was there to avoid.

     With no dark cascade there is nothing left to defeat: --warn-fill and --danger-fill are
     fully opaque, unconditionally, so the alias and the literal are the same colour and the
     alias cannot drift. The names stay — `.ps-pills` reads them and they still say what
     they are for — and room-carousel-acceptance still asserts the pairing is legible; the
     twin that required them to be literals is retired with the cascade that motivated it.

     No neutral pair here on purpose: the base `.pill` is --surface-2 + --text-dim, fully
     opaque at 5.16:1, so it is already photo-proof. */
  --warn-fill-opaque: var(--warn-fill);
  --text-on-warn-opaque: var(--text-on-warn);
  --danger-fill-opaque: var(--danger-fill);
  --text-on-danger-opaque: var(--text-on-danger);
  --radius: 22px;            /* was 14px — Organic over-rounds containers */
  --radius-pill: 999px;
  /* Forest-based, not bark-based. A shadow is a tint of the ink that casts it;
     leaving these at rgba(51,38,26) put a warm brown haze under every card on a
     sage ground, which is the kind of leftover that makes a repaint look like a
     repaint. */
  --shadow: 0 1px 2px rgba(12,59,46,0.10), 0 6px 18px rgba(12,59,46,0.09);
}


* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

/* ── Hover is gated on hardware that HAS one (#543, swept in #545) ──────────
   On a touch screen a tap fires `mouseover`, the `:hover` rule matches, and it STAYS
   matched after the finger lifts — the control sits lit up until you tap somewhere
   else. Nearly every user here is on a phone, so an ungated `:hover` is a live
   papercut, and one of them is what made the room carousel feel broken.

   So every `:hover` in this file lives inside `@media (hover: hover) and (pointer:
   fine)`. Two rules of thumb when you add one:

   1. Wrap it WHERE IT SITS. A media query changes neither specificity nor source
      order, so an in-place wrap is exactly cascade-neutral on a mouse. Hoisting
      several into one shared block at the bottom is NOT: `.btn:hover` and
      `button.secondary` are both (0,2,0) and the base rule only wins because it comes
      later, so collecting the hover rules downward would turn every secondary button
      accent-green on hover.
   2. Gate `:hover` ONLY. `:focus-visible` and `:active` are how a keyboard and a
      finger respectively say "this one", and both must keep working on touch. Where a
      selector list mixes hover with state that is not about pointers — `.suggest-item`
      pairs it with `[aria-selected="true"]` — split the list and leave the other half
      outside.

   Measured rather than assumed, in a 375px touch emulation (maxTouchPoints 5, `(hover:
   none)` and `(pointer: coarse)` both matching), by re-serving this stylesheet with
   every `:hover` rewritten to a class of equal weight: before the sweep 18 controls
   rendered differently under a stuck hover, after it none do. The one rule that needed
   no gating is noted at the camera shutter. */
html, body { margin: 0; padding: 0; }
/* The hidden attribute must always win. Any author rule that sets `display` on an
   element (flex/grid/inline-flex/...) silently outranks the UA's [hidden] rule and
   the element renders anyway — bit us twice: #fbBtn (login) and the review-ask
   overlay (#165, visible on page load with no listeners bound = unclosable modal).
   JS on this site toggles visibility via the hidden attribute; this makes that
   contract enforceable site-wide. */
[hidden] { display: none !important; }
body {
  font-family: var(--font-body);
  font-size: var(--text-size-body);
  line-height: 1.55;
  background: var(--bg);
  color: var(--text);
  min-height: 100vh;
  padding-bottom: 104px;
  -webkit-font-smoothing: antialiased;
  text-wrap: pretty;
}

:focus { outline: none; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--accent) 30%, transparent); }

/* ── Top bar ─────────────────────────────────────────────────────── */
/* The mockup's forest slab (#607). Everything inside the bar is repainted by
   REDEFINING the five tokens its contents already read, rather than by naming
   the contents — `.who`'s icon links carry their colour as an inline
   `style="color:var(--text-dim)"` written by common.js's renderTopbar(), and a
   custom property inside an inline style resolves against THIS element's
   cascade, so the override reaches them. The same mechanism covers
   `.admin-exit` on all eleven admin pages, `.sit-back`, and the avatar-initial
   fallback, none of which this rule has to know about.

   --accent is overridden too, and that is not decoration: :focus-visible paints
   a 2px accent ring, and a forest ring on a forest bar is no ring at all.

   The bar is opaque now. 86% of --bg behind a blur is a device for letting the
   page show through; a slab the page scrolls under is what the mockup draws,
   and it is one less compositing layer on a phone. */
header.topbar {
  position: sticky; top: 0; z-index: 10;
  --text: var(--topbar-ink);
  --text-dim: var(--topbar-ink-dim);
  --border: var(--topbar-line);
  --surface-2: var(--topbar-raise);
  --accent: var(--topbar-ink);
  background: var(--topbar-fill);
  color: var(--topbar-ink);
  border-bottom: 1px solid var(--topbar-line);
  padding: max(14px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) 14px max(20px, env(safe-area-inset-left));
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px;
}
header.topbar h1 {
  font-family: var(--font-heading); font-weight: 400;
  font-size: 21px; margin: 0; letter-spacing: -0.01em;
  display: flex; align-items: center; gap: 9px;
}
header.topbar h1 a { color: inherit; text-decoration: none; }
header.topbar .who {
  display: flex; align-items: center; gap: 8px;
  font-size: 13px; color: var(--text-dim);
}
header.topbar .who img { width: 30px; height: 30px; border-radius: 50%; }

/* Admin's LABELLED way out (#536).
   The back-arrow in the admin <h1> means "up to Overview". On the Overview page
   there is nowhere further up, so that page spelled the same arrow, in the same
   place, as "leave admin" — and the one that looked like the exit was the one
   that wasn't. You landed on Overview and had to notice the glyph had changed
   meaning. The cure is not a second arrow, which would be two ambiguous icons
   instead of one: per CLAUDE.md §1 an unlabelled glyph is a convention someone
   learned rather than a thing anyone knows, so here the WORD carries the meaning
   and the house sits beside it as decoration. Every admin page gets this, the
   Overview included, and it is the only control on the surface that leaves.
   Deliberately not `.btn` — the filled accent pill would out-shout the page's
   own actions, and `.btn`'s 50px would dominate a sticky bar. */
.admin-exit {
  display: inline-flex; align-items: center; gap: 7px;
  min-height: 38px; padding: 7px 14px;
  border: 1px solid var(--border); border-radius: var(--radius-pill);
  background: transparent; color: var(--text);
  font-family: var(--font-body); font-size: 14px; font-weight: 600;
  white-space: nowrap; text-decoration: none;
}
.admin-exit svg { width: 17px; height: 17px; flex: none; }
@media (hover: hover) and (pointer: fine) {
  .admin-exit:hover { background: color-mix(in srgb, var(--text) 6%, transparent); text-decoration: none; }
}
.admin-exit:active { background: color-mix(in srgb, var(--text) 12%, transparent); }
/* The exit and the identity block travel together at the right end. `wrap` on the
   bar is what keeps a narrow phone from pushing the exit off the edge — .who on an
   admin page already carries a name plus four icons, so the row genuinely can run
   out of width, and a second line beats an escape hatch you cannot reach. */
header.topbar.admin-topbar { flex-wrap: wrap; }
.admin-topbar .topbar-actions {
  display: flex; align-items: center; flex-wrap: wrap;
  justify-content: flex-end; gap: 12px;
}

main { max-width: 720px; margin: 0 auto; padding: 20px; }

/* Section headings — small caps rules, left-aligned per the system */
h2 {
  font-family: var(--font-body);
  font-size: 12px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--text-dim); margin: 30px 0 10px;
}
h2:first-child { margin-top: 4px; }

a { color: var(--accent-strong); text-decoration: none; text-underline-offset: 3px; }
/* Gated with the rest, and it has to be: several controls that are anchors —
   `.admin-exit`, `.plant-slide` — carry a `text-decoration: none` in their own :hover
   purely to cancel this underline. Gate those and leave this one open and the
   cancellation disappears on touch while the underline it cancels does not, so a
   tapped Exit-admin pill would sit underlined until you tapped elsewhere. */
@media (hover: hover) and (pointer: fine) {
  a:hover { text-decoration: underline; }
}

/* ── Cards ───────────────────────────────────────────────────────── */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 18px;
  box-shadow: var(--shadow);
  margin-bottom: 12px;
}
.card.tap { cursor: pointer; display: block; color: inherit; transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease; }
@media (hover: hover) and (pointer: fine) {
  .card.tap:hover {
    border-color: var(--accent);
    box-shadow: 0 2px 4px rgba(46,43,37,0.08), 0 10px 26px rgba(46,43,37,0.10);
    transform: translateY(-1px);
    text-decoration: none;
  }
}
.card.tap:active { transform: translateY(0); }

.row { display: flex; align-items: center; gap: 14px; }
.row.between { justify-content: space-between; }
.row.wrap { flex-wrap: wrap; }
.grow { flex: 1; min-width: 0; }
.stack { display: flex; flex-direction: column; gap: 12px; }

/* A row of actions whose LABELS all have to stay readable. `.row` + `.grow` cannot
   do that: `.row` never wraps, and `.grow`'s `min-width: 0` is exactly what lets a
   button shrink past its own text — which is how "Keep current health" and "Apply
   new assessment" ended up colliding on a 375px phone (#547). Truncating or
   shrinking the type was the wrong trade for this audience (CLAUDE.md §1), and
   choosing whether to replace a health assessment is not a choice to squint at.

   `flex-basis: max-content` makes each action's untruncated label its own floor for
   the line-breaking decision, so a pair that cannot sit side by side takes a full
   row each instead. `.grow`'s `min-width: 0` is deliberately left in place under
   that: it only comes into play once an action is alone on its line and STILL too
   wide, and there `white-space: normal` spends the overflow on a second line of
   text rather than on a clipped label or a page that scrolls sideways.

   Container-driven rather than a viewport breakpoint, because these rows sit in
   cards of several different widths on the same screen. */
.row.actions { flex-wrap: wrap; }
.row.actions > .grow { flex: 1 1 max-content; white-space: normal; }

/* ── Pills ───────────────────────────────────────────────────────── */
.pill {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 4px 11px; border-radius: var(--radius-pill);
  background: var(--surface-2); color: var(--text-dim);
  font-size: 12px; font-weight: 600; letter-spacing: 0.01em;
}
.pill svg { width: 13px; height: 13px; stroke-width: 2.75; }
.pill.water { background: var(--sage-700); color: #f7f1e4; }
.pill.skip { background: var(--surface-2); color: var(--text-dim); }
/* Overdue / concerning need to read as warnings — but the accent is now green,
   which is the "healthy" signal. Use an olive-warn tint so warning pills stay
   visually distinct from both the accent green and the sage healthy pill.
   Contrast: #524815 on #ecebc3 = 8.5:1 (light). The pair is themed, so there is
   no longer a dark override here — see --warn-fill / --text-on-warn. */
.pill.overdue { background: var(--warn-fill); color: var(--text-on-warn); }
.pill.healthy { background: var(--sage-200); color: var(--sage-800); }
.pill.concerning { background: var(--warn-fill); color: var(--text-on-warn); }
.pill.sick { background: var(--danger-fill); color: var(--text-on-danger); }

/* ── Buttons — pill, heading face ────────────────────────────────── */
button, .btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  min-height: 50px; padding: 13px 24px;
  border: 1px solid transparent; border-radius: var(--radius-pill);
  background: var(--accent); color: var(--text-on-accent);
  font-family: var(--font-heading); font-weight: 400;
  font-size: 16px; letter-spacing: 0.005em;
  white-space: nowrap;
  cursor: pointer;
  transition: background .12s ease, transform .1s ease;
}
button svg, .btn svg { width: 18px; height: 18px; stroke-width: 2.75; flex: none; }
@media (hover: hover) and (pointer: fine) {
  button:hover, .btn:hover { background: var(--accent-strong); text-decoration: none; }
}
button:active, .btn:active { transform: scale(0.985); }
button:disabled, .btn:disabled { opacity: 0.45; cursor: not-allowed; }
button.secondary, .btn.secondary {
  background: transparent; color: var(--text); border-color: var(--border);
}
@media (hover: hover) and (pointer: fine) {
  button.secondary:hover, .btn.secondary:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
}
button.secondary:active, .btn.secondary:active { background: color-mix(in srgb, var(--text) 12%, transparent); }
button.danger, .btn.danger { background: transparent; color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }
@media (hover: hover) and (pointer: fine) {
  button.danger:hover, .btn.danger:hover { background: color-mix(in srgb, var(--danger) 10%, transparent); }
}
button.full, .btn.full { width: 100%; }

/* ── Inputs — pill fields, sand fill ─────────────────────────────── */
input, select, textarea {
  width: 100%; padding: 13px 18px; min-height: 50px;
  background: var(--surface-2); color: var(--text);
  border: 1px solid transparent; border-radius: var(--radius-pill);
  font-size: 16px; font-family: var(--font-body);
  caret-color: var(--accent);
}
textarea { border-radius: var(--radius); resize: vertical; }
@media (hover: hover) and (pointer: fine) {
  input:hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--text) 22%, transparent); }
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--accent); }
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: none; border-color: var(--accent); }
input[type="checkbox"] { width: auto; min-height: 0; accent-color: var(--accent); margin-right: 8px; }
label { display: block; font-size: 12px; color: var(--text-dim); margin-bottom: 7px; font-weight: 600; letter-spacing: 0.02em; }
.field { margin-bottom: 16px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }

/* ── Credential block — a bearer URL shown back to its owner ─────── */
/* Monospace because these strings get read character by character when someone
   is checking they pasted the right one, and word-break because they are long
   and must never widen the card on a phone. `.masked` is the resting state: a
   settings page ends up in screenshots and screen shares, and a calendar feed
   URL grants access to a Home with no sign-in behind it. */
.credential {
  display: block; width: 100%;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px 14px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 13px; line-height: 1.5;
  color: var(--text);
  word-break: break-all; overflow-wrap: anywhere;
}
.credential.masked { color: var(--text-dim); }

/* ── Photos ──────────────────────────────────────────────────────── */
.plant-photo {
  width: 60px; height: 60px; border-radius: 18px; object-fit: cover;
  background: var(--surface-2); flex-shrink: 0;
  filter: saturate(0.85) contrast(0.94);
}
.plant-photo.big {
  width: 100%; height: 240px; border-radius: var(--radius); margin-bottom: 18px;
}
/* Fallback tile when a plant has no photograph yet. The mockup paints this
   UNDILUTED sage with a forest sprout on it — full strength is legal here and
   nowhere else, because the only thing that ever sits on this tile is an icon
   (4.31:1, over the 3.0 an icon owes, under the 4.5 a word would). The pairing
   is now the --sage-fill/--sage-ink token pair, which is themed, so the dark
   override this rule used to carry — and the hard-coded #89d294 inside it —
   are gone (#599's shape, in common.css itself). */
.plant-photo.placeholder {
  display: flex; align-items: center; justify-content: center;
  background: var(--sage-fill); color: var(--sage-ink); filter: none;
}
.plant-photo.placeholder svg { width: 24px; height: 24px; stroke-width: 2.75; }
.plant-photo.big.placeholder svg { width: 56px; height: 56px; }

/* ── Bottom action bar ───────────────────────────────────────────── */
.action-bar {
  position: fixed; bottom: 0; left: 0; right: 0;
  padding: 12px max(16px, env(safe-area-inset-right)) calc(14px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: saturate(1.4) blur(14px);
  border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
  display: flex; gap: 10px; justify-content: center;
  z-index: 20;
}
.action-bar > * { flex: 1; max-width: 220px; min-height: 52px; }

/* ── Verdict ─────────────────────────────────────────────────────── */
.verdict {
  padding: 30px 24px; border-radius: 32px; text-align: center;
  font-family: var(--font-heading); font-weight: 400;
  font-size: 30px; line-height: 1.1;
  display: flex; flex-direction: column; align-items: center; gap: 6px;
}
.verdict svg { width: 40px; height: 40px; stroke-width: 2.75; margin-bottom: 4px; }
/* Deep green, not --water: the 15px .reasoning underneath needs 4.5:1, which #50840b misses. */
.verdict.water { background: var(--sage-700); color: #f7f1e4; }
.verdict.skip { background: var(--sage-200); color: var(--sage-800); }
.verdict.unknown { background: var(--warn-fill); color: var(--text-on-warn); }
.verdict .reasoning {
  font-family: var(--font-body);
  font-size: 15px; font-weight: 400; line-height: 1.45;
  margin-top: 6px; opacity: 0.88; max-width: 34ch;
}

/* ── Verdict detail — the cold-start layer (#182, surfaced by #322) ─ */

/* The honest-confidence marker. Tinted from currentColor rather than from a token
   on purpose: it sits INSIDE .verdict, which has three palettes across two themes
   (cream on dark green, dark green on a light tint, olive on olive), and any fixed
   pair would fail against at least one of them. Inheriting the foreground the
   surrounding block already cleared 4.5:1 with makes that unfalsifiable. */
.verdict .confidence {
  font-family: var(--font-body);
  font-size: 12px; font-weight: 700;
  letter-spacing: 0.04em; text-transform: uppercase;
  padding: 4px 13px; border-radius: var(--radius-pill);
  background: color-mix(in srgb, currentColor 15%, transparent);
  border: 1px solid color-mix(in srgb, currentColor 32%, transparent);
  margin-top: 4px;
}

/* ── The affiliate disclosure (#644) ─────────────────────────────────────
   Shared because there are now THREE placements across two app pages plus the
   landing/start pages: first-check's answer card and no-meter card, and check's
   verdict card. rel="sponsored" is a signal to crawlers; this is the disclosure to
   a person, and the FTC wants it close to the link and legible. Of everything on a
   verdict card, the mandated line is the last one that may be hard to read.
   Body size via the token, never a footnote (§1 — presbyopia is near-universal in
   this audience). The in-verdict override inherits the foreground the surrounding
   block already cleared 4.5:1 with, exactly as .reasoning and .confidence above do:
   .verdict has three palettes, so a fixed --text-dim lands at 1.93:1 on
   .verdict.water and any other fixed pair fails against one of them too. */
.affiliate-note {
  font-family: var(--font-body);
  color: var(--text-dim);
  font-size: var(--text-size-body);
  line-height: 1.5;
  margin: 10px 0 0;
}
.verdict .affiliate-note { color: inherit; opacity: 0.88; }

/* The meter link (#327), sitting under the sentence that offered it rather than in a card
   further down — it is the answer to the "no verdict" the user just read, and separating
   the two costs it the context that makes it worth tapping.

   Tinted from currentColor for the same reason .confidence above is, and NOT from
   --accent/--text/--border: .btn.secondary's tokens are page-level, so on .verdict.unknown
   they resolve against the page ground, not the olive panel this actually sits on — the
   border all but vanishes in dark. currentColor is the foreground .verdict has already
   cleared 4.5:1 with on every one of its three palettes.

   white-space overrides .btn's nowrap: the longest label is 32 characters and a pill that
   refuses to wrap overflows the block at 375px. A stadium shape survives two lines. */
.verdict .btn.meter-link {
  margin-top: 10px;
  max-width: 100%;
  min-height: 44px; padding: 11px 22px;
  font-size: 15px; line-height: 1.25;
  white-space: normal;
  color: inherit;
  background: color-mix(in srgb, currentColor 12%, transparent);
  border-color: color-mix(in srgb, currentColor 38%, transparent);
}
@media (hover: hover) and (pointer: fine) {
  .verdict .btn.meter-link:hover { background: color-mix(in srgb, currentColor 22%, transparent); }
}
.verdict .btn.meter-link:active { background: color-mix(in srgb, currentColor 30%, transparent); }

/* One line of cold-start prose with its icon in the gutter. */
.vd-note { display: flex; gap: 11px; align-items: flex-start; font-size: 15px; line-height: 1.45; }
.vd-note + .vd-note { margin-top: 14px; }
.vd-note > svg { flex: none; margin-top: 3px; color: var(--accent-strong); }
/* Only the icon and the rule take the warn tone; the sentence stays --text.
   --warn #7a5a00 on --surface is 5.31:1, which is comfortable for a 18px glyph
   and a 1px rule and still NOT what you would choose for 15px prose — the caveat
   is the one line here that must be read, so it does not get to be the one line
   that is hard to read. (Was #7d6f1c at 4.2:1 before #576 rebuilt --warn from
   amber's dark sibling.) */
.vd-note.warn > svg { color: var(--warn); }
.vd-note.warn {
  border-left: 3px solid var(--warn);
  padding-left: 13px;
}

.vd-checks { list-style: none; margin: 16px 0 0; padding: 14px 0 0; border-top: 1px solid var(--border); }
.vd-checks li { display: flex; gap: 11px; align-items: flex-start; color: var(--text-dim); font-size: 14px; line-height: 1.45; }
.vd-checks li + li { margin-top: 10px; }
.vd-checks svg { flex: none; margin-top: 3px; }

/* The potting ask. TAN-tinted so it reads as an invitation rather than as
   another advisory — it is the one block on this screen that wants a tap, and
   in the mockup it is the one warm surface anywhere in the product (#607).
   Forest on it is 9.55:1, dim ink 4.94:1. */
.vd-ask { border-color: var(--notice-border); background: var(--notice-fill); }
.vd-ask legend {
  font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--text-dim); padding: 0;
}
.vd-ask fieldset { border: 0; padding: 0; margin: 16px 0 0; }
.vd-ask .chip-row { margin: 8px 0 0; }

/* ── Chips ───────────────────────────────────────────────────────── */
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; margin: 4px 0 18px; }
.chip {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 8px 16px; min-height: 38px;
  border-radius: var(--radius-pill);
  background: transparent; color: var(--text);
  border: 1px solid var(--border);
  font-family: var(--font-body); font-size: 14px; font-weight: 600;
  cursor: pointer; user-select: none;
}
@media (hover: hover) and (pointer: fine) {
  .chip:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
}
.chip[aria-pressed="true"] { background: var(--accent); color: var(--text-on-accent); border-color: var(--accent); }
.chip .count {
  background: color-mix(in srgb, var(--text) 10%, transparent);
  padding: 1px 8px; border-radius: var(--radius-pill);
  font-size: 12px; font-weight: 700;
}
.chip[aria-pressed="true"] .count { background: rgba(255,255,255,0.26); color: var(--text-on-accent); }

/* THE FILTER ROW WRAPS, and there is no scroller here any more (#705).

   It was a sideways scroller from #588 — Stephen's own words, "make the pills up top a scroll
   bar like in Uber Eats" — until he overruled that himself. The premise had changed underneath
   the instruction: what he asked for was *the peek plus a visible scrollbar*, and #605/#620 then
   took the painted bar off touch (correctly — under a finger it was a permanently painted
   divider, not a control), so half of the affordance stopped existing on the one surface where
   it mattered and the peek was left carrying it alone.

   MEASURED against this stylesheet at HIS viewport, 360x780 (Galaxy S24 at DPR 3): 667px of
   pills in a 360px box, 307px of overflow, and at scrollLeft 0 `Healthy` 64% visible with
   `Concerning` and `Sick` at 0%. Two of the five filters were reachable only by a swipe, on the
   app's primary screen, for an audience CLAUDE.md §1 says to assume has learned no such gesture.
   That is §1's "never make content disappear without an obvious way back", and it had been true
   since #588 shipped.

   THE COST IS REAL AND IT WAS HIS CALL: two rows push "All plants by room" from 178px to 246px,
   68px of first screen, and put 0 chips out of reach. #705 costs the alternatives — a labelled
   Previous/Next pair under the scroller was 48px and still left three chips behind a press, and
   shortening the labels is ruled out by arithmetic rather than taste (595px of pill against a
   320px content box, with §1 forbidding smaller type).

   #543'S PROHIBITION IS SATISFIED BY DELETION, NOT CIRCUMVENTED. "Never hide the scrollbar"
   exists so nothing is reachable only by a learned gesture. There is no scroller here now, so
   there is no bar to hide and nothing sitting behind one — every chip is on screen at every
   viewport this app sees. The rule is untouched and still binds `.carousel-track`, which IS
   still a scroller; do not read this deletion as a precedent for that one.

   WHAT WENT WITH IT, so nobody goes looking: `flex-wrap: nowrap`, `overflow-x: auto`,
   `overscroll-behavior-x: contain`, the `margin-inline: -20px` bleed that made the peek a real
   cut edge, `scroll-padding-inline`, this row's half of the shared scrollbar block below, and
   index.html's scroll-the-pressed-chip-into-view call (nothing can be off screen to scroll to).
   The whole #590/#602/#635 gesture argument went too — an element that does not scroll has no
   axis to arbitrate. That mechanism is NOT lost: it is written out at `.carousel-track.can-pan`,
   which still carries `touch-action: pan-x` on measured overflow and is the scroller it now
   belongs to. `scripts/chip-gesture-harness.mjs chips` no longer drives a scroller either — it
   measures this wrapped row instead, and what it pins is that every chip is whole on screen and
   that a vertical drag starting on the pills still scrolls the page.

   THE 44px IS WHY THIS IS STILL A MODIFIER. #590 raised these pills to the platform touch
   minimum and that had nothing to do with scrolling: the base pill is 38px, and these five are
   the primary screen's main control on a phone (§1). It stays scoped rather than raised on
   `.chip` itself because the base pill is also check.html's verdict-ask, first-check.html's, and
   four rows in the admin panel — none of those asked to be resized. */
.chip-row.chip-filters > .chip { min-height: 44px; }

/* ── Suggestions ─────────────────────────────────────────────────── */
.suggest-wrap { position: relative; }
.suggest-list {
  position: absolute; top: calc(100% + 6px); left: 0; right: 0;
  background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--radius);
  box-shadow: var(--shadow);
  max-height: 240px; overflow-y: auto; z-index: 30; display: none;
  padding: 6px;
}
.suggest-list[data-open="1"] { display: block; }
.suggest-item {
  padding: 11px 14px; cursor: pointer; font-size: 16px;
  border-radius: 14px; border-bottom: none;
}
/* Split, not wrapped whole: `[aria-selected="true"]` is where keyboard arrowing puts
   the highlight and has nothing to do with a pointer, so it stays outside the query.
   Both halves paint the same fill, which is why one rule was tempting. */
.suggest-item[aria-selected="true"] { background: var(--surface-2); }
@media (hover: hover) and (pointer: fine) {
  .suggest-item:hover { background: var(--surface-2); }
}

/* ── Room groups ─────────────────────────────────────────────────── */
details.location-group { margin-bottom: 10px; }
details.location-group > summary {
  list-style: none; cursor: pointer;
  padding: 10px 4px; margin: 20px 0 6px;
  font-family: var(--font-body);
  font-size: 12px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--text-dim);
  display: flex; align-items: center; gap: 10px; user-select: none;
}
details.location-group > summary::-webkit-details-marker { display: none; }
details.location-group > summary::before {
  content: ''; display: inline-block;
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); opacity: 0.55;
  transition: transform .15s;
}
details.location-group[open] > summary::before { transform: scale(1.45); opacity: 1; }
details.location-group > summary .count {
  margin-left: auto; background: var(--surface-2); color: var(--text-dim);
  padding: 2px 10px; border-radius: var(--radius-pill);
  font-size: 12px; letter-spacing: 0; text-transform: none;
}

/* ── The room CARD (#610) ────────────────────────────────────────────
   Stephen mocked three treatments for a room section and chose 2b: one cream container per
   room, holding its header, its carousel and its pager. Containment rather than a divider,
   and the reason is a §1 reason rather than a decorative one — the old full-width layout let
   a pager sit under a rule with nothing binding it to the room above it, so on a page of
   four rooms the two arrows nearest your thumb could plausibly belong to either neighbour.
   A card makes that ambiguity unstateable: the pager is inside the room's own box.

   A MODIFIER, not a promotion of the base. `details.location-group` is also upgrade.html's
   "Have a code?" disclosure, which already sits inside a `.card` — styling the base would
   nest a card in a card there. The base keeps the dot, the count pill and the summary
   layout; everything a ROOM additionally is lives under this class.

   --room-pad is read by .carousel-track below, which bleeds its slides out to the card's
   inner edge. It is a variable rather than a repeated literal because the two numbers have
   to agree or the peeked slide stops being cut off by the card and starts stopping tidily
   inside a margin, which is the affordance that says "there is more" (see the track). */
details.location-group.room-card {
  --room-pad: 16px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 2px var(--room-pad) 12px;
  margin-bottom: 16px;
}
/* The room name, in the mockup's display face. It was a 12px uppercase letterspaced eyebrow
   in --text-dim; it is now 20px Caprasimo in full forest ink — 11.71:1 on the card, and the
   single biggest legibility gain on this screen for the audience §1 describes. Nothing here
   removes the disclosure: the summary is still the control, still `cursor: pointer`, and
   still carries the sage dot that scales when the room opens. */
details.location-group.room-card > summary {
  font-family: var(--font-heading); font-weight: 400;
  font-size: 20px; line-height: 1.15;
  text-transform: none; letter-spacing: normal;
  color: var(--text);
  padding: 14px 2px 10px; margin: 0;
}
/* Scaled with the name it sits beside — a 7px dot next to 12px small caps and a 7px dot next
   to a 20px display face are not the same mark. */
details.location-group.room-card > summary::before { width: 9px; height: 9px; }
/* 14px, not the base's 12px. This was the ONLY place the room's plant count appeared in
   digits while the pager was a row of dots, and §1's "never encode information in size or
   colour alone" is what those dots leaned on. #627 replaced them with a sentence that states
   its own number, so this pill is no longer propping the pager up — but it is still the number
   a reader checks against a room she has COLLAPSED, which the pager cannot answer because it
   is inside the room. Still the wrong place to save two pixels. */
details.location-group.room-card > summary .count {
  font-size: 14px; padding: 3px 12px; font-weight: 700;
}

/* Non-collapsible location subhead — used inside "Due for a check" to group by room
   without taking part in the expand-state key that details.location-group persists (#519). */
.location-subhead {
  padding: 10px 4px; margin: 14px 0 6px;
  font-family: var(--font-body);
  font-size: 12px; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--text-dim);
  display: flex; align-items: center; gap: 10px;
}
.location-subhead .count {
  margin-left: auto; background: var(--surface-2); color: var(--text-dim);
  padding: 2px 10px; border-radius: var(--radius-pill);
  font-size: 12px; letter-spacing: 0; text-transform: none; font-weight: 600;
}

/* ── Room carousel (#534) ────────────────────────────────────────────
   A room's plants as a gallery of large photographs instead of a list of small ones.

   The track is a REAL overflow scroller with native CSS scroll-snap, and that is the whole
   accessibility argument for the component rather than an implementation detail. A trackpad,
   a touch drag, a scrollbar, the two arrow buttons below it, and simply TABBING to the next
   slide (the browser scrolls a focused link into view) all advance it, with no pointer
   listener anywhere in the page. A horizontal swipe is a convention someone learned, and a
   large part of this audience has not learned it — CLAUDE.md §1. Nothing here may become
   reachable only by that gesture, which is also why the arrows are on every form factor
   rather than desktop-only: the phone is precisely where the gesture-only version fails. */
.room-carousel { margin-bottom: 6px; }

.carousel-track {
  display: flex; gap: 12px;
  overflow-x: auto; overflow-y: hidden;
  /* PROXIMITY, not mandatory (#543). A slide is min(86%, 300px), so on a 375px phone the
     only resting positions under `mandatory` are 0, 300, 600 — there is none in between,
     and any drag shorter than half a slide is silently returned to exactly where it
     started. Measured in a 375px touch emulation against this stylesheet, with smooth
     scrolling switched off so it could not be the confound: under `mandatory`, offsets of
     20/50/80/110/120/130/150px ALL resolved back to 0 and only 170 advanced; under
     `proximity` everything from 120px up was left where the reader put it. A slow
     deliberate drag — the gesture this audience makes, CLAUDE.md §1 — lands squarely in
     that annihilated band, and a control that undoes your input reads as "it didn't move".
     This was the cause of the reported stick. Do not restore `mandatory` while a slide is
     this wide relative to the viewport. */
  scroll-snap-type: x proximity;
  scroll-behavior: smooth;
  overscroll-behavior-x: contain;
  -webkit-overflow-scrolling: touch;
  list-style: none; margin: 0; padding: 4px 0 12px;
  /* Bleed out to the container's inner edge so the peeked slide is genuinely cut off rather
     than a sliver pressed against a margin — the peek IS the affordance that says "there is
     more". scroll-padding puts a snapped slide back in line with the text above it.
     The number USED TO BE main's 20px gutter, because a room was a full-width section of the
     page. Since #610 a room is a card, so the edge that has to do the cutting is the card's,
     and the value comes from --room-pad on `details.location-group.room-card` — one
     declaration, so the padding and the bleed cannot drift apart. The 20px fallback is not
     dead code: it is what any future non-card carousel would land on, and it is the old
     behaviour exactly. There is still no spacing token that carries either number. */
  margin-inline: calc(var(--room-pad, 20px) * -1);
  padding-inline: var(--room-pad, 20px);
  scroll-padding-inline: var(--room-pad, 20px);
  /* The scrollbar STYLING lives below, in a fine-pointer query. It is NOT hidden anywhere
     — see the note on that block (#605/#620). It used to be shared with the filter chip
     row; since #705 wrapped that row, this is the only scroller left in it. */
}
/* THE GESTURE (#627). #602 gave the filter chip row `touch-action: pan-x` after #590 spent
   three rounds establishing that Blink hands a horizontal drag to the page rather than to a
   nested scroller unless this property says otherwise. That finding was then applied to one of
   the product's three horizontal scrollers and nobody asked about the other two, so Stephen hit
   this one on his phone three weeks later.

   THE MECHANISM IS SPELLED OUT HERE because this is now the only element in the product that
   declares it: #635 took it off the chip row, and #705 removed that scroller altogether along
   with the long note that used to carry this argument. Blink picks a scroll axis ONCE, when
   movement first passes the ~8px touch slop, and never revisits it. So `pan-x` does not
   redirect a gesture it disallows, it DROPS it — a drag drifting 12px upward before turning
   sideways moves nothing at all, and on an element with `overflow-y: hidden` (both of ours)
   there is nowhere for it to chain to either. That is the standing cost, and it is why this
   property may only go on an element measured to need it. On the chip row the numbers came out
   against it: 66px of strip on a 780px screen, and a thumb crossing a 6.2" phone to reach it
   arcs — so nearly every real gesture drifted past the slop and the app answered by doing
   nothing at all. Stephen's video measured row 0px AND page 0px across four continuous seconds.
   Here they come out the other way, and they were MEASURED rather than argued
   (`node scripts/chip-gesture-harness.mjs carousel` — real touch through CDP at 375px):

     - The band is 340px tall, 42% of the viewport, against the chip strip's 66px. If pan-x
       created a dead zone that size this rule would be indefensible.
     - IT DOES NOT, because the band is ALREADY vertically dead. Under `touch-action: auto` —
       the un-fixed stylesheet — a straight vertical drag started on the track moves nothing at
       all: not the track, not the page. `overflow-y: hidden` above makes this a scroll
       container with no room to scroll in y, and Blink consumes the gesture there rather than
       chaining it to the page. The harness's own NEGATIVE CONTROL is what makes that
       trustworthy instead of convenient: forced to `pan-y`, the same drag at the same point
       scrolls the page 187px, so chaining IS visible to the measurement when it happens — it
       simply does not happen here.
     - So pan-x takes no working gesture away. It takes an already-dead band and makes it
       reliably horizontal. The page scroll adjacent to it — the pager, the card gap, the next
       room's summary — measures 188px and is untouched.
     - The narrow regression #602 named is still real: Blink picks the axis once at the ~8px
       slop and never revisits, so a drag drifting 12px upward before turning sideways moves
       nothing. An 8px drift, and a ~30° diagonal, both scroll the track.

   GATED ON `can-pan`, which syncCarousel() in site/index.html puts on a track it has MEASURED
   to overflow. A room with one plant, or a room whose slides all fit on a tablet, has nothing
   to reach sideways — and a dead band bought for nothing is the one version of this change that
   could only cost. The class is absent until the page's module runs, so if that never happens
   the failure mode is today's behaviour rather than a page that will not scroll.

   NEVER move this onto a wrapper: one element out and the dead zone grows to whatever that
   element covers, for nothing, because the track is the only thing here that scrolls. And note
   that the third scroller, table.html's `.table-wrap`, deliberately did NOT get this — the same
   harness measured 79% of the viewport going dead there with no adjacent room at all. The
   reasoning is written at that rule; do not "finish the sweep" without reading it. */
.carousel-track.can-pan { touch-action: pan-x; }

/* ── Who gets a scrollbar we painted (#605/#620) ──────────────────────
   ONE SCROLLER SINCE #705. This block covered `.chip-row.chip-scroll` too, with the same
   four declarations for the same reasons, kept together here because two copies of a rule
   nobody can see the effect of is the drift this repo keeps paying for. That row wraps now
   and is not a scroller, so its selectors are gone rather than moved — there is no second
   copy anywhere. Keep this one block if a second scroller ever earns a painted bar.

   NOTHING HERE IS HIDDEN, and that prohibition (#543) is unchanged. `scrollbar-width:
   none`, `display: none` and a zero-size `::-webkit-scrollbar` are still forbidden here,
   because the bar is one of the non-gesture routes to the far slides, and §1 forbids
   anything being reachable only by a learned swipe. #705 satisfied the same constraint for
   the chips the other way — by deleting the overflow, so nothing sits behind a gesture at
   all. Deleting a scroller is allowed; hiding its bar is not.

   What changed is WHO WE PAINT IT FOR. Declaring `scrollbar-color` takes Blink off overlay
   scrollbars, so what shipped was a bar that is permanently visible, full-bleed from x=0
   (the track bleeds out through its container's gutter, and a native bar tracks the
   padding box), and painted in `--border` — the token the app's actual dividers use. On a
   phone that is not an affordance, it is a rule: Stephen read it as "something someone
   forgot to remove" (#605), and in the room card it was the first of three stacked
   affordances all saying "there is more" (#620). Under a finger it is also not a control
   at all — nobody drags an 8px bar with a thumb, and iOS never drew ours anyway.

   So the styling is scoped to a device where the bar IS a control someone can grab: a
   pointer that is fine, on a machine that hovers. There, everything is exactly as #543
   shipped it — measured with this stylesheet in headless Chrome 152 at 1280 with no touch
   emulation: `scrollbar-width: thin`, `scrollbar-color: rgb(198,210,193) rgba(0,0,0,0)`,
   11px gutter. At 375 under touch emulation both resolve to `auto` and the gutter is 0, so
   what a phone gets is the UA's own bar rather than ours.

   WHAT IS NOT MEASURED, and must not be read here as though it were: that the UA bar
   FADES. On Android Chrome and iOS Safari the platform bar is transient — it appears while
   the scroller moves and goes away after — and that is the behaviour this change banks on.
   Under headless mobile emulation nothing fades: a screenshot five seconds after a scroll
   is identical to one taken 120ms after, for the UA's bar and for ours alike, so the
   emulator cannot answer it either way (which is also why #605's session could not settle
   it). Evidence tier 3, CLAUDE.md §13 — it wants one look at a real phone. If it turns out
   NOT to fade there, what this traded is an olive rule for a grey one, and the answer then
   is a coarse-pointer branch painting it in something that is not the divider token — not
   a return to painting it in --border.

   MEASURED (Chrome 152, desktop, classic scrollbars, so thickness discriminates — the
   thing #605 could not settle under mobile emulation, where every variant is an overlay
   and the gutter is 0 for all of them):

     scrollbar-width: thin + ::-webkit-scrollbar height 8px  → 11px gutter
     scrollbar-width: thin + ::-webkit-scrollbar height 24px → 11px gutter   (webkit ignored)
     no standard props     + ::-webkit-scrollbar height 24px → 24px gutter   (webkit live)
     no standard props     + ::-webkit-scrollbar height 8px  →  8px gutter
     no standard props     + no webkit rules                 → 15px gutter   (UA default)

   That settles the claim the old comment made and #605 flagged as untested: the standard
   property really does move a current Blink onto the standard path and make the pseudo-
   element rules inert, so tune `scrollbar-width`/`scrollbar-color` and not the pseudo-
   elements. It is also why the ::-webkit rules had to come INSIDE this query rather than
   stay outside it: rows 3 and 4 above are what a coarse-pointer device would have got —
   the pseudo-elements waking up and painting the same 8px bar in the same divider colour,
   which is the defect wearing a different rule. */
@media (hover: hover) and (pointer: fine) {
  .carousel-track {
    scrollbar-width: thin;
    scrollbar-color: var(--border) transparent;
  }
  /* The fallback for engines that predate the two standard properties — Chrome/Edge before
     121, Safari before 18.2 — and NOT a second live mechanism running alongside them (see
     the measurement above). The height is the `thin` bar's rough equivalent, and no token
     carries it. */
  .carousel-track::-webkit-scrollbar { height: 8px; }
  .carousel-track::-webkit-scrollbar-track { background: transparent; }
  .carousel-track::-webkit-scrollbar-thumb {
    background: var(--border); border-radius: var(--radius-pill);
  }
}
/* `position: relative` is here for the quick Check control (#643), which is a SIBLING of
   `.plant-slide` rather than a child — a button inside an anchor is invalid HTML and costs
   that anchor its Tab order and its context menu. This <li> is the positioning context, so
   the control lands on the photo without ever entering the link. */
.carousel-slide { position: relative; flex: 0 0 auto; scroll-snap-align: start; width: min(86%, 300px); }
/* The 86% above is the PEEK — it exists so the next slide is visibly cut off. A room with
   one plant has no next slide, so the same number is just 14% of empty cream inside the
   card (#610). Full-width across the page it read as the scroller's resting position;
   inside a container it reads as a layout bug. There is nothing to peek at, so don't.
   The 360px cap is what stops the fix overshooting on a wide window: unbounded, a 4:5 tile
   in a 680px card is over 800px tall, so a room with one plant would fill a laptop screen
   with one photograph. Phones (§1's form factor) are under the cap and go flush. */
.carousel-slide:only-child { width: min(100%, 360px); }

.plant-slide {
  display: block; position: relative; overflow: hidden;
  aspect-ratio: 4 / 5;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--shadow);
  color: var(--text-on-photo);
  transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease;
}
/* Every :hover in this component is gated on a device that HAS a hover (#543). Measured in
   a 375px touch emulation: a touch on a slide fires `mouseover`, `.plant-slide:hover`
   matches, and it stays matched after the finger is gone — so an untouched slide sat
   lifted and accent-bordered until the reader tapped something else. That is the classic
   sticky-hover-on-touch, and while it was NOT what produced the reported stick (the snap
   threshold above was — it reproduces with no pointer involved at all), it is a real
   defect on its own and a hover transform under a finger is the wrong thing to animate
   mid-drag. The rest of the stylesheet was swept the same way in #545.
   The thumb rule is on the legacy pseudo-element path and so is inert wherever the
   standard properties apply — see the note above it. It is kept for the old engines that
   path exists for, not because it is doing the work in a current browser. */
@media (hover: hover) and (pointer: fine) {
  .plant-slide:hover { border-color: var(--accent); transform: translateY(-1px); text-decoration: none; }
  .carousel-track::-webkit-scrollbar-thumb:hover { background: var(--text-dim); }
}
.plant-slide:active { transform: translateY(0); }
/* The global :focus-visible outline is drawn OUTSIDE the slide, and the slide sits inside a
   clipping scroller — pull the ring inside so a keyboard user can see where they are on the
   first and last slide too. */
.plant-slide:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }

/* The photo fills the tile. `.plant-photo.big` is the existing 240px-tall detail-screen
   variant; inside a slide it is the whole card, so the fixed height and its trailing margin
   are dropped rather than a fourth photo size being invented. */
.plant-slide .plant-photo,
.plant-slide .plant-photo.big { width: 100%; height: 100%; margin-bottom: 0; border-radius: 0; }

.ps-caption {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 11px 14px 13px;
  background: var(--photo-scrim);
  color: var(--text-on-photo);
}
/* Cosmetic feather into the photograph. It carries no text on purpose: see --photo-scrim. */
.ps-caption::before {
  content: ''; position: absolute; left: 0; right: 0; bottom: 100%; height: 46px;
  background: linear-gradient(to top, var(--photo-scrim), transparent);
  pointer-events: none;
}
.ps-name {
  font-family: var(--font-heading); font-weight: 400;
  font-size: 19px; line-height: 1.2;
}
/* 14px cream rather than the card's 13px --text-dim. Dim ink is tuned against a page ground
   and has no meaning on a photograph; and this audience does not need a second reason to
   squint (CLAUDE.md §1), so the secondary line gets the same 8.4:1 the name does. */
.ps-type { font-size: 14px; line-height: 1.35; margin-top: 1px; }
.ps-pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 9px; }

/* ── The quick Check control (#643) ───────────────────────────────────────────
   One tap from the plant list into that plant's meter reading. Requested by a Pro customer
   (#643, one of three paying accounts), and kept because of what §1 measured behind the request: activation is the first
   READING, 4 of 67 accounts have ever logged one, and both paying customers are inside those
   four. The markup, why it is a sibling and why it is a LINK rather than a button, are at
   plantSlide() in site/index.html — read that before moving anything here.

   IT SITS ON A BARE PHOTOGRAPH, NOT ON THE SCRIM. The caption's contrast argument does not
   reach the top corner, so this control brings its own ground: an OPAQUE forest fill carrying
   cream, which is the pairing the whole system already uses for a filled button (§8) and which
   measures 11.10:1. Opaque is the load-bearing word — the same rule every chip on a slide
   obeys — because it makes the label's contrast a property of the control rather than of
   whatever the customer photographed.

   THE CONTROL'S OWN EDGE IS THE HARDER HALF, AND IT IS PROVED RATHER THAN EYEBALLED. A forest
   pill vanishes into a dark photograph and a cream one vanishes into a bright one, so no single
   colour bounds the boundary over every possible photo. TWO do. The ring is cream and the fill
   is forest, they differ by 11.10:1, and for any photo colour P the two contrasts multiply to
   at least that — so they cannot both be small: the worst case is where they are equal, at
   sqrt(11.10) = 3.33:1, which clears the 3.0 non-text floor. Whatever the photograph is doing,
   either the fill separates from it or the ring does. room-carousel-acceptance §G re-derives
   that bound and sweeps it. Change either colour and the guarantee changes with it.

   44px IN BOTH AXES, STATED (#631's lesson). The label sets the width here, but a future
   session shortening it to a glyph would otherwise inherit whatever the padding came to.
   15px is --text-size-body: §1 calls small type a wall, and this is an action control on the
   screen this audience spends its time on, so it does not go below the body size. */
.slide-check {
  position: absolute; top: 10px; right: 10px;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  min-height: 44px; min-width: 44px; padding: 0 15px;
  border-radius: var(--radius-pill);
  background: var(--accent);
  color: var(--text-on-accent);
  font-size: var(--text-size-body); font-weight: 600; line-height: 1;
  text-decoration: none;
  /* nowrap for the same reason `.btn` is (§8): this pill is positioned, so a wrapped label
     would grow it downward over the photograph instead of being clipped where anyone notices. */
  white-space: nowrap;
  /* The cream ring, then the system shadow. The ring is the half of the boundary proof above
     that survives a dark photograph; the shadow only ever helps on a light one. */
  box-shadow: 0 0 0 1.5px var(--text-on-accent), var(--shadow);
}
/* Gated, like every other hover in this component (#543/#545): on a touch screen a tap fires
   `mouseover` and the rule STAYS matched after the finger lifts, so an untapped control would
   sit lit until something else is touched. */
@media (hover: hover) and (pointer: fine) {
  .slide-check:hover { background: var(--accent-strong); text-decoration: none; }
}
.slide-check:active { background: var(--terracotta-700); }
/* The focus ring is FOREST OUTSIDE THE CREAM RING, not cream and not offset zero. Drawn
   against the photograph it would inherit the same unbounded-background problem the fill has;
   drawn against the control's own cream ring it is 11.10:1 in every case, so what a keyboard
   user sees is the ring thickening rather than a colour that may or may not be there. Offset
   2px keeps it clear of the 1.5px box-shadow ring instead of painting over it. */
.slide-check:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }


/* ── The pager (#610, reworked in #620, unlabelled in #630, counted in #627) ──
   ONE right-aligned cluster at the foot of the room's card: the position, in words, then a
   single segmented two-glyph control.

   THE LABELS ARE GONE AND THE QUESTION IS CLOSED. It went both ways: #610 shipped the words
   against a mockup drawing bare chevrons, #620 Stephen kept them ("needed and are clugey" —
   needed settled the words, clugey was the weight, and this block is that weight fix), and
   #630, looking at the lightened pager on real screens, he took the words off after all:
   "get rid of the words next and previous you can keep the arrows". The §1 argument for
   labelling a glyph was weighed and OVERRULED, not overlooked; it is kept in full at
   roomCarousel() in site/index.html, along with what the glyphs owe in return (#593's
   `aria-label` + `title`, and an end state that is a shape rather than a shade). Read that
   before touching the words in either direction.

   What made it heavy, and what each rule below does about it:

     1. THREE affordances stacked in one band — a permanently-painted scrollbar thumb, the
        position dots, and two full-size pills, all saying "there is more, and here is where
        you are". The bar is dealt with at .carousel-track: on a touch device it is now the
        platform's own transient one rather than ours painted in the divider colour, and it
        is still fully styled wherever a pointer can actually grab it. The dots are dealt
        with by #627, which replaced them with the sentence this row now carries — so of the
        three, one is gone, one is the platform's, and one is this control.
     2. PRIMARY-ACTION WEIGHT for a secondary job. These were `.btn secondary` — 50px of
        base height, 24px padding, and the Caprasimo display face, which is what the page's
        real actions are made of. They are not `.btn` at all any more: 44px tall, no border,
        no pill of their own, and since #630 no type either. `button {}` still supplies the
        flex box, cursor and press feedback, so nothing is hand-rolled here.
     3. AN UNBALANCED PAIR ACROSS A VOID. The position sat hard left and `margin-left: auto`
        threw the arrows to the right edge, so a wide card was a small cluster, a large gap
        and two pills of unequal width — two loose controls rather than one pager.
        `justify-content: flex-end` closes the gap, and the grid below makes the halves
        equal. Two glyphs are equal-width anyway now, but the grid stays: it is what stops a
        future label (or a translation) reintroducing the mismatch.

   `justify-content: flex-end` rather than `space-between`, for the reason #610 recorded and
   #627 kept: space-between with ONE child puts that child on the LEFT. #610 hit that past
   DOT_MAX, where a room drew no dots at all; DOT_MAX is gone, but a pager whose readout
   failed to render is the same shape and just as wrong. Both cases end up on the right.

   The arrows are hidden only when the track does not overflow — a control that cannot do
   anything is worse than no control — never by breakpoint.

   THE ROW STILL WRAPS, and the halves still cannot be squeezed (#548), but the second half
   of that is now the grid's job rather than `flex-wrap` inside the pair. `minmax(max-content,
   1fr)` means neither column can be sized below its own content, so a label can never spill
   the way `.btn`'s `white-space: nowrap` + `overflow: visible` let them (a squeeze
   `scrollWidth` cannot see). The 1fr half of the same declaration is what makes the two
   equal: in a shrink-to-fit grid both fr columns resolve to the wider one's max-content. If
   the whole cluster is too wide for the card, the readout wraps above it and the pager keeps
   its size — which is the same trade #548 asked for, stacking rather than shrinking.

   That wrap is not currently reached, and the number is worth writing down because the
   intuition runs the wrong way. #627's readout LOOKS longer than the dots it replaced and is
   narrower: measured at 375px, the worst case this page can produce ("Plant 10 of 12") is
   91px against 113px for a full row of eight 8px dots at a 7px gap. With #630's glyph pair at
   104px in a 301px row, 91 + 12 + 104 = 207px, so every pager on a phone is one 44px line.
   Two pixels of type were not bought back by shrinking anything; the dots were simply a
   wider way of saying less. */
.carousel-controls {
  display: flex; align-items: center; flex-wrap: wrap; justify-content: flex-end;
  gap: 10px 12px; margin: 2px 0 0;
}
.carousel-arrows {
  position: relative;
  display: inline-grid; grid-auto-flow: column;
  grid-auto-columns: minmax(max-content, 1fr);
  background: var(--surface-2); border-radius: var(--radius-pill);
}
/* The divider, drawn on the CONTAINER at the halves' seam rather than as a border on the
   second button. `button:disabled` is an opacity, and opacity applies to a border as
   readily as to a label — so a divider owned by a half would fade out with that half at
   exactly the end of the track, which is where the pager most needs to still look like one
   object. 50% is the seam because the grid above guarantees the two columns are equal.
   Inset top and bottom so it reads as a seam and not as a rule through the control. */
.carousel-arrows::before {
  content: ''; position: absolute; left: 50%; top: 9px; bottom: 9px;
  width: 1px; background: var(--border); pointer-events: none;
}
.carousel-nav {
  /* min-WIDTH as well as min-height since #630 took the words out. The label used to set the
     width and 44px was only ever the vertical floor; a glyph sets nothing, so without this a
     Lucide arrow plus padding is a 46px box that happens to clear the minimum by accident.
     Stated, it cannot be lost to a padding tweak. */
  min-height: 44px; min-width: 52px; padding: 0 15px;
  background: transparent; border: 0; border-radius: var(--radius-pill);
  color: var(--text);
  gap: 6px;
}
/* No `svg { width: 16px }` override any more: 16px was the size a glyph takes when it is
   sitting NEXT TO a word and the word is doing the work. It is the whole control now, so it
   takes the design system's button size (18px, §8) from `button svg` above. */

/* The two faces of one control (#630). Both are in the markup and CSS picks; the alternative
   was syncCarousel() rewriting innerHTML on every scroll frame, which would put the end-state
   glyph on the path of a function that must not throw mid-drag. This way syncCarousel still
   only ever toggles `disabled`, exactly as it did when the words were there. */
.carousel-nav .nav-end,
.carousel-nav:disabled .nav-go { display: none; }
.carousel-nav .nav-go,
.carousel-nav:disabled .nav-end { display: inline-flex; }
/* `button:hover` fills with --accent-strong, i.e. forest, because these are no longer
   `.btn secondary` — that would repaint half the pager as the page's loudest surface on a
   mouseover. Gated like every other :hover in this stylesheet (#543/#545). */
@media (hover: hover) and (pointer: fine) {
  .carousel-nav:hover { background: color-mix(in srgb, var(--text) 8%, transparent); }
}
.carousel-nav:active { background: color-mix(in srgb, var(--text) 14%, transparent); }
/* THE END STATE'S SECOND HALF. The first half is the face swap above — the arrow becomes an
   arrow stopped by a wall, which is what carries "you cannot go this way" in a SHAPE, the
   only channel §1 allows a state to use on its own.

   This is the shade that goes with it, and it is lifted off the inherited `button:disabled`
   value of .45 because the glyph still has to be SEEN at arm's length by a presbyopic reader
   (§1): .45 measures 2.41:1 on this fill and .6 measures 3.45:1. A disabled control is not an
   invisible one. room-carousel-acceptance §F7 measures both numbers and requires the shape
   difference alongside, so neither half can be dropped on the grounds that the other exists.

   (Until #630 this comment described hiding the arrowhead with `visibility` instead. That was
   the #620 mechanism, and it worked because the word "Previous" was still sitting beside the
   gap; with the words gone it would leave an empty box, which reads as broken rather than as
   finished.) */
.carousel-nav:disabled { opacity: .6; }

/* WHERE THE DOTS WERE (#627). #610 put one 8px dot per slide here, capped at eight, and a
   seven-plant room came back from Stephen's phone reading as a grey smear rather than as a
   position. The argument for replacing them rather than tuning them is at positionCounter() in
   site/index.html; the CSS consequence is that this is TEXT, and it obeys the ordinary rules
   for text rather than the 3.0:1 non-text floor the dots were held to.

   15px and `--text` rather than `--text-dim`, because this is now the only statement of where
   in the room the reader is and CLAUDE.md §1 is explicit that small type is a wall for this
   audience. The 15px used to be "matching the arrow labels beside it"; #630 took the words
   off the arrows, so there is no longer a label to match and the number has to stand on its
   own — it is `--step-body` territory, one rung above the 13px §1 calls a wall, and it is the
   ONLY type left in this row. Shrinking it to buy room in the pager is the move that is ruled
   out; the row wraps instead (see above).

   It is deliberately NOT `aria-hidden`, which the dots had to be. A dot differs from its
   neighbours by fill and scale and by nothing else, which is §1's "never encode information in
   size or colour alone" exactly, and is why the dots could only ever be decoration. A sentence
   does not have that problem. It also carries no `aria-live`: syncCarousel() rewrites it on
   every frame of a drag, and a live region here would narrate the whole gesture. */
.carousel-position {
  margin: 0;
  font-size: 15px; font-weight: 600;
  color: var(--text);
}

/* The smooth scroll lives in CSS rather than in the scrollBy() call, so this can switch it
   off: `behavior: 'auto'` — the default when the call omits it — means "obey scroll-behavior".
   A 300px pan is a large moving area, and vestibular sensitivity is common in the audience
   this screen is built for. */
@media (prefers-reduced-motion: reduce) {
  .carousel-track { scroll-behavior: auto; }
  .plant-slide { transition: none; }
}

/* ── Empty states ────────────────────────────────────────────────── */
.empty { text-align: center; padding: 56px 24px; color: var(--text-dim); }
.empty p { margin: 6px 0; max-width: 34ch; margin-inline: auto; }
.empty p strong {
  display: block; font-family: var(--font-heading); font-weight: 400;
  font-size: 22px; color: var(--text); margin-bottom: 6px;
}
.empty .leaf {
  width: 76px; height: 76px; margin: 0 auto 18px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: var(--sage-fill); color: var(--sage-ink);
  font-size: 32px;
}
.empty .leaf svg { width: 34px; height: 34px; stroke-width: 2.75; }
.empty .btn { margin-top: 20px; }

/* ── Feedback ────────────────────────────────────────────────────── */
.error { color: var(--danger); font-size: 14px; margin-top: 8px; }
.spinner {
  display: inline-block; width: 18px; height: 18px;
  border: 2px solid currentColor; border-top-color: transparent;
  border-radius: 50%; animation: spin 0.8s linear infinite;
  vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── Reviews — star row + landing testimonial cards ──────────────── */
.pt-star-row {
  display: inline-flex; gap: 4px; padding: 4px 0;
}
.pt-star {
  background: transparent;
  color: var(--border);
  border: none;
  padding: 6px;
  min-height: 0;
  border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer;
  transition: color .1s ease, transform .08s ease;
}
.pt-star svg { width: 26px; height: 26px; stroke-width: 2; }
/* Filled + colored when active. Fill uses the accent so hover previews look natural
   without a separate hover token; contrast against the sand fill is fine (sand: #d7c3ad,
   accent: #0c3b2e → 7.32:1 light and 5.66:1 dark on the sand-2 card, well over WCAG AA). */
.pt-star.active { color: var(--accent); }
.pt-star.active svg { fill: currentColor; }
@media (hover: hover) and (pointer: fine) {
  .pt-star:hover { transform: scale(1.06); color: var(--accent-strong); }
}
.pt-star:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Read-only star strip (landing testimonials, admin review card). Uses a stack of
   filled + empty spans; keep them the same size so the row is uniform. */
.pt-stars-static {
  display: inline-flex; gap: 2px; color: var(--accent);
  vertical-align: middle;
}
.pt-stars-static svg { width: 16px; height: 16px; stroke-width: 2; fill: currentColor; }
.pt-stars-static .empty { color: var(--border); }
.pt-stars-static .empty svg { fill: none; }

/* ── In-app support chat ─────────────────────────────────────────── */
/* Floating bubble sits above the action bar on pages that have one. */
.pt-chat-bubble {
  position: fixed;
  right: max(16px, env(safe-area-inset-right));
  bottom: calc(88px + env(safe-area-inset-bottom));
  width: 56px; height: 56px; min-height: 56px;
  padding: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--text-on-accent);
  border: none;
  box-shadow: 0 6px 18px rgba(51, 38, 26, 0.28);
  display: flex; align-items: center; justify-content: center;
  z-index: 40;
  cursor: pointer;
  transition: transform .12s ease, background .12s ease, box-shadow .12s ease;
}
@media (hover: hover) and (pointer: fine) {
  .pt-chat-bubble:hover { background: var(--accent-strong); box-shadow: 0 8px 22px rgba(51, 38, 26, 0.34); }
}
.pt-chat-bubble:active { transform: scale(0.96); }
.pt-chat-bubble svg { width: 24px; height: 24px; stroke-width: 2.75; }

.pt-chat-backdrop {
  position: fixed; inset: 0;
  background: rgba(51, 38, 26, 0.32);
  backdrop-filter: blur(2px);
  z-index: 45;
  opacity: 0;
  transition: opacity .2s ease;
}
.pt-chat-backdrop.open { opacity: 1; }
.pt-chat-backdrop[hidden] { display: none; }

.pt-chat-sheet {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  max-width: 720px; margin: 0 auto;
  height: min(85vh, 720px);
  background: var(--surface);
  border-top-left-radius: 24px;
  border-top-right-radius: 24px;
  border: 1px solid var(--border);
  border-bottom: none;
  box-shadow: 0 -8px 28px rgba(51, 38, 26, 0.22);
  z-index: 50;
  display: flex; flex-direction: column;
  transform: translateY(100%);
  transition: transform .22s ease;
}
.pt-chat-sheet.open { transform: translateY(0); }
.pt-chat-sheet[hidden] { display: none; }

.pt-chat-head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 14px 20px;
  border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.pt-chat-title {
  display: flex; align-items: center; gap: 10px;
  font-family: var(--font-heading); font-weight: 400;
  font-size: 18px; letter-spacing: -0.01em;
  color: var(--text);
}
.pt-chat-title svg { color: var(--accent); }
.pt-chat-head-actions { display: flex; gap: 6px; }
.pt-chat-icon-btn {
  min-height: 36px; width: 36px; padding: 0;
  background: transparent; color: var(--text-dim);
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  display: inline-flex; align-items: center; justify-content: center;
}
.pt-chat-icon-btn svg { width: 18px; height: 18px; }
@media (hover: hover) and (pointer: fine) {
  .pt-chat-icon-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
}

.pt-chat-body {
  flex: 1; min-height: 0;
  overflow-y: auto;
  padding: 18px 20px;
  display: flex; flex-direction: column; gap: 10px;
  scroll-behavior: smooth;
}
.pt-chat-note {
  text-align: center; color: var(--text-dim); font-size: 14px;
  margin: 20px auto;
}

.pt-chat-empty {
  text-align: center; color: var(--text-dim);
  padding: 40px 16px;
}
.pt-chat-empty-icon {
  width: 68px; height: 68px; margin: 0 auto 16px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 50%; background: var(--sage-fill); color: var(--sage-ink);
}
.pt-chat-empty p { margin: 6px 0; max-width: 34ch; margin-inline: auto; }
.pt-chat-empty p strong {
  display: block; font-family: var(--font-heading); font-weight: 400;
  font-size: 20px; color: var(--text); margin-bottom: 6px;
}
.pt-chat-msg {
  max-width: 82%;
  padding: 10px 14px;
  border-radius: 18px;
  font-size: 15px; line-height: 1.45;
  word-wrap: break-word;
}
.pt-chat-msg.user {
  align-self: flex-end;
  background: var(--accent); color: var(--text-on-accent);
  border-bottom-right-radius: 6px;
}
.pt-chat-msg.agent {
  align-self: flex-start;
  background: var(--surface-2); color: var(--text);
  border-bottom-left-radius: 6px;
}
.pt-chat-actions {
  align-self: flex-start;
  display: inline-flex; gap: 6px; flex-wrap: wrap;
  margin-top: -4px; margin-bottom: 4px;
  padding-left: 6px;
}
.pt-chat-action-pill {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 10px; border-radius: var(--radius-pill);
  background: var(--sage-200); color: var(--sage-800);
  font-size: 11px; font-weight: 600; letter-spacing: 0.02em;
}
.pt-chat-action-pill svg { width: 12px; height: 12px; stroke-width: 2.75; flex: none; }

.pt-chat-system {
  align-self: center;
  font-size: 12px; color: var(--text-dim);
  background: color-mix(in srgb, var(--text) 5%, transparent);
  padding: 6px 12px; border-radius: var(--radius-pill);
  max-width: 90%; text-align: center;
}
.pt-chat-typing {
  display: inline-flex; gap: 4px; padding: 4px 0;
}
.pt-chat-typing span {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--text-dim); opacity: 0.5;
  animation: pt-chat-bounce 1s infinite ease-in-out;
}
.pt-chat-typing span:nth-child(2) { animation-delay: .12s; }
.pt-chat-typing span:nth-child(3) { animation-delay: .24s; }
@keyframes pt-chat-bounce {
  0%, 80%, 100% { transform: translateY(0); opacity: 0.35; }
  40% { transform: translateY(-4px); opacity: 1; }
}

.pt-chat-filed {
  align-self: stretch;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  background: var(--sage-200); color: var(--sage-800);
  padding: 8px 14px; border-radius: var(--radius-pill);
  font-size: 13px; font-weight: 600;
  margin-bottom: 4px;
}
.pt-chat-filed a { color: inherit; text-decoration: underline; }

.pt-chat-composer {
  display: flex; gap: 10px; align-items: flex-end;
  /* The attachment tray is a full-width row above the controls; wrapping keeps it in the
     same flex container so the composer's padding and border still bound it. */
  flex-wrap: wrap;
  padding: 12px 16px calc(14px + env(safe-area-inset-bottom));
  border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
  background: var(--surface);
}
.pt-chat-composer textarea {
  flex: 1; min-height: 44px; max-height: 160px;
  padding: 10px 16px;
  border-radius: 20px;
  font-size: 15px;
  resize: none; overflow-y: auto;
}
.pt-chat-send {
  min-height: 44px; width: 44px; padding: 0;
  border-radius: 50%;
  background: var(--accent); color: var(--text-on-accent);
  border: none;
  display: inline-flex; align-items: center; justify-content: center;
  flex: none;
}
.pt-chat-send:disabled { opacity: 0.45; }

/* Photo attach (#395) — the trigger is a <label for> until camera.js swaps it for a real
   <button>, so every rule here has to hold for both element types. */
.pt-chat-photo-btn {
  min-height: 44px; width: 44px; padding: 0;
  border-radius: 50%;
  background: transparent; color: var(--text-dim);
  border: 1px solid var(--border);
  display: inline-flex; align-items: center; justify-content: center;
  flex: none;
  cursor: pointer;
  transition: background .12s ease, color .12s ease, opacity .12s ease;
}
.pt-chat-photo-btn svg { width: 18px; height: 18px; stroke-width: 2.75; }
@media (hover: hover) and (pointer: fine) {
  .pt-chat-photo-btn:hover { background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--text); }
}
.pt-chat-photo-btn.is-disabled { opacity: 0.45; pointer-events: none; }

.pt-chat-attach {
  flex-basis: 100%; order: -1;
  display: flex; flex-direction: column; gap: 8px;
  margin-bottom: 2px;
}
.pt-chat-attach-thumbs { display: flex; gap: 8px; }
.pt-chat-thumb {
  position: relative;
  width: 60px; height: 60px; flex: none;
  border-radius: var(--radius); overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface-2);
}
.pt-chat-thumb.failed { border-color: var(--danger); }
.pt-chat-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* Scrim + label over a photo, so neither side can be themed: --ink and --ground are the
   only two tokens that hold their value in both schemes, which is exactly what a layer
   sitting on arbitrary user pixels needs. 76% is the measured floor — the worst case is a
   photo of a pale plant on a white wall, where 62% left the glyph at 2.91:1 against the
   composited scrim, under the 3:1 a non-text control needs. At 76% that case is 4.0:1. */
.pt-chat-thumb-state {
  position: absolute; inset: 0;
  min-height: 0; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: color-mix(in srgb, var(--ink) 76%, transparent);
  color: var(--ground);
  border: none; border-radius: 0;
  cursor: pointer;
}
.pt-chat-thumb-state svg { width: 18px; height: 18px; stroke-width: 2.75; }
.pt-chat-thumb-x {
  position: absolute; top: 3px; right: 3px;
  width: 20px; height: 20px; min-height: 20px; padding: 0;
  border-radius: 50%;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border);
  display: inline-flex; align-items: center; justify-content: center;
  cursor: pointer;
}
.pt-chat-thumb-x svg { width: 12px; height: 12px; stroke-width: 2.75; }
.pt-chat-attach-error { margin: 0; font-size: 13px; color: var(--danger); }

/* Photos on a sent turn, rendered beside the bubble rather than inside it so the bubble
   padding doesn't box them in. */
.pt-chat-photos {
  display: flex; gap: 6px; flex-wrap: wrap;
  max-width: 82%;
}
.pt-chat-photos.user { align-self: flex-end; justify-content: flex-end; }
.pt-chat-photos.agent { align-self: flex-start; }
.pt-chat-photo {
  width: 132px; max-width: 38vw; aspect-ratio: 1;
  object-fit: cover; display: block;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--surface-2);
}

.pt-chat-feedback {
  margin-top: 16px;
  padding: 16px;
  background: color-mix(in srgb, var(--sage-100) 60%, var(--surface));
  border: 1px solid var(--border);
  border-radius: var(--radius);
  display: flex; flex-direction: column; gap: 10px;
}
.pt-chat-feedback h3 {
  font-family: var(--font-heading); font-weight: 400;
  font-size: 17px; margin: 0; letter-spacing: -0.01em;
  color: var(--text);
}
.pt-chat-feedback-help {
  margin: 0; font-size: 13px; color: var(--text-dim);
}
.pt-chat-feedback label {
  margin-top: 4px; margin-bottom: 0;
}
.pt-chat-feedback input, .pt-chat-feedback textarea {
  background: var(--surface);
}
.pt-chat-feedback textarea { border-radius: 16px; }
.pt-chat-feedback-actions {
  display: flex; gap: 8px; justify-content: flex-end; margin-top: 4px;
}
.pt-chat-feedback-actions button { min-height: 40px; padding: 8px 18px; font-size: 14px; }

/* ── Tables (plant.html care thresholds, table.html) ─────────────── */
table { width: 100%; border-collapse: collapse; font-size: 14px; }
th {
  text-align: left; font-size: 11px; font-weight: 700;
  letter-spacing: 0.09em; text-transform: uppercase;
  color: var(--text-dim); padding: 10px 8px;
  border-bottom: 1px solid var(--border);
}
@media (hover: hover) and (pointer: fine) {
  tbody tr:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
}

/* ── In-page camera viewfinder (#186) ────────────────────────────── */
/* The surface here is the live camera image, so this chrome does NOT flip with
   prefers-color-scheme — it is light-on-video in both themes, which is the only
   readable choice over arbitrary frames. Colours still come from tokens: --ink for the
   warm scrim (never neutral grey, per the dark palette's own rule) and --ground for the
   cream foreground, which is exactly what dark mode uses as --text. */
html.pt-cam-open, html.pt-cam-open body { overflow: hidden; }

.pt-cam {
  position: fixed; inset: 0; z-index: 70;
  background: var(--ink);
  color: var(--ground);
  display: flex; flex-direction: column;
  overscroll-behavior: contain;
}
.pt-cam-video {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  background: var(--ink);
  opacity: 0;
  transition: opacity .18s ease;
}
.pt-cam.ready .pt-cam-video { opacity: 1; }

/* Framing guide for the meter dial — a hint, deliberately not a hard crop: the capture
   keeps the full visible frame so a slightly-off dial still reads. */
.pt-cam-guide {
  position: absolute;
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: min(78vw, 340px); aspect-ratio: 1;
  border: 2px dashed color-mix(in srgb, var(--ground) 62%, transparent);
  border-radius: var(--radius);
  pointer-events: none;
  opacity: 0;
  transition: opacity .18s ease;
}
.pt-cam.ready .pt-cam-guide { opacity: 1; }

/* Chrome sits ABOVE the loading/error panel (z-index 3), not under it: the error copy
   says "pick a photo instead", and the Photos button it points at has to be crisp and
   tappable, not a ghost showing through the scrim. */
.pt-cam-top {
  position: relative; z-index: 4;
  display: flex; align-items: center; gap: 12px;
  padding: calc(12px + env(safe-area-inset-top)) max(14px, env(safe-area-inset-right)) 12px max(14px, env(safe-area-inset-left));
  background: linear-gradient(to bottom, color-mix(in srgb, var(--ink) 62%, transparent), transparent);
}
.pt-cam-hint {
  flex: 1; margin: 0; text-align: center;
  font-size: 14px; line-height: 1.35; font-weight: 600;
  text-shadow: 0 1px 3px color-mix(in srgb, var(--ink) 70%, transparent);
}
/* Framing advice is meaningless until there's a frame, and a shutter you can't press is
   louder than the action the error panel is actually asking for. */
.pt-cam:not(.ready) .pt-cam-hint { visibility: hidden; }
.pt-cam:not(.ready) .pt-cam-shutter { visibility: hidden; }

.pt-cam .pt-cam-icon {
  flex: none;
  /* 44px is the iOS minimum touch target — close and flip both sit under a thumb. */
  width: 44px; height: 44px; min-height: 44px; padding: 0;
  border-radius: 50%;
  background: color-mix(in srgb, var(--ink) 55%, transparent);
  color: var(--ground);
  border: 1px solid color-mix(in srgb, var(--ground) 28%, transparent);
  display: flex; align-items: center; justify-content: center;
  backdrop-filter: blur(6px);
}
@media (hover: hover) and (pointer: fine) {
  .pt-cam .pt-cam-icon:hover { background: color-mix(in srgb, var(--ink) 72%, transparent); }
}
.pt-cam .pt-cam-icon svg { width: 20px; height: 20px; }

.pt-cam-bottom {
  position: relative; z-index: 4;
  margin-top: auto;
  /* Grid, not flex: `1fr auto 1fr` makes the two outer tracks exactly equal, so the
     shutter is centred on the viewport no matter how wide the Photos pill renders.
     Matching flex-basis on a pill and an empty spacer does NOT achieve that — the pill
     has content and the spacer doesn't, so they resolve to different widths and the
     shutter drifts (18px off centre at 390px). */
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 12px;
  padding: 18px max(16px, env(safe-area-inset-right)) calc(22px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
  background: linear-gradient(to top, color-mix(in srgb, var(--ink) 68%, transparent), transparent);
}
.pt-cam .pt-cam-alt {
  justify-self: start; max-width: 100%;
  justify-content: flex-start;
  background: color-mix(in srgb, var(--ink) 55%, transparent);
  color: var(--ground);
  border: 1px solid color-mix(in srgb, var(--ground) 28%, transparent);
  border-radius: var(--radius-pill);
  font-size: 14px;
  backdrop-filter: blur(6px);
}
@media (hover: hover) and (pointer: fine) {
  .pt-cam .pt-cam-alt:hover { background: color-mix(in srgb, var(--ink) 72%, transparent); }
}

.pt-cam .pt-cam-shutter {
  width: 74px; height: 74px; min-height: 74px; padding: 0;
  border-radius: 50%;
  background: transparent;
  border: 3px solid var(--ground);
  display: flex; align-items: center; justify-content: center;
}
.pt-cam .pt-cam-shutter span {
  width: 58px; height: 58px; border-radius: 50%;
  background: var(--ground);
  transition: transform .12s ease, background .12s ease;
}
/* This one is a counter-rule, not a hover effect: it exists to stop `button:hover`
   painting the shutter accent-green. Measured, it never had to — `.pt-cam
   .pt-cam-shutter` is (0,2,0) and `button:hover` is (0,1,1), so the transparent base
   already wins, and deleting this line leaves the hovered shutter byte-identical. It
   is kept and gated alongside the rule it guards against rather than dropped: the
   thing it defends against now only exists on a hover device, so neither should it.
   Gating it changes nothing on touch, which is the point — the shutter must look the
   same under a finger as it does at rest. */
@media (hover: hover) and (pointer: fine) {
  .pt-cam .pt-cam-shutter:hover { background: transparent; }
}
.pt-cam .pt-cam-shutter:active span { transform: scale(0.88); background: var(--accent-strong); }
.pt-cam .pt-cam-shutter:disabled { opacity: 0.4; }

/* Loading + error panel. Sits over the (still black) video, centred. */
.pt-cam-state {
  position: absolute; inset: 0; z-index: 3;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px;
  padding: 24px max(24px, env(safe-area-inset-left));
  text-align: center;
  background: var(--ink);
  color: var(--ground);
}
.pt-cam-state[hidden] { display: none; }
.pt-cam-state-title {
  margin: 0;
  font-family: var(--font-heading); font-weight: 400; font-size: 21px; line-height: 1.2;
}
.pt-cam-state-body {
  margin: 0; max-width: 34ch;
  font-size: 14px; line-height: 1.5;
  color: color-mix(in srgb, var(--ground) 78%, transparent);
}

/* Landscape phones: the shutter would eat the frame, so tighten the chrome. */
@media (max-height: 460px) {
  .pt-cam .pt-cam-shutter { width: 58px; height: 58px; min-height: 58px; }
  .pt-cam .pt-cam-shutter span { width: 44px; height: 44px; }
  .pt-cam-bottom { padding-top: 10px; padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
  .pt-cam-guide { width: min(46vw, 240px); }
}

/* Desktop: a full-bleed webcam feed is not what anyone wants — the whole overlay
   becomes a centred card and the internal layout is unchanged. */
@media (min-width: 760px) and (min-height: 560px) {
  /* Scrim without extra markup — the overlay itself is the card at this size. */
  html.pt-cam-open body::after {
    content: ''; position: fixed; inset: 0; z-index: 65;
    background: color-mix(in srgb, var(--ink) 55%, transparent);
    backdrop-filter: blur(3px);
  }
  .pt-cam {
    inset: auto;
    top: 50%; left: 50%; transform: translate(-50%, -50%);
    width: min(760px, 92vw); height: min(600px, 88vh);
    border-radius: var(--radius);
    border: 1px solid color-mix(in srgb, var(--ground) 22%, transparent);
    overflow: hidden;
    box-shadow: 0 18px 48px color-mix(in srgb, var(--ink) 55%, transparent);
  }
  .pt-cam-top { padding: 14px 16px 12px; }
  .pt-cam-bottom { padding: 18px 16px 20px; }
}

@media (prefers-reduced-motion: reduce) {
  .pt-cam-video, .pt-cam-guide { transition: none; }
}

/* ── Sitter pass card (#408) ─────────────────────────────────────────────────
   The plant-sitter pass is the second bearer URL we hand out, after #397's
   calendar feed. It deliberately does NOT get its own credential treatment: the
   token itself renders in `.credential` / `.credential.masked` above, the same
   box the calendar link uses, because two visual languages for "this is a key"
   teaches a user that one of them is less serious than it is.

   What is genuinely new here is the grant list — the calendar feed grants one
   thing (read a schedule), the sitter pass grants two and withholds several, and
   that difference is the whole reason an owner hesitates before sending one. */

/* What a credential grants, and what it does not. `.grant.no` is deliberately
   NOT --danger: a capability we withhold is the reassuring half of this list,
   not an error, and painting it red reads as "something went wrong". */
.grant-list { list-style: none; margin: 0 0 16px; padding: 0; }
.grant-list li {
  display: flex; gap: 10px; align-items: flex-start;
  font-size: 14px; line-height: 1.45; color: var(--text-dim);
}
.grant-list li + li { margin-top: 8px; }
.grant-list svg { flex: none; margin-top: 3px; }
.grant.yes > svg { color: var(--accent-strong); }
.grant.no > svg { color: var(--text-dim); opacity: 0.75; }

/* A row in the outstanding-passes list. */
.pass-row + .pass-row { margin-top: 12px; }
.pass-meta {
  display: flex; gap: 6px; flex-wrap: wrap;
  margin-top: 8px;
}
.pass-row.spent { opacity: 0.72; }

/* ── Sitter walkthrough (#408, /sit/<token>) ─────────────────────────────────
   The sitter is standing in front of a plant holding a phone in one hand and a
   meter in the other, so the whole page is sized for one thumb. These classes
   are used ONLY by site/sit.html, which cannot import common.js — the page is
   deliberately session-free — but common.css loads there fine, which is why the
   styling lives here rather than inline in that file. */

.sit-progress {
  display: flex; align-items: center; gap: 12px;
  margin: 0 0 14px;
}
.sit-progress-track {
  flex: 1; height: 8px; border-radius: var(--radius-pill);
  background: var(--surface-2); overflow: hidden;
}
.sit-progress-fill {
  height: 100%; border-radius: var(--radius-pill);
  background: var(--accent);
  transition: width .25s ease;
}
.sit-progress-label { font-size: 12px; font-weight: 700; letter-spacing: 0.02em; color: var(--text-dim); white-space: nowrap; }

/* Back control in the sticky topbar. A quiet outline rather than the accent pill,
   because on the plant screen the ONE filled button belongs to the primary action
   at the bottom of the thumb's reach, not to "go back". */
.sit-back {
  min-height: 36px; min-width: 36px; padding: 6px 10px;
  background: transparent; color: var(--text-dim);
  border: 1px solid var(--border);
}
@media (hover: hover) and (pointer: fine) {
  .sit-back:hover { background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text); }
}

/* Plant rows. `.card.tap` already carries the press affordance; this adds what a
   <button> element needs on top of it, and both of those are required rather than
   cosmetic:
     - `width: 100%` because a form control shrink-wraps to its content even at
       display:block, so a card-as-button is otherwise as narrow as its text.
     - `font-family` because the base `button` rule sets the DISPLAY face, which is
       right for a pill button and wrong for a list row — without this the plant
       names and their pills render in Caprasimo. */
.sit-row {
  width: 100%; text-align: left;
  font-family: var(--font-body); font-size: 15px; font-weight: 400;
  letter-spacing: 0;
}
.sit-plant { display: flex; gap: 13px; align-items: center; width: 100%; }
.sit-plant-name { font-weight: 600; font-size: 16px; }
.sit-plant-sub { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
.sit-row.sit-done { opacity: 0.6; }

/* Big numeric entry — a meter reading typed with a thumb. The oversized face is
   the point: these three fields are the entire job. */
.sit-num input {
  font-size: 26px; font-weight: 600;
  text-align: center;
  min-height: 62px; padding: 10px 12px;
  font-variant-numeric: tabular-nums;
}
.sit-num label { text-align: center; }
.sit-num-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.sit-unit { display: block; text-align: center; font-size: 12px; color: var(--text-dim); margin-top: 6px; }

/* The "I watered it" toggle is a decision, not a footnote — it gets a real
   target rather than the 13px checkbox row the shell had. */
.sit-toggle {
  display: flex; align-items: center; justify-content: flex-start; gap: 10px;
  width: 100%;
  min-height: 54px; padding: 12px 18px;
  border: 1px solid var(--border); border-radius: var(--radius-pill);
  /* background-COLOR rather than the `background` shorthand, because the base `button`
     rule declares `transition: background` and this pair of rules is what that transition
     runs between. Worth knowing if you ever measure this control: while `background` is
     the animated property, getComputedStyle().backgroundColor keeps reporting the
     unpressed value here even once the pixels are green, so a contrast script reads the
     pressed state as 1.4:1 and is simply wrong. The real pairings are on the rule below.
     The longhand does not change what renders — it just narrows what is animating. */
  background-color: var(--surface-2); color: var(--text);
  font-family: var(--font-body); font-size: 15px; font-weight: 600;
  text-transform: none; letter-spacing: 0;
  margin-bottom: 16px;
  cursor: pointer;
}
/* Pressed. Measured from the tokens, not from a computed style (see above): dark is
   --text-on-accent #251d13 on --accent #7dbfac = 7.85:1; light is #f7f1e4 on #0c3b2e =
   11.10:1. --text-on-accent is themed in both directions for exactly this pairing (#262).
   Unpressed is --text on --surface-2: 8.58:1 light, 7.95:1 dark. */
.sit-toggle[aria-pressed="true"] {
  background-color: var(--accent); color: var(--text-on-accent); border-color: var(--accent);
}
.sit-toggle svg { flex: none; width: 18px; height: 18px; stroke-width: 2.75; }

/* Sticky footer for the primary action, so "Check" is reachable without
   scrolling back up on a phone. */
.sit-sticky {
  position: sticky; bottom: 0; z-index: 5;
  margin: 18px -20px 0;
  padding: 14px 20px calc(14px + env(safe-area-inset-bottom));
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: saturate(1.4) blur(12px);
  border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}

/* The scope banner. A sitter should be able to see what this link does and does
   not let them do without asking the person who sent it. */
.sit-scope {
  display: flex; gap: 11px; align-items: flex-start;
  font-size: 13px; line-height: 1.5; color: var(--text-dim);
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 14px 16px;
  margin-bottom: 12px;
}
.sit-scope svg { flex: none; margin-top: 2px; color: var(--accent-strong); }

@media (max-width: 420px) {
  .sit-num-row { grid-template-columns: 1fr; }
}

/* ── Reviews band, signup pages (#730) ────────────────────────────────
   The landing page carries its OWN copy of a reviews card, inline in
   landing.html, because routes/redirect.js injects a full-width band into
   it server-side. This is the compact, single-column version that the three
   STATIC signup pages share — signup.html, start.html and start-fb.html are
   served straight off disk by Harper's static handler (config.yaml
   `static: site/**`), so nothing can inject into them and they fill this
   band from the browser via site/assets/reviews-band.js.

   One definition here rather than three inline copies, which is the hazard
   the three copies of the attribution rule are documented under. It costs
   2,690 bytes raw / 927 gzipped on a file that is render-blocking everywhere,
   so #714's common.css slimming pass should know it is here.

   One column at every width on purpose: all three pages put this inside a
   400–560px card column, so a two-column row would be ~250px of quote per
   card. §1's readers are better served by the full measure and the larger
   type, and it costs no media query.

   No :hover rule anywhere in this block (room-carousel-acceptance §E1) —
   nothing here is interactive. */
.pt-reviews { margin: 32px 0 0; }
/* The UA's [hidden]{display:none} loses to any display declaration on the
   same element — the #fbBtn/.divider gotcha (#122). Nothing below sets
   display on the root, and this keeps it that way if something ever does. */
.pt-reviews[hidden] { display: none; }
.pt-reviews-title {
  margin: 0 0 12px; text-align: center;
  font-size: 12px; font-weight: 700; letter-spacing: 0.12em;
  text-transform: uppercase; color: var(--text-dim);
}
.pt-reviews-list { display: grid; gap: 12px; }
.pt-review {
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 18px 20px;
  display: flex; flex-direction: column; gap: 10px;
}
.pt-review-stars { display: inline-flex; gap: 2px; color: var(--accent); }
/* Filled, and stroked at 2 rather than the system's 2.75 — a filled star wants a
   thin outline, not the icon system's heavy one. The shape is common.js's polygon
   star and NOT Lucide's; see the comment on STAR in reviews-band.js for why, and
   for the fact that the landing band still draws the Lucide one. */
.pt-review-stars svg { width: 16px; height: 16px; fill: currentColor; stroke-width: 2; }
.pt-review blockquote { margin: 0; font-size: var(--text-size-body); line-height: 1.55; color: var(--text); }
/* margin:0 rather than the UA's 1em on a <p>: the card is a flex column with its
   own gap, and the two together give an uneven space under the quote. */
.pt-review-by { margin: 0; font-size: 14px; font-weight: 600; color: var(--text-dim); }

/* ── Print / Save as PDF (#512) ───────────────────────────────────────
   Last block in the file on purpose: these rules have to win against every
   themed rule above them, and they are the only ones in the system that are
   not theme-aware. Ink and paper are hard values rather than tokens because
   no token carries them — every colour token is a SCREEN colour, and in dark
   mode the whole palette would print a page of near-black paper. `black` and
   `white` here mean "the printer's ink and the sheet in the tray", which is a
   different question from the design system's light/dark pair. That is also
   why they are not redefined under prefers-color-scheme: dark.

   Page-specific print rules live with their page — see site/table.html. */
@media print {
  header.topbar,
  .action-bar,
  /* A control that opens a different screen is furniture on paper, and this one sits
     over the photograph it would be printed on top of (#643). */
  .slide-check,
  .pt-chat-bubble,
  .pt-chat-backdrop,
  .pt-chat-sheet { display: none !important; }

  body {
    background: white;
    color: black;
    /* The 104px reserve exists to clear the fixed .action-bar, which is hidden
       above — left in, it prints as a blank strip at the foot of the last page. */
    padding-bottom: 0;
  }
  main { max-width: none; padding: 0; }

  /* A URL the reader cannot click is just underlined noise on paper. */
  a { color: inherit; text-decoration: none; }

  /* A horizontal scroller prints as its first screenful and silently drops the rest of the
     room — the card list it replaced printed whole. Unroll it into a wrapping grid so paper
     gets every plant. The slides keep their shape, so the caption still sits where it does
     on screen; only the scrolling goes away. */
  .carousel-track {
    flex-wrap: wrap; overflow: visible;
    margin-inline: 0; padding-inline: 0; padding-bottom: 0;
  }
  /* `:only-child` is (0,2,0) against this rule's (0,1,0), so it has to be named here or a
     one-plant room prints a full-page photograph. Paper wants the grid either way. */
  .carousel-slide,
  .carousel-slide:only-child { width: 46%; }
  .plant-slide { break-inside: avoid; box-shadow: none; }
  .carousel-controls { display: none !important; }

  /* The room card is a SCREEN device — it binds a pager to its room, and the pager is
     hidden above, so on paper it is a box drawn around plants that were already grouped
     under a heading. Flatten it: the shadow prints as a grey smear, and the padding it
     needs to hold a bleeding scroller is dead margin once the track is unrolled. The
     heading itself is kept, because that is what says which room this is. */
  details.location-group.room-card {
    --room-pad: 0px;
    background: transparent; border: 0; box-shadow: none;
    padding: 0; margin-bottom: 14px; break-inside: avoid;
  }

  /* Backgrounds are dropped by default in most print paths, which would leave a
     status pill as unlabelled floating text. Outline it instead so the chip still
     reads as a chip whether or not the printer honours background graphics. */
  .pill {
    background: transparent !important;
    color: inherit !important;
    border: 1px solid currentColor;
  }
}
