/* ==========================================================================
   Bubl — blue primary, purple secondary, gradients between the two.
   Dark is the default; light is a full re-theme, not an inversion.
   ========================================================================== */

:root {
  --blue-400:#60A5FA; --blue-500:#3B82F6; --blue-600:#2563EB; --blue-700:#1D4ED8;
  --purple-400:#C084FC; --purple-500:#A855F7; --purple-600:#9333EA;
  --magenta:#E879F9;

  --grad: linear-gradient(135deg, var(--blue-600) 0%, var(--purple-500) 100%);
  /* The bubble. Written once because three things are made of it now -- the
     one the sign-in card grows out of, the one the composer grows out of, and
     the composer itself -- and a bubble that opens into a surface of a
     different colour is two things, not one. */
  /* The colour the composer ends on, and the colour the page ground is set to
     while it is open. One value, used at both ends, so the strip an installed
     iPhone will not let us paint cannot be a different shade from the thing
     above it. Measured off the device: the sheet's own bottom edge. */
  --compose-ground: #383063;
  /* And the colour it becomes when the Bubl is going to the centre of the map
     rather than to where the writer is. The two are different claims -- "I am
     here and this is happening" against "this is roughly about over there" --
     and the composer now says which one you are making while you make it,
     rather than leaving it in a line of text nobody reads twice.
     Blue for a place picked on the map, the app's own purple for one somebody
     actually stood in. Read off the sheet's bottom edge the same way the purple
     was, so the strip an installed iPhone will not let us paint still matches
     the thing above it. */
  --compose-ground-map: #1E3566;
  /* The two claims, as colours, used by the bubbles' rims and by the map
     control that filters them. Purple is the app's own hue and belongs to the
     stronger claim -- somebody stood there. Blue is the cooler, more distant
     one, for a place picked off the map from somewhere else.
     Bright, because these are read at the size of a coin over a satellite
     photograph, through glass. */
  --rim-here: #A855F7;
  --rim-map:  #38A5F7;
  /* The same idea for the sign-in page, which has its own glow rising from
     below the bottom edge and so its own colour where the strip meets it.
     Worked out from that gradient rather than guessed: at the bottom edge the
     glow is 20% of the way along, and the browser composites that to exactly
     this over #05070F -- checked by replaying the same stops through a
     canvas rather than trusting the arithmetic. */
  --welcome-ground: #1E1E3D;
  /* And for a photo opened full screen. The scrim is rgba(3,6,16,.78) over the
     app's own ground, which composites to this -- the third surface that can
     cover a phone, and the one that was leaving the strip the colour of a map
     nobody could see any more. */
  --lightbox-ground: #050914;
  /* The feed drawer's own colour where it meets the bottom of the screen, and
     so the colour of the strip an installed iPhone will not let us paint. The
     drawer is glass over a moving map, so its rendered colour is not any one
     value -- it settles onto this at its bottom edge, which is what makes the
     two match by construction.
     #31355A, and the drawer is painted with it directly -- the two are the same
     declaration now, so there is nothing left to keep in step.
     Four wrong answers came before this, and every one was the same mistake:
     naming a colour that was not a constant. --panel ignored the hue layer.
     #242142 was the arithmetic for the old mix, but the glow along the bottom
     edge lit it further. #302850 was right over a dark map and sixteen units
     wrong over snow, because a tenth of the basemap showed through and was
     being saturated on the way. #2F2750 fixed that but still lost when the
     drawer was collapsed, because the body was a diagonal gradient and the top
     of it -- the only part on screen then -- was a much lighter blue-slate.
     Sitting between what the phone actually showed at the two ends, #333B57 at
     the top and #2C2851 at the bottom, so neither position moves far. */
  --drawer-ground: #31355A;

  /* --- the two corners ----------------------------------------------------
     Every screen puts a button in the same two places, and they are the same
     two places on every screen: top left is the way out, top right is the way
     on. On the map those are the home button and the account chip; in the
     composer, close and post; over a photograph, close. A thumb learns one
     spot rather than four, and the button that appears there is never a
     surprise.
     Take them with .corner-tl / .corner-tr rather than by writing the offsets
     again -- three rules that agree today are three rules that disagree after
     the next change. See BUTTON-ANCHORS.md.
     Phones only: on a desktop these controls sit in the flow of the bar they
     belong to, and there is no thumb to build a habit in.
     -------------------------------------------------------------------- */

/* --- bubble glass -------------------------------------------------------
     The material the floating chrome is made of, named so it can be asked for
     by name. Four parts: a sheen down the top-left where the light would fall
     on something round, the two brand hues at low strength so it is tinted
     rather than grey, a translucent floor dark enough to keep text legible
     over a bright map, and a lit 1px top edge. Stack them in that order --
     sheen, hue, floor -- since a colour is only valid as the last layer.
     The search bar, the map buttons and the suggestion list all take these, so
     the chrome reads as one set and stays that way when one of them changes. */
  --glass-sheen: linear-gradient(150deg,
    rgba(255,255,255,.12) 0%, rgba(255,255,255,.025) 46%, rgba(255,255,255,0) 60%);
  --glass-hue: linear-gradient(150deg,
    color-mix(in srgb, var(--primary) 16%, transparent),
    color-mix(in srgb, var(--secondary) 16%, transparent));
  --glass-floor: color-mix(in srgb, var(--panel) 84%, transparent);
  --glass-edge: rgba(255, 255, 255, .20);
  --glass-lip: inset 0 1px 0 rgba(255, 255, 255, .28);
  --glass-blur: blur(22px) saturate(165%);
  --bubl-glass:
    linear-gradient(150deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 46%, rgba(255,255,255,0) 62%),
    linear-gradient(150deg,
      color-mix(in srgb, var(--primary) 34%, transparent),
      color-mix(in srgb, var(--secondary) 30%, transparent)),
    color-mix(in srgb, var(--panel) 86%, transparent);
  --grad-soft: linear-gradient(135deg, rgba(37,99,235,.16) 0%, rgba(168,85,247,.16) 100%);
  --grad-text: linear-gradient(120deg, var(--blue-400) 0%, var(--purple-400) 100%);

  --r-sm:8px; --r-md:12px; --r-lg:18px; --r-pill:999px;
  --nav-h:60px;
  --sidebar-w:392px;
  --ease:cubic-bezier(.22,.7,.28,1);
  /* For anything that fades. --ease leaves at full speed, which reads as a
     snap on a cross-fade; this one starts and finishes gently. */
  --ease-soft:cubic-bezier(.4,.04,.22,1);
  --font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
}

/* ---- dark (default) ---- */
:root, :root[data-theme="dark"] {
  --bg:#070A12;
  --panel:#0D1322;
  --panel-2:#141C30;
  --panel-3:#1B2540;
  --ink:#E9EEFA;
  --ink-2:#96A5C4;
  --ink-3:#63718F;
  --line:#1E2740;
  --line-2:#2A3554;
  --primary:#4F8DF7;
  --primary-ink:#FFFFFF;
  --secondary:#A971F8;
  --shadow: 0 1px 2px rgba(0,0,0,.5), 0 18px 44px -20px rgba(0,0,0,.9);
  --glow: 0 0 0 1px rgba(79,141,247,.28), 0 8px 30px -8px rgba(120,90,250,.5);
  --map-ocean:#050810;
  --map-land:#1A2540;
  --map-line:#3A4A78;
  --map-label:#8092BC;
  --scrim: rgba(3,6,14,.66);
}

:root[data-theme="light"] {
  --bg:#EEF3FA;
  --panel:#FFFFFF;
  --panel-2:#F3F7FC;
  --panel-3:#E7EEF8;
  --ink:#0C1322;
  --ink-2:#4C5C77;
  --ink-3:#7A89A4;
  --line:#DDE6F1;
  --line-2:#CBD8E8;
  --primary:#2563EB;
  --primary-ink:#FFFFFF;
  --secondary:#8B3FE8;
  --shadow: 0 1px 2px rgba(16,38,76,.07), 0 16px 38px -22px rgba(16,38,76,.35);
  --glow: 0 0 0 1px rgba(37,99,235,.22), 0 10px 26px -12px rgba(124,58,237,.35);
  --map-ocean:#CFDCEE;
  --map-land:#FAFCFE;
  --map-line:#A8BDD8;
  --map-label:#748AAB;
  --scrim: rgba(20,32,58,.38);
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body { height: 100%; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font);
  font-size: 14px;
  line-height: 1.5;
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
  overscroll-behavior: none;
}
svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7;
      stroke-linecap: round; stroke-linejoin: round; }
button, input, select, textarea { font: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }

/* ==========================================================================
   Nav
   ========================================================================== */
.nav {
  height: var(--nav-h);
  display: flex; align-items: center; gap: 16px;
  padding: 0 16px;
  background: color-mix(in srgb, var(--panel) 86%, transparent);
  backdrop-filter: blur(18px) saturate(150%);
  border-bottom: 1px solid var(--line);
  position: relative; z-index: 40;
}
.brand { display: flex; align-items: center; gap: 10px; text-decoration: none; color: var(--ink); flex: none; }
/* The app icon itself, rather than a flat gradient with a dot stuck on it.
   The icon is a lit sphere and the mark was an approximation of one; using the
   artwork means they cannot drift, and whoever redraws the icon redraws this.
   125%: the sphere is 154px inside a 192px canvas, so the picture is scaled up
   until its dark surround falls outside the circle. Measured, not guessed. */
.mark {
  width: 26px; height: 26px; border-radius: 50%;
  background: url('/icons/icon-192.png') center / 125% no-repeat, var(--grad);
  display: grid; place-items: center; position: relative;
  box-shadow: 0 4px 14px -4px rgba(124,58,237,.7);
}
/* The specular is painted into the artwork now. */
.mark i { display: none; }
.mark.small { width: 34px; height: 34px; }
.wordmark {
  font-size: 19px; font-weight: 700; letter-spacing: -.03em;
  background: var(--grad-text); -webkit-background-clip: text; background-clip: text; color: transparent;
}

/* ---- search ---- */
.searchwrap { width: 100%; position: relative; }
.searchbox {
  display: flex; align-items: center; gap: 8px;
  height: 38px; padding: 0 8px 0 12px;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: var(--r-pill);
  transition: border-color .18s, box-shadow .18s, background .18s;
}
.searchbox:focus-within { border-color: transparent; box-shadow: var(--glow); background: var(--panel); }
.searchbox .mag { color: var(--ink-3); flex: none; width: 16px; height: 16px; }
.searchbox input {
  flex: 1; min-width: 40px; border: 0; background: none; outline: none;
  font-size: 14px; color: var(--ink);
}
.searchbox input::placeholder { color: var(--ink-3); }

.tagchip {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  height: 24px; padding: 0 10px; border-radius: var(--r-pill);
  background: var(--grad); color: #fff;
  font-size: 12.5px; font-weight: 600; white-space: nowrap;
}
.tagchip .em { font-size: 12px; }

.suggest {
  position: absolute; top: calc(100% + 8px); left: 0; right: 0;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-md); box-shadow: var(--shadow);
  padding: 6px; z-index: 60; max-height: min(58vh, 420px); overflow-y: auto;
}
.suggest-item {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 8px 10px; border: 0; background: none; border-radius: var(--r-sm);
  cursor: pointer; text-align: left;
}
/* A lift, not a slab. --panel-2 is darker than the glass these sit on, so
   hovering sank the row instead of raising it. Same everywhere a row lives on
   one of the glass surfaces. */
.suggest-item:hover, .suggest-item.active { background: rgba(255, 255, 255, .07); }
.suggest-item .em { font-size: 16px; width: 20px; text-align: center; flex: none; }
.suggest-item .lab { font-weight: 600; }
.suggest-item .via { color: var(--ink-3); font-size: 12.5px; }
.suggest-item .cnt {
  margin-left: auto; color: var(--ink-3); font-size: 12px;
  font-variant-numeric: tabular-nums; font-family: var(--mono);
}
.suggest-empty { padding: 14px; color: var(--ink-3); text-align: center; }

/* ---- nav actions ---- */
.navactions { display: flex; align-items: center; gap: 8px; flex: none; }
.iconbtn {
  display: grid; place-items: center;
  width: 34px; height: 34px; border-radius: var(--r-sm);
  background: transparent; border: 1px solid transparent; color: var(--ink-2);
  cursor: pointer; transition: background .16s, color .16s, border-color .16s;
}
.iconbtn:hover { background: var(--panel-2); color: var(--ink); border-color: var(--line); }
.iconbtn.tiny { width: 28px; height: 28px; }
.iconbtn.tiny svg { width: 15px; height: 15px; }
.iconbtn.on { background: var(--grad-soft); color: var(--primary); border-color: var(--line-2); }

[data-theme="dark"] .ico-sun { display: block; }
[data-theme="dark"] .ico-moon { display: none; }
[data-theme="light"] .ico-sun { display: none; }
[data-theme="light"] .ico-moon { display: block; }

.btn {
  height: 34px; padding: 0 15px; border-radius: var(--r-pill);
  border: 1px solid var(--line-2); background: var(--panel-2); color: var(--ink);
  font-size: 13.5px; font-weight: 600; cursor: pointer;
  transition: transform .12s var(--ease), filter .16s, box-shadow .16s;
}
.btn:hover { filter: brightness(1.08); }
.btn:active { transform: scale(.97); }
.btn-primary {
  background: var(--grad); color: #fff; border-color: transparent;
  box-shadow: 0 6px 18px -8px rgba(124,58,237,.8);
}
.btn.wide { width: 100%; height: 42px; font-size: 15px; }
.linkbtn {
  border: 0; background: none; color: var(--ink-3); cursor: pointer;
  font-size: 12px; padding: 2px 4px; text-decoration: underline; text-underline-offset: 2px;
}
.linkbtn:hover { color: var(--primary); }

.userchip { display: flex; align-items: center; gap: 8px; }
.avatar {
  width: 28px; height: 28px; border-radius: 50%; background: var(--grad);
  display: grid; place-items: center; color: #fff; font-size: 12px; font-weight: 700;
}

/* The account chip before there is an account to draw.
 *
 * Three states on #userMenu: loading, out, in. The point of all of this is
 * that the control never changes shape -- it is the same disc in the same
 * place from the first paint, and only what is inside it changes. Before, a
 * "Sign in" button stood there until /api/bootstrap answered and was then
 * swapped for the avatar, which told every signed-in reader something untrue
 * for as long as that request took. */
.avatar-ph {
  width: 17px; height: 17px;
  fill: none; stroke: currentColor; stroke-width: 1.9;
  stroke-linecap: round; stroke-linejoin: round;
}
/* Neutral rather than the account gradient: this disc does not stand for
   anybody yet, and painting it in somebody's colour would be a small lie of
   its own. */
.usermenu[data-state="loading"] .avatar,
.usermenu[data-state="out"] .avatar {
  background: var(--panel-3);
  border: 1px solid var(--line);
  color: var(--ink-2);
  box-shadow: none;
}
/* Nothing to say and nothing to open, so the name and the chevron are not
   drawn -- which leaves the chip the width of the disc, and the top row is
   measured from it either way by fitTopRow(). */
.usermenu[data-state="loading"] .uname,
.usermenu[data-state="out"] .uname,
.usermenu[data-state="loading"] .chev,
.usermenu[data-state="out"] .chev { display: none; }
.usermenu[data-state="loading"] .userchip { cursor: default; }
.usermenu[data-state="out"] .userchip:hover .avatar { color: var(--ink); }
/* A breath of movement while the answer is outstanding, so the disc reads as
   waiting rather than as broken. Slow and shallow: it is on screen for a few
   hundred milliseconds and should not draw the eye to itself. */
.usermenu[data-state="loading"] .avatar { animation: chipWait 1.1s ease-in-out infinite; }
@keyframes chipWait { 0%, 100% { opacity: .55; } 50% { opacity: .85; } }
@media (prefers-reduced-motion: reduce) {
  .usermenu[data-state="loading"] .avatar { animation: none; }
}
.uname { font-weight: 600; font-size: 13.5px; max-width: 110px; overflow: hidden;
         text-overflow: ellipsis; white-space: nowrap; }

/* ==========================================================================
   Stage: map + sidebar
   ========================================================================== */
.stage {
  /* The app fills its containing block and takes no measurement to do it.
     `inset: 0` against the fixed body is the window itself -- no percentage
     resolving against a parent that has to be right first, and no viewport
     unit, which on an installed iPhone measures the safe area and stops short
     of the home indicator. It used to subtract --nav-h for a bar that both
     breakpoints now hide. */
  position: absolute;
  inset: 0;
  display: grid; grid-template-columns: minmax(0,1fr) var(--sidebar-w);
}
.mapwrap { position: relative; min-width: 0; background: var(--map-ocean); }
#map { position: absolute; inset: 0; }
/* The credit's corner. See the attribution block further down for why it is
   here on a desktop and lifted above the bottom row on a phone. */
.maplibregl-ctrl-bottom-right {
  left: 24px; right: calc(var(--sidebar-w) + 26px); bottom: 18px;
  display: flex; justify-content: flex-start;
}
@media (max-width: 900px) {
  .maplibregl-ctrl-bottom-right {
    left: var(--edge); right: var(--edge);
    /* Clear of the bottom row, and riding the same lift the row does, so the
       drawer cannot come up over it. */
    bottom: calc(var(--edge-b) + var(--tap) + 10px + var(--btn-lift, 0px));
  }
}

.mapchrome {
  position: absolute; left: 16px; bottom: 16px; z-index: 6;
  display: flex; flex-direction: column; gap: 8px; align-items: flex-start;
  pointer-events: none;
}
.legend {
  display: flex; align-items: center; gap: 8px;
  height: 32px; padding: 0 13px; border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  backdrop-filter: blur(14px);
  border: 1px solid var(--line);
  font-size: 12.5px; color: var(--ink-2); box-shadow: var(--shadow);
  pointer-events: auto; white-space: nowrap;
}
.precision {
  display: flex; align-items: center; gap: 10px;
  padding: 8px 10px 8px 13px; border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--panel) 92%, transparent);
  backdrop-filter: blur(14px);
  border: 1px solid var(--secondary);
  font-size: 12.5px; color: var(--ink-2); box-shadow: var(--shadow);
  pointer-events: auto; max-width: min(92vw, 420px);
}
.precision b { color: var(--ink); }
.precision button {
  flex: none; border: 0; border-radius: var(--r-pill); cursor: pointer;
  padding: 4px 12px; background: var(--grad); color: #fff;
  font-size: 12px; font-weight: 600;
}
.legend-label { font-size: 11px; color: var(--ink-3); }
.legend-ramp {
  width: 84px; height: 7px; border-radius: 4px;
  background: linear-gradient(90deg, var(--blue-600), var(--purple-500) 62%, var(--magenta));
}


/* ==========================================================================
   Sidebar feed
   ========================================================================== */
.sidebar {
  display: flex; flex-direction: column; min-height: 0;
  /* Named, so the fades at the top and bottom of the list can be drawn in it.
     It is the panel here and the drawer on a phone, and those two are set in
     one place each rather than in every rule that has to match them. */
  --surface: var(--panel);
  background: var(--surface);
  border-left: 1px solid var(--line);
  z-index: 20;
}
/* No fill of its own. It sits at the top of the drawer, and the drawer is glass
   now -- an opaque panel across the filters was a flat rectangle laid over the
   one surface that is supposed to be lit. The desktop layout had already had to
   override this to transparent; there is nothing left to override. */
.feedhead {
  padding: 10px 14px;
  display: flex; flex-direction: column; gap: 10px; flex: none;
  /* Laid over the list, not stacked above it. Collapsing it in the flow gave
     the list more room as it went, so the posts slid up under the finger that
     was scrolling them -- and moving what somebody is reading is exactly what
     this was supposed to avoid. Out of the flow, the list never learns that the
     header left.
     It takes the surface's own colour so the posts passing beneath it are
     hidden rather than showing through: `inherit` because that colour is the
     panel on a desktop and the drawer on a phone, and it should stay whichever
     of those it is without being told twice. */
  position: absolute;
  top: 0; left: 0; right: 0;
  z-index: 3;
  background-color: inherit;
  /* One duration and one curve for both. They used to be .3s and .2s, so from
     200ms to 300ms the header was already invisible while it was still on its
     way out -- and whatever was tied to its position had not arrived yet. */
  transition: transform .3s var(--ease-soft), opacity .3s var(--ease-soft);
}
/* The fade is painted over the list rather than masked out of it.
   A mask on a scrolling container makes that container its own layer and
   re-composites it through an alpha channel on every scroll -- and on every
   frame the drawer is dragged, since the drawer moves it. Two gradients in the
   surface's own colour look identical and cost a plain paint of two small
   boxes that never change.

   The top one is pinned to the top of the list and never moves. It used to sit
   at the header's lower edge and slide up with it, which meant the fade and the
   header were two animations of the same distance -- and any daylight between
   them was a strip of posts with no header over them and no fade under them,
   which is the gap that showed during the transition.

   It does not need to move. While the header is there it is behind it (z-index
   2 against 3) and the header's own opaque background is what hides the posts
   passing beneath. As the header leaves, the fade is already in place and is
   simply uncovered. Nothing to keep in step, because nothing else moves. */
.feedwrap { --feed-fade: 24px; }
.feedwrap::before,
.feedwrap::after {
  content: '';
  position: absolute;
  left: 0; right: 0;
  height: var(--feed-fade);
  pointer-events: none;
  z-index: 2;
}
.feedwrap::before {
  top: 0;
  background: linear-gradient(to bottom, var(--surface), transparent);
}
.feedwrap::after {
  bottom: 0;
  background: linear-gradient(to top, var(--surface), transparent);
}
.sidebar.head-hidden .feedhead {
  transform: translateY(-100%);
  opacity: 0;
  pointer-events: none;
}

/* The box the two share. The list keeps the header's height as padding, so its
   first post starts below it and nothing moves when the header goes. */
.feedwrap {
  position: relative;
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  background-color: inherit;
}
@media (prefers-reduced-motion: reduce) {
  .feedhead { transition: none; }
}
.queryrow { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.querychip {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  height: 30px; padding: 0 13px; border-radius: var(--r-pill);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10), rgba(255,255,255,0) 60%),
    var(--grad-soft);
  border: 1px solid var(--line);
  backdrop-filter: blur(10px) saturate(150%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.16);
  font-size: 12.5px; color: var(--ink-2); white-space: nowrap;
}
.querychip b { color: var(--ink); font-variant-numeric: tabular-nums; }
.querychip #queryText { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.querychip.pinned { border-color: var(--primary); }

.mediaseg { display: flex; gap: 6px; overflow-x: auto; scrollbar-width: none; padding-bottom: 1px; }
.mediaseg::-webkit-scrollbar { display: none; }
.mediaseg button {
  display: inline-flex; align-items: center; gap: 5px; flex: 0 0 auto;
  height: 27px; padding: 0 11px; border-radius: var(--r-pill);
  border: 1px solid var(--line); background: var(--panel-2); color: var(--ink-3);
  font-size: 12px; font-weight: 600; cursor: pointer; transition: all .15s;
}
.mediaseg button svg { width: 13px; height: 13px; }
.mediaseg button:hover { color: var(--ink); border-color: var(--line-2); }
.mediaseg button.on {
  background: var(--grad); color: #fff; border-color: transparent;
  box-shadow: 0 4px 12px -5px rgba(124,58,237,.8);
}
.mediaseg .mcount { font-family: var(--mono); font-size: 10px; opacity: .7; }
.mediaseg button.on .mcount { opacity: .85; }

.controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* Why the scope is said in a line of its own rather than being left to the
   picker: the picker narrows the list, the count beside it is the map's, and
   the map cannot be narrowed -- its circles come from a rollup that is one set
   of numbers for everybody. Without this sentence an unchanged map reads as
   the filter not working. */
.scopenote { margin: 0; }
.segmented {
  display: flex; flex: 1; min-width: 0; padding: 2px; gap: 2px;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-sm);
}
.segmented button {
  flex: 1; min-width: 0; height: 26px; padding: 0 6px; border: 0; border-radius: 6px;
  background: none; color: var(--ink-3); font-size: 12px; font-weight: 600;
  cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  transition: background .15s, color .15s;
}
.segmented button:hover { color: var(--ink); }
.segmented button.on { background: var(--grad); color: #fff; }
.select {
  height: 30px; border-radius: var(--r-sm); padding: 0 8px; flex: none;
  background: var(--panel-2); border: 1px solid var(--line); color: var(--ink-2);
  font-size: 12px; cursor: pointer;
}

.feed {
  flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  padding: 8px;
}
.feed.loading { opacity: .45; }
/* Fetching the next page. A bar rather than a spinner: it sits at the end of
   the list where the next Bubls are about to appear, so it reads as the list
   continuing rather than as the app being busy. The list itself stays fully
   legible -- only the very end of it says anything is happening. */
.feed.more::after {
  content: '';
  display: block;
  height: 3px;
  margin: 14px 8px 4px;
  border-radius: 3px;
  background: linear-gradient(90deg,
    transparent, color-mix(in srgb, var(--primary) 70%, transparent), transparent);
  background-size: 40% 100%;
  background-repeat: no-repeat;
  animation: feedmore 1.1s linear infinite;
}
@keyframes feedmore {
  from { background-position: -40% 0; }
  to   { background-position: 140% 0; }
}
@media (prefers-reduced-motion: reduce) {
  .feed.more::after { animation: none; background-position: 50% 0; }
}

/* Every post has an edge, all the time.
   The border only existed on hover, so at rest the feed was a column of text
   with nothing holding each post together -- and on a phone, where there is no
   hover at all, it never appeared. It is glass now: a rim you can see, lit
   along the top the way the rest of the chrome is, so a post reads as an object
   sitting on the drawer rather than as a paragraph in a list. */
.card {
  padding: 12px 13px; border-radius: var(--r-md);
  border: 1px solid rgba(255, 255, 255, .11);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .07);
  cursor: pointer;
}
/* Lit, not darkened. Hovering used to swap the whole fill for a heavier one,
   which read as the card dimming just as you reached for it. It keeps its
   colour and its edge brightens instead -- the same rim, turned up, with a soft
   bloom in the brand hue behind it. */
.card {
  transition: box-shadow .18s var(--ease), border-color .18s var(--ease),
              transform .18s var(--ease);
}
.card:hover, .card:focus-visible {
  border-color: color-mix(in srgb, var(--primary) 42%, transparent);
  box-shadow:
    var(--glass-lip),
    0 0 0 1px color-mix(in srgb, var(--primary) 16%, transparent),
    0 10px 30px -18px color-mix(in srgb, var(--secondary) 70%, transparent);
}
.card:active { transform: scale(.995); }
/* Posts sit apart, not stacked. Two pixels was right when a card was an
   untitled block of text with no edge -- now that each one has a rim, they were
   close enough to read as one panel with lines ruled across it. */
.card + .card { margin-top: 12px; }
/* Who wrote it, at a size you can actually see.
   22px is smaller than a favicon, and an emoji avatar inside one is a smudge --
   which is most of what an avatar is here, since people pick one. The row it
   sits in was tight to match, so the name, the place and the time ran together
   into a single grey line. */
.card-top { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.card-av {
  width: 32px; height: 32px; border-radius: 50%; flex: none;
  display: grid; place-items: center; font-size: 15px; font-weight: 700; color: #fff;
  /* The same lit rim the rest of the glass has, so a face reads as an object
     rather than a coloured dot. */
  box-shadow: inset 0 1px 0 rgba(255,255,255,.34), 0 2px 8px -3px rgba(2,6,20,.7);
}
.card-auth { font-weight: 650; font-size: 13.5px; }
.card-meta { color: var(--ink-3); font-size: 12px; display: flex; align-items: center; gap: 5px; min-width: 0; }
.card-meta .sep { opacity: .5; }
.card-place { color: var(--primary); font-weight: 600; }
.card-body { font-size: 14px; line-height: 1.48; margin: 0 0 9px; color: var(--ink); }

.card-media {
  position: relative; margin: 0 0 9px; border-radius: var(--r-md);
  overflow: hidden; background: var(--panel-3);
  border: 1px solid var(--line);
}
.card-media img, .card-media video {
  display: block; width: 100%; height: auto; max-height: 260px;
  object-fit: cover; background: var(--panel-3);
  /* Faded in when it arrives, rather than appearing a band at a time. */
  opacity: 0;
  transition: opacity .32s var(--ease-soft);
}
.card-media.is-ready img, .card-media.is-ready video { opacity: 1; }
/* ...unless it never arrived. The element is taken out of the flow rather than
   left transparent: a broken <video> on iOS paints its own pale panel whatever
   the opacity says, and that panel is the width of the card. What is left is a
   short, quiet frame that says the picture is missing and lets the words the
   Bubl was actually about carry on below it. */
.card-media.is-failed { min-height: 0; animation: none; }
.card-media.is-failed img, .card-media.is-failed video { display: none; }
.card-media.is-failed::after {
  content: 'Picture unavailable';
  display: block; padding: 22px 12px; text-align: center;
  font-size: 12px; color: var(--ink-3);
  background: var(--panel-3);
}
/* Until then the frame holds its place and shows that something is coming.
   The height is reserved so the card does not jump when the picture lands and
   push whatever you were reading down the screen. */
.card-media:not(.is-ready) {
  min-height: 190px;
  background:
    linear-gradient(90deg, var(--panel-2), var(--panel-3) 50%, var(--panel-2));
  background-size: 200% 100%;
  animation: shimmer 1.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .card-media:not(.is-ready) { animation: none; }
  .card-media img, .card-media video { transition: none; }
}
.card-media video { cursor: pointer; }
.card-media .playbtn {
  position: absolute; inset: 0; display: grid; place-items: center;
  border: 0; background: linear-gradient(180deg, rgba(6,10,20,.05), rgba(6,10,20,.45));
  cursor: pointer; transition: opacity .2s;
}
.card-media .playbtn i {
  width: 44px; height: 44px; border-radius: 50%; display: grid; place-items: center;
  background: rgba(12,18,32,.62); backdrop-filter: blur(8px);
  border: 1px solid rgba(255,255,255,.28);
  box-shadow: 0 8px 24px -8px rgba(0,0,0,.7);
}
.card-media .playbtn i::after {
  content: ''; width: 0; height: 0; margin-left: 3px;
  border-left: 13px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent;
}
.card-media.playing .playbtn { opacity: 0; pointer-events: none; }
.card-media .badge {
  position: absolute; top: 8px; left: 8px; z-index: 2;
  display: inline-flex; align-items: center; gap: 4px;
  padding: 3px 8px; border-radius: var(--r-pill);
  background: rgba(10,14,26,.6); backdrop-filter: blur(8px);
  border: 1px solid rgba(255,255,255,.16);
  color: #fff; font-size: 10.5px; font-weight: 700; letter-spacing: .04em;
}
.card-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; }
.minitag {
  display: inline-flex; align-items: center; gap: 4px;
  padding: 2px 8px; border-radius: var(--r-pill);
  background: var(--panel-3); border: 1px solid var(--line);
  font-size: 11.5px; font-weight: 600; color: var(--ink-2); cursor: pointer;
}
.minitag:hover { color: var(--primary); border-color: var(--primary); }
/* The disc is smaller here than in the composer -- these sit in a line of body
   text rather than in a field of their own. */
