/* Pulse — light, bold, simple. */
* { margin: 0; padding: 0; box-sizing: border-box; }
[hidden] { display: none !important; }
:root {
  --bg: #ffffff;
  --bg-soft: #f5f5f7;
  --ink: #111114;
  --ink-2: #5c5c66;
  --ink-3: #9a9aa4;
  --ink-inv: #ffffff;
  --line: #ececf0;
  --glass: rgba(255, 255, 255, 0.92);
  --accent: #6C5CE7;
  --good: #00B894;
  --warn: #E17055;
  --card-shadow: 0 1px 2px rgba(17, 17, 20, 0.04), 0 6px 20px rgba(17, 17, 20, 0.05);
  /* the chat's own surfaces: the answer, your own message, the lift under both,
     and the green that says the agent worked a message through */
  --bub-them: var(--bg);
  --bub-you: color-mix(in srgb, var(--accent) 12%, var(--bg));
  --bub-shadow: 0 1px 2px rgba(17, 17, 20, 0.05), 0 5px 16px rgba(17, 17, 20, 0.045);
  --bub-radius: 24px;
  --ok: #2e9e6b;
  /* the check inside the white disc on a message the agent worked through */
  --mark-ink: var(--accent);
  /* the read bar's height, so the page can hand the same number back as space
     underneath itself — 10px + a 42px button + 10px */
  --readbar-h: 62px;
  --radius: 20px;
  --radius-s: 12px;
  --maxw: 600px;
  --tabbar-h: 62px;
  font-size: 16px;
}
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #17171c;
    --bg-soft: #0f0f13;
    --ink: #f2f2f7;
    --ink-2: #a3a3af;
    --ink-3: #6e6e7a;
    --ink-inv: #111114;
    --line: #26262e;
    --glass: rgba(23, 23, 28, 0.92);
    --card-shadow: none;
    /* the tint has to work harder against a dark page to read as "mine", and a
       drop shadow under a bubble on near-black is just a smudge */
    --bub-you: color-mix(in srgb, var(--accent) 30%, var(--bg));
    --bub-shadow: none;
    --ok: #5ee6c4;
  }
}
html { scroll-behavior: smooth; }
body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  background: var(--bg-soft);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  overscroll-behavior-y: none;
}
a { color: inherit; text-decoration: none; }
button { font: inherit; border: 0; background: none; cursor: pointer; color: inherit; }
input, textarea, select {
  font: inherit; color: var(--ink); background: var(--bg);
  border: 2px solid var(--line); border-radius: var(--radius-s);
  padding: 10px 12px; width: 100%;
}
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--ink); }
label { font-size: 0.8rem; font-weight: 700; color: var(--ink-2); display: block; margin: 12px 0 4px; }

/* ---------- layout ---------- */
#topbar {
  position: sticky; top: 0; z-index: 20;
  background: var(--glass); backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--line);
}
.topbar-inner {
  max-width: var(--maxw); margin: 0 auto; padding: 12px 16px;
  display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 54px;
}
/* Back first, then the slot naming the page — the brand on a page that has no
   name of its own, otherwise the page's title. */
.topbar-lead { display: flex; align-items: center; gap: 4px; min-width: 0; flex: 1; }
.topbar-name { display: flex; align-items: center; min-width: 0; flex: 1; }
.brand { font-weight: 900; font-size: 1.35rem; letter-spacing: -0.02em; display: flex; align-items: center; gap: 8px; }
.brand-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--accent); display: inline-block; }
.back-btn { margin-left: -8px; }
.back-btn .ic { width: 24px; height: 24px; }
/* The page's own name, smaller than the brand it stands in for. It stays out of
   the way while the page's real heading is on screen and fades in as that
   scrolls away, so the header always answers "where am I" without ever being
   the second place the same words are written. */
.topbar-title {
  font-size: 1.02rem; font-weight: 800; letter-spacing: -0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
  opacity: 0; transform: translateY(4px); pointer-events: none;
  transition: opacity 0.16s ease, transform 0.16s ease;
}
body.scrolled .topbar-title { opacity: 1; transform: none; pointer-events: auto; }
/* Actions take the space left over from the lead; a wide action (the filter
   chip) shrinks to fit rather than pushing the bar out of shape. */
#topbar-actions { display: flex; gap: 8px; align-items: center; min-width: 0; justify-content: flex-end; }
#topbar-actions > * { flex: none; }

.view {
  max-width: var(--maxw); margin: 0 auto;
  padding: 16px 16px calc(var(--tabbar-h) + 32px + env(safe-area-inset-bottom));
  min-height: 70vh;
}

#tabbar {
  position: fixed; bottom: 0; left: 0; right: 0; z-index: 30;
  background: var(--glass); backdrop-filter: blur(12px);
  border-top: 1px solid var(--line);
  display: flex; justify-content: center; gap: 4px;
  padding-bottom: env(safe-area-inset-bottom);
}
.tab {
  flex: 1; max-width: 110px; height: var(--tabbar-h);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px;
  color: var(--ink-3); font-size: 0.68rem; font-weight: 700; position: relative;
}
.tab svg { width: 24px; height: 24px; fill: currentColor; stroke: none; }
.tab.active { color: var(--ink); }
.tab-badge {
  position: absolute; top: 8px; right: calc(50% - 20px);
  background: var(--warn); color: #fff; font-size: 0.62rem; font-weight: 800;
  min-width: 17px; height: 17px; border-radius: 9px; display: flex; align-items: center; justify-content: center; padding: 0 4px;
}

/* ---------- generic ---------- */
.card {
  background: var(--bg); border-radius: var(--radius);
  border: 1px solid var(--line); overflow: hidden; margin-bottom: 16px;
  box-shadow: var(--card-shadow);
}
.section-title { font-size: 1.5rem; font-weight: 900; letter-spacing: -0.02em; margin: 8px 0 16px; }
/* a heading with the control that belongs to *its list* beside it, rather than
   up in the top bar among the page's own actions */
.section-head { display: flex; align-items: center; gap: 10px; }
.section-head .section-title { flex: 1; min-width: 0; }
.section-head .view-toggle { flex: none; margin-bottom: 8px; }

/* "something arrived while you were reading" — the feed and an open chat both
   say it here rather than moving the page under you. */
