/* Chorely PWA shell — dark by default, mobile-first.

   S1 (owner decision): the chat used to offer seven selectable colour
   themes (K6, K14) plus an OS-level prefers-color-scheme light/dark
   switch. The owner chose simplicity over choice — "will be good for
   everyone" — so this is now the ONE look, for every user, always: no
   settings-menu UI (there never was one), no conversational theme
   preference (app/tools/preferences.py's `chat_theme` key is gone —
   requests to change it get a gentle catalogued explanation instead), and
   no OS light/dark media query overriding it. This is exactly the old
   `dark` theme's palette (the best-tested, most-used of the seven),
   promoted to :root unconditionally.

   Contrast (WCAG AA body-text minimum is 4.5:1): --ink #f3ece0 on --bg
   #1a1714 ~ 15.5:1. */
:root {
  --bg: #1a1714;
  --panel: #262019;
  --ink: #f3ece0;
  --ink-soft: #c2b39f;
  --accent: #e0925c;
  --accent-ink: #1a1714;
  --bubble-user: #3a3226;
  --bubble-assistant: #262019;
  --border: #4a4030;
  color-scheme: dark;
}

/* K17: independent text-colour override, driven by the stated `text_color`
   preference (app/tools/preferences.py). This only ever overrides `--ink`,
   applied via the `data-text-color` attribute app.js stamps onto <html>
   from GET /chat/me, same pattern as `data-font-scale`. "default" (no
   override) falls through to the :root --ink above, so it has no rule
   here.

   S1: with the chat now permanently dark (see :root above), the closed
   set of allowed values dropped to the ones that are actually readable
   against THIS ONE background — TEXT_COLOR_VALUES in preferences.py is
   now exactly {default, white, cream, soft-blue}; the two light-background
   colours (black, charcoal) were removed because they'd be unreadable
   here. Ratios against --bg #1a1714 (WCAG AA body-text minimum 4.5:1):
   white #ffffff ~17.8:1, cream #f0e6c8 ~14.3:1, soft-blue #5b8dbe ~5.1:1.

   --accent / --accent-ink (recording indicator, focus outlines) are
   untouched by every rule below, so both stay visible under every
   allowed text_color. */
:root[data-text-color="white"] {
  --ink: #ffffff;
}

:root[data-text-color="cream"] {
  --ink: #f0e6c8;
}

:root[data-text-color="soft-blue"] {
  --ink: #5b8dbe;
}

* {
  box-sizing: border-box;
}

/* C-F4: font-size presets, driven by the stated `font_scale` preference
   (app/tools/preferences.py) — no settings-menu UI, set conversationally
   and applied here via the `data-font-scale` attribute app.js stamps onto
   <html> from GET /chat/me on every load. Everything else in this sheet
   uses rem units, which are always relative to this html font-size, so
   the whole layout scales together and keeps its proportions at every
   step. "default" reproduces the previous fixed 17px base exactly. */
html {
  font-size: 17px; /* default */
}

html[data-font-scale="small"] {
  font-size: 15px;
}

html[data-font-scale="default"] {
  font-size: 17px;
}

html[data-font-scale="large"] {
  font-size: 20px;
}

html[data-font-scale="xlarge"] {
  font-size: 24px;
}

/* K14: `xxl`, one step past `xlarge` — added to the top of
   FONT_SCALE_ORDER (app/tools/preferences.py). Everything below still
   reads sizes in rem, so this scales the whole layout proportionally
   exactly like the presets above; verified by hand that the chat bubbles
   (.msg, max-width: 85% of the flex column, not a fixed px width) and
   the composer/auth buttons (padding in rem, no fixed widths) still wrap
   and reflow sanely at this size rather than overflowing. */
html[data-font-scale="xxl"] {
  font-size: 28px;
}

html,
body {
  height: 100%;
}

body {
  margin: 0;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  color: var(--ink);
  font-family:
    system-ui,
    -apple-system,
    "Segoe UI",
    Roboto,
    sans-serif;
  font-size: 1rem;
  line-height: 1.45;
}

.topbar {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: max(0.75rem, env(safe-area-inset-top)) 1rem 0.75rem;
  border-bottom: 1px solid var(--border);
  background: var(--panel);
}

.topbar h1 {
  margin: 0;
  font-size: 1.25rem;
  font-weight: 600;
}

