/* ── Reset ─────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }

/* WebKit ignores the inherited html tap-highlight for form controls / buttons,
   so pin it transparent on every tappable control at once. iOS otherwise paints
   a white bounding-box flash on tap that ignores border-radius (ugly over dark
   surfaces) — including on file-picker triggers like the composer's Choose field
   and the Edit-profile avatar button, which just open the native sheet and want
   no press state of their own. Consolidates the per-control fixes below. */
button, a, label, summary, input, textarea, select, [role="button"], [tabindex] {
  -webkit-tap-highlight-color: transparent;
}

html {
  -webkit-text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;   /* no grey flash on tap — app feel */
  /* The page fill lives on <html>, NOT <body>: the ambient wash is a z-index:-1
     child of <body>, and a body background box paints ABOVE its own negative
     z-index descendants (CSS painting order), which would bury the wash. Putting
     the fill on the root canvas lets the wash show through under the content. */
  background: var(--bg);
}

/* The same paper again, as a FIXED layer this time — because the canvas above
   ends with the document and the glass on top of it does not.

   The topbar is 55% paper over a backdrop blur, which is invisible at rest for
   the obvious reason: the thing behind it is also paper, so 55% of paper over
   paper is paper. Pull past the top and the document slides away, and for the
   length of the rubber band the bar is translucent over NOTHING — the void above
   the document, which composites nowhere near --bg. The bar stopped being
   invisible and became a lighter slab with a hard edge along the bottom,
   strongest in dark mode where three levels on a near-black is a real step
   (measured #111315 against a #0E1012 page, and #17191B right at the lip). It
   read as a panel someone forgot to fade out.

   Setting scrollView.backgroundColor in TriaViewController.swift fixed what you
   see BESIDE the document, and that fix is still right — but a UIKit colour is
   underneath the whole web view and a backdrop-filter can only sample what the
   web layer painted, so it could never reach this. The fix has to be inside the
   document: one viewport-sized fill that no amount of scrolling can move off,
   sitting under everything, so every piece of glass in the app has paper behind
   it in every scroll position there is.

   z-index -2, not -1: the ambient wash is a z-index:-1 child of <body> and has
   to stay ABOVE this, or a profile's glow would be painted over. Flat colour, no
   blur, no gradient — it's one composited layer that never repaints. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  background: var(--bg);
  pointer-events: none;
}

body {
  color: var(--text);
  font-family: 'Oxygen', system-ui, sans-serif;
  font-weight: 300;
  line-height: 1.5;
  min-height: 100dvh;
  -webkit-font-smoothing: antialiased;
}

img { max-width: 100%; display: block; }
/* appearance:none strips WebKit's native button chrome — including the pale
   pressed-state overlay iOS paints on tap, which reads as a white flash over a
   dark surface and is NOT the tap-highlight (that's already transparent above).
   Every button in the app is fully custom-styled, so removing native appearance
   is safe and kills the flash on file-picker triggers like the composer dropzone
   and the avatar edit button. */
button { font: inherit; color: inherit; cursor: pointer; -webkit-appearance: none; appearance: none; }
a { color: inherit; text-decoration: none; }

/* Chrome (nav, buttons, brand) shouldn't be long-press-selectable — that
   text-selection callout is another tell that it's a web page, not an app.
   Inputs/textareas stay selectable (below). */
button, a, .nav-link, .brand, .masthead-kicker {
  -webkit-user-select: none;
  user-select: none;
}
input, textarea { -webkit-user-select: auto; user-select: auto; }

/* ── Ambient wash (profiles only) ──────────────────────────────────────────
   One soft blob centred above the screen, behind the whole app (above the flat
   page colour, below all content) — symmetric depth from the background itself,
   not a coloured card. It's a profile special: only a profile lights it up, in a
   still colour sampled from that person's photo (set via --glow-photo by the
   router). Everywhere else it stays transparent. */
.ambient {
  /* The glow sits behind the top of the view and scrolls away as you read down —
     depth on arrival without sitting behind every post forever. It still does
     that, but it is no longer POSITIONED that way, and the difference is the
     whole reason this comment is long.

     It used to be `position: absolute; top: 0`, so it simply rode the document.
     That cannot survive the iOS rubber band: body::before (fixed) paints the
     entire overscroll gap and a scrolled layer is clipped to the document, so
     pulling down on a profile slid the wash out from under the fixed paper and
     opened a band of bare page above it — a hard step, #EDEEF0 to #E9D8DA across
     a single row, the same species of seam as the topbar band and about as loud.
     Growing this box upward looks like the obvious answer and is completely
     inert: content above the page origin is never painted at all (a magenta probe
     on body::before confirmed which layer owns that region).

     So the wash is fixed — it can no longer be dragged off the top by anything —
     and the scroll-away is driven by a SCROLL TIMELINE instead of by layout. That
     buys the one property `absolute` couldn't give: a scroll timeline's progress
     is clamped to the scroll range, so overscrolling past the top holds it at 0
     and the wash stays exactly where it is, covering the gap, while a normal
     scroll down moves it 1:1 with the page. 100vh of scroll over 100vh of travel
     is the same speed the document moves, so this is not parallax and there is
     nothing for prefers-reduced-motion to object to — it reproduces what
     `absolute` did, minus the one case where `absolute` broke.

     Scoped to the profile wash (below) rather than living here, because the other
     three washes are pinned ON PURPOSE and must not scroll away. And gated on
     @supports, so a webview without scroll timelines degrades to a wash that
     stays put — the old behaviour's opposite, but never the seam.

     inset:0 rather than a 100vh box for the reason spelled out under the About
     wash below: on iOS `100vh` is the LARGE viewport while `top: 0` pins to the
     small one, and this glow is brightest at that very edge. */
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  --glow: transparent;
  background: radial-gradient(ellipse 78% 62% at 50% -14%, var(--glow), transparent 70%);
  /* Promoted, and the animation below drives this same property — so it has to be
     a transform here too, or the keyframe would drop the promotion mid-scroll. */
  transform: translate3d(0, 0, 0);
  /* Fade in/out with the page transition (a gradient can't tween, but opacity
     can) so it eases in alongside the rest of the site rather than snapping. */
  opacity: 0;
  transition: opacity 0.4s ease;
}
body[data-ambient="photo"] .ambient { --glow: var(--glow-photo, transparent); opacity: 1; }
/* The scroll-away, on a scroll timeline (see .ambient). One viewport of travel
   per viewport of scroll — by 100vh the box's bottom edge has reached the top of
   the screen and there is nothing left to show, so the animation is done exactly
   when the glow is gone. `both` holds it there for the rest of a long profile. */
@keyframes wash-away { to { transform: translate3d(0, -100vh, 0); } }
@supports (animation-timeline: scroll()) {
  body[data-ambient="photo"] .ambient {
    animation: wash-away linear both;
    animation-timeline: scroll(root block);
    animation-range: 0 100vh;
  }
}

/* About page (signed in OR the signed-out front door) carries the self-lit,
   hue-drifting blue glow: pinned to the viewport top and falling down over the
   guidelines and the floating nav — position-locked, no longer a scroll-away
   wash. The LOGIN screen no longer wears it — its pastel now comes from the
   gradient Create-account / Log-in button — so the wash is keyed off
   data-ambient="about" only, not body.gate (the router sets 'about' on the
   signed-out front door and 'none' on the auth form). */

/* There's no photo to sample here, so the wash lights itself and drifts slowly
   through the hue wheel. It's anchored to the TOP — the glow falls down over
   the content. Pinned to the VIEWPORT (fixed) so it never scrolls off or shows
   a hard cut edge when the page is tall. */
/* Span BOTH viewport edges (inset: 0) rather than sizing with 100vh: on iOS
   Safari `100vh` is the large (toolbar-hidden) viewport while `top: 0` pins a
   fixed box to the small (toolbar-shown) one, so a 100vh box's top edge can
   land below the true top when the toolbar is collapsed. Since this glow is
   brightest at that very edge (centre at -18%, above the box), inset:0 lets
   the box reach the true viewport top, keeping the glow's bright base flush
   with the screen edge regardless of browser chrome. The GPU promotion keeps
   the hue-drift filter off the scroll path. */
body[data-ambient="about"] .ambient {
  position: fixed;
  inset: 0;
  height: auto;
  --glow: rgba(52, 120, 236, 0.36);
  background: radial-gradient(ellipse 120% 85% at 50% -18%, var(--glow), transparent 72%);
  opacity: 1;
  animation: hue-drift 30s linear infinite;
  transform: translateZ(0);
}
@keyframes hue-drift {
  to { filter: hue-rotate(360deg); }
}

/* Publish composer: the whole screen washes the inferred post type's hue —
   lavender Note, peach Find, cyan Frame, lime Activity, rose Poll. A half-screen radial
   falling from the top (the About glow's shape) but REACTIVE, not drifting:
   --glow-pub is a registered <color> so the wash tweens between types as you
   attach a link/photo or switch to Activity; the gradient takes its alpha via
   color-mix. Driven on <body> in renderPublish / syncType. */
@property --glow-pub {
  syntax: '<color>';
  inherits: true;
  initial-value: #b7a6e8;
}
body[data-ambient="publish"] .ambient {
  position: fixed;
  inset: 0;
  height: auto;
  /* Richer than the profile/About washes: a stronger colour core plus a saturate
     boost so the pastel reads as real colour rather than a faint tint. */
  background: radial-gradient(ellipse 120% 84% at 50% -18%,
    color-mix(in srgb, var(--glow-pub) 35%, transparent), transparent 74%);
  filter: saturate(1.5);
  opacity: 1;
  transition: --glow-pub var(--dur-move) var(--ease), opacity 0.4s ease;
  transform: translateZ(0);
}

/* A daily's page: the same full-screen wash, held at the ONE hue the daily wears
   (the post type it asks for). Nothing tweens it — a daily doesn't change type
   under you the way the composer does as you attach things — so it's a plain
   custom property rather than a registered one, set by applyAmbient. */
body[data-ambient="daily"] .ambient {
  position: fixed;
  inset: 0;
  height: auto;
  background: radial-gradient(ellipse 120% 84% at 50% -18%,
    color-mix(in srgb, var(--glow-daily, transparent) 30%, transparent), transparent 74%);
  filter: saturate(1.4);
  opacity: 1;
  transform: translateZ(0);
}

@media (prefers-reduced-motion: reduce) {
  .ambient { transition: none; }
  body[data-ambient="about"] .ambient { animation: none; }
  /* Colour still updates on type change, just without the tween. */
  body[data-ambient="publish"] .ambient { transition: opacity 0.4s ease; }
}

/* ── Grain ──────────────────────────────────────────────────────────────────
   A static, greyscale film-grain tile laid over the reading surface (bg, ambient
   wash, and content) — flat pastel fills and gradients pick up a tactile paper
   texture instead of reading digital-smooth. It's a single fixed, blended layer:
   no animation (so it's reduced-motion-safe by construction) and no network
   (the noise is an inline SVG in --grain), so the cost is one GPU-composited
   layer. Sits below the fixed chrome (100) and modals (250), so the wordmark,
   nav, and dialogs stay crisp; pointer-events:none keeps it inert. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 90;
  pointer-events: none;
  background-image: var(--grain);
  background-size: 140px 140px;    /* native tile — reads as fine grain even at 3x DPR */
  opacity: var(--grain-opacity);
  mix-blend-mode: overlay;         /* mid-grey noise both lifts and deepens the surface */
}
/* On dark paper overlay clips to harsh sparkle; soft-light keeps it as texture. */
@media (prefers-color-scheme: dark) {
  body::after { mix-blend-mode: soft-light; }
}
/* The boot splash owns the screen before the app fades in — no grain over it. */
body:has(.splash:not(.splash--out))::after { display: none; }

/* Visible keyboard focus on every interactive element. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: var(--radius);
}
:focus:not(:focus-visible) { outline: none; }

/* Instrument Serif is reserved for TITLES ONLY — page titles and post
   headlines. Everything else is Oxygen, leaning on weight (300 / 400 / 700)
   for hierarchy. */
.view-title, .card-title {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
}

/* ── Boot splash ────────────────────────────────────────────────────────────
   A full-page curtain in the page's own paper, crowned by a serif t wearing
   the Post button's drifting pastel gradient (same keyframes, same quintet).
   Painted from static HTML before any JS runs; app.js adds .splash--out after
   the first render and removes the node once the fade lands. The t arrives
   the way cards do — a soft rise — plus the site's blur-settle. */
.splash {
  position: fixed;
  inset: 0;
  z-index: 400;                     /* above topbar/nav (100) and modals (250) */
  display: grid;
  place-items: center;
  background: var(--bg);
  transition: opacity var(--dur-move) var(--ease);
}
.splash-t {
  /* Roman, not italic — the same t as the app icon's mark. */
  font-family: 'Instrument Serif', Georgia, serif;
  font-size: clamp(7.5rem, 26vw, 12rem);
  line-height: 1;
  padding: 0 0.15em 0.1em;          /* clip-to-text crops at the box — keep air around the glyph */
  background: linear-gradient(115deg,
    var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
  background-size: 300% 100%;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation:
    splash-in var(--dur-slow) var(--ease) both,
    publish-shift 2s ease-in-out infinite;  /* faster than the Post button's 5s: splashes are brief, show more of the quintet */
}
/* Grain the whole curtain — paper and the gradient glyph together — so the boot
   screen carries the same tactile surface as the app (the global body::after
   grain is hidden while the splash is up, so it's re-added here). Static, above
   the in-flow t so it textures the glyph; blend flips with the theme. */
.splash::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: var(--grain);
  background-size: 140px 140px;
  mix-blend-mode: overlay;
  opacity: var(--grain-opacity);
  pointer-events: none;
}
@media (prefers-color-scheme: dark) {
  .splash::after { mix-blend-mode: soft-light; }
}
@keyframes splash-in {
  from { opacity: 0; transform: translateY(12px); filter: blur(6px); }
  to   { opacity: 1; transform: translateY(0);    filter: blur(0); }
}
.splash--out {
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .splash { transition: none; }
  .splash--out { visibility: hidden; }
  .splash-t { animation: none; background-position: 50% 50%; }
}

.skip-link {
  position: absolute;
  left: -999px;
  top: 0;
  background: var(--accent);
  color: var(--on-accent);
  padding: 0.6rem 1rem;
  border-radius: 0 0 var(--radius) 0;
  z-index: 300;
}
.skip-link:focus { left: 0; }

/* Present to screen readers / the heading outline, but off-canvas visually. */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip: rect(0 0 0 0); clip-path: inset(50%);
  overflow: hidden; white-space: nowrap;
}

/* ── Sidebar (desktop) ──────────────────────────────────────────────────────
   A floating macOS-style glass rail, detached from the window edges: one
   rounded, translucent card (drawn by .nav below) carries the whole column —
   brand up top, nav stacked, publish pinned to the foot. The topbar itself is
   just a transparent overlay that rests the wordmark on the card's head; the
   glass, border, and shadow all live on .nav so there's no seam between them.
   On phones the rail splits into a top brand bar + a bottom tab bar (see foot). */
.topbar {
  position: fixed;
  top: 1rem;
  left: 1rem;
  width: calc(var(--sidebar-w) - 2rem);
  height: var(--topbar-h);
  z-index: 101;                     /* the wordmark rides on the floating card (z 100) */
  display: flex;
  align-items: center;
  padding: 0 1.75rem;               /* aligns the brand with the nav-link text below */
  border: none;
  background: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  pointer-events: none;             /* only the brand link takes clicks; rest is card */
}
.topbar .brand { pointer-events: auto; }

/* Share Tria (the tray). Desktop: the topbar is a left rail, so this breaks
   out of it and floats as a small glass disc pinned to the viewport's top-right
   corner over content — floating chrome, hence the glass material (blur + rim +
   float). On phones it sheds the disc and docks bare into the header bar (mobile
   block below). pointer-events re-enabled since the desktop topbar is inert.
   A <button> now, not a link: it fires the OS share sheet rather than going
   anywhere, so the appearance reset comes with it. */
.share-tria {
  -webkit-appearance: none;
  appearance: none;
  padding: 0;
  font: inherit;
  cursor: pointer;
  position: fixed;
  top: 1.15rem;
  right: 1.15rem;
  z-index: 102;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border-radius: var(--radius-pill);
  color: var(--muted);
  background: var(--glass-bg);
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), var(--glass-lift);
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  pointer-events: auto;
  transition: color var(--dur-micro) var(--ease), transform var(--dur-micro) var(--ease);
}
.share-tria svg { width: 22px; height: 22px; }
@media (hover: hover) {
  .share-tria:hover { color: var(--text); transform: translateY(-1px); }
}
.share-tria:active { transform: translateY(0); }
@media (prefers-reduced-motion: reduce) { .share-tria { transition: color var(--dur-micro) var(--ease); } }

/* Status-bar legibility scrim — only an INSTALLED shell needs it (see the note
   by the element in index.html). Off everywhere else. On a device with no top
   inset env(safe-area-inset-top) is 0, so it self-collapses to nothing.

   This used to be gated on `@media (display-mode: standalone)`, which is exactly
   the test that does not work in the App Store build: Capacitor's webview reports
   display-mode `browser` (it is the documented reason installedShell() exists in
   app.js), so the scrim was dead in the one shell that most needs it. The native
   app runs full-bleed under the clock — viewport-fit=cover plus Capacitor's own
   full-height webview — and .topbar--hidden tucks the only other backing away on
   scroll-down, so feed content passed under the OS glyphs with nothing behind it.

   The gate is now a single JS predicate stamped onto <html> (data-shell), which
   answers for all three shells at once instead of each stylesheet re-deriving
   "am I installed?" from a media feature that only one of them reports. */
.statusbar-scrim { display: none; }
@media (max-width: 680px) {
  html[data-shell="installed"] .statusbar-scrim {
    display: block;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: env(safe-area-inset-top);
    z-index: 102;                 /* above the frosted topbar (100) so it backs the glyphs there too */
    pointer-events: none;
    opacity: 0.8;                 /* baseline: topbar's frosted glass shares the load */
    background: var(--statusbar-scrim);   /* white in light mode, dark in dark — themed */
    transition: opacity var(--dur-move) var(--ease);
  }
  /* Topbar gone, bare content under the glyphs — take the scrim to full strength. */
  .topbar--hidden ~ .statusbar-scrim { opacity: 1; }
  @media (prefers-reduced-motion: reduce) { html[data-shell="installed"] .statusbar-scrim { transition: none; } }
}

.brand-mark {
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: 2.25rem;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--text);
}

/* On desktop the pill wrapper dissolves: its links become direct flex children
   of the sidebar column, exactly the flat list the sidebar always styled. The
   phone styles (see the mobile block) turn it into the floating glass pill.
   The glide (the sliding active-icon highlight) is a phone-only ornament. */
.nav-pill { display: contents; }
.nav-glide { display: none; }

.nav {
  position: fixed;
  left: 1rem;
  top: 1rem;
  bottom: 1rem;
  width: calc(var(--sidebar-w) - 2rem);
  z-index: 100;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  /* Top pad clears the brand overlay (topbar sits on the card's head). */
  padding: calc(var(--topbar-h) - 0.25rem) 0.85rem 1.25rem;
  border-radius: var(--radius-nav);
  border: var(--glass-edge);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur));
  -webkit-backdrop-filter: blur(var(--glass-blur));
  /* Specular rim + a soft drop, so the rail visibly floats off the page — the
     same glass language as the mobile nav pill, scaled up to a sidebar. */
  box-shadow: var(--glass-rim), var(--glass-lift);
}

.nav-link {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.62rem 0.9rem;
  border-radius: var(--radius-pill);
  color: var(--muted);
  font-size: 1.12rem;
  font-weight: 400;
  line-height: 1;
  transition: color var(--dur-micro);
}
.nav-link .nav-ico { width: 22px; height: 22px; flex-shrink: 0; }
@media (hover: hover) {
  .nav-link:hover { color: var(--text); }
}
/* Active page reads through color + weight alone — no background highlight. */
.nav-link[aria-current="page"] {
  color: var(--accent);
  font-weight: 700;
}

/* Post — the one primary action. At rest a drifting gradient OUTLINE (the four
   type pastels circling the pill — a quiet arrow pointing at posting); on
   hover / press / while on the compose page it fills with the same drifting
   gradient. Text contrast holds AA in every state and both modes: page ink on
   the page at rest, near-black --on-type on the pastel fill. The ring is drawn
   by a masked ::before, so it sits cleanly over any backdrop (sidebar surface,
   frosted topbar, composer paper) and unmasking it IS the fill. */
.publish-fill {
  position: relative;
  isolation: isolate;               /* keeps the z-index:-1 ring inside the button */
  color: var(--text);
  background: none;
  border: none;
  font-weight: 700;
  transition: color var(--dur-micro) var(--ease);
}
.publish-fill::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  padding: 2px;                     /* the ring's thickness */
  background: linear-gradient(115deg,
    var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
  background-size: 300% 100%;
  animation: publish-shift 14s ease-in-out infinite;
  /* Ring mode: keep only the 2px band between padding-box and border-box. */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
}
/* Grain on the gradient: a second, static layer over the pastel fill so the
   hero button reads with the same tactile paper as the rest of the surface.
   Kept off the animation (no crawling grain) and clipped to the pill; only lit
   in the filled states, since at rest the gradient is just a 2px ring. */
.publish-fill::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;                      /* painted after ::before, so grain sits on the gradient */
  border-radius: inherit;
  background-image: var(--grain);
  background-size: 140px 140px;
  mix-blend-mode: overlay;
  opacity: 0;                       /* only the filled states light it, below */
  transition: opacity var(--dur-micro) var(--ease);
  pointer-events: none;
}
@media (prefers-color-scheme: dark) {
  .publish-fill::after { mix-blend-mode: soft-light; }
}

/* Filled: drop the mask so the whole pill carries the gradient. Kept a clean,
   vibrant fill — no inner rim/shade, which only muddied the pastels. */
.publish-fill:active,
.publish-fill[aria-current="page"],
.publish-fill:focus-visible { color: var(--on-type); }
.publish-fill:active::before,
.publish-fill[aria-current="page"]::before,
.publish-fill:focus-visible::before { -webkit-mask: none; mask: none; }
.publish-fill:active::after,
.publish-fill[aria-current="page"]::after,
.publish-fill:focus-visible::after { opacity: 0.28; }
@media (hover: hover) {
  .publish-fill:hover { color: var(--on-type); }
  .publish-fill:hover::before { -webkit-mask: none; mask: none; }
  .publish-fill:hover::after { opacity: 0.28; }
}
/* Solid at rest: Post and Share Tria wear the gradient FILLED from the start
   (the ring is the resting state everywhere else). Same as the filled state
   above, just permanent. */
.publish-fill.is-solid { color: var(--on-type); }
.publish-fill.is-solid::before { -webkit-mask: none; mask: none; }
.publish-fill.is-solid::after { opacity: 0.28; }
.publish-fill:disabled { opacity: 0.6; }
@keyframes publish-shift {
  0%, 100% { background-position: 0% 50%; }
  50%      { background-position: 100% 50%; }
}
@media (prefers-reduced-motion: reduce) {
  .publish-fill::before { animation: none; background-position: 50% 50%; }
}
.nav-publish {
  margin-top: auto;
  justify-content: center;
  gap: 0.5rem;
  font-size: 1.08rem;
  padding: 0.8rem 0.9rem;
}

/* ── Main / views ────────────────────────────────────────────────────────── */
main {
  padding-left: var(--sidebar-w);
  min-height: 100dvh;
}

/* ── Page transitions ──────────────────────────────────────────────────────
   The router (app.js) swaps one `.page` for the next inside #view. The swap is
   ONE FADE: the incoming page is mounted fully opaque underneath, and the
   outgoing page — lifted out of flow, so it overlays — fades out on top of it.
   No direction, no movement, one rule for every route.

   ONE fade, not two, and that is the whole fix for the flash. This used to be a
   true cross dissolve: outgoing 1→0 while incoming 0→1, same curve. Two ramps
   crossing means neither layer is opaque in the middle of the move — at the
   halfway point each sits near 0.5, so the composite is 0.5·out + 0.25·in +
   0.25·PAGE BACKGROUND. That quarter of bare --bg punching through both layers
   is a real luminance spike on every single navigation: a white blink in light
   mode, a dark dip in dark mode. Nothing was mistimed and nothing was
   stuttering; the arithmetic of a cross dissolve simply cannot cover the page.
   Fading only the outgoing layer over an already-opaque incoming one keeps
   coverage at 100% for the entire move, so the background never shows and the
   destination is on screen, complete, from the first frame.

   MOTION BUDGET (why it's shaped this way): pages used to slide along a nav line
   (forward entered from the right, back from the left, and the outgoing page
   receded a touch for an iOS-style push-parallax). That reads fine on a page of
   text and badly on Discover, where the grid is dozens of photos still decoding
   while the slide runs: the movement turned every late tile into a glitch rather
   than a load, and the page looked like it was snapping into place. A fade has
   nothing to be out of step with — tiles paint whenever they're ready,
   underneath an opacity ramp that doesn't care. It's also the cheapest thing the
   compositor can do, and now it is half of that again: only the outgoing page
   is promoted, so a navigation carries ONE composited layer instead of two.
   (There was a 6px focus-in blur on the entering page even earlier; `filter:
   blur()` re-rasterizes the whole page every frame, which is what the old iOS
   jitter was. Long gone — don't bring either back.)

   `will-change: opacity` promotes the leaving page for the move and releases
   when the class clears. Deliberately NOT `will-change: transform`: that makes a
   page a containing block for its `position: fixed` children (the docked
   seg-tabs), which is the constraint the slide had to work around. */
#view { position: relative; overflow-x: clip; }

/* Entering page: no ramp at all. It carries `.enter` only so the two rules below
   can find it — it is opaque and unpromoted from its first frame. */

/* Leaving page — lifted out of flow so it overlays the destination instead of
   the layout collapsing between them, and (being positioned) painting above it.
   renderPage pins its `top` to the band the reader was actually looking at, AFTER
   the scroll has reached its destination — the scroll-anchor fix. */
.page.leave {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 1;                 /* say it out loud rather than leaning on paint order */
  will-change: opacity;
  transition: opacity var(--dur-quick) var(--ease-soft);
}
.page.leave.active { opacity: 0; }

/* The OUTGOING page's glass goes quiet for the move. iOS WebKit (standalone
   especially) keeps trying to sample backdrop-filter inside a promoted, fading
   layer and the frost visibly jumps — exactly on the pages that carry in-page
   glass (Friends: seg-tabs + search; Updates: seg-tabs + soft-ask; Profile: the
   account-card, a big piece of glass whose large sample area was crashing iOS
   WebKit outright on the way out to Circle). Turning it off explicitly stops the
   mis-sampling and drops the per-frame compositing cost. The translucent fills
   stay, so the surfaces keep their tone throughout. The nav pill lives outside
   #view and keeps its glass live the whole time (it has its own iOS layer pin —
   see .nav-pill).

   `.page.enter` used to be in this list and is deliberately NOT any more. The
   precondition the paragraph above names is "inside a promoted, fading layer",
   and since the swap became one fade that is no longer true of the destination:
   it is opaque, unpromoted, and not animating. What the rule bought there was a
   cost it no longer has, and what it charged was visible — the page you just
   arrived on wore FLAT glass until the router's cleanup ran, then frosted, so
   every navigation onto Discover, Updates or a profile ended in a little pop of
   the material switching on. Arriving glass is now simply glass. */
.page.leave .seg-tabs,
.page.leave .masthead-search-field,
.page.leave .push-ask,
.page.leave .daily-card,
.page.leave .account-card {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

/* The same collision, different cause: on a photo-heavy page, a promoted page
   layer tears down while a burst of photo-frame reveals is still mid-flight,
   right as several photo bitmaps are freshly decoded. That pile-up of
   simultaneous compositor layers + fresh large bitmaps is what was crashing
   Safari on iOS ("A problem repeatedly occurred") on page switches, not the
   glass. Letting photos snap in instead of fading during the move (they still
   reveal the instant they decode, just without an extra opacity layer riding
   along) cuts the concurrent-layer count during the one window that matters;
   fades return to normal the moment renderPage's cleanup clears these classes.
   Card rise itself no longer runs during a navigation at all — renderPage
   freezes freshly-mounted cards so they ride the swap (see the freeze in
   app.js), which both removes the old white flash and drops the card layers
   entirely.
   Discover's tiles carry the same contract under a different class, and they
   carry it dozens at a time — a grid landing mid-swap is the densest burst of
   fresh bitmaps anywhere in the app, so it is the last place that wants an extra
   opacity layer per photo riding along.
   This one keeps `.page.enter` even though the destination is no longer promoted,
   and now has a second reason to: the destination is on screen COMPLETE from the
   first frame, so a wave of photos fading up under a page that is dissolving away
   on top of them is two moves in the same 240ms. Snapping is the calm read. */
.page.enter .photo-frame img, .page.leave .photo-frame img,
.page.enter .ptile-face--media img, .page.leave .ptile-face--media img {
  transition: none;
}

@media (prefers-reduced-motion: reduce) {
  /* Match .page.leave's specificity so the override actually wins (the router
     also skips the swap entirely under reduced motion — belt and braces). */
  .page, .page.enter, .page.leave { transition: none; will-change: auto; }
}

/* Desktop: one centred column. The sidebar offsets `main` on the left, so we
   centre within the REMAINING space — the readable measure sits balanced in the
   headroom rather than hugging either edge. Masthead and feed share the same
   axis and width (see the content-block cap below) so nothing reads lopsided. */
.view {
  max-width: var(--content-max);
  margin-inline: auto;
  padding: 4rem 2rem 6rem;
}

/* Header aligns with the post-text column (inset past where the type-rules sit);
   full-width photos are the only thing reaching the outer feed edge. */
.filters { padding-inline: var(--inset); }
.view-title {
  font-size: 2.4rem;
  line-height: 1.05;
  letter-spacing: 0;
  color: var(--text);
}

/* ── Masthead — the editorial nameplate crowning each page ─────────────────── */
.masthead {
  margin-bottom: 2.6rem;
  padding: 0 var(--inset) 0.7rem;
}
.masthead-kicker {
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: var(--kicker);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--muted);
  margin-bottom: 1rem;
}
.masthead-kicker .dot { opacity: 0.5; margin: 0 0.35rem; }
.masthead-row {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
}
.masthead-title {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: var(--display);
  line-height: 1;
  letter-spacing: -0.01em;
  color: var(--text);
}

/* Updates title breathes a touch more before its seg-tabs below. */
.view--updates .masthead-title { padding-bottom: 0.35rem; }

/* Discover has no seg-tabs (one grid, no tabs), so its title parts from the
   grid on its own. */
.view--discover .masthead-title { padding-bottom: 0.6rem; }

/* Masthead search (Discover): a magnifier on the title line that wipes a
   FULL-WIDTH field open across the nameplate — the "Discover" title simply
   vanishes behind it. Same reveal-from-the-control feel as the + speed dial, but
   the bar spans the whole measure so it reads as one clean input, not a menu
   floating over a menu. The icon itself morphs into an X while open, so the same
   control closes it. */
/* Discover groups search + type filter at the right of the nameplate. The group
   isn't a positioning context, so the search field's absolute wipe still resolves
   to .masthead-row. The field is inset on the right by the filter's slot (its
   40px width + this gap) so opening search leaves the filter clear on the far
   right, and the X lands on the field's own edge rather than floating past it. */
/* Nudged 8px past the --inset column so the search/filter glyphs (centered in
   their 40px tap circles) read as flush with the post padding edge instead of
   sitting inboard of it. The search field's stop-offset below tracks this same
   8px so it still just clears the filter button with no gap or overlap. */