.minitag .tagface { width: 14px; height: 14px; font-size: 10px; }
.minitag .tagface.letter { font-size: 8px; }
.card-foot { display: flex; align-items: center; gap: 14px; color: var(--ink-3); font-size: 12px; }
.card-foot button {
  display: inline-flex; align-items: center; gap: 5px;
  border: 0; background: none; color: inherit; cursor: pointer; padding: 2px;
  font-variant-numeric: tabular-nums;
}
.card-foot button:hover { color: var(--primary); }
.card-foot button.liked { color: var(--secondary); }
.card-foot button.liked svg { fill: currentColor; }
.card-foot svg { width: 14px; height: 14px; }

.feedstate { padding: 34px 22px; text-align: center; color: var(--ink-2); }
.feedstate .big {
  width: 46px; height: 46px; margin: 0 auto 12px; border-radius: 50%;
  background: var(--grad-soft); border: 1px solid var(--line);
  display: grid; place-items: center; font-size: 20px;
}
.feedstate h3 { margin: 0 0 6px; font-size: 15px; color: var(--ink); }
.feedstate p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; }
.skeleton { padding: 12px 13px; }
.skeleton span {
  display: block; height: 9px; border-radius: 5px; margin-bottom: 8px;
  background: linear-gradient(90deg, var(--panel-2), var(--panel-3), var(--panel-2));
  background-size: 200% 100%; animation: shimmer 1.3s linear infinite;
}
@keyframes shimmer { to { background-position: -200% 0; } }

/* ==========================================================================
   Mobile: globe is the screen, feed is a bottom sheet
   ========================================================================== */
.mobile-only { display: none !important; }

@media (max-width: 900px) {
  .mobile-only { display: grid !important; }
  .stage { grid-template-columns: 1fr; }
  .wordmark { display: none; }
  .nav { gap: 8px; padding: 0 8px; }
  .searchwrap { max-width: none; }
  .searchbox { height: 34px; padding-left: 10px; }
  #projBtn { display: none; }
  .navactions { gap: 4px; }
  .btn { height: 30px; padding: 0 12px; font-size: 13px; }
  .uname { display: none; }
  /* labels stay readable: scroll the sorts rather than truncating them */
  .segmented { overflow-x: auto; scrollbar-width: none; }
  .segmented::-webkit-scrollbar { display: none; }
  .segmented button { flex: 0 0 auto; padding: 0 11px; }

  .legend { display: none; }
}

/* ==========================================================================
   Modals
   ========================================================================== */