.status {
  font-size: 0.85rem;
  color: var(--ink-soft);
}

/* Topbar navigation link (chat <-> dashboard). Same visual weight as
   .status so it reads as chrome, not content; 2.75rem touch target via
   padding for elderly-friendly tapping. */
.topbar-nav {
  margin-left: auto;
  margin-right: 0.75rem;
}

.topbar-link {
  color: var(--accent);
  text-decoration: none;
  font-size: 0.95rem;
  padding: 0.5rem 0.4rem;
  border-radius: 0.4rem;
}

.topbar-link:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* H3 (ops/contracts-ui-wave.md "H3 -- topbar responsive fix"): live
   findings on phone -- nav links stacked on top of each other, the
   "Household Dashboard" status text wrapped to two lines, and the owner
   asked for the Chorely name to be centered on narrow screens. This is
   the ONE shared fix (chat/index.html, dashboard.html, help.html,
   invites.html all use the same .topbar/.topbar-nav/.topbar-link/.status
   markup, no per-page overrides needed) -- verified against every page's
   own <header class="topbar"> shape above/in their respective files.

   Below the breakpoint the topbar becomes two tidy rows via flex-wrap:
   row 1 is just <h1>Chorely</h1>, centered (order + width:100% forces it
   onto its own row and text-align centers it); row 2 is the nav links
   (inline, comfortable gap, already wraps sanely at 3 links max) plus the
   status text, which gets nowrap + ellipsis so "Household Dashboard"
   never breaks across two lines even if space is tight. Touch targets
   (.topbar-link's 2.75rem via padding, from the rule above) are
   untouched by this rule -- only layout changes. Wide screens (the
   default, no media query needed) keep the original single-row
   space-between arrangement exactly as before. */
@media (max-width: 30rem) {
  .topbar {
    flex-wrap: wrap;
    justify-content: center;
    row-gap: 0.4rem;
  }

  .topbar h1 {
    width: 100%;
    text-align: center;
    order: -1;
  }

  .topbar-nav {
    margin-left: 0;
    margin-right: 0;
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
    justify-content: center;
  }

  .status {
    flex: 1 1 auto;
    min-width: 0;
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    font-size: 0.8rem;
    text-align: center;
  }
}

.log {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.msg {
  max-width: 85%;
  padding: 0.6rem 0.85rem;
  border-radius: 1rem;
  white-space: pre-wrap;
  word-wrap: break-word;
}

.msg.user {
  align-self: flex-end;
  background: var(--bubble-user);
  border-bottom-right-radius: 0.25rem;
}

.msg.assistant {
  align-self: flex-start;
  background: var(--bubble-assistant);
  border: 1px solid var(--border);
  border-bottom-left-radius: 0.25rem;
}

.msg.pending {
  color: var(--ink-soft);
}

/* Clickable links inside a chat bubble (web-search results, any URL the
   assistant or user includes) -- rendered as real <a> nodes by app.js's
   appendRichText. Underlined + accent-coloured so they read as tappable,
   and long URLs wrap/break instead of overflowing the bubble. */
.msg a {
  color: var(--accent);
  text-decoration: underline;
  overflow-wrap: anywhere;
  word-break: break-word;
}

.msg a:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 3px;
}

/* F3 (ops/contracts-family-wave.md "F3 -- help center + in-chat action
   chips"): the tappable chip rendered inside an assistant bubble by
   app/web/static/app.js's renderActionChip, and the outcome line it
   writes after a tap. */
.chat-chip {
  display: block;
  margin-top: 0.6rem;
  min-height: 2.75rem;
  padding: 0.5rem 1rem;
  border-radius: 0.6rem;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
  font-size: 0.95rem;
  cursor: pointer;
}

.chat-chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.chat-chip:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.chat-chip-outcome {
  margin: 0.35rem 0 0;
  font-size: 0.85rem;
  color: var(--ink-soft);
}

.composer {
  display: flex;
  gap: 0.5rem;
  align-items: flex-end;
  padding: 0.75rem 1rem max(0.75rem, env(safe-area-inset-bottom));
  border-top: 1px solid var(--border);
  background: var(--panel);
}

.composer textarea {
  flex: 1;
  resize: none;
  max-height: 8rem;
  padding: 0.6rem 0.75rem;
  border-radius: 0.9rem;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--ink);
  font: inherit;
}