.masthead-actions { display: flex; align-items: center; gap: 0.35rem; flex-shrink: 0; margin-right: -8px; }
.view--discover .masthead-search-field { right: calc(40px + 0.35rem - 8px); }
.masthead-search { flex-shrink: 0; }
.masthead-search-btn {
  position: relative;
  z-index: 2;                       /* rides above the field so the X stays tappable */
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: none;
  color: var(--muted);
  transition: color var(--dur-micro);
}
@media (hover: hover) { .masthead-search-btn:hover { color: var(--text); } }
.masthead.searching .masthead-search-btn { color: var(--text); }
/* The two glyphs stack in the one grid cell; opening cross-fades + quarter-turns
   the magnifier into the X (and back), so the morph reads as one control. */
.msb-ico {
  grid-area: 1 / 1;
  display: flex;
  transition: opacity var(--dur-quick) var(--ease-soft), transform var(--dur-quick) var(--ease);
}
.msb-ico svg { width: 22px; height: 22px; }
.msb-ico--close { opacity: 0; transform: rotate(-90deg); }
.masthead.searching .msb-ico--search { opacity: 0; transform: rotate(90deg); }
.masthead.searching .msb-ico--close { opacity: 1; transform: rotate(0); }

/* The title recedes behind the opening bar — a soft blur + fade, so it reads as
   depth under frosted glass rather than a hard swap. */
.masthead-title {
  transition: filter var(--dur-quick) var(--ease), opacity var(--dur-quick) var(--ease);
}
.masthead.searching .masthead-title { filter: blur(3px); opacity: 0.35; }

/* Frosted-glass field — the site's chrome treatment (nav rail, sticky bars). It's
   translucent, so the blurred title shows through as a faint wash behind it. */
.masthead-search-field {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  z-index: 1;
  font: inherit;
  color: var(--text);
  background: var(--glass-bg);
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  /* iOS reads a search field as a FILLED capsule, not an outlined one: the fill
     and the specular rim carry the shape, so the edge stays a barely-there
     hairline (the old --rule border landed as a bright ring on dark paper). */
  border: 1px solid color-mix(in srgb, var(--text) 7%, transparent);
  box-shadow: var(--glass-rim);
  border-radius: var(--radius-pill);
  padding: 0.6rem 3rem 0.6rem 1rem;   /* right pad clears the X control */
  opacity: 0;
  pointer-events: none;
  clip-path: inset(0 0 0 100% round 999px);   /* collapsed: wiped away to the right */
  transition: clip-path var(--dur-move) var(--ease), opacity var(--dur-quick) var(--ease);
}
.masthead.searching .masthead-search-field {
  opacity: 1;
  pointer-events: auto;
  clip-path: inset(0 0 0 0 round 999px);       /* open: full width */
}
/* Focus lifts the hairline just enough to read as active (keyboard a11y) without
   the old bright --accent ring — iOS keeps the capsule quiet and lets the caret
   and content say it's focused. */
.masthead-search-field:focus {
  outline: none;
  border-color: color-mix(in srgb, var(--text) 22%, transparent);
}
.masthead-search-field::placeholder { color: var(--muted); opacity: 0.6; }
/* Kill the browser's own clear "✕" — the morphing icon already closes + clears,
   so a second X inside the field is redundant. */
.masthead-search-field::-webkit-search-cancel-button,
.masthead-search-field::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
@media (prefers-reduced-motion: reduce) {
  .masthead-search-field { transition: opacity var(--dur-quick) ease; }
  .msb-ico, .masthead-title { transition: opacity var(--dur-quick) ease; }
}

/* The New-post masthead's reactive type mark: a coloured type-icon in the same
   right-hand slot the Friends search uses. It doesn't pick the type — it reflects
   the one the composer inferred (note → find → frame → activity), and pops when it
   flips (see syncPubType). Sized a touch larger than the feed's inline type-icon
   so it reads as the page's own mark, not a caption glyph. */
.type-indicator {
  flex-shrink: 0;
  margin-left: auto;
}
.masthead .type-indicator { width: 2.15rem; height: 2.15rem; }
@keyframes type-pop {
  0%   { transform: scale(0.55) rotate(-14deg); opacity: 0; }
  55%  { transform: scale(1.14) rotate(3deg);   opacity: 1; }
  100% { transform: scale(1)    rotate(0);      opacity: 1; }
}
.type-indicator.is-changing { animation: type-pop var(--dur-move) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .type-indicator.is-changing { animation: none; }
}

/* Composer nameplate swap — the title follows the Post ⇄ Activity switcher. The
   outgoing word sinks and blurs away while the new one rises through it: the same
   depth-under-glass move the search bar plays on this title, so the header changes
   rather than hard-cuts. The ghost is absolutely placed over the real <h1>, which
   already carries the new word, so the row never reflows mid-swap. */
.masthead-title--swap { position: relative; white-space: nowrap; }
.masthead-title--swap .title-word { display: inline-block; will-change: transform; }
.title-word--in { animation: title-rise var(--dur-move) var(--ease); }
/* max-content, or the ghost's shrink-to-fit width gets capped by the <h1> it sits
   in — leaving "New activity" wrapped onto two lines while the shorter "New post"
   is the one setting the width. */
.title-word--out {
  position: absolute;
  left: 0;
  top: 0;
  width: max-content;
  pointer-events: none;
  animation: title-sink var(--dur-move) var(--ease) forwards;
}
@keyframes title-rise {
  from { opacity: 0; transform: translateY(0.4em);  filter: blur(5px); }
  to   { opacity: 1; transform: translateY(0);      filter: blur(0); }
}
@keyframes title-sink {
  from { opacity: 1; transform: translateY(0);      filter: blur(0); }
  to   { opacity: 0; transform: translateY(-0.4em); filter: blur(5px); }
}
@media (prefers-reduced-motion: reduce) {
  .title-word--in  { animation: fade var(--dur-quick) ease; }
  .title-word--out { animation: fade-out var(--dur-quick) ease forwards; }
}

/* ── Masthead filter button ────────────────────────────────────────────────
   The sliders glyph at the right of the "My Circle" nameplate (the slot the
   Friends page spends on its search magnifier). Tapping it fans the filter dial.
   A hue dot rides its upper-right ONLY when a filter is on, so a folded menu
   still signals the feed is narrowed — the dot carries the active type's colour;
   All (no narrowing) carries no dot. */
.masthead-filter {
  position: relative;
  flex-shrink: 0;
  margin-left: auto;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  padding: 0;
  border: none;
  border-radius: 50%;
  background: none;
  color: var(--muted);
  transition: color var(--dur-micro), transform var(--dur-quick) var(--ease);
}
/* Home carries the filter alone (no .masthead-actions wrapper — that's
   Discover's), so it needs its own 8px nudge past --inset to match; scoped to
   a direct child of .masthead-row so it doesn't double up with Discover's
   nested filter, which gets its shift from .masthead-actions instead. */
.masthead-row > .masthead-filter { margin-right: -8px; }
@media (hover: hover) { .masthead-filter:hover { color: var(--text); } }
.masthead-filter[aria-expanded="true"] { color: var(--text); }
/* Press feedback comes from the shared press engine (app.js) — it animates the
   `scale` property so it composes with any transform a control already carries. */
.masthead-filter-ico { width: 22px; height: 22px; }
.masthead-filter-dot {
  position: absolute;
  top: 3px;
  right: 3px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--dot, var(--accent));
  box-shadow:
    0 0 0 2px var(--bg),                  /* punch it clear of the glyph strokes */
    0 0 8px color-mix(in srgb, var(--dot, var(--accent)) 70%, transparent);
}
.masthead-filter[data-active="note"]     { --dot: var(--type-note); }
.masthead-filter[data-active="find"]     { --dot: var(--type-find); }
.masthead-filter[data-active="photo"]    { --dot: var(--type-photo); }
.masthead-filter[data-active="poll"]     { --dot: var(--type-poll); }
.masthead-filter[data-active="activity"] { --dot: var(--type-activity); }
/* Discover's People row isn't a post type, so its dot stays ink rather than
   borrowing one of the five pastels — on is still unmistakably on. */
.masthead-filter[data-active="people"]   { --dot: var(--text); }

/* ── Filter dial ───────────────────────────────────────────────────────────
   The type-filter menu the masthead sliders button fans open: the + FAB's
   speed-dial recipe — labelled rows, the type colour blooming behind a frosted
   glyph disc — dropped DOWN from the top control instead of rising from the nav.
   The scrim dims + blurs the page and catches the tap-out; it sits ABOVE the nav
   (a top-anchored menu must clear it), unlike the FAB veil that tucks under it.
   Data-driven off FILTERS, so a new post type is one row, not a re-layout. */
.filter-dial-scrim {
  position: fixed;
  inset: 0;
  z-index: 300;
  background: color-mix(in srgb, var(--bg) 32%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  opacity: 0;
  transition: opacity var(--dur-quick) var(--ease);
}
.filter-dial-scrim.open { opacity: 1; }
.filter-dial {
  position: fixed;
  display: flex;
  flex-direction: column;      /* All (DOM-first) rests nearest the button */
  align-items: flex-end;       /* chips stack in a column straight under it */
  gap: 0.55rem;
}
.filter-dial-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0;
  background: none;             /* the button's default grey chrome, gone */
  border: 0;
  /* Collapsed: lifted toward the button + faded. Open: dropped into place, each
     staggered a beat behind the one above (nearest the button springs first). */
  opacity: 0;
  transform: translateY(-14px) scale(0.9);
  transition:
    opacity var(--dur-quick) var(--ease),
    transform var(--dur-quick) var(--ease);
}
.filter-dial-scrim.open .filter-dial-item {
  opacity: 1;
  transform: none;
  transition-delay: calc(var(--i) * 0.04s);
}
/* No pill behind an idle label — the blurred scrim plus the chip's own shadow
   carry the separation; a page-coloured halo keeps the words legible over
   whatever the scrim is dimming. */
.filter-dial-label {
  padding: 0 0.1rem;
  font-size: 0.98rem;
  font-weight: 700;
  line-height: 1;
  color: var(--text);
  white-space: nowrap;
  text-shadow:
    0 0 10px var(--bg),
    0 1px 3px color-mix(in srgb, var(--bg) 65%, transparent);
}
/* The active row wears a pill outline round its label — the reference's centred
   "Cycling" tell — so the current filter reads at a glance even mid-fan. */
.filter-dial-item.is-on .filter-dial-label {
  padding: 0.34rem 0.72rem;
  border-radius: var(--radius-pill);
  border: 1px solid color-mix(in srgb, var(--text) 20%, transparent);
  background: color-mix(in srgb, var(--surface) 82%, transparent);
  text-shadow: none;
}
/* The type colour glows from behind the glyph on a translucent disc (the
   dial/FAB "colour is light" move), not a hard chip. No backdrop-filter of its
   own — the scrim behind the whole dial already blurs the frozen page, so a
   second blur per disc was redundant work fighting the stagger-in transform
   each row animates through (chopped up the fan-open motion). */
.filter-dial-ico {
  position: relative;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  background: color-mix(in srgb, var(--bg) 72%, transparent);
  border: var(--glass-edge);
  box-shadow: var(--glass-lift);
  transition: transform 0.08s var(--ease);
}
.filter-dial-ico::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(64% 64% at 50% 50%, var(--glow) 0%, transparent 66%);
  opacity: 0.7;
}
.filter-dial-ico svg { position: relative; width: 1.5rem; height: 1.5rem; }
/* Active chip: a ring in its own hue seals it as the current pick. */
.filter-dial-item.is-on .filter-dial-ico {
  box-shadow: var(--glass-lift), 0 0 0 2px color-mix(in srgb, var(--glow) 55%, transparent);
}
.filter-dial-item:active .filter-dial-ico { transform: scale(0.92); }

@media (prefers-reduced-motion: reduce) {
  .filter-dial-item { transform: none; transition: opacity var(--dur-micro) ease; }
  .filter-dial-scrim.open .filter-dial-item { transition-delay: 0s; }
  .filter-dial-scrim { transition: none; }
}

/* Dark glass: run the frosted disc less transparent and deepen its shadow so it
   reads over dark content, and pull the pastel bloom down so the deep-ink glyph
   stays legible on the brighter disc — the same treatment the FAB dial takes. */
@media (prefers-color-scheme: dark) {
  .filter-dial-scrim { background: color-mix(in srgb, var(--bg) 44%, transparent); }
  .filter-dial-ico { background: color-mix(in srgb, var(--bg) 82%, transparent); }
  .filter-dial-ico::before { opacity: 0.5; }
  .filter-dial-item.is-on .filter-dial-ico {
    box-shadow: var(--glass-lift), 0 0 0 2px color-mix(in srgb, var(--glow) 60%, transparent);
  }
}

/* The composer's type filters are a closed set of exactly four, so they spread
   across the full measure as even columns (rather than the feed's shrink-to-fit,
   left-packed chips) and run a touch tighter than the feed's row. (.filters.compose-filters
   beats the plain .filters rule, which is declared later in the file.) */
.filters.compose-filters {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  margin-bottom: 1.6rem;
}
.compose-filters .filter { text-align: center; }

/* Masthead and every content block share one readable measure, centred on the
   same axis — a clean, balanced column rather than a lopsided broadsheet. */
.masthead,
.filters,
.feed,
.friends-list,
.composer,
.account,
.notif-list,
.requests,
.profile-shelf,
.daily-lede,
.daily-banner,
.feed-empty { max-width: var(--feed-width); margin-inline: auto; }
.view-sub {
  color: var(--muted);
  font-size: 0.92rem;
  margin-top: 0.35rem;
  letter-spacing: 0.01em;
}

/* ── Filter menu — the one splash of color. Each type carries its pastel hue;
   All stays monochrome ink. Active = pastel fill + near-black ink; idle =
   the type's deepened -ink as text + a tinted edge. */
.filters {
  display: flex;
  gap: 0.5rem;
  flex-wrap: wrap;
  margin-bottom: 2.4rem;
}
.filter {
  flex-shrink: 0;
  position: relative;               /* anchors the invisible tap-target extender */
  font-size: 0.9rem;
  font-weight: 400;
  line-height: 1;
  color: var(--muted);
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  padding: 0.45rem 0.95rem;
  transition: color var(--dur-micro), border-color var(--dur-micro), background-color var(--dur-micro);
}
/* HIG tap target (shared): small pill controls stay compact + editorial, but
   their HIT region is extended to the 44pt minimum with an invisible overlay.
   Apple's guidance is a 44pt hit AREA, not a 44pt drawn control (their own
   filter chips render shorter and extend touch slop the same way), so the visual
   isn't bloated. Vertical only — horizontal is already comfortable and neighbours
   share the row. Each host sets position:relative to anchor it. NB: not for
   .publish-fill pills (e.g. the profile Share) — those spend their ::after on the
   grain layer, so a tap overlay here would fight it; they size the hit area with
   real padding instead. */
.filter::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 44px;
  transform: translateY(-50%);
}
/* Idle: greyed out — neutral text on a hairline, so the whole row rests quiet
   and colour is spent only on the one active filter (its glow, below). */
@media (hover: hover) {
  .filter:not([aria-pressed="true"]):hover {
    color: var(--text);
    border-color: color-mix(in srgb, var(--text) 32%, var(--rule));
  }
}
/* Active — NEUTRAL by default: a solid ink chip, page-coloured text, a quiet
   colourless lift. This is the safe base for every non-type filter (All,
   Mentions, Find friends, …) so none can land as dark ink on a dark chip. The
   four POST TYPES override it just below with their pastel fill + hue glow. */
.filter[aria-pressed="true"] {
  --chip: var(--accent);
  color: var(--bg);
  background: var(--chip);
  border-color: var(--chip);
  box-shadow: 0 0 13px color-mix(in srgb, var(--text) 14%, transparent);
}
/* The four post types: the clean single-colour toggle — a flat pastel fill, ink
   on top — lit from behind by a soft glow in its own hue (the "colour is light"
   move from the nav's + button and dial). --chip carries the hue so one rule
   lights them all. Keyed on data-filter (the home / updates type rows). */
.filter[data-filter="note"][aria-pressed="true"],
.filter[data-filter="find"][aria-pressed="true"],
.filter[data-filter="photo"][aria-pressed="true"],
.filter[data-filter="poll"][aria-pressed="true"],
.filter[data-filter="activity"][aria-pressed="true"] {
  color: var(--on-type);
  box-shadow:
    0 0 15px color-mix(in srgb, var(--chip) 44%, transparent),
    0 0 5px color-mix(in srgb, var(--chip) 30%, transparent);
}
.filter[data-filter="note"][aria-pressed="true"]     { --chip: var(--type-note); }
.filter[data-filter="find"][aria-pressed="true"]     { --chip: var(--type-find); }
.filter[data-filter="photo"][aria-pressed="true"]    { --chip: var(--type-photo); }
.filter[data-filter="poll"][aria-pressed="true"]     { --chip: var(--type-poll); }
.filter[data-filter="activity"][aria-pressed="true"] { --chip: var(--type-activity); }

/* ── Segmented tab control (.seg-tabs) — a reusable iOS segmented control shared
   by the view switchers (Friends' My circle / Find friends, Updates' All /
   Mentions). One frosted-glass track (the nav pill's material, scaled to a
   control) holds equal segments over a raised thumb that SLIDES between them, so
   a switch reads as one deliberate control, not loose pills. Centred, self-
   contained. Two segments; the thumb position is driven by data-sel (0|1) so the
   control stays generic across surfaces. An optional .seg-tabs-glow lights the
   brand gradient under the thumb at index 0 — Friends opts in for My circle's
   identity; neutral switches (Updates) leave it off, keeping colour reserved. */
.seg-tabs {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  width: 100%;
  max-width: 300px;
  margin: 0 auto 2.4rem;
  padding: 4px;
  border-radius: var(--radius-pill);
  background: var(--glass-bg);
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), var(--glass-lift);
}
/* The sliding thumb — a lighter pill lifted off the track (the selected segment
   as a raised card). It covers the first segment and translates a full segment
   across for the second, so the choice glides rather than blinks. overflow
   clips its glow child to the pill; the drop shadow rides outside it. */
.seg-tabs-thumb {
  position: absolute;
  top: 4px;
  bottom: 4px;
  left: 4px;
  right: 50%;
  overflow: hidden;
  border-radius: var(--radius-pill);
  background: color-mix(in srgb, var(--surface) 88%, transparent);
  box-shadow: var(--lit-rim), var(--lit-drop);
  transition: transform var(--dur-quick) var(--ease);
  pointer-events: none;
}
.seg-tabs[data-sel="1"] .seg-tabs-thumb { transform: translateX(100%); }
/* Brand glow inside the thumb — lit only at index 0 (Friends' My circle); it
   fades out when the second segment is active. The drifting quintet masked to a
   soft core and blurred into coloured light, exactly the nav glide's engine. */