.modal { border: 0; padding: 0; background: none; max-width: 100vw; max-height: 100vh; }
.modal::backdrop { background: var(--scrim); backdrop-filter: blur(6px); }
.modal-card {
  position: relative; width: min(94vw, 420px);
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-lg); box-shadow: var(--shadow);
  padding: 26px 24px 22px; display: flex; flex-direction: column; gap: 16px;
}
.modal-x { position: absolute; top: 12px; right: 12px; }
.modal-head { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.modal-head h2 { margin: 0; font-size: 19px; letter-spacing: -.02em; }
.modal-head p { margin: 0; color: var(--ink-3); font-size: 13px; }
.tabs {
  display: flex; padding: 3px; gap: 3px;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-sm);
}
.tabs button {
  flex: 1; height: 30px; border: 0; border-radius: 6px; background: none;
  color: var(--ink-3); font-size: 13px; font-weight: 600; cursor: pointer;
}
.tabs button.on { background: var(--grad); color: #fff; }
.fields { display: flex; flex-direction: column; gap: 12px; }
.field { display: flex; flex-direction: column; gap: 5px; position: relative; }
.field > span { font-size: 12px; font-weight: 600; color: var(--ink-2); }
.field > span em { font-weight: 400; font-style: normal; color: var(--ink-3); }
.field input, .field textarea, .field select {
  width: 100%; padding: 10px 12px; border-radius: var(--r-sm);
  background: var(--panel-2); border: 1px solid var(--line); color: var(--ink);
  outline: none; resize: vertical; font-size: 14px;
  transition: border-color .16s, box-shadow .16s;
}
/* A select inherits none of the above on its own: the font and the colour of
   the closed control come from the UA sheet unless they are said here, which
   on a dark theme is black text on a dark panel. */
.field select { font-family: inherit; appearance: none; resize: none; cursor: pointer; }
.field select option { background: var(--panel-2); color: var(--ink); }
.field input:focus, .field textarea:focus, .field select:focus {
  border-color: transparent; box-shadow: var(--glow);
}
.counter { position: absolute; right: 4px; bottom: -16px; font-size: 11px; color: var(--ink-3);
           font-family: var(--mono); }
.formerror {
  margin: 0; padding: 9px 11px; border-radius: var(--r-sm);
  background: color-mix(in srgb, #F43F5E 14%, transparent);
  border: 1px solid color-mix(in srgb, #F43F5E 38%, transparent);
  color: #FB7185; font-size: 12.5px;
}
.hint { margin: 0; text-align: center; font-size: 11.5px; color: var(--ink-3); }
.hint code { font-family: var(--mono); background: var(--panel-2); padding: 1px 5px; border-radius: 4px; }
.hint.privacy { text-align: left; padding-top: 4px; }

/* --- the tag box --------------------------------------------------------- */
/* A field with chips in it, and a list under it. Replaces the wall of every tag
   we had, which could only work while the set was fixed. */

/* The face on a tag: its emoji, or a disc with its first letter for one that
   somebody made up and so has no picture and never will. */
.tagface {
  flex: none; display: grid; place-items: center;
  width: 18px; height: 18px; border-radius: 50%;
  font-size: 12px; line-height: 1;
}
.tagface.emoji { background: none; border-radius: 0; }
.tagface.letter { color: #fff; font-size: 10px; font-weight: 800; }

.tagbox {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  padding: 7px 8px;
  border: 1px solid rgba(255, 255, 255, .30);
  border-radius: var(--r-md);
  background: rgba(12, 16, 32, .32);
  transition: border-color .16s, box-shadow .16s;
  cursor: text;
}
.tagbox:focus-within {
  border-color: color-mix(in srgb, var(--primary) 78%, white 22%);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 34%, transparent);
}
.tagchips { display: contents; }
.tagchip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 4px 4px 8px; border-radius: var(--r-pill);
  background: var(--grad); color: #fff;
  font-size: 12.5px; font-weight: 700; max-width: 100%;
}
/* One somebody made up reads as theirs rather than as one of ours. */
.tagchip.custom {
  background: rgba(255, 255, 255, .12);
  border: 1px dashed rgba(255, 255, 255, .34);
  color: var(--ink);
}
.tagchip .lab { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tagchip .x {
  flex: none; width: 18px; height: 18px; border: 0; border-radius: 50%;
  background: rgba(0, 0, 0, .26); color: inherit;
  font-size: 14px; line-height: 1; cursor: pointer;
  display: grid; place-items: center;
}
.tagchip .x:hover { background: rgba(0, 0, 0, .44); }
#tagInput {
  flex: 1 1 120px; min-width: 100px;
  border: 0; background: none; outline: none;
  color: var(--ink); font: inherit; font-size: 14.5px; padding: 3px 2px;
}
#tagInput::placeholder { color: color-mix(in srgb, var(--ink) 45%, transparent); }
#tagInput:disabled { cursor: not-allowed; }

.tagsuggest {
  margin-top: 6px; padding: 5px;
  max-height: 232px; overflow-y: auto;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: color-mix(in srgb, var(--panel) 94%, transparent);
  box-shadow: var(--shadow);
}
.tagopt {
  display: flex; align-items: center; gap: 9px; width: 100%;
  padding: 8px 9px; border: 0; border-radius: var(--r-sm);
  background: none; color: var(--ink); font: inherit; font-size: 13.5px;
  text-align: left; cursor: pointer;
}
.tagopt .lab { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tagopt .cnt { flex: none; font-size: 11.5px; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.tagopt:hover, .tagopt.active { background: var(--panel-2); }
/* The one that makes a new tag is the odd one out and should look it. */
.tagopt.make .lab b { font-weight: 800; }
.tagopt.make .cnt {
  text-transform: uppercase; letter-spacing: .05em; font-weight: 800; font-size: 10px;
  color: var(--primary);
}

.tagpick { display: flex; flex-wrap: wrap; gap: 6px; max-height: 148px; overflow-y: auto; padding: 2px; }
.tagpick button {
  display: inline-flex; align-items: center; gap: 5px;
  padding: 5px 11px; border-radius: var(--r-pill);
  border: 1px solid var(--line); background: var(--panel-2); color: var(--ink-2);
  font-size: 12.5px; font-weight: 600; cursor: pointer; transition: all .14s;
}
.tagpick button:hover { border-color: var(--primary); color: var(--ink); }
.tagpick button.on { background: var(--grad); color: #fff; border-color: transparent; }

/* Toasts come down from the top, not up from the bottom.
   The bottom centre is the busiest place in this app: the posts pill sits there
   on a phone and the search dock does on a desktop, so a message announcing
   "Satellite imagery" landed on top of one or the other every time. The top
   centre is empty on both -- the wordmark and the account are to the left of it
   and the map controls to the right -- and on a phone it puts the message
   directly under the search bar, which is where you were already looking. */
.toast {
  position: fixed; left: 50%; top: 22px; transform: translate(-50%, -12px);
  max-width: min(88vw, 460px); text-align: center;
  padding: 11px 18px; border-radius: var(--r-pill); z-index: 99;
  background: color-mix(in srgb, var(--panel) 94%, transparent);
  backdrop-filter: blur(18px) saturate(160%);
  border: 1px solid var(--line); box-shadow: var(--shadow);
  font-size: 13px; font-weight: 500; opacity: 0;
  transition: opacity .22s, transform .22s var(--ease);
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }

@media (max-width: 900px) {
  /* Into the slot the search bar used to hold.
     It sat below the bar when the bar was up here; the bar has moved down to
     the button row, so the middle of the top row is empty and a message belongs
     in it -- between the home button and the account chip, on the same line,
     the same height, with the same gaps either side. Those three numbers are
     the dock's own, so the toast lands exactly where the field used to be. */
  .toast {
    top: var(--edge-t);
    left: calc(var(--edge) + var(--tap) + 8px);
    right: calc(var(--edge) + var(--tap) + 8px);
    max-width: none;
    /* As tall as the buttons either side of it for one line, and taller for
       more. It was a fixed --tap, so a message longer than the width of the
       phone was cut off at the ends -- and a notification you cannot read is
       not a notification. Growing downward costs nothing: what is under it is
       the map, and the message is gone in a couple of seconds. */
    min-height: var(--tap);
    height: auto;
    display: flex; align-items: center; justify-content: center;
    padding: 10px 16px;
    line-height: 1.35;
    text-wrap: pretty;
    transform: translateY(-8px);
    /* The material of the buttons it sits between, rather than a flatter panel
       floating over them -- and --ink rather than the softer body colour, since
       at this size it is the only thing on the line. */
    border-color: var(--glass-edge);
    background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
    box-shadow: 0 12px 34px -16px rgba(4, 8, 22, .8), var(--glass-lip);
    color: var(--ink);
    font-size: 13px; font-weight: 600;
    /* One line where one line fits, and as many as it takes when it does not.
       It used to be nowrap with an ellipsis, which starts from the row being
       one line high -- but the row is one line high because that is what the
       buttons either side of it need, not because the message is. A message cut
       off mid-sentence is not a message, and what is underneath is the map. */
    white-space: normal;
    overflow-wrap: anywhere;
  }
  .toast.show { transform: translateY(0); }
}

@media (min-width: 901px) {
  /* Centred over the map rather than over the window: the feed takes the right
     of the screen, so the middle of the window is not the middle of anything
     you are looking at, and it put the message under the map controls. Same
     expression the search dock uses, for the same reason. */
  .toast { left: calc((100% - var(--sidebar-w)) / 2); }
}

/* map popup */
.maplibregl-popup-content {
  background: var(--panel); color: var(--ink); border: 1px solid var(--line);
  border-radius: var(--r-md); box-shadow: var(--shadow); padding: 10px 12px; font-size: 12.5px;
}
.maplibregl-popup-tip { border-top-color: var(--panel) !important; border-bottom-color: var(--panel) !important; }
.bublpop b { font-size: 14px; }
.bublpop .sub { color: var(--ink-3); font-size: 11.5px; margin-top: 2px; }

@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}


/* ==========================================================================
   MapLibre chrome, themed to match the app
   ========================================================================== */

/* Zoom controls: same translucent panel as the rest of the UI, stroke icons at
   the same weight, and the brand gradient on press. */
.maplibregl-ctrl-group {
  background: color-mix(in srgb, var(--panel) 88%, transparent) !important;
  backdrop-filter: blur(14px) saturate(140%);
  border: 1px solid var(--line);
  border-radius: var(--r-sm) !important;
  box-shadow: var(--shadow) !important;
  overflow: hidden;
}
.maplibregl-ctrl-group button {
  width: 32px; height: 32px;
  background-color: transparent;
  transition: background-color .16s, background-image .16s;
}
.maplibregl-ctrl-group button + button { border-top: 1px solid var(--line); }
.maplibregl-ctrl-group button:not(:disabled):hover {
  background-color: color-mix(in srgb, var(--primary) 20%, transparent);
}
.maplibregl-ctrl-group button:not(:disabled):active { background-image: var(--grad); }
.maplibregl-ctrl-group button:focus,
.maplibregl-ctrl-group button:focus:not(:focus-visible) { box-shadow: none; }

.maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon  { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='29'%20height='29'%20viewBox='0%200%2029%2029'%3E%3Cpath%20d='M14.5%209.5v10M9.5%2014.5h10'%20stroke='%234F8DF7'%20stroke-width='2'%20stroke-linecap='round'%20fill='none'/%3E%3C/svg%3E") !important; }
.maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='29'%20height='29'%20viewBox='0%200%2029%2029'%3E%3Cpath%20d='M9.5%2014.5h10'%20stroke='%234F8DF7'%20stroke-width='2'%20stroke-linecap='round'%20fill='none'/%3E%3C/svg%3E") !important; }
:root[data-theme="light"] .maplibregl-ctrl-zoom-in .maplibregl-ctrl-icon  { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='29'%20height='29'%20viewBox='0%200%2029%2029'%3E%3Cpath%20d='M14.5%209.5v10M9.5%2014.5h10'%20stroke='%232563EB'%20stroke-width='2'%20stroke-linecap='round'%20fill='none'/%3E%3C/svg%3E") !important; }
:root[data-theme="light"] .maplibregl-ctrl-zoom-out .maplibregl-ctrl-icon { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='29'%20height='29'%20viewBox='0%200%2029%2029'%3E%3Cpath%20d='M9.5%2014.5h10'%20stroke='%232563EB'%20stroke-width='2'%20stroke-linecap='round'%20fill='none'/%3E%3C/svg%3E") !important; }
.maplibregl-ctrl-zoom-in:active .maplibregl-ctrl-icon  { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='29'%20height='29'%20viewBox='0%200%2029%2029'%3E%3Cpath%20d='M14.5%209.5v10M9.5%2014.5h10'%20stroke='%23FFFFFF'%20stroke-width='2'%20stroke-linecap='round'%20fill='none'/%3E%3C/svg%3E") !important; }
.maplibregl-ctrl-zoom-out:active .maplibregl-ctrl-icon { background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='29'%20height='29'%20viewBox='0%200%2029%2029'%3E%3Cpath%20d='M9.5%2014.5h10'%20stroke='%23FFFFFF'%20stroke-width='2'%20stroke-linecap='round'%20fill='none'/%3E%3C/svg%3E") !important; }

/* Anything MapLibre still draws with its hard-coded #333 glyph (geolocate and
   its state icons) at least stays legible against a dark panel. */
:root[data-theme="dark"] .maplibregl-ctrl-geolocate .maplibregl-ctrl-icon {
  filter: invert(1) opacity(.62);
}
:root[data-theme="light"] .maplibregl-ctrl-geolocate .maplibregl-ctrl-icon { opacity: .55; }

/* --- the basemap credit ----------------------------------------------------
 *
 * This block used to be `display: none !important` on the attribution and on
 * its button, and that made the site a licence violation for as long as it was
 * there: the imagery is CC-BY and Esri's terms ask for the credit. Rule 23,
 * and item one of BEFORE-LAUNCH.
 *
 * So it is styled down rather than styled out, and the line between the two is
 * the whole point of this block. Small, quiet, and on a surface that keeps it
 * readable over a photograph -- a credit that disappears over bright terrain
 * has been hidden by other means. There is no `opacity` on it below a value
 * you can read, and no `visibility`, and nothing under a breakpoint turns it
 * off.
 *
 * WHERE IT SITS, which is the part that took the thinking. All four anchors in
 * BUTTON-ANCHORS.md are taken, and the credit must not land under any of them:
 *
 *   On a desktop the bottom-left of the visible map is free -- the controls are
 *   the top-right stack there -- so it takes that corner.
 *
 *   On a phone the bottom-left is the map-settings button, so it lifts to just
 *   above the bottom row and rides `--btn-lift` with it. That is the same
 *   number drawSheet() writes per frame for the buttons, so the drawer can
 *   never come up over the credit: if the row is clear of the sheet, so is
 *   this.
 *
 * `right` is the sidebar expression on a desktop for the usual reason -- the
 * feed panel overlays the right of the map and the visible map ends where the
 * panel begins -- so a long credit wraps inside the map rather than under the
 * panel.
 */
.maplibregl-ctrl-bottom-left,
.maplibregl-ctrl-bottom-right {
  /* Below the chrome and above the map. The credit is not a control and must
     not be able to take a press meant for one. */
  z-index: 4;
}
.maplibregl-ctrl-attrib {
  font-size: 10px;
  line-height: 1.5;
  padding: 2px 7px;
  border-radius: var(--r-pill);
  /* Legible over imagery, which plain text on the map is not: the satellite
     view puts a coastline or a city at night behind every pixel of it. */
  background: color-mix(in srgb, var(--panel) 72%, transparent);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  color: var(--ink-2);
  max-width: min(80vw, 420px);
}
.maplibregl-ctrl-attrib a { color: var(--ink-2); text-decoration: underline; }
.maplibregl-ctrl-attrib a:hover { color: var(--ink); }
/* MapLibre's own sheet sets this to white; it has to follow the theme or the
   credit is black text on a dark panel. */
.maplibregl-ctrl-attrib.maplibregl-compact { background: none; }
.maplibregl-ctrl-attrib-inner { color: inherit; }


/* ==========================================================================
   Liquid glass: map controls and the docked search
   ========================================================================== */

/* One stack, top-right: zoom, then projection, then imagery. */
.mapctl {
  position: absolute; top: 14px; right: 14px; z-index: 7;
  display: flex; flex-direction: column; gap: 8px;
}
.glassbtn {
  position: relative; width: 38px; height: 38px; display: grid; place-items: center;
  border-radius: 13px; cursor: pointer; overflow: hidden;
  color: var(--ink);
  border: 1px solid rgba(255, 255, 255, .16);
  background:
    linear-gradient(150deg, rgba(255,255,255,.16) 0%, rgba(255,255,255,.03) 46%, rgba(255,255,255,0) 60%),
    linear-gradient(150deg, color-mix(in srgb, var(--primary) 26%, transparent),
                            color-mix(in srgb, var(--secondary) 26%, transparent));
  backdrop-filter: blur(18px) saturate(180%);
  -webkit-backdrop-filter: blur(18px) saturate(180%);
  box-shadow:
    0 6px 20px -8px rgba(6, 10, 24, .7),
    inset 0 1px 0 rgba(255, 255, 255, .30),
    inset 0 -8px 14px -10px rgba(120, 90, 250, .55);
  transition: transform .15s var(--ease), box-shadow .18s, border-color .18s;
}
.glassbtn::after {           /* specular sweep across the top edge */
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(120% 80% at 24% 0%, rgba(255,255,255,.42), rgba(255,255,255,0) 58%);
}
.glassbtn:hover { transform: translateY(-1px); border-color: rgba(255,255,255,.30); }
.glassbtn:active { transform: scale(.94); }
.glassbtn.on {
  border-color: color-mix(in srgb, var(--primary) 70%, transparent);
  box-shadow:
    0 6px 22px -6px color-mix(in srgb, var(--secondary) 70%, transparent),
    inset 0 1px 0 rgba(255,255,255,.36);
}
.glassbtn svg { width: 17px; height: 17px; position: relative; z-index: 1; }
.glassbtn .glabel {
  position: relative; z-index: 1;
  font-size: 12px; font-weight: 800; letter-spacing: .02em;
  font-family: var(--font);
}
[data-theme="light"] .glassbtn { color: #10203C; border-color: rgba(255,255,255,.55); }

/* Search, docked bottom-centre over the map. */
.searchdock {
  position: absolute; left: 50%; bottom: 22px; transform: translateX(-50%);
  width: min(560px, calc(100% - 120px)); z-index: 8;
}
.searchdock .searchbox {
  height: 48px; padding: 0 10px 0 16px; border-radius: var(--r-pill);
  border: 1px solid rgba(255,255,255,.20);
  /* Same glass recipe as the control buttons, so the chrome reads as one set. */
  /* Dark glass, lightly tinted. The bright version washed the placeholder out
     against whatever the map happened to be showing underneath. */
  background:
    linear-gradient(150deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.02) 46%, rgba(255,255,255,0) 60%),
    linear-gradient(150deg, color-mix(in srgb, var(--primary) 15%, transparent),
                            color-mix(in srgb, var(--secondary) 15%, transparent)),
    /* Nearly opaque, and that is the point of this one.
       The rest of the chrome is glass over a photograph and can afford to be,
       because none of it has to be read closely. This has typing in it. At 82%
       the map came through the panel and the placeholder sat on whatever
       happened to be underneath -- a coastline, a city at night -- so the text
       had a different background every time the map moved. 96% still carries
       the tint that makes it one of the set, and the words stop depending on
       where you are looking. */
    color-mix(in srgb, var(--panel) 96%, transparent);
  /* Less blur to go with it: a heavy blur under a nearly solid fill is paid
     for on every frame and seen by nobody. */
  backdrop-filter: blur(8px) saturate(135%);
  -webkit-backdrop-filter: blur(8px) saturate(135%);
  box-shadow:
    0 12px 34px -16px rgba(4, 8, 22, .8),
    inset 0 1px 0 rgba(255, 255, 255, .28),
    inset 0 -8px 16px -12px rgba(120, 90, 250, .5);
  position: relative;
  /* The sheen animates across the pill and must be clipped by it. */
  overflow: hidden;
}
.searchdock .searchbox::before {   /* the liquid sheen */
  content: ''; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  background: radial-gradient(120% 80% at 22% -4%, rgba(255,255,255,.22), rgba(255,255,255,0) 58%);
}
.searchdock .searchbox:focus-within {
  border-color: rgba(255,255,255,.40);
  box-shadow:
    0 16px 40px -16px rgba(4, 8, 22, .85),
    0 0 0 2px color-mix(in srgb, var(--primary) 26%, transparent),
    inset 0 1px 0 rgba(255,255,255,.34);
}
.searchdock .searchbox input { font-size: 14.5px; color: var(--ink); position: relative; z-index: 1; }
/* What the map is showing, at the end of the field you would use to change it.
   Only there once something is being searched -- otherwise the placeholder is
   free to carry the number instead, which reads better than a label. */
.searchcount {
  flex: none; z-index: 1;
  font-size: 12px; font-weight: 700; color: var(--ink-2);
  font-variant-numeric: tabular-nums; white-space: nowrap;
  padding-left: 2px;
}
.searchdock .searchbox input::placeholder { color: var(--ink-2); opacity: 1; }
.searchdock .searchbox .mag { color: var(--ink-2); width: 17px; height: 17px; z-index: 1; }
.searchdock .searchbox .iconbtn { color: var(--ink-2); z-index: 1; }
.searchdock .searchbox .iconbtn:hover { background: rgba(255,255,255,.16); border-color: transparent; }
.searchdock .tagchip {
  background: rgba(255,255,255,.22); color: #fff; z-index: 1;
  border: 1px solid rgba(255,255,255,.34); backdrop-filter: blur(8px);
}


/* Suggestions open upward from the dock. */
/* Bubble glass, the same as the bar it opens from -- it used to be a flatter,
   greyer panel, which read as a different thing hanging off the chrome rather
   than part of it. */
.searchdock .suggest {
  top: auto; bottom: calc(100% + 10px);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  border-color: var(--glass-edge);
  box-shadow: 0 -18px 48px -20px rgba(4,8,22,.9), var(--shadow), var(--glass-lip);
}

@media (max-width: 900px) {
  /* Where these sit on a phone is decided at the bottom of this file, with
     the rest of the phone layout. This is only how big they are. */
  .searchdock .searchbox { height: var(--tap); }
  .glassbtn { width: var(--tap); height: var(--tap); border-radius: 14px; }
  #projBtn { display: grid; }
}


/* ==========================================================================
   Bubbly glass, applied to the pills and chips
   ========================================================================== */
@keyframes bublpop {
  0%   { transform: scale(.82); opacity: 0; }
  55%  { transform: scale(1.06); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
@keyframes bublfloat {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-2px); }
}

/* A pill that behaves like a bubble: swells on hover, squashes on press. */
.mediaseg button, .segmented button, .minitag, .btn, .querychip, .select {
  transition: transform .18s var(--ease), box-shadow .2s, background .18s, color .18s, border-color .18s;
}
.mediaseg button:hover:not(.on), .minitag:hover { transform: translateY(-1.5px) scale(1.04); }
.mediaseg button:active, .segmented button:active, .minitag:active, .btn:active { transform: scale(.94); }

.mediaseg button, .select {
  border-color: rgba(255,255,255,.12);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10), rgba(255,255,255,.02) 55%, rgba(255,255,255,0)),
    var(--panel-2);
  backdrop-filter: blur(10px) saturate(150%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.16);
}
.mediaseg button.on {
  animation: bublpop .34s var(--ease);
  box-shadow:
    0 6px 16px -6px color-mix(in srgb, var(--secondary) 75%, transparent),
    inset 0 1px 0 rgba(255,255,255,.36);
}
.segmented {
  background:
    linear-gradient(150deg, rgba(255,255,255,.08), rgba(255,255,255,0) 60%),
    var(--panel-2);
  backdrop-filter: blur(10px) saturate(150%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.14);
}
.segmented button.on { animation: bublpop .3s var(--ease); }
.minitag {
  background:
    linear-gradient(150deg, rgba(255,255,255,.12), rgba(255,255,255,0) 60%),
    var(--panel-3);
  border-color: rgba(255,255,255,.12);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
}
.querychip, .legend, .precision {
  border-color: rgba(255,255,255,.13);
  box-shadow: var(--shadow), inset 0 1px 0 rgba(255,255,255,.18);
}
.tagchip { box-shadow: inset 0 1px 0 rgba(255,255,255,.3); }
.card-media { box-shadow: inset 0 1px 0 rgba(255,255,255,.10); }

/* ==========================================================================
   User menu
   ========================================================================== */
.navactions { margin-left: auto; }
.usermenu { position: relative; }
.userchip {
  display: flex; align-items: center; gap: 8px;
  height: 36px; padding: 0 10px 0 6px; border-radius: var(--r-pill);
  border: 1px solid rgba(255,255,255,.12); cursor: pointer;
  background:
    linear-gradient(150deg, rgba(255,255,255,.12), rgba(255,255,255,0) 60%),
    var(--panel-2);
  backdrop-filter: blur(12px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
  transition: transform .16s var(--ease), border-color .16s;
}
.userchip:hover { transform: translateY(-1px); border-color: var(--line-2); }
.userchip .chev { width: 15px; height: 15px; color: var(--ink-3); transition: transform .2s var(--ease); }
.userchip[aria-expanded="true"] .chev { transform: rotate(180deg); }

.dropdown {
  position: absolute; right: 0; top: calc(100% + 8px); z-index: 60;
  min-width: 194px; padding: 6px;
  /* Bubble glass -- the same material as the chip that opens it, the buttons
     beside it and the suggestion list. It was a flat near-opaque panel in a
     grey that is very nearly the drawer's own colour, so over an open drawer
     it read as part of the drawer rather than as something floating above it:
     the right stacking order and the wrong material look identical. */
  border: 1px solid var(--glass-edge);
  border-radius: var(--r-lg);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: 0 18px 48px -20px rgba(4, 8, 22, .9), var(--shadow), var(--glass-lip);
  animation: bublpop .22s var(--ease);
  transform-origin: top right;
}
/* Buttons and links alike. Two of these entries navigate to a page now, and an
   anchor that is not named here gets none of this -- it lays out inline, which
   is what put Settings and Admin side by side on one line with no styling. */
.dropdown button,
.dropdown a[role="menuitem"] {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 9px 11px; border: 0; border-radius: var(--r-sm);
  background: none; color: var(--ink); cursor: pointer;
  font-size: 13.5px; font-weight: 500; text-align: left;
  text-decoration: none;
}
.dropdown button:hover,
.dropdown a[role="menuitem"]:hover { background: rgba(255, 255, 255, .07); }
.dropdown button svg,
.dropdown a[role="menuitem"] svg { width: 16px; height: 16px; color: var(--ink-3); }
.dropdown-sep { height: 1px; background: var(--line); margin: 5px 8px; }

/* --- notifications ------------------------------------------------------- */

/* Wider than the other dropdowns, because this one carries sentences rather
   than one-word commands. Capped against the window so a phone gets the width
   it has rather than a scrollbar. */
#userDropdown { min-width: min(320px, calc(100vw - 24px)); max-width: calc(100vw - 24px); }

/* The count rides on the avatar rather than sitting beside it, so the thing you
   already look for -- your own face in the corner -- is the thing that changes.
   A pill, not a dot: how many is the whole point, and a dot that means "some"
   sends you to open the panel to find out it was one. */
.avwrap { position: relative; display: inline-grid; place-items: center; flex: none; }
.notifdot {
  position: absolute; top: -5px; right: -7px;
  min-width: 17px; height: 17px; padding: 0 4px;
  border-radius: 9px;
  background: linear-gradient(140deg, #FF5E7A, #E0245E);
  /* Ringed in the surface behind it, so it reads as sitting on top of the
     avatar rather than as part of it. */
  border: 2px solid var(--panel);
  color: #fff; font-size: 10.5px; font-weight: 800; line-height: 13px;
  display: grid; place-items: center;
  font-variant-numeric: tabular-nums;
  box-shadow: 0 2px 8px -2px rgba(224, 36, 94, .8);
}
/* On the map the chip sits on glass, not on a panel. */
.searchdock .notifdot { border-color: color-mix(in srgb, var(--panel) 80%, transparent); }

.dropdown-head {
  padding: 7px 11px 5px;
  font-size: 11.5px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
  color: var(--ink-3);
}
/* Bounded, and scrolls. A dropdown that grows with the list ends up taller than
   the window and puts Sign out somewhere off the bottom of the screen. */
.notiflist { max-height: min(46vh, 320px); overflow-y: auto; }
.notiflist .notif {
  display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
  width: 100%; padding: 8px 10px; border: 0; border-radius: var(--r-sm);
  background: none; color: var(--ink); text-align: left; cursor: pointer;
  font: inherit;
}
.notiflist .notif:hover, .notiflist .notif:focus-visible { background: rgba(255, 255, 255, .07); }
/* Unread carries a bar as well as a tint: a background a shade lighter than the
   one next to it is not a difference everyone can see. */
.notiflist .notif.unread {
  background: color-mix(in srgb, var(--primary) 12%, transparent);
  box-shadow: inset 2.5px 0 0 var(--primary);
}
.notiflist .notif .who { font-weight: 700; }
/* One face for the group, with a count when there are more behind it. */
.notiflist .notif-face { position: relative; flex: none; display: block; }
.notiflist .notif-face .more {
  position: absolute; right: -5px; bottom: -3px;
  min-width: 16px; height: 16px; padding: 0 3px;
  border-radius: 8px; border: 2px solid var(--panel);
  background: var(--panel-3); color: var(--ink-2);
  font-size: 9.5px; font-weight: 800; line-height: 12px;
  display: grid; place-items: center; font-variant-numeric: tabular-nums;
}

/* The friend buttons and Message, on someone's page.
   One box in the header's third column, because a received request puts three
   buttons here and three grid items in a three-column grid wraps the last one
   onto a row of its own. */
.profacts {
  justify-self: end; align-self: center;
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  justify-content: flex-end;
}
.friendacts { display: contents; }

/* A state, said in words as well as in colour, keeping its width across the
   labels it can hold so the header does not shift when you press it. */
.friendbtn {
  min-width: 96px; height: 34px; padding: 0 16px;
  border-radius: var(--r-pill); border: 1px solid transparent;
  background: var(--grad); color: #fff;
  font: inherit; font-size: 13px; font-weight: 700; cursor: pointer;
  transition: background .16s, border-color .16s, transform .16s var(--ease);
}
.friendbtn:hover { transform: translateY(-1px); }
.friendbtn:active { transform: scale(.96); }
/* Requested and Friends are both states you are already in, so neither asks
   for attention -- they are only the way back out, and say so on hover. */
.friendbtn.on {
  background: none; border-color: var(--line); color: var(--ink-2);
}
.friendbtn.on:hover { border-color: #FF8A8A; color: #FF8A8A; }
/* Decline sits beside Accept and must not read as its equal. */
.friendbtn.quiet {
  min-width: 0; background: none; border-color: var(--line); color: var(--ink-2);
  font-weight: 600;
}
.friendbtn.quiet:hover { border-color: #FF8A8A; color: #FF8A8A; }
.friendbtn.sm { min-width: 0; height: 28px; padding: 0 11px; font-size: 12px; }
.friendbtn:disabled { opacity: .6; cursor: default; transform: none; }

/* --- friend requests, in the account menu ------------------------------- */

.reqlist { max-height: min(32vh, 220px); overflow-y: auto; }
.req {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center; gap: 9px;
  padding: 7px 10px; border-radius: var(--r-sm);
}
.req:hover { background: rgba(255, 255, 255, .07); }
.req .req-who { font-size: 12.5px; line-height: 1.35; min-width: 0; }
.req .req-who .who { font-weight: 700; }
.req .req-who .when { font-size: 11.5px; color: var(--ink-3); white-space: nowrap; }
.req .req-acts { display: flex; gap: 6px; flex: none; }
.req .card-av { width: 26px; height: 26px; font-size: 11px; }
.notiflist .notif .what { font-size: 12.5px; line-height: 1.4; }
.notiflist .notif .excerpt {
  display: block; margin-top: 2px;
  font-size: 12px; color: var(--ink-3);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.notiflist .notif .when { font-size: 11.5px; color: var(--ink-3); white-space: nowrap; }
.notiflist .card-av { width: 26px; height: 26px; font-size: 11px; }
.notif-empty { padding: 10px 11px 14px; font-size: 12.5px; color: var(--ink-3); }

/* ==========================================================================
   Settings
   ========================================================================== */
.settings-card { width: min(94vw, 470px); max-height: 88vh; overflow-y: auto; }
.sset { display: flex; flex-direction: column; gap: 12px; }
.sset h3 {
  margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-3);
}
.setrow { display: flex; align-items: center; justify-content: space-between; gap: 14px; }
.setrow b { display: block; font-size: 14px; font-weight: 600; }
.setrow span { display: block; font-size: 12px; color: var(--ink-3); }
.segmented.compact { flex: 0 0 auto; width: 148px; }
.avatarrow { display: flex; gap: 14px; align-items: flex-start; }
.avatarfields { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.avatar.big {
  width: 64px; height: 64px; font-size: 26px; flex: none;
  box-shadow: inset 0 2px 0 rgba(255,255,255,.35), 0 8px 20px -8px rgba(0,0,0,.5);
}
.huerow { display: flex; flex-wrap: wrap; gap: 7px; }
.huedot {
  width: 26px; height: 26px; border-radius: 50%; cursor: pointer;
  border: 2px solid transparent; padding: 0;
  box-shadow: inset 0 2px 0 rgba(255,255,255,.4);
  transition: transform .15s var(--ease), border-color .15s;
}
.huedot:hover { transform: scale(1.14); }
.huedot.on { border-color: var(--ink); transform: scale(1.1); }


/* ==========================================================================
   Search: bubbly on hover, on press, and on the way open
   ========================================================================== */
@keyframes searchpop {
  0%   { transform: scale(1); }
  38%  { transform: scale(1.035, 0.955); }   /* squash */
  70%  { transform: scale(0.992, 1.022); }   /* stretch */
  100% { transform: scale(1); }
}
@keyframes sheenSweep {
  0%   { transform: translateX(-120%) skewX(-18deg); opacity: 0; }
  35%  { opacity: .5; }
  100% { transform: translateX(220%) skewX(-18deg); opacity: 0; }
}
@keyframes riseUp {
  0%   { opacity: 0; transform: translateY(16px) scale(.94); }
  62%  { opacity: 1; transform: translateY(-3px) scale(1.012); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes itemFloatIn {
  0%   { opacity: 0; transform: translateY(8px) scale(.96); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}

.searchdock .searchbox {
  transition: transform .22s var(--ease), box-shadow .22s, border-color .22s, background .22s;
  will-change: transform;
}
.searchdock .searchbox:hover {
  transform: translateY(-2px) scale(1.012);
  border-color: rgba(255,255,255,.30);
}
.searchdock .searchbox::after {          /* a highlight that sweeps on hover */
  content: ''; position: absolute; top: 0; bottom: 0; left: 0; width: 38%;
  border-radius: inherit; pointer-events: none; opacity: 0;
  background: linear-gradient(100deg, rgba(255,255,255,0), rgba(255,255,255,.30), rgba(255,255,255,0));
}
.searchdock .searchbox:hover::after { animation: sheenSweep 1.1s var(--ease); }
.searchdock .searchbox:focus-within { animation: searchpop .42s var(--ease); }
.searchdock .searchbox:active { transform: translateY(-1px) scale(.994); }

.searchdock .suggest {
  animation: riseUp .3s var(--ease);
  transform-origin: bottom center;
  border-radius: var(--r-lg);
  padding: 7px;
}
.searchdock .suggest-item {
  border-radius: var(--r-pill);
  animation: itemFloatIn .26s var(--ease) backwards;
  transition: transform .16s var(--ease), background .16s;
}
.searchdock .suggest-item:nth-child(1) { animation-delay: .01s; }
.searchdock .suggest-item:nth-child(2) { animation-delay: .04s; }
.searchdock .suggest-item:nth-child(3) { animation-delay: .07s; }
.searchdock .suggest-item:nth-child(4) { animation-delay: .10s; }
.searchdock .suggest-item:nth-child(5) { animation-delay: .13s; }
.searchdock .suggest-item:nth-child(n+6) { animation-delay: .16s; }
.searchdock .suggest-item:hover, .searchdock .suggest-item.active {
  transform: translateX(3px) scale(1.015);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10), rgba(255,255,255,0) 60%),
    color-mix(in srgb, var(--primary) 16%, transparent);
}
.searchdock .suggest-item .em {
  transition: transform .18s var(--ease);
}
.searchdock .suggest-item:hover .em { transform: scale(1.22) rotate(-6deg); }

/* Scrollbar inside the suggestions, to match. */
.searchdock .suggest { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--primary) 55%, transparent) transparent; }
.searchdock .suggest::-webkit-scrollbar { width: 9px; }
.searchdock .suggest::-webkit-scrollbar-track { background: transparent; margin: 6px 0; }
.searchdock .suggest::-webkit-scrollbar-thumb {
  border-radius: var(--r-pill);
  border: 2px solid transparent;
  background-clip: content-box;
  background-image: linear-gradient(180deg,
    color-mix(in srgb, var(--primary) 85%, transparent),
    color-mix(in srgb, var(--secondary) 85%, transparent));
}
.searchdock .suggest::-webkit-scrollbar-thumb:hover {
  background-image: linear-gradient(180deg, var(--primary), var(--secondary));
}

@media (prefers-reduced-motion: reduce) {
  .searchdock .searchbox:hover, .searchdock .searchbox:active { transform: none; }
  .searchdock .suggest, .searchdock .suggest-item { animation: none; }
}


/* The in-view counter lives in the top bar now, beside the account. */
.navactions .querychip { max-width: 320px; }
.navactions .querychip #queryText { overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 1100px) { .navactions .querychip { max-width: 190px; } }
@media (max-width: 820px)  { .navactions .querychip, #pinBtn { display: none; } }


/* ==========================================================================
   Desktop shell: no top bar, a floating sidebar, map running underneath
   ========================================================================== */
@media (min-width: 901px) {
  .nav { display: none; }
  .stage { grid-template-columns: 1fr; }
  .mapwrap { position: absolute; inset: 0; }

  /* The map runs the full width; the sidebar floats over its right edge. */
  .sidebar {
    position: absolute;
    right: 0;
    top: var(--rail-gap);
    bottom: var(--rail-gap);
    width: var(--sidebar-w);
    border: 1px solid var(--line);
    border-right: 0;
    border-radius: var(--r-xl) 0 0 var(--r-xl);
    background: color-mix(in srgb, var(--panel) 88%, transparent);
    backdrop-filter: blur(26px) saturate(170%);
    -webkit-backdrop-filter: blur(26px) saturate(170%);
    box-shadow: -26px 0 60px -30px rgba(2, 6, 18, .85), inset 0 1px 0 rgba(255,255,255,.10);
    overflow: hidden;
    z-index: 20;
  }

  /* Controls stay clear of it. */
  .mapctl { right: calc(var(--sidebar-w) + 18px); }
  .fab { right: calc(var(--sidebar-w) + 24px); }
  .searchdock {
    left: calc((100% - var(--sidebar-w)) / 2);
    width: min(560px, calc(100% - var(--sidebar-w) - 120px));
  }

  .brandfloat {
    position: absolute; top: 16px; left: 18px; z-index: 9;
    display: flex; align-items: center; gap: 10px;
    height: 40px; padding: 0 16px 0 10px; border-radius: var(--r-pill);
    text-decoration: none;
    border: 1px solid rgba(255,255,255,.14);
    background:
      linear-gradient(150deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.02) 46%, rgba(255,255,255,0) 60%),
      color-mix(in srgb, var(--panel) 78%, transparent);
    backdrop-filter: blur(20px) saturate(170%);
    box-shadow: 0 10px 28px -14px rgba(2,6,18,.85), inset 0 1px 0 rgba(255,255,255,.22);
    transition: transform .18s var(--ease);
  }
  .brandfloat:hover { transform: translateY(-1px); }
}

@media (max-width: 900px) { .brandfloat { display: none; } }

:root { --rail-gap: 14px; --r-xl: 22px; }

/* Account row inside the sidebar. */
.accountrow {
  display: flex; align-items: center; justify-content: flex-end; gap: 8px;
  padding-bottom: 2px;
}
.accountrow .usermenu { margin-left: 0; }
.accountrow .dropdown { right: 0; top: calc(100% + 8px); }
.queryrow .querychip { flex: 1; }


/* ==========================================================================
   Post detail
   ========================================================================== */
.detail { padding: 4px 4px 18px; }
.backbtn {
  display: inline-flex; align-items: center; gap: 6px;
  margin: 4px 6px 10px; padding: 0 14px 0 10px;
  height: 32px;
  border-radius: var(--r-pill);
  /* The same glass as the pill above it and the pickers beside it. It was a
     flat --panel-2 rectangle, which is the one colour on this surface that is
     darker than the surface -- so the only control on the screen that reads as
     sunk into the drawer was the one for getting out of it. */
  border: 1px solid var(--glass-edge);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: 0 10px 26px -16px rgba(4, 8, 22, .8), var(--glass-lip);
  color: var(--ink);
  font-size: 12.5px; font-weight: 600; cursor: pointer;
  transition: transform .15s var(--ease), border-color .15s, color .15s;
}
.backbtn:hover {
  transform: translateX(-2px);
  border-color: rgba(255, 255, 255, .34);
  color: var(--ink);
}
.backbtn:active { transform: translateX(-2px) scale(.97); }
.backbtn svg { width: 14px; height: 14px; }
/* The post you opened is the subject of the screen, so it is lit rather than
   sunk. It filled itself with --panel-2, which was "slightly lighter" back when
   the feed sat on a near-black panel and is darker than the drawer it sits on
   now -- so opening a post dimmed it. It keeps the rim every card has, turned
   up, with a little white over the top: the same way a card looks when you
   point at it, held. */
.card.detail-card {
  cursor: default;
  background: rgba(255, 255, 255, .05);
  border-color: color-mix(in srgb, var(--primary) 38%, transparent);
  box-shadow:
    var(--glass-lip),
    0 0 0 1px color-mix(in srgb, var(--primary) 14%, transparent),
    0 10px 30px -20px color-mix(in srgb, var(--secondary) 60%, transparent);
}
.card.detail-card .card-media img, .card.detail-card .card-media video { max-height: 340px; }
.comment-head {
  padding: 12px 13px 6px; font-size: 11.5px; font-weight: 700;
  letter-spacing: .09em; text-transform: uppercase; color: var(--ink-3);
}
.comments { display: flex; flex-direction: column; gap: 6px; }
.comment {
  display: flex; gap: 11px; padding: 12px 14px; border-radius: var(--r-md);
}
/* Lifted, never dimmed. --panel-2 is lighter than the old dark panel but darker
   than the drawer it now sits on, so hovering a comment sank it -- the same
   mistake the cards had. A little white instead, which is brighter than
   whatever is underneath it whatever that turns out to be. */
.comment { transition: background .15s; border-radius: var(--r-md); }
.comment:hover { background: rgba(255, 255, 255, .06); }
.comment .card-av { width: 30px; height: 30px; font-size: 14px; flex: none; }
.comment-meta {
  font-size: 12px; color: var(--ink-3); margin-bottom: 5px;
  display: flex; align-items: baseline; gap: 7px;
}
.comment-meta b { color: var(--ink); font-weight: 650; font-size: 13.5px; }
.comment p { margin: 0; font-size: 14px; line-height: 1.5; }
/* A reply sits under what it answers, once. One indent, with a line down the
   side so the thread is readable without a second one. */
.comment.is-reply {
  margin-left: 22px;
  padding-left: 12px;
  border-left: 2px solid color-mix(in srgb, var(--primary) 34%, transparent);
}
.replylink {
  margin-top: 4px; padding: 2px 0;
  border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 11.5px; font-weight: 700;
  color: var(--ink-3);
}
.replylink:hover { color: var(--primary); }
/* Reply and Report sit on the same line under a comment. The second is the
   quieter of the two: one is what you came to do and the other is what you do
   once in a while. */
.comment .replylink + .replylink { margin-left: 12px; }
.replylink.quiet { font-weight: 400; opacity: .72; }
/* Who the box is aimed at, and the way back to aiming it at the Bubl. */
.replyto {
  display: inline-flex; align-items: center; gap: 5px; flex: none;
  padding: 0 4px 0 9px; height: 26px; border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--primary) 22%, transparent);
  border: 1px solid color-mix(in srgb, var(--primary) 45%, transparent);
  font-size: 11.5px; font-weight: 700; color: var(--ink);
  white-space: nowrap;
}
.replyto .x {
  width: 17px; height: 17px; border: 0; border-radius: 50%;
  background: rgba(0,0,0,.26); color: inherit;
  font-size: 13px; line-height: 1; cursor: pointer; display: grid; place-items: center;
}
.replyto .x:hover { background: rgba(0,0,0,.45); }

.replybox { display: flex; gap: 8px; padding: 12px 13px 4px; }
.replybox input {
  flex: 1; min-width: 0; height: 36px; padding: 0 13px;
  border-radius: var(--r-pill); border: 1px solid var(--line);
  background: var(--panel-2); color: var(--ink); outline: none; font-size: 13.5px;
}
.replybox input:focus { border-color: transparent; box-shadow: var(--glow); }
.replybox .btn { height: 36px; }


/* ==========================================================================
   Blow a Bubl
   Same glass as the search bar and the counter, so the three read as one set.
   All the colour lives in the icon.
   ========================================================================== */
.fab {
  position: absolute; z-index: 9;
  right: 24px; bottom: 22px;
  display: flex; align-items: center; gap: 10px;
  height: 48px; padding: 0 20px 0 8px;
  border: 1px solid rgba(255, 255, 255, .20);
  border-radius: var(--r-pill);
  color: var(--ink); font-size: 14.5px; font-weight: 650; cursor: pointer;
  background:
    linear-gradient(150deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.02) 46%, rgba(255,255,255,0) 60%),
    linear-gradient(150deg, color-mix(in srgb, var(--primary) 15%, transparent),
                            color-mix(in srgb, var(--secondary) 15%, transparent)),
    color-mix(in srgb, var(--panel) 82%, transparent);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow:
    0 12px 34px -16px rgba(4, 8, 22, .8),
    inset 0 1px 0 rgba(255, 255, 255, .28),
    inset 0 -8px 16px -12px rgba(120, 90, 250, .5);
  animation: fabDrift 5.2s ease-in-out infinite;
  transition: border-color .18s, box-shadow .18s;
  will-change: transform;
}
.fab-label { position: relative; z-index: 2; }

/* the pop of colour: a small bubble with a plus in it */
.fab-icon {
  position: relative; z-index: 2; flex: none;
  width: 34px; height: 34px; border-radius: 50%;
  display: grid; place-items: center;
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,.75), rgba(255,255,255,0) 52%),
    linear-gradient(140deg, var(--blue-400) 0%, var(--blue-500) 38%,
                            var(--purple-500) 74%, var(--magenta) 100%);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.55),
    0 4px 12px -4px color-mix(in srgb, var(--secondary) 85%, transparent);
  transition: transform .22s var(--ease);
}
.fab-icon svg { width: 16px; height: 16px; stroke: #fff; stroke-width: 2.6; }
.fab:hover .fab-icon { transform: rotate(90deg) scale(1.08); }

.fab:hover {
  animation: fabWobble .62s var(--ease);
  border-color: rgba(255, 255, 255, .34);
  box-shadow:
    0 16px 40px -16px rgba(4, 8, 22, .85),
    inset 0 1px 0 rgba(255, 255, 255, .34);
}
.fab:active { animation: none; transform: scale(.94); }

@media (min-width: 901px) { .fab { right: calc(var(--sidebar-w) + 26px); } }
@media (max-width: 900px) {
  .fab { right: 44px; bottom: 74px; padding: 0 7px; }
  .fab-label { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .fab { animation: none; }
  .fab:hover .fab-icon { transform: none; }
}

/* ==========================================================================
   Topic panel: no sideways scrolling, one radius throughout
   ========================================================================== */
/* overflow-y set with overflow-x left at visible makes the browser promote the
   horizontal axis to auto, which is where the stray scrollbar came from. */
.searchdock .suggest { overflow-x: hidden; overflow-y: auto; scrollbar-gutter: stable; }
.searchdock .suggest * { min-width: 0; }
.srowgroup { padding-left: 4px; padding-right: 4px; }
.searchdock .suggest .catback,
.searchdock .suggest .freerow,
.searchdock .suggest .suggest-item { border-radius: var(--r-lg); }

/* ==========================================================================
   The in-view counter: same height and material as the search bar and the
   compose button, so the three read as one set of controls.
   ========================================================================== */
.mapchrome { left: 24px; bottom: 22px; gap: 10px; }
@media (max-width: 900px) {
  .mapchrome { left: 12px; bottom: 12px; }
}

/* A row that moves the map rather than filtering it. Warmer than the topics
   around it, so the one row in this panel with a different kind of consequence
   does not look like the rest of them. */
.placerow .srow-lab b, .phraserow .srow-lab b { font-weight: 800; }
/* The sentence row does two things at once, so it leads and it looks like it. */
.phraserow { border-color: color-mix(in srgb, var(--primary) 45%, transparent) !important; }
.searchdock .suggest .phraserow {
  background: color-mix(in srgb, var(--primary) 15%, transparent);
}
.searchdock .suggest .phraserow:hover,
.searchdock .suggest .phraserow.active {
  background: color-mix(in srgb, var(--primary) 26%, transparent);
}
.searchdock .suggest .placerow:hover,
.searchdock .suggest .placerow.active {
  background: color-mix(in srgb, var(--secondary) 16%, transparent);
  border-color: color-mix(in srgb, var(--secondary) 55%, transparent);
}
.srow-face.place {
  background: color-mix(in srgb, var(--secondary) 20%, transparent);
  border: 1px solid color-mix(in srgb, var(--secondary) 42%, transparent);
}

/* A category is the heavier of the two -- it opens another level. */
.srow-cat .srow-lab { font-size: 14px; font-weight: 700; letter-spacing: -.01em; }
.srow-cat .srow-face { font-size: 18px; }
.catback {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 9px 12px; margin-bottom: 4px;
  border: 1px solid var(--line); background: var(--panel-2); color: var(--ink);
  font-size: 13px; font-weight: 700; cursor: pointer;
  transition: transform .15s var(--ease), background .15s;
}
.catback:hover { transform: translateX(-2px); background: var(--panel-3); }
.catback svg { width: 15px; height: 15px; color: var(--ink-3); }
.catback-n { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--ink-3); font-weight: 500; }


/* ==========================================================================
   One control family
   The search bar, the in-view counter, Blow a Bubl and the map controls are
   the same glass at the same size, so the chrome reads as one set rather than
   four separate widgets. Everything below overrides the earlier per-widget
   recipes; the shared values live here in one place.
   ========================================================================== */
.glassbtn {
  width: 48px; height: 48px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(255, 255, 255, .20);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.02) 46%, rgba(255,255,255,0) 60%),
    linear-gradient(150deg, color-mix(in srgb, var(--primary) 15%, transparent),
                            color-mix(in srgb, var(--secondary) 15%, transparent)),
    color-mix(in srgb, var(--panel) 82%, transparent);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow:
    0 12px 34px -16px rgba(4, 8, 22, .8),
    inset 0 1px 0 rgba(255, 255, 255, .28),
    inset 0 -8px 16px -12px rgba(120, 90, 250, .5);
}
/* The old sweep was tuned for a brighter, smaller button and read as a hotspot
   at this size. */
.glassbtn::after {
  background: radial-gradient(120% 80% at 26% 0%, rgba(255,255,255,.26), rgba(255,255,255,0) 60%);
}
.glassbtn:hover {
  transform: none;
  border-color: rgba(255, 255, 255, .34);
  box-shadow:
    0 16px 40px -16px rgba(4, 8, 22, .85),
    inset 0 1px 0 rgba(255, 255, 255, .34);
}
.glassbtn svg { width: 19px; height: 19px; }
.glassbtn .glabel { font-size: 13px; font-weight: 700; }
.mapctl { top: 22px; right: 24px; gap: 10px; }
/* Bottom left, under the hand, the way it is on a phone -- where this is one
   button that opens a group upward and here it is the group itself, always
   open, because a desktop has the room. It was the top right, which is both the
   worst corner to reach and the one the account now wants. */
@media (min-width: 901px) {
  .mapctl {
    top: auto; right: auto;
    left: 24px; bottom: 22px;
  }
}
@media (max-width: 900px) {
  .mapctl { top: 12px; right: 12px; gap: 8px; }
  .glassbtn { width: 40px; height: 40px; }
  .glassbtn svg { width: 17px; height: 17px; }
}

/* Blow a Bubl: same type as the counter, and an icon sized to sit inside the
   pill rather than fill it. */
.fab { font-size: 14px; font-weight: 700; gap: 9px; padding: 0 20px 0 10px; }
.fab-icon { width: 28px; height: 28px; }
.fab-icon svg { width: 14px; height: 14px; stroke-width: 2.8; }
@media (max-width: 900px) { .fab { padding: 0 10px; } }


/* ==========================================================================
   Feed: room to breathe at the bottom
   The last card used to sit flush against the sidebar's rounded bottom edge,
   and mid-scroll a card was cut off by a hard line. A mask fades the scroll
   content out over the last stretch, and the extra padding lets the final card
   scroll clear of the fade instead of ending inside it.
   ========================================================================== */
/* No fade at the bottom. It used to soften the last 46px so a card would not
   be cut off by the panel's rounded edge -- but what it actually did was dim
   the last line of whatever you were reading, every time you reached the end.
   The padding does that job on its own: the final card scrolls clear of the
   edge rather than being hidden as it arrives at it.
   (It had already been turned off on phones for the same reason.) */
/* Where the list begins and ends, said with a fade rather than a rule.
   A line is a statement that two things are separate; these are not -- the list
   runs under the header and off the bottom of the drawer, and what should read
   is that it carries on, not that it stops. So the posts thin out into the
   surface at both ends instead.
   The top fade is pinned to the top of the list and does not follow the
   header. It used to, and was registered with @property so the move could be
   interpolated -- but two animations covering the same distance is one more
   than the job needs, and the daylight between them was the gap that showed
   every time the header left. See .feedwrap::before. */
.feed {
  padding: 8px 8px 34px;
  /* Room for the header lying over the top of it, so the first post starts
     below the header instead of behind it. This is the later of the two rules
     that set the feed's padding, so it is the one that has to carry it --
     stating it only on the first left the shorthand here overwriting it. */
  padding-top: calc(8px + var(--feedhead-h, 0px));
}
@media (max-width: 900px) {
  .feed { padding-bottom: 26px; }
}

/* Switch between "where I am" and "where the map is" in the composer. */
.wherebtn {
  margin-top: 6px; padding: 0; border: 0; background: none;
  font: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer;
  color: var(--primary); text-decoration: underline; text-underline-offset: 3px;
}
.wherebtn:hover { color: var(--secondary); }


/* ==========================================================================
   Search dropdown: a list
   Squares before, which gave the name a tile's width to fit in -- anything past
   about ten characters was cut -- and assumed every topic has an emoji, which
   stopped being true the moment people could make their own. A row gives the
   name the whole width and carries a count and a line of context besides.
   ========================================================================== */
.srowgroup { margin: 2px 0 4px; }
.srowhead {
  padding: 6px 6px 7px;
  font-size: 10.5px; font-weight: 800; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink-3);
}
.srow {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center; gap: 11px;
  width: 100%; padding: 9px 11px;
  border: 1px solid transparent; border-radius: var(--r-md);
  background: none; color: var(--ink);
  cursor: pointer; text-align: left; font: inherit;
}
.srow + .srow { margin-top: 2px; }
.srow .tagface, .srow-face {
  flex: none; display: grid; place-items: center;
  width: 30px; height: 30px; border-radius: 50%;
  font-size: 16px; line-height: 1;
}
/* An emoji needs no disc behind it; a letter is the disc. */
.srow .tagface.emoji, .srow-face.cat {
  background: var(--panel-3);
  border: 1px solid var(--line);
}
.srow .tagface.letter { color: #fff; font-size: 13px; font-weight: 800; }
.srow-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.srow-lab {
  font-size: 13.5px; font-weight: 650; line-height: 1.25;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.srow-sub {
  font-size: 11px; color: var(--ink-3); line-height: 1.25;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.srow-n {
  font-size: 11.5px; font-weight: 700; color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.srow-chev { width: 15px; height: 15px; fill: none; stroke: var(--ink-3); stroke-width: 2.2; }
/* The free-text row and the back button are rows, not tiles. */
.freerow {
  display: flex; align-items: center; gap: 9px; width: 100%;
  padding: 10px 12px; margin-bottom: 6px;
  border: 1px solid var(--line); background: var(--panel-2);
  color: var(--ink); cursor: pointer; text-align: left;
}
.freerow .lab { font-size: 13px; font-weight: 600; }
.freerow .via { margin-left: auto; font-size: 11px; color: var(--ink-3); }
/* A row has the width to move sideways into, which a tile in a grid cell did
   not -- so this is a nudge along the reading direction rather than a scale. */
.searchdock .suggest .srow:hover, .searchdock .suggest .srow.active {
  transform: translateX(3px);
  background: rgba(255, 255, 255, .08);
  border-color: color-mix(in srgb, var(--primary) 45%, transparent);
}
.searchdock .suggest .srow:hover .srow-chev,
.searchdock .suggest .srow.active .srow-chev { stroke: var(--primary); }


/* ==========================================================================
   Top-left: the wordmark and the account chip, side by side
   Both are the same glass as the search bar, so the corners of the map read
   as one set of controls rather than two unrelated widgets.
   ========================================================================== */
.topleft {
  position: absolute; top: 22px; left: 24px; z-index: 21;
  display: flex; align-items: center; gap: 10px;
}
/* And its opposite. Same offsets mirrored, same z-index, same glass -- the two
   corners are a pair, and a pair that is nearly the same is worse than either.
   The right inset is the control stack's, not 24px: the feed panel overlays the
   right of the map at this width, and the visible map ends where it begins.
   See BUTTON-ANCHORS.md. */
.topright {
  position: absolute; top: 22px; right: 24px; z-index: 21;
  display: flex; align-items: center; gap: 10px;
}
@media (min-width: 901px) {
  .topright { right: calc(var(--sidebar-w) + 26px); }
}
/* The float is no longer positioned on its own -- .topleft places it. */
@media (min-width: 901px) {
  .brandfloat { position: static; top: auto; left: auto; }
}
@media (max-width: 900px) {
  .topleft { top: 12px; left: 12px; gap: 8px; }
  .brandfloat { display: none; }
}

.topleft .brandfloat,
.topleft .userchip,
.topleft .btn,
.topright .userchip,
.topright .btn {
  height: 48px; border-radius: var(--r-pill);
  border: 1px solid rgba(255, 255, 255, .20);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.02) 46%, rgba(255,255,255,0) 60%),
    linear-gradient(150deg, color-mix(in srgb, var(--primary) 15%, transparent),
                            color-mix(in srgb, var(--secondary) 15%, transparent)),
    color-mix(in srgb, var(--panel) 82%, transparent);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow:
    0 12px 34px -16px rgba(4, 8, 22, .8),
    inset 0 1px 0 rgba(255, 255, 255, .28),
    inset 0 -8px 16px -12px rgba(120, 90, 250, .5);
  color: var(--ink);
}
.topleft .brandfloat { padding: 0 20px 0 12px; gap: 10px; }
.topleft .brandfloat .mark { width: 30px; height: 30px; }
.topleft .brandfloat .mark i { width: 9px; height: 9px; top: 5px; left: 6px; }
.topleft .userchip, .topright .userchip { padding: 0 14px 0 8px; gap: 9px; }
.topleft .userchip .avatar,
.topright .userchip .avatar { width: 32px; height: 32px; font-size: 13px; }
.topleft .userchip .uname,
.topright .userchip .uname { font-size: 14px; font-weight: 700; }
.topleft .btn, .topright .btn { padding: 0 22px; font-size: 14px; font-weight: 700; }
/* No room for a name between the two corners -- see fitTopRow(). The avatar on
   its own, which is what the chip is on a phone, so the narrow desktop and the
   phone agree rather than each having their own idea. */
.topright.tight .userchip {
  width: 48px; padding: 0; gap: 0;
  justify-content: center;
  border-radius: 50%;
}
.topright.tight .userchip .uname,
.topright.tight .userchip .chev { display: none; }
.topright.tight .btn { padding: 0 14px; }
.topleft .brandfloat:hover,
.topleft .userchip:hover,
.topleft .btn:hover,
.topright .userchip:hover,
.topright .btn:hover {
  transform: none;
  border-color: rgba(255, 255, 255, .34);
  box-shadow:
    0 16px 40px -16px rgba(4, 8, 22, .85),
    inset 0 1px 0 rgba(255, 255, 255, .34);
}
/* The mark hangs on the left edge, so anything opening out of that corner
   opens from the left. */
.topleft .dropdown {
  left: 0; right: auto; top: calc(100% + 10px);
  transform-origin: top left;
}
/* And the account is on the right, so its menu opens back towards the middle
   rather than off the side of the map. This is the .dropdown default, stated
   here because the corner it belongs to is what decides it. */
.topright .dropdown {
  right: 0; left: auto; top: calc(100% + 10px);
  transform-origin: top right;
}

/* The gap under the back button should read as the same gap as between tile
   rows, and its edges should line up with the grid rather than sit 4px wider. */
.searchdock .suggest .catback {
  margin: 0 4px 8px;
  width: calc(100% - 8px);
}
.searchdock .suggest .catback + .srowgroup { margin-top: 0; }


/* ==========================================================================
   Account dropdown: the same panel as the search suggestions
   Same glass, same radius, same item shape and hover, so opening either one
   feels like the same control.
   ========================================================================== */
.topleft .dropdown {
  min-width: 216px; padding: 7px;
  border-radius: var(--r-lg);
  border: 1px solid rgba(255, 255, 255, .14);
  background: color-mix(in srgb, var(--panel) 88%, transparent);
  backdrop-filter: blur(24px) saturate(180%);
  -webkit-backdrop-filter: blur(24px) saturate(180%);
  box-shadow: 0 18px 48px -20px rgba(4, 8, 22, .9), var(--shadow);
  animation: riseUp .3s var(--ease);
}
.topleft .dropdown button {
  border-radius: var(--r-lg);
  padding: 10px 12px;
  font-size: 13.5px; font-weight: 600;
  animation: itemFloatIn .26s var(--ease) backwards;
  transition: transform .16s var(--ease), background .16s;
}
.topleft .dropdown button:nth-of-type(1) { animation-delay: .01s; }
.topleft .dropdown button:nth-of-type(2) { animation-delay: .05s; }
.topleft .dropdown button:hover {
  transform: translateX(3px) scale(1.015);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10), rgba(255,255,255,0) 60%),
    color-mix(in srgb, var(--primary) 16%, transparent);
}
.topleft .dropdown button svg { transition: transform .18s var(--ease); }
.topleft .dropdown button:hover svg { transform: scale(1.16) rotate(-6deg); }
.topleft .dropdown-sep { margin: 6px 10px; background: rgba(255,255,255,.12); }
@media (prefers-reduced-motion: reduce) {
  .topleft .dropdown, .topleft .dropdown button { animation: none; }
  .topleft .dropdown button:hover { transform: none; }
}


/* ==========================================================================
   Feed controls: three dropdowns
   The media and sort pickers used to be segmented button strips. They grew on
   hover and the growth was clipped by the sidebar, and five sort options never
   fitted the width anyway. Dropdowns hold their size and hold their labels.
   ========================================================================== */
.controls { gap: 7px; }
.controls .select {
  flex: 1 1 0; min-width: 0; height: 32px;
  padding: 0 26px 0 10px;
  font-size: 12.5px; font-weight: 600; color: var(--ink);
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  background:
    /* the chevron, drawn rather than shipped */
    linear-gradient(45deg, transparent 50%, var(--ink-3) 50%) calc(100% - 14px) 13px / 5px 5px no-repeat,
    linear-gradient(135deg, var(--ink-3) 50%, transparent 50%) calc(100% - 9px) 13px / 5px 5px no-repeat,
    var(--panel-2);
  -webkit-appearance: none; appearance: none;
  text-overflow: ellipsis;
  transition: border-color .15s, background-color .15s;
}
.controls .select:hover { border-color: color-mix(in srgb, var(--primary) 45%, transparent); }
.controls .select:focus-visible { outline: none; border-color: transparent; box-shadow: var(--glow); }
/* The list itself is painted by the OS, so give it a readable ground. */
.controls .select option { background: var(--panel); color: var(--ink); }


/* ==========================================================================
   Feed pickers
   A native select draws its own list, so these are real elements: the closed
   button matches the chrome, and the open panel matches the search dropdown
   and the account menu. Bubbly on press, and nothing glows behind them.
   ========================================================================== */
.controls { gap: 7px; position: relative; z-index: 30; }
.picker { position: relative; flex: 1 1 0; min-width: 0; }

/* The same material as the pill above them.
   These were flat --panel-2, which is a dark slab against the drawer's glass --
   so the three controls under the pill looked like a different, heavier thing
   bolted on. Same recipe as .querychip now: a sheen down the top-left, the soft
   brand gradient, a lit rim. */
.picker-btn {
  display: flex; align-items: center; gap: 6px; width: 100%;
  height: 34px; padding: 0 9px 0 11px;
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10), rgba(255,255,255,0) 60%),
    var(--grad-soft);
  backdrop-filter: blur(10px) saturate(150%);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.16);
  color: var(--ink); font-size: 12.5px; font-weight: 650;
  cursor: pointer; overflow: hidden;
  transition: transform .16s var(--ease), border-color .16s, box-shadow .16s;
}
.picker-val {
  flex: 1; min-width: 0; text-align: left;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.picker-chev {
  flex: none; width: 14px; height: 14px; color: var(--ink-3);
  transition: transform .22s var(--ease);
}
/* Brightened, not replaced. Both of these used to swap the whole background for
   a flat --panel-3, which threw the glass away exactly when the control was
   being used -- and is darker than the drawer besides. A little white over the
   top keeps the material and lifts it. */
.picker-btn:hover {
  border-color: color-mix(in srgb, var(--primary) 55%, transparent);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.22), inset 0 0 0 999px rgba(255,255,255,.05);
  transform: translateY(-1px);
}
.picker-btn:active { transform: scale(.95); }
/* No glow: focus and open state are shown by the border and the chevron. */
.picker-btn:focus-visible {
  outline: none;
  border-color: color-mix(in srgb, var(--primary) 75%, transparent);
}
.picker.open .picker-btn {
  border-color: color-mix(in srgb, var(--primary) 70%, transparent);
  box-shadow: inset 0 1px 0 rgba(255,255,255,.26), inset 0 0 0 999px rgba(255,255,255,.08);
  animation: pickerPop .34s var(--ease);
}
.picker.open .picker-chev { transform: rotate(180deg); }