.composer textarea:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* H2 (ops/contracts-ui-wave.md "H2 -- composer placeholder one line"):
   live finding -- "Tell Chorely something…" wrapped to two lines on
   narrow phones. The text itself was shortened to "Tell Chorely…"
   (index.html) as the actual, always-effective fix; this rule is a
   best-effort belt-and-braces for browsers that DO honour
   white-space/text-overflow on a textarea's ::placeholder pseudo-element
   (Chromium/WebKit do; historically inconsistent across engines, which is
   exactly why the shorter text -- not this rule alone -- is what the
   contract calls "the fix" when it doesn't apply). Harmless no-op where
   unsupported: an unsupported ::placeholder rule is simply ignored, the
   textarea keeps wrapping as it always did, and the shorter text still
   carries the fix on its own. */
.composer textarea::placeholder {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.composer button {
  padding: 0.6rem 1.1rem;
  border-radius: 0.9rem;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.composer button:disabled {
  opacity: 0.6;
  cursor: default;
}

.signed-out {
  padding: 2rem 1rem;
  text-align: center;
  color: var(--ink-soft);
}

/* Auth flows (C-P3): invite accept + PIN gate screens. */

.auth-screen {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  padding: 2rem 1.25rem;
  text-align: center;
}

.auth-screen p {
  color: var(--ink-soft);
  max-width: 26rem;
}

.auth-form {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  width: 100%;
  max-width: 20rem;
}

.auth-form input {
  padding: 0.7rem 0.9rem;
  border-radius: 0.9rem;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--ink);
  font: inherit;
  text-align: center;
  letter-spacing: 0.2em;
}

.auth-form input:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.auth-form button {
  padding: 0.7rem 1.1rem;
  border-radius: 0.9rem;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.auth-form button:disabled {
  opacity: 0.6;
  cursor: default;
}

.auth-link-btn {
  background: none;
  border: none;
  color: var(--ink-soft);
  text-decoration: underline;
  font: inherit;
  cursor: pointer;
  padding: 0.4rem;
}

.auth-error {
  color: var(--accent);
  min-height: 1.2em;
}

/* P4 (ops/contracts-pin-ux.md "lockout messaging"): reason line + live
   countdown under the PIN / recovery / accept forms while a 429/423 holds. */
.auth-lockout {
  text-align: center;
}

.auth-lockout-reason {
  color: var(--accent);
  font-weight: 600;
  margin: 0 0 0.25rem;
}

.auth-lockout-countdown {
  color: var(--ink-soft);
  margin: 0;
}

/* P3 (ops/contracts-pin-ux.md "one box per word"): the 4 recovery-word
   inputs on the forgot-path, laid out like the show-once words grid. */
.recovery-word-boxes {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.6rem;
}

.auth-form .recovery-word-input {
  letter-spacing: 0.02em;
  font-size: 1.1rem;
  padding: 0.7rem 0.4rem;
}

/* I2 (ops/contracts-recovery-wave.md "I2 -- PIN recovery, wired end to
   end"): the show-once recovery-words treatment, shared by the PIN gate's
   forgot-path (pin.html) and the dashboard Security row's issue/reissue
   modal (dashboard.html) -- same big, well-spaced, plain-language look in
   both places, no jargon ("recovery words", never "passphrase"). */

.recovery-words-modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.25rem;
  z-index: 50;
}

.recovery-words-modal {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 1rem;
  padding: 1.5rem 1.25rem;
  max-width: 26rem;
  width: 100%;
  text-align: center;
}

.recovery-words-modal h2 {
  margin: 0 0 0.5rem;
  font-size: 1.15rem;
}

.recovery-words-modal p {
  color: var(--ink-soft);
  margin: 0 0 1rem;
}

.recovery-words-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.75rem;
  margin: 0 0 1.25rem;
}

.recovery-word {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 0.75rem;
  padding: 0.9rem 0.5rem;
  font-size: 1.35rem;
  font-weight: 700;
  letter-spacing: 0.02em;
}

.recovery-words-confirm-btn {
  min-height: 3rem;
  width: 100%;
  padding: 0.7rem 1.1rem;
  border-radius: 0.9rem;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-size: 1.05rem;
  font-weight: 700;
  cursor: pointer;
}

.recovery-words-confirm-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Owner invite page (C-E2). */

.invites-screen {
  gap: 1.25rem;
}

.invite-result {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  max-width: 20rem;
}

.invite-url-row {
  display: flex;
  gap: 0.5rem;
  width: 100%;
}

.invite-url-row input {
  flex: 1;
  padding: 0.7rem 0.9rem;
  border-radius: 0.9rem;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--ink);
  font: inherit;
  letter-spacing: normal;
  text-align: left;
}