.seg-tabs-glow {
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg,
    var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
  background-size: 300% 100%;
  animation: publish-shift 14s ease-in-out infinite;
  -webkit-mask: radial-gradient(circle at 50% 50%, #000 20%, transparent 95%);
          mask: radial-gradient(circle at 50% 50%, #000 20%, transparent 95%);
  filter: blur(9px) saturate(1.4) brightness(1.1);
  opacity: 0.38;
  transition: opacity var(--dur-quick) var(--ease);
  pointer-events: none;
}
.seg-tabs[data-sel="1"] .seg-tabs-glow { opacity: 0; }
/* The composer switcher (#c-group-tabs) opts OUT of the brand glow entirely
   (segTabsEl glow:false, so no glow element is rendered): it's a neutral Post /
   Activity toggle, and the reactive colour is carried by the full-screen wash. */
/* Segments — equal-width toggles riding above the thumb. Muted when off, ink
   when on; the thumb supplies the lit surface behind the active label. Centred
   with a 44px floor so the tap target clears the iOS accessibility minimum. */
.seg-tab {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0.4rem 0.5rem;
  font-size: 0.92rem;
  font-weight: 400;
  line-height: 1;
  color: var(--muted);
  background: none;
  border: none;
  border-radius: var(--radius-pill);
  transition: color var(--dur-micro) var(--ease);
}
.seg-tab[aria-selected="true"] { color: var(--text); }
@media (hover: hover) {
  .seg-tab:not([aria-selected="true"]):hover { color: var(--text); }
}
@media (prefers-reduced-motion: reduce) {
  .seg-tabs-thumb { transition: none; }
  .seg-tabs-glow { animation: none; background-position: 50% 50%; }
}

/* ── Feed — editorial blocks, no boxes; whitespace + a right type-tag ──────── */
/* Entries are separated by whitespace plus a centered hairline divider, so one
   post's tail never blurs into the next byline. Each carries a small colored
   type-tag at the right of its byline instead of a left rule. */
.feed { display: flex; flex-direction: column; gap: 0.9rem; max-width: var(--feed-width); }
.feed > .card + .card { border-top: 1px solid var(--rule); padding-top: 0.9rem; }

.feed-empty {
  color: var(--muted);
  font-size: 0.95rem;
  padding: 2rem 0.5rem;
  text-align: center;
}
.feed-empty--welcome { padding-top: 3.5rem; }
.feed-empty--welcome p { margin-bottom: 0.9rem; }
.feed-empty-cta {
  display: inline-block;
  color: var(--accent);
  font-weight: 700;
  border-bottom: 1px solid currentColor;
  padding-bottom: 1px;
  transition: opacity var(--dur-micro) var(--ease);
}
.feed-empty-cta:active { opacity: 0.6; }

.card {
  animation: rise var(--dur-slow) var(--ease) both;
  /* Skip layout + paint for cards scrolled out of view, so a long feed doesn't
     pay to render rows nobody's looking at. `auto` remembers each card's real
     height once seen, so the 420px estimate only ever applies to a card that's
     never yet been on screen (keeps the scrollbar honest). */
  content-visibility: auto;
  contain-intrinsic-size: auto 420px;
}
/* The post body sits in .card-main; the comment thread expands as a sibling
   below it (see .comments-panel). Text entries (post / find): content inset to
   match the photo text column; the type reads from the colored tag, not a rule. */
.card--note .card-main, .card--find .card-main, .card--activity .card-main,
.card--poll .card-main {
  padding: 0.35rem var(--inset);
}

/* Action row below the post: the social group (headcount / like / comment)
   sits on the RIGHT for thumb reach; the ••• overflow tucks away on the left.
   row-reverse keeps the markup order (social first) while pinning the social
   group to the right even when it's the only group. */
.card-actions {
  display: flex;
  flex-direction: row-reverse;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin-top: 1rem;
}
.card--photo .card-actions { padding-inline: var(--inset); margin-top: 1.25rem; }
.card-actions button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 34px;
  min-width: 34px;        /* 1:1 by default — the ••• hover wash is a circle, not an oval */
  padding: 0;
  color: var(--muted);
  background: none;
  border: none;
  border-radius: var(--radius-pill);
  transition: color var(--dur-micro), background var(--dur-micro);
}
.card-actions button svg { width: 18px; height: 18px; }
/* The social + activity glyphs read larger than the ••• overflow — they're the
   invitations, the menu is a quiet tool. A bigger 1:1 target too (44px clears the
   mobile touch-target guideline); counted buttons grow wider from here. */
.card-social button svg { width: 28px; height: 28px; }
.card-social button { height: 44px; min-width: 44px; }

/* The ••• overflow rides the bottom-left corner of the row — a quiet tool, held
   at the 34px/18px tool scale against the 44px/28px social glyphs. It's a direct
   child of .card-actions on every card type (row-reverse floats it leftmost);
   activities no longer split the row into two ends, since the headcount and the
   RSVP are one control now and sit in the social cluster with the rest. */
.card-menu { color: var(--muted); opacity: 0.6; }
.card-actions > .card-menu { height: 34px; min-width: 34px; }
.card-menu svg { width: 18px; height: 18px; }
.card-menu:active { opacity: 1; color: var(--text); }
@media (hover: hover) { .card-menu:hover { opacity: 1; color: var(--text); } }

/* Like + comment share the right, as one group. The group's negative margin
   cancels the last button's inner padding so its glyph lines up with the post
   text column's right edge. */
.card-social { display: flex; align-items: center; gap: 0rem; margin-right: -0.2rem; }

/* Icon-only likes/comments are a 44px square (the shared social tap target).
   When a COUNT rides along, the button grows to the left so the glyph keeps that
   square feel and the count sits a clear step off it. Trailing padding matches a
   bare icon's own ~0.35rem breathing room, so the gap to the next button reads
   the same whether or not a count is present. */
.card-like:has(.card-like-count),
.card-comment:has(.card-comment-count) {
  justify-content: flex-start;
  gap: 0.4rem;
  padding-inline: 0.35rem;
}
.card-comment-count,
.card-like-count { font-size: 0.82rem; font-weight: 700; line-height: 1; }

/* Optical nudge: the comment bubble's mass sits high (the tail is empty), so it
   reads a hair above the like heart's line — drop it to match. */
.card-social .card-comment svg { transform: translateY(2px); }

/* Comment toggle lifts to the accent when open. */
.card-comment[aria-expanded="true"],
.card-comment:hover,
.card-comment:focus-visible { color: var(--accent); }

/* The heart's weight lives in its top lobes — the point at the bottom is almost
   empty — so a mathematically-centred heart reads as floating above the comment
   glyph and the "going" text beside it. A hair of downward nudge sits it back on
   the row's optical line. (Visual tuning, not geometry — adjust to taste.) */
.card-social .card-like svg { transform: translateY(1.5px); }

/* Like heart. A friend sees a fillable heart, no count. The author sees the
   count and (via --owner) opens the "who liked" list; the heart fills to the
   accent when liked / when the author's panel is open. */
.card-like:hover,
.card-like:focus-visible,
.card-like.liked,
.card-like--owner[aria-expanded="true"] { color: var(--accent); }
/* A liked heart settles on its post's own type colour and sits still — no
   perpetual drift (that kept the compositor awake on every liked card). The
   flourish is all in the tap, where the heart TAKES INK (see below). Ink twins:
   AA in light mode, flipping to the pastels in dark. */
.card-like.liked svg { fill: currentColor; }
.card-like.liked {
  color: var(--burst, var(--heart-note));
}

/* ── Like tap: ink stamp + sparkle ──────────────────────────────────────────
   Liking should feel good. Not particles thrown across the screen (that's
   engagement-bait, the opposite of this app), but one confident editorial mark
   that twinkles. Three beats fire together on LIKE, all cheap (transform + clip
   + mask; no blur filter, no grain):
     1. the heart SNAPS — a sharp scale punch, no soft anticipation;
     2. the post's type colour FLOODS UP through the glyph like ink filling it
        (a masked overlay clip-revealed bottom→top while the outline holds);
     3. a tight little cluster of y2k four-point STARS twinkles out of it and
        winks away (JS injects them on LIKE, see burstSparkles) — the burst
        energy spent on a smile, not scattered confetti.
   Everything stays inside the card's paint-contained box (the heart sits near
   the card's bottom edge — see content-visibility on .card), so nothing crops. */
.card-like {
  position: relative;
  /* Filled-heart silhouette, shared by the ink overlay's mask below. */
  --heart-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 20.3 4.7 12.9a4.6 4.6 0 0 1 6.5-6.5l.8.8.8-.8a4.6 4.6 0 0 1 6.5 6.5z'/%3E%3C/svg%3E");
}
/* Burst colour = the post's type colour (matched to the liked-heart palette).
   The ink the heart takes, the ring, and the resting fill are all this colour. */
.card-like[data-type="note"]     { --burst: var(--heart-note); }
.card-like[data-type="find"]     { --burst: var(--heart-find); }
.card-like[data-type="activity"] { --burst: var(--heart-activity); }
.card-like[data-type="photo"]    { --burst: var(--heart-photo); }
.card-like[data-type="poll"]     { --burst: var(--heart-poll); }

/* 1. The snap. A hard, fast punch to 1.5× then a single undershoot and settle —
   no anticipation dip, so it reads sharp and dramatic rather than springy. Keeps
   the row's optical translateY nudge in every frame so the punch doesn't shift
   the glyph off its line. During the flood the svg's own fill is suppressed so
   only the outline holds while the ink rises; when .is-liking clears, the resting
   .liked fill takes over (same colour). The ink overlay (below) rides these SAME
   keyframes so the outline and its fill punch as one object. */
.card-like.is-liking .like-heart   { fill: none; animation: like-pop 0.42s var(--ease) both; }
.card-like.is-unliking .like-heart { animation: like-unpop 0.3s var(--ease-soft) both; }
@keyframes like-pop {
  0%   { transform: translateY(1.5px) scale(1); }
  20%  { transform: translateY(1.5px) scale(1.5); }
  48%  { transform: translateY(1.5px) scale(0.9); }
  100% { transform: translateY(1.5px) scale(1); }
}
@keyframes like-unpop {
  0%   { transform: translateY(1.5px) scale(1); }
  45%  { transform: translateY(1.5px) scale(0.8); }
  100% { transform: translateY(1.5px) scale(1); }
}

/* 2. The ink. A solid block of the post's colour, masked to the filled-heart
   silhouette, sitting exactly over the outline svg and clip-revealed from the
   bottom up — colour flooding into the glyph. It runs like-pop alongside ink-rise
   so it scales in lockstep with the outline (clip-path reveals the fill; the
   transform punches it). Holds full (`both`); when .is-liking clears, the svg's
   resting fill takes over seamlessly and this vanishes. Only the friend's
   icon-only heart ever animates, so the overlay is always centred in its 44px
   button. */
.card-like.is-liking::after {
  content: "";
  position: absolute;
  left: 50%; top: 50%;
  width: 28px; height: 28px;
  margin: -14px 0 0 -14px;
  transform: translateY(1.5px);
  background: var(--burst);
  -webkit-mask: var(--heart-mask) center / 100% 100% no-repeat;
          mask: var(--heart-mask) center / 100% 100% no-repeat;
  clip-path: inset(100% 0 0 0);
  pointer-events: none;
  animation: ink-rise 0.42s var(--ease) both, like-pop 0.42s var(--ease) both;
}
@keyframes ink-rise {
  from { clip-path: inset(100% 0 0 0); }
  to   { clip-path: inset(0 0 0 0); }
}

/* 3. The sparkle. A little cluster of y2k four-point stars twinkles out of the
   heart in the post's colour — each pops from nothing, spins a touch, drifts a
   hair outward, and winks away. JS injects the .like-sparkles layer on LIKE and
   pulls it once the burst is spent (see burstSparkles); every spark is a masked
   SVG star animating transform + opacity only (no blur), one-shot — nothing
   lingers, nothing loops. The heart lives in the card's bottom-right corner,
   ~18px from the paint-contained edge, so the offsets stay inside a ~16px radius
   and each spark fades fully to 0 before any tip reaches the boundary — the same
   trick the old ring used, so the graze is never visible. Per-spark position
   (--x/--y), size (--s), spin (--r) and stagger (animation-delay) come inline. */
.like-sparkles {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
}
/* Shared spark: a masked y2k star painted in the post's --burst colour. Position
   + motion are set per context — the like-tap burst flies out from the heart
   (below); the fresh-post burst floats up off the card (.post-sparkles). */
.spark {
  position: absolute;
  width: var(--s); height: var(--s);
  background: var(--burst);
  -webkit-mask: var(--spark-mask) center / 100% 100% no-repeat;
          mask: var(--spark-mask) center / 100% 100% no-repeat;
  opacity: 0;
  pointer-events: none;
}
.like-sparkles .spark {
  left: 50%; top: 50%;
  /* Two decoupled tracks so motion leads the light: the star is already flying
     outward while it's still fading up (position never waits on opacity, and
     never holds mid-flight). like-fly owns transform, spark-fade owns the
     opacity envelope; both start together on the same clock. */
  animation: like-fly 0.5s linear both, spark-fade 0.5s ease-in-out both;
}
/* Transform track: launch out from the heart (eased pop), then drift on
   (linear) — one continuous path, no hold. The per-keyframe timing functions
   keep the two segments seamless. Reach stays inside a ~16px radius while the
   star is lit (it's fading by the time it passes 1.1x), so the paint-contained
   corner never clips a visible tip. */
@keyframes like-fly {
  0%   { transform: translate(-50%, -50%) translate(calc(var(--x) * 0.12), calc(var(--y) * 0.12)) scale(0.2) rotate(0deg);
         animation-timing-function: cubic-bezier(0.15, 0.85, 0.3, 1); }
  30%  { transform: translate(-50%, -50%) translate(var(--x), var(--y))                           scale(1)   rotate(var(--r));
         animation-timing-function: linear; }
  100% { transform: translate(-50%, -50%) translate(calc(var(--x) * 1.3), calc(var(--y) * 1.3))   scale(0.4) rotate(calc(var(--r) + 18deg)); }
}
/* Opacity envelope, shared by both bursts: quick in, brief hold, gentle out.
   Runs on its own clock so the movement is well underway before the star peaks
   and keeps going while it fades — that's what makes the drift read as fluid. */
@keyframes spark-fade {
  0%   { opacity: 0; }
  18%  { opacity: 1; }
  52%  { opacity: 1; }
  100% { opacity: 0; }
}

/* ── Posted! celebration ─────────────────────────────────────────────────────
   The same y2k sparkle motif, now welcoming a fresh post: the moment you land
   back on the feed, the post you just made gets a sprinkle of stars that pop and
   float up off it, tinted to its own type colour. JS anchors a fixed .post-sparkles
   layer over the new card's box and pulls it once the burst is spent (see
   celebratePost) — a one-shot, transform+opacity only, no loop. Positions (left/
   top %), size (--s), spin (--r) and stagger (animation-delay) come inline. */
.post-sparkles {
  position: fixed;
  z-index: 300;
  pointer-events: none;
}
.post-sparkles[data-type="note"]     { --burst: var(--heart-note); }
.post-sparkles[data-type="find"]     { --burst: var(--heart-find); }
.post-sparkles[data-type="activity"] { --burst: var(--heart-activity); }
.post-sparkles[data-type="photo"]    { --burst: var(--heart-photo); }
.post-sparkles[data-type="poll"]     { --burst: var(--heart-poll); }
.post-sparkles .spark {
  /* Same decoupled tracks as the like burst: post-rise owns the upward drift,
     spark-fade owns opacity — so the star is already climbing as it fades in. */
  animation: post-rise 0.9s linear both, spark-fade 0.9s ease-in-out both;
}
/* Transform track: start a hair low, rise to rest (eased pop), then keep
   floating up (linear) — one continuous climb, never a hold. */
@keyframes post-rise {
  0%   { transform: translate(-50%, calc(-50% + 5px))  scale(0.2) rotate(-5deg);
         animation-timing-function: cubic-bezier(0.15, 0.85, 0.3, 1); }
  26%  { transform: translate(-50%, -50%)              scale(1)   rotate(var(--r));
         animation-timing-function: linear; }
  100% { transform: translate(-50%, calc(-50% - 24px)) scale(0.5) rotate(calc(var(--r) + 26deg)); }
}
@media (prefers-reduced-motion: reduce) {
  /* No motion: the fresh post just arrives (JS also skips spawning the layer). */
  .post-sparkles { display: none; }
}

/* ── Comment posted: a quieter echo ─────────────────────────────────────────
   Same y2k sparkle motif, dialed down for the lower-stakes moment of landing a
   comment: three stars instead of five-to-nine, smaller, a dimmer peak, gone
   quicker. JS anchors a .comment-sparkles layer over the new comment's avatar
   corner and pulls it once spent (see celebrateComment), tinted to the post's
   own type colour like the like/post bursts. */
.comment { position: relative; }
.comment-sparkles {
  position: absolute;
  left: 0; top: 0;
  width: 30px; height: 30px;
  pointer-events: none;
  z-index: 1;
}
.comment-sparkles[data-type="note"]     { --burst: var(--heart-note); }
.comment-sparkles[data-type="find"]     { --burst: var(--heart-find); }
.comment-sparkles[data-type="activity"] { --burst: var(--heart-activity); }
.comment-sparkles[data-type="photo"]    { --burst: var(--heart-photo); }
.comment-sparkles[data-type="poll"]     { --burst: var(--heart-poll); }
.comment-sparkles .spark {
  left: 50%; top: 50%;
  animation: like-fly 0.4s linear both, comment-spark-fade 0.4s ease-in-out both;
}
/* Same envelope shape as spark-fade, just a lower ceiling (0.7 not 1) — the
   dimmer peak is most of what reads as "more subtle" at this small size. */
@keyframes comment-spark-fade {
  0%   { opacity: 0; }
  18%  { opacity: 0.7; }
  52%  { opacity: 0.7; }
  100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .comment-sparkles { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  /* No motion: the heart just arrives filled — no rise, no sparkle. (JS already
     skips injecting the layer here; this hides it if one ever slips through.) */
  .card-like.is-liking .like-heart   { fill: currentColor; animation: none; }
  .card-like.is-unliking .like-heart { animation: none; }
  .card-like.is-liking::after { display: none; }
  .like-sparkles { display: none; }
}

/* Headcount + RSVP, one control (activities). Person-glyph + count, leading the
   social cluster and built like the comment count beside it — same 44px target,
   same 0.82rem bold number, growing leftward from the glyph's square. No
   background pill; the glyph carries the colour the way every other social glyph
   does. At rest it's muted like its neighbours (it's a tally you may join, not a
   standing claim); once you're in, it takes the activity ink. */
.card-attendees {
  justify-content: flex-start;
  gap: 0.4rem;
  padding-inline: 0.35rem 0.55rem;
  /* Sits a clear step off comment + like. The headcount is the plan; those two
     are gestures. Activities lost their two-ended row when this control merged,
     so this gap is what's left holding that distinction — and the glyph needs
     the air anyway, since its ink fills only the left half of the box (the right
     half is the check's landing space, see .check-arm). */
  margin-right: 0.5rem;
}
.card-attendees-count { font-size: 0.82rem; font-weight: 700; line-height: 1; }
/* Before you're in, the check's landing zone (the glyph's empty right half) sits
   between the person and the count, so the number reads detached. Pull it back
   across that zone so it rests the same short step off the person that it rests
   off the check once you've joined. Released in .going, where the check fills the
   zone and needs the room — the card is rebuilt on RSVP, so there's no jump. */
.card-attendees:not(.going) .card-attendees-count { margin-left: -0.55rem; }
.card-attendees:hover,
.card-attendees:focus-visible,
.card-attendees[aria-expanded="true"] { color: var(--accent); }

/* The check arm ships inside the glyph at every state and is dashed fully out
   until you're in — so joining DRAWS it on (below) instead of swapping icons,
   and the person never shifts to make room. State reads without colour (the arm
   is a shape change), which is what keeps the control legible to anyone who
   can't separate the grey from the green. */
.card-attendees .check-arm {
  stroke-dasharray: 1; stroke-dashoffset: 1;
  /* Belt AND braces: the dash trick above hides the arm only where pathLength is
     honoured. Safari ignores pathLength, so it paints the un-normalized dashes as
     a stray dotted remnant (a phantom "•" beside the person). visibility keeps it
     gone at rest regardless — the dash math still drives the draw once you're in. */
  visibility: hidden;
}
.card-attendees.going,
.card-attendees.going:hover,
.card-attendees.going:focus-visible,
.card-attendees.going[aria-expanded="true"] { color: var(--type-activity-ink); }
.card-attendees.going .check-arm { stroke-dashoffset: 0; stroke-width: 2.4; visibility: visible; }

/* RSVP reward — the moment you join, the check DRAWS itself onto the person
   (stroke walking its path; the arm carries pathLength=1 in the icon so the dash
   spans its real geometry) while the quintet gradient sweeps once across the
   count and SETTLES on green: the sweep ends with the pure activity-ink third of
   the gradient in view, so the class lift is invisible. The RSVP's own
   celebration verb, deliberately not another sparkle burst. */
.card-attendees.rsvp-inked .check-arm {
  animation: rsvp-draw 0.5s var(--ease) 0.1s both;
}
@keyframes rsvp-draw {
  from { stroke-dashoffset: 1; }
  to   { stroke-dashoffset: 0; }
}
.card-attendees.rsvp-inked .card-attendees-count {
  /* Green flat through 36%: at 300% size the visible window is a third, so the
     final frame (position 0%) is solid activity-ink, matching the resting state. */
  background: linear-gradient(115deg,
    var(--type-activity-ink) 0%, var(--type-activity-ink) 36%,
    var(--type-photo-ink) 52%, var(--type-find-ink) 68%,
    var(--type-poll-ink) 84%, var(--type-note-ink) 100%);
  background-size: 300% 100%;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation: rsvp-sweep 0.9s var(--ease-soft) both;
}
@keyframes rsvp-sweep {
  from { background-position: 100% 50%; }
  to   { background-position: 0% 50%; }
}
@media (prefers-reduced-motion: reduce) {
  .card-attendees.rsvp-inked .check-arm,
  .card-attendees.rsvp-inked .card-attendees-count { animation: none; }
}

/* Count tick — when a tap changes a count (raise a hand, add a comment), the
   new number rolls in: up when it grows, down when it shrinks. Transform +
   opacity only, so it's GPU-cheap on mobile and stops after one play. */
@keyframes count-tick-up {
  from { transform: translateY(0.6em); opacity: 0; }
  to   { transform: translateY(0);     opacity: 1; }
}
@keyframes count-tick-down {
  from { transform: translateY(-0.6em); opacity: 0; }
  to   { transform: translateY(0);      opacity: 1; }
}
.count-tick-up   { display: inline-block; animation: count-tick-up var(--dur-move) var(--ease); }
.count-tick-down { display: inline-block; animation: count-tick-down var(--dur-move) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .count-tick-up, .count-tick-down { animation: none; }
}

/* Odometer roll (odoTick in app.js) — where the OLD value is known (attendee and
   comment counts move by exactly one), the count becomes a one-glyph slot: the
   old number rolls out one way while the new one springs in the other, clipped
   to the line box. Polls keep the plain tick above (their numbers jump). */
.odo { position: relative; display: inline-block; overflow: hidden; }
.odo .odo-new { display: inline-block; }
.odo .odo-old { position: absolute; left: 0; top: 0; }
.odo--up .odo-new   { animation: count-tick-up 0.5s var(--spring) both; }
.odo--up .odo-old   { animation: odo-out-up 0.3s var(--ease-soft) both; }
.odo--down .odo-new { animation: count-tick-down 0.5s var(--spring) both; }
.odo--down .odo-old { animation: odo-out-down 0.3s var(--ease-soft) both; }
@keyframes odo-out-up {
  from { transform: translateY(0);       opacity: 1; }
  to   { transform: translateY(-0.9em);  opacity: 0; }
}
@keyframes odo-out-down {
  from { transform: translateY(0);       opacity: 1; }
  to   { transform: translateY(0.9em);   opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  /* odoTick itself bails under reduced motion; this is the CSS backstop. */
  .odo .odo-new { animation: none; }
  .odo .odo-old { display: none; }
}

/* Inline edit form — a card swapped for its editable fields. Inset to align with
   the text column; the actions row carries Cancel + Save together, pinned right
   (Delete moved out to the post's ••• menu). */
.card--editing { padding: 0.35rem var(--inset) 0.9rem; }
.card--editing.card--photo { padding-inline: var(--inset); }
.edit-form .field:first-child { margin-top: 0.35rem; }
.edit-actions {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 0.6rem;
  margin-top: 1.1rem;
}
/* Both commit buttons stay on screen — the quiet Cancel and the accent Save pill,
   the same pairing (and sizing) Edit profile's .modal-actions uses. Save dims to
   half while the form is untouched, so "nothing to save yet" reads without the
   button ever leaving, and backing out is always one tap away. */
.edit-actions .composer-submit {
  width: auto;
  margin: 0;
  padding: 0.6rem 1.4rem;
  /* Dirty state flips on every keystroke, so fade the dim rather than snapping it. */
  transition: background-color var(--dur-micro), opacity var(--dur-micro);
}
.edit-actions .composer-submit:disabled { opacity: 0.5; }

.edit-cancel {
  color: var(--muted);
  background: none;
  border: none;
  padding: 0.55rem 0.6rem;
  font-size: 0.9rem;
}
.edit-cancel:hover { color: var(--text); }

/* ── Byline (identity) ────────────────────────────────────────────────────── */
/* Flex row: the profile link on the left, the type-tag pushed to the right so it
   aligns with the identity. */
.byline {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.9rem;
  margin-bottom: 1rem;
}
.byline-link {
  display: flex;
  align-items: center;
  gap: 0.7rem;
  min-width: 0;
}

/* ── Type tag — small colored label (Note / Find / Photo) at the right of the
   byline, marking the entry's type. Replaces the old left type-rule; the colour
   is the type's own, as an outline pill with a faint matching wash. */
/* A single-colour glyph standing in for the old text pill. The SVG fills with
   currentColor, so the type's own colour comes from the class below. */
.type-icon {
  flex-shrink: 0;
  display: inline-flex;
  width: 1.15rem;
  height: 1.15rem;
  line-height: 0;
}
.type-icon svg { width: 100%; height: 100%; display: block; }
/* The ink twins: deeper, AA-legible on light paper; they flip back to the
   pastels in dark mode automatically, so one set of vars covers both. */
.type-icon--note     { color: var(--type-note-ink); }
.type-icon--poll     { color: var(--type-poll-ink); }
.type-icon--find     { color: var(--type-find-ink); }
.type-icon--photo    { color: var(--type-photo-ink); }
.type-icon--activity { color: var(--type-activity-ink); }
/* The note and photo glyphs read a hair smaller than the find/activity ones,
   so nudge them up to sit at the same visual size. */
.type-icon--note,
.type-icon--photo,
.type-icon--poll     { width: 1.35rem; height: 1.35rem; }
/* The poll burst fills its viewBox edge-to-edge, so it reads a touch heavier than
   the others; scale the glyph itself down a hair (works in every container — the
   feed byline and the composer's larger masthead mark alike). */
.type-icon--poll svg { transform: scale(0.87); }
/* An activity whose day has passed: the lime hands back to grey. */
.type-icon--past     { color: var(--muted); }
@media (hover: hover) { .byline-link:hover .byline-name { color: var(--muted); } }

.avatar {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  flex-shrink: 0;
  display: grid;
  place-items: center;
  background: var(--text);
  color: var(--bg);
  /* Photoless tiles show the initial in Instrument Serif — the app lettering —
     instead of the Oxygen block cap. */
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: 1.3rem;
  line-height: 1;
  user-select: none;
  overflow: hidden;
}
/* Photo variant — a real uploaded avatar fills the tile (same rounding/size). */
.avatar--photo { background: none; }
.avatar--photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.byline-text { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.byline-name { font-weight: 700; font-size: 0.95rem; line-height: 1.1; color: var(--text); }
.byline-meta {
  font-size: 0.8rem;
  color: var(--muted);
  letter-spacing: 0.02em;
}
.byline-meta .dot { opacity: 0.5; margin: 0 0.15rem; }

/* ── Headline + caption ───────────────────────────────────────────────────── */
.card-title {
  display: block;
  font-size: clamp(1.7rem, 2.6vw, 2.05rem);
  line-height: 1.14;
  letter-spacing: 0;
  color: var(--text);
  margin-bottom: 0.55rem;
}
.card-title a { color: inherit; transition: color var(--dur-micro); }
@media (hover: hover) { .card-title a:hover { color: var(--muted); } }
.card-title-ext { color: var(--muted); margin-left: 0.3rem; }
.card-title-ext svg { width: 0.62em; height: 0.62em; vertical-align: 0.02em; }

/* Finds read as links: the title — or, on an untitled find, the caption itself
   (see .card-note-link) — is underlined so the destination is never a guess. */
.card--find .card-title a,
.card-note-link {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.16em;
  text-decoration-color: color-mix(in srgb, currentColor 55%, transparent);
}
.card-note-link { display: block; color: inherit; transition: color var(--dur-micro); }
@media (hover: hover) { .card-note-link:hover { color: var(--muted); } }

/* Activity when/where-lines — a quiet calendar + day, then pin + place, tucked
   between title and details. Stacked lines pull together into one block. */
.card-location {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  color: var(--muted);
  font-size: 0.88rem;
  margin: -0.1rem 0 0.6rem;
}
.card-location + .card-location { margin-top: -0.35rem; }
/* Activities now stack where/when under the caption rather than the title —
   the caption (plain or clamped by Read-more) carries no bottom margin of its
   own, so give the first meta line room to breathe here instead. */
.card-note + .card-location, .readmore + .card-location { margin-top: 0.6rem; }
/* The where-line links out to maps. Same quiet look, underline on the place
   name so it reads as tappable, darkens on hover like other links. */
.card-location-link {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  color: inherit;
  transition: color var(--dur-micro);
}
.card-location-link span { text-decoration: underline; text-underline-offset: 2px; }
@media (hover: hover) { .card-location-link:hover { color: var(--text); } }
.card-location-ico { width: 14px; height: 14px; flex-shrink: 0; }

/* One size everywhere a caption/note appears — with a headline, under a photo,
   or carrying the entry alone (the one-liner). Consistency over emphasis. */
.card-note {
  font-size: var(--body);
  line-height: 1.5;
  color: var(--text);
  font-weight: 300;
}
.card-note:last-child { margin-bottom: 0; }
/* Paragraph rhythm when a note carries more than one block (long posts). */
.card-note + .card-note { margin-top: 0.7rem; }

/* ── Rich-note headings + emphasis (blog-style Notes) ──────────────────────────
   Headings are titles, so they stay in Instrument Serif (the "serif = titles"
   rule): H1 upright, a step under the post title; H2 the italic serif voice, a
   step under that. Inline bold/italic ride the Oxygen body — 700 reads loud
   against the 300 note weight. Same styles power the WYSIWYG editor below, so the
   feed and the field render identically. */
.card-h1 {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;                 /* match the post title (no synthetic bold) */
  font-size: clamp(1.4rem, 2.2vw, 1.62rem);
  line-height: 1.16;
  color: var(--text);
  margin: 0 0 0.4rem;
}
.card-h2 {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-style: italic;
  font-size: clamp(1.15rem, 1.8vw, 1.3rem);
  line-height: 1.2;
  color: var(--text);
  margin: 0 0 0.35rem;
}
/* A heading after any preceding block gets extra air above it, so sections
   breathe; a paragraph directly under its heading tucks in close. */
:where(.card-note, .card-h1, .card-h2) + .card-h1 { margin-top: 0.95rem; }
:where(.card-note, .card-h1, .card-h2) + .card-h2 { margin-top: 0.75rem; }
:where(.card-h1, .card-h2) + .card-note { margin-top: 0.3rem; }
.card-h1:last-child, .card-h2:last-child { margin-bottom: 0; }
.card-note strong, .card-note b { font-weight: 700; }
.card-note em, .card-note i { font-style: italic; }

/* ── Long notes — "Read more" ─────────────────────────────────────────────────
   The full note renders intact; when collapsed its height is clamped to a teaser
   and the copy below fades out (a mask — the text is never spliced, so it reads
   the same open or shut). "Read more" eases the clamp out to the real height
   (JS measures it — see wireReadMore). */
.readmore-clip {
  overflow: hidden;
  max-height: 8.6rem;                 /* ~six lines of teaser while collapsed */
  transition: max-height 0.42s var(--ease);
  touch-action: manipulation;         /* double-tap-to-collapse, not browser zoom */
  -webkit-mask-image: linear-gradient(180deg, #000 58%, transparent);
          mask-image: linear-gradient(180deg, #000 58%, transparent);
}
/* Opened: the fade lifts at once so the text is crisp as it grows; max-height is
   driven inline by JS during the tween, then released to none at its end. */
.readmore.open .readmore-clip {
  max-height: none;                   /* restored-open cards (no inline height) */
  -webkit-mask-image: none;
          mask-image: none;
}

.readmore-toggle {
  font-family: 'Oxygen', sans-serif;
  font-size: 0.8rem;
  color: var(--accent);
  background: none;
  border: none;
  padding: 0;
  margin-top: 0.6rem;
  cursor: pointer;
  transition: opacity var(--dur-micro);
}
@media (hover: hover) { .readmore-toggle:hover { text-decoration: underline; } }
@media (prefers-reduced-motion: reduce) {
  .readmore-clip { transition: none; }
}

/* ── Tags ──────────────────────────────────────────────────────────────── */
/* Tag pills are airy outlines, so they read taller than they measure; the
   tightened top margin plus a slightly shorter step down to the action row
   keeps a tagged post's density matching an untagged one. */
.tags { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 1rem; }
.tags + .card-actions { margin-top: 0.4rem; }
.tag {
  font-family: 'Oxygen', sans-serif;
  font-size: 0.76rem;
  color: var(--muted);
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  padding: 0.16rem 0.55rem;
  background: none;
  transition: color var(--dur-micro), border-color var(--dur-micro);
}
.tag.active { color: var(--accent); border-color: var(--accent); }
@media (hover: hover) {
  .tag:hover { color: var(--accent); border-color: var(--accent); }
}

/* ── Comments — a quiet thread nested under the entry ─────────────────────────
   Its content lines up with the post's own text column; a hairline rule in the
   left gutter (like a reply thread) sets it apart without pushing it inward. The
   panel reveals with the site's easing — a grid-rows 0fr→1fr height tween plus an
   opacity lift — so it eases open rather than snapping; `.comments-inner` clips
   the collapsing content. */
.comments-panel,
.likers-panel,
.going-panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  padding-left: 0.7rem;              /* the rule lives here, in the left gutter */
  padding-right: var(--inset);
  transition: grid-template-rows var(--dur-move) var(--ease),
              opacity 0.26s ease;
}
.comments-panel.open,
.likers-panel.open,
.going-panel.open { grid-template-rows: 1fr; opacity: 1; }
/* The grid child carries no padding/border — that would stop it collapsing to a
   true 0 height. Spacing + the nesting rule sit on .comments-content within it;
   its left padding realigns the content to the post text column (--inset). */
.comments-inner { overflow: hidden; min-height: 0; }
.comments-content {
  /* No left rule — the thread nests under the post on the text column. Even
     vertical padding gives the rows breathing room top and bottom; the left
     padding pulls content back to the --inset text axis (the panel's 0.7rem
     gutter, below, is absorbed here). */
  padding: 0.5rem 0 0.5rem calc(var(--inset) - 0.7rem);
}
@media (prefers-reduced-motion: reduce) {
  .comments-panel, .likers-panel, .going-panel { transition: none; }
}

/* Who-liked list: same avatar + name row as a comment, minus the body text. */
.likers-list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}
.liker .comment-body { display: flex; align-items: center; min-height: 26px; }
.likers-empty { font-size: 0.85rem; color: var(--muted); }

/* "Can't make it" — the way back out of a plan, at the FOOT of the who's-going
   list rather than on the card. Joining is one tap; leaving costs a deliberate
   second one, because the host planned around this headcount. A quiet muted row,
   warming to the delete red only on approach — it's a change of plans, not a
   destruction, so it never sits red at rest. No rule above it: the list of names
   ends and the muted row reads as a different kind of thing on its own. The 26px
   glyph column lines it up with the avatars above. */
.going-out {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  width: 100%;
  margin-top: 0.7rem;
  padding: 0 0 0.1rem;
  border: none;
  border-radius: var(--radius);
  background: none;
  font: inherit;
  font-size: 0.86rem;
  color: var(--muted);
  cursor: pointer;
  transition: color var(--dur-micro);
}
.going-out svg { width: 26px; height: 26px; flex-shrink: 0; }
.going-out:active { color: var(--type-find-ink); }
@media (hover: hover) { .going-out:hover { color: var(--type-find-ink); } }

/* Reset the list's default padding/bullets so rows align to the thread's edge. */
.comments-list {
  list-style: none;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  margin-bottom: 0.85rem;
}
.comment { display: flex; align-items: flex-start; gap: 0.55rem; }
.comment-avatar { width: 26px; height: 26px; font-size: 0.86rem; border-radius: 50%; }
.comment-avatar-link { flex-shrink: 0; margin-top: 0.05rem; }
.comment-body { min-width: 0; }
.comment-text { font-size: 0.88rem; line-height: 1.4; color: var(--text); }
.comment-name { font-weight: 700; color: var(--text); }
@media (hover: hover) { .comment-name:hover { color: var(--muted); } }
.comment-meta { font-size: 0.73rem; color: var(--muted); margin-top: 0.1rem; }
.comment-meta .dot { opacity: 0.5; margin: 0 0.15rem; }
/* Delete — the same trash glyph and muted→red treatment as the post delete
   control, right-aligned to the thread edge, aligned to the comment's top line. */
.comment-delete {
  flex-shrink: 0;
  margin-left: auto;
  margin-top: 0.05rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  color: var(--muted);
  background: none;
  border: none;
  border-radius: var(--radius-pill);
  transition: color var(--dur-micro), background var(--dur-micro);
}
.comment-delete svg { width: 16px; height: 16px; }
@media (hover: hover) {
  .comment-delete:hover, .comment-delete:focus-visible {
    color: var(--type-find-ink);
    background: color-mix(in srgb, var(--type-find) 18%, var(--bg));
  }
}

/* Bottom-aligned so the Post button hugs the last line as the box grows. */
.comment-form { display: flex; align-items: flex-end; gap: 0.7rem; }
.comment-form textarea {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 16px;          /* keep ≥16px so iOS doesn't auto-zoom on focus */
  line-height: 1.4;
  color: var(--text);
  background: none;
  border: none;
  border-bottom: 1px solid var(--rule);
  padding: 0.28rem 0;
  /* One line to start; JS grows the height to fit, so a long comment wraps into
     view instead of scrolling off the end. No manual drag handle. */
  resize: none;
  overflow: hidden;
  /* Ease the grow/shrink as lines wrap in and out, so the box breathes rather
     than jumping a line at a time. */
  transition: border-color var(--dur-micro), height var(--dur-quick) var(--ease);
}
@media (prefers-reduced-motion: reduce) {
  .comment-form textarea { transition: border-color var(--dur-micro); }
}
.comment-form textarea::placeholder { color: var(--muted); opacity: 0.7; }
.comment-form textarea:focus { outline: none; border-color: var(--accent); }
.comment-form button {
  position: relative;
  flex-shrink: 0;
  color: var(--accent);
  background: none;
  border: none;
  font-weight: 700;
  font-size: 0.88rem;
  padding: 0.28rem 0.1rem;
  transition: opacity var(--dur-micro);
}
/* Stretch the tap target to the 44px HIG minimum without bloating the flat row:
   an invisible overlay centred on the small label carries the hit area. */
.comment-form button::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 100%;
  height: 100%;
  min-width: 44px;
  min-height: 44px;
}
/* Empty field → the Post button is truly disabled (dimmed and inert), so it
   never looks tappable while doing nothing. JS flips `disabled` on input. */
.comment-form button:disabled { opacity: 0.5; cursor: default; }

/* ── @mentions ─────────────────────────────────────────────────────────────
   A tag renders as the friend's display name, bold in the running text (not
   dressed as a link); the picker is a quiet listbox tucked under the field. */
/* Italic sets a tagged name apart from the author's own voice — a leading "@Name"
   no longer reads like the byline (which is bold roman). */
.mention { font-weight: 700; font-style: italic; color: var(--text); text-decoration: none; }
@media (hover: hover) { a.mention:hover { color: var(--muted); } }

/* Floating autocomplete popover (mentions + location). A frosted-glass menu in
   the same material as the nav pill and glass modals, menu-scaled: the content
   behind it saturates through the blur, a hairline border and lit rim catch the
   edge, and a soft float shadow lifts it off the field. 14px sits between the
   12px composer inputs it hovers over and the 20px glass cards — a coherent step
   in the corner scale. */
.mention-list {
  list-style: none;
  margin: 0.4rem 0 0;
  padding: var(--pad-panel);
  max-height: 210px;
  overflow-y: auto;
  /* Panel tier: the composer behind it is not going anywhere while you pick a
     name, so this samples once and holds — it can carry the deep blur. */
  background: var(--glass-bg-panel);
  -webkit-backdrop-filter: var(--glass-filter-panel);
  backdrop-filter: var(--glass-filter-panel);
  border: var(--glass-edge);
  border-radius: var(--radius-card);
  box-shadow: var(--glass-rim), var(--glass-lift-panel);
}
.mention-list [role="option"] {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0.35rem 0.5rem;
  /* Concentric with the panel: 14px minus the 0.3rem pad. A 3px row inside a
     14px menu bent visibly tighter than the corner it sat in. */
  border-radius: var(--radius-row);
  cursor: pointer;
  font-size: 0.88rem;
}
.mention-list [role="option"].active,
.mention-list [role="option"]:hover { background: var(--surface-2); }
.mention-opt-name { font-weight: 700; color: var(--text); }
.mention-opt-handle { color: var(--muted); font-size: 0.8rem; }

/* Location suggestions reuse the mention listbox, but each row stacks a bold
   place name over its muted address. */
.loc-list [role="option"] { flex-direction: column; align-items: flex-start; gap: 0.05rem; }
.loc-opt-name { font-weight: 700; color: var(--text); }
.loc-opt-detail { color: var(--muted); font-size: 0.8rem; }

/* ── Action sheet — the ••• post menu and the friend menu (Remove/Block/Report).
   A floating glass panel that rises from the bottom over a dim scrim. Glass per
   the material rule (a menu floats above content); same frosted recipe as the
   mention popover, sheet-scaled. On wide screens it caps its width and still docks
   to the bottom, iOS-style. ─────────────────────────────────────────────────── */
.sheet-scrim {
  position: fixed; inset: 0; z-index: 300;
  display: flex; align-items: flex-end; justify-content: center;
  padding: 0.7rem;
  padding-bottom: calc(0.7rem + env(safe-area-inset-bottom));
  background: color-mix(in srgb, #000 34%, transparent);
  opacity: 0;
  transition: opacity 0.22s var(--ease-soft);
}
.sheet-scrim.open { opacity: 1; }
.sheet {
  width: 100%; max-width: 440px;
  display: flex; flex-direction: column; gap: 0.5rem;
  transform: translateY(14px);
  transition: transform 0.22s var(--ease-soft);
}
.sheet-scrim.open .sheet { transform: none; }
.sheet-items, .sheet-cancel {
  /* Panel tier: the page is frozen under the scrim while a sheet is open, so the
     blur samples once and then sits there. Nothing about this is scroll-hot. */
  background: var(--glass-bg-panel);
  -webkit-backdrop-filter: var(--glass-filter-panel);
  backdrop-filter: var(--glass-filter-panel);
  border: var(--glass-edge);
  border-radius: var(--radius-modal);
  box-shadow: var(--glass-rim), var(--glass-lift-panel);
}
.sheet-title {
  text-align: center; font-size: 0.8rem; color: var(--muted);
  padding: 0.7rem 1rem 0.1rem; margin: 0;
}
.sheet-items { overflow: hidden; padding: var(--pad-panel); }
.sheet-item {
  display: flex; align-items: center; gap: 0.7rem; width: 100%;
  padding: 0.85rem 0.9rem; border: none; background: none; cursor: pointer;
  font: inherit; font-size: 0.98rem; color: var(--text); text-align: left;
  /* Nests inside the panel's 20px minus its 0.3rem padding — an 8px row inside a
     20px sheet read as a different corner language. Now derived rather than
     eyeballed, so the pair stays concentric if either value moves. */
  border-radius: var(--radius-row-lg);
  -webkit-tap-highlight-color: transparent;
}
.sheet-item + .sheet-item { position: relative; }
.sheet-item + .sheet-item::before {
  content: ""; position: absolute; left: 0.9rem; right: 0.9rem; top: 0;
  border-top: 1px solid var(--rule);
}
/* No fill on tap: the press engine's compression + spring release IS the tap
   feedback here, and an opaque --surface-2 block punched a hole in the frost.
   Hover keeps a whisper of a tint so a pointer still knows which row it's on —
   translucent, so the glass reads through it. */
@media (hover: hover) { .sheet-item:hover { background: color-mix(in srgb, var(--text) 6%, transparent); } }
.sheet-item:focus-visible,
.sheet-cancel:focus-visible { outline: none; box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--text) 30%, transparent); }
.sheet-ico { width: 20px; height: 20px; flex-shrink: 0; color: var(--muted); }
.sheet-item--danger { color: var(--type-find-ink); }
.sheet-item--danger .sheet-ico { color: currentColor; }
.sheet-cancel {
  padding: 0.9rem; border: none; cursor: pointer;
  font: inherit; font-size: 0.98rem; font-weight: 700; color: var(--text);
  -webkit-tap-highlight-color: transparent;
}
/* Tint as a background-IMAGE layer: Cancel is its own glass panel, so restating
   `background` here would blow away its frosted fill. */
@media (hover: hover) {
  .sheet-cancel:hover {
    background-image: linear-gradient(color-mix(in srgb, var(--text) 6%, transparent),
                                      color-mix(in srgb, var(--text) 6%, transparent));
  }
}
/* No dark-mode block here any more: fill, rim and lift are all tokens now, and
   the tokens flip themselves. Same for the mention popover and the modal card. */
@media (prefers-reduced-motion: reduce) {
  .sheet-scrim, .sheet { transition: none; }
  .sheet { transform: none; }
  /* The press engine sits out under reduced motion, so the tint comes back as
     the tap confirmation — still translucent, still the row's own radius. */
  .sheet-item:active { background: color-mix(in srgb, var(--text) 8%, transparent); }
  .sheet-cancel:active {
    background-image: linear-gradient(color-mix(in srgb, var(--text) 8%, transparent),
                                      color-mix(in srgb, var(--text) 8%, transparent));
  }
}

/* ── Blocked wall — a blocked person's profile in place of their content ────── */
.blocked-wall {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  gap: 0.6rem; padding: 3rem 1.5rem;
}
.blocked-mark {
  width: 3rem; height: 3rem; color: var(--muted); opacity: 0.7;
  display: flex; align-items: center; justify-content: center; margin-bottom: 0.3rem;
}
.blocked-mark svg { width: 100%; height: 100%; }
.blocked-name { font-size: 1.4rem; margin: 0; }
.blocked-note { color: var(--muted); max-width: 30ch; margin: 0 0 0.6rem; line-height: 1.5; }
.blocked-unblock {
  padding: 0.6rem 1.4rem; border-radius: 999px; cursor: pointer;
  font: inherit; font-weight: 700; color: var(--text);
  background: var(--surface-2); border: 1px solid var(--rule);
}
@media (hover: hover) { .blocked-unblock:hover { background: var(--surface-3, var(--surface-2)); } }

/* ── Photo entries — identity + caption lead, full-bleed image last ────────── */
/* The image keeps its own aspect ratio (no forced 1:1 crop); the figure sizes to
   whatever was uploaded, tall or wide. */
.photo {
  display: block;
  position: relative;
  width: 100%;
  cursor: zoom-in;
  padding-inline: var(--inset);   /* inset to the post-text column, like the byline */
}
/* ── Placeholder frame + settle ────────────────────────────────────────────────
   The reserved box + placeholder live on .photo-frame, never on the <img>: the
   image is held at opacity 0 until its bitmap is DECODED (JS adds .is-loaded — see
   wirePhoto), so any stand-in painted on the image itself is hidden too, leaving a
   wall of page background until the photo pops. The frame is always visible: it
   reserves the photo's real aspect ratio (inline `aspect-ratio` from the stamped
   size) and draws a rounded hairline outline — a crop preview — over a soft fill
   (the photo's own average colour via --ph-fill, else the neutral surface). The
   decoded image then eases in on a plain scale + opacity settle: no blur, nothing
   composited beyond one transform. The outline retires once the photo covers it.
   Warm (pre-decoded) photos get .is-loaded before first paint, so they just appear
   — no fade to replay on every navigation. */
.photo-frame {
  position: relative;
  display: block;
  width: 100%;
  border-radius: var(--radius-card);
  background: var(--ph-fill, var(--surface-2));
  box-shadow: inset 0 0 0 1px var(--rule);
}
/* Legacy photos carry no stamped size, so we can't reserve the exact box. Hold a
   3:2 outline until the image loads (JS drops this class on load, letting the
   frame take the real height) so the feed doesn't jump. */
.photo-frame--reserve { aspect-ratio: 3 / 2; }
.photo-frame img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-card);   /* self-clip: Safari clips a plain <img>'s own corners cleanly */
  opacity: 0;
  transform: scale(1.02);
  transform-origin: center;
  transition: opacity var(--dur-move) var(--ease), transform var(--dur-move) var(--ease);
}
/* Past the 5:4 ceiling (see capFrame): the frame holds the capped box and the
   image covers it from the centre instead of setting its own height. Kills the
   column-eating phone screenshot, and the black bars on a letterboxed grab are
   the first thing the crop takes. Tap still opens the full photo in the
   lightbox — the crop is a feed manner, not a destructive edit. */