@keyframes pickerPop {
  0%   { transform: scale(1); }
  38%  { transform: scale(1.05, .93); }
  68%  { transform: scale(.985, 1.03); }
  100% { transform: scale(1); }
}

.picker-menu {
  position: absolute; z-index: 40; top: calc(100% + 7px); left: 0; right: 0;
  padding: 6px;
  max-height: min(46vh, 320px); overflow-y: auto; overflow-x: hidden;
  border-radius: var(--r-lg);
  /* Bubble glass, like the search list -- see the tokens. It was a flatter,
     greyer panel, which is the same complaint as the buttons that open it. */
  border: 1px solid var(--glass-edge);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: 0 18px 48px -20px rgba(4, 8, 22, .9), var(--shadow), var(--glass-lip);
  transform-origin: top center;
  animation: riseDown .26s var(--ease);
}
@keyframes riseDown {
  from { opacity: 0; transform: translateY(-8px) scale(.94); }
  to   { opacity: 1; transform: none; }
}

.picker-item {
  display: flex; align-items: center; gap: 8px; width: 100%;
  padding: 9px 11px; border: 0; border-radius: var(--r-lg);
  background: none; color: var(--ink);
  font-size: 12.5px; font-weight: 600; text-align: left; cursor: pointer;
  animation: itemFloatIn .24s var(--ease) backwards;
  transition: transform .16s var(--ease), background .16s;
}
.picker-item:nth-child(1) { animation-delay: .01s; }
.picker-item:nth-child(2) { animation-delay: .04s; }
.picker-item:nth-child(3) { animation-delay: .07s; }
.picker-item:nth-child(4) { animation-delay: .10s; }
.picker-item:nth-child(n+5) { animation-delay: .13s; }
.picker-lab { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker-note { flex: none; font-size: 11px; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; }
.picker-item:hover, .picker-item.active {
  transform: translateX(3px) scale(1.015);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10), rgba(255,255,255,0) 60%),
    color-mix(in srgb, var(--primary) 16%, transparent);
}
.picker-item.on .picker-lab { color: var(--ink); }
.picker-item.on::after {
  content: ''; flex: none; width: 6px; height: 6px; border-radius: 50%;
  background: var(--grad);
}
@media (prefers-reduced-motion: reduce) {
  .picker-menu, .picker-item, .picker.open .picker-btn { animation: none; }
  .picker-btn:hover, .picker-item:hover, .picker-item.active { transform: none; }
}

/* ==========================================================================
   Lightbox: a photo or video at the size it deserves
   ========================================================================== */
.lightbox {
  position: fixed; inset: 0; z-index: 120;
  display: grid; place-items: center;
  padding: 40px;
  /* Settling onto --lightbox-ground at the bottom, so the strip below it is
     the same colour by construction rather than by a constant kept in step by
     hand. Same reasoning as the composer and the sign-in page. */
  background:
    linear-gradient(to bottom, rgba(0, 0, 0, 0) 84%, var(--lightbox-ground) 100%),
    rgba(3, 6, 16, .78);
  backdrop-filter: blur(14px) saturate(120%);
  -webkit-backdrop-filter: blur(14px) saturate(120%);
  animation: lbIn .22s var(--ease);
  cursor: zoom-out;
}
@keyframes lbIn { from { opacity: 0; } to { opacity: 1; } }
.lightbox-inner {
  position: relative; max-width: min(1200px, 100%); max-height: 100%;
  display: flex; flex-direction: column; gap: 12px; align-items: center;
  cursor: default;
  animation: lbPop .3s var(--ease);
}
@keyframes lbPop {
  from { opacity: 0; transform: scale(.9); }
  to   { opacity: 1; transform: none; }
}
.lightbox img, .lightbox video {
  max-width: 100%; max-height: calc(100vh - 132px);
  /* The picture handles its own pinch and drag -- the page cannot, since the
     viewport is locked so the map can keep its own. See pinchable(). */
  touch-action: none;
  transform-origin: center center;
  will-change: transform;
  border-radius: var(--r-lg);
  box-shadow: 0 40px 90px -30px rgba(0, 0, 0, .9), inset 0 1px 0 rgba(255,255,255,.14);
  background: #05070F;
}
.lightbox-cap {
  max-width: 720px; text-align: center;
  font-size: 13.5px; line-height: 1.5; color: var(--ink-2);
}
.lightbox-cap b { color: var(--ink); font-weight: 650; }
/* The top-right corner, off the picture -- the account chip is directly behind
   this, so the spot means the same thing whether a photo is open or not.
   It carries its own copy of the anchor rather than .corner-tr because the
   lightbox covers the screen at every width, including the desktop where that
   class deliberately does nothing. The fallbacks are for exactly that case.
   Fixed rather than hung off the picture's corner, which used to put it
   somewhere different for a tall photograph than for a wide one. */
.lightbox-x {
  position: fixed;
  top: var(--edge-t, 16px);
  right: var(--edge, 16px);
  width: var(--tap, 44px); height: var(--tap, 44px);
  border-radius: 50%;
  display: grid; place-items: center;
  cursor: pointer; z-index: 2;
  color: var(--ink);
  border: 1px solid var(--glass-edge);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: 0 12px 34px -16px rgba(4, 8, 22, .8), var(--glass-lip);
  transition: transform .16s var(--ease), border-color .16s;
}
.lightbox-x:hover { transform: scale(1.1); border-color: rgba(255,255,255,.4); }
.lightbox-x:active { transform: scale(.92); }
.lightbox-x svg { width: 17px; height: 17px; }
@media (max-width: 900px) {
  .lightbox { padding: 16px; }
  /* Already in the corner -- the tokens put it there. */
}
/* The media in a card is now a control. */
.card-media { cursor: zoom-in; }


/* ==========================================================================
   The way in
   Blank, then bubbles rise, then one of them arrives in the middle carrying
   the word and opens into the form. Four phases, driven by a class on the
   root of the overlay so the whole sequence is declarative and interruptible.
   ========================================================================== */
.welcome {
  position: fixed; inset: 0; z-index: 200;
  display: grid; place-items: center;
  /* The keyboard takes the bottom of the screen, so the card centres in what is
     left rather than in the whole window. Without this the card stays centred
     on a window half of which is under the keyboard, the focused field ends up
     behind it, and iOS deals with that by panning the entire visual viewport --
     which slides the card up under the status bar and leaves a gap beneath it.
     Making room ourselves means there is nothing for it to pan away from.
     --kb is published by fitViewport(), which measures it from
     window.visualViewport. */
  padding-bottom: var(--kb, 0px);
  transition: padding-bottom .18s ease;
  overflow: hidden;
  /* Starts blank on purpose: the bubbles are the first thing that happens.
     The last stretch settles onto --welcome-ground, which is also what the page
     ground is set to while this is up -- so the strip an installed iPhone will
     not let us paint is the same colour as the page above it by construction
     rather than by a constant that the next change to the glow would silently
     invalidate. Same reasoning as the composer's bottom edge. */
  background:
    linear-gradient(to bottom, rgba(0, 0, 0, 0) 80%, var(--welcome-ground) 100%),
    radial-gradient(120% 90% at 50% 118%,
      color-mix(in srgb, var(--primary) 26%, transparent) 0%,
      color-mix(in srgb, var(--secondary) 16%, transparent) 34%,
      transparent 68%),
    #05070F;
  animation: welcomeIn .45s var(--ease);
}
@keyframes welcomeIn { from { opacity: 0; } to { opacity: 1; } }
.welcome.leaving { animation: welcomeOut .5s var(--ease) forwards; pointer-events: none; }
@keyframes welcomeOut {
  from { opacity: 1; }
  to   { opacity: 0; transform: scale(1.04); }
}

/* --- the field of bubbles rising past --- */
.welcome-field { position: absolute; inset: 0; pointer-events: none; }
.wb {
  position: absolute; bottom: -22vh; border-radius: 50%;
  opacity: 0;
  background:
    radial-gradient(circle at 30% 24%, rgba(255,255,255,.72), rgba(255,255,255,.10) 44%, rgba(255,255,255,0) 64%),
    linear-gradient(150deg, var(--blue-400), var(--purple-500) 72%, var(--magenta));
  box-shadow:
    inset 0 2px 6px rgba(255,255,255,.4),
    inset 0 -10px 22px -12px color-mix(in srgb, var(--secondary) 80%, transparent),
    0 10px 34px -14px color-mix(in srgb, var(--primary) 70%, transparent);
  animation: wbRise linear infinite;
  will-change: transform, opacity;
}
@keyframes wbRise {
  0%   { transform: translate3d(0, 0, 0) scale(.86); opacity: 0; }
  12%  { opacity: var(--wb-a); }
  50%  { transform: translate3d(var(--wb-dx), -62vh, 0) scale(1); }
  84%  { opacity: var(--wb-a); }
  100% { transform: translate3d(0, -124vh, 0) scale(.9); opacity: 0; }
}

/* --- the one that becomes the form --- */
.welcome-stage { position: relative; z-index: 2; display: grid; place-items: center; }
.welcome-hero {
  position: relative;
  display: grid; place-items: center;
  width: 12px; height: 12px; border-radius: 50%;
  transform: translateY(56vh);
  opacity: 0;
  overflow: hidden;
  background:
    linear-gradient(150deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 46%, rgba(255,255,255,0) 62%),
    linear-gradient(150deg,
      color-mix(in srgb, var(--primary) 34%, transparent),
      color-mix(in srgb, var(--secondary) 30%, transparent)),
    color-mix(in srgb, var(--panel) 86%, transparent);
  border: 1px solid rgba(255,255,255,.22);
  backdrop-filter: blur(22px) saturate(170%);
  -webkit-backdrop-filter: blur(22px) saturate(170%);
  box-shadow:
    0 40px 90px -34px rgba(2, 6, 20, .9),
    inset 0 2px 0 rgba(255,255,255,.30),
    inset 0 -16px 34px -18px color-mix(in srgb, var(--secondary) 75%, transparent);
  will-change: transform, width, height;
}
/* The specular cap that makes it read as a bubble rather than a disc. */
.welcome-gloss {
  position: absolute; inset: 0; pointer-events: none; border-radius: inherit;
  background: radial-gradient(58% 42% at 32% 16%, rgba(255,255,255,.42), rgba(255,255,255,0) 62%);
  transition: opacity .5s var(--ease);
}

/* Phase 2: it rises into the middle and settles. */
.welcome.phase-rise .welcome-hero {
  width: 232px; height: 232px;
  opacity: 1;
  transform: translateY(0);
  transition:
    transform 1.15s cubic-bezier(.16, .84, .3, 1),
    opacity .5s var(--ease),
    width .8s cubic-bezier(.34, 1.42, .5, 1),
    height .8s cubic-bezier(.34, 1.42, .5, 1);
}

/* Phase 3: it opens out into the card. */
.welcome.phase-open .welcome-hero {
  width: min(94vw, 430px);
  /* Measured from the form, so the two modes are different heights and the
     card is never taller or shorter than what is inside it. */
  height: var(--hero-h, min(86vh, 552px));
  border-radius: var(--r-xl);
  opacity: 1;
  transform: translateY(0);
  transition:
    width .72s cubic-bezier(.2, .9, .25, 1),
    height .72s cubic-bezier(.2, .9, .25, 1),
    border-radius .72s cubic-bezier(.2, .9, .25, 1);
}
.welcome.phase-open .welcome-gloss { opacity: .35; }

.welcome-word {
  position: absolute;
  display: flex; flex-direction: column; align-items: center; gap: 12px;
  opacity: 0; transform: scale(.8);
  transition: opacity .45s var(--ease), transform .45s var(--ease);
}
.welcome-word b {
  font-size: 27px; font-weight: 700; letter-spacing: -.02em; color: #fff;
  text-shadow: 0 2px 18px rgba(10, 14, 40, .6);
}
.welcome-word .mark { width: 42px; height: 42px; }
.welcome-word .mark i { width: 13px; height: 13px; top: 7px; left: 9px; }
.welcome.phase-word .welcome-word { opacity: 1; transform: none; }
.welcome.phase-open .welcome-word {
  opacity: 0; transform: scale(1.5);
  transition: opacity .26s var(--ease), transform .4s var(--ease);
}

/* Phase 4: the form itself, arriving a beat after the shape has settled. */
.welcome .authcard {
  position: relative; z-index: 2;
  width: min(94vw, 430px);
  padding: 26px 26px 22px;
  display: flex; flex-direction: column; gap: 15px;
  opacity: 0; transform: translateY(10px); pointer-events: none;
  transition: opacity .38s var(--ease) .16s, transform .44s var(--ease) .16s;
}
.welcome.phase-form .authcard { opacity: 1; transform: none; pointer-events: auto; }
.welcome .auth-head { display: flex; flex-direction: column; align-items: center; gap: 7px; text-align: center; }
.welcome .auth-head h2 { margin: 0; font-size: 21px; letter-spacing: -.02em; }
.welcome .auth-head p { margin: 0; font-size: 13px; color: var(--ink-2); }
.welcome .auth-head .mark { width: 38px; height: 38px; margin-bottom: 2px; }
.welcome .auth-head .mark i { width: 12px; height: 12px; top: 6px; left: 8px; }
.welcome .fields { display: flex; flex-direction: column; gap: 11px; }
.welcome .hint { text-align: center; }
/* Each row of the form floats in behind the one above it. */
.welcome.phase-form .authcard > * { animation: itemFloatIn .4s var(--ease) backwards; }
.welcome.phase-form .authcard > *:nth-child(1) { animation-delay: .18s; }
.welcome.phase-form .authcard > *:nth-child(2) { animation-delay: .24s; }
.welcome.phase-form .authcard > *:nth-child(3) { animation-delay: .30s; }
.welcome.phase-form .authcard > *:nth-child(4) { animation-delay: .36s; }
.welcome.phase-form .authcard > *:nth-child(n+5) { animation-delay: .42s; }

.welcome-later {
  position: absolute; bottom: 26px; left: 50%; transform: translateX(-50%);
  z-index: 3;
  padding: 9px 18px; border-radius: var(--r-pill);
  border: 1px solid rgba(255,255,255,.16);
  background: rgba(255,255,255,.05);
  color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer;
  opacity: 0; pointer-events: none;
  transition: opacity .4s var(--ease), background .16s, color .16s, transform .16s;
}
.welcome.phase-form .welcome-later { opacity: 1; pointer-events: auto; }
.welcome-later:hover { background: rgba(255,255,255,.1); color: var(--ink); }
.welcome-later:active { transform: translateX(-50%) scale(.95); }

@media (prefers-reduced-motion: reduce) {
  .welcome, .welcome-hero, .welcome-word, .welcome .authcard,
  .welcome.phase-form .authcard > *, .wb { animation: none !important; transition: none !important; }
  .wb { display: none; }
}


/* ==========================================================================
   Leaving the way in
   Three beats, in this order: the panel fades out, the sidebar slides in from
   the right, and the map fades up behind it. They overlap by a little so it
   reads as one move, but the order is what carries it.
   ========================================================================== */
.welcome.closing {
  pointer-events: none;
  /* Held back a beat so the field has time to empty in view. */
  animation: welcomeFade .54s var(--ease-soft) .22s forwards;
}
/* The field drains: every bubble drops its loop and rushes off the top. */
.welcome-field.rush .wb {
  animation: none !important;
  transition: transform .68s cubic-bezier(.36, .1, .18, 1), opacity .68s var(--ease-soft);
}
@keyframes welcomeFade {
  from { opacity: 1; }
  to   { opacity: 0; }
}
/* The card leads: it is what the eye is on, so it goes first and slightly up. */
.welcome.closing .authcard {
  opacity: 0; transform: translateY(-8px) scale(.99);
  transition: opacity .36s var(--ease-soft), transform .46s var(--ease-soft);
}
.welcome.closing .welcome-later { opacity: 0; transition: opacity .28s var(--ease-soft); }

/* Beat two: the sidebar arrives from the right edge it lives on. */
@media (min-width: 901px) {
  body.app-enter .sidebar {
    animation: sidebarSlide .68s cubic-bezier(.22, .82, .24, 1) .14s both;
  }
}
@keyframes sidebarSlide {
  from { transform: translateX(104%); opacity: 0; }
  to   { transform: none; opacity: 1; }
}

/* Beat three: the map, and then the controls on top of it. `both` holds the
   from-state through the delay, so nothing flashes in before its turn. */
body.app-enter .mapwrap { animation: mainFade .66s var(--ease-soft) .34s both; }
@keyframes mainFade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
body.app-enter .topleft,
body.app-enter .mapctl,
body.app-enter .mapchrome,
body.app-enter .searchdock,
body.app-enter .fab { animation: chromeIn .52s var(--ease-soft) both; }
@keyframes chromeIn {
  from { opacity: 0; transform: translateY(8px) scale(.99); }
  to   { opacity: 1; transform: none; }
}
body.app-enter .topleft    { animation-delay: .54s; }
body.app-enter .mapctl     { animation-delay: .62s; }
body.app-enter .searchdock { animation-delay: .68s; }
body.app-enter .mapchrome  { animation-delay: .74s; }
body.app-enter .fab        { animation-delay: .80s; }

@media (prefers-reduced-motion: reduce) {
  .welcome.closing, .welcome.closing .authcard,
  body.app-enter .sidebar, body.app-enter .mapwrap, body.app-enter .topleft,
  body.app-enter .mapctl, body.app-enter .mapchrome, body.app-enter .searchdock,
  body.app-enter .fab { animation: none !important; transition: none !important; }
}

/* The lockup: mark and wordmark together, in the bubble and on the card. */
.auth-brand { display: flex; align-items: center; gap: 9px; }
.auth-brand .wordmark { font-size: 22px; }
.welcome-word .auth-brand .wordmark { font-size: 25px; }
.welcome .auth-head .auth-brand { margin-bottom: 3px; }

/* Pop one on hover. The splash is made of separate elements rather than the
   bubble itself, because the bubble is mid-keyframe and cannot also be
   transitioned. Pointer events are re-enabled on the bubbles alone -- the field
   stays transparent to clicks so it never eats one meant for the form. */
.welcome-field .wb { pointer-events: auto; cursor: pointer; }
.wb.popped { animation: none; opacity: 0; pointer-events: none; }
/* The droplets it scatters. */
.wb-drop {
  position: absolute; border-radius: 50%; pointer-events: none;
  background: linear-gradient(150deg, var(--blue-400), var(--purple-500));
  box-shadow: inset 0 1px 0 rgba(255,255,255,.6);
  animation: wbDrop .5s cubic-bezier(.25, .7, .35, 1) forwards;
}
@keyframes wbDrop {
  0%   { transform: translate(0, 0) scale(1); opacity: .9; }
  100% { transform: translate(var(--dx), var(--dy)) scale(.2); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .wb-drop { display: none; }
}

/* ==========================================================================
   Switching between signing in and creating an account
   Only what actually differs moves. The first version faded the whole card out
   and back in, which is why the form appeared to vanish and return: almost
   nothing on it changes between the two modes, so almost nothing should
   animate. What changes is the heading, which fields are present, and the
   button's wording.
   ========================================================================== */
/* While the card is opening, its height is part of the reveal and moves with
   it. Once open, a height change is an adjustment -- a field appearing, an
   error showing -- and should just keep up. */
.welcome.settled .welcome-hero { transition: height .44s var(--ease-soft) !important; }

/* The heading is the only text that changes, so it is the only text that
   cross-fades -- briefly, and in the direction the card is about to grow. */
.welcome .auth-head {
  transition: opacity .30s var(--ease-soft), transform .40s var(--ease-soft);
}
.welcome .authcard.swap-out .auth-head {
  opacity: 0; transform: translateY(calc(var(--swap-dir, 6px) * 0.7));
}

/* A field that is not in this mode collapses rather than disappearing. It
   keeps its place in the flow until it has finished closing, and the negative
   margin cancels the row gap it would otherwise still take up. */
.welcome .field {
  overflow: hidden;
  max-height: 140px;
  transition: max-height .46s var(--ease-soft), opacity .34s var(--ease-soft),
              margin-bottom .46s var(--ease-soft), transform .42s var(--ease-soft);
}
.welcome .field.collapsed {
  max-height: 0;
  opacity: 0;
  margin-bottom: -11px;            /* cancels the .fields gap */
  transform: translateY(-4px);
  pointer-events: none;
}

/* The button does not move; only its wording changes. */
/* The button does not move and barely dims: only its wording changes, and
   blanking it made the whole card look like it was reloading. */
.welcome #authSubmit { transition: opacity .30s var(--ease-soft); }
.welcome .authcard.swap-out #authSubmit { opacity: .72; }
.welcome .hint { transition: opacity .30s var(--ease-soft); }
.welcome .authcard.swap-out .hint { opacity: 0; }

/* The tab pill slides between the two, instead of the highlight teleporting. */
.welcome .tabs { position: relative; isolation: isolate; }
.welcome .tabs::before {
  content: ''; position: absolute; z-index: -1;
  top: 3px; bottom: 3px; left: 3px;
  width: calc(50% - 3px);
  border-radius: calc(var(--r-md) - 2px);
  background: var(--grad);
  box-shadow: 0 4px 14px -6px color-mix(in srgb, var(--secondary) 90%, transparent);
  transition: transform .38s cubic-bezier(.34, 1.3, .5, 1);
}
.welcome .tabs.on-register::before { transform: translateX(100%); }
.welcome .tabs button.on { background: none; box-shadow: none; }
.welcome .tabs button { transition: color .24s var(--ease); position: relative; }

@media (prefers-reduced-motion: reduce) {
  .welcome.settled .welcome-hero, .welcome .auth-head, .welcome .field,
  .welcome #authSubmit, .welcome .hint, .welcome .tabs::before {
    transition: none !important;
  }
}