.invite-url-row button {
  padding: 0.7rem 1rem;
  border-radius: 0.9rem;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.invite-copy-status {
  color: var(--ink-soft);
  min-height: 1.2em;
  margin: 0;
}

.invite-qr {
  width: 12rem;
  height: 12rem;
  background: #ffffff;
  border-radius: 0.9rem;
  border: 1px solid var(--border);
  padding: 0.5rem;
}

.invite-expiry {
  color: var(--ink-soft);
  font-size: 0.85rem;
  margin: 0;
}

/* PWA install guidance shown after a successful invite accept (C-E2). */

.install-guidance {
  width: 100%;
  max-width: 26rem;
  text-align: left;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 0.9rem;
  padding: 1rem 1.1rem;
}

.install-guidance h2 {
  margin: 0 0 0.5rem;
  font-size: 1rem;
}

.install-guidance p {
  margin: 0.35rem 0;
  color: var(--ink-soft);
  max-width: none;
}

.install-guidance button {
  margin-top: 0.6rem;
  padding: 0.7rem 1.1rem;
  border-radius: 0.9rem;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

/* U2 (ops/contracts-auth-wave.md "U2 -- onboarding"): the always-visible
   "skippable-with-a-nudge" caption next to the onboarding PIN step's skip
   button, and the plain arrow pointing at the browser's own Share icon
   for the iOS "no auto-install API" instruction overlay. */

.onboard-nudge {
  font-size: 0.85rem;
  color: var(--ink-soft);
  margin: 0.75rem 0 0.25rem;
}

.ios-install-arrow {
  font-size: 2rem;
  text-align: center;
  margin-top: 0.5rem;
  color: var(--accent);
}

/* Visually hides content while keeping it available to screen readers
   (standard "sr-only" pattern). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Push-to-talk mic button (C-V4) — hold to record, release to send.
   §5.1: recording only ever happens while this button is actively held,
   and .recording below is the visible indicator required whenever it is. */

.mic-btn {
  position: relative;
  flex: 0 0 auto;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  touch-action: none;
  /* Hold-to-talk on mobile: without these, iOS/Android interpret the long
     press as text selection (magnifier loupe / highlight) instead of a
     button hold — live mobile finding 2026-08-28. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

.mic-btn * {
  -webkit-user-select: none;
  user-select: none;
  pointer-events: none;
}

.mic-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.mic-indicator {
  width: 0.9rem;
  height: 0.9rem;
  border-radius: 50%;
  background: var(--ink-soft);
}

/* Visible pulsing indicator while actively recording — the explicit,
   always-visible "you are being heard" signal §5.1 requires. */
.mic-btn.recording {
  border-color: var(--accent);
  background: var(--accent);
}

/* Photo capture button (V-wave, ops/spec-vision-photos.md) — a plain
   <input type="file" accept="image/*" capture="environment"> trigger,
   same round shape/sizing as the mic button beside it. Never a live
   camera preview -- opens the OS's own camera/photo picker only. */
.photo-btn {
  flex: 0 0 auto;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--panel);
  color: var(--ink);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  font-size: 1.25rem;
  line-height: 1;
}

.photo-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* A saved photo rendered inline inside an assistant bubble (either right
   after a fresh answer, or via search_photos' single-match recall). */
.chat-photo {
  display: block;
  max-width: 100%;
  margin-top: 0.5rem;
  border-radius: 0.5rem;
  cursor: zoom-in;
}

/* S-wave (ops/contracts-slideshow-2026-09-05.md deliverable 5): a PAGE of
   saved photos played together inside one assistant bubble, each with the
   server-composed attribution caption underneath it. One column, full
   width -- the target user is elderly and looks at ONE photo at a time
   while scrolling; a grid of thumbnails would shrink every face. */
.slideshow {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-top: 0.5rem;
}

.slide {
  margin: 0;
}

/* Label and attribution on their own lines -- a big-text reader should
   never have to parse a run-on ("Day 1 — yours — private"). */
.slide-caption {
  margin: 0.3rem 0 0;
}

.slide-label {
  display: block;
  font-size: 1rem;
  font-weight: 600;
  color: var(--ink);
}

.slide-who {
  display: block;
  font-size: 0.9rem;
  color: var(--ink-soft);
}

/* An authed photo the server wouldn't (or couldn't) give us -- never a
   silent gap (the credentialless-subresource lesson: a failed authed
   subresource must SAY it failed). */
.slide-failed {
  margin: 0;
  padding: 0.75rem;
  border: 1px dashed var(--border);
  border-radius: 0.5rem;
  font-size: 0.9rem;
  color: var(--ink-soft);
}

/* The code-composed "N more photos" line, printed verbatim. */
.slideshow-more {
  margin: 0.6rem 0 0;
  font-size: 0.95rem;
  color: var(--ink-soft);
}

/* LARGE Next / Back / Stop targets (owner decision: her users have poor
   fine-motor control -- big buttons, never a swipe; a swipe may be added
   later but must never be the only way). Well above the app's 2.75rem
   minimum tap target, and they wrap rather than shrink on a narrow
   phone. */
.slideshow-controls {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-top: 0.75rem;
}

.slideshow-btn {
  flex: 1 1 6rem;
  min-height: 3.5rem;
  padding: 0.75rem 1rem;
  border-radius: 0.7rem;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 700;
  font-size: 1.05rem;
  cursor: pointer;
}

.slideshow-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.slideshow-btn:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

/* AV-wave (the ALBUM VIEWER): the same page of photos, but the Next /
   Back buttons now turn the page CLIENT-SIDE (GET /photos/album/page) --
   no chat turn, no tokens, per tap. Swipe and the arrow keys do the same
   thing; the buttons stay the big, obvious path, because swipe is the
   hardest gesture for older hands and must never be the only way in. */
.photo-viewer {
  margin-top: 0.5rem;
  /* Horizontal swipes are ours; vertical panning stays the browser's, so
     scrolling the loaded photos never turns the page underneath her. */
  touch-action: pan-y;
}

.photo-viewer:focus {
  outline: none;
}

/* Scroll through the photos already loaded, without the bubble growing
   taller than the screen. */
.viewer-strip {
  max-height: 70vh;
  overflow-y: auto;
  margin-top: 0;
  -webkit-overflow-scrolling: touch;
}

.viewer-more:empty {
  display: none;
}

/* Q3 (ops/contracts-prod-polish.md "Q3 -- image lightbox"): full-screen
   photo viewer, social-media pattern -- dark backdrop, image object-fit
   contain (never cropped/stretched), top-right ✕ close target sized to
   the app's existing 2.75rem minimum tap-target convention (see
   .mic-btn/.photo-btn). `[hidden]` (style.css's global rule, above) wins
   over this `display: flex` whenever the element carries the `hidden`
   attribute -- app.js only ever toggles `.hidden`, never a class, to
   show/hide this. */
.photo-lightbox-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.9);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem 1rem;
  z-index: 100;
}

.photo-lightbox-img {
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: 0.5rem;
}

.photo-lightbox-close {
  position: fixed;
  top: 0.75rem;
  right: 0.75rem;
  width: 2.75rem;
  height: 2.75rem;
  min-width: 2.75rem;
  min-height: 2.75rem;
  border-radius: 50%;
  border: none;
  background: rgba(255, 255, 255, 0.15);
  color: #fff;
  font-size: 1.25rem;
  line-height: 1;
  cursor: pointer;
  z-index: 101;
  /* Live nit 2026-09-02: the ✕ sat off-centre in its circle -- default
     button text centering leaves the glyph on its font baseline (worst on
     iOS). Flex-centre it and zero the UA padding so the glyph is
     geometrically centred in the circle. */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}

.photo-lightbox-close:hover {
  background: rgba(255, 255, 255, 0.25);
}

.photo-lightbox-close:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* Safeword setup form (2026-09-02): opened by the "setup-safeword" action
   chip -- see index.html's #safewordModal comment. Same backdrop pattern
   as the photo lightbox; a plain, large-text card sized for elderly
   users on phones. */
.safeword-modal-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: rgba(0, 0, 0, 0.75);
  overflow-y: auto;
}