.photo-frame--crop img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.photo.is-loaded .photo-frame img { opacity: 1; transform: none; }
.photo.is-loaded .photo-frame { background: none; box-shadow: none; }

@media (prefers-reduced-motion: reduce) {
  .photo-frame img { opacity: 1; transform: none; transition: none; }
}
/* Byline + caption inset to align with post text; the image is full-width. The
   type reads from the green Photo tag in the byline, like posts and finds. */
.card--photo .byline { padding-inline: var(--inset); }
.card--photo .card-foot { padding: 0 var(--inset) 1rem; }
.card--photo .tags { margin-top: 0.6rem; }

/* ── Frame video (feed playback) ────────────────────────────────────────────
   The poster reveal above is untouched (wireFrameVideo calls the same
   revealCardImage a still uses); this just layers a <video> + controls over
   it once wireFrameVideo's IntersectionObserver inserts one. A <video> is its
   own compositing layer and doesn't self-clip its corners the way a plain
   <img> does (the same Safari bug that forced the blur-up removal), so this
   case alone gets a real overflow:hidden wrapper — stills keep the existing
   self-clip path untouched. */
.frame-video .photo-frame { overflow: hidden; }
.frame-clip {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The clip is inserted as the LAST child of .photo-frame, so without a z-index
     it paints over the sound chip + progress bar (both earlier siblings) the whole
     time it plays — hiding them AND swallowing their taps (the sound toggle read
     as dead because the click landed on the video). Pin the clip below the
     controls: like the lightbox's native controls, ours stay above the picture. */
  z-index: 1;
}
/* Small floating icon controls over the clip are chrome ABOVE content, not
   content themselves, so they wear the same frosted-chip material as the nav
   dial's icons rather than a flat scrim. They sit above .frame-clip (z-index:1)
   so they're visible and tappable while the clip plays, not just before/after. */
.frame-sound,
.frame-play,
.frame-progress { z-index: 2; }
.frame-sound,
.frame-play {
  position: absolute;
  display: grid;
  place-items: center;
  border-radius: 50%;
  color: #fff;
  border: var(--glass-edge);
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur));
  -webkit-backdrop-filter: blur(var(--glass-blur));
  box-shadow: var(--glass-rim), var(--glass-lift);
}
.frame-sound {
  right: 0.7rem;
  bottom: 0.7rem;
  width: 34px;
  height: 34px;
  padding: 0;
  /* A solid dark scrim, not the translucent --bg tint the play badge wears: a white
     speaker on a light video frame was near-invisible (why the control read as
     "missing"). Dark reads on any frame, light or dark, so the mute/unmute toggle
     is unmistakable right on the post — no tap-through required. */
  background: rgba(0, 0, 0, 0.5);
  border-color: rgba(255, 255, 255, 0.22);
}
.frame-sound-ico { width: 17px; height: 17px; }
@media (hover: hover) { .frame-sound:hover { background: rgba(0, 0, 0, 0.62); } }
/* 42px, not the 52 this shipped at. The feed plays one clip at a time on purpose
   (iOS caps concurrent decoders), so every video that isn't the active one wears
   this badge permanently — it's standing chrome on most video cards, not a flash
   before playback. At 42 it reads as an invitation rather than a lid on the
   photograph, and it still earns its place: under reduced motion nothing autoplays
   ever, and iOS Low Power Mode refuses too, so on those frames the badge is the
   only cue that a tap does anything. */
.frame-play {
  top: 50%;
  left: 50%;
  width: 42px;
  height: 42px;
  transform: translate(-50%, -50%);
  pointer-events: none;
  transition: opacity var(--dur-quick) var(--ease);
}
/* No margin here — the triangle's optical nudge lives in ICONS.play's own path. */
.frame-play-ico { width: 18px; height: 18px; }
.frame-video--playing .frame-play { opacity: 0; }
/* A 2px cyan hairline pinned to the frame's bottom edge — information, not
   motion, so it stays even under reduced motion (the clip just never advances
   it in that case). */
.frame-progress {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2px;
  background: rgba(0, 0, 0, 0.18);
}
.frame-progress-fill {
  display: block;
  height: 100%;
  width: 0%;
  background: var(--type-photo);
}

/* ── Lightbox ──────────────────────────────────────────────────────────── */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 2rem;
  background: rgba(0, 0, 0, 0.85);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  cursor: zoom-out;
  opacity: 0;
  visibility: hidden;
  transition: opacity var(--dur-quick), visibility var(--dur-quick);
}
.lightbox.open { opacity: 1; visibility: visible; }
.lightbox img,
.lightbox video { max-width: 92vw; max-height: 90vh; border-radius: var(--radius-img); }
.lightbox video { background: #000; }

/* ── Pull-to-refresh indicator ─────────────────────────────────────────────
   The quintet, worn small: five dots, one per post type, in FILTERS order,
   arranged on a ring. It rides the iOS rubber band down from under the status
   bar — JS (the ptr module) drives --ptr-y/--ptr-p straight from the overscroll,
   so while the finger is down there's no transition and the bounce IS the easing.

   The pull's progress is the RADIUS. At rest the five sit stacked on top of each
   other, reading as one small object; as you pull, the ring opens out of that
   point until the five are distinct and evenly spaced, and a fully open ring IS
   the "let go now" signal — no separate pop needed, the gesture and the colour do
   the telling. Then the whole ring turns while the re-pull goes out.

   A turning ring rather than the row-of-dots wave it started as: five dots
   counting left to right is a progress bar's gesture, and this is an indefinite
   wait, which every platform draws as something going round. The ring reads as an
   ordinary spinner at a glance and is still unmistakably the quintet up close,
   which the wave never quite was.

   `linear`, not --ease: a spinner with eased rotation lurches once per turn, and
   the eye reads the lurch as the app stalling. Flat pastel, NOT the lit dome (the
   dome is reserved for the two hero commit buttons, and at 7px a specular hotspot
   is mud anyway). Floats above content but never glass — it's a signal, not a
   surface. */
.ptr {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 18px);
  left: 50%;
  z-index: 60;
  margin-left: -22px;
  width: 44px;
  height: 44px;
  /* Stacked at a 2px radius, open at 12px — a 24px ring, 31px across the dots. */
  --ptr-r: calc(2px + 10px * var(--ptr-p, 0));
  pointer-events: none;
  opacity: 0;
  transform: translateY(0);
  transition: opacity var(--dur-quick) var(--ease), transform var(--dur-quick) var(--ease);
}
.ptr--show {
  opacity: var(--ptr-p, 0);
  transform: translateY(var(--ptr-y, 0px));
  transition: none;   /* follow the rubber band raw */
}
.ptr--spin {
  opacity: 1;
  transform: translateY(44px);
  --ptr-r: 12px;      /* hold it open for the whole wait, whatever the pull ended at */
  transition: opacity var(--dur-quick) var(--ease), transform var(--dur-quick) var(--ease);
}
/* The ring turns; .ptr holds the drop. Two elements because they're two
   different transforms on one object, and an animation on .ptr would win over
   the transition that settles it into place, snapping the drop instead of
   easing it. */
.ptr-ring { position: absolute; inset: 0; }
.ptr-dot {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 7px;
  height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  /* A soft contact shadow only — enough to lift the dot off the page without
     pretending to be a 3D object at this size. */
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
  /* Out to the radius along its own spoke. Five spokes, 72° apart, first at 12
     o'clock. */
  transform: rotate(var(--a)) translateY(calc(-1 * var(--ptr-r)));
  transition: transform var(--dur-quick) var(--ease);
}
/* The quintet, in FILTERS order: note, find, photo, activity, poll. */
.ptr-dot:nth-child(1) { --a:   0deg; background: var(--type-note); }
.ptr-dot:nth-child(2) { --a:  72deg; background: var(--type-find); }
.ptr-dot:nth-child(3) { --a: 144deg; background: var(--type-photo); }
.ptr-dot:nth-child(4) { --a: 216deg; background: var(--type-activity); }
.ptr-dot:nth-child(5) { --a: 288deg; background: var(--type-poll); }
/* Under the finger the radius tracks the rubber band with no smoothing, same
   reason .ptr--show kills its own transition. Release hands it back (the rule
   below, later in source order, wins on equal specificity) so the ring settles
   open rather than jumping. */
.ptr--show .ptr-dot { transition: none; }
.ptr--spin .ptr-dot { transition: transform var(--dur-quick) var(--ease); }

.ptr--spin .ptr-ring { animation: ptr-turn 0.9s linear infinite; }
@keyframes ptr-turn {
  to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
  /* Keep the feature and the colour, drop the travel: the ring still opens with
     the pull (that's information, not decoration) and simply holds, open and
     still, while the refresh runs. */
  .ptr--spin .ptr-ring { animation: none; }
}

/* The page holds open under the ring for as long as the re-pull runs, the way a
   UIRefreshControl does. Without it the ring lands in the only place the layout
   has nothing to spare: the rubber band snaps shut the instant you let go, so
   the ring drops out of the open space it was pulled into and comes to rest
   ON the masthead, its lower edge overlapping the serif by a few points. It
   stopped reading as a thing in front of the page and started reading as a
   sticker stuck to it.

   The space belongs to the gesture, not to the ring — a backing plate behind the
   dots would give the same clearance, but the ring is a signal and not a
   surface, and every surface in this app that floats is glass. So the page moves
   instead.

   Height on main::before rather than padding on main: it isolates the change
   from main's own box (which the sidebar breakpoints write to) so the two can't
   fight, and a transform is out of the question — it would make main a
   containing block for the fixed seg-tabs docked inside it on Updates.

   Transitioned IN as well as out, which is the counter-intuitive half. Instant
   would be correct if the page were still, but it isn't: the band is snapping
   back from -72px or further at exactly the same moment. Ramping both means the
   content barely travels at all (the opening space and the closing band very
   nearly cancel) and simply settles where the finger left it, where an instant
   open makes it drop the full band and haul itself back up. */
main::before {
  content: "";
  display: block;
  height: 0;
  transition: height var(--dur-move) var(--ease);
}
/* The ring's own box plus a gutter. */
body.ptr-hold main::before { height: 56px; }
@media (prefers-reduced-motion: reduce) {
  main::before { transition: none; }
}

/* ── Auth gate (setup / login) ───────────────────────────────────────────────
   Shown before sign-in. The whole chrome (sidebar + nav) is hidden; a single
   calm, centered column carries the wordmark, the form, and a live avatar. */
body.gate .topbar,
body.gate .nav { display: none; }
body.gate main { padding: 0; }

.auth {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* Top padding clears the fixed .auth-topbar (safe-area + its stacked height). */
  padding: calc(env(safe-area-inset-top) + 5.4rem) 1.5rem calc(2rem + env(safe-area-inset-bottom));
}
/* Auto margins centre the card when there's room, but collapse when the form is
   taller than the screen — so the top never gets clipped above the scroll and
   everything stays reachable (the flex `justify-content:center` clipping trap). */
.auth-card { width: 100%; max-width: 352px; margin-block: auto; }
.auth-brand {
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: 2.4rem;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--text);
}
.auth-tag {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-style: italic;
  color: var(--muted);
  font-size: 1.18rem;
  margin-top: 0.5rem;
}
.auth-head {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: 2.5rem;
  line-height: 1.04;
  letter-spacing: -0.01em;
  color: var(--text);
  margin-top: 2.5rem;
}

.field { margin-top: 1.1rem; }
.field label {
  display: block;
  font-size: 0.82rem;
  color: var(--muted);
  margin-bottom: 0.35rem;
  letter-spacing: 0.02em;
}
.field input,
.field textarea {
  width: 100%;
  font: inherit;
  font-weight: 400;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius-img);
  padding: 0.7rem 0.85rem;
  transition: border-color var(--dur-micro);
}
.field textarea { resize: vertical; line-height: 1.5; min-height: 3rem; }
.field input::placeholder,
.field textarea::placeholder { color: var(--muted); opacity: 0.6; }
.field input:focus,
.field textarea:focus { outline: none; border-color: var(--accent); }
.field-hint { font-size: 0.75rem; color: var(--muted); margin-top: 0.35rem; }
/* The Note composer's link nudge (see wireFindNudge): a hint line whose action
   reads like the auth screens' inline text buttons. */
.find-nudge button {
  background: none;
  border: none;
  padding: 0;
  color: var(--accent);
  font: inherit;
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* Combined field: a headline input and its note stacked in one bordered box,
   split by a hairline divider, so the pair reads as a single cleaner block. The
   inner controls shed their own borders; the box carries the frame + focus ring. */
.field--combo {
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius-img);
  overflow: hidden;
  transition: border-color var(--dur-micro);
}
.field--combo:focus-within { border-color: var(--accent); }
.field--combo .combo-title,
.field--combo .combo-note {
  width: 100%;
  border: none;
  border-radius: 0;
  background: none;
  color: var(--text);
  font: inherit;
  padding: 0.7rem 0.85rem;
}
/* The headline previews in the serif it posts as (titles are always Instrument
   Serif, weight 400) — so the composer reads true to the feed, matching the live
   H1/H2 in the note body below. Applies to every combo type, since a note, find,
   and activity headline all render serif. */
.field--combo .combo-title {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: 1.55rem;
  line-height: 1.2;
}
.field--combo .combo-title:focus,
.field--combo .combo-note:focus { outline: none; border-color: transparent; }
.field--combo .combo-note {
  display: block;
  resize: vertical;
  line-height: 1.5;
  min-height: 3.4rem;
}
/* The Activity composer's Details box is the only .combo-note without the Note
   editor's .rich-note class riding along — give it the same floor as the Note
   body (5.5rem) so the two composer types' main field read as one size. */
.composer .field--combo:not(.field--rich) .combo-note {
  min-height: 5.5rem;
}
.combo-divider { height: 1px; background: var(--rule); margin: 0 0.85rem; }
/* Holds the crop/trim preview once media lands — no padding of its own, so the
   empty box collapses while the upload dropzone (above it) is showing. */
.combo-frame { display: flex; flex-direction: column; gap: 12px; }
/* Signup identity combo: the @handle rides in the note slot, a single line with a
   muted @ prefix (rather than the tall textarea a real note uses). */
.combo-user { display: flex; align-items: center; padding: 0 0.85rem; }
.combo-user .at { color: var(--muted); flex-shrink: 0; }
.field--combo .combo-userinput {
  flex: 1;
  min-width: 0;
  border: none;
  border-radius: 0;
  background: none;
  color: var(--text);
  font: inherit;
  padding: 0.62rem 0.2rem;
}
.field--combo .combo-userinput:focus { outline: none; }
.field-hint--combo { margin-top: 0.4rem; }

/* ── Rich Note editor (contenteditable) + its formatting toolbar ───────────────
   The body is a contenteditable that renders headings/emphasis live (WYSIWYG) —
   it reuses the same serif heading + Oxygen emphasis styles as the feed, so what
   you type is what posts. The toolbar rides in a collapsible panel below the
   title, opened by the "Aa" toggle — most posts are short and never need it,
   so it stays out of the way by default; its glyphs are set as specimens (each
   in the style it applies). */
.rich-title-row { display: flex; align-items: center; gap: 0.3rem; }
.rich-title-row .combo-title { width: auto; flex: 1; min-width: 0; }
/* Grid-rows 0fr→1fr glide (matches .about-fold-panel/.aud-list-wrap). */
.rich-toolbar-panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows var(--dur-move) var(--ease), opacity 0.26s ease;
}
.rich-toolbar-panel.is-open { grid-template-rows: 1fr; opacity: 1; }
.rich-toolbar-inner { overflow: hidden; min-height: 0; }
@media (prefers-reduced-motion: reduce) {
  .rich-toolbar-panel { transition: none; }
}
.field--rich .rich-note {
  position: relative;
  min-height: 5.5rem;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.rich-note:focus { outline: none; }
/* Placeholder for an empty field — pulled out of flow so the caret sits at the
   true start (matches the .combo-note padding). Shown via .is-empty from JS,
   since a contenteditable is rarely :empty (it keeps stray <br>s). */
.rich-note.is-empty::before {
  content: attr(data-placeholder);
  position: absolute;
  left: 0.85rem;
  top: 0.7rem;
  color: var(--muted);
  opacity: 0.6;
  pointer-events: none;
}
.rich-note h1 {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: 1.5rem;
  line-height: 1.2;
  margin: 0.1rem 0 0.3rem;
}
.rich-note h2 {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-style: italic;
  font-size: 1.25rem;
  line-height: 1.2;
  margin: 0.1rem 0 0.3rem;
}
.rich-note p { margin: 0 0 0.45rem; }
.rich-note > :last-child { margin-bottom: 0; }
.rich-note strong, .rich-note b { font-weight: 700; }
.rich-note em, .rich-note i { font-style: italic; }

.rich-toolbar {
  display: flex;
  align-items: center;
  gap: 0.1rem;
  padding: 0.1rem 0.35rem;
}
.rt-btn {
  -webkit-appearance: none;
  appearance: none;
  border: none;
  background: none;
  min-width: 2.75rem;           /* 44px — iOS HIG minimum tap target */
  height: 2.75rem;              /* the glyphs read smaller, the hitbox stays full */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  cursor: pointer;
  border-radius: var(--radius-pill);   /* fully rounded hover/pressed pill */
  line-height: 1;
  transition: background var(--dur-micro), color var(--dur-micro);
}
@media (hover: hover) { .rt-btn:hover { color: var(--text); background: var(--surface-2); } }
.rt-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.rt-btn[aria-pressed="true"], .rt-toggle[aria-expanded="true"] { color: var(--text); background: var(--surface-2); }
/* Specimen glyphs: H1 upright serif, H2 italic serif, then B / I in Oxygen. */
.rt-h1 { font-family: 'Instrument Serif', 'Times New Roman', serif; font-size: 1.15rem; }
.rt-h2 { font-family: 'Instrument Serif', 'Times New Roman', serif; font-style: italic; font-size: 1.1rem; }
.rt-b  { font-family: 'Oxygen', sans-serif; font-weight: 700; font-size: 0.9rem; }
.rt-i  { font-family: 'Oxygen', sans-serif; font-style: italic; font-size: 0.9rem; }
.rt-sep { width: 1px; height: 1.1rem; background: var(--rule); margin: 0 0.25rem; flex: none; }
/* The toggle's own specimen — "Aa" stands for text styles in general. */
.rt-toggle { flex: none; font-family: 'Instrument Serif', 'Times New Roman', serif; font-size: 1.05rem; }
.rt-count {
  padding-right: 0.15rem;
  font-size: 0.72rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.rt-count.is-over { color: var(--type-find-ink); }

/* The Post field's attach bar — link + photo toggles at the FOOT of the note box,
   the counterweight to the "Aa" styles button in the title row. A hairline lifts
   it off the body. Each tool tints to the type it makes when it's the active one
   (Find peach / Frame cyan), the same "colour is meaning" cue the masthead mark
   and the bottom colour-wash carry. Wired in wireAttachBar (renderPublish). */
.rich-attach {
  display: flex;
  justify-content: flex-end;
  align-items: center;
  gap: 0.1rem;
  min-height: 2.75rem;  /* .rt-attach (2.4rem) plus this bar's own vertical
                            padding, so a tools-less bar (the Activity field's
                            lone audience lock) sits at the same total height
                            (~44px) as a Post's icon-bearing bar. */
  padding: 0.15rem 0.3rem 0.2rem;
  border-top: 1px solid var(--rule);
}
.rt-attach {
  -webkit-appearance: none;
  appearance: none;
  border: none;
  background: none;
  width: 2.4rem;
  height: 2.4rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  cursor: pointer;
  border-radius: var(--radius-pill);
  transition: color var(--dur-micro), background var(--dur-micro);
}
.rt-attach-ico { width: 20px; height: 20px; }
@media (hover: hover) { .rt-attach:hover { color: var(--text); background: var(--surface-2); } }
.rt-attach:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
#c-add-link[aria-pressed="true"] {
  color: var(--type-find-ink);
  background: color-mix(in srgb, var(--type-find) 22%, transparent);
}
#c-add-photo[aria-pressed="true"] {
  color: var(--type-photo-ink);
  background: color-mix(in srgb, var(--type-photo) 26%, transparent);
}
#c-add-poll[aria-pressed="true"] {
  color: var(--type-poll-ink);
  background: color-mix(in srgb, var(--type-poll) 24%, transparent);
}
/* ── Composer: the poll surface ──────────────────────────────────────────────
   Flat editorial (a poll is content, never glass): a stack of choice rows, each
   an input plus a remove ×, then a dashed "add option" that reads as an
   affordance, not a filled button. The question is the post itself, so there's
   no question field here. */
.poll-opts { display: flex; flex-direction: column; gap: 0.5rem; margin-top: 0.5rem; }
/* The remove × sits OVER the input (absolute), not beside it, so adding or
   dropping a choice never resizes the field. The input reserves a constant right
   inset for it whether or not it's shown. */
.poll-opt-row { position: relative; }
.poll-opt-row .poll-opt-input { width: 100%; padding-right: 2.5rem; }
.poll-opt-remove {
  -webkit-appearance: none;
  appearance: none;
  position: absolute;
  top: 50%;
  inset-inline-end: 0.4rem;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.85rem;
  height: 1.85rem;
  padding: 0;
  color: var(--muted);
  background: none;
  border: none;
  border-radius: var(--radius-pill);
  cursor: pointer;
  transition: color var(--dur-micro), background var(--dur-micro);
}
.poll-opt-remove-ico { width: 15px; height: 15px; }
@media (hover: hover) {
  .poll-opt-remove:hover { color: var(--type-poll-ink); background: var(--surface-2); }
}
.poll-opt-remove:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* At the two-choice floor the × disappears (a poll needs at least two); the
   input keeps its right inset so it holds the same width. */
.poll-opts.is-min .poll-opt-remove { display: none; }
.poll-add-opt {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  margin-top: 0.5rem;
  padding: 0.6rem 0.85rem;
  font: inherit;
  color: var(--muted);
  background: none;
  border: 1px dashed var(--rule);
  border-radius: var(--radius-img);
  cursor: pointer;
  transition: color var(--dur-micro), border-color var(--dur-micro);
}
@media (hover: hover) {
  .poll-add-opt:hover { color: var(--type-poll-ink); border-color: var(--type-poll); }
}
.poll-add-opt:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── Poll widget (in the feed card) ──────────────────────────────────────────
   The question, the choices, then a status line. A choice is a full-width row;
   once results are revealed a pink fill bar grows behind the label to its share,
   your pick is bordered + checked, and the leader carries a slightly deeper fill.
   Content, so it stays flat — no glass — with the pink drawn from the type token. */
.poll { margin: 0.9rem 0 0.2rem; }
.poll-options { display: flex; flex-direction: column; gap: 0.5rem; }
.poll-option {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  overflow: hidden;
  padding: 0.65rem 0.85rem;
  font: inherit;
  text-align: left;
  color: var(--text);
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius-img);
  transition: border-color var(--dur-micro), background var(--dur-micro);
}
button.poll-option { cursor: pointer; -webkit-appearance: none; appearance: none; }
@media (hover: hover) {
  button.poll-option:hover { border-color: var(--type-poll); }
}
button.poll-option:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button.poll-option:disabled { cursor: default; }
/* The share bar. Results are monochrome by design: a quiet wash of the page ink
   that fills to each share. Colour is spent on the VOTE (the gradient flourish
   below), never left sitting on the card. The left corners are clipped round by
   the row (overflow:hidden); the right edge stays a clean vertical level line.
   Grows from the leading edge. */
.poll-fill {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  background: color-mix(in srgb, var(--text) 9%, transparent);
  border-radius: 0;
  transform-origin: left center;
  z-index: 0;
}
.poll-option.is-leading .poll-fill {
  background: color-mix(in srgb, var(--text) 15%, transparent);
}
/* The vote flourish overlay: your freshly cast pick washes with the Tria quintet
   (same gradient as the Post button) in a single sweep. Painted on a pseudo that
   cross-fades out when JS drops .is-voting, so colour rewards the tap and then
   falls away to the flat result. Sits at opacity 0 on every other bar. */
.poll-fill::before {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg,
    var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
  background-size: 300% 100%;
  opacity: 0;
  transition: opacity 0.6s var(--ease);
  pointer-events: none;
}
.poll-fill.is-voting::before {
  opacity: 1;
  animation: poll-sweep 1s ease-out both;   /* one pass, matching the one-way growth */
}
@keyframes poll-sweep {
  from { background-position: 0% 50%; }
  to   { background-position: 100% 50%; }
}
/* Just voted: every revealed bar grows out from zero (one-shot, transform only);
   the gradient rides along on the pseudo above. */
.poll--just-voted .poll-fill { animation: poll-grow 0.55s var(--ease) both; }
@keyframes poll-grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}
/* The "weighed" beat: once the sweep lands (~0.92s in), a one-shot cluster of the
   SAME y2k stars as the like tap twinkles off the fill's leading edge — a quick
   "got it, here's where you stand," then gone. Two differences from the like burst:
   they're painted in page ink (--burst: --text, so black on light / white on dark)
   so colour stays reserved for the gradient, and they're anchored at the fill edge
   (left:pct%, clamped off the side walls). Everything else is shared: same star
   mask, and the same decoupled tracks (like-fly owns transform, spark-fade owns
   opacity, one clock) so the motion leads the light. Lives inside the row overflow;
   the stars fully fade before any tip reaches the boundary (see the JS spark table). */
.poll-burst {
  position: absolute;
  top: 50%;
  left: 0;
  z-index: 2;
  pointer-events: none;
  --burst: var(--text);
}
.poll-burst .spark {
  left: 0; top: 0;
  animation: like-fly 0.5s linear both, spark-fade 0.5s ease-in-out both;
}
.poll-option-label { position: relative; z-index: 1; flex: 1; min-width: 0; transition: color 0.6s var(--ease); }
.poll-option-pct {
  position: relative;
  z-index: 1;
  font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  transition: color 0.6s var(--ease);
}
.poll-check {
  position: relative;
  z-index: 1;
  display: inline-flex;
  width: 1rem;
  height: 1rem;
  color: var(--text);
  transition: color 0.6s var(--ease);
}
.poll-check svg { width: 100%; height: 100%; }
/* Your pick: a firmer neutral hairline + the check mark it (no colour — that's
   reserved for the tap). */
.poll-option.is-mine {
  border-color: color-mix(in srgb, var(--text) 40%, transparent);
}
.poll-option.is-mine .poll-option-pct { color: var(--text); font-weight: 600; }
/* Dark mode: the bright pastels wash out the row's near-white text while the vote
   flourishes, and the text straddles the growing fill edge (the label's tail on a
   low share, the pct/check on a high one). Rather than recolour the text — which
   would need shadows to survive both sides of the edge, and would invert any emoji
   in a label — deepen the gradient itself: still a saturated sweep of colour, now
   dark enough that the plain white text reads over it AND over the dark surface,
   seamlessly across the edge. Nothing touches the glyphs, so emoji stay intact. */
