/*
 * Gabbie — SCANOSS's collaboration app.
 *
 * The palette is the mascot's, exactly as supplied:
 *   outline #24193A · skin #F5B98F · nose #E8A278 · hair #4A3128
 *   beard #8A5A44 · mouth #3B2038 · hat & shoes #F2822E · trousers #3E56C4
 *   eye/teeth/bubble #FFFCF5 · backdrop #2A3358 → #0E1425
 *
 * Structure follows Donkie (panels, pills, sticky header, gradient accents) so
 * the two SCANOSS tools feel related — but Gabbie is warmer and rounder,
 * because it has to invite people to talk rather than to work a queue.
 *
 * TWO DELIBERATE RULES
 *
 * 1. ORANGE IS THE ACTION COLOUR, BLUE IS THE PERSON COLOUR.
 *    Every button that sends, saves or shares is hat-orange. Every avatar,
 *    mention and presence dot lives in the blue/violet family. So "what can I
 *    press" and "who is this" never compete for the same signal, which is what
 *    makes a dense chat scannable.
 *
 * 2. MY MESSAGES ARE BLUE, EVERYONE ELSE'S ARE NEUTRAL.
 *    Not orange. Orange bubbles would make every message look like a button in
 *    peripheral vision, and in a chat the eye is mostly in peripheral vision.
 *
 * MOBILE IS THE PRIMARY LAYOUT, not a fallback. The single column is the base
 * stylesheet and the two-pane desktop layout is what the media query adds — so
 * a phone never downloads or computes a desktop layout it then overrides. This
 * is the opposite of how desktop-first CSS ages.
 */

:root {
  /* The percentage on an OKR card. Purple because that is what BambooHR uses
     for it, and this panel is read side by side with theirs. `:root` is the
     DARK palette in this file, so this is the value that has to carry on a dark
     ground; the light override is with the rest of them further down. */
  --accent-okr: #a78bfa;
  /* The words a reply quoted, marked in the original (::highlight(reply-quote)
     below). A theme token, like every colour here; the light theme's is
     lighter, so the text under it stays readable on white. */
  --quote-mark: rgba(242, 130, 46, 0.45);
  /* ---- Brand, straight from the mascot ---- */
  --outline: #24193a;
  --skin: #f5b98f;
  --nose: #e8a278;
  --hair: #4a3128;
  --beard: #8a5a44;
  --mouth: #3b2038;
  --orange: #f2822e;
  --blue: #3e56c4;
  --cream: #fffcf5;

  /* ---- Derived, for states the palette does not name ---- */
  --orange-bright: #ff9c4f;
  --orange-deep: #d96a1c;
  --blue-bright: #5f74dd;
  --blue-soft: #8b9bf0;

  /* ---- Dark surfaces, from the mascot's backdrop gradient ---- */
  --bg: #14102b;
  --bg-far: #0e1425;
  --bg-near: #2a3358;
  --panel: rgba(255, 255, 255, 0.045);
  /* The shade --panel resolves to over --bg, but OPAQUE. Anything that floats
     above the page — menus, the emoji bar, the forward sheet — needs this: a 4%
     white wash lets a chat thread read straight through it, which is illegible.
     Keep in step with --bg and --panel if either changes. (Donkie's lesson.) */
  --panel-solid: #1e1a35;
  --panel-raised: #262143;
  --panel-border: rgba(139, 155, 240, 0.15);
  --panel-hover: rgba(255, 255, 255, 0.08);

  /* ---- RECESSED SURFACES: a quoted reply, an attachment row, a code block.
   *
   * These were written as `rgba(0, 0, 0, 0.2)` and similar, straight into the
   * rules — which is correct on a near-black page and is a GREY SLAB on a white
   * one. In light mode a fifth of black over white is #cccccc, so every reply
   * quote and every shared file in a chat came out grey: "in light mode, the
   * chats are still grey, and hard to read".
   *
   * A recess is not "darker", it is "away from the reader" — which means darker
   * over a dark page and LIGHTER over a light one. Hence a variable per depth
   * rather than a colour per rule.
   *
   * The three depths replace five hand-written alphas (0.20, 0.22, 0.24, 0.28,
   * 0.32) that differed by amounts nobody could see. */
  --sunk: rgba(0, 0, 0, 0.22);
  --sunk-firm: rgba(0, 0, 0, 0.32);
  --sunk-hover: rgba(0, 0, 0, 0.34);

  /* THE CHAT LIST'S COLUMN. Its own shade rather than --panel: the list is a
     region of the app and not a card on top of it, and it wants to read as
     slightly set back from the thread beside it. */
  --rail: rgba(14, 20, 37, 0.4);

  --text: #f0eef9;
  --text-dim: #c8c3e0;
  /* The brand's lavender, for a label that names a category. */
  --violet: #a9b4f5;
  --muted: #948eb4;

  --grad-warm: linear-gradient(103deg, var(--orange) 0%, #ff9c4f 100%);
  --grad-cool: linear-gradient(103deg, var(--blue) 0%, #6f83e8 100%);
  --grad-brand: linear-gradient(103deg, var(--blue) 0%, var(--orange) 135%);

  --ok: #35c88a;
  --warn: #e8a33d;
  --bad: #e4574d;

  /* Chat bubbles want a bigger radius than panels: roundness reads as
     conversation, squareness reads as a form. */
  --radius: 16px;
  --radius-sm: 10px;
  --radius-bubble: 20px;
  --shadow: 0 10px 30px rgba(4, 2, 14, 0.45);

  /* The frosted bars — header, composer, day dividers. Tokens rather than
     literals because each one assumed a dark page behind it. */
  --glass: rgba(20, 16, 43, 0.86);
  --glass-soft: rgba(20, 16, 43, 0.72);
  --glass-firm: rgba(20, 16, 43, 0.9);

  --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
          Helvetica, Arial, "Apple Color Emoji", sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* The header height, as a variable because three sticky things and one
     scroll container all have to agree about it. Hard-coded in four places is
     how a layout drifts by two pixels on one page. */
  --header-h: 56px;
  --composer-h: 64px;
}

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }

/* HIDDEN MEANS HIDDEN.
   The browser's own rule is `[hidden] { display: none }` — an unqualified
   attribute selector, which ANY class rule outweighs. So `el.hidden = true` on
   something carrying `.btn` (display: inline-flex) or `.panel` did nothing at
   all, and the element stayed on screen.

   That is how "Save quote" broke: it used to be removed from the page and is now
   hidden and reshown to stop it flickering during a drag — except it never
   actually hid, so the button sat floating wherever it was last put. Three
   places had already worked around this locally. One rule instead. */
[hidden] { display: none !important; }
html {
  /* Stops iOS Safari zooming the whole page when a text input is focused, and
     stops the rubber-band scroll revealing white behind the app. */
  -webkit-text-size-adjust: 100%;
  background: var(--bg);
}
body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  /* 100dvh, not 100vh: on iOS Safari 100vh is the height WITHOUT the browser
     chrome, so a full-height chat put its composer permanently under the
     address bar. dvh is the one that accounts for it. */
  min-height: 100dvh;
  overscroll-behavior-y: none;
}

/* THE SHELL IS THE VIEWPORT, so the document itself never scrolls.
 *
 * On iOS the virtual keyboard does not shrink the layout viewport — only the
 * visual one — so `body` stayed a full 100dvh tall while `.app` shrank to
 * `--app-height`. The document was then taller than what could be seen, iOS
 * scrolled it to bring the focused box into view, and two things followed at
 * once: the thread header slid up under the status bar, and the composer rose
 * off the keyboard leaving a band of dead space under it. Reported as "all
 * kinds of glitches ... and then all kinds of spaces in the bottom".
 *
 * `overflow: hidden` on both html and body is what makes those impossible: if
 * the document cannot scroll, nothing can scroll it. The shell inside is
 * already sized to the visual viewport and its panes do their own scrolling.
 *
 * ONLY WHERE THERE IS A SHELL. Signed-out pages are ordinary documents and
 * must keep scrolling — they are `.app-plain`, and `data-shell` is absent. */
html:has(> body[data-shell]), body[data-shell] {
  height: 100%;
  overflow: hidden;
}

/* The ambient glow, in the mascot's own backdrop gradient. Fixed, so it does
   not move when a long thread scrolls. */
.bg-glow {
  position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background:
    radial-gradient(58% 46% at 14% 0%, rgba(62, 86, 196, 0.30), transparent 70%),
    radial-gradient(50% 40% at 88% 6%, rgba(139, 155, 240, 0.14), transparent 70%),
    radial-gradient(46% 52% at 52% 110%, rgba(242, 130, 46, 0.11), transparent 70%),
    linear-gradient(170deg, var(--bg-near) 0%, var(--bg) 42%, var(--bg-far) 100%);
}

a { color: var(--blue-soft); text-decoration: none; }
a:hover { color: var(--orange-bright); }

/* Links inside AUTHORED text are underlined; links in the chrome are not.
   A colour change alone is enough on a button or a menu row, where the shape
   already says "this does something". In a paragraph it is not: a pasted URL
   now renders as a link, and it has to look like one at a glance rather than on
   hover. `from-font` so the line clears descenders instead of striking through
   every g and y. */
.msg-text a, .prose a, .bubble a:not(.attach):not(.ticket-card):not(.quoted) {
  text-decoration: underline;
  text-underline-offset: 2px;
  text-decoration-thickness: from-font;
  /* A pasted URL can be longer than the bubble. Breaking anywhere is ugly on a
     word and correct on a URL, and only URLs get this long. */
  overflow-wrap: anywhere;
}
.msg-text a:hover, .prose a:hover { text-decoration-color: var(--orange-bright); }
h1, h2, h3, h4 { line-height: 1.18; letter-spacing: -0.02em; margin: 0 0 0.5em; }
h1 { font-size: 1.55rem; }
h2 { font-size: 1.2rem; }
h3 { font-size: 1.02rem; }
p { margin: 0 0 0.8em; }
.muted { color: var(--muted); }
.small { font-size: 0.82rem; }
.tiny { font-size: 0.74rem; }
.mono { font-family: var(--mono); font-size: 0.9em; }
.nowrap { white-space: nowrap; }
.hidden { display: none !important; }
.grow { flex: 1; }
.right { margin-left: auto; }
.center { text-align: center; }
.stack { display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; align-items: center; gap: 10px; }
.wrap { flex-wrap: wrap; }
.grad-text {
  background: var(--grad-brand); -webkit-background-clip: text;
  background-clip: text; -webkit-text-fill-color: transparent; color: transparent;
}

/* Anything focused by keyboard gets a visible ring. Set once, globally: a
   chat app is used one-handed and half-looking, and an invisible focus is an
   accessibility failure that also makes it feel broken. */
:focus-visible {
  outline: 2px solid var(--orange-bright);
  outline-offset: 2px;
}

/* ============================================================ app shell */

/* The whole app is one grid: header, body, and on desktop a sidebar column.
   Grid rather than flex because the body must be exactly the remaining height
   for the thread to scroll INSIDE it rather than scrolling the page — which is
   what keeps the composer pinned on a phone. */
/* ---- HOW MUCH OF THE SCREEN IS NOT OURS -------------------------------
 *
 * Reported from the app on a short phone: "the top bar and part of the
 * bottom including part of the type box run off the screen."
 *
 * TWO DIFFERENT MECHANISMS, and they must not both apply at once.
 *
 * In a BROWSER — Safari on a notched iPhone, Chrome on Android — the page is
 * laid out under the notch and the home indicator, and `env(safe-area-inset-*)`
 * is what says how far. That is what these tokens are by default.
 *
 * In the ANDROID APP the window is edge-to-edge whether it asks to be or not
 * (anything targeting SDK 35+), so the WebView fills the screen INCLUDING the
 * strips behind the status bar and the navigation bar — and a WebView reports
 * `env(safe-area-inset-*)` for a display CUTOUT only, never for the system
 * bars. So the header sat behind the clock and the composer behind the
 * navigation bar, which on a tall screen looks like a tight margin and on a
 * short one loses the top bar and half the type box.
 *
 * The shell now hands the WebView a box that is already inside the bars
 * (GabbieActivity.keepOurselvesInsideTheBars), so here the insets are ZERO:
 * adding them again would count the same strip twice and leave a dead band at
 * each end. One mechanism per shell, never both.
 */
:root {
  --safe-top: env(safe-area-inset-top);
  --safe-bottom: env(safe-area-inset-bottom);
}
body.shell-apk {
  --safe-top: 0px;
  --safe-bottom: 0px;
}

.app {
  display: grid;
  /* THE HEADER'S ROW HAS TO BE AS TALL AS THE HEADER ACTUALLY IS.
   *
   * .site-header is `var(--header-h) + var(--safe-top)` tall, because
   * it has to clear the notch — but this row was only `var(--header-h)`. So the
   * header was taller than the box it lives in and, with z-index 40, painted
   * over the row below it. In a browser var(--safe-top) is 0 and
   * nothing showed; in a home-screen PWA on an iPhone it is around fifty
   * pixels, which is the whole of .sidebar-head — so the search bar was not
   * partly covered, it was entirely underneath the header and did not appear at
   * all.
   *
   * Reported as "search bar is hidden in iOS PWA", and it was every phone with
   * a notch: the wordmark and the new-chat button were under there too. */
  grid-template-rows: calc(var(--header-h) + var(--safe-top)) 1fr;
  /* ONE COLUMN, AND IT MAY BE NARROWER THAN ITS CONTENTS.
     A grid's implicit column is `auto`, which means MAX-CONTENT — so the whole
     shell grew to fit its widest descendant. With a long group name in the
     thread header, "Gabbie Feedback (Issues, Features,etc)", the shell came out
     wider than the phone: the name was pushed off to the right, the header's own
     buttons went with it, and what was left on screen was an avatar and no name.
     Which is exactly the report — "I don't see which group/person I am chatting
     with" — and why it looked fine for a short name and broken for a long one. */
  grid-template-columns: minmax(0, 1fr);
  /* THREE DECLARATIONS FOR ONE HEIGHT, IN ORDER OF HOW MUCH THEY KNOW.
     A browser takes the last one it understands and ignores the rest.

     `dvh` needs Chromium 108. Older engines — and Julian's Huawei is one, its
     WebView reports Chromium 101 — DROP a declaration whose unit they do not
     know, so `height: 100dvh` alone left this element with no height at all: it
     grew to the height of the whole thread, the document became the scroller,
     and the chat name scrolled off the top with everything else. Which is
     exactly how it was reported: "I don't have the name of the chat".

     100vh is the floor every engine has understood for a decade. --app-height is
     app.js's measurement of visualViewport, which is the only figure that is
     right when a keyboard or a browser toolbar is in the way. */
  height: 100vh;
  height: 100dvh;
  height: var(--app-height, 100dvh);
  max-height: 100vh;
  max-height: 100dvh;
  max-height: var(--app-height, 100dvh);
  overflow: hidden;
}

/*
 * The signed-out pages — sign in, the password door, two-factor — are ORDINARY
 * SCROLLING PAGES, not the app shell.
 *
 * They must opt out of the grid above, and this is why: `.app` declares two rows
 * and the first is the header's height. Signed in, that row holds the header.
 * Signed out there is no header, so the page content landed in a 56px-tall row
 * with `overflow: hidden` on the container — which showed the top tenth of the
 * sign-in page and clipped the rest, with the form's own scroll trapped inside
 * that sliver. It is the kind of bug that is invisible to any check made while
 * logged in.
 *
 * Fixed by making these pages plain block flow, which is what they always
 * should have been: they have no panes to keep in sync and nothing to pin.
 */
.app-plain {
  display: block;
  height: auto;
  max-height: none;
  min-height: 100dvh;
  overflow: visible;
}
.app-plain .page {
  /* `overflow: visible`, overriding the app shell's `overflow-y: auto`, so the
     BODY scrolls and not a box inside it. A nested scroller on a phone is what
     produces "I can scroll a little inside this small area" — the browser's own
     momentum scrolling, the address-bar collapse and the keyboard avoidance all
     work on the document, not on a div. */
  overflow: visible;
  min-height: 100dvh;
  padding-top: calc(24px + var(--safe-top));
  padding-bottom: calc(32px + var(--safe-bottom));
}

.site-header {
  grid-row: 1;
  display: flex; align-items: center; gap: 10px;
  padding: 0 12px;
  border-bottom: 1px solid var(--panel-border);
  background: var(--glass);
  -webkit-backdrop-filter: blur(14px);
  backdrop-filter: blur(14px);
  z-index: 40;
  /* The notch and the home indicator. Without this the header sits under the
     status bar in a home-screen PWA on an iPhone. */
  padding-top: var(--safe-top);
  height: calc(var(--header-h) + var(--safe-top));
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .site-header { background: var(--bg); }
}

/* THE ANDROID APP, MAXIMISED ON A BIG SCREEN.
 *
 * Android's desktop windowing draws a caption bar OVER the app once the window
 * is maximised — and only then. Unlike a phone's notch it is not reported
 * through var(--safe-top), so the header had nothing to clear and sat
 * underneath the window controls.
 *
 * THE FIX IS A SMALLER BOX, NOT MORE PADDING INSIDE THE SAME ONE. The first
 * attempt padded the header and left `.app` at 100dvh, which cleared the top
 * and left the bottom 32px — the composer — hanging below the visible area.
 * The window is genuinely shorter than the viewport claims, so the shell has to
 * be shorter too: offset from the top by the caption, and take that height back
 * off the total.
 *
 * `.maximised` is set by app.js, which is the only thing that can tell —
 * "maximised" is not a media query. Without it this used to fire on any wide
 * window, which put the same dead space above the header when the app was
 * merely large.
 *
 * --caption-h is a guess, because nothing reports the real height. One number to
 * change, and max() means a platform that DOES report an inset still wins.
 */
/* THE MEASURED HEIGHT WINS OVER 100dvh IN THE INSTALLED APP.
   100dvh is meant to be the visible viewport and in a browser it is. In the
   Android app it came out taller than the screen, so the composer — the last
   thing in the shell — sat below the fold, and the only way to reach it was to
   scroll the PAGE rather than the thread. Scrolling the thread to its bottom,
   which is what opening a chat does, could never help. app.js measures
   visualViewport and publishes it here. */
/* Now folded into `.app` itself, which every browser needs and not only the
   installed shells — see the three height declarations there. */
/* And nothing outside the shell may scroll: with the height right there is
   nothing below the fold to reach, so a page scroll could only hide the
   composer again. */
body.shell-apk, body.shell-pwa { overflow: hidden; }

body.shell-apk.maximised {
  --caption-h: 32px;
  /* THE BOTTOM IS THE CAPTION AGAIN, PLUS A HAIR.
     Working back from what was actually observed: with only 5px here, the
     sidebar's own badge sat almost entirely below the screen — about a caption's
     worth. That is the tell. The window is not merely overlaid at the top, it is
     the caption's height SHORTER than 100dvh overall, so reserving that height
     at the top without also taking it off the bottom overhangs by exactly one
     caption bar. The extra 5px is the gap that was asked for on top of that. */
  --window-bottom: calc(var(--caption-h) + 5px);
}
/* PINNED, NOT MEASURED. The arithmetic version — margin-top plus a calc()
   height — kept leaving the page a couple of dozen pixels scrollable, because
   the margin adds to the document's height while the shell is trying to BE the
   document's height. Every combination of those two numbers is a chance to be
   wrong by their difference.

   `position: fixed` with all four edges takes the shell out of flow entirely,
   so it cannot make the document taller than the window and there is nothing to
   scroll. The edges say where it sits; no height is computed at all. */
body.shell-apk.maximised .app {
  position: fixed;
  height: auto;
  max-height: none;
  top: max(var(--safe-top), var(--caption-h));
  left: 0;
  right: 0;
  bottom: var(--window-bottom);
  height: auto;
  max-height: none;
}
/* And nothing behind it may scroll either. */
body.shell-apk.maximised,
body.shell-apk.maximised > html { overflow: hidden; }
/* The header's own notch padding would now double-count what .app already
   offset, so it goes back to a plain bar. */
body.shell-apk.maximised .site-header {
  padding-top: 0;
  height: var(--header-h);
}
/* And the row shrinks with it. `.app` already offsets past the caption from the
   top, so leaving the row at header+inset would open a gap under the header
   exactly as tall as a notch the window does not have. */
body.shell-apk.maximised .app {
  grid-template-rows: var(--header-h) 1fr;
}
body.shell-apk.maximised .flashes {
  top: calc(var(--header-h) + max(var(--safe-top), var(--caption-h)) + 10px);
}


.brand { display: flex; align-items: center; gap: 8px; color: var(--text); font-weight: 650; }
.brand:hover { color: var(--text); }
.brand img { display: block; height: 30px; width: 30px; }
.brand-name { font-size: 1.08rem; letter-spacing: -0.02em; }
.brand .sub { color: var(--muted); font-size: 0.72rem; font-weight: 400; }

.icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 38px; height: 38px; flex: 0 0 38px;
  border: 1px solid transparent; border-radius: 11px;
  background: transparent; color: var(--text-dim);
  cursor: pointer; font-size: 1.05rem; position: relative;
  /* No transition on transform: a tap highlight that animates feels laggy on a
     phone, where the whole point is that it responds instantly. */
  transition: background .12s ease, color .12s ease;
}
.icon-btn:hover { background: var(--panel-hover); color: var(--text); }
.icon-btn.on { color: var(--orange); }
.dot {
  position: absolute; top: 7px; right: 7px;
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--orange); border: 2px solid var(--bg);
}
.count {
  position: absolute; top: 3px; right: 2px;
  min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: 9px; background: var(--orange); color: #2a1400;
  font-size: 0.66rem; font-weight: 800; line-height: 17px; text-align: center;
  border: 2px solid var(--bg);
}

.app-body {
  grid-row: 2;
  display: grid;
  /* minmax(0, 1fr) AND NOT 1fr. `1fr` is shorthand for minmax(auto, 1fr), and
     that `auto` minimum is the item's MIN-CONTENT width — so a column that is
     meant to be "whatever is left" silently grows to fit its widest child
     instead. Measured on a 393px screen: the search page's column came out 917px
     wide and everything in it was clipped at the right edge, chips and sentences
     both. Nothing overflowed the document, so there was no scrollbar to hint at
     it — the text was simply cut off. */
  grid-template-columns: minmax(0, 1fr);
  min-height: 0;   /* lets the children scroll instead of the grid growing */
  overflow: hidden;
}

/* ---- the sidebar ---- */
.sidebar {
  display: flex; flex-direction: column;
  min-height: 0;
  border-right: 1px solid var(--panel-border);
  background: var(--rail);
}
.sidebar-head { padding: 10px 12px 6px; display: flex; align-items: center; gap: 8px; }
.sidebar-list { overflow-y: auto; padding: 4px 8px 16px; flex: 1; -webkit-overflow-scrolling: touch; }
.sidebar-foot {
  padding: 8px 10px calc(8px + var(--safe-bottom));
  border-top: 1px solid var(--panel-border);
  display: flex; gap: 6px; align-items: center;
}

.conv {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px; border-radius: 13px;
  color: var(--text-dim); margin-bottom: 2px;
  position: relative;
}
.conv:hover { background: var(--panel-hover); color: var(--text); }
.conv.active { background: rgba(62, 86, 196, 0.22); color: var(--text); }
.conv.active::before {
  content: ""; position: absolute; left: 0; top: 12px; bottom: 12px;
  width: 3px; border-radius: 0 3px 3px 0; background: var(--orange);
}
.conv-main { min-width: 0; flex: 1; }
.conv-title {
  display: block; font-weight: 600; font-size: 0.92rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.conv-sub {
  display: block; color: var(--muted); font-size: 0.78rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.conv.unread .conv-title { color: var(--cream); font-weight: 700; }
/* An unread row's preview is the thing being previewed, so it is not dimmed to
   the same grey as a member count. Not full white either — the title is still
   what you scan for. */
.conv.unread .conv-sub { color: var(--text-dim); }
.conv-badge {
  flex: 0 0 auto; min-width: 20px; height: 20px; padding: 0 6px;
  border-radius: 10px; background: var(--orange); color: #2a1400;
  font-size: 0.7rem; font-weight: 800; line-height: 20px; text-align: center;
}
/* AN AGENT'S "SOMETHING NEW". A dot and not a pill: the transcript grew,
   which is one fact, and there is no count to put in a number. Sized and
   placed like the badge it stands in for so a list of both does not wobble. */
/* THE ROBOT IN A ROW. The same drawing the header used to carry, sized to the
   small avatar it stands in for and drawn in the text colour rather than its
   own, so a list of people, groups and agents keeps one weight. */
.avatar.bot { display: inline-flex; align-items: center; justify-content: center; }
.avatar.bot svg { width: 60%; height: 60%; opacity: .85; }

.conv-dot { width: 9px; height: 9px; border-radius: 50%; flex: none;
            background: var(--blue); align-self: center; margin-right: 2px; }

.conv-mute { color: var(--muted); font-size: 0.8rem; }

/* ---- avatars ---- */
/* Initials on a derived colour, not an uploaded photo. Nobody uploads one, and
   a grid of grey silhouettes is worse than a grid of distinct colours. */
.avatar {
  flex: 0 0 auto;
  display: inline-flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; border-radius: 12px;
  font-size: 0.76rem; font-weight: 750; letter-spacing: 0.02em;
  color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
  position: relative; overflow: visible;
}
.avatar.sm { width: 28px; height: 28px; border-radius: 9px; font-size: 0.66rem; }
.avatar.lg { width: 52px; height: 52px; border-radius: 17px; font-size: 1.05rem; }
.avatar .presence {
  position: absolute; right: -2px; bottom: -2px;
  width: 11px; height: 11px; border-radius: 50%;
  background: var(--muted); border: 2.5px solid var(--bg);
}
.avatar .presence.online { background: var(--ok); }
/* A group's avatar is its emoji on a neutral tile — an emoji on a coloured one
   fights with it, and a group has no initials worth reading. */
.avatar.group { background: var(--panel-raised); font-size: 1.05rem; text-shadow: none; }

/* The outgoing thread while the next one loads. Switching chats is a full page
 * load; this covers the gap between the click and the new thread, so a click
 * that is being acted on does not look like one that was ignored.
 *
 * Opacity and nothing else: no spinner, no skeleton. The thread is about to be
 * replaced wholesale, and a placeholder that flashes for 200ms reads as a
 * fault. Pointer events go too, so a second impatient click cannot start a
 * second navigation.
 *
 * .28s out, and no transition back — `leaving` is only ever removed by the
 * back button, where the page should simply be there. */
.thread.leaving {
  opacity: 0.38;
  transition: opacity 0.28s ease-out;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .thread.leaving { transition: none; }
}

/* ============================================================ the thread */

.thread {
  display: flex; flex-direction: column;
  min-height: 0; min-width: 0;
  position: relative;
}
.thread-head {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px; min-height: 52px;
  border-bottom: 1px solid var(--panel-border);
  background: var(--glass-soft);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  z-index: 20;
}
/* A long group name shortens rather than pushing the header's buttons off the
   right of a phone. (.app is minmax(0,1fr) so the shell no longer grows to fit
   it, but the name still has to give way to the icons beside it.) */
.thread-title { font-weight: 700; font-size: 0.98rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thread-sub { color: var(--muted); font-size: 0.76rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Nothing to say below the name — no topic, not a direct message — leaves the
   header one line tall rather than one line plus an empty strip. */
.thread-sub:empty { display: none; }

/* THE scroll container. Everything else in the app is fixed height, so this is
   the only thing that scrolls — which is what lets the composer stay put. */
.messages {
  flex: 1; min-height: 0;
  overflow-y: auto; overflow-x: hidden;
  padding: 14px 12px 8px;
  -webkit-overflow-scrolling: touch;
  /* Anchoring keeps the reader's place when messages are prepended by the
     scroll-back. Without it, loading older messages jumps the view. */
  overflow-anchor: auto;
  /* And reaching either end does not pass the scroll on to the page, which
     would drag the header — and the chat's name with it — off the top. */
  overscroll-behavior: contain;
  /* NO scroll-behavior: smooth HERE.
     It used to be, and it was the cause of a bug that looked like three
     different ones. `scrollTo({behavior: "auto"})` does not mean "jump" — it
     means "do whatever the CSS says", so with smooth set every programmatic
     scroll animated. Opening a chat therefore travelled visibly from the top of
     the thread to the bottom, taking longer the longer the history, and for the
     whole of that journey the composer was off-screen — which is precisely how
     it was reported: the text box hidden until you scrolled manually.
     Smooth is now asked for explicitly, in the one place it belongs: a new
     message arriving while you are already at the bottom. */
}

/* ---- OPENING A CHAT ----
 *
 * "would it be possible for the chat feed to show up with a quick/short slide
 * to left animation? That would improve the look and feel"
 *
 * The feed only — not the header, not the sidebar, not the composer: those are
 * the frame and they do not move between one chat and the next, so moving them
 * would say the whole app had been replaced rather than the conversation in it.
 *
 * 200ms and a dozen pixels. Long enough to read as deliberate, short enough
 * that somebody clicking down a list of chats is never waiting for it — and it
 * covers the last of the settling (a late image, a swapped font) with movement
 * that was going to happen anyway, which is the other half of what was asked
 * for here.
 *
 * transform and opacity ONLY, so it is composited and never reflows the thread
 * it is animating. `backwards` so the first frame applies before it starts,
 * rather than a flash of the finished position.
 */
@media (prefers-reduced-motion: no-preference) {
  .messages { animation: feed-in 0.2s cubic-bezier(0.22, 0.61, 0.36, 1) backwards; }
}
@keyframes feed-in {
  from { opacity: 0.35; transform: translate3d(16px, 0, 0); }
  to   { opacity: 1; transform: none; }
}
@keyframes feed-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translate3d(-16px, 0, 0); }
}

/* ---- SWITCHING CHATS ON A BIG SCREEN ----
 *
 * "in a large screen the whole screen flickers before switching chats. i think
 * it is because of the drawing of the chat list?"
 *
 * Close: it is the drawing of EVERYTHING. Opening a chat is a real navigation
 * — deliberately, because the alternative is a router and a decade of subtle
 * back-button bugs (see the note in app.js) — so the browser tears down one
 * document and builds another. It holds the old picture while it can, and at
 * ~130ms of server render plus 275KB of HTML on a wide screen it runs out of
 * patience and paints a blank frame first. The sidebar is identical either
 * side of that blank frame, which is what makes it read as a flicker rather
 * than as a page load.
 *
 * A CROSS-DOCUMENT VIEW TRANSITION is the browser's own answer: it keeps the
 * old rendering on screen, renders the new one behind it, and animates between
 * the two — so there is no blank frame to see. The feed gets a name of its own
 * and slides; everything else cross-fades, and since the header, the sidebar
 * and the composer are the same on both sides, cross-fading them is invisible.
 *
 * WIDE SCREENS ONLY. On a phone the list and the thread are the same document
 * with one of them hidden by app.js after it loads, and transitioning between
 * two snapshots taken either side of that is a way to make a pop visible that
 * currently is not.
 */
/* AT THE TOP LEVEL, NOT INSIDE A MEDIA QUERY.
 *
 * It was nested in `@media (min-width: 900px)` to keep phones out of it, and
 * the result was the slide disappearing everywhere: browsers do not all honour
 * @view-transition inside a conditional rule, so the transition never started
 * — while the @supports rule below had already switched off the page's own
 * animation on the grounds that the transition would be doing it. Neither ran.
 *
 * So: every width opts in, and the phone concern is handled where it belongs,
 * by what the animation does rather than by whether it exists. */
@view-transition { navigation: auto; }
.messages { view-transition-name: feed; }

/* THE PARTS THAT DO NOT CHANGE MUST NOT BE ANIMATED AS IF THEY HAD.
 *
 * "sometimes the list of chats flickers when switching groups."
 *
 * It does, and this is why: everything without a name of its own is one
 * snapshot called `root`, and switching chats cross-fades that whole snapshot
 * — including a sidebar and a header that are IDENTICAL either side. Fading a
 * picture of the list into an identical picture of the list is exactly what a
 * flicker looks like, and it happened on every navigation.
 *
 * Naming them takes them out of `root` and gives each its own pair, and
 * `animation: none` on both halves leaves the new one painted at once. What is
 * actually different — the highlighted row, a badge — simply appears, which is
 * what it would have done on a plain page load.
 */
.sidebar { view-transition-name: chat-list; }
.site-header { view-transition-name: shell-header; }
::view-transition-old(chat-list), ::view-transition-new(chat-list),
::view-transition-old(shell-header), ::view-transition-new(shell-header) {
  animation: none;
  mix-blend-mode: normal;
}
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-old(root), ::view-transition-new(root) { animation-duration: 0.18s; }
  ::view-transition-old(feed) {
    animation: feed-out 0.2s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  }
  ::view-transition-new(feed) {
    animation: feed-in 0.2s cubic-bezier(0.22, 0.61, 0.36, 1) both;
  }
}
/* AND THEN NOT TWICE. Where the browser runs the transition it is doing the
   slide, so the page's own copy must not also play as the new document loads.
   
   `at-rule(@view-transition)` and not `(view-transition-name: none)`: the
   property has been supported since long before CROSS-DOCUMENT transitions
   were, and asking the wrong question is what left a browser with the property
   and without the transition showing no animation at all. A browser too old to
   understand this test simply keeps the page's own slide, which is the right
   answer for it. */
@supports at-rule(@view-transition) {
  .messages { animation: none; }

  /* EXCEPT WHEN THE BROWSER SKIPPED IT, which it does more often than the
   * feature's existence suggests.
   *
   * "still flickers ... when I switch chats, but only sometimes. Maybe 1 out
   * of 5 times."
   *
   * A cross-document transition is not a promise. The browser drops it when
   * the outgoing page is still running one (switching chats twice quickly),
   * when the new document takes too long to reach its first paint, and in
   * several other cases it does not report. Every one of those left this app
   * with NO animation at all — because the rule above had already switched
   * off the page's own slide on the grounds that the transition would be
   * doing it. A skipped transition therefore showed the raw navigation, which
   * is the flicker, and it was intermittent because skipping is.
   *
   * app.js asks, on `pagereveal`, whether a transition is actually running
   * and marks the document when it is not — before the first paint, which is
   * the only moment this can be decided. See theSkippedTransition.
   */
  :root.no-vt .messages {
    animation: feed-in 0.2s cubic-bezier(0.22, 0.61, 0.36, 1) backwards;
  }
}
/* Somebody who asked for less motion still gets the transition — it is what
   stops the blank frame — but instantly, with nothing sliding or fading. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
}

.day-line {
  display: flex; align-items: center; gap: 10px;
  margin: 18px 2px 12px; color: var(--muted);
  font-size: 0.72rem; font-weight: 650; text-transform: uppercase;
  letter-spacing: 0.06em;
}
.day-line::before, .day-line::after {
  content: ""; height: 1px; flex: 1; background: var(--panel-border);
}

.msg { display: flex; gap: 9px; margin-bottom: 3px; position: relative; }
/* SWIPE TO REPLY OR FORWARD (swipeToAct in app.js, Mariano 2026-10-19). On a
   touch screen the browser keeps the vertical scroll and hands sideways moves
   to the script; things that scroll sideways themselves keep theirs. */
@media (hover: none) {
  .msg { touch-action: pan-y; }
  .msg pre, .msg table, .msg .table-wrap { touch-action: auto; }
}
.swipe-sign {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 30px; height: 30px; border-radius: 50%;
  display: grid; place-items: center; pointer-events: none;
  background: var(--panel-raised); border: 1px solid var(--panel-border);
  color: var(--muted); font-size: 1rem; opacity: .6;
  transition: opacity .12s ease, color .12s ease;
}
.swipe-sign.left { left: 6px; }
.swipe-sign.right { right: 6px; }
.swipe-sign.ready { opacity: 1; color: var(--text); border-color: var(--orange); }
/* Consecutive messages from one person collapse: no repeated avatar, no
   repeated name, tighter spacing. This is most of what makes a thread readable
   rather than a list of cards. */
.msg.same { margin-top: 0; }
.msg.same .avatar { visibility: hidden; height: 0; }
.msg.same .msg-meta { display: none; }
/* …EXCEPT WHERE THE READING STOPPED: the first of a run of your own messages
   whose read state differs from the one above shows its ticks again, and
   only its ticks (markTickTurns in app.js — Charles, 2026-10-20). */
.msg.same.tick-turn .msg-meta { display: flex; }
.msg.same.tick-turn .msg-meta > :not(.ticks) { display: none; }
.msg.gap { margin-top: 14px; }
.msg-col { min-width: 0; max-width: min(680px, 84%); }
/* A sticker is the message. No fill, no border, no padding — a bubble drawn
   behind a transparent picture is a grey rectangle with a frog in it. */

.sticker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  gap: 6px;
  max-height: 46vh; overflow-y: auto;
  padding: 2px;
}
.sticker-grid button {
  border: 0; background: var(--panel); border-radius: 12px;
  padding: 6px; cursor: pointer; aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
}
.sticker-grid button:hover { background: var(--panel-hover); }
.sticker-grid img { max-width: 100%; max-height: 100%; display: block; }
.sticker-tab { font-size: 0.78rem; }
/* A cell on the management page: the picture, with a remove button that only
   appears on hover so a grid of ninety-seven is not a grid of crosses. */
.sticker-cell {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  padding: 6px; border-radius: 12px; background: var(--panel);
}
.sticker-cell img { max-width: 100%; max-height: 100%; display: block; }
.sticker-x {
  position: absolute; top: 2px; right: 2px;
  width: 20px; height: 20px; padding: 0;
  border: 0; border-radius: 50%;
  background: var(--bad); color: #fff;
  font-size: 0.8rem; line-height: 1; cursor: pointer;
  opacity: 0; transition: opacity .12s ease;
}
.sticker-cell:hover .sticker-x, .sticker-x:focus-visible { opacity: 1; }

.bubble.bare {
  background: none !important;
  border: 0;
  padding: 0;
  box-shadow: none;
}
.msg.mine .sticker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
  gap: 6px;
  max-height: 46vh; overflow-y: auto;
  padding: 2px;
}
.sticker-grid button {
  border: 0; background: var(--panel); border-radius: 12px;
  padding: 6px; cursor: pointer; aspect-ratio: 1;
  display: flex; align-items: center; justify-content: center;
}
.sticker-grid button:hover { background: var(--panel-hover); }
.sticker-grid img { max-width: 100%; max-height: 100%; display: block; }
.sticker-tab { font-size: 0.78rem; }
/* A cell on the management page: the picture, with a remove button that only
   appears on hover so a grid of ninety-seven is not a grid of crosses. */
.sticker-cell {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  padding: 6px; border-radius: 12px; background: var(--panel);
}
.sticker-cell img { max-width: 100%; max-height: 100%; display: block; }
.sticker-x {
  position: absolute; top: 2px; right: 2px;
  width: 20px; height: 20px; padding: 0;
  border: 0; border-radius: 50%;
  background: var(--bad); color: #fff;
  font-size: 0.8rem; line-height: 1; cursor: pointer;
  opacity: 0; transition: opacity .12s ease;
}
.sticker-cell:hover .sticker-x, .sticker-x:focus-visible { opacity: 1; }

.bubble.bare { background: none !important; }
.sticker-img {
  display: block;
  /* Same reason as .attach-img above: `width: auto` would cancel the width the
     markup supplies as a presentational hint, and a sticker narrower than the
     ceiling would then reserve 148px and shrink to its real width on arrival. */
  height: auto;
  max-width: 148px; max-height: 148px;
  /* Bigger than an emoji and smaller than an attachment: a sticker is meant to
     be seen at a glance and not to push the conversation off the screen. */
}

/* The bubble and the ⋮ that acts on it, side by side. In its own row because a
   column is as wide as its widest child, and for a short message that is the
   meta line rather than the bubble — which is how the ⋮ ended up a long way from
   the message, further the shorter the message was. */
.bubble-row { display: flex; align-items: center; gap: 2px; min-width: 0; }
.msg.mine .bubble-row { flex-direction: row-reverse; }
.msg-meta {
  display: flex; align-items: baseline; gap: 7px;
  margin: 0 0 3px 3px; font-size: 0.78rem;
}
.msg-author { font-weight: 700; color: var(--text); }
.msg-time { color: var(--muted); font-size: 0.72rem; }

.bubble {
  background: var(--panel-solid);
  border: 1px solid var(--panel-border);
  border-radius: var(--radius-bubble);
  border-top-left-radius: 6px;
  padding: 8px 13px;
  overflow-wrap: anywhere;   /* a pasted 400-character URL must not widen the layout */
}
.bubble > :first-child { margin-top: 0; }
.bubble > :last-child { margin-bottom: 0; }
.bubble pre {
  background: var(--sunk-firm); border-radius: var(--radius-sm);
  padding: 10px 12px; overflow-x: auto; font-size: 0.84rem;
  margin: 8px -4px;
}
.bubble code { font-family: var(--mono); font-size: 0.88em; }
.bubble :not(pre) > code {
  background: var(--sunk-firm); padding: 1px 5px; border-radius: 5px;
}
.bubble table { border-collapse: collapse; font-size: 0.86rem; display: block; overflow-x: auto; }
.bubble th, .bubble td { border: 1px solid var(--panel-border); padding: 5px 9px; }
.bubble blockquote {
  margin: 6px 0; padding: 2px 0 2px 11px;
  border-left: 3px solid var(--blue); color: var(--text-dim);
}
.bubble img { max-width: 100%; border-radius: var(--radius-sm); }

/* Mine: blue, right-aligned, mirrored corner. Not orange — see the header
   comment. */
.msg.mine { flex-direction: row-reverse; }
.msg.mine .msg-col { margin-left: auto; }
.msg.mine .msg-meta { flex-direction: row-reverse; margin-right: 3px; margin-left: 0; }
.msg.mine .bubble {
  background: linear-gradient(160deg, rgba(62, 86, 196, 0.38), rgba(62, 86, 196, 0.22));
  border-color: rgba(95, 116, 221, 0.4);
  border-top-left-radius: var(--radius-bubble);
  border-top-right-radius: 6px;
}
.msg.mine .avatar { display: none; }

/* A system message is not a bubble. It is a line of narration, centred and
   quiet, so it never looks like something a colleague said. */
.msg.system { justify-content: center; margin: 12px 0; }
.msg.system .sys {
  color: var(--muted); font-size: 0.78rem; text-align: center;
  background: var(--panel); border: 1px solid var(--panel-border);
  border-radius: 999px; padding: 4px 14px; max-width: 90%;
}
/* The welcome, in everybody's My Stuff. A bubble rather than the narration pill
   a system message gets: it is seven paragraphs of markdown, and the pill is
   sized for "Ana added Ben". Marked with the person-blue rather than the action
   orange, because it is Gabbie talking, not something to press. */
.msg.welcome .bubble {
  border-left: 3px solid var(--blue-bright);
  background: linear-gradient(100deg, rgba(62, 86, 196, 0.14), var(--panel-solid) 62%);
}
/* No tools and no selection. There is nothing to reply to, forward or react to.
   The template does not render them either — this is the belt to that braces,
   because a stray ⋮ on a message with no actions is a dead end. */
.msg.welcome .msg-tools, .msg.welcome .msg-more { display: none !important; }
.msg.welcome.selected .bubble { box-shadow: none; }

/* A Donkie notification, though, IS a card: it carries a link somebody should
   act on, so it gets weight the narration lines do not. */
.msg.donkie .bubble {
  border-left: 3px solid var(--orange);
  background: linear-gradient(100deg, rgba(242, 130, 46, 0.13), var(--panel-solid) 60%);
}

.msg-deleted {
  color: var(--muted); font-style: italic; font-size: 0.85rem;
  border: 1px dashed var(--panel-border); border-radius: 14px; padding: 5px 12px;
}

/* ---- voice notes ---- */

/*
 * THE WAVEFORM IS A PROGRESS INDICATOR, NOT A PLAYER.
 *
 * There is no play button anywhere, and no route on the server that returns the
 * audio: a recording is an input to transcription and is deleted as soon as
 * whisper is done with it. What this shows is that something was said and is
 * being read — so it animates while that is true, and is replaced by the words
 * when it is not.
 *
 * That is why the motion is a SWEEP rather than the bouncing bars a player uses.
 * Bouncing bars mean "this is playing" in every app anybody has used, and would
 * be an invitation to press something that does not exist. A sweep travelling
 * left to right reads as being scanned.
 */
.voice { display: flex; align-items: center; gap: 10px; padding: 3px 0 1px; min-width: 170px; }

.wave { display: flex; align-items: center; gap: 2px; height: 20px; flex: 1; min-width: 0; }
.wave i {
  display: block; flex: 1; min-width: 2px; max-width: 3px;
  border-radius: 2px; background: var(--blue-soft); opacity: 0.35;
  height: 30%;
}
.msg.mine .wave i { background: var(--cream); opacity: 0.3; }

/* Heights that read as speech rather than as a sine wave. */
.wave i:nth-child(3n)   { height: 78%; }
.wave i:nth-child(3n+1) { height: 42%; }
.wave i:nth-child(4n)   { height: 96%; }
.wave i:nth-child(5n)   { height: 26%; }
.wave i:nth-child(7n)   { height: 66%; }

/* The sweep. Each bar brightens in turn, so a band of light travels across the
   waveform — the same gesture as a scanner, and nothing like a play head. */
.voice.analysing .wave i { animation: scan 1.8s ease-in-out infinite; }
@keyframes scan {
  0%, 70%, 100% { opacity: 0.3; transform: scaleY(0.8); }
  35%           { opacity: 1;   transform: scaleY(1.15); }
}
.voice.analysing .wave i:nth-child(1)  { animation-delay: 0s; }
.voice.analysing .wave i:nth-child(2)  { animation-delay: .045s; }
.voice.analysing .wave i:nth-child(3)  { animation-delay: .09s; }
.voice.analysing .wave i:nth-child(4)  { animation-delay: .135s; }
.voice.analysing .wave i:nth-child(5)  { animation-delay: .18s; }
.voice.analysing .wave i:nth-child(6)  { animation-delay: .225s; }
.voice.analysing .wave i:nth-child(7)  { animation-delay: .27s; }
.voice.analysing .wave i:nth-child(8)  { animation-delay: .315s; }
.voice.analysing .wave i:nth-child(9)  { animation-delay: .36s; }
.voice.analysing .wave i:nth-child(10) { animation-delay: .405s; }
.voice.analysing .wave i:nth-child(11) { animation-delay: .45s; }
.voice.analysing .wave i:nth-child(12) { animation-delay: .495s; }
.voice.analysing .wave i:nth-child(13) { animation-delay: .54s; }
.voice.analysing .wave i:nth-child(14) { animation-delay: .585s; }
.voice.analysing .wave i:nth-child(15) { animation-delay: .63s; }
.voice.analysing .wave i:nth-child(16) { animation-delay: .675s; }
.voice.analysing .wave i:nth-child(17) { animation-delay: .72s; }
.voice.analysing .wave i:nth-child(18) { animation-delay: .765s; }
.voice.analysing .wave i:nth-child(19) { animation-delay: .81s; }
.voice.analysing .wave i:nth-child(20) { animation-delay: .855s; }
.voice.analysing .wave i:nth-child(21) { animation-delay: .9s; }
.voice.analysing .wave i:nth-child(22) { animation-delay: .945s; }
.voice.analysing .wave i:nth-child(23) { animation-delay: .99s; }
.voice.analysing .wave i:nth-child(24) { animation-delay: 1.035s; }
.voice.analysing .wave i:nth-child(25) { animation-delay: 1.08s; }
.voice.analysing .wave i:nth-child(26) { animation-delay: 1.125s; }
.voice.analysing .wave i:nth-child(27) { animation-delay: 1.17s; }
.voice.analysing .wave i:nth-child(28) { animation-delay: 1.215s; }

/* Somebody who has asked for less motion still needs to know it is working, so
   the bars stay lit rather than stopping dead. */
@media (prefers-reduced-motion: reduce) {
  .voice.analysing .wave i { animation: none; opacity: 0.75; }
}

.voice-time {
  flex: 0 0 auto; font-size: 0.76rem; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.msg.mine .voice-time { color: rgba(255, 252, 245, 0.7); }

.msg-text.transcribing { color: var(--muted); font-size: 0.84rem; font-style: italic; }

/* The editor, IN PLACE OF THE TEXT rather than in place of the message. It sits
   where .msg-text sat — above the picture, the poll or the file the bubble also
   holds, all of which stay on screen while a caption is retyped. See startEdit:
   replacing the whole bubble is what made an image vanish when its text was
   edited. */
.msg-edit { margin-bottom: 6px; }

/* The provenance pill on a transcribed message.
   A miniature of the analysing waveform, so the two read as one thing at two
   stages — and STATIC, because the animated version means "working" and a
   pulsing one would look like a player. */
.voice-pill {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 1px 6px 1px 5px; border-radius: 999px;
  background: rgba(139, 155, 240, 0.14);
  border: 1px solid rgba(139, 155, 240, 0.22);
  vertical-align: middle; line-height: 1;
  cursor: default;
}
.msg.mine .voice-pill {
  background: rgba(255, 252, 245, 0.13);
  border-color: rgba(255, 252, 245, 0.2);
}
.voice-pill .vp-time {
  font-size: 0.66rem; color: var(--muted);
  font-variant-numeric: tabular-nums; letter-spacing: 0.01em;
}
.msg.mine .voice-pill .vp-time { color: rgba(255, 252, 245, 0.72); }

/* The miniature waveform. Its own heights rather than the big one's nth-child
   rules, which are tuned for 24-28 bars and would collapse to noise at seven. */
.wave.mini { height: 10px; gap: 1.5px; flex: 0 0 auto; width: 20px; }
.wave.mini i { min-width: 1.5px; max-width: 2px; border-radius: 1px; opacity: 0.75; }
.wave.mini i:nth-child(1) { height: 35%; }
.wave.mini i:nth-child(2) { height: 70%; }
.wave.mini i:nth-child(3) { height: 100%; }
.wave.mini i:nth-child(4) { height: 55%; }
.wave.mini i:nth-child(5) { height: 90%; }
.wave.mini i:nth-child(6) { height: 45%; }
.wave.mini i:nth-child(7) { height: 25%; }
/* Never animated, whatever the surrounding state says. */
.voice-pill .wave.mini i { animation: none !important; }

/* Visible to a screen reader, to nobody else. The pill is a picture, and
   "transcribed from a voice note" is the thing it is a picture of. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---- recording ---- */

/* Replaces the composer row while recording. Replaces rather than sits beside:
   there is nothing else to do while recording, and a live countdown next to a
   text box invites typing into a field that is about to be ignored. */
.recorder {
  display: flex; align-items: center; gap: 12px;
  padding: 8px 12px; border-radius: 21px;
  background: rgba(228, 87, 77, 0.12);
  border: 1px solid rgba(228, 87, 77, 0.4);
}
.rec-dot {
  width: 11px; height: 11px; border-radius: 50%; background: var(--bad);
  flex: 0 0 auto; animation: rec 1.3s ease-in-out infinite;
}
@keyframes rec { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }

/* The countdown. Tabular so it does not jiggle as the digits change, and it
   turns amber then red as it runs out — the clip is not lost at zero, it is
   sent and a new one starts, but people should see it coming. */
.rec-count {
  font-family: var(--mono); font-size: 1.02rem; font-weight: 700;
  font-variant-numeric: tabular-nums; color: var(--text);
  flex: 0 0 auto; min-width: 2.4em;
}
.rec-count.warn { color: var(--warn); }
.rec-count.soon { color: var(--bad); }
.rec-label { flex: 1; min-width: 0; color: var(--text-dim); font-size: 0.86rem; }
.rec-live { display: flex; align-items: center; gap: 2px; height: 20px; flex: 0 0 70px; }
.rec-live i { flex: 1; background: var(--bad); border-radius: 1px; height: 20%; opacity: .8; }

/* The queue banner: how many notes are waiting, and — when sending has stopped —
   the way to start it again. */
.voice-queue {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 7px; padding: 7px 12px; border-radius: 12px;
  background: var(--panel); border: 1px solid var(--panel-border);
  font-size: 0.83rem; color: var(--text-dim);
}
.voice-queue.stuck {
  background: rgba(228, 87, 77, 0.13);
  border-color: rgba(228, 87, 77, 0.45);
  color: #ffd0cc;
}

/* ---- delivery ticks ---- */
/*
 * Three states on your own messages: a clock while the server has not confirmed
 * it, one tick once it has, two once somebody else has read it.
 *
 * The colour only changes on the LAST step. A tick that recolours on delivery
 * would make "the server has it" look like an achievement, when the thing
 * anybody actually watches for is whether it was read.
 */
.ticks {
  font-size: 0.76rem;
  line-height: 1;
  color: var(--muted);
  letter-spacing: -0.32em;   /* the two checks overlap, as they do everywhere else */
  padding-right: 0.32em;
  white-space: nowrap;
}
.ticks.seen { color: var(--blue-soft); }
.ticks.pending { letter-spacing: normal; padding-right: 0; opacity: 0.7; }

/* A message that has not reached the server yet. Dimmed rather than greyed out:
   it is going to send, and making it look broken while it does is worse than
   saying nothing. */
/* ---- ON ITS WAY ----------------------------------------------------------
 *
 * "the message that hasn't been delivered yet could have some sort of
 * animation that it is on its way. Perhaps some gradient scan of some sort
 * going back and forth"
 *
 * A light sweeping across the bubble and back, over a bubble that is already
 * dimmed. Not a spinner: a spinner says "the app is busy", and this says "this
 * message is not there yet", which is about the message and is what somebody
 * is looking at the bubble to find out. It stops the moment the real message
 * replaces it, because the real one has no .pending.
 *
 * `background-size` on a gradient the width of the bubble and a translate on a
 * pseudo-element — no layout, no repaint of the text, and it costs nothing on
 * a phone.
 */
.msg.pending .bubble { opacity: 0.62; position: relative; overflow: hidden; }
.msg.pending .bubble::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background: linear-gradient(100deg, transparent 20%,
              rgba(255, 255, 255, 0.16) 50%, transparent 80%);
  transform: translateX(-100%);
  animation: onitsway 1.8s ease-in-out infinite alternate;
}
@keyframes onitsway {
  from { transform: translateX(-100%); }
  to   { transform: translateX(100%); }
}
/* A LIGHT SWEEP OVER A PALE BUBBLE IS INVISIBLE. In light mode the same
   gesture has to be a shadow rather than a highlight. Dark is the base here
   — this file's :root IS the dark palette and light is the explicit choice,
   so this is the one override. */
:root[data-theme="light"] .msg.pending .bubble::after {
  background: linear-gradient(100deg, transparent 20%,
              rgba(20, 16, 43, 0.10) 50%, transparent 80%);
}

/* ---- how far the upload has got ----
 *
 * "when uploading images, or any other file, we need some sort of progress
 * indication as images sometimes get stuck there and the user doesn't know
 * what is going on until sent"
 *
 * Two states, because the wait has two halves and only one of them has a
 * number: the bytes going up (a percentage) and the server reading them
 * (`.working`, which is a stripe travelling rather than a bar filling). A bar
 * frozen at 100% is exactly what "stuck" looks like, so it never sits there.
 */
.up { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.up-track {
  flex: 1; height: 4px; border-radius: 3px; overflow: hidden;
  background: rgba(139, 155, 240, 0.22);
}
.up-fill {
  height: 100%; width: 0; border-radius: 3px; background: var(--orange);
  transition: width 0.18s ease-out;
}
.up-label {
  font-family: var(--mono); font-size: 0.66rem; color: var(--text-dim);
  min-width: 5.5em; text-align: right; font-variant-numeric: tabular-nums;
}
.up.working .up-fill {
  width: 100% !important;
  background: linear-gradient(90deg, transparent, var(--orange), transparent);
  background-size: 40% 100%; background-repeat: no-repeat;
  animation: upworking 1.1s linear infinite;
}
@keyframes upworking {
  from { background-position: -40% 0; }
  to   { background-position: 140% 0; }
}
/* Nothing has moved for eight seconds. Amber, not red: it is slow, and it may
   yet arrive. */
.up.slow .up-label { color: var(--orange-bright); }

@media (prefers-reduced-motion: reduce) {
  /* SAY THE SAME THING WITHOUT MOVING. Somebody who has asked for less motion
     still needs to know the message has not gone yet. */
  .msg.pending .bubble::after { animation: none; transform: none;
                                background: none; }
  .msg.pending .bubble { opacity: 0.5; }
  .up.working .up-fill { animation: none; background: var(--orange);
                         opacity: 0.55; }
}
.msg.failed .bubble { box-shadow: 0 0 0 1.5px var(--bad); opacity: 1; }
.msg.failed .ticks { color: #ffb3ad; }

/* ---- reply and forward context ---- */
/* Shown INSIDE the bubble, above the text: a reply's quote must travel with the
   reply, so it survives being read on a phone where there is no hover. */
.quoted {
  display: block; margin: -2px -4px 7px; padding: 5px 10px;
  border-left: 3px solid var(--blue-soft); border-radius: 0 8px 8px 0;
  background: var(--sunk); font-size: 0.82rem; color: var(--text-dim);
}
.quoted b { color: var(--blue-soft); font-weight: 650; }
.quoted-text {
  display: block; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; max-width: 100%;
}
/* A QUOTE OF A SELECTED PART IS SHOWN WHOLE.
 *
 *     "quoting only the selected part (complete) instead of just the first
 *      part in a regular reply"
 *
 * The rule above is right for an ordinary reply, where the excerpt is an
 * arbitrary first 90 characters and one line of it is a hint rather than a
 * quotation. It is wrong for a chosen one: a sentence cut off at its 90th
 * character is not the sentence somebody selected.
 *
 * `overflow-wrap` because a selection can contain a URL, and `pre-wrap` so the
 * line breaks inside it survive — a quoted list that comes back as one run-on
 * line is not what was on screen. */
.quoted-text.full {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  text-overflow: clip;
}
.fwd-mark {
  display: flex; align-items: center; gap: 5px;
  margin: -2px 0 6px; font-size: 0.74rem; color: var(--muted); font-style: italic;
}

/* ---- the menu that follows a selection ----
 *
 * Fixed, because it is positioned against the VIEWPORT: it sits outside the
 * scroll container so that a message arriving from the poll does not slide it
 * off the words it is pointing at.
 *
 * Above everything, including the composer and the thread header, which it can
 * legitimately overlap when the selection is at the top or bottom of the
 * thread. */
.sel-menu {
  position: fixed; z-index: 60;
  display: flex; flex-direction: column; gap: 2px;
  padding: 4px; border-radius: 10px;
  background: var(--panel-raised); border: 1px solid var(--panel-border);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.34);
}
.sel-item {
  display: flex; align-items: center; gap: 8px;
  padding: 7px 12px 7px 9px; border: 0; border-radius: 7px;
  background: transparent; color: var(--text);
  font: inherit; font-size: 0.86rem; white-space: nowrap;
  cursor: pointer; text-align: left;
}
.sel-item:hover, .sel-item:focus-visible { background: var(--sunk-firm); }
.sel-item svg { width: 16px; height: 16px; flex: none; opacity: 0.8; }

/* ---- OKR cards ----
 *
 * Cards with a percentage, and a tab to move between what is running and what
 * is finished. Each card is a <details> whose <summary> IS the card, so the
 * thing you click and the thing that opens cannot come apart, and it needs no
 * JavaScript.
 *
 * auto-fit with a minimum, not a fixed column count: three goals should not
 * stretch to fill a desktop width and one should not sit alone in a row of
 * three empty ones. */
.okr-grid {
  display: grid; gap: 10px; margin-top: 12px;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
/* THE CARD IS A WRAPPER since 2026-10-01: the <details> that opens, and "Fed
   by" under it. The border belongs to the wrapper so the two read as one. */
.okr-card {
  border: 1px solid var(--panel-border); border-radius: var(--radius-sm);
  background: var(--panel-raised); overflow: hidden;
  display: flex; flex-direction: column; position: relative;
}
/* A CHILD DRAWN INSIDE ITS PARENT.
 *
 *     "if there is a child company OKR, then it should only show inside the
 *      corresponding parent"
 *
 * The same card, told apart from the one around it by an indent and a rail
 * rather than by a second border — two nested boxes read as a mistake. It is
 * deliberately NOT `.okr-card`: the drag code matches that class, and a card
 * that cannot be reordered must not look like one that can. */
.okr-sub {
  margin: 8px 0 2px 10px; padding-left: 10px;
  border-left: 2px solid var(--accent-okr, var(--panel-border));
  display: flex; flex-direction: column;
}
.okr-sub > .okr { border: 1px solid var(--panel-border);
                  border-radius: var(--radius-sm); background: var(--panel-raised); }
.okr { overflow: hidden; }
/* DRAGGING. The handle sits in the corner; the card being moved goes faint
   and dashed and travels through the grid, so where it lands is where it is. */
.okr-grip {
  position: absolute; top: 7px; right: 8px; z-index: 1;
  cursor: grab; touch-action: none; user-select: none;
  color: var(--muted); font-size: 1rem; line-height: 1; padding: 3px 4px;
  border-radius: 6px;
}
.okr-grip:hover { color: var(--text); background: var(--sunk); }
.okr-card.sortable .okr-title { padding-right: 22px; }
.okr-card.dragging { opacity: .55; outline: 2px dashed var(--accent-okr);
                     outline-offset: 2px; }
.okr-grid.sorting, .okr-grid.sorting * { cursor: grabbing; user-select: none; }
.okr > summary {
  display: grid; gap: 8px; padding: 12px 13px; cursor: pointer;
  list-style: none;
}
.okr > summary::-webkit-details-marker { display: none; }
.okr[open] > summary { border-bottom: 1px solid var(--panel-border); }
.okr > summary:hover { background: var(--sunk); }
/* The title sets the card's height, so a long one wraps rather than being cut:
   a goal you cannot read the end of is a goal you have to open to identify. */
.okr-title { font-weight: 650; line-height: 1.3; }
.okr-pc { font-size: 1.35rem; font-weight: 700; color: var(--accent-okr); }
.okr-pc span { font-size: 0.8rem; font-weight: 400; color: var(--text-dim); }
.okr-due { margin-top: -2px; }
/* THE TWO FIELDS, ON THE CARD'S FACE. They exist whether or not they are
   filled in — a field with nothing in it still has to say its own name, or
   nobody knows it is there to fill in. */
.okr-meta { display: flex; gap: 7px; align-items: baseline; font-size: .74rem; }
.okr-meta-k { color: var(--muted); flex: none; min-width: 5.6em; }
.okr-meta-v { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis;
              white-space: nowrap; }
.okr-meta-v.empty { color: var(--warn); }
/* Linking from the card, without opening the edit form. */
.okr-link { display: flex; gap: 7px; align-items: center; margin: 0 0 8px; }
.okr-link select { flex: 1; min-width: 0; font-size: .8rem; }
.okr-more { padding: 11px 13px 13px; }

/* ---- editing an objective ---- */
.okr-edit { margin-top: 10px; }
.okr-edit > summary {
  cursor: pointer; color: var(--text-dim); font-size: 0.78rem;
  padding: 4px 0; list-style: none;
}
.okr-edit > summary::-webkit-details-marker { display: none; }
.okr-edit > summary::before { content: "\270E\00a0"; }
.okr-edit > summary:hover { color: var(--text); }
.goal-form { display: grid; gap: 9px; margin-top: 8px; }
.goal-form .field { margin: 0; }

/* A key result's tick and its remove sit INSIDE the row, so the row stays one
   line and the text is what moves when the window narrows. */
.kr-list li { display: flex; align-items: baseline; gap: 6px; }
.kr-tick, .kr-drop { display: inline; }
.kr-tick button, .kr-drop button {
  border: 0; background: none; padding: 0; cursor: pointer;
  color: var(--text-dim); font: inherit; line-height: 1;
}
.kr-tick button { font-size: 1.05em; }
.kr-tick button:hover, .kr-drop button:hover { color: var(--text); }
/* Only on hover, and never on a phone where there is no hover to wait for —
   a delete that is always visible beside every line invites the wrong tap. */
.kr-drop { margin-left: auto; opacity: 0; transition: opacity .12s; }
.kr-list li:hover .kr-drop, .kr-drop button:focus-visible { opacity: 1; }
@media (hover: none) { .kr-drop { opacity: 1; } }

/* ---- the call roll-call ----
 *
 * "it is difficult to see who joined and who is still ringing"
 *
 * A row per person: face, name, and what they are doing. The state is the
 * column that matters, so it is the one that is aligned — a reader scanning for
 * "who can hear me" reads down one edge rather than across every line. */
.callbox-open {
  display: block; flex: 1; min-width: 0;
  border: 0; background: none; padding: 0; margin: 0;
  font: inherit; color: inherit; text-align: left; cursor: pointer;
  border-radius: 10px;
}
.callbox-open:hover { background: rgba(255, 255, 255, 0.06); }
.callbox-open:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }

.call-roll { list-style: none; margin: 10px 0 0; padding: 0;
             display: flex; flex-direction: column; gap: 2px; }
.call-roll-row {
  display: flex; align-items: center; gap: 10px;
  padding: 7px 8px; border-radius: 9px;
}
.call-roll-row.on { background: var(--sunk); }
/* Dimmed until they answer — the same rule the bar's faces follow, so the two
   cannot say different things about the same person. */
.call-roll-row:not(.on) { opacity: 0.62; }
.call-roll-name { flex: 1; min-width: 0; font-weight: 600;
                  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.call-roll-state { color: var(--text-dim); font-size: 0.78rem; white-space: nowrap; }
.call-roll-row.on .call-roll-state { color: var(--ok); }

/* The composer's bar when a private reply is being written. The same shape as
   the reply bar, in the accent that says "this is not the thread you were just
   reading" — somebody who arrived here by pressing a menu row needs the
   destination to look different from the origin. */
.reply-bar.privately {
  border-left: 3px solid var(--accent-okr);
  background: var(--sunk-firm);
}

/* ---- the person card, from a direct message's header ----
 *
 *     "clicking on the other partie's name or avatar in the header of a
 *      private chat should show a modal with the user's profile and a list of
 *      groups shared with that person"                — Sam, 2026-10-12
 */

/* THE FACE AND THE NAME ARE ONE TARGET. A button, so it is reachable by
   keyboard — and stripped back to looking like what it replaced, because a
   header that suddenly has a button in it reads as a new control rather than
   as the same name being clickable. */
.thread-who {
  display: flex; align-items: center; gap: 10px;
  flex: 1; min-width: 0; text-align: left;
  background: none; border: 0; padding: 2px 4px; margin: -2px -4px;
  border-radius: 10px; color: inherit; font: inherit; cursor: pointer;
}
.thread-who:hover { background: var(--panel-hover); }
.thread-who:focus-visible { outline: 2px solid var(--blue); outline-offset: 1px; }

.person-card { display: flex; gap: 14px; align-items: flex-start; }
.person-card .avatar.lg { flex: none; }
.person-card-main { min-width: 0; flex: 1; }
.person-card-name { margin: 0 0 2px; font-size: 1.1rem; }
.person-card-role { margin: 0 0 4px; color: var(--text-dim); font-size: .9rem; }
.person-card-meta { margin: 0; color: var(--muted); font-size: .82rem;
                    font-variant-numeric: tabular-nums; }
.person-card-status { margin: 6px 0 0; font-size: .86rem; }
.person-card-h {
  margin: 18px 0 6px; font-size: .74rem; font-weight: 650;
  text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
}
/* The groups, as rows that go somewhere. "We are both in Releases" is a fact
   somebody acts on by going there. */
.person-groups { list-style: none; margin: 0; padding: 0; }
.person-groups li { margin: 0; }
.person-groups .person {
  display: flex; align-items: center; gap: 9px;
  padding: 7px 6px; border-radius: 10px; color: inherit; text-decoration: none;
}
.person-groups .person:hover { background: var(--panel-hover); }

/* ======================================================= the call, full screen
 *
 *     "cuando estoy en una llamada, deberia aparecer una ventana modal (o full
 *      screen mejor) con la info de la llamada, los tiles de cada persona ...
 *      el nombre de la persona abajo a la izquierda de su tile y el microfono a
 *      la derecha, poniendolo en rojo si estan en mute. Arriba ... el titulo
 *      del grupo ... un contador de tiempo y los botones para cortar ... y un
 *      boton para poder minimizar."                       — Sam, 2026-10-06
 *
 * ABOVE THE BAR (z-index 400 against the bar's 300), because this IS the call
 * and the bar is what a minimised one looks like. Both are fixed layers rather
 * than dialogs: a <dialog>'s focus trap would fight whichever of the two is
 * showing, and `showModal` from inside a poll handler steals focus from
 * somebody mid-sentence.
 */
.call-room {
  position: fixed; inset: 0; z-index: 400;
  display: flex; flex-direction: column;
  background: var(--bg);
  /* THE TOKENS, NEVER env() DIRECTLY. In the Android shell the activity
     already pads the WebView inside the system bars, and a page that also
     reads the raw environment grows a dead band at each end — see
     tests/test_altdoor.py. In a browser these are the environment; in the app
     they are zero. */
  padding-top: var(--safe-top); padding-bottom: var(--safe-bottom);
}
/* The page underneath does not scroll while the room is up: it is not visible
   and a stray wheel event should not move it. */
body.in-call-room { overflow: hidden; }

.call-room-top {
  display: flex; align-items: center; gap: 14px;
  padding: 14px 18px; border-bottom: 1px solid var(--panel-border);
  flex: none;
}
.call-room-id { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.call-room-title {
  font-weight: 650; font-size: 1.02rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.call-room-sub {
  color: var(--muted); font-size: 0.8rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The one number on the screen, and it changes every second — proportional
   digits make it jitter under the eye. */
.call-room-clock {
  font-variant-numeric: tabular-nums; font-size: 1.15rem; font-weight: 600;
  color: var(--text); flex: none;
}

/* THE LINE'S OWN READOUT, beside the clock. Small, quiet and factual until
   something is wrong with it, because on a call that is fine it is furniture
   and on a call that is not it is the first thing anybody should read. */
.call-room-net {
  font-size: 0.74rem; color: var(--muted); flex: none;
  font-variant-numeric: tabular-nums;
  padding: 3px 9px; border-radius: 999px; background: var(--sunk-firm);
}
.call-room-net.bad { color: var(--orange-bright, #f6a560); }
@media (max-width: 620px) { .call-room-net { display: none; } }

.call-room-body {
  flex: 1; min-height: 0; overflow: auto;
  padding: 18px; display: flex; flex-direction: column; gap: 14px;
}
.call-room-link { margin: 0; flex: none; word-break: break-all; }

.call-room-acts {
  flex: none; display: flex; justify-content: center; gap: 10px;
  padding: 14px 18px 18px; border-top: 1px solid var(--panel-border);
  flex-wrap: wrap;
}
.call-room-btn { padding: 11px 18px; }
.call-room-btn.on { background: var(--blue); border-color: var(--blue); color: #fff; }

/* ---- a tile for every person, on camera or not ----
 *
 * The grid used to exist only when somebody turned a camera on, which made an
 * audio call — almost all of them — a screen with nothing on it. An avatar IS
 * a face; the camera replaces it inside the same tile when there is one.
 *
 * `data-count` rather than auto-fit alone: auto-fit cannot know whether two
 * people should be side by side or stacked, and one face must fill the room
 * rather than sit in a 140px box in the corner of it.
 */
/* THE ROWS SHARE THE ROOM'S HEIGHT, and a tile fills its cell.
 *
 *     "The meeting view breaks in full-screen mode"  — Agustin Groh, 2026-10-21
 *
 * Tiles used to be 4:3 boxes. With three or four people the grid is 2×2, and
 * a 4:3 tile half the width of a landscape screen is taller than half its
 * height. The second row then started inside the first: tiles overlapped,
 * and the guest link sat on top of them. Any wide, short window did it; a
 * full screen just made it obvious. Now each row takes an equal share of the
 * height the room has (never under 120px; below that the room scrolls rather
 * than overlapping) and the tile stretches to its cell. A camera fills it with
 * object-fit: cover.
 * `min-height: auto` (not 0) so the grid is never squeezed below its rows. */
.call-tiles {
  display: grid; gap: 12px; flex: 1; min-height: auto;
  align-content: center;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  grid-auto-rows: minmax(120px, 1fr);
}
.call-tiles[data-count="1"] { grid-template-columns: minmax(0, min(640px, 100%));
                              justify-content: center; }
.call-tiles[data-count="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.call-tiles[data-count="3"], .call-tiles[data-count="4"] {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 620px) {
  .call-tiles, .call-tiles[data-count="2"], .call-tiles[data-count="3"],
  .call-tiles[data-count="4"] { grid-template-columns: minmax(0, 1fr); }
}

.call-tile {
  margin: 0; position: relative; border-radius: 16px; overflow: hidden;
  background: var(--sunk-firm, #171b26);
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--panel-border);
  transition: border-color .15s ease;
}
/* WHO IS TALKING. On a call where every camera is off this is the only thing
   on the screen that moves, and it is the one thing a grid of faces can say
   that a list of names cannot. */
.call-tile.talking { border-color: var(--ok); box-shadow: 0 0 0 2px var(--ok) inset; }
/* THREE BARS, TOP-LEFT. LiveKit's own per-participant reading, which is the
   number that matters: on a call of five, "the call is bad" is not actionable
   and "Ana's line is poor" is. Drawn only where there is a reading — three
   grey bars against a name nobody has heard from is a measurement nobody
   took. */
.call-tile-net {
  position: absolute; top: 10px; left: 12px;
  display: flex; align-items: flex-end; gap: 2px; height: 14px;
  padding: 4px 5px; border-radius: 6px; background: rgba(8, 10, 16, 0.45);
}
.call-tile-net i { width: 3px; background: rgba(255, 255, 255, 0.28);
                   border-radius: 1px; display: block; }
.call-tile-net i:nth-child(1) { height: 5px; }
.call-tile-net i:nth-child(2) { height: 9px; }
.call-tile-net i:nth-child(3) { height: 13px; }
.call-tile-net i.on { background: #fff; }
.call-tile-net[data-q="poor"] i.on { background: var(--orange-bright, #f6a560); }
.call-tile-net[data-q="lost"] { background: rgba(226, 87, 76, 0.85); }

/* Still ringing: present, not yet here. */
.call-tile.waiting { opacity: 0.5; }
.call-tile-state {
  position: absolute; top: 10px; right: 12px;
  font-size: 0.72rem; color: var(--muted);
  background: var(--panel-solid); border-radius: 999px; padding: 3px 9px;
}

/* The avatar, large and centred — it is the face until a camera says otherwise. */
.call-tile-face {
  width: 96px; height: 96px; font-size: 2rem; flex: none;
}
.call-tile-face .avatar-img { width: 100%; height: 100%; }
.call-tile.has-video .call-tile-face { display: none; }
/* THE ELEMENT IS ALWAYS THERE; THE PICTURE IS NOT. Every track a person
   publishes goes into one <video> so that their voice and their camera stay in
   step — which means the element exists on an audio-only call too, carrying
   nothing but sound. Shown only once something has actually attached a video
   track to it, or every tile would be a black rectangle over the avatar. */
.call-tile video { display: none; }
.call-tile video[data-video="1"] {
  display: block; position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover;
}
.call-tile figcaption {
  position: absolute; left: 0; right: 0; bottom: 0;
  display: flex; align-items: flex-end; gap: 8px;
  padding: 18px 12px 9px; font-size: 0.82rem; color: #fff;
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.72));
}
/* The name on the left and the microphone on the right, which is where every
   other call application on these people's machines puts them. */
.call-tile-name {
  flex: 1; min-width: 0; font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.call-tile-mic { flex: none; display: inline-flex; color: #fff; opacity: .75; }
.call-tile-mic svg { width: 17px; height: 17px; }
/* RED MEANS MUTED, and it is drawn as a filled badge rather than a red line:
   a red icon on a photograph of somebody's living room is not reliably red. */
.call-tile-mic.muted {
  opacity: 1; color: #fff; background: #e2574c;
  border-radius: 999px; padding: 4px;
}
/* The camera button while it is on, so "on" is visible without reading it. */
.call-cam.on { background: var(--blue); border-color: var(--blue); color: #fff; }

/* ---- a shared screen ----
 *
 * NOT IN THE TILE GRID. A screen share is a document being read, not a face
 * being glanced at; put in the same grid it becomes a slide deck the size of a
 * postage stamp. `contain` rather than `cover` for the same reason — a cropped
 * terminal is a terminal with the command cut off.
 */
/* In the room the stage is a flex child, so its spacing comes from the
   column's gap rather than from margins of its own. */
.call-stage { margin: 0; flex: none; }
/* AND THE FACES GET OUT OF ITS WAY. A shared screen is a document somebody is
   reading; the tiles become a strip under it rather than keeping half the
   room for themselves. */
.call-room-body:has(.call-stage:not(.hidden)) .call-tiles {
  flex: none; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
}
.call-room-body:has(.call-stage:not(.hidden)) .call-tile { aspect-ratio: 16 / 10; }
.call-room-body:has(.call-stage:not(.hidden)) .call-tile-face {
  width: 54px; height: 54px; font-size: 1.1rem;
}
.call-share-frame {
  margin: 0; position: relative; border-radius: 12px; overflow: hidden;
  background: #000; aspect-ratio: 16 / 9;
  border: 1px solid var(--blue-bright);
}
.call-share-frame video { width: 100%; height: 100%; object-fit: contain;
                          display: block; }
.call-share-frame figcaption {
  position: absolute; left: 0; right: 0; bottom: 0;
  padding: 12px 8px 5px; font-size: 0.74rem; color: #fff;
  background: linear-gradient(transparent, rgba(0, 0, 0, 0.72));
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.call-share.on { background: var(--blue); border-color: var(--blue); color: #fff; }

/* THE INDICATOR, on the bar. It is a button because it opens the sheet: seeing
 * that somebody is sharing and having no way to look at it is worse than not
 * being told. */
.callbox-sharing {
  border: 1px solid var(--blue-bright); background: transparent;
  color: var(--blue-bright); cursor: pointer;
  border-radius: 999px; padding: 3px 11px; font-size: 0.72rem; line-height: 1.5;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.callbox-sharing:hover { background: rgba(95, 116, 221, .14); }
/* MY OWN SCREEN, IN THE COLOUR THAT MEANS "THIS IS YOU DOING SOMETHING". A
 * screen shared by accident and left up is how a private window ends up in a
 * meeting, and this is the only thing that would catch it. */
.callbox-sharing.mine { border-color: var(--orange); color: var(--orange-bright); }
.callbox-sharing.mine:hover { background: rgba(242, 130, 46, .14); }

/* ---- the OKR cascade ----
 *
 * An indented tree with drawn connectors. The chart here is very flat — twelve
 * people to one manager — so a left-to-right box diagram would be one objective
 * with twelve boxes beside it and a horizontal scrollbar on every screen. Going
 * DOWN means a parent with one child and a parent with twelve look the same,
 * which is the question being asked: what rolls up to what.
 *
 * The connectors come from the list's own structure rather than from an SVG
 * computed off positions, so they stay correct at any width, survive a reflow,
 * and print. ::before is the elbow into each card; ::after is the spine that
 * runs down past every child but the last. */
.okr-tree { list-style: none; margin: 0; padding: 0; }
.okr-tree .okr-tree { margin-left: 22px; padding-left: 20px; }
.okr-node { position: relative; padding: 5px 0; }
.okr-tree .okr-tree > .okr-node::before,
.okr-tree .okr-tree > .okr-node::after {
  content: ""; position: absolute; left: -20px; border-color: var(--panel-border);
  border-style: solid; border-width: 0;
}
/* The elbow: down to this card's middle, then across to it. */
.okr-tree .okr-tree > .okr-node::before {
  top: 0; height: 28px; width: 14px;
  border-left-width: 2px; border-bottom-width: 2px;
  border-bottom-left-radius: 8px;
}
/* The spine, continuing to the next sibling. Not on the last one, or the line
   carries on past the end of the branch into nothing. */
.okr-tree .okr-tree > .okr-node:not(:last-child)::after {
  top: 28px; bottom: -5px; border-left-width: 2px;
}
.okr-tree-flat .okr-node::before, .okr-tree-flat .okr-node::after { display: none; }
.okr-tree-flat .okr-node { padding-left: 0; }

.okr-card {
  border: 1px solid var(--panel-border); border-radius: 10px;
  background: var(--panel-raised); padding: 9px 12px;
}
.okr-card-top { display: flex; align-items: baseline; gap: 9px; }
.okr-card-pc {
  font-weight: 700; color: var(--accent-okr); font-variant-numeric: tabular-nums;
  min-width: 3.1em;
}
.okr-card-title { font-weight: 600; line-height: 1.3; }
.okr-card .goal-bar { margin: 7px 0 6px; }
.okr-card-who { font-size: 0.78rem; color: var(--text-dim); }
.okr-card-who a { color: var(--text-dim); }
.okr-card-who a:hover { color: var(--text); }

/* PRINTING IS THE EXPORT. The browser's own print makes a PDF on every machine
   here without Gabbie growing a rendering pipeline — so what comes out has to
   be the diagram rather than the application around it. */
@media print {
  .sidebar, .app-nav, .callbox, .composer, .btn, .sel-menu { display: none !important; }
  .app, .app-body, .page, .page-wide { display: block !important; height: auto !important;
                                       max-height: none !important; overflow: visible !important; }
  body, .page { background: #fff !important; color: #000 !important; }
  .okr-card { border-color: #bbb !important; background: #fff !important;
              break-inside: avoid; }
  .okr-card-pc { color: #000 !important; }
  .okr-card-who, .okr-card-who a, .muted, .tiny { color: #444 !important; }
  .okr-tree .okr-tree > .okr-node::before,
  .okr-tree .okr-tree > .okr-node::after { border-color: #bbb !important; }
  .goal-bar { border: 1px solid #ccc; }
  .goal-bar span { background: #666 !important; }
}

/* ---- the profile's header banner ----
 *
 * "a header banner at the top of each profile showing the person's name and
 *  position on a purple rectangle, matching how BambooHR currently displays it"
 *
 * A gradient rather than a flat fill: flat #6d28d9 across a wide desktop is a
 * lot of one colour, and the second stop is the same hue darkened rather than a
 * new one, so it stays the purple everything else uses. */
.person-banner {
  display: flex; align-items: center; gap: 15px; flex-wrap: wrap;
  margin: 0 0 16px; padding: 18px 20px;
  border-radius: 14px;
  background: linear-gradient(135deg, #6d28d9, #4c1d95);
  color: #fff;
}
.person-banner h1 { margin: 0; font-size: 1.35rem; color: #fff; }
/* Not `muted`: that token is a grey for a dark page and disappears here. */
.person-banner p { margin: 3px 0 0; color: rgba(255, 255, 255, 0.82);
                   font-size: 0.88rem; }
.person-banner-face {
  width: 64px; height: 64px; font-size: 1.3rem; flex: none;
  box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.22);
}
@media print {
  /* The gradient costs a page of toner and prints as a grey slab; the rule
     under it says "header" just as well. */
  .person-banner { background: #fff !important; color: #000 !important;
                   border-bottom: 2px solid #6d28d9; border-radius: 0;
                   padding-left: 0; }
  .person-banner h1, .person-banner p { color: #000 !important; }
}

/* ---- "a call is happening here" ----
 *
 * Under the header, above the thread: the same place the call panel sits, and
 * they are never both on screen — one says you are on a call and the other says
 * you are not. Green rather than the call panel's blue, because it is an
 * invitation rather than a state. */
.join-call {
  position: fixed; left: 0; right: 0; top: 0; z-index: 55;
  display: flex; align-items: center; gap: 10px;
  /* THE TOKEN, NEVER THE RAW INSET. `--safe-top` is `env()` in a browser and
     0 in the Android shell, which pads itself — reaching past it here is how
     each end of the page grows a dead band. There is a test for exactly this. */
  padding: calc(9px + var(--safe-top)) 14px 9px;
  background: linear-gradient(180deg, rgba(53, 200, 138, .22), rgba(53, 200, 138, .12));
  border-bottom: 1px solid rgba(53, 200, 138, .32);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  font-size: .86rem;
}
.join-call svg { width: 17px; height: 17px; flex: none; color: var(--ok); }
body.has-join { padding-top: 46px; }
body.has-join .site-header { top: 46px; }

/* ---- reactions ---- */
.reactions { display: flex; flex-wrap: wrap; gap: 4px; margin: 4px 0 2px 3px; }
.reaction {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 1px 8px; border-radius: 999px; cursor: pointer;
  background: var(--panel); border: 1px solid var(--panel-border);
  font-size: 0.82rem; color: var(--text-dim); line-height: 1.5;
}
.reaction:hover { background: var(--panel-hover); }
/* Held to see who reacted (app.js): no iOS callout or text selection under the
   finger while it is. */
.reaction { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
.reaction.mine { background: rgba(62, 86, 196, 0.3); border-color: var(--blue-bright); color: var(--cream); }
.reaction .n { font-size: 0.72rem; font-weight: 700; }
.msg.mine .reactions { justify-content: flex-end; margin-right: 3px; }

/* ---- reaching a message's actions ---- */
/*
 * TWO WAYS IN, because a mouse and a finger want different things.
 *
 *   pointer  a toolbar on hover. Fast, no clicks wasted, invisible until wanted.
 *   touch    tap the message to SELECT it, which reveals a ⋮ beside it; tap that
 *            for the full menu.
 *
 * The touch path is not a nicety — it is the only way in on a phone. `:hover` on
 * a touchscreen either never fires or latches on until you tap elsewhere, so a
 * hover-only toolbar meant reply, forward, edit and delete were literally
 * unreachable there. Tap-to-select also gives something hover cannot: a visible
 * answer to "which message am I about to act on", which matters when the targets
 * are 40px apart and under your thumb.
 */
/* ---- the hover toolbar, BESIDE the message it acts on ----
 *
 * "the floating icon bar (reply, forward, like) is far away from the message.
 *  It should be immediately left from it for own messages, and immediately
 *  right from it for someone else's messages"
 *
 * It was pinned to the edge of the ROW — `right: 8px` of `.msg`, which is the
 * full width of the feed — so on a three-word message the buttons sat a hundred
 * and fifty pixels away from the thing they acted on, and the eye had to travel
 * to find out which message they belonged to.
 *
 * The markup already put them inside `.bubble-row`, beside the bubble; it was
 * the CSS that took them out of it. `.bubble-row` is the positioning context
 * now, so "immediately beside" is literally what `left: 100%` means — and the
 * row is `row-reverse` for your own messages, so left and right swap without a
 * second rule needing to know which way round anything is.
 */
.bubble-row { position: relative; }
.msg-tools {
  position: absolute; top: 0; left: calc(100% + 6px);
  display: flex; gap: 2px; padding: 2px;
  background: var(--panel-raised); border: 1px solid var(--panel-border);
  border-radius: 11px; box-shadow: var(--shadow);
  opacity: 0; pointer-events: none; transition: opacity .12s ease;
  z-index: 10;
}
/* YOURS SIT ON THE OTHER SIDE, for the same reason: the bubble is on the right
   of the feed, so the only free space is to its left. */
.msg.mine .msg-tools { left: auto; right: calc(100% + 6px); }

/* AND WHEN THERE IS NO ROOM BESIDE IT, back over the corner.
 *
 * The bar is about 200px of buttons and a bubble may take 84% of a narrow
 * column, which leaves nowhere to put it — and the feed clips horizontally, so
 * an overflowing bar would be a bar with its last two buttons cut off. Below
 * this width it goes back to sitting over the message's top corner, which is
 * where it has always been and is at least attached to the right message. */
@media (max-width: 860px) {
  .msg-tools { top: -12px; left: auto; right: 4px; }
  .msg.mine .msg-tools { right: auto; left: 4px; }
}
@media (hover: hover) and (pointer: fine) {
  .msg:hover .msg-tools, .msg-tools:focus-within { opacity: 1; pointer-events: auto; }
}
.msg-tools button {
  border: 0; background: transparent; cursor: pointer; color: var(--text-dim);
  /* BIGGER THAN THE 30x28 IT WAS. These were reported as "very small", and they
     were: a 28px box holding a 14px character is a target you aim at. 34px is
     still compact on a desktop and is close to the 44px a finger wants. */
  width: 34px; height: 32px; border-radius: 9px;
  display: inline-flex; align-items: center; justify-content: center;
}
.msg-tools button:hover { background: var(--panel-hover); color: var(--text); }
/* And the drawings themselves, which were 14px of somebody else's font. */
.msg-tools .ico-svg { width: 19px; height: 19px; }
/* Delete reads as delete before it is pressed. Only on hover, so a row of
   actions is not one red button and five grey ones. */
.msg-tools button.danger:hover { color: var(--bad); }

/* The selected message. A ring rather than a fill: a fill fights the bubble's
   own colour and makes your own messages look like somebody else's. */
.msg.selected .bubble {
  box-shadow: 0 0 0 2px var(--orange);
}
.msg.selected .msg-deleted { box-shadow: 0 0 0 2px var(--orange); }

/* The ⋮ that appears beside a selected message.
   44px: Apple's and Google's minimum touch target. Anything smaller is a button
   you miss, and missing this one deselects the message you just chose. */
/* IN THE FLOW, NOT PINNED TO THE EDGE. It used to be absolutely positioned at
   `right: -4px` of the message row — and a message row is the full width of the
   thread, so on somebody else's message the ⋮ appeared at the far right, a
   screen away from the bubble it acts on.

   As a flex item it simply sits next to the bubble, and `.msg.mine` is already
   row-reverse, so it lands on the correct side of both without a second rule.
   It occupies no space until a message is selected. */
.msg-more {
  flex: 0 0 auto;
  width: 34px; height: 44px; padding: 0;
  display: none; align-items: center; justify-content: center;
  border: 0; background: transparent; cursor: pointer;
  color: var(--orange); font-size: 1.3rem; line-height: 1;
  z-index: 12;
}
.msg.selected .msg-more { display: flex; }
/* THE ⋮ TAKES ITS ROOM FROM OUTSIDE THE COLUMN, not from the bubble.
 *
 *     "When I select a message the wrapped text changes."
 *                                          — Isasmendi, 2026-10-06
 *
 * It is a 34px flex item (plus the 2px gap) that only exists while the message
 * is selected, so selecting one squeezed the bubble by 36px and its text
 * re-wrapped. The column is at most 84% wide, so there is always room beside
 * it: the row reaches 36px further out on the ⋮'s side instead, and the bubble
 * keeps exactly the width it had. */
.msg.selected .bubble-row { margin-right: -36px; }
.msg.mine.selected .bubble-row { margin-right: 0; margin-left: -36px; }
.msg.welcome.selected .bubble-row { margin-right: 0; margin-left: 0; }
.msg-more:active { transform: scale(0.9); }

/* No padding hack needed any more: the ⋮ is a flex item beside the bubble
   rather than something overlapping its edge. */

/* The action sheet's rows. Bigger than a menuitem: this is the touch surface, and
   the whole point is that it is hard to mis-tap. */
.action-row {
  display: flex; align-items: center; gap: 13px;
  width: 100%; padding: 13px 14px;
  border: 0; border-radius: 12px; background: transparent;
  color: var(--text); font: inherit; font-size: 0.97rem; text-align: left;
  cursor: pointer;
}
.action-row:hover, .action-row:focus-visible { background: var(--panel-hover); }
.action-row .ico { font-size: 1.15rem; width: 26px; text-align: center; flex: 0 0 26px; }
.action-row.danger { color: #ffb3ad; }
.action-row .sub { color: var(--muted); font-size: 0.8rem; display: block; }

/* A quote of the message being acted on, so the sheet says WHICH one. Without it
   a menu that slid up over the thread is a menu about nothing in particular. */
.sheet-quote {
  display: block; margin: 0 0 12px; padding: 9px 12px;
  border-left: 3px solid var(--blue-soft); border-radius: 0 10px 10px 0;
  background: var(--sunk); font-size: 0.86rem; color: var(--text-dim);
  max-height: 4.4em; overflow: hidden;
}
.sheet-quote b { color: var(--blue-soft); font-weight: 650; display: block;
                 font-size: 0.8rem; margin-bottom: 2px; }

/* ============================================================ composer */

.composer {
  border-top: 1px solid var(--panel-border);
  background: var(--glass-firm);
  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
  padding: 8px 10px calc(8px + var(--safe-bottom));
  z-index: 25;
}
.composer-row { display: flex; align-items: flex-end; gap: 7px; }
.composer textarea {
  flex: 1; resize: none;
  min-height: 42px; max-height: 40vh;
  padding: 10px 13px;
  border-radius: 21px;
  border: 1px solid var(--panel-border);
  background: var(--panel-solid); color: var(--text);
  /* 16px minimum. Anything smaller and iOS Safari zooms the page when the field
     is focused, and does not zoom back out — the single most common way a
     mobile web app feels broken. */
  font: inherit; font-size: 16px; line-height: 1.4;
  font-family: var(--font);
}
.composer textarea:focus { outline: none; border-color: var(--blue-bright); }
.composer textarea::placeholder { color: var(--muted); }

.send {
  flex: 0 0 auto;
  width: 42px; height: 42px; border-radius: 50%;
  border: 0; cursor: pointer;
  background: var(--grad-warm); color: #2a1400;
  font-size: 1.15rem; font-weight: 700;
  display: inline-flex; align-items: center; justify-content: center;
  box-shadow: 0 4px 14px rgba(242, 130, 46, 0.35);
}
.send:disabled { opacity: 0.4; box-shadow: none; cursor: default; }
.send:active:not(:disabled) { transform: scale(0.94); }

/* The bar above the composer showing what you are replying to, with a way out.
   Always visible while active — a reply state you cannot see is a message sent
   to the wrong place. */
.reply-bar {
  display: flex; align-items: center; gap: 8px;
  margin: 0 0 7px; padding: 6px 10px;
  border-radius: 12px; background: rgba(62, 86, 196, 0.18);
  border: 1px solid var(--panel-border); font-size: 0.82rem;
}
.reply-bar .quoted-text { color: var(--text-dim); }

.attach-preview { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 7px; }
/* A THUMBNAIL, NOT A FILE NAME. A pasted screenshot is called "image.png" and
   every one of them is called "image.png": the name says nothing about which
   picture is about to be sent, and a wrong picture sent into a group chat is not
   a small mistake. Drawn from the file itself, before any upload. */
.attach-thumb {
  width: 34px; height: 34px; border-radius: 7px; object-fit: cover;
  margin: -2px 2px -2px -4px; vertical-align: middle;
  border: 1px solid var(--panel-border); background: var(--sunk);
}
.chip.has-thumb { padding-left: 6px; align-items: center; }

/* ---- SOMETHING IS BEING DRAGGED IN ----
 *
 * The whole page is the target (see the drop handler in app.js), so the whole
 * page says so. An outline rather than a dimming veil: a chat you can still
 * read while dropping a screenshot into it is one where you can see WHERE the
 * screenshot is going. */
body.dropping::after {
  content: "Drop to attach";
  position: fixed; inset: 8px; z-index: 60;
  border: 2px dashed var(--orange);
  border-radius: var(--radius);
  background: rgba(242, 130, 46, 0.07);
  color: var(--orange-bright);
  font-weight: 700; font-size: 0.95rem;
  display: flex; align-items: flex-end; justify-content: center;
  padding-bottom: 22px;
  /* NOT A TARGET ITSELF. An overlay that accepts the pointer swallows the
     dragleave of everything underneath it, so the outline would stick. */
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  body.dropping::after { transition: opacity 0.12s ease; }
}
/* The bars app.js floats over the thread: "a new version is ready", "a new app
   is available". They used to carry their geometry inline, which meant the
   maximised-window insets below could not reach them and they sat below the
   visible area — the one place a bar announcing something is useless. */
/* "There is something new in here." Deliberately just a dot: an update to a
   chat app is not urgent, and the bar this replaced announced itself across the
   bottom of the screen on every launch, naming a version number that meant
   nothing to whoever read it. */
/* THE ICONS ARE LINE ART, NOT EMOJI.
   An emoji is drawn by the operating system: it arrives in somebody else's
   colours, at somebody else's weight, and looks different on every phone in the
   room. In the header — the one part of the app always on screen — that reads as
   unfinished. These inherit currentColor and the app's own stroke weight. */
.ico-svg {
  width: 20px; height: 20px;
  display: block;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.icon-btn .ico-svg { pointer-events: none; }


/* ==================================================== messages sent later
 *
 * "A clock icon (monochrome) should show next to composer whenever scheduled
 * messages are present for the user. Clicking on them will show them at the
 * bottom of the chat feed, in another color..."
 *
 * ANOTHER COLOUR, and the amber one: it is the shade this app already uses for
 * "not yet / careful" (see --warn), it is nobody's bubble colour, and it cannot
 * be mistaken for something that has been said. A dashed edge for the same
 * reason — everything real in a thread has a solid one.
 */
.sched-clock { position: relative; flex: 0 0 38px; }
.sched-count {
  position: absolute; top: 2px; right: 1px;
  min-width: 15px; height: 15px; padding: 0 3px;
  border-radius: 8px; background: var(--warn); color: #2a1400;
  font-size: 0.62rem; font-weight: 800; line-height: 15px; text-align: center;
}
.sched-panel {
  margin: 0 0 8px; padding: 8px 10px;
  border: 1px dashed var(--warn);
  border-radius: var(--radius);
  background: rgba(232, 163, 61, 0.08);
  /* Its own scroll, so twenty waiting messages cannot push the composer off
     the screen — which is where the button to write the next one lives. */
  max-height: 42vh; overflow-y: auto;
}
.sched-panel-head {
  display: flex; align-items: center; gap: 8px;
  font-size: 0.86rem; color: var(--warn); margin-bottom: 6px;
}
.sched {
  border-top: 1px dashed rgba(232, 163, 61, 0.35);
  padding: 7px 0 5px;
}
.sched:first-child { border-top: 0; padding-top: 0; }
.sched-head {
  display: flex; align-items: center; gap: 7px;
  font-size: 0.78rem; color: var(--warn);
}
.sched-head .ico-svg { width: 15px; height: 15px; }
.sched-when { font-weight: 700; font-variant-numeric: tabular-nums; }
.sched-body { margin-top: 3px; }
/* The row's own buttons are small and quiet until the row is under the cursor —
   the same bargain as a message's ⋮. */
.sched-head .icon-btn.sm { width: 26px; height: 26px; flex: 0 0 26px; opacity: 0.55; }
.sched-head .icon-btn.sm .ico-svg { width: 15px; height: 15px; }
.sched:hover .sched-head .icon-btn.sm { opacity: 1; }
@media (hover: none) {
  .sched-head .icon-btn.sm { opacity: 1; }
}
.sched-preview {
  margin: 0 0 10px; padding: 8px 11px;
  border-left: 3px solid var(--warn); border-radius: 0 8px 8px 0;
  background: var(--sunk); color: var(--text-dim);
  font-size: 0.86rem; white-space: pre-wrap; overflow-wrap: anywhere;
  max-height: 8em; overflow: hidden;
}

/* The hover preview of an avatar. Fixed, so it is never clipped by whatever
   scrolling box the small one lives in. */
.avatar-peek {
  position: fixed; z-index: 200;
  width: 168px; height: 168px;
  padding: 6px;
  border-radius: 20px;
  background: var(--panel-solid);
  border: 1px solid var(--panel-border);
  box-shadow: var(--shadow);
  pointer-events: none;              /* it must never be in the way of a click */
  animation: peek-in 0.12s ease-out;
}
.avatar-peek img {
  width: 100%; height: 100%;
  border-radius: 15px;
  object-fit: cover;
  display: block;
}
/* THE DEFAULT AVATAR, MAGNIFIED. Most people have never uploaded a picture, so
   this is the common case rather than the fallback: their initials on their
   colour, at a size where the colour is the thing that identifies them across a
   list. */
.peek-face {
  display: flex; align-items: center; justify-content: center;
  width: 100%; height: 100%;
  border-radius: 15px;
  font-size: 3.4rem; font-weight: 700; letter-spacing: -0.02em;
  color: #fff; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.35);
}
/* What time it is where they are. Bottom right, overlapping the corner: it
   belongs TO the face rather than sitting under it in a second box. */
.peek-time {
  position: absolute; right: -4px; bottom: -8px;
  padding: 2px 9px; border-radius: 999px;
  background: var(--panel-raised);
  border: 1px solid var(--panel-border);
  box-shadow: var(--shadow);
  color: var(--text); font-size: 0.82rem; font-weight: 700;
  font-variant-numeric: tabular-nums;
}
@keyframes peek-in {
  from { opacity: 0; transform: scale(0.94); }
  to   { opacity: 1; transform: scale(1); }
}

/* A submit that stays reachable over a list longer than the screen. Its own
   backdrop, because a translucent button over scrolling text is unreadable. */
.sticky-submit {
  position: sticky;
  bottom: 0;
  margin-top: 10px;
  z-index: 5;
  box-shadow: 0 -14px 20px -12px var(--bg);
}

/* ---- cards ---- */
.card-row {
  display: flex; align-items: center; gap: 12px;
  padding: 11px 4px; border-bottom: 1px solid var(--panel-border);
  color: var(--text);
}
.card-row:last-child { border-bottom: 0; }
.card-row:hover { color: var(--text); background: var(--panel-hover); border-radius: 10px; }
.card-main { min-width: 0; flex: 1; }
.card-name { display: flex; align-items: center; gap: 6px; font-weight: 650; }
.card-sub {
  display: block; color: var(--muted); font-size: 0.82rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-meta { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.card-row.pinned .card-name { color: var(--cream); }

.card-title-input {
  flex: 1; min-width: 0;
  font-size: 1.15rem; font-weight: 700;
  background: transparent; border: 1px solid transparent;
  color: var(--text); padding: 4px 6px; border-radius: 8px;
}
.card-title-input:focus { border-color: var(--panel-border); background: var(--panel); }

/* THE CARD AS IT IS READ. Same size as the input it replaces, so switching to
   Edit does not make the page jump. */
.card-title {
  flex: 1; min-width: 0; margin: 0;
  font-size: 1.15rem; font-weight: 700; color: var(--text);
  padding: 4px 6px 4px 0;
}
/* Notes are prose, and prose has a readable ceiling — the same one the
   document pages use. A card's own thread below it is already capped. */
.card-note-body { max-width: 900px; }
/* The panel supplies the padding; the first and last blocks must not add
   their own, or the notes sit off-centre inside it. */
.card-note-body > *:first-child { margin-top: 0; }
.card-note-body > *:last-child { margin-bottom: 0; }

/* A card's thread is a section of an ordinary scrolling page, not the pinned
   pane the main chat uses — a card is read top to bottom, notes first. */
.card-thread { margin-bottom: 10px; }
/* And the composer stays with the thread rather than stretching to the window,
   for the same reason a chat's bubbles do not: a line of text 1400px wide is
   unreadable however much room there is. */
.card-thread, .composer.card-composer, .card-thread + form { max-width: 900px; }
.composer.card-composer {
  position: sticky; bottom: 0;
  border: 1px solid var(--panel-border); border-radius: var(--radius);
  padding: 8px 10px;
}

/* One markup, two layouts. A JavaScript swap would flicker on every load and
   break with the script disabled. */
.narrow-only { display: none; }
.wide-only { display: inline-flex; }
@media (max-width: 899px) {
  .narrow-only { display: block; }
  .wide-only { display: none; }
}
/* CENTRE, not flex-end. A <label> and a <button> do not share a baseline — the
   label's box is text-shaped and the buttons' are not — so aligning on the
   bottom edge left the paperclip sitting higher than its neighbours. */
.compose-tools { align-items: center; gap: 2px; flex: 0 0 auto; }
/* And all three are the same box whatever element they happen to be. */
.compose-tools > .icon-btn { width: 38px; height: 38px; flex: 0 0 38px; }

.ticket-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
/* The mascot beside the ticket number. Nudged down a pixel because it sits on a
   text baseline and its own feet are the bottom of the drawing. */
.donkie-mark {
  height: 18px; width: auto;
  vertical-align: -4px;
  margin-right: 5px;
  display: inline-block;
}
/* CONTAIN, not cover. The mascot is a tall drawing and the avatar tile is
   square: cover would crop his ears off. */
.msg.donkie .avatar-img {
  object-fit: contain;
  padding: 3px;
}
.ticket-company {
  font-size: 0.74rem; font-weight: 700; letter-spacing: 0.02em;
  color: var(--orange-bright);
}
.ticket-subject { font-weight: 650; font-size: 0.9rem; margin-top: 2px; }
/* The summary is the reason the card exists, so it gets room to be read: two
   lines, then an ellipsis, rather than one truncated clause. */
.ticket-summary {
  color: var(--text-dim); font-size: 0.83rem; line-height: 1.4;
  margin: 4px 0 6px;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
  overflow: hidden;
}

.float-bar {
  position: fixed; left: 50%; transform: translateX(-50%);
  bottom: calc(16px + var(--safe-bottom));
  z-index: 96; display: flex; gap: 10px; align-items: center;
  width: max-content; max-width: calc(100% - 24px);
}
/* "There is something new in here." Deliberately just a dot: an update to a
   chat app is not urgent, and the bar this replaced announced itself across the
   bottom of the screen on every launch, naming a version number that meant
   nothing to whoever read it. */
.new-dot {
  position: absolute; top: 5px; right: 5px;
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--orange);
  box-shadow: 0 0 0 2px var(--bg);
  pointer-events: none;
}

/* THE ICONS ARE LINE ART, NOT EMOJI.
   An emoji is drawn by the operating system: it arrives in somebody else's
   colours, at somebody else's weight, and looks different on every phone in the
   room. In the header — the one part of the app always on screen — that reads as
   unfinished. These inherit currentColor and the app's own stroke weight. */
.ico-svg {
  width: 20px; height: 20px;
  display: block;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}


/* A key cap in a menu row. Pushed to the right so the words stay left-aligned
   with everything else, and muted, because it is a hint rather than the label. */
.kbd {
  margin-left: auto;
  padding: 1px 6px;
  border: 1px solid var(--panel-border);
  border-radius: 5px;
  background: var(--panel);
  color: var(--muted);
  font: 600 0.72rem/1.5 var(--mono);
}



































/* In the maximised Android window the bottom of the viewport is not the bottom
   of the screen, so a bar pinned 16px up from it lands in the strip that is off
   screen — the one place a bar announcing something is useless. */
body.shell-apk.maximised .float-bar {
  bottom: calc(16px + var(--window-bottom));
}

/* A picture laid over the coloured initials, not instead of them: if it fails to
   load, what shows through is exactly the fallback that would have been there. */
/* ANY picture inside an avatar, class or no class. `.avatar-img` is what the
   partial writes and remains the name to use — but a bare <img> put here by
   JavaScript used to be laid out at the file's own size, several hundred pixels
   of it, on top of whatever was underneath. Making the element selector do the
   work means the next person to append one cannot get it wrong. */
.avatar > img, .avatar-img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  border-radius: inherit;
  object-fit: cover;
  display: block;
}

.chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 10px; border-radius: 999px;
  background: var(--panel); border: 1px solid var(--panel-border);
  font-size: 0.8rem; color: var(--text-dim);
}
.chip button { border: 0; background: transparent; color: var(--muted); cursor: pointer; padding: 0; }
.chip.on { background: rgba(242, 130, 46, 0.2); border-color: var(--orange); color: var(--cream); }

/* A radio group that flows instead of stacking. Eight one-line options as
   full-width rows is most of a phone screen spent on eight words, and pushes
   the box you type in below the fold.

   The <input> is moved off-screen rather than `display:none`, which would take
   it out of the tab order and out of reach of a screen reader — the pill still
   has to be a real radio. */
/* An announcement group, seen by somebody who may only read it. Where the
   composer would be, so the bottom of the thread does not simply end. */
.composer.readonly-note {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}

.choices { display: flex; flex-wrap: wrap; gap: 6px; margin: 14px 0; }
.choice { display: inline-flex; }
.choice input {
  position: absolute; width: 1px; height: 1px;
  opacity: 0; margin: 0; pointer-events: none;
}
.choice span {
  display: inline-block; padding: 6px 12px; border-radius: 999px;
  background: var(--panel); border: 1px solid var(--panel-border);
  color: var(--text-dim); font-size: 0.82rem; font-weight: 600;
  cursor: pointer; white-space: nowrap;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.choice:hover span { border-color: var(--text-dim); color: var(--text); }
.choice input:checked + span {
  background: rgba(242, 130, 46, 0.2); border-color: var(--orange);
  color: var(--cream);
}
/* Keyboard focus has to be visible, because the input itself is not. */
.choice input:focus-visible + span { outline: 2px solid var(--orange); outline-offset: 2px; }

/* ============================================================ buttons, forms */

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: 9px 16px; border-radius: 12px;
  border: 1px solid var(--panel-border); background: var(--panel);
  color: var(--text); font: inherit; font-size: 0.9rem; font-weight: 600;
  cursor: pointer; text-align: center; white-space: nowrap;
  transition: background .12s ease, border-color .12s ease;
}
.btn:hover { background: var(--panel-hover); color: var(--text); }
.btn-primary {
  background: var(--grad-warm); border-color: transparent; color: #2a1400;
  box-shadow: 0 4px 14px rgba(242, 130, 46, 0.28);
}
.btn-primary:hover { background: var(--orange-bright); color: #2a1400; }
.btn-blue { background: var(--grad-cool); border-color: transparent; color: #fff; }
.btn-blue:hover { color: #fff; background: var(--blue-bright); }
.btn-quiet { background: transparent; border-color: transparent; color: var(--text-dim); }
.btn-quiet:hover { background: var(--panel-hover); }
.btn-danger { color: #ffb3ad; border-color: rgba(228, 87, 77, 0.4); }
.btn-danger:hover { background: rgba(228, 87, 77, 0.18); color: #ffd0cc; }
.btn.sm { padding: 5px 11px; font-size: 0.8rem; border-radius: 9px; }
.btn.block { width: 100%; }
.btn:disabled { opacity: 0.45; cursor: default; }

input[type="text"], input[type="email"], input[type="password"],
input[type="search"], select, textarea {
  width: 100%; padding: 10px 13px;
  border-radius: 12px; border: 1px solid var(--panel-border);
  background: var(--panel-solid); color: var(--text);
  font: inherit; font-size: 16px;   /* see the composer comment */
  font-family: var(--font);
}
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--blue-bright); }
label { display: block; font-size: 0.82rem; color: var(--text-dim); margin-bottom: 5px; font-weight: 600; }
.field { margin-bottom: 14px; }
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 5px; }

.checkline { display: flex; align-items: flex-start; gap: 9px; margin-bottom: 11px; }
.checkline input { width: auto; flex: 0 0 auto; margin-top: 3px; accent-color: var(--orange); }
.checkline label { margin: 0; font-weight: 500; color: var(--text); }

/* ============================================================ panels, pages */

.page {
  overflow-y: auto; min-height: 0;
  /* And it may be narrower than its contents would like. */
  min-width: 0;
  padding: 18px 14px 40px; -webkit-overflow-scrolling: touch;
  /* app.js sets `display: flex` on this element to switch panes on a phone (see
     the pane switching in app.js), so the direction has to be stated here or a
     second child would sit BESIDE the first rather than under it. */
  flex-direction: column;
}

/* WIDTH 100% AND MIN-WIDTH 0 ON ALL FOUR, and both are load-bearing.
   With `display: flex` on the parent, a wrapper's flex-basis is `auto` — which
   means its MAX-CONTENT width. Measured on a 393px screen: the search page's
   wrapper came out 918px and every line in it was cut off at the right edge,
   with no scrollbar anywhere to hint at why, because the overflow was clipped by
   an ancestor rather than reaching the document. `width: 100%` makes the basis
   the container instead, and `min-width: 0` lets it shrink there. */
.page-wide, .page-full, .page-narrow, .page-feed { width: 100%; min-width: 0; }
.page-wide { max-width: 1000px; margin: 0 auto; }
/* THE ROOM THERE IS, ON A COMPUTER.
 *
 *     "I think we should use all the available desk space … Leave the chat
 *      list on the left (unless the user hides it with the icon on top) but
 *      use all usable space wisely"  — Julian, 2026-10-08
 *
 * From 1100px the column is no longer 1000px in the middle of a maximised
 * window: it takes the width beside the chat list (or all of it with the list
 * hidden), with 8–32px side margins, up to 1800px — past that, on a 2560px
 * screen, rows get too long to scan. Phones are untouched. "Wisely": running
 * text keeps a readable measure (see .prose below), and forms stay
 * `.page-narrow`. */
@media (min-width: 1100px) {
  .page-wide { max-width: 1800px; padding: 0 clamp(8px, 2vw, 32px); }
}
/* No column at all: the page's own padding is the only margin. For the composer,
   where a capped width means an editor using a third of a big screen. */
.page-full { max-width: none; margin: 0; }
/* The prose-shaped things UNDER the editor keep a readable ceiling: an
   instruction box or a history list stretched across a 2560px screen is harder
   to use, not easier. The editor itself wants all of it. */
.page-full .below { max-width: 1200px; }
.page-narrow { max-width: 620px; margin: 0 auto; }
/* THE WIDTH OF THE CHAT, which is to say no width of its own: `.messages` fills
   its pane and lets individual bubbles do the limiting. Cards are the same kind
   of surface — a thread with a header on it — so a 620px column made them look
   like a settings page beside a conversation that used the whole window. */
.page-feed { max-width: none; margin: 0; }

.panel {
  background: var(--panel); border: 1px solid var(--panel-border);
  border-radius: var(--radius); padding: 16px;
  margin-bottom: 14px;
}
/* WRAPS, because a panel's header is a title and however many buttons that
   panel needs, and on a phone that is more than one line's worth. Without this
   they were all squeezed onto one row — `.right` pushes them hard against the
   edge — and the last one added was the one that stopped being visible.
   Reported as "Where is the sticker download icon? I dont see it". */
.panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 12px; }
.panel-head h2, .panel-head h3 { margin: 0; }

.pill {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px 9px; border-radius: 999px;
  background: var(--panel); border: 1px solid var(--panel-border);
  font-size: 0.74rem; color: var(--text-dim); font-weight: 600;
}
.pill.orange { background: rgba(242, 130, 46, 0.16); border-color: rgba(242, 130, 46, 0.4); color: #ffc499; }
.pill.blue { background: rgba(62, 86, 196, 0.22); border-color: rgba(95, 116, 221, 0.4); color: #b9c4f7; }
.pill.ok { background: rgba(53, 200, 138, 0.15); border-color: rgba(53, 200, 138, 0.35); color: #93e8c4; }

.empty {
  text-align: center; padding: 40px 20px; color: var(--muted);
}
/* Full opacity since the mascot became the app icon (2026-10-02): the 0.85 that
   softened the line-art character washed the blue square out on light themes. */
.empty img { width: 108px; height: auto; margin-bottom: 14px; }
.empty h3 { color: var(--text-dim); }

/* ---- flash messages ---- */
.flashes { position: fixed; top: calc(var(--header-h) + var(--safe-top) + 10px);
           left: 50%; transform: translateX(-50%); z-index: 90;
           width: min(560px, calc(100% - 24px)); pointer-events: none; }
.flash {
  pointer-events: auto;
  padding: 10px 14px; margin-bottom: 8px; border-radius: 13px;
  background: var(--panel-raised); border: 1px solid var(--panel-border);
  box-shadow: var(--shadow); font-size: 0.88rem;
  animation: drop .22s ease;
}
.flash.success { border-left: 3px solid var(--ok); }
.flash.error { border-left: 3px solid var(--bad); }
.flash.info { border-left: 3px solid var(--blue-bright); }
/* THE WAY BACK, on a toast that reports something being taken away — a draft
   this app cleared because it had already been sent from another device. A
   notice about a deletion with no undo beside it is how people learn not to
   trust the thing that did it. */
.flash-do {
  display: inline-block; margin-left: 10px; padding: 2px 8px;
  border: 1px solid var(--panel-border); border-radius: 8px;
  background: transparent; color: var(--blue-bright);
  font: inherit; font-size: 0.82rem; font-weight: 600; cursor: pointer;
}
.flash-do:hover { background: var(--panel-hover); color: var(--text); }
@keyframes drop { from { opacity: 0; transform: translateY(-10px); } }

/* ---- documents ---- */
/* minmax(0, 1fr), for the THIRD time in this stylesheet — a grid's implicit
   column is `auto`, which means max-content, so a list of documents grew to fit
   its longest title and every row was cut off at the right on a phone. Taking
   the ⋮ menu with it, which is the one thing the row was gaining. */
.doc-list { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
.doc-card {
  display: flex; gap: 12px; align-items: flex-start;
  padding: 12px 14px; border-radius: var(--radius);
  background: var(--panel); border: 1px solid var(--panel-border);
  color: var(--text);
}
.doc-card:hover { background: var(--panel-hover); border-color: rgba(139, 155, 240, 0.3); color: var(--text); }
.doc-icon {
  flex: 0 0 auto; width: 42px; height: 42px; border-radius: 12px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--panel-raised); font-size: 1.25rem;
}
.doc-body { min-width: 0; flex: 1; }
.doc-title { font-weight: 650; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.doc-sub { color: var(--muted); font-size: 0.78rem; }
.doc-excerpt {
  color: var(--text-dim); font-size: 0.82rem; margin-top: 3px;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
  /* A search result gets a third line. Two is right for a file list, where the
     excerpt is only a hint of what the thing is; in a search result the excerpt
     IS the answer, and on a phone two lines was not enough to reach the word
     that was matched. */
}

/* Attachments shown inside a message bubble. */
.attach {
  display: flex; align-items: center; gap: 9px;
  margin-top: 6px; padding: 7px 11px;
  border-radius: 12px; background: var(--sunk);
  border: 1px solid var(--panel-border); color: var(--text);
}
.attach:hover { background: var(--sunk-hover); color: var(--text); }
/* height: auto WITH the width and height attributes in the markup, which is the
   pair that reserves the right box: the attributes give the browser the ratio
   before the bytes arrive, and this stops the stated height being used
   literally once max-width has narrowed it. The markup also carries an explicit
   aspect-ratio, which is what makes a <video> reserve its box too. */
.attach-img {
  display: block; margin-top: 6px; border-radius: 12px;
  /* The ceiling the `fit` filter scales to — keep the two in step; a test
     asserts they agree. `min(…, 100%)` so a narrow bubble still governs, and
     max-height only ever bites when the dimensions are unknown, where the
     width is `auto` and both shrink together. */
  max-width: min(320px, 100%); max-height: 340px;
  /* NO `width: auto` HERE, and that omission is the whole of a bug.
   *
   * The width and height attributes reach the cascade as PRESENTATIONAL HINTS,
   * which any author declaration outranks — so `width: auto` cancelled the very
   * width the markup had just supplied. The element then fell back to the
   * default object size for a replaced element with nothing loaded yet: 300px
   * wide, 212 high at this ratio, growing to 320 x 226 the moment the picture
   * arrived. Twenty pixels of push, at the bottom of the feed, on the message
   * you had just sent — reported as "the last image I sent ... pushes the chat
   * up since the space is not reserved for it".
   *
   * `height: auto` stays: it is what lets max-width narrow the picture without
   * the stated height being taken literally, and the ratio then decides the
   * height. */
  height: auto;
}

/* ---- MEDIA ARRIVES BY FADING IN ----
 *
 * "perhaps images and gifs in addition to have the reserved space to avoid
 * bumping could fade in gracefully to make it look nicer?"
 *
 * The space is already held, so nothing moves either way — this is only about
 * the picture appearing rather than snapping in. A quarter of a second, which
 * is about how long a thumbnail takes on a decent connection: long enough to
 * read as an arrival, short enough not to be a wait.
 *
 * ONLY WHEN SCRIPT IS RUNNING. `media-fade` is put on <html> by the inline
 * script at the top of the thread — before the first paint, so nothing flashes
 * in and then out — and it is what makes the hidden state conditional. Without
 * JavaScript there is no class, so nothing is ever hidden and a picture that
 * fails to fire `load` is still a picture.
 */
#messages .attach-img,
#messages .gif-view,
#messages .sticker-img { transition: opacity 0.26s ease; }
/* SCOPED TO THE THREAD, because the hiding half only comes back off where
   fadeInMedia runs. A picture in a picker, a sheet or a document page must
   never be able to end up hidden by a rule meant for the feed. */
html.media-fade #messages .attach-img:not(.ready),
html.media-fade #messages .gif-view:not(.ready),
html.media-fade #messages .sticker-img:not(.ready) { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  #messages .attach-img,
  #messages .gif-view,
  #messages .sticker-img { transition: none; }
}

/* A Donkie ticket card, unfurled from a pasted link. */
.ticket-card {
  display: block; margin-top: 7px; padding: 9px 12px;
  border-radius: 12px; border: 1px solid rgba(242, 130, 46, 0.35);
  background: rgba(242, 130, 46, 0.09); color: var(--text);
}
.ticket-card:hover { background: rgba(242, 130, 46, 0.16); color: var(--text); }
.ticket-num { color: var(--orange); font-weight: 750; font-size: 0.78rem; }

/* ---- the editor ---- */
/* Side by side on a wide screen, tabbed on a phone. Tabs rather than stacked:
   a stacked editor and preview on a 390px screen gives you two useless halves. */
.editor { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); min-height: 0; }
.editor textarea {
  min-height: 52vh; font-family: var(--mono); font-size: 0.92rem; line-height: 1.6;
  resize: vertical;
}

/* THE PROOFREAD BUTTON, INSIDE THE BOX.
 *
 * Bottom right rather than top right: the top right of a textarea is where
 * the eye goes when reading back from the start of a line, and a control
 * sitting there is in the way of the writing. The bottom right is where the
 * resize grip already lives, so it is a corner people expect furniture in —
 * and it is offset above it so the two do not overlap.
 *
 * `pointer-events` on the pane and not just the button: the pane is only a
 * positioning context and must not steal a click meant for the text. */
.write-pane { position: relative; }
.proofread {
  position: absolute; right: 14px; bottom: 26px; z-index: 2;
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; padding: 0;
  border-radius: 999px; cursor: pointer;
  border: 1px solid var(--panel-border);
  background: var(--panel); color: var(--text);
  box-shadow: var(--shadow); opacity: .72;
  transition: opacity .12s ease, transform .12s ease;
}
.proofread:hover { opacity: 1; }
.proofread:active { transform: scale(.94); }
.proofread:disabled { cursor: progress; opacity: .5; }
/* Once it has changed the text it is no longer a suggestion, it is a thing
 * that happened and can be taken back — so it stops being quiet. */
.proofread[data-state="undo"],
.proofread[data-state="redo"] { opacity: 1; border-color: var(--accent); color: var(--accent); }
.proofread .ico-svg { width: 18px; height: 18px; }
/* The microphone sits beside the pencil, not on top of it. */
.proofread.dictate { right: 56px; }
/* LISTENING IS A STATE WORTH SEEING ACROSS THE ROOM. A recording control that
 * looks the same running as idle is one people leave running. */
.proofread[data-state="recording"] {
  opacity: 1; color: #fff; border-color: transparent;
  background: var(--danger, #c2443d);
  animation: dictating 1.4s ease-in-out infinite;
}
.proofread[data-state="thinking"] { opacity: 1; cursor: progress; }
@keyframes dictating {
  0%, 100% { box-shadow: 0 0 0 0 rgba(194, 68, 61, .55); }
  70%      { box-shadow: 0 0 0 8px rgba(194, 68, 61, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .proofread { transition: none; }
  .proofread:active { transform: none; }
  .proofread[data-state="recording"] { animation: none; }
}
.editor-preview {
  padding: 16px; border-radius: var(--radius);
  background: var(--panel); border: 1px solid var(--panel-border);
  overflow-y: auto; min-height: 52vh;
}
.prose { line-height: 1.65; }
/* A READABLE MEASURE FOR THE WORDS, not for the page. Since pages take the
   width of the screen, a paragraph would otherwise run 1700px; tables, code
   and pictures in the same document still get all of it. */
.prose > p, .prose > ul, .prose > ol, .prose > blockquote,
.prose > h1, .prose > h2, .prose > h3, .prose > h4 { max-width: 85ch; }
.prose h1, .prose h2, .prose h3 { margin-top: 1.4em; }
.prose h1:first-child, .prose h2:first-child { margin-top: 0; }
.prose pre {
  background: var(--sunk-firm); border-radius: var(--radius-sm);
  padding: 12px 14px; overflow-x: auto; font-size: 0.86rem;
}
.prose code { font-family: var(--mono); }
.prose :not(pre) > code { background: var(--sunk-firm); padding: 1px 5px; border-radius: 5px; }
.prose table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; }
.prose th, .prose td { border: 1px solid var(--panel-border); padding: 7px 11px; text-align: left; }
.prose th { background: var(--panel); }
.prose blockquote {
  margin: 1em 0; padding: 2px 0 2px 14px;
  border-left: 3px solid var(--blue); color: var(--text-dim);
}
.prose img { max-width: 100%; border-radius: var(--radius-sm); }
.prose hr { border: 0; border-top: 1px solid var(--panel-border); margin: 1.6em 0; }

.tabs { display: flex; gap: 4px; padding: 3px; background: var(--panel); border-radius: 12px; }
.tabs button {
  flex: 1; padding: 7px; border: 0; border-radius: 9px; cursor: pointer;
  background: transparent; color: var(--text-dim); font: inherit;
  font-size: 0.86rem; font-weight: 650;
}
.tabs button.on { background: var(--panel-raised); color: var(--text); }
/* THE SAME STRIP, FOR TABS THAT ARE LINKS. An OKR tab is an address — it
   survives a reload, it can be sent to somebody, and it needs no JavaScript —
   so it is an <a>, and it has to sit in the strip exactly as the buttons do. */
.tabs a {
  flex: 1; padding: 6px 12px; border-radius: 9px;
  font-size: 0.86rem; font-weight: 600; text-align: center;
  color: var(--text-dim); text-decoration: none;
}
.tabs a:hover { color: var(--text); }
.tabs a.on { background: var(--panel-raised); color: var(--text); }
.tab-n {
  margin-left: 5px; padding: 0 5px; border-radius: 7px;
  background: var(--sunk-firm); color: var(--muted);
  font-size: 0.72rem; font-variant-numeric: tabular-nums;
}
.tabs a.on .tab-n { color: var(--text-dim); }

/* ---- the waiting modal ---- */

dialog.ai-wait .sheet-inner { text-align: center; max-width: 460px; margin: 0 auto; }

/* 16:9, so a clip of any size lands in the same hole and the dialog does not
   resize when one video is swapped for another mid-wait. */
.ai-wait-video {
  position: relative; width: 100%; aspect-ratio: 16 / 9;
  border-radius: var(--radius); overflow: hidden;
  background: var(--panel-raised);
  display: flex; align-items: center; justify-content: center;
}
.ai-wait-video video {
  width: 100%; height: 100%; display: block;
  /* CONTAIN, not cover. The real clips turned out to be 480x268, 288x216 and
     208x146 — reaction loops are whatever aspect the original meme was, and
     cropping one to 16:9 cuts the face, which is the entire content. Letterboxed
     against the panel is the honest way to show a 4:3 clip.

     Upscaling a 208px-wide loop stays soft whichever fit is used; not cropping
     it is the part that is actually a choice. */
  object-fit: contain;
}
/* Shown until a clip is playing, and left alone if there are none at all —
   somebody who has not put any videos in gets a tidy modal, not a black hole. */
.ai-wait-fallback {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  color: var(--muted); font-size: 1.6rem;
}
.ai-wait-video.playing .ai-wait-fallback { display: none; }

/* The three dots, also used by the transcribing line. */
.dots { display: inline-flex; gap: 5px; align-items: center; }
.dots i {
  width: 7px; height: 7px; border-radius: 50%; background: currentColor;
  animation: blink 1.4s ease-in-out infinite;
}
.dots i:nth-child(2) { animation-delay: .2s; }
.dots i:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%, 60%, 100% { opacity: .25; } 30% { opacity: 1; } }

#ai-wait-elapsed { font-variant-numeric: tabular-nums; }

/* ---- the prompt box ---- */

.prompt-box {
  border: 1px solid var(--panel-border);
  border-radius: var(--radius);
  background: var(--panel-solid);
  padding: 10px 12px;
}
.prompt-box textarea {
  border: 0; background: transparent; padding: 4px 0;
  min-height: 70px; resize: vertical; width: 100%;
  font-family: var(--font); font-size: 16px; line-height: 1.5;
}
.prompt-box textarea:focus { outline: none; }
.prompt-row { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
/* NOTHING IN THIS ROW SHRINKS except the brand dropdown. A <select> asks for as
   much width as its longest option name — a brand guide called "SCANOSS
   Presentation & Document Guidelines" is most of a row — and the buttons beside
   it were paying for that by wrapping their own labels onto a second line. The
   dropdown gives way instead; it can ellipsise, a button cannot. */
.prompt-row > * { flex: 0 0 auto; }
.prompt-row #prompt-brand { flex: 0 1 auto; min-width: 0; max-width: 240px; }
/* Wide enough for the label on one line, and it stays that width whatever the
   label says while it is working. */
#prompt-go { min-width: 148px; }

/* The attached files, above the instruction rather than inline in it.
   The stored form is [[doc:12|Name.md]] — machine syntax that reads as a mistake
   in prose — so what somebody sees while typing is a row of chips, and the
   tokens stay out of the way in the textarea's value. */
.prompt-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.prompt-chips:not(:empty) { margin-bottom: 8px; }
.prompt-chips .chip { background: rgba(62, 86, 196, .2); border-color: var(--blue-bright); }

/* THE ICONS ARE LINE ART, NOT EMOJI.
   An emoji is drawn by the operating system: it arrives in somebody else's
   colours, at somebody else's weight, and looks different on every phone in the
   room. In the header — the one part of the app always on screen — that reads as
   unfinished. These inherit currentColor and the app's own stroke weight. */
.ico-svg {
  width: 20px; height: 20px;
  display: block;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}


















































































.chip { background: rgba(62, 86, 196, .2); border-color: var(--blue-bright); }

/* A reference rendered inside the instruction text itself. */
.ref-chip {
  display: inline-flex; align-items: center; gap: 3px;
  padding: 0 6px; border-radius: 6px;
  background: rgba(62, 86, 196, .22); color: var(--blue-soft);
  font-size: 0.92em; white-space: nowrap;
}

/* ---- a dropdown that sits among buttons ---- */

/*
 * NOT `.btn`, which is what these used to be. A button's background is
 * `--panel` — 4.5% white, translucent — which is fine on the page because the
 * page is behind it. A native <select> popup is drawn by the operating system
 * against ITS OWN background, usually white, so the same 4.5% white resolved to
 * near-white text on near-white rows and the options were unreadable.
 *
 * So: opaque backgrounds on both the control and its options, and
 * `color-scheme: dark` so the browser draws the popup's own chrome — the
 * scrollbar, the highlight on the row under the cursor — to match rather than in
 * its default light theme.
 */
.combo {
  appearance: none; -webkit-appearance: none;
  padding: 5px 26px 5px 10px;
  border-radius: 9px;
  border: 1px solid var(--panel-border);
  background-color: var(--panel-solid);
  color: var(--text);
  font: inherit; font-size: 0.8rem; font-weight: 600;
  cursor: pointer;
  color-scheme: dark;
  /* The chevron, drawn rather than left to the platform, since appearance:none
     removes the native one. */
  background-image:
    linear-gradient(45deg, transparent 50%, var(--text-dim) 50%),
    linear-gradient(135deg, var(--text-dim) 50%, transparent 50%);
  background-position: right 13px center, right 8px center;
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}
.combo:hover { border-color: var(--blue-bright); }
.combo:focus { outline: none; border-color: var(--blue-bright); }

/* The options themselves. Explicit and opaque, because a translucent colour here
   is composited against whatever the platform draws the popup on. */
.combo option {
  background-color: var(--panel-solid);
  color: var(--text);
  padding: 6px 10px;
}
/* The first option is the placeholder — "Brand guide…" — and should read as a
   prompt rather than as a choice. */
.combo option:first-child { color: var(--muted); }

/* Every other select in the app gets the same treatment for the popup, which is
   the part the platform draws. */
select { color-scheme: dark; }
select option { background-color: var(--panel-solid); color: var(--text); }

/* ---- the file picker ---- */

.pick-row {
  display: flex; align-items: flex-start; gap: 11px;
  width: 100%; padding: 10px 11px; border: 0; border-radius: 12px;
  background: transparent; color: var(--text); font: inherit; text-align: left;
  cursor: pointer;
}
.pick-row:hover, .pick-row:focus-visible { background: var(--panel-hover); }
.pick-row .doc-icon { width: 34px; height: 34px; font-size: 1rem; }
.pick-row .t { font-weight: 650; font-size: 0.9rem; display: block;
               overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pick-row .m { color: var(--muted); font-size: 0.76rem; display: block; }
.pick-row .x { color: var(--text-dim); font-size: 0.8rem; display: -webkit-box;
               -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
#pick-sheet select { width: auto; min-width: 120px; padding: 6px 10px;
                     font-size: 0.84rem; border-radius: 9px; }

/* ---- the VU meter, on the profile page ---- */

.vu {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 12px; border-radius: var(--radius-sm);
  background: var(--panel-solid); border: 1px solid var(--panel-border);
}
.vu-bars { display: flex; align-items: flex-end; gap: 2px; height: 36px; flex: 1; min-width: 0; }
.vu-bars i {
  display: block; flex: 1; min-width: 2px; height: 8%;
  border-radius: 2px; background: var(--ok); opacity: 0.4;
  transition: height .06s linear;
}
/* Colour by position, so the meter reads like a meter: the far end lighting up
   is what "too loud" looks like without needing a number. */
.vu-bars i:nth-child(n+11) { background: var(--warn); }
.vu-bars i:nth-child(n+14) { background: var(--bad); }

.vu-read { flex: 0 0 auto; text-align: right; font-family: var(--mono); }
.vu-db { display: block; font-size: 1rem; font-variant-numeric: tabular-nums; }
.vu-peak { display: block; font-size: 0.7rem; color: var(--muted); }
.vu-scale {
  display: flex; justify-content: space-between;
  font-size: 0.68rem; color: var(--muted); padding: 3px 2px 0;
  letter-spacing: .04em; text-transform: uppercase;
}

/* The three verdicts. Stated in words, because -34 dB means nothing to most
   people and "too quiet" means something to everybody. */
#mic-verdict.good { color: var(--ok); }
#mic-verdict.low  { color: var(--warn); }
#mic-verdict.bad  { color: var(--bad); }

/* ---- search ---- */
.searchbar { position: relative; flex: 1; max-width: 420px; }
.searchbar input { padding-left: 34px; border-radius: 999px; }
.searchbar .mag { position: absolute; left: 11px; top: 50%; transform: translateY(-50%);
                  color: var(--muted); pointer-events: none;
                  display: flex; align-items: center; }
/* Slightly smaller inside a field than in the header, where it is a target. */
.searchbar .mag .ico-svg { width: 17px; height: 17px; }
/* In a menu row the icon sits with the words, so it matches their size. */
.menuitem .ico-svg { width: 17px; height: 17px; flex: 0 0 17px; }
h3 .ico-svg { width: 18px; height: 18px; display: inline-block; vertical-align: -3px; }
.suggest {
  position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 60;
  background: var(--panel-solid); border: 1px solid var(--panel-border);
  border-radius: var(--radius); box-shadow: var(--shadow);
  overflow: hidden; max-height: 62vh; overflow-y: auto;
}
.suggest a {
  display: block; padding: 9px 13px; color: var(--text);
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.suggest a:last-child { border-bottom: 0; }
.suggest a:hover, .suggest a.on { background: var(--panel-hover); color: var(--text); }
.suggest .s-title { font-weight: 650; font-size: 0.88rem; }
.suggest .s-meta { color: var(--muted); font-size: 0.75rem; }

.result {
  display: block; padding: 13px 15px; margin-bottom: 9px;
  border-radius: var(--radius); background: var(--panel);
  border: 1px solid var(--panel-border); color: var(--text);
}
.result:hover { background: var(--panel-hover); color: var(--text); }
.result mark { background: rgba(242, 130, 46, 0.35); color: var(--cream); border-radius: 3px; padding: 0 2px; }

.filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 14px; }
.filters a {
  padding: 5px 12px; border-radius: 999px; font-size: 0.8rem; font-weight: 600;
  background: var(--panel); border: 1px solid var(--panel-border); color: var(--text-dim);
}
.filters a.on { background: rgba(242, 130, 46, 0.18); border-color: var(--orange); color: #ffc499; }

/* ---- sheets and menus ---- */
/* A bottom sheet on a phone, a centred dialog on a desktop. One element, one
   media query — two components would drift. */
dialog.sheet {
  border: 0; padding: 0; margin: 0; max-width: none; max-height: none;
  width: 100%; height: auto;
  position: fixed; inset: auto 0 0 0;
  background: var(--panel-solid);
  border-radius: 22px 22px 0 0;
  border-top: 1px solid var(--panel-border);
  box-shadow: var(--shadow);
  color: var(--text);
}
dialog.sheet::backdrop { background: rgba(6, 4, 16, 0.6); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); }
.sheet-inner {
  padding: 16px 16px calc(20px + var(--safe-bottom));
  max-height: 78dvh; overflow-y: auto;
}
.sheet-grip {
  width: 40px; height: 4px; border-radius: 2px; background: var(--panel-border);
  margin: 8px auto 12px;
}
.emoji-bar { display: flex; gap: 4px; flex-wrap: wrap; }
.emoji-bar button {
  width: 44px; height: 44px; border: 0; border-radius: 12px; cursor: pointer;
  background: var(--panel); font-size: 1.35rem; line-height: 1;
}
.emoji-bar button:hover { background: var(--panel-hover); transform: scale(1.08); }

.menu { position: relative; }
.menu > summary { list-style: none; cursor: pointer; }
.menu > summary::-webkit-details-marker { display: none; }
.menu-panel {
  position: absolute; right: 0; top: calc(100% + 6px); z-index: 70;
  min-width: 216px; padding: 6px;
  background: var(--panel-solid); border: 1px solid var(--panel-border);
  border-radius: var(--radius); box-shadow: var(--shadow);
}
.menu-panel.left { right: auto; left: 0; }

/* Opens UPWARD, for the one in the composer: downward would put the panel off
   the bottom of the screen, which is where the composer already is. Anchored
   left, because the composer's ⋮ is the leftmost thing in its row. */
.menu.up > .menu-panel {
  top: auto; bottom: calc(100% + 6px);
  right: auto; left: 0;
  min-width: 160px;
}
.menuitem {
  display: flex; align-items: center; gap: 9px;
  padding: 9px 11px; border-radius: 10px;
  color: var(--text-dim); font-size: 0.88rem; width: 100%;
  background: transparent; border: 0; font: inherit; cursor: pointer; text-align: left;
}
.menuitem:hover { background: var(--panel-hover); color: var(--text); }
/* THE HEADER'S ICONS, IN THE MENU ON A PHONE.
 *
 *     "we have too many icons now. Maybe a hamburger menu is best for mobile?"
 *                                                     — Paula, 2026-10-02
 *     "yes, we should move them inside the existing burger menu in mobile
 *      screens"                                       — Julian
 *
 * The icons are `wide-only` in the header; the module entries the menu did not
 * already have are `narrow-only` here. Spelled out for .menuitem because its
 * own `display: flex` comes later than the generic helpers and would beat them. */
.menuitem.narrow-only { display: none; }
@media (max-width: 899px) { .menuitem.narrow-only { display: flex; } }
.menuitem .count { margin-left: auto; position: static; border: 0; }
.menuitem.on { color: var(--orange); }
.menu-sep { height: 1px; background: var(--panel-border); margin: 5px 3px; }

/* ---- the person list ---- */
.person {
  display: flex; align-items: center; gap: 11px;
  padding: 9px 11px; border-radius: 13px; color: var(--text);
}
.person:hover { background: var(--panel-hover); color: var(--text); }
.person-main { min-width: 0; flex: 1; }
.person-name { font-weight: 650; font-size: 0.92rem; }
.person-sub { color: var(--muted); font-size: 0.78rem; }

/* ============================================================ desktop */

/* Everything above is the phone. This is the only place a second layout
   appears. 900px because at 860 the two-pane view leaves the thread too narrow
   to read comfortably — measured, not guessed. */
/* WHICH PANE A PHONE SHOWS — decided here, from what the SERVER already knows.
 *
 *     "all kinds of glitches like the chat feed and the chat list mixed
 *      together"                                   — Julian, 2026-10-22
 *
 * It was decided only by app.js, which sets `display` on the two panes after
 * it loads. That is one decision resting on one script: if anything throws
 * before `layout()` runs, both panes stay at their CSS default — both
 * visible — and a phone shows the chat list with a conversation stacked under
 * it, which is exactly what was photographed.
 *
 * The server has always rendered the answer and nothing read it:
 * `data-narrow-hidden` on the sidebar when a conversation is open, and
 * `data-default` on a thread that was opened FOR you rather than navigated
 * to. Reading them here means the right pane is showing before a line of
 * JavaScript has run, and a script that dies cannot produce the mixture.
 *
 * `layout()` stays for the one case CSS cannot see: a window being resized
 * across the breakpoint. It sets the same two values, so the two never
 * disagree.
 */
@media (max-width: 899px) {
  .sidebar[data-narrow-hidden] { display: none; }
  #pane-thread[data-default="1"] { display: none; }
}

@media (min-width: 900px) {
  :root { --header-h: 60px; }

  .app-body { grid-template-columns: 312px minmax(0, 1fr); }
  /* On a phone the sidebar and the thread are separate pages; on a desktop both
     are always present, so the "which one am I looking at" classes stop
     applying. */
  .sidebar { display: flex !important; }
  .thread, .page { display: flex !important; }

  .site-header { padding: 0 18px; }
  .messages { padding: 18px 26px 10px; }
  .composer { padding: 10px 26px 14px; }
  .thread-head { padding: 10px 22px; }
  .page { padding: 24px 28px 60px; }
  /* THE TABS STAY ON A WIDE SCREEN NOW. They used to be hidden and both panes
     forced visible, which meant a wide screen had no way to give the whole
     width to writing — and writing is what somebody opened the composer for.
     "Mix" is the old behaviour, chosen rather than imposed. */
  .editor.both { grid-template-columns: 1fr 1fr; }
  .editor.both > * { display: block !important; }

  dialog.sheet {
    inset: 0; margin: auto;
    width: min(560px, calc(100% - 48px)); height: fit-content;
    border-radius: 20px; border: 1px solid var(--panel-border);
  }
  .sheet-grip { display: none; }
  .doc-list { grid-template-columns: 1fr 1fr; }
  .back-btn { display: none; }
}

/* Wider still: the thread gets a comfortable measure rather than stretching to
   1900px, which is unreadable. 1320px since 2026-10-08 ("use all the available
   desk space"); a bubble has its own ceiling inside it. */
@media (min-width: 1500px) {
  .messages > .msg, .messages > .day-line { max-width: 1320px; margin-left: auto; margin-right: auto; }
  .msg-col { max-width: min(820px, 84%); }
  .messages > .msg { margin-bottom: 3px; }
}

/* ---- respecting the reader ---- */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* Gabbie is a dark application by design — the mascot is lit against a dark
   backdrop and the palette is built for it. But a forced-colours reader is not
   expressing a taste, they are expressing a need, so borders and text get real
   system colours rather than our translucent ones. */
@media (forced-colors: active) {
  .bubble, .panel, .btn, .conv { border: 1px solid CanvasText; }
  .bg-glow { display: none; }
}

/* ---- print ---- */
@media print {
  .site-header, .sidebar, .composer, .msg-tools, .flashes { display: none !important; }
  body, .app { height: auto; max-height: none; overflow: visible; background: #fff; color: #111; }
  .messages, .page { overflow: visible; }
  .bubble, .panel { border-color: #ccc; background: #fff; }
}

/* ============================================================== light mode
 *
 * A SETTING, NOT A GUESS. Dark is the default because that is what Gabbie has
 * always looked like and what the mascot's palette was drawn for; light is for
 * the people who cannot read the other one comfortably, or who work next to a
 * window. The choice lives on the account, so it follows somebody between their
 * phone and their desk instead of being re-made on every device.
 *
 * ONLY THE SURFACES CHANGE. Orange and blue are the brand and stay exactly as
 * they are — a light theme that also recolours the identity is a different
 * product. What is redefined here is background, panel, text and the frosted
 * bars, which is the whole of what "dark" actually meant.
 *
 * Note the panels invert from white-on-transparent to black-on-transparent: at
 * 4.5% white over a white page a panel is invisible, which is how a light mode
 * ends up looking unfinished.
 */
:root[data-theme="light"] {
  /* Darker, because #a78bfa on white is a pale smear rather than a figure. */
  --accent-okr: #6d28d9;
  --quote-mark: rgba(242, 130, 46, 0.28);
  --bg: #faf9f7;
  --bg-far: #f4f3fa;
  --bg-near: #e6e9f8;

  /* The chat list sits on --panel over --bg. At 3.5% ink that came out as a
     grey darker than the page it is meant to lift off, which read as dimmed
     rather than raised. Barely there is the right amount: the border and the
     shadow do the separating. */
  --panel: rgba(20, 16, 43, 0.018);
  --panel-solid: #ffffff;
  --panel-raised: #ffffff;
  --panel-border: rgba(62, 86, 196, 0.18);
  --panel-hover: rgba(20, 16, 43, 0.06);

  --text: #1b1730;
  --text-dim: #423c5c;
  /* Darker on a light ground, or a lavender label on white is
     a label nobody can read. */
  --violet: #4b3fa8;
  /* A shade darker than the dark theme's equivalent, because the eye reads
     grey-on-white worse than grey-on-black: at #6f6889 a timestamp was 4.97
     against the page, and this is 6.33. Everything quiet — times, hints, the
     narration pills — is this colour. */
  --muted: #5f5878;
  --cream: #2a1400;

  --blue-soft: #3346b8;

  --glass: rgba(250, 249, 247, 0.9);
  --glass-soft: rgba(250, 249, 247, 0.76);
  --glass-firm: rgba(250, 249, 247, 0.94);

  --shadow: 0 10px 30px rgba(30, 26, 60, 0.12);

  /* INK, AND BARELY ANY. Over white, a recess has to be a tint rather than a
     shade: 5% of the text colour is #f3f3f4, which reads as set-back next to a
     white bubble. The old rgba(0,0,0,.2) was #cccccc — grey furniture in the
     middle of a conversation. */
  --sunk: rgba(20, 16, 43, 0.05);
  --sunk-firm: rgba(20, 16, 43, 0.075);
  --sunk-hover: rgba(20, 16, 43, 0.1);

  /* "the colors in the chat list in light mode is a gray that looks ugly. Can
     we make it a much lighter gray?"
     A NEAR-BLACK NAVY AT 40% was what it was: over this page that composites to
     #9c9da3, a mid grey column with the row text at 3.82 against it — ugly, and
     the reason the list was hard to read as well. 3% ink is #f3f2f1: a hint of
     warm grey, set back from the page by a hair, with the border doing the
     separating and the text at 9.2. */
  --rail: rgba(20, 16, 43, 0.03);
  color-scheme: light;
}

/* The glow behind the app is a dark-mode device: two coloured blooms lifting a
   near-black page. On a light one it reads as a stain, so it goes. */
:root[data-theme="light"] .bg-glow { opacity: 0.35; }

/* MY OWN MESSAGES, which are blue.
 *
 * The dark theme's tint is 38% blue over the panel. Over white that composites
 * to #b6bfe9 — a muddy periwinkle that reads as grey, which is the other half
 * of "the chats are still grey". Lighter and cleaner here, with the ordinary
 * text colour on top rather than the near-black brown it used to carry (a
 * leftover from when these bubbles were orange). 13:1 against the text. */
:root[data-theme="light"] .msg.mine .bubble {
  background: linear-gradient(160deg, rgba(62, 86, 196, 0.18), rgba(62, 86, 196, 0.11));
  border-color: rgba(62, 86, 196, 0.28);
  color: var(--text);
}

/* A voice note I sent sits on that same pale blue. Its pill was 13% WARM WHITE,
   which over a near-black bubble is a raised patch and over a pale blue one is
   nothing at all — the pill and its border both disappeared. */
:root[data-theme="light"] .msg.mine .voice-pill {
  background: var(--sunk);
  border-color: var(--panel-border);
}

/* A code block is a 7.5% tint here, which is a shape rather than a slab — so it
   gets an outline to be one. Not in the dark theme, where the shade alone is
   plenty. */
:root[data-theme="light"] .bubble pre,
:root[data-theme="light"] .msg-text pre,
:root[data-theme="light"] .prose pre {
  border: 1px solid var(--panel-border);
}

/* MY OWN REACTION, marked with the same blue as my own bubbles and at the same
   strength here as they are: 30% over white is #c2c8e8, which next to a white
   bubble reads as a dark chip rather than a marked one. */
:root[data-theme="light"] .reaction.mine {
  background: rgba(62, 86, 196, 0.2);
  border-color: rgba(62, 86, 196, 0.45);
  color: var(--text);
}

/* THE STATUS PILLS. Each is a wash of its brand colour with a PALE version of
   that colour as the text — which is right on a dark page and is nothing at all
   on a light one: 1.2 against the background, measured. The wash stays; the
   lettering goes dark. */
:root[data-theme="light"] .pill.orange { color: #8a4508; }
:root[data-theme="light"] .pill.blue { color: #27358f; }
:root[data-theme="light"] .pill.ok { color: #11603f; }

/* Selects and other native controls draw their own popups from color-scheme. */
:root[data-theme="light"] select,
:root[data-theme="light"] .combo { color-scheme: light; }
:root[data-theme="light"] .combo,
:root[data-theme="light"] .combo option { background-color: #ffffff; color: var(--text); }

/* ---- the document composer ---- */

/* The heading and the title occupy the same slot: one is hidden, the other is
   not. A separate row for each would move the editor down by a line the moment a
   title appeared, which is a page jump caused by the composer being helpful. */
.composer-head {
  display: flex; align-items: center; gap: 10px;
  margin-bottom: 12px; min-height: 44px;
}
.composer-head h1 { margin: 0; font-size: 1.5rem; }
#doc-title-input {
  flex: 1; min-width: 0;
  font-size: 1.4rem; font-weight: 700;
  background: transparent; border: 1px solid transparent;
  color: var(--text); padding: 4px 8px; border-radius: 10px;
}
/* Pushed to the far edge whichever of the two occupies the slot, so it does not
   sit against the heading on a new document and against nothing on an old one. */
#doc-done { margin-left: auto; flex: 0 0 auto; }
#doc-title-input:hover { border-color: var(--panel-border); }
#doc-title-input:focus { border-color: var(--panel-border); background: var(--panel); }

/* Under the editor, in the place a markdown cheat-sheet used to occupy. Right
   aligned and a fixed height, so the one line that changes as you type does not
   also move. */
.save-bar {
  display: flex; justify-content: flex-end; align-items: center;
  min-height: 22px; margin: 6px 2px 14px;
}
/* Green when it is saved, grey while it is not. This is the whole of what
   replaced a Save button, so it has to be legible at a glance and never move. */
.save-state {
  font-size: 0.78rem; font-weight: 650;
  color: var(--ok);
  min-width: 62px; text-align: right;
}
.save-state.editing { color: var(--muted); }
.save-state.failed { color: var(--bad); }

/* "Mix" needs two panes' worth of room. On a phone the control is a straight
   toggle between one thing and the other, because a split 390px screen is two
   useless halves. */
.wide-only-tab { display: none; }
@media (min-width: 900px) { .wide-only-tab { display: inline-flex; } }

/* The editor gets the window; the prose-shaped things below it do not. */
.editor textarea { min-height: 58vh; }
@media (min-width: 900px) { .editor textarea { min-height: 62vh; } }

/* THE DECK BUTTONS. Only for a generated presentation, and only next to the
   view controls, because "look at it full screen" is a view control. */
.deck-actions { display: inline-flex; gap: 6px; align-items: center; }

/* A generated deck previews as itself. White ground unconditionally: a
   presentation written for a projector assumes one, and inheriting the app's
   dark panel behind it shows somebody a broken version of their own slides. */
.deck-frame {
  display: block; width: 100%;
  border: 1px solid var(--panel-border);
  border-radius: var(--radius);
  background: #fff; padding: 0;
  min-height: 58vh; height: 58vh;
}
@media (min-width: 900px) { .deck-frame { min-height: 64vh; height: 64vh; } }
/* Inside a panel it provides its own edges. */
.panel > .deck-frame { border: 0; border-radius: 0; }

/* ---- diagrams ---- */

/* A ```mermaid fence, before and after mermaid replaces it with an SVG. Before,
   it is the source; after, it is a picture. Neither wants a code block's grey
   panel and monospace, so it never gets one. */
pre.mermaid {
  background: none; border: 0; padding: 0;
  margin: 14px 0;
  font: inherit; color: var(--muted);
  text-align: center;
  /* A wide diagram scrolls inside its own box. The page must not scroll
     sideways because one flowchart is broad. */
  overflow-x: auto;
}
pre.mermaid svg { max-width: 100%; height: auto; display: inline-block; }

/* It would not parse. The source stays exactly where it is — readable, and
   showing where the mistake is — with a line saying why nothing was drawn.
   Failing silently would leave somebody staring at a code block for no reason. */
pre.mermaid.mermaid-failed {
  text-align: left;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.82rem;
  white-space: pre-wrap;
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text);
}
pre.mermaid.mermaid-failed::before {
  content: "This diagram could not be drawn — the source is below.";
  display: block;
  margin-bottom: 8px;
  /* Explicit, not inherit: inheriting inside a <pre> would set this notice in
     monospace, which reads as part of the source it is describing. */
  font-family: Inter, system-ui, -apple-system, sans-serif;
  font-size: 0.76rem;
  font-weight: 650;
  color: var(--bad);
}

/* ---- a matched word in a search result ---- */

/* The browser's own <mark> is a yellow highlighter, which on a dark panel is a
   shout. Orange at low opacity is the same signal in the app's own voice. */
mark {
  background: rgba(242, 130, 46, 0.26);
  color: inherit;
  border-radius: 3px;
  padding: 0 2px;
  font-weight: 650;
}
:root[data-theme="light"] mark { background: rgba(242, 130, 46, 0.34); }
/* A snippet is two lines of context, so the ellipsis between fragments needs to
   read as a gap rather than as punctuation somebody typed. */
.doc-excerpt mark { font-weight: 700; }

/* ---- @mentions ---- */

/* A mention in a message. A chip, not a link: it names somebody, and making it
   clickable would promise a profile page that does not exist. */
.mention {
  background: rgba(62, 86, 196, 0.26);
  color: var(--blue-bright, #8b9bf0);
  border-radius: 5px;
  padding: 0 3px;
  font-weight: 650;
}
:root[data-theme="light"] .mention {
  background: rgba(62, 86, 196, 0.14);
  color: #2f42a8;
}

/* The suggestion list, ABOVE the composer. Below it would be under the
   keyboard on a phone, which is the only place a list like this is ever read. */
.composer { position: relative; }
/* "Ana is typing…" — hangs above the composer from the same positioned box as
   the @mention list, so it never moves the thread. Layout is set inline by
   app.js (showTyping); this is only how it looks. */
.typing-line {
  padding: 3px 16px; font-size: .74rem; font-style: italic;
  color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mention-list {
  position: absolute;
  left: 10px; right: 10px; bottom: calc(100% - 4px);
  z-index: 60;
  background: var(--panel-solid);
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  box-shadow: var(--shadow);
  overflow: hidden;
  max-height: 46vh; overflow-y: auto;
}
.mention-row {
  display: flex; align-items: baseline; gap: 8px;
  width: 100%; text-align: left;
  padding: 9px 12px;
  background: none; border: 0; color: var(--text);
  font: inherit; cursor: pointer;
}

/* THE EMOJI IN A SUGGESTION ROW. Big enough to recognise at a glance — the
   whole point of the list is that you pick a face without reading — and the
   face you typed shown on the right where a mention shows its handle, so the
   shortcut teaches itself. */
.emoji-glyph { font-size: 1.15rem; line-height: 1; margin-right: 2px; }
.emoji-list .mention-handle { font-family: var(--mono); }

.mention-row.on, .mention-row:hover { background: var(--panel-hover); }

/* FOLLOWING AN EDIT BOX instead of sitting above the composer. The composer is
   pinned to the bottom of the screen and the list has nowhere else to go; an
   edit box is somewhere in the middle of a scrolling thread, so the list is
   placed against it by app.js — see placeMentions. */
.mention-list.floating {
  position: fixed;
  left: auto; right: auto; bottom: auto;
  z-index: 90;
}
.mention-name { font-weight: 650; }
.mention-handle { color: var(--muted); font-size: 0.82rem; }

/* See the note in .doc-excerpt: in a search result the excerpt is the answer,
   not a hint, so it gets a third line to reach the matched word on a phone. */
.result .doc-excerpt { -webkit-line-clamp: 3; }

/* ---- a document's source ---- */

/* The markdown behind a rendered document. A panel rather than a bare <pre>, so
   it sits in the page the same way the rendered form does and the switch between
   them does not move everything else. */
.doc-source {
  white-space: pre-wrap;
  word-break: break-word;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.84rem;
  line-height: 1.55;
  max-height: 70vh;
  overflow: auto;
  margin: 0;
  tab-size: 4;
}

/* ---- who you are talking to, in the top bar ---- */

/* THE CONVERSATION IS NAMED IN ITS OWN HEADER, at every width, beside its
   picture — see .thread-title. This bar carried the name on a phone for a
   while and the thread header was hiding its own copy to make room; what that
   left next to the avatar was the topic, or a list of members, and never the
   group's name. The header of a thing should say what the thing is.

   The thread header cannot scroll away — .app is a fixed-height grid and the
   message list is the only thing that moves — so the name is permanently on
   screen there, which is what the top bar was borrowed for. It goes back to
   being the wordmark. */

/* An icon sitting in a line of text — the reply bar, "Forwarded from" — wants to
   be the size of the words it is in, not the size of a button's icon. */
.reply-bar .ico-svg, .fwd-mark .ico-svg {
  width: 15px; height: 15px;
  flex: 0 0 auto;
}

/* ---- a row in a document list ---- */

/* The card, plus its own ⋮. The menu is a SIBLING of the link and not inside it:
   an <a> may not contain a button or a form, and a browser closes the anchor
   early rather than nesting them — which is how a card editor once lost its save
   button. */
.doc-row { display: flex; align-items: flex-start; gap: 4px; }
.doc-row .doc-card { flex: 1; min-width: 0; }
/* Visible on hover on a desktop, always on a touch screen — there is no hover
   there, and an action you cannot reveal is an action you do not have. */
.doc-menu { opacity: 0; transition: opacity .12s ease; flex: 0 0 auto; }
.doc-row:hover .doc-menu, .doc-menu[open] { opacity: 1; }
@media (hover: none) { .doc-menu { opacity: 1; } }
.doc-menu > summary { margin-top: 8px; }

/* A button that reads as a link, for an action inside a sentence — "Hear it"
   beside the sound setting. A real <button> because it does something rather
   than going somewhere, and an <a href="#"> that does something is a link that
   lies to a screen reader. */
.link-btn {
  border: 0; background: none; padding: 0;
  font: inherit; color: var(--blue-bright, #8b9bf0);
  text-decoration: underline; cursor: pointer;
}
.link-btn:hover { color: var(--text); }

/* ------------------------------------------------------------------ polls
 *
 * Drawn inside a message bubble, under the question. The bar is a sibling
 * behind the label rather than a background on the button, so it can grow
 * without dragging the text with it — a label that slides while you read it is
 * the reason animated progress bars usually get turned off.
 */
.poll { margin-top: 8px; display: flex; flex-direction: column; gap: 6px; }

.poll-head { display: flex; align-items: center; gap: 8px; }
.poll-kind { font-size: .74rem; color: var(--muted); text-transform: uppercase;
             letter-spacing: .04em; }

.poll-option {
  position: relative;
  display: flex; align-items: center; gap: 8px;
  width: 100%;
  padding: 9px 11px;
  border: 1px solid var(--panel-border);
  border-radius: 10px;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  overflow: hidden;
  /* The whole row is the target. A radio dot is a four-millimetre thing to hit
   * on a phone, and this is a chat people use one-handed. */
  min-height: 40px;
}
.poll-option:hover:not(:disabled) { background: var(--panel-hover); }
.poll-option:disabled { cursor: default; opacity: .85; }
.poll-option.mine { border-color: var(--blue-soft); }

.poll-bar {
  position: absolute; left: 0; top: 0; bottom: 0;
  background: var(--grad-cool);
  opacity: .22;
  border-radius: 9px;
  transition: width .28s ease;
  /* Behind the label, and never a click target of its own. */
  pointer-events: none;
}
.poll-option.mine .poll-bar { opacity: .34; }

.poll-label { position: relative; display: flex; align-items: center; gap: 7px;
              min-width: 0; flex: 1; }
.poll-text { overflow-wrap: anywhere; }
.poll-tick { width: 1em; flex: none; color: var(--blue-soft); font-weight: 700; }
.poll-count { position: relative; flex: none; font-size: .8rem;
              font-variant-numeric: tabular-nums; }

.poll-foot { display: flex; align-items: center; gap: 10px; margin-top: 2px; }
.poll.closed .poll-option { border-style: dashed; }

/* The composer's poll sheet: a stack of plain inputs. */
#poll-form input[type="text"] { width: 100%; margin-bottom: 6px; }
#poll-question { font-weight: 600; }

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

/* --------------------------------------------------------------- diagrams
 *
 * The editor fills what is left of the page under the title. A fixed height
 * would either waste a laptop screen or make a phone unusable, and draw.io
 * needs a real box: it lays out its own panels against the frame's height, so a
 * frame that is `height:auto` collapses to nothing.
 */
.diagram-editor {
  position: relative;
  height: calc(100dvh - 140px);
  min-height: 380px;
  border: 1px solid var(--panel-border);
  border-radius: 12px;
  overflow: hidden;
  background: var(--panel-solid);
}
.diagram-editor iframe { width: 100%; height: 100%; border: 0; display: block; }
.diagram-loading {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
}

/* A saved diagram, wherever it is shown: the document page, a chat bubble, the
 * library list. Always an <img> of the stored SVG — never inline, never a live
 * renderer. See library.views.diagram_svg. */
.diagram-view {
  display: block;
  max-width: 100%;
  height: auto;
  border: 1px solid var(--panel-border);
  border-radius: 10px;
  /* Diagrams are drawn on white and stay legible on it in either theme. A
   * transparent SVG over a dark panel loses every black stroke in it. */
  background: #fff;
  padding: 8px;
}
.diagram-empty {
  padding: 18px; text-align: center;
  border: 1px dashed var(--panel-border); border-radius: 10px;
}

/* ------------------------------------------------------- folders, uploads */
.folder-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
              margin-bottom: 10px; }
.crumbs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap;
          font-size: .88rem; }
.crumbs a { color: var(--text-dim); text-decoration: none; }
.crumbs a:hover { text-decoration: underline; }
.crumbs a.on { color: var(--text); font-weight: 600; }

/* =========================================================== the file list
 *
 * "Directories should be folder viewed from the front, not in diagonal, and
 * should have the folder name next to it, but shouldn't be inside a
 * button/pill. Just the folder with its name as in a regular file manager."
 *
 * So the card is gone: no border, no fill, no pill. A folder or a page in line
 * art, the name beside it, and a hover that lifts the whole row the way a file
 * manager's does.
 *
 * FOLDERS AND FILES ARE THE SAME KIND OF CELL. They were two containers with
 * two shapes, which is why they could not line up; now one grid holds both, in
 * order, and each cell is the same size whichever it holds.
 *
 * TWO SHAPES, ONE MARKUP. Every cell always carries the date, the author and
 * the size; the icon view hides them. So the toggle is a class on the
 * container — no round trip, and nothing to keep in step server-side.
 */
.files { margin-bottom: 14px; }
.file { display: flex; align-items: center; min-width: 0; }
.file-open {
  display: grid; align-items: center; gap: 10px;
  flex: 1; min-width: 0;
  padding: 7px 10px; border-radius: 10px;
  color: var(--text); text-decoration: none;
}
.file-open:hover { background: var(--panel-hover); }
.file-ico { display: flex; color: var(--blue-soft); flex: none; }
.file-ico .ico-svg { width: 22px; height: 22px; }
/* A folder is the thing being organised, a page is a thing in it: the folder
   reads first. Up-and-out is quieter still — it is a way back, not content. */
.file-open[data-drop-folder] .file-ico { color: var(--orange); }
/* Written to outweigh the rule above, which also matches this cell — up-and-out
   carries data-drop-folder because it is where you drop something to take it
   OUT of a folder. */
.file-open[data-drop-folder] .file-ico.up { color: var(--muted); }
.file-name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-weight: 600; font-size: 0.94rem;
}
.file-when, .file-who, .file-size {
  color: var(--muted); font-size: 0.8rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.file-menu > summary { opacity: 0; }
.file:hover .file-menu > summary,
.file-menu[open] > summary { opacity: 1; }
/* A finger has no hover, so the ⋮ is simply always there. */
@media (hover: none) {
  .file-menu > summary { opacity: 1; }
}

/* ---- as a list: name, then attributes in columns ----
 *
 * "in list view, columns for folders dont line up with columns for files."
 *
 * THE ⋮ WAS THE WHOLE OF IT. A file has one and a folder does not, and it sat
 * beside the row inside a flex container — so a file's row was thirty pixels
 * narrower than a folder's, the 1fr name column absorbed the difference, and
 * every column to the right of it shifted. The columns were right; the rows
 * they were in were not the same width.
 *
 * So the row reserves the slot whether or not there is a menu to put in it, and
 * the icon column is a fixed width rather than sized by what is in it. Each row
 * is still its own grid — which is fine once every row is the same width, and
 * subgrid is not needed to say so.
 */
.files.as-list .file {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 30px;
  align-items: center;
}
.files.as-list .file-open {
  grid-template-columns: 24px minmax(0, 1fr) 92px 110px 84px;
}
.files.as-list .file { border-bottom: 1px solid var(--panel-border); }
.files.as-list .file:last-child { border-bottom: 0; }
.files.as-list .file-size { text-align: right; }
/* Narrow enough and the columns stop being columns: a date squeezed to four
   characters is worse than no date. */
@media (max-width: 720px) {
  .files.as-list .file-open { grid-template-columns: 24px minmax(0, 1fr) 92px; }
  .files.as-list .file-who, .files.as-list .file-size { display: none; }
}
@media (max-width: 460px) {
  .files.as-list .file-open { grid-template-columns: 24px minmax(0, 1fr); }
  .files.as-list .file-when { display: none; }
}

/* ---- as icons: the name and nothing else ---- */
.files.as-icons {
  display: grid; gap: 4px;
  grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
  /* Cells to the TOP of their row rather than stretched or centred in it — see
     the note below the grid. */
  align-items: start;
}
/* "in icon view, files and folders should be aligned to top within their space,
 * so that longer or shorter file/folder names dont push the icon up."
 *
 * Grid rows are as tall as their tallest cell, and everything in a cell was
 * CENTRED in it — so a two-line name made its own cell taller and pushed every
 * one-line neighbour's icon down to the middle of the row. The icons are what
 * the eye scans, and they were on three different lines.
 *
 * Aligned to the top at both levels: the cell to the top of the grid row, and
 * the icon and name to the top of the cell. Names then hang below a straight
 * line of icons however long they are. */
.files.as-icons .file { position: relative; align-items: start; }
.files.as-icons .file-open {
  grid-template-columns: 1fr;
  align-content: start;
  justify-items: center; text-align: center;
  gap: 6px; padding: 12px 8px;
}
.files.as-icons .file-ico .ico-svg { width: 38px; height: 38px; }
.files.as-icons .file-name {
  /* Two lines and then an ellipsis: a long name in a narrow cell is the normal
     case, and truncating it to one line loses the part that identifies it. */
  white-space: normal; display: -webkit-box; -webkit-line-clamp: 2;
  -webkit-box-orient: vertical; font-weight: 500; font-size: 0.86rem;
  overflow-wrap: anywhere;
}
.files.as-icons .file-when,
.files.as-icons .file-who,
.files.as-icons .file-size { display: none; }
.files.as-icons .file-menu { position: absolute; top: 2px; right: 2px; }

/* The two shape buttons. The one you are looking at is held down. */
.viewmode { display: flex; gap: 2px; flex: none; }
.viewmode .icon-btn[aria-pressed="true"] {
  background: var(--panel-hover); color: var(--text);
}

.upload-sheet .sheet-inner { max-width: 640px; }
.upload-rows { display: flex; flex-direction: column; gap: 10px;
               max-height: 46vh; overflow-y: auto; }
.upload-row {
  display: flex; gap: 10px; align-items: flex-start;
  padding: 10px; border: 1px solid var(--panel-border); border-radius: 10px;
}
.upload-thumb {
  width: 56px; height: 56px; flex: none;
  display: flex; align-items: center; justify-content: center;
  font-size: 1.5rem;
  border-radius: 8px; overflow: hidden; background: var(--panel-hover);
}
.upload-thumb img { width: 100%; height: 100%; object-fit: cover; }
.upload-fields { display: flex; flex-direction: column; gap: 5px;
                 min-width: 0; flex: 1; }
.upload-fields input { width: 100%; }

.upload-progress { display: flex; align-items: center; gap: 10px;
                   margin-top: 12px; }
.upload-bar {
  flex: 1; height: 8px; border-radius: 999px; overflow: hidden;
  background: var(--panel-hover);
}
.upload-bar span {
  display: block; height: 100%; width: 0;
  background: var(--grad-cool);
  transition: width .15s linear;
}
@media (prefers-reduced-motion: reduce) { .upload-bar span { transition: none; } }

/* ------------------------------------- markdown INSIDE a chat message
 *
 * Reported: long messages overflow the screen on mobile.
 *
 * `.bubble` already carries `overflow-wrap: anywhere`, which handles a pasted
 * 400-character URL — and that is why this looked fixed. It is not what
 * overflows. A chat message is MARKDOWN, rendered by the same renderer as a
 * document, so it can contain a fenced code block, a table, or a long inline
 * `code` token — and none of those obey overflow-wrap:
 *
 *    <pre>   is `white-space: pre` by default and does not wrap at all
 *    <table> sizes itself to its widest cell
 *    <code>  is a single unbroken token as far as the wrapper is concerned
 *
 * The rules that deal with all three already existed, on `.prose` — the
 * document body — and a chat message is `.msg-text`, so it got none of them.
 * A wide element inside a flex row then pushed the whole feed wider than the
 * viewport, which on a phone is the entire screen sliding sideways.
 *
 * Each scrolls INSIDE ITSELF rather than being clipped: a table nobody can
 * reach the right-hand column of is not better than one that overflowed.
 */
.msg-text pre {
  background: var(--sunk-firm); border-radius: var(--radius-sm);
  padding: 10px 12px; overflow-x: auto; font-size: 0.84rem;
  /* The one that actually stops the page moving: without a max-width a <pre>
   * in a flex item is sized by its content, and `overflow-x` never engages. */
  max-width: 100%;
}
.msg-text code { font-family: var(--mono); }
.msg-text :not(pre) > code {
  background: var(--sunk-firm); padding: 1px 5px; border-radius: 5px;
  /* An inline token IS breakable here. In a document it can push a line wide
   * and be scrolled to; in a 340px bubble it has to fold. */
  overflow-wrap: anywhere;
}
.msg-text table {
  border-collapse: collapse; display: block;
  max-width: 100%; overflow-x: auto;
}
.msg-text th, .msg-text td {
  border: 1px solid var(--panel-border); padding: 5px 8px; text-align: left;
}
.msg-text th { background: var(--panel); }
.msg-text blockquote {
  margin: .6em 0; padding: 2px 0 2px 10px;
  border-left: 3px solid var(--blue); color: var(--text-dim);
}
.msg-text img { max-width: 100%; height: auto; border-radius: var(--radius-sm); }
/* mermaid draws an SVG at whatever size the diagram wants. */
.msg-text pre.mermaid, .msg-text svg { max-width: 100%; }

/* And the belt to those braces: nothing in a bubble may make the column wider
 * than the column. A single rule that does not depend on having anticipated
 * which element it was.
 *
 * EXCEPT REPLACED ELEMENTS, which size themselves and say so. This rule has the
 * same specificity as `.sticker-img` and `.gif-view` and is declared after them,
 * so without the exclusion it silently replaced their deliberate caps with
 * `100%` — a sticker kept its size only because its max-HEIGHT survived, which
 * is luck rather than design. */
.bubble > *:not(img):not(video) { max-width: 100%; }
.msg-col, .bubble { min-width: 0; }

/* ------------------------------------------------------------------- GIFs
 *
 * A short silent clip plays itself on a loop. It is sized like an image, not
 * like a video player, because that is what it is standing in for.
 */
/* A DEFINITE max-width, not min(280px, 100%).
 *
 * Reported as "GIFs look centered instead of left/right justified", and that is
 * what a percentage does here. While a browser works out how wide `.msg-col`
 * wants to be, a PERCENTAGE max-width is treated as none — so the column asked
 * for the clip's real width (848px for the seeded ones), hit its own
 * `max-width: 84%`, and became a very wide column with a 280px video at the left
 * of it. `.msg.mine .msg-col { margin-left: auto }` then has nothing left to
 * push, so an own message stops looking right-aligned and a wide near-empty
 * column reads as centred.
 *
 * A length is definite at intrinsic-sizing time: the column asks for 280px, gets
 * 280px, and the existing alignment works exactly as it does for text. */
.gif-view {
  display: block;
  max-width: 280px;
  height: auto;
  border-radius: var(--radius-sm);
  background: #000;
}
.video-view {
  display: block;
  max-width: 360px;
  height: auto;
  border-radius: var(--radius-sm);
  background: #000;
}
/* On a phone those lengths can exceed the bubble. Here the percentage is the
   right answer and costs nothing: the column is already capped at 84% and a
   message is near-full-width anyway, so there is no alignment left to lose. */
@media (max-width: 520px) {
  .gif-view, .video-view { max-width: 100%; }
}
/* The Save button sits over the clip's corner rather than under it: a row of
 * buttons beneath every GIF in a busy thread is more furniture than content. */
.gif-wrap { position: relative; display: inline-block; max-width: 100%; }
.gif-save {
  position: absolute; right: 6px; bottom: 6px;
  opacity: 0; transition: opacity .12s ease;
}
.gif-wrap:hover .gif-save, .gif-save:focus-visible { opacity: 1; }
/* On a touch screen there is no hover, so it is simply always there. */
@media (hover: none) { .gif-save { opacity: .92; } }

/* QUICK DOWNLOAD ON AN ATTACHMENT.
 *
 *     "Please add a quick download option to attachments shown in chats, so
 *      users can download a file without opening it first."
 *                                                    — Lucila, 2026-10-05
 *
 * A small violet arrow on every uploaded file in the feed: over the corner of a
 * picture or a GIF, beside a file's chip, beside a .md's name. It is a SIBLING
 * of the link that opens the attachment, never inside it, so pressing it cannot
 * also open the file — there is no propagation to stop. Always visible: there
 * is no hover on a phone, and one look on every screen is simpler than two.
 * The link is /d/<pk>/download?save=1, the same one the video's Download uses,
 * which serves `attachment` with the original filename. */
.attach-wrap { position: relative; display: inline-block; max-width: 100%; vertical-align: top; }

/* A BIGGER PICTURE ON A BIG SCREEN — "a bigger preview, specially in large
   displays" (Julian). The markup states the width the 320x340 box gave it as
   --w; here it is half as big again, with the height following the ratio, so
   the box reserved before the picture arrives is still exactly its box. The
   thumbnail is 720px, which 480 stays within. */
@media (min-width: 1200px) {
  #messages .attach-img[style*="--w"] {
    width: calc(var(--w) * 1.5); max-width: min(480px, 100%); max-height: 510px;
  }
}

/* The picture viewer (pictureViewer in app.js): the picture as large as the
   window allows, a bar with what it is and what can be done with it. */
.pic-viewer {
  width: 100vw; height: 100dvh; max-width: none; max-height: none;
  /* Black in both themes, as the document page's .photoview is: a picture is
     looked at against black, not against the page's own colour. Opaque, and
     covering the window, so no ::backdrop is needed. */
  margin: 0; padding: 0; border: 0; background: #000;
  color: #fff; overflow: hidden;
}
.pic-viewer[open] { display: grid; grid-template-rows: auto 1fr; }
.pic-bar {
  display: flex; align-items: center; gap: 8px;
  padding: calc(8px + var(--safe-top, 0px)) 12px 8px;
}
.pic-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
             white-space: nowrap; font-size: .9rem; opacity: .85; }
.pic-count { font-size: .8rem; opacity: .7; font-variant-numeric: tabular-nums; }
.pic-close { font-size: 1.5rem; color: #fff; }
.pic-stage {
  grid-row: 2; min-height: 0; overflow: hidden;
  display: grid; place-items: center;
}
.pic-img {
  max-width: 96vw; max-height: calc(100dvh - 70px); object-fit: contain;
  border-radius: 6px; cursor: zoom-in;
}
/* A tap on the picture: its real size, in a box that scrolls (a finger pans
   it, which the Android WebView does where it cannot pinch). */
.pic-stage.zoomed { overflow: auto; place-items: start; -webkit-overflow-scrolling: touch; }
.pic-stage.zoomed .pic-img { max-width: none; max-height: none; cursor: zoom-out; }
.pic-step {
  position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
  width: 44px; height: 64px; border: 0; border-radius: 10px;
  background: rgba(255, 255, 255, 0.08); color: #fff; font-size: 2rem; cursor: pointer;
}
.pic-step:hover { background: rgba(255, 255, 255, 0.16); }
.pic-step.prev { left: 10px; }
.pic-step.next { right: 10px; }
.attach-wrap.row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.attach-wrap.row > .attach { flex: 1 1 auto; min-width: 0; margin-top: 0; }
.attach-get {
  display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto;
  width: 30px; height: 30px; border-radius: 50%;
  color: var(--violet);
  background: color-mix(in srgb, var(--violet) 16%, var(--panel-solid));
  border: 1px solid color-mix(in srgb, var(--violet) 40%, transparent);
}
.attach-get .ico-svg { width: 16px; height: 16px; }
.attach-get:hover, .attach-get:focus-visible {
  color: var(--text); outline: none;
  background: color-mix(in srgb, var(--violet) 30%, var(--panel-solid));
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--violet) 55%, transparent);
}
/* Over a picture: the image is display:block with a 6px top margin. */
.attach-get.over { position: absolute; top: 12px; right: 6px;
                   box-shadow: 0 2px 8px rgba(0, 0, 0, .35); }
.gif-wrap > .attach-get.over { top: 6px; }
.md-attach-head { display: flex; align-items: center; gap: 8px; }
.md-attach-head .attach-get { width: 26px; height: 26px; }
@media (prefers-reduced-motion: reduce) { .gif-save { transition: none; } }

.gif-grid {
  display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  max-height: 52vh; overflow-y: auto;
}
.gif-cell {
  position: relative; padding: 0; border: 1px solid var(--panel-border);
  border-radius: 10px; overflow: hidden; background: #000; cursor: pointer;
  aspect-ratio: 1 / 1;
}
.gif-cell video { width: 100%; height: 100%; object-fit: cover; display: block; }
.gif-cell:hover { border-color: var(--blue-soft); }
.gif-empty { padding: 20px; text-align: center; }

/* The composer's paperclip is a menu now, so its summary needs the same box the
 * direct icon buttons have — the `.compose-tools > .icon-btn` rule cannot reach
 * it, because the flex child is the <details> and the button is inside it. */
.compose-tools > .menu { flex: 0 0 38px; }
.compose-tools > .menu > summary.icon-btn {
  width: 38px; height: 38px;
  display: inline-flex; align-items: center; justify-content: center;
}

/* ------------------------------------------------------- playing a voice note
 *
 * A row, not a player: a round button and the time left. The browser's own
 * <audio controls> is a full-width grey bar with a scrubber, a volume slider and
 * a download menu, which around four seconds of sound is all furniture.
 */
.voice-play {
  display: inline-flex; align-items: center; gap: 9px;
  margin-top: 7px;
}
.voice-btn {
  flex: 0 0 auto;
  width: 34px; height: 34px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  border: 1px solid var(--panel-border);
  background: var(--panel-hover);
  color: var(--text);
  cursor: pointer;
  padding: 0;
}
.voice-btn:hover { border-color: var(--blue-soft); }
.voice-btn .ico-svg { width: 16px; height: 16px; }
/* The triangle is visually left-heavy inside a circle; a pixel of optical
   centring is the difference between "a button" and "a button, slightly wrong". */
.voice-play:not(.playing) .voice-btn .ico-svg { margin-left: 2px; }

.voice-left {
  font-size: .8rem; color: var(--muted);
  font-variant-numeric: tabular-nums;
  min-width: 3.2em;
}
.voice-play.playing .voice-left { color: var(--text-dim); }


/* The rule between "people and groups" and everything else in search. Two kinds
   of answer to one word; without a line they read as one badly sorted list. */
.search-sep { border: 0; border-top: 1px solid var(--panel-border); margin: 14px 0; }
.s-sep {
  padding: 6px 12px 4px; font-size: .72rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: .04em;
  border-top: 1px solid var(--panel-border); margin-top: 4px;
}

/* "Saved" beside a setting that has just saved itself. Quiet: it confirms
   something the person already decided, and a green banner for choosing a
   timezone is a celebration nobody asked for. */
.pref-saved { font-size: .8rem; color: var(--ok); margin-left: 2px; }
.pref-saved.bad { color: var(--bad); }

/* Filing a document by dragging it onto a folder. */
.doc-card[draggable="true"] { cursor: grab; }
.doc-card.dragging { opacity: .45; }
/* The folder says it will take it. A dashed ring rather than a fill: the card
   still has to be readable while something is hovering over it. */
/* WHERE THE DOCUMENT WOULD LAND. An outline, because the cell has no border of
   its own to change — see the file list above. */
[data-drop-folder].drop-over {
  outline: 2px dashed var(--blue-soft);
  outline-offset: -2px;
  border-radius: 10px;
  background: var(--panel-hover);
}
.doc-menu select { max-width: 190px; }

/* The shortcut letter inside an action row. Bold and nothing else: it marks the
   key without adding a character, and it is silent to a screen reader, which
   read "(D)elete" aloud as punctuation.
 *
 * THE LABEL IS WRAPPED IN A SPAN, and that is not tidiness. `.action-row` is a
 * flex container with a 13px gap, so a bare <b> becomes a flex ITEM and the rest
 * of the word becomes a second, anonymous one — with the gap between them. It
 * rendered as "C opy". One element, one item, no gap. */
.action-row b { font-weight: 800; }
.action-row > span { min-width: 0; }

/* Your own sticker collections, marked so somebody else's do not read as yours.
   A weight rather than a colour: the tabs are already a row of buttons and a
   second colour in there is a legend to learn. */
.sticker-tab.own { font-weight: 700; }


/* THE CARD A MESSAGE WAS SAID IN, in the group's main feed. A link, because the
   question it raises — "what is that card?" — should be one tap and not a hunt
   through the card list. Sized to the meta line it sits in, and it shortens
   rather than pushing the timestamp out of a phone-width bubble. */

/* The newest line in a card, in the card list. Dimmer than the card's own text
   and on its own row: the text is what the card IS, this is what is happening
   to it, and the two run together if they share a line. */
.card-said {
  display: block; color: var(--text-dim); font-size: 0.8rem; margin-top: 2px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-said b { font-weight: 650; color: var(--muted); }

/* ============================================================== voice calls */

/* THE PHONE ON A CHAT ROW. Only on direct messages, and only when the row is
   under the pointer or the button has focus: the list is for reading, and a
   phone icon on every line turns it into a keypad. Always visible to a keyboard,
   which cannot hover. */
.conv-row { position: relative; }
/* HOLDING A ROW OPENS ITS MENU (holdAChat in app.js), so the phone must not
   take the press first: on an iPhone it opened Safari's link preview and the
   menu never came. And no text selection on the way. */
.conv-row, .conv-row a { -webkit-touch-callout: none; -webkit-user-select: none;
                         user-select: none; }
/* ALWAYS THERE, not on hover. Hiding it until the pointer arrives made it a
   feature you had to already know about — and on a touch screen there is no
   hover at all, so on a phone it was reachable only by accident. It is quiet
   rather than hidden: dim until you go near it. */
/* GROUPS OF CHATS in the list — see chatGroups() in app.js and ChatFolder.
   The header is a row like any other with a caret; the ⋮ sits over its right
   end the way the call button sits over a DM's; the chats inside are indented
   by a rule so it is plain which group they belong to. */
.conv-group-head { cursor: pointer; user-select: none; padding-right: 44px; }
.conv-group-head .conv-title { font-weight: 600; }
.conv-group-icon { font-size: 15px; }
.conv-group-caret { color: var(--muted); font-size: .8rem; transition: transform .15s ease; }
.conv-group.collapsed .conv-group-caret { transform: rotate(-90deg); }
.conv-group-head .conv-badge + .conv-group-caret { margin-left: 2px; }
.conv-group-more { position: absolute; right: 6px; top: 6px; z-index: 3; }
.conv-group-more > summary { width: 30px; height: 30px; display: grid; place-items: center;
                             color: var(--muted); }
.conv-group-more .menu-panel { min-width: 130px; }
.conv-group-body { margin-left: 14px; padding-left: 4px;
                   border-left: 2px solid var(--panel-border); }
/* The picker. */
#chat-group-sheet .cg-list { max-height: 48vh; overflow-y: auto; }
#chat-group-sheet .cg-head { font-size: .72rem; text-transform: uppercase; letter-spacing: .04em;
                             color: var(--muted); margin: 10px 4px 2px; }
#chat-group-sheet .cg-item { gap: 10px; }

.conv-call {
  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
  width: 32px; height: 32px; padding: 0;
  display: grid; place-items: center;
  border: 1px solid var(--panel-border); border-radius: 50%;
  background: var(--panel-solid); color: var(--muted); cursor: pointer;
  opacity: 0.75; z-index: 2;
}
.conv-row:hover .conv-call, .conv-call:focus-visible { opacity: 1; }
.conv-call:hover { color: var(--ok); border-color: var(--ok); opacity: 1; }
.conv-call svg { width: 15px; height: 15px; }
/* And the row's own text stops short of it, so a long name does not run
   underneath the button. */
.conv-row:has(.conv-call) .conv { padding-right: 46px; }

/* THE CALL PANEL. Fixed and above everything, because the call does not belong
   to the page under it: navigating tears the connection down, so hanging up has
   to be one tap from wherever you are. */
/* IN THE MIDDLE OF THE SCREEN, at every size.
   
   It started in a corner, on the reasoning that a call should not cover what
   you were doing. That was wrong twice: on a wide monitor the corner is where
   nobody is looking — reported as "way off the screen, I can barely see it" —
   and a ringing telephone IS the thing you are doing. Every phone on earth puts
   an incoming call in the middle, and this is the one interruption that has
   earned it.

   Centred with a transform rather than flex, so nothing about the page under it
   matters, and translate(-50%,-50%) is exact at any width without a media
   query. */
/* ---- a call on the page ----
 *
 * "Rather than a model screen, when I am calling or talking to someone, what we
 * should have is instead a banner on top so that during the call I could move
 * to other teams and work as I am talking without losing the connection."
 *
 * TWO SHAPES, AND THE STATE DECIDES WHICH.
 *
 *   A RINGING PHONE IS A QUESTION and it stays a panel: somebody has to answer
 *   or decline, and a dimmed page is how a question gets answered rather than
 *   scrolled past. This is the one case where being in the way is the point.
 *
 *   EVERYTHING ELSE IS A BANNER — an outgoing call waiting to be picked up, and
 *   a call in progress. Slim, at the very top, above the header, out of the way
 *   of the work. `body.has-call` pays for its height so nothing is covered.
 */
.callbox {
  position: fixed; z-index: 300;
  left: 0; right: 0; top: 0;
  display: flex; align-items: center; gap: 12px;
  padding: 8px 14px;
  background: var(--panel-solid);
  border-bottom: 1px solid var(--panel-border);
  box-shadow: 0 2px 14px rgba(0, 0, 0, .28);
  text-align: left;
}
body.has-call { padding-top: 52px; }
body.has-call .site-header { top: 52px; }

.callbox .avatar { width: 30px; height: 30px; font-size: 0.8rem; flex: none; }
.callbox-who { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
.callbox-main { min-width: 0; display: flex; align-items: baseline; gap: 10px;
                flex-wrap: wrap; }
.callbox-name {
  font-weight: 650; font-size: 0.92rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.callbox-state { color: var(--muted); font-size: 0.82rem;
                 font-variant-numeric: tabular-nums; }
.callbox-acts { display: flex; gap: 8px; flex: none; }

/* ---- and the panel, for a phone that is actually ringing at you ----
 *
 *     "Si estan en el app les debe saltar el modal con la llamada entrante."
 *                                                    — Sam, 2026-10-06
 *
 * It was a 340px card over a lightly dimmed page, which is the size of a
 * confirmation dialog — and a telephone is not asking whether you are sure. It
 * is now the size of the thing it is: a face, a name, and two buttons you can
 * hit without aiming, over a page that has plainly stopped. */
.callbox.deciding {
  left: 50%; right: auto; top: 50%; transform: translate(-50%, -50%);
  width: min(420px, calc(100vw - 28px));
  flex-direction: column; gap: 20px; padding: 30px 26px;
  border: 1px solid var(--panel-border); border-radius: 20px;
  box-shadow: 0 24px 70px rgba(0, 0, 0, .55); text-align: center;
}
/* A dimmed page behind it, so the eye goes to the call and a stray click does
   not land on the thread underneath. Drawn by the panel itself rather than as a
   second element for app.js to remember to show and hide. */
.callbox.deciding::before {
  content: ""; position: fixed; inset: 0; z-index: -1;
  background: rgba(6, 8, 16, 0.76);
  -webkit-backdrop-filter: blur(5px); backdrop-filter: blur(5px);
}
.callbox.deciding .avatar { width: 92px; height: 92px; font-size: 2rem; }
.callbox.deciding .callbox-who { flex-direction: column; align-items: center; gap: 16px; }
.callbox.deciding .callbox-main { flex-direction: column; align-items: center; gap: 4px; }
.callbox.deciding .callbox-name { font-size: 1.35rem; }
.callbox.deciding .callbox-state { font-size: 0.95rem; }
.callbox.deciding .callbox-acts { width: 100%; gap: 12px; }
.callbox.deciding .callbox-acts .btn {
  flex: 1; justify-content: center; padding: 13px 10px; font-size: 0.98rem;
}
/* A FACE BIG ENOUGH TO RECOGNISE FROM ACROSS A DESK. The avatar's own `img`
   rule pins it inside its box; without this the 92px box holds a 30px face. */
.callbox.deciding .avatar .avatar-img { width: 100%; height: 100%; }
/* The whole card breathes, not just its border — the panel has to be visible
   in the corner of an eye that is on another window. */
.callbox.deciding { animation: callring 1.6s ease-in-out infinite; }
@keyframes callring {
  0%, 100% { box-shadow: 0 24px 70px rgba(0, 0, 0, .55),
                         0 0 0 0 rgba(53, 200, 138, .40); }
  50%      { box-shadow: 0 24px 70px rgba(0, 0, 0, .55),
                         0 0 0 14px rgba(53, 200, 138, 0); }
}
@media (prefers-reduced-motion: reduce) {
  .callbox.deciding { animation: none;
    box-shadow: 0 24px 70px rgba(0, 0, 0, .55), 0 0 0 3px rgba(53, 200, 138, .6); }
}

/* WHAT THE BANNER SAYS ABOUT LEAVING. A call cannot survive a page load — the
   connection belongs to this document — so while one is up, links open in a new
   tab instead. Said once, quietly, rather than discovered by losing a call. */
.callbox-hint { font-size: 0.74rem; color: var(--muted); flex: none; }
@media (max-width: 720px) { .callbox-hint { display: none; } }

.call-answer { background: var(--ok-soft, rgba(53,200,138,.16));
               border-color: rgba(53, 200, 138, 0.45); color: #93e8c4; }
.call-end { background: rgba(232, 90, 90, 0.16);
            border-color: rgba(232, 90, 90, 0.42); color: #ffb4b4; }
.call-mute.on { background: var(--panel-hover); color: var(--cream); }
.callbox svg { width: 15px; height: 15px; }

/* A ringing panel breathes, so that a call is visible out of the corner of an
   eye on a screen somebody is not looking at. Still, for anybody who asked for
   less motion. */
.callbox:has(.call-answer:not(.hidden)) { animation: callpulse 1.6s infinite; }
@keyframes callpulse {
  0%, 100% { border-color: var(--panel-border); }
  50% { border-color: rgba(53, 200, 138, 0.75); }
}
@media (prefers-reduced-motion: reduce) {
  .callbox:has(.call-answer:not(.hidden)) { animation: none;
    border-color: rgba(53, 200, 138, 0.75); }
}

/* The phone in a chat's own header. Green on hover like the one in the list, so
   the two read as the same button in two places rather than two buttons. */
.call-btn:hover { color: var(--ok); }

/* ===================================================== the AI noticeboard */

/* WHAT THE AI IS DOING WHILE YOU DO SOMETHING ELSE. Bottom-left: the right side
   is where a call lands and the bottom-right is where toasts appear, and this
   one has to be able to sit still for two minutes without being in the way of
   either. Above the composer on a phone, for the same reason. */
.ai-pills {
  position: fixed; z-index: 240;
  left: 12px; bottom: 12px;
  display: flex; flex-direction: column; gap: 6px;
  max-width: min(340px, calc(100vw - 24px));
}
@media (max-width: 899px) {
  .ai-pills { bottom: calc(var(--composer-h, 64px) + 12px); }
}

/* ---- a chat in a window that follows you ----
 *
 *     "have an icon that would take the chatfeed to a floating window one can
 *      move around that will always stay on top even if we change to another
 *      module/chat"                                    — Julian, 2026-10-15
 *
 * Fixed, so it is placed against the viewport and not against whatever page
 * is underneath; above everything of Gabbie's own except a dialog and the
 * call bar, which are the two things that must be able to interrupt it.
 *
 * Its position and size are set inline by app.js from sessionStorage, so the
 * only thing decided here is what it looks like.
 */
.popout {
  position: fixed; z-index: 60;
  display: flex; flex-direction: column;
  min-width: 260px; min-height: 180px;
  background: var(--panel-solid); border: 1px solid var(--panel-border);
  border-radius: var(--radius); box-shadow: var(--shadow);
  overflow: hidden;
}
.popout[hidden] { display: none; }

.popout-head {
  display: flex; align-items: center; gap: 6px;
  padding: 6px 8px 6px 12px;
  border-bottom: 1px solid var(--panel-border);
  background: var(--panel-raised);
  /* THE HEADER IS THE HANDLE. `cursor: move` says so before anything is
     tried, and `user-select: none` stops a drag from selecting the title
     instead of moving the window. */
  cursor: move; user-select: none; flex: none;
}
.popout.dragging { user-select: none; }
.popout-title {
  flex: 1; min-width: 0; font-weight: 600; font-size: .9rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.popout-feed {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  padding: 10px 10px 4px;
}
/* The window is narrow, so a bubble may have the width it needs rather than
   the 84% a full-size thread gives it. */
.popout-feed .msg-col { max-width: 92%; }
.popout-feed .msg-meta { font-size: .72rem; }

.popout-send {
  display: flex; align-items: flex-end; gap: 6px;
  padding: 7px 8px; border-top: 1px solid var(--panel-border); flex: none;
}
.popout-send textarea {
  flex: 1; resize: none; min-height: 34px; max-height: 110px;
  padding: 7px 9px; font: inherit; font-size: .88rem;
}

.popout-grip {
  position: absolute; right: 0; bottom: 0; width: 16px; height: 16px;
  cursor: nwse-resize;
  /* Two short strokes, the convention everywhere, drawn rather than imported. */
  background:
    linear-gradient(135deg, transparent 50%, var(--panel-border) 50%) no-repeat,
    linear-gradient(135deg, transparent 50%, var(--panel-border) 50%) no-repeat;
  background-size: 10px 2px, 6px 2px;
  background-position: 2px 10px, 6px 6px;
}

/* ON A PHONE IT IS NOT A WINDOW. There is no room to float anything over a
   list that is already the whole screen, and nothing to move it with that is
   not also how you scroll. It becomes a sheet across the bottom, which is
   what a phone has instead of a window. */
@media (max-width: 760px) {
  .popout {
    left: 0 !important; right: 0; bottom: 0 !important;
    top: auto !important;
    width: auto !important; height: min(62vh, 420px) !important;
    border-radius: var(--radius) var(--radius) 0 0;
    border-bottom: 0;
  }
  .popout-head { cursor: default; }
  .popout-grip { display: none; }
}

/* ---- the red microphone: talk to the GOAT ----
 *
 *     "add a red microphone icon ... This will be persistent even in a mobile
 *      view, which usually hides most options."       — Julian, 2026-10-17
 *
 * The one control in the header that is not a door to a page: it starts
 * recording when pressed, so it does not look like its neighbours.
 */
.goat-mic { color: #e4574d; }
.goat-mic:hover { color: #fff; background: #e4574d; border-color: #e4574d; }
.goat-mic[data-state="recording"] {
  color: #fff; background: #e4574d; border-color: #e4574d;
  animation: presence-ring 1.6s ease-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .goat-mic[data-state="recording"] { animation: none; }
}

/* The sheet it opens. Narrow, centred, and the same dialog machinery every
   other sheet uses — what is different is that it is already recording when
   it appears, so the only two things in it are the two answers to that. */
.goat-sheet { max-width: 380px; }
.goat-sheet .goat-level {
  height: 4px; border-radius: 2px; background: var(--sunk); overflow: hidden;
  margin: 12px 0;
}
.goat-sheet .goat-level span {
  display: block; height: 100%; width: 0; background: #e4574d;
  transition: width .1s linear;
}
.goat-sheet .goat-say { min-height: 2.6em; }
.goat-trouble { color: var(--bad); }

/* ---- back to the bottom of a feed ----
 *
 *     "If I am not at the bottom of the chatfeed, have a floating pill with
 *      an arrow down that would take me to the bottom."
 *                                                     — Julian, 2026-10-15
 *
 * Anchored to the PANE and not to the scrolling box: a child of a scroller
 * scrolls with it, which would pin this to a point in the conversation
 * instead of to the screen. `.thread` is already positioned, and so is the
 * agent page's.
 *
 * Above the composer by the same measure the AI pills use, so the two never
 * argue about the same corner — and on the left, because the right is where
 * the scrollbar and the message tools already are.
 */
.to-bottom {
  position: absolute; z-index: 20;
  left: 18px; bottom: calc(var(--composer-h, 64px) + 14px);
  width: 36px; height: 36px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 1.05rem; line-height: 1;
  color: var(--text); cursor: pointer;
  background: var(--panel-raised); border: 1px solid var(--panel-border);
  box-shadow: var(--shadow);
  /* It appears when you scroll away and goes when you come back. Fading
     rather than snapping, because something that blinks into the corner of
     your eye while you are reading is read as an alert. */
  opacity: 1; transform: none;
  transition: opacity .16s ease, transform .16s ease;
}
/* `.away` AND NOT `.hidden`, because the global rule is
   `.hidden { display: none !important; }` and a thing that is display:none
   cannot fade. The state it starts in is in the markup, so there is no frame
   where it is visible before app.js has run. */
.to-bottom.away {
  opacity: 0; transform: translateY(6px);
  pointer-events: none;
}
.to-bottom:hover { background: var(--panel-hover); }
/* ON A CARD'S PAGE the page itself scrolls, so there is no pane to anchor to:
   sticky instead, at the bottom of the screen while you read higher up, and
   in its own place under the thread once you are down there (where `.away`
   hides it anyway). "Add an icon … that will take you to the very bottom of
   the card in an instant" — Tatiana, 2026-10-16. */
.to-bottom.card-to-bottom {
  position: sticky; left: auto; bottom: 16px;
  margin: 0 0 0 auto;
}
@media (prefers-reduced-motion: reduce) { .to-bottom { transition: none; } }
.ai-pill {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 10px;
  background: var(--panel-solid); border: 1px solid var(--panel-border);
  border-radius: 999px; box-shadow: var(--shadow);
  font-size: 0.82rem; color: var(--text-dim);
  text-decoration: none;
}
.ai-pill:hover { color: var(--text); border-color: var(--blue-soft); }
.ai-pill-main {
  min-width: 0; flex: 1;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ai-pill-time { flex: 0 0 auto; color: var(--muted); font-variant-numeric: tabular-nums; }
.ai-pill .x {
  flex: 0 0 auto; width: 20px; height: 20px; padding: 0;
  border: 0; border-radius: 50%; cursor: pointer;
  background: transparent; color: var(--muted); font-size: 0.9rem; line-height: 1;
}
.ai-pill .x:hover { background: var(--panel-hover); color: var(--text); }
/* Done, and worth a glance: the one state where this is news rather than
   status. Failed is the same shape in the warning colour — neither of them
   flashes, because a thing that finished while you were reading something else
   should not take your eye off it. */
.ai-pill.done { border-color: rgba(53, 200, 138, 0.5); color: #93e8c4; }
.ai-pill.failed { border-color: rgba(232, 90, 90, 0.5); color: #ffb4b4; }
.ai-pill .spin {
  flex: 0 0 auto; width: 12px; height: 12px; border-radius: 50%;
  border: 2px solid var(--panel-border); border-top-color: var(--orange);
  animation: aispin 0.9s linear infinite;
}
@media (prefers-reduced-motion: reduce) { .ai-pill .spin { animation: none; } }
@keyframes aispin { to { transform: rotate(360deg); } }

/* Removing somebody is its own control at the end of their row. It used to be a
   label wrapped round the WHOLE row, which meant a tap anywhere on it armed a
   removal that only showed up when Apply was pressed — see the members sheet. */
.person-managed { align-items: center; }
.person-remove {
  flex: 0 0 auto; display: flex; align-items: center; gap: 5px;
  padding: 3px 8px; border-radius: 999px; cursor: pointer;
  border: 1px solid transparent;
  color: var(--muted); font-size: 0.76rem;
}
.person-remove input { width: auto; flex: 0 0 auto; accent-color: var(--bad); }
.person-remove:hover { color: #ffb4b4; border-color: rgba(232, 90, 90, 0.4); }
/* Ticked is loud, because the whole failure was a removal nobody could see. */
.person-remove:has(input:checked) {
  color: #ffb4b4; border-color: rgba(232, 90, 90, 0.7);
  background: rgba(232, 90, 90, 0.12);
}

/* A roster row: everybody who could be in this group, ticked where they are.
   Clickable as a whole, which is right for a list of checkboxes — and safe now,
   because unticking somebody who was in is confirmed by name before it applies
   (see the members guard in app.js). */
.roster-row { cursor: pointer; align-items: center; }
.roster-row input { width: auto; flex: 0 0 auto; accent-color: var(--ok); }
.roster-row:hover { background: var(--panel-hover); border-radius: 10px; }
/* In the group reads as the normal state; not in it is dimmed rather than
   marked, so the list scans as "who is here" first. */
.roster-row:not(.in) .person-name { color: var(--text-dim); }
.roster-row input:disabled { accent-color: var(--muted); }

/* ------------------------------------------------- status on the avatar
 *
 * The dot answers two questions at once: online is whether somebody is there,
 * the mark is whether they want to be interrupted. Both are real and they are
 * not the same — "online, on Do Not Disturb" is the case the feature exists for.
 *
 * A GLYPH AS WELL AS A COLOUR. Amber and red are one dot to a colourblind
 * reader at this size, so busy carries a dash and Do Not Disturb a cross, and
 * both carry a title.
 */
.avatar .presence.busy,
.avatar .presence.dnd {
/* ON A CALL — the third thing the dot says, and the one that outranks the
   others. Online and Busy are states somebody is in or chose; being on a call
   is what they are doing this minute, and it is the one that changes whether
   you should ring them. Filled, so it is not the same dot as Busy to somebody
   who cannot tell the two colours apart, and it breathes so that a glance
   catches it. */
.presence.calling {
  background: #e4574d; border-color: #e4574d; color: #fff;
  animation: presence-ring 1.6s ease-out infinite;
}
@keyframes presence-ring {
  0%   { box-shadow: 0 0 0 0 rgba(228, 87, 77, .55); }
  70%  { box-shadow: 0 0 0 5px rgba(228, 87, 77, 0); }
  100% { box-shadow: 0 0 0 0 rgba(228, 87, 77, 0); }
}
@media (prefers-reduced-motion: reduce) { .presence.calling { animation: none; } }

  display: grid; place-items: center;
  color: #fff; font-size: 8px; line-height: 1; font-weight: 700;
  width: 13px; height: 13px;
}
.avatar .presence.busy { background: var(--warn, #e0a13c); }
.avatar .presence.dnd { background: var(--bad, #e85a5a); }
/* Offline keeps the mark and loses the confidence: they said Do Not Disturb and
   then went home, and both halves of that are worth showing. */
.avatar .presence.busy:not(.online),
.avatar .presence.dnd:not(.online) { opacity: 0.55; }
.avatar.sm .presence.busy, .avatar.sm .presence.dnd { width: 11px; height: 11px; font-size: 7px; }
.avatar.lg .presence.busy, .avatar.lg .presence.dnd { width: 16px; height: 16px; font-size: 10px; }

/* The chooser on the profile: three real options, each showing its own mark, so
   the thing being chosen is the thing that will appear beside your name. */
.status-pick { display: flex; gap: 8px; flex-wrap: wrap; }
.status-pick label {
  display: flex; align-items: center; gap: 7px; cursor: pointer;
  padding: 7px 12px; border-radius: 999px;
  border: 1px solid var(--panel-border); background: var(--panel);
  font-size: 0.86rem;
}
.status-pick label:hover { border-color: var(--blue-soft); }
.status-pick input { width: auto; flex: 0 0 auto; }
.status-pick .mark {
  width: 13px; height: 13px; border-radius: 50%;
  display: grid; place-items: center;
  color: #fff; font-size: 8px; font-weight: 700; line-height: 1;
}
.status-pick .mark.available { background: var(--ok); }
.status-pick .mark.busy { background: var(--warn, #e0a13c); }
.status-pick .mark.dnd { background: var(--bad, #e85a5a); }
.status-pick input:checked + .mark + span { font-weight: 650; color: var(--text); }

/* ============================================ selecting several messages */

/* THE TICK. Present on every message and invisible until the mode is on, so a
   message arriving from the poll mid-selection has one too. */
.msg-pick { display: none; }
body.picking .msg-pick {
  display: grid; place-items: center;
  flex: 0 0 auto; width: 20px; height: 20px; margin-right: 2px;
  border: 1.5px solid var(--panel-border); border-radius: 50%;
  background: var(--panel-solid);
}
body.picking .msg { cursor: pointer; }
/* Ticked: filled, with a check drawn in CSS so there is no second asset and no
   font to wait for. */
body.picking .msg.picked .msg-pick {
  background: var(--blue-soft, #3e56c4); border-color: var(--blue-soft, #3e56c4);
}
body.picking .msg.picked .msg-pick::after {
  content: ""; width: 6px; height: 10px; margin-top: -3px;
  border: 2px solid #fff; border-top: 0; border-left: 0;
  transform: rotate(45deg);
}
body.picking .msg.picked { background: rgba(62, 86, 196, 0.12); border-radius: 12px; }
/* While selecting, nothing else on a message is clickable: the ⋮, the reactions
   and the quote button all act on ONE message and would be answering a
   different question from the one being asked. */
body.picking .msg-tools, body.picking .msg-more { display: none !important; }

/* The bar that takes over the top while a selection exists. */
.pickbar {
  display: flex; align-items: center; gap: 6px;
  flex: 1; min-width: 0;
}
.pickbar .right { flex: 1; }
.pickbar-count {
  font-weight: 650; font-size: 0.92rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Everything the header normally holds steps aside, rather than being squeezed
   in beside a count and three buttons on a phone. */
body.picking .site-header > *:not(.pickbar) { display: none; }


/* ==================================================== projects and boards
 *
 * A group's plans: the list of them, and the Kanban board that runs one.
 *
 * THE BOARD SCROLLS SIDEWAYS AND THE COLUMNS SCROLL DOWN. Four columns do not
 * fit a phone and never will, so the board is a horizontal scroller with
 * columns wide enough to read a card in — rather than a grid that squeezes
 * four unreadable columns onto 390 pixels.
 */
.proj-bar {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  margin-bottom: 12px; font-size: 0.88rem;
}
.proj-bar a { color: var(--text-dim); text-decoration: none;
              display: inline-flex; align-items: center; gap: 5px; }
.proj-bar a:hover { color: var(--text); }
.proj-bar a.on { color: var(--text); font-weight: 650; }
.proj-bar .ico-svg { width: 17px; height: 17px; }

/* ---- the columns editor ----
 *
 * One row per column, in the order they will be in. Deliberately a plain
 * stack of form controls rather than a drag-and-drop list: this is opened
 * rarely, by one person, to make one change — and arrows work with a thumb,
 * a keyboard and a screen reader without any of the three being an
 * afterthought.
 */
.wf-rows { display: flex; flex-direction: column; gap: 6px;
           max-height: 48vh; overflow-y: auto; }
.wf-row { display: flex; align-items: center; gap: 6px; }
.wf-row input[type="color"] {
  width: 30px; height: 30px; flex: 0 0 30px; padding: 2px;
  border: 1px solid var(--panel-border); border-radius: 8px;
  background: var(--panel-solid);
}
.wf-row input[type="text"] { flex: 1; min-width: 0; padding: 6px 9px; }
.wf-row .icon-btn.sm { width: 28px; height: 28px; flex: 0 0 28px; font-size: 0.9rem; }
/* The two flags: small, quiet, and in the mono face the app uses for
   metadata, because that is what they are. */
.wf-flag {
  display: inline-flex; align-items: center; gap: 3px; flex: none;
  font-family: var(--mono); font-size: 0.64rem; letter-spacing: 0.02em;
  color: var(--muted); margin: 0; cursor: pointer;
}
.wf-flag input { width: auto; flex: 0 0 auto; accent-color: var(--orange); }
@media (max-width: 560px) {
  /* On a phone the row wraps rather than shrinking every control to nothing. */
  .wf-row { flex-wrap: wrap; }
  .wf-row input[type="text"] { flex: 1 1 60%; }
}

/* The colour of a workflow step, beside its name at the head of its column. */
.kb-swatch {
  display: inline-block; width: 9px; height: 9px; border-radius: 3px;
  flex: none;
}

.kanban {
  display: flex; gap: 10px; align-items: flex-start;
  overflow-x: auto; padding-bottom: 8px;
  /* Each column is its own scroller, so a long To do does not push Done off
     the bottom of the page. */
  max-height: calc(100dvh - 230px);
}
.kb-col {
  flex: 0 0 268px; display: flex; flex-direction: column; min-height: 0;
  max-height: 100%;
  border: 1px solid var(--panel-border); border-radius: var(--radius);
  background: var(--panel);
}
.kb-head {
  display: flex; align-items: center; gap: 8px;
  padding: 9px 12px 7px; border-bottom: 1px solid var(--panel-border);
}
.kb-head h3 { margin: 0; font-size: 0.9rem; }
.kb-count {
  margin-left: auto; color: var(--muted); font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}
.kb-drop { padding: 8px; overflow-y: auto; flex: 1; min-height: 60px; }
/* Where a dragged card would land. An outline rather than a fill: the cards
   underneath stay readable, which is how somebody decides where to drop it. */
.kb-drop.over { outline: 2px dashed var(--blue-soft); outline-offset: -4px;
                border-radius: var(--radius-sm); }
.kb-card {
  background: var(--panel-solid); border: 1px solid var(--panel-border);
  border-radius: var(--radius-sm); padding: 8px 10px; margin-bottom: 7px;
  cursor: grab;
}
.kb-card:last-child { margin-bottom: 0; }
.kb-card.dragging { opacity: 0.4; }
.kb-card.done .kb-title { color: var(--muted); text-decoration: line-through; }
/* Late is a state of the work, not an error, so it is marked and not shouted:
   a warm edge and a warm date, no red panel. */
.kb-card.late { border-color: rgba(232, 163, 61, 0.5); }
.kb-card.cancelled { opacity: 0.6; }
.kb-card.cancelled .kb-title { text-decoration: line-through; }
.kb-title { font-size: 0.9rem; line-height: 1.35; overflow-wrap: anywhere; }
/* Tags are metadata, so they are set in the mono face the rest of the app
   uses for it — small, quiet, and never wrapping mid-word. */
.kb-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
.kb-tag {
  font-family: var(--mono); font-size: 0.66rem; letter-spacing: 0.02em;
  padding: 1px 6px; border-radius: 999px;
  background: var(--sunk); color: var(--text-dim);
}
/* HIGH PRIORITY IS ONE CHARACTER. A row of coloured flags on every card is a
   board that shouts; the exclamation marks the few that are urgent. */
.kb-pri {
  color: var(--warn); font-weight: 800; font-size: 0.9rem; line-height: 1;
}
.kb-foot {
  display: flex; align-items: center; gap: 6px; margin-top: 7px;
  font-size: 0.76rem; color: var(--muted);
}
.kb-foot .ico-svg { width: 14px; height: 14px; }
.kb-when { display: inline-flex; align-items: center; gap: 3px; }
.kb-when.late { color: var(--warn); font-weight: 650; }
.kb-foot .icon-btn.sm { width: 24px; height: 24px; flex: 0 0 24px;
                        opacity: 0; font-size: 0.9rem; }
.kb-card:hover .kb-more, .kb-more:focus-visible { opacity: 1; }
@media (hover: none) { .kb-foot .icon-btn.sm { opacity: 1; } }
.kb-empty { color: var(--muted); font-size: 0.8rem; margin: 4px 2px; }
.kb-add { padding: 6px 8px 8px; }
.kb-add input {
  width: 100%; padding: 7px 10px; font-size: 0.86rem;
  border-radius: 9px; border: 1px dashed var(--panel-border);
  background: transparent; color: var(--text);
}
.kb-add input:focus { border-style: solid; background: var(--panel-solid); }

/* ---------------------------------------------------------- the timeline
 *
 * THE GANTT: one grid per row, every row the same track list, so a bar
 * cannot drift out of line with the day above it. `--days` and `--dw` come
 * from the server (projects/views.py:_timeline_rows), which also decides how
 * wide a day should be — six weeks and a year are the same screen, and only
 * the grain changes.
 */
.tl-scroll {
  overflow-x: auto; overflow-y: auto; padding-bottom: 8px;
  max-height: calc(100dvh - 250px);
  border: 1px solid var(--panel-border); border-radius: var(--radius);
  background: var(--panel);
}
.tl { min-width: max-content; }
.tl-row { display: flex; align-items: stretch; }
/* The names stay put while the days scroll: a bar in April is meaningless
   once the task it belongs to has scrolled off to the left. */
.tl-name {
  position: sticky; left: 0; z-index: 2; flex: 0 0 190px;
  display: flex; align-items: center; gap: 6px;
  padding: 4px 10px; font-size: 0.84rem; overflow: hidden;
  background: var(--panel-solid);
  border-right: 1px solid var(--panel-border);
}
.tl-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tl-grid {
  display: grid; grid-template-columns: repeat(var(--days), var(--dw));
  position: relative; flex: 0 0 auto;
}
.tl-months { position: sticky; top: 0; z-index: 3; background: var(--panel-solid); }
.tl-months .tl-month {
  font-size: 0.76rem; font-weight: 650; padding: 5px 8px; white-space: nowrap;
  border-left: 1px solid var(--panel-border); overflow: hidden;
}
.tl-days {
  position: sticky; top: 26px; z-index: 3; background: var(--panel-solid);
  border-bottom: 1px solid var(--panel-border);
}
.tl-day {
  font-family: var(--mono); font-size: 0.6rem; line-height: 1.1;
  text-align: center; padding: 3px 0; color: var(--muted);
  display: flex; flex-direction: column; align-items: center;
}
.tl-day b { font-weight: 600; color: var(--text-dim); }
.tl-day i { font-style: normal; opacity: 0.7; }
/* A window of half a year has no room for numbers; the weekend shading and
   the today line are what remain readable, and they are enough to place a
   bar to within a day or two. */
.tl-days.narrow .tl-day { min-height: 16px; }
.tl-day.weekend, .tl-cell.weekend { background: rgba(139, 155, 240, 0.06); }
.tl-day.month { border-left: 1px solid var(--panel-border); }
.tl-cell { border-right: 1px solid rgba(139, 155, 240, 0.05); }
.tl-day.today, .tl-cell.today { background: rgba(242, 130, 46, 0.16); }
.tl-task { border-bottom: 1px solid rgba(139, 155, 240, 0.08); min-height: 30px; }
.tl-task:hover .tl-name { background: var(--panel-raised); }
.tl-task.done .tl-name span,
.tl-task.cancelled .tl-name span { color: var(--muted); text-decoration: line-through; }
/* THE BAR IS A BUTTON. It opens the same sheet a card does — a date is
   changed as often from the chart as from the board, and a chart that can
   only be read is a chart that goes stale. */
.tl-bar {
  align-self: center; margin: 3px 1px; min-height: 20px; min-width: 8px;
  border: 0; border-radius: 6px; cursor: pointer; overflow: hidden;
  background: color-mix(in srgb, var(--c) 78%, transparent);
  color: #12102a; font-size: 0.74rem; line-height: 20px; text-align: left;
  padding: 0 6px; white-space: nowrap; z-index: 1;
}
.tl-bar span { pointer-events: none; }
.tl-bar:hover { filter: brightness(1.12); }
.tl-bar.late { box-shadow: inset 0 0 0 2px var(--orange); }
/* A task with one date is a deadline, not a stretch of work: drawn as a mark
   on the day rather than as a bar that pretends to have a length. */
.tl-bar.pin { border-radius: 50px; }
.tl-task.done .tl-bar, .tl-task.cancelled .tl-bar { opacity: 0.55; }
.tl-loose-head { font-size: 0.95rem; margin: 16px 0 8px; }
.tl-loose { display: flex; flex-wrap: wrap; gap: 6px; }
.tl-chip {
  border: 1px solid var(--panel-border); border-left: 3px solid var(--c);
  background: var(--panel-solid); color: var(--text);
  border-radius: 8px; padding: 5px 9px; font-size: 0.82rem; cursor: pointer;
}
.tl-chip:hover { background: var(--panel-hover); }

/* ------------------------------------------------------------- the list */
.tbl-scroll { overflow-x: auto; border: 1px solid var(--panel-border);
              border-radius: var(--radius); background: var(--panel); }
table.tasks { width: 100%; border-collapse: collapse; font-size: 0.86rem; }
table.tasks th {
  text-align: left; font-weight: 600; font-size: 0.74rem; letter-spacing: 0.03em;
  text-transform: uppercase; color: var(--muted); white-space: nowrap;
  padding: 8px 10px; border-bottom: 1px solid var(--panel-border);
  position: sticky; top: 0; background: var(--panel-solid); z-index: 1;
}
table.tasks th a { color: inherit; text-decoration: none; }
table.tasks th a:hover { color: var(--text); }
table.tasks th.on { color: var(--text); }
table.tasks td { padding: 7px 10px; border-bottom: 1px solid rgba(139, 155, 240, 0.08);
                 vertical-align: middle; }
table.tasks tbody tr[data-open-task] { cursor: pointer; }
table.tasks tbody tr:hover { background: var(--panel-hover); }
table.tasks tr.done .t-title span:nth-child(2),
table.tasks tr.cancelled .t-title span:nth-child(2) {
  color: var(--muted); text-decoration: line-through;
}
table.tasks tr.cancelled { opacity: 0.65; }
.t-title { display: flex; align-items: center; gap: 7px; min-width: 220px; }
.t-title .ico-svg { width: 14px; height: 14px; }
.t-who { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.t-when { white-space: nowrap; font-variant-numeric: tabular-nums; }
.t-when.late { color: var(--orange-bright); }
.t-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.list-add { margin-top: 10px; max-width: 420px; }

/* --------------------------------------------------------- the calendar */
.cal-bar { align-items: center; gap: 8px; margin: 0 0 10px; }
.cal-bar h2 { margin: 0; font-size: 1.05rem; min-width: 8.5em; text-align: center; }
.cal { border: 1px solid var(--panel-border); border-radius: var(--radius);
       overflow: hidden; background: var(--panel); }
.cal-week { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); }
.cal-dow .cal-cell {
  min-height: 0; padding: 6px 8px; font-size: 0.72rem; letter-spacing: 0.03em;
  text-transform: uppercase; color: var(--muted); background: var(--panel-solid);
  border-bottom: 1px solid var(--panel-border);
}
.cal-cell {
  min-height: 96px; padding: 4px; display: flex; flex-direction: column; gap: 3px;
  border-right: 1px solid rgba(139, 155, 240, 0.08);
  border-bottom: 1px solid rgba(139, 155, 240, 0.08);
  overflow: hidden;
}
.cal-cell:nth-child(7n) { border-right: 0; }
.cal-cell.weekend { background: rgba(139, 155, 240, 0.05); }
/* The days either side of the month are drawn, not blank: a week is seven
   days whichever month it starts in, and a deadline on the 1st is easier to
   see when the 30th is beside it. */
.cal-cell.outside { opacity: 0.45; }
.cal-cell.today { background: rgba(242, 130, 46, 0.12); }
.cal-num { font-size: 0.76rem; color: var(--muted); font-variant-numeric: tabular-nums;
           display: flex; gap: 4px; align-items: baseline; }
.cal-cell.today .cal-num { color: var(--orange-bright); font-weight: 700; }
.cal-mon { font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.03em; }
.cal-chip {
  display: block; width: 100%; text-align: left; cursor: pointer;
  border: 0; border-left: 3px solid var(--c); border-radius: 5px;
  background: color-mix(in srgb, var(--c) 20%, transparent);
  color: var(--text); font-size: 0.74rem; line-height: 1.25;
  padding: 3px 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  text-decoration: none;
}
.cal-chip:hover { background: color-mix(in srgb, var(--c) 34%, transparent); }
/* A start is a fainter mark than a deadline: one is when somebody picks the
   work up, the other is when it is wanted, and only one of them is a
   promise. */
.cal-chip.start { background: transparent; border-left-style: dashed;
                  color: var(--text-dim); }
/* The days between the start and the end of something short — an event, see
   projects/views.py:_marks. As faint as a start: it is context for the day,
   not a deadline. */
.cal-chip.during { background: transparent; border-left-style: dotted;
                   color: var(--text-dim); }
.cal-chip.late { box-shadow: inset 0 0 0 1px var(--orange); }
.cal-chip.done, .cal-chip.cancelled { text-decoration: line-through;
                                      color: var(--muted); }
/* WHOSE FRIDAY IT IS. On the whole-company month the group's name is the
   first thing on the chip, because "Friday" only means something once you
   know which piece of work it belongs to. */
.cal-who { font-family: var(--mono); font-size: 0.62rem; color: var(--muted);
           margin-right: 4px; }
@media (max-width: 700px) {
  /* A month on a phone: the grid stays seven wide — a calendar that reflows
     into a list is a list — but the chips become marks and the day is
     opened to read them. */
  .cal-cell { min-height: 62px; padding: 2px; }
  .cal-chip { font-size: 0.66rem; padding: 2px 3px; }
  .cal-who { display: none; }
  .tl-name { flex-basis: 120px; }
}

/* ------------------------------------------------- the parts of a task ----
 *
 * "necesito poder crear subtareas con fechas, especificas."
 *
 * A row, not a card: a subtask is a part of the thing above it, and drawing it
 * like a task of its own is how a list of six becomes a wall of twenty.
 */
.st { display: flex; align-items: center; gap: 8px; padding: 5px 0;
      border-bottom: 1px solid rgba(139, 155, 240, 0.08); }
.st:last-child { border-bottom: 0; }
.st-tick {
  width: 20px; height: 20px; flex: none; border-radius: 6px; cursor: pointer;
  border: 1.5px solid var(--panel-border); background: transparent;
  color: var(--ok); font-size: 0.8rem; line-height: 1;
}
.st-tick:hover { border-color: var(--blue-soft); }
.st.done .st-tick, .pending-task.done .st-tick { background: color-mix(in srgb, var(--ok) 22%, transparent);
                    border-color: var(--ok); }
.st-title { flex: 1; min-width: 0; color: var(--text); text-decoration: none;
            font-size: 0.9rem; overflow-wrap: anywhere; }
.st-title:hover { text-decoration: underline; }
.st.done .st-title { color: var(--muted); text-decoration: line-through; }
.st-when { font-size: 0.76rem; color: var(--text-dim); white-space: nowrap;
           font-variant-numeric: tabular-nums; }
.st-when.late { color: var(--orange-bright); }
/* How many parts are done, on the card that owns them. */
.kb-parts { display: inline-flex; align-items: center; gap: 3px;
            font-family: var(--mono); font-size: 0.66rem; color: var(--muted); }
.kb-parts .ico-svg { width: 12px; height: 12px; }
.kb-parts.all { color: var(--ok); }

/* ---- what has been said about one task ---- */
.tc { display: flex; gap: 8px; padding: 8px 0;
      border-bottom: 1px solid rgba(139, 155, 240, 0.08); }
.tc:last-child { border-bottom: 0; }
.tc-body { min-width: 0; flex: 1; }
.tc-who { display: flex; align-items: center; gap: 6px; font-size: 0.82rem;
          font-weight: 650; }
.tc-del { margin-left: auto; }
.tc-text { font-size: 0.9rem; line-height: 1.45; overflow-wrap: anywhere;
           color: var(--text-dim); }
.struck { text-decoration: line-through; color: var(--muted); }

/* ---- a notification that came FOR you ----
 *
 * "cada mensaje se vuelve una notificacion, que termina ocultando las que me
 * importan.. al menos las que vienen para mi"
 *
 * A rail, not a fill: the row still reads as one of a list, and the eye finds
 * the three that were addressed to somebody without the other twenty having to
 * be dimmed. Dimming the rest would make a page of ordinary group news look
 * broken. */
.result.addressed { border-left: 2px solid var(--orange); padding-left: 10px; }

/* The column, changed from inside the task. A select rather than a drag —
   dragging is a pointer gesture on a board somebody may not even be looking at,
   and on a phone it fights the scroll. */
.tv-step { font-size: 0.82rem; font-weight: 650; padding: 3px 8px;
           border-radius: 6px; max-width: 200px; }

/* ---- a message that belongs to a card, seen in the main feed ----
 *
 * "messages belonging to a card, when displayed in the main thread, they should
 *  ALL wear the card badge. They should be clearly different from the ones in
 *  the main thread."
 *
 * TWO MARKS, because they answer different questions at different distances.
 *
 *   THE RAIL is read without reading: a coloured edge down the bubble says
 *   "this one is not feed chatter" from across the room, and a run of them
 *   reads as a thread inside the thread — which is what it is.
 *
 *   THE TAG names WHICH card, and is the way into it. It is its own row above
 *   the message rather than a pill inside the meta line, because the meta line
 *   is collapsed away on every message after the first of a run — which is
 *   exactly why only the first one was wearing a badge.
 */
.msg.in-card .bubble {
  border-left: 3px solid var(--blue-soft, var(--blue));
  border-top-left-radius: 6px;
}
/* Your own bubbles sit on the right, so the edge that faces the thread is the
   other one — a rail on the far side reads as part of the margin. */
.msg.in-card.mine .bubble {
  border-left: 1px solid var(--panel-border);
  border-right: 3px solid var(--blue-soft, var(--blue));
  border-top-right-radius: 6px;
}
.card-tag {
  display: inline-flex; align-items: center; gap: 4px;
  margin: 0 0 3px 3px; padding: 1px 7px 1px 5px;
  font-size: 0.7rem; font-weight: 650; letter-spacing: .01em;
  color: var(--blue-bright, var(--blue));
  background: rgba(62, 86, 196, 0.14);
  border: 1px solid rgba(62, 86, 196, 0.34);
  border-radius: 999px; text-decoration: none;
  max-width: 260px;
}
.card-tag span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card-tag:hover { background: rgba(62, 86, 196, 0.24); }
.card-tag .ico-svg { width: 12px; height: 12px; flex: none; }
/* On your own messages the column is right-aligned, so the tag follows it. */
.msg.mine .card-tag { margin: 0 3px 3px auto; }

/* THE COMPOSER, WHILE WHAT YOU TYPE WILL LAND IN A CARD.
 *
 * The same rail as the message bubbles wear, for the same reason and in the
 * same colour: whatever is about to be written is part of that thread, and the
 * box should look like the messages it is about to join. Visible while typing,
 * rather than only in the bar above it — which is the part somebody stops
 * reading once they start writing.
 */
.composer.into-card {
  border-left: 3px solid var(--blue-soft);
  padding-left: 9px;
}
.composer.into-card #composer-body::placeholder { color: var(--blue-soft); }

/* Identification documents: four columns, a heading row and two slots. A grid
   rather than a table because the panel is narrow and a table's own layout
   rules fight a 320px screen. */
.idoc-head, .idoc-row {
  display: grid; grid-template-columns: 1.1fr 1fr 1.3fr 1fr; gap: 10px;
  padding: 5px 0; font-size: 0.86rem;
}
.idoc-head {
  font-size: 0.7rem; text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); border-bottom: 1px solid var(--panel-border);
}
.idoc-row { border-bottom: 1px solid rgba(139, 155, 240, 0.07); }
.idoc-row:last-of-type { border-bottom: 0; }
.idoc-row .mono { font-family: var(--mono); font-size: 0.8rem; }
@media (max-width: 520px) {
  .idoc-head { display: none; }
  .idoc-row { grid-template-columns: 1fr 1fr; }
}

/* Beside the chart rather than under it — see Employee.aside. Inset and
   quieter, so it reads as an annexe rather than another branch. */
.org-aside { border-left: 3px solid var(--panel-border); }

/* ---- the sixteen card colours ----
 *
 * "cards should have a color attribute configurable between 16 preset colors
 *  (color name) that would color the card."
 *
 * THE NAME IS THE DATA AND THE HEX LIVES HERE, per theme. That is the whole
 * reason the model stores "amber" rather than #d97706: the dark theme and the
 * light one need different values for the same name, and a colour chosen by
 * somebody on one of them would otherwise be unreadable on the other.
 *
 * Tuned for a DARK ground first, since that is Gabbie's default, and redefined
 * below for the light one. Each is used three ways — a rail down the card, a
 * dot in the list, and a swatch in the editor — from one variable, so the three
 * cannot drift.
 */
:root {
  --c-slate:  #94a3b8;  --c-grey:   #a1a1aa;  --c-stone:  #b6aea4;
  --c-red:    #f4756b;  --c-orange: #f59e5b;  --c-amber:  #e8b23d;
  --c-yellow: #e3d45c;  --c-lime:   #a8d95f;  --c-green:  #5ed19a;
  --c-teal:   #4ecfc0;  --c-cyan:   #56c8e8;  --c-blue:   #6f9af5;
  --c-indigo: #8b9bf0;  --c-violet: #b18ff0;  --c-pink:   #ef88bd;
  --c-brown:  #c08a63;
}
@media (prefers-color-scheme: light) {
  :root:not([data-theme="dark"]) {
    --c-slate:  #64748b;  --c-grey:   #71717a;  --c-stone:  #8a7f72;
    --c-red:    #d2453a;  --c-orange: #c96a17;  --c-amber:  #a87b0c;
    --c-yellow: #8a7c10;  --c-lime:   #5d8a19;  --c-green:  #1c8a5c;
    --c-teal:   #0f8a80;  --c-cyan:   #0f7fa0;  --c-blue:   #2f5fd0;
    --c-indigo: #4a58c4;  --c-violet: #7a4fc4;  --c-pink:   #bf3d84;
    --c-brown:  #8a5a32;
  }
}
:root[data-theme="light"] {
  --c-slate:  #64748b;  --c-grey:   #71717a;  --c-stone:  #8a7f72;
  --c-red:    #d2453a;  --c-orange: #c96a17;  --c-amber:  #a87b0c;
  --c-yellow: #8a7c10;  --c-lime:   #5d8a19;  --c-green:  #1c8a5c;
  --c-teal:   #0f8a80;  --c-cyan:   #0f7fa0;  --c-blue:   #2f5fd0;
  --c-indigo: #4a58c4;  --c-violet: #7a4fc4;  --c-pink:   #bf3d84;
  --c-brown:  #8a5a32;
}

/* A RAIL, NOT A FILL. A card is mostly text somebody has to read; a tinted
   panel fights every word on it, and on sixteen different hues one of them
   always loses. The edge is unmistakable and costs the text nothing. */
.card-colour { border-left: 4px solid var(--card-hue, transparent); }
.c-slate  { --card-hue: var(--c-slate);  }
.c-grey   { --card-hue: var(--c-grey);   }
.c-stone  { --card-hue: var(--c-stone);  }
.c-red    { --card-hue: var(--c-red);    }
.c-orange { --card-hue: var(--c-orange); }
.c-amber  { --card-hue: var(--c-amber);  }
.c-yellow { --card-hue: var(--c-yellow); }
.c-lime   { --card-hue: var(--c-lime);   }
.c-green  { --card-hue: var(--c-green);  }
.c-teal   { --card-hue: var(--c-teal);   }
.c-cyan   { --card-hue: var(--c-cyan);   }
.c-blue   { --card-hue: var(--c-blue);   }
.c-indigo { --card-hue: var(--c-indigo); }
.c-violet { --card-hue: var(--c-violet); }
.c-pink   { --card-hue: var(--c-pink);   }
.c-brown  { --card-hue: var(--c-brown);  }

/* A dot in the list, where a rail would compete with the row's own borders. */
.card-dot { width: 9px; height: 9px; border-radius: 50%; flex: none;
            background: var(--card-hue, transparent); }
.card-dot.c-none { border: 1px solid var(--panel-border); }

/* The editor's swatches. `none` is a crossed circle rather than an empty one,
   because an empty circle among sixteen full ones reads as a missing image. */
.swatches { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 6px; }
.swatch { width: 26px; height: 26px; border-radius: 8px; cursor: pointer;
          background: var(--card-hue, transparent);
          border: 2px solid transparent; box-shadow: 0 0 0 1px var(--panel-border);
          display: grid; place-items: center; }
.swatch input { position: absolute; opacity: 0; width: 0; height: 0; }
.swatch.on { border-color: var(--text); }
.swatch:focus-within { outline: 2px solid var(--orange); outline-offset: 2px; }
.swatch.c-none { position: relative; background: transparent; }
.swatch.c-none::after {
  content: ""; position: absolute; inset: 6px;
  border-top: 2px solid var(--muted); transform: rotate(-45deg);
}

/* THE CARD'S NAME, STUCK TO THE TOP OF ITS PAGE.
 *
 * "could a header of the card stick to the top, so that you don't have to
 *  scroll 2km up if you want to see/edit the card?"
 *
 * A strip and not the panel: pinning a page of markdown would leave nothing to
 * read under it. Sticky rather than fixed, so it needs no scroll listener and
 * cannot get out of step with one — `.page` is the scroller, which is why this
 * works at all.
 *
 * It is ALWAYS there, including at the top of the page where it sits directly
 * above the card's own title. A bar that appears on scroll is a bar that moves
 * the page under the reader at the moment they start reading.
 */
.card-stick {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; gap: 8px;
  margin: 0 0 10px; padding: 7px 12px;
  background: var(--glass-firm);
  -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px);
  border: 1px solid var(--panel-border); border-radius: 10px;
}
.card-stick-name {
  font-weight: 700; font-size: 0.95rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.card-stick .btn { flex: none; }

/* ---- who reacted ----
 *
 * A heading per emoji and a row per person under it: the question is "who
 * liked this", so the emoji is the grouping and the names are the answer.
 */
.react-head { display: flex; align-items: baseline; gap: 8px; margin: 12px 0 4px;
              padding-bottom: 4px; border-bottom: 1px solid var(--panel-border); }
.react-head:first-child { margin-top: 0; }
.react-emoji { font-size: 1.1rem; }
.react-n { font-size: 0.74rem; color: var(--muted); text-transform: uppercase;
           letter-spacing: .04em; }
.react-who { display: flex; align-items: center; gap: 8px; padding: 4px 0;
             font-size: 0.9rem; }

/* ---- Gabbie People ----
 *
 * A record is read, not scanned, so it is a narrow column of label/value pairs
 * rather than cards. The chart is an indented list: twenty people, one root and
 * a manager with thirteen reports make a conventional tree very wide and two
 * levels deep, which on a phone is a horizontal scroll.
 */
.kv { display: grid; grid-template-columns: auto 1fr; gap: 6px 16px; margin: 0;
      font-size: 0.9rem; }
.kv dt { color: var(--muted); font-size: 0.78rem; text-transform: uppercase;
         letter-spacing: .04em; padding-top: 2px; }
.kv dd { margin: 0; overflow-wrap: anywhere; }

.org-row { display: flex; align-items: center; gap: 10px; padding: 7px 0;
           color: inherit; text-decoration: none;
           border-bottom: 1px solid rgba(139, 155, 240, 0.07); }
.org-row:last-child { border-bottom: 0; }
.org-row:hover .org-name { text-decoration: underline; }
.org-main { display: flex; flex-direction: column; min-width: 0; }
.org-name { font-size: 0.9rem; font-weight: 600; }
.org-sub { font-size: 0.76rem; color: var(--muted); }

/* ---- my week ----
 *
 * "Be able to have a view of my stuff due this week as a list or cards"
 *
 * ONE MARKUP, TWO LAYOUTS. The rows are identical; `.list` stacks them full
 * width to be read down, `.cards` puts them in a grid to be scanned across.
 * Anything that differed beyond layout would be a second definition of what a
 * task looks like.
 */
.agenda { display: flex; flex-direction: column; gap: 18px; }

.ag-day { display: flex; flex-direction: column; gap: 8px; }
.ag-head { display: flex; align-items: baseline; gap: 10px; margin: 0;
           font-size: 0.82rem; font-weight: 700; letter-spacing: .04em;
           text-transform: uppercase; color: var(--muted);
           border-bottom: 1px solid var(--panel-border); padding-bottom: 5px; }
.ag-day.is-today .ag-head { color: var(--orange); border-bottom-color: var(--orange); }
.ag-day.late .ag-head { color: var(--warn);
                        border-bottom-color: currentColor; }
.ag-day.weekend .ag-head { opacity: .72; }
.ag-count { font-family: var(--mono); font-weight: 600;
            font-variant-numeric: tabular-nums; }

.ag-rows { display: flex; flex-direction: column; gap: 6px; }
.agenda.cards .ag-rows { display: grid; gap: 8px;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); }

.ag-task { display: flex; align-items: flex-start; gap: 8px;
           padding: 8px 10px; border-radius: 8px;
           background: var(--panel); border: 1px solid var(--panel-border); }
.ag-task:hover { background: var(--panel-hover); }
/* The stripe says late without spending a colour on the whole row: a page of
   red is a page nobody can read the priorities off. */
.ag-task.late { border-left: 3px solid var(--warn); }

.ag-body { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1;
           color: inherit; text-decoration: none; }
.ag-body:hover .ag-title { text-decoration: underline; }
.ag-title { font-size: 0.94rem; font-weight: 600; overflow-wrap: anywhere; }
.ag-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px;
           font-size: 0.74rem; color: var(--muted); }
.ag-where { font-weight: 600; }
.ag-due { display: inline-flex; align-items: center; gap: 3px; }
.ag-due .ico-svg { width: 12px; height: 12px; }
.ag-due.late { color: var(--warn); }
.ag-notes { font-size: 0.8rem; color: var(--text-dim); overflow: hidden;
            display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
/* In a card the notes have room; in a list they are a hint, not the content. */
.agenda.list .ag-notes { -webkit-line-clamp: 1; }

/* MY WEEK, 2026-10-13: the row's text, chat label and editor stacked beside
   the tick; the chat label is its own link (to the chat), and the editor is a
   <details> that opens under the row. */
.ag-main { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.ag-main .ag-body { flex: 0 0 auto; }
.ag-meta .avatar.sm { width: 18px; height: 18px; font-size: .5rem; }
.wk-chat { display: inline-flex; align-items: center; gap: 4px;
           padding: 0 7px; border-radius: 999px; color: var(--muted);
           border: 1px solid var(--panel-border); text-decoration: none; }
.wk-chat:hover { color: var(--text); border-color: var(--violet); }
.wk-edit > summary { list-style: none; cursor: pointer; display: inline-block;
                     font-size: .74rem; color: var(--muted); }
.wk-edit > summary::-webkit-details-marker { display: none; }
.wk-edit > summary:hover { color: var(--text); text-decoration: underline; }
.wk-edit[open] > summary { color: var(--violet); }
.wk-form { display: grid; gap: 8px; margin: 8px 0 4px;
           grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
.wk-field { display: flex; flex-direction: column; gap: 3px; font-size: .74rem;
            color: var(--muted); min-width: 0; }
.wk-field input, .wk-field select { width: 100%; min-width: 0; }
.wk-wide { grid-column: 1 / -1; }
.wk-buttons { display: flex; align-items: flex-end; }
.wk-delete { margin: 0 0 4px; }
.ag-day.unscheduled .ag-head { color: var(--muted); }

/* THE CHAT'S ACTION ITEMS, ASSIGNED — the tasks they became (2026-10-13). */
.pending-subhead { margin: 14px 16px 6px; font-size: .78rem; color: var(--muted);
                   text-transform: uppercase; letter-spacing: .04em; }
.pending-task { display: flex; gap: 8px; align-items: flex-start; padding: 8px 16px; }
.pending-task-main { flex: 1; min-width: 0; }
.pending-task.done .pending-what { text-decoration: line-through; color: var(--muted); }

@media (max-width: 560px) {
  .agenda.cards .ag-rows { grid-template-columns: 1fr; }
}

/* ---- the faces on a group call ----
 *
 * Overlapped rather than spaced: three avatars in a row push the name and the
 * buttons off a narrow phone, and a stack reads as "these people, together"
 * which is what a call is. Dimmed while that person is still being rung.
 */
.call-faces { display: inline-flex; align-items: center; flex: none; }
.call-face { margin-right: -8px; border: 2px solid var(--panel-solid);
             box-shadow: 0 1px 3px rgba(0, 0, 0, .25); }
.call-face:last-child { margin-right: 0; }
.call-face.waiting { opacity: .45; animation: call-waiting 1.6s ease-in-out infinite; }
@keyframes call-waiting { 50% { opacity: .8; } }
@media (prefers-reduced-motion: reduce) {
  .call-face.waiting { animation: none; }
}

/* ---- files attached to one task ----
 *
 * "Also, allow uploading attachments to cards."
 *
 * A ROW EACH, not a gallery: what is attached to a task is a spec, a
 * screenshot of the bug, an invoice — the name is what somebody scans for,
 * and a picture says what it is with a thumbnail of itself.
 */
.tf { display: flex; align-items: center; gap: 6px; padding: 5px 0;
      border-bottom: 1px solid rgba(139, 155, 240, 0.08); }
.tf:last-child { border-bottom: 0; }
.tf-open { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1;
           color: inherit; text-decoration: none; }
.tf-open:hover .tf-name { text-decoration: underline; }
.tf-thumb { width: 36px; height: 36px; border-radius: 6px; object-fit: cover;
            flex: none; background: rgba(139, 155, 240, 0.12); }
.tf-ico { display: grid; place-items: center; width: 36px; height: 36px;
          flex: none; border-radius: 6px; color: var(--muted);
          background: rgba(139, 155, 240, 0.12); }
.tf-name { min-width: 0; overflow: hidden; text-overflow: ellipsis;
           white-space: nowrap; font-size: 0.88rem; }
.tf-size { flex: none; }
/* The label IS the button — the input inside it is hidden — so "attaching…"
   has to be said on the label or it is said nowhere. */
.btn.busy { opacity: 0.6; pointer-events: none; }

/* ---- a day that a task is being dropped onto ----
 *
 * "poder drag/drop cosas por el calendario". An outline rather than a fill, so
 * what is already on that day stays readable while deciding.
 */
.cal-cell.over { outline: 2px dashed var(--blue-soft); outline-offset: -3px;
                 background: rgba(139, 155, 240, 0.12); }
.cal-chip[draggable="true"] { cursor: grab; }
.cal-chip.dragging, .tl-chip.dragging { opacity: 0.4; }
.tl-chip[draggable="true"] { cursor: grab; }

/* ---- what a CSV said, before any of it is written ---- */
#import-report { border: 1px solid var(--panel-border); border-radius: 10px;
                 padding: 8px 10px; margin-top: 10px; line-height: 1.5;
                 max-height: 32vh; overflow-y: auto; }

/* ------------------------------------------- a chat coming to the top ----
 *
 * "Can you do a nice animation when someone writes that will bring the
 * user/group to the top by sliding up and sliding the rest down?"
 *
 * Done with FLIP in app.js — measure, move, invert, play — so the rows
 * actually travel from where they were to where they now are. These are the
 * two lines that make it look deliberate: the row that came up is lit for a
 * moment, so a list that rearranged itself while you were reading says WHICH
 * one arrived rather than leaving you to work it out.
 */
.conv-row.arriving > .conv, .conv.arriving {
  animation: arrived 900ms ease-out;
}
@keyframes arrived {
  0%   { background: color-mix(in srgb, var(--orange) 26%, transparent); }
  100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
  /* The list still reorders — it simply does it between one frame and the
     next, with nothing sliding and nothing lit. */
  .conv-row.arriving > .conv, .conv.arriving { animation: none; }
}

/* ---- something already in the diary ----
 *
 * "the main calendar for the user … should show the calendar entries from the
 * user's Outlook/Teams calendar."
 *
 * Drawn differently from a task on purpose: a meeting is an appointment
 * somebody else may have made, and a month where the two are indistinguishable
 * is a month where "what am I doing on Thursday" needs a second look. Quieter,
 * with the time in front, and it leads out of Gabbie rather than into a sheet.
 */
.cal-chip.meeting {
  border-left: 3px solid var(--blue-bright);
  background: color-mix(in srgb, var(--blue-bright) 14%, transparent);
  color: var(--text-dim);
}
.cal-chip.meeting:hover { background: color-mix(in srgb, var(--blue-bright) 26%, transparent); }
/* Something on the calendar that is not blocking time — a reminder, a
   tentative invitation — reads as an outline rather than a block. */
.cal-chip.meeting.free { background: transparent; border-left-style: dotted; }
.cal-at { font-family: var(--mono); font-size: 0.62rem; color: var(--muted);
          margin-right: 4px; }

/* ------------------------------------------- MONTH, WEEK AND DAY
 *
 *     "Please add Week and Day views to the calendar … with a view switcher in
 *      the top right corner of the calendar."       — Lucila, 2026-10-05
 *
 * The switcher is a segmented control in the app's violet; the active view is
 * filled. The week and the day are one grid (projects/week.html) with seven
 * columns or one: a gutter of hours, an all-day strip, and a column per day in
 * which timed meetings are absolutely placed from custom properties the
 * server sets — --top and --len in minutes from the first hour shown, --lane
 * of --lanes for meetings that overlap. --ppm is the scale: pixels per minute.
 * Today and weekends are tinted as in the month, so the three read as one. */
.cal-views {
  display: inline-flex; border: 1px solid color-mix(in srgb, var(--violet) 45%, transparent);
  border-radius: 10px; overflow: hidden; flex: 0 0 auto;
}
.cal-views a {
  padding: 4px 11px; font-size: .8rem; color: var(--text-dim); text-decoration: none;
}
.cal-views a + a { border-left: 1px solid color-mix(in srgb, var(--violet) 30%, transparent); }
.cal-views a:hover { background: color-mix(in srgb, var(--violet) 12%, transparent); color: var(--text); }
.cal-views a.on {
  background: color-mix(in srgb, var(--violet) 30%, var(--panel-solid));
  color: var(--text); font-weight: 600;
}

.tcal {
  --ppm: .8px; --gutter: 46px;
  border: 1px solid var(--panel-border); border-radius: var(--radius);
  background: var(--panel); overflow: hidden;
}
.tcal-row { display: grid; grid-template-columns: var(--gutter) repeat(var(--days), minmax(0, 1fr)); }
.tcal-head > div, .tcal-allday > div { border-bottom: 1px solid rgba(139, 155, 240, 0.12); }
.tcal-h { padding: 6px 8px; display: flex; align-items: baseline; gap: 6px; font-size: .78rem; }
.tcal-dow { color: var(--muted); text-transform: uppercase; letter-spacing: .03em; font-size: .7rem; }
.tcal-num { font-variant-numeric: tabular-nums; font-weight: 600; }
.tcal-h.today .tcal-num {
  background: var(--orange); color: #2a1400; border-radius: 999px; padding: 0 7px;
}
.tcal-label { font-size: .64rem; color: var(--muted); padding: 6px 4px; text-align: right; }
.tcal-ad {
  padding: 4px; display: flex; flex-direction: column; gap: 3px; min-width: 0; min-height: 28px;
  border-left: 1px solid rgba(139, 155, 240, 0.08);
}
.tcal-body { position: relative; }
.tcal-gutter { position: relative; height: calc(var(--hours) * 60 * var(--ppm)); }
.tcal-hour {
  position: absolute; right: 6px; top: calc(var(--h) * 60 * var(--ppm) - .5em);
  font-family: var(--mono); font-size: .62rem; color: var(--muted);
}
.tcal-hour:first-child { top: 2px; }
.tcal-col {
  position: relative; height: calc(var(--hours) * 60 * var(--ppm)); min-width: 0;
  border-left: 1px solid rgba(139, 155, 240, 0.08);
  background-image: repeating-linear-gradient(to bottom,
    rgba(139, 155, 240, 0.10) 0, rgba(139, 155, 240, 0.10) 1px,
    transparent 1px, transparent calc(60 * var(--ppm)));
}
.tcal-col.weekend, .tcal-ad.weekend { background-color: rgba(139, 155, 240, 0.05); }
.tcal-col.today, .tcal-ad.today { background-color: rgba(242, 130, 46, 0.08); }
.tcal-ev {
  position: absolute; box-sizing: border-box; overflow: hidden;
  top: calc(var(--top) * var(--ppm));
  height: calc(var(--len) * var(--ppm));
  left: calc((100% - 4px) * var(--lane) / var(--lanes) + 2px);
  width: calc((100% - 4px) / var(--lanes) - 2px);
  padding: 2px 5px; border-radius: 5px;
  border-left: 3px solid var(--blue-bright);
  background: color-mix(in srgb, var(--blue-bright) 22%, var(--panel-solid));
  color: var(--text); font-size: .72rem; line-height: 1.25; text-decoration: none;
  display: flex; flex-direction: column;
}
.tcal-ev:hover { background: color-mix(in srgb, var(--blue-bright) 34%, var(--panel-solid)); color: var(--text); }
.tcal-ev.free { background: var(--panel-solid); border-left-style: dotted; }
.tcal-subject { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.tcal-where { color: var(--muted); font-size: .66rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tcal-now {
  position: absolute; left: 0; right: 0; top: calc(var(--at) * var(--ppm)); z-index: 2;
  border-top: 2px solid var(--violet); pointer-events: none;
}
.tcal-now::before {
  content: ""; position: absolute; left: -4px; top: -5px;
  width: 8px; height: 8px; border-radius: 50%; background: var(--violet);
}
/* The day has room: a taller hour, so a half-hour meeting shows its place. */
.tcal-single { --ppm: 1.1px; }
/* On a phone the week keeps seven readable columns by scrolling sideways
   rather than squeezing; the day fits as it is. */
@media (max-width: 640px) {
  .tcal:not(.tcal-single) { overflow-x: auto; }
  .tcal:not(.tcal-single) .tcal-row { min-width: 620px; }
  .cal-bar { flex-wrap: wrap; }
}

/* ------------------------------------------- the reaction picker, as a board
 *
 * "I'd like it to have the shape of an emoji keyboard with just three rows
 * QWERTYUIOP ASDFGHJKL ZXCVBNM so that you can just press the corresponding
 * key to use the emoji"
 *
 * SHAPED LIKE ONE SO IT READS LIKE ONE. The rows are centred and the lower two
 * sit slightly right, which is the whole of what makes a staggered board
 * recognisable at a glance — and recognising it is what tells somebody they
 * can type at it.
 *
 * The keys grow to fill the width rather than being a fixed size: ten across a
 * phone and ten across a laptop are different sizes, and a board that scrolls
 * sideways is not a board.
 */
.emoji-keys { display: flex; flex-direction: column; gap: 6px; }
.emoji-row { display: flex; gap: 5px; justify-content: center; }
/* The offset a real keyboard has, counted from the LETTERS: the number row
   and QWERTY are flush, ASDF is a little in, ZXCV further. In the units the
   keys are in, so it holds at every width. */
.emoji-row:nth-child(3) { padding-left: 3%; padding-right: 1%; }
.emoji-row:nth-child(4) { padding-left: 9%; padding-right: 7%; }
.emoji-key {
  flex: 1 1 0; min-width: 0; aspect-ratio: 1 / 1.15; max-width: 58px;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 1px; padding: 0; cursor: pointer;
  border: 1px solid var(--panel-border); border-radius: 9px;
  background: var(--panel-solid); color: var(--text);
  /* The little lift a key has, and the shadow it sits on. */
  box-shadow: 0 1.5px 0 rgba(20, 16, 43, 0.35);
  transition: transform 0.06s ease-out, background 0.12s ease-out;
}
.emoji-face { font-size: 1.35rem; line-height: 1; }
.emoji-cap {
  font-family: var(--mono); font-size: 0.56rem; letter-spacing: 0.04em;
  color: var(--muted);
}
.emoji-key:hover { background: var(--panel-hover); }
/* PRESSED IS PRESSED, whether by a finger or by the letter it carries: the
   keyboard path adds this class so a key that was typed at does what a key
   that was tapped does. */
.emoji-key:active, .emoji-key.pressed {
  transform: translateY(1.5px); box-shadow: none;
  background: color-mix(in srgb, var(--orange) 26%, var(--panel-solid));
}
.emoji-key:focus-visible { outline: 2px solid var(--blue-soft); outline-offset: 1px; }
@media (max-width: 420px) {
  /* On a narrow phone the letters would be smaller than they are readable, and
     nobody is typing at it anyway — the emoji get the room instead. */
  .emoji-cap { display: none; }
  .emoji-key { aspect-ratio: 1 / 1; }
  .emoji-face { font-size: 1.2rem; }
}
@media (prefers-reduced-motion: reduce) {
  .emoji-key { transition: none; }
}

/* ---- the summary dialog ----
 *
 * "Once entered, it will stay modal until is completed giving a CANCEL option
 * to the user. When the summary is done … show it in a text box for the user
 * to review."
 *
 * The text box is the summary, not a preview of it: big enough to read a page
 * of markdown without scrolling twice, in the mono face the editor uses, and
 * editable — what Post publishes is what is in here.
 */
#summary-text {
  width: 100%; font-family: var(--mono); font-size: 0.82rem; line-height: 1.5;
  max-height: 46vh; resize: vertical;
}
#summary-sheet .action-row { cursor: pointer; }
#summary-sheet .action-row input[type="radio"] {
  width: auto; margin-right: 9px; accent-color: var(--orange);
}
/* The bar that says something is happening while nothing can be shown yet. It
   is the upload stripe, borrowed: one idea, drawn once. */
#summary-sheet .up-track.working .up-fill {
  width: 100% !important;
  background: linear-gradient(90deg, transparent, var(--orange), transparent);
  background-size: 40% 100%; background-repeat: no-repeat;
  animation: upworking 1.1s linear infinite;
}

/* ---- a GIF on its way ----
 *
 * "when sending a GIF, nothing happens for a few seconds until the GIF shows
 * up. Maybe we could bump the chat and draw a box with the dimensions of the
 * gif and a gradient of the animation that then is replaced by the gif when
 * loaded?"
 *
 * The box is the size the clip will be, so the replacement moves nothing, and
 * the gradient travels across it — the same sweep a pending message has,
 * because it is the same fact: this is on its way.
 */
.gif-waiting {
  max-width: 100%; border-radius: var(--radius-sm); overflow: hidden;
  background: linear-gradient(100deg,
              var(--panel-solid) 20%,
              color-mix(in srgb, var(--blue-soft) 22%, var(--panel-solid)) 50%,
              var(--panel-solid) 80%);
  background-size: 220% 100%;
  animation: gifwaiting 1.25s ease-in-out infinite;
}
@keyframes gifwaiting {
  from { background-position: 120% 0; }
  to   { background-position: -20% 0; }
}
/* A picker cell shows its poster until it is this page's turn to play. */
.gif-cell video { background: var(--panel-solid); }
@media (prefers-reduced-motion: reduce) {
  .gif-waiting { animation: none; }
}

/* ---- a month takes the whole screen ----
 *
 * "the calendar should use the entire available part of the screen, to
 * maximize appointment text display. It now uses a narrow part of a big
 * screen, centered."
 *
 * Every other page in Gabbie is a column on purpose — prose at 1000px is
 * comfortable and prose at 2400px is unreadable. A month is not prose. It is a
 * grid of seven, and every pixel it is not given is a word of somebody's
 * meeting that gets cut off with an ellipsis. So this one page keeps the
 * column's padding and drops its ceiling, and the cells grow taller with the
 * room, because a wider cell with the same height just fits more ellipsis.
 */
/* A MONTH AND A BOARD BOTH WANT THE WHOLE SCREEN, for the same reason and by
   the same name. `.page-full` exists but also drops the page's own margins,
   which these two want to keep. */
.page-month, .page-roomy { max-width: none; }
/* And the columns grow with the room rather than leaving it empty: five
   columns on a wide screen should be five readable columns, not five narrow
   ones and a gap. */
@media (min-width: 1400px) {
  .page-roomy .kb-col { flex: 1 1 0; min-width: 268px; max-width: 420px; }
}
@media (min-width: 1100px) {
  .page-month .cal-cell { min-height: 118px; }
  .page-month .cal-chip { font-size: 0.78rem; padding: 3px 6px; }
  .page-month .cal-num { font-size: 0.8rem; }
}
@media (min-width: 1600px) {
  .page-month .cal-cell { min-height: 136px; }
}

/* ---- saying what each column of a spreadsheet is ----
 *
 * "Allow field mapping when importing." A row per column: what it is called,
 * what is in its first cell — which is how somebody recognises a column whose
 * heading is "Field 7" — and what to treat it as.
 */
.map-rows { display: flex; flex-direction: column; gap: 5px;
            max-height: 34vh; overflow-y: auto; }
.map-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 9.5em;
           align-items: center; gap: 8px; margin: 0; }
.map-head { font-weight: 650; font-size: 0.84rem; overflow: hidden;
            text-overflow: ellipsis; white-space: nowrap; }
.map-sample { font-family: var(--mono); font-size: 0.72rem; overflow: hidden;
              text-overflow: ellipsis; white-space: nowrap; }
.map-row select { padding: 5px 7px; font-size: 0.82rem; }
@media (max-width: 560px) {
  .map-row { grid-template-columns: minmax(0, 1fr) 9em; }
  .map-sample { display: none; }
}

/* ---- ticking tasks on the board ----
 *
 * "when hovering over a task in the board view, the three dot menu pops up.
 * When it does, a select checkbox should popup left from it."
 *
 * So the tick keeps the ⋮'s company: invisible until a hand is over the card,
 * and then permanently visible for every card once anything at all is ticked —
 * because at that point the question is which of these, not whether this one.
 */
.kb-pick { display: inline-flex; align-items: center; opacity: 0; margin: 0; }
.kb-card:hover .kb-pick,
.kb-pick:focus-within,
.kanban.picking .kb-pick { opacity: 1; }
@media (hover: none) { .kb-pick { opacity: 1; } }
.kb-pick input {
  width: 15px; height: 15px; margin: 0; cursor: pointer;
  accent-color: var(--orange);
}
/* A ticked card says so from across the board, not only at its corner. */
.kb-card.picked {
  border-color: var(--orange);
  background: color-mix(in srgb, var(--orange) 12%, var(--panel-solid));
}
/* The button that is not there until there is something to delete. */
.btn.danger { color: #ffb3ad; border-color: rgba(255, 179, 173, 0.4); }
.btn.danger:hover { background: rgba(255, 179, 173, 0.12); }

/* ------------------------------------------------------- the picture viewer
 *
 * ONLY THE ANDROID APP EVER DRAWS THIS. A browser opens a picture in a new tab
 * with `target="_blank"` and its own viewer takes over; the app has no second
 * tab to open and no pinch-zoom in its WebView, so the page provides both. The
 * markup is built in web/templates/library/detail.html, which also carries the
 * reasoning.
 *
 * BELOW THE CALL BANNER (z-index 300), deliberately. A picture is dismissed
 * with one tap and a call in progress is not; hiding the hang-up button behind
 * a photograph is how somebody stays connected by accident.
 */
.photoview {
  position: fixed; inset: 0; z-index: 290;
  background: #000;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
  /* The browser must not claim the gesture: without this the WebView scrolls
     the page underneath instead of moving the picture, and a pinch does
     nothing at all. */
  touch-action: none;
  overscroll-behavior: contain;
}
.photoview img {
  max-width: 100%; max-height: 100%;
  transform-origin: 50% 50%;
  will-change: transform;
  user-select: none; -webkit-user-drag: none; -webkit-touch-callout: none;
}
/* A LIFT OFF THE BLACK, not a scrim over it. Every other overlay in here
   darkens what is behind it; this one has nothing behind it but the picture,
   and a black chip on black is invisible. It is also the reason the rule reads
   as an exception to the no-flat-black rule in tests/test_chat.py and is not
   one: there is no page showing through to be greyed. */
.photoview .photoclose {
  /* --safe-top, never env() directly: in the app the shell has already
     padded the WebView inside the system bars and the token is 0 there,
     so a raw inset would count the same strip twice. */
  position: absolute; top: calc(10px + var(--safe-top)); right: 12px;
  width: 38px; height: 38px; padding: 0;
  border: 0; border-radius: 50%;
  background: rgba(255, 255, 255, .22); color: #fff;
  font-size: 1.5rem; line-height: 1; cursor: pointer;
}

/* ----------------------------------------------- acknowledging a comment
 *
 *     "FR: Be able to like comments in tasks. to aknowledge seeing them"
 *
 * QUIET UNTIL IT MEANS SOMETHING. An unpressed button is an outline with no
 * number in it, because a task with forty comments would otherwise carry forty
 * bright zeroes down the page and none of them would be information. Pressed,
 * it takes the accent — that is the state somebody is scanning for.
 */
.tc-seen {
  display: flex; align-items: center; gap: 8px;
  margin: 6px 0 0;
}
.tc-seen-btn {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 2px 8px;
  border: 1px solid var(--panel-border); border-radius: 999px;
  background: transparent; color: var(--muted);
  font-size: .8rem; line-height: 1.5; cursor: pointer;
}
.tc-seen-btn:hover { border-color: var(--orange); color: var(--text); }
.tc-seen-btn.on {
  border-color: var(--orange); color: var(--text);
  background: var(--sunk);
}
.tc-seen-n:empty { display: none; }
.tc-seen-n { font-variant-numeric: tabular-nums; }
.tc-seen-who { font-size: .76rem; color: var(--muted); }

/* ------------------------------------------- a file that is a picture of itself
 *
 *     "when looking at a group's files as icons, images should show a thumbnail
 *      instead of a generic icon"
 *
 * Sized to match the icon it replaces in each view — 22px in the list, 38px as
 * icons — so a row's height does not depend on what kind of file is in it.
 *
 * object-fit: cover and a square box: a portrait screenshot is cropped to the
 * cell rather than squashed into it, which is the same choice the sticker
 * normaliser makes and for the same reason. The radius is the row's, one step
 * smaller.
 */
.file-thumb {
  width: 22px; height: 22px;
  object-fit: cover; border-radius: 4px;
  background: var(--sunk);
}

/* AS ICONS, THE BOX IS FIXED AND THE CONTENTS VARY. A thumbnail worth looking
 * at is bigger than the 38px glyph it replaces — and if the two were simply
 * different sizes, a row of mixed files would have its names at two different
 * heights, which is exactly the complaint the top-alignment rule above was
 * written to answer. So every cell gets the same square, the glyph sits in the
 * middle of it and the picture fills it. */
.files.as-icons .file-ico {
  width: 56px; height: 56px;
  align-items: center; justify-content: center;
}
.files.as-icons .file-thumb {
  width: 100%; height: 100%; border-radius: 7px;
}

/* ------------------------------------------------------ hiding the chat list
 *
 *     "FR hide left-hand side column to see the task board (if i want to share
 *      my screen for the board, i don't want to show all of my conversations)"
 *
 * INSIDE THE DESKTOP MEDIA QUERY, and only there. On a phone the chat list and
 * the thread are separate pages that this class must not touch — `.sidebar`
 * carries `display: flex !important` at this width precisely because the
 * narrow layout toggles it, and anything hiding it has to outrank that.
 */
@media (min-width: 900px) {
  body.side-hidden .sidebar { display: none !important; }
  /* One column, so the board takes the width the list was using rather than
     leaving a 312px hole where it was. */
  body.side-hidden .app-body { grid-template-columns: minmax(0, 1fr); }
  /* Held down while it is on, like the two file-view buttons. */
  #hide-side[aria-pressed="true"] { background: var(--panel-hover); color: var(--orange); }
}

/* ------------------------------------------------ where your unread begins
 *
 *     "there will be a line dividing red and unread and it will say unread with
 *      an arrow pointing down so that I know that from that point on is unread"
 *
 * The same shape as the day divider it sits beside — a rule with a label on it
 * — so the thread has one kind of divider and not two. What makes it different
 * is the colour: this one is a place you are meant to notice, once, and the day
 * lines are furniture.
 */
.unread-line {
  display: flex; align-items: center; gap: 10px;
  margin: 14px 2px 10px;
  color: var(--orange);
  font-size: 0.74rem; font-weight: 700;
  text-transform: uppercase; letter-spacing: 0.06em;
}
.unread-line::before, .unread-line::after {
  content: ""; flex: 1; height: 1px; background: var(--orange); opacity: .55;
}
.unread-line span { flex: none; }

/* --------------------------------------------- Gabbie People: time off, goals
 *
 * THE NUMBER IS THE POINT, so it is the biggest thing in the cell. "How many
 * days do I have left" is answered at a glance or it is not answered — a table
 * of figures at body size makes somebody read four rows to find one.
 */
.tally { display: grid; gap: 8px; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
.tally-cell {
  display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px; border-radius: 10px;
  background: var(--sunk); border: 1px solid var(--panel-border);
}
.tally-name { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em;
              color: var(--muted); }
.tally-left { font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.tally-sub { font-size: .74rem; color: var(--muted); }

.leave-list { display: flex; flex-direction: column; }
.leave-row {
  display: grid; gap: 10px; align-items: center;
  grid-template-columns: minmax(0, 11rem) minmax(0, 1fr) auto auto;
  padding: 6px 2px; border-top: 1px solid var(--panel-border);
  font-size: .84rem;
}
.leave-when { color: var(--muted); font-variant-numeric: tabular-nums; }
.leave-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.leave-days { color: var(--muted); font-variant-numeric: tabular-nums; }
/* Approved is the ordinary case and says so quietly; the two that need a human
   to do something are the ones that carry colour. */
.pill.approved { color: var(--ok); }
.pill.requested { color: var(--orange); }
.pill.denied, .pill.canceled, .pill.cancelled { color: var(--muted); text-decoration: line-through; }

.goal + .goal { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--panel-border); }
.goal-bar { height: 6px; border-radius: 999px; background: var(--sunk); margin-top: 8px; overflow: hidden; }
.goal-bar span { display: block; height: 100%; background: var(--orange); border-radius: 999px; }
.kr-list { margin: 8px 0 0; padding-left: 18px; font-size: .86rem; }
.kr-list li { margin: 3px 0; }
.kr-list li.done { color: var(--muted); text-decoration: line-through; }

/* ------------------------------------------------- the org chart, drawn
 *
 *     "Add an option to display the org chart as a horizontal chart (in
 *      addition to the current list view)"
 *
 * The classic nested-list chart: each <ul> is a row of boxes, each <li> hangs
 * a connector up to its parent. No layout engine, no JavaScript, and it prints.
 *
 * IT SCROLLS SIDEWAYS AND THE PAGE DOES NOT. One root with a manager of
 * thirteen under it is very wide and two levels deep — which is why the
 * indented list exists and is still the default. The overflow belongs to the
 * panel, so nothing else on the screen moves when you drag it.
 */
.org-chart-wrap { overflow-x: auto; overscroll-behavior-x: contain; }
.org-chart, .org-chart ul {
  display: flex; justify-content: center; align-items: flex-start;
  list-style: none; margin: 0; padding: 0; gap: 6px;
}
.org-chart li {
  display: flex; flex-direction: column; align-items: center;
  position: relative; padding: 18px 6px 0;
}
/* The stalk up out of every box except the roots. */
.org-chart ul li::before {
  content: ""; position: absolute; top: 0; left: 50%;
  width: 1px; height: 18px; background: var(--panel-border);
}
/* The rail across a row of siblings, trimmed at the two ends so it does not
   stick out past the outermost boxes. */
.org-chart ul li::after {
  content: ""; position: absolute; top: 0; left: 0; right: 0;
  height: 1px; background: var(--panel-border);
}
.org-chart ul li:first-child::after { left: 50%; }
.org-chart ul li:last-child::after { right: 50%; }
.org-chart ul li:only-child::after { display: none; }
/* And the stalk down out of a box that has children. */
.org-chart li:has(> ul)::after { content: none; }
.org-chart li > ul { position: relative; margin-top: 18px; }
.org-chart li > ul::before {
  content: ""; position: absolute; top: -18px; left: 50%;
  width: 1px; height: 18px; background: var(--panel-border);
}

.org-box {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  width: 132px; padding: 10px 8px;
  border: 1px solid var(--panel-border); border-radius: 10px;
  background: var(--panel); color: var(--text); text-decoration: none;
  text-align: center;
}
.org-box:hover { border-color: var(--orange); }
.org-box-name { font-size: .82rem; font-weight: 600; line-height: 1.25; }
.org-box-sub {
  font-size: .7rem; color: var(--muted); line-height: 1.25;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ------------------------------------ a profile made of collapsible sections
 *
 *     "Every section in the profile should have a hide/display toggle button
 *      positioned in the upper right corner of that section"
 *
 * <details> IS THE TOGGLE. No JavaScript, keyboard-operable, announced as a
 * disclosure by a screen reader, and it prints open. A button wired to a class
 * would have been all of that again, worse, and off by one on the first reload.
 *
 * The marker is moved to the right-hand end, which is where it was asked for
 * and where a person's eye is already going once the heading is read.
 */
.sec > summary.sec-head {
  display: flex; align-items: center; gap: 10px;
  list-style: none; cursor: pointer;
  margin: 0 0 10px;
}
.sec > summary.sec-head::-webkit-details-marker { display: none; }
/* PURPLE SECTION TITLES, asked for so this panel reads beside BambooHR's own.
   The token, not a literal: it is already defined for both themes, and a second
   purple would drift from the first the next time either was adjusted. */
.sec > summary.sec-head h2 { font-size: .95rem; margin: 0; color: var(--accent-okr); }
/* The chevron, drawn rather than a glyph so it can turn. Pushed right by the
   auto margin, which is what puts it in the corner. */
.sec > summary.sec-head::after {
  content: ""; margin-left: auto; flex: none;
  width: 8px; height: 8px;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform .12s ease;
}
.sec[open] > summary.sec-head::after { transform: rotate(-135deg) translate(-2px, -2px); }
.sec > summary.sec-head:hover::after { border-color: var(--orange); }
/* A collapsed section keeps its own bottom margin, so the stack does not jump
   about as things are opened and closed. */
.sec:not([open]) > summary.sec-head { margin-bottom: 0; }

/* A toggle INSIDE a section — "other leave", "booked and taken". Quieter than
   the section's own, because it is a smaller promise. */
.sec-more { margin-top: 12px; }
.sec-more > summary {
  cursor: pointer; font-size: .82rem; font-weight: 600; color: var(--text-dim);
}
.sec-more > summary:hover { color: var(--orange); }

/* A label on a card. Stored uppercase — see chat/cards.py:_labels for why that
   is the data and not a text-transform — so this only has to space it out.
   Letter-spacing because uppercase text set tight is harder to read than the
   same words in mixed case, which is the whole reason small-caps exist. */
.pill.label { letter-spacing: .06em; }

/* ---- the grip, inside a text box ----
 *
 *     "something INSIDE THE INPUT TEXT (both when typing a message or when
 *      editing) that I could click to go into composer mode with the text I'm
 *      editing"
 *
 * The native resize handle's own glyph — three diagonal lines in the bottom
 * right corner — because that is the mark people already read as "this box can
 * get bigger", and it was what the picture showed. Drawn rather than borrowed:
 * `resize: both` gives the handle but not the behaviour, and a control that
 * looks draggable and is not would be worse than no control.
 *
 * BUILT BY app.js, NOT BY A TEMPLATE, and deliberately. A template is read from
 * disk on every request, so saving one puts markup live before the stylesheet
 * that positions it — which is exactly how this was broken for everybody the
 * first time it was attempted. JavaScript and CSS ship together in the same
 * hashed bundle or not at all.
 */
.grip {
  position: absolute; right: 6px; bottom: 6px;
  width: 18px; height: 18px; padding: 0; border: 0; border-radius: 4px;
  background: transparent; cursor: pointer; color: var(--muted);
  display: grid; place-items: center; line-height: 0;
  opacity: .55; transition: opacity .12s ease, color .12s ease;
  /* Above the text, but never in the way of a caret put near it. */
  z-index: 2;
}
.grip:hover, .grip:focus-visible { opacity: 1; color: var(--text); }
.grip svg { width: 14px; height: 14px; display: block;
            fill: none; stroke: currentColor; stroke-width: 1.6;
            stroke-linecap: round; }
/* The box it sits in has to be the thing it is positioned against, and the
   textarea needs room at the corner so the glyph never lands on a word. */
.has-grip { position: relative; display: flex; flex: 1 1 auto; min-width: 0; }
.has-grip > textarea { flex: 1 1 auto; min-width: 0; }
.has-grip > textarea { padding-right: 30px; }
/* An edit box grown to its full size. Height only: the width is the bubble's,
   and a wider one would sit outside the message it belongs to. */
.msg-edit textarea.tall { min-height: 55vh; }

/* ---- the leave ledger's History table ----
 *
 *     "Date / Description / Used Days (-) / Earned Days (+) / Balance"
 *
 * DEPLOYED BEFORE THE MARKUP THAT USES IT. A template is read from disk on
 * every request and a stylesheet is not, so markup added in the same breath as
 * its CSS is markup that is live and unstyled for however long the deploy takes.
 * These rules land first and style nothing until the next release.
 *
 * A TABLE, NOT A LIST OF CARDS. Five columns of numbers that have to be added
 * up by eye is the one case where a table is the right answer, and tabular
 * figures are what make a column of them checkable.
 */
.ledger-wrap { overflow-x: auto; margin: 10px 0 0; }
.ledger { width: 100%; border-collapse: collapse; font-size: .8rem; }
.ledger th, .ledger td { padding: 7px 9px; text-align: left; white-space: nowrap; }
.ledger thead th {
  font-size: .68rem; text-transform: uppercase; letter-spacing: .05em;
  color: var(--muted); font-weight: 600;
  border-bottom: 1px solid var(--panel-border);
}
.ledger tbody tr + tr td { border-top: 1px solid var(--panel-border); }
.ledger td.desc { white-space: normal; min-width: 14rem; }
.ledger td.num, .ledger th.num {
  text-align: right; font-variant-numeric: tabular-nums;
}
/* The two that are the point of the table. */
.ledger td.used { color: var(--warn); }
.ledger td.earned { color: var(--ok); }
.ledger td.bal { font-weight: 650; color: var(--text); }
/* Who made an adjustment, under their own words rather than inside them — a
   rename must not rewrite history. */
.ledger .by { display: block; color: var(--muted); font-size: .72rem; }
.ledger tr.kind-opening td, .ledger tr.kind-accrual td { background: var(--sunk); }
.ledger-filters { display: flex; gap: 8px; flex-wrap: wrap; align-items: center;
                  margin: 10px 0 0; }
.ledger-total { margin: 10px 0 0; font-size: .82rem; }
.ledger-total b { font-size: 1.05rem; }

/* ---- the recording indicator ----
 *
 *     "Debe haber un indicador de que se esta grabando."
 *
 * A RED DOT THAT PULSES, which is the one visual language everybody already
 * reads as "this is being recorded" — and it is on the call BAR, not in a panel
 * somebody has to open, because the person who most needs to see it is the one
 * who did not start it and is not looking for it.
 *
 * DEPLOYED BEFORE THE MARKUP THAT USES IT: a template is read from disk on
 * every request, a stylesheet is not.
 */
.callbox-rec {
  display: inline-flex; align-items: center; gap: 6px;
  border: 1px solid var(--bad, #e5484d); color: var(--bad, #e5484d);
  background: transparent; border-radius: 999px;
  padding: 3px 10px; font-size: 0.72rem; line-height: 1.5;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.callbox-rec::before {
  content: ""; width: 8px; height: 8px; border-radius: 50%;
  background: var(--bad, #e5484d); flex: 0 0 auto;
  animation: rec-pulse 1.6s ease-in-out infinite;
}
@keyframes rec-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
/* SOMEBODY WHO CANNOT SEE MOVEMENT still has to be told, so the dot is a
   decoration on a label that says the word. */
@media (prefers-reduced-motion: reduce) {
  .callbox-rec::before { animation: none; }
}
/* The button while it is on, so "recording" is visible without reading it. */
.call-rec.on { background: var(--bad, #e5484d); border-color: var(--bad, #e5484d);
               color: #fff; }

/* Somebody is on camera. The same pill as the screen-share one and for the
 * same reason: the faces live in the sheet, and the bar is all a person sees
 * while they are reading another group. */
.callbox-faces {
  border: 1px solid var(--blue-bright); background: transparent;
  color: var(--blue-bright); cursor: pointer;
  border-radius: 999px; padding: 3px 11px; font-size: 0.72rem; line-height: 1.5;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.callbox-faces:hover { background: rgba(95, 116, 221, .14); }

/* Leave categories, named in the brand's purple.
 *
 *     "Make these titles purple: Vacations, Sick leave, Birthday, Marriage
 *      leave, Parental leave"
 *
 * The number beside each stays in the text colour: the label is what is being
 * coloured, and colouring the figure too would make the whole tile shout.
 */
.tally-name { color: var(--violet); font-weight: 600; }

/* A section's own action, in its heading — "New Goal", "Start an Assessment".
 * Sits at the right of the <summary> without stopping it toggling: the anchor
 * takes the click, the rest of the row still opens the section. */
.sec-head { display: flex; align-items: baseline; gap: 10px; }
.sec-head > h2 { flex: 0 0 auto; }
.sec-action { margin-left: auto; font-size: .78rem; font-weight: 500; }
/* THE ACTION GOES RIGHT, NEXT TO THE CHEVRON — not in the middle.
 *
 *     "The "New Goal" button is still appearing in the middle of the OKR
 *      section. Move it to the right, next to the section's dropdown/expand
 *      arrow, as previously requested"  — Lucila, 2026-10-01
 *
 * The chevron (`summary.sec-head::after`) and the action BOTH had
 * `margin-left: auto`, and two auto margins in one flex row split the free
 * space between them: the button landed halfway along. The 29 Sep fix blamed
 * the open form underneath and closed it, which was right on its own and left
 * this untouched. With an action present the action takes the auto margin and
 * the chevron simply follows it. Same for Holidays' year buttons and the
 * Assessments count, which sat mid-row for the same reason. */
.sec > summary.sec-head:has(> .sec-action)::after { margin-left: 0; }

/* GABBIE PEOPLE IS PURPLE WHERE GABBIE IS ORANGE.
 *
 *     "Change all orange elements (buttons, lines, borders, or any other
 *      orange UI elements) to purple. Keep the same shade intensity/style —
 *      just swap the hue"  — Lucila, 2026-10-01
 *
 * The People pages only (`.people-page`, on their <section>), not the sidebar
 * or the rest of Gabbie: orange is the app's own colour, from the mascot, and
 * the request was about this module. Done by redefining the tokens rather
 * than touching rules, so anything orange drawn here follows. The hue moves
 * from ~26° to 265° with saturation and lightness kept, which is what "same
 * shade intensity" means. The gradients are restated because a custom property
 * that uses var(--orange) is resolved where it is defined, at :root. Purple at
 * the same lightness is darker to the eye than orange, so text on a primary
 * button turns light to keep its contrast. */
.people-page {
  --orange: hsl(265 88% 56%);
  --orange-bright: hsl(265 100% 65%);
  --orange-deep: hsl(265 77% 48%);
  --grad-warm: linear-gradient(103deg, var(--orange) 0%, var(--orange-bright) 100%);
  --grad-brand: linear-gradient(103deg, var(--blue) 0%, var(--orange) 135%);
}
.people-page .btn-primary,
.people-page .btn-primary:hover { color: #fff; }
.people-page .btn-primary { box-shadow: 0 4px 14px hsl(265 88% 56% / 0.28); }
/* The rules that wrote orange as a literal rather than through the token. */
.people-page .chip.on,
.people-page .choice input:checked + span { background: hsl(265 88% 56% / 0.2); }
.people-page .filters a.on { background: hsl(265 88% 56% / 0.18); color: #d7c2ff; }
.people-page .pill.orange { background: hsl(265 88% 56% / 0.16);
                            border-color: hsl(265 88% 56% / 0.4); color: #d7c2ff; }

/* Purple like a section heading, on the panels that are not a <details>. */
.sec-title { color: var(--accent-okr); }
.reports-faces .avatar { text-decoration: none; }

/* A PASSPORT RUNNING OUT, above the sections — on the person's own profile and
 * on HR's view of it. The warn token, not a red: it is something to act on,
 * not something that went wrong. */
.passport-alert {
  display: flex; align-items: center; gap: 10px;
  margin: 0 0 14px; padding: 10px 14px; border-radius: 10px;
  border: 1px solid var(--warn); color: var(--text);
  background: color-mix(in srgb, var(--warn) 12%, transparent);
  font-size: .86rem;
}
.passport-alert svg { flex: none; color: var(--warn); }

/* WHO IS OUT, on the main calendar.
 *
 *     "it should be a calendar layer that can be hidden if needed"
 *
 * Its own colour so the layer reads as a layer: a glance at a week should say
 * "three people away" without anybody parsing the chips. Warm rather than the
 * task palette, because being out is not a deadline.
 */
.cal-chip.ooo {
  background: rgba(242, 130, 46, .14); color: var(--orange-bright);
  border: 1px solid rgba(242, 130, 46, .34);
}
:root[data-theme="light"] .cal-chip.ooo { color: var(--orange-deep); }

/* "Fed by" — the objectives that feed into one, named rather than counted. */
/* PROGRESS FROM MILESTONES (2026-10-08): the count, the hint when there is
   nothing to compute from, each child's percentage, and editing a milestone. */
.okr-count { display: block; }
.okr-hint { display: block; color: var(--accent-okr); }
.okr-kid-pc { color: var(--accent-okr); font-weight: 650; margin-left: 4px; }
.kr-edit { display: inline-block; margin-left: 4px; }
.kr-edit > summary { list-style: none; cursor: pointer; color: var(--muted);
                     display: inline; font-size: .8rem; }
.kr-edit > summary::-webkit-details-marker { display: none; }
.kr-edit[open] { display: block; }
/* FED BY, a dropdown under every card — closed until clicked. */
.okr-fedby { font-size: .78rem; border-top: 1px solid var(--panel-border);
             margin-top: auto; }
.okr-fedby > summary { padding: 7px 13px; cursor: pointer; color: var(--muted);
                       list-style: none; }
.okr-fedby > summary::-webkit-details-marker { display: none; }
.okr-fedby > summary::after { content: " \25BE"; }
.okr-fedby[open] > summary::after { content: " \25B4"; }
.okr-fedby > summary:hover { background: var(--sunk); }
.okr-fedby ul { margin: 0; padding: 0 13px 9px 1.9rem; }
.okr-fedby li { margin: 1px 0; }
.okr-fedby p { margin: 0; padding: 0 13px 9px; }

/* A CROPPED MESSAGE'S TWO WAYS OUT.
 *
 *   "rather than [cropped] maybe say [expand] with an arrow down, and
 *    [full view]"
 *
 * The old marker was a line of italic text saying *[cropped]*, which told you
 * the message went on and gave you nothing to do about it — the only way to
 * read the rest was the ⋮ menu, three taps away and not obviously the answer to
 * the question the marker had just raised. A marker that states a problem
 * should carry its own remedy.
 *
 * TWO REMEDIES, because they are different intents. Expand opens the rest in
 * place, for somebody who wants to read three more lines and carry on down the
 * feed. Full view leaves for the document, for somebody who came to read the
 * whole thing.
 *
 * Deliberately quiet: a pasted log is not the most important thing on the page
 * just because it is the longest, and two loud buttons under every one of them
 * would make the feed look like a form. */
.msg-cropped {
  display: flex; gap: 6px; flex-wrap: wrap;
  margin: 8px 0 0;
  /* The rule reads as "the text stops here" — a border rather than a word, so
     the marker costs no line of its own once the buttons are carrying it. */
  padding: 7px 0 0;
  border-top: 1px dashed var(--panel-border);
}
.msg-crop-btn {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 9px;
  font: inherit; font-size: 0.74rem; line-height: 1.5;
  color: var(--text-dim);
  background: transparent;
  border: 1px solid var(--panel-border);
  border-radius: 999px;
  cursor: pointer;
}
.msg-crop-btn:hover { background: var(--panel-hover); color: var(--text); }
.msg-crop-btn:focus-visible {
  outline: 2px solid var(--orange-bright); outline-offset: 1px;
}
/* THE ARROW TURNS RATHER THAN BEING SWAPPED, so the control says which way it
   is about to go without the label changing width and shifting the button
   beside it. */
.msg-crop-caret { display: inline-block; font-size: 0.7rem; transition: transform .15s; }
.msg-cropped.open .msg-crop-caret { transform: rotate(180deg); }
@media (prefers-reduced-motion: reduce) { .msg-crop-caret { transition: none; } }
/* WAITING FOR THE REST. The button is the only thing on screen that knows the
   fetch is happening, so it says so rather than sitting inert. */
.msg-crop-btn[disabled] { opacity: .55; cursor: default; }

/* A SYSTEM LINE THAT IS MORE THAN A LINE — the call announcement.
 *
 *   "el post que se pone con el link, con la URL para unirse, debería ser
 *    clickeable, para que uno pueda clickearlo o copiarlo. No hay que
 *    seleccionarlo dentro del texto que se presenta."
 *
 * `.sys` is a narration pill: 999px radius, one line, sized for "Ana added
 * Ben". The meeting announcement is a heading, a URL and a caveat, and it has
 * been squeezed into that pill as raw markdown source since the day it was
 * written — which is why the link was neither clickable nor easy to select.
 *
 * So a second shape for the same kind of message, rather than stretching the
 * pill: the pill is right for what it is right for. */
.msg.system .sys.notice {
  border-radius: var(--radius-bubble, 14px);
  padding: 10px 14px;
  text-align: left;
  max-width: 34rem;
  font-size: 0.82rem;
  line-height: 1.45;
}
.msg.system .sys.notice p { margin: 0 0 6px; }
.msg.system .sys.notice p:last-child { margin-bottom: 0; }
/* THE URL IS THE LINK TEXT, so it can be long. It wraps anywhere rather than
   pushing the bubble past the column — the same reasoning as a pasted URL in an
   ordinary message. */
.msg.system .sys.notice a { overflow-wrap: anywhere; }
/* WHAT TO DO ABOUT IT, under the words rather than inside them. Join is the
   action; Copy is for the person who wants it in another browser, on a phone,
   or in a calendar invitation — which is the case that was impossible. */
.sys-actions { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }

/* WHOSE BIRTHDAY IT IS — "Display each birthday entry in purple, showing the
 * person's name."
 *
 * Purple was asked for and is also the right slot: orange is already the
 * out-of-office layer, and the two sit side by side at the top of a day. Two
 * facts about people, told apart at a glance without reading either.
 *
 * `--violet` rather than a new hex: it is defined for both themes, which a
 * literal here would not be, and the light theme's violet is a much darker one
 * because the chip's ground is pale there. */
.cal-chip.birthday {
  background: color-mix(in srgb, var(--violet) 16%, transparent);
  color: var(--violet);
  border: 1px solid color-mix(in srgb, var(--violet) 38%, transparent);
}

/* ---------------------------------------- WHOSE BIRTHDAY IT IS, AND WHO IS OUT
 *
 *     "Add a banner at the top left of the Gabbie app that alerts the whole
 *      team when someone is having their birthday, similar to how Telegram
 *      displays its notification banners."
 *     "Add a section at the top of the app showing Who's Out Today."
 *                                                   — Lucila, 2026-09-29
 *
 * TWO STRIPS, NOT ONE. The request listed "birthday" among the reasons somebody
 * might be under "Who's Out Today", and somebody whose birthday it is is AT
 * WORK — saying otherwise to nineteen colleagues is worse than saying nothing.
 * So they are separate, they read differently, and only the ones with something
 * to say are drawn at all.
 *
 * TOP LEFT AND OUT OF THE WAY. Fixed rather than in the flow: every page in
 * this app is its own load, and a band that pushes the layout down would make
 * the whole app jump on the one day of the year it appears. */
.top-strips {
  position: fixed; top: 8px; left: 8px; z-index: 60;
  display: flex; flex-direction: column; gap: 6px;
  max-width: min(26rem, calc(100vw - 16px));
  pointer-events: none;                 /* the page underneath stays usable */
}
.top-strips > * { pointer-events: auto; }

.strip {
  display: flex; align-items: center; gap: 9px;
  padding: 6px 10px 6px 7px;
  border-radius: 999px;
  font-size: .8rem; line-height: 1.3;
  background: var(--panel-solid);
  border: 1px solid var(--panel-border);
  box-shadow: 0 6px 20px rgba(0, 0, 0, .28);
}
.strip .avatar { flex: 0 0 auto; }
.strip-text { min-width: 0; overflow: hidden; text-overflow: ellipsis;
              white-space: nowrap; }
.strip-x {
  flex: 0 0 auto; margin-left: 2px;
  background: transparent; border: 0; cursor: pointer;
  color: var(--muted); font-size: 1rem; line-height: 1;
  padding: 2px 4px; border-radius: 6px;
}
.strip-x:hover { background: var(--panel-hover); color: var(--text); }

/* The birthday one carries the same violet as its calendar chip, so the banner
   and the calendar are recognisably the same fact. */
.strip.birthday {
  border-color: color-mix(in srgb, var(--violet) 46%, transparent);
  background: color-mix(in srgb, var(--violet) 14%, var(--panel-solid));
}
.strip.birthday b { color: var(--violet); }

/* WHO IS OUT was orange, the calendar absence layer's colour, until 2026-10-13. */
/* PURPLE SINCE 2026-10-13 — "change the color of the "Out today" banner …
   from orange to purple … Reuse the existing purple color variable … Remove
   all orange from the banner" (Lucila). `--violet` is defined for both themes
   (a light periwinkle on dark, a deep violet on light), so the tint under it
   stays faint and the text readable in each. The calendar's own OOO chips are
   a different thing and keep their colour. */
.strip.out {
  color: var(--violet);
  border-color: color-mix(in srgb, var(--violet) 46%, transparent);
  background: color-mix(in srgb, var(--violet) 8%, var(--panel-solid));
}
.strip.out .strip-k { color: var(--violet); font-weight: 600; }

/* ALREADY DISMISSED TODAY. Set by the inline script in base.html before this
   element is parsed, so the strip is never painted at all — see the comment
   there. app.js still removes the node a moment later; this is only about the
   frame before it gets the chance. */
:root.bday-gone #bday-strip { display: none; }

/* MORE THAN ONE BIRTHDAY takes turns rather than stacking: two strips is a
   notice, five is a wall in front of the app. */
.strip-turn { display: none; }
.strip-turn.on { display: inline; }

@media (max-width: 640px) {
  .top-strips { top: 6px; left: 6px; right: 6px; max-width: none; }
  .strip { font-size: .74rem; }
}

/* OUT TODAY, ABOVE THE SEARCH BOX — on every screen.
 *
 *     "out of office shows up in a weird spot in mobile view. Maybe move above
 *      Search?"
 *     "the "out today" also looks weird on desktop, please move to the same
 *      place above the Search"                      — Paula, 2026-10-02
 *
 * It used to float in .top-strips with the birthday, fixed at the top left,
 * where on a phone it lay across the header and on a desktop sat over the top
 * of the chat list. Unlike the birthday it has no ×. Now it is drawn only in
 * the chat list, in the flow, above the search box (partials/sidebar.html),
 * pushing the list down instead of covering anything. `.strip` gives it its
 * shape; this only places it. */
.side-out { margin: 10px 12px 0; box-shadow: none; }

/* ------------------------------------------- OUT TODAY, WITH FACES, AND HOLIDAYS
 *
 *     "Add a "Out today" block showing everyone who is on holiday or approved
 *      time off today, with their avatar, name and type of leave … If nobody
 *      is out, show "Everyone is in today". Add a "Next days" option"
 *     "Show the upcoming holidays on the day they are happening in the top
 *      left of the main view, in the existing style but in yellow"
 *                                                   — Lucila, 2026-10-12
 *
 * The strip above the search box is now the SUMMARY of a <details>: the faces
 * and names as before, and a tap opens the list with each person's one word
 * and the way to the next days. No script — <details> is the toggle. */
.side-out-box { margin: 10px 12px 0; }
.side-out-box > summary { list-style: none; cursor: pointer; margin: 0; }
.side-out-box > summary::-webkit-details-marker { display: none; }
.side-out-box > summary .strip-text { flex: 1 1 auto; }
.side-out-box > summary::after {
  content: "\25B8"; color: var(--muted); font-size: .7rem; transition: transform .15s;
}
.side-out-box[open] > summary::after { transform: rotate(90deg); }
.side-out-box.all-in .strip-k { color: var(--violet); font-weight: 500; }
.side-out-box .strip.out { border-radius: 14px; }
.out-faces { display: inline-flex; flex: 0 0 auto; }
.out-faces .avatar { width: 20px; height: 20px; font-size: .55rem;
                     margin-left: -6px; box-shadow: 0 0 0 2px var(--panel-solid); }
.out-faces .avatar:first-child { margin-left: 0; }
.side-out-body {
  margin: 6px 0 0; padding: 8px 10px; border-radius: 12px;
  background: var(--panel); border: 1px solid var(--panel-border);
  font-size: .8rem;
}
.side-out-body .out-list { margin: 0 0 6px; }
.side-out-next { display: inline-flex; gap: 10px; font-size: .78rem; }

.out-list { list-style: none; margin: 0; padding: 0;
            display: flex; flex-direction: column; gap: 6px; }
.out-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.out-name { min-width: 0; flex: 1 1 auto; overflow: hidden;
            text-overflow: ellipsis; white-space: nowrap; }
/* One purple for all three words (2026-10-13): the label says which; the
   colour says "out", like the banner it sits in. */
.out-label {
  flex: 0 0 auto; font-size: .7rem; padding: 1px 8px; border-radius: 999px;
  background: color-mix(in srgb, var(--violet) 12%, transparent);
  color: var(--violet);
  border: 1px solid color-mix(in srgb, var(--violet) 40%, transparent);
}
.side-out-box .out-name { color: var(--violet); }
.side-out-box > summary::after { color: var(--violet); }
.side-out-body {
  border-color: color-mix(in srgb, var(--violet) 46%, transparent) !important;
  background: color-mix(in srgb, var(--violet) 6%, var(--panel-solid)) !important;
}
.side-out-box a, .side-out-box a:hover, .side-out-box a:focus-visible { color: var(--violet); }
.side-out-box a:hover { text-decoration: underline; }
.side-out-box > summary:hover .strip.out,
.side-out-box > summary.strip.out:hover {
  background: color-mix(in srgb, var(--violet) 14%, var(--panel-solid));
}
.side-out-box > summary:focus-visible { outline: 2px solid var(--violet); outline-offset: 2px; }
.out-grid { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 900px) { .out-grid { grid-template-columns: 1fr 1fr; } }
.out-panel h2 { margin: 0 0 10px; font-size: 1.05rem; }
.out-day { margin: 12px 0 6px; font-size: .85rem; color: var(--muted); font-weight: 600; }
.out-day:first-of-type { margin-top: 0; }

/* THE HOLIDAY STRIP — the birthday strip's shape, in yellow, beside it. */
.strip.holiday {
  border-color: color-mix(in srgb, var(--c-yellow) 50%, transparent);
  background: color-mix(in srgb, var(--c-yellow) 16%, var(--panel-solid));
}
.strip.holiday b { color: var(--c-yellow); }
.strip-ico { flex: 0 0 auto; padding-left: 4px; }
.strip-link { flex: 0 0 auto; font-size: .75rem; color: var(--c-yellow);
              text-decoration: underline; white-space: nowrap; }
:root.hol-gone #hol-strip { display: none; }

.hol-today { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 12px; }
.hol-pill {
  font-size: .82rem; padding: 4px 12px; border-radius: 999px;
  background: color-mix(in srgb, var(--c-yellow) 16%, transparent);
  border: 1px solid color-mix(in srgb, var(--c-yellow) 42%, transparent);
}
.hol-month { margin: 0 0 12px; }
.hol-month h2 { margin: 0 0 8px; font-size: 1rem; }
.hol-table { width: 100%; border-collapse: collapse; font-size: .86rem; }
.hol-table td { padding: 5px 8px 5px 0; border-top: 1px solid var(--panel-border);
                vertical-align: top; }
.hol-table tr:first-child td { border-top: 0; }
.hol-date { white-space: nowrap; width: 8.5em; color: var(--muted); }
.hol-name { font-weight: 600; }
.hol-who { white-space: nowrap; text-align: right; }
.hol-now td { background: color-mix(in srgb, var(--c-yellow) 12%, transparent); }
@media (max-width: 640px) {
  .hol-table tr { display: grid; grid-template-columns: auto 1fr; gap: 0 8px;
                  border-top: 1px solid var(--panel-border); padding: 6px 0; }
  .hol-table tr:first-child { border-top: 0; }
  .hol-table td { border: 0; padding: 0; }
  .hol-where { grid-column: 2; }
  .hol-who { display: none; }
}

/* THE CALENDAR'S HOLIDAY LAYER — "holidays in yellow". */
.cal-chip.holiday {
  background: color-mix(in srgb, var(--c-yellow) 16%, transparent);
  color: var(--c-yellow);
  border: 1px solid color-mix(in srgb, var(--c-yellow) 42%, transparent);
}

/* WHERE ONE PERSON SITS — the org chart's boxes, the person in purple. */
.org-position { overflow-x: auto; padding: 16px 8px; }
.org-box.org-focus {
  border: 2px solid var(--violet);
  background: color-mix(in srgb, var(--violet) 14%, var(--panel));
}
.org-box .avatar.sm { margin: 0 auto; }

/* THE ORG CHART ICON beside the phone: on a profile banner and in a chat. */
.person-banner-tools { margin-left: auto; display: flex; gap: 6px; align-self: center; }
.person-banner-tools .icon-btn { color: #fff; background: rgba(255, 255, 255, .14); }
.person-banner-tools .icon-btn:hover { background: rgba(255, 255, 255, .26); }
/* A 1-1 chat's Profile and OKRs buttons — the app's purple (Lucila, 2026-10-14). */
.icon-btn.people-btn { color: var(--violet); }
.icon-btn.people-btn:hover { color: var(--violet); }

/* DUPLICATE A MEETING — a deeplink into Outlook's compose window.
 *
 * Quiet until wanted: a calendar cell is small and every meeting already has a
 * chip, a time and a name in it. On a touch screen there is no hover, so it
 * stays visible there rather than being unreachable. */
.cal-dup {
  display: inline-flex; align-items: center; justify-content: center;
  width: 1.1rem; height: 1.1rem; margin-left: 2px;
  border-radius: 5px; font-size: .78rem; line-height: 1;
  color: var(--muted); text-decoration: none;
  opacity: 0; transition: opacity .12s;
}
.cal-cell:hover .cal-dup, .cal-dup:focus-visible { opacity: 1; }
.cal-dup:hover { background: var(--panel-hover); color: var(--text); }
@media (hover: none) { .cal-dup { opacity: .7; } }
@media (prefers-reduced-motion: reduce) { .cal-dup { transition: none; } }

/* SAVE A VIDEO — ours, because only Chrome's player offers one of its own.
   Under the clip rather than over it: an overlay on a video is a control that
   covers the thing it is about, and the player already owns that corner. */
.video-get { margin-top: 6px; display: inline-flex; align-items: center; gap: 5px; }

/* WHAT A DOWNLOAD WILL GIVE YOU, on the button.
 *
 *     "download button to download MD file as MD file, not PDF. maybe show both
 *      with file extension type"                  — Paula, 2026-09-29
 *
 * Both controls existed; they were two mute icons side by side and only the
 * tooltip said which was which — and a tooltip does not exist on a phone. The
 * extension is four characters and removes the guess entirely. */
.icon-btn.with-ext { width: auto; flex: 0 0 auto; gap: 3px; padding: 0 8px; }
.btn-ext { font-size: .64rem; font-weight: 600; letter-spacing: .02em;
           opacity: .85; }

/* A MARKDOWN FILE IN THE FEED, shown rather than filed away behind an icon.
 * The name sits above it because unlike a note this is a FILE somebody sent,
 * and its filename is part of what they sent. */
.md-attach { margin-top: 4px; }
.md-attach-name {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: .76rem; font-weight: 600; color: var(--text-dim);
  text-decoration: none; margin-bottom: 4px;
}
.md-attach-name:hover { color: var(--text); text-decoration: underline; }

/* THE FORWARD SHEET: the list scrolls, the button stays.
 *
 *     "1. I have to scroll down to see the forward button"  — Agustin
 *
 * The room list was capped at 44vh with the note and the buttons below it in
 * the ordinary flow, so on a phone the thing you press to finish was under the
 * fold. A dialog whose action you have to go looking for reads as one with no
 * action at all. */
#forward-form { display: flex; flex-direction: column; min-height: 0; }
#forward-form .sheet-inner { display: flex; flex-direction: column; min-height: 0; }
.fwd-list { gap: 1px; margin: 0 0 12px; flex: 1 1 auto; min-height: 4rem;
            overflow-y: auto; }
.fwd-foot { flex: 0 0 auto; }


/* ---------------------------------------------- pending action items
 *
 *     "when you click on it, it will slide in from the right the list of
 *      pending action items"                          — Sam, 2026-10-05
 *
 * FROM THE RIGHT, OVER THE THREAD, and not a page: what is in it only makes
 * sense beside what was said, and sending somebody to another screen to triage
 * three lines is how three lines never get triaged.
 */
.pending-drawer {
  position: fixed; inset: 0 0 0 auto; z-index: 70;
  width: min(420px, 100%);
  display: flex; flex-direction: column;
  /* --panel-solid, NOT --panel. The latter is a 4% white wash meant to sit
   * OVER --bg, and the comment next to it says what happens when something
   * floating uses it: the page reads straight through. On the dark theme that
   * was merely soft; on the light one the drawer was barely there. */
  background: var(--panel-solid); border-left: 1px solid var(--panel-border);
  box-shadow: var(--shadow);
  transform: translateX(100%);
  transition: transform .18s ease;
}
.pending-drawer.on { transform: translateX(0); }
.pending-drawer header {
  display: flex; align-items: center; gap: 10px;
  padding: 14px 16px; border-bottom: 1px solid var(--panel-border);
}
.pending-drawer header h3 { margin: 0; font-size: 1rem; }
.pending-body { overflow-y: auto; flex: 1; }
/* The sheet is above the page; the page behind it stops scrolling under it. */
/* THE SAME SHADE AS `dialog.sheet::backdrop`, which is the thing this is:
 * a backdrop is meant to be dark on a light page as well as on a dark one,
 * and taking its colour from the one already in here means there is one
 * answer to "how dark is behind a sheet" rather than two that drift. */
.pending-veil {
  position: fixed; inset: 0; z-index: 69;
  background: rgba(6, 4, 16, 0.6); opacity: 0;
  -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px);
  transition: opacity .18s ease;
}
.pending-veil.on { opacity: 1; }

.pending-list { list-style: none; margin: 0; padding: 0; }
.pending { padding: 14px 16px; border-bottom: 1px solid var(--panel-border); }
.pending-what { line-height: 1.45; }
.pending-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; }
.pending-row .grow { flex: 1; }
.pending-src { font-size: .78rem; opacity: .7; }
.pending-assign { margin-top: 10px; display: grid; gap: 8px; }
.pending-when { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.pending-date { max-width: 150px; }
/* The same rows under the Action Items checklist, one per open item, each
 * able to become a task in any group — see actionItems() in app.js. */
.actions-tasks { margin-top: 10px; max-height: 40vh; overflow-y: auto;
                 border-top: 1px solid var(--panel-border); }
.actions-tasks .pending { padding: 10px 4px; }
/* On its way out: it has been dealt with, and the row leaving says so without
 * a toast anybody has to read. */
.pending.gone { opacity: 0; transform: translateX(16px); transition: .18s ease; }

@media (prefers-reduced-motion: reduce) {
  .pending-drawer, .pending-veil, .pending.gone { transition: none; }
}

/* =========================================================== My Agents
 *
 * Claude Code sessions on the person's own VPS (agents/). The terminal styles
 * are the ones the first My Agents had (removed 0f835b4, restored 2026-10-08);
 * the list and its dots are new.
 */

/* ---- the session list ---- */
.ag-list { list-style: none; margin: 0; padding: 0;
           display: flex; flex-direction: column; gap: 8px; }
.ag-row { border: 1px solid var(--panel-border); border-radius: 11px;
          background: var(--panel-raised); padding: 11px 13px; }
.ag-row.ag-red { opacity: .78; }
.ag-name { font-weight: 650; color: var(--text); min-width: 0;
           overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ag-title { margin: 5px 0 0 19px; font-size: .84rem; color: var(--text-dim); }
.ag-last { margin: 4px 0 0 19px; font-size: .78rem; color: var(--text-dim);
           overflow-wrap: anywhere; }
.ag-actions { gap: 6px; margin: 9px 0 0 19px; flex-wrap: wrap; align-items: flex-start; }
.ag-actions form { margin: 0; }
.ag-more { position: relative; }
.ag-more > summary { list-style: none; }
.ag-more > summary::-webkit-details-marker { display: none; }
.ag-more-body { margin-top: 8px; padding: 10px; border-radius: 9px;
                background: var(--sunk); min-width: min(320px, 78vw); }

/* THE DOT.
 *     "a red dot for exited session, a yellow for a session running but
 *      idling and a green dot for a session that is actually doing stuff"
 * Green breathes, because "doing stuff" is the one state that changes by
 * itself and a still green dot reads as "fine", not "busy". */
.ag-dot { flex: none; display: inline-block; width: 10px; height: 10px;
          border-radius: 50%; background: var(--muted); }
.ag-dot-red { background: #e4574d; }
.ag-dot-yellow { background: #e8b931; }
.ag-dot-green { background: #3ec47a; box-shadow: 0 0 0 0 rgba(62, 196, 122, .6);
                animation: ag-breathe 1.6s ease-out infinite; }
@keyframes ag-breathe {
  0% { box-shadow: 0 0 0 0 rgba(62, 196, 122, .55); }
  100% { box-shadow: 0 0 0 7px rgba(62, 196, 122, 0); }
}
@media (prefers-reduced-motion: reduce) { .ag-dot-green { animation: none; } }

.term-ended { flex: none; padding: 14px; border-radius: 10px;
              border: 1px solid var(--panel-border); background: var(--panel-raised); }

/* Page Up / Page Down: they move the page's view, not Claude, so they read
 * as a pair apart from the keys that type. */
.term-key-page { margin-left: auto; font-size: .72rem; letter-spacing: .04em;
                 border-color: var(--panel-border); }
.term-key-page + .term-key-page { margin-left: 0; }
/* "N lines back · Back to live", over the bottom of the screen. */
.term-back {
             position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%);
             z-index: 5; padding: 6px 14px; border-radius: 999px; cursor: pointer;
             border: 1px solid var(--orange); background: rgba(13, 10, 30, .92);
             color: var(--orange-bright); font-size: .8rem; white-space: nowrap; }

/* The microphone beside Send on the terminal page. Red and breathing while it
 * listens, so nobody leaves it recording by mistake. */
.term-mic { flex: none; width: 42px; height: 40px; border-radius: 10px;
            display: grid; place-items: center; cursor: pointer;
            border: 1px solid var(--panel-border); background: var(--panel);
            color: var(--text-dim); }
.term-mic:hover { color: var(--text); background: var(--panel-hover); }
.term-mic .ico-svg { width: 20px; height: 20px; }
/* The same two states the console's microphone wears, on the one in an
   agent's composer: recording is unmistakable, and thinking is not clickable.
   One rule for both rather than a second copy that drifts. */
.term-mic[data-state="thinking"], #ag-mic[data-state="thinking"] { opacity: .55; }

/* A file on its way into an agent's folder, in the composer's own preview
   strip — the same place an attachment waits in a chat. */
.attach-chip { font-size: .78rem; padding: 3px 8px; border-radius: 999px;
               background: var(--sunk); border: 1px solid var(--panel-border);
               color: var(--muted); }

.term-mic[data-state="recording"], #ag-mic[data-state="recording"] { color: #fff; background: #e4574d; border-color: #e4574d;
            animation: ag-breathe-red 1.4s ease-out infinite; }
.term-mic[data-state="thinking"] { opacity: .55; cursor: progress; }
@keyframes ag-breathe-red {
  0% { box-shadow: 0 0 0 0 rgba(228, 87, 77, .55); }
  100% { box-shadow: 0 0 0 9px rgba(228, 87, 77, 0); }
}
@media (prefers-reduced-motion: reduce) { .term-mic[data-state="recording"] { animation: none; } }

/* "via agent" beside a message an agent sent or edited — quiet, but there. */
.msg-via-agent { font-style: italic; }

/* ---- the left column: sessions where the chats are ---- */
/* The scrolling body of the column — .sidebar-list's shape under its own name,
 * because app.js fills anything called .sidebar-list with chats. */
.ag-side-list { overflow-y: auto; padding: 4px 8px 16px; flex: 1;
                -webkit-overflow-scrolling: touch; }
.ag-side-head { justify-content: space-between; }
.ag-side-title { display: flex; align-items: center; gap: 7px; font-weight: 650;
                 color: var(--text); }
.ag-side-title .ico-svg { width: 18px; height: 18px; }
.ag-side-row { display: flex; align-items: center; gap: 10px; padding: 9px 10px;
               border-radius: 10px; color: var(--text); text-decoration: none; }
.ag-side-row:hover { background: var(--sunk); }
.ag-side-row.active { background: var(--panel-raised); }
.ag-side-text { display: flex; flex-direction: column; min-width: 0; }
.ag-side-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis;
                white-space: nowrap; }
.ag-side-sub { font-size: .74rem; color: var(--text-dim); overflow: hidden;
               text-overflow: ellipsis; white-space: nowrap; }
.ag-new { margin-bottom: 14px; }
.ag-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: .78rem;
             color: var(--text-dim); }
.ag-legend > span { display: inline-flex; align-items: center; gap: 6px; }
/* The "…" panel in the terminal bar opens leftwards, over the screen.
 *
 * OPAQUE, because it floats. --sunk is a tint (5% in the light theme), right
 * for a panel in the flow and wrong for one on top of a conversation: every
 * line of the transcript showed through it and its own text, which is the
 * "overlapping" Giuliana sent a screenshot of. */
.ag-more-right { position: absolute; right: 0; z-index: 20;
                 background: var(--panel-raised);
                 border: 1px solid var(--panel-border);
                 box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
                 /* NEVER WIDER THAN THE SCREEN. It opens leftwards from the
                  * "…" at the right edge, and on a phone its unwrapped
                  * buttons made it wider than the screen: everything left
                  * of the edge was cut off — the second "overlap",
                  * Mariano's screenshot, 2026-10-15. */
                 width: min(360px, calc(100vw - 24px));
                 max-width: calc(100vw - 24px); box-sizing: border-box; }
.ag-more-right .btn { white-space: normal; height: auto; text-align: left; }
.ag-more-right .btn.block { display: block; }
.ag-more-right input { min-width: 0; }

/* ---- the file manager ---- */
.ag-trail { display: flex; flex-wrap: wrap; gap: 5px; align-items: center;
            font-size: .82rem; margin: 0 0 12px; color: var(--text-dim); }
.ag-trail a { color: var(--text-dim); }
.ag-trail a:hover { color: var(--text); }
.ag-trail a:last-of-type { color: var(--text); font-weight: 600; }
.ag-files { list-style: none; margin: 14px 0 0; padding: 0;
            display: flex; flex-direction: column; gap: 1px; }
.ag-files li { display: flex; align-items: center; gap: 10px;
               padding: 8px 10px; border-radius: 9px; }
.ag-files li:hover { background: var(--sunk); }
.ag-files form { margin: 0; }
.ag-file-name { flex: 1; min-width: 0; overflow: hidden;
                text-overflow: ellipsis; white-space: nowrap; }

/* ---- the terminal ---- */
.term-page { padding: 10px 10px 8px; overflow: hidden; display: flex;
  flex-direction: column; gap: 8px; }
.term-bar { display: flex; align-items: center; gap: 8px; flex: none; }
.term-name { font-size: .95rem; letter-spacing: .01em; }
.term-state { font-size: .72rem; color: var(--warn); }

.term-screen { flex: 1 1 auto; min-height: 0; overflow: hidden; position: relative;
  border: 1px solid var(--panel-border); border-radius: 10px;
  background: #0d0a1e; padding: 6px 8px; }
.term-screen .xterm { height: 100%; }
/* xterm draws its own scrollbar track; there is no scrollback to scroll. */
.term-screen .xterm-viewport { overflow: hidden !important; }

.term-keys { flex: none; display: flex; gap: 6px; overflow-x: auto;
  padding-bottom: 2px; }

/* THE KEYS ARE NOT `.btn`, and that is deliberate: a button in a row of nine
 * that is pressed every few seconds wants to read as a key on a keyboard, not
 * as an action in a form. */
.term-key {
  flex: 0 0 auto; min-width: 44px; height: 38px; padding: 0 10px;
  border-radius: 10px; cursor: pointer;
  border: 1px solid transparent; background: var(--panel);
  color: var(--text-dim); font-size: .95rem; line-height: 1;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.term-key:hover { background: var(--panel-hover); color: var(--text); }
.term-key:active { transform: translateY(1px); }

/* OUTLINED, because an arrow is a shape and not a word: without an edge the
 * four of them read as decoration in the row rather than as things to press. */
.term-key-arrow {
  border-color: var(--blue-bright); color: var(--text);
  font-size: 1.2rem; background: transparent;
}
.term-key-arrow:hover { background: rgba(95, 116, 221, .14); }

/* The one that is pressed constantly, and spelled out. */
.term-key-wide {
  min-width: 82px; letter-spacing: .09em; font-size: .72rem; font-weight: 700;
  border-color: var(--orange); color: var(--orange-bright);
  background: transparent;
}
.term-key-wide:hover { background: rgba(242, 130, 46, .14); color: var(--orange-bright); }

/* The terminal page takes a drop anywhere on it, so the whole screen says so. */
.term-screen.dropping { border-color: var(--orange); }
.term-drop {
  position: absolute; inset: 0; display: grid; place-items: center;
  border-radius: 10px; background: rgba(13, 10, 30, .82);
  color: var(--orange-bright); font-size: .9rem; pointer-events: none;
}

/* The link a session offered. Long, and a URL is read by its ends as much as
 * its start, so it wraps anywhere rather than being cut off. */
.term-link-url {
  margin: 0 0 14px; padding: 10px 12px; border-radius: 10px;
  background: var(--sunk); border: 1px solid var(--panel-border);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: .78rem; line-height: 1.45; word-break: break-all;
  max-height: 30vh; overflow-y: auto; user-select: all;
}

.term-say { flex: none; display: flex; gap: 8px; align-items: flex-end; }
.term-say textarea { flex: 1 1 auto; min-width: 0; resize: none;
  /* 16px or iOS zooms the whole page when it is focused — the same rule the
     chat composer follows. */
  font-size: 16px; line-height: 1.35; padding: 9px 11px;
  border-radius: 10px; border: 1px solid var(--panel-border);
  background: var(--panel-solid); color: var(--text); max-height: 96px; }


/* ------------------------------------------- Company OKRs, charts, wide People
 *
 *     "Move Company OKRs out of Julian's profile into a top-level "Company
 *      OKRs" section … Chart view for OKRs … Wider layout for desktop"
 *                                                     — Lucila, 2026-10-08
 */

/* The list: each Company OKR's card, its children indented under it. */
.company-okrs { display: flex; flex-direction: column; gap: 18px; }
.company-okr > .okr-card { border-color: var(--accent-okr); }
.okr-tree, .okr-tree ul { list-style: none; margin: 0; padding: 0; }
.okr-tree { margin: 6px 0 0 14px; border-left: 2px solid var(--accent-okr); }
.okr-tree ul { margin-left: 18px; border-left: 1px solid var(--panel-border); }
.okr-tree li { position: relative; }
.okr-row {
  flex: 1; min-width: 0;
  display: grid; grid-template-columns: auto minmax(0, 1fr) 120px 3.2em;
  align-items: center; gap: 12px; padding: 7px 10px 7px 14px;
  color: var(--text); text-decoration: none; border-radius: var(--radius-sm);
}
.okr-row:hover { background: var(--sunk); }
.okr-row-main { display: flex; flex-direction: column; min-width: 0; }
.okr-row-title { font-size: .88rem; line-height: 1.3; }
.okr-row .goal-bar { margin: 0; }
.okr-row .okr-kid-pc { text-align: right; margin: 0; }
.okr-tree-empty { margin: 6px 0 0 16px; }
@media (max-width: 600px) {
  .okr-row { grid-template-columns: auto minmax(0, 1fr) 3em; }
  .okr-row .goal-bar { grid-column: 2 / -1; grid-row: 2; }
}

/* COLLAPSED BY DEFAULT, by app.js (`.js-collapse`); with no script every
   level stays open. One arrow per OKR with children, at every level. */
.js-collapse .okr-kids:not(.open) { display: none; }
.okr-line { display: flex; align-items: center; gap: 2px; }
.okr-arrow, .okr-arrow-gap { flex: none; width: 26px; height: 26px; }
.okr-arrow {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  border: 0; border-radius: 6px; background: none; color: var(--accent-okr);
  cursor: pointer; padding: 0;
}
.okr-arrow:hover { background: var(--sunk); }
.okr-arrow::before { content: "\25B8"; font-size: .95rem; transition: transform .15s; }
.okr-arrow[aria-expanded="true"]::before { transform: rotate(90deg); }
/* Under a Company OKR's card: the same arrow, with words, the card's width. */
.okr-arrow.okr-arrow-bar {
  width: auto; height: auto; padding: 6px 10px; margin: 6px 0 0 6px;
  font-size: .82rem; color: var(--text);
}
.okr-arrow-bar::before { color: var(--accent-okr); }
/* The owner's face: round, initials on purple when there is no picture. */
.avatar.okr-face {
  flex: none; width: 26px; height: 26px; border-radius: 50%;
  font-size: .62rem; background: hsl(265 88% 56%); color: #fff;
  overflow: hidden;
}
.avatar.okr-face .avatar-img { width: 100%; height: 100%; object-fit: cover;
  border-radius: 50%; }
.okr-card summary .okr-face { margin-right: 6px; vertical-align: middle; }
/* Arrived at from a link: the card says it is the one. */
.okr-linked { outline: 2px solid var(--accent-okr); outline-offset: 2px;
  border-radius: var(--radius-sm); transition: outline-color 1s; }

/* The chart: the org chart's tree with purple lines and an OKR in each box. */
.okr-chart ul li::before, .okr-chart ul li::after,
.okr-chart li > ul::before { background: var(--accent-okr); }
.okr-box { width: 168px; height: 156px; gap: 3px; justify-content: flex-start; }
.okr-box .okr-face { width: 30px; height: 30px; }
.okr-box-n { color: var(--muted); font-weight: 500; }
/* One Company OKR open at a time (app.js, or ?open= without it), and a
   folded level hides every row under it. */
.okr-chart > li.okr-top:not(.open) > ul,
.okr-chart li.cut > ul { display: none; }
.okr-chart > li.okr-top > .okr-box { cursor: pointer; }
.okr-chart > li.okr-top.open > .okr-box { box-shadow: 0 0 0 3px hsl(265 88% 56% / .35); }
/* "Level 1, 2, 3 …" in a gutter inside the canvas, so they zoom and move
   with the chart; app.js puts each at its row's height. */
.okr-chart-canvas { position: relative; padding-left: 104px; }
.okr-levels { position: absolute; left: 10px; top: 0; bottom: 0; width: 88px; }
.okr-level {
  position: absolute; left: 0; display: inline-flex; align-items: center; gap: 4px;
  padding: 4px 8px; border-radius: 999px; cursor: pointer;
  border: 1px solid var(--accent-okr); background: var(--panel);
  color: var(--text); font-size: .75rem; font-weight: 600; white-space: nowrap;
}
.okr-level::before { content: "\25BE"; color: var(--accent-okr); transition: transform .15s; }
.okr-level[aria-expanded="false"]::before { transform: rotate(-90deg); }
.okr-box .org-box-name {
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical;
  overflow: hidden;
}
.okr-box .goal-bar { width: 100%; margin: 4px 0 0; }
.okr-box .okr-kid-pc { margin: 0; font-size: .8rem; }
.okr-box.company { border-color: var(--accent-okr); border-width: 2px; }

/* Zoom and pan (app.js, "[data-zoompan]"). The panel is a window onto the
   canvas; the canvas is as big as the drawing and is moved, not scrolled. */
.zp-wrap { position: relative; overflow: hidden; padding: 0;
  height: min(74vh, 820px); touch-action: none; cursor: grab;
  user-select: none; }
.zp-wrap.panning { cursor: grabbing; }
.zp-wrap .org-box { -webkit-user-drag: none; }
.zp-canvas { width: max-content; padding: 12px; transform-origin: 0 0;
  will-change: transform; }
.zp-tools { position: absolute; top: 8px; right: 8px; z-index: 2;
  display: flex; gap: 4px; }
.zp-tools .btn { min-width: 34px; justify-content: center; }
.zp-hint { position: absolute; left: 10px; bottom: 6px; z-index: 2; margin: 0;
  pointer-events: none; }

/* WIDER ON A COMPUTER — the width itself is `.page-wide`'s, app-wide since
   2026-10-08. The profile's label/value lists go two pairs to a row there, so a wide panel
   is not a narrow list with a lot of empty space beside it. */
@media (min-width: 1100px) {
  .people-page .sec .kv { grid-template-columns: auto 1fr auto 1fr; }
}

/* The Company OKRs heading carries its bullseye, in the OKR purple. */
.okr-h1 { display: inline-flex; align-items: center; gap: 8px; }
.okr-h1-ico { display: inline-flex; color: var(--accent-okr); }
.okr-h1-ico .ico-svg { width: 24px; height: 24px; }

/* Time off, counted as it is filled in (Lucila, 2026-10-14) — app.js leaveForm. */
.leave-count { font-weight: 600; border: 0; background: transparent; padding: 0;
  color: var(--text); width: 100%; }
.leave-warn { color: var(--danger, #d64545); }

/* Polls, 2026-10-15 (Lucila): purple bars, the winner, who voted, the
   composer's rows, settings and preview. chat/polls.py, partials/poll.html. */
.poll .poll-bar { background: var(--violet); opacity: .22; }
.poll-option.mine { border-color: var(--violet); }
.poll-option.mine .poll-bar { opacity: .36; }
.poll-tick { color: var(--violet); }
.poll-option.winner { border-color: var(--violet); border-style: solid; }
.poll-won { font-size: .68rem; font-weight: 700; text-transform: uppercase;
  color: var(--violet); margin-left: 6px; letter-spacing: .03em; }
.poll-names { display: block; font-size: .72rem; color: var(--muted); margin-top: 2px; }
.poll-when { margin-left: auto; }
.poll-menu { margin-left: auto; }
.poll-when + .poll-menu { margin-left: 0; }
.poll-edit, .poll-write { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.poll-edit input[type=text], .poll-write input[type=text] { flex: 1 1 12rem; min-width: 0; }
.poll-answers { display: flex; flex-direction: column; gap: 4px; }
.poll-answer { font-size: .85rem; padding: 6px 9px; border-radius: 8px;
  background: var(--panel-hover); overflow-wrap: anywhere; }
.poll-opt-row { display: flex; gap: 6px; align-items: center; }
.poll-opt-row input { flex: 1; }
#poll-options { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
#poll-add-row { align-self: flex-start; }
.poll-settings { display: flex; flex-direction: column; gap: 6px; margin: 10px 0; }
.poll-preview { border: 1px dashed var(--panel-border); border-radius: 10px; padding: 8px 10px; }
.poll-preview .poll-option { cursor: default; }

/* An admin editing the Time Off History (Lucila, 2026-10-16). */
.ledger td.hist-tools { width: 1%; padding: 2px 4px; }
.hist-edit-btn { color: var(--violet); }
.hist-edit-btn:hover { color: var(--violet); }
.hist-edited { display: inline-block; margin-left: 6px; font-size: .68rem; font-weight: 600;
  color: var(--violet); border: 1px solid var(--violet); border-radius: 999px; padding: 0 6px; }
tr.hist-editor > td { white-space: normal; background: var(--panel-hover); }
.hist-form .field label { font-size: .72rem; }
.hist-log-list { margin: 6px 0 0; padding-left: 16px; }
.hist-log-list li { margin-bottom: 6px; }

/* History, 2026-10-19 (Lucila): Used, Earned and Balance centred, the balance
   and the pencil in the app's purple, the days box narrow beside the words. */
.ledger th.num.center, .ledger td.num.used, .ledger td.num.earned,
.ledger td.num.bal { text-align: center; }
.ledger td.bal { color: var(--violet); }
.ledger .icon-btn.hist-edit-btn, .ledger .icon-btn.hist-edit-btn:hover { color: var(--violet); }
.hist-form .hist-days { flex: 0 0 6.5rem; max-width: 6.5rem; margin-right: 14px !important; }
.hist-form .hist-days input { width: 100%; }

/* The words a reply quoted, marked in the original when the reply's quote is
   clicked — markQuoted in app.js (Charles, 2026-10-19). A highlight paints a
   range without changing the message's markup. */
::highlight(reply-quote) {
  background-color: var(--quote-mark);
  color: inherit;
}

/* AN ASSISTANT/STAFF POSITION on the org chart (Lucila, 2026-10-20) —
   people/chart_node.html. A two-column row under the manager's box, split at
   the centre: the box sits in the left column, a short rail joins it to the
   central line, and that line runs on, unbroken, to the row of reports. */
.org-chart li.org-has-assist { padding-bottom: 0; }
.org-assist-row {
  position: relative; align-self: stretch;
  display: grid; grid-template-columns: 1fr 1fr;
  padding: 14px 0 0;
}
/* The central line, through the whole row. */
.org-assist-row::before {
  content: ""; position: absolute; top: 0; bottom: -18px; left: 50%;
  width: 1px; background: var(--panel-border);
}
.org-assist-only > .org-assist-row::before { bottom: auto; height: 50%; }
.org-assist {
  grid-column: 1; justify-self: end; margin-right: 44px;
  display: flex; flex-direction: column; gap: 8px; position: relative;
}
/* The rail from each assistant's box to the central line. */
.org-assist > .org-box { position: relative; }
.org-assist > .org-box::after {
  content: ""; position: absolute; top: 50%; right: -45px;
  width: 44px; height: 1px; background: var(--panel-border);
}
/* With the row in between, the reports' own stalk starts lower. */
.org-chart li.org-has-assist > ul { margin-top: 18px; }
.org-chart li.org-has-assist > ul::before { display: none; }
/* NARROW SCREENS: a normal position under the manager, centred. */
@media (max-width: 700px) {
  .org-assist-row { display: flex; justify-content: center; }
  .org-assist { margin-right: 0; }
  .org-assist > .org-box::after { display: none; }
  /* The central line still runs on; the centred box covers it. */
  .org-assist > .org-box { z-index: 1; }
}
.org-aside-toggle { margin: -6px 0 12px; }

/* ------------------------------------------- org chart lines, made visible
 *
 *     "make the connector lines … more visible … thicker … a darker,
 *      higher-contrast purple … square joints with rounded corners … Keep the
 *      line thickness consistent when zooming … the main line that connects a
 *      manager to the people who report to them extra emphasis … On hover …
 *      highlight the full line from them up to the top … Dim the other lines"
 *                                                    — Lucila, 2026-10-20
 *
 * --accent-okr IS THE COLOUR: the darker purple in the light theme, the
 * lighter one in the dark. --zp-inv is set by the zoom/pan script (1/scale,
 * never below 1), so a line stays at least its width on screen when zoomed
 * out instead of thinning away. The top row — the line to the managers —
 * and the assistant's branch are 3px; every other line is 2px.
 */
.org-chart:not(.okr-chart) {
  --org-line: var(--accent-okr, #7c3aed);
  --org-w: calc(2px * var(--zp-inv, 1));
  --org-w-main: calc(3px * var(--zp-inv, 1));
}
.org-chart:not(.okr-chart) ul li::before { width: var(--org-w); background: var(--org-line);
  transform: translateX(-50%); }
/* THE RAIL OVER A MANAGER WHO HAS REPORTS. `li:has(> ul)::after { content:
   none }` above also took away that box's piece of the rail across its row,
   so the line along the managers broke at every manager with a team. */
.org-chart:not(.okr-chart) ul li::after { content: ""; height: var(--org-w); background: var(--org-line); }
.org-chart:not(.okr-chart) li > ul::before { width: var(--org-w); background: var(--org-line);
  transform: translateX(-50%); }
/* The managers' line: the first row of branches under a root. */
.org-chart:not(.okr-chart) > li > ul > li::before,
.org-chart:not(.okr-chart) > li > ul::before { width: var(--org-w-main); }
.org-chart:not(.okr-chart) > li > ul > li::after { height: var(--org-w-main); }
.org-chart:not(.okr-chart) .org-assist-row::before { width: var(--org-w-main);
  background: var(--org-line); transform: translateX(-50%); }
.org-chart:not(.okr-chart) .org-assist > .org-box::after { height: var(--org-w-main);
  background: var(--org-line); transform: translateY(-50%); }
/* ROUNDED CORNERS where a rail turns down into the outermost boxes: the end
   of the rail and the top of the stalk become one bordered corner. */
.org-chart:not(.okr-chart) ul li:first-child:not(:only-child)::after,
.org-chart:not(.okr-chart) ul li:last-child:not(:only-child)::after {
  background: none; height: 10px; border-top: var(--org-w) solid var(--org-line);
}
.org-chart:not(.okr-chart) ul li:first-child:not(:only-child)::after {
  border-left: var(--org-w) solid var(--org-line); border-top-left-radius: 8px;
  margin-left: calc(var(--org-w) / -2);
}
.org-chart:not(.okr-chart) ul li:last-child:not(:only-child)::after {
  border-right: var(--org-w) solid var(--org-line); border-top-right-radius: 8px;
  margin-right: calc(var(--org-w) / -2);
}
.org-chart:not(.okr-chart) > li > ul > li:first-child:not(:only-child)::after,
.org-chart:not(.okr-chart) > li > ul > li:last-child:not(:only-child)::after {
  border-top-width: var(--org-w-main);
}
.org-chart:not(.okr-chart) > li > ul > li:first-child:not(:only-child)::after { border-left-width: var(--org-w-main); }
.org-chart:not(.okr-chart) > li > ul > li:last-child:not(:only-child)::after { border-right-width: var(--org-w-main); }
.org-chart:not(.okr-chart) ul li:first-child:not(:only-child)::before,
.org-chart:not(.okr-chart) ul li:last-child:not(:only-child)::before { top: 10px; height: 8px; }
/* Cards always above the lines. */
.org-chart .org-box { position: relative; z-index: 1; }

/* THE HIGHLIGHTED CHAIN, drawn by app.js as an SVG over the canvas; the
   other lines dim while it shows. */
.zp-canvas, .org-position { position: relative; }
.org-hl-svg { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; z-index: 2; }
.org-hl-svg path { fill: none; stroke: var(--accent-okr, #7c3aed); stroke-linecap: round;
  stroke-linejoin: round; filter: drop-shadow(0 0 3px hsl(265 88% 56% / .55)); }
.org-chart.org-dim li::before, .org-chart.org-dim li::after,
.org-chart.org-dim ul::before, .org-chart.org-dim .org-assist-row::before,
.org-chart.org-dim .org-assist > .org-box::after { opacity: .3; }
.org-chart.org-dim .org-box.org-hl-box { border-color: var(--accent-okr); }

/* ---- an OKR opens as a sheet on the right ----
 * "instead of expanding the card in place, i would open a modal or something
 * with a little bit more space to work" (Matias, 2026-10-20). Only a TOP-LEVEL
 * card (`.okr-card > .okr`); a nested one opens inside the sheet as before.
 * Full screen on a phone. The backdrop is a real element (app.js okrSheet),
 * so a click on it can close the sheet. */
.okr-sheet-head { display: none; }
.okr-card > .okr[open] > .okr-more {
  position: fixed; z-index: 61; top: 0; right: 0; bottom: 0;
  width: min(620px, 100vw); overflow-y: auto; overscroll-behavior: contain;
  background: var(--panel-solid); border-left: 1px solid var(--panel-border);
  box-shadow: -14px 0 40px rgba(0, 0, 0, .35);
  padding: var(--safe-top) 18px calc(var(--safe-bottom) + 18px);
}
.okr-card > .okr[open] > .okr-more > .okr-sheet-head {
  display: flex; align-items: center; gap: 10px; position: sticky; top: 0;
  z-index: 1; margin: 0 -18px 12px; padding: 14px 12px 12px 18px;
  background: var(--panel-solid); border-bottom: 1px solid var(--panel-border);
}
.okr-sheet-head b { flex: 1; font-size: 1.05rem; line-height: 1.3; }
.okr-sheet-close { font-size: 1.4rem; line-height: 1; }
/* The same shade as `dialog.sheet::backdrop` and `.pending-veil`: one answer
 * to "how dark is behind a sheet". */
.okr-backdrop {
  position: fixed; inset: 0; z-index: 60; background: rgba(6, 4, 16, 0.6);
}

/* ---- the OKR filter bar (people/okr_filter.html, app.js okrFilter) ---- */
.okr-filter { gap: 6px; align-items: center; margin: 0 0 10px; }
.okr-filter [aria-pressed="true"] { background: var(--accent-okr); border-color: var(--accent-okr); color: #fff; }
.okr-filter-person { font-size: .8rem; max-width: 14rem; }
.okr-f-out { display: none !important; }
.okr-f-context > .okr > summary, .okr-f-context > .okr-line { opacity: .5; }
.okr-filtering.js-collapse .okr-kids:not(.open) { display: block; }

/* ---- dragging an OKR onto another to nest it (app.js okrDrag) ---- */
[data-okr-drag] { cursor: grab; }
.okr-dragging { opacity: .45; }
.okr-drop-on { outline: 2px dashed var(--accent-okr); outline-offset: 2px; border-radius: var(--radius); }

/* THE PAGE YOU ARE ON, in the top bar — the Company OKRs and People icons
   (Lucila, 2026-10-21). The app's purple on a soft ground, both themes. */
.icon-btn.here { color: var(--violet); background: var(--panel-hover); }

/* ---- a phone's chat header: the name before the tools ----
 * "in mobile view, the buttons cover the chat name/title" (Paula,
 * 2026-10-21). Documents, Cards, Plan and Calendar move into the ⋮ menu
 * (conversation.html `thread-tool` / `thread-tool-menu`); the call, the
 * pending count and ⋮ stay, and the name gets the rest. */
.menuitem.thread-tool-menu { display: none; }
@media (max-width: 560px) {
  /* The floating window too: on a phone there is no page beside it to keep
     working in. */
  .thread-head .thread-tool, .thread-head #popout-open { display: none; }
  .menuitem.thread-tool-menu { display: flex; }
}

/* ---- the board: folding, its own scroll per column, the rest of Done ----
 * Giuliana, 2026-10-21 (Gabbie Feedback #34374).
 * THE COLUMN'S HEIGHT IS THE WINDOW'S, not "100%" of a board whose height is
 * only a maximum: a percentage of an indefinite height is ignored, which is
 * why a long Done stretched the page instead of scrolling inside itself. */
.kb-col { max-height: calc(100dvh - 230px); }
.kb-fold { margin-left: 2px; font-size: 1rem; line-height: 1; }
.kb-col.kb-folded { flex: 0 0 44px; cursor: pointer; }
.kb-col.kb-folded .kb-head {
  flex-direction: column; gap: 8px; border-bottom: 0; padding: 10px 4px;
}
.kb-col.kb-folded .kb-head h3 {
  writing-mode: vertical-rl; white-space: nowrap; font-size: 0.84rem;
}
.kb-col.kb-folded .kb-count { margin-left: 0; }
.kb-col.kb-folded .kb-fold { transform: rotate(180deg); }
.kb-col.kb-folded .kb-drop, .kb-col.kb-folded .kb-add { display: none; }
.kb-card.kb-later { display: none; }
.kb-col.kb-all .kb-card.kb-later { display: block; }
.kb-col.kb-all .kb-show-more { display: none; }
.kb-show-more { width: 100%; margin-top: 4px; }
/* Dragging the board sideways by its background. */
.kanban.kb-panning, .kanban.kb-panning * { cursor: grabbing !important; user-select: none; }

/* ---- the Files ⋯ menu, between the name and the date (Lucila, 2026-10-22)
 *
 * In the list the row keeps one grid for every row, files and folders alike,
 * with an EMPTY 30px column after the name; the menu (a sibling of the row's
 * link, never inside it) is laid over that column. The offset from the right
 * is the fixed columns after it, plus their gaps and the row's padding — so it
 * changes with the same breakpoints the columns do. Always visible, quieter
 * until the row is hovered. */
.files.as-list .file { position: relative; grid-template-columns: minmax(0, 1fr); }
.files.as-list .file-open { grid-template-columns: 24px minmax(0, 1fr) 30px 92px 110px 84px; }
.files.as-list .file-open > .file-ico { grid-column: 1; }
.files.as-list .file-open > .file-name { grid-column: 2; }
.files.as-list .file-open > .file-when { grid-column: 4; }
.files.as-list .file-open > .file-who { grid-column: 5; }
.files.as-list .file-open > .file-size { grid-column: 6; }
.files.as-list .file-menu {
  position: absolute; top: 50%; transform: translateY(-50%);
  right: calc(92px + 110px + 84px + 3 * 10px + 10px);
}
@media (max-width: 720px) {
  .files.as-list .file-open { grid-template-columns: 24px minmax(0, 1fr) 30px 92px; }
  .files.as-list .file-menu { right: calc(92px + 10px + 10px); }
}
@media (max-width: 460px) {
  .files.as-list .file-open { grid-template-columns: 24px minmax(0, 1fr) 30px; }
  .files.as-list .file-menu { right: 10px; }
}
.file-menu > summary { opacity: .45; }
.file:hover .file-menu > summary, .file-menu[open] > summary,
.file-menu > summary:focus-visible { opacity: 1; }
.file-menu .menu-panel { min-width: 210px; z-index: 30; }
.file-menu .menuitem select { flex: 1; min-width: 0; }
.file-rename { font: inherit; width: 100%; padding: 2px 6px; }
.file-flag { display: inline-flex; margin-left: 6px; color: var(--violet); vertical-align: middle; }
.file-flag .ico-svg { width: 13px; height: 13px; }
.file-flag.fav .ico-svg { fill: currentColor; }
.file-colours { flex-direction: column; align-items: flex-start; gap: 4px; cursor: default; }
.file-swatch { width: 18px; height: 18px; border-radius: 50%; border: 2px solid transparent;
  padding: 0; cursor: pointer; }
.file-swatch.on { border-color: var(--text); }
.file-swatch.sw-none { background: var(--panel-hover); border: 2px dashed var(--muted); }
.file-swatch.sw-none.on { border-style: solid; border-color: var(--text); }
/* The palette — one value for the swatch and the folder icon. */
.sw-purple, .file-ico.fc-purple { --fc: #7c3aed; }
.sw-blue, .file-ico.fc-blue { --fc: #2563eb; }
.sw-green, .file-ico.fc-green { --fc: #16a34a; }
.sw-yellow, .file-ico.fc-yellow { --fc: #ca8a04; }
.sw-orange, .file-ico.fc-orange { --fc: #ea580c; }
.sw-red, .file-ico.fc-red { --fc: #dc2626; }
.sw-pink, .file-ico.fc-pink { --fc: #db2777; }
.sw-grey, .file-ico.fc-grey { --fc: #6b7280; }
.file-swatch[class*="sw-"]:not(.sw-none) { background: var(--fc); }
.file-open[data-drop-folder] .file-ico[class*="fc-"] { color: var(--fc); }
/* The row whose ⋯ menu is open sits above the rows after it — they are
   positioned too, and would otherwise paint over the panel. */
.file:has(> .file-menu[open]) { z-index: 40; }
.file-menu[open] { z-index: 40; }
/* Delete, readable in both themes. */
.file-menu .menuitem.file-danger { color: #dc2626; }
:root[data-theme="dark"] .file-menu .menuitem.file-danger { color: #ffb3ad; }

/* ---- pop-ups for arriving notifications (app.js showNotePops) ----
 * Top right, so they never sit on the toasts at the bottom right. */
.note-pops {
  position: fixed; z-index: 450; right: 14px; top: calc(var(--safe-top) + 64px);
  display: flex; flex-direction: column; gap: 8px; width: min(340px, calc(100vw - 28px));
}
.note-pop {
  position: relative; display: flex; flex-direction: column; gap: 2px;
  padding: 10px 30px 10px 12px; border-radius: var(--radius);
  background: var(--panel-raised); border: 1px solid var(--panel-border);
  box-shadow: 0 8px 24px rgba(6, 4, 16, 0.35); color: var(--text); text-decoration: none;
}
.note-pop b { font-size: .86rem; }
.note-pop span { font-size: .8rem; color: var(--muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.note-pop-x { position: absolute; top: 4px; right: 6px; background: none; border: 0;
  color: var(--muted); font-size: 1.1rem; cursor: pointer; }

/* ---- selecting several (Lucila, 2026-10-22) — app.js fileSelection ----
 *
 * A circle to the left of every row; filled with a tick when chosen. On a
 * desktop it appears on hover, or everywhere once anything is selected (the
 * Drive/Gmail convention: a row of empty circles is noise until you want
 * one); on a touch screen it is always there. */
.files.as-list .file { grid-template-columns: 28px minmax(0, 1fr); }
.files.as-list .file > .file-open { grid-column: 2; }
.file-pick, .file-pick-slot { width: 20px; height: 20px; flex: none; justify-self: center; }
.file-pick {
  border-radius: 50%; border: 2px solid var(--muted); background: transparent;
  padding: 0; cursor: pointer; position: relative; opacity: 0;
  transition: opacity .12s ease;
}
.file:hover .file-pick, .file-pick:focus-visible, .file-pick[aria-checked="true"],
.files.selecting .file-pick, .file-selbar .file-pick, .files-head .file-pick { opacity: 1; }
@media (hover: none) { .file-pick { opacity: 1; } }
.file-pick[aria-checked="true"], .file-pick[aria-checked="mixed"] {
  background: var(--violet); border-color: var(--violet);
}
.file-pick[aria-checked="true"]::after {
  content: ""; position: absolute; left: 5px; top: 1px; width: 5px; height: 10px;
  border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.file-pick[aria-checked="mixed"]::after {
  content: ""; position: absolute; left: 3px; right: 3px; top: 7px; height: 2px; background: #fff;
}
.file.picked > .file-open { background: color-mix(in srgb, var(--violet) 12%, transparent); }
.files-head { display: grid; grid-template-columns: 28px minmax(0, 1fr); align-items: center;
  padding: 2px 0 6px; border-bottom: 1px solid var(--panel-border); }
.files.as-icons .files-head { display: flex; gap: 8px; grid-column: 1 / -1; border: 0; }
.files.as-icons .file > .file-pick { position: absolute; top: 6px; left: 6px; z-index: 2; }
.files.as-icons .file-pick-slot { display: none; }
.file-selbar {
  position: sticky; top: 0; z-index: 45; display: flex; align-items: center; gap: 10px;
  padding: 8px 12px; margin: 0 0 8px; border-radius: 10px;
  background: var(--panel); border: 1px solid var(--violet);
  box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
}
.file-selbar[hidden] { display: none; }
.file-selbar .file-bulk { position: relative; transform: none; right: auto; top: auto; }
.file-selbar .file-bulk > summary { opacity: 1; }
.file-selbar [data-sel-clear] { margin-left: auto; }
/* The selection bar's ⋯ sits near the left edge: its panel opens rightwards. */
.file-selbar .file-bulk .menu-panel { left: 0; right: auto; }

/* ---- Files: the Name header as a sort control, and the A–Z jump bar ----
 * Lucila, 2026-10-22. The bar is fixed to the right edge of the window,
 * vertical on every screen, and only exists for a list sorted by name with
 * at least 25 rows (app.js filesJump). */
.files-sort { text-decoration: none; }
.files-sort:hover { color: var(--text); }
.files-jump {
  position: fixed; z-index: 30; right: 2px; top: 50%; transform: translateY(-50%);
  display: flex; flex-direction: column; align-items: center; gap: 1px;
  padding: 4px 2px; border-radius: 10px; background: var(--panel-solid);
  border: 1px solid var(--panel-border); font-size: .66rem; line-height: 1.15;
}
.files-jump button { background: none; border: 0; padding: 0 3px; color: var(--text);
  font: inherit; font-weight: 600; cursor: pointer; }
.files-jump button:disabled { color: var(--muted); opacity: .45; cursor: default; }
.files-jump button:not(:disabled):hover { color: var(--accent-okr, var(--blue)); }
.files.has-jump { padding-right: 18px; }

/* ---- a long message folds to a preview (app.js clampLong) ----
 * Charles, 2026-10-22. Cut at 280px with a fade; "Show more" under it. */
.msg-text.clamped {
  max-height: 280px; overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, #000 72%, transparent);
  mask-image: linear-gradient(to bottom, #000 72%, transparent);
}
.msg-fold {
  display: inline-block; margin: 4px 0 2px; padding: 2px 0; border: 0;
  background: none; color: var(--blue-soft); font: inherit; font-size: .8rem;
  font-weight: 600; cursor: pointer;
}
.msg-fold:hover { text-decoration: underline; }

/* ---- dragging a chat into a group of chats (app.js chatGroupDrag) ---- */
.sidebar-list .conv-row.dragging { opacity: .45; }
.sidebar-list .conv-group.drop-on { outline: 2px dashed var(--blue-soft); outline-offset: -2px;
  border-radius: var(--radius-sm); }
.sidebar-list.drop-out { outline: 2px dashed var(--panel-border); outline-offset: -4px; }