.safeword-modal {
  background: var(--bubble-assistant);
  border: 1px solid var(--border);
  border-radius: 1rem;
  padding: 1.25rem;
  max-width: 26rem;
  width: 100%;
  max-height: 90vh;
  overflow-y: auto;
}

.safeword-modal h2 {
  margin: 0 0 0.5rem;
  font-size: 1.2rem;
}

.safeword-modal-intro {
  margin: 0 0 1rem;
  color: var(--ink-soft);
  font-size: 0.95rem;
}

.safeword-modal label {
  display: block;
  margin-bottom: 0.85rem;
  font-size: 0.95rem;
}

.safeword-modal input {
  display: block;
  width: 100%;
  box-sizing: border-box;
  margin-top: 0.3rem;
  padding: 0.6rem 0.75rem;
  font-size: 1rem;
  border-radius: 0.6rem;
  border: 1px solid var(--border);
  background: var(--bubble-user);
  color: inherit;
}

.safeword-form-error {
  margin: 0 0 0.75rem;
  color: #e0925c;
  font-size: 0.95rem;
}

.safeword-modal-actions {
  display: flex;
  gap: 0.75rem;
  justify-content: flex-end;
}

.safeword-modal-actions button {
  min-height: 2.75rem;
  padding: 0.5rem 1.1rem;
  border-radius: 0.6rem;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 600;
  font-size: 0.95rem;
  cursor: pointer;
}