@media (prefers-color-scheme: dark) {
  /* Keep the flourish deepened for its whole life — through the cross-fade out
     after .is-voting drops — so it never flashes back to full brightness as it
     settles. (Harmless on the resting bars: their ::before sits at opacity 0.) */
  .poll-fill::before { filter: brightness(0.55) saturate(1.2); }
}
.poll-meta {
  margin-top: 0.6rem;
  font-size: 0.78rem;
  color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
  /* Drop the growth and the sweep; the pick still cross-fades from gradient to
     the flat result (an opacity change, not motion). */
  .poll--just-voted .poll-fill { animation: none; }
  .poll-fill.is-voting::before { animation: none; opacity: 1; }
  .poll-burst { display: none; }
}

/* An activity's "When" — date + time sharing one row (the date takes the lead),
   so the optional pair reads as a single quiet line, not two more form rows. */
.when-row { display: flex; gap: 0.6rem; }
.when-row input[type="date"] { flex: 1.4; min-width: 0; }
/* The time input keeps an em floor (not 0) so it can never squeeze below its
   own value: iOS (home-screen installs especially, where text can run larger)
   otherwise wraps the AM/PM onto a second line inside the control. em tracks
   the rendered font, so the floor holds at any iOS text size; the date field
   beside it stays min-width:0 and absorbs the squeeze. */
.when-row input[type="time"] { flex: 1; min-width: 6.8em; }
/* iOS renders date/time inputs as centered steppers; keep the height + left
   alignment of the text fields so the row sits flush with the form. */
.when-row input { -webkit-appearance: none; appearance: none; text-align: left; }
/* iOS paints the value in these pseudos, each carrying a default vertical margin
   that stacks on our padding and inflates the row's height. The value pseudo
   drives a filled input; the edit pseudo drives an empty one. Zero both, and
   forbid the value from ever line-breaking ("10:30" / "PM"). */
.when-row input::-webkit-date-and-time-value { text-align: left; margin: 0; white-space: nowrap; }
.when-row input::-webkit-datetime-edit { margin: 0; padding: 0; white-space: nowrap; }
/* iOS shows nothing at all in an empty date/time input — paint the hint
   ourselves (the placeholder attr, flagged by wireWhenHints in app.js).
   Scoped to iOS so other browsers keep their native mm/dd/yyyy ghost. */
@supports (-webkit-touch-callout: none) {
  .when-row input:not(.has-value)::before {
    content: attr(placeholder);
    color: var(--muted);
    opacity: 0.6;
    white-space: nowrap;   /* the time hint "--:-- --" must never break after the colon pair */
  }
}

.auth-error { color: var(--type-find-ink); font-size: 0.85rem; margin-top: 1rem; min-height: 1.1rem; }

/* Sub-headline under a reset / recovery / confirmed title — same muted body voice
   as the rest of the gate, a touch of breathing room below the serif head. */
.auth-sub {
  margin-top: 0.9rem; font-size: 0.95rem; line-height: 1.5; color: var(--muted);
}

/* "Forgot password?" — a quiet link tucked under the login password field, right-
   aligned so it reads as a secondary way out, not a field. */
.auth-forgot { margin-top: 0.55rem; text-align: right; font-size: 0.85rem; }
.auth-forgot a {
  color: var(--muted); text-decoration: underline; text-underline-offset: 2px;
}
@media (hover: hover) { .auth-forgot a:hover { color: var(--text); } }

/* Inline resend under the confirm-email nudge — a plain accent text-button, in
   the same family as .auth-alt's toggle. */
.auth-resend { margin-top: 0.7rem; font-size: 0.88rem; }
.auth-resend button {
  background: none; border: none; color: var(--accent); font-weight: 700;
  text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
}
.auth-resend button:disabled { color: var(--muted); text-decoration: none; cursor: default; }

/* Signup's guidelines agreement — a quiet inline checkbox above the error line.
   The accent tick matches the primary action; the copy stays small and muted. */
.auth-agree {
  display: flex; align-items: flex-start; gap: 0.55rem;
  margin-top: 1.1rem; font-size: 0.82rem; color: var(--muted); line-height: 1.4;
  cursor: pointer;
}
.auth-agree input {
  flex-shrink: 0; width: 1.05rem; height: 1.05rem; margin-top: 0.05rem;
  accent-color: var(--type-activity-ink); cursor: pointer;
}
.auth-agree a { color: var(--text); text-decoration: underline; text-underline-offset: 2px; }
@media (hover: hover) { .auth-agree a:hover { color: var(--type-activity-ink); } }
.auth-submit {
  width: 100%;
  margin-top: 0.5rem;
  color: var(--on-accent);
  background: var(--lit-top), var(--accent);
  border: none;
  border-radius: var(--radius-pill);
  font-weight: 700;
  font-size: 1.02rem;
  padding: 0.8rem;
  box-shadow: var(--lit-rim), var(--lit-drop);
  transition: background-color var(--dur-micro);
}
.auth-submit:hover { background: var(--lit-top), var(--accent-press); }
/* The auth gate's Create account / Log in commit wears the same lit-dome pastel
   fill as the hero Post pill and compose "+" (.publish-fill.is-solid): the flat
   accent bg would paint over the gradient, so drop it here and let the dome
   below (shared with .composer-post) carry the look. */
.auth-submit.publish-fill { background: none; box-shadow: none; color: var(--on-type); }
.auth-submit.publish-fill:hover { background: none; }
.auth-alt { margin-top: 1.5rem; font-size: 0.88rem; color: var(--muted); text-align: center; }
.auth-alt button {
  background: none;
  border: none;
  color: var(--accent);
  font-weight: 700;
  text-decoration: underline;
  text-underline-offset: 2px;
}

/* ── Profile (own account or a friend's) — identity header + their column ──── */
/* The identity header: a floating glass CARD on the profile's own colour wash.
   The wash (.ambient, a soft blob tinted from the photo via --glow-photo, set by
   applyAmbient) sits behind the page, so the card's backdrop-blur has real colour
   to refract — every profile reads in its own custom hue. Inside the card: the
   photo sits LEFT as its own floating glass panel, and the whole identity group
   (name / handle / stats / bio / actions) stacks beside it. (max-width + centring
   come from the shared .account rule up top, so it tracks the feed column.) */
.account { padding-top: 0.25rem; }

.account-card {
  position: relative;                  /* anchors the corner edit-profile badge */
  padding: 1.5rem 1.4rem;
  border-radius: var(--radius-modal);  /* a larger piece of glass, rounder corners */
  background: var(--glass-bg-panel);
  -webkit-backdrop-filter: blur(var(--glass-blur));
          backdrop-filter: blur(var(--glass-blur));
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), var(--glass-lift);
}

/* Photo panel left, the whole identity group (name, handle, stats, bio, actions)
   beside it. Top-aligned so the name crowns the photo. */
.account-head {
  display: flex;
  align-items: center;
  gap: 1.2rem;
}
/* The profile photo is its OWN floating glass panel stacked on the card: a
   4:3-tall (portrait) tile, framed by a hairline + lit rim + float shadow so it
   reads as a distinct glass layer sitting on top. Profile-page-only crop — the
   square-stored avatar is object-fit:cover'd into 3:4, so it needs no separate
   upload and stays circular everywhere else in the app. */
.account-photo {
  position: relative;
  flex: 0 0 clamp(132px, 40%, 196px);
  aspect-ratio: 3 / 4;
  /* Pull the panel toward the card's top/left/bottom edges — the text next to it
     keeps the card's own padding, but the photo hugs the glass layer below. */
  margin: -0.7rem 0 -0.7rem -0.65rem;
  border-radius: 17px;                 /* rounder to sit with the card's 20px */
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid color-mix(in srgb, var(--text) 12%, transparent);
  box-shadow: var(--lit-rim), 0 10px 24px rgba(0, 0, 0, 0.18);
}
.account-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.account-photo--empty { display: grid; place-items: center; }
.account-photo-initial {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-size: clamp(3rem, 13vw, 4.5rem);
  line-height: 1;
  color: color-mix(in srgb, var(--text) 32%, transparent);
  user-select: none;
}

/* The identity beside the photo is one left-aligned axis: name+handle, an inline
   stat line, the bio, then the action — every row shares the same left edge, so
   the column reads as a single clean editorial stack. */
.account-meta {
  min-width: 0;
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.7rem;
}
.account-id { min-width: 0; }

/* The identity column always sits vertically centred against the taller photo
   tile: a short (or missing) bio centres against the photo for a balanced card
   instead of hugging the top with dead space beneath, and a long bio simply
   grows the column past the photo. One rule, every content state. */
.account-name {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: clamp(1.7rem, 5.5vw, 2.1rem);
  line-height: 1.05;
  letter-spacing: 0;
  color: var(--text);
}
.account-handle {
  margin-top: 0.12rem;
  font-size: 0.92rem;
  color: var(--muted);
}

/* One inline stat line: "N posts · N friends". The count is inked, its label
   muted, the two stats parted by a faint dot. A locked profile shows the friend
   count alone (its dot is dropped in the template). */
.account-stats {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem;
  font-size: 0.85rem;
  color: var(--muted);
}
.account-stat {
  /* The friends stat is a <button>, so strip the UA chrome — reads as text. */
  display: inline;
  padding: 0;
  background: none;
  border: 0;
  font: inherit;
  color: var(--muted);
}
.account-stat-num { font-weight: 700; color: var(--text); }
.account-stat-label { color: var(--muted); }
.account-stat-dot { color: var(--muted); opacity: 0.5; }
/* The friends stat is a live button — opens their circle. The tap hint is a
   quiet underline on hover. */
.account-stat--friends { cursor: pointer; }
@media (hover: hover) {
  .account-stat--friends:hover .account-stat-label {
    color: var(--text);
    text-decoration: underline;
  }
}

/* Bio sits in the identity grid, below the stats (row spacing comes from the
   grid gap, so no top margin of its own). */
.account-bio {
  font-size: 0.95rem;
  line-height: 1.5;
  color: var(--text);
}

/* ── Discover — the masonry grid of people ─────────────────────────────────
   One surface, two columns, ragged. Each tile is a person faced by whatever
   they posted, so tile heights vary by CONTENT rather than by design: a photo
   takes its own aspect ratio, a note takes as many lines as it needs, a quiet
   account takes a square portrait. That variation IS the masonry — we don't
   compose it, we just stop flattening it.

   The columns are REAL elements dealt by JS (see layoutGrid), not CSS
   `columns`. CSS columns fill column one to the bottom before starting column
   two, so a chronological list comes out as N parallel timelines standing side
   by side, the top of the second column already dozens of posts old. Discover
   reads in time order (rule 5 in renderDiscover), and that layout would hide
   the very thing the order fixes. So JS owns the deal and CSS owns the count:
   --cols is the one knob, and the breakpoint below is the only place it's set.

   Glass, minus the blur. These float above the page so the material is right,
   but a backdrop-filter is per-element work the compositor cannot share and a
   scrolling grid is exactly where that bill lands. So the fill, the hairline,
   the lit top rim and the float shadow carry it, and the sample-and-blur goes.
   The fill sits on --surface rather than --glass-bg for the same reason: a blur
   is what normally separates glass from the paper behind it, and without one a
   --bg-tinted fill lands at the page's own value and stops reading as raised. */
.pgrid {
  --cols: 2;
  display: flex;
  align-items: flex-start;
  gap: 0.85rem;
  max-width: var(--feed-width);
  margin-inline: auto;
}
.pgrid-col { flex: 1 1 0; min-width: 0; }
/* Set by layoutGrid on a re-deal (a rotate, a window resize) rather than a
   repaint. Re-dealing detaches every tile, and a detached element restarts its
   CSS animation the moment it goes back in — so without this the whole page
   would play its entrance again because the phone turned. */
.pgrid--settled .ptile { animation: none; }

/* ── Trending tags — the one index on Discover (rule 7 in renderDiscover) ─────
   Flat editorial, NOT glass. It's a contents strip for the page below it, and
   the material rule reserves frosted glass for what floats ABOVE content.

   Five words separated by hairlines, not five chips: pills would make this a
   filter bar, and a filter bar at the top of Discover competes with the actual
   filter already sitting in the masthead. The tag is set in the serif because
   it's the only word on the row doing display work; the count stays in Oxygen at
   caption size so it reads as an annotation rather than a score. It's the one
   number allowed anywhere on this page (tiles carry none, on purpose). */
.dtags {
  max-width: var(--feed-width);
  /* 1.4rem of air under the tags, less the rail's own bottom padding below. */
  margin: 0 auto 0.64rem;
  /* Inset to the same axis as the masthead's type. The rail and the grid share a
     box, but the rail is TYPE and the grid is media, and this page follows the
     same rule the feed does: text lines up with the nameplate, media reaches the
     edge of the measure. Without this, "TRENDING" started 26px left of
     "Discover" — two labels a few pixels apart on the page, each on its own
     axis. Mobile zeroes it again (see the mobile block), where .view already
     supplies the inset and the masthead drops its own. */
  padding-inline: var(--inset);
}
.dtags-cap {
  /* Likewise: the 0.35rem gap to the tags, less the rail's top padding. The
     padding is a tap target (see .dtag), not spacing, so it's taken back here
     rather than allowed to push the strip open. */
  margin: 0 0 -0.41rem;
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--muted);
}
/* One line that scrolls sideways rather than wrapping — a tag list that reflows
   onto a second row on a phone starts fighting the grid for the top of the page.
   No visible scrollbar: the last tag clipping at the edge is the affordance.
   overscroll-behavior-x keeps the swipe INSIDE the rail: without it, running out
   of tags hands the gesture on, and in an installed iOS app the thing waiting to
   catch a horizontal swipe is the back navigation. touch-action stays `auto` on
   purpose (unlike .reel, which owns its drag) so a finger that lands on the rail
   can still scroll the page down. */
.dtags-rail {
  display: flex;
  align-items: baseline;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
}
.dtags-rail::-webkit-scrollbar { display: none; }
/* A tag is one line of type, about 23px tall — half Apple's 44pt tap floor. The
   .filter chips solve that with an invisible ::after extender, but that trick
   can't work in here: `overflow-x: auto` on the rail forces overflow-y to auto
   as well, so anything reaching past the line would be clipped (and could even
   open a vertical scrollbar inside the strip). So the padding is REAL, and the
   spacing it would otherwise add is taken back on .dtags above. */
.dtag {
  flex: 0 0 auto;
  position: relative;               /* anchors the hairline divider */
  display: inline-flex;
  align-items: baseline;
  gap: 0.3rem;
  margin-right: 0.8rem;
  padding: 0.76rem 0.8rem 0.76rem 0;
  border: 0;
  background: none;
  color: var(--text);
  font: inherit;
  cursor: pointer;
  transition: opacity 0.18s var(--ease-soft);
}
/* The divider is drawn, not a border, so it stays the height of the WORD while
   the button around it grows to a thumb. A border-right would have stretched to
   44px and turned a hairline into a rule. */
.dtag::after {
  content: '';
  position: absolute;
  right: 0;
  top: 50%;
  width: 1px;
  height: 1.15rem;
  transform: translateY(-50%);
  background: var(--rule);
}
.dtag:last-child { margin-right: 0; padding-right: 0; }
.dtag:last-child::after { content: none; }
.dtag:active { opacity: 0.55; }
.dtag-name {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: 1.24rem;
  line-height: 1;
}
.dtag-hash { color: var(--muted); }
.dtag-n {
  font-size: 0.72rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.dtag-go {
  font-size: 0.78rem;
  color: var(--muted);
}
/* The live tag underlines its word — the same "this is what's narrowing the
   page" language the seg-tabs and the filter dial already speak. */
.dtag[aria-pressed="true"] .dtag-name,
.dtag[aria-pressed="true"] .dtag-hash { color: var(--text); }
.dtag[aria-pressed="true"] .dtag-name {
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.2em;
}
@media (hover: hover) {
  .dtag:hover .dtag-name {
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 0.2em;
  }
}

.ptile {
  display: block;
  margin-bottom: 0.85rem;              /* layoutGrid reads this to balance the columns */
  overflow: hidden;                    /* the face is full-bleed; the tile clips it */
  border-radius: var(--radius-card);
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  /* The glass-minus-blur surface: it takes the edge, the specular rim and the
     float from the same tokens as every other floating panel, and drops only the
     sample-and-blur (see the note above — a scrolling masonry grid is exactly
     where per-element compositor work bills you). The rim is a box-shadow, so
     matching the material here costs nothing. */
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), 0 6px 18px rgba(0, 0, 0, 0.10);
  transition: transform var(--dur-quick) var(--ease), box-shadow var(--dur-quick) var(--ease);
  animation: rise var(--dur-slow) var(--ease) both;   /* same soft rise as feed cards */
}
@media (hover: hover) {
  .ptile:hover {
    transform: translateY(-2px);
    box-shadow: var(--glass-rim), 0 12px 26px rgba(0, 0, 0, 0.13);
  }
}

/* ── Face 1: a Frame ─────────────────────────────────────────────────────
   The box is stamped with the media's real aspect ratio and filled with its
   average colour (the `tint` column) before anything loads, so the grid settles
   once and the bitmap eases in over its own colour instead of popping in over
   grey. Same reserve-then-settle contract as .photo-frame in the feed. */
.ptile-face--media {
  position: relative;
  background: var(--ph-fill, var(--surface-2));
}
.ptile-face--media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  opacity: 0;
  transition: opacity var(--dur-slow) var(--ease);
}
.ptile-face--media img.is-loaded { opacity: 1; }
/* No type glyph over a photo, on purpose: a photo announces itself, so a badge
   there would be labelling the one face that never needed a label (and it had
   to wear a plate to stay legible over arbitrary media, which is a lot of
   apparatus for no information). The say faces keep theirs, where it works. */
/* A clip shows its play mark but never plays here — see the JS note. */
.ptile-play {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  color: #fff;
  filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45));
  pointer-events: none;
}
.ptile-play-ico { width: 2.4rem; height: 2.4rem; }

/* ── Face 2: the post speaks ─────────────────────────────────────────────
   Everything that isn't a photo becomes type: the post's own headline in the
   serif, its type glyph above in that type's colour, and a quiet second line
   where there's one (a find's domain, an activity's place). Clamped so one
   long-winded note can't run a column on its own. */
.ptile-face--say {
  padding: 1rem 0.95rem 1.05rem;
  background: color-mix(in srgb, var(--surface-2) 55%, transparent);
  border-bottom: 1px solid color-mix(in srgb, var(--text) 8%, transparent);
}
.ptile-type { margin-bottom: 0.5rem; }
.ptile-say {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: clamp(1.1rem, 4.6vw, 1.3rem);
  line-height: 1.22;
  color: var(--text);
  display: -webkit-box;
  -webkit-line-clamp: 6;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.ptile-sub {
  margin-top: 0.4rem;
  font-size: 0.78rem;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ── Face 3: nothing public to show ──────────────────────────────────────
   The portrait, full square, corners cut by the tile. Strips the profile hero's
   floating-panel dress (its own frame, radius, shadow, negative margins). This
   is the tile that keeps a private account from reading as a gap in the grid,
   so it is deliberately the SAME size as everyone else's face, not smaller. */
.ptile-face--who {
  width: 100%;
  aspect-ratio: 1 / 1;
  flex: none;
  margin: 0;
  border: 0;
  border-bottom: 1px solid color-mix(in srgb, var(--text) 10%, transparent);
  border-radius: 0;
  box-shadow: none;
}
/* The monogram scales with the square it sits in, so an account with no photo
   reads as a deliberate letterform rather than a small mark in a big empty box. */
.ptile .account-photo-initial { font-size: clamp(2.6rem, 15vw, 4rem); }

/* ── The identity foot ───────────────────────────────────────────────────── */
.ptile-foot { padding: 0.7rem 0.8rem 0.75rem; }
/* Portrait (only when the face is a post), name, arrow — the row centres on the
   identity block whether that block is one line or two, so a post tile's bare
   byline and a portrait tile's name-over-handle both sit against the arrow. */
.ptile-who {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.ptile-av { width: 30px; height: 30px; font-size: 1rem; }
.ptile-id { min-width: 0; flex: 1; }
.ptile .account-name {
  font-size: clamp(1.02rem, 4vw, 1.2rem);
  overflow-wrap: anywhere;             /* a long unbroken name must not shove the arrow off */
}
.ptile .friend-go { flex-shrink: 0; font-size: 1rem; }

/* Only on a portrait tile (see ptileEl): the one line that tells two people with
   the same name apart. Small and muted so it reads as a subtitle to the name
   rather than a second name, and clipped to one line — a handle is a key, and a
   key that wraps has stopped looking like one. */
.ptile-handle {
  margin-top: 0.14rem;
  font-size: 0.78rem;
  line-height: 1.2;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Only ever on a portrait-faced tile — a person with nothing public to say gets
   to say it in words instead. Two lines, then ellipsis. */
.ptile-bio {
  margin-top: 0.5rem;
  font-size: 0.84rem;
  line-height: 1.45;
  color: var(--muted);
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* The tile carries NO counts. A post count and a friend count on every tile made
   the grid a page of scoreboards, and neither number is why anyone taps. The one
   mark left is the lock, and it isn't a statistic: it's a heads-up that the tap
   lands on a wall. It rides the NAME (the handle it used to sit on is gone)
   because being closed is a property of the ACCOUNT, not of the post above it.

   Its spacing is measured off the INK, not off the svg. Every icon here is a
   24-unit box and ICONS.lock only fills x 5→19 of it, so with the 1.8 stroke the
   drawn shape runs 4.1→19.9 and the element carries 17.1% of its own width as
   empty space down each side. Setting margin-left: 0.3rem on that box therefore
   asked for a 0.3rem gap and produced closer to 0.45rem, while the trailing
   17.1% padded the name out toward the arrow with nothing in it — the gap looked
   wrong on both sides because it was being set on a box that isn't the glyph.
   So the insets are named once and subtracted: --lock-gap is the real optical
   gap, and the trailing inset is pulled straight back off. The same arithmetic
   is why the box GREW — at 0.82rem the ink was only 0.61rem against a name whose
   cap height is nearer 0.8rem, so the lock read as a speck rather than a mark. */
/* The last word of the name plus its lock, kept on one line (see ptileEl). */
.name-fence { white-space: nowrap; }
.ptile-lock {
  --lock-box: 1.02rem;
  --lock-inset: calc(var(--lock-box) * 0.171);  /* dead space each side of the ink */
  --lock-gap: 0.28rem;
  width: var(--lock-box);
  height: var(--lock-box);
  margin-left: calc(var(--lock-gap) - var(--lock-inset));
  margin-right: calc(var(--lock-inset) * -1);
  vertical-align: -0.13em;
  color: var(--muted);
}

/* Room to breathe once there is any: three columns and a bigger type scale from
   tablet up, where a 2-up grid would leave the tiles absurdly wide. */
@media (min-width: 620px) {
  .pgrid { --cols: 3; gap: 1.05rem; }
  .ptile { margin-bottom: 1.05rem; }
  .ptile-foot { padding: 0.85rem 0.95rem 0.9rem; }
}

/* ── The sidebar band: back to two ─────────────────────────────────────────────
   Every breakpoint on this page measures the VIEWPORT, but the grid doesn't live
   in the viewport — it lives in the column left over after the sidebar, which
   appears at 681px and takes 268px with it. So the grid gets NARROWER as the
   window gets wider across that line: 203px tiles at 680, and 111px tiles at 700
   with the column count still on three. For most of this band a tile was smaller
   than the same tile on a phone, which is backwards, and at the bottom of it the
   name wrapped to two lines on every single tile.
   Three columns are worth having again once the leftover column can hold them at
   something like the phone's tile width, which is around 900. Below that, two. */
@media (min-width: 681px) and (max-width: 899px) {
  .pgrid { --cols: 2; }
}

/* Three is the ceiling. A fourth column shipped briefly and came straight back
   out: it needs Discover to drop --feed-width and take the whole content box
   (four tiles don't fit a 660px measure at any usable size), and at 196px a tile
   is narrow enough that the say-face runs about fourteen characters a line and
   the name starts wrapping beside its avatar. The denser wall wasn't worth the
   page leaving the measure every other page shares. */

/* ── Dailies — the coloured card, and the page it opens ───────────────────────
   A daily wears the colour of the post type it asks for, so a prompt that wants a
   Frame is cyan all the way through: the card on Discover, the chip on an answer,
   and the wash behind the page that card opens. That's the quintet doing
   exactly the job it already has (five colours, five post types) rather than a
   sixth colour meaning "daily" — the colour still tells you what to make.

   COLOURED GLASS, and real glass this time. The material rule reserves frost for
   what floats above content, which this does. The masonry tiles drop the blur
   because a scrolling grid pays that bill per tile dozens of times over; this is
   ONE card that sits still at the top of the page, so it can afford the
   sample-and-blur that makes the material read.

   The whole card is the tap (a stretched link over it) with Answer riding on top
   as its own control: read the room, or go say your bit, and nothing in between. */
.daily-card {
  --daily: var(--type-note);
  --daily-ink: var(--type-note-ink);
  position: relative;
  max-width: var(--feed-width);
  /* 2rem below, not 1.2: this is a floating panel and the trending rail under it
     is flat type, so the two need more air between them than two paragraphs
     would. At 1.2rem the card also sat closer to the strip below it than to the
     masthead above (2.6rem), which reads as the card belonging to the rail. It
     belongs to neither: it's the page's headline, on its own. */
  margin: 0 auto 2rem;
  padding: 1rem 1.15rem 0.95rem;
  border-radius: var(--radius-card);
  /* Panel-grade FILL over chrome-grade BLUR, which looks like a contradiction and
     isn't: the card carries a serif question and needs the body, but it sits in
     Discover's normal flow and therefore scrolls with a grid of photographs
     re-sampling behind it. Cost follows the backdrop, not the component name. */
  background: color-mix(in srgb, var(--daily) 26%, var(--glass-bg-panel));
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  border: 1px solid color-mix(in srgb, var(--daily-ink) 30%, transparent);
  box-shadow: var(--glass-rim), var(--glass-lift);
}
/* The one place the type→colour map is written for dailies. Activities are absent
   on purpose: a daily never asks for one (see the note above DAILIES in app.js). */
.daily-card[data-type="note"],  .view--daily[data-type="note"],  .tag--daily[data-type="note"],  .daily-banner[data-type="note"]  { --daily: var(--type-note);  --daily-ink: var(--type-note-ink); }
.daily-card[data-type="find"],  .view--daily[data-type="find"],  .tag--daily[data-type="find"],  .daily-banner[data-type="find"]  { --daily: var(--type-find);  --daily-ink: var(--type-find-ink); }
.daily-card[data-type="photo"], .view--daily[data-type="photo"], .tag--daily[data-type="photo"], .daily-banner[data-type="photo"] { --daily: var(--type-photo); --daily-ink: var(--type-photo-ink); }
.daily-card[data-type="poll"],  .view--daily[data-type="poll"],  .tag--daily[data-type="poll"],  .daily-banner[data-type="poll"]  { --daily: var(--type-poll);  --daily-ink: var(--type-poll-ink); }

/* Dark mode: the pastel is a LIGHT colour, so the same 26% wash over a dark glass
   fill lands as a milky panel rather than a tinted one. Less pigment, and the ink
   twin is the pastel itself down here, so the border carries the hue instead. */
@media (prefers-color-scheme: dark) {
  .daily-card {
    background: color-mix(in srgb, var(--daily) 15%, var(--glass-bg));
    border-color: color-mix(in srgb, var(--daily) 34%, transparent);
  }
}

/* No dot before the words. The card is a hue-filled panel; a hue-filled disc sat
   on top of it said the same thing twice, and the second time is decoration. */
.daily-kicker {
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: var(--kicker);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--daily-ink);
  margin-bottom: 0.5rem;
}
.daily-sep { opacity: 0.45; margin: 0 0.3rem; }

/* The prompt is the card. Serif, big enough to read as the page's headline and
   small enough that a two-line question doesn't push the grid off the screen. */
.daily-open { display: block; color: var(--text); }
.daily-open::after {                  /* the stretched link: the whole card taps */
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
}
.daily-prompt {
  display: block;
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: clamp(1.5rem, 4.4vw, 1.95rem);
  line-height: 1.1;
  letter-spacing: -0.01em;
}
.daily-hint {
  margin-top: 0.4rem;
  font-size: 0.86rem;
  line-height: 1.45;
  color: var(--muted);
}

/* Who has answered, and the way in. The faces are the honest version of a count
   (you recognise people, you don't recognise a number), and the number beside
   them is the one Discover allows besides the trending rail's — "12 answers" is
   the difference between walking into a party and walking into an empty room. */
.daily-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  margin-top: 0.95rem;
}
.daily-answered { display: flex; align-items: center; gap: 0.55rem; min-width: 0; }
.daily-faces { display: flex; }
.daily-face.avatar {
  width: 24px;
  height: 24px;
  font-size: 0.85rem;
  border: 2px solid color-mix(in srgb, var(--daily) 30%, var(--bg));
}
.daily-face.avatar + .daily-face.avatar { margin-left: -0.5rem; }
.daily-count { font-size: 0.82rem; color: var(--muted); }

/* The invitation is TYPE, not a button. The card is already a filled coloured
   object and a second filled object sitting inside it is what made a pill here
   read as cheap — so this is the daily's own ink plus the go-arrow the tiles and
   the tag rail already use, and no fill at all. The word matters as much as the
   material: "Answer" is a command stacked on a question, which turns a prompt
   into a worksheet. "Add yours" is an invitation, and the arrow does the going. */
.daily-answer {
  position: relative;                 /* rides above the stretched link */
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  flex-shrink: 0;
  border: none;
  background: none;
  padding: 0.2rem 0;
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: 0.9rem;
  color: var(--daily-ink);
  transition: opacity var(--dur-micro) var(--ease);
}
.daily-go { font-size: 1rem; transition: transform var(--dur-micro) var(--ease); }
@media (hover: hover) {
  .daily-answer:hover .daily-go { transform: translateX(2px); }
}
.daily-answer:active { opacity: 0.55; }
/* Already answered (one each): the invitation is spent and nothing replaces it.
   There's no "yours is in" line here — the faces and the count already moved when
   you posted, and a card that narrates your own action back to you is talking
   about the app instead of about the room. */

/* On the daily's own page the invitation rides the kicker line, so it takes that
   line's type wholesale: the same micro-caps at the same tracking. Caption left,
   control right — the masthead's own arrangement, moved up a line. The arrow
   drops the tracking (0.16em after a glyph is a trailing gap, not letter-spacing)
   and sizes to the caps around it. */
.daily-answer--inline {
  padding: 0;
  gap: 0.22rem;
  font-size: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
}
.daily-answer--inline .daily-go { font-size: 0.8rem; letter-spacing: 0; }
/* The standing half of the line, dimmed so the live half reads as the live half. */
.daily-when { opacity: 0.6; }
@media (prefers-reduced-motion: reduce) {
  .daily-answer, .daily-go { transition: none; }
}

/* ── The daily's own page ─────────────────────────────────────────────────────
   Discover held to one question: the prompt as the nameplate, the answers dealt
   into the same masonry grid, and the daily's hue washing the whole page through
   the ambient (see body[data-ambient="daily"]) so opening the card reads as
   stepping into it. The prompt is a SENTENCE, so it can't take the display size a
   one-word nameplate wears — it steps down to roughly a post headline. */
/* The masthead's own clearance, for a prompt with no hint under it. */
.view--daily .masthead { margin-bottom: 2.4rem; }
/* More air under the back link than a profile gives it: there, it sits above a
   card with its own border to push off. Here it's a small grey line directly
   above a tracked caption in colour, and 1rem left the two reading as one stack
   of small print with the page's question buried in it. */
.view--daily .profile-back { margin-bottom: 1.7rem; }
/* The hint belongs to the title, not to the page. The masthead's page-level gap
   moves to the BOTTOM of the lede so the two sit as one block: 0.7rem between
   them (the masthead's own padding, all that's left once the margin goes) and
   the full clearance below. Before this they were 1.7rem apart and 1.9rem off
   the grid, which left the caption floating exactly halfway between the question
   it explains and the answers it doesn't. */
.view--daily .masthead:has(+ .daily-lede) { margin-bottom: 0; }
/* The status line is a row: "Daily · 6h left" left, the invitation right. */
.view--daily .masthead-kicker {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  color: var(--daily-ink);
}
.masthead-title--daily {
  display: block;
  font-size: clamp(1.9rem, 3.4vw, 2.7rem);
  line-height: 1.08;
}
/* Capped and centred with the masthead (it joins the shared content-block list
   above), then inset to the same axis its type sits on. Both are needed: the cap
   puts it in the same column, the inset lines its first letter up with the
   nameplate's. Without the cap it ran the full width of the view and started
   113px to the left of the title it captions. */
.daily-lede {
  margin-bottom: 2.6rem;
  padding: 0 var(--inset);
  font-size: 0.92rem;
  color: var(--muted);
}
/* Nothing sits beside the prompt: the invitation moved up onto the kicker line
   (see .daily-answer--inline), which is where the rest of the small print about
   the occasion already lives. The masthead row is the question alone. */

/* ── The chip on an answer's own card ─────────────────────────────────────────
   What a daily answer wears in the tag row instead of the `daily-<slug>` join
   tag that actually carries it. Tags are words a person chose; a slug sitting
   among them reads as metadata leaking into writing, and every answer on the
   page ends up wearing a barcode. So the chip says the QUESTION, tinted in the
   daily's hue, and links to everyone else's answers — which is what the slug
   was standing in for, and a far better thing to be able to tap.

   It's a .tag first, so it keeps the row's shape; the prompt truncates rather
   than wrapping a chip to three lines. */
.tag--daily {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  min-width: 0;
  max-width: 100%;
  color: var(--daily-ink);
  border-color: color-mix(in srgb, var(--daily-ink) 32%, transparent);
  background: color-mix(in srgb, var(--daily) 18%, transparent);
}
.tag-daily-cap {
  flex-shrink: 0;
  font-size: 0.62rem;
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  opacity: 0.72;
}
.tag-daily-q {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
@media (hover: hover) {
  .tag--daily:hover { color: var(--daily-ink); border-color: color-mix(in srgb, var(--daily-ink) 60%, transparent); }
}

/* ── The composer, answering ──────────────────────────────────────────────────
   The daily's masthead in miniature, directly under the composer's: the tracked
   caption over the question in serif, in that daily's ink. Arriving from the card
   then reads as one continuous surface — the wash is already the right colour,
   the nameplate names what you're making, and this says what you're answering, in
   the same voice the page you just left used to ask it.

   It lives OUTSIDE the form and takes the masthead's own inset. The composer's
   fields sit var(--inset) further left on purpose (a box outdented against the
   type looks right; the boxes ARE the left edge), but a line of type hanging
   under a title has to start where the title starts. Same rule that put
   .daily-lede on the nameplate's axis, and it joins the same two lists. */
.daily-banner {
  --daily: var(--type-note);
  --daily-ink: var(--type-note-ink);
  padding: 0 var(--inset);
  margin-bottom: 2rem;
}
/* Pulled up tight to the nameplate: this captions it, so the masthead's full
   2.6rem clearance would leave the caption floating between two things. */
.masthead:has(+ .daily-banner) { margin-bottom: 0.9rem; }
.daily-banner-cap {
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: var(--kicker);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--daily-ink);
  margin-bottom: 0.4rem;
}
.daily-banner-prompt {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: clamp(1.25rem, 3.4vw, 1.5rem);
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--text);
}
/* Not an answer any more: the type on the page stopped matching the type the
   prompt asked for (see dailyAccepts). The question doesn't vanish — you may
   still be about to go back to it, and a banner that disappeared would read as
   the app losing your place — it goes grey and its caption names the way back.
   Draining the colour is the whole signal: the ink was the only thing tying this
   line to the coloured page you came from, and without it this is just a note
   about a question you're no longer answering. The tween matches the ambient
   wash's own colour shift, so the banner and the page turn together. */
.daily-banner--off .daily-banner-cap,
.daily-banner--off .daily-banner-prompt { color: var(--muted); }
.daily-banner-cap,
.daily-banner-prompt { transition: color var(--dur-move) var(--ease); }
@media (prefers-reduced-motion: reduce) {
  .daily-banner-cap, .daily-banner-prompt { transition: none; }
}

/* ── A profile's frame wall ─────────────────────────────────────────────
   Frames is the one row on a profile's dial that changes the LAYOUT instead of
   just narrowing the column: that person's photographs, dealt by the same
   dealMasonry into the same .pgrid Discover uses. So it inherits everything
   above — the column count, the gaps, the tile material, the breakpoints. Three
   is still the ceiling and the 681–899 sidebar band still drops back to two;
   there is no second set of rules to keep in sync.

   Tiles keep their real aspect ratios, and that IS the feature. A square-cropped
   contact sheet flattens a portrait and a wide landscape into the same brick,
   which is what every other platform's profile grid does and precisely what Tria
   stores photos uncropped to avoid (only avatars crop). The ragged edge is the
   photographs being themselves.

   A tile is the face and nothing else — no foot, no byline, no caption, no
   counts. Discover's tiles carry a person because that grid is a room full of
   strangers; here every tile is the same person, and repeating them down the
   page would be forty labels saying what the card at the top already said. So
   there is barely a rule to write: dropping .ptile-foot from the markup is the
   whole design, and .ptile's own clip, hairline, lit rim and float shadow frame
   the photograph on their own. */
/* The one rule the wall needs of its own, and it's a flexbox trap rather than a
   design decision: .pgrid centres itself with `margin-inline: auto`, which is
   free inside Discover's block-level body but lands here inside #feed, a FLEX
   column. An auto margin on the cross axis cancels the default stretch, so the
   grid shrink-wrapped its content — and since .pgrid-col is `flex: 1 1 0`, its
   content had no width to wrap, which collapsed every column to nothing and
   every aspect-ratio face with it. Take the full column back explicitly; the
   feed is already capped at --feed-width, so this centres on the same axis. */
.pgrid--frames { width: 100%; margin-top: 0.15rem; }

/* A private profile, seen by an outsider: the posts column is replaced by a
   quiet, centred nudge toward adding them. Flat editorial (content, not glass),
   in the same muted register as .feed-empty. */
.profile-locked {
  max-width: var(--feed-width);
  margin-inline: auto;
  padding: 2.5rem 1.25rem;
  text-align: center;
  color: var(--muted);
}
.profile-locked-ico {
  width: 30px;
  height: 30px;
  color: var(--muted);
  opacity: 0.7;
  margin-bottom: 0.85rem;
}
.profile-locked-line {
  font-size: 1rem;
  color: var(--text);
}
.profile-locked-sub {
  margin-top: 0.4rem;
  font-size: 0.9rem;
  line-height: 1.5;
}
/* Softened variant: shown BELOW an outsider's visible public posts (their circle
   holds more), so it's a quiet footer, not a full wall. Lighter padding + a
   hairline above to set it off from the last card. */
.profile-locked--soft {
  padding: 1.75rem 1.25rem 1rem;
  margin-top: 1.25rem;
  border-top: 1px solid var(--rule);
}
.profile-locked--soft .profile-locked-ico { width: 24px; height: 24px; margin-bottom: 0.6rem; }
.profile-locked--soft .profile-locked-line { font-size: 0.94rem; }

/* Back to the directory, above a friend's card. */
.profile-back {
  display: inline-block;
  color: var(--muted);
  font-size: 0.9rem;
  margin-bottom: 1rem;
  transition: color var(--dur-micro);
}
@media (hover: hover) { .profile-back:hover { color: var(--text); } }

/* Their posts sit below the card as a single-author column, with a clear gap so
   the card reads as its own header, not the top of the feed. */
.account + .feed { margin-top: 2.4rem; }

/* ── The profile shelf ────────────────────────────────────────────────────────
   The seam between the identity card and the posts: a caption naming the pane
   below it, with the type dial at the far right. That's the masthead's own
   arrangement (nameplate left, filter right) borrowed for a page whose masthead
   is a photograph — so the caption takes the same tracked micro-caps the kicker
   and Discover's "TRENDING" wear, and the control is literally .masthead-filter,
   hue dot and all. One idiom, three pages.

   Flat editorial, deliberately NOT glass. It captions the content below rather
   than floating above it, and a second frosted bar tucked under the frosted
   identity card would be two panes of glass stacked with nothing in between. A
   hairline does the parting instead, the same rule the feed's own cards sit on.

   It renders only when there's something to narrow (see renderUser), so a quiet
   profile still runs its card straight into its posts, exactly as before. When
   it IS there it takes over the gap from .account + .feed, which is why the
   margins below are asymmetric: the long breath stays above the shelf, and the
   posts sit close under their own caption. */
.account + .profile-shelf { margin-top: 2.2rem; }
.profile-shelf {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin-bottom: 1.1rem;
  padding-inline: var(--inset);
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--rule);
}
/* The dial is a 40px disc on a ~17px line, so it sets the row height and would
   otherwise push the hairline miles off the caption. Half its overhang is taken
   back top and bottom (the tap target stays 40px — only the box it reserves
   shrinks), and the 8px nudge past --inset is the same optical alignment
   .masthead-row gives its own copy. */
.profile-shelf > .masthead-filter { margin-block: -0.55rem; margin-right: -8px; }
.profile-shelf-cap {
  font-size: 0.7rem;
  font-weight: 700;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--muted);
}
/* The shelf replaces the card-to-feed gap when it's present. */
.profile-shelf + .feed { margin-top: 0; }