/* Signing out: the entrance in reverse. */
body.app-leave .topleft,
body.app-leave .mapctl,
body.app-leave .mapchrome,
body.app-leave .searchdock,
body.app-leave .fab { animation: chromeOut .34s var(--ease-soft) both; }
@keyframes chromeOut {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(8px) scale(.99); }
}
@media (min-width: 901px) {
  body.app-leave .sidebar { animation: sidebarOut .56s cubic-bezier(.34, .06, .26, 1) .12s both; }
}
@keyframes sidebarOut {
  from { transform: none; opacity: 1; }
  to   { transform: translateX(104%); opacity: 0; }
}
body.app-leave .mapwrap { animation: mainFadeOut .50s var(--ease-soft) .20s both; }
@keyframes mainFadeOut {
  from { opacity: 1; }
  to   { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  body.app-leave .topleft, body.app-leave .mapctl, body.app-leave .mapchrome,
  body.app-leave .searchdock, body.app-leave .fab, body.app-leave .sidebar,
  body.app-leave .mapwrap { animation: none !important; }
}


/* ==========================================================================
   The 3D and satellite toggles, switched on
   The active state used to sit in a purple pool of its own light. The border
   and the fill say "on" perfectly well.
   ========================================================================== */
.glassbtn.on {
  border-color: color-mix(in srgb, var(--primary) 70%, transparent);
  box-shadow:
    0 12px 34px -16px rgba(4, 8, 22, .8),
    inset 0 1px 0 rgba(255, 255, 255, .36);
}
/* The same purple bloom, inside the button this time, on every one of them. */
.glassbtn {
  box-shadow:
    0 12px 34px -16px rgba(4, 8, 22, .8),
    inset 0 1px 0 rgba(255, 255, 255, .28);
}

/* ==========================================================================
   Mobile: map first, everything else floating on top of it
   No nav bar. The wordmark and the account chip stack in the top-left corner
   with the in-view counter under them, the search sits at the bottom centre,
   and the feed is a full-width drawer you swipe in from the right.
   ========================================================================== */

/* The counter lives in the top-left stack in the DOM now. On desktop it is
   still pinned to the bottom-left of the viewport -- fixed rather than
   absolute, because .topleft is its offset parent and is up in the corner. */
@media (min-width: 901px) {
  /* Top middle, spanning the visible map so the note centres in it -- the slot
     the toast uses on a phone, and the right one for a statement about what you
     are looking at rather than a control. It was the bottom-left corner, which
     is now the map controls'. Spanning and centring rather than a translate,
     because the entrance animation drives `transform` and would drop it.
     The right inset is the control stack's old one: the feed panel overlays the
     right of the map, and the visible map ends where the panel begins. */
  .mapchrome {
    position: absolute;
    /* One row below the corners, not level with them. The note is up to 420px
       wide and the gap between the mark and the account chip is 502px at
       1440 and 331px at 901 -- so level with them it clears by 7px on a wide
       screen and overlaps the mark outright on a narrow one. Below them it
       cannot collide at any width, and it still reads as the top of the map. */
    top: calc(22px + 48px + 14px);
    bottom: auto;
    left: 24px; right: calc(var(--sidebar-w) + 26px);
    align-items: center;
  }
}

@media (max-width: 900px) {
  /* The nav is gone entirely: its brand and its feed button both have better
     homes now. */
  .nav { display: none; }
  .stage { grid-template-columns: 1fr; }

  .topleft {
    top: 12px; left: 12px; right: 12px;
    flex-direction: column; align-items: flex-start; gap: 8px;
  }
  .brandfloat { display: flex; }
  .topleft .brandfloat,
  .topleft .userchip,
  .topleft .btn { height: 42px; }
  .topleft .brandfloat { padding: 0 16px 0 10px; }
  .topleft .brandfloat .wordmark { font-size: 17px; }
  .topleft .userchip { padding: 0 13px 0 7px; }
  .topleft .userchip .avatar { width: 28px; height: 28px; }

  /* Third in the column, not pinned to a corner. */
  .mapchrome { position: static; }

  /* Clear of the stack above it. */
  .mapctl { top: 12px; right: 12px; }

  /* Bottom centre, clear of the compose button. */
  .searchdock {
    left: 50%; right: auto; transform: translateX(-50%);
    width: min(560px, calc(100% - 92px));
    bottom: 18px;
  }
  .searchdock .searchbox { height: 46px; }

  .fab { right: 14px; bottom: 18px; }

  /* All the way across: a feed you can actually read. */
  .sidebar {
    width: 100%;
    border-radius: 0;
    border-left: 0;
  }

}

/* Those two used to be squeezed into a nav bar. In the top-left stack there is
   room for the names, and a nameless pill next to a nameless chip tells you
   nothing. */
@media (max-width: 900px) {
  .topleft .wordmark { display: inline; }
  .topleft .uname { display: inline; max-width: 34vw; }
}


/* ==========================================================================
   Add to home screen
   A sheet above the search dock, in the same glass as everything else, shown
   once and then never again unless it is asked for from Settings.
   ========================================================================== */
.installcard {
  position: fixed; z-index: 60;
  left: 12px; right: 12px; bottom: 76px;
  display: flex; align-items: center; gap: 12px;
  padding: 13px 14px;
  border-radius: var(--r-lg);
  border: 1px solid rgba(255, 255, 255, .20);
  background:
    linear-gradient(150deg, rgba(255,255,255,.10) 0%, rgba(255,255,255,.02) 46%, rgba(255,255,255,0) 60%),
    linear-gradient(150deg, color-mix(in srgb, var(--primary) 15%, transparent),
                            color-mix(in srgb, var(--secondary) 15%, transparent)),
    color-mix(in srgb, var(--panel) 88%, transparent);
  backdrop-filter: blur(22px) saturate(160%);
  -webkit-backdrop-filter: blur(22px) saturate(160%);
  box-shadow: 0 18px 46px -20px rgba(4, 8, 22, .9), inset 0 1px 0 rgba(255,255,255,.26);
  /* Comes up from under the search bar rather than fading in on top of it. */
  opacity: 0; transform: translateY(14px) scale(.98);
  transition: opacity .32s var(--ease), transform .38s var(--ease);
}
.installcard.in { opacity: 1; transform: none; }

.install-mark {
  position: relative; flex: none;
  width: 40px; height: 40px; border-radius: 50%;
  background: var(--grad);
  box-shadow: inset 0 2px 0 rgba(255,255,255,.4),
              0 6px 18px -6px color-mix(in srgb, var(--secondary) 85%, transparent);
}
.install-mark i {
  position: absolute; top: 8px; left: 9px;
  width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,.62);
}

.install-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.install-body b { font-size: 13.5px; font-weight: 700; color: var(--ink); }
.install-body span { font-size: 12px; line-height: 1.45; color: var(--ink-2); }
.install-body span b { font-weight: 700; color: var(--ink); font-size: 12px; }

/* The iOS share glyph, drawn rather than shipped: a box with an arrow out of
   the top, which is the only way to name the button Apple gives no API for. */
.ios-share {
  display: inline-block; vertical-align: -3px;
  width: 13px; height: 15px; margin: 0 1px;
  border: 1.5px solid var(--ink-2);
  border-top: 0;
  border-radius: 3px;
  position: relative;
}
.ios-share::before {
  content: ''; position: absolute; left: 50%; top: -7px;
  width: 1.5px; height: 9px; background: var(--ink-2); transform: translateX(-50%);
}
.ios-share::after {
  content: ''; position: absolute; left: 50%; top: -7px;
  width: 6px; height: 6px;
  border-left: 1.5px solid var(--ink-2); border-top: 1.5px solid var(--ink-2);
  transform: translateX(-50%) rotate(45deg); transform-origin: top left;
  margin-left: -0.75px;
}

.install-actions { display: flex; flex-direction: column; align-items: stretch; gap: 5px; flex: none; }
.install-actions .btn { height: 34px; padding: 0 16px; font-size: 13px; }
.install-actions .linkbtn { font-size: 12px; color: var(--ink-3); }
.install-actions .linkbtn:hover { color: var(--ink); }

.install-x {
  position: absolute; top: -9px; right: -9px;
  width: 26px; height: 26px; border-radius: 50%;
  display: grid; place-items: center; cursor: pointer;
  border: 1px solid rgba(255,255,255,.2);
  background: color-mix(in srgb, var(--panel) 94%, transparent);
  backdrop-filter: blur(12px);
  color: var(--ink-2);
  transition: transform .16s var(--ease), color .16s;
}
.install-x:hover { transform: scale(1.1); color: var(--ink); }
.install-x svg { width: 13px; height: 13px; }

/* Wider than a phone, it sits in the corner rather than spanning the screen. */
@media (min-width: 620px) {
  .installcard { left: auto; right: 22px; width: 380px; bottom: 22px; }
}
@media (prefers-reduced-motion: reduce) {
  .installcard { transition: none; }
}


/* ==========================================================================
   Phones: the corrections
   ========================================================================== */

/* Safari's URL bar makes 100vh taller than what you can actually see, so the
   map ran under it. dvh is the viewport as it currently is, and the
   -webkit-fill-available line is the fallback for the iOS versions that
   predate it. */
html { height: 100%; }
/* The document itself must never scroll. Without this, focusing the search
   field lets iOS scroll the whole page up to make room for the keyboard and
   the map goes with it. Pinning the body is enough; the root element is left
   alone, because a fixed <html> is a strange thing to hand a browser. */
html { overflow: hidden; overscroll-behavior: none; }
/* ...except on the pages that are documents rather than a map. The map pins
   the root so focusing the search field cannot scroll the whole app away; a
   settings page has to scroll, and on iOS a root with overflow:hidden means a
   finger drag does nothing at all. The class is on <html> because that is the
   element being undone -- a class on <body> cannot reach it. */
html.sitepage, html.sitepage body {
  position: static;
  height: auto;
  overflow: visible;
  overscroll-behavior: auto;
}
body {
  /* The app is the initial containing block, and nothing else.
     This used to be `height: 100dvh`, and that is the strip of page background
     along the bottom of an installed iPhone: with viewport-fit=cover the
     window covers the whole display, but the viewport units iOS reports in a
     standalone app measure the *safe* area, which stops above the home
     indicator. So the body came up short and its own background showed
     underneath -- which is why the gap moved but never went away when the
     background colour was changed.
     `inset: 0` on a fixed element is the containing block itself, which is the
     window, home indicator and all. No viewport units, so nothing to disagree
     about. Its height is definite either way, which is what .stage needs. */
  position: fixed;
  inset: 0;
  /* height:auto so the insets decide the box. `html, body { height: 100% }` at
     the top of this file would otherwise over-constrain it -- given top, bottom
     and a height, the height wins and the insets are quietly discarded. */
  height: auto;
  overflow: hidden;
  overscroll-behavior: none;
}


@media (max-width: 900px) {
  /* The feed scrolls; the sheet is dragged by its grab bar, which opts out of
     browser panning itself.
     pan-x is named as well as pan-y, and it is not decoration. touch-action is
     intersected up the ancestor chain, not overridden by the nearest element:
     a picture strip inside here declaring pan-x got pan-y anyway, because
     these two vetoed it. So the browser was not panning the strip at all --
     and native scroll-snap only applies to a native pan, which is why the
     snapping could be tuned in the stylesheet all day and still land between
     two pictures. Neither of these scrolls sideways, so permitting it here
     costs nothing and is only a way of not vetoing what is inside them. */
  .sidebar, .feed { touch-action: pan-x pan-y; }

  .installcard { bottom: calc(136px + var(--kb, 0px) + env(safe-area-inset-bottom, 0px)); }
  /* Same reasoning as --edge-t: the gap sits above the inset, or the mark
     ends up inside the status bar's blur. */
  .topleft { top: max(12px, calc(var(--safe-t, 0px) + 12px),
                      calc(env(safe-area-inset-top, 0px) + 12px)); }

  /* The list drops from a bar at the top, so its ceiling is the room below the
     bar -- which the keyboard eats into from underneath. --suggest-max is
     measured in JS; the min() is the fallback before the first measurement. */
  /* The height is set on the element by placeSuggest(), measured against the
     visible band. This is only what it uses before the first measurement. */
  .searchdock .suggest {
    max-height: var(--suggest-max, 46vh);
  }
  /* The width of the phone, not the width of the field -- the list should sit
     on --edge at both ends like every other row. The field is a positioned
     element now (it rides the button row), so the offsets that do that live
     with the rule that positions it, further down. */

  /* One per line now, so there is no column count to fix -- the rows take the
     width the list gained and give it to the name. */
  .searchdock .srow { padding: 10px 11px; }
  .searchdock .srow-lab { font-size: 14px; }
}

/* The wordmark is a desktop luxury: on a phone it takes a row of map to say
   something the icon already says. Only the mark travels, and it sits at the
   head of the control stack rather than alone in the opposite corner. */
.brandmini { display: none; }
@media (max-width: 900px) {
  .brandmini {
    display: grid; place-items: center;
    width: 40px; height: 40px; flex: none;
    text-decoration: none;
  }
  .brandmini .mark { width: 32px; height: 32px; }
  /* Sized and dressed like the account chip opposite it, so the two ends of
     the bar carry the same weight and the row reads as one piece of chrome. */
  .searchdock .brandmini {
    width: var(--tap); height: var(--tap); border-radius: 50%;
    border: 1px solid rgba(255,255,255,.12);
    background:
      linear-gradient(150deg, rgba(255,255,255,.12), rgba(255,255,255,0) 60%),
      var(--panel-2);
    backdrop-filter: blur(12px) saturate(160%);
    box-shadow: inset 0 1px 0 rgba(255,255,255,.18);
  }
  .searchdock .brandmini .mark { width: 36px; height: 36px; }
  .searchdock .brandmini .mark i { width: 11px; height: 11px; top: 6px; left: 8px; }
  .brandmini .mark i { width: 10px; height: 10px; top: 5px; left: 7px; }
  .brandmini:active { transform: scale(.92); }
  /* Which leaves the account chip and the counter alone on the left. */
  .topleft .brandfloat { display: none; }
}


/* ==========================================================================
   iPhone: the status bar, the home indicator, and the notch
   With viewport-fit=cover the page owns the whole screen, which is what we
   want for the map -- and it means everything readable has to keep itself out
   from under the hardware. The feed was running under the clock.
   ========================================================================== */
@media (max-width: 900px) {
  /* The sheet stops short of the status bar by design, so it needs no inset at
     the top -- that was 47 points of nothing between the grip and the first
     Bubl. The bottom inset is on .sidebar, with the sheet layout. */
  .feed { padding-bottom: 26px; }

  /* Post detail and modals sit above the same hardware. */
  .modal-card { padding-top: max(18px, env(safe-area-inset-top, 0px)); }
}

/* What sits behind the page, and deliberately a literal rather than a variable.
   iOS paints two things from it: any strip of screen outside the viewport, and
   the tint of Safari's own bars. In light mode --bg is #EEF3FA and --panel is
   white, which is why an iPhone showed a bright band along the bottom while the
   map above it was night-time satellite -- so this cannot follow the theme.
   The value is the drawer's own navy. Where a strip of it does show on an
   installed iPhone, it sits directly under the drawer and reads as part of it
   rather than as a line ruled across the bottom of the screen. Pure black was
   worse: darker than both the panel above it and the sky. */
/* The page ground is the drawer's colour, because on a phone the drawer is what
   reaches the bottom of the screen -- and the strip an installed iPhone will not
   let us paint takes this. One token, so the two cannot drift. */
html, body { background-color: var(--drawer-ground); }


/* ==========================================================================
   Phones: one column of controls, all of it the same size
   The account chip, the mark and the four map buttons are one stack down the
   right-hand edge, and every one of them is a 40px circle -- a row that is
   nearly the same size reads worse than one that is obviously different.
   ========================================================================== */
@media (max-width: 900px) {
  .mapctl .usermenu { position: relative; flex: none; }

  .mapctl .userchip,
  .brandmini,
  .mapctl .glassbtn {
    width: 40px;
    height: 40px;
    flex: none;
  }
  .mapctl .userchip {
    padding: 0;
    gap: 0;
    justify-content: center;
    border-radius: 50%;
  }
  /* The avatar is the control on a phone; the name and the chevron go. */
  .mapctl .userchip .uname,
  .mapctl .userchip .chev { display: none; }
  .mapctl .userchip .avatar { width: 32px; height: 32px; font-size: 13px; }


  /* It hangs off the right edge now, so it opens from the right. */
  .mapctl .dropdown {
    left: auto;
    right: 0;
    top: calc(100% + 10px);
    transform-origin: top right;
  }

  /* Which leaves the counter alone in the opposite corner. */
  .topleft { gap: 0; }
}

/* ==========================================================================
   ?diag=1 -- what the device thinks the viewport is
   Several of these numbers only ever misbehave on a phone, which is the one
   place a console is not to hand. The safe-area probes are read from real
   elements because env() cannot be read back out of a computed style.
   ========================================================================== */
.diag {
  position: fixed; z-index: 300; inset: auto 8px 8px 8px;
  max-height: 62vh; overflow: auto;
  padding: 12px 14px;
  border-radius: var(--r-lg);
  border: 1px solid rgba(255,255,255,.2);
  background: rgba(6, 10, 24, .94);
  backdrop-filter: blur(18px);
  color: #DDE6FA;
  box-shadow: 0 20px 50px -20px rgba(0,0,0,.9);
}
.diag h4 {
  margin: 0 0 8px; font-size: 11px; letter-spacing: .1em;
  text-transform: uppercase; color: #8FA3C8;
}
.diag pre {
  margin: 0; font-family: var(--mono); font-size: 11.5px; line-height: 1.55;
  white-space: pre-wrap; word-break: break-word;
}
.diag-x {
  position: absolute; top: 6px; right: 8px;
  border: 0; background: none; color: #8FA3C8;
  font-size: 20px; line-height: 1; cursor: pointer; padding: 4px 6px;
}
/* The four probes: fixed boxes whose size *is* the inset, since env() values
   cannot be read back from a computed style. */
.safe-probe { position: fixed; visibility: hidden; pointer-events: none; }
#probeTop    { top: 0; left: 0; height: env(safe-area-inset-top, 0px); width: 1px; }
#probeBottom { bottom: 0; left: 0; height: env(safe-area-inset-bottom, 0px); width: 1px; }
#probeLeft   { top: 0; left: 0; width: env(safe-area-inset-left, 0px); height: 1px; }
#probeRight  { top: 0; right: 0; width: env(safe-area-inset-right, 0px); height: 1px; }


/* ==========================================================================
   Phones: two controls, not six
   A map is touched directly -- pinch zooms, drag pans -- so the buttons around
   it should be the ones you cannot do with a finger. That is the account, and
   the compose button. Which basemap, flat or globe, and the way home are
   occasional, so they fold behind one control and the map gets its edge back.
   ========================================================================== */

/* On a desktop the group is not a box at all: its children lay out in .mapctl
   exactly as they did before it existed. */
.mapctl-group { display: contents; }

@media (max-width: 900px) {
  /* Pinch is the zoom control on a touchscreen, and it is always available. */
  #zoomInBtn, #zoomOutBtn2 { display: none; }

  .mapctl-group {
    display: flex;
    flex-direction: column;
    gap: 8px;
    align-items: flex-end;
    /* Collapsed: laid out, measured, and simply not there. Animating to a
       fixed height would either clip the contents or leave a gap. */
    max-height: 0;
    opacity: 0;
    overflow: hidden;
    pointer-events: none;
    transform: translateY(-6px);
    transition: max-height .3s var(--ease), opacity .22s var(--ease),
                transform .28s var(--ease);
  }
  .mapctl-group.open {
    max-height: 220px;
    opacity: 1;
    pointer-events: auto;
    transform: none;
  }

  /* The toggle turns into a close affordance while the group is out. */
  #mapMoreBtn svg { transition: transform .3s var(--ease); }
  #mapMoreBtn[aria-expanded="true"] svg { transform: rotate(90deg); }
  #mapMoreBtn[aria-expanded="true"] {
    border-color: color-mix(in srgb, var(--primary) 70%, transparent);
  }
}

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


/* ==========================================================================
   Compose, in the column
   On a phone it joins the stack on the right edge at the same 40px as
   everything else. It keeps its gradient while the rest are glass, because it
   is the one thing in that column you press on purpose -- a row of six
   identical circles would hide it.
   ========================================================================== */
@media (max-width: 900px) {
  .mapctl .fab {
    position: static;
    width: 40px;
    height: 40px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    justify-content: center;
    flex: none;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow: none;
    /* The drift belongs to a button floating over the map. In a column it just
       makes the row jitter. */
    animation: none;
  }
  .mapctl .fab .fab-label { display: none; }
  .mapctl .fab .fab-icon {
    width: 40px;
    height: 40px;
    box-shadow:
      inset 0 1px 0 rgba(255,255,255,.55),
      0 6px 18px -6px color-mix(in srgb, var(--secondary) 85%, transparent);
  }
  .mapctl .fab .fab-icon svg { width: 18px; height: 18px; }
  .mapctl .fab:hover { animation: none; box-shadow: none; }
  .mapctl .fab:active { transform: scale(.92); }
  .mapctl .fab:hover .fab-icon { transform: none; }
}


/* ==========================================================================
   Phones, rebuilt around the thumb
   ==========================================================================
   Three things decided this layout.

   The reachable part of a phone held in one hand is an arc up from the bottom
   corner, so what you press often lives at the bottom: compose, and the sheet.
   The top-right is the worst place on the screen and had the most in it.

   Search is a destination, not a control -- you tap it, a keyboard covers half
   the screen, you leave again. It does not need to be under the thumb, and at
   the top it stops competing with everything else for the bottom edge.

   And the feed was a side drawer, which is a desktop idea in mobile clothes:
   all-or-nothing, and it hides the map it is describing. A bottom sheet with
   detents lets you hold both at once, which is the whole point of a feed that
   follows the map.
   ========================================================================== */
@media (max-width: 900px) {
  /* Every floating control is this big and this far from the edge. They read
     as one set of chrome laid over the map, so a button that is four pixels
     wider than its neighbour is the only thing anybody sees. */
  :root {
    --tap: 44px;      /* also the smallest comfortable touch target */
    --edge: 12px;
    /* How much of the drawer stays on screen when it is down. Not a peek --
       nothing is legible in 18px -- but enough that its rounded top and its
       border are visible, so the page ground below them, which is the same
       colour as the drawer, reads as the drawer carrying on rather than as a
       band.
       34, not 18: iOS keeps the bottom of the screen for its own home-indicator
       swipe, so most of an 18px lip was inside a strip where the first upward
       drag never reaches us. Kept in step with SHEET_LIP in app.js. */
    --sheet-lip: 34px;

    /* The gap at the top and bottom is the same gap as at the sides -- unless
       there is a status bar or a home indicator in the way, in which case it
       is the room the system asked for and not a pixel more.
       max(), not addition: the inset already includes its own clearance, so
       adding the edge gap on top of it is what made the top and bottom read as
       loose next to the sides. */
    /* Both sources, as peers -- never env() as the fallback of --safe-t.
       A fallback only applies while the custom property is *undefined*, and
       publishInsets() defines both on the first frame. So `var(--safe-t,
       env(...))` reads env() exactly once, before the measurement, and never
       again: from then on a probe that measured zero -- because iOS had not
       settled the inset yet, or because it settled after load -- silently
       overrode a perfectly good env() with nothing, and the top chrome sat
       under the clock for the rest of the session with no way to recover.
       max() of the two means neither source can subtract from the other, and a
       late-arriving env() is picked up by the cascade without JS running
       again. max(), still not addition: the inset already carries its own
       clearance, and adding the edge gap on top of it reads as loose. */
    /* The gap goes ON TOP of the inset, not instead of it.
       This was max() -- "the inset already carries its own clearance" -- and
       that was true while the status bar style was `black`, because then the
       inset was zero and max() simply gave back the edge gap. With
       `black-translucent` the page runs under the clock and the inset is a
       real forty to sixty pixels, so max() put the chrome flush against the
       bottom edge of the status bar. iOS draws a translucent blur there that
       fades downward over the content beneath it, and the account chip and
       the mark were sitting in it -- blurred by the system, with nothing in
       this stylesheet to show for it. A gap above them is the whole fix. */
    --edge-t: max(var(--edge),
                  calc(var(--safe-t, 0px) + var(--edge)),
                  calc(env(safe-area-inset-top, 0px) + var(--edge)));
    /* The bottom is the home indicator rather than a blur, and it does not
       bleed into what is above it, so the inset alone still clears it. */
    --edge-b: max(var(--edge), var(--safe-b, 0px), env(safe-area-inset-bottom, 0px));

    /* The floating row sits where it always sits. Nothing here moves it out of
       the keyboard's way any more: the app takes iOS's default behaviour, which
       is to slide the whole viewport up, and everything goes with it. */
  }

  /* ---- search, at the top ---- */
  .searchdock {
    /* --safe-t is published from JS, because env(safe-area-inset-top) is the
       right answer everywhere except an iOS home-screen app with a translucent
       status bar -- which is exactly where this chrome would end up under the
       clock. See publishInsets(). */
    top: var(--edge-t);
    bottom: auto;
    left: var(--edge);
    right: var(--edge);
    width: auto;
    transform: none;
    /* Above the drawer, which is 26. The dock itself never overlaps it -- the
       search bar rides 12px clear of its top edge the whole way up -- but what
       opens *out* of the dock does: the account menu drops down over the drawer
       when the drawer is at the top, and the suggestion list does the same.
       Below 26 they were being drawn behind it. */
    z-index: 40;
  }
  /* Which way it opens depends on where the bar currently is, and the bar
     moves -- so the direction is set from JS when the list is shown rather than
     fixed here. Default is upward, because that is where the bar spends most of
     its time. */
  /* Which way it grows is a class; where its edge actually sits is set by
     placeSuggest(), because that is the only place the keyboard is known. The
     offsets are deliberately absent here -- a `bottom: calc(100% + 10px)` in
     the stylesheet would be a second opinion about a position that has to be
     clamped to the visible band. */
  .searchdock .suggest {
    transform-origin: bottom center;
    animation: riseUp .28s var(--ease-soft);
  }
  .searchdock .suggest.opens-down {
    transform-origin: top center;
    animation: riseDown .28s var(--ease-soft);
  }

  /* ---- compose, back under the thumb ---- */
  .mapctl .fab, .fab {
    position: absolute;
    top: auto;
    left: auto;
    right: var(--edge);
    bottom: var(--edge-b);
    /* The lift, on `translate` rather than inside `transform`, and with no
       transition. --sheet-peek is rewritten every frame the sheet moves, so
       those frames *are* the animation; a transition on top is a second
       animation chasing the first, which is why these trailed the sheet all
       the way down. `translate` is its own property, so it composes with the
       press effect's transform instead of replacing it, and it composites
       rather than relaying the button out on every frame the way `bottom` did.
       Only the press is left in the transition below. --btn-lift, not
       --sheet-peek: the row keeps the same gap from the drawer that it keeps
       from the sides, and never less than the home indicator's inset from the
       bottom. drawSheet() works out which of the two applies. */
    translate: 0 calc(-1 * var(--btn-lift, 0px));
    width: var(--tap);
    height: var(--tap);
    border-radius: 50%;
    padding: 0;
    justify-content: center;
    z-index: 22;
    transition: transform .18s var(--ease);
  }
  .fab .fab-icon { width: var(--tap); height: var(--tap); }
  .fab .fab-icon svg { width: 20px; height: 20px; }
  .fab .fab-label { display: none; }

  /* ---- the one remaining map control, opposite it ---- */
  .mapctl {
    top: auto;
    right: auto;
    left: var(--edge);
    bottom: var(--edge-b);
    translate: 0 calc(-1 * var(--btn-lift, 0px));
    flex-direction: column-reverse;        /* the group opens upward */
    align-items: flex-start;
    gap: 10px;
    z-index: 22;
  }
  .mapctl-group { flex-direction: column-reverse; align-items: flex-start; }
  /* Which kind of Bubl, above what the map is labelled with. The group stacks
     upward here, so document order runs bottom-to-top and the two would come
     out the other way round from the desktop -- where the same document order
     already puts them right. One line here rather than moving the markup and
     having to correct the desktop instead. */
  #originBtn { order: 1; }
  .mapctl .usermenu { display: none; }   /* the avatar lives in the search bar */

  /* ---- the feed, as a sheet ---- */
  /* Most of the sheet lives below the fold. Left unclipped it becomes page
     overflow, and then anything that scrolls an element into view -- focusing
     the grab bar, say -- scrolls the entire layout off the top. */
  .stage, .mapwrap { overflow: hidden; }

  .sidebar {
    position: absolute;
    inset: auto 0 0 0;
    top: auto;
    width: 100%;
    /* Exactly the room below where the search bar comes to rest.
       The bar rides the drawer's top edge all the way up and stops in its old
       slot at the top of the screen; the drawer stops with it, one --edge
       below. So the height is not a taste decision -- it is everything under
       the bar's landing place, and stating it that way is what makes the two
       arrive together on any phone. It was 92%, which happens to be the right
       number on a 852pt screen and fifteen pixels wrong on a 667pt one.
       A percentage of .stage, which is the window: vh and dvh both measure the
       safe area on an installed iPhone, so a sheet sized in them comes up short
       of the height it is asking for. */
    height: calc(100% - var(--tap) - var(--edge-t) - var(--edge));
    padding-top: 0;
    /* One flat colour, and that is the whole point.
       A body gradient cannot coexist with a strip that is one flat colour. The
       drawer ran from a light blue-slate at the top to purple at the bottom, so
       the strip could match one end or the other and never both -- and
       collapsed, when only the top few pixels of the drawer are on screen, it
       was the light end sitting directly on the dark one. Measured off the
       phone: drawer #333B57, strip #222140. Open, the seam is at the drawer's
       bottom; collapsed, it is near its top. One colour is the only thing that
       is right in both places, and it is the same declaration the page ground
       reads, so there is nothing left to keep in step.
       Opaque, too. At 90% the last tenth was whatever the map happened to be
       under it, saturated half again by the backdrop filter, so the bottom edge
       ran from #2E274F over empty space to #3E365E over snow. Dropping that
       filter with it is a real saving on a phone -- it was blurring a
       full-screen region every frame the sheet moved, to produce something
       nobody saw.
       What still makes this read as glass is the lit 1px edge along the top and
       the corners it turns, both of which are below. */
    --surface: var(--drawer-ground);
    background: var(--surface);
    border: 1px solid rgba(255, 255, 255, .18);
    border-bottom: 0;
    /* Generous, like the composer -- this is a bubble that stopped rising. */
    border-radius: 28px 28px 0 0;
    /* No glow along the bottom edge. It painted over the background -- box
       shadows do -- so the very bottom of the drawer came out brighter and more
       purple than the flat colour the strip below it was being given, which is
       the mismatch. The lit top edge is what makes this read as glass; the
       bottom of it is against the bottom of the screen where nobody sees a
       glow anyway. */
    box-shadow:
      0 -18px 50px -22px rgba(0, 0, 0, .75),
      inset 0 1px 0 rgba(255, 255, 255, .28);
    overflow: hidden;                      /* so the rounded top actually clips */
    /* Only the bottom needs an inset. The sheet stops short of the status bar
       by design, so anything added at the top is dead space between the grip
       and the first line of the feed -- which is the first thing you see when
       you pull it up. */
    padding-bottom: max(var(--safe-b, 0px), env(safe-area-inset-bottom, 0px));
    /* Driven by drawSheet(). Kept as a custom property on this element rather
       than an inline transform: an inline transform has to beat every rule and
       every animation that also sets transform, and losing that race leaves the
       drawer wherever the loser put it -- off the bottom of the screen, with
       nothing to say it is there. The cost this avoids was measured and it is
       not here: the drag's expense was two custom properties written to the
       root element, where a write restyles the whole document. This one is on
       the drawer. */
    transform: translateY(var(--sheet-y, 100%));
    transition: transform .42s cubic-bezier(.22, .82, .24, 1);
    z-index: 26;
  }
  /* The transform is the finger's while dragging and the spring's while
     settling. Either way a CSS transition would be a second opinion. */
  .sidebar.dragging, .sidebar.settling { transition: none; }

  .queryrow { justify-content: flex-start; }
  .queryrow .querychip { flex: 1 1 auto; }

  /* The grip is a handle, not a header. It gets the room it needs to be
     grabbed and not one pixel more -- the feed starts directly under it. */
  .sheet-grab {
    display: flex !important;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 20px;
    position: relative;
    flex: none;
    padding: 0;
    border: 0;
    background: none;
    cursor: grab;
    touch-action: none;                    /* the drag belongs to the sheet */
  }
  .sheet-grab span {
    width: 40px; height: 4px; border-radius: 4px;
    background: var(--ink-3);
    opacity: .5;
  }
  /* Collapsed, the whole lip is the handle rather than a 20px bar inside it.
     There is nothing else down there to press, and the difference between
     hitting the grip and hitting a few pixels under it is not one anybody
     should have to think about. */
  .sidebar:not(.expanded) .sheet-grab { height: var(--sheet-lip); }
  .sheet-grab:active { cursor: grabbing; }
  .sidebar .feedhead { padding-top: 6px; }


  /* ---- one size, stated last ----
     The phone layout grew in layers, and three earlier blocks in this file
     still set 40px and 46px on some of these. Restating it here, at the
     specificity those rules use, is what makes the four floating controls
     actually match rather than nearly match. */
  .searchdock .searchbox,
  .searchdock .brandmini,
  .searchdock .userchip,
  .mapctl .glassbtn,
  .mapctl .fab,
  .fab { height: var(--tap); }

  .searchdock .brandmini,
  .searchdock .userchip,
  .mapctl .glassbtn,
  .mapctl .fab,
  .fab { width: var(--tap); }

  .mapctl .glassbtn svg { width: 19px; height: 19px; }
  .mapctl { gap: 10px; }

  /* The counter had its own corner. The peek row is the counter now. */
  .mapchrome { display: none !important; }

  /* The feed only scrolls once the sheet is open; at peek a drag should move
     the sheet, not the list underneath it. */
  .sidebar:not(.expanded) .feed { overflow: hidden; }

  /* And nothing in the feed can be pressed until the drawer has arrived.
     A swipe that ends over a photo used to open it -- the release lands as a
     click on whatever happened to be under the finger, which is a photo about
     as often as not. The list is inert while the sheet is moving and while it
     is down, and comes back the moment it settles at a detent.
     Taps are unaffected: `dragging` is only set once a drag has passed the
     recognition threshold, so a press that never moved still reaches the card
     underneath it. */
  .sidebar:not(.expanded) .feed,
  .sidebar.dragging .feed,
  .sidebar.settling .feed { pointer-events: none; }
}