.safeword-modal-actions .safeword-btn-secondary {
  background: transparent;
  color: inherit;
  border-color: var(--border);
}

.mic-btn.recording .mic-indicator {
  background: var(--accent-ink);
  animation: mic-pulse 1.1s ease-in-out infinite;
}

@keyframes mic-pulse {
  0%,
  100% {
    transform: scale(1);
    opacity: 1;
  }
  50% {
    transform: scale(1.35);
    opacity: 0.6;
  }
}

/* Respect reduced-motion preferences: keep the color/state change (still
   a clear visible indicator) but drop the pulsing animation itself. */
@media (prefers-reduced-motion: reduce) {
  .mic-btn.recording .mic-indicator {
    animation: none;
  }
}

/* K14: update-available banner (app.js `applyUserPrefs()` — see its
   comment for the version-tracking mechanism). Styled entirely through
   the same --panel/--ink/--border/--accent variables as everything else
   on the page, so it just reads correctly against the one dark palette
   above with no separate override needed. Non-blocking — sits above the
   composer, never covers the message log, and never auto-reloads
   anything; the user presses the button on their own schedule. */
.update-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.6rem 1rem;
  background: var(--panel);
  color: var(--ink);
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  font-size: 0.9rem;
}

.update-banner button {
  flex: 0 0 auto;
  padding: 0.4rem 0.85rem;
  border-radius: 0.7rem;
  border: none;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}

.update-banner button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Mobile selection policy (owner UX directive 2026-08-28): app chrome is
   NOT selectable — press-and-hold was highlighting the whole screen. Only
   things a user genuinely copies stay selectable: conversation text and
   their own typing. */
body {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

.msg,
textarea,
input {
  -webkit-user-select: text;
  user-select: text;
}

.msg {
  -webkit-touch-callout: default;
}

/* Live bug 2026-08-28 (owner trapped in an empty recovery-words modal):
   any class that sets its own `display` silently OVERRIDES the HTML
   `hidden` attribute (whose effect is just UA-level display:none). This
   global rule makes `hidden` unconditionally win — every show/hide in
   this codebase toggles the attribute, so this is the intended semantics
   everywhere. jsdom harnesses cannot catch this class of bug (they assert
   the property, not computed style). */
[hidden] {
  display: none !important;
}

/* ==========================================================================
   GA-wave (the GALLERY). Owner design: "one image and a few underneath it.
   Tap it, then swipe left/right to browse." The chat gets a compact CARD
   (cover thumbnail, album name, count line, attribution, one big Open);
   Open puts up a full-screen gallery -- one big photo, a horizontally
   scrollable strip of small thumbnails, big arrows, big Close.
   ========================================================================== */
.album-card {
  display: grid;
  grid-template-columns: minmax(6rem, 9rem) 1fr;
  grid-template-rows: auto auto;
  gap: 0.6rem 0.9rem;
  align-items: center;
  margin-top: 0.6rem;
  padding: 0.6rem;
  border: 1px solid var(--border);
  border-radius: 0.8rem;
  background: var(--panel);
}

.album-cover-figure {
  grid-row: 1 / span 2;
  margin: 0;
  cursor: pointer;
}

.album-cover {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: cover;
  border-radius: 0.6rem;
}

.album-card-text p {
  margin: 0;
}

.album-card-title {
  font-size: 1.15rem;
  font-weight: 700;
  color: var(--ink);
}

.album-card-count,
.album-card-who {
  font-size: 0.95rem;
  color: var(--ink-soft);
}

/* The one control on the card: LARGE. Well above the app's 2.75rem
   minimum tap target. */
.album-open-btn {
  grid-column: 2;
  min-height: 3.5rem;
  padding: 0.75rem 1.25rem;
  border-radius: 0.7rem;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
  font-weight: 700;
  font-size: 1.15rem;
  cursor: pointer;
}

.album-open-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The gallery: a full-screen dialog. Built by app.js, never in
   index.html; `[hidden]` is not used here -- it is removed from the DOM
   on close so the blob sweep frees everything it held. */
.gallery-backdrop {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  flex-direction: column;
  background: rgba(0, 0, 0, 0.95);
  color: #fff;
  /* Horizontal swipes are ours; vertical panning stays the browser's. */
  touch-action: pan-y;
}

.gallery-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.6rem 0.9rem;
}