/* The pre-friend tie sits at the foot of the identity column, on the same left
   axis as everything above it. (Your own card has no in-flow action — Share +
   Edit are corner icons; once you're friends the tie is a corner icon too.) */
.account-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;    /* left axis, matching name/stats/bio */
  gap: 0.5rem;
}

/* Corner badge cluster: a top-right row of small glass discs floating on the
   card. Own card carries Share + Edit; a friend's carries the friends tie. All
   share one disc treatment (a quiet outline on faint glass); only the colour
   differs per badge. */
.account-badges {
  position: absolute;
  top: 10px; right: 10px;
  z-index: 2;
  display: flex;
  gap: 6px;
}
.account-badges > button {
  width: 32px; height: 32px;
  display: grid; place-items: center;
  color: var(--muted);
  background: color-mix(in srgb, var(--glass-bg) 55%, transparent);
  border: 1px solid var(--rule);
  border-radius: 50%;
  transition: color var(--dur-micro), border-color var(--dur-micro),
              background-color var(--dur-micro), transform 0.12s;
}
@media (hover: hover) {
  .account-badges > button:hover { color: var(--text); border-color: var(--text); }
}
.account-friend-ico { width: 18px; height: 18px; }
.account-more-ico { width: 18px; height: 18px; }
/* The friends tie reads as a settled positive state (text ink + a firmer rim),
   so it sits a touch more present than the neutral ••• disc. */
.account-friend-badge { color: var(--text); border-color: color-mix(in srgb, var(--text) 22%, transparent); }

/* Reserve room on the name's first line so it clears the corner cluster. Every
   card now carries exactly one disc — the ••• glyph (own card + non-friend
   visitor, its menu holds Share/Edit or Block/Report) or the friend tie — so a
   single-disc reserve fits all of them. */
.account-card:has(.account-friend-badge) .account-name { padding-right: 2rem; }
.account-card:has(.account-more-badge) .account-name { padding-right: 2rem; }

/* Friend toggle (pre-friend states only — add / requested / accept; once you're
   friends it collapses to the corner badge). Centred in the action row. */
/* Every state is the SAME box — Add friend, Requested, Following, Add back all
   draw at one size, so the button never resizes under your thumb when it flips.
   Sizing lives here and only here; the state rules below change dress (gradient
   vs. outline), never geometry. The 44pt HIG target is drawn rather than bought
   with the shared ::after overlay, because the publish-fill variant spends
   ::after on its grain layer (see the .filter::after note). border-box is global,
   so the outline state's 1px border doesn't make it a pixel taller. */
.friend-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  min-width: 122px;
  min-height: 44px;
  border-radius: var(--radius-pill);
  padding: 0.46rem 1.35rem;
  font-weight: 700;
  font-size: 0.87rem;
  line-height: 1;
  transition: color var(--dur-micro), background-color var(--dur-micro), border-color var(--dur-micro);
}
/* The accent fill is the NON-gradient dress (only "Requested" wears it now, via
   [aria-pressed] below). Scoped off .publish-fill so it can't override the
   gradient's own background/border/colour — .friend-btn is later in the file and
   would otherwise win on equal specificity. */
.friend-btn:not(.publish-fill) {
  color: var(--on-accent);
  background: var(--accent);
  border: 1px solid var(--accent);
}
@media (hover: hover) {
  .friend-btn:not(.publish-fill):hover { background: var(--accent-press); border-color: var(--accent-press); }
}
.friend-btn[aria-pressed="true"] {
  color: var(--muted);
  background: none;
  border-color: var(--rule);
  font-weight: 400;
}
@media (hover: hover) {
  .friend-btn[aria-pressed="true"]:hover { color: var(--text); border-color: var(--text); }
}

/* Slim single-author meta line standing in for the byline on a profile: the date
   on the left, the type-tag pushed right (mirroring the byline layout). */
.card-solometa {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.8rem;
  font-size: 0.8rem;
  color: var(--muted);
  letter-spacing: 0.02em;
  margin-bottom: 0.7rem;
}
.card-solometa .dot { opacity: 0.5; margin: 0 0.15rem; }
.card--photo .card-solometa { padding-inline: var(--inset); margin-bottom: 1.15rem; }

/* ── Friends directory — a quiet list of rows linking out to each profile ────
   These rows only appear inside modals now (someone's circle, the composer's
   audience picker). Discover, which used to render them inline with a live
   search over them, is a grid of .ptile instead — so nothing filters this list
   any more, and the [hidden] machinery that went with it is gone. */
.friends-list { display: flex; flex-direction: column; }
.friend {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 1.1rem var(--inset);
  transition: background-color var(--dur-micro);
  animation: rise var(--dur-slow) var(--ease) both;   /* same soft rise as feed cards */
}
/* The list opens with a top rule of its own — the boundary comes from the
   content, not from a header divider. */
.friend:first-child { border-top: 1px solid var(--rule); }
.friend:not(:last-child) { border-bottom: 1px solid var(--rule); }
@media (hover: hover) { .friend:hover { background: var(--surface); } }
.friend .avatar { width: 56px; height: 56px; font-size: 1.8rem; }
.friend-text { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; flex: 1; }
.friend-name { font-weight: 700; font-size: 1rem; line-height: 1.2; color: var(--text); }
.friend-user { font-size: 0.82rem; color: var(--muted); }
.friend-go {
  flex-shrink: 0;
  color: var(--muted);
  font-size: 1.1rem;
  transition: color var(--dur-micro), transform var(--dur-micro);
}
@media (hover: hover) {
  .friend:hover .friend-go,
  .ptile:hover .friend-go { color: var(--text); transform: translateX(3px); }
}

/* Empty Discover list: the "no one new" note plus a soft ask to share Tria,
   with a copy-the-link pill wearing the Post button's gradient ring. */
.friends-share p { margin: 0; }
/* Standing at the foot of Find Friends — a hairline lifts it off the list above. */
.friends-share { border-top: 1px solid var(--rule); margin-top: 1.25rem; }
.friends-share-ask { margin-top: 0.35rem; }
.friends-share-copy {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  margin-top: 1rem;
  border-radius: var(--radius-pill);
  padding: 0.45rem 1rem;
  font-size: 0.85rem;
}
.friends-share-ico { width: 15px; height: 15px; }

/* ── Push pre-prompt — the soft ask on Updates. Still editorial (serif title,
   not a system alert), but on the same frosted-glass card material as the
   notifications it sits above, so the head of the Updates list reads as one
   family. The affirmative action wears the Post button's gradient, the dismiss
   stays a quiet ghost. ── */
.push-ask {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.85rem 1.2rem;
  margin: 0.35rem 0 1.5rem;
  padding: 1rem 1.15rem;
  border-radius: var(--radius-card);
  background: var(--glass-bg-panel);
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), var(--glass-lift);
}
.push-ask-copy { flex: 1 1 15rem; min-width: 0; }
.push-ask-title {
  margin: 0 0 0.25rem;
  font-family: 'Instrument Serif', Georgia, serif;
  font-size: 1.2rem;
  line-height: 1.05;
}
.push-ask-body { margin: 0; font-size: 0.88rem; line-height: 1.4; color: var(--muted); }
.push-ask-actions {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}
.push-ask-dismiss {
  border: none;
  background: none;
  border-radius: var(--radius-pill);
  padding: 0.45rem 0.9rem;
  font-size: 0.85rem;
  color: var(--muted);
}
.push-ask-dismiss:hover { color: var(--text); }
/* The affirmative action, but not a publish/share gesture — so it wears the
   monochrome ink fill (like the Save-style submits), not the reserved pastel
   publish-fill gradient. */
.push-ask-on {
  border-radius: var(--radius-pill);
  padding: 0.45rem 1.05rem;
  font-size: 0.85rem;
  font-weight: 700;
  border: none;
  color: var(--on-accent);
  background: var(--lit-top), var(--accent);
  box-shadow: var(--lit-rim), var(--lit-drop);
  transition: background-color var(--dur-micro) var(--ease);
}
.push-ask--out { opacity: 0; transition: opacity var(--dur-quick) var(--ease); }
@media (max-width: 460px) {
  .push-ask-actions { width: 100%; }
  .push-ask-on { margin-left: auto; }   /* push the pair to the right on its own row */
}

/* A settings-style row in the Edit profile form: label left, iOS-style switch
   right, spanning full width below the bio. */
.push-toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.7rem;
  width: 100%;
  margin-top: 1.1rem;   /* sits in the form now, matching the field rhythm */
}
.push-toggle-label { font-size: 0.9rem; color: var(--muted); }
.push-toggle {
  position: relative;
  width: 40px;
  height: 24px;
  border: none;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  transition: background var(--dur-quick) var(--ease);
  flex: none;
}
.push-toggle[aria-checked="true"] { background: var(--accent); }
.push-toggle-knob {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--bg);
  transition: transform var(--dur-quick) var(--ease);
}
.push-toggle[aria-checked="true"] .push-toggle-knob { transform: translateX(16px); }
.push-toggle:disabled { opacity: 0.55; }
@media (prefers-reduced-motion: reduce) {
  .push-toggle, .push-toggle-knob { transition: none; }
}

/* ── Updates — a quiet ledger. Hairline rows, no badge anywhere; the only
   "unread" signal is a small ink dot on rows newer than your last visit.
   Row geometry mirrors the Friends directory (rows carry the inset, hovers
   run edge to edge) so the two list pages read as one system. ── */
.notif-list {
  list-style: none;
  margin-block: 0;   /* inline margins stay auto (the shared centering above) */
  padding: 0;
  display: flex;
  flex-direction: column;
}
/* Each notification is a flat editorial row, hairline-divided — the same content-
   list language as the Friends directory (rows carry the inset, hovers run edge to
   edge), so the two list pages read as one system. Glass is reserved for what
   FLOATS above content (the seg-tabs, the soft-ask), never a content list itself;
   a stack of backdrop-filter cards is also exactly what iOS Safari re-samples every
   frame through a scroll or a page transition, so flat rows keep the ledger smooth
   as well as on-material. (Glass card era: v=95–118. Back to flat at v=120.) */
.notif {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 1.05rem var(--inset);
  color: var(--text);
  transition: background-color var(--dur-micro);
  animation: rise var(--dur-slow) var(--ease) both;   /* same soft rise as the directory rows */
}
/* Same reasoning as the Friends directory: no masthead divider now, so the list
   opens with its own top rule instead. */
.notif-list li:first-child .notif { border-top: 1px solid var(--rule); }
.notif-list li:not(:last-child) .notif { border-bottom: 1px solid var(--rule); }
@media (hover: hover) { .notif:hover { background: var(--surface); } }
/* Unread: a faint accent wash across the row (the dot still marks it), so fresh
   items read a touch warmer than the ones you've already seen. */
.notif--new { background: color-mix(in srgb, var(--accent) 7%, transparent); }
.notif-body {
  display: flex;
  flex-direction: column;
  gap: 0.18rem;
  min-width: 0;
  flex: 1;
}
.notif-text { font-size: 0.95rem; font-weight: 300; line-height: 1.4; }
.notif-text strong { font-weight: 700; }
.notif .tags { margin-top: 0.15rem; }
.notif-quote {
  font-size: 0.9rem;
  font-weight: 300;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.notif-date { font-size: 0.78rem; color: var(--muted); letter-spacing: 0.02em; }
.notif-dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--accent);
  opacity: 0;
  flex-shrink: 0;
}
.notif--new .notif-dot { opacity: 1; }

/* ── Friend requests on Updates — an actionable notice sitting atop the passive
   ledger. Same hairline-row language as the ledger below (and the Friends
   directory), under the site's section kicker; the only difference is the row
   carries Accept / Ignore where a passive row's unread dot would be. ── */
.requests { margin-bottom: 0.4rem; }
.requests-kicker {
  font-family: 'Oxygen', sans-serif;
  font-weight: 700;
  font-size: var(--kicker);
  text-transform: uppercase;
  letter-spacing: 0.16em;
  color: var(--muted);
  padding: 0 var(--inset);
  margin-bottom: 0.5rem;
}
.requests-list { list-style: none; margin-block: 0; padding: 0; }
.request-row {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  padding: 0.95rem var(--inset);
  border-bottom: 1px solid var(--rule);
  transition: background-color var(--dur-micro) var(--ease);
  animation: rise var(--dur-slow) var(--ease) both;   /* same soft rise as the ledger */
}
@media (hover: hover) { .request-row:hover { background: var(--surface); } }
.request-who {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  min-width: 0;
  flex: 1;
  color: var(--text);
}
.request-text { font-size: 0.95rem; font-weight: 300; line-height: 1.4; min-width: 0; }
.request-text strong { font-weight: 700; }
.request-actions { display: flex; align-items: center; gap: 0.25rem; flex-shrink: 0; }
/* Accept wears the compact filled-accent pill (the directory's Add); Ignore is
   a quiet muted text button beside it — secondary, understated. */
.request-accept {
  color: var(--on-accent);
  background: var(--accent);
  border: 1px solid var(--accent);
  border-radius: var(--radius-pill);
  padding: 0.4rem 1rem;
  font-size: 0.85rem;
  font-weight: 700;
  transition: background-color var(--dur-micro), border-color var(--dur-micro);
}
@media (hover: hover) {
  .request-accept:hover { background: var(--accent-press); border-color: var(--accent-press); }
}
.request-ignore {
  background: none;
  border: none;
  color: var(--muted);
  font-size: 0.85rem;
  padding: 0.4rem 0.55rem;
  transition: color var(--dur-micro);
}
@media (hover: hover) { .request-ignore:hover { color: var(--text); } }

/* ── Publish (composer) — aligned to the text column; a calm stack of fields ── */
.composer { padding-inline: var(--inset); }

/* Type picker reuses the filter chips, laid out as three equal segments. */
.type-pick { display: flex; gap: 0.5rem; margin-bottom: 1.9rem; }
.type-opt { flex: 1; text-align: center; padding-inline: 0.5rem; }

.composer .field:first-of-type { margin-top: 0; }
/* Softer, more iOS corners on the composer's own fields — a touch rounder than the
   shared --radius-img (8px), staying concentric under the 20px glass surfaces and
   in step with the filled liquid-glass Post button. Scoped to the composer + the
   inline edit form (which mirrors the composer's field layout) + the About page's
   feedback form, so post photos and other --radius-img corners are untouched. */
.composer .field input,
.composer .field textarea,
.composer .field--combo,
.composer .dropzone,
.edit-form .field input,
.edit-form .field textarea,
.edit-form .field--combo,
.fb-form .field input,
.fb-form .field textarea { border-radius: var(--radius-field); }
.composer-error {
  color: var(--type-find-ink);
  font-size: 0.85rem;
  margin-top: 1rem;
  min-height: 1.1rem;
}
/* Submits are compact, right-aligned actions — not full-width slabs. */
.composer-submit {
  display: block;
  width: auto;
  margin-top: 0.4rem;
  margin-left: auto;
  border: none;
  border-radius: var(--radius-pill);
  font-weight: 700;
  font-size: 0.95rem;
  padding: 0.62rem 1.6rem;
  transition: background-color var(--dur-micro);
}
/* Save-style submits stay monochrome ink; the composer's own Publish carries
   the shifting pastel fill instead (.publish-fill). */
.composer-submit:not(.publish-fill) {
  color: var(--on-accent);
  background: var(--lit-top), var(--accent);
  box-shadow: var(--lit-rim), var(--lit-drop);
}
@media (hover: hover) { .composer-submit:not(.publish-fill):hover { background: var(--lit-top), var(--accent-press); } }

/* The composer's own Post button: a centred, wider pill. Centring keeps it clear
   of the floating "+" (which used to sit on top of the right-aligned fill). It
   wears the same shifting-pastel liquid-glass fill as the nav Post and Share
   buttons (.publish-fill.is-solid) — the hero commit reads as the brand gesture. */
.composer .composer-post {
  margin-inline: auto;
  margin-top: 1.4rem;
  min-width: 13rem;
}
.composer .composer-post:disabled { opacity: 0.55; }

/* The reactive colour now lives full-screen: the page ambient wash adopts the
   inferred type's hue (see body[data-ambient="publish"] .ambient), instead of a
   pool sitting behind the already-glowing Post pill. */

/* Post progress — the composer's shared bar for a video's two slow phases (local
   trim re-encode, then the byte-level upload). Flat editorial, not glass: it's
   inline status on a content form, so it sits quiet above the Post button. The
   fill wears a still slice of the publish quintet so it reads as the same brand
   gesture as the button it feeds, without competing with it. */
.post-progress {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  width: 100%;
  /* Collapsed at rest, revealed by .is-active. We animate opacity + max-height
     (never display) and give it its own layer so iOS WebKit composites the reveal
     even while the main thread is busy encoding/uploading — a display:none→flex
     flip in that window silently fails to paint until the user scrolls. */
  max-height: 0;
  margin: 0 auto;
  opacity: 0;
  overflow: hidden;
  pointer-events: none;
  transform: translateZ(0);
  transition: max-height var(--dur-quick) var(--ease),
              opacity var(--dur-quick) var(--ease),
              margin var(--dur-quick) var(--ease);
}
.post-progress.is-active {
  max-height: 5rem;
  margin: 1rem auto 0.2rem;
  opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
  .post-progress { transition: none; }
}
.post-progress-track {
  width: 100%;
  height: 10px;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
  overflow: hidden;
}
.post-progress-fill {
  height: 100%;
  width: 0;
  border-radius: inherit;
  /* Lit top rim over the drifting quintet so the fill reads as a filled bar, not a
     flat block — same family as the publish button it feeds. */
  background:
    linear-gradient(rgba(255, 255, 255, 0.35), rgba(255, 255, 255, 0) 55%),
    linear-gradient(90deg, var(--type-find), var(--type-activity), var(--type-photo));
  transition: width var(--dur-quick) ease-out;
}
.post-progress-label {
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.01em;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
  .post-progress-fill { transition: none; }
}

/* The hero Post wears the same lit dome as the compose "+" (.nav-publish): a
   top-left specular gloss and a base cavity shadow layered over the drifting
   quintet, plus a contact + ambient drop, so it reads as a glossy 3D object,
   not a flat pill. Only the colour band drifts; the highlight and cavity stay
   put so the light source never wanders (mirrors publish-dome on the FAB). */
.composer .composer-post.publish-fill.is-solid,
.auth-submit.publish-fill.is-solid,
.friends-share-copy.publish-fill.is-solid {
  /* The drifting colour band, hoisted into a variable so the composer's Post pill
     can swap it (Post drops lime, Activity goes lime-forward) without redeclaring
     the dome radials. Auth / share keep the full quintet. */
  --pill-band: linear-gradient(115deg,
    var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
  box-shadow:
    0 2px 5px rgba(0, 0, 0, 0.16),
    0 10px 24px rgba(0, 0, 0, 0.18);
}
.composer .composer-post.publish-fill.is-solid::before,
.auth-submit.publish-fill.is-solid::before,
.friends-share-copy.publish-fill.is-solid::before {
  background:
    radial-gradient(75% 130% at 34% -20%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 55%),
    radial-gradient(92% 130% at 50% 120%, rgba(0, 0, 0, 0.16) 0%, rgba(0, 0, 0, 0) 55%),
    var(--pill-band);
  background-size: 100% 100%, 100% 100%, 300% 100%;
  background-position: 0 0, 0 0, 0% 50%;
  animation: publish-dome-pill 14s ease-in-out infinite;
}
/* Dark mode flattens the button: the black cavity + drop shadows all but vanish
   against a dark surface, so the volume has to come from LIGHT. Brighter hotspot,
   a deeper cavity that still bites the pastel fill, and a crisp lit top rim, so
   it reads as a glossy 3D object in the dark too. Light is left as-is. */
@media (prefers-color-scheme: dark) {
  .composer .composer-post.publish-fill.is-solid,
  .auth-submit.publish-fill.is-solid,
  .friends-share-copy.publish-fill.is-solid {
    box-shadow:
      inset 0 1px 0 rgba(255, 255, 255, 0.7),        /* lit top rim (reads on dark) */
      inset 0 -10px 18px rgba(0, 0, 0, 0.28),         /* cavity depth into the fill */
      0 2px 6px rgba(0, 0, 0, 0.45),
      0 14px 32px rgba(0, 0, 0, 0.55);                 /* float, darker to clear the dark bg */
  }
  .composer .composer-post.publish-fill.is-solid::before,
  .auth-submit.publish-fill.is-solid::before,
  .friends-share-copy.publish-fill.is-solid::before {
    background:
      radial-gradient(70% 120% at 34% -16%, rgba(255, 255, 255, 0.92) 0%, rgba(255, 255, 255, 0) 52%),
      radial-gradient(95% 130% at 50% 122%, rgba(0, 0, 0, 0.34) 0%, rgba(0, 0, 0, 0) 55%),
      var(--pill-band);
    /* The `background` shorthand above resets background-size/position, so restate
       them: the band stays 300%-wide (2-3 colours in view, drifting) like light. */
    background-size: 100% 100%, 100% 100%, 300% 100%;
    background-position: 0 0, 0 0, 0% 50%;
  }
}
/* Only the colour band drifts; the highlight and cavity stay pinned so the light
   source never wanders. */
@keyframes publish-dome-pill {
  0%, 100% { background-position: 0 0, 0 0, 0% 50%; }
  50%      { background-position: 0 0, 0 0, 100% 50%; }
}
@media (prefers-reduced-motion: reduce) {
  .composer .composer-post.publish-fill.is-solid::before,
  .auth-submit.publish-fill.is-solid::before,
  .friends-share-copy.publish-fill.is-solid::before {
    animation: none;
    background-position: 0 0, 0 0, 50% 50%;
  }
}

/* ── Audience row (activity composer) — an iOS settings-style disclosure cell:
   "Shared with  Everyone in your circle ›". Reads as a field, opens the picker
   sheet. (Design preview, demo-gated.) */
/* Audience lock — the shared "who sees this" control at the foot of every
   composer. Flat editorial (the composer is content, never glass): a quiet pill
   of icon + current level. It sits bottom-left of the Post attach bar and as its
   own field on the Activity form. */
.aud-lock {
  -webkit-appearance: none;
  appearance: none;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.3rem 0.6rem 0.3rem 0.45rem;
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  background: none;
  color: var(--muted);
  font: inherit;
  font-size: 0.86rem;
  font-weight: 600;
  cursor: pointer;
  transition: color var(--dur-micro), border-color var(--dur-micro), background var(--dur-micro);
}
.aud-lock-ico { width: 16px; height: 16px; flex-shrink: 0; }
.aud-lock-label { white-space: nowrap; }
@media (hover: hover) {
  .aud-lock:hover { color: var(--text); border-color: var(--muted); background: var(--surface-2); }
}
.aud-lock:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Attach bar with the lock present: lock hugs the left, the link/photo/poll tools
   stay clustered on the right. With no tools (the Activity box) the lone lock
   still lands left, since space-between puts a single child at the start. */
.rich-attach--withlock { justify-content: space-between; }
.rich-attach-tools { display: flex; align-items: center; gap: 0.1rem; }

/* Audience sheet — a clean iOS checkmark list. No hard borders (they read as
   harsh rings on frosted glass); selection is carried by a soft tint + an
   accent tick. The checklist glides open/shut (grid-rows, like the About folds)
   and its rows cascade in with the app's shared stagger. */
/* SVG check as a MASK so the mark inherits a themed colour (accent / bg) without
   per-theme duplicate URIs. */
:root {
  --check-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='3.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
  /* Y2K four-point sparkle: sharp points, pinched (concave) waists. Shared by
     every .spark — the like-tap burst and the fresh-post celebration — each
     painted in the post's --burst colour like the ink flood. */
  --spark-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='M12 1c.4 7 3.6 10.6 11 11-7.4.4-10.6 4-11 11-.4-7-3.6-10.6-11-11 7.4-.4 10.6-4 11-11z'/%3E%3C/svg%3E");
}

/* Two modes grouped in one inset-list card, split by a hairline — no outlines. */
.aud-modes {
  border-radius: var(--radius-field);
  overflow: hidden;
  background: color-mix(in srgb, var(--text) 4%, transparent);
  margin-bottom: 0.6rem;
}
.aud-mode {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-rows: auto auto;
  gap: 0.04rem 0.6rem;
  width: 100%;
  text-align: left;
  padding: 0.72rem 0.9rem;
  border: 0;                 /* kill the UA default 2px outset button border */
  background: none;
  color: var(--text);
  transition: background-color var(--dur-micro);
}
.aud-mode + .aud-mode { box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 8%, transparent); }
.aud-mode[aria-pressed="true"] { background: color-mix(in srgb, var(--accent) 9%, transparent); }
.aud-mode-t { grid-column: 1; font-weight: 700; font-size: 0.95rem; }
.aud-mode-d { grid-column: 1; font-size: 0.82rem; color: var(--muted); }
.aud-mode-tick {
  grid-column: 2;
  grid-row: 1 / span 2;
  align-self: center;
  width: 18px;
  height: 18px;
  background: var(--accent);
  -webkit-mask: var(--check-mask) center / contain no-repeat;
          mask: var(--check-mask) center / contain no-repeat;
  opacity: 0;
  transform: scale(0.6);
  transition: opacity var(--dur-micro) var(--ease), transform var(--dur-micro) var(--ease);
}
.aud-mode[aria-pressed="true"] .aud-mode-tick { opacity: 1; transform: scale(1); }