@media (prefers-reduced-motion: reduce) {
  .sidebar, .mapctl, .fab { transition: none !important; }
}

/* The Bubl on its way to the map.
   Its own glass, not a copy of the sprite: the sprite is a photograph warped
   onto a sphere at a fixed size, and this thing changes size the whole way
   across. Built out of the same tokens instead, so it arrives looking like what
   it is about to become -- rim colour included, which the flight sets from the
   post's own origin. */
.blown {
  position: fixed;
  z-index: 130;
  border-radius: 50%;
  pointer-events: none;
  background-color: color-mix(in srgb, var(--panel) 92%, transparent);
  background-size: cover;
  background-position: center;
  border: 1.5px solid color-mix(in srgb, var(--blown-rim, var(--rim-map)) 85%, transparent);
  box-shadow:
    0 0 26px -4px color-mix(in srgb, var(--blown-rim, var(--rim-map)) 70%, transparent),
    inset 0 6px 14px -6px rgba(255, 255, 255, .55),
    inset 0 -10px 18px -10px rgba(6, 10, 22, .55);
  backdrop-filter: blur(2px) saturate(120%);
  -webkit-backdrop-filter: blur(2px) saturate(120%);
  will-change: left, top, width, height;
}
/* A Bubl being read is no longer a copy of one drawn here: it is the Bubl
   itself, moved to a layer of its own on the map and faded there, so nothing
   about it is restated in CSS and nothing can drift out of step with how a Bubl
   is actually drawn. See POP_LAYER in app.js. */

/* The specular cap, so it reads as a sphere and not a disc while it travels. */
.blown::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background:
    radial-gradient(58% 46% at 32% 24%, rgba(255,255,255,.5), rgba(255,255,255,0) 62%);
}

/* The origin filter's button wears the colour of what it is showing, so the
   control and the Bubls agree without anyone having to read the letter on it.
   The two hues are the same pair the bubbles use -- see BUBBLE_RIM in app.js. */
/* The gradient's stops, so they follow the tokens the rims use. Set here
   rather than as attributes: var() does not resolve inside an SVG presentation
   attribute, only in a declaration. */
#originBothA { stop-color: var(--rim-map); }
#originBothB { stop-color: var(--rim-here); }
/* Both kinds showing: both hues.

   And a body, not only a rim. This state used to be the ring alone, 1.7px of
   gradient over glass, and it read as an empty button: the four icons around
   it are white lines, and a thin line in two mid-tone hues has a fraction of
   their contrast. Scaled up four times the gradient was there and correct --
   it was simply too little paint at 19px. The filtered states never had the
   problem because they also light the button's border and glow; this one
   must not, since it is the default and a glow is what says "filtered". So
   the bubble is filled, faintly, with the same two hues, and its rim is
   heavier. Measured against the white icons beside it at 1:1, that is the
   least that reads. */
#originBtn svg { stroke: url(#originBoth); }
#originBtn svg circle { fill: url(#originBoth); fill-opacity: .34; stroke-width: 2.2; }
#originBtn.is-here svg { stroke: var(--rim-here); }
#originBtn.is-map svg { stroke: var(--rim-map); }
/* Filtered: one hue, and the button itself carries it, so the outline alone
   is enough. */
#originBtn.is-here svg circle,
#originBtn.is-map svg circle { fill: none; stroke-width: 1.7; }
/* The highlight arc reads as a bubble rather than a ring, and wants to be
   thinner than the body it sits inside. */
#originBtn svg path { stroke-width: 1.6; opacity: .85; }

#originBtn.is-here {
  border-color: color-mix(in srgb, var(--rim-here) 62%, transparent);
  color: color-mix(in srgb, var(--rim-here) 40%, white);
  box-shadow: 0 0 22px -6px color-mix(in srgb, var(--rim-here) 70%, transparent),
              inset 0 1px 0 rgba(255,255,255,.28);
}
#originBtn.is-map {
  border-color: color-mix(in srgb, var(--rim-map) 62%, transparent);
  color: color-mix(in srgb, var(--rim-map) 40%, white);
  box-shadow: 0 0 22px -6px color-mix(in srgb, var(--rim-map) 70%, transparent),
              inset 0 1px 0 rgba(255,255,255,.28);
}

/* The other claim, in blue.
   A second copy of the sheet's own background, faded over the first, rather
   than a change to the first: gradients do not interpolate, so swapping the
   colours in place would cut from one to the other on the frame the class
   lands -- and the point of the switch is watching the sheet become a
   different kind of thing.
   The bottom stop is opaque, so at full strength the sheet's bottom edge is
   exactly --compose-ground-map, and the phone band, which is set to the same
   token, still meets it in one colour. */
.compose-sheet::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: inherit;
  background:
    linear-gradient(to bottom, rgba(0, 0, 0, 0) 62%, var(--compose-ground-map) 100%),
    linear-gradient(150deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 46%, rgba(255,255,255,0) 62%),
    linear-gradient(150deg,
      color-mix(in srgb, #2563EB 40%, transparent),
      color-mix(in srgb, #1D4ED8 34%, transparent)),
    color-mix(in srgb, #101C3A 86%, transparent);
  opacity: 0;
  transition: opacity .5s var(--ease-soft);
}
.compose-modal.to-map .compose-sheet::after { opacity: 1; }
/* Everything in the sheet sits above that layer -- without this the form would
   be painted underneath the colour it is supposed to be wearing. */
.compose-sheet > * { position: relative; z-index: 1; }

/* --- the search bar carries the account, so the corners stay empty --- */
@media (max-width: 900px) {
  .searchdock { display: flex; align-items: center; gap: 8px; }
  /* The field is not in the dock any more; it rides the button row at the
     bottom. What is left up here is the mark and the account chip, which stay
     at the two ends. */
  .searchdock { justify-content: space-between; }
  .searchdock .searchwrap {
    position: fixed;
    /* Exactly the gap the dock leaves either side of it -- so the bar sits in
       the same column of the screen whether it is at the bottom or the top, and
       the whole journey is vertical. */
    left: calc(var(--edge) + var(--tap) + 8px);
    right: calc(var(--edge) + var(--tap) + 8px);
    /* The base rule sets width:100%, which beats `right` on a positioned box
       and let the bar run out past the compose button. */
    width: auto;
    bottom: var(--edge-b);
    /* The same lift as the two buttons beside it. They fade out on the way up
       and this does not: it is going somewhere. */
    translate: 0 calc(-1 * var(--btn-lift, 0px));
    /* With the dock, for the list that opens out of it -- see the note there. */
    z-index: 40;
  }
  /* The list opens against the field, which is now a positioned element, so it
     needs pulling back out to the edges of the screen. */
  .searchdock .suggest {
    left: calc(-1 * (var(--tap) + 8px));
    right: calc(-1 * (var(--tap) + 8px));
  }
  .searchdock .usermenu { flex: none; }
  /* The chip is an avatar and nothing else: at this width a name and a
     chevron buy no information and cost the search field its room. */
  .searchdock .userchip {
    width: var(--tap); height: var(--tap); padding: 0; justify-content: center;
    border-radius: 50%;
  }
  .searchdock .userchip .uname, .searchdock .userchip .chev { display: none; }
  /* Exactly the mark's geometry on the other side of the bar: same 36px disc
     in the same 44px chip, so the two ends of the row are mirror images
     rather than two things that are nearly the same. The glow is applied in
     the avatar's own hue, from paintAvatar -- see there for why it is not
     here. */
  .searchdock .avatar {
    width: 36px; height: 36px;
    font-size: 15px;
  }
  .searchdock .dropdown { top: calc(100% + 10px); }

  /* Once the sheet is more than half up, the map is no longer the thing you are
     looking at, and floating buttons over a list are just litter. The search bar
     is the exception -- it is not litter, it is on its way back to the top of
     the screen, and it arrives exactly as the drawer stops.
     Fading only. A transform here would replace the one that keeps them locked
     to the sheet, and they would jump as they went. `sheet-high` is set while
     the sheet is still moving, so they are gone by the time it arrives rather
     than blinking off once it has.
     No kb-open case any more. They used to hide when the keyboard came up,
     because nothing down there was reachable -- but the app takes iOS's default
     behaviour now, so the whole viewport slides up and the row goes with it,
     staying exactly where it is relative to everything else. */
  body.sheet-high .fab,
  body.sheet-high .mapctl,
  body.sheet-full .fab,
  body.sheet-full .mapctl {
    opacity: 0; pointer-events: none;
    transition: opacity .22s var(--ease);
  }
}


/* ==========================================================================
   Moderation, and a person's page
   ========================================================================== */
.admin-card {
  width: min(94vw, 620px);
  max-height: min(88vh, 760px);
  gap: 14px;
}
.adminbody {
  min-height: 220px;
  overflow-y: auto;
  display: flex; flex-direction: column; gap: 12px;
}

/* Four numbers, and the two that mean work sit next to the two that are just
   context, so "3 suspended" reads as a queue rather than a statistic. */
.astats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
.astat {
  display: flex; flex-direction: column; gap: 2px;
  padding: 12px 14px; border-radius: var(--r-md);
  background: var(--panel-2); border: 1px solid var(--line);
}
.astat b { font-size: 22px; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.astat span { font-size: 12px; color: var(--ink-2); }
.astat em { font-style: normal; font-size: 11px; color: var(--ink-3); }

/* .segmented.compact is 148px wide, which is right for the two-word theme
   toggle in settings and far too narrow for three section names. The tab strip
   is the panel's primary navigation, so it gets the full width. */
#adminTabs.segmented.compact { width: 100%; flex: none; }
#adminTabs button { flex: 1; height: 30px; padding: 0 12px; font-size: 12.5px; }

.atools { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
#adminFilters.segmented.compact { width: auto; flex: 0 0 auto; }
#adminFilters button { flex: 0 0 auto; padding: 0 12px; }
.asearch {
  flex: 1 1 200px; min-width: 0; height: 34px; padding: 0 12px;
  border-radius: var(--r-pill); border: 1px solid var(--line);
  background: var(--panel-2); color: var(--ink); font-size: 13.5px; outline: none;
}
.asearch:focus { border-color: transparent; box-shadow: var(--glow); }

.alist { display: flex; flex-direction: column; gap: 6px; }
.arow {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center; gap: 10px;
  padding: 9px 10px; border-radius: var(--r-md);
  border: 1px solid var(--line); background: var(--panel-2);
}
/* A suspended account or a removed Bubl is dimmed and struck through the
   left edge, so a list of forty rows still reads at a glance. */
.arow.is-banned { opacity: .72; border-left: 3px solid #F43F5E; }
.arow-who { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.arow-who b { font-size: 13.5px; }
.arow-who span { font-size: 11.5px; color: var(--ink-3); }
.arow-who em {
  font-style: normal; font-size: 11.5px; color: var(--ink-2);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.arow-body { color: var(--ink-2) !important; }
.arow-tags { display: flex; gap: 4px; }
.arow-do { display: flex; gap: 6px; align-items: center; }
.arow-note { font-size: 11.5px; color: var(--ink-3); }

.rolechip {
  display: inline-flex; align-items: center;
  padding: 2px 8px; border-radius: var(--r-pill);
  font-size: 10.5px; font-weight: 700; letter-spacing: .03em; text-transform: uppercase;
  border: 1px solid transparent;
}
.r-admin { background: color-mix(in srgb, var(--primary) 22%, transparent); color: var(--ink); }
.r-moderator { background: color-mix(in srgb, var(--secondary) 20%, transparent); color: var(--ink); }
.r-banned { background: color-mix(in srgb, #F43F5E 18%, transparent); color: #FB7185; }
/* Not a person: a publication the aggregator posts as, or one of the daily
   generator's voices. Quiet rather than alarming -- it is a fact about the
   account, not a warning about it -- but never the same colour as the text
   beside it, or it reads as part of the name. */
.r-bot { background: color-mix(in srgb, var(--ink-3) 20%, transparent); color: var(--ink-2); }

/* The same fact on a Bubl's card, where it has to sit beside a name without
   crowding it. Lower case and no border: at this size the uppercase chip the
   profile wears shouts, and what this is doing is answering a question the
   reader may not have thought to ask. */
.botmark {
  flex: none; margin-left: 6px; padding: 1px 6px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--ink-3) 16%, transparent);
  color: var(--ink-3);
  font-size: 10.5px; font-weight: 600; letter-spacing: .01em;
  white-space: nowrap;
}

/* Who a Bubl is for, on the card. Tinted rather than grey, unlike .botmark
   beside it: "not a person" is a caveat on the author and this is a fact about
   the Bubl, and the two should not read as the same kind of remark. */
.audmark {
  flex: none; padding: 1px 6px;
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--primary) 18%, transparent);
  color: color-mix(in srgb, var(--primary) 70%, var(--ink));
  font-size: 10.5px; font-weight: 600;
  max-width: 11em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The separator after it, so "Friends" is not welded to the place name. */
.audmark + .card-place { margin-left: 6px; }

.btn.tiny, .select.tiny { height: 28px; padding: 0 10px; font-size: 12px; }
/* The two irreversible-feeling actions are the only red on the page. */
.btn.danger {
  border-color: color-mix(in srgb, #F43F5E 42%, transparent);
  color: #FB7185;
}
.btn.danger:hover { background: color-mix(in srgb, #F43F5E 16%, transparent); }

/* --- the author, as a way in ---------------------------------------------- */
.card-who {
  display: inline-flex; align-items: center; gap: 8px;
  border: 0; background: none; padding: 0; cursor: pointer;
  color: inherit; font: inherit; border-radius: var(--r-pill);
  transition: opacity .14s;
}
.card-who:hover { opacity: .72; }
.card-who.inline { gap: 0; }
.card-who:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* The shield sits at the far end of the byline, where it is out of the way
   until you are looking for it. */
.modbtn {
  margin-left: auto; flex: none;
  width: 26px; height: 26px; display: grid; place-items: center;
  border-radius: 8px; border: 1px solid transparent; background: none;
  color: var(--ink-3); cursor: pointer; transition: all .14s;
}
.modbtn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.7; }
.modbtn:hover { color: var(--ink); border-color: var(--line-2); background: var(--panel-2); }

/* The flag, beside the shield and the same size as it, because they are the
   same kind of thing from two different seats: a moderator acts, everybody
   else says something is wrong. Both carry margin-left:auto -- the first one
   present takes the free space and the second lands against it, so either can
   be absent without the other moving. */
.repbtn {
  margin-left: auto; flex: none;
  width: 26px; height: 26px; display: grid; place-items: center;
  border-radius: 8px; border: 1px solid transparent; background: none;
  color: var(--ink-3); cursor: pointer; transition: all .14s;
  /* Quieter than the shield at rest: this one is on every card a reader sees,
     and a row of flags reads as an instruction to use them. */
  opacity: .5;
}
.repbtn svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.7; }
.repbtn:hover {
  opacity: 1; color: var(--ink); border-color: var(--line-2); background: var(--panel-2);
}
.repbtn:focus-visible { opacity: 1; outline: 2px solid var(--primary); outline-offset: 2px; }
/* On a message bubble it rides inside the bubble beside the timestamp, so it
   cannot widen the bubble or push the thread about. */
.repbtn.onmsg { margin-left: 0; width: 20px; height: 20px; border-radius: 6px; }
.repbtn.onmsg svg { width: 12px; height: 12px; }

/* --- a person's page ------------------------------------------------------ */
.profhead {
  display: grid;
  /* Three columns now: the face, who they are, and the follow button. The rows
     under it span all three. */
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center; gap: 10px 12px;
  padding: 14px;
  /* The card and the Bubls under it are two different things -- who this is,
     and what they wrote -- and 4px read as one block with a line through it.
     The same gap the posts keep from each other, so the card sits in the list
     rather than on top of it. */
  margin-bottom: 14px;
  border-radius: var(--r-lg);
  border: 1px solid var(--line); background: var(--panel-2);
}
/* The banner, behind the top of the card.
   Bled to the card's edges by cancelling its padding rather than by taking the
   padding off and putting it back on every child -- and clipped by the card's
   own radius, so the picture takes the shape of the thing it is in. */
.profbanner {
  grid-column: 1 / -1;
  margin: -14px -14px 2px;
  height: 104px;
  overflow: hidden;
  border-radius: calc(var(--r-lg) - 1px) calc(var(--r-lg) - 1px) 0 0;
  background: var(--panel-3);
}
.profbanner img {
  display: block; width: 100%; height: 100%;
  object-fit: cover;
}
/* With one, the avatar rides up over its lower edge -- which is what makes the
   two read as one object rather than a picture with a card under it. */
.profhead.has-banner .card-av.big {
  margin-top: -30px;
  box-shadow: 0 0 0 3px var(--panel-2);
}

/* Who they are, above what they have done. */
.profbio {
  grid-column: 1 / -1;
  margin: 2px 0 0;
  font-size: 13px; line-height: 1.5;
  color: var(--ink-2);
  overflow-wrap: anywhere;
}

.card-av.big { width: 44px; height: 44px; font-size: 18px; }
.profwho { min-width: 0; }
.profwho h3 {
  margin: 0; font-size: 16px; letter-spacing: -.01em;
  display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
}
.profwho p { margin: 2px 0 0; font-size: 12px; color: var(--ink-3); }
.profstats {
  grid-column: 1 / -1;
  display: flex; gap: 16px; font-size: 12.5px; color: var(--ink-2);
}
.profstats b { color: var(--ink); font-variant-numeric: tabular-nums; }
.prof-places {
  grid-column: 1 / -1;
  margin: 0; font-size: 12px; color: var(--ink-3);
}
/* The last line of the card rather than a control beside the friend button.
   The grid above it has exactly three columns and the friend button holds the
   third, so anything else up there auto-places onto the next row's first
   column -- under the avatar, which is not where a report belongs. */
.prof-report { grid-column: 1 / -1; margin: 2px 0 0; }
.prof-report .linkbtn, .ev-report .linkbtn, .biz-report .linkbtn {
  font-size: 11.5px; color: var(--ink-3); padding-left: 0;
}
.prof-report .linkbtn:hover, .ev-report .linkbtn:hover, .biz-report .linkbtn:hover {
  color: var(--primary);
}
.ev-report, .biz-report { margin: 10px 0 0; }

@media (max-width: 900px) {
  .admin-card { width: 100vw; max-width: 100vw; max-height: 92dvh; border-radius: 0; }
  /* Four columns of controls do not fit a phone, so the actions drop under
     the name rather than being squeezed into a strip too narrow to hit. */
  .arow { grid-template-columns: auto minmax(0, 1fr); }
  .arow-tags, .arow-do { grid-column: 2; }
  .arow-do { justify-content: flex-start; }
}

/* --- the ask dialog and the shield menu ----------------------------------- */
.ask-card { width: min(94vw, 420px); gap: 14px; }
.ask-card .modal-head h2 { font-size: 17px; }
.ask-card .modal-head p { text-align: center; line-height: 1.5; }
/* Adding a place: the same card with seven fields in it, so it has to be able
   to scroll on a phone in landscape rather than running off the bottom. */
.placecard { max-height: 88dvh; overflow-y: auto; }
.placecard .modal-head p { text-align: left; font-size: 13px; }
/* Left-aligned, because in the event composer these are sentences rather than
   asides -- "it starts at seven in Europe/Berlin, which is six where you are"
   is the thing being checked, not a footnote to it. */
.placecard .hint { margin: 0; text-align: left; }
#eventClock { display: flex; flex-direction: column; gap: 4px; }
/* Louder than a hint and quieter than an error, which is exactly where the
   exact-address notice belongs: nothing has gone wrong, and the rule it states
   is the opposite of the one every other circle on this map follows. The same
   tint as .ev-exact on the event's own page, so the two read as one statement
   made twice. */
.hint-warn {
  padding: 8px 10px; border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--primary) 9%, transparent);
  border: 1px solid var(--line);
  font-size: 12px; color: var(--ink-2); line-height: 1.5;
}
/* What the server resolved the typed address to. Printed before anything is
   posted, because an event publishes this exactly as it reads. */
.evform-where {
  padding: 8px 10px; border-radius: var(--r-sm);
  background: var(--panel-2); border: 1px solid var(--line);
  font-size: 12.5px; color: var(--ink-2);
}
.evform-where.is-waiting { color: var(--ink-3); font-style: italic; }
/* The same rose .formerror uses. There is no --danger token; a literal here
   rather than inventing one for two selectors. */
.evform-where.is-bad {
  border-color: color-mix(in srgb, #F43F5E 38%, transparent);
  color: #FB7185;
}
.evform-addr { margin: 0; line-height: 1.45; }
.evform-note { margin: 4px 0 0; font-size: 11.5px; color: var(--ink-3); }
.askrow { display: flex; gap: 8px; }
.askrow .btn { flex: 1; height: 38px; justify-content: center; }
.askcheck {
  display: flex; gap: 10px; align-items: flex-start; cursor: pointer;
  padding: 10px 12px; border-radius: var(--r-sm);
  background: var(--panel-2); border: 1px solid var(--line);
}
.askcheck input { margin-top: 2px; accent-color: var(--primary); }

/* The two ends of a custom window.
 *
 * Side by side, because they are one range and reading them as a pair is the
 * whole point. They wrap to a column when they will not fit -- and that is
 * decided by the flex basis rather than by a width in a media query, because
 * what has to fit is a date input, whose width comes from the browser's own
 * rendering of a date and not from the size of the screen. Measured in the
 * dialog at 375px: 144px each, with the value not truncated. Below that the
 * basis stops fitting and they stack on their own, at whatever width that
 * turns out to be on the reader's phone rather than on the one guessed here. */
.daterange { display: flex; flex-wrap: wrap; gap: 10px; }
.daterange .field { flex: 1 1 140px; min-width: 0; }
/* Safari lays a date input out at its intrinsic width and ignores width:100%,
   which leaves one end of the range wider than the other. */
.daterange input[type="date"] { min-width: 0; width: 100%; box-sizing: border-box; }
/* The native indicator is near-black on a dark panel, so it disappears. */
.daterange input[type="date"]::-webkit-calendar-picker-indicator {
  filter: invert(1); opacity: .55; cursor: pointer;
}
.daterange input[type="date"]::-webkit-calendar-picker-indicator:hover { opacity: .9; }
:root[data-theme="light"] .daterange input[type="date"]::-webkit-calendar-picker-indicator {
  filter: none;
}


.askcheck span { display: flex; flex-direction: column; gap: 2px; font-size: 13px; }
.askcheck em { font-style: normal; font-size: 11.5px; color: var(--ink-3); }

.modmenu {
  position: fixed; z-index: 80;
  min-width: 190px; padding: 6px;
  background: color-mix(in srgb, var(--panel) 94%, transparent);
  backdrop-filter: blur(22px) saturate(180%);
  border: 1px solid var(--line); border-radius: var(--r-md);
  box-shadow: var(--shadow);
  animation: bublpop .2s var(--ease);
  transform-origin: top right;
  display: flex; flex-direction: column; gap: 2px;
}
.modmenu button {
  text-align: left; padding: 8px 10px; border: 0; border-radius: 7px;
  background: none; color: var(--ink-2); font-size: 13px; font-weight: 600; cursor: pointer;
}
.modmenu button:hover { background: var(--panel-2); color: var(--ink); }
.modmenu button.danger { color: #FB7185; }
.modmenu button.danger:hover { background: color-mix(in srgb, #F43F5E 14%, transparent); }


/* ==========================================================================
   Blowing a Bubl
   ==========================================================================
   The composer is the bubble. A handful burst off the button and one of them
   keeps growing until it is the whole screen -- the same move the sign-in card
   makes, so the two moments in the app that ask you for something arrive the
   same way.

   No heading: the button you pressed said what this is, and a full screen that
   opens with a title telling you what you just chose is furniture.
   ========================================================================== */
.compose-modal { max-width: none; max-height: none; width: 100%; height: 100%; }
.compose-modal::backdrop { background: var(--scrim); backdrop-filter: blur(10px); }

.compose-sheet {
  position: fixed;
  inset: 0;
  display: flex;
  flex-direction: column;
  /* The bubble's own glass, so the sheet is the inside of the thing that just
     rose rather than a panel that replaced it -- with the last stretch of it
     settling onto one flat colour.
     That last part is what makes the iPhone's unpaintable strip disappear. The
     glass is a gradient, so its colour at the bottom edge is not any single
     value you can compute and match: measured on the device it was #383063 at
     the bottom and #494473 two hundred pixels higher. Fading the bottom onto
     --compose-ground, and setting the page ground to the same token, makes the
     two equal by construction rather than by arithmetic that the next change to
     the gradient would quietly invalidate.
     On its own, with no colour appended: --bubl-glass already ends in one, and
     a colour in any layer but the last makes the whole declaration invalid --
     which silently left the sheet with no background at all. */
  background:
    linear-gradient(to bottom, rgba(0, 0, 0, 0) 62%, var(--compose-ground) 100%),
    var(--bubl-glass);
  backdrop-filter: blur(22px) saturate(170%);
  -webkit-backdrop-filter: blur(22px) saturate(170%);
  color: var(--ink);
  /* One movement. There is no separate bubble element any more -- the sheet's
     own clip is the bubble, and it rises and grows at once. */
  animation: bublRise 1.15s cubic-bezier(.46, .03, .32, 1) both;
}
/* The rise and the swell, as one interpolation.
   A bubble used to rise as an element of its own, stop in the middle, and hand
   over to a sheet that swelled from where it had stopped: a pause and a
   handover inside what should be a single gesture. Both ends of this keyframe
   are the same circle -- it starts the size of a bubble with its centre below
   the bottom edge, and arrives centred and larger than the screen -- so
   interpolating moves it up and grows it together, and there is nothing to
   hand over because nothing is ever swapped.

   112%, not more: a circle at 100% already reaches the corners, so every
   percent past that grows off-screen where nobody can see it while still
   costing time on the timeline. */
@keyframes bublRise {
  /* Starting in frame, and small enough to read as a bubble. The centre used
     to be at 124% -- below the bottom edge -- so the only thing ever on screen
     was the top cap of something enormous: a rising dome, not a bubble. It now
     starts as a whole circle near the bottom, which is the thing that is
     supposed to be growing. */
  0%   { clip-path: circle(var(--bubl-r, 64px) at 50% 86%); opacity: 0; }
  8%   { opacity: 1; }
  100% { clip-path: circle(112% at 50% 50%); opacity: 1; }
}
/* The contents are inside the bubble, so they arrive with it rather than being
   there from the first frame at full size behind a small hole. */
.compose-bar, .compose-scroll { animation: composeContent .42s var(--ease) .52s both; }
@keyframes composeContent {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* --- and how it goes ----------------------------------------------------
   It pops. It used to shrink back down into the bubble it grew from, which is
   an un-inflation: bubbles do not do that, they hold their shape until the film
   gives out all at once. So the surface tightens and swells a fraction first,
   then tears -- gone in about a tenth of a second, faster than it swelled, and
   scaling *up* on the way out rather than down. Nothing that pops gets smaller.
   The spray that sells it is drawn in JS, since a ring of droplets needs a ring
   of angles. See popCompose(). */
.compose-modal.closing .compose-sheet {
  animation: bublPop .26s cubic-bezier(.55, 0, .8, 1) both;
}
/* A film holds its shape until it does not.
   The old curve started leaving immediately, so it read as a fade with a bit of
   scale on it. This one spends its first half tightening while still fully
   opaque -- barely moving, which is what makes the second half read as the
   thing letting go -- and then tears: nearly all of the disappearing happens in
   the last 40%, faster than it swelled. */
@keyframes bublPop {
  0%   { transform: scale(1);     opacity: 1; }
  42%  { transform: scale(1.018); opacity: 1; }
  60%  { transform: scale(1.038); opacity: .94; }
  76%  { transform: scale(1.075); opacity: .38; }
  100% { transform: scale(1.14);  opacity: 0; }
}
/* The contents go with it, and slightly ahead of it -- they were inside. */
.compose-modal.closing .compose-bar,
.compose-modal.closing .compose-scroll {
  animation: popContents .17s cubic-bezier(.4, 0, .9, .6) both;
}
@keyframes popContents {
  from { opacity: 1; transform: scale(1); }
  to   { opacity: 0; transform: scale(1.05); }
}
.compose-modal.closing::backdrop { animation: popScrim .34s ease-out both; }
@keyframes popScrim { from { opacity: 1; } to { opacity: 0; } }

/* --- the spray ---
   Droplets off the rim, and one ring going out where the surface used to be.
   Fixed rather than absolute so it sits in the dialog's top layer with the
   sheet; it is a sibling of the sheet, so the sheet's own fade does not take
   the droplets with it. */
.popfield { position: fixed; inset: 0; pointer-events: none; z-index: 3; }
.popfield i {
  position: absolute; border-radius: 50%;
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,.92), rgba(255,255,255,.16) 44%, rgba(255,255,255,0) 68%),
    linear-gradient(150deg, var(--blue-400), var(--purple-500) 72%, var(--magenta));
  box-shadow: inset 0 1px 3px rgba(255,255,255,.6);
  animation: popDrop .38s cubic-bezier(.08, .82, .3, 1) both;
}
/* Thrown, not drifting. Almost all the travel is in the first third -- that is
   what a burst does -- and they are gone before they stop. */
@keyframes popDrop {
  0%   { opacity: 0; transform: translate(0, 0) scale(.42); }
  10%  { opacity: 1; transform: translate(calc(var(--dx) * .3), calc(var(--dy) * .3)) scale(1.12); }
  55%  { opacity: .55; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(.2); }
}
/* The shockwave: a thin ring where the film was, on its way out. */
.popfield b {
  position: absolute; border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--primary) 70%, white 30%);
  animation: popRing .34s cubic-bezier(.2, .7, .3, 1) both;
}
@keyframes popRing {
  from { width: calc(var(--r0) * 2); height: calc(var(--r0) * 2);
         margin: calc(var(--r0) * -1) 0 0 calc(var(--r0) * -1);
         opacity: .85; border-width: 3px; }
  to   { width: calc(var(--r1) * 2); height: calc(var(--r1) * 2);
         margin: calc(var(--r1) * -1) 0 0 calc(var(--r1) * -1);
         opacity: 0; border-width: 1px; }
}

/* The bubble's surface while it rises.
   Same recipe as .welcome-hero, because it is meant to read as the same object:
   the two moments in the app that ask you for something should arrive the same
   way. Positioned and sized from JS so it tracks the sheet's clip circle
   exactly -- the circle is what grows, and this is what makes the circle look
   like a bubble rather than a hole. */
.compose-bubble {
  position: fixed; left: 50%; top: 86%;
  width: 0; height: 0;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  pointer-events: none;
  z-index: 3;
  opacity: 0;
  border: 1px solid rgba(255,255,255,.22);
  background:
    linear-gradient(150deg, rgba(255,255,255,.14) 0%, rgba(255,255,255,.03) 46%, rgba(255,255,255,0) 62%),
    linear-gradient(150deg,
      color-mix(in srgb, var(--primary) 22%, transparent),
      color-mix(in srgb, var(--secondary) 18%, transparent));
  box-shadow:
    0 40px 90px -34px rgba(2, 6, 20, .9),
    inset 0 2px 0 rgba(255,255,255,.30),
    inset 0 -16px 34px -18px color-mix(in srgb, var(--secondary) 75%, transparent);
  will-change: width, height, top, opacity;
}
.compose-modal.closing .compose-bubble { display: none; }

/* --- the ones that come up with it ---
   A few small bubbles rising alongside the sheet's own circle, from the same
   place. They are decoration and they are also what makes the growing clip read
   as having come from somewhere.
   (The single large `.riser` that used to lead them is gone: the sheet's clip
   is the bubble now, so there is no second element to keep in step.) */
.burst { position: fixed; inset: 0; pointer-events: none; z-index: 90; }
/* The same bubbles the sign-in page raises -- .wb's recipe exactly, down to the
   specular placement and the three shadows, so the two moments match. */
.burst i {
  position: absolute; border-radius: 50%;
  background:
    radial-gradient(circle at 30% 24%, rgba(255,255,255,.72), rgba(255,255,255,.10) 44%, rgba(255,255,255,0) 64%),
    linear-gradient(150deg, var(--blue-400), var(--purple-500) 72%, var(--magenta));
  box-shadow:
    inset 0 2px 6px rgba(255,255,255,.4),
    inset 0 -10px 22px -12px color-mix(in srgb, var(--secondary) 80%, transparent),
    0 10px 34px -14px color-mix(in srgb, var(--primary) 70%, transparent);
  animation: burstRise 1.15s cubic-bezier(.32, .5, .34, 1) both;
}
/* They rise and go, rather than spraying outward. A splash is what something
   makes on the way in; these are coming up with the big one. */
@keyframes burstRise {
  0%   { opacity: 0; transform: translate(0, 0) scale(.45); }
  14%  { opacity: .92; }
  70%  { opacity: .7; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(1.05); }
}

/* --- the sheet itself ---------------------------------------------------- */
.compose-bar {
  flex: none;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 12px;
  padding: max(12px, env(safe-area-inset-top, 0px)) 14px 12px;
}
/* On a dark panel --ink-3 is a quiet grey; on this glass it is barely there.
   Everything in here goes up a step. */
.compose-bar .btn { height: 38px; padding: 0 20px; border-radius: var(--r-pill); }
/* The confirm button: the same bubble as the close, in the colour that means
   go. Two bubbles in the two top corners, one to leave and one to finish. */
.compose-bar .iconbtn.go {
  border-color: color-mix(in srgb, var(--primary) 62%, white 12%);
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,.42), rgba(255,255,255,.08) 54%),
    var(--grad);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.5),
    inset 0 -7px 14px -8px rgba(30, 10, 70, .8),
    0 6px 18px -8px color-mix(in srgb, var(--primary) 85%, transparent);
  color: #fff;
}
.compose-bar .iconbtn.go:hover {
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.62),
    inset 0 -7px 14px -8px rgba(30, 10, 70, .85),
    0 8px 24px -8px color-mix(in srgb, var(--primary) 95%, transparent);
}
/* A tick does not want to spin the way the close cross does. */
.compose-bar .iconbtn.go:hover svg { transform: none; }
.compose-bar .iconbtn svg { width: 19px; height: 19px; fill: none; stroke: currentColor;
  stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.compose-bar .iconbtn .waiting { display: none; }
/* Waiting on a location: the tick steps aside for a ring that turns, rather
   than the label being overwritten with words the icon cannot hold. */
.compose-bar .iconbtn.busy .tick { display: none; }
.compose-bar .iconbtn.busy .waiting {
  display: block;
  stroke-dasharray: 14 40;
  animation: goSpin .9s linear infinite;
}
@keyframes goSpin { to { transform: rotate(360deg); } }
/* The pill in the middle is the location switch. It reads as pressable and
   changes with what it is set to, rather than describing a state some other
   control changes. Disabled when there is nothing to switch to -- while the
   location is still being looked for, or when it was never found. */
/* Set to the reader's own location: the state worth showing, so it is lit. */

/* Round, like everything else in a composer made of bubbles -- and this one is
   a bubble, since pressing it pops the whole thing. Glass with a specular cap,
   breathing gently on its own so it reads as something with air in it rather
   than a dismiss control.
   The breathing is on `scale` and the press is on `transform`, which are
   separate properties that compose: put both on `transform` and the running
   animation wins, so the button would not squash when pressed. */
.compose-bar .iconbtn {
  position: relative;
  width: 38px; height: 38px; border-radius: 50%;
  display: grid; place-items: center;
  border: 1px solid rgba(255, 255, 255, .26);
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,.30), rgba(255,255,255,.06) 52%),
    linear-gradient(150deg, rgba(255,255,255,.16), rgba(255,255,255,.04));
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.42),
    inset 0 -6px 12px -8px color-mix(in srgb, var(--secondary) 80%, transparent),
    0 4px 14px -8px rgba(2, 6, 20, .9);
  color: var(--ink);
  transition: background .16s, transform .2s var(--ease), border-color .16s, box-shadow .2s;
}
/* The highlight that makes a disc read as a sphere. */
.compose-bar .iconbtn::after {
  content: ''; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none;
  background: radial-gradient(54% 38% at 34% 20%, rgba(255,255,255,.52), rgba(255,255,255,0) 64%);
}
/* The idle pulse is gone. It read as the buttons popping the whole time the
   composer was open, and these two are meant to be the same objects as the home
   button and the account chip on the map -- which sit still. The press, the
   hover and the specular cap are what make them feel like glass; a thing that
   never stops moving is just movement. */
.compose-bar .iconbtn:hover {
  border-color: rgba(255, 255, 255, .46);
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.55),
    inset 0 -6px 12px -8px color-mix(in srgb, var(--secondary) 90%, transparent),
    0 6px 20px -8px color-mix(in srgb, var(--primary) 70%, transparent);
}
/* Squashes like something with give in it, then springs back. */
.compose-bar .iconbtn:active { transform: scale(.84); transition-duration: .06s; }
.compose-bar .iconbtn svg { transition: transform .2s var(--ease); }
.compose-bar .iconbtn:hover svg { transform: rotate(90deg); }

