/* web/app.css — the whole look of Firmline, hand-written, no framework.
   Built for a phone first: this app is read standing up, one thumb, in bright light. */

:root {
  --ink: #14201f;
  --ink-soft: #5c6b6a;
  --line: #dfe5e4;
  --paper: #f4f6f6;
  --card: #ffffff;
  --brand: #0f766e;
  --brand-soft: #d7efec;
  --mine: #d9f2e6;
  --bad: #9b2c2c;
  --bad-soft: #fdeaea;
  --radius: 14px;
}

* { box-sizing: border-box; }

/* The browser hides [hidden] elements with its own display:none, and ANY rule of ours
   that sets display beats it — silently. That is not a theory: .call-screen sets
   display:flex, so the call screen covered every page from the first day, green and
   unresponsive, over a list nobody could reach. This line makes the attribute win
   everywhere, once, so no future rule can take an element's hiding away from it. */
[hidden] { display: none !important; }

body {
  margin: 0;
  font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color: var(--ink);
  background: var(--paper);
  -webkit-text-size-adjust: 100%;
}

/* Every screen names itself, and every screen carries its way back. Both live here so
   neither can be forgotten on a new page. */
.topbar {
  position: sticky; top: 0; z-index: 10;
  display: flex; align-items: center; gap: 4px;
  padding: 8px 8px;
  background: var(--brand); color: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .16);
}
.topbar h1 { font-size: 1.05rem; margin: 0; font-weight: 600; flex: 1; min-width: 0;
             white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* A screen whose bar opens with its own name rather than with the way back needs that name
   held off the edge; a bar that opens with the back arrow does not, because the arrow's
   44-pixel target already carries its own air. */
.topbar > h1:first-child { margin-left: 10px; }
.topbar a, .topbar .linkish { color: #fff; text-decoration: none; font-size: .95rem; }
/* The person this conversation is with: their face, then their name. The name stays a word
   — that is how this screen names itself (second design law) and no icon replaces it. */
.topbar .who { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.topbar .who h1 { flex: 1 1 auto; }

/* THERE IS NO LANGUAGE SWITCH IN THE BAR ANY MORE. A <select> lived here, on the home
   screen, and the rules for it are gone with it. The choice is made once now, on the
   invitation page, before anybody is signed in at all — .langdoor further down this file is
   what draws it, and the note in web/app.js says why it moved. */

main { padding: 16px; max-width: 720px; margin: 0 auto; }
main.narrow { max-width: 460px; }
/* A conversation and a list run to the edges of the phone, the way every messenger's do —
   their rows and bubbles carry their own inset, and a second one from the page would waste
   a fifth of a 390-pixel screen. */
main.flush { padding: 0; }
main.flush .notice { margin: 12px 10px; }
/* A list of rows on white, edge to edge, rather than a card floating on grey. */
.listcard { background: var(--card); border-bottom: 1px solid var(--line); }

.panel {
  background: var(--card); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 16px; margin-bottom: 16px;
}
h2 { font-size: 1rem; margin: 0 0 10px; }
p.hint { color: var(--ink-soft); font-size: .9rem; margin: 6px 0 0; }

/* Nothing waits in silence: these three are the only ways a screen may be empty. */
.notice {
  padding: 12px 14px; border-radius: var(--radius);
  background: var(--card); border: 1px solid var(--line); color: var(--ink-soft);
  margin: 8px 0;
}
.notice-error { background: var(--bad-soft); border-color: #f2c4c4; color: var(--bad); }
.notice-ok { background: var(--brand-soft); border-color: #b9e2dc; color: #0b5a54; }

.linkish {
  background: none; border: 0; padding: 0; margin: 0;
  color: var(--brand); text-decoration: underline; cursor: pointer;
  font: inherit;
}

button, .button {
  font: inherit; padding: 10px 16px; border-radius: 10px;
  border: 1px solid var(--brand); background: var(--brand); color: #fff;
  cursor: pointer; min-height: 44px;   /* a thumb, not a mouse pointer */
}
button.secondary { background: #fff; color: var(--brand); }
button.danger { background: #fff; border-color: var(--bad); color: var(--bad); }
button[disabled] { opacity: .55; cursor: default; }

input[type=text], textarea {
  font: inherit; width: 100%; padding: 10px 12px;
  border: 1px solid var(--line); border-radius: 10px; background: #fff;
}
label { display: block; font-size: .9rem; color: var(--ink-soft); margin-bottom: 4px; }

/* ── icons, and the controls made of them ─────────────────────────────────────────────
   Every icon is an inline <svg> built by web/icons.js from path data kept in that file:
   Bootstrap Icons 1.11.3, MIT, notice in the file's own header. Nothing is fetched from
   anywhere — this app has to work in an office with no internet, and a CDN link would both
   break it there and tell an outside company who is using it.

   NOT ONE RULE IN THIS SECTION SETS `display`, and that is deliberate. An icon-only control
   is a <button>, which the browser already lays out as an inline-block and already centres
   its content inside, so width, height and a border-radius are the whole of it. Several of
   these buttons are hidden with the `hidden` attribute — the call button, the People button,
   the round add-a-person button — and a `display` here would silently beat that attribute.
   That is the trap that once put a green lid over the entire app; see line 25. */
.i { width: 22px; height: 22px; fill: currentColor; vertical-align: middle; flex: 0 0 auto; }
.i.sm { width: 15px; height: 15px; }
.i.xs { width: 13px; height: 13px; }

.iconbtn {
  -webkit-appearance: none; appearance: none;
  background: none; border: 0; color: inherit; padding: 0;
  width: 44px; height: 44px; min-height: 44px; border-radius: 22px;
  text-align: center; cursor: pointer; flex: 0 0 auto;
  /* line-height:0 collapses the empty line box a button would otherwise reserve for text,
     so the single icon inside really does sit in the middle rather than two pixels below
     it. These buttons never hold a word — that is the whole point of them. */
  line-height: 0;
}
.iconbtn:hover { background: rgba(20, 32, 31, .07); }
.iconbtn[disabled] { opacity: .4; cursor: default; }
.topbar .iconbtn { color: #fff; }
.topbar .iconbtn:hover { background: rgba(255, 255, 255, .16); }
/* A control that GOES somewhere rather than doing something is a real link, so it can be
   opened the way links can. It is REMOVED when it does not apply — never hidden — which is
   the only reason it is allowed a `display`: owner-only furniture is built by the script on
   the owner's screen and simply does not exist on a guest's. */
.topbar a.iconbtn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; border-radius: 22px; flex: 0 0 auto;
}
.topbar a.iconbtn:hover { background: rgba(255, 255, 255, .16); text-decoration: none; }

/* ── in the app right now ──────────────────────────────────────────────────────────────
   One small green dot beside a name, and NOTHING when they are not there.

   That absence is the whole design and it is deliberate. There is no grey dot for "away",
   no "last seen at 14:32", no "typing…" — those are the features that turn a messenger into
   a way of checking up on people, and this app is sold to an office on the opposite promise.
   The dot answers one question, the only one worth answering: can I expect an answer right
   now. When it is not there, the honest reading is "no idea", and that is correct.

   Sixty seconds of silence puts it out (api/auth.py). Twelve would flicker every time a
   phone walked past a thick wall, and a dot that flickers is a dot nobody believes.

   Built and removed by the script, never hidden with the attribute, which is why it is
   allowed a `display` at all — see line 25. */
.here {
  display: inline-block; flex: 0 0 auto;
  width: 9px; height: 9px; border-radius: 5px;
  background: #18a08f; margin-left: 7px; vertical-align: 1px;
}
/* On the green bar the house colour would be invisible, so the dot goes pale and carries a
   faint ring — the same trick the header's avatar uses to lift itself off the same green. */
.topbar .here {
  background: #7ff0cd; box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .6);
}

/* ── a face for every name ─────────────────────────────────────────────────────────────
   Firmline holds no photographs, so the circle beside a name is not a placeholder waiting
   for a picture: it is the picture. The colour is worked out from the name in app.js, so
   the same person is the same colour on every screen for ever. */
.av {
  flex: 0 0 auto; width: 48px; height: 48px; border-radius: 24px;
  color: #fff; font-weight: 600; font-size: 1rem; letter-spacing: .5px;
  text-align: center; line-height: 48px; user-select: none;
}
.av-0 { background: #0f766e; } .av-1 { background: #7c5cbf; } .av-2 { background: #b5651d; }
.av-3 { background: #2d6ca2; } .av-4 { background: #8a1c53; } .av-5 { background: #4a6b2a; }
/* Somebody whose access is gone keeps their place in the list and loses their colour. */
.av.gone { background: #8b9a98; }
.av.sm { width: 46px; height: 46px; line-height: 46px; font-size: .95rem; }
.av.hdr {
  width: 34px; height: 34px; line-height: 34px; font-size: .82rem;
  box-shadow: 0 0 0 1.5px rgba(255, 255, 255, .5);
}
.av.big {
  width: 112px; height: 112px; line-height: 112px; font-size: 2.1rem;
  background: rgba(255, 255, 255, .16); box-shadow: 0 0 0 2px rgba(255, 255, 255, .3);
}

/* ── the conversation list ─────────────────────────────────────────────────────────────
   Face, name, one line of what was last said, the time, and how many are unread. That
   order is unanimous across WhatsApp, Signal and Telegram, and the only thing Firmline was
   missing was the face. */
.threads { list-style: none; margin: 0; padding: 0; }
.threads li + li a { border-top: 1px solid var(--line); }
.threads a {
  display: flex; gap: 12px; align-items: center;
  padding: 11px 16px; text-decoration: none; color: inherit;
}
.threads a:hover { background: var(--paper); }
.thread-body { flex: 1 1 auto; min-width: 0; }
.thread-name {
  font-weight: 600; line-height: 1.3;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The mark beside the name of somebody who has never opened their invitation. Quiet on
   purpose: it reports a fact, not a fault, and it removes itself the moment they sign in.
   That disappearance is the whole message, which is why it needs no colour and no word. */
.thread-name .waiting-link {
  width: 15px; height: 15px; margin-left: 6px;
  vertical-align: -2px; color: var(--ink-soft); opacity: .8;
}
.thread-last {
  color: var(--ink-soft); font-size: .9rem; line-height: 1.35;
  display: flex; align-items: center; gap: 5px;
  white-space: nowrap; overflow: hidden;
}
.thread-last .txt { overflow: hidden; text-overflow: ellipsis; }
.thread-last .i { opacity: .75; }
/* A conversation whose newest message this device cannot open yet. Grey and italic, never
   red — the same choice the bubbles make, for the same reason. */
.thread-last.locked { font-style: italic; opacity: .85; }
.thread-meta {
  flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end;
  gap: 5px; min-width: 52px;
}
.thread-time { font-size: .76rem; color: var(--ink-soft); white-space: nowrap; }
/* The time turns the house colour when there is something unread, so the eye finds the
   row before it has read a single word of it. */
.thread-time.unread { color: var(--brand); font-weight: 600; }
.badge {
  min-width: 21px; height: 21px; padding: 0 6px; border-radius: 11px;
  background: var(--brand); color: #fff; font-size: .75rem; font-weight: 600;
  display: inline-flex; align-items: center; justify-content: center;
}

/* In Firmline, "start a conversation" and "add a person" are the same act — adding
   somebody creates the conversation with them in the same breath (api/routes_people.py).
   So the round button is a person-plus and not a pencil: a compose icon would promise a
   chooser of people that does not exist. Owner only, and built only on the owner's screen. */
.fab {
  position: fixed; right: 18px; bottom: 22px; z-index: 20;
  display: flex; align-items: center; justify-content: center;
  width: 58px; height: 58px; border-radius: 29px;
  background: var(--brand); color: #fff; border: 0; padding: 0;
  box-shadow: 0 6px 16px rgba(15, 118, 110, .45); cursor: pointer;
}
.fab:hover { text-decoration: none; }
.fab .i { width: 26px; height: 26px; }

/* ── a screen with nothing on it yet ───────────────────────────────────────────────────
   One of the few places words are allowed without argument: nobody has ever learned an
   icon for "you have not started yet". It still names itself and still says what happens
   next (second and third design laws). NO `display` on the box itself — it is hidden with
   the attribute whenever there is anything to list. */
.emptyscreen { text-align: center; padding: 34px 26px 110px; }
.emptyscreen .mark {
  width: 88px; height: 88px; border-radius: 44px; line-height: 88px;
  background: var(--brand-soft); color: var(--brand); margin: 0 auto 14px;
}
.emptyscreen .mark .i { width: 40px; height: 40px; }
.emptyscreen h2 { font-size: 1.08rem; margin: 0 0 6px; }
.emptyscreen p { margin: 0 auto; color: var(--ink-soft); font-size: .93rem; max-width: 34ch; }
.emptyscreen .cta {
  display: inline-flex; align-items: center; gap: 9px;
  margin-top: 16px; height: 50px; padding: 0 22px; border-radius: 26px;
  background: var(--brand); color: #fff; border: 0; font-weight: 600; text-decoration: none;
}
.emptyscreen .cta .i { width: 19px; height: 19px; }
/* The promise, once, on the emptiest screen, where there is room for it. It never appears
   again anywhere on this screen. */
.promise {
  display: inline-flex; align-items: center; gap: 6px;
  margin-top: 14px; padding: 6px 12px; border-radius: 20px;
  background: var(--brand-soft); color: #0b5a54; font-size: .8rem;
}
.promise .i { width: 14px; height: 14px; }

/* ── one conversation ──────────────────────────────────────────────────────────── */
.messages {
  list-style: none; margin: 0; padding: 10px 10px 4px;
  display: flex; flex-direction: column; gap: 7px;
}
.msg {
  max-width: 80%; padding: 7px 10px 5px; border-radius: var(--radius);
  background: var(--card); border: 1px solid var(--line);
  align-self: flex-start; word-wrap: break-word; overflow-wrap: anywhere;
  box-shadow: 0 1px 1px rgba(0, 0, 0, .04);
}
.msg.mine { align-self: flex-end; background: var(--mine); border-color: #b8e3cf; }
.msg .who { font-size: .78rem; color: var(--brand); font-weight: 600; margin-bottom: 2px; }
.msg .body { font-size: .95rem; line-height: 1.4; }
.msg .when {
  font-size: .7rem; color: var(--ink-soft); margin-top: 2px;
  display: flex; align-items: center; justify-content: flex-end; gap: 4px;
}
/* Sent, delivered, read — WhatsApp's language, which is the one a Croatian office has
   already learned: one tick, two ticks, two blue ticks. Drawn now instead of typed. */
.msg .ticks { color: var(--ink-soft); }
.msg .ticks.read { color: #1d7ec4; }
.msg .ticks.pending { opacity: .75; }
.msg audio { width: 100%; max-width: 240px; display: block; margin-top: 4px; }
.msg img { max-width: 100%; border-radius: 10px; display: block; margin-top: 4px; }

/* The one sentence the app says inside a conversation, at the top of its history, once.
   It is the reason the customer bought this and nobody has ever learned an icon for it. */
.msg.system {
  align-self: center; max-width: 90%; text-align: center;
  background: var(--brand-soft); border-color: #b9e2dc; color: #0b5a54;
  border-radius: 12px; font-size: .8rem; line-height: 1.4; box-shadow: none;
}
.msg.system .i { margin-right: 6px; opacity: .9; }

/* A line the SERVER wrote — the message kind 'system' in the database, which today means
   exactly one thing: somebody opened their invitation and is now here.

   Not the same thing as .msg.system directly above, and the two are worth telling apart.
   That one is not a message at all: it is the app's own promise, drawn once at the top of
   every history, and it is coloured because it is the thing the customer bought. This one
   is a real row in the messages table, and it is deliberately the quietest thing on the
   screen — flat, centred, grey, no bubble, no border. It is a fact in passing, not
   somebody speaking, and every messenger draws it this way. */
.msg.event {
  align-self: center; max-width: 90%; text-align: center;
  background: none; border: 0; box-shadow: none; padding: 3px 8px;
  color: var(--ink-soft); font-size: .8rem; line-height: 1.4;
}
.msg.event .body { font-size: inherit; color: inherit; }

/* Which day the messages under it belong to. Without this a conversation from four days
   ago reads as if it happened this morning, because every bubble shows only a clock. */
.daysep {
  align-self: center; background: rgba(20, 32, 31, .07); color: var(--ink-soft);
  border-radius: 11px; padding: 2px 11px; font-size: .74rem; font-weight: 600; margin: 4px 0;
}

/* A message we hold and cannot read. Deliberately quiet — grey and italic, not red. It is
   not an error, it is a message that has arrived before its key, and it will fill itself
   in without anybody doing anything. Red would make people press things.

   The long explanation appears ONCE PER CONVERSATION and the short form after that: the
   sentence was needed the first time, not once per bubble. */
.msg.locked { background: #fbfbfb; }
.msg .body.waiting {
  color: var(--ink-soft); font-style: italic; font-size: .87rem; line-height: 1.4;
}
.msg .body.waiting .i { margin-right: 6px; opacity: .8; }

/* A message somebody took back.
   The bubble STAYS, in its place, in the order it was said, and says what happened. That is
   the whole difference between a deletion and a hole: a bubble that simply vanished would
   leave the person opposite quietly missing a piece of a conversation they had already read,
   with no way to know a piece was missing at all.
   It borrows the locked bubble's voice — grey, italic, a size down — because it is the same
   kind of statement: not an error, not a warning, a fact about a bubble with nothing in it.
   No `display` here: the tombstone replaces the body's contents, it never hides anything. */
.msg.deleted { background: #fbfbfb; }
.msg.mine.deleted { background: #eef5f1; border-color: #cfe4d8; }
.msg .body.deleted {
  color: var(--ink-soft); font-style: italic; font-size: .87rem; line-height: 1.4;
}
.msg .body.deleted .i { margin-right: 6px; opacity: .7; }

/* A long press on a bubble opens the menu that can delete it. On an iPhone the same gesture
   fires Safari's own "Copy / Look Up" callout on top of ours, so that one is turned off
   here. Selecting the words of a message is deliberately still allowed — there is no
   copy-a-message button in this app, so a selection is the only way to get a sentence out of
   it, and taking that away to tidy up a gesture would be a real loss for a fixed cosmetic. */
.msg { -webkit-touch-callout: none; }

/* ── a voice message ──────────────────────────────────────────────────────────────────
   A round play button, a bar chart, a duration, and no word anywhere: there is no
   messenger on earth that draws this differently. The bars are NOT the sound — we cannot
   know its shape without downloading and unlocking it, which is the one thing this control
   exists to avoid doing until somebody asks. They are a fixed pattern per message that
   fills in as it plays, so the thing they really report is how far through you are. */
.voice { display: flex; align-items: center; gap: 10px; min-width: 200px; max-width: 100%; }
.vplay {
  flex: 0 0 auto; width: 38px; height: 38px; min-height: 38px; border-radius: 19px;
  background: var(--brand); color: #fff; border: 0; padding: 0;
  display: flex; align-items: center; justify-content: center;
}
.msg.mine .vplay { background: #0b5a54; }
.vplay .i { width: 17px; height: 17px; }
.wave { flex: 1 1 auto; display: flex; align-items: center; gap: 2px; height: 26px; }
.wave span { flex: 1 1 auto; background: #9db4b1; border-radius: 2px; min-width: 2px; }
.msg.mine .wave span { background: #6ba892; }
.wave span.on { background: var(--brand); }
.vlen {
  flex: 0 0 auto; font-size: .72rem; color: var(--ink-soft); min-width: 26px; text-align: right;
}
/* Nobody has listened to this yet. Ours, not borrowed — Signal has no such marker and
   WhatsApp colours the microphone instead. Remembered on this device only. */
.newdot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 4px; background: var(--brand); }
/* Fetching and unlocking a recording takes a moment on a phone, so the button says so
   while it happens — with the word beside it, never a bare spinner (third design law). */
.spin {
  display: inline-block; width: 17px; height: 17px; border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, .45); border-top-color: #fff;
  animation: firmline-spin 900ms linear infinite;
}
@keyframes firmline-spin { to { transform: rotate(360deg); } }
.vopening { font-size: .8rem; color: var(--ink-soft); }

/* Why the composer is quiet, said above the composer where it will be read.
   NO `display` HERE: this element is hidden with the `hidden` attribute every time a key
   is in hand, which is almost always. */
.keynote {
  position: sticky; bottom: 68px;
  margin: 0 10px 8px; padding: 9px 12px;
  background: var(--brand-soft); border: 1px solid #b9e2dc; color: #0b5a54;
  border-radius: 10px; font-size: .85rem; line-height: 1.45;
}
.keynote .i { margin-right: 6px; }

/* Said once, the first time a person ever records, and then never again. It is the price
   of Firmline's microphone working differently from the three they already know. */
.firsthint {
  position: sticky; bottom: 68px;
  margin: 0 10px 8px; padding: 8px 12px;
  background: #fff; border: 1px solid var(--line); border-radius: 10px;
  color: var(--ink-soft); font-size: .83rem; line-height: 1.45;
}
.firsthint .i { margin-right: 6px; }

/* ── the composer ─────────────────────────────────────────────────────────────────────
   It never scrolls away: the thing you came here to do is always on screen. Four controls
   used to sit in this row at all times — a picture, a microphone, the box, and a green
   button with the word "Send" on it. Now the camera lives inside the writing pill and the
   round button outside it changes job: microphone when there is nothing typed, paper plane
   the moment there is, red square while recording. Same circle, same place under the thumb.

   The pill and the round button are NEVER hidden — they change shape instead — which is
   what lets them have a `display`. The recorder is built by swapping the pill's children,
   not by hiding one row and showing another. */
.composer {
  position: sticky; bottom: 0;
  display: flex; gap: 8px; align-items: flex-end;
  padding: 8px 10px 12px; background: var(--card); border-top: 1px solid var(--line);
}
.pill {
  flex: 1 1 auto; min-width: 0;
  display: flex; align-items: flex-end; gap: 6px;
  background: var(--paper); border: 1px solid var(--line);
  border-radius: 22px; padding: 3px 4px 3px 14px; min-height: 44px;
}
.pill:focus-within { border-color: #b7d6d2; background: #fff; box-shadow: 0 0 0 2px rgba(15, 118, 110, .10); }
/* The box where you write must be the widest thing here. min-width:0 is what stops a
   flex item from refusing to shrink below its content — without it the placeholder
   wrapped onto two lines and pushed the whole row taller. */
.pill textarea {
  flex: 1 1 auto; min-width: 0; resize: none; max-height: 120px;
  border: 0; background: none; padding: 8px 0 7px; border-radius: 0;
}
.pill textarea:focus { outline: none; }
.pill .iconbtn { width: 38px; height: 38px; min-height: 38px; color: var(--ink-soft); }
.pill .iconbtn .i { width: 20px; height: 20px; }
.round {
  flex: 0 0 auto; width: 46px; height: 46px; min-height: 46px; border-radius: 23px;
  background: var(--brand); color: #fff; border: 0; padding: 0;
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 2px 6px rgba(15, 118, 110, .35);
}
.round .i { width: 21px; height: 21px; }
.round[disabled] { opacity: .5; box-shadow: none; cursor: default; }
.composer.off .pill { opacity: .5; }

/* While recording, the pill IS the recorder: a pulsing dot, a running clock, and bars.
   The running clock is the "it is working" — no second banner somewhere else on screen. */
.pill.recording { background: var(--bad-soft); border-color: #f2c4c4; align-items: center; padding: 0 14px; }
.recdot {
  flex: 0 0 auto; width: 10px; height: 10px; border-radius: 5px; background: #c53030;
  animation: firmline-pulse 1.2s ease-in-out infinite;
}
@keyframes firmline-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .25; } }
.recclock {
  flex: 0 0 auto; font-variant-numeric: tabular-nums; font-weight: 600;
  color: var(--bad); font-size: .95rem;
}
.pill.recording .wave span { background: #d99a9a; }
.round.stop { background: #c53030; box-shadow: 0 2px 6px rgba(197, 48, 48, .4); }

/* ── the conversation screen is one full-height column ────────────────────────────────
   THE THING THIS FIXES. `position: sticky; bottom: 0` only sticks to something that
   scrolls. A conversation with four messages in it does not fill a phone, so there was
   nothing to stick to: the writing box came to rest immediately under the last bubble,
   halfway up the screen, with a field of grey below it. Every messenger anybody has ever
   used puts that box on the bottom edge and stacks the messages upwards from it, and the
   empty space of a short conversation ends up ABOVE the messages, not below them.

   Four rules do the whole of it:

     body.thread   one column, exactly as tall as the visible part of the screen
     main#scroll   the only thing that scrolls. flex:1 AND min-height:0 — without the
                   second one a flex item refuses to shrink below its own content, so the
                   page would grow instead of the list scrolling inside it
     .stack        margin-top:auto, which is what pushes a short history DOWN to sit just
                   above the composer. Auto margins eat the leftover space when there is
                   any and are worth nothing when there is none, so a long history scrolls
                   normally with no second rule needed. (justify-content:flex-end would
                   look identical and make the top of a long history unreachable in some
                   browsers — the classic version of this bug.)
     .composer     the last item in the column, which is the bottom edge of the screen

   --vvh is the height of the visible part of the page, published by trackKeyboard() in
   app.js. Using it rather than 100vh is what makes the composer ride ON TOP of the phone
   keyboard rather than underneath it, on the browsers that slide the keyboard over the
   page instead of shrinking it. Before the script runs there is no such variable and
   100vh is exactly right.

   NOT ONE RULE HERE SETS `display` ON ANYTHING THAT CAN BE HIDDEN. .keynote and .firsthint
   spend almost all their lives carrying the `hidden` attribute; they are re-POSITIONED
   here and never displayed, for the reason written at line 25. */
body.thread {
  display: flex; flex-direction: column;
  height: var(--vvh, 100vh);
  overflow: hidden;              /* the page itself never scrolls; the list inside it does */
}
body.thread > main.flush {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column;
  /* width:100% is NOT belt and braces, and removing it breaks the phone. `main` already
     carries `margin: 0 auto` to centre itself on a wide screen — and a flex item with an
     auto margin across the column is exempted from stretching, so without a width of its
     own it would shrink to fit its content and the conversation would sit in a narrow
     ribbon down the middle of a 390-pixel screen. With it, the item is full width, still
     capped at 720, and the auto margins go back to doing only the centring. */
  width: 100%;
  /* Keep a flick at the top of the history from dragging the whole page behind it, which
     on a phone reads as the app coming loose. */
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}
body.thread .stack { margin-top: auto; }
/* Sticky was how these three held their place on a page that scrolled as a whole. They are
   real rows in a column now, so there is nothing left for it to do, and leaving it in would
   be a rule that looks like it is doing work and is not. */
body.thread > .keynote,
body.thread > .firsthint,
body.thread > .composer { position: static; flex: 0 0 auto; }

/* ── people ─────────────────────────────────────────────────────────────────────────────
   The owner's screen, and the only administrative surface in Firmline. It used to spend
   most of its words on itself: two grey paragraphs of preamble, a labelled form, two
   word-buttons per person, and — under every single person, all the time — a red box about
   permanent revocation. All of that red moved into the confirmation, where it appears
   before the press and is the only red thing on the screen. Words that are everywhere are
   read nowhere. */
.people { list-style: none; margin: 0; padding: 0; }
.people li + li .person-row { border-top: 1px solid var(--line); }
.person-row { display: flex; align-items: center; gap: 12px; padding: 10px 6px 10px 16px; }
.person-body { flex: 1 1 auto; min-width: 0; }
/* When the body is a link (tap the person, get the conversation), it must look like the
   person, not like a link: no blue, no underline — the whole row is the affordance. The
   padding grows the touch target to the full height of the row. */
a.person-body { color: inherit; text-decoration: none; display: block; padding: 6px 0; margin: -6px 0; }
a.person-body:active { opacity: .7; }
.person-name {
  font-weight: 600; line-height: 1.3;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* The one fact about a person that actually changes: is their invitation still lying
   around unused, or is their access gone. The number behind it is one the server already
   sends and this screen used to throw away. */
.person-sub {
  font-size: .83rem; color: var(--ink-soft); line-height: 1.35;
  display: flex; align-items: center; gap: 5px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.person-sub .i { width: 13px; height: 13px; opacity: .85; }
.person-sub.open { color: #0b5a54; }
.person-sub.gone { color: #c53030; }
.people li.revoked .person-row { opacity: .6; }
.rowbtn { width: 40px; height: 40px; min-height: 40px; border-radius: 20px; color: var(--brand); }
.rowbtn:hover { background: var(--brand-soft); }
.rowbtn.more { color: var(--ink-soft); width: 34px; }
.rowbtn.more:hover { background: rgba(20, 32, 31, .06); }

/* ── a sheet that slides up from the bottom ────────────────────────────────────────────
   Every one of these is BUILT when it opens and REMOVED when it closes, so none of them
   ever carries the `hidden` attribute and the `display` rules below cannot collide with
   it. Tapping the dark behind a sheet closes it — the way back, on every one of them. */
.dim { position: fixed; inset: 0; z-index: 40; background: rgba(11, 32, 30, .45); }
.bsheet {
  /* --kb is the height of the on-screen keyboard, published by trackKeyboard() in app.js.
     Sitting on top of it is what keeps the Add button reachable while you are typing the
     name; --vvh caps the sheet to what is still visible so it scrolls rather than hides. */
  position: fixed; left: 0; right: 0; bottom: var(--kb, 0px); z-index: 41;
  max-width: 720px; margin: 0 auto; overflow-y: auto;
  max-height: calc(var(--vvh, 100vh) * .88);
  background: var(--card); border-radius: 20px 20px 0 0;
  padding: 8px 18px 22px; box-shadow: 0 -10px 30px rgba(0, 0, 0, .25);
}
.grip { width: 38px; height: 4px; border-radius: 2px; background: var(--line); margin: 2px auto 12px; }
.bsheet h3 { margin: 0 0 4px; font-size: 1.05rem; }
.bsheet p { margin: 0; color: var(--ink-soft); font-size: .88rem; line-height: 1.5; }
.bsheet p + p { margin-top: 8px; }
.sheetav { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
.sheetav .av { width: 42px; height: 42px; line-height: 42px; font-size: .9rem; }
.sheetav h3 { margin: 0; }
.field { margin: 14px 0 4px; }
.field input {
  font: inherit; width: 100%; padding: 12px 14px; min-height: 48px;
  border: 1px solid var(--line); border-radius: 12px; background: var(--paper);
}
.field input:focus {
  outline: 0; border-color: var(--brand); background: #fff;
  box-shadow: 0 0 0 3px rgba(15, 118, 110, .14);
}
.btnrow { display: flex; gap: 10px; margin-top: 16px; }
/* A commit gets a word. A lone person-plus inside a sheet already titled "Add a person"
   would be a riddle, and Copy and Send side by side as two bare icons would be a guess
   about which one wastes the only chance to read the link. */
.btn {
  flex: 1 1 auto; min-height: 50px; border-radius: 25px; border: 0; padding: 0 18px;
  background: var(--brand); color: #fff; font: inherit; font-weight: 600; cursor: pointer;
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
}
.btn .i { width: 18px; height: 18px; }
.btn[disabled] { opacity: .55; cursor: default; }
/* Backing out is an X and costs nothing if mis-pressed, because nothing has happened yet. */
.btn.ghost { background: #fff; color: var(--ink-soft); border: 1px solid var(--line); flex: 0 0 auto; width: 56px; padding: 0; }
.btn.quiet { background: #fff; color: var(--brand); border: 1px solid var(--brand); }
.btn.stop { background: #c53030; box-shadow: 0 3px 10px rgba(197, 48, 48, .35); }
.okline, .danger {
  display: flex; gap: 9px; align-items: flex-start;
  border-radius: 12px; padding: 11px 13px; font-size: .87rem; line-height: 1.5; margin-top: 12px;
}
.okline { background: var(--brand-soft); color: #0b5a54; border: 1px solid #b9e2dc; }
.okline .i { width: 15px; height: 15px; margin-top: 3px; }
/* The one warning in this app with real teeth, in the one place it will be read: before
   the press, and the only red object on the screen. */
.danger { background: var(--bad-soft); color: var(--bad); border: 1px solid #f2c4c4; }
.danger .i { width: 17px; height: 17px; margin-top: 2px; }
.danger b { color: var(--bad); }
.sheetmenu { list-style: none; margin: 12px 0 0; padding: 0; }
.sheetmenu li button {
  width: 100%; min-height: 52px; padding: 12px 4px; border: 0; border-radius: 0;
  border-top: 1px solid var(--line); background: none; color: var(--ink);
  font: inherit; text-align: left; cursor: pointer;
  display: flex; align-items: center; gap: 12px;
}
.sheetmenu li button.bad { color: #c53030; }
.linkbox {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem;
  word-break: break-all; background: var(--paper);
  border: 1px solid var(--line); border-radius: 10px; padding: 10px; margin-top: 8px;
}
.warn {
  background: var(--bad-soft); border: 1px solid #f2c4c4; color: var(--bad);
  border-radius: 10px; padding: 10px; font-size: .88rem; margin-top: 8px;
}
.invite-out .row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }

/* ── the invitation screen, before anybody is signed in ────────────────────────────────
   The one screen a stranger sees, and the only place in this app where an action cannot be
   undone by the person taking it: opening the link spends it.

   NOT ONE RULE BELOW SETS `display` on anything that also gets `hidden`. The warning box,
   each escape hint, each set of install steps and the Install button are all on the page in
   every case and hidden down to the one that applies — so a `display` here would quietly
   beat the attribute and put all four sets of instructions on screen at once. That is the
   same trap as the green lid over the whole app, and this is where it would happen next. */
/* WHICH LANGUAGE DO YOU READ — the first thing on the invitation page and the only place in
   Firmline that asks. Two targets, side by side, each big enough for a thumb, each written
   in its own language. Deliberately NOT styled as a switch with an on and an off: they are
   two doors, and the one you came in by is simply marked.

   These are real links and they are always both present — nothing here is ever hidden, which
   is why a `display` is safe on them (see line 25). */
.langdoor { display: flex; gap: 10px; margin-bottom: 16px; }
.langdoor .langbtn {
  flex: 1 1 0; min-height: 52px; border-radius: 26px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--card); border: 1px solid var(--line); color: var(--ink);
  font-size: 1.02rem; font-weight: 600; text-decoration: none;
}
.langdoor .langbtn:hover { border-color: var(--brand); }
/* The one currently on screen. It stays pressable — pressing it again simply reloads the
   page you are already reading, which costs nothing and is easier to understand than a
   button that has quietly stopped working. */
.langdoor .langbtn.current {
  background: var(--brand); border-color: var(--brand); color: #fff;
}

.warn h2 { font-size: 1rem; margin: 0 0 6px; color: inherit; }
.warn p { margin: 0 0 8px; }
.warn p:last-child { margin-bottom: 0; }
.warn.big { padding: 14px; font-size: .95rem; }

/* One way, and it says so above the button rather than in a dialog after it. Not red: red
   in this app means something has gone wrong, and nothing has. This is the house colour
   saying read me first. */
.oneway {
  background: var(--brand-soft); border: 1px solid #b9e2dc; color: #0b5a54;
  border-radius: 10px; padding: 12px; font-size: .92rem; margin: 16px 0 12px;
}

.invite-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 8px; }
.invite-row .said { color: var(--brand); font-size: .9rem; }

/* A numbered list because it is a sequence of taps in an order, and on an iPhone the order
   is the whole instruction. */
ol.steps { margin: 8px 0 0; padding-left: 22px; font-size: .93rem; }
ol.steps li { margin: 6px 0; }

.signin-big { width: 100%; font-size: 1.02rem; }

/* ── calls ──────────────────────────────────────────────────────────────────────────
   Two pieces, and the difference between them is deliberate.

   The BANNER is a strip. Somebody ringing must be answerable without losing whatever was
   on the screen a second ago, so it sits over the top of the page and leaves the rest
   visible and readable behind it.

   The CALL SCREEN is the whole screen, because once a call is live it IS what the person
   is doing, and the button that ends it should be impossible to miss.

   BOTH OUTER BOXES ARE HIDDEN WITH THE `hidden` ATTRIBUTE, so neither of them may be given
   a `display` — and until today both were, `display:flex`, which is precisely how the call
   screen once covered every page in the app with a green lid nobody could get out from
   under (line 25). The outer box is now a plain positioned block and the flexing happens on
   an inner element that is never hidden. */
.call-banner {
  position: fixed; top: 0; left: 0; right: 0; z-index: 60;
  background: #0b5a54; color: #fff; box-shadow: 0 2px 10px rgba(0, 0, 0, .3);
}
.call-banner-in {
  display: flex; gap: 12px; align-items: center;
  padding: 10px 14px 11px; max-width: 720px; margin: 0 auto;
}
.call-banner .av { box-shadow: 0 0 0 2px rgba(255, 255, 255, .35); }
.call-banner .call-who {
  flex: 1 1 auto; min-width: 0; font-weight: 600; line-height: 1.25; font-size: .95rem;
}

/* Answer and Decline are the two controls in this app that KEEP a word each, and that is
   deliberate. Everywhere else a universally known icon loses its label; not here. Declining
   cannot be undone in the moment it matters, the two circles sit two centimetres apart
   under a thumb moving fast, and a red phone and a green phone are only obvious to somebody
   who is looking. One word each is the cheapest insurance in the app. */
.callbtn { flex: 0 0 auto; text-align: center; }
.callbtn button {
  width: 46px; height: 46px; min-height: 46px; border-radius: 23px; border: 0; padding: 0;
  color: #fff; line-height: 0; box-shadow: 0 2px 8px rgba(0, 0, 0, .3);
}
.callbtn .lab { display: block; font-size: .66rem; opacity: .9; margin-top: 3px; }
.btn-decline { background: #c53030; }
.btn-answer { background: #18a08f; }

.call-screen {
  position: fixed; inset: 0; z-index: 70;
  background: var(--brand); color: #fff;
}
.call-in {
  display: flex; flex-direction: column; align-items: center;
  height: 100%; padding: 40px 26px 34px; text-align: center;
}
.call-screen .av.big { margin: 40px 0 20px; }
.call-screen h2 { font-size: 1.35rem; margin: 0; font-weight: 600; }
/* Every state of a call is a sentence here, never a spinner. This element is the reason
   there is no spinner in the calling code at all, and not one of those twelve sentences
   was touched when this screen was redrawn. */
.call-status { margin: 8px 0 0; font-size: 1.02rem; opacity: .92; min-height: 2.8em; line-height: 1.4; }
.call-spacer { flex: 1 1 auto; }

/* WHERE THE SOUND IS COMING OUT OF, said in words on the phones that cannot be told to move
   it. On an iPhone running iOS 26 the browser lets a page choose the earpiece, and there is a
   toggle in the row below instead. Everywhere else — every Android browser, deliberately,
   since 2013 — there is nothing to choose: the call is on the loudspeaker and a web page has
   no way to move it. So the screen SAYS so rather than letting somebody find out socially,
   and it says the one thing that does work: plug in earbuds and Android moves the call to
   them by itself.

   NO `display` HERE. This element carries the `hidden` attribute for the whole of every call
   on a phone that has the toggle, which is exactly the trap described at line 25. */
.call-note {
  max-width: 30ch; margin: 0 0 22px; padding: 10px 14px;
  background: rgba(255, 255, 255, .14); border-radius: 12px;
  font-size: .82rem; line-height: 1.45; opacity: .95;
}
.softrow { display: flex; gap: 26px; justify-content: center; margin-bottom: 30px; }
.softbtn { text-align: center; }
.softbtn button {
  width: 58px; height: 58px; min-height: 58px; border-radius: 29px; padding: 0; line-height: 0;
  border: 1px solid rgba(255, 255, 255, .4); background: rgba(255, 255, 255, .14); color: #fff;
}
/* A toggle keeps a word, because the word is what lets you read the state without pressing
   it to find out. */
.softbtn button.on { background: #fff; color: var(--brand); border-color: #fff; }
.softbtn .lab { display: block; font-size: .72rem; opacity: .85; margin-top: 6px; }
/* How loud, on the phones that will be told. Deliberately NOT shaped like the two round
   toggles above it: it is a continuous setting, not a state with two sides, and a slider
   that looked like a button would be read as one. The number sits at the end and is always
   there, so a quiet call can never be a mystery. Wide thumb — this is dragged mid-call, on a
   phone, one-handed. */
.call-vol {
  display: flex; align-items: center; gap: 10px;
  width: min(280px, 78vw); margin: 0 auto 26px;
}
.call-vol input[type="range"] {
  flex: 1; min-width: 0; height: 30px; margin: 0; padding: 0;
  accent-color: #fff; background: transparent; cursor: pointer;
}
.call-vol .lab {
  min-width: 4.5ch; text-align: right; font-size: .78rem; opacity: .9;
  font-variant-numeric: tabular-nums;
}
/* Hang up was a white pill with red lettering, the same size and shape as the button beside
   it. It is now the only red object on a green screen, round, alone at the bottom where the
   thumb rests. Nobody will ever have to look for it. */
.endbtn {
  width: 76px; height: 76px; min-height: 76px; border-radius: 38px; padding: 0; line-height: 0;
  background: #c53030; border: 0; color: #fff; box-shadow: 0 8px 24px rgba(0, 0, 0, .35);
}
.endbtn .i { width: 32px; height: 32px; }
.call-screen audio { display: none; }   /* the sound, not a player somebody has to find */