/* Collapsible checklist: grid-rows 0fr→1fr glide (matches .about-fold-panel). */
.aud-list-wrap {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows 0.32s var(--ease), opacity var(--dur-quick) ease;
}
.aud-list-wrap.is-open { grid-template-rows: 1fr; opacity: 1; }
.aud-list-wrap > .aud-list { min-height: 0; }

.aud-pick {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  width: 100%;
  padding: 0.55rem 1.6rem;
  border: 0;                 /* kill the UA default 2px outset button border */
  background: none;
  color: var(--text);
  text-align: left;
  animation: rise 0.4s var(--ease) both;   /* the app's shared row entrance */
}
.aud-pick .aud-avatar { width: 44px; height: 44px; font-size: 1.4rem; }
/* Trailing selector: a soft empty ring at rest (never a hard white outline),
   an accent disc with a bg-coloured check when picked. */
.aud-check {
  position: relative;
  margin-left: auto;
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  border: 1.5px solid color-mix(in srgb, var(--text) 16%, transparent);
  transition: background-color var(--dur-micro), border-color var(--dur-micro);
}
.aud-check::after {
  content: '';
  position: absolute;
  inset: 0;
  background: var(--bg);
  -webkit-mask: var(--check-mask) center / 12px no-repeat;
          mask: var(--check-mask) center / 12px no-repeat;
  opacity: 0;
  transition: opacity var(--dur-micro);
}
.aud-pick[aria-checked="true"] .aud-check { background: var(--accent); border-color: var(--accent); }
.aud-pick[aria-checked="true"] .aud-check::after { opacity: 1; }
@media (hover: hover) { .aud-pick:hover { background: color-mix(in srgb, var(--text) 4%, transparent); } }
.aud-empty { color: var(--muted); text-align: center; padding: 1.2rem 0; }
@media (prefers-reduced-motion: reduce) {
  .aud-list-wrap { transition: opacity var(--dur-quick) ease; }
  .aud-pick { animation: none; }
}

/* ── Frame picker — a plain upload field ────────────────────────────────────
   Same frame + fill as every other composer field (.field input/textarea) —
   a field that happens to hold an upload prompt instead of text, not a separate
   material. Tapping it opens the OS picker; picking a photo swaps in the .crop
   preview below, a video swaps in the .trim surface (the dropzone hides while
   they show). Content, not floating chrome. */
.dropzone {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  width: 100%;
  padding: 0.7rem 0.85rem;
  font: inherit;
  color: var(--muted);
  text-align: left;
  border: 1px solid var(--rule);
  border-radius: var(--radius-field);
  background: var(--surface);
  cursor: pointer;
  /* This is a role=button <div>, NOT a <button> (see frameFieldHtml): a native
     filled form control paints an un-removable pressed-state fill on iOS
     standalone PWAs — the white tap-flash — that -webkit-appearance:none can't
     kill. A div has no native chrome, so it can't. user-select:none replaces
     what the <button> reset gave (no long-press callout on the label); the
     tap-highlight is already pinned transparent by the reset's [role=button]. */
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: border-color var(--dur-micro), color var(--dur-micro);
}
@media (hover: hover) {
  .dropzone:hover {
    border-color: color-mix(in srgb, var(--text) 28%, transparent);
    color: var(--text);
  }
}
.dropzone[hidden] { display: none; }
.dropzone-ico { width: 22px; height: 22px; flex-shrink: 0; color: var(--muted); }
/* Same size/family as the Find link input's text (both are plain field inputs,
   font: inherit) — muted, reading like that field's placeholder prompt. */
.dropzone-label { font: inherit; }

/* ── Trim surface — the post-capture review + iOS-style scrubber ────────────
   A recorded or library-picked clip plays here (looping inside the window),
   with a filmstrip below carrying a draggable ≤10s selection. The stage reads
   like the capture surface (dark, contain-fit); the strip is a flat editorial
   control (content, not floating chrome), with the Frame's own cyan accent on
   the window + handles. See wireFrameCapture / finishVideo. */
.trim { display: flex; flex-direction: column; gap: 12px; }
.trim[hidden] { display: none; }

/* The stage hugs the clip's own aspect ratio — width shrinks to fit-content and
   centres, so a tall (portrait) clip no longer pillarboxes and a wide one no
   longer letterboxes. The video is sized by its own max-width/height (keeping
   its native ratio), and the stage wraps it exactly, so no matte ever shows. */
.trim-stage {
  position: relative;
  width: -webkit-fit-content;   /* older iOS Safari needs the prefix, else falls to full-width */
  width: fit-content;
  max-width: 100%;
  margin-inline: auto;
  border-radius: 14px;
  overflow: hidden;
  background: var(--surface-2);
}
.trim-video {
  display: block;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 46vh;
  object-fit: contain;
}
/* ── Trim loading + reveal ───────────────────────────────────────────────────
   While we read metadata + sample the filmstrip off this one element, the clip is
   being seeked around. We hide those frames, hold the surface open with a calm
   cyan shimmer (the photo-type colour), then reveal the preview + strip together
   once playback starts — a settle, never a pop. */
.trim-video {
  transition: opacity var(--dur-slow) var(--ease);
}
.trim-loading {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: none;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  color: var(--muted);
  font-size: 0.85rem;
  letter-spacing: 0.01em;
  pointer-events: none;
}
.trim-loading-dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--type-photo);
  box-shadow: 0 0 0 0 color-mix(in srgb, var(--type-photo) 55%, transparent);
  animation: trim-pulse 1.1s var(--ease-soft) infinite;
}
.trim--loading .trim-stage {
  min-height: 200px;
  /* A soft cyan sweep over the neutral placeholder — reads as "developing." */
  background-image: linear-gradient(100deg,
    transparent 38%,
    color-mix(in srgb, var(--type-photo) 16%, transparent) 50%,
    transparent 62%);
  background-size: 220% 100%;
  background-repeat: no-repeat;
  animation: trim-shimmer 1.25s linear infinite;
}
.trim--loading .trim-video { opacity: 0; }
.trim--loading .trim-loading { display: flex; }
.trim--loading .reel {
  pointer-events: none;   /* no scrubbing until the preview is ready */
  background-image: linear-gradient(100deg,
    transparent 38%,
    color-mix(in srgb, var(--type-photo) 16%, transparent) 50%,
    transparent 62%);
  background-size: 220% 100%;
  background-repeat: no-repeat;
  animation: trim-shimmer 1.25s linear infinite;
}
/* Nothing to see behind the shimmer — hide the frame/scrims/playhead/ticks until
   the clip is ready, so they don't flash in over the neutral track. */
.trim--loading .reel-frame,
.trim--loading .reel-scrim,
.trim--loading .reel-playhead,
.trim--loading .reel-ticks { opacity: 0; }
@keyframes trim-shimmer {
  from { background-position: 220% 0; }
  to   { background-position: -120% 0; }
}
@keyframes trim-pulse {
  0%   { transform: scale(0.85); box-shadow: 0 0 0 0 color-mix(in srgb, var(--type-photo) 55%, transparent); }
  70%  { transform: scale(1);    box-shadow: 0 0 0 7px transparent; }
  100% { transform: scale(0.85); box-shadow: 0 0 0 0 transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .trim-video { transition: none; }
  .trim--loading .trim-stage,
  .trim--loading .reel { animation: none; }
  .trim-loading-dot { animation: none; }
}
.trim-sound {
  position: absolute;
  right: 10px;
  bottom: 10px;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  display: grid;
  place-items: center;
  color: #fff;
  background: color-mix(in srgb, #000 45%, transparent);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  cursor: pointer;
}
.trim-sound-ico { width: 20px; height: 20px; }

/* ── Trim reel ───────────────────────────────────────────────────────────────
   A full-width, horizontally-scrollable thumbnail filmstrip with a fixed centered
   selection frame. You SCROLL the reel to choose the moment and drag the frame's
   ends to set length (≤10s). Content, not floating chrome, in the Frame's cyan
   accent. Thumbnails are a progressive enhancement (neutral cells if a sample comes
   back blank); the frame + ticks + scrims carry it either way. The frame/scrims/
   playhead are absolutely positioned within .reel-wrap and offset by 21px (the
   ticks row: 15px tall + 6px gap) so they overlay the 60px reel exactly. */
.reel-wrap { position: relative; }
.reel-ticks { position: relative; height: 15px; margin-bottom: 6px; overflow: hidden; }
.reel-tick {
  position: absolute;
  top: 0;
  left: 0;
  font: 600 10px/1 ui-monospace, monospace;
  color: var(--muted);
  letter-spacing: .02em;
  white-space: nowrap;
  transition: opacity var(--dur-slow) var(--ease);
}
.reel {
  position: relative;
  height: 60px;
  border-radius: 12px;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-x;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  background: var(--surface-2);
}
.reel::-webkit-scrollbar { display: none; }
.reel-track { position: relative; height: 100%; }
.reel-thumb {
  position: absolute;
  top: 0;
  height: 100%;
  background-size: cover;
  background-position: center;
  background-color: color-mix(in srgb, var(--surface-2) 65%, #000);
}
/* The fixed selection frame + scrims + playhead do NOT scroll — the reel moves
   under them. The frame is CSS-centered; JS sets its width. */
.reel-frame {
  position: absolute;
  top: 21px;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  box-sizing: border-box;
  border: 2px solid var(--type-photo);
  border-radius: 10px;
  pointer-events: none;
  z-index: 3;
  transition: opacity var(--dur-slow) var(--ease);
}
.reel-scrim {
  position: absolute;
  top: 21px;
  bottom: 0;
  background: color-mix(in srgb, var(--bg) 60%, transparent);
  pointer-events: none;
  z-index: 2;
  transition: opacity var(--dur-slow) var(--ease);
}
.reel-handle {
  position: absolute;
  top: -2px;
  bottom: -2px;
  width: 22px;
  background: var(--type-photo);
  pointer-events: auto;
  touch-action: none;
  cursor: ew-resize;
}
.reel-handle--l { left: -2px; border-radius: 10px 0 0 10px; }
.reel-handle--r { right: -2px; border-radius: 0 10px 10px 0; }
.reel-handle::after {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 3px;
  height: 20px;
  border-radius: 2px;
  background: color-mix(in srgb, var(--type-photo-ink) 70%, #000);
}
.reel-playhead {
  position: absolute;
  top: 18px;
  bottom: -3px;
  width: 2px;
  border-radius: 2px;
  background: #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .35);
  pointer-events: none;
  z-index: 4;
  transition: opacity var(--dur-slow) var(--ease);
}
.trim-meta {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  flex-wrap: wrap;
}
.trim-dur {
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  font-size: .9rem;
  padding: 2px 9px;
  border-radius: 999px;
  color: var(--text);
  background: color-mix(in srgb, var(--type-photo) 22%, transparent);
}
.trim-hint { color: var(--muted); font-size: .8rem; }

.crop {
  position: relative;
  width: 100%;
  aspect-ratio: 1 / 1;
  overflow: hidden;
  border-radius: var(--radius-img);
  background: var(--surface-2);
  cursor: grab;
  touch-action: none;      /* let us own the drag on touch */
  user-select: none;
}
.crop.dragging { cursor: grabbing; }
.crop img { position: absolute; top: 0; left: 0; max-width: none; }
.crop-hint {
  position: absolute;
  left: 50%;
  bottom: 0.6rem;
  transform: translateX(-50%);
  padding: 0.2rem 0.7rem;
  font-size: 0.75rem;
  color: #fff;
  background: rgba(0, 0, 0, 0.5);
  border-radius: var(--radius-pill);
  pointer-events: none;
}
.crop.dragging .crop-hint { opacity: 0; }
/* Swap-the-frame control: one centred pill (Choose another) that re-opens the OS
   picker, same editorial chip material as the type filters — a content-area
   control, not floating glass. */
.crop-replace {
  display: block;
  width: fit-content;          /* role=button div: shrink-wrap so margin:auto still centers it */
  margin: 0.8rem auto 0;
  padding: 0.5rem 1.1rem;
  font-size: 0.85rem;
  font-weight: 400;
  line-height: 1;
  color: var(--muted);
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  cursor: pointer;
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;   /* same iOS tap-flash fix as .dropzone */
  transition: color var(--dur-micro), border-color var(--dur-micro), background-color var(--dur-micro);
}
.crop-replace[hidden] { display: none; }
@media (hover: hover) {
  .crop-replace:hover { color: var(--text); border-color: color-mix(in srgb, var(--text) 28%, transparent); }
}

/* Profile editor's photo: a big centred avatar with a camera badge at its corner.
   Tapping the badge picks a file, which hides this row and reveals the inline crop
   below. */
.pf-photo {
  display: flex;
  justify-content: center;
  margin-bottom: 1rem;
}
.pf-photo[hidden] { display: none; }
.pf-photo-figure { position: relative; display: inline-block; }
.pf-photo-avatar { width: 116px; height: 116px; font-size: 3.7rem; }
.pf-photo-edit {
  position: absolute;
  right: -2px; bottom: -2px;
  width: 34px; height: 34px;
  display: grid; place-items: center;
  color: var(--on-accent);
  background: var(--accent);
  border: 2px solid var(--bg);
  border-radius: 50%;
  cursor: pointer;             /* role=button div, not <button> — no free cursor */
  -webkit-user-select: none;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
  transition: background-color var(--dur-micro), transform 0.12s;
}
.pf-photo-ico { width: 16px; height: 16px; }
@media (hover: hover) { .pf-photo-edit:hover { background: var(--accent-press); } }

/* ── Modal (avatar editor) — a frosted overlay + a calm card ───────────────── */
.modal {
  position: fixed;
  inset: 0;
  z-index: 250;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
  background: color-mix(in srgb, var(--bg) 55%, transparent);
  /* Panel tier: a full-screen sample, but of a page that cannot scroll while the
     modal owns it, so the radius is free after the first frame. */
  backdrop-filter: var(--glass-filter-panel);
  -webkit-backdrop-filter: var(--glass-filter-panel);
  animation: fade 0.18s ease;
}
.modal-card {
  width: 100%;
  max-width: 380px;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius-img);
  padding: 1.6rem;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.28);
  animation: rise var(--dur-quick) var(--ease);
}
/* Liquid-glass card: the same frosted panel language as the nav pill, scaled up
   to a modal. Translucent frosted background (the dimmed page saturates through
   it), a hairline border, a top inner highlight (the "lit rim"), and a deep
   float shadow. The .modal veil already blurs what's behind, so this reads as a
   pane of glass floating over frosted glass rather than a flat opaque sheet. */
.modal-card--glass {
  /* iOS HIG rounding: a soft continuous-feeling corner in the card/sheet range
     (well above the 8px --radius-img), so the glass panel reads like a native
     iOS modal rather than a boxy web dialog. */
  border-radius: var(--radius-modal);
  background: var(--glass-bg-panel);
  backdrop-filter: var(--glass-filter-panel);
  -webkit-backdrop-filter: var(--glass-filter-panel);
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), var(--glass-lift-panel);
}
.modal-title {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-size: 1.7rem;
  color: var(--text);
  margin-bottom: 1.1rem;
}
/* The avatar crop is a centered circle (what you see is what you get — photos
   display round), not full-width like the composer's. */
.crop--avatar { width: 260px; max-width: 100%; margin: 0 auto; border-radius: 50%; }

/* Post photos aren't cropped — the composer preview shows the whole image at its
   own aspect ratio, so there's nothing to pan (no square frame, no drag hint). */
.crop--free { aspect-ratio: auto; cursor: default; touch-action: auto; }
.crop--free img { position: static; width: 100%; height: auto; max-width: 100%; }
/* The friends-list modal: a scrollable directory inside the frosted card. Rows
   reuse the .friend geometry but sit edge-to-edge in the card (negative inset
   cancels the card padding) with a lighter vertical rhythm. */
.modal-card--list { max-width: 420px; }
/* Edge-to-edge scroll area inside the card (negative inset cancels the padding);
   a soft top/bottom mask fades rows into the frost as they scroll under the
   title and Close row, matching the read-more fade elsewhere. */
.friends-list--modal {
  max-height: min(60vh, 26rem);
  overflow-y: auto;
  margin: 0 -1.6rem;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14px, #000 calc(100% - 14px), transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 14px, #000 calc(100% - 14px), transparent);
}
.friends-list--modal .friend { padding: 0.85rem 1.6rem; }
/* The modal's top-mask already fades the first row into the frost, so it doesn't
   need the flat directory's top rule (see .friend:first-child above) — a hard
   line there would fight the fade. */
.friends-list--modal .friend:first-child { border-top: none; }
.friends-list--modal .friend .avatar { width: 44px; height: 44px; font-size: 1.4rem; }

.modal-actions { display: flex; gap: 0.6rem; justify-content: center; margin-top: 1.2rem; }
.modal-actions .edit-cancel {
  color: var(--muted);
  background: none;
  border: 1px solid var(--rule);
  border-radius: var(--radius-pill);
  padding: 0.6rem 1.2rem;
  font-size: 0.92rem;
}
.modal-actions .composer-submit { width: auto; margin: 0; padding: 0.6rem 1.4rem; }

/* Account actions — Log out + Delete, grouped in their own zone below the form's
   Cancel/Save row and split off by a hairline. Icon + label each. Log out stays
   neutral; Delete carries a resting coral (find) tint so it reads as the risky
   one at a glance. The confirm sheet still guards Delete, so grouping them is safe. */
.pf-account {
  display: flex;
  gap: 0.6rem;
  justify-content: space-between;
  margin-top: 1.1rem;
  padding-top: 1rem;
  border-top: 1px solid var(--rule);
}
.pf-account-btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--muted);
  background: none;
  border: none;
  padding: 0.5rem 0.85rem;
  font-size: 0.9rem;
  border-radius: var(--radius-pill);
  transition: color var(--dur-micro), background var(--dur-micro);
}
.pf-account-btn svg { width: 18px; height: 18px; }
.pf-delete { color: var(--type-find-ink); }
@media (hover: hover) {
  .pf-logout:hover { color: var(--text); background: color-mix(in srgb, var(--text) 8%, var(--bg)); }
  .pf-delete:hover { background: color-mix(in srgb, var(--type-find) 18%, var(--bg)); }
}
.pf-logout:focus-visible { color: var(--text); background: color-mix(in srgb, var(--text) 8%, var(--bg)); }
.pf-delete:focus-visible { background: color-mix(in srgb, var(--type-find) 18%, var(--bg)); }

/* Toast — a brief, quiet notice; sits above the mobile bottom nav + home bar.
   Tops the whole stack (above modals at 250 and the filter layer at 400): the
   notifications toggle lives inside the Edit profile modal, so its confirmation
   toast has to clear that veil or it flashes invisibly behind it. */
.toast {
  position: fixed;
  left: 50%;
  bottom: calc(98px + env(safe-area-inset-bottom));
  transform: translate(-50%, 0.8rem);
  z-index: 500;
  max-width: min(88vw, 340px);
  padding: 0.7rem 1.1rem;
  background: var(--text);
  color: var(--bg);
  border-radius: var(--radius-img);
  font-size: 0.9rem;
  text-align: center;
  box-shadow: 0 10px 34px rgba(0, 0, 0, 0.24);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-quick) var(--ease), transform var(--dur-quick) var(--ease);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }
@media (prefers-reduced-motion: reduce) { .toast { transition: opacity var(--dur-quick) ease; } }
.modal-actions .composer-submit:disabled { opacity: 0.5; }
@media (hover: hover) { .modal-actions .edit-cancel:hover { color: var(--text); border-color: var(--text); } }
@keyframes fade { from { opacity: 0; } }