/* The way out is where the way home is.
   The home button sits at (--edge, --edge-t) and is one --tap across; this
   takes exactly those three values rather than numbers that happen to match, so
   that corner means one thing -- press here to get back out -- whether the
   composer is open or not, and neither can be moved without the other.
   It has to sit below the composer's own rules rather than up with the rest of
   the phone layout: same specificity, so whichever comes last in the file wins,
   and up there it silently lost. */
@media (max-width: 900px) {
  /* The two habitual corners. Anything wearing these lands exactly where the
     home button and the account chip sit on the map.
     The class is written twice on purpose. An anchor has to beat the sizing a
     component gives its own buttons -- .compose-bar .iconbtn is two classes
     and makes these 38px in the flow of the bar -- and a single class loses to
     that, silently, leaving the button wherever the component put it. Doubling
     it matches that weight without naming any component, so the next thing
     that takes an anchor does not have to discover this the hard way. */
  .corner-tl.corner-tl, .corner-tr.corner-tr {
    position: fixed;
    top: var(--edge-t);
    width: var(--tap); height: var(--tap);
    z-index: 4;
  }
  .corner-tl.corner-tl { left: var(--edge); }
  .corner-tr.corner-tr { right: var(--edge); }
  /* Both are out of the grid, so what is left is the line between them.
     8px, not 10: the same gap the search dock puts between its own three
     pieces, which is what makes the middle of this row start where the middle
     of that one starts. */
  .compose-bar {
    grid-template-columns: minmax(0, 1fr);
    padding: var(--edge-t) calc(var(--edge) + var(--tap) + 8px) 12px;
    min-height: var(--tap);
  }
  /* The line in the middle is the search bar's twin.
     The map's top row is a button, a pill, a button; this row was a button,
     some loose text, a button -- the same three positions but only two of them
     shaped alike, so the eye had to re-find the middle of it. Same height, same
     radius, same glass, arrived at from the same tokens rather than by copying
     numbers across.
     The rule under this comment had gone missing, which is why the title was
     back to being loose text between two buttons. */
  .compose-title {
    justify-self: center;
    display: inline-flex; align-items: center;
    height: 38px; padding: 0 18px;
    border-radius: var(--r-pill);
    background: var(--panel-2);
    border: 1px solid var(--line);
    /* The same lit top edge the buttons either side of it have, so the three
       read as one row of objects rather than a pill between two bubbles. */
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18);
    backdrop-filter: blur(12px) saturate(160%);
    -webkit-backdrop-filter: blur(12px) saturate(160%);
    max-width: 100%;
  }
  /* Room between the row and the form.
     The bar's own padding is the whole of it: .compose-scroll's padding is a
     shorthand set further down this file, so a padding-top written up here
     loses to it silently -- the same trap the corner anchors above hit. 22
     here plus the scroll's own 16 is 38px from the pill to the first field,
     against 28 before, which is enough that the two stop reading as one block. */
  .compose-bar { padding-bottom: 22px; }
  }

.compose-scroll {
  flex: 1; min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  display: flex; flex-direction: column; gap: 16px;
  padding: 16px 16px calc(28px + env(safe-area-inset-bottom, 0px));
}

/* The message is a field, and has to look like one.
   It was borderless and transparent, which on a full-screen sheet made it
   indistinguishable from the sheet -- there was nothing to say where you could
   type or how much room you had. A boundary is not decoration here: WCAG 1.4.11
   asks for 3:1 against the surface behind it for exactly this, the visual
   information needed to identify a control. */
.composer-field { gap: 8px; }
.composer-box {
  border: 1px solid rgba(255, 255, 255, .30);
  border-radius: var(--r-md);
  background: rgba(12, 16, 32, .32);
  padding: 12px 14px;
  transition: border-color .16s, box-shadow .16s, background .16s;
}
/* Focus on the box, since the box is what reads as the control. A ring rather
   than a colour change alone -- 2.4.7 wants focus visible, and a border that
   merely brightens is easy to miss on glass. */
.composer-box:focus-within {
  border-color: color-mix(in srgb, var(--primary) 78%, white 22%);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 34%, transparent);
  background: rgba(12, 16, 32, .44);
}
.compose-scroll .composer-field > label {
  font-size: 13px; font-weight: 700; letter-spacing: -.01em; color: var(--ink);
}

#composeBody {
  width: 100%; min-height: 152px; resize: none;
  padding: 0; border: 0; background: none; color: var(--ink);
  font: inherit; font-size: 17px; line-height: 1.5; outline: none;
}
#composeBody::placeholder { color: color-mix(in srgb, var(--ink) 62%, transparent); }

.compose-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toolbtn {
  display: inline-flex; align-items: center; gap: 7px;
  height: 36px; padding: 0 13px; border-radius: var(--r-pill);
  border: 1px solid var(--line); background: var(--panel-2); color: var(--ink-2);
  font-size: 13px; font-weight: 600; cursor: pointer;
  transition: border-color .15s, color .15s, transform .15s var(--ease);
}
.toolbtn svg { width: 16px; height: 16px; }
.toolbtn:hover { color: var(--ink); border-color: var(--line-2); }
.toolbtn:active { transform: scale(.94); }
.toolbtn[disabled] { opacity: .45; cursor: default; }
.compose-tools .counter {
  position: static; margin-left: auto; font-family: var(--mono); font-size: 11.5px;
}

/* A column, not a row. It was a row when it held one thumbnail and a Remove
   button beside it; with a strip of attachments and a line of help underneath,
   a row put the help to the right of the strip and let the strip size itself
   to its own content -- which is what pushed the buttons out past the edge of
   the card they belong to. */
.compose-media {
  display: flex; flex-direction: column; align-items: stretch;
  gap: 8px; min-width: 0;
}

/* A hashtag typed into the body reads as one, so it is obvious the word is
   doing something. */