.gallery-title {
  margin: 0;
  font-size: 1.2rem;
  font-weight: 700;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.gallery-close {
  flex: 0 0 auto;
  min-height: 3.25rem;
  min-width: 6rem;
  padding: 0.6rem 1rem;
  border-radius: 0.7rem;
  border: 1px solid rgba(255, 255, 255, 0.5);
  background: rgba(255, 255, 255, 0.15);
  color: #fff;
  font-size: 1.1rem;
  font-weight: 700;
  cursor: pointer;
}

.gallery-stage {
  flex: 1 1 auto;
  min-height: 0;
  display: grid;
  grid-template-columns: 3.5rem 1fr 3.5rem;
  align-items: center;
  gap: 0.25rem;
  padding: 0 0.25rem;
}

.gallery-hero {
  margin: 0;
  min-height: 0;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
}

.gallery-hero-img {
  max-width: 100%;
  max-height: calc(100% - 3.5rem);
  object-fit: contain;
  border-radius: 0.5rem;
}

.gallery-hero .slide-caption {
  text-align: center;
}

.gallery-hero .slide-label {
  color: #fff;
}

.gallery-hero .slide-who {
  color: rgba(255, 255, 255, 0.8);
}

.gallery-hero .slide-failed {
  color: rgba(255, 255, 255, 0.85);
  border-color: rgba(255, 255, 255, 0.4);
}

/* Big arrows: the primary way to browse (swipe is an addition). */
.gallery-arrow {
  min-height: 5rem;
  width: 3.5rem;
  border-radius: 0.7rem;
  border: 1px solid rgba(255, 255, 255, 0.4);
  background: rgba(255, 255, 255, 0.15);
  color: #fff;
  font-size: 1.6rem;
  cursor: pointer;
}

.gallery-arrow:disabled,
.gallery-close:disabled {
  opacity: 0.35;
  cursor: not-allowed;
}

.gallery-arrow:focus-visible,
.gallery-close:focus-visible,
.gallery-thumb:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

.gallery-note {
  margin: 0.3rem 0.9rem;
  min-height: 1.2rem;
  font-size: 0.95rem;
  color: rgba(255, 255, 255, 0.8);
  text-align: center;
}

.gallery-note:empty {
  display: none;
}

/* The strip of small thumbnails: one row, scrolls sideways. */
.gallery-thumbs {
  flex: 0 0 auto;
  display: flex;
  gap: 0.5rem;
  padding: 0.5rem 0.9rem 0.9rem;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scroll-behavior: smooth;
}

.gallery-thumb {
  flex: 0 0 auto;
  width: 5rem;
  height: 5rem;
  padding: 0;
  border-radius: 0.5rem;
  border: 3px solid transparent;
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  font-size: 0.8rem;
  overflow: hidden;
  cursor: pointer;
}

.gallery-thumb[aria-current="true"] {
  border-color: #fff;
}

.gallery-thumb-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

@media (prefers-reduced-motion: reduce) {
  .gallery-thumbs {
    scroll-behavior: auto;
  }
}