.new-pill {
  position: fixed; left: 50%; transform: translateX(-50%); z-index: 30;
  top: calc(78px + env(safe-area-inset-top));
  border: 0; border-radius: 999px; padding: 9px 16px; cursor: pointer;
  background: var(--accent); color: #fff; font-size: 0.82rem; font-weight: 800;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
}
.new-pill:active { transform: translateX(-50%) scale(0.96); }
.new-pill.chat-pill {
  top: auto; bottom: calc(var(--tabbar-h) + 78px + env(safe-area-inset-bottom));
}
.muted { color: var(--ink-2); }
.tiny { font-size: 0.75rem; color: var(--ink-3); font-weight: 600; }
.chip {
  display: inline-flex; align-items: center; gap: 6px;
  background: var(--bg-soft); border-radius: 999px; padding: 5px 12px;
  font-size: 0.78rem; font-weight: 700; color: var(--ink-2);
}
a.chip:active { transform: scale(0.97); }
.pill {
  display: inline-block; padding: 3px 10px; border-radius: 999px;
  font-size: 0.68rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em;
}
.pill.active { background: #e3f8f1; color: #00785f; }
.pill.idea { background: #f0edff; color: #5b4bd6; }
.pill.paused { background: #fff3e0; color: #b06000; }
.pill.shipped { background: #e3f2fd; color: #0b63b5; }
.pill.archived { background: #f0f0f3; color: var(--ink-2); }
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  background: var(--ink); color: var(--ink-inv); font-weight: 800;
  border-radius: 999px; padding: 12px 22px; font-size: 0.92rem;
}
.btn:active { transform: scale(0.98); }
.btn.secondary { background: var(--bg-soft); color: var(--ink); }
.btn.small { padding: 8px 14px; font-size: 0.8rem; }
.btn.danger { background: #fdecec; color: #c0392b; }
.icon-btn {
  width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
  background: var(--bg-soft); font-size: 1.1rem; font-weight: 800;
}
/* Shared inline SVG icon — scales with its text; context rules (tabbar, sheet,
   post-menu…) that target `svg` still win by specificity. */
.ic { width: 1.05em; height: 1.05em; vertical-align: -0.15em; flex: none; }
.icon-btn .ic { width: 22px; height: 22px; }
h4 .ic { margin-right: 5px; }
.hero-actions .btn .ic, .article-footer .btn .ic { margin-right: 2px; }
.empty {
  text-align: center; padding: 56px 24px; color: var(--ink-2);
}
.empty .big { font-size: 2.6rem; margin-bottom: 12px; }
.empty h3 { font-weight: 800; color: var(--ink); margin-bottom: 6px; }
.empty p { font-size: 0.9rem; max-width: 420px; margin: 0 auto; line-height: 1.5; }

/* ---------- markdown ---------- */
.md { line-height: 1.6; font-size: 0.95rem; overflow-wrap: anywhere; }
.md p, .md ul, .md ol, .md blockquote, .md pre { margin-bottom: 10px; }
.md p:last-child, .md ul:last-child, .md pre:last-child { margin-bottom: 0; }
.md h3, .md h4, .md h5, .md h6 { margin: 14px 0 6px; font-weight: 800; letter-spacing: -0.01em; }
.md ul, .md ol { padding-left: 22px; }
.md code { background: var(--bg-soft); border-radius: 6px; padding: 2px 6px; font-size: 0.85em; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.md pre { background: #16161c; color: #f2f2f7; border-radius: var(--radius-s); padding: 14px; overflow-x: auto; }
.md pre code { background: none; color: inherit; padding: 0; }
.md a { color: var(--accent); font-weight: 700; }
.md blockquote { border-left: 4px solid var(--line); padding-left: 12px; color: var(--ink-2); }
.md hr { border: 0; border-top: 1px solid var(--line); margin: 14px 0; }
.md img { max-width: 100%; border-radius: 12px; display: block; }

/* tables — the wrapper scrolls so a wide table never widens the article */
.md-table-wrap {
  margin: 12px 0; max-width: 100%; overflow-x: auto; -webkit-overflow-scrolling: touch;
  border: 1px solid var(--line); border-radius: var(--radius-s);
}
.md-table { border-collapse: collapse; width: 100%; font-size: 0.86rem; line-height: 1.45; }
.md-table th, .md-table td { padding: 8px 12px; text-align: left; vertical-align: top; border-bottom: 1px solid var(--line); }
.md-table thead th { background: var(--bg-soft); font-weight: 800; color: var(--ink); white-space: nowrap; }
.md-table tbody tr:last-child td { border-bottom: 0; }
.md-table td { color: var(--ink-2); }
.md-table code { white-space: nowrap; }

/* In-article images & albums. The rounding belongs to each picture, not to the
   figure around them: the figure also holds the caption, so clipping it left
   every captioned image round on top and square along the bottom, where the
   caption — not the picture — was taking the corners. */
.md-album { display: grid; gap: 4px; margin: 12px 0; grid-template-columns: 1fr 1fr; }
.md-album.n1 { grid-template-columns: 1fr; }
.md-album img { width: 100%; height: 100%; object-fit: cover; display: block; border-radius: var(--radius-s); cursor: zoom-in; }
/* a cell still waiting for its picture fills the same space, but keeps the
   centred layout its own rule gives it */
.md-album .img-pending { width: 100%; height: 100%; border-radius: var(--radius-s); }
.md-album:not(.n1) img, .md-album:not(.n1) .img-pending { aspect-ratio: 1; }
.md-album.n3 > :first-child { grid-column: 1 / -1; aspect-ratio: 2 / 1; }
.md-album figcaption { grid-column: 1 / -1; font-size: 0.8rem; color: var(--ink-3); padding: 6px 4px 0; }
/* ---------- media still on its way ----------
   Two states, one language: a picture that hasn't landed yet (.img-pending) and
   one being rendered again over what you already have (.img-working). Neither
   spins. A wide band of light drifts across the surface instead — the same
   gesture as a pulse, at the pace of something working rather than something
   stuck. */
@keyframes mediaSweep {
  0% { transform: translateX(-38%); }
  100% { transform: translateX(38%); }
}
@keyframes mediaBreathe { 0%, 100% { opacity: 0.5; } 50% { opacity: 1; } }
@keyframes mediaPulse {
  0%, 100% { opacity: 0.35; transform: scale(0.7); }
  50% { opacity: 1; transform: scale(1); }
}
.img-pending {
  position: relative; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  background: var(--bg-soft); color: var(--ink-3);
  border-radius: var(--radius-s); min-height: 140px; padding: 16px; text-align: center;
  font-size: 0.8rem; font-weight: 700; cursor: default;
}
.img-pending::before {
  content: ""; position: absolute; inset: -50% -70%;
  background: linear-gradient(100deg,
    transparent 36%, rgba(108, 92, 231, 0.22) 47%, rgba(0, 184, 148, 0.18) 55%, transparent 66%);
  animation: mediaSweep 2.6s ease-in-out infinite alternate;
}
.img-pending .ip-label {
  position: relative; max-width: 90%; line-height: 1.4;
  animation: mediaBreathe 2.6s ease-in-out infinite;
}

/* ---------- media cards ----------
   The roomy, clean card an embedded link gets: a big icon on the left, the
   thing's name, one quiet line of detail under it, and — for something that
   opens elsewhere — the go button on the right. The session link and every
   audio track wear it, so an article's attachments read as one family. */
.media-card {
  position: relative;
  display: flex; align-items: center; gap: 14px;
  padding: 13px 14px; border-radius: 18px;
  background: var(--bg); border: 1px solid var(--line); box-shadow: var(--card-shadow);
  color: var(--ink); text-decoration: none;
}
.mc-icon {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; border-radius: 14px;
  background: var(--bg-soft); color: var(--accent);
}
.mc-icon .ic { width: 24px; height: 24px; }
.mc-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
/* one line each, cut with an ellipsis — a card that grows to fit a long label
   stops being the calm object it's here to be */
.mc-title {
  font-size: 1rem; font-weight: 800; line-height: 1.3; letter-spacing: -0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mc-sub {
  font-size: 0.78rem; font-weight: 600; color: var(--ink-3); line-height: 1.35;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.mc-go {
  flex: none; display: flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; border-radius: 14px;
  background: var(--bg-soft); color: var(--ink-2);
}
.mc-go .ic { width: 21px; height: 21px; }
a.media-card:hover { border-color: var(--accent); }
a.media-card:hover .mc-go { background: var(--accent); color: #fff; }
a.media-card:active { transform: scale(0.995); }

/* A track wears the same rounded card as the session link — it's one of the
   article's attachments and reads as one. What changed is what's inside it: the
   browser's own grey player is gone, and everything below the title is drawn by
   public/player.js on the card's own background. The <audio> is only the engine. */
.audio-block {
  position: relative; display: block; margin: 14px 0;
  padding: 10px 12px; border-radius: 16px;
  background: var(--bg); border: 1px solid var(--line); box-shadow: var(--card-shadow);
  color: var(--ink);
}
/* the name of the track, one line, with the headphones inline in front of it —
   a tile around them was 44px of height spent on decoration */
.ab-head { display: flex; align-items: center; gap: 8px; }
.ab-icon { flex: none; display: flex; align-items: center; color: var(--accent); }
.ab-icon .ic { width: 18px; height: 18px; }
.ab-title {
  flex: 1; min-width: 0;
  font-size: 0.92rem; font-weight: 800; line-height: 1.3; letter-spacing: -0.01em;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ab-sub {
  font-size: 0.78rem; font-weight: 600; color: var(--ink-3); line-height: 1.35;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ap-native { display: none; }

/* the row that matters: everything here is as small as it can be read at, so
   the bar between them gets the rest */
.aplayer { display: flex; align-items: center; gap: 10px; margin: 6px 0 0; }
.ap-play {
  flex: none; width: 34px; height: 34px; border-radius: 50%;
  border: 0; background: var(--accent); color: #fff;
  display: flex; align-items: center; justify-content: center; cursor: pointer;
  transition: transform 0.12s ease;
}
.ap-play:active { transform: scale(0.93); }
.ap-play svg { width: 18px; height: 18px; fill: currentColor; }
/* the line: a hairline you can hit with a thumb — the padding is the target,
   the 3px inside it is what you see */
.ap-bar { flex: 1; min-width: 0; padding: 10px 0; cursor: pointer; touch-action: none; }
.ap-bar:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 4px; }
.ap-track {
  position: relative; display: block; height: 3px; border-radius: 3px;
  background: var(--line);
}
.ap-buffer, .ap-fill {
  position: absolute; inset: 0 auto 0 0; height: 100%; border-radius: 3px; width: 0;
}
.ap-buffer { background: var(--ink-3); opacity: 0.25; }
.ap-fill { background: var(--accent); }
.ap-knob {
  position: absolute; top: 50%; left: 0; width: 11px; height: 11px; border-radius: 50%;
  background: var(--accent); transform: translate(-50%, -50%);
  transition: width 0.12s ease, height 0.12s ease;
}
.aplayer.scrubbing .ap-knob { width: 15px; height: 15px; }
.ap-time {
  flex: none; font-size: 0.72rem; font-weight: 700; color: var(--ink-3);
  font-variant-numeric: tabular-nums; display: flex; gap: 2px;
}
.ap-speed {
  flex: none; border: 0; background: none; cursor: pointer; padding: 2px 0 2px 2px;
  font-size: 0.72rem; font-weight: 800; color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.ap-speed:hover { color: var(--accent); }

/* An article's own recording, dressed as the link card it sits next to: tile,
   name, one quiet line of detail, one 44px button on the right — the session
   link's shape, with a play button where its go button is. The transport is the
   player's own markup, so instead of moving it we let it fall through
   (`display: contents`) and place its parts on this grid. Folded away, only the
   button is on the grid; once something has played, the line you scrub takes a
   second row and the button stays exactly where the thumb left it.
   The badge column exists only when there is a badge — an empty track would
   otherwise leave a column's gap between the name and the button. */
.audio-link {
  display: grid; align-items: center;
  grid-template-columns: 44px minmax(0, 1fr) auto auto;
  grid-template-areas: "icon text badge play";
  column-gap: 14px; row-gap: 2px;
  padding: 13px 14px; border-radius: 18px;
}
.audio-link:not(:has(> .ai-badge)) {
  grid-template-columns: 44px minmax(0, 1fr) auto;
  grid-template-areas: "icon text play";
}
.audio-link > .mc-icon { grid-area: icon; }
.audio-link > .mc-text { grid-area: text; }
/* the extra class is deliberate: the 24px badge a track wears mid-article is
   defined further down, and on this card the badge answers to the link card's
   size instead */
.audio-block.audio-link > .ai-badge { grid-area: badge; width: 32px; height: 32px; font-size: 0.66rem; }
.audio-link .aplayer { display: contents; }
.audio-link .ap-play { grid-area: play; width: 44px; height: 44px; border-radius: 14px; }
.audio-link .ap-play svg { width: 20px; height: 20px; }
.audio-link .ap-bar { grid-area: bar; display: none; padding: 6px 0 2px; }
.audio-link .ap-speed { grid-area: speed; display: none; justify-self: end; }
/* The clock is the subtitle's job here, not a column's. A grid shares its
   columns down the whole card, so a "0:00 / 4:25" sitting under the badge was
   widening that column and eating the title above it — the name of the track
   got an ellipsis the moment you pressed play. */
.audio-link .ap-time { display: none; }
.audio-link.open { grid-template-areas: "icon text badge play" "bar bar bar speed"; }
.audio-link.open:not(:has(> .ai-badge)) { grid-template-areas: "icon text play" "bar bar speed"; }
.audio-link.open .ap-bar { display: block; }
.audio-link.open .ap-speed { display: block; }
/* how long it runs, written in by the player once the metadata lands, and where
   you are in it once it's running — the digits are tabular so a card doesn't
   twitch on every tick. Before either exists the subtitle is just what kind of
   recording this is, with no dangling separator. */
.audio-link .ab-dur { font-variant-numeric: tabular-nums; }
.audio-link .ab-dur:not(:empty)::after { content: " · "; }

/* a track that hasn't been narrated yet — the line is already there, waiting */
.audio-block.pending { box-shadow: none; border-style: dashed; background: var(--bg-soft); }
.audio-block.pending .ab-icon { color: var(--ink-3); }
.audio-block.pending .ab-title { color: var(--ink-2); font-weight: 700; }
.audio-link.pending .mc-icon { color: var(--ink-3); background: var(--bg); }
.audio-link.pending .mc-title { color: var(--ink-2); font-weight: 700; }

/* where each card sits in an article */
.md-audio, .md-audio-block { margin: 14px 0; }
.post-audio { margin: 0 0 14px; }
.post-body > .post-audio:last-child { margin-bottom: 0; }
/* The chat an article came out of — the one provenance card still on the page,
   because it is also where your notes about the article go. Everything else
   about where the piece came from lives behind the byline. */
.post-source { margin-top: 18px; }

/* ---------- byline ----------
   Who wrote the article, on the line that already says where and when it came
   from: World · Fri, Jul 31 · 11:33 · Claude Code ›. It used to be a full strip
   under the title, which made the second-loudest thing on the page a question
   almost nobody has while reading. The trail behind a piece — which session,
   which routine, which PR — is still one tap away, in the panel that was always
   behind it. */
.by-tag {
  display: inline-flex; align-items: center; gap: 1px;
  margin: 0; padding: 0; border: 0; background: none;
  font: inherit; color: inherit; cursor: pointer;
  text-decoration: underline; text-decoration-style: dotted;
  text-underline-offset: 3px; text-decoration-color: var(--line);
}
.by-tag::before { content: "·"; margin: 0 5px 0 3px; text-decoration: none; color: var(--ink-3); }
.by-tag:hover { color: var(--ink-2); text-decoration-color: var(--ink-3); }
.by-go { flex: none; display: flex; color: var(--ink-3); }
.by-go .ic { width: 13px; height: 13px; }

/* the panel behind it: the trail, one card per place */
.prov-list { display: flex; flex-direction: column; gap: 8px; margin: 6px 0 4px; }
.prov-card { padding: 10px 11px; border-radius: 14px; box-shadow: none; }
.prov-card .mc-icon, .prov-card .mc-go { width: 36px; height: 36px; border-radius: 11px; }
.prov-card .mc-icon .ic { width: 19px; height: 19px; }
.prov-card .mc-title { font-size: 0.92rem; }
button.mc-go { border: 0; cursor: pointer; }
button.mc-go:hover { background: var(--accent); color: #fff; }
/* an origin with no link: the id itself, in text you can select and copy */
.prov-ref {
  display: block; margin: -2px 0 0; padding: 7px 10px;
  border-radius: 10px; background: var(--bg-soft);
  font-family: ui-monospace, Menlo, monospace; font-size: 0.76rem; color: var(--ink-2);
  word-break: break-all; user-select: all;
}

/* lightbox */
.lightbox { position: fixed; inset: 0; z-index: 100; background: rgba(10, 10, 14, 0.93); display: flex; align-items: center; justify-content: center; }
.lightbox figure { display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: 96vw; }
.lightbox img { max-width: 96vw; max-height: 84vh; object-fit: contain; border-radius: 8px; }
.lightbox figcaption { color: #eee; font-size: 0.85rem; text-align: center; max-width: 80vw; }
.lightbox button {
  position: absolute; z-index: 1; width: 42px; height: 42px; border: 0; border-radius: 999px;
  background: rgba(255, 255, 255, 0.14); color: #fff; font-size: 1.3rem;
  display: flex; align-items: center; justify-content: center; cursor: pointer;
}
.lb-close { top: max(14px, env(safe-area-inset-top)); right: 14px; }
.lb-prev { left: 10px; top: 50%; transform: translateY(-50%); }
.lb-next { right: 10px; top: 50%; transform: translateY(-50%); }
.lb-count {
  position: absolute; bottom: max(18px, env(safe-area-inset-bottom)); left: 50%; transform: translateX(-50%);
  color: #fff; font-size: 0.8rem; font-weight: 700; opacity: 0.85;
}

/* ---------- feed ---------- */
.feed-group {
  font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--ink-3); margin: 22px 4px 10px;
}
#feed-list > .feed-group:first-child { margin-top: 2px; }
/* The cover is rendered wide now (640×384 — see src/genspec.js) and shown in the
   16:9 band it always was, so the crop takes a sliver off the top and bottom
   instead of the 44 % a square render lost. The band stays: a cover is the way
   into an article, not a picture you're looking at yet, and a full-height one
   pushed the words that follow it off the screen. The whole render opens on the
   article's own page. */
.post-cover { position: relative; aspect-ratio: 16/9; display: block; }
/* On an article's own page the cover opens full-size, like every other picture
   in the article. In the feed it has no such handle — there the cover is the
   way into the article, not a picture you're looking at yet. */
.post-cover.cover-zoom { cursor: zoom-in; }
.post-cover img, .post-cover svg { width: 100%; height: 100%; object-fit: cover; display: block; }
.post-body { padding: 16px; }
.post-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
/* One icon, one shape: a rounded square, everywhere a channel, project or chat
   has a face. The corner is a percentage rather than a pixel count so it is the
   *same* rounded square at every size, and so no context can quietly restate it
   — the chats list used to clip the very same icon into a circle, and a channel
   read as two different kinds of thing depending on which list you met it in.
   Only the size varies below; the shape is settled here. */
.avatar {
  width: 36px; height: 36px; border-radius: 31%; flex: none;
  display: flex; align-items: center; justify-content: center; font-size: 1.15rem;
  position: relative; overflow: hidden;
}
/* Icon-image avatar: a plain white tile (favicons are drawn for a light
   background), with a hairline ring so the tile stays visible on white cards. */
.avatar--img {
  background: #fff;
  box-shadow: inset 0 0 0 1px var(--line);
}
/* Custom icon image (an app's favicon/logo) filling the tile. object-fit: cover
   keeps it crisp at any avatar size; if it fails to load, the onerror handler
   replaces it with the emoji fallback. */
.avatar-img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; border-radius: inherit; background: inherit;
}

/* Size variants of the one icon component. Every place that shows a channel,
   project or chat icon picks one of these instead of styling its own glyph, so
   an uploaded icon and an emoji fallback line up wherever they turn up. */
.avatar--sm { width: 28px; height: 28px; font-size: 0.95rem; }
.avatar--xs { width: 24px; height: 24px; font-size: 0.85rem; }
/* A page title that leads with its channel's icon. */
.section-title.with-icon { display: flex; align-items: center; gap: 10px; }

/* Channel/project icon picker in the edit modal: a live avatar preview beside
   the emoji field and the upload/remove controls. */
.icon-picker { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 4px; }
.icon-picker .avatar { width: 56px; height: 56px; font-size: 1.7rem; }
.icon-picker-controls { flex: 1; min-width: 0; }
.icon-picker-controls input { margin: 0; }
.icon-picker-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.icon-picker-actions label.btn { margin: 0; cursor: pointer; }
.icon-picker .tiny { margin-top: 6px; }
.btn.ghost { background: transparent; color: var(--ink-2); }
.post-head .who { flex: 1; min-width: 0; }
.post-head .who b { display: block; font-size: 0.88rem; font-weight: 800; }
.post-title { font-size: 1.22rem; font-weight: 900; letter-spacing: -0.02em; line-height: 1.25; margin-bottom: 8px; }
.post.hero .post-title { font-size: 1.35rem; }
.post-excerpt {
  font-size: 0.9rem; color: var(--ink-2); line-height: 1.5;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.post-excerpt + .post-audio { margin-top: 12px; }

/* ---------- episode cards (serial / episodic channels) ---------- */
/* A post from a series channel wears its place in the story — a slim strip
   above the card header. The active episode (the one card the mixed feed shows
   for the whole channel) gets a quiet accent ring: a touch more present than
   its neighbours, never shouting. */
.episode-strip {
  display: flex; align-items: center; gap: 6px; margin-bottom: 10px;
  font-size: 0.7rem; font-weight: 800; letter-spacing: 0.05em;
  text-transform: uppercase; color: var(--ink-3);
}
.episode-strip .ic { width: 14px; height: 14px; }
.post.episode-active .episode-strip { color: var(--accent); }
.card.post.episode-active {
  border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent), var(--card-shadow);
}
.episode-strip .series-bar { margin-left: auto; }
/* One card in the feed standing in for eight is the thing a reader most needs
   warning about, and a bar over the whole story is the shortest way to give it:
   how much is behind you, how much is left, before you've read a word. */
.series-bar {
  display: block; width: 64px; height: 3px; border-radius: 3px; flex: none;
  background: color-mix(in srgb, var(--ink-3) 25%, transparent); overflow: hidden;
}
.series-bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
.series-bar.big { width: 100%; height: 5px; margin: 12px 0 14px; }

/* A series card carries a stacked edge — two sheets peeking out from under it.
   The active episode of a run with more behind it is not one article and should
   not look like one; everything else about the card stays exactly as it is.
   Drawn in shadows rather than pseudo-elements because a card clips its own
   content (overflow: hidden) and would cut the sheets off at the fold. */
.card.post.episode-stacked {
  box-shadow:
    0 8px 0 -5px var(--bg), 0 8px 0 -4px var(--line),
    0 14px 0 -9px var(--bg), 0 14px 0 -8px var(--line),
    var(--card-shadow);
  margin-bottom: 24px;
}
.card.post.episode-stacked.episode-active {
  box-shadow:
    0 8px 0 -5px var(--bg), 0 8px 0 -4px var(--line),
    0 14px 0 -9px var(--bg), 0 14px 0 -8px var(--line),
    0 0 0 1px color-mix(in srgb, var(--accent) 20%, transparent), var(--card-shadow);
}

/* What kind of channel this is, said once on its page. */
.mode-pill {
  display: inline-flex; align-items: center; gap: 6px; margin-top: 8px;
  background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent);
  border-radius: 999px; padding: 4px 12px; font-size: 0.72rem; font-weight: 800;
}
.mode-pill .ic { width: 13px; height: 13px; }

/* ---------- a series channel's page: the run, under a hero ---------- */
/* Where you are in the story, one button into the part you're on, and then the
   episodes under each other — a season under a show, a podcast under its name.
   The list is the page: a series whose parts you have to go and find in a feed
   is a series only its author knows about. */
.series-head { padding: 16px; }
.series-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.series-kind {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 0.78rem; font-weight: 800; color: var(--ink-2);
}
.series-kind .ic { width: 15px; height: 15px; color: var(--accent); }
.series-lockmark {
  display: inline-flex; align-items: center; gap: 5px; margin-left: auto;
  font-size: 0.7rem; font-weight: 800; color: var(--ink-3);
}
.series-lockmark .ic { width: 12px; height: 12px; }
.btn.series-continue {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px; width: 100%;
  text-decoration: none;
}
.btn.series-continue .ic { width: 16px; height: 16px; }
.series-done { font-size: 0.85rem; color: var(--ink-3); margin: 0; }

.episode-list { list-style: none; margin: 0 0 16px; padding: 0; }
.episode-list li { margin-bottom: 8px; }
.ep-row {
  display: flex; align-items: center; gap: 12px; padding: 10px 12px;
  background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-s);
  text-decoration: none; color: inherit;
}
.ep-row:active { background: var(--bg-soft); }
.ep-num {
  flex: none; width: 26px; text-align: center;
  font-size: 0.95rem; font-weight: 900; color: var(--ink-3); font-variant-numeric: tabular-nums;
}
.ep-art {
  flex: none; width: 56px; height: 42px; border-radius: 6px; overflow: hidden; background: var(--bg-soft);
}
.ep-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.ep-info { flex: 1; min-width: 0; }
.ep-info b { display: block; font-size: 0.92rem; font-weight: 800; line-height: 1.3; }
.ep-teaser {
  display: block; font-size: 0.8rem; color: var(--ink-2); margin-top: 2px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ep-meta { display: block; font-size: 0.72rem; color: var(--ink-3); margin-top: 3px; font-weight: 700; }
.ep-mark { flex: none; color: var(--ink-3); }
.ep-mark .ic { width: 17px; height: 17px; }
/* The one you're on: the only row with the accent on it, so the page answers
   "where was I" before you've read a title. */
.ep-row.next {
  border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
  background: color-mix(in srgb, var(--accent) 5%, var(--bg));
}
.ep-row.next .ep-num, .ep-row.next .ep-mark { color: var(--accent); }
.ep-row.next .ep-meta { color: var(--accent); }
/* Read: still here, still openable, just done — dimmed rather than removed,
   because the part you go back to look something up in is always a read one. */
.ep-row.done .ep-info b { font-weight: 700; color: var(--ink-2); }
.ep-row.done .ep-num { opacity: 0.5; }
.ep-row.done .ep-mark { color: var(--good, var(--accent)); }
/* Locked: visible, legible, and still a link. The lock is a claim about what
   the material needs, not a wall around your own articles. */
.ep-row.locked { opacity: 0.62; }
.ep-row.locked .ep-teaser { filter: blur(2.5px); user-select: none; }

/* ---------- walking through a series from inside an episode ---------- */
/* An episode is a place in something. Without a way on and a way back, a series
   is a pile of articles that happen to be numbered. */
.episode-nav {
  display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap;
  margin: 0 0 16px;
}
.ep-step {
  flex: 1 1 0; min-width: 45%; display: flex; flex-direction: column; gap: 2px;
  padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-s);
  background: var(--bg); text-decoration: none; color: inherit;
}
.ep-step.empty { border: 0; background: none; padding: 0; }
.ep-step.next { text-align: right; }
.ep-step:active { background: var(--bg-soft); }
.ep-dir {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 0.7rem; font-weight: 800; letter-spacing: 0.04em;
  text-transform: uppercase; color: var(--ink-3);
}
.ep-step.next .ep-dir { justify-content: flex-end; }
.ep-dir .ic { width: 12px; height: 12px; }
.ep-name {
  font-size: 0.86rem; font-weight: 700; line-height: 1.3;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.ep-step.locked { opacity: 0.7; }
.ep-step.locked .ep-name { font-weight: 600; color: var(--ink-3); }
.ep-all {
  flex: 0 0 100%; order: 3; text-align: center; padding: 8px;
  font-size: 0.78rem; font-weight: 800; color: var(--ink-2); text-decoration: none;
}

/* Opened out of order — from a link, from search, or by saying "open anyway".
   The article is right here; this only says what it assumes you've read. */
.lock-note {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 12px 14px; margin-bottom: 12px;
  border: 1px solid color-mix(in srgb, var(--warn, var(--accent)) 35%, var(--line));
  background: color-mix(in srgb, var(--warn, var(--accent)) 7%, var(--bg));
  border-radius: var(--radius-s);
}
.lock-note .lk-ic { flex: none; color: var(--ink-2); }
.lock-note .lk-ic .ic { width: 17px; height: 17px; }
.lock-note .lk-txt { flex: 1; min-width: 180px; font-size: 0.83rem; color: var(--ink-2); line-height: 1.45; }

/* A checkbox with its explanation under it — the series lock in the channel
   editor, where the setting needs a sentence more than it needs a label. */
.check-row { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0 4px; font-weight: 700; }
.check-row input { width: auto; margin: 2px 0 0; flex: none; }
.check-row .tiny { display: block; font-weight: 600; margin-top: 3px; }

/* A card in the feed is one target, all of it. The headline stays the real
   anchor — so it still reads as a link, opens in a new tab, and copies its URL —
   but an invisible layer stretched from it covers the whole card, which is why
   the teaser, the thumbnail and the space around them all open the article too.
   Feed cards only: the detail view uses the same classes for the same post. */
.feed-card { position: relative; }
.feed-card .post-title a::after {
  content: ""; position: absolute; inset: 0; z-index: 1; border-radius: inherit;
}
/* Everything on the card that does something else has to sit above that layer,
   or the card would swallow it. */
.feed-card .post-head .who a,
.feed-card .post-menu { position: relative; z-index: 2; }
.feed-card:active { transform: scale(0.995); }

/* compact card — routine kinds (update, note) in the feed */
.post.compact .post-title { font-size: 1rem; margin-bottom: 4px; }
.post.compact .post-excerpt { font-size: 0.85rem; }
.compact-main { display: flex; gap: 12px; align-items: flex-start; }
.compact-text { flex: 1; min-width: 0; }
.post-thumb {
  flex: none; width: 74px; height: 74px; border-radius: var(--radius-s);
  overflow: hidden; display: block; background: var(--bg-soft);
}
.post-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* course-check strip — one slim row in the feed */
.post.course { border-left: 4px solid var(--good); }
.post.course.drifting { border-left-color: var(--warn); }
.course-strip { display: flex; align-items: center; gap: 12px; padding: 12px 16px; }
.course-strip .ico { font-size: 1.25rem; }
.course-strip .info { flex: 1; min-width: 0; }
.course-strip .info b { display: block; font-size: 0.88rem; font-weight: 800; }
.post.course.on_track .info b { color: #00785f; }
.post.course.drifting .info b { color: #b3491f; }
.course-strip .note {
  display: block; font-size: 0.8rem; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.course-banner {
  display: flex; align-items: center; gap: 10px; padding: 12px 16px;
  font-weight: 800; font-size: 0.9rem;
}
.course-banner.on_track { background: #e3f8f1; color: #00785f; }
.course-banner.drifting { background: #fdeee7; color: #b3491f; }
.feed-loader { text-align: center; padding: 24px; color: var(--ink-3); font-weight: 700; }

/* hero cards go edge-to-edge on phones — covers get the full width */
@media (max-width: 599px) {
  .post.hero {
    margin-left: -16px; margin-right: -16px;
    border-radius: 0; border-left: 0; border-right: 0;
  }
}

/* ---------- projects ---------- */
.project-row {
  display: flex; align-items: center; gap: 14px; padding: 16px;
}
.project-row .avatar { width: 52px; height: 52px; font-size: 1.6rem; }
.project-row .info { flex: 1; min-width: 0; }
.project-row .info b { font-size: 1.05rem; font-weight: 800; display: block; }
.project-row .info .tagline { display: block; font-size: 0.82rem; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.unread-dot { min-width: 20px; height: 20px; border-radius: 10px; background: var(--warn); color: #fff; font-size: 0.7rem; font-weight: 800; display: flex; align-items: center; justify-content: center; padding: 0 6px; }

.project-hero { padding: 24px 16px 16px; text-align: center; }
.project-hero .avatar { width: 84px; height: 84px; font-size: 2.6rem; margin: 0 auto 12px; }
/* The channels this one sits inside, outermost first — small, above the name,
   every step a link back up. Long trails scroll sideways rather than wrapping
   into a second line and pushing the name down the screen. */
.hero-trail {
  display: flex; align-items: center; justify-content: center; gap: 6px;
  margin-bottom: 6px; font-size: 0.78rem; font-weight: 700;
  max-width: 100%; overflow-x: auto; white-space: nowrap;
  scrollbar-width: none;
}
.hero-trail::-webkit-scrollbar { display: none; }
.hero-trail a { color: var(--ink-3); }
.hero-trail a:active { color: var(--accent); }
.hero-trail .sep { color: var(--ink-3); opacity: 0.6; }
.project-hero h1 { font-size: 1.7rem; font-weight: 900; letter-spacing: -0.02em; }
.project-hero .tagline { color: var(--ink-2); margin-top: 4px; font-size: 0.95rem; }
.hero-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 16px; }
.link-row { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 16px 16px; justify-content: center; }
.detail-section { padding: 16px; border-top: 1px solid var(--line); }
.detail-section h4 {
  font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--ink-3); margin-bottom: 8px;
}
.direction-card {
  margin: 0 16px 16px; padding: 14px 16px; border-radius: var(--radius-s);
  background: linear-gradient(135deg, #f6f4ff, #eef7ff); border: 1px solid #e4defc;
}
.direction-card h4 { font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; color: #5b4bd6; margin-bottom: 6px; }
.tech-chips { display: flex; flex-wrap: wrap; gap: 6px; }

/* ---------- chats ---------- */
.conv-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
.conv-row .info { flex: 1; min-width: 0; }
.conv-row .info b { display: block; font-weight: 800; font-size: 0.95rem; }
.conv-row .last { display: block; font-size: 0.82rem; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* What's still open in a chat, under its name — the line that used to preview
   the last message. Three counts, each one a different question: what the agent
   has said that you haven't read, what it hasn't worked through, and what it
   hasn't even picked up. Colour carries which is which; the words carry what
   they mean, because a row of bare numbers would just be three badges again. */
.cnt-line { display: flex; align-items: center; gap: 6px; flex-wrap: nowrap; overflow: hidden; }
.conv-row .cnt {
  flex: none; font-size: 0.72rem; font-weight: 800; line-height: 1.5;
  padding: 1px 8px; border-radius: 999px; white-space: nowrap;
}
.conv-row .cnt.new { background: color-mix(in srgb, var(--accent) 14%, transparent); color: var(--accent); }
.conv-row .cnt.todo { background: var(--bg-soft); color: var(--ink-2); }
.conv-row .cnt.unseen { background: transparent; color: var(--ink-3); padding-left: 0; padding-right: 0; font-weight: 700; }
.conv-row .cnt.none { color: var(--ink-3); font-weight: 600; padding: 0; background: none; font-size: 0.8rem; }
.chat-context {
  display: block; width: 100%; text-align: left; margin: 0 0 14px; padding: 12px 15px;
  border: 0; border-radius: 18px;
  background: color-mix(in srgb, var(--accent) 7%, var(--bg));
  cursor: pointer; box-shadow: none;
}
.chat-context .cap {
  display: flex; align-items: center; gap: 8px; font-size: 0.7rem; font-weight: 800;
  text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-3); margin-bottom: 5px;
}
.chat-context .edit-link { margin-left: auto; color: var(--accent); text-transform: none; letter-spacing: 0; font-weight: 700; }
.chat-context .md { font-size: 0.86rem; line-height: 1.5; color: var(--ink-2); }
.chat-context .md > :last-child { margin-bottom: 0; }
.chat-head { cursor: pointer; }
.chat-head-info { flex: none; color: var(--ink-3); font-size: 1.15rem; }
.kv { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; font-size: 0.88rem; }
.kv + .kv { border-top: 1px solid var(--line); }
.kv .k { color: var(--ink-3); font-weight: 600; flex: none; }
.kv .v { color: var(--ink); text-align: right; overflow: hidden; text-overflow: ellipsis; }
/* ---------- chat ----------
   The thread reads as a conversation and not as a form. What you said is a soft
   tinted bubble, the answer a raised white one, both on a radius big enough to
   look drawn rather than boxed, and nothing is drawn under a message unless
   there is something to say. The near-black bubble this started with made every
   second message a heavy block on a light page — the tint says "this one is
   mine" at a fraction of the weight. The colours live on four variables so dark
   mode is one place instead of thirty.
   The bottom padding clears the composer pinned above the tab bar, so the
   newest message is never sitting under it. */
.chat-thread { display: flex; flex-direction: column; gap: 14px; padding-bottom: 96px; }
/* One message: who said it on a line above (the agent only), the bubble, and
   the time and to-process mark folded away under it.
   The face is *in* that line, not in a column beside the bubble. A column costs
   every answer its width for the whole length of the chat — and the answers are
   the long half of this conversation — to repeat once per run something the
   line above already says. Out of the column, an agent's message runs the full
   width of the thread. Your own messages have no face: the side they sit on is
   the whole of who sent them. */
.msg { display: flex; flex-direction: column; align-items: flex-start; align-self: flex-start; max-width: 92%; }
.msg.user { align-self: flex-end; align-items: flex-end; max-width: 86%; }
/* A message with something stuck under it spans the whole thread, because that
   thing is an agent message and starts at the agent's edge. The bubble keeps
   the width it would have had on its own. */
.msg.user.resolved { align-self: stretch; max-width: 100%; }
.msg.user.resolved > .bubble { max-width: 86%; }
/* And so does a message carrying the *other* side's ghost, for the same reason.
   A row is only as wide as what stands in it, so telling the ghost to sit at the
   far end of its row only reached the far edge of the chat when the bubble under
   it happened to be that wide: a short answer took its row down with it and a
   copy of one of your messages ended up drawn on the agent's side, which is the
   one thing the ghost is there not to do. The row spans the thread so the ghost
   can stand at its own side's edge; the bubble keeps the width its side gets. */
.msg:not(.user):has(> .reply-quote.from-user),
.msg.user:has(> .reply-quote.from-agent),
.msg.user:has(> .reply-quote.from-bot) { align-self: stretch; max-width: 100%; }
.msg:not(.user):has(> .reply-quote.from-user) > .bubble { max-width: 92%; }
.msg.user:has(> .reply-quote.from-agent) > .bubble,
.msg.user:has(> .reply-quote.from-bot) > .bubble { max-width: 86%; }
.msg-head { display: flex; align-items: center; gap: 7px; margin: 0 0 6px 3px; }
.msg-head .avatar { width: 22px; height: 22px; font-size: 0.8rem; flex: none; }
/* a run of messages from the same sender closes up into one block */
.msg.same { margin-top: -8px; }
.bubble {
  position: relative; max-width: 100%; min-width: 0;
  padding: 11px 15px; border-radius: var(--bub-radius); font-size: 0.93rem; line-height: 1.55;
  background: var(--bub-them); color: var(--ink); border: 0; box-shadow: var(--bub-shadow);
  /* A long enough run of characters with no space in it — a URL, a token, a
     stack frame — has to break inside the bubble. Left to itself it pushes the
     bubble wider than the thread, the thread wider than the page, and the whole
     layout starts scrolling sideways around one message. */
  overflow-wrap: anywhere;
}
.bubble .md, .bubble .md p, .bubble .md li { min-width: 0; overflow-wrap: anywhere; }
/* Code keeps its own scroller rather than breaking mid-token, so a pasted
   snippet stays readable — it just never widens the page doing it. */
.bubble .md pre { max-width: 100%; overflow-x: auto; }
/* The one corner that isn't round: the bottom one on the side the message came
   from. It does the work a tail used to do without drawing one. */
.msg:not(.user) .bubble { border-bottom-left-radius: 8px; }
.msg.user .bubble { border-bottom-right-radius: 8px; }
/* Your own message is the accent itself, not a wash of it. A tint had to carry
   "this one is mine" against a page that is nearly the same colour; the solid
   fill says it outright, and it is still nothing like the near-black slab this
   started as. Everything inside it is then drawn for white text. */
.msg.user .bubble { background: var(--accent); color: #fff; box-shadow: none; }
.msg.user .bubble .md code { background: rgba(255, 255, 255, 0.22); color: #fff; }
.msg.user .bubble .md a { color: #fff; text-decoration: underline; text-underline-offset: 2px; }
.msg.user .bubble .md pre { background: rgba(255, 255, 255, 0.16); }
.msg.user .bubble .md blockquote { border-color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.85); }
.msg.bot .bubble { background: color-mix(in srgb, var(--good) 11%, var(--bg)); }
/* press-and-hold is the message menu, so don't let the OS hijack it with its own
   text-selection UI on a touch screen */
@media (pointer: coarse) {
  .bubble { -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; }
}

/* Long messages fold, fading out at the bottom of the bubble. "Long" used to
   mean four lines, which is shorter than most notes anyone dictates into this
   app — nearly every message in the chat wore a fold and the controls under
   them turned the thread into a column of buttons. Nine lines is the length at
   which a message actually starts pushing the rest of the chat off the screen,
   so that is where the fold starts earning its place. */
/* The fade runs out exactly where the clip does, and takes the last line to do
   it. It used to finish 2em above the cut, so the message went out at the fourth
   line and the fifth was drawn at nothing: two dozen empty pixels between the
   last word you could read and the bottom of the bubble, which is padding as far
   as anyone looking at it is concerned.
   The stops are percentages of the clipped box, not lengths. `em` inside a
   gradient is not the `em` max-height is measured in — the mask resolves it
   against the bubble's font size and the height against the text's own, which is
   how the fade came to finish a line early in the first place. A percentage is
   the same fraction of the same box either way; 100% is the cut, and the line
   before it is 21% of five lines (12% of the nine an open message folds at). */
.bubble.folded:not(.open) .md {
  max-height: 13.6em; overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 88%, transparent 100%);
}
/* a message the agent is done with folds sooner — five lines is enough to know
   which one it is, and the point of settling is to give the room back */
.msg.settled .bubble.folded:not(.open) .md {
  max-height: 7.6em;
  -webkit-mask-image: linear-gradient(to bottom, #000 79%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 79%, transparent 100%);
}
/* The way out of the fold, and how much of the message it is holding back. It
   used to be a dim grey line of text across the foot of the bubble, which read
   as part of the message that had gone wrong rather than as the control it is.
   Now it's a pill sitting on the fade — the same shape the rest of the chat
   uses for "tap this" — and it still says the size of what it's hiding, so the
   fold stays a decision instead of a surprise. */
.bubble .expand {
  display: flex; align-items: center; justify-content: center; gap: 5px;
  width: fit-content; margin: -1px auto -3px; padding: 4px 11px 4px 9px;
  border: 0; border-radius: 999px; cursor: pointer; line-height: 1;
  /* opaque, not tinted glass: it sits over the last line of the message, which
     the fade has taken most of the way out but not all of it */
  background: color-mix(in srgb, var(--accent) 11%, var(--bub-them));
  color: var(--accent);
}
.bubble .expand .ic { width: 14px; height: 14px; flex: none; transition: transform 0.18s ease; }
.bubble .expand .ex-label { font-size: 0.7rem; font-weight: 800; letter-spacing: 0.01em; }
.bubble .expand:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); }
.bubble.open .expand .ic { transform: rotate(180deg); }
/* on your own bubble the pill sits on the accent itself, so it is drawn in the
   white the text there is */
.msg.user .bubble .expand { background: rgba(255, 255, 255, 0.22); color: #fff; }
.msg.user .bubble .expand:hover { background: rgba(255, 255, 255, 0.3); }
@media (prefers-reduced-motion: reduce) { .bubble .expand .ic { transition: none; } }

/* settled = your message the agent worked through (or you took back), and any
   agent message answering an already-settled stretch. It is the same message it
   always was — same colours, same words, same width its sender gets — one size
   down, and on your own messages it wears a disc with a check over its leading
   edge.
   It has been through two worse versions of this. First a half-transparent
   two-line stub, which read as a rendering fault. Then a wash of the accent
   instead of the accent, which made the messages you had actually got somewhere
   with the palest thing in the chat — exactly backwards. The colour is not the
   state; the mark is. */
.msg.settled .bubble {
  padding: 9px 13px; border-radius: 17px; font-size: 0.87rem; line-height: 1.5;
  cursor: pointer;
}
/* The disc: a check inside the message, in the trailing bottom corner — where a
   message has carried its own delivery state since long before this app. It sat
   half on the edge for a version, which made it an object stuck to the bubble
   rather than something the bubble says about itself.
   The trailing corner and not the leading one, because the leading one is where
   every last line of text begins.
   The room it needs is taken sideways, not downwards. A band under the text
   made every processed message taller for 19px that are usually empty; letting
   the words run under a translucent disc instead cost nothing on a long message
   and covered the last two characters of a one-line one, which is most of what
   anybody writes in a chat. Trailing padding costs the same room either way and
   never lands on a word: a one-liner grows sideways by the width of its own
   mark. */
.done-mark {
  position: absolute; right: 8px; bottom: 10px;
  z-index: 1; display: flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 50%; padding: 0; cursor: pointer;
  /* solid white in both themes: it only ever sits on the accent bubble, so it
     is white against that and not against the page */
  background: #fff; color: var(--mark-ink);
  box-shadow: 0 1px 3px rgba(17, 17, 20, 0.18);
}
.done-mark .ic { width: 14px; height: 14px; stroke-width: 3; }
.done-mark:hover { box-shadow: 0 1px 5px rgba(17, 17, 20, 0.28); }
.done-mark:active { transform: scale(0.9); }
/* The room the disc stands in, taken from the end of the lines rather than from
   under them — no line can reach it, on a one-liner or a nine-liner. The disc is
   sized and seated so that the air it leaves above itself on a single-line
   bubble is the air it leaves below: a 22px disc in a 42.3px bubble wants 10.15
   either side. The 39px I worked that out from on paper was the line-height; the
   line box a paragraph actually draws is taller, and 3px of arithmetic is 3px
   you can see. Measured in the browser, not computed. */
.msg.settled.user .bubble { padding-right: 36px; }
/* a run of settled messages closes up a little — it's one block of "done".
   Same-sender runs are already tighter than this; leave those alone. */
.msg.settled + .msg.settled:not(.same) { margin-top: -5px; }
/* The only thing a settled message still says in words: that you took it back
   before the agent ever saw it. Everything else about being done with a message
   is carried by the bubble itself going quiet. */
.settled-note {
  display: inline-flex; align-items: center; gap: 3px;
  flex: none; margin-top: 4px; padding: 0 4px;
  font-size: 0.6rem; font-weight: 800; color: var(--ink-3);
}
.settled-note .ic { width: 13px; height: 13px; }
.settled-note .sn-label { text-transform: uppercase; letter-spacing: 0.04em; }
/* ---------- threads ----------
   The answers to a message hang under it instead of becoming more entries in
   the chat: a routine that works through six notes used to push six messages of
   its own between you and the next thing you said. They are joined to it now
   rather than merely indented near it — an elbow drops out of the message above
   and turns into the answering face, so a reply reads as "this answers that"
   before you have read a word of it. A single answer never folds: the pill that
   said "1 reply" asked for a tap to find out what there was already room for. */
.thread {
  position: relative; display: flex; flex-direction: column; align-items: flex-start;
  align-self: stretch; gap: 10px;
  /* Nothing is drawn around the answers. They sat in a tinted case for a while,
     which put a second frame around messages that already have a shape, a face
     and a name of their own — and it cost the agent's answers the width of its
     own padding, on the side of the conversation that needs the width. What
     says "this answers that" is that it sits directly under it. */
  margin: -4px 0 0; padding: 0;
}
.thread .msg { max-width: 100%; }
.thread .msg:not(.user) { max-width: 92%; }
/* A reply of yours has the run of the thread's width — where the row has been
   stretched so its ghost can reach the agent's edge, that width moves to the
   bubble rather than being lost with it. */
.thread .msg.user:not(.resolved):has(> .reply-quote.from-agent) > .bubble,
.thread .msg.user:not(.resolved):has(> .reply-quote.from-bot) > .bubble { max-width: 100%; }
/* a reply of your own still leans to your side, just inside the thread */
.thread .msg.user { align-self: flex-end; }
/* An answer to a message this window doesn't reach: the quote of what it
   answers stands in for the message itself, at the head of a thread of one. The
   reply is inside that thread like every other reply, rather than floating in
   the chat as a remark from nowhere. Tapping the quote fetches the earlier
   messages, and the real one takes its place. */
.thread.orphan { margin-top: 10px; }
.thread-stub {
  display: block; width: 100%; text-align: left; cursor: pointer;
  margin: 0 0 8px; padding: 5px 11px;
  border: 0; border-left: 3px solid var(--accent); border-radius: 4px 12px 12px 4px;
  background: color-mix(in srgb, var(--accent) 8%, transparent);
  font: inherit; font-size: 0.8rem; line-height: 1.35;
}
.thread-stub .rq-who { display: block; font-weight: 800; color: var(--accent); font-size: 0.7rem; }
.thread-stub .rq-text {
  display: block; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* what you're answering, above the box you answer in */
.reply-bar {
  display: flex; align-items: center; gap: 9px;
  max-width: var(--maxw); margin: 0 auto; padding: 7px 14px 0;
  font-size: 0.74rem; color: var(--ink-3);
}
.rb-ic { flex: none; display: flex; color: var(--accent); }
.rb-ic .ic { width: 15px; height: 15px; }
.rb-text { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.rb-text b { color: var(--ink-2); font-size: 0.72rem; font-weight: 800; }
.rb-quote { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rb-x {
  flex: none; display: flex; padding: 4px; cursor: pointer;
  background: none; border: 0; color: var(--ink-3); border-radius: 8px;
}
.rb-x:hover { background: var(--bg-soft); color: var(--ink-2); }
.rb-x .ic { width: 15px; height: 15px; }

/* widen the tail window — the thread only loads its newest messages */
.load-earlier {
  align-self: center; margin-bottom: 4px; padding: 6px 15px; cursor: pointer;
  font-size: 0.72rem; font-weight: 800; color: var(--ink-3);
  background: var(--bg); border: 0; border-radius: 999px; box-shadow: var(--bub-shadow);
}
.load-earlier:hover { color: var(--ink-2); }
.load-earlier:disabled { opacity: 0.6; cursor: default; }

/* Who said it, on the line with their face. It used to be an uppercase
   letter-spaced strip, which shouted the same four letters down the length of
   the chat; beside a face it only has to be a name. */
.msg-who { font-size: 0.76rem; font-weight: 700; letter-spacing: 0; color: var(--ink-2); }

/* neither the time nor the to-process mark sits on the thread: a stamp and a
   ring under every bubble are noise nobody asked for. Tap a message and both
   slide out from under it, tap again and they fold back away. */
.msg-time {
  display: flex; align-items: center; gap: 8px; overflow: hidden; padding: 0 4px;
  max-height: 0; opacity: 0; transform: translateY(-6px);
  transition: max-height 0.18s ease, opacity 0.16s ease, transform 0.18s ease, margin-top 0.18s ease;
}
.msg.user .msg-time { justify-content: flex-end; }
.msg-time time { font-size: 0.68rem; color: var(--ink-3); white-space: nowrap; }
.msg.settled .msg-time time { font-size: 0.64rem; }
.msg.time-open .msg-time { max-height: 22px; opacity: 1; transform: none; margin-top: 4px; }
@media (prefers-reduced-motion: reduce) { .msg-time { transition: none; } }
.chat-input {
  position: fixed; bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom)); left: 0; right: 0; z-index: 25;
  background: var(--glass); backdrop-filter: blur(12px); border-top: 1px solid var(--line);
}
/* The composer, built the way a chat app builds one: a pill that is exactly one
   line tall until what you're writing needs more, an attach button on one side
   and a round send button on the other. It used to inherit the app's form-field
   padding and a two-line-looking box that only had room for one, which is why
   the placeholder sat clipped between two half-lines. Everything here is stated
   rather than inherited: line-height, padding and min-height together make one
   clean row, and the same numbers are what the auto-grow measures against. The
   box has no border of its own — a filled pill is one shape where an outlined
   one is two, and the ring only shows up while you're actually in it. */
.chat-input .inner {
  max-width: var(--maxw); margin: 0 auto; padding: 9px 12px;
  display: flex; gap: 6px; align-items: flex-end;
}
.chat-input textarea {
  flex: 1; min-width: 0; resize: none; overflow-y: auto;
  max-height: 132px; min-height: 42px; height: 42px;
  background: var(--bg-soft); border: 0; border-radius: 21px;
  padding: 11px 16px; font-size: 0.95rem; line-height: 1.35;
}
.chat-input textarea:focus { border: 0; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 50%, transparent); }
.chat-input textarea::placeholder { color: var(--ink-3); }
/* Send is an icon, not a word: the label was the widest thing in the row and
   said nothing the arrow doesn't. It wears the accent rather than the near-
   black the rest of the chat just gave up. Dimmed until there is something to
   send. */
.send-btn {
  flex: none; width: 42px; height: 42px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--accent); color: #fff; cursor: pointer;
  transition: opacity 0.12s ease-out;
}
/* solid, not outlined — at 20px the plane's notch reads as a chevron when it's
   only drawn in stroke */
.send-btn .ic { width: 20px; height: 20px; fill: currentColor; stroke: none; }
.send-btn:disabled { opacity: 0.3; cursor: default; }
.send-btn:not(:disabled):active { transform: scale(0.94); }
.attach-btn { flex: none; height: 40px; width: 40px; cursor: pointer; }
.attach-btn .ic { width: 20px; height: 20px; }
/* pictures picked but not sent yet — they sit above the box, each one droppable */
.attach-tray {
  max-width: var(--maxw); margin: 0 auto; padding: 10px 16px 0;
  display: flex; gap: 8px; flex-wrap: wrap;
}
.attach-chip { position: relative; display: block; width: 60px; height: 60px; }
.attach-chip img {
  width: 100%; height: 100%; object-fit: cover;
  border-radius: 12px; border: 1px solid var(--line);
}
.attach-x {
  position: absolute; top: -6px; right: -6px; width: 22px; height: 22px; border-radius: 50%;
  border: 0; background: var(--ink); color: var(--ink-inv); font-size: 0.7rem; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
}
/* a picture inside a chat bubble — as wide as the bubble lets it be */
.msg .md img { max-width: 100%; border-radius: 12px; display: block; margin: 4px 0; }
/* …except in one of your processed messages. That bubble holds 36px open on the
   right for the tick, which is invisible on ragged text and glaring on a
   picture: 13px of air down one side and 36 down the other reads as broken.
   A bubble with a picture in it doesn't reserve the gutter at all — the tick
   lands on the picture's corner, which is where a delivery mark has sat on a
   photo since forever. (Pulling the picture back into the gutter with a
   negative margin was the first try. It doesn't work: the bubble is a
   shrink-to-fit box, so the picture's percentage max-width and the bubble's
   width each wait for the other and the browser keeps its first answer.) */
.msg.settled.user .bubble:has(.md img) { padding-right: 13px; }
/* while the on-screen keyboard is up (visualViewport-driven): pin the composer
   just above it and tuck the tab bar out of the way — like a native chat app */
body.kb-open #tabbar { transform: translateY(100%); }
body.kb-open .chat-input { transition: bottom 0.12s ease-out; }

/* What a reply is answering: a ghost of that message, above the answer and
   tucked behind it. The answer's bubble sits on top of its lower edge, so the
   two read as one object — the shape every phone chat uses for a reply, and the
   reason it works is that the overlap says "these belong together" without a
   line, a rail or a box drawn around anything.
   It used to be a panel *inside* the bubble, which made an answer look like a
   message with a form field stuck to its top. */
.reply-quote {
  display: flex; align-items: center; gap: 8px; max-width: 84%;
  /* Even air all round, and the overlap taken out of it rather than added to it.
     The ghost used to carry half a line of extra padding underneath for the
     bubble to cover — which only worked on the part of it the bubble actually
     covers. The two are rarely the same width and never on the same side, so
     wherever the ghost stood out past its answer — every ghost of yours over an
     agent's answer, every ghost of the agent's over a short reply of yours — the
     corner that showed wore 18px of air under one line of text and 8px over it,
     and read as a box with something missing out of it. Now the box is even and
     the bubble takes 6 of its 8px back (below), so the covered edge and the
     bare one are 2px apart instead of 10. */
  padding: 8px 15px; cursor: pointer;
  border: 0; border-radius: var(--bub-radius);
  font-size: 0.85rem; line-height: 1.45; text-align: left;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* It is a ghost of a message, not the message: it is drawn in that message's
     own colours, so what is left to say "this one is a copy" is that you can
     see the page through it. */
  opacity: 0.72;
}
.reply-quote .rq-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* a picture in the answered message, at the size of the line it stands on */
.rq-thumb { flex: none; width: 26px; height: 26px; object-fit: cover; border-radius: 7px; display: block; }
.reply-quote.has-thumb { padding-left: 9px; }
.reply-quote.has-thumb .rq-text:empty { display: none; }
.reply-quote.has-thumb .rq-text:empty + * { margin: 0; }
/* The ghost stands on the side its original stood on, not on the side of
   whoever is answering: it is a copy of that message, and a copy of one of your
   messages belongs where your messages are. It used to lean against the
   replier's edge, which put your own words on the agent's side of the chat. */
.reply-quote.from-user { align-self: flex-end; margin: 0 14px -6px 0; }
.reply-quote.from-agent, .reply-quote.from-bot { align-self: flex-start; margin: 0 0 -6px 14px; }
/* It is a smaller, half-hidden copy of the message it points at, in that
   message's own colours — the grey label with a "You" caption on it was a
   citation *about* a message, which is not the same thing and read as one. */
.reply-quote.from-user { background: var(--accent); color: #fff; }
.reply-quote.from-agent { background: var(--bub-them); color: var(--ink); box-shadow: var(--bub-shadow); }
.reply-quote.from-bot { background: color-mix(in srgb, var(--good) 11%, var(--bg)); color: var(--ink); }
/* An answer is always the thing in front. It rides over the ghost of what it
   answers, and — where the answer is a note finishing one of your messages — it
   rides over that message too. Whichever way round the two are drawn, the one
   that came second is on top: that is what makes the overlap read as "this
   answers that" instead of "this got covered by that". */
.msg .bubble { z-index: 1; }

/* What became of a message: the agent's answer, stuck to the underside of the
   note it finishes instead of standing further down the chat as a message of
   its own quoting that note back. The two share a right edge and the seam
   between them is 4px of overlap, so they read as one object — a thing you
   asked for and what came of it. No ghost on it: it is already touching the
   message it would be quoting. */
.msg-note {
  position: relative; z-index: 2; align-self: flex-start; max-width: 92%;
  /* It tucks under the message it finishes by exactly that message's bottom
     padding — 9px — so the overlap eats the air under the last line and never
     the line. */
  margin-top: -9px; padding: 12px 15px 11px;
  border-radius: var(--bub-radius); border-bottom-left-radius: 8px;
  background: var(--bub-them); color: var(--ink);
  box-shadow: var(--bub-shadow);
  font-size: 0.9rem; line-height: 1.55; overflow-wrap: anywhere;
}
.msg-note .md > :last-child { margin-bottom: 0; }
.msg-note .md img { max-width: 100%; border-radius: 12px; display: block; margin: 4px 0; }
/* With something stuck underneath, the message's own bottom edge is no longer an
   edge — it is a seam. Everything that lived on it moves to the top of the pair,
   which is an edge: the tick to its corner, a reaction to the other one. */
.msg.resolved .done-mark { top: 8px; bottom: auto; }
.msg.resolved .reactions { top: -11px; bottom: auto; left: auto; right: 46px; }
.msg.resolved.has-react { margin-bottom: 0; margin-top: 9px; }


/* Emoji reactions (yours + the agent's) — one chip hanging off the bottom edge
   of the message they belong to. On a line of their own under the bubble they
   read as a loose object drifting between two messages; sitting on the edge
   there is no question what they are attached to. The ring is what keeps the
   chip visible while it is half on the bubble and half on the page — those are
   two different colours, in both themes. An agent reaction only ever appears on
   a message it marked processed. */
.reactions {
  position: absolute; bottom: -11px; right: 12px; z-index: 1;
  display: flex; align-items: center; gap: 3px;
  padding: 2px 6px; border-radius: 999px; background: var(--bg);
  box-shadow: 0 0 0 2px var(--bg-soft), 0 2px 6px rgba(17, 17, 20, 0.12);
}
.msg.user .reactions { right: auto; left: 12px; }
/* room under the message for the half of the chip that hangs past it */
.msg.has-react { margin-bottom: 9px; }
.reaction {
  font-size: 0.82rem; line-height: 1.25; padding: 0 1px;
  background: none; border: 0; cursor: default;
}
.reaction.mine { cursor: pointer; }
/* a tick drawn by the app rather than pasted from the emoji font: the same mark,
   in the chat's own ink, at the weight the fact deserves */
.reaction.tick { display: inline-flex; align-items: center; color: var(--ink-2); }
.reaction.tick .ic { width: 13px; height: 13px; }
.reaction.mine:active { transform: scale(0.9); }

/* has the agent dealt with this message? Spelled out on every message you sent:
   ○ to process → the agent hasn't touched it, ✓ processed → it worked it through
   (and only then is it allowed to react). */
.mstat {
  display: inline-flex; align-items: center; justify-content: center; padding: 0; cursor: pointer;
  background: none; border: 0; line-height: 0;
}
.mstat .ic { width: 14px; height: 14px; }
.mstat.todo { color: var(--ink-3); opacity: 0.65; }        /* hollow ring — untouched */
.mstat.done { color: var(--ok); }                          /* check — the agent worked it through */
.mstat.unsent { color: var(--ink-3); opacity: 0.5; cursor: default; }
.mstat.todo:hover, .mstat.done:hover { opacity: 1; }
/* cancelled sends stay visible to you only — greyed out and dashed */
.msg.retracted .bubble {
  background: var(--bg-soft); color: var(--ink-3); border: 1px dashed var(--line);
  box-shadow: none; opacity: 0.75;
}
.msg.retracted .bubble .md a, .msg.retracted .bubble .md code { color: var(--ink-3); background: none; }
/* the quoted message at the top of the per-message action sheet */
.sheet-quote {
  font-size: 0.8rem; color: var(--ink-3); background: var(--bg-soft); border-radius: var(--radius-s);
  padding: 8px 12px; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
}
/* emoji picker sheet */
.emoji-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 4px 4px; justify-content: center; }
.emoji-opt {
  font-size: 1.5rem; line-height: 1; padding: 8px; border-radius: 14px;
  background: var(--bg-soft); border: 1px solid transparent; cursor: pointer;
}
.emoji-opt.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 15%, transparent); }
.emoji-opt:active { transform: scale(0.92); }
/* reactions already on the message — tap one to take it off, the agent's included */
.emoji-opt.placed { display: flex; flex-direction: column; align-items: center; gap: 2px; font-size: 1.25rem; padding: 6px 10px; }
.emoji-opt.placed .by { font-size: 0.6rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; color: var(--ink-3); }
.sheet-cap { font-size: 0.7rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.06em; color: var(--ink-3); padding: 6px 4px 0; }

/* how many of my messages the agent hasn't seen yet */
.pending-chip {
  display: inline-block; margin-top: 5px; font-size: 0.72rem; font-weight: 700;
  color: var(--ink-3); background: var(--bg-soft); border: 1px solid var(--line);
  border-radius: 999px; padding: 2px 9px; white-space: nowrap;
}
/* the chat's processed ledger: how many of your messages the agent took, and how
   many are still sitting there */
.pending-strip {
  display: flex; justify-content: center; align-items: center; gap: 12px; flex-wrap: wrap;
  width: fit-content; max-width: 100%; margin: 0 auto 14px;
  font-size: 0.72rem; font-weight: 800; color: var(--ink-3);
  background: var(--bg); border: 0; border-radius: 999px;
  padding: 7px 15px; box-shadow: var(--bub-shadow);
}
.pending-strip .ps-todo, .pending-strip .ps-done { display: inline-flex; align-items: center; gap: 5px; }
.pending-strip .ps-todo { color: var(--ink-3); }
.pending-strip .ps-done { color: var(--ok); }
.pending-strip .ic { width: 13px; height: 13px; }
/* link-only chat notice + lock indicators */
.private-strip {
  display: flex; align-items: center; gap: 7px; font-size: 0.76rem; font-weight: 600; color: var(--ink-3);
  background: var(--bg-soft); border: 0; border-radius: 16px;
  padding: 9px 14px; margin-bottom: 12px;
}
.private-strip svg { width: 15px; height: 15px; flex: none; }
/* the thumbtack next to a pinned chat's name */
.pin-glyph { display: inline-flex; vertical-align: -2px; margin-left: 5px; color: var(--accent); }
.pin-glyph svg { width: 13px; height: 13px; }
/* the padlock after the name of a channel or chat the agent can't find on its
   own — trailing, like the thumbtack, so the name still starts the line. There
   is no counterpart for the discoverable ones: that's every other row. */
.lock-glyph { display: inline-flex; vertical-align: -2px; margin-left: 5px; color: var(--ink-3); }
.lock-glyph svg { width: 13px; height: 13px; }
/* stacked label + subtext inside a bottom-sheet action */
.sheet-label { display: flex; flex-direction: column; min-width: 0; }
.sheet-sub { display: block; font-size: 0.74rem; font-weight: 500; color: var(--ink-3); margin-top: 2px; }
@keyframes bubbleFlash { 0% { box-shadow: 0 0 0 3px var(--accent); } 100% { box-shadow: 0 0 0 3px transparent; } }
.msg.flash .bubble { animation: bubbleFlash 1.1s ease-out; }

/* ---------- sheet / modal / toast ---------- */
#sheet-backdrop, #modal-backdrop {
  position: fixed; inset: 0; background: rgba(17,17,20,0.4); z-index: 40;
}
#sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 41;
  background: var(--bg); border-radius: 24px 24px 0 0; padding: 8px 16px calc(20px + env(safe-area-inset-bottom));
  max-width: var(--maxw); margin: 0 auto;
  animation: slideup 0.18s ease;
  /* A sheet can hold more rows than a phone screen (every channel, say): the
     head stays put and the body scrolls inside the sheet, keeping that scroll
     to itself so the feed underneath stays where it was. */
  max-height: 82vh; max-height: 82dvh;
  display: flex; flex-direction: column; overflow: hidden;
}
.sheet-body {
  overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
  margin: 0 -16px; padding: 0 16px;
}
/* Title (and search box) for a long sheet — pinned above the scrolling rows. */
.sheet-head {
  flex: none; margin: -6px -16px 6px; padding: 2px 16px 10px; border-bottom: 1px solid var(--line);
}
.sheet-head h4 { font-size: 0.78rem; font-weight: 800; color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.05em; }
.sheet-search {
  display: flex; align-items: center; gap: 8px; margin-top: 8px;
  background: var(--bg-soft); border-radius: var(--radius-s); padding: 0 12px; color: var(--ink-3);
}
.sheet-search input {
  flex: 1; border: 0; background: none; padding: 11px 0; font-size: 0.95rem; font-weight: 600;
}
.sheet-search input:focus { outline: none; }
.sheet-none { padding: 22px 8px; text-align: center; color: var(--ink-3); font-weight: 700; font-size: 0.88rem; }
/* The filter picker is the same .chatlist rows as the Channels tab and the
   chats list — only the trimmings differ: a count and a tick instead of a
   chevron, and the row you're filtered to filled in rather than merely ticked
   at the far edge. */
.chan-row .chan-n { font-size: 0.75rem; font-weight: 800; color: var(--ink-3); }
.chan-row.active { background: color-mix(in srgb, var(--accent) 10%, transparent); color: var(--accent); }
.chan-row.active .last { color: var(--accent); opacity: 0.75; }
.chan-row .chan-check { color: var(--accent); flex: none; display: flex; }
.chan-row .chan-check svg { width: 22px; height: 22px; display: block; }
.chan-row .conv-meta { flex-direction: row; align-items: center; gap: 8px; }
@keyframes slideup { from { transform: translateY(40px); opacity: 0; } to { transform: none; opacity: 1; } }
.sheet-handle { width: 40px; height: 4px; border-radius: 2px; background: var(--line); margin: 6px auto 14px; }
.sheet-item {
  display: flex; align-items: center; gap: 14px; width: 100%; padding: 14px 8px;
  font-weight: 800; font-size: 1rem; border-radius: var(--radius-s); text-align: left;
}
.sheet-item:active { background: var(--bg-soft); }
.sheet-item .ico { width: 40px; height: 40px; border-radius: 14px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; font-size: 1.2rem; }
#modal {
  position: fixed; inset: 0; z-index: 41; display: flex; align-items: center; justify-content: center; padding: 20px;
  pointer-events: none;
}
/* a charter read in the modal: it can be long, so it gets the room and scrolls
   with the box rather than stretching it */
.charter-body { margin-top: 10px; font-size: 0.92rem; }
.modal-card {
  background: var(--bg); border-radius: var(--radius); padding: 20px;
  width: 100%; max-width: 480px; max-height: 86vh; overflow-y: auto;
  pointer-events: auto; animation: slideup 0.18s ease;
}
.modal-card h3 { font-weight: 900; font-size: 1.2rem; margin-bottom: 4px; letter-spacing: -0.01em; }
/* Modal buttons wrap instead of running off a phone screen, and share the width
   evenly once they do. */
.modal-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; justify-content: flex-end; }
.modal-actions .btn { flex: 1 1 auto; min-width: 120px; justify-content: center; }

/* ---------- AI media badge & info ---------- */
/* One mark for everything a model made — a picture, a narration — always in the
   top-right corner of the thing it describes. A plain round "AI": at this size
   the two letters say it better than any icon next to them would, and it's a
   big enough target to hit with a thumb. */
.ai-badge {
  position: absolute; top: 10px; right: 10px; z-index: 4;
  display: inline-flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; padding: 0; border-radius: 50%; cursor: pointer;
  font-size: 0.72rem; font-weight: 900; letter-spacing: 0.04em; line-height: 1;
  color: #fff; background: rgba(23, 23, 28, 0.68); border: 1px solid rgba(255, 255, 255, 0.25);
  backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
}
.ai-badge:hover { background: rgba(23, 23, 28, 0.88); }
.ai-badge:active { transform: scale(0.95); }
/* the same mark, sitting still in the panel's heading */
.ai-badge.static {
  position: static; width: 28px; height: 28px; font-size: 0.62rem;
  vertical-align: middle; margin-right: 4px; cursor: default;
}
/* an audio track is type on the page, not a photo — the mark keeps its shape but
   drops the glass and sits at the end of the track's title line, where a link
   card keeps its go button */
.media-card .ai-badge, .audio-block .ai-badge {
  position: static; flex: none; width: 32px; height: 32px; font-size: 0.66rem;
  color: var(--ink-inv); background: var(--ink); border-color: transparent;
}
/* on a track it sits at the end of the title line, so it answers to that line's
   height rather than setting it */
.audio-block .ai-badge { width: 24px; height: 24px; font-size: 0.58rem; }
/* the same mark on media that hasn't landed yet — it breathes at the pace of
   the surface underneath it, so it reads as part of something being made rather
   than as a claim that the picture is already there. Under the pointer it
   settles: you're about to read the prompt, not watch it. */
.ai-badge.pending { animation: mediaBreathe 2.6s ease-in-out infinite; }
.ai-badge.pending:hover { animation: none; }
.audio-block.pending .ai-badge { background: var(--ink-3); }
/* wrapper that lets the badge sit over an inline/album image — it carries the
   picture's rounding so the corners the badge sits in are the picture's own */
.md-img { position: relative; display: inline-block; border-radius: var(--radius-s); overflow: hidden; }
.md-img > img { display: block; width: 100%; }
.md .md-album .md-img { display: block; }
.md .md-album:not(.n1) .md-img { width: 100%; height: 100%; aspect-ratio: 1; }
.md .md-album.n3 > .md-img:first-child { aspect-ratio: 2 / 1; grid-column: 1 / -1; }
.md .md-album .md-img > img { object-fit: cover; cursor: zoom-in; }
.md .md-album:not(.n1) .md-img > img { height: 100%; }
/* a picture being re-rendered (or a cover still being painted): what you have
   stays on screen under a light veil, with the same band of light drifting
   across it and one line saying what's happening */
.img-working {
  /* purely a veil: it must never swallow the tap meant for the image, its link
     or the AI badge above it */
  pointer-events: none;
  position: absolute; inset: 0; z-index: 2; overflow: hidden;
  display: flex; align-items: flex-end; justify-content: center; padding: 12px;
  background: linear-gradient(to top, rgba(17, 17, 20, 0.5), rgba(17, 17, 20, 0.1) 55%, rgba(17, 17, 20, 0.26));
  backdrop-filter: blur(1.5px); -webkit-backdrop-filter: blur(1.5px);
}
.img-working::before {
  content: ""; position: absolute; inset: -50% -70%;
  background: linear-gradient(100deg,
    transparent 36%, rgba(255, 255, 255, 0.28) 48%, rgba(255, 255, 255, 0.1) 56%, transparent 66%);
  animation: mediaSweep 2.2s ease-in-out infinite alternate;
}
/* the label rides on a pill so it stays readable over any picture */
.img-working .txt {
  position: relative; display: inline-flex; align-items: center; gap: 7px;
  max-width: 100%; padding: 6px 12px; border-radius: 999px;
  background: rgba(17, 17, 20, 0.6); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  color: #fff; font-size: 0.72rem; font-weight: 800; letter-spacing: 0.02em; text-align: left;
}
.img-working .txt::before {
  content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: #fff;
  animation: mediaPulse 1.5s ease-in-out infinite;
}
/* nothing here carries information the motion doesn't repeat, so it can all stop */
@media (prefers-reduced-motion: reduce) {
  .img-pending::before, .img-pending .ip-label,
  .img-working::before, .img-working .txt::before,
  .ai-badge.pending { animation: none; }
}
.ai-prompt {
  background: var(--bg-soft); border-radius: var(--radius-s); padding: 10px 12px;
  font-size: 0.86rem; line-height: 1.5; color: var(--ink); white-space: pre-wrap;
}
.ai-meta { margin-top: 12px; display: flex; flex-direction: column; gap: 2px; }
.ai-meta > div { display: flex; justify-content: space-between; gap: 12px; padding: 6px 0; border-top: 1px solid var(--line); }
.ai-meta dt { font-size: 0.8rem; font-weight: 700; color: var(--ink-2); }
.ai-meta dd { font-size: 0.86rem; font-weight: 700; text-align: right; word-break: break-word; }
/* the request exactly as it went out, folded away for whoever wants it */
#toast {
  position: fixed; bottom: calc(var(--tabbar-h) + 20px + env(safe-area-inset-bottom));
  left: 50%; transform: translateX(-50%); z-index: 60;
  background: var(--ink); color: var(--ink-inv); font-weight: 700; font-size: 0.85rem;
  padding: 10px 18px; border-radius: 999px; box-shadow: 0 8px 24px rgba(0,0,0,0.18);
  display: flex; align-items: center; gap: 16px; max-width: calc(100vw - 32px);
}
.toast-undo {
  color: #b6adfa; font-weight: 900; font-size: 0.85rem; flex: none;
  padding: 2px 2px; margin: -4px 0;
}
.toast-undo:active { opacity: 0.7; }

/* ---------- connect page ---------- */
.code-box {
  background: #16161c; color: #e8e8f0; border-radius: var(--radius-s);
  padding: 14px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.78rem; overflow-x: auto; white-space: pre; position: relative; line-height: 1.5;
}
.copy-row { display: flex; gap: 8px; align-items: center; margin: 8px 0 16px; }
.copy-row .value {
  flex: 1; background: var(--bg-soft); border-radius: var(--radius-s); padding: 10px 12px;
  font-family: ui-monospace, Menlo, monospace; font-size: 0.78rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.token-row { display: flex; align-items: center; gap: 10px; padding: 12px 16px; }
.token-row .info { flex: 1; min-width: 0; }
.token-row .info b { display: block; font-size: 0.9rem; font-weight: 800; }
.token-row .info span { font-size: 0.72rem; color: var(--ink-3); font-family: ui-monospace, Menlo, monospace; }

/* ---------- note button & taste profile ---------- */
/* segmented picker (note target) */
.seg { display: flex; gap: 8px; margin: 6px 0 12px; }
.seg-btn {
  flex: 1; text-align: left; background: var(--bg); color: var(--ink-2);
  border: 2px solid var(--line); border-radius: var(--radius-s); padding: 10px 12px;
}
.seg-btn b { display: block; font-size: 0.9rem; font-weight: 800; color: var(--ink); margin-bottom: 1px; }
.seg-btn span { font-size: 0.72rem; font-weight: 700; color: var(--ink-3); }
.seg-btn.active { border-color: var(--accent); background: #f0edff; }
.seg-btn:active { transform: scale(0.98); }

/* feed order toggle in the top bar */
.sort-toggle { gap: 6px; }
.sort-toggle svg { width: 15px; height: 15px; }
.new-dot {
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--accent); vertical-align: middle; margin-left: 2px;
}
.hidden-tag { color: var(--warn); font-weight: 800; }

/* per-post ⋯ overflow menu (read/unread · not interested) */
.post-menu {
  flex: none; width: 34px; height: 34px; margin: -4px -6px -4px 0;
  border-radius: 50%; color: var(--ink-3);
  display: flex; align-items: center; justify-content: center;
}
.post-menu svg { width: 20px; height: 20px; display: block; }
.post-menu:active { background: var(--bg-soft); transform: scale(0.94); }
.sheet-item .ico svg { width: 22px; height: 22px; display: block; }
.sheet-item .ico.danger { color: #c0392b; }
/* read cards read a touch quieter than unread ones */
.post.compact.seen .post-title { color: var(--ink-2); }
/* icon + text + trailing control, one line per row (taste-profile history) */
.list-row { display: flex; gap: 12px; padding: 14px 16px; align-items: flex-start; }
.list-row .ico {
  width: 34px; height: 34px; border-radius: 12px; flex: none; background: var(--bg-soft);
  display: flex; align-items: center; justify-content: center; font-size: 1rem;
}
.list-row .info { flex: 1; min-width: 0; }
.list-row .info b { display: block; font-weight: 800; font-size: 0.9rem; line-height: 1.35; }

/* ---------- auth screen ---------- */
body.auth-screen #tabbar, body.auth-screen #topbar { display: none; }
body.auth-screen .view { padding-top: 8vh; }
.auth-wrap { max-width: 400px; margin: 0 auto; }
.auth-hero { text-align: center; margin-bottom: 24px; }
.auth-hero h1 { font-size: 2.2rem; font-weight: 900; letter-spacing: -0.03em; margin-top: 10px; }
.auth-hero p { color: var(--ink-2); margin-top: 6px; line-height: 1.5; font-weight: 600; }
.auth-error {
  margin-top: 12px; background: #fdecec; color: #c0392b;
  border-radius: var(--radius-s); padding: 10px 12px; font-size: 0.85rem; font-weight: 700;
}
.auth-switch { display: block; width: 100%; text-align: center; margin-top: 14px; font-weight: 800; font-size: 0.85rem; color: var(--accent); }

@media (min-width: 720px) {
  :root { --maxw: 640px; }
  .view { padding-top: 24px; }
}

/* ---------- dark mode — components with hardcoded pastel colors ---------- */
@media (prefers-color-scheme: dark) {
  /* the drifting light needs brighter hues to read against a dark panel */
  .img-pending::before {
    background: linear-gradient(100deg,
      transparent 36%, rgba(139, 124, 255, 0.22) 47%, rgba(0, 214, 173, 0.16) 55%, transparent 66%);
  }
  .pill.active { background: #0e3b30; color: #5ee6c4; }
  .pill.idea { background: #272047; color: #a99df5; }
  .pill.paused { background: #3d2a10; color: #f0b46a; }
  .pill.shipped { background: #10304a; color: #7cc3f2; }
  .pill.archived { background: #26262e; color: var(--ink-2); }
  .course-banner.on_track { background: #0e3b30; color: #5ee6c4; }
  .course-banner.drifting { background: #42210f; color: #f09a76; }
  .post.course.on_track .info b { color: #5ee6c4; }
  .post.course.drifting .info b { color: #f09a76; }
  .btn.danger { background: #42191a; color: #f19999; }
  .sheet-item .ico.danger { color: #f19999; }
  .seg-btn.active { background: #272047; }
  .auth-error { background: #42191a; color: #f19999; }
  .direction-card { background: linear-gradient(135deg, #211d33, #16222e); border-color: #2e2a4a; }
  .direction-card h4 { color: #a99df5; }
  .md pre { background: #0c0c10; }
}

/* ---------- channels & feed states ---------- */
/* Channel picker is an icon button in the top bar; it opens a bottom sheet.
   Highlighted when a channel filter is active. */
.icon-btn.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }

/* ---------- active feed filter ---------- */
/* Top-bar chip: an on filter says its own name, right next to the sort button,
   and stays on screen (the bar is sticky) however far you scroll. */
/* The one action allowed to give up width on a narrow phone — its name
   ellipsises instead of shoving the sort button off the bar. */
#topbar-actions > .filter-chip { flex: 0 1 auto; min-width: 72px; }
.filter-chip {
  display: inline-flex; align-items: center; gap: 6px; max-width: 46vw;
  height: 38px; padding: 0 10px 0 8px; border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  color: var(--accent); font-weight: 800; font-size: 0.85rem;
}
.filter-chip:active { transform: scale(0.97); }
.filter-chip .fc-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.filter-chip .ic { width: 15px; height: 15px; opacity: 0.75; flex: none; }
/* Strip above the first card: the full channel name and the way out. */
.filter-strip {
  display: flex; align-items: center; gap: 10px; margin-bottom: 14px;
  padding: 10px 12px; border-radius: var(--radius-s);
  background: color-mix(in srgb, var(--accent) 9%, var(--bg));
  border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
}
/* The strip is where the channel's name is spelled out in full — it wraps
   rather than truncating, since the top-bar chip is the short version. */
.filter-strip .fs-text {
  flex: 1; min-width: 0; font-size: 0.85rem; font-weight: 600; color: var(--ink-2);
  line-height: 1.35; overflow-wrap: anywhere;
}
.filter-strip .fs-text b { font-weight: 800; color: var(--ink); }
.filter-strip .fs-clear {
  display: inline-flex; align-items: center; gap: 4px; flex: none;
  padding: 6px 10px; border-radius: 999px; background: var(--bg);
  font-size: 0.78rem; font-weight: 800; color: var(--accent);
}
.filter-strip .fs-clear .ic { width: 13px; height: 13px; }
.filter-strip .fs-clear:active { transform: scale(0.97); }

/* Header badges for engagement state. */
.saved-tag { color: var(--good); font-weight: 800; }
.sched-tag { color: var(--ink-2); font-weight: 800; }

/* Soft "that's today's feed" divider — a marker, not a wall. */
.feed-divider {
  display: flex; align-items: center; text-align: center;
  gap: 12px; margin: 26px 4px 20px; color: var(--ink-3);
}
.feed-divider::before, .feed-divider::after {
  content: ""; flex: 1; height: 2px; border-radius: 2px;
  background: linear-gradient(to var(--dir, right), var(--line), transparent);
}
.feed-divider::before { --dir: left; }
.feed-divider span {
  font-size: 0.74rem; font-weight: 800; letter-spacing: 0.02em;
  text-transform: uppercase; white-space: nowrap;
}

/* The counts on the AI panel's "run it again" row. A single button that added
   one iteration made you press it four times to see a difference; these say how
   much more work to do and ask for exactly that. */
.step-pick { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.step-pick .btn { min-width: 52px; font-variant-numeric: tabular-nums; }
.step-pick .sp-label { color: var(--ink-3); }

/* What is left at the end of an article. The three full-width buttons that used
   to close every page are gone: saving and writing a note are rare, they live in
   the ⋯ menu, and stacking them here made the loudest thing on the page the part
   with the least to say. The shelf stays for one thing — a serial's "next
   episode" button, which belongs in the page rather than in a floating bar. */
.article-footer { margin: 8px 0 40px; }
.article-footer .af-group { display: flex; flex-direction: column; gap: 10px; }
.article-footer .af-group:empty { display: none; }
/* The mark an observer watches for: when this is on screen, the article's end
   is on screen, and the read bar comes up. */
.read-end { display: block; height: 1px; }

/* Marking it read: the one control an article carries, above the tab bar where
   a thumb already is. It stays away until you reach the end of the piece, so it
   never covers the paragraph you're reading — and on an article short enough to
   fit the screen it is there at once, which is honest. */
/* What the page keeps free for the bar. The bar is fixed, so it covers the end
   of the article instead of following it; this is that height given back. */
.read-bar-space { height: calc(var(--readbar-h) + 8px); }
.read-bar {
  position: fixed; bottom: calc(var(--tabbar-h) + env(safe-area-inset-bottom)); left: 0; right: 0; z-index: 24;
  min-height: var(--readbar-h);
  background: var(--glass); backdrop-filter: blur(12px); border-top: 1px solid var(--line);
  animation: read-bar-in 0.18s ease;
}
.read-bar[hidden] { display: none; }
.read-bar .rb-inner {
  max-width: var(--maxw); margin: 0 auto; padding: 10px 16px calc(10px + env(safe-area-inset-bottom) * 0);
  display: flex; align-items: center; gap: 12px;
}
.read-bar .read-btn { flex: 1; }
.read-bar .read-btn.done { background: var(--good); color: #fff; }
@keyframes read-bar-in { from { transform: translateY(100%); } to { transform: none; } }
@media (prefers-reduced-motion: reduce) { .read-bar { animation: none; } }

/* …and once it's pressed, the article closes as it always did — and the only
   question worth asking arrives after it, over the feed: a small card in the
   middle of the screen, five seconds, then gone on its own. Nothing waits for an
   answer; the stars are a filing device, so that "the ones that were worth it"
   is a place in the feed and not a memory. The wrapper takes no pointer events,
   so the feed underneath keeps scrolling while it's up. */
#rate-pop {
  position: fixed; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
#rate-pop[hidden] { display: none; }
.rp-card {
  pointer-events: auto;
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  max-width: 86vw; padding: 18px 22px 14px;
  border-radius: 22px; background: var(--bg); border: 1px solid var(--line);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.22);
  animation: rate-in 0.2s cubic-bezier(0.2, 0.9, 0.3, 1.4);
}
#rate-pop.going .rp-card { animation: rate-out 0.2s ease forwards; }
.rp-title {
  max-width: 240px; font-size: 0.72rem; font-weight: 700; color: var(--ink-3);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.rp-q { font-size: 0.95rem; font-weight: 800; color: var(--ink); }
.rp-stars { display: inline-flex; gap: 2px; margin-top: 2px; }
.rp-stars .star {
  background: none; border: 0; cursor: pointer; padding: 4px 5px;
  font-size: 1.85rem; line-height: 1; color: var(--line);
  transition: transform 0.1s ease, color 0.1s ease;
}
.rp-stars .star.on { color: #f5a623; }
.rp-stars .star:active { transform: scale(1.25); }
/* The way out of the stars, for an article that left you with more than a
   number: the same note the ⋯ menu opens, offered at the one moment you have
   just finished reading. Wider than the row of stars above it — five small
   targets and one big one read as a scale and an alternative to it, rather
   than six things to choose between. */
.rp-note { align-self: stretch; margin-top: 12px; padding: 11px 18px; font-size: 0.88rem; }
@keyframes rate-in { from { opacity: 0; transform: translateY(10px) scale(0.94); } to { opacity: 1; } }
@keyframes rate-out { to { opacity: 0; transform: scale(0.96); } }
@media (prefers-reduced-motion: reduce) {
  .rp-card, #rate-pop.going .rp-card { animation: none; }
}
/* A rated article says so wherever it appears in a list. */
.rating-tag { color: #f5a623; font-weight: 800; white-space: nowrap; }

/* "Anyone with the link can read this" — shown under an article once its public
   link is live, with the way to take it back. It carries the bottom clearance
   the footer normally provides, so the two never double up. */
.article-footer:has(+ .share-state:not([hidden])) { margin-bottom: 10px; }
.share-state {
  display: flex; align-items: center; gap: 10px; margin: 0;
  padding: 10px 12px; border: 1px solid var(--line); border-radius: var(--radius-s);
  background: var(--bg-soft);
}
.share-state .txt { flex: 1; min-width: 0; }
.share-state button {
  font-size: 0.76rem; font-weight: 800; color: var(--warn);
  background: none; border: none; padding: 4px; white-space: nowrap;
}

/* ---------- shared article (/s/<token>): the app chrome, minus the app ---------- */
.share-page .view { padding-bottom: 40px; }
.share-page .post-title { font-size: 1.42rem; margin-bottom: 12px; }
.share-foot {
  margin: 18px 4px 0; text-align: center;
  font-size: 0.78rem; color: var(--ink-3); font-weight: 600;
}

/* Channels management rows. */
.channel-row { display: flex; align-items: center; gap: 12px; padding: 14px 16px; width: 100%; text-align: left; }
.channel-row .avatar { width: 46px; height: 46px; font-size: 1.4rem; }
.channel-row .info { flex: 1; min-width: 0; }
.channel-row .info b { display: block; font-size: 0.95rem; font-weight: 800; }
.channel-row .info .tagline { display: block; font-size: 0.82rem; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ---------- messenger-style chat list: one white surface, hairline dividers ---------- */
.chatlist {
  background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius);
  overflow: hidden; box-shadow: var(--card-shadow); margin-bottom: 12px;
}
.chatlist .conv-row {
  display: flex; align-items: center; gap: 14px; width: 100%; text-align: left;
  padding: 13px 16px; border-bottom: 1px solid var(--line);
  background: transparent; color: inherit; transition: background 0.12s ease;
}
.showmore { width: 100%; margin: -4px 0 14px; color: var(--ink-2); }
.chatlist .conv-row:last-child { border-bottom: 0; }
.chatlist a.conv-row:active, .chatlist button.conv-row:active { background: var(--bg-soft); }
.chatlist .avatar { width: 50px; height: 50px; font-size: 1.45rem; flex: none; }
/* stands in for an avatar on rows that mean an action ("Chat", "Articles")
   rather than a channel or a person — so it takes the avatar's shape too, at
   the same size: it sits in the avatar column, and a round one there read as a
   different sort of row rather than a row about a different sort of thing */
.row-glyph {
  display: flex; align-items: center; justify-content: center; flex: none;
  width: 50px; height: 50px; border-radius: 31%; color: var(--ink-2);
  background: var(--bg-soft); border: 1px solid var(--line);
}
.row-glyph .ic { width: 22px; height: 22px; }
.chatlist .info { flex: 1; min-width: 0; }
.chatlist .info b { display: block; font-weight: 800; font-size: 0.98rem; }
.chatlist .last { display: block; font-size: 0.85rem; color: var(--ink-2); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-meta { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; flex: none; }
.conv-meta .tiny { color: var(--ink-3); }
.conv-meta .chev { color: var(--ink-3); font-size: 1.15rem; line-height: 1; display: flex; }
/* where a channel sits, on a flat list that has dropped the indentation */
/* Where a channel sits, on the line under its name in the flat list. It was set
   in heavy bold at the palette's lightest grey — weight saying "read me" and
   colour saying "don't", which is what made it look smeared rather than quiet.
   The path is now the readable half of the line and the tagline the faint one,
   which is also the right way round: the path is why the row is here. */
.chatlist .last .row-in { font-weight: 700; color: var(--ink-2); }
.chatlist .last .row-tag { color: var(--ink-3); font-weight: 600; }
/* fold a branch of the channel tree open or shut. It sits inside the row but is
   its own button: the row still opens the channel, this only unfolds it. */
/* Circled, where the "go to this channel" chevron is bare: collapsed, the two
   point the same way, and only the button-ness tells them apart. */
.row-fold {
  display: flex; align-items: center; justify-content: center; flex: none;
  width: 32px; height: 32px; margin: -4px -4px -4px 0; border-radius: 50%;
  color: var(--ink-2); background: var(--bg-soft); border: 1px solid var(--line); cursor: pointer;
  transform: rotate(-90deg); transition: transform 0.15s ease, color 0.12s ease;
}
.row-fold.open { transform: none; }
/* with a fold button the right-hand side is a row, not a stack */
.conv-meta:has(.row-fold) { flex-direction: row; align-items: center; gap: 8px; }
.row-fold:hover { color: var(--ink); background: var(--bg-soft); }
.row-fold .ic { width: 20px; height: 20px; }
.pending-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--ink-3); opacity: 0.45; }

/* Shelf life on a card — how long the piece stays worth reading, next to when
   it landed. Quiet by default; only 'urgent' (about to expire, or breaking)
   earns colour, because a badge that shouts on every card stops being read. */
.shelf-tag { color: var(--ink-2); font-weight: 700; white-space: nowrap; }
.shelf-tag.urgent { color: var(--warn); }

/* --- social: the picture is the post -------------------------------------
   No cover crop and no teaser-then-tap: the image runs the full width of the
   card and the words sit under it as a caption, because there is nothing
   behind a social post worth opening. */
.card.post.social .social-head { padding-bottom: .5rem; }
.card.post.social .social-shot {
  display: block; position: relative; width: 100%; aspect-ratio: 4 / 5;
  overflow: hidden; background: var(--bg-soft);
}
.card.post.social .social-shot > * { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.card.post.social .social-cap { padding-top: .6rem; }
.card.post.social .social-title { margin: 0 0 .2rem; font-weight: 800; line-height: 1.35; }
.card.post.social .post-excerpt { margin: 0; }

/* --- picks: several things to look at, laid out in the feed ---------------
   A scrolling row rather than a grid: the count varies and a grid with three
   items in it looks like a mistake. Each item is its own tap target and goes
   straight out to its link — opening an article to reach a list of links is
   the step this kind exists to remove. */
.card.post.picks .pick-row {
  display: flex; gap: .6rem; overflow-x: auto; scroll-snap-type: x mandatory;
  padding: .2rem .9rem 1rem; margin: 0; -webkit-overflow-scrolling: touch;
}
.card.post.picks .pick-row::-webkit-scrollbar { display: none; }
.card.post.picks .pick-row { scrollbar-width: none; }
.pick {
  flex: 0 0 clamp(140px, 42%, 190px); scroll-snap-align: start;
  display: flex; flex-direction: column; gap: .45rem;
  border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
  background: var(--bg); color: inherit; text-decoration: none;
}
a.pick:hover { border-color: var(--accent); }
.pick > img, .pick .pick-pending {
  display: block; width: 100%; aspect-ratio: 1 / 1; object-fit: cover;
  background: var(--bg-soft); position: relative;
}
.pick-text { display: flex; flex-direction: column; gap: .15rem; padding: 0 .6rem .65rem; }
.pick-text b { font-size: .85rem; line-height: 1.3; font-weight: 700; }
.pick-note { font-size: .8rem; font-weight: 800; color: var(--accent); }