.compose-scroll .tagpick { max-height: none; }
.compose-scroll .tagbox { background: rgba(12, 16, 32, .28); }
.compose-scroll .tagsuggest {
  background: color-mix(in srgb, #1B1733 88%, transparent);
  backdrop-filter: blur(14px);
}
.compose-scroll .tagopt:hover, .compose-scroll .tagopt.active {
  background: rgba(255, 255, 255, .13);
}
/* The rest of the copy, lifted to read on the glass rather than on a panel. */
.compose-scroll .field > span { color: var(--ink); }
.compose-scroll .field > span em { color: color-mix(in srgb, var(--ink) 68%, transparent); }
.compose-scroll .hint { color: color-mix(in srgb, var(--ink) 66%, transparent); }
.compose-scroll .counter { color: color-mix(in srgb, var(--ink) 70%, transparent); }
/* The count is quiet until it is worth reading. Colour is not the only signal --
   the number itself changes, and it turns to how many are left once that is the
   useful figure, so this does not depend on telling the two hues apart. */
.counter.warn { color: #FFC46B; font-weight: 700; }
.counter.over { color: #FF8A8A; font-weight: 700; }
/* Two separate reasons a field can look wrong -- too long, and rejected on
   submit -- each owning its own class, so neither clears the other's state. */
.composer-box.over-limit,
.composer-box.invalid,
.composer-box.over-limit:focus-within,
.composer-box.invalid:focus-within {
  border-color: #FF8A8A;
  box-shadow: 0 0 0 3px rgba(255, 138, 138, .26);
}
.compose-scroll .toolbtn {
  color: var(--ink);
  border-color: rgba(255, 255, 255, .20);
  background: rgba(255, 255, 255, .10);
}
.compose-scroll .toolbtn:hover { background: rgba(255, 255, 255, .18); }
.compose-scroll .tagpick button {
  color: var(--ink);
  border-color: rgba(255, 255, 255, .18);
  background: rgba(255, 255, 255, .09);
}
.compose-scroll .tagpick button.on { background: var(--grad); border-color: transparent; }

@media (min-width: 901px) {
  /* The dialog fills the viewport so the backdrop does, and then has to place
     the sheet itself -- a <dialog> only centres its own box, and its box is now
     the screen. Scoped to [open] because `display` is what a dialog uses to
     hide itself, and a grid is not display:none. */
  .compose-modal[open] { display: grid; place-items: center; }
  /* On a desktop a full screen for four fields is too much room, so it stops
     at a tall column -- still one surface, still scrolling, still the bubble. */
  .compose-sheet {
    inset: auto; position: relative;
    width: min(92vw, 560px); height: min(88vh, 760px);
    /* Generous, because this is a bubble that stopped growing, not a dialog. */
    --sheet-radius: 34px;
    border-radius: var(--sheet-radius);
    border: 1px solid rgba(255, 255, 255, .18);
    box-shadow:
      0 40px 90px -34px rgba(2, 6, 20, .9),
      inset 0 2px 0 rgba(255,255,255,.28),
      inset 0 -16px 34px -18px color-mix(in srgb, var(--secondary) 65%, transparent);
    overflow: hidden;
  }
  .compose-bar { padding: 14px 16px; }
}

@media (prefers-reduced-motion: reduce) {
  .compose-sheet, .compose-modal.closing .compose-sheet { animation: none !important; }
  .compose-bar, .compose-scroll { animation: none !important; }
  .compose-bar .iconbtn { animation: none; }
  .burst, .popfield { display: none; }
}


/* ==========================================================================
   The feed's width, on a desktop
   ==========================================================================
   A fixed 392px is right for reading a column of posts and wrong for a 34"
   screen, where there is room to see three photographs at once and no reason
   not to. So the edge is draggable, the width is remembered, and the default is
   exactly what it was.

   The whole thing is one custom property, which is why the map padding, the
   search dock and the floating controls all follow it without being told.
   ========================================================================== */
.sidebar-grip { display: none; }

@media (min-width: 901px) {
  .sidebar-grip {
    display: block;
    position: absolute;
    left: -5px; top: 0; bottom: 0;
    width: 11px;
    cursor: col-resize;
    z-index: 22;
    /* No hit area smaller than this is findable, and none larger is
       accidentally grabbed instead of the feed behind it. */
    background: none;
    border: 0;
  }
  /* A hairline that appears under the pointer, so the edge announces itself
     without drawing a permanent seam down the middle of the app. */
  .sidebar-grip::after {
    content: '';
    position: absolute; left: 4px; top: 0; bottom: 0; width: 3px;
    border-radius: 3px;
    background: var(--grad);
    opacity: 0;
    transition: opacity .16s var(--ease);
  }
  .sidebar-grip:hover::after,
  .sidebar-grip:focus-visible::after,
  body.sidebar-resizing .sidebar-grip::after { opacity: .85; }
  .sidebar-grip:focus-visible { outline: none; }

  /* While dragging, nothing else should be selecting text or catching the
     pointer -- least of all the map, which would pan. */
  body.sidebar-resizing { cursor: col-resize; user-select: none; }
  body.sidebar-resizing .mapwrap { pointer-events: none; }
  /* And the feed must not animate its way to each new width, thirty times a
     second. */
  body.sidebar-resizing .sidebar { transition: none; }
}


/* ==========================================================================
   The tutorial
   A ring over the control being described, and a card that never covers it.
   The card is bubble glass like the rest of the floating chrome -- it is one
   more thing hovering over the map, and dressing it as a modal would make it
   the only rectangle in the app.
   Copy and order live in TOUR in app.js; nothing here knows what a step says.
   ========================================================================== */
.tour { position: fixed; inset: 0; z-index: 140; }

/* Dark enough to lift the card off the map, light enough that the app is still
   legible behind it -- the whole point is to look at the app while reading. */
.tour-veil {
  position: absolute; inset: 0;
  /* No blur. The steps that point at a control dim the app with the ring's own
     spread shadow instead of this element -- a shadow can leave a hole and a
     backdrop-filter cannot -- and a blur here would make those steps and the
     ones without a target look like two different scrims. */
  background: rgba(6, 8, 20, .58);
  animation: tourFade .3s var(--ease-soft);
}
@keyframes tourFade { from { opacity: 0; } to { opacity: 1; } }

/* The highlight. Not a cut-out: a ring drawn over the veil, with the control
   showing through the middle of it. A hole punched in the veil would need the
   veil rebuilt every step and would still be a rectangle underneath. */
.tour-ring {
  position: absolute;
  /* Set per step, from the shape of what is being pointed at -- see tourStep(). */
  border-radius: 50%;
  pointer-events: none;
  /* The control, unveiled. A large spread shadow paints the ring's own circle
     back to full brightness without touching anything outside it. */
  box-shadow:
    0 0 0 2px rgba(255, 255, 255, .85),
    0 0 0 9999px rgba(6, 8, 20, .58),
    0 0 34px 6px color-mix(in srgb, var(--primary) 70%, transparent);
  background: transparent;
  transition: left .38s var(--ease-soft), top .38s var(--ease-soft),
              width .38s var(--ease-soft), height .38s var(--ease-soft);
  animation: tourPulse 2.6s ease-in-out infinite;
}
/* Breathing, not blinking. It marks a place to look at while you read, so it
   has to survive being looked at for twenty seconds. */
@keyframes tourPulse {
  0%, 100% { box-shadow:
    0 0 0 2px rgba(255,255,255,.85),
    0 0 0 9999px rgba(6,8,20,.58),
    0 0 30px 4px color-mix(in srgb, var(--primary) 60%, transparent); }
  50%      { box-shadow:
    0 0 0 2px rgba(255,255,255,.95),
    0 0 0 9999px rgba(6,8,20,.58),
    0 0 46px 12px color-mix(in srgb, var(--primary) 85%, transparent); }
}
/* The veil is painted by the ring's own spread once there is a ring, so the
   flat one underneath would double it. */
.tour-ring:not([hidden]) ~ .tour-veil,
.tour:has(.tour-ring:not([hidden])) .tour-veil { opacity: 0; }

.tour-card {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: min(420px, calc(100vw - 2 * var(--edge, 16px)));
  padding: 22px 22px 18px;
  border-radius: 26px;
  border: 1px solid var(--glass-edge);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: 0 26px 70px -26px rgba(4, 8, 22, .95), var(--glass-lip);
  /* A bubble that came to rest, and has not quite stopped moving. */
  animation: tourRise .46s cubic-bezier(.22, .82, .24, 1);
}
.tour-card.below  { top: var(--tour-top); bottom: auto; }
.tour-card.above  { bottom: var(--tour-bottom); top: auto; }
.tour-card.centred {
  top: 50%;
  /* Beside a full-height target, or the middle of the screen when a step points
     at nothing -- tourStep() sets --tour-left for the first case. */
  left: var(--tour-left, 50%);
  transform: translate(-50%, -50%);
  animation: tourRiseMid .46s cubic-bezier(.22, .82, .24, 1);
}
@keyframes tourRise {
  from { opacity: 0; transform: translateX(-50%) translateY(16px) scale(.9); }
  to   { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
}
@keyframes tourRiseMid {
  from { opacity: 0; transform: translate(-50%, -50%) scale(.86); }
  to   { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}

/* Big, because it is the first thing the eye lands on and it is doing the work
   an illustration would do. It bobs, like the thing it is standing for. */
.tour-art {
  font-size: 40px;
  line-height: 1;
  margin-bottom: 12px;
  animation: tourBob 3.4s ease-in-out infinite;
  filter: drop-shadow(0 8px 18px rgba(4, 8, 22, .6));
}
@keyframes tourBob {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50%      { transform: translateY(-7px) rotate(2deg); }
}

.tour-title {
  margin: 0 0 8px;
  font-size: 21px; font-weight: 800; letter-spacing: -.01em;
  color: var(--ink);
  text-wrap: balance;
}
.tour-body {
  margin: 0 0 18px;
  font-size: 14.5px; line-height: 1.55;
  color: var(--ink-2);
}

.tour-foot { display: flex; align-items: center; gap: 14px; }
.tour-dots { display: flex; gap: 6px; flex: 1; }
.tour-dots i {
  width: 6px; height: 6px; border-radius: 50%;
  background: rgba(255, 255, 255, .22);
  transition: background .24s, width .24s var(--ease);
}
/* The one you are on is a bead rather than a dot -- countable at a glance
   without counting. */
.tour-dots i.on { width: 18px; border-radius: 999px; background: var(--grad); }

.tour-acts { display: flex; align-items: center; gap: 8px; }
.tour-btn {
  height: 38px; padding: 0 18px;
  /* Never two lines. The pill is a fixed height, so a label that wraps is a
     label sticking out of its own button. */
  white-space: nowrap;
  border-radius: var(--r-pill);
  font: inherit; font-size: 14px; font-weight: 700;
  cursor: pointer;
  transition: transform .16s var(--ease), border-color .16s, background .16s;
}
.tour-btn:active { transform: scale(.95); }
.tour-btn.ghost {
  border: 1px solid var(--glass-edge);
  background: transparent;
  color: var(--ink-2);
}
.tour-btn.ghost:hover { border-color: rgba(255,255,255,.34); color: var(--ink); }
.tour-btn.go {
  border: 1px solid color-mix(in srgb, var(--primary) 62%, white 12%);
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,.42), rgba(255,255,255,.08) 54%),
    var(--grad);
  color: #fff;
  box-shadow:
    inset 0 1px 0 rgba(255,255,255,.5),
    0 8px 22px -10px color-mix(in srgb, var(--primary) 90%, transparent);
}

/* Out of the way of the buttons, and quiet: leaving is always available and
   never the thing being offered. */
.tour-skip {
  position: absolute; top: 14px; right: 16px;
  border: 0; background: none; padding: 4px;
  font: inherit; font-size: 12.5px; font-weight: 600;
  color: var(--ink-3); cursor: pointer;
}
.tour-skip:hover { color: var(--ink); }

/* Nothing behind the tutorial is pressable while it is up -- a half-followed
   tutorial over an app you have started changing is worse than no tutorial. */
body.tour-open .sidebar,
body.tour-open .mapctl,
body.tour-open .fab,
body.tour-open .searchdock { pointer-events: none; }

@media (max-width: 900px) {
  .tour-card { padding: 20px 18px 16px; border-radius: 24px; }
  .tour-title { font-size: 19px; }
  .tour-art { font-size: 34px; }
}

@media (prefers-reduced-motion: reduce) {
  .tour-ring { animation: none; transition: none; }
  .tour-art { animation: none; }
  .tour-card { animation: none; }
}

/* ==========================================================================
   Messages

   Its own surface rather than a corner of the account menu: a conversation
   needs room to be read and somewhere to write, and private things put in a
   dropdown that closes when you look away is how they get sent to the wrong
   person. Two columns on a desktop; on a phone the list and the conversation
   take turns, which is what .msg-open-thread switches between.

   Made of the same glass as the rest of the app rather than a flat panel --
   the tour card's tokens, so there is one description of what glass is here.
   ========================================================================== */
.msgwrap {
  position: fixed;
  inset: 0;
  z-index: 140;                 /* over the drawer (26) and the flying Bubl (130) */
  display: grid;
  place-items: center;
  padding: 20px;
  background: rgba(6, 10, 24, .56);
  backdrop-filter: blur(7px) saturate(115%);
  -webkit-backdrop-filter: blur(7px) saturate(115%);
  animation: msgIn .2s var(--ease-soft);
}
@keyframes msgIn { from { opacity: 0; } }
.msgcard {
  width: min(940px, 100%);
  height: min(680px, 100%);
  display: flex;
  flex-direction: column;
  border-radius: 24px;
  overflow: hidden;
  border: 1px solid var(--glass-edge);
  background: var(--glass-sheen), var(--glass-hue), var(--glass-floor);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: 0 30px 80px -28px rgba(4, 8, 22, .9), var(--glass-lip);
}

/* Close first, on the left, because the top-left is always the way out. */
.msgtop {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--line);
  flex: none;
}
.msgclose { flex: none; }
.msgclose svg { width: 19px; height: 19px; fill: none; stroke: currentColor;
  stroke-width: 2; stroke-linecap: round; }
.msgclose:hover { background: rgba(255,255,255,.08); color: var(--ink); }
.msgtitle { margin: 0; font-size: 15px; font-weight: 700; color: var(--ink);
  letter-spacing: -.01em; }

.msgcols { display: flex; flex: 1; min-height: 0; }
.msglistwrap {
  width: 300px; flex: none; min-height: 0; overflow-y: auto;
  border-right: 1px solid var(--line);
}
.msglist { display: flex; flex-direction: column; }

.conv {
  display: flex; align-items: center; gap: 10px;
  padding: 11px 13px; border: 0; background: none; cursor: pointer;
  text-align: left; color: var(--ink); width: 100%;
  border-bottom: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
  transition: background .14s;
}
.conv:hover { background: rgba(255,255,255,.055); }
.conv.current { background: color-mix(in srgb, var(--primary) 16%, transparent); }
.conv-av { width: 34px; height: 34px; border-radius: 50%; flex: none;
  display: grid; place-items: center; font-size: 15px; font-weight: 700; color: #fff; }
.conv-text { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; }
.conv-top { display: flex; align-items: baseline; gap: 8px; }
.conv-who { font-weight: 650; font-size: 13.5px; flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv-when { font-size: 11.5px; color: var(--ink-3); flex: none; }
.conv-last { font-size: 12.5px; color: var(--ink-3);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.conv.unread .conv-who, .conv.unread .conv-last { color: var(--ink); font-weight: 650; }
.conv-dot {
  flex: none; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px;
  background: var(--primary); color: #fff; font-size: 11px; font-weight: 700;
  display: grid; place-items: center;
}

.msgthread { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.msgempty, .msgempty-list {
  margin: auto; padding: 26px; text-align: center;
  color: var(--ink-3); font-size: 13px; max-width: 32ch; line-height: 1.55;
}
.msgconv { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.msghead {
  display: flex; align-items: center; gap: 8px; flex: none;
  padding: 9px 13px; border-bottom: 1px solid var(--line);
}
.msgback { display: none; }
.msgwho {
  flex: 1; text-align: left; background: none; border: 0; cursor: pointer;
  font-weight: 650; font-size: 14px; color: var(--ink); padding: 4px 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.msgwho:hover { text-decoration: underline; }
.msgblock {
  flex: none; background: none; border: 1px solid var(--line); cursor: pointer;
  border-radius: var(--r-pill); padding: 5px 12px; font-size: 12px; color: var(--ink-3);
  transition: color .14s, border-color .14s;
}
.msgblock:hover { color: var(--ink); border-color: var(--ink-3); }

.msgbody {
  flex: 1; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column; gap: 6px; padding: 16px 14px 20px;
  overflow-wrap: anywhere;
}
.msgspin { margin: auto; color: var(--ink-3); font-size: 13px; }
.msg {
  max-width: 76%; align-self: flex-start;
  background: rgba(255, 255, 255, .09);
  border: 1px solid rgba(255, 255, 255, .07);
  border-radius: 15px 15px 15px 5px;
  padding: 8px 11px; font-size: 13.5px; line-height: 1.45; color: var(--ink);
  display: flex; align-items: flex-end; gap: 9px;
}
.msg.mine {
  align-self: flex-end;
  background: color-mix(in srgb, var(--primary) 80%, transparent);
  border-color: color-mix(in srgb, var(--primary) 92%, white 8%);
  color: #fff;
  border-radius: 15px 15px 5px 15px;
}
.msg-text { white-space: pre-wrap; min-width: 0; }
.msg-when { flex: none; font-size: 10.5px; opacity: .6; }

.msgform {
  display: flex; align-items: flex-end; gap: 8px; flex: none;
  padding: 10px 12px; border-top: 1px solid var(--line);
}
.msgform textarea {
  flex: 1; resize: none; border-radius: 14px; padding: 9px 12px;
  border: 1px solid rgba(255, 255, 255, .18);
  background: rgba(12, 16, 32, .34);
  color: var(--ink); font: inherit; font-size: 13.5px; line-height: 1.4;
  max-height: 140px; outline: none;
}
.msgform textarea:focus {
  border-color: color-mix(in srgb, var(--primary) 78%, white 22%);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 30%, transparent);
}
.msgsend {
  flex: none; width: 38px; height: 38px; border-radius: 50%; border: 0;
  background: var(--primary); color: #fff; cursor: pointer;
  display: grid; place-items: center;
}
.msgsend svg { width: 17px; height: 17px; fill: none; stroke: currentColor;
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.msgsend:disabled { opacity: .5; cursor: default; }
.msgnote {
  margin: 0; padding: 12px 14px; border-top: 1px solid var(--line);
  font-size: 12.5px; color: var(--ink-3); text-align: center;
}

/* The Message button sits beside Follow and reads as its quieter neighbour. */
.msgbtn {
  border-radius: var(--r-pill); padding: 7px 14px; font-size: 13px; font-weight: 600;
  border: 1px solid var(--line); background: none; color: var(--ink);
  cursor: pointer; transition: border-color .14s;
}
.msgbtn:hover { border-color: var(--ink-3); }

/* The count beside "Messages" in the account menu. */
.menucount {
  margin-left: auto; min-width: 18px; height: 18px; padding: 0 5px;
  border-radius: 9px; background: var(--primary); color: #fff;
  font-size: 11px; font-weight: 700; display: grid; place-items: center;
}

/* A phone has room for one of the two columns at a time.
   The insets are spent here, not on the wrapper: the card fills the screen at
   this width, so the wrapper has no padding left to carry them and the header
   ended up under the status bar with the only way out inside it. */
@media (max-width: 700px) {
  .msgwrap { padding: 0; }
  .msgcard { width: 100%; height: 100%; border-radius: 0; border: 0; }
  .msgtop {
    padding-top: max(10px, var(--safe-t, 0px), env(safe-area-inset-top, 0px));
    padding-left: max(10px, env(safe-area-inset-left, 0px));
  }
  .msgcols { position: relative; }
  .msglistwrap { width: 100%; border-right: 0; }
  .msgthread { display: none; }
  .msgform, .msgnote {
    padding-bottom: max(10px, var(--safe-b, 0px), env(safe-area-inset-bottom, 0px));
  }
  .msgback { display: grid; place-items: center; flex: none;
    width: 34px; height: 34px; border-radius: var(--r-sm); border: 0;
    background: none; color: var(--ink); cursor: pointer; }
  .msgback svg { width: 20px; height: 20px; fill: none; stroke: currentColor;
    stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
  body.msg-open-thread .msglistwrap { display: none; }
  body.msg-open-thread .msgthread { display: flex; width: 100%; }
}

/* An avatar with a photograph in it. The coloured disc stays underneath: it is
   what shows while the picture loads, and again the moment it is removed, so
   the space an avatar occupies is never briefly empty. */
.card-av.has-pic, .conv-av.has-pic { position: relative; overflow: hidden; }
.card-av.has-pic img, .conv-av.has-pic img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

/* The way back in, under the sign-in form. A link rather than a button: it is
   a smaller thing to do than either of the two buttons around it, and looking
   like a third equal choice would say otherwise. */
.authforgot {
  align-self: center;
  margin-top: 2px;
  font-size: 13.5px;
  color: var(--ink-2);
}
.authforgot:hover { color: var(--ink); }

/* --- where a Bubl lands ---------------------------------------------------- */
/* This is the claim the Bubl makes about itself -- "I was here" against "this
   is roughly over there" -- and the map draws the two differently. It used to
   be a line of small grey text in the header that was also, unannounced, a
   button. Now it is two options you can see, with the answer written under
   each so neither has to be guessed at. */
/* In the accessibility tree and out of the layout.
 *
 * Not `display: none` and not `visibility: hidden` -- both of those take the
 * element out of the tree as well, which is the opposite of the point. The
 * clip-path leaves it rendered, one pixel, in the flow's own coordinate space,
 * so a screen reader still reads it and nothing else can see it. */
.a11y-only {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%);
  overflow: hidden; white-space: nowrap;
}

.compose-title {
  margin: 0; min-width: 0; text-align: center;
  font-size: 14px; font-weight: 650; color: var(--ink);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.wherefield { gap: 8px; }
.wherelabel { font-size: 13px; font-weight: 600; color: var(--ink-2); }

.segmented.whereseg {
  display: grid; grid-template-columns: 1fr 1fr; gap: 6px;
  padding: 5px;
  border-radius: var(--r-lg);
}
.whereseg button {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas: "icon title" "icon sub";
  align-items: center;
  gap: 1px 9px;
  padding: 10px 12px;
  min-height: 56px;
  border: 1px solid transparent; border-radius: var(--r-md);
  background: none; color: var(--ink-3);
  text-align: left; cursor: pointer;
  transition: background .16s, color .16s, border-color .16s;
}
.whereseg button svg {
  grid-area: icon; width: 19px; height: 19px;
  fill: none; stroke: currentColor; stroke-width: 1.7;
  stroke-linecap: round; stroke-linejoin: round;
}
.whereseg button b {
  grid-area: title; font-size: 13.5px; font-weight: 650; color: var(--ink-2);
  transition: color .16s;
}
.whereseg button span {
  grid-area: sub; font-size: 11.5px; line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.whereseg button:not(:disabled):hover { background: var(--panel-2); }
/* The chosen one is filled rather than merely tinted: at a glance the question
   should already be answered. */
.whereseg button.on {
  background: color-mix(in srgb, var(--primary) 20%, transparent);
  border-color: color-mix(in srgb, var(--primary) 55%, transparent);
  color: var(--ink-2);
}
.whereseg button.on b { color: var(--ink); }
.whereseg button:disabled { opacity: .55; cursor: default; }
.whereseg button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }

/* The two claims keep their colours here too, so the control agrees with the
   rim on the map and with the sheet it sits in. */
.compose-modal.to-map .whereseg button.on {
  background: color-mix(in srgb, var(--rim-map) 22%, transparent);
  border-color: color-mix(in srgb, var(--rim-map) 58%, transparent);
}

/* --- who it is for -------------------------------------------------------- */
/* The same control as the row above it, which is the point: these are the two
   claims a Bubl makes about itself -- where it is and who it is for -- and
   they should not look like two different kinds of question.
   Three across on a desktop and stacked on a phone, because three of these
   boxes side by side on 393px leaves no room for the subtitle that says what
   each one means. */
.whofield { gap: 8px; }
.segmented.whoseg {
  display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px;
  padding: 5px;
  border-radius: var(--r-lg);
}
.whoseg button {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-areas: "icon title" "icon sub";
  align-items: center;
  gap: 1px 9px;
  padding: 10px 12px;
  min-height: 56px;
  border: 1px solid transparent; border-radius: var(--r-md);
  background: none; color: var(--ink-3);
  text-align: left; cursor: pointer;
  transition: background .16s, color .16s, border-color .16s;
}
.whoseg button svg {
  grid-area: icon; width: 19px; height: 19px;
  fill: none; stroke: currentColor; stroke-width: 1.7;
  stroke-linecap: round; stroke-linejoin: round;
}
.whoseg button b {
  grid-area: title; font-size: 13.5px; font-weight: 650; color: var(--ink-2);
  transition: color .16s;
}
.whoseg button span {
  grid-area: sub; font-size: 11.5px; line-height: 1.35;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.whoseg button:not(:disabled):hover { background: var(--panel-2); }
.whoseg button.on {
  background: color-mix(in srgb, var(--primary) 20%, transparent);
  border-color: color-mix(in srgb, var(--primary) 55%, transparent);
  color: var(--ink-2);
}
.whoseg button.on b { color: var(--ink); }
.whoseg button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.whogroup { margin-top: 2px; }

@media (max-width: 700px) {
  .segmented.whoseg { grid-template-columns: 1fr; }
  .whoseg button { min-height: 48px; }
}

/* --- more than one picture -------------------------------------------------- */
/* A strip that scrolls and snaps, rather than a carousel driven from
   JavaScript: the browser already does this, it does it with the platform's own
   inertia, and it keeps working with a keyboard and with the scrollbar. */
.card-gallery { position: relative; }
.gallery-rail {
  /* Flex rather than grid, and no gap.
   *
   * Both of those were in the first version and both were wrong, in the same
   * way: they made the distance between one picture and the next different
   * from the width of the frame it lands in. With a 6px gap the snap points
   * sit at i x (W + 6) while the frame is W wide, so anything that lands even
   * slightly off centre shows a strip of the neighbour down one edge -- which
   * is exactly what it did on a phone. Grid tracks plus momentum scrolling
   * made it worse: Safari resolves a snap on a grid container less reliably
   * than on a flex one, and a fling could stop between two positions and stay
   * there.
   *
   * With no gap the pitch is exactly W, every snap position is an integer
   * multiple of the frame, and a picture either fills it or the maths is
   * visibly wrong. The pictures do not need a gap between them: only one is
   * ever on screen. */
  display: flex;
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  border-radius: var(--r-lg);
  /* The rail is what scrolls, so it is the thing that must not also scroll the
     page sideways when a finger overshoots the end. */
  overscroll-behavior-x: contain;
  /* Both axes, deliberately. This said pan-x, which is the declaration for
     "this element pans horizontally and nothing else pans at all" -- so a
     finger that came down on a photograph could no longer scroll the feed
     past it, which is most of the feed. The horizontal gesture is the rail's
     and the vertical one belongs to the list underneath; naming both is what
     lets the browser route each to the right place.
     The drawer is kept off this strip in app.js instead, at the point where it
     arms, which is where the decision actually was. */
  touch-action: pan-x pan-y;
}
.gallery-rail::-webkit-scrollbar { display: none; }
.gallery-rail > .card-media {
  flex: 0 0 100%;
  min-width: 0;
  margin: 0;
  /* start, not center: the target is the frame's own left edge, which is an
     exact multiple of the width. Centring asks the browser to halve the
     difference between two boxes, and on a phone at 3x that lands on a third
     of a pixel and rounds whichever way it likes. */
  scroll-snap-align: start;
  /* One picture per swipe. Without this a fast fling crosses several and stops
     wherever momentum runs out, which is the other way this ended up resting
     between two of them. */
  scroll-snap-stop: always;
}

.gallery-dots {
  position: absolute; left: 0; right: 0; bottom: 10px;
  display: flex; justify-content: center; gap: 5px;
  pointer-events: none;
}
.gallery-dots i {
  width: 6px; height: 6px; border-radius: 50%;
  background: rgba(255,255,255,.45);
  box-shadow: 0 1px 2px rgba(0,0,0,.5);
  transition: background .18s, transform .18s;
}
.gallery-dots i.on { background: #fff; transform: scale(1.25); }

.gallery-count {
  position: absolute; top: 10px; right: 10px;
  padding: 2px 8px; border-radius: var(--r-pill);
  background: rgba(8,12,22,.62); color: #fff;
  font-size: 11.5px; font-weight: 650;
  font-variant-numeric: tabular-nums;
  backdrop-filter: blur(6px);
  pointer-events: none;
}
/* The per-face badge already says which one this is, so the two would repeat
   each other. The count in the corner is the one that stays still. */
.card-gallery .badge-n { display: none; }

/* --- the composer's strip --------------------------------------------------- */
.mediatray { display: flex; flex-direction: column; gap: 10px; }
.mediatray.busy { opacity: .55; pointer-events: none; }
.mediaitem {
  display: grid;
  grid-template-columns: 84px minmax(0, 1fr);
  grid-template-areas: "face do" "face alt";
  align-items: center;
  gap: 6px 10px;
  margin: 0;
  padding: 8px;
  border: 1px solid var(--line); border-radius: var(--r-lg);
  background: var(--panel-2);
  /* Nothing inside is allowed to make the row wider than the column it sits
     in. Without this the four buttons set the width and the card grew past the
     sheet, taking the delete button outside its own rounded corner. */
  min-width: 0;
  overflow: hidden;
}
.mediaitem-face {
  grid-area: face; position: relative;
  width: 84px; height: 64px; border-radius: var(--r-md);
  overflow: hidden; background: var(--panel);
}
.mediaitem-face img, .mediaitem-face video {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: transform .2s var(--ease);
}
/* Which one the map will draw. The order is the author's, so the consequence of
   it should be visible while they are choosing. */
.mediaitem-lead {
  position: absolute; left: 4px; bottom: 4px;
  padding: 1px 6px; border-radius: var(--r-pill);
  background: rgba(8,12,22,.7); color: #fff;
  font-size: 10px; font-weight: 700; letter-spacing: .04em;
}
.mediaitem-do {
  grid-area: do; display: flex; gap: 4px; min-width: 0;
  /* They shrink before they overflow. On the narrowest phone four 30px
     buttons and their gaps are wider than the space beside an 84px
     thumbnail, and something has to give -- better a slightly smaller
     target than one that is off the edge and cannot be pressed at all. */
  flex-wrap: nowrap;
}
.mediaitem-do button {
  width: 30px; height: 30px;
  flex: 0 1 30px; min-width: 26px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--panel); color: var(--ink-2);
  font-size: 15px; line-height: 1; cursor: pointer;
  transition: background .14s, color .14s, border-color .14s;
}
.mediaitem-do button:hover:not(:disabled) { color: var(--ink); border-color: var(--ink-3); }
.mediaitem-do button:disabled { opacity: .35; cursor: default; }
.mediaitem-do button.del { margin-left: auto; }
.mediaitem-do button.del:hover { color: #F43F5E; border-color: #F43F5E; }

.mediaalt {
  grid-area: alt; min-width: 0;
  padding: 7px 10px; font-size: 12.5px;
  border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--panel); color: var(--ink);
}
.mediaalt::placeholder { color: var(--ink-3); }

/* Walking a Bubl's pictures full-screen. Only drawn when there is more than
   one: two arrows over a single photograph promise something that is not
   there. */
.lightbox-nav {
  position: fixed; top: 50%; transform: translateY(-50%);
  width: 44px; height: 44px; display: grid; place-items: center;
  border: 1px solid rgba(255,255,255,.22); border-radius: 50%;
  background: rgba(10,14,26,.55); color: #fff;
  backdrop-filter: blur(8px); cursor: pointer; z-index: 2;
  transition: background .16s, transform .16s;
}
.lightbox-nav.prev { left: max(12px, env(safe-area-inset-left, 0px)); }
.lightbox-nav.next { right: max(12px, env(safe-area-inset-right, 0px)); }
.lightbox-nav:hover { background: rgba(20,28,48,.8); }
.lightbox-nav:active { transform: translateY(-50%) scale(.93); }
.lightbox-nav svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2; }
.lightbox-count {
  position: fixed; top: max(16px, calc(env(safe-area-inset-top, 0px) + 12px));
  left: 50%; transform: translateX(-50%);
  padding: 3px 11px; border-radius: var(--r-pill);
  background: rgba(10,14,26,.6); color: #fff;
  font-size: 12.5px; font-weight: 650; font-variant-numeric: tabular-nums;
  backdrop-filter: blur(8px); z-index: 2;
}

/* The two ways out of a Bubl's page: back to the list, or over to the map.
   Back is the quieter of the two -- it is where most people are going -- so it
   keeps the plain treatment and the other one is a button. */
.detail-top {
  display: flex; align-items: center; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
}
.btn.gotobubl {
  display: inline-flex; align-items: center; gap: 6px;
  flex: none;
}
.btn.gotobubl svg {
  width: 15px; height: 15px; flex: none;
  fill: none; stroke: currentColor; stroke-width: 1.8;
  stroke-linecap: round; stroke-linejoin: round;
}

/* Narrow phones: the thumbnail keeps its size and everything else stacks under
   it rather than fighting for the same line. */
@media (max-width: 430px) {
  .mediaitem {
    grid-template-columns: 72px minmax(0, 1fr);
    gap: 6px 8px;
    padding: 7px;
  }
  .mediaitem-face { width: 72px; height: 56px; }
  .mediaitem-do button { width: 28px; height: 28px; min-width: 24px; font-size: 14px; }
  .mediaalt { padding: 6px 8px; font-size: 12px; }
}

/* A Bubl that has been changed says so. Quiet -- it is a footnote on the line
   that already says when it was written, not a second headline. */
.card-edited { color: var(--ink-3); font-style: italic; }

/* Editing hides what an edit may not change, and the sheet is shorter for it.
   Nothing here moves; the rows are simply not in the document. */
.compose-sheet.is-editing .privacy { display: none; }

/* --- what the drawer costs to slide ---------------------------------------- */
/* Sliding the drawer up reveals more of the list, and the browser was laying
   out and painting every card in it on every frame of the travel -- all
   twenty-five, whether or not they had come into view yet. Measured with the
   list at a full page: 0.6ms a frame with one card, 2.0ms with twenty-five, on
   a desktop. That is the shape of the complaint: it is not the drag that is
   expensive, it is the list arriving.

   content-visibility lets the browser skip the work for cards that are not on
   screen. contain-intrinsic-size gives it something to reserve in their place,
   and `auto` there means it remembers what each card actually measured once it
   has been seen, so the scrollbar stops guessing after the first pass. */
.feed .card {
  content-visibility: auto;
  contain-intrinsic-size: auto 260px;
}
/* The Bubl's own page is one card and a list of replies. Skipping work there
   buys nothing and the estimate would fight the reply box for position. */
.feed .detail-card { content-visibility: visible; }

/* Nothing on a card animates while the drawer is moving. A transition that
   fires mid-travel is a second animation competing with the one the finger is
   driving, and the hover states cannot be reached during a drag anyway. */
.sidebar.dragging .card,
.sidebar.dragging .card-media img,
.sidebar.dragging .card-media video { transition: none !important; }
.sidebar.dragging .card:hover { border-color: rgba(255, 255, 255, .11); box-shadow: none; }

/* Promoted only while it is actually moving. A permanent layer costs memory on
   exactly the devices this is meant to help. */
.sidebar.dragging { will-change: transform; }

/* ==========================================================================
   An event, in the panel
   ========================================================================== */
/* A flyer, which is a different kind of object from a Bubl and should not be
   mistaken for one. A Bubl is glass and round because its position is an area;
   this is a card with an address on it. */
.evcard {
  padding: 14px;
  border-radius: var(--r-lg);
  border: 1px solid var(--line);
  background: var(--panel-2);
}
.evcard.is-off .ev-title,
.evcard.is-off .ev-when { opacity: .55; }
.evcard.is-off .ev-title { text-decoration: line-through; }

/* Said before anything else, because it changes what the rest means. */
.ev-off {
  margin: 0 0 10px; padding: 7px 10px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, #FF8A8A 16%, transparent);
  border: 1px solid color-mix(in srgb, #FF8A8A 40%, transparent);
  color: #FF8A8A; font-size: 12.5px; font-weight: 700;
}

.ev-flyer { margin: -14px -14px 12px; border-radius: var(--r-lg) var(--r-lg) 0 0; overflow: hidden; }
.ev-flyer img, .ev-flyer video { display: block; width: 100%; height: auto; }

.ev-title { margin: 0 0 10px; font-size: 19px; line-height: 1.25; }

.ev-when, .ev-where {
  display: grid; grid-template-columns: 18px minmax(0, 1fr);
  gap: 9px; align-items: start;
  margin: 0 0 8px; font-size: 13.5px; line-height: 1.45;
}
.ev-when svg, .ev-where svg {
  width: 16px; height: 16px; margin-top: 2px;
  fill: none; stroke: var(--ink-3); stroke-width: 1.7;
  stroke-linecap: round; stroke-linejoin: round;
}
/* The zone, named only when it is not the reader's own -- see eventWhen. A
   time with no zone on it is read as local by everybody, which is exactly the
   mistake this is here to stop. */
.ev-tz {
  margin-left: 6px; padding: 1px 6px;
  border-radius: var(--r-pill); border: 1px solid var(--line);
  font-size: 11px; color: var(--ink-3); white-space: nowrap;
}

/* The one line on this map that says a position is precise. Quiet, because it
   is a statement of fact rather than a warning -- but always present, because
   every other circle here means the opposite and the difference matters. */
.ev-exact {
  margin: 10px 0 0; padding: 8px 10px;
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--primary) 9%, transparent);
  border: 1px solid var(--line);
  font-size: 12px; color: var(--ink-2);
}

.ev-body { margin: 12px 0 0; font-size: 14px; line-height: 1.55; white-space: pre-wrap; }
.ev-goto { margin-top: 12px; }
.ev-by { margin: 12px 0 0; font-size: 12.5px; color: var(--ink-3); }
.ev-by .card-who { font: inherit; color: var(--ink-2); }

/* ==========================================================================
   A business, in the panel
   ========================================================================== */
.bizcard {
  padding: 14px; margin-bottom: 14px;
  border-radius: var(--r-lg);
  border: 1px solid var(--line); background: var(--panel-2);
}
.biz-photo { margin: -14px -14px 12px; border-radius: var(--r-lg) var(--r-lg) 0 0; overflow: hidden; }
.biz-photo img { display: block; width: 100%; height: auto; }
.biz-name { margin: 0 0 8px; font-size: 19px; line-height: 1.25; }
.biz-cat { margin: 0 0 12px; display: flex; gap: 6px; flex-wrap: wrap; }
.biz-cat .chip {
  padding: 2px 9px; border-radius: var(--r-pill);
  border: 1px solid var(--line); background: var(--panel-3);
  font-size: 11.5px; font-weight: 700; color: var(--ink-2);
}
.biz-cat .chip.claimed {
  border-color: color-mix(in srgb, var(--primary) 46%, transparent);
  color: color-mix(in srgb, var(--primary) 40%, white);
}

/* --- the number, and the shape behind it --------------------------------- */
.biz-rating { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 10px; }
.biz-avg { font-size: 26px; font-weight: 800; line-height: 1; }
.biz-count { font-size: 12.5px; color: var(--ink-3); }
.biz-none { font-size: 13.5px; color: var(--ink-3); }

/* Stars are drawn rather than typed: a glyph font would be one more thing to
   load for five shapes, and the half state has no character at all. */
.stars { display: inline-flex; gap: 2px; }
.stars .star {
  width: 14px; height: 14px; display: block;
  background: var(--line-2);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
}
.stars .star.full { background: #F5A524; }
/* Half: the same star, filled from the left only. */
.stars .star.half { background: linear-gradient(90deg, #F5A524 50%, var(--line-2) 50%); }
.stars.sm .star { width: 11px; height: 11px; }

.bd { display: grid; gap: 3px; margin: 0 0 12px; }
.bd-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) 34px; gap: 7px; align-items: center; }
.bd-n { font-size: 11px; color: var(--ink-3); text-align: right; }
.bd-track { height: 6px; border-radius: 3px; background: var(--panel-3); overflow: hidden; }
.bd-fill { display: block; height: 100%; background: #F5A524; border-radius: 3px; }
.bd-c { font-size: 11px; color: var(--ink-3); font-variant-numeric: tabular-nums; }

.biz-where {
  display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 9px; align-items: start;
  margin: 0 0 8px; font-size: 13.5px; line-height: 1.45;
}
.biz-where svg {
  width: 16px; height: 16px; margin-top: 2px; fill: none;
  stroke: var(--ink-3); stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}
.biz-meta { margin: 0 0 6px; font-size: 13px; color: var(--ink-2); }
.biz-meta b { color: var(--ink-3); font-weight: 700; margin-right: 6px; }
.biz-desc { margin: 10px 0 0; font-size: 14px; line-height: 1.55; white-space: pre-wrap; }
.biz-goto { margin-top: 12px; }

/* ODbL attribution. A licence condition, so it is quiet but never absent. */
.biz-src { margin: 12px 0 0; font-size: 11px; color: var(--ink-3); }
.biz-src a { color: inherit; }

.biz-ownnote {
  margin: 0 0 14px; padding: 10px 12px;
  border-radius: var(--r-sm); border: 1px solid var(--line);
  background: color-mix(in srgb, var(--primary) 8%, transparent);
  font-size: 12.5px; color: var(--ink-2); line-height: 1.5;
}

/* --- writing one --------------------------------------------------------- */
.revform {
  padding: 14px; margin-bottom: 14px;
  border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel-2);
}
.revform h4 { margin: 0 0 10px; font-size: 14px; }
.revform textarea {
  width: 100%; margin: 10px 0; padding: 10px;
  border-radius: var(--r-sm); border: 1px solid var(--line);
  background: var(--panel-3); color: var(--ink);
  font: inherit; font-size: 14px; resize: vertical;
}
.starpick { display: flex; gap: 6px; }
.pickstar {
  width: 30px; height: 30px; padding: 0; border: 0; background: none; cursor: pointer;
  position: relative;
}
.pickstar::after {
  content: ''; position: absolute; inset: 3px;
  background: var(--line-2);
  clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%);
  transition: background .12s, transform .12s var(--ease);
}
.pickstar:hover::after { transform: scale(1.14); }
.pickstar.on::after { background: #F5A524; }

/* --- reading them -------------------------------------------------------- */
.revhead { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
.revhead h4 { margin: 0; font-size: 14px; }
.revsort { display: flex; gap: 4px; }
.revsortbtn {
  padding: 4px 9px; border-radius: var(--r-pill);
  border: 1px solid var(--line); background: none; color: var(--ink-3);
  font: inherit; font-size: 11.5px; font-weight: 600; cursor: pointer;
}
.revsortbtn.on { color: var(--ink); border-color: var(--line-2); background: var(--panel-3); }

.rev {
  padding: 12px; margin-bottom: 10px;
  border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel-2);
}
.rev-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; align-items: center; }
.rev-head .card-av { width: 28px; height: 28px; font-size: 11px; }
.rev-who { font-size: 12.5px; line-height: 1.35; min-width: 0; }
.rev-who .when { font-size: 11.5px; color: var(--ink-3); white-space: nowrap; }
.rev-body { margin: 9px 0 0; font-size: 13.5px; line-height: 1.5; white-space: pre-wrap; }
.rev-media { margin-top: 9px; border-radius: var(--r-sm); overflow: hidden; }
.rev-media img { display: block; width: 100%; height: auto; }

/* The manager's answer, indented under the thing it answers so it reads as a
   reply rather than as a second review. */
.rev-reply {
  margin: 10px 0 0 10px; padding: 9px 11px;
  border-left: 2px solid color-mix(in srgb, var(--primary) 40%, transparent);
  border-radius: 0 var(--r-sm) var(--r-sm) 0;
  background: color-mix(in srgb, var(--primary) 7%, transparent);
  font-size: 13px; line-height: 1.5;
}
.rev-reply p { margin: 0; }
.rev-reply-by { font-size: 11.5px; font-weight: 700; color: var(--ink-3); margin-bottom: 3px !important; }

.rev-foot { display: flex; gap: 12px; margin-top: 9px; }
.rev-foot:empty { display: none; }
.rev-foot .linkbtn { font-size: 12px; }
.rev-foot .linkbtn.quiet { color: var(--ink-3); }

.feedstate-sm { padding: 14px; font-size: 13px; color: var(--ink-3); text-align: center; }

/* ==========================================================================
   Groups, in the panel
   ========================================================================== */
.grp-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; }
.grp-head h3 { margin: 0; font-size: 18px; }
.btn.sm { height: 30px; padding: 0 12px; font-size: 12.5px; }

.grp-search { margin-bottom: 14px; }
.grp-search input {
  width: 100%; height: 38px; padding: 0 12px;
  border-radius: var(--r-pill); border: 1px solid var(--line);
  background: var(--panel-3); color: var(--ink); font: inherit; font-size: 13.5px;
}

.grpform {
  padding: 14px; margin-bottom: 14px;
  border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel-2);
}
.grpform h4 { margin: 0 0 10px; font-size: 14px; }
.grpform input[type="text"], .grpform textarea {
  width: 100%; margin-bottom: 8px; padding: 9px 10px;
  border-radius: var(--r-sm); border: 1px solid var(--line);
  background: var(--panel-3); color: var(--ink); font: inherit; font-size: 13.5px;
  resize: vertical;
}
.grp-policy { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; font-size: 13px; color: var(--ink-2); }

.grp-sect { margin: 16px 0 8px; font-size: 12px; font-weight: 800;
            letter-spacing: .04em; text-transform: uppercase; color: var(--ink-3); }

.grp {
  display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center;
  padding: 10px 12px; margin-bottom: 8px;
  border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel-2);
}
.grp-open {
  display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: center;
  border: 0; background: none; color: inherit; font: inherit; text-align: left;
  cursor: pointer; padding: 0; min-width: 0;
}
.grp-emoji {
  width: 34px; height: 34px; display: grid; place-items: center;
  border-radius: var(--r-sm); background: var(--panel-3); font-size: 17px;
}
.grp-emoji.big { width: 46px; height: 46px; font-size: 24px; }
.grp-text { display: grid; gap: 2px; min-width: 0; }
.grp-text b { font-size: 14px; }
.grp-meta { font-size: 11.5px; color: var(--ink-3); }
.grp-blurb { font-size: 12.5px; color: var(--ink-2); overflow: hidden;
             text-overflow: ellipsis; white-space: nowrap; }
.grp-blurb.big { white-space: normal; margin: 10px 0 0; font-size: 13.5px; line-height: 1.5; }

/* The same shape as the friend button, because it is the same kind of
   question: one word from the server, one label at a time. */
.grpbtn {
  min-width: 78px; height: 32px; padding: 0 14px;
  border-radius: var(--r-pill); border: 1px solid transparent;
  background: var(--grad); color: #fff;
  font: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer;
  transition: background .16s, border-color .16s, transform .16s var(--ease);
}
.grpbtn:hover { transform: translateY(-1px); }
.grpbtn.on { background: none; border-color: var(--line); color: var(--ink-2); }
.grpbtn.on:hover { border-color: #FF8A8A; color: #FF8A8A; }
.grpbtn.sm { min-width: 0; height: 26px; padding: 0 10px; font-size: 11.5px; }
.grpbtn.quiet { background: none; border-color: var(--line); color: var(--ink-2); font-weight: 600; }
.grpbtn:disabled { opacity: .6; cursor: default; transform: none; }
.grp-own { font-size: 12px; color: var(--ink-3); }

.grpcard {
  padding: 14px; margin-bottom: 14px;
  border-radius: var(--r-lg); border: 1px solid var(--line); background: var(--panel-2);
}
.grpcard-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center; }
.grpcard-head h3 { margin: 0 0 2px; font-size: 18px; }
.grpcard-head p { margin: 0; }

.grp-members { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.grp-member {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 9px; align-items: center;
  padding: 7px 10px; border-radius: var(--r-sm); background: var(--panel-2);
}
.grp-member .card-av { width: 26px; height: 26px; font-size: 11px; }
.grp-role {
  font-size: 10.5px; font-weight: 800; letter-spacing: .04em; text-transform: uppercase;
  color: var(--ink-3);
}
.grp-decide { display: flex; gap: 6px; }