@keyframes rise {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* Closing mirrors the open: the frost fades and the card sinks back down the
   same 12px it rose, so dismissing reads as the reverse of appearing. */
@keyframes fade-out { to { opacity: 0; } }
@keyframes sink {
  from { opacity: 1; transform: translateY(0); }
  to   { opacity: 0; transform: translateY(12px); }
}
.modal--closing { animation: fade-out var(--dur-quick) var(--ease) forwards; }
.modal--closing .modal-card { animation: sink var(--dur-quick) var(--ease) forwards; }
@media (prefers-reduced-motion: reduce) {
  .modal--closing, .modal--closing .modal-card { animation: fade-out var(--dur-micro) ease forwards; }
  /* Entrances and the tile's hover lift go. What stays is the tile face's
     opacity fade — a cross-fade isn't motion, and without it a photo would
     still pop in over its placeholder. */
  .card, .friend, .ptile, .notif, .request-row { animation: none; }
  .ptile, .ptile:hover { transform: none; transition: none; }
}

/* ── Mobile: sidebar → top brand bar + bottom tab bar; entries go edge-to-edge ── */
@media (max-width: 680px) {
  /* Slimmer, left-aligned header on phones. Overriding the token keeps the bar
     height and main's top padding in sync automatically. */
  :root { --topbar-h: 60px; }

  .topbar {
    top: 0;                          /* undo the desktop float inset */
    left: 0;
    z-index: 100;
    width: auto;
    right: 0;
    height: calc(var(--topbar-h) + env(safe-area-inset-top));
    padding: env(safe-area-inset-top) 1.15rem 0;   /* left edge aligns with content */
    border-right: none;
    /* The scroll edge effect, which is what iOS 26 uses instead of a bar with a
       rule under it. The fill is heaviest at the very top — that band is the
       safe-area inset, i.e. exactly where the OS clock and battery sit and need
       something to read against — and thins toward the bottom edge so content
       dissolves INTO the bar rather than stopping at a wall. The gradient IS the
       edge, so the hairline goes: a hard 1px rule under a fading bar draws the
       one line the effect exists to remove.

       It also means the bar does most of the status-bar scrim's job while it's
       on screen. The scrim still matters, because .topbar--hidden takes all of
       this away on scroll-down and leaves bare feed under the glyphs. */
    border-bottom: none;
    background: linear-gradient(
      180deg,
      color-mix(in srgb, var(--bg) 84%, transparent) 0%,
      color-mix(in srgb, var(--bg) 70%, transparent) 58%,
      color-mix(in srgb, var(--bg) 44%, transparent) 100%);
    backdrop-filter: blur(var(--glass-blur));
    -webkit-backdrop-filter: blur(var(--glass-blur));
    transition: transform var(--dur-move) var(--ease);
  }
  /* Scrolling down tucks the bar away (see the scroll watcher in app.js);
     scrolling up brings it straight back. */
  .topbar.topbar--hidden { transform: translateY(-100%); }
  @media (prefers-reduced-motion: reduce) { .topbar { transition: none; } }
  .brand-mark { font-size: 1.6rem; }

  /* Tray docks into the header bar: bare glyph pushed to the right edge
     (opposite the wordmark), no glass disc — the frosted topbar already is the
     chrome, so a disc-on-glass would double up. Full 44px tap target, which
     centres the 22px glyph 11px in from the bar's padding; the -10px pulls the
     glyph itself out to the masthead filter/search line (those are 40px boxes
     nudged -8px, so they sit 1px in) without shrinking the tap area. */
  .share-tria {
    position: static;
    top: auto;
    right: auto;
    margin-left: auto;
    margin-right: -10px;
    background: none;
    border: none;
    box-shadow: none;
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  /* Floating liquid-glass nav: the old edge-to-edge tab bar becomes a detached
     pill hovering above the bottom edge, with Post breaking out beside it as
     its own round gradient button (Zoe's nav overhaul, July 2026). The .nav
     wrapper itself is now just an invisible positioning row — the glass lives
     on .nav-pill so the gap between pill and button stays tap-transparent. */
  .nav {
    top: auto;
    /* Hug the bottom edge. iOS floating tab bars (Liquid Glass) sit close to the
       home indicator, not lifted well into the screen — safe-area-inset-bottom
       already reserves the indicator, so this is a small float over it, not a gap. */
    bottom: calc(0.5rem + env(safe-area-inset-bottom));
    left: 0;
    right: 0;
    width: auto;
    height: auto;
    z-index: 101;                     /* the pill + dial ride above the dial veil (100) */
    /* The + is a --fab-size disc set --nav-gap off the pill; on the compose page
       it tucks away and the pill slides right by half that footprint to recenter
       (see .nav--compose). Both are vars so the recenter maths stays exact. */
    --fab-size: 56px;
    --nav-gap: 0.7rem;
    flex-direction: row;
    align-items: center;
    justify-content: center;
    gap: var(--nav-gap);
    padding: 0;
    border: none;
    border-radius: 0;
    box-shadow: none;                 /* undo the desktop card's float shadow/rim */
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    pointer-events: none;             /* the row spans the screen; only its children take taps */
  }
  .nav-pill, .nav-publish { pointer-events: auto; }
  .nav-pill {
    position: relative;               /* anchors the sliding .nav-glide highlight */
    display: flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.4rem 0.55rem;
    border-radius: var(--radius-pill);
    background: var(--glass-bg);
    backdrop-filter: blur(var(--glass-blur));
    -webkit-backdrop-filter: blur(var(--glass-blur));
    /* Glass edges: a hairline border, the specular rim around the whole
       perimeter, and a soft drop so the pill visibly floats off the page. */
    border: var(--glass-edge);
    box-shadow: var(--glass-rim), var(--glass-lift);
    /* Keep the pill on its own compositing layer. iOS Safari (standalone) will
       otherwise drop a fixed + backdrop-filter element's layer after a big DOM
       swap (a page switch), leaving the nav invisible until a scroll repaints
       it. Pinning the layer here, plus the repaint nudge in route(), fixes it. */
    transform: translateZ(0);
    /* Glides to true center when the + drops away on the compose page. */
    transition: transform var(--dur-move) var(--ease);
  }
  /* Icon-only destinations: labels stay in the DOM for the accessible name but
     drop from view. Each target is a 50px circle with a 28px glyph — trimmed a
     touch from the earlier 56px, still well above the 44pt iOS tap-target floor. */
  .nav-link {
    width: 50px;
    height: 50px;
    padding: 0;
    justify-content: center;
    border-radius: var(--radius-pill);
  }
  .nav-link .nav-label { display: none; }
  .nav-link .nav-ico { width: 28px; height: 28px; }
  /* The active highlight is the glide below, not a per-link background, so it
     can SLIDE between destinations instead of blinking from one to the next.
     The icon stays --accent (bright in dark, ink in light) — it's LIT by the
     glow behind it, not sitting on a chip, so the pill supplies its contrast. */
  .nav-link[aria-current="page"] { color: var(--accent); }
  /* Press compression lives in the shared press engine (app.js) — `scale`, so it
     never fights the FAB's own transforms or the glide. */
  /* Ambient light, not a chip: the active marker glows like the dial icons.
     The Post button's drifting quintet (publish-shift, shared keyframes) is the
     colour engine so it cycles the brand hues; a tight radial mask pulls that
     into a hot core fading to nothing (the dial's radial bloom), and a blur
     turns it into coloured light pooling under the icon and spilling into the
     frosted pill. Saturate + brighten so the pastels actually read as glow.
     z-index -1 keeps it above the pill glass but under the icons (the pill's
     translateZ layer is the stacking context that scopes it). */
  .nav-glide {
    display: block;
    position: absolute;
    top: 0;
    left: 0;
    z-index: -1;
    border-radius: var(--radius-pill);
    background: linear-gradient(115deg,
      var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
    background-size: 300% 100%;
    animation: publish-shift 14s ease-in-out infinite;
    -webkit-mask: radial-gradient(circle at 50% 50%, #000 8%, transparent 58%);
            mask: radial-gradient(circle at 50% 50%, #000 8%, transparent 58%);
    filter: blur(20px) saturate(1.7) brightness(1.2) opacity(0.75);
    opacity: 0;
    transition: transform var(--dur-move) var(--ease), opacity var(--dur-micro) var(--ease);
    pointer-events: none;
  }
  @media (prefers-reduced-motion: reduce) {
    .nav-glide {
      animation: none;
      background-position: 50% 50%;   /* rest on a static mid-quintet blend */
      transition: opacity var(--dur-micro) ease;
    }
  }

  /* Post breaks out of the pill as a round button. On a screen a gradient
     reads best as LIGHT, so this isn't a flat quintet disc: the five type
     colours drift UNDERNEATH a fixed lit dome — a specular hotspot up at the
     top-left and a soft cavity shadow pooling at the base — so it reads as a
     glossy, lit object with a real light direction. All of it lives in the
     ::before (the ring mask is dropped here), so there's no layer-order fight
     with the grain (::after) or the + glyph riding on top. */
  .nav-publish {
    margin: 0;
    width: var(--fab-size);
    height: var(--fab-size);
    padding: 0;
    color: var(--on-type);
    border-radius: 50%;
    /* A tighter contact shadow under a wider ambient one — the button sits in
       the scene rather than floating on a single flat drop. */
    box-shadow:
      0 2px 5px rgba(0, 0, 0, 0.18),
      0 12px 28px rgba(0, 0, 0, 0.20);
    /* Fades fast while it sinks slower, so it reads as dropping behind the nav
       (matching the seg-tabs rise) rather than blinking out. */
    transition: opacity var(--dur-quick) var(--ease),
                transform var(--dur-move) var(--ease);
  }
  .nav-publish .nav-ico { width: 30px; height: 30px; }
  .nav-publish::after { opacity: 0.28; }
  .nav-publish::before {
    -webkit-mask: none;
    mask: none;
    background:
      radial-gradient(72% 62% at 32% 24%, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 46%),
      radial-gradient(85% 52% at 50% 112%, rgba(0, 0, 0, 0.16) 0%, rgba(0, 0, 0, 0) 52%),
      linear-gradient(125deg, var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
    background-size: 100% 100%, 100% 100%, 300% 100%;
    background-position: 0 0, 0 0, 0% 50%;
    animation: publish-dome 16s ease-in-out infinite;
  }
  /* Only the colour band drifts; the highlight and cavity stay put, so the
     light source never wanders. */
  @keyframes publish-dome {
    0%, 100% { background-position: 0 0, 0 0, 0% 50%; }
    50%      { background-position: 0 0, 0 0, 100% 50%; }
  }
  /* On the compose page the destination rule above would repaint the + with
     --accent (white in dark mode); the glyph sits on the pastel fill in every
     state, so it always wears the on-pastel ink. */
  .nav-publish[aria-current="page"] { color: var(--on-type); }
  /* The + does a whole turn as it becomes the ×: a full spin plus the 45° that
     lands the plus on its diagonal. */
  .nav-publish .nav-ico { transition: transform var(--dur-move) var(--ease); }
  .nav-publish.dial-open .nav-ico { transform: rotate(405deg); }

  /* Compose page (renderNav toggles .nav--compose): the + would fight the
     composer's own publish button, so it sinks behind the nav and fades out,
     and the tab pill glides to true center. The + stays in flow (opacity 0), so
     shifting the pill right by exactly half the +'s footprint — its width plus
     the row gap — lands the pill's center on the screen center. */
  .nav--compose .nav-publish {
    opacity: 0;
    transform: translateY(14px) scale(0.6);
    pointer-events: none;
  }
  .nav--compose .nav-pill {
    transform: translateX(calc((var(--fab-size) + var(--nav-gap)) / 2)) translateZ(0);
  }

  /* ── The + button's post-type speed dial ──────────────────────────────────
     Tapping + fans these four labeled type buttons up out of the button (see
     wireDial); the veil blurs the page behind them. Each item's icon chip
     lines up in a column straight above the +, its label floating to the left
     — the standard speed-dial reading. Absolutely placed inside the (fixed)
     .nav; wireDial pins its right/bottom to the + each time it opens. */
  .nav-dial {
    position: absolute;
    flex-direction: column-reverse;   /* Note (DOM-first) fans out nearest the + */
    align-items: flex-end;
    gap: 0.6rem;
    pointer-events: none;             /* the items switch it back on once open */
  }
  /* Guarded so the `hidden` attribute still wins at rest (a bare `display: flex`
     would outrank [hidden] and leave the empty menu in the a11y tree). */
  .nav-dial:not([hidden]) { display: flex; }
  .nav-dial.is-open { pointer-events: auto; }
  .nav-dial-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    /* Collapsed: dropped onto the button and faded. Open: lifted into place,
       each staggered a beat behind the one below it (nearest springs first). */
    opacity: 0;
    transform: translateY(14px) scale(0.9);
    transition:
      opacity var(--dur-quick) var(--ease),
      transform var(--dur-quick) var(--ease);
  }
  .nav-dial.is-open .nav-dial-item {
    opacity: 1;
    transform: none;
    transition-delay: calc(var(--i) * 0.04s);
  }
  /* No pill behind the label — the blurred veil plus the chip's own shadow
     carry the separation. A soft page-coloured halo keeps the words legible
     over whatever content the veil is dimming. */
  .nav-dial-label {
    padding: 0 0.1rem;
    font-size: 0.98rem;
    font-weight: 700;
    line-height: 1;
    color: var(--text);
    white-space: nowrap;
    text-shadow:
      0 0 10px var(--bg),
      0 1px 3px color-mix(in srgb, var(--bg) 65%, transparent);
  }
  /* Idea (b): the type colour GLOWS from behind the glyph rather than filling a
     chip — a soft pastel bloom on frosted glass, keynote-ish, not a hard block. */
  .nav-dial-ico {
    position: relative;
    width: 46px;
    height: 46px;
    border-radius: 50%;
    display: grid;
    place-items: center;
    background: color-mix(in srgb, var(--bg) 72%, transparent);
    /* No backdrop-filter of its own — the veil behind the whole dial already
       blurs the frozen page, so a second blur per disc is redundant sampling
       fighting the stagger transform each row animates through. This is the
       identical fix .filter-dial-ico already carries; the two dials are the same
       component wearing different anchors and they now agree. It is also the
       "never glass on glass" rule: one material at a time. */
    border: var(--glass-edge);
    box-shadow: var(--glass-lift);
  }
  .nav-dial-ico::before {
    content: '';
    position: absolute;
    inset: 0;
    border-radius: 50%;
    /* Bloom near centre so the hue reads as light through the whole disc — was
       sunk low, but visually it lost the glyph; pulled up toward the middle. */
    background: radial-gradient(64% 64% at 50% 50%, var(--glow) 0%, transparent 66%);
    opacity: 0.7;
  }
  .nav-dial-ico svg { position: relative; width: 1.5rem; height: 1.5rem; }
  .nav-dial-item:active .nav-dial-ico { transform: scale(0.92); transition: transform 0.08s; }

  /* The page-dimming veil behind an open dial. */
  .dial-veil {
    position: fixed;
    inset: 0;
    z-index: 100;                     /* over the page + topbar, under the nav (101) */
    background: color-mix(in srgb, var(--bg) 32%, transparent);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    opacity: 0;
    transition: opacity var(--dur-quick) var(--ease);
  }
  .dial-veil.is-open { opacity: 1; }

  @media (prefers-reduced-motion: reduce) {
    .nav-dial-item { transform: none; transition: opacity var(--dur-micro) ease; }
    .nav-dial.is-open .nav-dial-item { transition-delay: 0s; }
    .nav-publish .nav-ico, .dial-veil { transition: none; }
    /* Snap the + away and the pill to center — no slide under reduced motion. */
    .nav-pill, .nav-publish { transition: none; }
    /* This override sits after (and matches) the global publish-fill reduced
       rule, so it has to re-still the FAB's dome drift itself. */
    .nav-publish::before { animation: none; background-position: 0 0, 0 0, 50% 50%; }
  }

  /* Dark glass. The pill's own fill, rim and lift are tokens now and flip
     themselves, so what's left here is the pill's SPECIFIC dark problem: muted
     icons over a bright photo bleeding through chrome-tier glass drop below
     AA-graphics contrast, so it runs firmer than the shared chrome fill. */
  @media (prefers-color-scheme: dark) {
    .nav-pill { background: color-mix(in srgb, var(--bg) 72%, transparent); }
    /* The active glow stays chromatic on dark paper: brightening pastels there
       pushes them toward white, which would swallow the (light) --accent glyph
       riding on the hot core. Trade the brightness boost for more saturation so
       it reads as rich coloured light, not a white blowout. The opacity() in the
       filter dims it a touch further here (the CSS opacity property is off limits
       — JS drives it to fade the glide in/out on route changes). */
    .nav-glide { filter: blur(22px) saturate(1.9) brightness(1) opacity(0.65); }
    /* Same treatment as the pill: run the icon chip less transparent so it reads
       over dark content. The shadow is --glass-lift and deepens on its own. */
    .nav-dial-ico { background: color-mix(in srgb, var(--bg) 82%, transparent); }
    /* The pastel bloom glows far harder on dark paper — pull it right down so
       the (light) glyph reads against the chip, not lost in a bright disc. */
    .nav-dial-ico::before { opacity: 0.42; }
    .dial-veil { background: color-mix(in srgb, var(--bg) 44%, transparent); }
  }

  /* ── The view switcher docks above the nav and RISES on page-enter ──────────
     On phones the Friends / Updates seg-tabs isn't an inline row under the
     masthead — it floats as its own glass control just above the bottom nav
     (the material rule: glass floats above content, and a persistent switcher
     is exactly that). z-index sits UNDER the nav pill (101) so at rest it can
     tuck down behind the pill; the router adds .tuck while a page is entering
     (snapped hidden, no travel) then removes it once the page settles, so the
     control lifts straight up out from behind the nav on the signature easeOut.
     Default (untucked) is the visible docked state, so an in-place re-render
     — e.g. Updates rebuilding after a push-ask answer — just shows it, no
     vanish. Desktop has no bottom nav, so none of this reaches that layout. */
  .seg-tabs {
    position: fixed;
    left: 0;
    right: 0;
    bottom: calc(env(safe-area-inset-bottom) + 5.1rem);
    z-index: 40;
    width: min(86%, 300px);
    margin: 0 auto;
    /* Opacity settles fast while the transform keeps rising, so it reads as
       sliding UP out of the nav rather than fading in above it. The rise used to
       be --dur-slow (0.5s), which was the right weight when it started AFTER the
       page had finished changing; it now goes up on the same frame the page does,
       so at 0.5s it was the last thing still moving by a clear quarter second and
       the navigation ended on it rather than with it. --dur-move keeps the
       overhang that makes it read as one object rising, and lands it with the page. */
    transition: transform var(--dur-move) var(--ease),
                opacity   var(--dur-quick) var(--ease);
  }
  /* Tucked start: snapped down behind the nav with no transition, so adding the
     class hides it instantly; removing it lets the base transition run the rise. */
  .seg-tabs.tuck {
    transform: translateY(92px) scale(0.96);
    opacity: 0;
    transition: none;
  }
  /* Room to scroll the roster / ledger clear of the docked switcher + nav. */
  .seg-panel { padding-bottom: 5.5rem; }
  /* The composer's Post / Activity switcher is the exception: it's a form control
     that belongs ABOVE the note field, not docked chrome floating over the nav.
     Pin it back inline (the router already skips it in the tuck logic, so it
     never gets the rise either). */
  #c-group-tabs {
    position: static;
    width: 100%;
    max-width: 300px;
    margin: 0 auto 1.4rem;
    z-index: auto;
    transition: none;
    /* Own containing block for the absolute sliding thumb, WITHOUT position:
       relative (which yanked the switcher up onto the serif title). The base
       .seg-tabs is position:relative so its thumb (top/bottom:4px) is bounded by
       its own little track; the phone rule above flips every .seg-tabs to fixed,
       and pinning this one back to static stripped that containing block away —
       the thumb then resolved against whatever positioned ancestor it found and
       top/bottom:4px stretched it into a giant full-column pill flashing in on
       the way to Publish. translateZ(0) restores a local containing block (a
       transform is one) with zero layout shift. */
    transform: translateZ(0);
  }
  @media (prefers-reduced-motion: reduce) {
    /* No rise — the switcher is simply present. (The router also skips .tuck
       under reduced motion, so this is belt and braces.) */
    .seg-tabs, .seg-tabs.tuck { transition: none; }
  }

  main {
    padding-left: 0;
    padding-top: calc(var(--topbar-h) + env(safe-area-inset-top));
    padding-bottom: calc(96px + env(safe-area-inset-bottom));
  }

  /* Full-width column on phones — drop the desktop asymmetric margins. */
  .view { margin: 0; max-width: none; padding: 1.5rem 1.15rem 2rem; }

  /* Signed-out About renders in a chromeless main (body.gate main padding:0),
     with no topbar to clear the notch and no bottom nav reserving space — so the
     "Back to sign in" link hid under the status bar and the Feedback fold crowded
     the home indicator. Restore the safe-area insets for the gated About only;
     the auth screen (also under body.gate main) keeps its own centered layout. */
  body.gate .view.about {
    padding-top: calc(env(safe-area-inset-top) + 5.2rem);   /* clears the fixed .auth-topbar */
    padding-bottom: calc(env(safe-area-inset-bottom) + 3rem);
  }
  /* Already inset by .view here, so the type-axis padding comes back off. .dtags
     rides with them: on a phone the rail, the title and the grid all sit on the
     view's own inset and no second one is wanted. */
  .masthead, .filters, .dtags, .daily-lede, .daily-banner { padding-inline: 0; }
  /* The profile shelf rides with them for the same reason — its caption is on
     the view's own inset here, and its rule runs the full width of the column
     (the shelf keeps the block padding, so only the inline half is dropped). */
  .profile-shelf { padding-inline: 0; }
  /* The frame wall lives inside #feed, which breaks out by 1.15rem on a phone to
     put post photos screen-edge-to-edge. A masonry grid doesn't want that: it's
     already on Discover's axis and the card above it is on the view's, so the
     bleed is taken straight back. */
  .pgrid--frames { padding-inline: 1.15rem; }
  .view-title { font-size: 2rem; }
  .masthead { margin-bottom: 1.8rem; padding-bottom: 0.5rem; }
  /* Friends / Updates: the seg-tabs that used to sit under the masthead docks to
     the bottom nav on phones (position: fixed), so its 1.8rem gap would leave
     dead space above the first row now there's no divider to anchor against. Pull
     the list up close — the row's own top padding is enough breathing room. */
  .masthead:has(+ .seg-tabs) { margin-bottom: 0.35rem; }
  .masthead-kicker { margin-bottom: 0.7rem; letter-spacing: 0.13em; }
  /* Five chips outgrow a phone row — one swipeable line (edge-to-edge, no
     scrollbar) instead of wrapping an orphan onto a second line. The block
     padding (cancelled by the negative margins, so the row doesn't move) is
     headroom for the active chip's glow: overflow-x:auto forces overflow-y to
     clip, which would otherwise crop the soft drop shadow top and bottom. */
  .filters {
    flex-wrap: nowrap;
    overflow-x: auto;
    margin: -18px -1.15rem calc(2rem - 18px);
    padding: 18px 1.15rem;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .filters::-webkit-scrollbar { display: none; }
  /* A touch smaller so all five fit a phone row without clipping. */
  .filter { font-size: 0.85rem; padding: 0.4rem 0.8rem; }

  /* Entries break out of the view's side padding to sit screen-edge-to-edge;
     the hairline dividers run with them. Gap matches the divider padding so the
     line sits centered between entries. */
  .feed { margin-inline: -1.15rem; gap: 0.9rem; }
  .card--note .card-main, .card--find .card-main, .card--activity .card-main,
  .card--poll .card-main { padding-inline: 1.15rem; }
  .card--photo .card-actions { padding-inline: 1.15rem; }
  /* Thread aligns to the phone text column (1.15rem), rule still in the gutter. */
  .comments-panel, .likers-panel, .going-panel { padding-left: 0.7rem; padding-right: 1.15rem; }
  .comments-content { padding-left: calc(1.15rem - 0.7rem - 2px); }
  /* Tighter inset to the phone text column. Tap still opens full-screen. */
  .photo { padding-inline: 1.15rem; }
  .card--photo .byline,
  .card--photo .card-solometa,
  .card--photo .card-foot { padding-inline: 1.15rem; }

  /* Tapped photo goes full-bleed edge-to-edge, uncropped (portraits letterbox,
     landscapes span the full width). Drop the desktop padding + inset caps. */
  .lightbox { padding: 0; }
  .lightbox img,
  .lightbox video { width: 100%; max-width: 100vw; max-height: 100dvh; border-radius: 0; }

  /* Wider, a touch more present gradient on phones (the blob reads smaller on a
     narrow screen otherwise). Note this — not the base rule — is what actually
     paints on a phone, so it's the one to read when a wash looks wrong there. */
  .ambient { background: radial-gradient(ellipse 140% 52% at 50% -4%, var(--glow), transparent 76%); }

  /* Content-list rows break out to the screen edges so their hovers + hairline
     dividers run full-bleed while the content keeps its inset (via padding) — the
     feed, the Friends directory, and the Updates ledger all read as one system. */
  .friends-list, .requests-list, .notif-list { margin-inline: -1.15rem; }
  .friend, .request-row, .notif { padding-inline: 1.15rem; }
  .requests-kicker { padding-inline: 0; }

  /* Composer already sits inside the view's padding here. */
  .composer { padding-inline: 0; }

  /* `contain`, NOT `none`. This said `none` from the first commit, long before
     there was a pull-to-refresh, and `none` doesn't just stop scroll chaining —
     it cancels the rubber band itself. The ptr module reads the bounce (a
     negative window.scrollY IS the pull), so this one line was quietly holding
     the quintet indicator at zero on the only device class it exists for. The
     cost of `contain` is Chrome Android's own pull-to-refresh, which the
     viewport still counts as its default overscroll effect, so an Android
     browser may offer its reload alongside ours. Worth it: iOS is where Tria is
     installed, and there the bounce is the whole gesture. (The native app needs
     a second fix on the Swift side — Capacitor hard-sets scrollView.bounces =
     false, which no stylesheet can reach. See TriaViewController.swift.) */
  html, body { overscroll-behavior-y: contain; }
}

/* ── About page (#/about) ─────────────────────────────────────────────────────
   The public front door: editorial long-read column on the feed measure. Also
   renders on the gate (no chrome), where the rainbow gate ambient plays behind
   it and a back link leads to sign-in. */
.about-body {
  max-width: var(--feed-width);
  margin-inline: auto;
  padding-inline: var(--inset);
}
.about-body p {
  line-height: 1.62;
  margin-bottom: 0.85rem;
  font-weight: 300;
  font-size: 0.92rem;
  color: color-mix(in srgb, var(--text) 78%, var(--muted));
}
.about-body strong { font-weight: 600; color: var(--text); }
/* The two intro paragraphs carry the page: a touch larger, full-strength text. */
.about-lede {
  font-size: 1.05rem;
  line-height: 1.5;
  color: var(--text);
  margin-bottom: 0.9rem;
}

/* Section heads: serif (echoing the masthead) but quieted down to a control-sized
   line, so the folds below read as a clean disclosure list, not a wall of titles. */
.about-head {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-style: italic;              /* serif italic = title voice, app-wide */
  font-size: 1.5rem;
  line-height: 1.15;
  letter-spacing: -0.01em;
  margin: 0;
}
.about-body h3 {
  font-size: 0.94rem;
  font-weight: 600;
  color: var(--text);
  margin-top: 1.45rem;
  margin-bottom: 0.3rem;
}
.about-body h3.faq-q {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-style: italic;
  font-size: 1.18rem;
  color: var(--text);
  margin-top: 1.7rem;
}

/* Each section folds behind its head, so the feedback form stays close. The
   folds form one connected disclosure list: shared hairlines, a full-width tap
   row with a round chevron chip (iOS settings-group feel), and the same 0fr→1fr
   grid tween + opacity lift as .comments-panel when a panel opens. */
.about-fold { border-top: 1px solid var(--rule); }
.about-fold:first-of-type { margin-top: 1.4rem; }
.about-fold:last-of-type { border-bottom: 1px solid var(--rule); }
.about-fold-head { margin: 0; }
.about-fold-toggle {
  font: inherit;
  letter-spacing: inherit;
  color: var(--text);
  background: none;
  border: none;
  width: calc(100% + 1.6rem);
  margin-inline: -0.8rem;              /* let the hover tint bleed past the text */
  padding: 0.85rem 0.8rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  text-align: left;
  cursor: pointer;
  border-radius: var(--radius-card);
  transition: background var(--dur-micro) var(--ease);
}
@media (hover: hover) {
  .about-fold-toggle:hover { background: var(--surface); }
  .about-fold-toggle:hover .about-fold-chev { background: var(--surface-2); }
}
/* Round chevron chip — a clear, tappable "expand me" affordance. */
.about-fold-chev {
  position: relative;
  flex: none;
  width: 1.8rem;
  height: 1.8rem;
  border-radius: var(--radius-pill);
  background: var(--surface);
  transition: background var(--dur-micro) var(--ease);
}
.about-fold-chev::before {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 0.44rem;
  height: 0.44rem;
  border-right: 2px solid var(--muted);
  border-bottom: 2px solid var(--muted);
  transform: translateY(-1.5px) rotate(45deg);   /* points down while closed */
  transition: transform var(--dur-move) var(--ease),
              border-color var(--dur-micro) var(--ease);
}
.about-fold.open .about-fold-chev {
  background: color-mix(in srgb, var(--accent) 14%, var(--surface));
}
.about-fold.open .about-fold-chev::before {
  transform: translateY(1.5px) rotate(225deg);
  border-color: var(--text);
}
.about-fold-panel {
  display: grid;
  grid-template-rows: 0fr;
  opacity: 0;
  transition: grid-template-rows var(--dur-move) var(--ease),
              opacity 0.26s ease;
}
.about-fold.open .about-fold-panel { grid-template-rows: 1fr; opacity: 1; }
.about-fold-inner { overflow: hidden; min-height: 0; }
.about-fold-inner > :first-child { margin-top: 0.1rem; }
/* A fold's content ends a clear beat above the next section's hairline, so an
   open accordion reads as its own block rather than crowding the rule below. */
.about-fold-inner > :last-child { margin-bottom: 1.8rem; }
@media (prefers-reduced-motion: reduce) {
  .about-fold-panel, .about-fold-chev, .about-fold-chev::before { transition: none; }
}

/* The one link out of a fold, and the link off the bottom of #/business. Reads
   as a sentence continuing rather than a button, because everything around it
   is prose. */
.about-more {
  font-weight: 600;
  color: var(--text);
  text-decoration: none;
  border-bottom: 1px solid var(--rule);
  padding-bottom: 1px;
}
@media (hover: hover) {
  .about-more:hover { border-bottom-color: currentColor; }
}

/* ── Tria for business (#/business) ─────────────────────────────────────────
   A pricing page in an app that has spent every other screen avoiding the look
   of one. It stays inside the About page's editorial column and its type scale,
   and the plans are the only thing on it that lifts off the page.

   The three plan cards are the GLASS-MINUS-BLUR surface — the same settlement
   the masonry tiles took: the fill, the --glass-edge hairline, the --glass-rim
   perimeter and a float shadow, all from the tokens, dropping only the
   sample-and-blur. Here the reason isn't scroll cost, it's the backdrop: this
   page carries the About wash, which DRIFTS, so three backdrop-filters over it
   would re-rasterize on every frame of an animation that never stops, on a page
   that is otherwise completely still. */
.biz-head { margin-top: 2.6rem; margin-bottom: 0.5rem; }

.biz-plans {
  display: grid;
  gap: 0.85rem;
  margin: 1.3rem 0 1.1rem;
}
/* The plans row is the one thing on this page allowed out of the reading
   column, and it has to be, because --feed-width is 660px and 660 thirds into
   194px columns — the same number the 4-up Discover grid died on. At 194px
   every feature line wraps to two or three and a comparison you can only read
   one column at a time is not a comparison.

   So it's a full-bleed figure in a text column: it fills the whole .view and
   overhangs the prose symmetrically. The measurement is a CONTAINER QUERY and
   not a media query on purpose — the slack either side of the column is
   (.view width - 660) / 2, and .view's width depends on the sidebar, which
   depends on both the viewport AND whether anyone is signed in. 100cqw reads
   the number instead of predicting it, so the row lands flush to .view at every
   width and in both states, and can never overhang into an overflow. The
   container is scoped to this page: .view is used by every view in the app, and
   layout containment on all of them would make each one a containing block for
   its position: fixed children (the docked seg-tabs, see the page-transition
   notes). This page has none. */
.view.business { container-type: inline-size; }
@media (min-width: 900px) {
  .biz-plans {
    grid-template-columns: repeat(3, 1fr);
    width: 100cqw;
    margin-inline: calc((100% - 100cqw) / 2);
    gap: 1rem;
  }
}

.biz-plan {
  position: relative;
  display: flex;
  flex-direction: column;
  padding: 1.15rem 1.15rem 1.25rem;
  border-radius: var(--radius-card);
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  border: var(--glass-edge);
  box-shadow: var(--glass-rim), 0 6px 18px rgba(0, 0, 0, 0.10);
}
.about-body .biz-plan-name {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-weight: 400;
  font-style: italic;
  font-size: 1.5rem;
  line-height: 1.1;
  color: var(--text);
  margin: 0 0 0.15rem;
}
.about-body .biz-plan-price {
  font-size: 1.65rem;
  font-weight: 300;
  line-height: 1.2;
  color: var(--text);
  margin: 0 0 0.5rem;
  letter-spacing: -0.02em;
}
.biz-plan-price span {
  display: block;
  font-size: 0.72rem;
  font-weight: 400;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin-top: 0.15rem;
}
.biz-plan-feat {
  list-style: none;
  display: grid;
  gap: 0.45rem;
  /* The hairline used to belong to the self-select line above ("One place. One
     or two people posting."), which said in a sentence what the list below it
     says in items. The rule stays, because it separates the price from what
     the price gets you, which is the one division on the card that earns one. */
  margin: 0.85rem 0 0;
  padding: 0.85rem 0 0;
  border-top: 1px solid var(--rule);
}
.biz-plan-feat li {
  position: relative;
  padding-left: 1.05rem;
  font-size: 0.85rem;
  font-weight: 300;
  line-height: 1.45;
  color: color-mix(in srgb, var(--text) 82%, var(--muted));
}
.biz-plan-feat li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.5em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--rule);
}
/* Activities wear the activity lime, the same way the daily card wears the hue
   of the type it's asking for: the colour says WHAT YOU CAN MAKE. It is the
   only chromatic pixel on this page. */
.biz-plan-feat li.biz-feat--activity { color: var(--text); font-weight: 400; }
.biz-plan-feat li.biz-feat--activity::before {
  background: var(--type-activity);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--type-activity) 22%, transparent);
}

/* Back to the gate (only rendered when signed out). */
.about-back {
  max-width: var(--feed-width);
  margin: 1.6rem auto 1.6rem;
  padding-inline: var(--inset);
  font-size: 0.9rem;
}
.about-back a {
  color: var(--muted);
  text-decoration: none;
}
.about-back a:hover { color: var(--text); }

/* Install tutorial: one step list with an iPhone/Android switch (matches the
   signed-out welcome front door), each step wearing a small icon tile that
   resolves into focus (blur + lift on the signature ease), one after another
   down the list — the page-transition motif in miniature. */
/* The iPhone / Android switch above the steps — one quiet pill, two options,
   the picked one filled. The list keeps the same three rows either way; only
   the words and the lead icon change (see installStepsHtml in app.js). */
.os-toggle {
  display: flex;
  width: max-content;
  margin: 1.5rem 0 0;
  padding: 2px;
  gap: 2px;
  border: 1px solid var(--rule);
  border-radius: 999px;
}
.os-opt {
  background: none;
  border: none;
  border-radius: 999px;
  padding: 0.3rem 0.85rem;
  font-size: 0.8rem;
  font-weight: 700;
  color: var(--muted);
  transition: background-color var(--dur-micro), color var(--dur-micro);
}
.os-opt[aria-pressed="true"] {
  background: var(--surface);
  color: var(--text);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}

.install-steps { list-style: none; padding: 0; margin: 1.4rem 0 0; }
.install-steps li {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  margin-top: 1rem;
  font-size: 0.95rem;
  font-weight: 300;
  line-height: 1.45;
}
.install-icon {
  flex: none;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  background: var(--surface);
  border: 1px solid var(--rule);
  border-radius: var(--radius-img);
  color: var(--text);
  /* `backwards` holds the delayed tiles (steps 2 and 3) at the 0% rest state —
     soft, slightly blurred — through their stagger delay. Without it they sit
     fully sharp until the delay elapses, then visibly snap to the rest state. */
  animation: install-drift 5.4s var(--ease-soft) infinite backwards;
}
.install-icon svg { width: 20px; height: 20px; }
.install-steps li:nth-child(2) .install-icon { animation-delay: 1.8s; }
.install-steps li:nth-child(3) .install-icon { animation-delay: 3.6s; }
.install-t {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-size: 1.5rem;
  line-height: 1;
  transform: translateY(-1px);
}
/* Rest state sits soft and slightly blurred; each tile sharpens, lifts, and
   settles back — staggered so the three steps read in order. */
@keyframes install-drift {
  0%, 22%, 100% { opacity: 0.55; filter: blur(1px); transform: translateY(1.5px) scale(0.97); }
  10%           { opacity: 1;    filter: blur(0);   transform: translateY(-1.5px) scale(1.05); }
}
@media (prefers-reduced-motion: reduce) {
  .install-icon { animation: none; opacity: 1; filter: none; transform: none; }
}

/* Feedback form: compact right-aligned submit (per the composer's precedent). */
.fb-form { margin-top: 1.6rem; }
.fb-form .auth-submit {
  display: block;
  width: auto;
  margin-inline: auto;
  padding: 0.65rem 1.4rem;
  font-size: 0.95rem;
}
.fb-thanks {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-style: italic;
  font-size: 1.25rem;
  margin-top: 2rem;
}

/* Link back to About from the auth gate. */
.auth-about { margin-top: 0.8rem; font-size: 0.88rem; text-align: center; }
.auth-about a {
  color: var(--muted);
  text-decoration: underline;
  text-underline-offset: 2px;
}
.auth-about a:hover { color: var(--text); }

/* The last step's tile IS the Tria app icon: the create/publish pastel quintet,
   drifting like the Post button, with the dark-ink serif "t" — the tutorial's
   payoff, and where the loud gradient now lives (not on a button). */
.install-appicon {
  background: linear-gradient(115deg,
    var(--type-note), var(--type-poll), var(--type-find), var(--type-activity), var(--type-photo), var(--type-note));
  background-size: 300% 100%;
  border: none;
  box-shadow: var(--lit-rim), var(--lit-drop);
  /* Runs the same install-drift as the other two tiles (it's step 3, so the
     nth-child 3.6s delay settles it right after them, blur and all), with the
     Post button's colour drift layered on so the payoff tile stays alive. */
  animation: install-drift 5.4s var(--ease-soft) infinite backwards,
             publish-shift 14s ease-in-out infinite;
}
.install-appicon .install-t { color: var(--on-type); }
@media (prefers-reduced-motion: reduce) {
  .install-appicon { animation: none; background-position: 50% 50%; }
}
/* Signed-out brand header — the SAME frosted bar as the signed-in feed header
   (edge-to-edge frost, hairline underline, wordmark at the left, a docked glyph
   at the right where the sprout sits). Just given extra height so the slogan can
   ride under the wordmark. Fixed, so tutorial content scrolls under it. */
.auth-topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: calc(env(safe-area-inset-top) + 0.75rem) 1.15rem 0.75rem;
  /* Same scroll edge effect as the signed-in topbar, so the gated pages don't
     hand a first-time visitor a different bar from the one they'll live with. */
  background: linear-gradient(
    180deg,
    color-mix(in srgb, var(--bg) 84%, transparent) 0%,
    color-mix(in srgb, var(--bg) 70%, transparent) 58%,
    color-mix(in srgb, var(--bg) 44%, transparent) 100%);
  -webkit-backdrop-filter: blur(var(--glass-blur));
  backdrop-filter: blur(var(--glass-blur));
  border-bottom: none;
}
.auth-topbar-brand { display: flex; flex-direction: column; }
.auth-topbar .brand-mark { font-size: 1.6rem; }
/* Gated (signed-out) About carries the same fixed header, so clear its height. */
.view.about--front { padding-top: calc(env(safe-area-inset-top) + 5.2rem); }
.auth-topbar-tag {
  font-family: 'Instrument Serif', 'Times New Roman', serif;
  font-style: italic;
  color: var(--muted);
  font-size: 1.05rem;
  line-height: 1.1;
  margin-top: 0.12rem;
}

@media (max-width: 680px) {
  .about-body, .about-back { padding-inline: 0; }   /* already inset by .view here */
  .about-back { margin-top: 1.6rem; }
  .about-head { font-size: 1.4rem; }
}
