:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f7;
  --bg-tertiary: #e3e3e6;
  /* The pressed fill for the dev card at both sizes: one step darker than
     the hover fill (zinc-50) on the same ramp, so a press reads as more of
     the same gesture rather than as a new colour. */
  --dev-press: #eaeaea;
  --bg-code: #f5f5f7;
  --bg-code-header: #e3e3e6;
  --bg-card: rgba(10, 124, 255, 0.06);
  --bg-card-alt: rgba(16, 185, 129, 0.04);
  --bg-input: #f5f5f7;
  --border: #d1d1d6;
  --border-light: #e3e3e6;
  --text-primary: #0a0a0a;
  --text-secondary: #3a3a3c;
  /* #68686c, not the #6c6c70 eyedropped with the rest (QA 2026-09-24 Q20):
     the same four-step correction tailwind.config.js made to zinc-500, and for
     the same reason. #6c6c70 is 4.34:1 on the zinc-100 track the theme
     toggle's idle segments sit in; #68686c clears 4.61. */
  --text-muted: #68686c;
  --text-faint: #8e8e93;
  --text-code: #3a3a3c;
  /* #0a6ee0, not the #0a7cff eyedropped from the design screenshots.
     White on that blue is 3.93:1 — below AA for normal text, which made EVERY
     primary button in the product (Send, Open, Publish profile, Improve, New
     conversation) a control you could see and a label you could not quite
     read. It failed the same way as ink: `text-violet-600` on white was the
     identical 3.93. One step darker clears both at 4.86, and the hue is
     unchanged to two decimal places of what the deck shows.

     The DARK accent below is untouched: it is a lighter blue on purpose,
     because against a near-black page it has to be, and it is used mostly as
     ink rather than as a fill. Where it IS a fill, --accent-ink supplies the
     near-black text that goes on it. */
  --accent: #0a6ee0;
  --accent-light: #1f86ff;
  --accent-tint: rgba(10, 110, 224, 0.08);
  /* The accent as a FILL you can put dark ink on — violet-100, the pale end
     of the same ramp. `--accent-tint` is a translucent overlay for hovers and
     stripes; this is an opaque surface, which is what the dev composer's send
     circle needs so its halo reads against it. */
  --accent-wash: #d6e9ff;
  --accent-wash-ink: #0062cc;
  /* Accent-coloured TEXT sitting on --accent-tint (QA 2026-09-24 Q20). The
     tint is translucent accent, so it pulls the accent's own contrast down:
     #0a6ee0 on the 8% tint over white is 4.34:1, under AA for the card band's
     12px labels ("Start work", "Claim this issue"). One step down the ramp
     (violet-700) reads at 5.19 and is the same hue. Dark keeps the accent
     itself, which already clears 5.4 on its tint. */
  --accent-tint-ink: #0062cc;
  /* Text ON the accent, wherever the shell puts type on a saturated fill.
     It must not follow the page. The outgoing chat bubble used to be the
     headline case and no longer is a fill at all; .messages-state button
     still is. */
  --accent-ink: #ffffff;

  /* ── The dev chat's surface ladder ───────────────────────
     Three steps, and the ONLY thing they guarantee is that each one is
     nearer the reader than the last: the strip sits on the page wallpaper,
     the session sheet sits on the strip, and a card (your message, a coding
     run) sits on the sheet.

     They exist because --bg-secondary / --bg-primary could not express that
     in both themes. In light those two ascend (#f5f5f7 → #ffffff), which is
     what the lift was built on. In DARK they descend — --bg-primary is
     #0b0b0c and --bg-secondary is #1c1c1e — so the stack inverted: the sheet
     came out as the darkest thing on the screen, a near-black hole punched
     into the #0b0d1b wallpaper, with the strip ABOVE it lighter than both.
     Measured: wallpaper (11,13,27) → strip (28,28,30) → sheet (11,11,12).

     Nothing here changes light mode. --dc-strip and --dc-raised are
     --bg-secondary's value, --dc-sheet is --bg-primary's, so the light stack
     is byte-identical to what shipped. Dark is where the values are new, and
     they climb: (11,13,27) → (19,19,22) → (28,28,30) → (44,44,46). */
  --dc-strip: #f5f5f7;
  --dc-sheet: #ffffff;
  /* A CARD SITTING ON A SHEET, which is a different job from the sheet. The
     category cards took `--dc-sheet-fill` — the same value as the pane they
     sit on — so in DARK MODE they were rgba(28,28,30,.72) on rgba(28,28,30,
     .72) and the only thing separating them was a 12%-white hairline. Light
     mode got away with it because its hairline is 10% BLACK on near-white,
     which reads far harder than 12% white on near-black does. Opaque in both
     themes, because a raised surface that is see-through is not raised. */
  --dc-sheet-raise: #ffffff;
  --dc-raised: #f5f5f7;

  /* ── …and the two PLANES, which are not the same thing ───
     The three tokens above are SURFACE VALUES: what a card, an inset or a
     chip paints. The two below are what the strip and the session sheet —
     the ladder's two full-screen planes — actually fill with, and they are
     translucent so the page wallpaper reads THROUGH the session rather than
     being covered by it.

     They are separate tokens on purpose. --dc-sheet is read by nineteen
     rules as an opaque inset inside a card; making THAT translucent would
     have let every chip and every code block see the card behind it, which
     is a different change and a wrong one. Only the plane is glass.

     NO BLUR, ON ANY PLATFORM. `--dc-frost` was `blur(24px) saturate(1.6)`,
     and WebKit re-ran that filter over every frosted surface on every frame
     that content scrolled behind it or a spring moved it: the phone stutter
     #787 and #3104 measured. It is `none` everywhere now, so every platform
     draws the same colours: a plane that only ever sits on the fixed
     wallpaper keeps its translucent fill (the washes are soft shapes already,
     and measured against the glass the fill alone lands within a few points
     of it), and a surface that content scrolls or slides behind, or that
     floats over other UI, is opaque in the `-solid` colour below. The token
     stays so every `backdrop-filter: var(--dc-frost)` keeps resolving. */
  --dc-strip-fill: rgba(244, 242, 228, 0.38);
  --dc-sheet-fill: rgba(255, 255, 255, 0.50);
  --dc-frost: none;
  /* THE PLANE COLOURS, OPAQUE: each fill composited over the wallpaper's
     base (244, 242, 228), which is what the glass showed wherever nothing
     but the wallpaper was behind it. Used by the bars, sheets, panels and
     heads that content passes behind, so they read as the same material as
     the planes without letting that content show through. Not --dc-sheet /
     --dc-strip: those are the neutral #ffffff / #f5f5f7 surface values, and
     a bar in them came out whiter and greyer than the glass it replaced. */
  --dc-sheet-solid: #faf9f2;
  --dc-strip-solid: #f4f2e4;
  /* FLOATING surfaces are denser (#2887). A pane is glass over the
     wallpaper, which the platform draws and keeps soft; a dialog or a menu
     floats over WHATEVER is under it — an app's dark UI, a code block, a
     photo — and at the pane's alpha that showed through as shapes behind
     the form's own text. Mostly opaque, still frosted: the blur stays, so
     the surface keeps its material, and the words stay legible over dark
     content in both themes. Used by the kit's modal (Send Feedback and
     every other dialog) and the platform mark's menu. */
  --dc-float-fill: rgba(255, 255, 255, 0.95);

  /* ── Safe-area tokens ────────────────────────────────────
     THE single source of truth for the shell's own safe-area
     arithmetic. Every rule in this file that needs an inset reads one
     of these two — never bare env() — so there is exactly one place to
     change if the source ever moves.

     Written as `var(--un-safe-inset-X, env(safe-area-inset-X, 0px))`,
     the kit's convention (see the "Safe areas" block in
     public/usernode-native/v1/native.css). The shell is a top-level
     document, so nothing sets the custom property and env() supplies
     the real value; writing it this way means the shell would also
     honour a forwarded inset if it were ever framed, and it is what
     lets `?shot=safe-bottom` (public/js/app.js) paint the whole shell
     — kit `.un-safe-*` classes included — as if it were on a notched
     phone. */
  --platform-safe-bottom: var(--un-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
  /* HOW MUCH OF THAT INSET THE PHONE TAB BAR SPENDS (#2766). Everywhere but
     iOS, all of it — see `html.un-ios` further down for the one exception
     and `.platform-tabs` for where it is spent. Declared here, beside the
     inset it reads, so the Android standalone override of the inset (#2755)
     flows through it on the same element. */
  --platform-tabs-inset: var(--platform-safe-bottom);
  /* THE AIR BETWEEN THE WORKSHOP'S LANDER AND THE FOOT OF ITS SCROLLER.
     `.dev-ws`'s `--ws-area` and `--ws-fit` both read it. Root-scoped since
     the tab bar was portalled out of `.dev-ws`; the bar is back in flow at
     the head of the page (#2767) and the token simply stayed where every
     reader can see it. */
  --ws-gap: 8px;
  --platform-safe-top: var(--un-safe-inset-top, env(safe-area-inset-top, 0px));

  /* Content-row height of the mobile install strip (#1372). Declared here,
     unconditionally, rather than on either element that reads it: `body`
     reserves this much padding and the strip itself is sized to it, and a
     custom property set on the strip could not be read by its own ancestor.
     One declaration, so the reserved space and the drawn height cannot
     drift into a gap or into covered content. */
  --install-strip-h: 3rem;

  /* The authored top bar's height, WITHOUT the status-bar inset it extends
     into (that is --platform-safe-top, added by whoever needs the total).
     Not a new fact: it is what the "Header height invariant" block near
     #header-title.is-centered derives from the markup — pt-3/pb-5 around a
     28px content row — restated once so a surface that hangs off the bottom
     of the bar can say so rather than hard-code a number that quietly stops
     matching. #apps-switcher-sheet's desktop popover reads it as its
     fallback position, for a frame where the mark cannot be measured;
     tests/app-switcher-dropdown.test.js pins this against the header's own
     classes so the two cannot drift.

     52px UNTIL THE BOTTOM PADDING GREW. The bar was py-3 — 12px above and
     below a 28px row — and it also carries `-mb-2`, which pulls whatever
     follows 8px UP into it to cut the notch every platform surface reads as
     its rounded top. Those two facts together left FOUR pixels between the
     controls and the next thing on the screen. On most routes that next
     thing is transparent and nobody could tell. On an app route and in a
     proposal session it is a raised sheet — a 28px radius, a hairline and a
     shadow reaching up into the bar — and the chip, the bell and Improve sat
     on its lip. `pb-5` spends 8px buying that clearance back, so the notch
     costs the controls nothing. */
  --platform-header-h: 3.25rem;

  /* ── Semantic state tokens (dev board) ───────────────────
     ONE meaning per hue, so the same lifecycle stage never reads two
     ways depending on which helper drew it. Every dev-card status
     badge / pill / composite-pill tone routes through these; nothing
     in the board's status vocabulary should hard-code a hex again.

       neutral    — not started, skipped, draft, paused, archived
       progress   — actively happening: merging, resolving, checks
                    running, in vote. Violet, NOT amber: amber used to
                    do this job as well as three others.
       attention  — non-blocking heads-up: behind main, console
                    errors, advisory votes, explicit approval. Amber
                    now means ONLY this.
       blocked    — blocks the merge: checks failing, checks couldn't
                    run, preview won't boot, conflict. Red. ("Checks
                    failing" used to be amber, reading like a warning
                    when it actually blocks.)
       ok         — done and good: merged, checks passing, passed —
                    merging shortly, a Yes tally. Green. ("✓ Checks
                    passing" used to inherit the violet Merged colour.)
       admin      — an admin bypass. Same amber family as attention
                    but distinguished by an OUTLINE (see
                    .gc-vote-btn-admin) so hue alone isn't the signal.

     `-bg` companions are the faint fills; `-fill` the stronger
     proportional-fill wash used inside the tally pill. */
  --state-neutral: #6b7280;
  --state-neutral-bg: rgba(107, 114, 128, 0.12);
  --state-progress: #7c3aed;
  --state-progress-bg: rgba(124, 58, 237, 0.12);
  --state-attention: #b45309;
  --state-attention-bg: rgba(245, 158, 11, 0.14);
  /* THE BRAND PERIWINKLE — the homescreen design's second accent, distinct
     from --accent (the blue that fills buttons and links across the
     product). It is the ink of the header's controls (the app chip, the
     bell, the back disc) and of the section links on the home screen, with
     a tint for their fill and a hairline one step darker than the tint.
     Sampled from the design's render (#37477b / #e4e9f7); the hairline is
     interpolated. Read through Tailwind arbitrary values
     (`text-[color:var(--brand-ink)]`) so the classes stay complete
     literals for the extractor. */
  --brand-ink: #37477b;
  --brand-tint: #e4e9f7;
  --brand-line: #c9d2f2;
  /* WHERE YOU ARE: the lit tab on the phone's bar and the lit row on the
     rail, in the product's one accent (AGENTS.md, "Type and colour"). It was
     the periwinkle above, which beside the action blue made two blues that
     meant nearly the same thing; the periwinkle stays the header's own ink
     (the chip, the bell, the back disc) and "you are here" is the accent,
     one step darker as ink so the 11px label clears 4.5:1 on the tint. */
  --lit-ink: #0062cc;
  --lit-tint: #eaf4ff;
  --lit-line: rgba(10, 110, 224, 0.25);
  /* The running app's sheet edge (#1571) — see "THE APP SHEET" below for
     which element wears it and why. Straight off the design's render
     (node 736:4306): a black-at-10% hairline, and a two-layer shadow in
     the deck's blue-black rather than neutral grey, which is what keeps
     it from reading as dirt over the cream ground. */
  --app-sheet-line: rgba(0, 0, 0, 0.1);
  --app-sheet-shadow-near: rgba(24, 39, 75, 0.12);
  --app-sheet-shadow-far: rgba(24, 39, 75, 0.08);
  /* The lift's two layers as one value — see `.dc-lift`. */
  --dc-lift-shadow:
    0 6px 12px -6px var(--app-sheet-shadow-near),
    0 8px 24px -4px var(--app-sheet-shadow-far);
  /* The modal dim, cast BY the pane rather than painted behind it. Same 40%
     black the overlays used to carry as `bg-black/40`; see `.dc-lift-panel`.
     Ink and alpha are split out so the kit's sheet and side panel can scale
     the alpha by `--un-presence` and ride the slide (see "THE KIT'S SURFACES
     TAKE THE PANE GLASS"); the composed token is what everything else reads. */
  --pane-scrim-ink: 0 0 0;
  --pane-scrim-alpha: 0.4;
  --pane-scrim: rgb(var(--pane-scrim-ink) / var(--pane-scrim-alpha));
  --state-blocked: #dc2626;
  --state-blocked-bg: rgba(239, 68, 68, 0.12);
  --state-blocked-fill: rgba(239, 68, 68, 0.32);
  --state-ok: #15803d;
  --state-ok-bg: rgba(34, 197, 94, 0.12);
  --state-ok-fill: rgba(34, 197, 94, 0.32);
  --state-admin: #b45309;
  --state-admin-bg: rgba(245, 158, 11, 0.10);
}

/* ── Safe-area utilities ─────────────────────────────────
   The platform's half of "backgrounds extend into the bottom inset,
   interactive content insets itself":

   .platform-safe-scroll — a scroll container whose content ends at the
     bottom of the screen. Block-end padding is part of a scroller's
     scrollable overflow, so the last row becomes reachable AND the
     scroller's own background still paints through the strip.
   .platform-safe-bar — a pinned bottom bar (every composer block) that
     already carries Tailwind `p-2`. The 8px base gap is preserved and
     the inset is added below it.

   `!important` is load-bearing, not laziness: /css/tailwind.css is
   linked AFTER /css/app.css (index.html — and the inline probe there
   enforces that order), so a plain class here loses the specificity
   tie against `p-2` / `p-4` on the same element purely on source
   order. The kit's own `.un-safe-*` helpers carry `!important` for
   exactly this reason.

   Deliberately NOT the kit's `.un-safe-bottom`: that one REPLACES the
   element's own bottom padding with the bare inset (a composer would
   lose its 8px gap) and has no keyboard-up suppression. These wrap the
   kit's token rather than forking its CSS. */
.platform-safe-scroll {
  /* `max()` because the two things this clears are stacked, not added: when
     #platform-tabs is up, `--platform-tabs-h` is the bar's FULL outer height
     and the bar's own lower padding already covers the home-indicator strip,
     so taking the larger of the two reserves each exactly once. When the bar
     is down the token is 0px and this is the bare inset it has always been.
     See the #platform-tabs section at the end of this file. */
  padding-bottom: max(var(--platform-tabs-h, 0px), var(--platform-safe-bottom)) !important;
}

.platform-safe-bar {
  /* Same `max()` as the scroller above, inside this rule's own base gap: a
     composer row is the bottom of a column that reaches the viewport, so
     what it has to clear is whichever of the tab bar and the home-indicator
     strip is taller — never both stacked. Every surface that wears this
     class sits on a screen where the bar is EITHER up (#messages-screen,
     #global-chat-screen) or hidden (everything inside #app-view), and in the
     hidden case `--platform-tabs-h` is 0px and this is exactly the
     declaration it has always been. */
  padding-bottom: calc(0.5rem + max(var(--platform-tabs-h, 0px), var(--platform-safe-bottom))) !important;
}

/* .platform-safe-sheet — a sheet's own FOOTER row (the Apps sheet's
   Home | Explore pair). Same additive rule as .platform-safe-bar, with the
   sheet's larger base gap: a sheet has rounded top corners and sits on the
   ground rather than against the screen edge, so its last row needs real
   padding under it whether or not the device reports an inset. This exists
   because .platform-safe-scroll REPLACES the element's bottom padding with
   the bare inset — correct for a scroller whose content runs to the bottom
   of the screen, wrong for a button row, which on every 0-inset device
   (desktop, most Android, the in-app preview) then sat flush on the sheet's
   rounded edge. */
.platform-safe-sheet {
  padding-bottom: calc(1.25rem + var(--platform-safe-bottom)) !important;
}

/* Keyboard up: the home-indicator strip sits BEHIND the on-screen
   keyboard, so reserving it there is pure dead space above the
   keyboard. Same suppression the kit applies to its own surfaces
   (`html.un-kb .un-panel-body` / `.un-action-sheet` / `.un-kb-avoid`
   in native.css). The bar keeps its 8px base gap; a scroller that also
   carries the kit's `.un-kb-avoid` gets the keyboard inset from the
   kit instead. */
html.un-kb .platform-safe-scroll {
  padding-bottom: 0 !important;
}

html.un-kb .platform-safe-bar {
  padding-bottom: 0.5rem !important;
}

html.un-kb .platform-safe-sheet {
  padding-bottom: 1.25rem !important;
}

/* Keyboard up: reserve its height on a COMPOSER COLUMN (#1937, #1491).

   Four screens are built the same way — the topic thread, the general chat,
   the dev chat and Messages. Each is a flex column holding a `flex-1`
   scroller with the typing slot and composer pinned BELOW it as `shrink-0`
   siblings, OUTSIDE the scroller. That shape is deliberate (thread-shell.tsx
   explains it), and it is exactly what the kit's keyboard avoidance cannot
   help with: `attachKeyboardAvoidance` gates every path on
   `scrollEl.contains(field)`, and `html.un-kb .un-kb-avoid`'s padding lands
   INSIDE the scroller. So nothing lifted the composer and you typed blind.

   On iOS there was a second symptom on top of that. Safari pans the document
   to reveal the focused field, and `settledPin()` — which runs on EVERY
   settled viewport event, not only on focus — reset that pan with
   `window.scrollTo(0, 0)`. Typing resizes the visual viewport (QuickType), so
   the page snapped back once per keypress and took the transcript with it.
   Android never had that half: Chrome does not pan (measured: scrollY stays
   0), though its composer was covered just the same.

   Reserving the inset on the column fixes both, and is what native does — iOS
   Messages and Android Messages both shrink the content area by the keyboard's
   height and pin the composer above it rather than covering or panning.
   The scroller loses that height instead of being covered, so the transcript
   stays visible, the composer sits above the keys, and the settled pin is
   no longer destructive.

   It is NOT inert, and a column needs it (#3571). iOS pans the page on the
   tap, before this padding exists, by the most it can: the keyboard's
   height. The padding then lifts the composer by that height again inside
   the panned page, so it ends near the top edge, above the screen. The pin
   (attachKeyboardAvoidance, via attachScreenFx on the legacy-mounted chats)
   puts the pan back once the keyboard settles. A React column that no
   controller mounts attaches it itself: lib/composer-keyboard.ts.

   Transition shape copied from the kit's own `.un-kb-avoid`: none while the
   keyboard is up so the bar tracks it exactly, eased on the way out so the
   browser's scrollTop clamp rides the ease instead of snapping.

   A column that takes this must NOT also carry `.un-kb-avoid` on its
   scroller — that would reserve the inset twice and leave dead space under
   the transcript. */
.platform-kb-column {
  transition: padding-bottom 150ms ease-out;
}

html.un-kb .platform-kb-column {
  padding-bottom: var(--un-kb-inset, 0px);
  transition: none;
}

/* …and the scroller inside it must NOT reserve it a second time.

   `attachKeyboardAvoidance` ADDS `un-kb-avoid` to whatever scroller
   `attachScreenFx` hands it — the general chat's `#gc-messages`, the topic
   thread's `#gc-thread-scroll`, the dev chat's `#dc-messages` — so on those
   screens the class is there at runtime whether or not any source file writes
   it. With the column reserving as well, the inset is counted twice, and the
   second one is the damaging one: a scroll container cannot shrink below its
   own padding, so `#gc-messages` bottomed out at 368px (its `py-2` 8px plus a
   360px keyboard inset) and floored the whole column. Measured on a Pixel
   emulator against the live general chat: the composer rose 163px of the 360
   it needed and stayed behind the keys. The topic thread escaped only because
   its column had enough slack to absorb the floor.

   Suppressing it here restores the single reservation: general chat's composer
   moved from 647 to 450 — exactly the keyboard line — while the topic thread
   stayed at 450, unchanged. Specificity (0,3,1) beats the kit's (0,2,1) and
   app.css loads after native.css, so no !important is needed. */
html.un-kb .platform-kb-column .un-kb-avoid {
  padding-bottom: 0;
}

/* ── Platform Messages screen ─────────────────────────────
   Fully React-owned (frontend/src/features/messages). Every rule is scoped
   to messages-* so the app group chat keeps its own contract.

   DRAWN ON THE DEV SESSION'S LIFT LADDER — see `.dc-lift` and its two plane
   classes further down. The wallpaper is the ground (this route is in the
   body:has(…) wallpaper list). `.messages-layout` is the frosted STRIP,
   carrying the screen's title, the New disc and the conversation rows drawn
   straight on it. The open conversation is the frosted SHEET rising on the
   strip — `.messages-thread-pane` — with its title row, transcript, typing
   line and composer inside. Both planes wear `.dc-lift` plus
   `.dc-lift-strip` / `.dc-lift-session` from index.tsx, so the fills, the
   frost, the no-backdrop-filter fallback and both themes come from one
   place rather than being restated here.

   The same construction at both widths: on a phone the list screen IS the
   strip and a thread is the strip's shoulder showing above the sheet; from
   768px up both panes sit on one strip with the sheet beside the list. */
/* The tokens are the project page's Discussion tab's too (#3653): an app
   channel's reply thread opens there, in the same pane as here, with no
   `.messages-layout` around it (project-discussion.tsx). */
.messages-layout,
.dev-ws-discussion {
  /* The OPAQUE surfaces drawn on the two planes: a bubble from the other
     side, a shared-item card, a floating disc, the composer. White on the
     light sheet. In dark it is the ladder's own raised step (--dc-raised),
     so a card is one step lighter than the sheet rather than a hole in it —
     --bg-primary is near-black there and would be exactly that hole. */
  --messages-surface: #ffffff;
  /* The step off a surface: a glyph tile on a card, a disc's hover. */
  --messages-surface-2: var(--bg-secondary);
  --messages-hair: var(--border-light);
  /* The open conversation's row on the strip, and a row under the pointer.
     A translucent white so it reads as the row the sheet came out of. */
  --messages-selected: rgba(255, 255, 255, 0.55);
  --messages-disc-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.messages-layout {
  display: flex;
  width: 100%;
  height: 100%;
  min-height: 0;
}
.dark .messages-layout,
.dark .dev-ws-discussion {
  --messages-surface: var(--dc-raised);
  --messages-surface-2: #3a3a3c;
  --messages-hair: rgba(255, 255, 255, 0.08);
  --messages-selected: rgba(255, 255, 255, 0.06);
  --messages-disc-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}

.messages-list-pane {
  flex-direction: column;
  width: 100%;
  min-width: 0;
  min-height: 0;
}
/* The sheet. Its top margin is what shows the strip's shoulder above it on
   a phone — the same reading the dev session gives, where the session sheet
   sits ON the strip rather than abutting it. */
.messages-thread-pane {
  flex-direction: column;
  width: 100%;
  min-width: 0;
  min-height: 0;
  margin-top: 12px;
}

/* The list's head: the screen's name at Home's heading size, and New as a
   floating disc. The header bar's chip already names the screen, so there is
   no subtitle under it. */
.messages-list-toolbar {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  gap: 12px;
  padding: 18px 16px 8px;
}
.messages-list-title { min-width: 0; flex: 1 1 auto; }
/* Preflight resets headings to inherit, so the size is this rule's to give. */
.messages-list-title h2,
.platform-screen-title {
  color: var(--text-primary);
  font-size: 28px;
  font-weight: 700;
  line-height: 1.15;
  letter-spacing: -0.01em;
}
/* ONE SIZE FOR A PLATFORM SCREEN'S NAME (#2718).
   Messages named itself at 28px and the Workshop named itself with the
   grouped list's SECTION LABEL — 15px, regular weight, muted — so two screens
   one tab apart disagreed about what a screen title is. The section label is
   for a group WITHIN a screen; a screen's own name is the biggest thing on
   it.
   The selector above is a second spelling of the Messages rule rather than a
   replacement of it: that one is reached as `.messages-list-title h2` from a
   flex toolbar that also held the New disc, and rewriting its markup to take
   a class is a change to a screen this issue is not otherwise touching.
   Home is deliberately NOT a caller. The bar above it carries the Homeroom
   wordmark, and a screen called Home under a wordmark is the same word twice;
   the wordmark IS Home's title. Every other platform screen is named by the
   tab that opened it, so it says its own name. */
.platform-screen-title { padding: 18px 16px 8px; }

/* THE DISCS: New, the thread's actions, and the per-message tray's buttons.
   The language's floating circular control — a white disc on the plane,
   with the same hairline of shadow the header's discs carry. */
.messages-new-button,
.messages-thread-action {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 9999px;
  color: var(--text-primary);
  background: var(--messages-surface);
  box-shadow: var(--messages-disc-shadow);
  transition: background 150ms;
}
.messages-new-button { width: 44px; height: 44px; color: var(--accent); }
.messages-new-button:hover,
.messages-thread-action:hover { background: var(--messages-surface-2); }
.messages-new-button svg,
.messages-thread-action svg,
.messages-composer-action svg,
.messages-send svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.messages-new-button svg { stroke-width: 2.5; }

.messages-list-scroll,
.messages-thread-scroll {
  min-height: 0;
  flex: 1 1 auto;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* THE LIST, AND NOT THE THREAD. Both scrollers reach the bottom of the
   screen, but the thread has the composer below it and the composer clears
   the tab bar itself (`.platform-safe-bar`), so padding here as well would
   reserve the band twice. The list has nothing under it — on a phone it IS
   the screen, and from 768px up it is the left column beside the sheet —
   so its last conversation row would otherwise sit behind the bar.

   The bare token rather than the `max()` the others take: this scroller
   reserves NOTHING today, so the inset is not part of what it is correcting
   and pulling it in here would be a second change riding along. 0px with no
   bar keeps that exactly as it was. */
.messages-list-scroll {
  padding-bottom: var(--platform-tabs-h, 0px);
}
/* THE HISTORY ABOVE A COMPOSER RESERVES NOTHING AT ITS FOOT (iPhone app,
   19 October 2026). The note above is the rule, but the scroller carries
   `.platform-safe-scroll` too, and that class won: on a phone the history
   kept a band of the tab bar's full height (56px in a browser, about 90 in
   the app, with the home-indicator strip) empty above the composer, which
   clears the bar again itself. So the band goes wherever a composer
   (`.platform-safe-bar`: Send, the request awaiting acceptance, "you can't
   send here") follows the history in its pane, the reply thread's
   included. Where none does (an invitation before it is accepted), the
   history reaches the foot of the screen and keeps the band. 0 rather
   than the 6px below: `.platform-safe-scroll` already made it 0 wherever
   the bar is down, so a desktop is exactly as it was. */
.messages-thread-scroll.platform-safe-scroll:has(~ .platform-safe-bar) {
  padding-bottom: 0 !important;
}
/* THE BOT'S CHAT, OPENING (features/messages/open-hold.ts). Its rows are
   laid out, so the transcript can tell whether they fill the screen, and
   not seen until they are what it will stay as; the spinner stays at the
   top of the scroller over them, however far down it is scrolled. */
.messages-thread-pane[data-opening] > .messages-thread-scroll > :not(.messages-state),
.messages-thread-pane[data-opening] > [data-transcript-overlay] {
  visibility: hidden;
}
.messages-thread-pane[data-opening] > .messages-thread-scroll > .messages-state {
  position: sticky;
  top: 0;
}

/* A conversation row, drawn straight on the strip: the square swatch
   avatar, then two lines — the name with the time on its trailing edge, the
   preview with the unread count on its. 15/13px, the grouped list's scale
   (AGENTS.md, "Type and colour"): 17px bold made every name a heading. */
.messages-conversation-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 66px;
  padding: 13px 16px;
  color: inherit;
  text-decoration: none;
  transition: background 150ms;
}
/* The row separator, INSET past the avatar to where the name starts: 16px of
   padding + the 44px `lg` UserAvatar + the 14px gap. A pseudo-element rather
   than `border-bottom` because a border cannot be inset, and because the row
   is a fixed-height box whose 66px the border would eat into.

   Same treatment, same reason, as the drawer's nav rows, the home panels and
   @/components/ui/grouped-list.tsx: the widget language starts a row
   separator at the content, and the last row has none. */
.messages-conversation-row::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 74px;
  right: 0;
  height: 1px;
  background: var(--messages-hair);
  pointer-events: none;
}
.messages-conversation-row:last-child::after { display: none; }
.messages-conversation-active { background: var(--messages-selected); }
/* A pointer that can actually hover only (the #2892 rule for app tiles). On
   a touch screen WebKit keeps :hover on the last row tapped, so the row you
   opened, or merely scrolled from, stayed tinted like the active one after
   you came back to the list. */
@media (hover: hover) {
  .messages-conversation-row:hover { background: var(--messages-selected); }
}
.messages-row-line {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
/* A group row's tile (#5393): the faces of two of its other members, stacked
   in the same 44px box the `lg` square takes, so the 74px separator inset
   (16 + 44 + 14) holds. The last face's ring is the card the rows sit on
   (.messages-section-card paints `--dc-sheet-solid`), so the pair reads as
   two faces and not one melted one. */
.messages-group-faces { position: relative; flex: 0 0 44px; width: 44px; height: 44px; }
.messages-group-faces > * { position: absolute; }
.messages-group-faces > :first-child { left: 0; top: 0; }
.messages-group-faces > :last-child { right: 0; bottom: 0; box-shadow: 0 0 0 2px var(--dc-sheet-solid, var(--app-bg)); }
.messages-row-name,
.messages-row-preview {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.messages-row-name { color: var(--text-primary); font-size: 15px; font-weight: 650; line-height: 20px; }
.messages-row-preview { margin-top: 2px; color: var(--text-muted); font-size: 13px; line-height: 18px; }
/* An invitation's preview is the ACTION the row offers, so it takes the
   accent — the language's "actionable / yours". */
.messages-row-preview-invited { color: var(--accent); font-weight: 500; }
.messages-row-time {
  flex: 0 0 auto;
  color: var(--text-muted);
  font-size: 13px;
  font-variant-numeric: tabular-nums;
}
/* #18 of the 19 Oct polish board, ONE MEANING PER BADGE: the accent is
   "waiting on you" (a vote, an answer), and an unread message is new to
   read, which is grey everywhere. So an unread row's time is the ink, and
   its count is said, "2 new", in grey (.messages-unread below). */
.messages-row-time-unread { color: var(--text-primary); font-weight: 600; }
.messages-group-tag {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  height: 22px;
  margin-left: 6px;
  padding: 0 8px;
  border-radius: 9999px;
  color: var(--text-muted);
  background: var(--bg-tertiary);
  font-size: 12px;
  font-weight: 600;
  vertical-align: 2px;
}
/* NEW TO READ, SAID ON THE ROW (#18): "2 new" in grey at the preview's
   end, where a blue bubble with a bare number was. A full row says its count
   in words (the shell's rule); bare numbers are for tabs and the sidebar's
   compact rows, and grey there too. */
.messages-unread {
  flex: 0 0 auto;
  margin-left: 8px;
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 650;
  line-height: 18px;
  white-space: nowrap;
}

.messages-network-banner {
  flex: 0 0 auto;
  margin: 0 16px 8px;
  padding: 8px 12px;
  border-radius: 12px;
  color: #92400e;
  background: #fef3c7;
  font-size: 13px;
  text-align: center;
}
.dark .messages-network-banner { color: #fde68a; background: #451a03; }

/* The empty and loading states, at the language's type scale. The list's
   empty state is a white card on the strip with a filled pill; "Choose a
   conversation" (desktop only — below 768px the list IS the screen until you
   pick a row) is the same words standing on the strip where the sheet would
   be, because a card there would read as a conversation with nothing in it. */
.messages-state,
.messages-empty,
.messages-thread-empty,
.messages-no-selection {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 8px;
  padding: 24px;
  color: var(--text-muted);
  font-size: 15px;
  text-align: center;
}
.messages-state,
.messages-thread-empty { min-height: 180px; }
.messages-empty {
  margin: 8px 16px 0;
  padding: 36px 24px;
  border-radius: 20px;
  background: var(--messages-surface);
}
.messages-empty h3,
.messages-no-selection h2 { color: var(--text-primary); font-size: 20px; font-weight: 700; }
.messages-empty p,
.messages-no-selection p { max-width: 22rem; }
.messages-thread-empty > span { font-size: 32px; }
.messages-empty button,
.messages-state button {
  display: inline-flex;
  align-items: center;
  height: 44px;
  margin-top: 8px;
  padding: 0 20px;
  border-radius: 9999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 17px;
  font-weight: 600;
}
.messages-empty button:hover,
.messages-state button:hover { background: var(--accent-light); }
.messages-state-error { color: #dc2626; }
.messages-spinner {
  width: 20px;
  height: 20px;
  border: 2px solid var(--border);
  border-top-color: var(--accent);
  border-radius: 9999px;
  animation: messages-spin .7s linear infinite;
}
@keyframes messages-spin { to { transform: rotate(360deg); } }

/* The sheet's title row: who the conversation is with, its context line,
   and the actions as discs. No hairline under it — the transcript's first
   day label is what separates it, the way the dev session's header sits
   over its transcript. */
.messages-thread-header {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  gap: 12px;
  padding: 14px 16px 8px;
}
.messages-thread-name,
.messages-thread-sub { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messages-thread-name { color: var(--text-primary); font-size: 17px; font-weight: 700; }
/* #5329: a change title that opens the Details sheet — a reset button that
   reads as the heading it sits in, underlined only on hover. */
.messages-thread-name-link {
  appearance: none; border: 0; background: none; padding: 0; margin: 0;
  font: inherit; color: inherit; cursor: pointer;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.messages-thread-name-link:hover { text-decoration: underline; text-underline-offset: 3px; }
.messages-thread-name-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.messages-thread-sub { color: var(--text-muted); font-size: 15px; }
.messages-thread-menu {
  position: absolute;
  z-index: 30;
  top: calc(100% + 6px);
  right: 0;
  width: 220px;
  padding: 6px;
  border-radius: 16px;
  background: var(--messages-surface);
  box-shadow: 0 12px 30px rgba(0, 0, 0, .18);
}
.messages-thread-menu button {
  display: block;
  width: 100%;
  padding: 10px 12px;
  border-radius: 10px;
  color: var(--text-primary);
  font-size: 15px;
  text-align: left;
}
.messages-thread-menu button:hover { background: var(--messages-surface-2); }

/* A pending invitation, inside the sheet above the (empty) transcript: a
   tinted card with the language's filled pills — Accept in the accent,
   Decline neutral, the block action in the danger tint. */
.messages-invitation {
  display: flex;
  align-items: center;
  gap: 12px;
  flex: 0 0 auto;
  margin: 0 16px 8px;
  padding: 12px 16px;
  border-radius: 16px;
  background: var(--accent-tint);
  font-size: 15px;
}
.messages-invitation strong { display: block; color: var(--accent); font-size: 17px; }
/* #3228: the invited group's name and member count, the card's one bold line. */
.messages-invitation .messages-invitation-group { font-weight: 600; color: var(--text-primary); }
.messages-invitation p { margin-top: 2px; color: var(--text-muted); }
.messages-invitation [role="alert"] { display: block; margin-top: 4px; color: #dc2626; }
.messages-invite-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.messages-invite-decline,
.messages-invite-block,
.messages-invite-accept {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 40px;
  padding: 0 16px;
  border-radius: 9999px;
  font-size: 15px;
  font-weight: 600;
  transition: background 150ms;
}
.messages-invite-decline { color: var(--text-primary); background: var(--bg-tertiary); }
.messages-invite-block { color: #dc2626; background: rgba(220, 38, 38, .1); }
.messages-invite-accept { color: var(--accent-ink); background: var(--accent); }
.messages-invite-accept:hover { background: var(--accent-light); }
.messages-invite-decline:disabled,
.messages-invite-block:disabled,
.messages-invite-accept:disabled { opacity: .55; }

.messages-thread-scroll { padding: 4px 0 6px; }
.messages-day {
  padding: 10px 0 6px;
  color: var(--text-muted);
  font-size: 15px;
  text-align: center;
}
.messages-load-older {
  display: inline-flex;
  align-items: center;
  height: 32px;
  padding: 0 14px;
  border-radius: 9999px;
  color: var(--text-primary);
  background: var(--bg-tertiary);
  font-size: 13px;
  font-weight: 500;
}

/* ── The named-row transcript (a group) ──────────────────────────────────
   The app chat's shape: square avatar, bold name, muted time, flat text. */
.messages-message {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: 12px;
  padding: 8px 16px;
}
/* A message in flight is drawn at once, faded, and changes nothing else
   (#2907): no spinner, no "sending…" line, so confirming it moves no row.
   The fade lifts when the server has it. */
.messages-message { transition: opacity .15s ease-out; }
.messages-message-pending { opacity: .55; }
.messages-message-failed { background: rgba(239, 68, 68, .06); }
.messages-message-meta.messages-message-failed-note { display: flex; align-items: baseline; gap: 12px; padding: 2px 0 0; }
.messages-message-failed-note button {
  color: var(--accent);
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.messages-message-failed-note button.messages-discard { color: var(--text-muted); font-weight: 400; }
.messages-message-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 8px;
  min-width: 0;
  min-height: 22px;
  color: var(--text-primary);
  font-size: 17px;
  font-weight: 700;
}
.messages-message-head time,
.messages-message-head time ~ span { color: var(--text-muted); font-size: 15px; font-weight: 400; white-space: nowrap; }
/* QA 2026-09-24 Q22: a long name gives way before the time does. The name
   takes what the line has left, up to its own width, and ends in an ellipsis
   rather than breaking at a hyphen; the time never wraps inside itself. The
   6em basis is the point at which they stop sharing a line — below it the
   time drops to a second line of the head instead of squeezing the name to
   a stub. */
.messages-message-author {
  flex: 1 1 6em;
  min-width: 0;
  max-width: max-content;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A platform event in #general (a proposal up for a vote, a merge): the
   platform's own line, so it reads as a notice rather than a person — the
   words muted, the author plain, and the proposal's card under it. */
.messages-message-system .messages-message-author { color: var(--text-muted); }
.messages-message-system .messages-markdown.gc-msg-content { color: var(--text-secondary); }
/* The body at the language's reading size. Doubled up with .gc-msg-content
   because format.tsx puts both classes on the node — that one is the
   markdown vocabulary (its list, code and table rules), and it sets 13px
   later in this file, which a bare .messages-markdown would lose to. */
.messages-markdown.gc-msg-content {
  color: var(--text-primary);
  font-size: 17px;
  line-height: 1.3;
  overflow-wrap: anywhere;
}
.messages-markdown a { color: var(--accent); text-decoration: underline; }
/* #3770: `#N` and `PR#N` chips in a message (channels.ts decorateRefs).
   Here no drawer opens from them as in the app chat, so a chip is a link
   only when the message names its project (the Homeroom bot's), and then it
   is a real one, opening that request. Any other is drawn as text: no
   pointer, no underline under it. The issue ink is --state-ok, which in dark
   is the chip's own emerald; in light the emerald read at 1.6:1 on the
   sheet, and on a lighter wash the green reads at 4.5:1. */
.messages-markdown .gc-ref-issue { color: var(--state-ok); }
html:not(.dark) .messages-markdown .gc-ref-issue { background: rgba(16, 185, 129, 0.05); }
/* One request, one story: in the bot's own words a request's number wears its
   tag's hue (frontend/src/features/messages/bot-request.ts), the same as in
   the activity drawer, the chat's rows and its thread's header. The `html`
   prefix outranks the light wash above. */
html .messages-markdown .gc-ref-issue.gc-ref-request { color: hsl(var(--req-h) 70% 26%); background: hsl(var(--req-h) 80% 92%); font-weight: 600; }
html.dark .messages-markdown .gc-ref-issue.gc-ref-request { color: hsl(var(--req-h) 80% 82%); background: hsl(var(--req-h) 35% 25%); }
.messages-markdown a.gc-ref { text-decoration: none; }
.messages-markdown span.gc-ref { cursor: auto; }
@media (hover: hover) {
  .messages-markdown a.gc-ref:hover { text-decoration: underline; }
  .messages-markdown span.gc-ref:hover { filter: none; text-decoration: none; }
}

/* A CONTINUATION LINE (#2783): the same person again, close behind their
   last message. No avatar and no name — the time of day sits in the 36px
   column the avatar would, muted, and only on hover (always on touch), which
   is Discord's arrangement. */
.messages-message-grouped { padding-top: 1px; padding-bottom: 1px; }
.messages-message-gutter {
  flex: 0 0 36px;
  width: 36px;
  padding-top: 4px;
  text-align: right;
  font-size: 11px;
  line-height: 1.4;
  color: var(--text-muted);
  visibility: hidden;
}
.messages-message-grouped:hover .messages-message-gutter { visibility: visible; }
@media (hover: none) {
  .messages-message-gutter { visibility: visible; }
}

/* ── The bot's change blocks (#4564) ────────────────────────────────────
   In the chat with Homeroom bot, everything it says about one change is
   one outlined block led by that change's card: the same hairline and
   rounding the cards already wear (20px, one hairline, no fill, so the
   cards inside keep their own surface), drawn around them. The rows keep
   every part of a row they had — their id, their data, their action bar —
   and only the outline and the vertical padding between them change, so
   the line runs unbroken where one row continues into the next. The part
   classes sit on the row (message-row.tsx); the box they draw is its
   content column, which is where the cards already stop (`.messages-
   object-list`'s 480px, plus this padding). The phone's row padding
   (the @media rule above for .messages-message) stays: a narrow column
   keeps the same 12px inside the line. */
.messages-bot-block-body {
  max-width: 506px;
  padding: 0 12px;
  border-left: 1px solid var(--app-sheet-line);
  border-right: 1px solid var(--app-sheet-line);
}
.messages-bot-block-first .messages-bot-block-body,
.messages-bot-block-only .messages-bot-block-body {
  border-top: 1px solid var(--app-sheet-line);
  border-radius: 20px 20px 0 0;
  padding-top: 10px;
}
.messages-bot-block-last .messages-bot-block-body,
.messages-bot-block-only .messages-bot-block-body {
  border-bottom: 1px solid var(--app-sheet-line);
  border-radius: 0 0 20px 20px;
  padding-bottom: 10px;
}
.messages-bot-block-only .messages-bot-block-body { border-radius: 20px; }
.messages-bot-block-first, .messages-bot-block-only { margin-top: 6px; }
/* The row's own vertical padding gives way where the block continues, so
   the outline is unbroken from the top of its first row to the foot of its
   last. */
.messages-bot-block-first, .messages-bot-block-middle { padding-bottom: 0; }
.messages-bot-block-middle, .messages-bot-block-last { padding-top: 0; }

/* ── The bubble transcript (a direct conversation) ───────────────────────
   Two participants, one of them you, so side and surface say who is
   speaking and no name is drawn. Theirs is an opaque white bubble on the
   left; yours is on the right in the accent as a TINT — the same 8% the dev
   chat gives your turns, with the page's own ink, so a quoted reply or a
   link inside it keeps its normal colours and no inverted token is needed.
   The gutter is the cue; the tint confirms it. */
.messages-message-bubble {
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  padding: 4px 16px;
}
.messages-message-bubble.messages-message-self { align-items: flex-end; }
.messages-bubble-wrap {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: inherit;
  max-width: 78%;
  min-width: 0;
}
.messages-bubble {
  min-width: 0;
  border-radius: 20px;
  padding: 10px 16px;
  color: var(--text-primary);
  background: var(--messages-surface);
  font-size: 17px;
  line-height: 1.3;
  overflow-wrap: anywhere;
}
.messages-message-self .messages-bubble { background: var(--accent-tint); }
.messages-message-bubble .messages-attachments,
.messages-message-bubble .messages-object-list { width: 100%; max-width: 78%; }
.messages-message-meta {
  padding: 0 6px;
  color: var(--text-muted);
  font-size: 13px;
}

/* ── The hover bar (#2387) ──────────────────────────────────────────────
   Every chat row's controls in one floating pill, Discord's shape: the three
   recent reactions, a divider, then the picker, Reply, Save and ⋯. It sits
   on the row's top edge at the trailing side, half over the row above, so it
   never covers the text it acts on. Hover-revealed on a pointer (and on
   focus-within, for the keyboard); on a touch screen it is not drawn at all
   and a long press opens the sheet (.msgx-sheet) instead. The shared parts
   are the `.msgx-*` rules in the next section; this is where the Messages
   row places it. */
.messages-message-actions {
  position: absolute;
  z-index: 6;
  top: -16px;
  right: 16px;
  display: none;
}
.messages-message:hover .messages-message-actions,
.messages-message:focus-within .messages-message-actions,
.messages-message-actions.msgx-bar-open { display: flex; }

/* The tray of a message still in flight keeps its place but draws nothing
   (#2907) — on touch it is a line of the row, and growing it only once the
   send confirmed moved every row under it. */
.messages-message-actions.messages-message-actions-reserved { visibility: hidden; }

/* A saved message carries the accent as an inset bar down its row (#1280),
   so it reads as saved without being hovered; the bar's bookmark is solid
   and accented while the pill is up. */
.messages-message-saved:not(.messages-message-bubble) { box-shadow: inset 3px 0 0 var(--accent); }

/* The app chat's rows place the same bar (#2387): on the row's top edge,
   trailing side, over the row above, revealed on hover and focus-within. The
   row's own actions slot is in the header's flow, so the host takes no width
   and the bar floats out of it. */
.gc-msg { position: relative; }
.gc-msg-bar-host { display: contents; }
.gc-msg-actions {
  position: absolute;
  z-index: 6;
  top: -16px;
  right: 16px;
  display: none;
}
.gc-msg:hover .gc-msg-actions,
.gc-msg:focus-within .gc-msg-actions,
.gc-msg-actions.msgx-bar-open { display: flex; }
.gc-msg-deleted-text { color: var(--text-muted); font-size: 15px; font-style: italic; }
.gc-reply-count {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 4px 16px 8px;
  color: var(--text-muted);
  font-size: 13px;
}
.gc-reply-count::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--border-light); }

/* ── The message actions, shared (#2387) ─────────────────────────────────
   The pill, the ⋯ menu, the emoji picker and the phone's long-press sheet,
   drawn identically on the Messages screen and in an app's chat
   (frontend/src/features/message-actions/). Floating surfaces take the
   dense float fill (#2887), so what is under them never shows through. */
.msgx-bar {
  align-items: center;
  gap: 2px;
  padding: 3px;
  border: 1px solid var(--border-light);
  border-radius: 12px;
  background: var(--dc-float-fill);
  -webkit-backdrop-filter: var(--dc-frost);
  backdrop-filter: var(--dc-frost);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.1);
}
.dark .msgx-bar { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45); }
.msgx-bar > button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  color: var(--text-secondary);
  background: transparent;
  cursor: pointer;
  transition: background 120ms, color 120ms;
}
.msgx-bar > button:hover,
.msgx-bar > button:focus-visible { color: var(--text-primary); background: var(--msgx-hover, rgba(10, 10, 10, 0.06)); outline: none; }
.dark .msgx-bar > button:hover,
.dark .msgx-bar > button:focus-visible { background: rgba(255, 255, 255, 0.1); }
.msgx-bar > button svg { display: block; width: 18px; height: 18px; }
.msgx-bar-emoji { font-size: 18px; line-height: 1; }
.msgx-bar-emoji:hover { transform: scale(1.12); }
.msgx-bar > .msgx-bar-emoji-on { background: var(--accent-wash); }
.msgx-bar > .msgx-bar-icon-on { color: var(--text-primary); background: rgba(10, 10, 10, 0.06); }
.dark .msgx-bar > .msgx-bar-icon-on { background: rgba(255, 255, 255, 0.1); }
.msgx-bar > .msgx-bar-icon-accent,
.msgx-bar > .msgx-bar-icon-accent:hover { color: var(--accent); }
.msgx-bar-divider { width: 1px; height: 20px; margin: 0 3px; background: var(--border-light); }
@media (prefers-reduced-motion: reduce) {
  .msgx-bar-emoji:hover { transform: none; }
}

/* The ⋯ menu and the picker hang off the pill: below it, or above when the
   row is near the foot of the transcript (placementFor). */
.msgx-menu,
.msgx-picker {
  position: absolute;
  z-index: 30;
  right: 0;
  border-radius: 14px;
  background: var(--dc-float-fill);
  -webkit-backdrop-filter: var(--dc-frost);
  backdrop-filter: var(--dc-frost);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18), 0 0 0 1px var(--border-light);
}
.msgx-menu-below, .msgx-picker-below { top: calc(100% + 6px); }
.msgx-menu-above, .msgx-picker-above { bottom: calc(100% + 6px); }
.msgx-menu {
  display: flex;
  flex-direction: column;
  min-width: 14rem;
  padding: 4px;
}
.msgx-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  border: 0;
  border-radius: 10px;
  color: var(--text-primary);
  background: transparent;
  font: inherit;
  font-size: 14px;
  text-align: left;
  white-space: nowrap;
  cursor: pointer;
}
.msgx-menu-item:hover,
.msgx-menu-item:focus-visible { background: rgba(10, 10, 10, 0.06); outline: none; }
.dark .msgx-menu-item:hover,
.dark .msgx-menu-item:focus-visible { background: rgba(255, 255, 255, 0.1); }
.msgx-menu-item:disabled { opacity: 0.45; cursor: default; }
.msgx-menu-glyph { flex: 0 0 auto; width: 17px; height: 17px; color: var(--text-muted); }
.msgx-menu-danger,
.msgx-menu-danger .msgx-menu-glyph { color: #c81e1e; }
.dark .msgx-menu-danger,
.dark .msgx-menu-danger .msgx-menu-glyph { color: #f87171; }
.msgx-menu-rule { height: 1px; margin: 4px 8px; background: var(--border-light); }

.msgx-picker {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 344px;
  max-width: calc(100vw - 24px);
  padding: 10px;
  box-sizing: border-box;
}
.msgx-picker-inline {
  position: static;
  width: 100%;
  max-width: none;
  padding: 0;
  background: transparent;
  box-shadow: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
.msgx-picker-search {
  display: flex;
  align-items: center;
  gap: 8px;
  height: 36px;
  padding: 0 10px;
  border-radius: 10px;
  color: var(--text-faint);
  background: var(--bg-secondary);
}
.msgx-picker-search input {
  flex: 1 1 auto;
  min-width: 0;
  border: 0;
  outline: none;
  color: var(--text-primary);
  background: transparent;
  font: inherit;
  font-size: 14px;
}
.msgx-picker-tabs { display: flex; justify-content: space-between; }
.msgx-picker-tab {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 30px;
  border: 0;
  border-radius: 8px;
  color: var(--text-muted);
  background: transparent;
  cursor: pointer;
}
.msgx-picker-tab:hover { color: var(--text-primary); }
.msgx-picker-tab-active { color: var(--accent-wash-ink); background: var(--accent-wash); }
.msgx-picker-scroll { height: 264px; overflow-y: auto; overscroll-behavior: contain; }
.msgx-picker-inline .msgx-picker-scroll { height: min(46vh, 340px); }
.msgx-picker-head {
  margin: 6px 2px 4px;
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.msgx-picker-grid { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 2px; }
.msgx-picker-emoji {
  height: 36px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
}
.msgx-picker-emoji:hover,
.msgx-picker-emoji:focus-visible { background: var(--bg-secondary); outline: none; }
.msgx-picker-empty { padding: 24px 8px; color: var(--text-muted); font-size: 14px; text-align: center; }
.msgx-picker-foot {
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 24px;
  padding: 2px 2px 0;
  border-top: 1px solid var(--border-light);
  color: var(--text-muted);
  font-size: 13px;
}
.msgx-picker-foot-emoji { font-size: 20px; }

/* The phone's sheet: a scrim, and a sheet rising from the bottom edge. */
.msgx-sheet-layer { position: fixed; z-index: 2100; inset: 0; }
.msgx-sheet-scrim { position: absolute; inset: 0; border: 0; background: rgba(0, 0, 0, 0.32); }
.msgx-sheet {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
  max-height: 88vh;
  padding: 8px 16px calc(16px + var(--platform-safe-bottom, 0px));
  overflow-y: auto;
  border-radius: 20px 20px 0 0;
  background: var(--dc-sheet-raise);
  box-shadow: 0 -8px 30px rgba(0, 0, 0, 0.2);
  outline: none;
  animation: msgx-sheet-in 180ms ease-out;
}
@keyframes msgx-sheet-in { from { transform: translateY(24px); opacity: 0.6; } to { transform: none; opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .msgx-sheet { animation: none; } }
.msgx-sheet-grabber { align-self: center; width: 36px; height: 5px; border-radius: 3px; background: var(--border); }
.msgx-sheet-preview {
  overflow: hidden;
  color: var(--text-muted);
  font-size: 15px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.msgx-sheet-preview strong { color: var(--text-primary); }
.msgx-sheet-reactions { display: flex; gap: 10px; }
.msgx-sheet-emoji {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  border: 0;
  border-radius: 9999px;
  color: var(--text-secondary);
  background: var(--bg-secondary);
  font-size: 26px;
  line-height: 1;
}
.msgx-sheet-emoji-on { background: var(--accent-wash); }
.msgx-sheet-list { display: flex; flex-direction: column; overflow: hidden; border-radius: 14px; background: var(--bg-secondary); }
.msgx-sheet-item {
  display: flex;
  align-items: center;
  gap: 14px;
  min-height: 48px;
  padding: 0 16px;
  border: 0;
  box-shadow: inset 0 -1px 0 var(--border-light);
  color: var(--text-primary);
  background: transparent;
  font: inherit;
  font-size: 17px;
  text-align: left;
}
.msgx-sheet-item:last-child { box-shadow: none; }
.msgx-sheet-item-separated { margin-top: 10px; border-top: 10px solid var(--dc-sheet-raise); }
.msgx-sheet-glyph { flex: 0 0 auto; width: 20px; height: 20px; color: var(--text-muted); }
.msgx-sheet-danger, .msgx-sheet-danger .msgx-sheet-glyph { color: #c81e1e; }
.dark .msgx-sheet-danger, .dark .msgx-sheet-danger .msgx-sheet-glyph { color: #f87171; }

/* No hover, no bar: a long press opens the sheet. */
@media (hover: none) {
  .msgx-bar { display: none !important; }
}

/* The quoted reply above a message, and the composer's staged-reply chip:
   one border, one author line, one snippet line, drawn twice on purpose so
   staging a reply looks like what it is about to produce. */
.messages-quote,
.messages-reply-draft {
  width: 100%;
  min-width: 0;
  border-left: 3px solid var(--accent);
  padding: 2px 10px;
  color: var(--text-muted);
  font-size: 15px;
  text-align: left;
}
.messages-quote { margin: 0 0 6px; }
.messages-quote span,
.messages-reply-draft span { display: block; color: var(--accent); font-weight: 700; }
.messages-quote p,
.messages-reply-draft p { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* #3624: the Homeroom bot's messages. Its name carries a small "Bot"
   badge; a question it asks carries its suggested answers as pills in the
   suggestion language Global Chat uses (the default first, marked), a
   "Something else" pill in the neutral fill, and one muted line saying the
   answer is posted publicly on the request. */
.messages-bot-badge {
  display: inline-flex;
  align-items: center;
  height: 18px;
  padding: 0 6px;
  border-radius: 6px;
  color: var(--text-secondary);
  background: var(--dc-raised);
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .02em;
}
.messages-bot-question { margin-top: 8px; }
.messages-bot-answers {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
}
.messages-bot-answers button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 36px;
  max-width: 100%;
  padding: 6px 13px;
  border-radius: 9999px;
  color: var(--brand-ink);
  background: var(--brand-tint);
  font-size: 13px;
  font-weight: 650;
  line-height: 1.2;
  text-align: left;
}
.messages-bot-answers button:hover { filter: brightness(.97); }
.messages-bot-answers .messages-bot-other,
.messages-bot-answers .messages-bot-secondary {
  color: var(--text-secondary);
  background: var(--dc-raised);
}
/* #3770: an offer's File it is the act, filled in the accent as Accept is;
   its Not now takes the neutral fill above. */
.messages-bot-answers .messages-bot-primary { color: var(--accent-ink); background: var(--accent); }
.messages-bot-answers .messages-bot-primary:hover { filter: none; background: var(--accent-light); }
/* #4046: "Notify me when it's ready" under the plan: an action in the
   accent's tint, quieter than the plan's Build it. */
.messages-bot-answers .messages-bot-tint { color: var(--accent-tint-ink); background: var(--accent-tint); }
/* Tapped, it turns grey and says so, with a check: settled, not pressable. */
.messages-bot-answers .messages-bot-done { color: var(--text-muted); background: var(--dc-raised); cursor: default; filter: none; }
/* Run six: a first version's "We'll tell you when it's live", on. Settled,
   so grey with a check as Done is; but a switch, so a tap turns it off. */
.messages-bot-answers .messages-bot-on { color: var(--text-muted); background: var(--dc-raised); }
/* B6: an answer tapped as a choice (a plan's, or one of two questions)
   takes the same accent fill. Nothing is sent until Build it. */
.messages-bot-answers button[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent); }
.messages-bot-answers button[aria-pressed="true"]:hover { filter: none; background: var(--accent-light); }
.messages-bot-default {
  font-size: 11px;
  font-weight: 600;
  opacity: .7;
}
.messages-bot-answered {
  margin-top: 4px;
  color: var(--text-muted);
  font-size: 13px;
}
/* #4197: a question once answered: its words as a small label, the answer
   under it as the tapped answer's accent pill, drawn as a chip (it is not a
   button any more). Only the answer chosen; the other options go. */
.messages-bot-choices {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0;
}
.messages-bot-choices dd { margin: 0; }
.messages-bot-choice-label {
  margin-bottom: 4px;
  color: var(--text-muted);
  font-size: 12px;
  font-weight: 600;
  line-height: 1.3;
}
.messages-bot-chosen {
  display: inline-flex;
  align-items: center;
  max-width: 100%;
  min-height: 32px;
  padding: 6px 13px;
  border-radius: 9999px;
  color: var(--accent-ink);
  background: var(--accent);
  font-size: 13px;
  font-weight: 650;
  line-height: 1.2;
  overflow-wrap: anywhere;
}
/* #4197: Build it pressed: a check, then "Building it". */
.messages-bot-done {
  display: flex;
  align-items: center;
  gap: 5px;
}
/* B5: the bot's hello, above the first card it sends somebody. */
.messages-bot-hello {
  margin: 0 0 6px;
}
.messages-bot-note {
  display: flex;
  align-items: flex-start;
  gap: 5px;
  margin-top: 6px;
  color: var(--text-muted);
  font-size: 12px;
  line-height: 1.35;
}
.messages-bot-note svg { margin-top: 1px; }
.messages-reply-draft .messages-bot-note { display: block; white-space: normal; }
/* Reaction pills, the language's own: a filled neutral chip, and the one
   that is YOURS on a tinted accent ground with accent ink — a fill, not an
   outline, because a bordered pill among filled ones reads as a different
   component rather than a different state. */
.messages-reactions { display: flex; flex-wrap: wrap; gap: 6px; }
.messages-message:not(.messages-message-bubble) .messages-reactions { margin-top: 8px; }
.messages-reactions button {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 32px;
  padding: 0 12px;
  border-radius: 9999px;
  color: var(--text-primary);
  background: var(--bg-tertiary);
  font-size: 15px;
  font-weight: 500;
  transition: background 150ms;
}
.messages-reactions button:hover { background: var(--border); }
.messages-reactions .messages-reaction-mine { color: var(--accent-wash-ink); background: var(--accent-wash); }
.messages-edit textarea {
  width: 100%;
  min-height: 56px;
  /* #1408: the ceiling lib/use-auto-grow.ts grows into. Taller than the
     composer's because an edit box holds a message that already exists — it
     starts full rather than empty, so stopping it at the composer's height
     would put most edits behind a scrollbar immediately. */
  max-height: 260px;
  margin-top: 3px;
  border: 1px solid var(--accent-light);
  border-radius: 14px;
  padding: 9px 12px;
  color: var(--text-primary);
  background: var(--messages-surface);
  font-size: 17px;
  line-height: 1.3;
  /* `resize: vertical` stays: the grow is a floor on convenience, not a
     ceiling on choice, and a hand-dragged height is a deliberate act the next
     keystroke should not undo. */
  resize: vertical;
  overflow-y: auto;
}
.messages-edit > div { display: flex; gap: 12px; margin-top: 6px; }
.messages-edit button { color: var(--accent); font-size: 15px; font-weight: 600; }

.messages-report {
  display: grid;
  gap: 8px;
  width: 100%;
  max-width: 26rem;
  border-radius: 16px;
  padding: 12px;
  background: var(--messages-surface);
}
.messages-message:not(.messages-message-bubble) .messages-report { margin-top: 8px; }
.messages-report label { display: grid; gap: 4px; color: var(--text-secondary); font-size: 13px; font-weight: 600; }
.messages-report select,
.messages-report textarea {
  width: 100%;
  border: 1px solid var(--border-light);
  border-radius: 10px;
  padding: 8px 10px;
  color: var(--text-primary);
  background: var(--bg-input);
  font-size: 15px;
}
.messages-report textarea { resize: vertical; }
.messages-report > div { display: flex; gap: 12px; }
.messages-report button { color: var(--accent); font-size: 15px; font-weight: 600; }
.messages-report button:disabled { opacity: .55; }

/* Files and shared items: the language's cards — white, rounded, no border,
   a neutral glyph tile leading. */
.messages-attachments,
.messages-object-list { display: grid; gap: 6px; max-width: 480px; }
.messages-message:not(.messages-message-bubble) .messages-attachments,
.messages-message:not(.messages-message-bubble) .messages-object-list { margin-top: 8px; }
.messages-attachment,
.messages-object-card,
.messages-pending-object {
  display: flex;
  align-items: center;
  gap: 12px;
  min-width: 0;
  border-radius: 16px;
  padding: 10px 12px;
  color: var(--text-primary);
  background: var(--messages-surface);
  font-size: 15px;
  text-decoration: none;
  transition: background 150ms;
}
.messages-attachment img { width: 160px; max-height: 120px; border-radius: 10px; object-fit: cover; }
.messages-attachment span { color: var(--text-muted); font-size: 13px; }
.messages-file-icon,
.messages-object-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 12px;
  color: var(--text-primary);
  background: var(--messages-surface-2);
  font-size: 17px;
  font-weight: 700;
}
.messages-attachment-view { color: var(--accent); font-size: 13px; font-weight: 600; }
.messages-object-card:hover { background: var(--messages-surface-2); }
.messages-object-unavailable { opacity: .6; }
/* #3660: a Homeroom link's card under a message in an app's discussion
   (features/messages/link-cards.tsx). That transcript is drawn on the app's
   own pages as well as in this inbox — outside .messages-layout, where the
   card's two surfaces are not defined — so there it wears the surface the
   discussion's own boxes do (.gc-event-box): the frosted sheet fill and its
   hairline. */
.gc-msg .messages-link-embeds { margin-top: 6px; }
.gc-msg .messages-link-embeds .messages-object-card {
  background-color: var(--dc-sheet-fill);
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
}
.gc-msg .messages-link-embeds .messages-object-card:hover { background-color: var(--bg-tertiary); }
.gc-msg .messages-link-embeds .messages-object-icon { background: var(--bg-tertiary); }
/* #2884: under the first card of a folded run, in the column the message
   writes in (16px inset + 36px avatar + 12px gap). */
.messages-card-run { padding: 0 16px 6px 64px; }

.messages-typing {
  flex: 0 0 auto;
  min-height: 18px;
  padding: 2px 20px;
  color: var(--text-muted);
  font-size: 13px;
}

/* ── The composer ────────────────────────────────────────────────────────
   One white card at the foot of the sheet: bare glyphs, a borderless field
   at reading size, and the round accent send disc the dev chat draws. The
   BAR around the card is what carries the home-indicator inset
   (`platform-safe-bar`), so on a notched phone the gap under the card
   grows and the card itself does not. */
.messages-composer,
.messages-composer-disabled {
  position: relative;
  flex: 0 0 auto;
  padding: 6px 12px 8px;
}
.messages-composer-card {
  border-radius: 22px;
  padding: 8px 8px 8px 12px;
  background: var(--messages-surface);
  box-shadow: var(--messages-disc-shadow);
  transition: box-shadow 150ms;
}
/* #1954: FOCUS LIVES ON THE CARD, as it does on the dev chat's `.dc-card`.
   The textarea draws no box of its own (`outline: none`, transparent), so a
   caret was the only sign the field was live; the ring goes around the whole
   input box instead. `:focus-within` because the card is one control group —
   the attach and share buttons are still "in the composer". */
.messages-composer-card:focus-within {
  box-shadow: var(--messages-disc-shadow), 0 0 0 2px var(--accent);
}
@media (prefers-reduced-motion: reduce) {
  .messages-composer-card { transition: none; }
}
.messages-composer-disabled { padding: 14px 16px; color: var(--text-muted); font-size: 15px; text-align: center; }
/* QA 2026-09-24 Q2: a direct request the other person has not accepted yet.
   Before the opening message, one muted line over the card says what it will
   be; after it, the card itself gives way to the invitation banner's tinted
   panel, which names who the thread is waiting for. */
.messages-composer-hint { margin: 0 4px 6px; color: var(--text-muted); font-size: 13px; line-height: 1.35; }
.messages-awaiting {
  padding: 12px 16px;
  border-radius: 16px;
  background: var(--accent-tint);
  font-size: 15px;
  line-height: 1.35;
}
.messages-awaiting strong { display: block; color: var(--accent); font-size: 15px; font-weight: 600; }
.messages-awaiting p { margin-top: 2px; color: var(--text-muted); }
.messages-composer-input {
  flex: 1 1 auto;
  min-width: 0;
  max-height: 140px;
  min-height: 40px;
  border: 0;
  border-radius: 0;
  padding: 9px 4px;
  color: var(--text-primary);
  background: transparent;
  font-size: 17px;
  line-height: 22px;
  outline: none;
  resize: none;
  /* #1408: lib/use-auto-grow.ts sets `height` to the content height, and
     `max-height` above is what stops it. Past that ceiling the extra lines
     have to be reachable, so the box scrolls rather than clipping them. */
  overflow-y: auto;
  /* #2882: ONE OUTLINE, THE CARD'S. The ring above goes around the whole
     composer; the field inside it must draw no edge of its own in any
     engine. `outline: none` covers the focus ring, but WebKit on iOS also
     paints a native textarea box (a hairline and an inset shade) until the
     appearance is reset, and a tap flashes its highlight over the field —
     both read as a second, inner outline around just the text area. */
  -webkit-appearance: none;
  appearance: none;
  box-shadow: none;
  -webkit-tap-highlight-color: transparent;
}
/* #2387: FIREFOX'S RING. Tailwind's preflight ships
   `:-moz-focusring { outline: auto }`, and tailwind.css loads AFTER this file
   (the order head.html pins) at the same specificity as `outline: none`
   above — so in Gecko the later rule won and the field drew its own focus
   ring, a second rectangle inside the card's. Chromium and WebKit drop the
   `-moz-` selector, which is why only Firefox showed it. One more class
   outranks preflight without touching the stylesheet order. */
.messages-composer-card .messages-composer-input { outline: none; }
.messages-composer-input::placeholder { color: var(--text-faint); }

/* ── The agent session's composer (#2779 follow-up) ──────────────────────
   The same rule as the card above, for features/agent-session's form: the
   outline goes around the WHOLE composer card while anything in it has
   focus (the field, the model pickers, the button), and the field inside
   draws none of its own. The card keeps its 1px border, so the ring is that
   border turned accent plus one more pixel outside it: two pixels, as on
   Messages, with no shift in layout. */
.agent-session-composer {
  transition: border-color 150ms, box-shadow 150ms;
}
/* `form.` outranks the card's own `dark:border-zinc-700` (two classes, and
   tailwind.css loads last), so the dark theme turns accent too. */
form.agent-session-composer:focus-within {
  border-color: var(--accent);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05), 0 0 0 1px var(--accent);
}
@media (prefers-reduced-motion: reduce) {
  .agent-session-composer { transition: none; }
}
/* #2882's reset (WebKit's native textarea box and tap flash) and #2387's
   Firefox fix: preflight's `:-moz-focusring { outline: auto }` loads later
   at one class's specificity, so the second class here is what keeps Gecko
   from drawing a ring around the field inside the card's. */
.agent-session-composer .agent-session-composer-input {
  outline: none;
  -webkit-appearance: none;
  appearance: none;
  box-shadow: none;
  -webkit-tap-highlight-color: transparent;
}

/* The Mayor at work (features/agent-session LiveTurn): three dots where its
   reply will appear, rising in turn. Still dots under reduced motion. */
.agent-session-typing-dot {
  animation: agent-session-typing 1.2s ease-in-out infinite;
}
.agent-session-typing-dot:nth-child(2) { animation-delay: 0.15s; }
.agent-session-typing-dot:nth-child(3) { animation-delay: 0.3s; }
@keyframes agent-session-typing {
  0%, 80%, 100% { opacity: 0.35; transform: translateY(0); }
  40% { opacity: 1; transform: translateY(-3px); }
}
@media (prefers-reduced-motion: reduce) {
  .agent-session-typing-dot { animation: none; opacity: 0.6; }
}
.messages-composer-action {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 36px;
  height: 36px;
  border-radius: 9999px;
  color: var(--text-secondary);
  transition: background 150ms;
}
.messages-composer-action svg { stroke-width: 1.8; }
.messages-composer-action:hover { background: var(--messages-surface-2); }
.messages-composer-action:disabled { opacity: .35; }
/* #1955 — the one "+" and the two named rows it opens. The wrapper is the
   positioning context AND the outside-press boundary, so the menu has to
   live inside it rather than beside the button. */
.messages-composer-add { position: relative; flex: 0 0 auto; }
.messages-composer-menu {
  position: absolute;
  z-index: 25;
  bottom: calc(100% + 6px);
  left: 0;
  min-width: 176px;
  padding: 6px;
  border-radius: 16px;
  background: var(--messages-surface);
  box-shadow: 0 -7px 20px rgba(0, 0, 0, .12);
}
.messages-composer-menu button {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  /* 40px tall: this is a phone target, and the control it replaced was two
     36px circles the width of a fingertip apart. */
  padding: 9px 12px;
  border-radius: 10px;
  color: var(--text-primary);
  font-size: 15px;
  text-align: left;
  white-space: nowrap;
}
.messages-composer-menu button svg { width: 18px; height: 18px; stroke-width: 1.8; flex: 0 0 auto; }
.messages-composer-menu button:hover:not(:disabled) { background: var(--messages-surface-2); }
.messages-composer-menu button:disabled { opacity: .35; }
.messages-send {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 9999px;
  color: var(--accent-ink);
  background: var(--accent);
  transition: background 150ms;
}
.messages-send svg { width: 20px; height: 20px; stroke-width: 2.4; }
.messages-send:hover { background: var(--accent-light); }
.messages-send:disabled { opacity: .35; }
/* #3735: ONE COMPACT LINE ON A PHONE. Below 768px the card is the "+", the
   field and the send disc on a single 44px row, and it grows only with what
   is typed, up to the field's 140px ceiling as everywhere. It was about 75px:
   8px of padding round a 40px row, plus the character count's line, which is
   laid out even while empty (composer.tsx says why) and so drew a blank band
   under the field. On a phone:

   - the card pads 4px above and below a 36px row, 4 + 36 + 4 = 44, so with
     its 22px radius it is a pill whose send disc sits concentric in the
     right-hand cap;
   - the field is 36px tall (7 + 22 + 7) and keeps its 17px type;
   - the count's line is not laid out until the text nears the limit, where
     it appears in amber (`data-near-limit`); #4202 made that true at every
     width (the rule after this block);
   - the "+" and the disc are drawn at 36px and stay 44px tap targets, the
     platform's minimum: a pseudo-element overhangs each by 4px, into the
     card's padding and the row's 6px gap, and so never over the field.

   The bar round the card is not touched: the tab bar, home-indicator and
   keyboard clearances (`.platform-safe-bar`, `.platform-kb-column`) are as
   they were. From 768px up nothing here applies. */
@media (max-width: 767px) {
  .messages-composer-card { padding: 4px 4px 4px 8px; }
  /* A reply, a shared item or files staged above the row keep the 8px the
     card always had over them: at 4px the reply's square left edge would
     cross the card's rounded corner. */
  .messages-composer-card > .messages-reply-draft:first-child,
  .messages-composer-card > .messages-pending-object:first-child,
  .messages-composer-card > .dc-attach-strip:first-child { margin-top: 4px; }
  .messages-composer-input { min-height: 36px; padding: 7px 4px; }
  .messages-send { width: 36px; height: 36px; }
  /* The "+" sits in its menu's wrapper, which as a block would hold it on a
     text line whose strut can add a few pixels under it: a flex box is
     exactly the button's height, so the row is the field's 36px. */
  .messages-composer-add { display: flex; }
  .messages-composer-action,
  .messages-send { position: relative; }
  .messages-composer-action::after,
  .messages-send::after { content: ''; position: absolute; inset: -4px; }
}
/* #4202: THE SAME CARD ON DESKTOP AS AN APP CHANNEL'S. The count's line was
   laid out on desktop even while empty, a 4px gap and a 15px blank band
   under the row, so every Messages composer (the Homeroom bot's thread, a
   DM, a group, #general, a reply thread) stood about 75px against the app
   channel's 56px (`.gc-composer-card`: 8 + 40 + 8). It is now laid out at
   every width only once the text nears the limit, where it appears in amber,
   so the card is that same 8 + 40 + 8. The element stays in the DOM (a
   declared check selects it). Two selectors' worth of specificity, so it
   outranks the element's own `flex` although tailwind.css loads later. */
.messages-composer-count:not([data-near-limit]) { display: none; }
.messages-reply-draft,
.messages-pending-object { margin-bottom: 6px; }
.messages-reply-draft {
  display: flex;
  align-items: center;
  border-radius: 0 12px 12px 0;
  padding: 6px 10px;
  background: var(--accent-tint);
}
.messages-pending-object { padding: 8px 12px; background: var(--messages-surface-2); }
/* #1927: the dismiss × is a 44px tap target, the platform's minimum. The
   negative block/inline margins let it overhang the strip's own 6–8px
   padding, so the bigger button does not make the strip any taller. */
.messages-reply-draft button,
.messages-pending-object button {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin: -6px -8px -6px auto;
  width: 44px;
  height: 44px;
  border-radius: 9999px;
  color: var(--text-muted);
  font-size: 20px;
}
.messages-mention-menu {
  position: absolute;
  z-index: 25;
  right: 12px;
  bottom: calc(100% - 3px);
  left: 12px;
  max-height: 180px;
  overflow-y: auto;
  padding: 6px;
  border-radius: 16px;
  background: var(--messages-surface);
  box-shadow: 0 -7px 20px rgba(0, 0, 0, .12);
}
.messages-mention-menu button { display: block; width: 100%; padding: 9px 12px; border-radius: 10px; color: var(--text-primary); font-size: 15px; text-align: left; }
.messages-mention-menu button:hover { background: var(--messages-surface-2); }
/* QA 2026-09-24 Q13: the row the arrow keys have highlighted, which Enter or
   Tab will pick. A tint of the accent rather than the hover grey, so the
   keyboard's choice still reads when the pointer is resting on another row. */
.messages-mention-menu button[aria-selected="true"] { background: var(--accent-tint); box-shadow: inset 3px 0 0 var(--accent); }
.messages-mention-menu .messages-channel-option-name { color: var(--text-muted); font-size: 13px; }
/* `:th` — the emoji shortcode menu (features/messages/composer.tsx). The
   mention menu's box; the heading stays put while the rows scroll, a row is
   the glyph beside its `:code:`, and the keyboard's row wears the hover. */
.messages-emoji-menu { max-height: none; overflow: visible; }
.messages-emoji-menu-heading { padding: 4px 12px 6px; color: var(--text-muted); font-size: 12px; font-weight: 600; }
.messages-emoji-menu-query { color: var(--text-primary); }
.messages-emoji-menu-list { max-height: min(288px, 38vh); overflow-y: auto; overscroll-behavior: contain; }
.messages-emoji-menu .messages-emoji-menu-list button { display: flex; align-items: center; gap: 10px; padding: 6px 12px; }
.messages-emoji-menu .messages-emoji-menu-list button[aria-selected="true"] { background: var(--messages-surface-2); }
.messages-emoji-option-glyph { flex: none; width: 24px; font-size: 20px; line-height: 24px; text-align: center; }
.messages-emoji-option-code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.messages-composer-dragging .messages-composer-card { outline: 2px solid var(--accent); outline-offset: -2px; }
.messages-drop-overlay,
.attach-drop-overlay {
  position: absolute;
  z-index: 30;
  inset: 4px 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px dashed var(--accent-light);
  border-radius: 22px;
  color: var(--accent);
  background: color-mix(in srgb, var(--messages-surface) 94%, transparent);
  font-size: 15px;
  font-weight: 700;
  pointer-events: none;
}
/* #4065: the same outline over the other composers. It lies over the card
   itself rather than the bar around it, so it takes the card's own edges;
   the card is the positioned parent. `#feedback-form` is "Suggest an
   improvement", where the whole form is the drop zone. */
.attach-drop-overlay { inset: 0; border-radius: inherit; background: color-mix(in srgb, var(--bg-primary) 94%, transparent); }
.gc-composer-card,
#gc-form,
#gc-thread-form,
.dc-card,
.agent-session-composer,
#feedback-form { position: relative; }
#feedback-form > .attach-drop-overlay { inset: -6px; border-radius: 16px; }
/* The boxed thread composer inside a board card has no card radius of its own. */
#gc-form:not(.gc-composer-card) > .attach-drop-overlay,
#gc-thread-form:not(.gc-composer-card) > .attach-drop-overlay { border-radius: 12px; }

/* ── The two panes, and the width they are actually drawn at ────────────
   From 768px up both panes sit on the one strip: the list column fixed at
   22rem, the sheet rising beside it with its own margin so the strip shows
   around its shoulders. Below that exactly one pane is rendered at a time
   (the list until a row is picked, the thread after, the header bar's back
   arrow between them) and both stay full width. */
@media (min-width: 768px) {
  .messages-list-pane {
    flex: 0 0 22rem;
    width: 22rem;
    padding-right: 8px;
  }
  .messages-thread-pane {
    flex: 1 1 auto;
    margin: 14px 12px 0 8px;
  }
  .messages-thread-pane.messages-no-selection { margin: 0; }
}

/* ── Full width (#2387) ─────────────────────────────────────────────────
   The list folds away on a desktop so the open discussion — and a thread
   beside it — has the whole strip. The toggle ends every discussion pane's
   header, just before ⋯ where there is one, and is not drawn below 768px,
   where one pane is shown at a time anyway. Its glyph says the state (arrows
   out, arrows in), so it keeps the disc's ink rather than turning accent. */
.messages-list-toggle { display: none; }
@media (min-width: 768px) {
  .messages-list-toggle { display: inline-flex; }
  .messages-layout.messages-list-collapsed > .messages-list-pane { display: none; }
  .messages-layout.messages-list-collapsed > .messages-thread-pane { margin-left: 12px; }
}

/* ── A reply thread beside its chat (#2387) ─────────────────────────────
   A second sheet on the strip, to the right of the chat, Slack's layout.
   Below 1600px the list steps aside while a thread is open, so the chat
   between them keeps a readable measure (a 1360px laptop with the rail
   open left the chat under 300px wide); on a phone the thread covers the
   chat entirely and the header bar's back arrow returns to it. */
.messages-reply-pane {
  position: relative;
  display: flex;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
}
.messages-reply-host { display: flex; flex-direction: column; }
.messages-reply-host > * { flex: 1 1 auto; min-height: 0; }
@media (max-width: 767px) {
  .messages-layout.messages-has-reply-thread > .messages-thread-pane { display: none; }
  .messages-reply-pane { flex: 1 1 auto; width: 100%; margin-top: 12px; }
  /* One way back (polish N16): the header bar's arrow. The thread's own ✕
     beside it said the same thing a second time. A community page's copy
     (`.messages-layout-embedded`) keeps its ✕. */
  .messages-layout:not(.messages-layout-embedded) > .messages-reply-pane [data-reply-close] { display: none; }
}
/* #5691: the divider between a chat and its open thread, dragged to trade
   room between them. It sits in the 8px the thread pane's own margin leaves
   (`.messages-reply-pane` is positioned), so the layout never shifts. At
   rest it is the sheet's hairline; under the pointer or the keyboard it is
   the accent, as a change session's side pane divider is. Drawn from 768px
   only, where the two panes are side by side; on a phone the thread covers
   the chat. */
.messages-reply-resizer { display: none; }
@media (min-width: 768px) {
  .messages-reply-pane { flex: 0 0 var(--messages-reply-width, min(420px, 42%)); margin: 14px 12px 0 0; }
  /* A community page's embedded copy of a thread (#3653) keeps its own width:
     the divider is Messages' alone. */
  .messages-layout-embedded > .messages-reply-pane { flex-basis: min(420px, 42%); }
  .messages-layout-embedded > .messages-reply-pane > .messages-reply-resizer { display: none; }
  .messages-reply-resizer { display: block; position: absolute; left: -7px; top: 0; bottom: 0; width: 6px; z-index: 1;
    border-radius: 3px; cursor: col-resize; touch-action: none;
    background: linear-gradient(var(--app-sheet-line), var(--app-sheet-line)) center / 1px 100% no-repeat; }
  .messages-reply-resizer:hover, .messages-reply-resizer:focus-visible { background: var(--accent, #2563eb); outline: none; }
  .messages-reply-resizer:focus-visible { outline: 2px solid var(--accent, #2563eb); outline-offset: 1px; }
  .messages-layout.messages-has-reply-thread > .messages-thread-pane { margin-right: 8px; }
}
@media (min-width: 768px) and (max-width: 1599px) {
  .messages-layout.messages-has-reply-thread > .messages-list-pane { display: none; }
  /* The list is already out of the way; its toggle would do nothing. */
  .messages-layout.messages-has-reply-thread .messages-list-toggle { display: none; }
  .messages-layout.messages-has-reply-thread > .messages-thread-pane { margin-left: 12px; }
}

.messages-reply-count {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 2px 16px 8px;
  color: var(--text-muted);
  font-size: 13px;
}
.messages-reply-count::after { content: ''; flex: 1 1 auto; height: 1px; background: var(--border-light); }
.messages-composer-thread .messages-composer-card { box-shadow: var(--messages-disc-shadow); }

/* The "N replies · last reply" chip under a message a thread hangs off. */
.msgx-thread-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  max-width: 100%;
  margin-top: 6px;
  padding: 3px 8px 3px 4px;
  border: 1px solid transparent;
  border-radius: 10px;
  color: var(--text-muted);
  background: transparent;
  font: inherit;
  font-size: 13px;
  cursor: pointer;
  transition: background 120ms, border-color 120ms;
}
.msgx-thread-chip:hover,
.msgx-thread-chip:focus-visible { border-color: var(--border-light); background: var(--dc-sheet-raise); outline: none; }
.msgx-thread-chip-active { border-color: var(--accent-wash); background: var(--accent-wash); }
.msgx-thread-faces { display: inline-flex; }
.msgx-thread-faces > * { width: 22px !important; height: 22px !important; border-radius: 6px !important; box-shadow: 0 0 0 2px var(--dc-sheet-raise); font-size: 10px !important; }
.msgx-thread-faces > * + * { margin-left: -6px; }
.msgx-thread-count { color: var(--accent); font-weight: 700; white-space: nowrap; }
.msgx-thread-last { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.msgx-thread-chevron { flex: 0 0 auto; width: 14px; height: 14px; opacity: 0; transition: opacity 120ms; }
.msgx-thread-chip:hover .msgx-thread-chevron,
.msgx-thread-chip:focus-visible .msgx-thread-chevron { opacity: 1; }

/* The thread card (#2387 follow-up, Discord's layout): "3 replies ›" over
   the newest reply, in a white box under the message. `.msgx-thread-chip`
   stays on it for the selectors that find a thread by it; this block
   restyles it as the card. */
.msgx-thread-chip.msgx-thread-card,
.msgx-thread-activity-card {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  width: 100%;
  max-width: 460px;
  min-width: 0;
  box-sizing: border-box;
  margin-top: 8px;
  padding: 9px 12px 10px;
  border: 1px solid var(--border-light);
  border-radius: 14px;
  background: var(--dc-sheet-raise);
  color: var(--text-primary);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 120ms, box-shadow 120ms;
}
.msgx-thread-chip.msgx-thread-card:hover,
.msgx-thread-chip.msgx-thread-card:focus-visible,
.msgx-thread-activity-card:hover,
.msgx-thread-activity-card:focus-visible {
  border-color: var(--accent-wash);
  background: var(--dc-sheet-raise);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
  outline: none;
}
.msgx-thread-activity-card:focus-visible,
.msgx-thread-chip.msgx-thread-card:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
.msgx-thread-chip.msgx-thread-card.msgx-thread-chip-active { border-color: var(--accent); }
.msgx-thread-card-head { display: flex; align-items: center; gap: 6px; font-size: 14px; }
.msgx-thread-card-glyph { flex: 0 0 auto; width: 16px; height: 16px; color: var(--accent); }
.msgx-thread-card-chevron { flex: 0 0 auto; width: 14px; height: 14px; color: var(--text-muted); }
.msgx-thread-line { display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 14px; }
.msgx-thread-line > :first-child { flex: 0 0 auto; }
.msgx-thread-line-name { flex: 0 0 auto; font-weight: 700; }
.msgx-thread-line-text { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-secondary); }
.msgx-thread-line-time { flex: 0 0 auto; color: var(--text-muted); font-size: 13px; }

/* A thread's replies where they landed in the main transcript (#2387
   follow-up): the thread glyph in the column the faces use, then the card. */
.msgx-thread-activity { display: flex; align-items: flex-start; gap: 12px; padding: 2px 16px 6px; }
.msgx-thread-activity-glyph { flex: 0 0 36px; display: inline-flex; justify-content: center; padding-top: 18px; color: var(--text-muted); }
.msgx-thread-activity-glyph svg { width: 18px; height: 18px; }
.msgx-thread-activity-card { margin-top: 0; max-width: 520px; }
/* The app chat's faces are 44px wide: the glyph takes the same column, so
   the card lines up with the messages' text. */
#gc-messages .msgx-thread-activity-glyph { flex-basis: 44px; }
.msgx-thread-activity-head { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.msgx-thread-activity-what { flex: 0 0 auto; }
.msgx-thread-activity-root { min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--accent); font-weight: 600; }
.msgx-thread-activity-root-deleted { color: var(--text-muted); font-style: italic; font-weight: 400; }
.msgx-thread-activity-time { flex: 0 0 auto; }
.msgx-thread-activity-head .msgx-thread-card-chevron { margin-left: auto; }
/* A reply line's face, whichever surface drew it, at the card's 22px. */
.msgx-thread-face { display: inline-flex; flex: 0 0 auto; }
.msgx-thread-face > * { width: 22px !important; height: 22px !important; border-radius: 6px !important; font-size: 10px !important; }

/* A deleted message keeps its place and says so (#2387). */
.messages-deleted { color: var(--text-muted); font-size: 15px; font-style: italic; }

/* A message a link pointed at flashes once (#2387). */
.messages-message-focus { animation: messages-focus-flash 2.4s ease-out; }
@keyframes messages-focus-flash {
  0%, 30% { background: var(--accent-wash); }
  100% { background: transparent; }
}
@media (prefers-reduced-motion: reduce) {
  .messages-message-focus { animation: none; background: var(--accent-tint); }
}

/* A linked page stops short of the present: two ways back to it. */
.messages-newer { display: flex; justify-content: center; gap: 8px; padding: 8px 0; }

/* 16 Oct 2026: the agent chats nobody used this week, and the archived
   ones, behind a quiet link at the foot of Agents. Muted words in the
   titles' column (16px of padding + the 44px avatar + the 14px gap), and no
   chevron: a chevron there matched the sections' own folds and read as a
   third heading. */
.messages-inbox-older {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: 12px 16px 12px 74px;
  border: 0;
  color: var(--text-muted);
  background: transparent;
  font: inherit;
  font-size: 14px;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
}
.messages-inbox-older:hover,
.messages-inbox-older:focus-visible { color: var(--text-primary); text-decoration: underline; outline: none; }

/* #2883: THE TRANSCRIPT READS ONE STEP DOWN ON A DESKTOP. 17px is the phone's
   reading size, and the phone keeps it; beside a 22rem list whose previews
   and cards are already at 15px, a 17px body and a 17px bold name were the
   largest text on the screen. So from 768px up a message reads at the size
   the list's preview line and the shared-item cards use — 15px, with the
   header's name at 15 and its time at 13, the next step on the same scale —
   and the composer and the inline editor come down with it, so what you type
   is the size it will be drawn at. Nothing new on the scale: 17 → 15 → 13. */
@media (min-width: 768px) {
  .messages-message-head { min-height: 20px; font-size: 15px; }
  .messages-message-head time,
  .messages-message-head time ~ span { font-size: 13px; }
  .messages-markdown.gc-msg-content { font-size: 15px; line-height: 1.4; }
  .messages-composer-input { font-size: 15px; }
  .messages-edit textarea { font-size: 15px; }
}

/* A READABLE MEASURE (N14 of the 19 Oct polish board). A message's text ran
   the pane's whole width on a desktop: about 1,100px beside the sidebar and
   1,380px without it, a hundred and fifty characters a line. From 768px its
   lines stop at 72 characters; the face, the name line and the cards keep
   their own widths. A phone is narrower than this already. */
@media (min-width: 768px) {
  .messages-markdown.gc-msg-content {
    max-width: 72ch;
  }
}

/* ── An app's discussion, as a thread of this inbox (#2718 review) ──────
   The section is the BOX and features/group-chat draws the sheet inside it,
   so this adds no fill, no frost and no radius — see the note in the
   component. All it does is let the host fill the pane, because the sheet
   inside is `h-full` and a host that collapsed would give it nothing to be
   the height of. */
/* The agent row's delete, and the confirm that replaces the row (#2718
   review). Both came from the Improve panel's own list of these chats, which
   was the only surface that offered the delete — so it moved here with them
   rather than retiring with the panel. */
.messages-row-delete {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 9999px;
  color: var(--text-muted);
  opacity: 0;
  transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
}
.messages-conversation-row:hover .messages-row-delete,
.messages-row-delete:focus-visible { opacity: 1; }
.messages-row-delete:hover { background: var(--messages-surface-2); color: var(--text-primary); }
/* Touch has no hover to reveal it with, so it is simply there. */
@media (hover: none) { .messages-row-delete { opacity: 1; } }
.messages-row-confirm { gap: 8px; font-size: 14px; color: var(--text-primary); }
.messages-row-confirm-cancel,
.messages-row-confirm-delete {
  flex: 0 0 auto;
  min-height: 36px;
  padding: 0 10px;
  border-radius: 8px;
  font-weight: 600;
}
.messages-row-confirm-cancel { color: var(--text-muted); }
.messages-row-confirm-delete { color: #dc2626; }
.dark .messages-row-confirm-delete { color: #f87171; }

.messages-discussion-host { display: flex; flex-direction: column; min-height: 0; }
.messages-discussion-host > * { flex: 1 1 auto; min-height: 0; }

/* ── An agent thread, as a thread of this inbox (#2813) ─────────────────
   A global agent chat is the chat's own panel drawn on the conversation
   pane's sheet, so the panel adds no plane of its own. A dev session is
   DevChat's whole session view in a host, which draws its own sheet — the
   same arrangement as a discussion above, so the host only has to fill the
   pane. The bar above it carries what the pane adds: the session's own
   full-window address, for the spec and preview at full width, and the
   full-width toggle every discussion pane ends its header with. */
.messages-thread-agent { overflow: hidden; }
.global-chat-embedded { flex: 1 1 auto; }
/* In the pane the toolbar spans the sheet, as every discussion's header
   does, and only the transcript and composer keep their reading column —
   so the full-width toggle at its end stays under the pointer when the
   pane widens, rather than moving in with a centred column. */
.global-chat-embedded > .global-chat-toolbar { width: 100%; }
.messages-session-host { display: flex; flex-direction: column; min-height: 0; }
.messages-session-host > * { flex: 1 1 auto; min-height: 0; }
.messages-session-host[hidden] { display: none; }
.messages-session-bar {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  flex: 0 0 auto;
  padding: 0 4px 6px;
}
.messages-session-full {
  font-size: 12px;
  font-weight: 600;
  color: var(--text-muted);
}
.messages-session-full:hover { color: var(--text-primary); }
.messages-inbox-session-active > a { background: var(--messages-selected); }
/* #5378: A CHANGE IN FLIGHT TAKES THE INBOX'S OWN ROW GEOMETRY on the
   cards: a conversation row's 16px of padding and 14px gap around the 44px
   tile, so its tile shares the avatars' left edge and its title their 74px
   column (where the separators and "N older chats" already sit), in the
   rows' 15 over 13 (AGENTS.md). ../improve/session-row.tsx is the bell's row
   too, with Tailwind's own spacing, so this is scoped to the wrapper Messages
   puts round it; the desktop's one-line column overrides it below. */
.messages-inbox-session > a { min-height: 66px; gap: 14px; padding: 13px 16px; }
.messages-inbox-session > a > .min-w-0 > :first-child { font-size: 15px; font-weight: 650; line-height: 20px; }
.messages-inbox-session > a > .min-w-0 > :first-child + * { margin-top: 2px; font-size: 13px; line-height: 18px; }

/* Phone-width only. Each of these exists because a narrow column cannot lay
   the row out sideways; the thread pane on a desktop is up to ~59rem and can. */
@media (max-width: 767px) {
  .messages-invitation { align-items: flex-start; flex-direction: column; }
  .messages-invitation > div:last-child { align-self: stretch; }
  .messages-invite-actions { justify-content: stretch; }
  .messages-invite-decline, .messages-invite-block, .messages-invite-accept { flex: 1 1 auto; }
  .messages-message { padding-right: 12px; padding-left: 12px; }
  .messages-attachment img { width: 112px; }
}

/* ── Global Chat (experimental) ────────────────────────────────────────
   A normal hash-routed, React-owned sibling screen. App._showOnlyScreen owns
   mutual exclusion with the other roots, so this surface needs no body mode
   or cross-screen inert writes. Each durable session has a #chat/<uuid> URL. */
#global-chat-screen {
  position: relative;
  z-index: 8;
  width: 100%;
}
.global-chat-shell {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  min-width: 0;
  min-height: 0;
  width: 100%;
  overflow: hidden;
}
/* THE EXPERIMENTAL CHAT ENTRY IS THE INBOX'S NOW (#2718 review).
 * `.global-chat-new-chat-btn` styled a pill at the head of the Improve
 * panel's Chats section — first a header toggle flipping `Chat` / `Classic`,
 * then an entry point in the panel. The panel retired, and the control had
 * already been superseded: `#messages-new-agent` under the Messages inbox's
 * Agents filter starts a chat, gated on the same two flags, beside the rows
 * that resume one. One list, one way in.
 */
.global-chat-toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 0 0 auto;
  width: min(100%, 54rem);
  margin: 0 auto;
  padding: 16px 16px 8px;
}
.global-chat-toolbar h2 {
  color: var(--text-primary);
  font-size: 22px;
  font-weight: 750;
  line-height: 1.15;
}
.global-chat-toolbar h2 span {
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 500;
}
.global-chat-toolbar p {
  margin-top: 2px;
  color: var(--text-muted);
  font-size: 12px;
}
.global-chat-budget {
  margin-left: auto;
  color: var(--text-muted);
  font-size: 11px;
  white-space: nowrap;
}
.global-chat-new {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 4px;
  flex: 0 0 auto;
  min-height: 36px;
  padding: 0 12px;
  border-radius: 9999px;
  color: var(--text-primary);
  background: var(--dc-raised);
  font-size: 13px;
  font-weight: 650;
}
.global-chat-new:disabled { opacity: .45; }
.global-chat-transcript {
  flex: 1 1 auto;
  min-height: 0;
  width: min(100%, 54rem);
  margin: 0 auto;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 8px 16px 18px;
  scroll-padding-bottom: 100px;
}
.global-chat-turn {
  width: 100%;
  margin: 8px 0;
}
.global-chat-turn-user {
  display: flex;
  justify-content: flex-end;
}
.global-chat-turn-user p {
  max-width: min(78%, 38rem);
  padding: 9px 14px;
  border-radius: 18px;
  color: var(--text-primary);
  background: var(--accent-tint);
  font-size: 16px;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
.global-chat-turn-assistant {
  padding-right: min(8vw, 64px);
}
.global-chat-assistant-copy {
  margin: 0 0 8px;
  color: var(--text-primary);
  font-size: 16px;
  line-height: 1.4;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.global-chat-first-use,
.global-chat-unavailable {
  display: flex;
  flex: 1 0 auto;
  flex-direction: column;
  justify-content: center;
  min-height: min(48vh, 380px);
  padding: 28px 0;
}
.global-chat-first-use h3,
.global-chat-unavailable h3 {
  color: var(--text-primary);
  font-size: 24px;
  font-weight: 700;
  line-height: 1.2;
}
.global-chat-suggestion-hint {
  margin-top: 6px;
  color: var(--text-muted);
  font-size: 12px;
}
.global-chat-unavailable p {
  max-width: 34rem;
  margin-top: 5px;
  color: var(--text-muted);
  font-size: 15px;
}
.global-chat-suggestions,
.global-chat-inline-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 7px;
}
.global-chat-suggestions { margin-top: 10px; }
.global-chat-related-suggestions {
  display: flex;
  flex: 0 0 100%;
  flex-wrap: wrap;
  gap: 7px;
  padding-top: 1px;
}
.global-chat-related-suggestions button {
  color: var(--text-secondary);
  background: var(--dc-raised);
}
.global-chat-suggestions button,
.global-chat-inline-actions button,
.global-chat-expand,
.global-chat-history {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 36px;
  padding: 0 13px;
  border-radius: 9999px;
  color: var(--brand-ink);
  background: var(--brand-tint);
  font-size: 13px;
  font-weight: 650;
  line-height: 1.1;
}
.global-chat-suggestions button:hover,
.global-chat-inline-actions button:hover,
.global-chat-expand:hover,
.global-chat-history:hover { filter: brightness(.97); }
.global-chat-suggestions .global-chat-more-suggestions,
.global-chat-inline-actions button:last-child,
.global-chat-expand,
.global-chat-history {
  color: var(--text-secondary);
  background: var(--dc-raised);
}
.global-chat-inline-actions .global-chat-action-primary {
  color: var(--accent-ink);
  background: var(--accent);
}
.global-chat-inline-actions button:disabled { opacity: .5; }
.global-chat-result {
  margin: 8px 0;
  overflow: hidden;
  border-radius: 18px;
  background: var(--dc-sheet-fill);
  box-shadow: 0 1px 0 var(--app-sheet-line);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
.global-chat-result-head {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 38px;
  padding: 7px 12px;
  color: var(--text-muted);
  font-size: 12px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .035em;
}
.global-chat-result-head button {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  margin-left: auto;
  color: var(--accent);
  font-size: 12px;
  font-weight: 650;
  text-transform: none;
  letter-spacing: 0;
}
.global-chat-result-items > :not(:last-child) {
  border-bottom: 1px solid var(--border-light);
}
.global-chat-result[data-renderer="app"] .global-chat-result-items {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  border-top: 1px solid var(--border-light);
}
.global-chat-result[data-renderer="app"] .global-chat-result-items > .global-chat-item {
  min-width: 0;
  border-right: 1px solid var(--border-light);
  border-bottom: 1px solid var(--border-light);
}
.global-chat-result[data-renderer="app"] .global-chat-result-items > .global-chat-item:nth-child(even) {
  border-right: 0;
}
.global-chat-item {
  display: flex;
  gap: 10px;
  padding: 10px 12px 11px;
}
.global-chat-result[data-renderer="app"] .global-chat-item {
  gap: 8px;
  padding: 8px 10px;
}
.global-chat-app-icon {
  width: 34px;
  height: 34px;
  border-radius: 10px;
}
.global-chat-item-toggle {
  display: flex;
  width: 100%;
  align-items: flex-start;
  gap: 8px;
  border-radius: 8px;
  text-align: left;
}
.global-chat-item-toggle:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.global-chat-item-title {
  display: block;
  color: var(--text-primary);
  font-size: 15px;
  font-weight: 700;
  line-height: 1.25;
  overflow-wrap: anywhere;
}
.global-chat-item-meta {
  display: block;
  margin-top: 2px;
  color: var(--text-muted);
  font-size: 12px;
  line-height: 1.3;
  overflow-wrap: anywhere;
}
.global-chat-item-chevron {
  width: 15px;
  height: 15px;
  flex: 0 0 auto;
  margin-top: 2px;
  color: var(--text-muted);
  transition: transform .16s ease;
}
.global-chat-item[data-expanded] .global-chat-item-chevron { transform: rotate(180deg); }
.global-chat-item-selector .global-chat-item-toggle {
  min-height: 34px;
  align-items: center;
}
.global-chat-item-selector .global-chat-item-title { font-size: 14px; }
.global-chat-item-details { padding-top: 3px; }
.global-chat-item-summary {
  margin-top: 4px;
  color: var(--text-secondary);
  font-size: 13px;
  line-height: 1.35;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.global-chat-item-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 6px 12px;
  margin-top: 8px;
}
.global-chat-item-fields dt {
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 650;
}
.global-chat-item-fields dd {
  margin: 1px 0 0;
  color: var(--text-secondary);
  font-size: 12px;
  overflow-wrap: anywhere;
}
.global-chat-inline-actions { margin-top: 8px; }
.global-chat-inline-actions button {
  min-height: 30px;
  padding: 0 10px;
  font-size: 12px;
}
.global-chat-expand {
  gap: 4px;
  margin: 8px 12px 12px;
  min-height: 32px;
}
.global-chat-client-action { padding: 0 12px 12px; }
.global-chat-result-nested {
  margin: 9px 0 2px;
  border: 1px solid var(--border-light);
  border-radius: 14px;
  background: var(--dc-raised);
  box-shadow: none;
}
.global-chat-inline-loading,
.global-chat-inline-error {
  margin-top: 8px;
  color: var(--text-muted);
  font-size: 12px;
}
.global-chat-inline-error { color: var(--danger, #b91c1c); }
.global-chat-setting-edit {
  margin-top: 9px;
  color: var(--brand-ink);
  font-size: 12px;
  font-weight: 650;
}
.global-chat-setting-controls { margin-top: 9px; }
.global-chat-setting-instruction {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
  margin-top: 9px;
}
.global-chat-setting-instruction input {
  min-width: min(100%, 260px);
  flex: 1 1 260px;
  min-height: 36px;
  padding: 7px 10px;
  border: 1px solid var(--border-light);
  border-radius: 10px;
  color: var(--text-primary);
  background: var(--bg-input, var(--dc-sheet));
  font-size: 13px;
}
.global-chat-setting-instruction button {
  min-height: 36px;
  padding: 0 11px;
  border-radius: 9999px;
  color: var(--brand-ink);
  background: var(--brand-tint);
  font-size: 12px;
  font-weight: 650;
}
.global-chat-setting-instruction button:last-child {
  color: var(--text-secondary);
  background: var(--dc-raised);
}
.global-chat-setting-instruction button:disabled { opacity: .45; }
.global-chat-settings-editor { padding: 2px 12px 14px; }
.global-chat-settings-editor > :last-child { margin-bottom: 0; }
.global-chat-development-editor {
  display: grid;
  gap: 12px;
}
.global-chat-setting-status {
  color: #047857;
  font-size: 12px;
}
.global-chat-setting-note {
  color: var(--text-muted);
  font-size: 11px;
  line-height: 1.4;
}
.dark .global-chat-setting-status { color: #6ee7b7; }
.global-chat-result-done {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 9px 12px 12px;
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 650;
}
.global-chat-confirmation {
  margin: 8px 0;
  padding: 12px;
  border-radius: 18px;
  color: var(--text-primary);
  background: var(--state-attention-bg);
}
.global-chat-confirmation strong { font-size: 15px; }
.global-chat-confirmation p { margin-top: 2px; color: var(--text-muted); font-size: 12px; }
.global-chat-confirmation-details {
  display: grid;
  gap: 4px;
  margin-top: 8px;
}
.global-chat-confirmation-details > div {
  display: grid;
  grid-template-columns: minmax(68px, auto) minmax(0, 1fr);
  gap: 8px;
  font-size: 12px;
  line-height: 1.35;
}
.global-chat-confirmation-details dt {
  color: var(--text-muted);
  text-transform: capitalize;
}
.global-chat-confirmation-details dd {
  color: var(--text-primary);
  overflow-wrap: anywhere;
}
.global-chat-confirmation-muted { color: var(--text-muted); background: var(--dc-raised); font-size: 13px; }
.global-chat-result-loading,
.global-chat-loading,
.global-chat-activity {
  display: flex;
  align-items: center;
  gap: 7px;
  min-height: 38px;
  color: var(--text-muted);
  font-size: 13px;
}
.global-chat-progress {
  margin: 9px 0;
  padding: 9px 11px;
  border: 1px solid var(--border-light);
  border-radius: 14px;
  color: var(--text-muted);
  background: var(--dc-raised);
  font-size: 12px;
}
.global-chat-progress-current {
  display: flex;
  align-items: center;
  gap: 7px;
  min-height: 22px;
}
.global-chat-progress-current span {
  min-width: 0;
  flex: 1 1 auto;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
}
.global-chat-progress-current time {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
}
.global-chat-progress details { margin-top: 5px; }
.global-chat-progress summary {
  width: max-content;
  cursor: pointer;
  color: var(--accent);
  font-weight: 650;
}
.global-chat-progress-details {
  display: grid;
  gap: 4px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--border-light);
}
.global-chat-progress-details ol,
.global-chat-progress-details ul {
  display: grid;
  gap: 3px;
  margin: 0;
  padding-left: 18px;
}
.global-chat-progress-details li[data-status="completed"] { color: var(--text-secondary); }
.global-chat-progress-details li[data-status="failed"] { color: var(--danger, #b91c1c); }
.global-chat-history { margin: 0 auto 10px; }
.global-chat-error {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 10px 0;
  padding: 9px 12px;
  border-radius: 14px;
  color: var(--state-blocked);
  background: var(--state-blocked-bg);
  font-size: 13px;
}
.global-chat-error span { min-width: 0; flex: 1 1 auto; }
.global-chat-error button {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  flex: 0 0 auto;
  font-weight: 700;
}
.global-chat-composer {
  display: flex;
  align-items: flex-end;
  gap: 8px;
  flex: 0 0 auto;
  width: min(calc(100% - 24px), 52rem);
  margin: 0 auto;
  /* THE BOTTOM IS THE SAFE BAR'S. The form wears `.platform-safe-bar` (top of
     this file): its 8px base gap plus the larger of the tab bar's band and
     the home-indicator strip, since #global-chat-screen keeps the platform
     tab bar up on a phone. This rule cleared the strip alone — `calc(10px +
     var(--platform-safe-bottom))` — so the field sat under the tab bar. The
     8px declared here is the value the class computes with no bar and no
     inset, so the two cannot disagree on a desktop. */
  padding: 10px 12px 8px;
  border-radius: 22px 22px 0 0;
  background: var(--dc-sheet-fill);
  box-shadow: 0 -5px 18px rgba(0, 0, 0, .06);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
.global-chat-composer textarea {
  flex: 1 1 auto;
  min-width: 0;
  min-height: 40px;
  max-height: 144px;
  resize: none;
  overflow-y: auto;
  border: 0;
  outline: 0;
  padding: 9px 4px;
  color: var(--text-primary);
  background: transparent;
  font-size: 16px;
  line-height: 1.35;
}
.global-chat-composer textarea::placeholder { color: var(--text-faint); }
.global-chat-send {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 40px;
  height: 40px;
  border-radius: 9999px;
  color: var(--accent-ink);
  background: var(--accent);
}
.global-chat-send:disabled { opacity: .35; }
.global-chat-stop-mark {
  width: 11px;
  height: 11px;
  border-radius: 2px;
  background: currentColor;
}
@media (max-width: 639px) {
  .global-chat-toolbar { padding-right: 12px; padding-left: 12px; }
  .global-chat-toolbar p { display: none; }
  .global-chat-budget { display: none; }
  .global-chat-new { width: 44px; min-height: 44px; padding: 0; }
  .global-chat-new span { display: none; }
  .global-chat-transcript { padding-right: 12px; padding-left: 12px; }
  .global-chat-turn-assistant { padding-right: 0; }
  .global-chat-turn-user p { max-width: 86%; }
  .global-chat-suggestions button { min-height: 42px; }
  .global-chat-inline-actions button { min-height: 36px; }
  .global-chat-result[data-renderer="app"] .global-chat-result-items { grid-template-columns: minmax(0, 1fr); }
  .global-chat-result[data-renderer="app"] .global-chat-result-items > .global-chat-item { border-right: 0; }
  .global-chat-item { padding-top: 12px; padding-bottom: 12px; }
  .global-chat-item-toggle { min-height: 36px; }
  .global-chat-setting-instruction input,
  .global-chat-setting-instruction button { min-height: 42px; }
  .global-chat-progress { padding: 9px 10px; }
  .global-chat-composer { width: calc(100% - 16px); }
}
@media (prefers-reduced-motion: reduce) {
  .global-chat-result-loading svg,
  .global-chat-loading svg,
  .global-chat-activity svg { animation: none; }
  .global-chat-progress-current svg { animation: none; }
}


.dark {
  --bg-primary: #0b0b0c;
  --bg-secondary: #1c1c1e;
  --bg-tertiary: #2c2c2e;
  /* zinc-700, one step up from the dark hover fill (zinc-800). */
  --dev-press: #3a3a3c;
  /* The ladder above, climbing away from the #0b0d1b wallpaper rather than
     down into it. --dc-sheet takes --bg-secondary's value and --dc-raised
     takes --bg-tertiary's: a card on the sheet has to be one step lighter
     again, which is the step the old pairing had no room for. */
  --dc-strip: #131316;
  --dc-sheet: #1c1c1e;
  /* One step up the same ramp as the sheet below it (`--bg-tertiary`), which
     is the step the rest of the dark palette already uses for "on top of". */
  --dc-sheet-raise: #2c2c2e;
  --dc-raised: #2c2c2e;
  /* The dark planes. The strip's tint is the dark wallpaper's own ground
     rather than the strip's opaque value: at 0.55 over that ground it is
     nearly a no-op on the flat areas and lets the washes through where
     they are, which is the point.

     DENSER THAN LIGHT'S, on purpose. The two themes are not the same
     problem. Light's wallpaper is cream with pastel washes — averaging it
     with white barely moves it, so 0.50 still reads as a sheet and shows
     the washes plainly. Dark's is near-black with washes several times
     more saturated than their ground; the same alpha there does not read
     as glass over a page, it reads as the washes themselves with type on
     top. So dark stays denser and light goes thinner, and the numbers
     differ because the grounds do. */
  --dc-strip-fill: rgba(11, 13, 27, 0.55);
  --dc-sheet-fill: rgba(28, 28, 30, 0.72);
  --dc-float-fill: rgba(28, 28, 30, 0.95);
  /* The same fills composited over the dark wallpaper's ground (11, 13, 27);
     see the light block for what these are for. */
  --dc-sheet-solid: #17181d;
  --dc-strip-solid: #0b0d1b;
  --bg-code: #141416;
  --bg-code-header: #1c1c1e;
  --bg-card: rgba(90, 169, 255, 0.10);
  --bg-card-alt: rgba(16, 185, 129, 0.05);
  --bg-input: #1c1c1e;
  --border: #2c2c2e;
  --border-light: #3a3a3c;
  --text-primary: #f5f5f7;
  --text-secondary: #e3e3e6;
  /* #8e8e93, not the #6c6c70 this carried. --text-muted was the ONE token in
     this block with no dark counterpart — it kept its light value, so on a
     near-black page it was as dark as it is on a near-white one. Every muted
     label the token paints measured 3.2:1 or worse against the dark ground,
     and the create modal's unselected segment pills 2.7:1 against their track.
     One step up the neutral ramp reads at 6.0 on the ground and 5.3 on a card,
     and is still plainly quieter than --text-secondary above.

     #98989d, not #8e8e93 (QA 2026-09-24 Q20): the ground was measured, the
     raised surfaces were not. On --bg-tertiary (#2c2c2e), which is what the
     Workshop tabs, the Messages filter track, the theme toggle and the
     category cards sit on, #8e8e93 is 4.27:1. Five steps lighter clears
     4.85 there and stays well under --text-secondary. */
  --text-muted: #98989d;
  --text-faint: #8e8e93;
  --text-code: #e3e3e6;
  --accent: #5aa9ff;
  --accent-light: #8cc4ff;
  --accent-tint: rgba(90, 169, 255, 0.14);
  /* violet-950 and violet-300: the wash goes DARK here for the same reason
     the bubble does — a pale fill on a near-black page is the brightest thing
     on screen, and this is a control, not the message. */
  --accent-wash: #00264d;
  --accent-wash-ink: #85bcff;
  --accent-tint-ink: #5aa9ff;
  --accent-ink: #0b0b0c;
  /* Dark counterparts of the semantic state tokens. Every --state-*
     declared on :root has one here — tests/dev-color-tokens.test.js
     asserts the two lists match, so a token added above without a dark
     value fails the suite rather than rendering an unreadable badge. */
  --state-neutral: #9ca3af;
  --state-neutral-bg: rgba(156, 163, 175, 0.16);
  --state-progress: #a78bfa;
  --state-progress-bg: rgba(167, 139, 250, 0.18);
  --state-attention: #fbbf24;
  --brand-ink: #c7d0f5;
  --brand-tint: #262b45;
  --brand-line: #3a4270;
  --lit-ink: #8cc4ff;
  --lit-tint: rgba(90, 169, 255, 0.16);
  --lit-line: rgba(90, 169, 255, 0.35);
  /* Dark inverts both halves rather than dimming them: the hairline goes
     to white-at-low-alpha (a black one is invisible on a near-black
     ground) and the shadow goes DEEPER, not softer, because a shadow has
     to out-darken #0b0d1b to separate anything from it at all. */
  --app-sheet-line: rgba(255, 255, 255, 0.12);
  --app-sheet-shadow-near: rgba(0, 0, 0, 0.55);
  --app-sheet-shadow-far: rgba(0, 0, 0, 0.4);
  --dc-lift-shadow:
    0 6px 12px -6px var(--app-sheet-shadow-near),
    0 8px 24px -4px var(--app-sheet-shadow-far);
  --pane-scrim-ink: 0 0 0;
  --pane-scrim-alpha: 0.4;
  --pane-scrim: rgb(var(--pane-scrim-ink) / var(--pane-scrim-alpha));
  --state-attention-bg: rgba(245, 158, 11, 0.18);
  --state-blocked: #f87171;
  --state-blocked-bg: rgba(239, 68, 68, 0.18);
  --state-blocked-fill: rgba(239, 68, 68, 0.38);
  --state-ok: #34d399;
  --state-ok-bg: rgba(34, 197, 94, 0.18);
  --state-ok-fill: rgba(34, 197, 94, 0.38);
  --state-admin: #fbbf24;
  --state-admin-bg: rgba(245, 158, 11, 0.14);
}

/* ── Accessibility: text size and high contrast (#3969) ─────────────
   Owned by the inline `window.Display` block in frontend/src/head.html,
   which puts data-text-size / data-contrast on <html> before first paint
   (the same head-blocking pass as `.dark`, so nothing flashes).

   Text size scales everything in rem — the primitives' text-[0.9375rem],
   Tailwind's text-xs…text-3xl, and the rem paddings and heights with them,
   so tap targets keep their proportion. Fixed-pixel text (font-size: Npx
   rules and text-[Npx] classes) deliberately does not scale; converting it
   is a later change. CSS zoom was ruled out: the shell is pinned to
   height:100dvh and zoom pushes the fixed bars off screen.

   High contrast deepens the existing ink and line tokens — every value
   below is one already on the zinc ramp or in the token blocks above, so
   nothing changes hue. The light overrides are scoped to html:not(.dark)
   and the dark ones to html.dark, so an element carrying only a light-only
   zinc class is never darkened on a dark page. The attribute selectors
   (0,2,1) outrank Tailwind's single-class (0,1,0) and its `:is(.dark …)`
   (0,2,0) rules, so the stylesheet order native → app → tailwind stays as
   it is; they do NOT touch the [data-app-tone] ink blocks on
   #platform-header / #app-frame-host, which keep their tuned ink over a
   toned app. */
html[data-text-size="large"] { font-size: 112.5%; }
html[data-text-size="larger"] { font-size: 125%; }

html:not(.dark)[data-contrast="more"] {
  --text-muted: #3a3a3c;
  --text-faint: #48484a;
  --border: #8e8e93;
  --border-light: #c7c7cc;
  --app-sheet-line: #c7c7cc;
}
html:not(.dark)[data-contrast="more"] .text-zinc-400,
html:not(.dark)[data-contrast="more"] .text-zinc-500 { color: #3a3a3c; }
html:not(.dark)[data-contrast="more"] .border-zinc-200 { border-color: #c7c7cc; }

html.dark[data-contrast="more"] {
  --text-muted: #e3e3e6;
  --text-faint: #c7c7cc;
  --border: #8e8e93;
  --border-light: #48484a;
  --app-sheet-line: #48484a;
}
html.dark[data-contrast="more"] .dark\:text-zinc-400,
html.dark[data-contrast="more"] .dark\:text-zinc-500 { color: #e3e3e6; }
html.dark[data-contrast="more"] .dark\:border-zinc-800 { border-color: #48484a; }

/* ── usernode-native kit theming ─────────────────────────
   The platform loads its own hosted kit (/usernode-native/v1/) — see
   index.html. The kit's defaults already ARE the platform violet, but
   we pin the surface/hairline tokens to the platform palette explicitly
   so kit chrome (sheets, grouped cards, toasts, nav bars) tracks
   app.css if either palette ever drifts. Override --un-* variables
   only; never fork or out-specificity the kit CSS. */
:root {
  --un-accent: var(--accent);
  --un-surface: var(--bg-secondary);
  --un-hairline: var(--border);
  --un-muted: var(--text-muted);
  --un-group-bg: var(--bg-card);
  --un-sheet-bg: var(--bg-primary);
  /* Anchored menus (the home card's "…" actions menu, header menus).
     Pinned explicitly to an OPAQUE platform surface — --un-group-bg is a
     translucent violet tint by design, so a popover inheriting it showed
     the app grid straight through (#847). Matches the platform's other
     floating dropdowns (.attr-popover, .gc-mention-menu). */
  --un-popover-bg: var(--bg-primary);
  --un-navbar-bg: rgba(255, 255, 255, 0.82);
  --un-radius-card: 12px;
  /* The tile-zoom overlay surface: the app view's screens paint on the
     body background, so the moving card must match it exactly. On the
     wallpaper routes the body's ground is the cream, and the same rule
     that paints it re-points this at --home-ground (see "The home
     ground") — otherwise the card zooms open WHITE and snaps to cream
     when the kit lifts its surface. */
  --un-zoom-bg: var(--bg-primary);
}

.dark {
  --un-accent: var(--accent);
  --un-surface: var(--bg-secondary);
  --un-hairline: var(--border);
  --un-muted: var(--text-faint);
  --un-group-bg: #1c1c1e;
  --un-sheet-bg: #1c1c1e;
  /* One step lighter than --bg-primary (#0b0b0c) so the floating menu
     reads as elevated above the page rather than merging into it. */
  --un-popover-bg: #1c1c1e;
  --un-navbar-bg: rgba(11, 11, 12, 0.78);
  --un-zoom-bg: var(--bg-primary);
}

/* Full-screen modal roots whose card is currently riding inside a kit
   presentModal (see frontend/src/lib/static-modal.ts): the legacy fixed
   overlay/scrim must not paint a second backdrop. */
.platform-modal-adopted {
  display: none !important;
}

/* A legacy modal card riding inside the kit modal shell: the shell
   already draws the surface, radius, shadow and padding — the card's
   own chrome would stack on it (double border, doubled whitespace).
   One chrome only: the kit's. */
.un-modal .platform-modal-card {
  background: transparent !important;
  box-shadow: none !important;
  border: 0 !important;
  border-radius: 0 !important;
  padding: 0 !important;
  width: 100% !important;
  max-width: none !important;
  margin: 0 !important;
}

/* The header drawer riding inside a kit SIDE PANEL on touch
   (unNative.presentPanel via App.HeaderMenu.open). Same neutralisation
   of the legacy fixed slide-over chrome as .platform-sheet-adopted
   below, but sized for a full-height drawer: it FILLS the kit panel
   rather than being capped, which is what lets #drawer-footer's
   `mt-auto` hug the bottom of the screen instead of being pushed past
   a 70vh cut-off. Deliberately no max-height here. */
.platform-panel-adopted {
  position: static !important;
  inset: auto !important;
  transform: none !important;
  transition: none !important;
  width: 100% !important;
  max-width: none !important;
  height: 100% !important;
  max-height: none !important;
  border: none !important;
  border-radius: 0 !important;
  box-shadow: none !important;
  background: transparent !important;
  display: flex !important;
  flex: 1 1 auto;
  min-height: 0;
}

/* Panels adopted into kit bottom sheets on touch (notifications, work
   drawer, dev console — the header drawer moved to the side panel
   above). Their legacy classes pin them as fixed dropdown/slide-over
   chrome; while riding inside a sheet the sheet owns position, size and
   surface. */
.platform-sheet-adopted {
  position: static !important;
  inset: auto !important;
  transform: none !important;
  transition: none !important;
  width: auto !important;
  max-width: none !important;
  max-height: 70vh !important;
  height: auto !important;
  border: none !important;
  border-radius: 0 !important;
  box-shadow: none !important;
  background: transparent !important;
  display: flex !important;
}

/* ONE PANE OF GLASS, NOT TWO (#2825). The kit's sheet, panel and modal are
   already frosted in the shell (`--dc-frost`, the "Shell-only frosted kit
   surfaces" block). The panels that ride inside them — the Homeroom menu and
   the notifications sheet wear `.dc-lift-panel` — kept their OWN
   `backdrop-filter` when adopted: `background: transparent` above cleared the
   fill but not the filter, so the panel re-frosted the already-frosted sheet
   behind it and read as a second, whiter box inset by the sheet's padding —
   the "extra white box around pop up dialogs". The kit shell is the only
   surface; the content sits straight on it. */
.platform-sheet-adopted,
.platform-panel-adopted,
.un-modal .platform-modal-card {
  backdrop-filter: none !important;
  -webkit-backdrop-filter: none !important;
}

/* ── The nav surfaces grow further, and keep their scroller ────────────
   The 70vh above is right for a kit sheet that is a DIALOG — a form, a
   confirm, a short list. The four navigation surfaces are not: the Improve
   panel carries every change in flight, and the messages/notifications
   sheets carry as many rows as there are. They get the same near-full cap
   their no-kit CSS uses, so the behaviour is one rule at every width and on
   both paths. */
/* The 3rem gap is measured from BELOW the status bar, not from the top of the
   viewport. A flat `100dvh - 3rem` is 48px, and the status-bar inset on a
   notched phone is 54pt — so at the cap the sheet's head slid UNDER the clock
   and the signal bars, which is the one place the gap exists to prevent. The
   fallback keeps desktop and any browser without the env() unchanged. */
.platform-sheet-adopted#messages-sheet {
  max-height: calc(100dvh - env(safe-area-inset-top, 0px) - 3rem) !important;
}

/* The app menu stops at 80%, matching the no-kit bottom-sheet rule in the
   `max-width: 639px` block: it is a short list of destinations, and a menu
   that climbs to the status bar reads as a screen you have navigated to
   rather than something laid over the page.
   Only the sheet. The desktop presentation is a dropdown under the header
   chip, capped against the fold by its own rule.

   THE TWO VISIBILITY PROPERTIES ARE RESET HERE ON PURPOSE. That dropdown
   hides with `opacity: 0; visibility: hidden` rather than by sliding off an
   edge, and the generic `.platform-sheet-adopted` block above — which flattens
   position, transform, transition, border and shadow — covers neither. A
   touch device WIDER than 639px (a tablet) matches the desktop rule and the
   kit adoption at once, so without this the kit's sheet could present an
   invisible panel. It is not merely theoretical insurance for the open state
   either: the kit measures the content at present time. */
/* HANDED BACK BY THE KIT, OUT OF SIGHT until its next presentation
   (lib/kit-surface.ts REHOMED_CLASS): the menu's bottom sheet stood again
   at its open place for 190ms after the kit's exit had taken it away. */
.platform-surface-rehomed {
  visibility: hidden !important;
  transition: none !important;
}
.platform-sheet-adopted#apps-switcher-sheet {
  max-height: min(80dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 3rem)) !important;
  opacity: 1 !important;
  visibility: visible !important;
}

/* THE BELL IS A FIXED HEIGHT, and that is the whole point of it.
   Every other surface here is `height:auto` under a cap, so it is as tall as
   whatever it happens to contain. On the notifications sheet that content
   changes under the viewer's feet: Unread and All are two filters over one
   list, so switching tabs re-sized the sheet: a two-row Unread tab
   was a short sheet and All was a tall one, and the kit seeds its slide-up
   spring from a single measurement at present time (lib/sheet-controller.js),
   so the panel jumped rather than resized. A definite height takes the
   content out of the decision entirely: the sheet is the same object on both
   tabs and only the rows inside it change. The inner scroller
   (`flex-1 min-h-0` on the row list) already handles the overflow this
   creates on a full All tab.
   #5289: the WHOLE screen, less the status bar and a sliver above the kit's
   grabber (21px) that keeps it reading as a sheet you can pull down. It was
   two thirds, which on a phone fit five rows: the bell is a list you read
   down, and a third of the screen spent on the page behind it bought
   nothing. The bottom safe inset is the kit's own padding under the sheet.
   `height` has to beat `height: auto !important` above, hence the
   !important. */
.platform-sheet-adopted#notifications-sheet {
  height: calc(100dvh - env(safe-area-inset-top, 0px)
    - var(--un-safe-inset-bottom, env(safe-area-inset-bottom, 0px)) - 2.25rem) !important;
  max-height: none !important;
}

/* THE KIT'S DRAG WOULD OTHERWISE EAT THE SCROLL.
   `.un-sheet` sets `touch-action: none` and claims any vertical drag for
   drag-to-dismiss, with no scroller detection — which is why the header
   drawer became a kit PANEL rather than a sheet (#915), and why an inner
   `overflow-y: auto` inside an adopted sheet has been decorative up to now.
   `touch-action` is intersected down the ancestor chain, so the scroller
   cannot opt back in on its own; the SHEET has to allow vertical panning.

   Scoped to the sheets that actually contain a scroller, so every other
   adopted sheet keeps the kit's dismiss gesture exactly as it was. Inside
   these, a vertical pan goes to the scroller and the kit's pointer handler
   takes a `pointercancel` (native.js handles it with v=0 and springs back);
   a drag starting anywhere else in the sheet still dismisses it, and so
   does the grabber. */
.un-sheet:has(#notifications-sheet),
.un-sheet:has(#messages-sheet) {
  touch-action: pan-y;
}

/* THE SWITCHER'S ONLY SCROLLER RUNS THE OTHER WAY. Its app rail is a
   horizontal strip, and `pan-y` here made that strip unreachable by touch —
   the overflow existed and no gesture could get at it. `pan-x` inverts the
   trade in this sheet's favour, and it costs less than the panel's does: a
   VERTICAL drag anywhere still reaches the kit's pointer handler, so
   drag-to-dismiss survives intact here. */
.un-sheet:has(#apps-switcher-sheet),
.un-sheet:has([data-staking-sheet]) {
  touch-action: pan-x;
}
/* …EXCEPT ON ITS ABOUT PANE, WHICH IS A PAGE. The app's facts, its builders
   and its MORE rows run past the sheet's 80% cap on a phone, and `pan-x`
   above leaves #switcher-nav's overflow unreachable by touch — the pane would
   end where the sheet does, with Share and Fork below the fold and no gesture
   to reach them. So while About is showing the sheet takes the notifications
   sheet's trade instead (the `pan-y` block above): a vertical drag in the
   pane scrolls it, and the grabber still dismisses. Same specificity as the
   rule above, so this one wins by coming after it; the menu pane keeps the
   drag-to-dismiss it has always had. */
.un-sheet:has(#app-about-pane) {
  touch-action: pan-y;
}

/* THE HOME-INDICATOR INSET IS THE KIT'S JOB INSIDE AN ADOPTED SURFACE.
   #improve-footer carries .platform-safe-scroll, which is right in the two
   cases where nothing else reserves that strip: the desktop slide-over
   (where the inset is 0 anyway) and mobile Safari, where the kit is absent
   and the panel stays a fixed slide-over.

   Inside a kit sheet or panel it is counted TWICE. `.un-sheet` already sets
   `padding-bottom: var(--un-safe-inset-bottom, env(safe-area-inset-bottom))`
   and `.un-sheet-body` adds 20px under that, so a 34px indicator put the
   version row 88px off the bottom (34 + 20 + 34) instead of 54. Measured,
   not estimated — the duplicate is this one.

   IT WAS NEVER ONLY #improve-footer, AND THAT IS WHY THIS IS BY CLASS.
   Every adoptable sheet has an element carrying one of these classes, and
   measured on a 34px indicator all three double-counted it:

     #improve-footer      .platform-safe-scroll   34 + 20 + 34 =  88px
     #switcher-nav        .platform-safe-sheet    54 + 20 + 34 = 108px
     the notifications
       scroller           .platform-safe-scroll   34 + 20 + 34 =  88px

   The app menu's is worst because .platform-safe-sheet adds 1.25rem on top
   of the inset. And the notifications scroller HAS NO ID — an id-based rule
   could not have reached it however many ids were listed, which is the
   argument for writing the rule the comment above already states: inside an
   adopted surface the inset is the kit's job, wherever it appears.

   THE GAP IS ALSO HEIGHT. A bottom sheet is as tall as its content, so
   every one of those pixels pushed the sheet further up the screen. The
   report was "extra white space at the bottom, which means it doesn't have
   to come up as much" — exactly the mechanism: the sheet rose to make room
   for padding nobody could see. Zeroing it drops the app menu from 82% of a
   390x844 phone to 75%.

   What is left under the last row is the kit's own 20px plus the indicator,
   which is what .platform-safe-sheet's comment asks for anyway ("real
   padding under it whether or not the device reports an inset"). Ours is
   the copy that goes, and only on this path: with no kit, nothing else
   reserves that strip and both classes keep doing their job.

   `!important` because both classes use it; the descendant selector is what
   wins the tie. Descendants only — no adopted root carries either class
   itself today, and one that did would want its own answer rather than this
   one applied silently. */
.platform-sheet-adopted .platform-safe-scroll,
.platform-sheet-adopted .platform-safe-sheet,
.platform-panel-adopted .platform-safe-scroll,
.platform-panel-adopted .platform-safe-sheet {
  padding-bottom: 0 !important;
}

/* Chat/proposal screen headers wired through unNative.attachNavBar
   (platform-ui.js attachScreenFx): the kit toggles .un-scrolled once
   the screen's scroller moves — that's the cue for the frosted
   nav-bar treatment. The bars stay in-flow (not .un-navbar fixed);
   only the scrolled-state styling changes. */
.platform-chat-header.un-scrolled {
  background: var(--un-navbar-bg);
  -webkit-backdrop-filter: saturate(180%) blur(16px);
  backdrop-filter: saturate(180%) blur(16px);
  box-shadow: 0 0.5px 0 var(--un-hairline);
}

/* THE KIT'S TOAST CLEARS THE TAB BAR (QA 2026-09-24 Q28). native.css rests
   the toast 16px above `--un-toast-inset-bottom`; on a phone that is the
   tab bar's full outer height, which `--platform-tabs-h` already says (0px
   on a desktop, where the "bar" is the rail). Read on the toast itself, a
   child of body, so it inherits body's value. PlatformUI.toast overrides it
   inline when a composer sits above the bar. */
.un-toast {
  --un-toast-inset-bottom: var(--platform-tabs-h, 0px);
}

/* THE LANGUAGE NOTICE RESTS WHERE A TOAST DOES ("Showing Homeroom in Español
   · Switch to English", features/shell/language-notice.tsx). Fixed, because
   it has to stay in view until it is answered: in the page flow it scrolled
   away with Home on a phone, and the sign-in screens are `fixed inset-0`
   over anything in flow. Above those screens (z-40) and below the drawer and
   panels (z-50), like the install strip. `--language-notice-inset` is the
   island's own measurement when a composer sits above the tab bar. */
#language-notice {
  position: fixed;
  left: 50%;
  bottom: calc(16px + max(var(--language-notice-inset, var(--platform-tabs-h, 0px)), var(--platform-safe-bottom, 0px)));
  z-index: 45;
  width: max-content;
  max-width: min(calc(100vw - 24px), 480px);
  transform: translateX(-50%);
}

/* ── No glass, on any platform (#787, #3104, and since the phone audit) ──
   Live backdrop blur is unusually expensive in WebKit on a phone while the
   content behind it scrolls or a spring moves the surface every frame. #787
   measured it in the app's WKWebView and #3104 took it off there, which left
   the app drawing the opaque no-blur fallbacks (neutral #ffffff / #f5f5f7)
   while Safari, the PWA, Android and desktop drew warm glass: two colour
   schemes for one product. The decision since is ONE scheme and no blur: the
   shell's `--dc-frost` is `none` everywhere (see the token), the planes keep
   their translucent fills, and what content passes behind is opaque in the
   `-solid` plane colours, identically on every platform.

   The kit's own frosted chrome goes the same way here, for the shell only
   (native.css keeps its defaults for apps): no blur on the nav bar, action
   sheet cards, alerts and toasts, and the two nav tokens, translucent by
   design, pinned to the platform surface so nothing reads through them.

   THE MOTION CHANGES ONLY IN THE APP: `html.un-ios.in-native-webview`. The
   Flutter shell marks the top document with .in-native-webview before first
   paint (index.html); with .un-ios that is a reliable scope for the native
   snapshot's view transition and the decorative loops further down. Do not
   guess a phone model from the UA, and do not reduce motion in Safari/PWA or
   on Android. */
:root {
  --un-navbar-bg: var(--bg-primary);
  --un-toast-bg: #1c1c2a;
}

.platform-chat-header.un-scrolled,
.un-navbar,
.un-action-card,
.un-alert,
.un-toast {
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}

/* The kit's default iOS page transition dims its full-page snapshot with a
   brightness filter while also translating it.  Keep the spatial push/pop
   cue, but use transform-only keyframes and a shorter capture lifetime in the
   native shell.  Reduce Motion remains stricter in native.css and disables
   these animations entirely. */
html.un-ios.in-native-webview[data-un-vt]::view-transition-old(root),
html.un-ios.in-native-webview[data-un-vt]::view-transition-new(root) {
  animation-duration: 260ms;
}
html.un-ios.in-native-webview[data-un-vt="push"]::view-transition-old(root) {
  animation-name: un-vt-native-ios-parallax-out-left;
}
html.un-ios.in-native-webview[data-un-vt="pop"]::view-transition-new(root) {
  animation-name: un-vt-native-ios-parallax-in-left;
}
@keyframes un-vt-native-ios-parallax-out-left {
  from { transform: translateX(0); }
  to { transform: translateX(-30%); }
}
@keyframes un-vt-native-ios-parallax-in-left {
  from { transform: translateX(-30%); }
  to { transform: translateX(0); }
}
/* app.css loads after native.css, so repeat the accessibility override at
   this selector's higher specificity. Without this rule, the performance
   animation-name above would win back one sub-property from the kit's earlier
   `animation: none` shorthand and accidentally re-enable Reduce Motion. */
@media (prefers-reduced-motion: reduce) {
  html.un-ios.in-native-webview[data-un-vt]::view-transition-old(root),
  html.un-ios.in-native-webview[data-un-vt]::view-transition-new(root) {
    animation: none;
  }
}

/* ── The top bar does not travel with the page ────────────────────────────
   Switching apps from the header chip animates `::view-transition-*(root)`,
   and the root snapshot is the WHOLE document — the header included. So the
   bar slid out to the left and a second copy slid in from the right, while
   every control on it stayed in exactly the same place before and after. The
   only thing that actually changed was the app window underneath.

   Naming the header hoists it out of the root snapshot into a group of its
   own, and killing that group's animation pins it. The bar stays put and the
   push/pop cue is left to the content, which is the thing that moved.

   Two details this depends on:
     - #platform-header paints an opaque background (see platform-header.tsx).
       A named group is composited ABOVE ::view-transition-group(root), so a
       transparent bar would have the sliding page showing through it.
     - The name has to be unique per capture. #platform-header is a single
       element in the shell and #landing-header is a different id on a shell
       that never coexists with it, so there is exactly one at any capture.

   Reduce Motion needs no branch here: this rule REMOVES animation, and the
   kit's own `animation: none` under prefers-reduced-motion still governs the
   root group. */
#platform-header {
  view-transition-name: platform-header;
}
html[data-un-vt]::view-transition-group(platform-header) {
  animation: none;
}

/* ── THE REST OF WHAT PINNING A GROUP DOES NOT DO (#2718 review) ────

   `animation: none` on the GROUP stops it sliding. It does not stop the two
   IMAGES inside it cross-fading, which is the default for every named group
   — so the bar stayed put and spent 130ms showing "Homeroom" and "Workshop"
   superimposed, both at half opacity, over a rail whose five labels were
   ghosting through the root snapshot at the same time. Caught by
   screenshotting 40ms into a Home -> Workshop push.

   The old image goes and the new one is simply there. A cross-fade is how
   you animate a thing CHANGING; this bar does not change, it relabels, and
   two labels at once reads as a fault rather than as motion.

   THE RAIL GETS ITS OWN GROUP for the same reason it needed one: unnamed, it
   is part of the root snapshot and slides with the page it is navigation
   for. Which tab is lit does change — and pops rather than fading, which is
   the same trade the bar makes and the right one for chrome.

   Unique per capture, like the header's: both are single elements in the
   shell. A group present in only one state (the rail going away as an app
   opens) has nothing to cross-fade with and pops, which is what the zoom
   transitions already do to it. */
#platform-tabs {
  view-transition-name: platform-tabs;
}
html[data-un-vt]::view-transition-group(platform-tabs) {
  animation: none;
}
html[data-un-vt]::view-transition-old(platform-header),
html[data-un-vt]::view-transition-old(platform-tabs) {
  animation: none;
  opacity: 0;
}
html[data-un-vt]::view-transition-new(platform-header),
html[data-un-vt]::view-transition-new(platform-tabs) {
  animation: none;
  opacity: 1;
}

/* EXCEPT WHEN THE RAIL IS THE THING GOING AWAY (#2718 review). Opening an
   app hides it, so its group has an OLD image and no new one — and the rule
   above then reads as "remove it on frame 0", which opened a 224px hole down
   the left of the window and held it there while the page behind it was
   still fading. The sidebar vanished, and only then did the app arrive.

   Held at full opacity instead, the bar simply stays where it is for the
   length of the transition and the app rises over it, which is what "an app
   covers the rail" should look like. It leaves when the transition does,
   under a screen that is by then covering that column.

   `:has()` against the LIVE DOM is what tells the two cases apart: during a
   transition the document is already in its new state, so `#platform-tabs`
   carrying `hidden` means this bar is gone on the other side. A tab-to-tab
   change has the rail in both states and keeps the rule above, where the old
   image must go — two translucent rails stacked would double the fill and
   light two rows at once. */
html[data-un-vt]:has(#platform-tabs.hidden)::view-transition-old(platform-tabs) {
  opacity: 1;
}

/* THEY DO NOT GO OPAQUE, and that was tried. The note above says a named
   group has to paint an opaque background because it composites ABOVE
   ::view-transition-group(root) — true of an element being COMPOSITED, and
   these are not: pinning the two images means the group renders the NEW
   snapshot at full opacity, and a snapshot is a picture, not a live surface
   sampling a backdrop that is moving underneath it.

   Forcing `--dc-sheet` here instead made both bars `#ffffff` for the length
   of every navigation — over a cream wallpaper they normally read as a pale
   wash of it, so the cure was a white flash on every tab press, which is
   worse than what it was aimed at. Measured: `rgba(255,255,255,0.5)` with a
   blur at rest, `rgb(255,255,255)` opaque mid-push.

   Left as prose rather than as a disabled rule: the next person to read the
   note above will reach for this, and the answer is that the images are
   already doing the work. */

/* ── AND THE GROUND UNDER THEM STAYS THE WALLPAPER (#2758) ──────────────
   Pinning the two bars stopped them moving; it did not stop them flickering.
   The desktop swap is the kit's fade-through, so for a stretch in the middle
   of every tab change BOTH root snapshots are near opacity 0 — and the
   wallpaper is the body's background, inside those snapshots. What showed
   through the translucent bars for those frames was the flat canvas instead:
   the rail at (100, 600) measured rgb(255,241,232) at rest, rgb(250,249,242)
   40-80ms into a Home -> Discover push, and rgb(255,241,232) again from
   120ms. A tint pulse across the whole chrome, on every press.

   `--un-vt-ground` is the body's `--home-wallpaper`, copied onto <html> by
   frontend/src/lib/transition-ground.ts as the transition starts — every
   ::view-transition pseudo inherits from <html>, which cannot see a property
   declared on the body. On a route with no wallpaper it is unset, the
   declaration is invalid at computed time, and the ground is the canvas it
   always was. After the fix the same pixel reads rgb(255,241,232) on every
   sampled frame.

   The page's own fade-through is unchanged: the content still leaves before
   the next screen arrives (#1920), it just passes through the wallpaper, which
   is the "container's own ground" that design always meant.

   Nor is this the opaque-bar idea the note above rules out: the bars stay
   the translucent pictures they were, and what changed is only what they are
   pictures OVER.

   DESKTOP ONLY. The phone's bottom bar is being reworked separately (#2766),
   and the iOS lane slides an opaque snapshot over this ground anyway. */
@media (min-width: 768px) {
  html[data-un-vt]::view-transition {
    background: var(--un-vt-ground);
  }
}

/* Infinite decorative effects keep compositing even when the user is idle.
   Their state must remain legible without motion: lifecycle colors stay in
   place and busy session dots gain a static ring.  Functional progress
   spinners are intentionally not selected. */
html.un-ios.in-native-webview .status-dot.creating,
html.un-ios.in-native-webview .work-cog-spinning,
html.un-ios.in-native-webview .app-version-pill--stale .app-version-pill-dot {
  animation: none;
}
html.un-ios.in-native-webview .dc-active-dot-busy.dc-active-dot-active {
  animation: none;
  box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.38);
}
html.un-ios.in-native-webview .dc-active-dot-busy.dc-active-dot-promoted {
  animation: none;
  box-shadow: 0 0 0 2px rgba(31, 134, 255, 0.38);
}

/* Pressed-state opt-outs: the kit gives every button an instant
   scale-press, which reads wrong on dense strip controls that sit
   flush against siblings (segmented pills, theme mode pills, the Dev
   board's tab strip) — the scale makes adjacent cells appear to shift.
   Keep the brightness dim, drop the transform. */
.members-vis-pill:active,
[data-theme-mode]:active {
  transform: none;
}

html, body {
  height: 100dvh;
  overflow: hidden;
  touch-action: pan-x pan-y;
  -webkit-text-size-adjust: 100%;
  /* The kit's pull-to-refresh owns the pull-down gesture on the
     platform's scrollers — keep mobile Chrome's native page-refresh
     from competing for the same drag. */
  overscroll-behavior-y: none;
}

/* #1518: browser toolbars follow document scrolling. The bridge selects one
   pageable surface at a time; native/installed apps, chats and app iframes
   retain the bounded shell above. Clip horizontal hit-area overhang without
   making these pages into nested vertical scroll containers again. */
html[data-browser-scroller] {
  height: auto;
  min-height: 100%;
  overflow-x: clip;
  overflow-y: auto;
}
html[data-browser-scroller] body {
  --browser-banner-h: 0px;
  height: auto !important; /* the generated body's fixed-shell inline height */
  min-height: 100dvh;
  overflow: visible;
  isolation: isolate;
  background: var(--home-ground) !important;
}
/* ── THE BOTTOM STILL BOUNCES, SO SAFARI CAN TUCK ITS BAR AWAY (#3565) ──
   "On mobile Safari, if already scrolled to the bottom of the page, cannot
   scroll down more to make the address bar shrink."

   Safari tucks its toolbar away when a drag moves the page. At the end of
   an ordinary page there is nothing left to scroll, but the drag still
   moves the rubber band, and that is enough. This document has no rubber
   band. `html, body { overscroll-behavior-y: none }` above was written for
   the bounded shell, whose document never scrolls, to keep the browser's own
   refresh out of the kit's pull (#737). #1518 made the document the scroller
   in a phone browser, and on <html> that one rule now switches off the
   viewport's bounce at BOTH ends: WebKit's main scroll view bounces
   vertically unless the root's `overscroll-behavior-y` is `none` (WKWebView
   `_updateScrollViewForTransaction` -> `_setBouncesInternal`). So at the
   foot of Home, Discover or a project page, with the bar showing, an upward
   drag moved nothing and the bar stayed.

   The TOP keeps `none`. The kit's pull-to-refresh lives there, and a native
   bounce or the browser's own refresh would join it. There is no per-edge
   `overscroll-behavior`, so the edge is told apart by where the page is:
   lib/browser-scroll.ts marks <html> `data-browser-past-top` while the
   document sits PAST_TOP_PX (64px) or more below its top, and only then does
   the viewport take `contain`. That is not `none`, so the far edge bounces
   (WebKit applies a change to the root's value from the next commit; its
   own fast/scrolling/ios/overscroll-behavior-on-body.html flips it the other
   way mid-page). `contain` rather than `auto` so Chrome's refresh stays off
   if a flick ever outruns the mark to the top. A pull that starts at the
   top always meets `none`, exactly as before, and a page with less than
   64px to scroll keeps `none` everywhere. On Android the bottom edge gets
   the system's stretch back, as on any other page. Body takes the value too
   only because the rule it overrides names both; WebKit reads <html>.

   Phones only: a narrow desktop window also pages the document, and its
   trackpad has no toolbar to tuck away. */
@media (pointer: coarse) {
  html[data-browser-scroller][data-browser-past-top],
  html[data-browser-scroller][data-browser-past-top] body {
    overscroll-behavior-y: contain;
  }
}
/* ── THE PAGE HOLDS STILL UNDER A FULL-SCREEN SCREEN (#4893) ──────────────
   Onboarding run, 12 Oct 2026, iPhone 17 Safari: with "What do you want to
   make?" open, Home could still be scrolled underneath, and its cards came
   up behind the status bar. The make, made and "You're in" screens are fixed
   boxes, and in a phone browser the document is Home's scroller (above), so
   a drag their own scroller did not take went on to it.
   frontend/src/lib/page-lock.ts marks <html> `platform-page-locked` while one
   is up, and the document stops scrolling: `hidden` rather than `auto`,
   which keeps where it was (the page is not moved, and is there again when
   the screen goes) and which iOS honours on the root since 16, bounce and
   all, so the overscroll rules above are left as they are. What scrolls
   inside the screen still does. The second selector outranks the paged
   document's own `overflow-y: auto` by weight, not only by coming later. */
html.platform-page-locked,
html.platform-page-locked[data-browser-scroller] {
  overflow: hidden;
}
/* AND WHAT IT HOLDS IS OUT OF SIGHT (onboarding run three, 12 Oct 2026,
   iPhone 17 Safari). The band behind Safari's translucent toolbar is not
   the screen's (Safari draws a fixed box only to its layout viewport's
   foot), so it showed Home under the make and made screens. What the lock
   makes inert (lib/page-lock.ts PAGE_HIDDEN) is drawn at no opacity, which a
   descendant cannot undo as it could `visibility`, so that band shows the
   body's own ground. The screens cover it all anyway everywhere else. */
html.platform-page-locked [data-page-locked] {
  opacity: 0 !important;
}
/* Body padding reserves these fixed strips at the start of a page. Sticky
   headers must keep reserving them after that padding scrolls away. */
html[data-browser-scroller] body.has-install-strip {
  --browser-banner-h: var(--install-strip-h);
}
html[data-browser-scroller] body.is-offline {
  --browser-banner-h: var(--offline-strip-h);
}
html[data-browser-scroller] body.is-offline.has-install-strip {
  --browser-banner-h: calc(var(--offline-strip-h) + var(--install-strip-h));
}
/* Keep the ambient wallpaper viewport-sized as the document grows. Its star
   continues to track the active scroller via wallpaper-scroll.ts. A fixed
   layer also works on iOS, where background-attachment: fixed does not. */
html[data-browser-scroller] body::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: var(--home-wallpaper);
}
html[data-browser-scroller="home-screen"] #home-screen,
html[data-browser-scroller="browse-screen"] #browse-screen,
html[data-browser-scroller="leaderboard-screen"] #leaderboard-screen,
html[data-browser-scroller="profile-screen"] #profile-screen,
html[data-browser-scroller="settings-screen"] #settings-screen,
html[data-browser-scroller="auth-landing-scroll"] #auth-landing-scroll,
html[data-browser-scroller="dev-forum-scroll"] #dev-forum-scroll,
html[data-browser-scroller="auth-login-screen"] #auth-login-screen,
html[data-browser-scroller="auth-register-screen"] #auth-register-screen,
html[data-browser-scroller="auth-waiting-screen"] #auth-waiting-screen,
html[data-browser-scroller="auth-waitlist-screen"] #auth-waitlist-screen,
html[data-browser-scroller="auth-more-screen"] #auth-more-screen {
  flex: none;
  height: auto;
  overflow-x: clip;
  overflow-y: visible;
}
html[data-browser-scroller^="auth-"] body :is(
  #auth-landing-screen, #auth-login-screen, #auth-register-screen,
  #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen
):not(.hidden) {
  position: relative;
  inset: auto;
  min-height: calc(100dvh - var(--browser-banner-h));
}
/* THE STORY'S FOOT CLEARS SAFARI'S TOOLBAR (iPhone 17 simulator, iOS 26
   Safari, 5 October 2026). "Already have an account? Sign in" under Get
   started sat behind Safari's bottom toolbar. In a phone browser the landing
   is the paged document, and Safari 26 draws the page on under its floating
   toolbar: a fixed box ends above the toolbar (the small viewport), the
   document runs on beneath it. The page's foot reserved the home indicator
   (`.platform-safe-scroll`) and 34px of air, and Safari reports no inset for
   its own toolbar, so the last line could never be scrolled out from under
   it. `100lvh - 100svh` is that toolbar: the page with it tucked away less
   the page with it shown (about 98px on an iPhone 17; 0 where no toolbar
   comes and goes). Reserved under the story's own foot, the end of the page
   now scrolls clear of the toolbar shown, and of the slimmer bar Safari
   tucks it into. iOS only: there the toolbar is at the foot. In the
   Homeroom app and an installed app there is no browser toolbar and no
   `data-browser-scroller`, so nothing changes. */
html.un-ios[data-browser-scroller="auth-landing-scroll"] [data-landing-story] {
  padding-bottom: max(0px, calc(100lvh - 100svh));
}
/* The same toolbar under the sign-in screen's foot (#4593): with the story
   switched off, the release mail's "Create my account" lands on #signup,
   and its steps (the account step's button, the line pinned at the foot)
   ran on under Safari's toolbar the way the story's foot did. */
html.un-ios[data-browser-scroller="auth-login-screen"] #auth-login-screen > .min-h-full {
  padding-bottom: max(0px, calc(100lvh - 100svh));
}
/* THE INVITE PAGE'S LAST LINE IS AT THE FOOT OF ITS SCROLLER (#4049). Paged
   by the document in a phone browser, the landing's scroller is
   content-height (`flex: none` above), so the column's `min-h-full` had
   nothing to fill and the line that says what Homeroom is stopped under the
   note, with empty page above the pinned Join bar. While an invite's page is
   up, the scroller takes the rest of the screen above the bar and the column
   takes all of it, so the line's `mt-auto` puts it at the foot, just above
   Join. The bar clears Safari's toolbar and the home indicator itself. */
html[data-browser-scroller="auth-landing-scroll"] #auth-landing-scroll:has(> * > [data-landing-invite-homeroom]) {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
}
html[data-browser-scroller="auth-landing-scroll"] #auth-landing-scroll > :has(> [data-landing-invite-homeroom]) {
  flex: 1 0 auto;
  width: 100%;
}
/* THE STORY IS A WHOLE SCREEN IN A PHONE BROWSER TOO (#4037). Paged by the
   document, the landing's scroller is content-height (`flex: none` above),
   so the column's `min-h-full` had nothing to fill and "Make an account",
   its waitlist line and Sign in stopped under the tiles, with empty page
   below them. While the story is up, the scroller takes the rest of the
   screen under the header and the column takes all of it, so the story's
   foot sits at the foot of the screen, as in the app. Scoped to the story:
   the pitch and the invite cards keep their own height. The landing's logo
   bar (#landing-header) stays above the story: it is a stranger's first
   screen, so the logo says where they are (the owner's ruling, after a build
   that hid it). tests/header-height-parity.test.js pins that nothing hides it. */
html[data-browser-scroller="auth-landing-scroll"] #auth-landing-scroll:has(> * > [data-landing-story]) {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
}
html[data-browser-scroller="auth-landing-scroll"] #auth-landing-scroll > :has(> [data-landing-story]) {
  flex: 1 0 auto;
  width: 100%;
}
/* The anonymous overlay covered the signed-in shell, including any screen
   painted from the last-session snapshot. In document flow those roots must
   leave the layout too, so visitors see only the anonymous page. */
html[data-browser-scroller^="auth-"] :is(
  #platform-header, #home-screen, #browse-screen, #leaderboard-screen,
  #profile-screen, #settings-screen, #messages-screen, #admin-screen, #app-view
) {
  display: none;
}
/* UNDER THE SIGNED-OUT LANDING the platform header is covered (the landing
   is a fixed overlay with its own bar) but was still read out: an empty
   level-1 heading beside the page's own on a desktop (polish N10). In a
   phone browser the rule above already takes it out of the layout. */
body:has(#auth-landing-screen:not(.hidden)) #platform-header {
  visibility: hidden;
}
/* IN BROWSER-SCROLLER MODE the document is the scroller and this bar is
   sticky over it, so it has to hide the content sliding under it rather than
   letting it read through — which is why it takes a near-opaque wash of the
   page ground instead of the frosted fill below.

   #platform-header is the exception now (#2718 review): the tab bar at the
   foot is `position: fixed` over the very same scrolling document and has
   always been `--dc-sheet-fill` under `--dc-frost`, so "frost over moving
   content" is a thing this shell already does at one edge of the screen. The
   two bars are the same kind of object and the owner asked for them to look
   it, on phone and desktop alike. The landing header keeps the wash: it has
   no tab bar to match and nothing frosted anywhere near it.

   The `!important` goes with the exception. It was here to beat the Tailwind
   `bg-zinc-200` utility on the element, and the surface rule further down
   beats that on specificity alone (`body:has(...) #platform-header`), so
   dropping it costs nothing and stops this rule silently outranking a
   later one. */
html[data-browser-scroller] :is(#platform-header, #landing-header) {
  position: sticky;
  top: var(--browser-banner-h);
  z-index: 30;
}
/* Faked glass, not a live blur: the landing scrolls under this header over
   the fixed wallpaper, so it paints its tint over the washes once (see "THE
   ONE FAKED LAYER"). */
html[data-browser-scroller] #landing-header {
  --fake-glass-tint: color-mix(in srgb, var(--home-ground) 92%, transparent);
  background: transparent;
  clip-path: inset(0);
}
html[data-browser-scroller="browse-screen"] #browse-search-bar {
  top: calc(var(--browser-banner-h) + var(--platform-header-h) + var(--platform-safe-top));
}
html[data-browser-scroller^="auth-"] :is(
  #auth-login-screen, #auth-register-screen, #auth-waiting-screen
) > .min-h-full {
  min-height: calc(100dvh - var(--browser-banner-h));
}
/* These screens are already below the strips in document flow. The bounded
   shell's separate offset on the corner Back link would count them twice. */
html[data-browser-scroller^="auth-"] body.is-offline [data-auth-back] {
  top: calc(env(safe-area-inset-top, 0px) + 1rem) !important;
}
html[data-browser-scroller="dev-forum-scroll"] :is(#app-view, #app-content),
html[data-browser-scroller="dev-forum-scroll"] #app-content > div {
  flex: none !important;
  height: auto !important;
  overflow: visible !important;
}
/* During a tile zoom the outgoing page remains in document flow until the
   animation ends. Give the incoming app its final viewport bounds while
   both are painted, rather than measuring a zero-height flex child. */
html[data-browser-scroller]:not([data-browser-scroller="dev-forum-scroll"]) #app-view {
  position: fixed;
  top: calc(var(--browser-banner-h) + var(--platform-header-h) + var(--platform-safe-top) - .5rem);
  bottom: 0;
  left: 0;
  right: 0;
  height: auto !important;
}
html[data-browser-scroller="auth-landing-scroll"] #app-viewer {
  position: fixed;
  top: calc(var(--browser-banner-h) + var(--platform-header-h) + var(--platform-safe-top));
  bottom: 0;
  left: 0;
  right: 0;
}

/* The home feed scroller: contain overscroll so a pull that starts on
   a short list can't chain to the browser's own refresh gesture. The
   anonymous landing scroller (the inner wrapper, not the fixed overlay
   — see index.html #auth-landing-scroll) carries its own kit
   pull-to-refresh (auth-screens.js _wireLanding) and needs the same
   containment.

   ── AND THE FEED SCROLLS ONE WAY ───────────────────────────────────
   `overflow-x: hidden` is not belt and braces, it is the whole of a fix.
   Both of these are `overflow-y-auto` and NOTHING else, and CSS turns that
   into a scroller on BOTH axes: `overflow` computes `visible` to `auto` on
   one axis as soon as the other is not `visible`. So the home feed has
   always been a horizontal scroll container by accident, waiting for
   something inside it to be one pixel too wide — and on a phone something
   is. `.un-touch-target` (native.css) grows any control to a 44px hit box
   with an absolutely-positioned ::after CENTRED on it, so the ⋮ that opens
   an area's menu — `w-4`, 16px, and the last thing in the row — reaches
   14px past itself on each side. The section's gutter is `px-3`. 12 < 14,
   so the box lands 2px beyond the viewport, and a 390px home screen
   measured 392px of scrollWidth: enough to drag the whole feed sideways and
   rubber-band it back, on every phone, for the sake of a tap area nobody
   can see.
   Clipping is right rather than merely convenient. That overhang is a HIT
   AREA, not content: it is meant to hang off its control, and this is the
   only thing that decides whether hanging off becomes geometry. The 2px it
   costs comes off the outside of a target that still extends 12px past a
   16px button on that side. Nothing else in the feed is expected to escape
   it — the ⋮ menu itself is a kit popover, fixed and parented to <body>, as
   is the drag-to-rearrange ghost (.un-reorder-ghost) — so a lift can still
   be carried to the screen edge and past it.
   `hidden` AND NOT `clip`, AND THAT IS NOT A BROWSER-SUPPORT HEDGE.
   `clip` reads like the better answer — it makes the box no scroll container
   on that axis at all, so there is not even an overflow region left — and it
   is simply not available here: CSS Overflow 3 says a `clip` sitting beside
   an `auto` on the other axis COMPUTES TO `hidden`, the same way `visible`
   beside it computes to `auto`. Written either way this box ends up
   `overflow-x: hidden`, which was measured, not assumed. So the axis keeps a
   2px overflow region that `element.scrollLeft = n` could still move; no
   GESTURE reaches it, which is the whole of the reported bug, and nothing in
   the shell scrolls a feed sideways on purpose.
   tests/home-vertical-scroll-only.test.js pins the pair. */
#home-screen,
#auth-landing-scroll {
  overscroll-behavior-y: contain;
  overflow-x: hidden;
}

/* ── EVERY PAGE BOUNCES, HOWEVER LITTLE IT HOLDS (#3566) ───────────────
   "Should be able to over scroll on home page down, doesn't feel as native
   because can't", and after #3591 (#3565) gave Home's foot its bounce back
   in a phone browser, All communities still did not.

   A page's rubber band comes from one of two places, and a screen has one
   only if its own place gives it:

   - THE DOCUMENT, in a phone browser, for the screens browser-scroll.ts
     PAGES (Home, Discover, Leaderboard, Profile, Settings, a project page).
     #3591's `data-browser-past-top` gives its foot `contain`, which bounces.
   - ITS OWN SCROLLER everywhere else: every screen in the installed app
     and the native WebView, and in a phone browser the screens the document
     does not page (All communities, Your changes / requests / votes, the
     Messages list). Those roots are `overflow-y: auto` and the bounce is
     iOS's, which rubber-bands a scroller only when it CAN scroll. One with
     no range hands the drag to the document, which the bounded shell holds
     still on purpose (`html, body`: `overflow: hidden`,
     `overscroll-behavior-y: none`), so neither end moves.

   Measured at 390x664 (iPhone agent, touch), in the phone browser and the
   installed layout alike: #workshop-screen 572/572 and 620/620
   (scrollHeight/clientHeight) with two cards on it, #profile-proposals-
   screen the same, and .messages-list-scroll 457/457 with one conversation.
   No range, so no bounce at either end. Home, Discover, Profile, Settings
   and Leaderboard bounced in the installed app only because this account's
   were longer than the screen; a new account's short page would not.

   SO EVERY PAGE SCROLLER HAS AT LEAST ONE PIXEL TO SCROLL, by #3544's
   mechanism for a project's short tabs (see "A SHORT TAB STILL HAS
   SOMETHING TO SCROLL" further down): a 1px box hung 1px below the
   scroller's bottom edge, which puts that pixel into its scrollable
   overflow whatever the content does. Absolute and offset from the
   scroller's own padding box, so neither a flex chain that fills the
   screen (Home's `.home-body-fill`, an open Settings section) nor a
   percentage floor can take it back. Past the fold of a long page it is a
   transparent pixel in the middle of the content and changes nothing.

   A PAGE SAYS IT IS ONE with `data-page-bounce` on its scroller: Home,
   Discover, All communities, Leaderboard, Profile, Your changes / requests
   / votes, Settings, Admin, and the Messages list. Every screen root among
   them is already its own containing block (`position: relative`, inline);
   the Messages list was not, and becomes one here: its rows are their own
   containing blocks, and nothing in it was positioned against anything
   further out (measured).

   ONLY WHERE THE SCROLLER IS THE PAGE: `html:not([data-browser-scroller])`.
   When the document pages a screen the root is `overflow: visible`, and the
   pixel would only lengthen the page; that bounce is #3591's. ONLY ON A
   TOUCH SCREEN: with a mouse, a pixel of range would put a scrollbar on a
   window that has nothing to scroll.

   NOT the Communities Needs you feed (`[data-workshop-pane="needs"]`): it
   is fitted to the screen on purpose (#3516) and scrolls inside, like a
   project's Needs you and Discussion, which #3544 leaves fitted too. NOT the
   chats (a Messages thread, Global Chat, an agent session): a transcript
   opens on its newest line and stays there; it is not a page. A project
   page is #3544's rule. tests/page-overscroll.test.js holds App.SCREEN_IDS
   against the attribute, so a new screen root carries it or is named there
   with its reason. */
@media (pointer: coarse) {
  html:not([data-browser-scroller]) [data-page-bounce] {
    position: relative;
  }
  html:not([data-browser-scroller]) [data-page-bounce]::after {
    content: '';
    position: absolute; left: 0; bottom: -1px;
    width: 1px; height: 1px;
    pointer-events: none;
  }
  /* A rule of its own, so an engine without `:has()` drops only this. */
  html:not([data-browser-scroller]) [data-page-bounce]:has([data-workshop-pane="needs"])::after {
    content: none;
  }
}

/* ── `.touch-target-32`: the kit's hit-slop, sized for a dense row ──────
   QA 2026-09-24 Q19. `.un-touch-target` (native.css) grows a control's hit
   box to 44px, which is right for a lone icon in a bar. In a DENSE row it is
   wrong: two Revoke buttons stacked 8px apart, or View and Delete sitting over
   an open file preview, and a 44px box reaches over the neighbour and takes
   its taps, because the later element's ::after paints on top. This is the
   same trick at the 32px a phone needs, which fits inside those gaps, and only
   on a coarse pointer: a mouse is precise enough to hit the control itself.
   Zero-specificity `position` for the same reason the kit gives. */
@media (pointer: coarse) {
  :where(.touch-target-32) { position: relative; }
  .touch-target-32::after {
    content: "";
    position: absolute;
    left: 50%;
    top: 50%;
    width: max(100%, 32px);
    height: max(100%, 32px);
    transform: translate(-50%, -50%);
  }
}

/* A held launcher tile can select nearby text (such as Discover) even when
   the tile itself is non-selectable. Protect the whole launcher content
   before the long-press timer fires. The search field is outside #home-body
   so its text selection and editing callout remain available. */
#home-body {
  -webkit-user-select: none;
  user-select: none;
  -webkit-touch-callout: none;
}

/* ── The home ground ───────────────────────────────────────
   The launcher's own wallpaper, ported layer for layer from the homescreen
   design's Figma export (a 402x874 phone frame): a warm cream page, a fine
   yellow paper grain, three gaussian-blurred colour discs and a blue
   halftone star tucked into the top-right corner.

   PAINTED ON THE BODY, on the home route and inside an app. The body is
   the fixed 100dvh column that the header and the screen roots all sit
   in, so one background here runs under both — the bar is cleared over it
   (below) and the star lands behind the bell, where the design puts it —
   and the washes never scroll: the grid and panels move over a wallpaper,
   not under a banner. The star is the one layer that rides the page — its
   position reads `--home-star-y`, which frontend/src/lib/wallpaper-scroll.ts
   writes on <html> from the visible screen's scroll offset, so it slides
   away under the bar with the tiles and returns with them. The gradient
   layers are sized to the body (100% 100%), which is the viewport, so a
   long feed cannot stretch a wash across two screens.

   `body:has(:is(<roots>):not(.hidden))` is the route test. The shell
   swaps screens by toggling `.hidden` on their roots (see
   frontend/src/lib/visibility-store.ts and App.SCREEN_IDS), so "this root
   is showing" IS "we are on this route", read from the DOM with no class
   for a module to publish. THE ROOTS are the launcher and everything the
   design treats as its peers — the app view (so tapping an app from the
   launcher leaves the bar exactly as it was: the frame covers everything
   below it, the wallpaper shows as the band behind the bell, and the push
   has nothing to animate in the bar), the leaderboard, the profile, the
   directory, settings, and the anonymous landing shell, so a signed-out
   visitor lands on the same page. The platform surfaces an app shows in
   its own frame (the Dev board, a session) sit on the wallpaper too, as
   the launcher's cards do. Messages and the admin console keep the
   `bg-zinc-100` ground and the zinc-200 bar: both are dense working
   surfaces, not pages. An engine without :has() (none the shell supports)
   simply keeps that everywhere.

   THE CREAM IS DIMMED. The export's page is #FFFEEA — 98% relative
   luminance, which is white with a cast to the eye, and a bright one: the
   greys most apps sit on (zinc-100, iOS's grouped background) are 88-90%.
   The ground here is the same hue at that luminance, #F4F2E4 (88%), and
   each wash is lowered by the same lightness step (about 3 points) so it
   keeps the presence it had on the export's cream rather than sinking
   into the dimmer page. The white cards read as cards on it, which on the
   export's page they only just did.

   THE DISCS are the export's blurred circles as radial gradients: each
   holds its fill to (r - 2σ), is at half opacity at r, and is gone by
   (r + 2σ), which is a gaussian edge to within what the eye reads. The two
   corner discs are anchored in px to their corners, as in the export; the
   mid-height one is anchored at 59% (515/874) so it stays mid-screen on a
   taller viewport instead of parking at one fixed offset.

   THE STAR AND THE GRAIN ARE INLINE, not files. app.css is the one asset
   the shell already precaches and build-scopes (/b/<sha>/css/…), and a
   url() to a sibling image would need the same treatment in sw.js and in
   the build-scoped path parser — two more places to keep honest for two
   decorative layers. The grain is the export's own filter chain
   (fractalNoise → luminanceToAlpha → one discrete band → #FFEE6F) on one
   stitched SVG tile, under 1 KB — at 30% rather than the export's 50%,
   which is what a pixel sweep against the designer's own render settled
   on: at 50% the specks measured a third louder than hers. The star is the designer's own
   raster of the element WITH its "halftone dot screen" effect — 194x156,
   the export's 97x78 box at 2x, so it is pixel-exact on a 2x screen — set
   32px in from the right and flush with the top, where the export puts it
   (its top point is already cropped in the raster, as the design crops it
   at the screen edge). It is one colour (#DEEDF8) at varying alpha, so it
   is stored as an indexed PNG: one palette entry at 32 alpha levels, which
   keeps the halftone intact at a quarter of the RGBA size. Do not rebuild
   it by hand — an SVG re-creation of the halftone came before it, and
   every engine drew it differently (Firefox drops blend modes inside an
   SVG image, for one); a raster is the same everywhere.

   DARK MODE IS THE SAME WALLPAPER ON THE INVERTED GROUND. The mock is a
   light-mode design, so the dark one is derived from it rather than drawn:
   the ground is the cream inverted (#0b0d1b — near black, a breath of
   blue), and every colour layer keeps its OWN hue on top of it. A plain
   inversion was tried first and lost the washes: inverted pastels sit a
   few lightness points above the inverted ground, which near black is
   nothing. So the washes are re-tuned instead — same lavender, rose and
   ember, the same alphas and geometry, at a lightness that reads on the
   dark ground (the ember a notch lower than the other two, since it is
   the most saturated of the three). The star stays periwinkle, which
   lifts off dark by itself, and the grain keeps its gold at a lower
   opacity, so it reads as a faint speckle rather than a glow. The .dark
   rule below sets those four values; everything else — the zoom surface,
   the cleared bar, the launch cover, the landing root — follows through
   the same custom properties in both modes.

   #agent-session-screen IS A ROOT TOO (#3082). An agent session is drawn on
   the dev session's lift ladder, whose strip is translucent glass over the
   body — so it has to be on this list or the glass frosts the bare
   `bg-zinc-100` / `dark:bg-zinc-950` body instead of the wallpaper. On a
   desktop the session usually shows inside #messages-screen (listed), which
   hid the omission; the side panel beside an app is phone-width, swaps
   `messages/agent/<id>` for `agent/<id>`, and so drew it gray.

   #profile-proposals-screen IS ONE TOO (#3498). Profile's Your changes,
   Your requests and Your votes are a drill-in off Me like Settings and the
   Leaderboard, drawn in the same plane cards, but the screen arrived after
   these lists and was on none of them: the page kept the gray body and the
   bar its zinc-200 surface. Every list here that names #workshop-screen,
   its React-owned sibling, names it as well. */
body:has(:is(#home-screen, #app-view, #leaderboard-screen, #profile-screen, #browse-screen, #settings-screen, #messages-screen, #workshop-screen, #profile-proposals-screen, #agent-session-screen, #auth-landing-screen, #auth-login-screen, #auth-register-screen, #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen):not(.hidden)) {
  --home-ground: #f4f2e4;
  /* The kit's tile-zoom paints the moving #app-view on this (native.js
     sets its inline background to the variable), and it is read from
     the body's descendants, so this is where it has to follow the ground:
     the card that zooms open out of a tile carries the wallpaper itself —
     the kit lays the view out at its settled size and scales it with a
     transform, so the star and the washes grow out of the tile and land
     pixel-for-pixel on the body's, and nothing pops when the kit lifts
     the surface. */
  --un-zoom-bg: var(--home-wallpaper);
  --home-star: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAACcCAMAAAAau4SEAAAAYFBMVEXe7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fje7fiX04/GAAAAIHRSTlMACBAZISkxOkJKUlpja3N7hIyUnKWttb3Fztbe5u/3/3zanboAAAwlSURBVHja7VzZcqM4FLUW0AJCrJJAQvz/X05dAY6zdSeO7dg91ksmXZUaDpxz96vD4dIHIYzQARGuKsUJycqu78oMHxAhBB0e6aC8KIocISLbrpH4sR5+RUBk0zaSEKatM1WGH+nhMc0oQZRr66zmQvU++E5miGR5Rh4DQl5WimPe2DAHU3d2muM89hLzuqnFQ/AJ8aZtJZGDj8syDTbEZVmCU6QYzFDgJ4Qnkf4/cl4PFXWCwIvOh6nlD/XwBwTOWbQOiNTUtZnjPJQ5SucxAFDGeU6Vm5dl8c6YKS5xbAXOhOAZ2iDetxRkb0wtm2SJ4hwCQJlHnZXGmhIfDgjj+w43EFY+zoPq4MlfztzkdYihJk8INyWSf1giHQ67nMNbOfNHkfMBU0oJLlcIdhjGuESrOcLwz4/i1IRgmU5ECpNzPn0FiSnnjN73+185jqiom1rJFj7CEoP3yT+YKhO61oKge9YChhgIUdmM0zQ0ZrVIMcYk69CVzTiNNSeIMJbfabhBGGMU5WqYl2XqXXxlVKPRQ1xiLynKhBAZekL4RSIxguj9Euke5LwHw+e6nvR3CHNd64LVCcIc/AT/MQ0lYUqV7LoEQoQSjE4CgGN8/zVI69/hXA3W1PtX2AIMb1Qm27YR1/FtiGYZTQwoCk4QolxKhhEiTIgcg0x5CgxIzhj8xDnLwUXhLD/+JOnvpchxVnaT94MeNi3Mc3JxYyNrKInlBOEso/jw8qrQ26//fRLQQlcFQUT0zrWcENE51+QY887aOkOIt8Zoejiwdhgqcjiwpu8U/Ky7toCfuoGCIykGZysqe0j3JzPuFimuSFyfygEVw6yqFBCKZFnKpSEgAUiYkBUSxt8Fkemuqyii5bgsVlJajMtiOMbFGOPAEJJunvvsgKQNoaWHgzDT1MDPYXSaHA6it6YiiFZ+iV2uxjm9df/aqC5+DTQ6iWXbtVDJoGCEDysUvEIhp1/n/wXhMyLB701OmZ5iNDKHhws9p7S0fmpzTOTgrKYIiXboS4JIaUenf4dIn8kZfhe5aNy8xKlv6sHH2eqybJyfOslY2ZuhygnhuqklQSiHfsKncm6TnPUf5IzOl/Mbo4rAAuUYZ1yWRVHUiRjzNLppjtGbvjM+eNto3RprW1XIqu3aSjAmVaUkE+oUQtwhuFpsRXr0jjAXTIHSa0CE67ZVGas665yz41aJmNOzgLcNMcYwTaMPIfjRudF778zQm3EcTds7D0SyU3wNoa+HkKp6OC8KuRrptbqHycW8NYJsCxFWmXHsC5XKunF+ncV/cOKpCwuT32Ki7e9iTBDmyaUE1DVFXrZdU1BCBGgCI5xLCWEfxE4MqgM7NLJp5BvypkWlJMm08fM89YPfXuPypbOHc3Pcf38DcV4hjr3uJz+1UojGjU7nNKusMyVGpByGTqIDlm1bc3RAXKkigzeb51/tEWVV2yrKOqBAcOO8XONE17m4RKPKcojL3PKcdXEJmiBahzApdMCVdQNAKdpWs8MBZZx/NbL9ByCsRMovTaTl60QyndiIxM4j0o3lTN/LmbMk4+2Rz5Dz7h24bpv3RjWeBeHFqJoe6kmmElnRQjMXY1aWRZ4efbVAZ3jlT8N+CKKTaytW1xbhLfp4DpGOEEabvoKtVdmOk9Msy1XftxIjDITJU8CxvvVLtThSSQsCjNKt3tnH8+S7E2kc/bLEaeg6N8cwVGXZARSeM4ACfoJvX2X3Cz9IULc/RWvYV45/IdIXtbJ+jdl7P8clemvMOEMXSOvBg7w5r91oFUFHv/ANGb+HACEjSsG2EeV0Dds6r2WBOA190sigpAQjW1O8G1Uwpoz8jyGsRILEs2GFnX9GpOUcIrGfEWmXc0r/CWvBT19CzuEDOdefyBkfLtNKScE3LRrjnOmGVNI6uqyvI9iNql1dm9ZmnkMrWBryKQnCvKrKSxvVNz47F0JwVg4p5bHfi50+hhBD9wIB5YUUNL31q7V/MCSmmDV2jnHqzPxjvxDjfPQLLNsDjVN/cGko4OIYzqshLMvY9OHHfmE5kXNdymYcXZVhLJvkD6AscGlCZWVVCZIpePix7sJFYu6jUa1VCroZwcraZEwpyy9d+f4HIBBRSIapgOLLoLZ+cozn+4XbEwkRSkFtom5bxdZq3exDPNsvnMi5vo2cX2QtJSO8teM02t76LS+IL6L9jlHN92D7mC/coouTSmWEyUrrUlQGaqTeuLXk9UkA8hmEVmRkS3lu2cSFgWUYoYDZOwG10wjEsH+E8Gm+oPhOoGNZ+AZQCF9bJrwzpqISvsLi7fhHIn2aLxgtU/qfYcR1XbG1FHbtNiItVMnxXpyXBgQdxums8G/23dGYFn0qgUEzl9MnhK8QCaVGVa8Ih/HBeezt/BciLXdEpE3O0C5kFFrkbpr6sglflXP4fTm/jV9l07UqV1uF42NX93m+QH9/CiB505xi0Y2nlukbEO5gxGotmW0Q3BS/5xdYmluFvvUvjuBC31lla6nsDwnpJ35BZWobN9zbhb9woGnbsMqfZVQnnTdzhKL8Uc5PCOcSqfw7kZY7JtJ35RzuT877twD/7M1DGtVTCOGTr3DXru01kWYfzs4X8O8tTr7yCzGenS/AxCR9QKP6EmwjVq6NqieEH/uFv9SV7pRI35VzuD85Q4qCdwjv8oX1u+xzqt4MW9P2xaimlvmvG9Us25a/5ncBxvpdoPC4+oOmW1vnaiuF0WNr5PdSHpyXShWi8R8nnrN/tdfWdSuUttkKkoKprmsLitGvhXk4V2Ych2b4JP3fpwG2dSTvtwGHaSsLt1ubMMNb+n/7Qzgk/tNewfh612crztt1JkPlexHmCeH/SaS/yzl8KOf2fuSMUFaUpRT1GwjzBEOefjRp6HOoddVaZ5uirLpxGlspYYV7ajglsmk0/02jiqnshr7Vb4gUXdNP3vdKrKO3eZYxIXhOaSZ1XQma8do5oyihZde3v7RIv46ZrAVJN7xdO0oQpq5gdL1QBcHCJKMwPw2LMDSH2MhqxmRjraly/JL63C7/JEzwDK9l4fCu5xbcel1BJbQbHawfibaD3UKR1pGE6tdt9KZzIfihYmS3SPvY+Q0OldBKX4vz7yPUuE1CtmU3L7EXFMNEvSK5SkthRePWcM84yOJcK6j2YVToZfj/CeHLREJHIi1vVy0egEh/lrPVXZJzTnY5QwWcvJUzfy/nm5eTqKjdNNlu7/KkZnoMY8P3K53So5/+RVoWhr5EUwuMadG26XaSX6uEsdqeNKri7Dq478LWgqQdHfx+ixAloqA0sEYQglHbtMdwQwK9REfQri9S03YfTz9C0BzTbQ/udF8NpbWj1OVPewoUBiJeDzzfslEF7ULNqym+rOqk0ZZEJAebbWaAvbe93Lu33LdrbSAy7ZO8X4+d37K6B03bvqjDhyWv0GTKx9AAhNcbU2i7XAhXPkwVzoo3w/+3aNr+QxDeEGn5gEjmzokEfecfyPlwB3IG10Z2CNbaKcxhMnWhB2NqaHp80c4fl8LozUthsLrHeb0SKQxKG2sbmdOMwddB33gV61u/OQREeFVrXW3DntGUlbEWLodI8kVff/TVe/8CkQjXdprcsTE19caHYGuB80rrr2yVb/dgILiYUSB8ezlTCWOe83EZeG1RTX1JOGzc/h3CfokEVm60JSa3N6r/AISPiDR/i0j0t4mU5Fx/KufDA8gZYaa0VlLbtCzs6hwslPzB67t5sA1JsLNQ0lpXthkmXzSmf6iq3QpBinVQVvYedvgLURlrIDf+KQH2RtVxFuN6iNY5VdGv2yRNUU8h9IIisl3tcebL39uFqfO5aeJqM9tpyFOmfD/6dbDQSIrIfo3Zt1W1QtiatkjUjWbXlfU/AOE1kWoB/YUGLs66JJGuO/z/Ws6cQn/hEvcd3VDOp0a1Sa4sz8gDXp+9uTZJ0SNcpPj+/cP9SGu+UCQIF/aquziup4X1fqQ1zOvAErH1yqeL5eRboHGFdaT9HaXLhbI12LZlDrdSyUsOIGzF+Wsshf07EFYi0VMiXfZay+sT6UM548uq+dpyPi3CZI/oD16XwvDDPTR6IdLN/ncXfu1JZvu9edf/yhfXwn4jw37p3A0g4CeEf5BI/4Kcn+d5nud5nud5nud5nud5nud5nud5zj//Ac7b73f5xK5wAAAAAElFTkSuQmCC");
  --home-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='n' x='0' y='0' width='100%' height='100%' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.3333' numOctaves='3' seed='8632' stitchTiles='stitch'/%3E%3CfeColorMatrix type='luminanceToAlpha'/%3E%3CfeComponentTransfer result='a'%3E%3CfeFuncA type='discrete' tableValues='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0'/%3E%3C/feComponentTransfer%3E%3CfeFlood flood-color='%23FFEE6F' flood-opacity='.3'/%3E%3CfeComposite in2='a' operator='in'/%3E%3C/filter%3E%3Crect width='240' height='240' filter='url(%23n)'/%3E%3C/svg%3E");
  /* THE WHOLE WALLPAPER AS ONE SHORTHAND VALUE, so the body and the zoom
     surface paint the identical thing. The kit assigns the moving view's
     `background` (the shorthand) from --un-zoom-bg, so a multi-layer
     shorthand is what that variable has to hold; the body uses the same
     value, and the 640px rule below swaps in the wider one. Each layer is
     `<image> <position> / <size> <repeat>`, the colour rides on the last. */
  --home-washes:
    radial-gradient(circle at calc(100% - 92px) 115px, rgba(229, 216, 239, 0.55) 138px, rgba(229, 216, 239, 0.275) 198px, rgba(229, 216, 239, 0) 258px) 0 0 / 100% 100% no-repeat,
    radial-gradient(circle at calc(100% - 62px) 59%, rgba(245, 215, 230, 0.65) 50px, rgba(245, 215, 230, 0.325) 120px, rgba(245, 215, 230, 0) 190px) 0 0 / 100% 100% no-repeat,
    radial-gradient(circle at 70px calc(100% - 69px), rgba(252, 219, 212, 0.75) 90px, rgba(252, 219, 212, 0.375) 170px, rgba(252, 219, 212, 0) 250px) 0 0 / 100% 100% no-repeat var(--home-ground);
  --home-wallpaper:
    var(--home-star) right 32px top var(--home-star-y, 0px) / 97px 78px no-repeat,
    var(--home-grain) 0 0 / 240px 240px repeat,
    var(--home-washes);
  background: var(--home-wallpaper);
}
/* THE WALLPAPER FOLLOWS THE APP'S TONE AS WELL AS THE SHELL'S (#1945). The
   dark rule below fires for the dark shell, as it always did, AND for a
   dark app under the light shell; the light base above it holds for the
   light shell and for a light app under the dark shell. `data-app-tone` is
   what features/app-frame/app-tone.js writes on <html> from the page colour
   the framed app's bridge reports, and it exists only while that app is on
   screen — so on every other route these selectors are exactly the two they
   were. See "THE BAR TAKES THE APP'S TONE" below for the tokens that ride
   along with the ground. */
:is(.dark:not([data-app-tone="light"]), [data-app-tone="dark"]) body:has(:is(#home-screen, #app-view, #leaderboard-screen, #profile-screen, #browse-screen, #settings-screen, #messages-screen, #workshop-screen, #profile-proposals-screen, #agent-session-screen, #auth-landing-screen, #auth-login-screen, #auth-register-screen, #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen):not(.hidden)) {
  --home-ground: #0b0d1b;
  /* THE STAR IS AS QUIET HERE AS IT IS ON CREAM. The same halftone raster,
     with its one palette entry re-tinted: #DEEDF8 on #F4F2E4 is a faint cool
     cast a reader notices only when looking for it, and that same near-white
     blue on a near-black page is the loudest thing on the screen. Matched in
     CIE Lab rather than by eye — the light pair sits ~14 dE apart, and
     #1A2239 on this ground is ~12, which is the same "there, but not
     announcing itself". The geometry, the alpha ramp and the file are
     untouched; only the palette entry differs. */
  --home-star: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAACcCAMAAAAau4SEAAAAYFBMVEUaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjkaIjlBgHcuAAAAIHRSTlMACBAZISkxOkJKUlpja3N7hIyUnKWttb3Fztbe5u/3/3zanboAAAwlSURBVHja7VzZcqM4FLUW0AJCrJJAQvz/X05dAY6zdSeO7dg91ksmXZUaDpxz96vD4dIHIYzQARGuKsUJycqu78oMHxAhBB0e6aC8KIocISLbrpH4sR5+RUBk0zaSEKatM1WGH+nhMc0oQZRr66zmQvU++E5miGR5Rh4DQl5WimPe2DAHU3d2muM89hLzuqnFQ/AJ8aZtJZGDj8syDTbEZVmCU6QYzFDgJ4Qnkf4/cl4PFXWCwIvOh6nlD/XwBwTOWbQOiNTUtZnjPJQ5SucxAFDGeU6Vm5dl8c6YKS5xbAXOhOAZ2iDetxRkb0wtm2SJ4hwCQJlHnZXGmhIfDgjj+w43EFY+zoPq4MlfztzkdYihJk8INyWSf1giHQ67nMNbOfNHkfMBU0oJLlcIdhjGuESrOcLwz4/i1IRgmU5ECpNzPn0FiSnnjN73+185jqiom1rJFj7CEoP3yT+YKhO61oKge9YChhgIUdmM0zQ0ZrVIMcYk69CVzTiNNSeIMJbfabhBGGMU5WqYl2XqXXxlVKPRQ1xiLynKhBAZekL4RSIxguj9Euke5LwHw+e6nvR3CHNd64LVCcIc/AT/MQ0lYUqV7LoEQoQSjE4CgGN8/zVI69/hXA3W1PtX2AIMb1Qm27YR1/FtiGYZTQwoCk4QolxKhhEiTIgcg0x5CgxIzhj8xDnLwUXhLD/+JOnvpchxVnaT94MeNi3Mc3JxYyNrKInlBOEso/jw8qrQ26//fRLQQlcFQUT0zrWcENE51+QY887aOkOIt8Zoejiwdhgqcjiwpu8U/Ky7toCfuoGCIykGZysqe0j3JzPuFimuSFyfygEVw6yqFBCKZFnKpSEgAUiYkBUSxt8Fkemuqyii5bgsVlJajMtiOMbFGOPAEJJunvvsgKQNoaWHgzDT1MDPYXSaHA6it6YiiFZ+iV2uxjm9df/aqC5+DTQ6iWXbtVDJoGCEDysUvEIhp1/n/wXhMyLB701OmZ5iNDKHhws9p7S0fmpzTOTgrKYIiXboS4JIaUenf4dIn8kZfhe5aNy8xKlv6sHH2eqybJyfOslY2ZuhygnhuqklQSiHfsKncm6TnPUf5IzOl/Mbo4rAAuUYZ1yWRVHUiRjzNLppjtGbvjM+eNto3RprW1XIqu3aSjAmVaUkE+oUQtwhuFpsRXr0jjAXTIHSa0CE67ZVGas665yz41aJmNOzgLcNMcYwTaMPIfjRudF778zQm3EcTds7D0SyU3wNoa+HkKp6OC8KuRrptbqHycW8NYJsCxFWmXHsC5XKunF+ncV/cOKpCwuT32Ki7e9iTBDmyaUE1DVFXrZdU1BCBGgCI5xLCWEfxE4MqgM7NLJp5BvypkWlJMm08fM89YPfXuPypbOHc3Pcf38DcV4hjr3uJz+1UojGjU7nNKusMyVGpByGTqIDlm1bc3RAXKkigzeb51/tEWVV2yrKOqBAcOO8XONE17m4RKPKcojL3PKcdXEJmiBahzApdMCVdQNAKdpWs8MBZZx/NbL9ByCsRMovTaTl60QyndiIxM4j0o3lTN/LmbMk4+2Rz5Dz7h24bpv3RjWeBeHFqJoe6kmmElnRQjMXY1aWRZ4efbVAZ3jlT8N+CKKTaytW1xbhLfp4DpGOEEabvoKtVdmOk9Msy1XftxIjDITJU8CxvvVLtThSSQsCjNKt3tnH8+S7E2kc/bLEaeg6N8cwVGXZARSeM4ACfoJvX2X3Cz9IULc/RWvYV45/IdIXtbJ+jdl7P8clemvMOEMXSOvBg7w5r91oFUFHv/ANGb+HACEjSsG2EeV0Dds6r2WBOA190sigpAQjW1O8G1Uwpoz8jyGsRILEs2GFnX9GpOUcIrGfEWmXc0r/CWvBT19CzuEDOdefyBkfLtNKScE3LRrjnOmGVNI6uqyvI9iNql1dm9ZmnkMrWBryKQnCvKrKSxvVNz47F0JwVg4p5bHfi50+hhBD9wIB5YUUNL31q7V/MCSmmDV2jnHqzPxjvxDjfPQLLNsDjVN/cGko4OIYzqshLMvY9OHHfmE5kXNdymYcXZVhLJvkD6AscGlCZWVVCZIpePix7sJFYu6jUa1VCroZwcraZEwpyy9d+f4HIBBRSIapgOLLoLZ+cozn+4XbEwkRSkFtom5bxdZq3exDPNsvnMi5vo2cX2QtJSO8teM02t76LS+IL6L9jlHN92D7mC/coouTSmWEyUrrUlQGaqTeuLXk9UkA8hmEVmRkS3lu2cSFgWUYoYDZOwG10wjEsH+E8Gm+oPhOoGNZ+AZQCF9bJrwzpqISvsLi7fhHIn2aLxgtU/qfYcR1XbG1FHbtNiItVMnxXpyXBgQdxums8G/23dGYFn0qgUEzl9MnhK8QCaVGVa8Ih/HBeezt/BciLXdEpE3O0C5kFFrkbpr6sglflXP4fTm/jV9l07UqV1uF42NX93m+QH9/CiB505xi0Y2nlukbEO5gxGotmW0Q3BS/5xdYmluFvvUvjuBC31lla6nsDwnpJ35BZWobN9zbhb9woGnbsMqfZVQnnTdzhKL8Uc5PCOcSqfw7kZY7JtJ35RzuT877twD/7M1DGtVTCOGTr3DXru01kWYfzs4X8O8tTr7yCzGenS/AxCR9QKP6EmwjVq6NqieEH/uFv9SV7pRI35VzuD85Q4qCdwjv8oX1u+xzqt4MW9P2xaimlvmvG9Us25a/5ncBxvpdoPC4+oOmW1vnaiuF0WNr5PdSHpyXShWi8R8nnrN/tdfWdSuUttkKkoKprmsLitGvhXk4V2Ych2b4JP3fpwG2dSTvtwGHaSsLt1ubMMNb+n/7Qzgk/tNewfh612crztt1JkPlexHmCeH/SaS/yzl8KOf2fuSMUFaUpRT1GwjzBEOefjRp6HOoddVaZ5uirLpxGlspYYV7ajglsmk0/02jiqnshr7Vb4gUXdNP3vdKrKO3eZYxIXhOaSZ1XQma8do5oyihZde3v7RIv46ZrAVJN7xdO0oQpq5gdL1QBcHCJKMwPw2LMDSH2MhqxmRjraly/JL63C7/JEzwDK9l4fCu5xbcel1BJbQbHawfibaD3UKR1pGE6tdt9KZzIfihYmS3SPvY+Q0OldBKX4vz7yPUuE1CtmU3L7EXFMNEvSK5SkthRePWcM84yOJcK6j2YVToZfj/CeHLREJHIi1vVy0egEh/lrPVXZJzTnY5QwWcvJUzfy/nm5eTqKjdNNlu7/KkZnoMY8P3K53So5/+RVoWhr5EUwuMadG26XaSX6uEsdqeNKri7Dq478LWgqQdHfx+ixAloqA0sEYQglHbtMdwQwK9REfQri9S03YfTz9C0BzTbQ/udF8NpbWj1OVPewoUBiJeDzzfslEF7ULNqym+rOqk0ZZEJAebbWaAvbe93Lu33LdrbSAy7ZO8X4+d37K6B03bvqjDhyWv0GTKx9AAhNcbU2i7XAhXPkwVzoo3w/+3aNr+QxDeEGn5gEjmzokEfecfyPlwB3IG10Z2CNbaKcxhMnWhB2NqaHp80c4fl8LozUthsLrHeb0SKQxKG2sbmdOMwddB33gV61u/OQREeFVrXW3DntGUlbEWLodI8kVff/TVe/8CkQjXdprcsTE19caHYGuB80rrr2yVb/dgILiYUSB8ezlTCWOe83EZeG1RTX1JOGzc/h3CfokEVm60JSa3N6r/AISPiDR/i0j0t4mU5Fx/KufDA8gZYaa0VlLbtCzs6hwslPzB67t5sA1JsLNQ0lpXthkmXzSmf6iq3QpBinVQVvYedvgLURlrIDf+KQH2RtVxFuN6iNY5VdGv2yRNUU8h9IIisl3tcebL39uFqfO5aeJqM9tpyFOmfD/6dbDQSIrIfo3Zt1W1QtiatkjUjWbXlfU/AOE1kWoB/YUGLs66JJGuO/z/Ws6cQn/hEvcd3VDOp0a1Sa4sz8gDXp+9uTZJ0SNcpPj+/cP9SGu+UCQIF/aquziup4X1fqQ1zOvAErH1yqeL5eRboHGFdaT9HaXLhbI12LZlDrdSyUsOIGzF+Wsshf07EFYi0VMiXfZay+sT6UM548uq+dpyPi3CZI/oD16XwvDDPTR6IdLN/ncXfu1JZvu9edf/yhfXwn4jw37p3A0g4CeEf5BI/4Kcn+d5nud5nud5nud5nud5nud5nud5zj//Ac7b73f5xK5wAAAAAElFTkSuQmCC");
  /* NO GRAIN IN DARK MODE. The speck field is a scatter of gold dots at a
     low opacity: on cream it is the paper's tooth, which is what the export
     draws it as. On a near-black ground the same dots are little bright
     points — the page read as a starfield, which is a different picture
     from the one the design is of. Dropped rather than dimmed: at an opacity
     low enough to stop reading as stars it was not visible at all, so the
     layer was costing a repeat paint for nothing.  is a valid layer
     image, so the shorthands below keep their shape and the desktop rule
     needs no second copy. */
  --home-grain: none;
  --home-washes:
    radial-gradient(circle at calc(100% - 92px) 115px, rgba(80, 48, 105, 0.55) 138px, rgba(80, 48, 105, 0.275) 198px, rgba(80, 48, 105, 0) 258px) 0 0 / 100% 100% no-repeat,
    radial-gradient(circle at calc(100% - 62px) 59%, rgba(117, 36, 77, 0.65) 50px, rgba(117, 36, 77, 0.325) 120px, rgba(117, 36, 77, 0) 190px) 0 0 / 100% 100% no-repeat,
    radial-gradient(circle at 70px calc(100% - 69px), rgba(118, 31, 15, 0.75) 90px, rgba(118, 31, 15, 0.375) 170px, rgba(118, 31, 15, 0) 250px) 0 0 / 100% 100% no-repeat var(--home-ground);
  --home-wallpaper:
    var(--home-star) right 32px top var(--home-star-y, 0px) / 97px 78px no-repeat,
    var(--home-grain) 0 0 / 240px 240px repeat,
    var(--home-washes);
}
/* WIDE VIEWPORTS. The three discs above are the phone frame's, anchored in
   px to its corners, and on a desktop window they shrink into the corners
   and leave a field of plain cream. From the shell's desktop breakpoint four
   more washes join them — the same three tints, sized in viewport units so
   they scale with the window rather than with the phone, and placed across
   the middle of the page where the frame's discs never reach. The dark rule
   above is the more specific selector, so it would keep the phone layers
   at every width on its own — the second rule here gives dark the same
   four extra washes, in its own tints. */
@media (min-width: 640px) {
  body:has(:is(#home-screen, #app-view, #leaderboard-screen, #profile-screen, #browse-screen, #settings-screen, #messages-screen, #workshop-screen, #profile-proposals-screen, #agent-session-screen, #auth-landing-screen, #auth-login-screen, #auth-register-screen, #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen):not(.hidden)) {
    --home-washes:
      radial-gradient(circle at calc(100% - 92px) 115px, rgba(229, 216, 239, 0.55) 138px, rgba(229, 216, 239, 0.275) 198px, rgba(229, 216, 239, 0) 258px) 0 0 / 100% 100% no-repeat,
      radial-gradient(circle at calc(100% - 62px) 59%, rgba(245, 215, 230, 0.65) 50px, rgba(245, 215, 230, 0.325) 120px, rgba(245, 215, 230, 0) 190px) 0 0 / 100% 100% no-repeat,
      radial-gradient(circle at 70px calc(100% - 69px), rgba(252, 219, 212, 0.75) 90px, rgba(252, 219, 212, 0.375) 170px, rgba(252, 219, 212, 0) 250px) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 38vw 36vh at 24% 20%, rgba(229, 216, 239, 0.65), rgba(229, 216, 239, 0) 70%) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 36vw 32vh at 66% 76%, rgba(245, 215, 230, 0.65), rgba(245, 215, 230, 0) 70%) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 32vw 32vh at 4% 64%, rgba(252, 219, 212, 0.7), rgba(252, 219, 212, 0) 70%) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 34vw 28vh at 90% 32%, rgba(229, 216, 239, 0.55), rgba(229, 216, 239, 0) 70%) 0 0 / 100% 100% no-repeat var(--home-ground);
    --home-wallpaper:
      var(--home-star) right 32px top var(--home-star-y, 0px) / 97px 78px no-repeat,
      var(--home-grain) 0 0 / 240px 240px repeat,
      var(--home-washes);
  }
  :is(.dark:not([data-app-tone="light"]), [data-app-tone="dark"]) body:has(:is(#home-screen, #app-view, #leaderboard-screen, #profile-screen, #browse-screen, #settings-screen, #messages-screen, #workshop-screen, #profile-proposals-screen, #agent-session-screen, #auth-landing-screen, #auth-login-screen, #auth-register-screen, #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen):not(.hidden)) {
    --home-washes:
      radial-gradient(circle at calc(100% - 92px) 115px, rgba(80, 48, 105, 0.55) 138px, rgba(80, 48, 105, 0.275) 198px, rgba(80, 48, 105, 0) 258px) 0 0 / 100% 100% no-repeat,
      radial-gradient(circle at calc(100% - 62px) 59%, rgba(117, 36, 77, 0.65) 50px, rgba(117, 36, 77, 0.325) 120px, rgba(117, 36, 77, 0) 190px) 0 0 / 100% 100% no-repeat,
      radial-gradient(circle at 70px calc(100% - 69px), rgba(118, 31, 15, 0.75) 90px, rgba(118, 31, 15, 0.375) 170px, rgba(118, 31, 15, 0) 250px) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 38vw 36vh at 24% 20%, rgba(80, 48, 105, 0.65), rgba(80, 48, 105, 0) 70%) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 36vw 32vh at 66% 76%, rgba(117, 36, 77, 0.65), rgba(117, 36, 77, 0) 70%) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 32vw 32vh at 4% 64%, rgba(118, 31, 15, 0.7), rgba(118, 31, 15, 0) 70%) 0 0 / 100% 100% no-repeat,
      radial-gradient(ellipse 34vw 28vh at 90% 32%, rgba(80, 48, 105, 0.55), rgba(80, 48, 105, 0) 70%) 0 0 / 100% 100% no-repeat var(--home-ground);
    --home-wallpaper:
      var(--home-star) right 32px top var(--home-star-y, 0px) / 97px 78px no-repeat,
      var(--home-grain) 0 0 / 240px 240px repeat,
      var(--home-washes);
  }
}
/* THE HEADER STAYS CLEAR OF THE STAR in a running app and on New project
   (polish N20). On Home the star sits behind the bell by design, but over
   somebody's app the bar is the app's header, and on the make screen the
   bar holds only the ✕ and the mark: there it read as a stray shape showing
   through. Both draw the same wallpaper with its first layer, the star,
   left out. `html:root` lifts this past the dark and wide rules above,
   which define the full shorthand at the same route test. The make screen
   paints `var(--home-wallpaper)` on its own root (make.tsx), so the
   variable is redefined there too; it reads the body's grain and washes. */
html:root body:has(#app-view:not(.hidden):not([data-app-surface="platform"])),
[data-first-session-make] {
  --home-wallpaper:
    var(--home-grain) 0 0 / 240px 240px repeat,
    var(--home-washes);
}
/* THE BAR OVER THE WALLPAPER. On every wallpaper route the header's
   zinc-200 surface goes, so the wallpaper (and the star) run up behind the
   bell — the one place the design's "page ground runs to the top of the
   screen" is literal. (#landing-header never had a surface; it sits on
   the body's ground already.) The controls on it carry their own
   periwinkle fill and hairline (--brand-tint / --brand-line), so they
   still lift off cream and off dark alike. Everywhere else the bar keeps
   its surface — see the "THE BAR'S OWN SURFACE" note in
   platform-header.tsx for why it has one. The view-transition group note
   above (#platform-header is opaque) has its exception here, and it is
   what makes the launcher → app push read as one bar: both routes paint
   the same wallpaper under the same cleared bar, so the bar's snapshot is
   identical on both sides of the transition and only the page moves. */
body:has(:is(#home-screen, #app-view, #leaderboard-screen, #profile-screen, #browse-screen, #settings-screen, #messages-screen, #workshop-screen, #profile-proposals-screen, #global-chat-screen, #agent-session-screen, #auth-landing-screen, #auth-login-screen, #auth-register-screen, #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen):not(.hidden)) #platform-header {
  background-color: transparent;
}

/* ── THE BAR IS THE SAME MATERIAL AS THE BAR AT THE FOOT (#2718 review) ──

   The rule above clears the header on every screen that paints the
   wallpaper, so the ground runs straight through it. That was right while
   the bar floated alone on a page; it is wrong now that a second bar is on
   screen with it, because the two are the same KIND of thing — the platform's
   own chrome around somebody's content — and they were drawn as two
   different ones: `.platform-tabs` a frosted wall with a hairline, the header
   nothing at all.

   So the header takes the tab bar's surface exactly: `--dc-sheet-fill` under
   `--dc-frost`, the pane material every floating surface in the shell wears.
   On a desktop the rail and the header meet at the corner and read as one
   continuous edge around the page, which is what the design draws.

   NOT INSIDE AN APP, and not signed out. `#app-view` keeps the clear bar
   because there is no tab bar there to match and the strip takes the APP's
   tone instead (`data-app-tone`, #1945) — a frost over somebody else's page
   colour is a smear, not a surface. The auth screens have no bar either.

   NO BORDER-BOTTOM. The tab bar's hairline is its top edge meeting the page;
   this bar's bottom edge is `rounded-b-2xl -mb-2`, a notch every platform
   surface below tucks 8px up into, so a line drawn across it would cut the
   notch in half. The frost IS the edge here. */
body:has(:is(#home-screen, #leaderboard-screen, #profile-screen, #browse-screen,
             #settings-screen, #messages-screen, #workshop-screen,
             #global-chat-screen, #agent-session-screen,
             #profile-proposals-screen):not(.hidden)) #platform-header {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* …AND OVER AN APP'S OWN WORKSHOP (#2806). The exclusion above is about the
   APP: its page colour is under the bar, and the bar takes that tone instead.
   An app's Workshop tab (board, sessions, a discussion) is not the app, it is
   the platform's own page about it — `data-app-surface="platform"`, set by
   AppView._setSurface for everything the shell renders into #app-content —
   and with the frame parked there is no app tone either (toneForState answers
   null). So it is the same chrome as the standalone Workshop screen and wears
   the same glass; left clear, the bar went bare on exactly those pages and
   nowhere else. A separate rule, not a widened selector: the one above is
   pinned to exclude #app-view, because a running app must stay clear. */
body:has(#app-view:not(.hidden)[data-app-surface="platform"]) #platform-header {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* …AND THE HEADER, faked the same way as the tab bar ("FAKED GLASS" below;
   its layer is in the shared rule there),
   on exactly the routes the glass rules above cover. The clip follows the
   bar's own `rounded-b-2xl` (1.25rem in tailwind.config.js), so the notch
   the surfaces below tuck into keeps its shape. Nothing inside the header
   reaches past its box, so the clip cuts nothing. */
body:has(:is(#home-screen, #leaderboard-screen, #profile-screen, #browse-screen,
             #settings-screen, #messages-screen, #workshop-screen,
             #global-chat-screen, #agent-session-screen,
             #profile-proposals-screen):not(.hidden)) #platform-header,
body:has(#app-view:not(.hidden)[data-app-surface="platform"]) #platform-header {
  background-color: transparent;
  clip-path: inset(0 round 0 0 1.25rem 1.25rem);
}
/* THE STRIP OVER THE BAR FOLLOWS IT HOME (onboarding run three, 12 Oct
   2026, iPhone 17 Safari 26). In a browser tab Safari colours the strip
   behind the status bar from the fixed bar's own `background-color`, and
   only an opaque one: the project's green bar turned it green, and back on
   Home, whose bar is transparent with its glass drawn by the layer below,
   it stayed green (measured on a copy: green, then transparent, held the
   green; an opaque colour took it back at once). So the bar here has an
   opaque ground under its glass, the glass's own solid plane colour. The
   layer covers it whole (its washes end on the ground colour), so nothing
   on the bar changes; only the strip does. */
html.un-ios.web-browser-chrome:not([data-community-tint]) body:has(:is(#home-screen, #leaderboard-screen, #profile-screen, #browse-screen,
             #settings-screen, #messages-screen, #workshop-screen,
             #global-chat-screen, #agent-session-screen,
             #profile-proposals-screen):not(.hidden)) #platform-header,
html.un-ios.web-browser-chrome:not([data-community-tint]) body:has(#app-view:not(.hidden)[data-app-surface="platform"]) #platform-header {
  background-color: var(--dc-sheet-solid);
}
/* …AND ON THE APP TAB, WHERE THE PAGE IS NOT THE SCROLLER (newcomer run
   six, 14 Oct 2026, iPhone Safari 26). Safari takes the strip's colour from
   a bar FIXED (or sticky) at the top. On the routes above a phone browser
   scrolls the document (lib/browser-scroll.ts), so the bar is sticky
   (`html[data-browser-scroller]`) and the strip follows its ground. The App
   tab of a first version being built is the platform's own page, but the
   document is not its scroller (pageScroller answers only the project's own
   pages, #dev-forum-scroll), so its bar sat in the flow, Safari found no bar
   to take a colour from, and the strip stayed the hub's teal over the App
   tab's light bar. Sticky at the top here as well: the page does not
   scroll, so the bar stays exactly where it was, and the strip takes the
   opaque ground the rule above gives it. Back on the hub the bar is sticky
   and tinted again (`data-community-tint`), and the strip turns teal.
   …AND ON EVERY OTHER SCREEN THE DOCUMENT DOES NOT SCROLL (newcomer run
   seven, 14 Oct 2026, iPhone Safari 26): from the hub to Messages the strip
   stayed teal over Messages' light bar, for the same reason. Of the routes
   above, only Home, Browse, Leaderboard, Profile and Settings are page
   scrollers (lib/browser-scroll.ts PAGES); Messages, Communities, #general,
   an agent's session and a profile's proposals each scroll an element of
   their own, so their bar is sticky here too. Sticky changes no layout on
   them: it offsets the bar only when its nearest scroller, <body>
   (`overflow: hidden`, 100dvh), scrolls, and nothing scrolls it, so the
   bar stays where it sits in the flow; and it is already `relative z-10`,
   so it was a stacking context and a containing block before. */
html.un-ios.web-browser-chrome:not([data-community-tint]):not([data-browser-scroller]) body:has(:is(#messages-screen, #workshop-screen, #global-chat-screen,
             #agent-session-screen, #profile-proposals-screen):not(.hidden)) #platform-header,
html.un-ios.web-browser-chrome:not([data-community-tint]):not([data-browser-scroller]) body:has(#app-view:not(.hidden)[data-app-surface="platform"]) #platform-header {
  position: sticky;
  top: 0;
}
/* THE BAR TAKES THE APP'S TONE (#1945). A dark app under the light shell
   used to sit beneath a cream strip, and a light app under the dark shell
   beneath a night one: the bar has no surface inside an app, so the
   shell's wallpaper ran up behind its controls whatever colour the page
   under them was. The framed app's bridge already tells the shell its
   opaque page colour (`__usernode_background`, #1581);
   features/app-frame/app-tone.js reads it as a tone and writes
   `data-app-tone` on <html> while the frame is on screen. The wallpaper
   rules above key off it, and these carry the tokens that have to move
   with the ground: the bar's periwinkle controls (--brand-*: the chip, the
   bell, the back disc, which "lift off cream and off dark alike" only with
   the value for the ground they are on) and the sheet edge (--app-sheet-*:
   a black hairline is invisible on a night ground, a white one on cream).

   SCOPED TO THE BAR AND THE SHEET, deliberately, rather than to <html>.
   The theme is still the shell's: `.dark` on <html> is the theme module's
   alone and every `dark:` utility keeps reading it. The sheets, dialogs and
   menus opened over a dark app follow its tone too since #2803, but by
   carrying `.dark` themselves (lib/surface-tone.ts, and the
   `.platform-app-tone-dark` rule below), not through these tokens. Custom properties
   inherit, so setting them on the two elements reaches every control and
   edge inside them and nothing outside. The chip's subtitle is the one
   control drawn with a `dark:` utility rather than a brand token, so it is
   re-inked here by hand.

   THE INK AND THE SURFACES MOVE WITH THE GROUND TOO (#2704) — and that is
   what these blocks were missing. The ground is repainted PAGE-WIDE (the
   wallpaper rules above put --home-ground at #0b0d1b for a dark app under
   the light shell), but the tokens were scoped here and carried only the
   brand controls and the sheet edge. So everything inside the bar and the
   sheet that inks itself from the platform's text ramp, or paints itself
   from its surface tokens, kept the LIGHT values on a ground that had
   already gone dark. That is the hybrid: a dark page with light-theme parts
   on it, which is exactly as readable as it sounds.

   THE LAUNCH COVER IS WHERE IT WAS WORST, and it is on screen every time an
   app opens. .app-launch-cover takes --home-ground (the rule above) and
   .app-launch-cover-name takes --text-primary, so the app's own name was
   painted #0a0a0a on #0b0d1b: 1.03:1, which is not dim, it is gone. The
   mirror case is identical — a light app under the dark shell put #f5f5f7
   on cream, also 1.03:1. Carrying the ramp takes both to 17.6:1, which is
   the contrast the same cover already had in the untoned theme.

   THE VALUES ARE THE THEME'S OWN, dark tone from `.dark` and light tone from
   `:root`, so a toned surface stays byte-identical to a themed one — the
   invariant tests/app-tone.test.js already pins for the brand tokens.
   --text-faint and --text-muted are one value in both themes today and are
   carried anyway, so the ramp is complete and a later divergence cannot
   quietly reopen the split.

   STILL SCOPED TO THE BAR AND THE SHEET, and still never to <html>. This
   widens what the strip repaints; it does not widen what the strip is. The
   floating surfaces are the next block's job. */
[data-app-tone="dark"]:not(.dark) #platform-header,
[data-app-tone="dark"]:not(.dark) #app-frame-host {
  color: #eaeaea; /* zinc-100, the body's dark ink */
  --text-primary: #f5f5f7;
  --text-secondary: #e3e3e6;
  --text-muted: #98989d;
  --text-faint: #8e8e93;
  --bg-primary: #0b0b0c;
  --bg-secondary: #1c1c1e;
  --border: #2c2c2e;
  --border-light: #3a3a3c;
  --brand-ink: #c7d0f5;
  --brand-tint: #262b45;
  --brand-line: #3a4270;
  --app-sheet-line: rgba(255, 255, 255, 0.12);
  --app-sheet-shadow-near: rgba(0, 0, 0, 0.55);
  --app-sheet-shadow-far: rgba(0, 0, 0, 0.4);
}
.dark[data-app-tone="light"] #platform-header,
.dark[data-app-tone="light"] #app-frame-host {
  color: #1c1c1e; /* zinc-900, the body's light ink */
  --text-primary: #0a0a0a;
  --text-secondary: #3a3a3c;
  --text-muted: #68686c;
  --text-faint: #8e8e93;
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f7;
  --border: #d1d1d6;
  --border-light: #e3e3e6;
  --brand-ink: #37477b;
  --brand-tint: #e4e9f7;
  --brand-line: #c9d2f2;
  --app-sheet-line: rgba(0, 0, 0, 0.1);
  --app-sheet-shadow-near: rgba(24, 39, 75, 0.12);
  --app-sheet-shadow-far: rgba(24, 39, 75, 0.08);
}
/* THE ICON TILE IS THE ONE SURFACE THE CARRIED TOKENS CANNOT REACH ON THEIR
   OWN. .app-icon-tile draws its face from --bg-primary and its ring from
   --border-light, and dark mode does not simply hand those tokens dark
   values for it: `.dark .app-icon-tile` steps the face UP to --bg-secondary
   and the ring DOWN to --border, because --bg-primary is near-black and a
   tile painted in it reads as a hole in the page rather than a surface on
   it (that rule carries the whole argument). The step is keyed to the
   CLASS, so under a tone it never happened — the app's own icon stayed a
   white square on the near-black launch cover, beside the name that had
   gone invisible. These are that rule's twins, scoped to the sheet the tone
   owns and reading the tokens carried above. Same reason the chip's
   subtitle is re-inked by hand below. */
[data-app-tone="dark"]:not(.dark) #app-frame-host .app-icon-tile {
  background-color: var(--bg-secondary);
  border-color: var(--border);
}
.dark[data-app-tone="light"] #app-frame-host .app-icon-tile {
  background-color: var(--bg-primary);
  border-color: var(--border-light);
}
[data-app-tone="dark"]:not(.dark) #platform-header #app-switcher-subtitle {
  color: #8e8e93; /* zinc-400 */
}
.dark[data-app-tone="light"] #platform-header #app-switcher-subtitle {
  color: #68686c; /* zinc-500 */
}
/* …AND THE SIDEBAR BESIDE THE APP. On a desktop the rail stays docked beside
   a running app (App._railBesideApp), so it is part of the strip around the
   app just as the bar is, and it takes the same tone: with the shell's own
   values it drew a pale column (a white half-fill over the toned, night
   wallpaper) beside a dark bar, or a night one beside a light bar.

   A DARK APP UNDER THE LIGHT SHELL puts `.dark` on the rail and its parked
   foot (lib/surface-tone.ts, the way the sheets over a dark app follow it,
   #2803), so the theme's own dark palette draws them: fill, ink, lit row,
   counts and tiles, with nothing restated here.

   A LIGHT APP UNDER THE DARK SHELL cannot do that (`.dark` cannot be taken
   away below a dark <html>), so the light values are carried here, the
   theme's own from `:root`, as the bar's are above. The two plane fills are
   set on the rail itself rather than as tokens: `--dc-sheet-fill` and
   `--dc-sheet-solid` are declared once per theme and nowhere else
   (tests/no-glass-one-scheme.test.js), so the rail paints `:root`'s values
   directly, on the desktop, where it is a rail. */
.dark[data-app-tone="light"] #platform-tabs,
.dark[data-app-tone="light"] #platform-parked {
  color: #1c1c1e; /* zinc-900, the body's light ink */
  color-scheme: light;
  --text-primary: #0a0a0a;
  --text-secondary: #3a3a3c;
  --text-muted: #68686c;
  --text-faint: #8e8e93;
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f7;
  --border: #d1d1d6;
  --border-light: #e3e3e6;
  --app-sheet-line: rgba(0, 0, 0, 0.1);
  --lit-ink: #0062cc;
  --lit-tint: #eaf4ff;
  --lit-line: rgba(10, 110, 224, 0.25);
  --accent: #0a6ee0;
  --accent-ink: #ffffff;
}
@media (min-width: 768px) {
  .dark[data-app-tone="light"] #platform-tabs {
    background-color: rgba(255, 255, 255, 0.50); /* :root's --dc-sheet-fill */
  }
  .dark[data-app-tone="light"] #platform-tabs.platform-tabs-peek {
    background-color: #faf9f2; /* :root's --dc-sheet-solid */
  }
}
/* Its app tiles take the light theme's face and ring, as the sheet's do
   (see "THE ICON TILE IS THE ONE SURFACE" above). */
.dark[data-app-tone="light"] :is(#platform-tabs, #platform-parked) .app-icon-tile {
  background-color: var(--bg-primary);
  border-color: var(--border-light);
}
/* AND WHAT OPENS OVER A DARK APP IS DARK TOO (#2803). This used to be the
   one thing the tone left alone: every sheet, dialog and menu kept the
   viewer's own mode, so Send feedback and the Homeroom menu opened as cream
   cards over a night-black app under a bar that had already gone dark. They
   follow the app now. lib/surface-tone.ts puts `.dark` on each kit shell
   (modal, sheet, panel, action sheet, popover, alert) and on the two in-tree
   sheets while `data-app-tone="dark"` holds under the light shell, marked
   with this class so it only removes its own. `.dark` is all the palette
   needs — the token blocks here and in native.css are plain `.dark { … }`
   and `dark:` utilities compile to `.dark .dark\:…` — so a toned surface is
   drawn exactly as it is under the dark shell.

   The one thing `.dark` on the surface cannot carry is the INK: the body's is
   `text-zinc-900 dark:text-zinc-100`, keyed on <html>, and these shells
   inherit it from <body>. So it is set here, the same literal the bar's tone
   uses. `:where()` keeps this at zero specificity: a kit rule that inks its
   own surface from a token still wins, and reads the dark token anyway.

   A light app under the dark shell is out of scope, as the request says:
   that direction would need `.dark` taken AWAY below a dark <html>. */
:where(.platform-app-tone-dark) {
  color: #eaeaea; /* zinc-100, the body's dark ink */
  color-scheme: dark;
}
/* THE CANVAS UNDER THE PUSH is the ground too — <html>'s own background,
   set inline in the head (frontend/src/head.html) so the first paint is
   never white, moved from the old zinc greys to the wallpaper's base in
   each mode when the wallpaper became the platform's ground. It is what
   the page transition shows wherever its snapshots do not reach: the seam
   of the iOS push and a frame of the Android fade. Nothing route-scoped
   is needed for it any more; theme-color and the manifest carry the same
   value, so the browser chrome and a cold launch frame the page in it.
   The launch cover reads the same ground, so the app opens on the page's
   colour in both modes. */
body:has(:is(#home-screen, #app-view, #leaderboard-screen, #profile-screen, #browse-screen, #settings-screen, #messages-screen, #workshop-screen, #profile-proposals-screen, #agent-session-screen, #auth-landing-screen, #auth-login-screen, #auth-register-screen, #auth-waiting-screen, #auth-waitlist-screen, #auth-more-screen):not(.hidden)) .app-launch-cover {
  background-color: var(--home-ground);
}
/* THE LANDING ROOT PAINTS THE WALLPAPER ITSELF. #auth-landing-screen is a
   fixed, full-viewport overlay with its own opaque `bg-white` (it covers
   whatever the signed-in shell has left on the page), so the body's
   wallpaper never showed through it. It takes the same shorthand the body
   paints — inherited from the body's rule, which is on because this root
   is showing — in both modes. */
#auth-landing-screen,
#auth-login-screen,
#auth-register-screen,
#auth-waiting-screen,
#auth-waitlist-screen,
#auth-more-screen {
  background: var(--home-wallpaper);
}

/* ── THE LANDING'S ACTIVITY RAIL LOOPS ─────────────────────────────────
   The signed-out landing draws four chips wider than a phone
   (frontend/src/features/auth/landing.tsx). The overflow is the point — it
   says more goes on here than fits — but a strip that only overflows reads
   as a row somebody forgot to finish. Looping it says the same thing on
   purpose.

   The track holds the four chips TWICE and travels exactly half its own
   width, so the second copy arrives where the first began and the seam
   never shows. Half is exact only because each chip carries its 8px as a
   right MARGIN rather than the row carrying `gap`: with `gap` the track is
   eight chips plus seven gaps, and half of that lands mid-gap and stutters
   once per cycle. The second copy is aria-hidden in the markup, so a reader
   hears the four lines once.

   34s is a deliberate amble — about 25px a second. Fast enough to be
   clearly alive on a screen somebody looks at for a few seconds, slow
   enough not to pull the eye off the heading, which is the thing this
   screen is actually for.

   The transform runs on the compositor (translate3d, will-change) because
   this is the one thing animating on a screen that also decodes a 90KB
   illustration on first paint. */
.landing-rail-track {
  display: flex;
  width: max-content;
  will-change: transform;
  animation: landing-rail-loop 34s linear infinite;
}
@keyframes landing-rail-loop {
  from { transform: translate3d(0, 0, 0); }
  to { transform: translate3d(-50%, 0, 0); }
}
/* Decoration, so it STOPS rather than slows: a reader who asked for less
   motion still gets the whole row, still overflowing, simply still. */
@media (prefers-reduced-motion: reduce) {
  .landing-rail-track { animation: none; }
}

/* ── App cards ─────────────────────────────────────────── */

.app-card { transition: background-color 150ms; }
/* A pointer that can actually hover only (#2892). On a touch screen WebKit
   emulates :hover from the last tap and keeps it until the next tap lands
   somewhere else — so a tile held for its menu kept this fill as a stuck
   "selected" look, most visibly after "Add to Homeroom widget", the one menu
   action that leaves you on the home screen with the tile still in view. */
@media (hover: hover) {
  .app-card:hover { background-color: var(--bg-card); }
}

/* The widget strip as a DROP TARGET (#2894): a "Your apps" tile dragged over
   it. home.js (_markWidgetDrop) sets `data-drop` for the span of the hover —
   an attribute because the strip's className is React's. `ok` is the same
   tint as the grid's own target cells (.home-grid-cell--on); `refused` (already pinned, or the widget is
   full) is a muted outline that says "not here" without shouting. */
#widget-strip[data-drop="ok"] {
  border-style: solid;
  border-color: rgba(31, 134, 255, 0.75);
  background-color: rgba(31, 134, 255, 0.14);
}
#widget-strip[data-drop="refused"] {
  border-color: rgb(161 161 170);
  opacity: 0.7;
}

/* THE TILE LABEL — iOS-homescreen sized (#951).
   11px on a 13px line, TWO lines, ellipsised on overflow. #4934 tried one
   step up (12px on a 14px line) because over the wallpaper's washes the
   label read tiny — but on a phone's four columns the bigger type cut most
   names after a few letters (#5195), so it is back here. One pixel of type
   and one of line height is what a step up would cost: the two-line lane
   would be 2px taller, and the --home-cell-h derivation below would grow
   by the same 2px. The colour is inherited from the
   body's text-zinc-900 / dark:text-zinc-100, which clears 13:1 on the
   cream ground and its washes and 10:1 on the darkest dark wash (the
   ember); the label sets no colour of its own so the Create tile's muted
   ink still applies.

   Before #951 the label was one 14px/20px line with `truncate`, which cut
   most real app names after ~12 characters; at this size and two lines a tile shows roughly
   three times as much of a long name.

   The lane measures exactly two lines (26px, 2 x 13) as a MIN-height, not
   a fixed height: a one-line title and a two-line title still occupy the
   same box at the design size, so the caption below a tile — and the
   tile's whole content height — does not shift per tile, and the 124px
   budget in the --home-cell-h derivation is unchanged. But the box now
   GROWS when a device renders the font larger than the design size (#3661):
   Android's text scaling / font boosting inflates this label's font-size
   and line-height (~14px on ~17px lines) while rem-based lengths stay put,
   and the old fixed-height lane clipped that second line mid-glyph. With
   the floor the inflated text simply takes its two lines' worth of room —
   at most a few pixels into the row gap on such a device, never a cut-off
   word. The grid's rows themselves stay fixed; only the label's own box
   is content-sized beyond its floor.

   line-height is unitless (13px at the design size, 13/11) for the same
   reason: it tracks whatever font-size the device actually renders, so
   the two line boxes scale with the glyphs instead of staying 13px under
   them.

   -webkit-line-clamp is the only cross-browser way to ellipsise at a
   line COUNT (the standard `line-clamp` is listed alongside it for the
   engines that have shipped it unprefixed); it needs the -webkit-box
   display, which is why this is a class rather than Tailwind's
   `truncate`. overflow-wrap: anywhere keeps a single long unbroken word
   (a slug-style name) inside the tile instead of overflowing it. */
.app-card-title {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  width: 100%;
  min-height: 1.625rem; /* 26px — exactly two lines at the design size */
  font-size: 0.6875rem; /* 11px */
  line-height: 1.18; /* 13px at the design size; unitless so it tracks the font */
  font-weight: 500;
  text-align: center;
  overflow-wrap: anywhere;
  word-break: break-word;
}

/* The status caption ("Spinning up…" / "Awaiting secrets" / "Error")
   steps down with it: 11px on a 12px line, no top margin, so the two
   text lanes together are 26 + 12 = 38px — the same as the old
   20 (title) + 16 (caption) + 2 (mt-0.5) budget. */
.app-card-status {
  font-size: 0.6875rem; /* 11px */
  line-height: 0.75rem; /* 12px */
}

/* ── App icon tiles ────────────────────────────────────────
   The single treatment for every homescreen-style app icon tile:
   home cards, the widget strip and the landing (unauthed) grid. A plain
   white face (an elevated
   zinc surface in dark mode) with a faint hairline tracing whatever
   rounded shape the call site picks (rounded-xl on the 56px tiles,
   rounded-lg on the 40px widget tiles) — the border is drawn by this
   class, the radius by the element's own Tailwind class, so the ring
   always follows the tile.

   The hairline uses a DIFFERENT token per mode. --border-light is
   inverted between the two palettes: in light mode it is fainter than
   --border (#e4e4e7 vs #d4d4d8), but in dark mode it is stronger
   (#484870 vs #2e2e50). Using it in both would make the tile ring the
   most contrasty border on a dark page, wrapped around every icon —
   so dark mode steps down to --border instead (see below). The token
   itself is left alone; 17 other rules depend on its current value.

   The glyph inherits `color` from here. Image icons still fill the
   tile: they're `w-full h-full object-cover` inside the border box, so
   the image sits flush inside the hairline.

   Tile call sites must NOT set their own background / text colour
   utilities — routing every tile through this one class is the point. */
.app-icon-tile {
  background-color: var(--bg-primary);
  border: 1px solid var(--border-light);
  color: var(--text-secondary);
}
/* THE PER-APP IDENTITY TINT IS GONE. The reskin hashed each app's slug into
   one of six pastels and painted the tile with it (`--tile-lime` and friends,
   `.app-icon-tile[data-tint="…"]`). A launcher of six unrelated pastels reads
   as six unrelated things rather than as one shelf, and an app's icon is its
   own artwork — the tile should hold it, not compete with it. One neutral
   face everywhere, which is what the rule above already draws.

   Nothing sets `data-tint` any more, so the `:not([data-tint])` guards that
   protected the two rules below from it are gone with it: the specificity
   trap they described — both selectors at (0,2,0), source order deciding —
   cannot recur without the attribute. */
/* The first-letter fallback specifically — one step fainter than the base
   glyph colour so a name initial reads as a quiet placeholder rather than as
   content. Tiles tag their kind with data-icon, so this never touches emoji
   tiles (whose colour glyphs ignore `color` anyway) or image tiles. Both modes
   step to the same faint token. */
.app-icon-tile[data-icon="letter"] {
  color: var(--text-faint);
}
/* An image tile's initial while its picture loads (AppIconContent's
   IconImage, polish board #11): the letter tile's own quiet placeholder,
   laid under the image, and gone once the image has drawn. The tile is its
   box at zero specificity, so a tile that already positions itself keeps
   doing so. */
:where(:has(> .app-icon-wait)) { position: relative; }
.app-icon-wait {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  color: var(--text-faint); font-weight: 700; line-height: 1;
  pointer-events: none;
}
.dark .app-icon-tile {
  /* --bg-primary is near-black in dark mode; one step up so the tile
     reads as a surface sitting on the page rather than a hole in it. */
  background-color: var(--bg-secondary);
  /* …and back DOWN one step for the ring: --border-light is the
     brighter of the two hairline tokens in dark mode (see above), so
     the faint-hairline intent needs --border here. */
  border-color: var(--border);
}
/* Drag-reorderable starred cards (issue #191): a long-press must
   pick the card up, not select its title text or pop the mobile
   copy/look-up callout. Static rather than toggled at drag start
   because selection begins during the ~350ms long-press window,
   before any JS drag state exists. Scoped to the drag-enabled
   class (added in home.js only when reordering is possible) so
   text everywhere else on the page selects normally. */
.app-card-draggable {
  user-select: none;
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

/* ── Home section headers ──────────────────────────────── */

.home-section-header {
  font-size: 0.75rem;
  color: var(--text-muted);
  padding: 0.25rem 0;
}

/* The home screen's content column: everything in the feed — the search
   bar, the "Your apps" grid, and the trailing Featured / Create sections —
   is capped at 1024px and centred in the viewport, so a wide desktop
   window reads as one column instead of a grid stretched edge to edge.

   The cap INCLUDES each block's own horizontal gutter (the px-3 / p-3 the
   children carry), which is why the search bar's padding lives on its
   inner .home-column rather than on the full-bleed bar: both columns then
   have identical content edges. Below 1024px this is a no-op and the
   gutters are the only horizontal padding, which is what a phone wants.

   Inside the column the trailing sections' boxes are FULL WIDTH — they
   used to be narrower and indented to line up with the leftmost app icon,
   a measured offset that existed only because the column itself was
   unbounded. Centring the column is the better answer, so don't
   re-introduce a per-box width bound. */
.home-column {
  width: 100%;
  max-width: 64rem; /* 1024px */
  margin-left: auto;
  margin-right: auto;
}

/* ── Home-screen sections (#911) ────────────────────────────
   Discover, Challenges and Create app: three bordered blocks stacked under
   the launcher grid, in that order, each a plain full-width child of its own
   <section>. THE UI OVERHAUL fixed them there. They were draggable ITEMS of
   #app-list before it — a `.home-panel-slot` host at a stored (column, row)
   cell, spanning real cells so app icons flowed around them the iOS-widget
   way — and everything below that answered to a rectangle: a two-cell height
   cap, a half-column width cap, a stretch-to-fill, and a phone branch that
   reshaped the content to fit ONE 116px cell. All of it is gone with the
   placement. A section is as tall as it draws and as wide as .home-column.

   HEIGHT — nothing survives the rectangle. The block used to carry
   --home-panel-max-h ("about two rows of app icons", 16rem, derived from
   --home-cell-h), enforced by flex — flex-none title bar and footer, a rows
   list with min-height: 0 and overflow: hidden — so content CLIPPED at the
   ceiling instead of growing the tile, with .home-panel--expanded lifting it.

   A section grows. What bounds the Challenges block is the MARKUP:
   HomePanels.challengesView draws a short pick (every First challenge, or
   at most three Next ones) and "See all" in its heading goes to the
   Challenges tab.

   THE LEADERBOARD FILL (.home-panel-fill) was a standings preview under the
   challenge rows, and it is REMOVED — two labelled lists with two different
   tap destinations inside one card called Challenges. The standings are a
   screen, and the section's heading carries the one tap to it. Its rules are
   gone from this file, and so is the heading's own "Open challenges" link
   (`.home-panel-lb-browse`), replaced by "See all" (`.home-panel-see-all`).

   NO WIDTH CAP any more. It was --home-panel-max-w, half of .home-column
   (512px of 1024px), and the argument for it was "a widget is not a section":
   a challenges row is one short line plus two small chips, and stretching it
   to 1024px left a lonely reward chip pinned to the far right. These ARE
   sections now — they are the screen's areas, stacked, each one the full
   width of the column above it — and a half-width Discover under a
   full-width app grid reads as a rendering fault, not as restraint. The
   lanes and rows inside them lay out against the width they are given.

   tests/home-panels-render.test.js pins the row budget and the tile tokens
   it was derived from, so a drift fails rather than silently mis-sizing the
   block. */
:root {
  /* ONE APP-GRID ROW. Every other height on the home screen is derived from
     it: the grid's own grid-auto-rows and the drag overlay's cells, which
     must line up with real tiles to the pixel.

     Desktop (>= 640px), from .app-card's own tokens in home.js renderAppCard:
       p-3        0.75rem x 2  = 1.5rem
       w-14 h-14  3.5rem       = 3.5rem
       gap-1.5    0.375rem     = 0.375rem
       title      2 x 13px     = 1.625rem  <- .app-card-title, two lines
       caption    12px line    = 0.75rem   <- .app-card-status, see below
                                --------
                                7.75rem   (124px)

     #951 shrank the label from one 14px/20px line to two 11px/13px lines
     and tightened the icon->label gap and the caption line to match, so
     the total is UNCHANGED and nothing derived from --home-cell-h moved.
     Both text lanes are fixed heights, so a one-line and a two-line title
     produce identical tiles.
     #4934 grew the label to 12px on 14px lines; #5195 brought it back,
     because on a phone's four columns the bigger type cut most names
     after a few letters.
     The phone variant is 116px: index.html tightens the tile to `p-2`
     (0.5rem x 2), which is 8px less. Four 56px icons per row only read as a
     home screen at that density.

     THE CAPTION LANE IS NOT OPTIONAL. Most tiles are running apps and draw
     nothing there, so it looks like dead space — but the status dot is gone
     from the tile face, which makes "Spinning up…" / "Awaiting secrets" /
     "Error" the ONLY status signal a tile has. Rows are a FIXED height (the
     8-row canvas and the drag overlay both depend on it), so a caption with
     no room doesn't push the row taller — it paints over the tile below.
     Budgeting for it is what keeps an errored app legible instead of
     bleeding into its neighbour. */
  --home-cell-h: 7.75rem; /* 124px — see the derivation above */
  /* ONE EMPTY ROW on a phone (#975). Half a cell — 58px against the phone's
     116px — so a row the viewer left empty keeps its place in the arrangement
     without reserving a whole tile to be empty. DERIVED from the cell rather
     than written out, so the two can never drift.

     Emitted by Home.rowTemplate for the rows HomeLayout.blankRows names, and
     only at four columns; the :root value here (62px) is never used, since a
     desktop grid gets no inline template at all. A variable and not an inline
     calc() because every track in that template has to stay a single
     space-free token — see rowTemplate. */
  --home-blank-row-h: calc(var(--home-cell-h) / 2);
  /* A CHALLENGE ROW IS TWO LINES NOW (56px, was 40).

     The METER LANE is gone with the third line of tokens that used to sit
     here (--home-panel-meter-lane / -bar-h / -bar-gap). That lane was a 14px
     strip reserved along the bottom of every row in a panel that had at
     least one numeric challenge, with the row's text padded clear of it — so
     the bar had somewhere to ride and every goal still sat on one baseline.

     It worked, and it cost two things nobody chose. Reserving the lane on
     EVERY row put all the text 7px above the row's true centre; and on a row
     with no bar to draw, the reserved strip was simply a hole, so a mixed
     list read as some rows aligned and some rows not.

     Both go away by giving the row a second LINE instead of a reserved strip:
     the goal, its count and its reward take line one, the track takes line
     two and runs the full width of the text column, and every challenge draws
     one — a yes-or-no challenge gets a two-state track (empty or full) rather
     than nothing. There is no shared baseline to protect because there is no
     row that lacks a bar, and the flex centring is over the whole row again.

     The track's own geometry is Tailwind utilities on the element (see
     panels/challenges.tsx) rather than derived rules here: it is an ordinary
     block in the row's second line now, so there is nothing left to derive. */
  --home-panel-row-h: 3.5rem; /* 56px — see the budget above */
}

/* NO HEIGHT CAP. `max-height: var(--home-panel-max-h)` lived here, and the
   whole apparatus below it — .home-panel-rows' overflow: hidden, the
   flex-none title bar and footer, .home-panel--expanded lifting the cap —
   existed to make a block CLIP inside the two-app-row rectangle it occupied
   on the launcher canvas.

   A section has no rectangle. What bounds the Challenges block now is the
   MARKUP: HomePanels.challengesView draws a short pick and "See all" goes to
   the Challenges tab, so there is nothing left for a second, invisible
   mechanism to bound. Keeping the cap after the
   fill stopped being leftover-sized would have clipped the standings preview
   the Challenges area now exists to show. */
.home-panel {
  display: flex;
  flex-direction: column;
}

/* THE TITLE BAR IS GONE (and it WAS the drag handle, #911). It carried
   `cursor: grab` and a "Drag to move this widget" tooltip while these blocks
   were grid items; THE UI OVERHAUL fixed them into sections and took the grip
   with it, leaving a `user-select: none` strip that held a title. The title is
   the section's own label above the card now and the block's controls went
   with it (frontend/src/features/home/panels/ui.tsx), so there is no bar left
   to style — a card opens on its content.

   The label row it became is `.home-area-label`, and it needs no rule here:
   it is utilities all the way down, and DELIBERATELY not `.home-section-header`
   above, which is the 12px caption the widget strip and the search-results
   heading use. */
.home-area-label button {
  cursor: pointer;
}

/* The rows list must be allowed to SHRINK below its content height —
   min-height: 0 is what makes overflow: hidden clip instead of the flex
   item refusing to compress and pushing the article past the cap. */
.home-panel-rows {
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
}

/* The challenges widget's BODY (#947): the rows list plus, on the desktop
   tile, the LEADERBOARD fill under it. It is the flex child that takes the
   article's remaining height and clips — the job .home-panel-rows used to do
   alone, moved out one level so the two stacked blocks inside it keep their
   natural heights.

   That is the whole reason this wrapper exists: with `flex: 1 1 auto` still
   on the rows list, the surplus would be absorbed BETWEEN the challenge rows
   and the leaderboard label, splitting the tile with a gap down its middle.
   Pinning both children to their content height collects the leftover at the
   BOTTOM of the tile, where it reads as ordinary padding.

   Scoped to the challenges panel; discover and create keep their markup, and
   the existing descendant selectors (`.home-panel .home-panel-rows--metered
   .home-panel-row`, asserted in dapp.json) still match through it. */
.home-panel-body {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
}
.home-panel-body > .home-panel-rows {
  flex: 0 0 auto;
}

/* `.home-panel-footer::before`, the rule above the footer, is gone. The
   Challenges block was the only one with a footer, and it sits on the page
   ground now with no divider anywhere in it (the ITERATION 03 board's Home
   screen): the 14px step between items is what separates them. */

/* Uniform height is what makes the 4-slot budget above exact. Each row is
   TWO LINES — goal/count/reward, then the track — and both are fixed, so a
   row with a long goal and a row with a short one occupy the same box.

   STRICTLY ONE LINE PER LINE. `nowrap` here is the belt to the per-element
   `whitespace-nowrap` braces in panels/challenges.tsx: a fixed-height row
   HIDES a wrap (the extra line just overflows the box and gets clipped by
   the panel), so relying on the goal's `truncate` alone and on flex to keep
   the chips honest is how a three-line row ships unnoticed. Declaring it on
   the row means every present and future child inherits it, and
   `overflow: hidden` clips rather than letting a chip spill over the
   neighbouring row.

   The whole row is the tap target into the Challenges screen and it spans
   the column, so its hit AREA is far past what the 44px minimum-dimension
   guideline protects — that rule is about small icon controls, and the one
   of those here (the ⋮ menu) keeps .un-touch-target. */
.home-panel-row {
  position: relative;
  height: var(--home-panel-row-h);
  white-space: nowrap;
  overflow: hidden;
}
/* A hairline between two stacked rows, FLUSH with the column. No markup draws
   two today: the only `.home-panel-row` is the Challenges block's
   between-seasons line, and it is alone, so this rule does not fire. It used
   to start 0.625rem in, matching that line's `px-2.5` inside the retired
   plate's rounded corners; the block is a flat column on the page ground now
   and its text starts at the heading's edge, so a rule starts there too.

   A pseudo-element rather than a border because the row is a fixed-height box
   whose height budget (--home-panel-row-h) is spent exactly: a 1px border is
   part of the border box and would make every row after the first a pixel
   shorter than the first, which is the whole reason the 4-slot budget above
   is stated in whole rows. */
.home-panel-row + .home-panel-row::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--border-light);
  pointer-events: none;
}

/* `.home-panel-rows--metered` and the `.home-panel-bar-track` geometry rule
   are gone with the lane, and the track and fill names went with the count
   capsule that inherited them: a Home challenge card draws the Challenges
   tab's shared progress rail now (features/leaderboard/challenge-card.tsx),
   and the declared checks select on its `[role=progressbar]`. */

/* The season progress's own hairline (`.home-panel-season::after`) is gone
   with the plate it divided. `.home-panel-season` stays in the markup as the
   hook the declared checks select on, and needs no rule: the block is one flat
   column now and the progress is just its first band. */

/* The footer bar (`.home-panel-footer`, the "See all N challenges" / "Show
   less" toggle) and `.home-panel--expanded .home-panel-rows`, which stopped
   the rows list clipping while the block was expanded in place, are gone
   with the toggle: the block shows a short pick and "See all" in its heading
   goes to the Challenges tab (the Semi design, 2026-10-16). */

/* `.home-panel-slot` — the in-grid drag host — is gone with the placement.
   It spanned real cells so app icons flowed around a widget the iOS way, and
   it carried a width cap, a stretch-to-fill and a phone release from that
   stretch. THE UI OVERHAUL made the three blocks fixed <section>s, so there
   is no rectangle to fill and nothing to drag. */

/* ── The launcher grid ──────────────────────────────────────
   Free-form placement: every child is positioned at an explicit
   (column, row) by an inline `grid-column` / `grid-row` from
   Home.renderGridItem. Rows are a FIXED height so eight of them are a
   deterministic canvas and so the drag overlay's cells line up with real
   tiles to the pixel — `auto` rows would size to content and the overlay
   would drift the moment one tile had a status line and another didn't.

   position: relative anchors that overlay (below).

   The SEARCH view is exempt: it is a flat, transient list, and its "N
   results" header and empty-state line would each claim a whole tile row
   under a fixed height. Home.render() stamps data-view on the grid.

   ON A PHONE THIS IS THE DEFAULT ROW HEIGHT, NOT EVERY ROW'S (#968, #975).
   Home.render() writes an inline `grid-template-rows` below 640px — one
   entry per occupied row, `var(--home-cell-h)` for most of them plus two
   exceptions:

     * a content-sized `minmax(floor, auto)` for a row a FIT widget owns
       outright (HomeLayout.fitRows; challenges only today). Without it the
       between-seasons widget drew a ~68px box inside a 116px reservation and
       handed the other ~48px to nobody: a grid item cannot shrink a fixed
       track, so shrinking the BOX was never going to shrink the PAGE.
     * `var(--home-blank-row-h)` — half a cell — for a row with NOTHING in it
       (HomeLayout.blankRows). Same reasoning one step further: an empty row
       was spending a whole tile's height on nothing, and on the default phone
       arrangement there are two of them between the app tiles and the lower
       widgets.

   Note what shrinking a blank row is NOT: nothing re-packs, nothing collapses,
   nothing is tidied away. The row keeps its index, keeps its place in the
   arrangement and stays a drop target — a hole is still an arrangement the
   viewer chose. Only its pixel height changes, and only on a phone.

   Two things the rule deliberately does not do: it never shrinks a row
   holding an app tile (a card with no status caption is ~100px, and that lane
   is load-bearing — see --home-cell-h), which is also why a PARTLY empty row
   stays full; and it never emits more entries than there are occupied rows (an
   explicit grid exists whether or not anything is in it, so eight entries
   would pad a three-app home screen out to ~950px — which is likewise why the
   trailing empty rows below the last tile are not in the template and have
   nothing to shrink). Desktop gets no template at all and this token is still
   the whole story there. */
#app-list {
  position: relative;
  grid-auto-rows: var(--home-cell-h);
}
/* HOLD THE LAUNCHER'S SPACE UNTIL THE GRID HAS RENDERED ONCE.
   The document the service worker serves is the PRERENDER, and the prerender's
   #app-list is empty — so a cached load paints the header, five area labels
   and the account row stacked at the top of the page, and everything below the
   grid then slides down by two rows the moment the bundle hydrates and the
   skeleton appears. That is the largest single jump between the cached paint
   and the loaded one, and it is pure CSS to remove: reserve the two-row
   default (HomeLayout.DEFAULT_ROWS) that the grid is about to draw anyway.
   Keyed on `data-view`, which app-grid.tsx sets on its first READY render and
   the prerender therefore lacks — so this applies for exactly the parse-to-
   hydrate window and never to a loaded grid, including the empty one a brand
   new account has (which would otherwise be padded with two blank rows). */
#app-list:not([data-view]) {
  /* Two cells PLUS the gap between them and the grid's own padding
     (app-grid.tsx: `gap-1.5 p-2 pt-1.5`, from 640px `gap-2 p-3 pt-2`). The
     box is border-box, so two bare cells came up 20px short of the two rows
     they stand for (28px from 640px), and the sections under the grid moved
     by that much when it drew (#10). */
  min-height: calc(2 * var(--home-cell-h) + 1.25rem);
}
@media (min-width: 640px) {
  #app-list:not([data-view]) {
    min-height: calc(2 * var(--home-cell-h) + 1.75rem);
  }
}
/* …AND HOLD EVERY AREA AT THE HEIGHT THIS DEVICE LAST DREW IT (#10, 19 Oct
   2026). Two rows was the right guess for the grid alone, but the real
   launcher draws as many rows as the screen has room for, plus "Show all N
   apps", and Discover and Challenges below it land at heights no skeleton can
   know: the desktop measured one shift of 0.0585 on every cold load (Discover
   54 → 121px, Challenges pushed from y 392 to 523). The head's inline script
   puts the heights this device last drew on <html> (`first-paint-reserved`
   and the `--reserve-*` properties; frontend/src/lib/first-paint-reserve.ts
   keeps them), and each area holds its height only while it is still a
   skeleton: the grid until its first ready render (`data-view`, as above), a
   section while `.home-section-pending` (features/home/panels/sections.tsx).
   The real page then lands in boxes of its own size. `overflow: hidden` clips
   a skeleton taller than its box; nothing real is ever inside one. The
   sidebar's community rows have the same hold, beside their own rules. */
html.first-paint-reserved #home-apps-section:has(#app-list:not([data-view])) {
  height: var(--reserve-home-apps);
  overflow: hidden;
}
html.first-paint-reserved #home-discover-section.home-section-pending {
  height: var(--reserve-home-discover);
  overflow: hidden;
}
html.first-paint-reserved #home-challenges-section.home-section-pending {
  height: var(--reserve-home-challenges);
  overflow: hidden;
}
#app-list[data-view="search"] {
  grid-auto-rows: auto;
}

/* ── The drag-time grid overlay ─────────────────────────────
   While something is lifted, the grid it snaps to is drawn underneath it —
   every cell of the canvas outlined, including the empty ones, so "you can
   drop this anywhere" is visible rather than something to discover.

   It is a real GRID sharing #app-list's own gap and row height (the column
   template is set inline, since the column count is a JS-side decision), and
   it is inset by the grid's own padding. That is what makes it align with
   zero arithmetic: it is laid out by the same engine, with the same numbers,
   as the tiles it sits behind.

   The row height below is the DEFAULT and the padding for rows past the
   content (#968, #975). Since a phone row can now be shorter than a cell —
   content-sized for a fit widget, half a cell for an empty row —
   Home._showGridOverlay copies #app-list's USED tracks
   (getComputedStyle → "116px 67.5px 58px …") onto this element inline and pads
   them out to the eight-row canvas with this token. The padded rows stay a
   FULL cell on purpose: they are past the template, so they are the drag-only
   region below the content, where there is no page height to reclaim and a big
   drop target is worth more. Copied rather than
   re-derived on purpose: one element decides the row heights and the other
   reads its answer, so the two grids cannot disagree. In pixels, too — the
   cells here are empty divs, so a content-sized track would collapse to
   nothing.

   The inset must track that padding EXACTLY, including the tighter top —
   #app-list is `p-2 pt-1.5 sm:p-3 sm:pt-2`, so a uniform inset sits the whole
   overlay 2px (phone) / 4px (desktop) low. That is not just a cosmetic drift
   in the tint: Home._rectForCell measures these cells to find where a
   committed drop lands, so any offset here becomes a jump at the end of the
   release glide, exactly where the motion is supposed to be seamless.

   It doubles as the drag's HIT-TEST surface (Home._cellFromPoint), which is
   why the layer disables pointer events but the cells re-enable them: a hit
   must land on a cell, not on a tile behind it. The kit's ghost is
   pointer-events:none, so it never occludes them either. */
.home-grid-overlay {
  position: absolute;
  /* #app-list's p-2 pt-1.5 on a phone */
  inset: 0.375rem 0.5rem 0.5rem;
  display: grid;
  gap: 0.375rem;            /* gap-1.5, matching the phone grid */
  grid-auto-rows: var(--home-cell-h);
  pointer-events: none;
  z-index: 0;
}
.home-grid-cell {
  position: relative;
  pointer-events: auto;
  border: 1px dashed var(--border-light);
  border-radius: 0.75rem;   /* rounded-xl, same as a tile */
  transition: background-color 120ms, border-color 120ms;
}
/* The cell's HIT AREA bleeds into the surrounding gap. The overlay is a real
   grid, so the gap between cells belongs to no cell — and a pointer resting
   there would resolve to nothing, dropping the highlight and making a
   release over a gap spring the item back. A pseudo-element hit-tests as its
   originating cell, so half the gap in each direction closes the seams with
   no arithmetic. Adjacent areas overlap by a pixel or two; elementFromPoint
   takes the later cell in DOM order, which is deterministic. */
.home-grid-cell::before {
  content: '';
  position: absolute;
  inset: -4px;
}
/* The cells the drop would land in. A widget tints its whole footprint, so
   "this doesn't fit here" is legible before releasing; an ILLEGAL cell is
   left plain rather than tinted red — the absence of the affordance is the
   clearer signal, and it matches the ghost springing back. */
.home-grid-cell--on {
  background-color: rgba(31, 134, 255, 0.14);
  border-color: rgba(31, 134, 255, 0.75);
  border-style: solid;
}
@media (prefers-reduced-motion: reduce) {
  .home-grid-cell { transition: none; }
}

/* ── The live displacement preview ──────────────────────────
   While something hovers a target, the items it WOULD push move to the cells
   they'd land in. The flow reorder this replaced gave that away for free
   (everything shuffled as you dragged); free placement has to show it
   deliberately, because only the items that actually collide move at all —
   without it a drop onto an occupied cell is a guess.

   The transform is set inline from the plan (a measured delta between two of
   the overlay's own cells); this rule only supplies the MOTION, so it matches
   the kit's FLIP family — 200ms ease, the same as attachReorder's gridFlip.
   z-index keeps a sliding tile above its stationary neighbours instead of
   disappearing behind the one it is passing. */
.home-item-displaced {
  transition: transform 200ms ease;
  z-index: 2;
}

/* Pushed clean off the canvas into the dense overflow rows below it. There is
   no overlay cell to slide to, so the tile says so where it stands: dimmed,
   with a downward chevron. Better than sliding it to a position that does not
   exist yet, and better than moving it silently. */
.home-item-to-overflow {
  position: relative;
  opacity: 0.45;
}
.home-item-to-overflow::after {
  content: '↓';
  position: absolute;
  right: 0.25rem;
  bottom: 0.25rem;
  width: 1.125rem;
  height: 1.125rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 9999px;
  font-size: 0.75rem;
  line-height: 1;
  color: #fff;
  background-color: rgba(31, 134, 255, 0.9);
}

@media (prefers-reduced-motion: reduce) {
  /* The new POSITION is the information, so the tiles still move — only the
     tween goes. */
  .home-item-displaced { transition: none; }
}

/* Tiles sit ABOVE the overlay. Without this the overlay's cells would paint
   over the icons they are supposed to sit behind. */
#app-list > .app-card {
  position: relative;
  z-index: 1;
}

/* ...but they must be TRANSPARENT TO HIT-TESTING for the duration of a drag.
   This is load-bearing, not a polish detail. The overlay's cells are what
   Home._cellFromPoint resolves a pointer to, and they sit BELOW the tiles
   (they have to — a dashed outline painted over an app icon would be
   nonsense). So while the tiles keep their pointer events, elementFromPoint
   under the finger returns a TILE for every occupied cell, cellFromPoint
   returns null, and the drop is treated as "not over the grid":

     • dropping onto another app did nothing at all — the target was never
       even resolvable, so displacement could not be reached;
     • and a tile could not be moved to any position overlapping its OWN
       footprint, because its own drop slot swallowed the hit-test.

   The kit puts .un-reordering on the list for exactly the span of a lift, so
   scoping it there means normal taps are untouched. */
#app-list.un-reordering > .app-card {
  pointer-events: none;
}

/* ── THE TILE GRID IS RETIRED ───────────────────────────────
   `.home-discover-tiles` was six explicit grid tracks (`--lane-tracks`, set
   from the lane's own count and floored at four), `.home-discover-icon-wrap`
   capped the fluid icon at the 40px the tile always drew at, `.home-discover-
   icon` held it square, `.home-discover-tiles .home-discover-tile` stripped
   the launcher card's padding back off, and `.home-discover-name` clamped the
   label to two lines because a ~55px track rendered most names as "Opinio…".
   Every one of those existed to fit six icons into ONE grid cell on a phone.

   The lane is a rail of cards (see `.home-discover-rail` below) and the block
   is a section that grows to its content, so none of that arithmetic has
   anything left to constrain. What survives is the lane wrapper and the
   empty note.

   The NAME keeps its class and loses its clamp: on a 152px card a real name
   has room to be read on one line, and the second line would only ever be
   spent on the longest few. Truncation is a utility on the element now. */
.home-discover-lane {
  flex: 1 1 auto;
}
/* `.home-discover-divider` is RETIRED. It drew the hairline row that carried
   the "Popular" caption between the block's two rails. Discover is one
   category and one rail now — the curated apps and the most-used ones flow
   as a single continuous set of cards — so there is no sub-group left to
   label and no seam left to draw. */
/* The empty state: no rail at all, one centred line in its place, so the
   block reads as a compact strip rather than a mostly-blank box. */
/* #1913: a card now, in the rail's card language (the tint vars come from
   the `.home-tint-*` class on the element) — so the empty lane still reads
   as a lane with something in it, and the whole plate is the link on to
   the directory. */
.home-discover-empty {
  display: flex;
  padding: 0.875rem 1rem;
  border-radius: 0.875rem;
  background: var(--tint-bg);
  border: 1px solid var(--tint-line);
  text-decoration: none;
}
.home-discover-empty:hover { filter: brightness(0.98); }
.home-discover-empty:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ── THE CARD LANGUAGE: TINTS ───────────────────────────────
   Discover's app cards and Challenges' challenge cards are the same object
   in two sizes — a tinted plate with art on it — and they share one palette
   so the launcher reads as one page rather than two lists that happened to
   get colours. Five tints, assigned by a hash of the app slug / challenge id
   (see tintOf in panels/ui.tsx) rather than by position, so a card keeps its
   colour as the lane reorders around it.

   Raw hex, like the wallpaper above and for the same reason: these are the
   design's own tints, not steps of a Tailwind scale, and `zinc`/`violet` are
   the only scales the product is allowed to name (see the palette rule in
   tests/admin-ui-registry.test.js). `--tint-art` is the deeper block the
   icon sits on; `--tint-line` is the hairline that keeps a pale card off a
   pale wallpaper.

   Dark mode does what the wallpaper does: the same hues, re-tuned to a
   lightness that reads on the inverted ground rather than inverted into
   mud. */
.home-tint-1, .home-tint-1[data-preview-theme="light"], .dark .home-tint-1[data-preview-theme="light"] { --tint-bg: #e3efe0; --tint-line: #cfe0ca; --tint-art: #cfe3c7; }
.home-tint-2, .home-tint-2[data-preview-theme="light"], .dark .home-tint-2[data-preview-theme="light"] { --tint-bg: #dfeaf6; --tint-line: #c9dcee; --tint-art: #c6dcf0; }
.home-tint-3, .home-tint-3[data-preview-theme="light"], .dark .home-tint-3[data-preview-theme="light"] { --tint-bg: #e9e2f2; --tint-line: #d9cfe8; --tint-art: #ded2ef; }
.home-tint-4, .home-tint-4[data-preview-theme="light"], .dark .home-tint-4[data-preview-theme="light"] { --tint-bg: #fbe6d5; --tint-line: #f2d5bd; --tint-art: #f7ddc6; }
.home-tint-5, .home-tint-5[data-preview-theme="light"], .dark .home-tint-5[data-preview-theme="light"] { --tint-bg: #f8dde8; --tint-line: #eec7d6; --tint-art: #f4d6de; }
.dark .home-tint-1, .home-tint-1[data-preview-theme="dark"], .dark .home-tint-1[data-preview-theme="dark"] { --tint-bg: #1b2a1f; --tint-line: #2c4433; --tint-art: #22331f; }
.dark .home-tint-2, .home-tint-2[data-preview-theme="dark"], .dark .home-tint-2[data-preview-theme="dark"] { --tint-bg: #17263a; --tint-line: #27405e; --tint-art: #1b2f47; }
.dark .home-tint-3, .home-tint-3[data-preview-theme="dark"], .dark .home-tint-3[data-preview-theme="dark"] { --tint-bg: #241f38; --tint-line: #3a3157; --tint-art: #2b2442; }
.dark .home-tint-4, .home-tint-4[data-preview-theme="dark"], .dark .home-tint-4[data-preview-theme="dark"] { --tint-bg: #33231a; --tint-line: #50392a; --tint-art: #3d2a1f; }
.dark .home-tint-5, .home-tint-5[data-preview-theme="dark"], .dark .home-tint-5[data-preview-theme="dark"] { --tint-bg: #331d28; --tint-line: #513043; --tint-art: #3e2231; }

/* Harmonic palette: select tone 50 once. Dark surfaces are computed from
   that same token, blended into the shell's zinc-950 ground. The fixed 10%,
   14%, 24% proportions give every hue the same quiet surface hierarchy.
   These formulas also drive explicit editor previews; no second palette. */
.home-tone-cream { --tone-50: #fefdf5; --tone-light-line: #f5f0d0; }
.home-tone-yellow { --tone-50: #fff9de; --tone-light-line: #f0d85c; }
.home-tone-orange { --tone-50: #fff0e0; --tone-light-line: #e8a878; }
.home-tone-coral { --tone-50: #fde8e4; --tone-light-line: #e09488; }
.home-tone-pink { --tone-50: #f8e4ee; --tone-light-line: #d48eb4; }
.home-tone-purple { --tone-50: #ede4f4; --tone-light-line: #aa92c2; }
.home-tone-indigo { --tone-50: #e4e8f6; --tone-light-line: #8896c8; }
.home-tone-blue { --tone-50: #deedf8; --tone-light-line: #6aadd4; }
.home-tone-teal { --tone-50: #deeeed; --tone-light-line: #72bab2; }
.home-tone-mint { --tone-50: #e2f2e8; --tone-light-line: #88c49e; }
.home-tone-sage { --tone-50: #eaf2e4; --tone-light-line: #a9cf97; }
.home-tone-gray { --tone-50: #f4f3f1; --tone-light-line: #b8b4ac; }
:is(.home-tone-cream, .home-tone-yellow, .home-tone-orange, .home-tone-coral, .home-tone-pink, .home-tone-purple, .home-tone-indigo, .home-tone-blue, .home-tone-teal, .home-tone-mint, .home-tone-sage, .home-tone-gray) {
  --tone-dark-bg: color-mix(in srgb, var(--tone-50) 10%, #0b0b0c);
  --tone-dark-art: color-mix(in srgb, var(--tone-50) 14%, #0b0b0c);
  --tone-dark-line: color-mix(in srgb, var(--tone-50) 24%, #0b0b0c);
  --tint-bg: var(--tone-50); --tint-line: var(--tone-light-line); --tint-art: var(--tone-50);
}
.dark :is(.home-tone-cream, .home-tone-yellow, .home-tone-orange, .home-tone-coral, .home-tone-pink, .home-tone-purple, .home-tone-indigo, .home-tone-blue, .home-tone-teal, .home-tone-mint, .home-tone-sage, .home-tone-gray), :is(.home-tone-cream, .home-tone-yellow, .home-tone-orange, .home-tone-coral, .home-tone-pink, .home-tone-purple, .home-tone-indigo, .home-tone-blue, .home-tone-teal, .home-tone-mint, .home-tone-sage, .home-tone-gray)[data-preview-theme="dark"] {
  --tint-bg: var(--tone-dark-bg); --tint-line: var(--tone-dark-line); --tint-art: var(--tone-dark-art);
}
.dark :is(.home-tone-cream, .home-tone-yellow, .home-tone-orange, .home-tone-coral, .home-tone-pink, .home-tone-purple, .home-tone-indigo, .home-tone-blue, .home-tone-teal, .home-tone-mint, .home-tone-sage, .home-tone-gray)[data-preview-theme="light"], :is(.home-tone-cream, .home-tone-yellow, .home-tone-orange, .home-tone-coral, .home-tone-pink, .home-tone-purple, .home-tone-indigo, .home-tone-blue, .home-tone-teal, .home-tone-mint, .home-tone-sage, .home-tone-gray)[data-preview-theme="light"] {
  --tint-bg: var(--tone-50); --tint-line: var(--tone-light-line); --tint-art: var(--tone-50);
}

/* ── Discover: the card rail ────────────────────────────────
   The lane was a GRID of six 40px icon tiles inside the block's white card:
   the most an area called Discover could say about an app was its name,
   clamped to two lines at 9.6px. It is a horizontal rail of cards now — art,
   name, the app's own sentence, and how many people built it — which is what
   the design asks for and what makes the area answer "should I open this?"

   IT BLEEDS TO BOTH SCREEN EDGES. The negative margin cancels the section's
   px-3 gutter so the first card starts on the text's left edge and the last
   one runs off the right, which is the affordance: a card cut by the edge
   says there are more. #home-screen states `overflow-x: hidden` (see the
   gesture rule above, pinned by tests/home-vertical-scroll-only.test.js), so
   the overhang is clipped by the feed rather than widening it.

   BOTH AXES, AND THE SECOND ONE IS NOT OPTIONAL (#1762). This rule said
   `touch-action: pan-x` for the same reason the switcher's app rail carries
   it — without it a diagonal drag that starts here can be claimed by the
   vertical feed and the rail never moves — under a comment claiming that "a
   VERTICAL drag still reaches the page, the browser only takes the axis this
   element declares". That claim is simply false, and it cost the whole
   screen: `touch-action` is not a preference about which axis this element
   would like, it is the complete set of gestures the browser may run for a
   touch that STARTS here, intersected down the ancestor chain. `pan-x`
   therefore DENIES vertical panning; it does not hand it to the feed. Two
   rails of ~13rem cards bleeding to both screen edges is most of the
   Discover block, so on a phone that was a band you could not scroll the
   page from at all — put a finger on a card, drag up, and nothing moved, not
   even the rubber band. (`.un-sheet:has(#notifications-sheet)` a thousand lines up
   has to opt vertical panning back IN for its scroller for exactly this
   reason, and states the rule correctly.)

   `pan-x pan-y` is what `html, body` already declares, so this is the page's
   own value rather than a new one: both axes are legal for a touch starting
   here and the browser's axis lock picks the one the drag is going in — a
   mostly-horizontal drag scrolls the rail, a mostly-vertical one scrolls the
   feed. What that costs is the narrow case the `pan-x` was written for: a
   genuinely diagonal drag that is slightly more vertical now scrolls the
   feed instead of the rail. That is the trade every horizontal rail on the
   web makes, and it is the right side of it — failing to scroll the rail
   costs one retry, failing to scroll the page costs the screen.

   It also has to stay `pan-y` for the drag-to-add gesture (#1763): the kit
   arms a lift on a long press with a movement slop, so a scroll that starts
   here cancels the press rather than competing with it — but only if the
   scroll is allowed to start. */
.home-discover-rail {
  display: flex;
  gap: 0.625rem;
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x proximity;
  -webkit-overflow-scrolling: touch;
  touch-action: pan-x pan-y;
  scrollbar-width: none;
  margin-inline: -0.75rem;
  padding-inline: 0.75rem;
  /* The snap position must be the KEYLINE, not the rail's own edge. Without
     it `scroll-snap-align: start` lines the first card up with the scrollport
     edge — 0.75rem outside the section's gutter, since the negative margin
     above cancelled it — and WebKit applies that snap on layout, so on iOS
     the rail arrived pre-scrolled with the first card stuck to the screen. */
  scroll-padding-inline: 0.75rem;
  /* Room for the cards' own shadow-less hairline to not be clipped, and for
     a focus ring on the last card. */
  padding-block: 0.125rem;
}
.home-discover-rail::-webkit-scrollbar { display: none; }

/* ── THE RAIL STOPS AT THE CONTENT'S EDGE, AND FADES THERE (#2718 review) ──

   On a phone the rail BLEEDS: `margin-inline: -0.75rem` with the padding
   added back lets a card scroll flush to the screen edge, which is the whole
   affordance — the row visibly continues past the frame.

   On a desktop the frame is not the screen. The page has a rail on its left
   and a gutter mirrored on its right (see --platform-gutter), and a lane that
   bleeds 12px past that gutter puts its cards 12px closer to the window edge
   than every other thing on the page — which reads as the lane being
   misaligned rather than as it continuing.

   So the bleed is cancelled and a MASK says the same thing instead: the last
   2rem of the scrollport fades to transparent, so a row with more in it
   trails off rather than being cut. The mask is static rather than driven by
   scroll position — a lane shorter than the port fades empty space, which is
   invisible, and the alternative is a scroll-timeline this shell cannot rely
   on in every browser it ships to. */
@media (min-width: 768px) {
  .home-discover-rail {
    margin-inline: 0;
    padding-inline: 0;
    scroll-padding-inline: 0;
    -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
    mask-image: linear-gradient(to right, #000 calc(100% - 2rem), transparent);
  }
}

/* 9.5rem is the reference's card at a 390pt phone (150/390 of the frame),
   and it is a fixed width on purpose: cards that flex would resize as the
   lane's count changes, and the rail's whole affordance is that the row
   continues past the edge at a constant rhythm. */
.home-discover-card {
  flex: 0 0 auto;
  width: 9.5rem;
  scroll-snap-align: start;
  border-radius: 0.875rem;
  background: var(--tint-bg);
  border: 1px solid var(--tint-line);
  overflow: hidden;
  cursor: pointer;
}
/* `cover`, not `contain`: the framing editor's zoom bottoms out at the exact
   cover fit and its pan is clamped to it, so the art fills the block at every
   legal framing rather than letterboxing itself against the tint. */
.home-discover-illustration { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.home-discover-art {
  overflow: hidden;
  height: 7.5rem;
  background: var(--tint-art);
  display: flex;
  align-items: center;
  justify-content: center;
}
/* The app's own icon, at the size the art block can carry. An emoji is a
   glyph and scales with font-size; an uploaded image fills the block, which
   is the one case that looks like the reference's own art. */
.home-discover-art-emoji { font-size: 2.75rem; line-height: 1; }
.home-discover-art-img { width: 100%; height: 100%; object-fit: cover; }
.home-discover-art-letter { font-size: 2.25rem; line-height: 1; font-weight: 700; opacity: .45; }
/* Two lines, ellipsised — the reference's own treatment. `line-clamp` is
   three properties and one of them is prefixed, which is why it is here
   rather than as utilities beside the element. */
.home-discover-blurb {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}
/* The same two lines for the NAME (#4937): the single `truncate` it carried
   cut long community names mid-word on a card wide enough to read them.
   `overflow-wrap: anywhere` keeps a name with no spaces in it from
   overflowing the card instead of wrapping. */
.home-discover-name {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  overflow-wrap: anywhere;
}

/* ── Challenges: a flat column of cards ─────────────────────
   The block was one white card of divided rows, then a translucent PLATE
   (`.home-challenges-plate`) holding the season summary and the cards. Both
   are gone. The ITERATION 03 board's Home screen draws the area straight on
   the page ground: season summary, group headers, cards and footer in one
   column, 14px apart, aligned with the section heading and with no border or
   rule anywhere. The cards and the group headers are surfaces of their own,
   so the plate was a second frame around them, and its padding pulled the
   block in from the heading's edge. The spacing is utilities in
   frontend/src/features/home/panels/challenges.tsx; nothing here draws it.

   The cards carry no rules of their own. Each is the Challenges
   tab's shared card (features/leaderboard/challenge-card.tsx), drawn entirely
   in utilities; `.home-challenge-card` survives only as the root class the
   declared checks and tests select on. The tinted card, the white pill, the
   count capsule and the category well that lived here are retired with the
   design they drew. */

/* ── The Create tile: the launcher grid's last cell ─────────────
   "Create an app" is the grid's trailing tile (the prototype's scrHome), drawn
   entirely in utilities in frontend/src/features/home/create-tile.tsx: an app
   card's cell with a dashed, unfilled face and a muted label. It replaced the
   full-width Create section that sat below Challenges, and the
   `.home-create-widget` wrapper went with that section.

   Here are only the three things a utility cannot say, and each mirrors a rule
   the app cards already carry (the `#app-list > .app-card` rules): the tile
   sits ABOVE the drag overlay, at the top of its row, and it is transparent to
   hit-testing for the span of a lift. That last one is load-bearing for the
   same reason it is on the cards — the overlay cell under the tile has to stay
   a drop target, and a drop there is an ordinary drop into an empty cell (the
   tile is not in the layout; the next paint moves it along). It also fades
   while a drag is live: it is the one thing on the canvas that does not move
   with the preview, and at full strength it read as a tile being displaced. */
#app-list > .home-create-tile {
  position: relative;
  z-index: 1;
  align-self: start;
}
#app-list.un-reordering > .home-create-tile {
  pointer-events: none;
  opacity: 0.4;
}
/* Keyboard focus draws the ring the challenge cards draw
   (`.home-challenge-card[role="button"]:focus-visible`). */
.home-create-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ── EDIT MODE (#4669): the wiggle ───────────────────────────
   A held tile puts My apps into edit mode, and the tiles gently wiggle the
   way a phone's home screen does. The animation is on the ICON WRAPPER
   (the card's `.relative` child), not on the card itself: the kit's drag
   writes `transform` to the CARD element for the lift, displacement preview
   and release spring, and an animation transform on the same element would
   fight it. The wrapper is React's alone.

   `alternate` swings the one keyframe pair between -1.5deg and 1.5deg, and
   the per-row delays (2n / 3n) spread the phase so the grid reads as a
   jelly of tiles rather than one rigid tile repeated. */
@keyframes home-wiggle {
  from { transform: rotate(-1.5deg); }
  to { transform: rotate(1.5deg); }
}
#app-list[data-editing] > .app-card > .relative {
  animation: home-wiggle 260ms ease-in-out infinite alternate;
}
#app-list[data-editing] > .app-card:nth-child(2n) > .relative {
  animation-delay: -130ms;
}
#app-list[data-editing] > .app-card:nth-child(3n) > .relative {
  animation-delay: -65ms;
}
/* While a drag is live the kit puts .un-reordering on the list; the wiggle
   pauses so the dragged tile's wrapper is not also turning under the finger. */
#app-list.un-reordering[data-editing] > .app-card > .relative {
  animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
  /* The arrangement affordances stay — the remove buttons, "Done" and the
     Add apps tile are markup, not motion — only the wiggle goes. */
  #app-list[data-editing] > .app-card > .relative {
    animation: none;
  }
}

/* ── Phone density ──────────────────────────────────────────
   Below 640px the grid is 4 columns with a tighter tile, so the cell is 8px
   shorter (p-2 instead of p-3) and the gap is 6px instead of 8, and the
   overlay's inset/gap follows from that — kept here, in one block, so the
   two can't drift.

   640px IS the breakpoint: it is Tailwind's `sm` on #app-list's
   `grid-cols-4 sm:grid-cols-5`, and HomeLayout.BREAKPOINT_PX in
   frontend/src/features/home/home-layout.js. All three must agree or the JS
   lays out against a column count the CSS isn't rendering. */
@media (max-width: 639.98px) {
  :root {
    /* 116px — p-2 (16) + icon 56 + gap-1.5 (6) + title 26 + caption 12 */
    --home-cell-h: 7.25rem;
    /* --home-panel-max-h was redefined here as ONE CELL (#968), because a
       block was a grid item whose phone footprint was a single row and a
       238px cap in a 116px cell would have let an oversized system text size
       paint the article over the app tiles below it. The blocks are fixed
       sections outside the grid now — they overlap nothing and they size to
       their own content — so neither the override nor the token it narrowed
       exists any more. */
  }
  .home-grid-overlay { gap: 0.375rem; }
  /* The doubled class is deliberate. This rule is what makes the phone
     cell 116px rather than 124px, but the card element carries Tailwind's
     own `p-3`, and tailwind.css is linked AFTER app.css in index.html — so
     at equal specificity the utility won and this tightening never applied
     (the cell budget above said p-2 while tiles rendered at p-3, which is
     how a captioned tile came to overflow its row). `.app-card.app-card`
     is the same element, one specificity step up, so the intended phone
     padding lands without an !important or a #app-list scope that would
     miss the landing grid's tiles. It also buys the label 8px of extra
     line width, which at 11px is roughly two more characters per line.

     NOT the Discover card. It carries `.app-card` only as the wiring contract
     with `_wireDiscoveryCards`; it is a tile of its own whose art block is
     meant to run edge to edge, and this padding inset the illustration inside
     a frame of tint on every phone. */
  .app-card.app-card:not(.home-discover-card) { padding: 0.5rem; }

  /* `.home-panel-slot > .home-panel--fit { align-self: flex-start }` lived
     here: the phone-only release from the slot's stretch-to-fill, so a short
     block ended sooner instead of spending its cell on whitespace. Both the
     slot and the stretch went with the placement — a section is as tall as it
     draws, everywhere, with no rule needed to say so. */
}
@media (min-width: 640px) {
  .home-grid-overlay {
    /* #app-list's sm:p-3 sm:pt-2 */
    inset: 0.5rem 0.75rem 0.75rem;
    gap: 0.5rem;      /* sm:gap-2 */
  }
  /* THE MY APPS BLOCK'S WIDTH (#4861): the area spans the full column
     again. #4738 had capped it at 48rem and centred it, so a few apps
     grouped closer — but on review of that softening the narrower block
     read as odder than wide spacing: the grid sat inside a box narrower
     than the search bar above it and the sections below (evan, on #4861),
     so the cap is gone and everything on Home lines up at the same edges
     again. Tile padding and gaps are untouched, so the overlay's inset
     below still mirrors #app-list, and the cap being on no box at all means
     the absolutely positioned drag overlay (inside #app-list) and Home's
     cell measurements still line up with zero arithmetic. Search keeps the
     full width it always had. */
}

/* App tiles sit at the TOP of their row rather than stretching to fill it.
   Rows are a fixed --home-cell-h whenever tiles alone define them, so this
   changes nothing on its own. It earned its keep in the rows a multi-cell
   WIDGET spanned, where an expanded block made the row taller than a card and
   a stretched tile grew a pool of dead space under its title; nothing spans
   rows since THE UI OVERHAUL. It stays because the blank rows
   Home.rowTemplate emits (#975) are half a cell, and a tile stretched to a
   short track is the same wrong default in the other direction. */
#app-list > .app-card {
  align-self: start;
}

/* The feed body is a full-height flex COLUMN so the trailing sections can
   be bottom-anchored on a tall screen.

   min-height:100% (of the #home-screen scroller) does double duty:
   - it guarantees the scroller can always be scrolled by at least the
     hidden search bar's height, even with an empty "Your apps" — a short
     page would otherwise leave the bar permanently on screen (the search
     bar sits ABOVE this box inside the same scroller, so total content is
     always barHeight + 100%, and Home._searchReveal parks the scroller at
     barHeight);
   - it makes this box exactly as tall as the visible feed at that resting
     scroll position, which is what makes "anchored to the bottom of the
     box" read as "anchored to the bottom of the page".

   Height stays AUTO (min-height is only a floor), so nothing shrinks and
   nothing clips once the content is taller than the viewport. The
   safe-area padding keeps a bottom-anchored section off an iPhone's home
   indicator; box-sizing:border-box means it eats into the 100% rather
   than adding scroll. */
.home-body-fill {
  display: flex;
  flex-direction: column;
  min-height: 100%;
  /* #home-screen is the one screen root with no `.platform-safe-scroll`, so
     it reserves the tab bar's band here instead — same `max()`, same reason
     (see that utility near the top of this file). It matters more here than
     anywhere else: the trailing sections are BOTTOM-ANCHORED to this box, so
     under-reserving does not merely crop the last card, it parks Create and
     Challenges permanently behind the bar. */
  padding-bottom: max(var(--platform-tabs-h, 0px), var(--platform-safe-bottom));
  /* 4px, so the first label (its own `pt-4`) clears the header's notch by
     the 12px Workshop, Messages and Profile leave: the bar overlaps this
     box by 8. It sits on #home-body, not the search bar, because the bar
     parks by its own offsetHeight (home.js), and a taller bar would park
     the gap away with it. */
  padding-top: 4px;
}
html[data-browser-scroller="home-screen"] .home-body-fill {
  /* The header overlaps its content by .5rem (its rounded lower edge). */
  min-height: calc(100dvh - var(--browser-banner-h) - var(--platform-header-h) - var(--platform-safe-top) + .5rem);
}

/* THE PARKED SEARCH BAR IS TRANSPARENT (QA 2026-09-24 Q30c). Parking the
   scroller at the bar's height does not take the bar off the screen on a
   phone: the platform header is translucent (rgba(255,255,255,.5) over a
   blur) and overlays the top of the scroller, so the parked bar sat right
   under it and "Search your apps…" read through the Homeroom logo on every
   load. Home._searchReveal already stamps whether the bar is showing
   (data-revealed, half the bar or more); the bar simply fades with it. A
   focused bar is never transparent, whatever its scroll position, so a
   keyboard focus can never leave the caret in an invisible field (the focus
   handler in home.js also scrolls it out, see _searchReveal.reveal). */
#home-search-bar {
  transition: opacity 150ms ease;
}
#home-search-bar[data-revealed="false"]:not(:focus-within) {
  opacity: 0;
}

/* NOTE: .home-bottom-anchor is gone with the two trailing sections it
   existed for ("Featured apps" and "Create an app", which pushed to the
   bottom of a short feed). Both are widgets in the grid now, so there is
   nothing below the grid left to anchor. .home-body-fill above stays — its
   min-height is what keeps the hidden search bar pullable on a short page,
   which was always a separate job. */

/* ── Browse screen rows / boxes ─────────────────────────────
   The #apps screen renders ONE row markup (icon · name+meta · Add
   button) and lets CSS pick the layout: a hairline-divided vertical
   list on a phone, bordered app boxes in a 2/3-column grid on a wide
   screen — the Apple App Store shape at both sizes. Only the GRID is
   Tailwind (responsive classes on #browse-list); every border lives
   here, which is deliberate and load-bearing:

   Tailwind's `divide-y` compiles to
     .divide-y > :not([hidden]) ~ :not([hidden]) { border-top-width: 1px;
                                                   border-bottom-width: 0 }
   and `md:divide-y-0` zeroes BOTH widths on the same selector. That
   selector is (0,3,0) and beats a bare `.browse-row` (0,1,0), so mixing
   the two mechanisms stripped the top AND bottom border from every box
   except the first child — which is exactly the bug this replaced. The
   hairline is therefore a sibling rule here, in the same cascade as the
   box, and #browse-list carries no divide-* utilities at all.

   Written as one min-width query matching Tailwind's `md` (768px) —
   keep the two in step if the container's breakpoint ever moves. */

.browse-row {
  /* One hairline token per theme so the rules below never need a `.dark`
     variant (which would out-specify the sibling/hover rules). */
  --browse-border: var(--border-light);
  transition: background-color 150ms, border-color 150ms;
}
.dark .browse-row { --browse-border: var(--border); }
/* Neutral, not --bg-card: that token is an accent TINT, so a plain hover read
   as a selected/highlighted row once the accent turned blue. */
.browse-row:hover { background-color: var(--bg-secondary); }

/* #1617: on phones, the name gets the whole width above metadata + Add.
   One DOM tree keeps the explicit add/remove action and row navigation intact.
   Desktop boxes and all other ListRow users retain their existing layout. */
@media (max-width: 639px) {
  .browse-row.browse-row {
    display: grid;
    grid-template-columns: 2.75rem minmax(0, 1fr) auto;
    column-gap: 1rem;
    row-gap: 0.25rem;
  }
  .browse-row > .app-icon-tile { grid-column: 1; grid-row: 1 / 3; }
  .browse-row .browse-row-content { display: contents; }
  .browse-row .browse-row-title { grid-column: 2 / 4; grid-row: 1; white-space: normal; }
  .browse-row .browse-row-name { white-space: normal; overflow-wrap: anywhere; }
  .browse-row .browse-row-meta { grid-column: 2; grid-row: 2; min-width: 0; }
  .browse-row > .browse-add-btn { grid-column: 3; grid-row: 2; min-height: 44px; }
  .browse-row .browse-row-title > span { align-items: baseline; }
}

/* QA 2026-09-24 Q10: from 640px up the name keeps its row beside the icon and
   the Add pill, and it used to be one `truncate`d line there, which cut
   "Staging demo Puzzle Chain" to "Staging de…" in a desktop box. It may take
   TWO lines now before it ellipsises. The phone rule above already lets it wrap
   freely on its own full-width row; this is the clamped version of the same
   idea for the boxes. */
@media (min-width: 640px) {
  .browse-row .browse-row-title { white-space: normal; }
  .browse-row .browse-row-title > span { align-items: baseline; }
  .browse-row .browse-row-name {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    white-space: normal;
    overflow-wrap: anywhere;
  }
}

/* Phone: a single hairline BETWEEN consecutive rows, INSET to the text column.
   The rows sit in one white card now (#browse-list carries the card classes
   below md), so a rule running the card's full width would reach its corner
   radius — the same reason the home panels' hairlines are inset, and the same
   4.75rem depth @/components/ui/grouped-list.tsx uses for a row with a leading
   tile (px-4 1rem + tile 2.75rem + gap-4 1rem).

   A pseudo-element rather than `border-top` so the md+ box below can own the
   `border` shorthand outright: .browse-row is a ListRow, which is already
   `relative`. */
.browse-row + .browse-row::before {
  content: '';
  position: absolute;
  top: 0;
  left: 4.75rem;
  right: 0;
  height: 1px;
  background: var(--browse-border);
  pointer-events: none;
}

/* md+: every row is its own complete box. The sibling selector is listed
   alongside so this wins over the phone rule at equal specificity rather
   than relying on source order alone. */
@media (min-width: 768px) {
  /* Each row is its own box here, so the between-rows hairline has nothing to
     separate — it would draw across the top of every box but the first. */
  .browse-row + .browse-row::before { content: none; }
  .browse-row,
  .browse-row + .browse-row {
    /* The widget language separates by figure/ground, so the box is a white
       surface on the grey page rather than a hairline outline. The border
       stays declared and transparent so the hover rule below has a width to
       colour in without the box changing size under the cursor. */
    border: 1px solid transparent;
    background-color: var(--bg-primary);
    border-radius: 1rem;
  }
  .browse-row:hover,
  .browse-row + .browse-row:hover { border-color: var(--accent-light); }
}

/* ── The phone directory: controls on the ground, the list in a card ────
   SINCE THE COMMUNITIES PROTOTYPE the search, the filter chips and Sort sit
   on the ground above the card rather than as its head: the card holds what
   it lists, and the controls over it read as the control rows over Home's
   and the Communities screen's cards do. What follows is #1919's reasoning
   for the one-pane shape it replaces; the parts, the page-scoped classes
   and the empty-note rule still hold, and the split ring does not (the
   body and the note are whole cards now).

   #1919: below md the search bar, the Sort control and the list they narrow wear
   the Workshop's working pane (`.dev-ws-pane`, further down): one frosted
   sheet (`--dc-sheet-fill` over `--dc-frost`), a 22px radius and a hairline
   ring, with the head pinned at the top and the rows sliding under its
   frost. Before this the bar was a wallpaper-coloured strip and the list a
   plain white rounded-2xl card below it, which is the pre-pane shape every
   other recent screen has moved off.

   PAGE-SCOPED CLASSES, deliberately not the Workshop's own. The two panes
   are the same treatment today and should be allowed to move separately;
   sharing `.dev-ws-pane-*` would also drag the Workshop's sticky offset and
   its stage-board width rules onto a screen that has neither.

   NO WRAPPER, so no single element to ring. The head (#browse-search-bar)
   and the body (#browse-list) are siblings under #browse-screen — the head
   sticks against the SCREEN's scroller, and wrapping both in a pane would
   put a new ancestor between the head and the thing it sticks in. So the
   ring is a `border` split across the two parts (the head keeps no bottom
   edge, the body no top edge) rather than the Workshop's inset box-shadow,
   which cannot leave one side out. Same margins on both parts is what makes
   the two half-rings meet as one.

   The parts paint the SAME fill over the SAME backdrop, for the reason the
   Workshop's note gives: a fill on a wrapper plus a fill on the sticky head
   stacks 50% on 50% and draws a lighter band across the controls.

   THE NOTE IS THE BODY WHEN THE LIST IS EMPTY. A search that matches nothing
   leaves #browse-list with no children and #browse-empty with the sentence;
   the empty list collapses (`:empty`) and the note takes the body's bottom
   half of the ring instead, so the pane still closes. And while neither is
   drawn — the directory is still loading, or the load failed and the error
   card is the body's only child — the head closes itself (`:has`, the same
   way the shell's body rules already read screen state).

   Written as one max-width query matching the md breakpoint's lower edge
   (767px), the mirror of the min-width query above — keep the two in step
   if the container's breakpoint ever moves. */
@media (max-width: 767px) {
  /* The controls on the ground (the prototype's Discover): no sheet, no
     ring, no fill, and NOT PINNED on a phone. A pinned head has to hide the
     rows sliding under it, which takes a fill, and a fill is a band across
     the wallpaper's washes and star that no flat colour matches (the header
     fakes its glass for the same reason; a fixed layer inside this
     scroller is not safe to fake, since the screen transitions transform
     it). So on a phone the controls scroll away with the list, as a search
     over a store list does. md+ keeps its pinned bar as it was. */
  #browse-screen .browse-pane-head {
    position: static;
    margin: 0;
    /* 20px at the top: 8 under the header's notch, 12 of air (12 left the
       search field 4px under the bar). */
    padding: 20px 12px 10px;
    background: transparent;
  }
  #browse-screen .browse-pane-body,
  #browse-screen .browse-pane-note {
    background-color: var(--dc-sheet-fill);
    backdrop-filter: var(--dc-frost);
    -webkit-backdrop-filter: var(--dc-frost);
    border: 1px solid var(--app-sheet-line);
    margin: 0 12px 12px;
    border-radius: 22px;
    /* The rows' hover fill and the hairlines would otherwise poke out of
       the corners. Nothing in the body positions itself outside it (the
       "+" menu the Workshop pane keeps `overflow: visible` for has no
       counterpart here). */
    overflow: hidden;
  }
  #browse-screen .browse-pane-body:empty { display: none; }
  #browse-screen .browse-pane-note { padding: 12px 14px 16px; }
}
/* Where there is no backdrop-filter the fill is all there is, and 50% white
   with rows sliding crisply under the pinned head is the rendering fault the
   frost prevents. All three parts go opaque together, because they must stay
   the same colour as each other above all. */
/* SOLID ON A PHONE, ALL THREE TOGETHER, on every platform. The list scrolls
   under the pinned head, so with no blur the head has to be opaque, and the
   three parts are one pane, so they stay one colour (see --dc-sheet-solid).
   md+ is untouched: there the bar wears the wallpaper's own ground. */
@media (max-width: 767px) {
  #browse-screen .browse-pane-body,
  #browse-screen .browse-pane-note { background-color: var(--dc-sheet-solid); }
}

/* ── App-card "…" actions menu ─────────────────────────────
   Popover built by Home.openCardMenu (appended to <body>, fixed-
   positioned against the trigger rect). Plain CSS rather than
   Tailwind utilities so the dynamically-inserted panel doesn't
   depend on the CDN JIT having generated classes at scan time. */

/* The "…" menu's container and item rows are kit-owned now
   (unNative.popover's .un-popover / .un-popover-item, #741) — only the
   build-info header content below stays app-styled; it rides inside the
   kit popover as a headerEl. */

/* Build-info header (Home.renderMenuHeaderHtml): full untruncated app
   name + slug + the deployed-commit pill that used to sit on the card
   face. max-width bounds long names — they wrap, never truncate. */
.card-menu-header {
  max-width: 18rem;
  padding: 0.5rem 0.875rem 0.55rem;
  margin-bottom: 0.25rem;
  border-bottom: 1px solid var(--border-light);
}

.card-menu-title {
  font-size: 0.875rem;
  font-weight: 600;
  color: var(--text-primary);
  overflow-wrap: anywhere;
}

.card-menu-slug {
  font-size: 0.7rem;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}

.card-menu-version {
  margin-top: 0.375rem;
  font-size: 0.75rem;
  color: var(--text-muted);
}

.card-menu-updated {
  margin-top: 0.25rem;
  font-size: 0.7rem;
  color: var(--text-muted);
}

/* The app's full pill set inside the menu header — the card face
   carries no chips at all, so this is the one place they render. */
.card-menu-pills {
  margin-top: 0.375rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.375rem;
}

/* ── Status dots ───────────────────────────────────────── */

.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.status-dot.running { background-color: #22c55e; }
.status-dot.creating { background-color: #eab308; animation: pulse 1.5s ease-in-out infinite; }
.status-dot.error { background-color: #ef4444; }

@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.4; }
}

/* ── App iframe ────────────────────────────────────────── */

#app-content { position: relative; }

/* Surface-dependent bottom safe area (#970, extended).
   #app-view used to carry the kit's `un-safe-bottom` unconditionally, so
   its padding was subtracted from the flex height of EVERY surface inside
   #app-content — the running app included. On a phone that left the app
   ~34px short of the screen's rounded bottom edge, with the body
   background showing through the gap, while the shell itself painted
   edge-to-edge. #970 gated that padding on the PLATFORM surface.

   NEITHER SURFACE CARRIES PADDING ANY MORE. Gating it was only half the
   fix: on a platform surface the padding still sat on the OUTER box, so
   every Dev-mode surface was shrunk by the inset and the strip below it
   was dead background — the dev-chat composer floated above an empty
   band and the chat lost that height. The inset moved INWARD to the
   innermost thing that needs it (each scroller's content padding, each
   composer bar's own padding, via .platform-safe-scroll /
   .platform-safe-bar above), so Dev mode now paints edge-to-edge like
   the app surface does and only its content is held clear.

   What these two rules do is publish the TOKEN, so the surface contract
   stays stated where it is read and tested:
   - platform: restate the real inset (identical to :root — explicit
     here because this is the boundary the contract is about);
   - app: ZERO it, so no shared class can ever reserve the strip over an
     app frame. The frame keeps reaching the true bottom edge and the
     insets are forwarded INTO the app instead (AppView.broadcastSafeArea
     → the bridge's --un-safe-inset-*), which is what lets an app inset
     its own bottom chrome rather than being clipped by ours.
   No `!important` needed: nothing competes with either rule. */
#app-view[data-app-surface="platform"] {
  --platform-safe-bottom: var(--un-safe-inset-bottom, env(safe-area-inset-bottom, 0px));
}

#app-view[data-app-surface="app"] {
  --platform-safe-bottom: 0px;
}

#app-iframe {
  position: absolute;
  top: 0; left: 0; width: 100%; height: 100%;
  border: 0;
  background: var(--bg-primary);
}

/* THE "STILL OPEN" DOT (#2902), on an app kept loaded — its tile on Home,
   its App row in the sidebar (features/app-frame/live-apps.tsx). QUIET
   GREY since the 19 Oct polish board (N17): it was green, and the shell
   shows a coloured status dot only when something is wrong (AGENTS.md,
   "let zero say nothing"), never a green one on every running app. An app
   kept open is a convenience, not news; the dot stays because the sidebar's
   close control (#5525) sits where it is. Ringed in the page ground so it
   stays a dot on any icon behind it. */
.app-live-dot {
  display: block;
  width: 10px;
  height: 10px;
  flex: none;
  border-radius: 999px;
  background: var(--text-muted);
  box-shadow: 0 0 0 2px var(--bg-primary);
}
/* On a launcher tile: the icon's top-right corner, where the fork tag's
   bottom-left one leaves room. */
.app-card-live-dot {
  position: absolute;
  top: -2px;
  right: -2px;
}

/* ── APPS KEPT ALIVE BEHIND THE ONE ON SCREEN (#2902) ──────────────────
   The last few apps you opened stay loaded, each in its own frame beside
   #app-iframe (features/app-frame/app-frame.tsx), so resuming one shows it
   exactly as you left it. A kept frame keeps #app-iframe's box — the same
   absolute inset — so the app inside never sees a resize while it waits, and
   is hidden by `visibility`, not `display`: nothing paints and nothing can be
   hit, but its layout (and so its scroll position) is untouched. `inert` and
   `aria-hidden` on the element take it out of the keyboard and screen-reader
   order; this only takes it off the screen. */
.app-launch-host > iframe[data-kept] {
  position: absolute;
  top: 0; left: 0; width: 100%; height: 100%;
  border: 0;
  visibility: hidden;
  pointer-events: none;
}

/* ── THE APP SHEET, and why the corner lives HERE (#1571) ──────────────
   The running app arrives as a raised sheet on the page ground: its own
   top radius, its own hairline, its own shadow. That is a different
   mechanism from the one every OTHER route uses, and the split is not
   an inconsistency — it is the only arrangement that works on both.

   #platform-header carries `rounded-b-2xl -mb-2`: a notch cut out of THE
   BAR, overlapping downward so whatever sits below reads as
   rounded-topped. It costs the screens below it nothing, which is what
   makes it right for them — every screen root is TRANSPARENT (the body
   paints the ground, the roots are `flex-1 overflow-y-auto` and nothing
   else), so there is no fill on them to round in the first place.

   That notch needs a bar surface to cut, and on an app route there
   isn't one: the rule at "THE BAR OVER THE WALLPAPER" above clears
   #platform-header's background wherever the wallpaper shows, and
   #app-view is on that list. So inside an app the notch produced
   nothing at all, while the one opaque thing on the screen — the app's
   own document, behind #app-iframe's `background: var(--bg-primary)` —
   met the cream in a hard horizontal line corner to corner. That line
   is what #1571 reported ("cuts out on apps … a gray horizontal line
   not matching the background of the app"), and no radius on the bar
   can reach it.

   So the app frame rounds ITSELF. #app-frame-host is the right element
   and not #app-view: it is React-owned end to end (see
   features/app-frame/app-frame.tsx), it is shown only while an app is
   actually mounted, and it is already `overflow: hidden`, so the radius
   clips the frame with nothing further to arrange. Its sibling
   #app-content — Dev mode, the status placeholders — keeps the header's
   notch like every other platform surface, because like them it is
   transparent and has nothing to round.

   The shadow reaches UP into the bar's strip on purpose, which is what
   makes the sheet read as lifted rather than abutted. Nothing clips it:
   #app-view sets no overflow, and the bar's own `z-10` paints it above
   the shadow without hiding it, because on this route the bar has no
   background to hide anything with.

   Geometry is the design's (Streamlined Concept, node 736:4306): a 28px
   top radius, a 1px hairline and a two-layer shadow.

   THE HAIRLINE IS `border-top` ONLY, and that is the whole of it. The
   sheet is as wide as the screen and runs off the bottom, so a border on
   any other side is not an edge of anything — it draws a 1px rule down
   the left and right of the display for the entire height of the app,
   which reads as a letterboxed frame around it rather than as a card.
   Only the top edge is a real boundary here, because it is the only side
   where the sheet actually meets the page ground. */
#app-frame-host {
  border-radius: 1.75rem 1.75rem 0 0;
  border-top: 1px solid var(--app-sheet-line);
  box-shadow:
    0 6px 12px -6px var(--app-sheet-shadow-near),
    0 8px 24px -4px var(--app-sheet-shadow-far);
  /* KEPT ON A LAYER, or the corners flicker while the app opens — see
     "WHY BOTH PROMOTIONS" below for what this costs and why the frame
     needs one of its own too. */
  will-change: transform;
}

/* THE CLIP, STATED AT EVERY LEVEL, because one level is not enough here.

   #app-frame-host's own `overflow: hidden` (an inline style, see
   features/app-frame/app-frame.tsx) is the whole fix in Chrome and
   Firefox for content that stays on its parent's layer. Two things in
   this subtree do not:

   - #app-iframe. WebKit has a long-standing bug where an <iframe>
     ignores an ancestor's border-radius outright.
   - .app-launch-cover. It cross-fades on `opacity` (see its rule further
     down), so it is on its own layer for the length of the launch.

   #1656's note here had the launch cover CAUSING a corner artifact. It
   does the opposite: while the cover is up it is the topmost thing at
   the corners, it is an ordinary div, and it clips correctly — so if
   anything it masks one. (An earlier version of this note went on to
   explain "some apps" by how fast they boot. That was wrong too: it is
   which of them repaint LATE. See below.)

   None of these clips survives an animation on its own — see "WHY BOTH
   PROMOTIONS" below, which is the other half of the corner story and
   the part that took the longest to find.

   So the radius is restated on each of them, and .app-launch-host takes
   an `overflow: hidden` of its own so the clip also exists one level
   closer to the cover than the host's. Same value everywhere, via
   `inherit` — a child rounding to a DIFFERENT radius than the box
   clipping it is worse than either alone. */
#app-frame-host > .app-launch-host {
  border-radius: inherit;
  overflow: hidden;
}

#app-frame-host #app-iframe,
#app-frame-host .app-launch-cover {
  border-radius: inherit;
}

/* ── WHY BOTH PROMOTIONS ───────────────────────────────────────────────
   The corners flickered while an app opened: they would go square for a
   frame and snap back. Both `will-change`s here are the fix, and they
   are not redundant — they stabilise two DIFFERENT layers.

   An <iframe> owns a compositing layer of its own, and the rounded clip
   above is on its ANCESTORS. Every time either layer is re-rastered
   mid-animation, the clip has to be re-applied to it, and for a frame
   or two it is not. So the host is kept on a layer (the open animation
   re-rasters it) and so is the frame (a repaint INSIDE the app
   re-rasters that one, which nothing on the host can reach).

   This was found by elimination on the staging preview, because it
   cannot be reproduced in a dev container: the kit and native.css are
   centrally hosted and a dev container's egress denies that host, so
   every local repro exercises an animation that is not the one the
   product runs. Four confident local diagnoses were wrong for exactly
   that reason. What identified it was that the flicker hit SOME apps:
   promoting the host alone fixed Gym Tracker and left Todo List
   flickering, and the difference between those two is that Todo List
   loads Google Fonts with `display=swap` — a swap repaints the app's
   whole document when the face arrives, inside the open animation's
   window. Gym Tracker loads no web fonts. So "some apps" was never
   about how fast they boot; it is about which of them repaint late.

   Two traps, both paid for:

   - `translateZ(0)` does NOT work here. It computes to a 2D matrix and
     is optimised away — getComputedStyle returns
     `matrix(1, 0, 0, 1, 0, 0)` — so it never creates the layer it looks
     like it creates. `will-change: transform` does.
   - Do not "verify" a fix for this by screenshotting a live animation
     in a loop. A 260ms animation outruns the loop and the frames land
     at different times each run, which returns false positives.
     Freeze it instead: `await vt.ready`, pause everything from
     document.getAnimations(), then step currentTime.

   WHAT IT COSTS, so it is a decision and not an accident: `will-change`
   holds a layer alive for as long as it is set, and these two are set
   permanently rather than only during the animation. That is bounded —
   #app-frame-host is a singleton, at most one app runs at a time, and
   the frame composites on its own regardless, so the standing cost is
   one extra full-screen layer. Scoping them to the open would need the
   launch sequence to add and remove a class around an animation the
   kit owns and this side does not observe; the layer is cheaper than
   that coupling. Revisit if a profile says otherwise.

   Ruled out on the preview and not worth re-running: the shadow (the
   flicker survives `box-shadow: none`), a `view-transition-name` on
   the host, and the launch cross-fade on its own. `border-radius: 0`
   does stop it, by deleting the corner that flickers, so it says
   nothing. */
#app-frame-host #app-iframe {
  will-change: transform;
}

/* CHROMELESS TAKES IT ALL BACK OFF. The chromeless route is
   `#app/<slug>/full` — `const chromeless = tab === 'full'` in app.js's
   restoreFromHash, which then rewrites tab to 'app', so the ordinary
   in-app route `#app/<slug>/app` is NOT this and keeps the sheet. There
   the bar is not transparent, it is GONE (App.setChromeless publishes the
   header's visibility off and platform-header.tsx's useHiddenClass puts
   `hidden` on the element), so the frame starts at the top of the
   viewport. A sheet needs something to be a sheet ON, and there is
   nothing above it: the radius would round the app's top corners against
   the bare page ground and the shadow would fall on nothing. Chromeless
   means the app IS the screen, so it goes back to filling it edge to
   edge.

   Keyed on the header's `hidden` class rather than on App.chromeless
   because that flag has no DOM presence to select on. The cost is that
   the class arrives via the visibility store's re-render, so LEAVING
   chromeless through the pill can show one square frame before the sheet
   returns. Worth knowing about; not worth a body-class write from
   app.js to pre-empt, since the transition it happens under is a
   full-document one either way. */
body:has(#platform-header.hidden) #app-frame-host {
  border-radius: 0;
  border: 0;
  box-shadow: none;
}

/* ── Dev chat messages ─────────────────────────────────── */

/* NO `scroll-behavior: smooth` on .dc-messages-container. It made every
   programmatic write to the pane's scrollTop an animation, and the pane
   follows its newest message by writing scrollTop as tokens stream in: each
   write restarted the smooth scroll from wherever the last one had got to,
   so the pane crept after the text instead of staying with it. The jumps
   that are meant to glide ask for it themselves (scrollTo with
   `behavior: 'smooth'`). */

/* #1942: a new session's empty state, centred in the message pane. */
.dc-empty-state {
  min-height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 32px 24px;
  text-align: center;
}
.dc-empty-title { font-size: 20px; font-weight: 600; color: var(--text-primary); }

/* #1905: on a brand-new session the composer comes up to meet the empty
   state, the way a new conversation opens in Claude or ChatGPT: title,
   line and input box as one group in the middle of the pane. Keyed on the
   empty state being in the pane, so the first message sends the box back
   to the bottom with no state of its own to clear. Tablet width and up
   only: on a phone the box stays at the bottom, where the keyboard opens and the
   thumb already is, and a mid-screen field would jump as the keyboard
   pushes it. Its own breakpoint, deliberately not the Workshop's 700px:
   tests/dev-workshop.test.js reads that block as the only one of its kind. */
@media (min-width: 768px) {
  .dc-chat-pane:has(#dc-empty-state) { justify-content: center; }
  .dc-chat-pane:has(#dc-empty-state) > #dc-messages { flex: 0 0 auto; overflow: visible; }
  .dc-chat-pane:has(#dc-empty-state) #dc-empty-state { min-height: 0; padding-bottom: 16px; }
  .dc-chat-pane:has(#dc-empty-state) > #dc-composer-bar {
    width: 100%;
    max-width: 44rem;
    align-self: center;
  }
}

.dc-msg { padding: 8px 12px; max-width: 92%; }

/* ONE bubble, not two.
   Your turn is a card; the agent's is plain text on the ground. Both were
   filled surfaces before — yours a solid --accent, the agent's --bubble-bg —
   which made a two-party conversation two competing blocks of colour, and
   put the transcript's loudest element on the half of it a reader already
   wrote and does not need to re-read.

   THE ACCENT FILL IS THE PART TO EXPLAIN, because it took real work to keep
   legible and all of it is now retired. White on the accent is 3.93:1 at the
   eyedropped #0a7cff, which is why --accent was darkened to #0a6ee0 in the
   first place; the fill then needed --accent-ink for the body, a separate
   inversion for the header, an opacity rule for the stamp that had to be
   re-scoped off the role label after it took that label to 2.7:1, and two
   more rules for links and for the header slots that declared colour inline.
   Seven rules and three contrast incidents, to say "this one is yours" —
   which side, shape and surface already say. (--accent-ink itself stays: it
   is the token for near-black-on-light-accent and .messages-state button is
   still a real fill.)

   The agent's row loses its surface entirely. --bubble-bg existed only
   because --bg-primary IS the page ground in dark, so a bubble drawn on it
   vanished; with the session sheet as the ground and the agent's turn drawn
   as plain text, there is nothing left to keep distinct, and the token has
   no other reader. Plain text is also the honest shape: the agent's turn is
   the body of the screen, not an aside in it.

   The tail notch (`… 4px 12px`) is still gone: uniformly rounded, relying on
   side and surface to say who is speaking. */
/* YOUR TURN, TOLD APART FROM THE AGENT'S. Two cues, because each of them
   alone had already failed once.

   SURFACE. This was --dc-raised, which is the SAME token the coding-run
   card, the PR card and the stopped card fill with. Your message and the
   agent's cards were the identical grey, and the only thing separating them
   was an alignment that (see below) was not visible either. It takes the
   accent as a TINT now — 8% over the sheet, not the saturated fill that was
   retired above for contrast: at this alpha the body text still reads
   against the page's own ink and no inverted token is needed.

   WIDTH. `.dc-msg` caps every row at 92%, which on a message of any real
   length is indistinguishable from full width — so `margin-left: auto` had
   nothing to push against and the right-alignment cue simply did not exist.
   76% leaves a gutter down the left that no agent row ever crosses, because
   the agent's cards are full-bleed with a 12px margin. The gutter is the
   cue; the tint confirms it. */
.dc-msg-user {
  margin-left: auto;
  max-width: 76%;
  background: var(--accent-tint);
  border-radius: 1.25rem;
  margin-bottom: 4px;
}

.dc-msg-assistant {
  margin-right: auto;
  background: transparent;
  border-radius: 1.25rem;
  margin-bottom: 4px;
}

.dc-msg-header {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 4px;
  font-size: 11px;
  font-weight: 500;
}

.dc-msg-content {
  font-size: 13px;
  line-height: 1.6;
  color: var(--text-secondary);
  word-break: break-word;
}

/* The stamp and the model name, on both rows. They used to be declared
   INLINE, which beat every class rule and is why the accent fill's inversion
   could not reach them; they are classes now and there is no inversion left
   to reach. --text-muted at 9px is what makes the stamp an aside — the 0.72
   opacity the fill needed is gone with the fill, and with it the incident
   where it dimmed the role label to 2.7:1. */
.dc-msg-meta {
  color: var(--text-muted);
  font-size: 9px;
}
.dc-msg-model {
  color: var(--text-muted);
}

.dc-msg-content p, .dc-p { margin-top: 0.5em; }

/* ── Markdown ──────────────────────────────────────────── */

/* Distinct heading levels (F3): # → dc-h3, ## → dc-h4, ###+ → dc-h5. */
.dc-h3 { font-size: 17px; font-weight: 700; margin: 14px 0 4px; color: var(--text-primary); }
.dc-h4 { font-size: 15px; font-weight: 600; margin: 12px 0 4px; color: var(--text-primary); }
.dc-h5 { font-size: 13px; font-weight: 600; margin: 10px 0 3px; color: var(--text-secondary); text-transform: none; }

.dc-msg-content a { color: var(--accent); text-decoration: underline; word-break: break-all; }
.dc-msg-content a:hover { color: var(--accent-light); }

.dc-msg-content strong { font-weight: 600; color: var(--text-primary); }
.dc-msg-content em { font-style: italic; color: var(--text-faint); }

.dc-code-header {
  font-size: 11px;
  color: var(--text-muted);
  padding: 4px 12px;
  background: var(--bg-code-header);
  border-radius: 8px 8px 0 0;
  border-bottom: 1px solid var(--border);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

.dc-code-block {
  background: var(--bg-code);
  border-radius: 0 0 8px 8px;
  padding: 12px;
  overflow-x: auto;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-code);
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  margin: 4px 0 8px;
}

.dc-code-header + .dc-code-block { border-radius: 0 0 8px 8px; }
.dc-code-block:not(.dc-code-header + .dc-code-block) { border-radius: 8px; }

.dc-inline-code {
  background: var(--bg-code-header);
  padding: 1px 5px;
  border-radius: 4px;
  font-size: 12px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

.dc-blockquote {
  border-left: 3px solid var(--border-light);
  padding: 4px 12px;
  margin: 8px 0;
  color: var(--text-faint);
  font-size: 13px;
}

.dc-ol { list-style: decimal; padding-left: 20px; margin: 6px 0; }
.dc-ul { list-style: disc; padding-left: 20px; margin: 6px 0; }
.dc-ol li, .dc-ul li { margin: 2px 0; }

/* GFM task items (F4): non-interactive ☐ / ✓ marker, no list bullet. */
.dc-task-item { list-style: none; margin-left: -16px; }
.dc-task-check { display: inline-block; width: 1em; color: var(--text-muted); font-family: 'JetBrains Mono', 'Fira Code', monospace; }
.dc-task-check.dc-task-checked { color: var(--accent); }

/* Held-back trailing line of a live-streaming assistant bubble: the
   still-being-typed final line, rendered as escaped plaintext until its
   newline arrives (so a `- [ ]` fragment never momentarily parses as a
   checkbox). Matches the surrounding prose and preserves leading spaces. */
.dc-streaming-tail { display: block; white-space: pre-wrap; }

/* Tables (F1): styled by class, not by .dc-msg-content ancestry, so they
   render identically in chat, the spec preview snippet, and the viewer. */
/* #683: inline markdown images (issue-body screenshot embeds). #2349 keeps
   the compact preview but makes its original asset an obvious full-size
   click/tap target. */
.dc-inline-img-link { display: block; width: fit-content; max-width: 100%; margin: 6px 0; border-radius: 8px; cursor: zoom-in; }
.dc-inline-img-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dc-inline-img-link .dc-inline-img { margin: 0; }
.dc-inline-img { max-width: 100%; max-height: 320px; border-radius: 8px; margin: 6px 0; display: block; }
/* #3940: the inline issue-video player (issue-body video embeds), beside
   the images above. Same box, its own controls. */
.dc-inline-video { max-width: 100%; max-height: 320px; border-radius: 8px; margin: 6px 0; display: block; }
.dc-table { border-collapse: collapse; margin: 8px 0; width: 100%; font-size: 13px; display: block; overflow-x: auto; }
.dc-table th, .dc-table td { border: 1px solid var(--border-light); padding: 4px 8px; text-align: left; }
.dc-table th { font-weight: 600; color: var(--text-primary); background: var(--bg-code-header); }

/* Fallback when marked / DOMPurify fail to load (F7). */
.dc-md-fallback-notice { font-size: 11px; color: var(--text-muted); margin: 0 0 4px; font-style: italic; }
.dc-md-fallback {
  white-space: pre-wrap;
  word-break: break-word;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-code);
  background: var(--bg-code);
  padding: 12px;
  border-radius: 8px;
  margin: 4px 0 8px;
}
.dc-msg-content del { text-decoration: line-through; }
.dc-msg-content hr { border: none; border-top: 1px solid var(--border-light); margin: 12px 0; }

/* ── Status lines (deploying, building, etc.) ──────────── */

.dc-status-log {
  margin-bottom: 8px;
  border-left: 2px solid var(--border-light);
  padding-left: 8px;
}

/* #3415: 12px on each side is the inset .dc-msg and the transcript's cards
   (.dc-pr-card, .dc-cc-attached, .dc-failure) already use, so a step's check
   lines up with the reply label and card edges instead of touching the pane. */
.dc-status-line {
  font-size: 11px;
  color: var(--text-muted);
  padding: 2px 12px;
  display: flex;
  align-items: center;
  gap: 6px;
}

/* #3559: a status row's sentence, and only its sentence (StatusLine in
   frontend/src/features/dev-chat/transcript.tsx). It is a flex item, so it
   was held at the width of its longest word: a step naming a link or a
   branch pushed the row past the transcript's edge, and the transcript,
   which scrolls, then slid sideways on a phone. `anywhere` lets the word
   break inside the row, and lowers the item's min-content with it, which
   `break-word` would not. The stamp and the clock beside it keep theirs. */
.dc-status-text { min-width: 0; overflow-wrap: anywhere; }

/* #2597: the venue caption under a running row's heading.
   The heading says what is happening ("Coding agent is running"); this says
   where, in the same muted second-line treatment .dc-cc-note already gives
   the run card's guess and cohort hint.

   .dc-status-line is a single centred flex ROW, and every other row in the
   transcript depends on it staying one — so the wrap is opted into by the
   captioned rows alone, and the caption claims a whole line by basis rather
   than by leaving the flow. The 20px indent is the icon (14px) plus the
   row's gap (6px), which lands it under the first letter of the heading.
   Inside .dc-cc-attached-summary the summary is already a COLUMN, so the
   caption is a row of it and neither rule applies. */
.dc-status-venue {
  font-size: 11px;
  line-height: 1.45;
  color: var(--text-muted);
  min-width: 0;
}
.dc-status-line-captioned { flex-wrap: wrap; }
.dc-status-line-captioned > .dc-status-venue {
  flex: 0 0 100%;
  padding-left: 20px;
}

/* Explicit square box with centered glyph is required for the
   rotation animation to spin around the glyph's visual center. Without
   width/height/line-height, the span's transform-origin ends up wherever
   the inline-block glyph happens to sit, which makes the animation look
   static or wobbly on a radially-symmetric character like ⚙. */
.dc-status-icon {
  font-size: 12px;
  display: inline-block;
  width: 14px;
  height: 14px;
  line-height: 14px;
  text-align: center;
  transform-origin: 50% 50%;
}

.dc-cc-progress {
  font-size: 11px;
  color: var(--text-muted);
  padding: 2px 0 2px 22px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  opacity: 0.7;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.dc-status-spinning {
  animation: dc-spin 1.5s linear infinite;
  color: var(--accent);
}

/* Pure-CSS arc spinner used for "in-progress" status lines. The gear
   glyph is too rotationally symmetric to read as spinning; an arc with
   ~270° of border vs a transparent gap is unambiguous. Sized to align
   with the gear so layout doesn't shift when the line transitions from
   active to done. */
.dc-status-spinner-arc {
  box-sizing: border-box;
  width: 12px;
  height: 12px;
  margin: 1px;
  border: 2px solid var(--accent);
  border-right-color: transparent;
  border-radius: 50%;
  animation: dc-spin 0.8s linear infinite;
  vertical-align: -2px;
}
/* Still, for someone who has asked for less motion. The arc with its gap
   says "in flight" without turning, the label beside it names what is, and
   the header's cog already holds still under the same setting. A board can
   carry dozens of these at once (41 on this repository's own, every card
   open), most of them in cards scrolled out of view. */
@media (prefers-reduced-motion: reduce) {
  .dc-status-spinner-arc { animation: none; }
}

/* Completed state — explicit green check so a glance at the chat tells
   the user which steps finished. Kept at the same box dimensions as
   the spinner so nothing shifts when a step transitions from active
   → done. */
.dc-status-check {
  color: #10b981;
  font-weight: 700;
}

/* ── Before / after captures ──────────────────────────────
   AppView.visualsTilesHtml builds these as innerHTML into two hosts — the
   dev chat's Changes-ready card and the board's proposal detail — so the
   media keeps its inline style and only the parts added here are classed.

   Three things the strip could not say before:

   .usn-visual-phone — a `viewport: 'mobile'` capture. #768 said so in the
     row label, "(mobile)", which a reader takes in only AFTER wondering why
     the shot looks oddly cropped. The outline says it first. The label
     stays: it is what a screen reader gets, and it survives with no CSS.

   .usn-visual-empty — the side that has no capture. It used to render
     NOTHING, so a new page came out as one lone tile beside a caption, and
     a missing half is indistinguishable from a capture that failed. A
     drawn, dashed placeholder is the difference between "there is nothing
     to compare" and "something went wrong".

   .usn-visual-more — every route after the first. They stacked inline, so
     a three-route capture put three full-width strips between a proposal's
     title and its actions. */
.usn-visual-media { display: block; min-width: 0; }
.usn-visual-phone {
  border: 2px solid var(--border);
  border-radius: 12px;
  padding: 4px 3px;
  background: var(--bg-secondary);
}
.usn-visual-phone > * { border-radius: 7px !important; }
.usn-visual-empty {
  /* Takes its PARTNER's height — the row is `align-items: stretch`, so a
     placeholder that is visibly a different size from the tile beside it
     would read as a third thing rather than as the other half of a pair. */
  height: 100%;
  min-height: 96px;
  border: 1px dashed var(--border);
  border-radius: 6px;
  background: var(--bg-secondary);
}
.usn-visual-figure { display: flex; flex-direction: column; }
.usn-visual-figure > .usn-visual-empty { flex: 1; }
.usn-visual-more { margin: 2px 0 0; }
.usn-visual-more-summary {
  cursor: pointer;
  list-style: none;
  font-size: 11px;
  font-weight: 600;
  color: var(--accent);
  padding: 4px 0;
}
.usn-visual-more-summary::-webkit-details-marker { display: none; }
.usn-visual-more-summary::marker { display: none; }
.usn-visual-more-summary::after { content: ' \203A'; }
.usn-visual-more[open] > .usn-visual-more-summary::after { content: ' \02C5'; }

/* ── A failed turn ────────────────────────────────────────
   A CARD, not a status row, because a failure is not a step in the ladder
   above it — it is the end of that ladder. As a row it inherited both the
   ladder's shape and the ladder's tick: `turnError` has been persisted by
   routes/sessions.js since #894 and nothing read it, so every failed turn
   came back green.

   --state-blocked is the platform's "this blocks you" red — the same token
   the board's checks-failing pill and the merge badges take, so a failure
   reads the same here as it does anywhere else in the product. It is a
   TINT plus a hairline rather than a fill: this sits inside a scrolling
   transcript, and a saturated block at message width is a stop sign for
   something the next message usually recovers from.

   No action button. The producers that set `turnError` also send
   `quickReplies: turnPills('failed')`, so the one-tap retry is already in
   the pill bar over the composer, with every other suggested next message.
   A second one here would be two controls for one act. */
.dc-failure {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  margin: 8px 12px;
  padding: 10px 12px;
  border-radius: 12px;
  border: 1px solid var(--state-blocked);
  background: var(--state-blocked-bg);
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-primary);
}
.dc-failure-icon {
  flex: none;
  color: var(--state-blocked);
  font-weight: 700;
  font-size: 14px;
  line-height: 1.45;
}
.dc-failure-body { flex: 1; min-width: 0; }
/* #3559: the body may shrink (min-width: 0) but its text did not follow it.
   A failure that quotes a link, which a build error often does, ran past the
   card and widened the whole transcript; `anywhere` breaks it in the card. */
.dc-failure-text { display: block; min-width: 0; overflow-wrap: anywhere; }
.dc-failure-text a { color: var(--accent); }

/* THE STOP PAIR. Both are "the run did not finish", so both are this card
   rather than the pipeline's ✓ row — but only one of them is a problem.

   `stopping` is #937's escalation: a stop that is not landing. Red, and it
   keeps the Force stop button, because that is the one state where the user
   is stuck and needs the action. It used to be a status row, which meant the
   escalation settled to a green tick the moment the turn went inactive.

   `stopped` landed. The user asked for it, so it is NOT red — painting
   someone's own decision as an error is the green tick's mistake in reverse.
   The neutral surface with a hairline, one step up from the sheet, and the
   landing in chips beside the sentence. */
.dc-failure-stopped {
  border-color: var(--border-light);
  background: var(--dc-raised);
}
.dc-failure-stopped .dc-failure-icon {
  color: var(--text-muted);
  font-size: 11px;
  line-height: 1.75;
}
.dc-failure-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  min-width: 0;
  margin-top: 6px;
}
.dc-failure-chips > * {
  flex: none;
  max-width: 100%;
  font-size: 11px;
  line-height: 1.5;
  padding: 2px 8px;
  border-radius: 7px;
  /* An inset: one step back DOWN from the card, exactly as the coding-run
     card's chips do it. Same chip, so the two cards read as one language. */
  background: var(--dc-sheet);
  border: 1px solid var(--border-light);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.dc-failure-chips > :empty { display: none; }

/* ── PR card ────────────────────────────────────────────── */

/* THE TRANSCRIPT'S CARDS READ THE LADDER, not --bg-secondary directly.
   Same reason the message bubble does: that token is one step UP from
   --bg-primary in light and, in dark, it is the session sheet's own value —
   so a card painted with it sat flush on the sheet and disappeared. This
   card is the one that matters most: it carries the before/after strip and
   the Propose button. Its inner surfaces move with it, from --bg-primary
   (which is now DARKER than the sheet in dark, a hole rather than an inset)
   to --dc-sheet, one honest step back down. Light is unchanged: the ladder's
   light values are the tokens these rules already used. */
.dc-pr-card {
  margin: 8px 12px;
  padding: 12px;
  /* A card in the language: an opaque surface with no hairline and the
     language's corner. --bg-card is an accent TINT, which read as a
     highlighted state once the accent turned blue; the neutral surface is
     what the deck's embedded cards use. */
  background: var(--dc-raised);
  border: 1px solid transparent;
  border-radius: 1rem;
  transition: background 300ms;
}

.dc-pr-card-header { margin-bottom: 8px; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }

.dc-pr-link { font-size: 14px; font-weight: 600; color: var(--accent); text-decoration: none; }
.dc-pr-link:hover { color: var(--accent-light); text-decoration: underline; }

.dc-pr-title { font-size: 13px; color: var(--text); font-weight: 500; }

.dc-pr-card-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* #3605: the lifecycle badge, once proposed, opens the vote page. The badge
   keeps its own look; the wrapper only adds the link cues. */
.dc-pr-status-link { display: inline-flex; padding: 0; border: 0; background: none; cursor: pointer; border-radius: 9999px; }
.dc-pr-status-link:hover .ms-badge { text-decoration: underline; }
.dc-pr-status-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* #699: expandable report body on the platform-issue draft card. The
   <summary> holds the 300-char preview plus a "Show full report" cue;
   opening reveals the remainder so the text reads continuously (the cue
   hides while open). Open state persists via data-persist-id — see
   _applyDetailsPersistence in dev-chat.js. */
.dc-pi-report { margin: 0 0 6px; }
.dc-pi-report-summary {
  cursor: pointer;
  list-style: none;
  font-size: 13px;
  color: var(--text-muted);
  white-space: pre-wrap;
}
.dc-pi-report-summary::-webkit-details-marker { display: none; }
.dc-pi-report-cue {
  color: var(--accent);
  font-size: 12px;
  user-select: none;
  white-space: nowrap;
}
.dc-pi-report-cue:hover { color: var(--accent-light); }
.dc-pi-report[open] .dc-pi-report-cue { display: none; }
.dc-pi-report-rest {
  font-size: 13px;
  color: var(--text-muted);
  white-space: pre-wrap;
}

.dc-pr-btn {
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  border: none;
  transition: background 150ms;
}

/* ── Out-of-credits card ────────────────────────────────────────────────
   Rendered in the message stream in place of an assistant bubble when a
   send is refused with code 'budget_exceeded'. Markup comes from
   public/js/credit-options.js, which also builds the compact button row
   inside the red #dc-credits-banner. Deliberately NOT red-alarm styled:
   it is an offer of three ways forward, not an error. */
.dc-credits-card {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--dc-raised);
  padding: 12px 14px;
  margin: 6px 0;
}

.dc-credits-card-lead {
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-credits-card-detail {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
}

.dc-credits-card-intro {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 8px;
}

.dc-credits-options {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 8px;
}

.dc-credits-option {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--dc-sheet);
}

.dc-credits-option-text { flex: 1 1 220px; min-width: 0; }

.dc-credits-option-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-credits-option-blurb {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
  line-height: 1.4;
}

/* The per-option CTA. Shares .dc-pr-btn's geometry; the first option is
   the primary route so every button reads equally actionable. */
.dc-credits-go {
  background: var(--accent);
  color: white;
  flex: 0 0 auto;
}
.dc-credits-go:hover { background: var(--accent-light); }

/* #4378: an unverified account's "Verify my account", under its line. */
.dc-credits-verify { margin-top: 8px; }

/* The "Are you a developer?" half of the out-of-credits card (#1281).
   The routes that need a terminal — the CLI lease and importing your own
   pull request — live in here rather than in the flat list above, so the
   ways anyone can follow are the ones met first. A plain <details>: the
   disclosure is the browser's, not ours. Styled as a quiet footer to the
   card, never as a fourth option, because it is a question about the
   reader rather than another thing to click. */
.dc-credits-dev {
  margin-top: 10px;
  border-top: 1px solid var(--border);
  padding-top: 10px;
}

.dc-credits-dev-summary {
  font-size: 12px;
  font-weight: 600;
  color: var(--text-secondary);
  cursor: pointer;
}

.dc-credits-dev-summary:hover { color: var(--text-primary); }

.dc-credits-dev-hint {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 4px;
  line-height: 1.4;
}

/* The expanded rows sit flush under the hint rather than repeating the
   list's own top margin, which would read as a gap the closed state
   doesn't have. */
.dc-credits-dev .dc-credits-options { margin-top: 6px; }

/* QA 2026-09-24 Q27: the credits banners fold to one line on a phone. The
   lead stays; what follows it (`.dc-credits-banner-more`) and the button row
   wait behind "Details" (dev-chat/banners.tsx), which is the whole strip's
   height handed back to the conversation, and the banner's "Check API key"
   no longer sits over the identical button on the transcript's card. The
   toggle is phone-only: from 640px up the banner is the one-row strip it
   always was. */
.dc-credits-banner-toggle { display: none; }
@media (max-width: 639.98px) {
  #dc-credits-banner:not([data-credits-open]) :is(.dc-credits-banner-more, .dc-credits-banner-actions),
  #dc-credits-low-banner:not([data-credits-open]) :is(.dc-credits-banner-more, .dc-credits-banner-actions) {
    display: none;
  }
  .dc-credits-banner-toggle {
    display: inline-flex; align-items: center; flex: none;
    min-height: 28px; padding: 0 2px;
    border: 0; background: none;
    font: inherit; font-size: 12px; font-weight: 600;
    text-decoration: underline; text-underline-offset: 2px;
    cursor: pointer;
  }
}

/* Banner variant: three small buttons inside the red exhausted banner.
   The row wraps onto its own line under the message on a phone (the
   banner itself is flex-wrap, and the message keeps a min-width), rather
   than squeezing the text into a sliver and running the buttons off the
   right edge. */
.dc-credits-banner-actions {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
}

.dc-credits-banner-btn {
  border-radius: 4px;
  padding: 4px 8px;
  font-size: 11px;
  font-weight: 500;
  cursor: pointer;
  border: 1px solid rgb(252 165 165);
  background: transparent;
  color: rgb(153 27 27);
  transition: background 150ms;
}
.dc-credits-banner-btn:hover { background: rgb(254 226 226); }
.dc-credits-banner-btn-primary {
  background: rgb(220 38 38);
  border-color: rgb(220 38 38);
  color: white;
}
.dc-credits-banner-btn-primary:hover { background: rgb(239 68 68); }

.dark .dc-credits-banner-btn {
  border-color: rgb(153 27 27);
  color: rgb(254 202 202);
}
.dark .dc-credits-banner-btn:hover { background: rgb(69 10 10); }
.dark .dc-credits-banner-btn-primary {
  background: rgb(220 38 38);
  border-color: rgb(220 38 38);
  color: white;
}

/* ── Development-flow picker + walkthrough (#1049) ──────────────────────
   Markup comes from public/js/dev-flow-select.js. Reuses the out-of-credits
   card's geometry on purpose: both are "here are your routes forward"
   cards in the message stream, and they render metres apart. */
/* ── The launchpad (#1281) ───────────────────────────────────────────────
   What a session shows instead of a composer when the work is happening
   somewhere else. Markup comes from features/dev-chat/own-tools-guide.tsx
   (own-tools-pr) and public/js/dev-flow-select.js (web hand-offs); dev-chat.js
   renders whichever into #dc-launchpad-slot and hides
   #dc-composer-controls beside it.

   Deliberately quiet: a panel styled as an alert would read as something
   having gone wrong rather than as the normal state of a hand-off. */

/* #1348: PINNED TO THE TOP of the chat pane, above the message scroller
   rather than in the composer's old place at the bottom.

   `flex: none` is the pin — the slot keeps its own height and the
   transcript below it takes what is left and scrolls. The cap reserves a
   FIXED strip for the transcript rather than splitting the pane by
   fraction: in a launchpad the card is the subject and the transcript is
   reference, so the card should have everything it can use, and a
   proportional cap (60%, say) would hold back half the pane from a
   five-step walkthrough to show messages nobody came here to read. Past
   the cap the card scrolls internally. The percentage resolves because
   every ancestor up to .dc-session-body is a flex item with min-h-0, so
   the pane's height is definite.

   It carries its own padding now: it used to sit inside the bottom bar's
   p-2 and has no wrapper of its own up here. */
.dc-launchpad-slot {
  flex: none;
  max-height: calc(100% - 5rem);
  overflow-y: auto;
  padding: 0 8px;
  border-bottom: 1px solid var(--border);
}
.dc-launchpad-slot:empty { display: none; }

.dc-launchpad {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--dc-raised);
  padding: 12px 14px;
  margin: 6px 0;
}

.dc-launchpad-lead {
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-launchpad-sub {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 4px;
  line-height: 1.45;
}

/* The resume banner (#1350). Two states, one box: "there is a branch with
   work on it, the copy below continues it" and "there is nothing here
   yet". Only the first is tinted with the accent, because only the first
   carries a consequence for getting it wrong. */
.dc-launchpad-resume {
  margin-top: 8px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--dc-sheet);
}

.dc-launchpad-resume[data-launchpad-resume="continue"] {
  border-color: var(--accent);
}

.dc-launchpad-resume-title {
  font-size: 12px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-launchpad-resume[data-launchpad-resume="continue"] .dc-launchpad-resume-title {
  color: var(--accent);
}

.dc-launchpad-resume-detail {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
  line-height: 1.45;
}

.dc-launchpad-resume-detail code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11px;
  color: var(--text-primary);
  word-break: break-all;
}

.dc-launchpad-steps {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 10px;
}

.dc-launchpad-step { display: flex; gap: 10px; align-items: flex-start; }

/* The numeral. Fixed square so a two-column step body keeps its left edge
   whatever the digit is. */
.dc-launchpad-step-mark {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: var(--dc-sheet);
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 600;
  line-height: 18px;
  text-align: center;
}

.dc-launchpad-step-body { flex: 1 1 auto; min-width: 0; }

.dc-launchpad-step-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-launchpad-step-detail {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
  line-height: 1.45;
}

/* A copyable block: the text, then its own button. The <pre> scrolls
   rather than stretching the panel — the "tell your agent" prefill is a
   dozen lines and this sits above the home indicator on a phone. */
.dc-launchpad-copy { margin-top: 6px; }

.dc-launchpad-pre {
  max-height: 168px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11px;
  line-height: 1.5;
  color: var(--text-secondary);
  background: var(--dc-sheet);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 10px;
  margin: 0;
  -webkit-user-select: text;
  user-select: text;
}

.dc-launchpad-copy-btn,
.dc-launchpad-btn {
  margin-top: 6px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--dc-sheet);
  color: var(--text-primary);
  font-size: 12px;
  font-weight: 600;
  padding: 5px 10px;
  cursor: pointer;
}
.dc-launchpad-copy-btn:hover,
.dc-launchpad-btn:hover { border-color: var(--accent); color: var(--accent); }

.dc-launchpad-actions { display: flex; flex-wrap: wrap; gap: 8px; }

/* The walkthrough's own brief field (#1281). It lives in the card rather
   than in the composer, because a hand-off session hides the composer —
   see the launchpad block above. Sized to three lines: enough to see a
   sentence, small enough that the steps under it stay on screen. */
.dc-flow-brief {
  display: block;
  width: 100%;
  margin-top: 6px;
  padding: 7px 9px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--dc-sheet);
  color: var(--text-primary);
  font: inherit;
  font-size: 13px;
  line-height: 1.45;
  resize: vertical;
}
.dc-flow-brief::placeholder { color: var(--text-muted); }
.dc-flow-brief:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 2px rgba(10, 124, 255, 0.25);
}
.dc-flow-brief:disabled { opacity: 0.6; }

/* ── The vendor toggle (#1281) ───────────────────────────────────────────
   Claude / ChatGPT at the top of the type 2 launchpad. Two buttons rather
   than a select so the current one is legible without opening anything,
   which is the whole point of a toggle on a phone. The active one is
   `disabled` — it is a statement, not an offer — so it must not be styled
   as a dead control the way a disabled button normally is. */
.dc-flow-vendors {
  display: inline-flex;
  margin-top: 8px;
  border: 1px solid var(--border);
  border-radius: 8px;
  overflow: hidden;
}

.dc-flow-vendor {
  font-size: 12px;
  font-weight: 600;
  padding: 4px 14px;
  border: 0;
  background: var(--dc-sheet);
  color: var(--text-muted);
  cursor: pointer;
}
.dc-flow-vendor:hover:not(:disabled) { color: var(--text-primary); }

.dc-flow-vendor-on,
.dc-flow-vendor-on:disabled {
  background: var(--accent);
  color: #fff;
  cursor: default;
  opacity: 1;
}

.dc-flow-card {
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--dc-raised);
  padding: 12px 14px;
  margin: 6px 0;
}

.dc-flow-card-lead {
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-flow-card-detail,
.dc-flow-card-hint {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 4px;
  line-height: 1.4;
}

.dc-flow-options {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 8px;
}

.dc-flow-option {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--dc-sheet);
}

.dc-flow-option-text { flex: 1 1 220px; min-width: 0; }

.dc-flow-option-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-flow-option-blurb {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
  line-height: 1.4;
}

.dc-flow-go {
  background: var(--accent);
  color: white;
  flex: 0 0 auto;
}
.dc-flow-go:hover { background: var(--accent-light); }

.dc-flow-remember {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 10px;
  font-size: 12px;
  color: var(--text-secondary);
  cursor: pointer;
}

.dc-flow-remember-hint {
  font-size: 11px;
  color: var(--text-muted);
  margin-top: 2px;
}

/* Walkthrough. Exactly one step is 'current' at a time — the numbered mark
   carries that state so the card is scannable without reading it. */
.dc-flow-steps {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 10px;
}

.dc-flow-step {
  display: flex;
  gap: 10px;
  padding: 8px 0;
  border-top: 1px solid var(--border);
}
.dc-flow-step:first-child { border-top: none; }

.dc-flow-step-mark {
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  border-radius: 999px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  font-weight: 600;
  border: 1px solid var(--border);
  color: var(--text-muted);
  background: var(--dc-sheet);
}

.dc-flow-step-body { flex: 1 1 auto; min-width: 0; }

.dc-flow-step-title {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
}

.dc-flow-step-detail {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
  line-height: 1.4;
}

/* The connector prerequisite on the hand-off step: set off from the
   step's detail so it reads as "before you paste", with the one link
   the person has to follow. */
.dc-flow-step-note {
  font-size: 12px;
  color: var(--text-primary);
  margin-top: 6px;
  padding: 6px 8px;
  border-radius: 6px;
  background: var(--accent-tint);
  line-height: 1.4;
}
.dc-flow-step-note a,
.dc-flow-card-hint a,
/* #2706: the hint's "show the steps" is a BUTTON — it opens the inline
   connector card rather than navigating — but it is a link in the middle of
   a sentence and has to read as one. `inherit` for font and size, because a
   <button> takes neither from its parent. */
.dc-flow-hint-link {
  color: var(--accent);
  text-decoration: underline;
}

.dc-flow-hint-link {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  cursor: pointer;
}

.dc-flow-step-done .dc-flow-step-mark {
  background: #059669;
  border-color: #059669;
  color: white;
}
.dc-flow-step-done .dc-flow-step-title { color: var(--text-muted); }

.dc-flow-step-current .dc-flow-step-mark {
  background: var(--accent);
  border-color: var(--accent);
  color: white;
}

.dc-flow-step-todo { opacity: 0.65; }

.dc-flow-actions {
  display: flex;
  gap: 6px;
  flex-wrap: wrap;
  margin-top: 8px;
}

.dc-flow-actions-footer { margin-top: 10px; }

.dc-flow-action {
  background: var(--dc-sheet);
  color: var(--text-secondary);
  border: 1px solid var(--border);
}
/* The href-bearing actions ("Fork on GitHub", "Open Claude Code") are real
   anchors, not buttons — the scripted window.open they replaced never left
   the page on mobile (#1312). Same box as the buttons beside them. */
a.dc-flow-action {
  display: inline-block;
  text-decoration: none;
  line-height: normal;
}
.dc-flow-action:hover { background: var(--bg-tertiary); }
.dc-flow-action:disabled { opacity: 0.5; cursor: not-allowed; }

.dc-flow-action-primary {
  background: var(--accent);
  border-color: var(--accent);
  color: white;
}
.dc-flow-action-primary:hover { background: var(--accent-light); }

.dc-flow-error {
  font-size: 12px;
  color: rgb(185 28 28);
  margin-top: 6px;
  line-height: 1.4;
}
.dark .dc-flow-error { color: rgb(252 165 165); }

.dc-flow-notice {
  font-size: 12px;
  color: rgb(4 120 87);
  margin-top: 6px;
}
.dark .dc-flow-notice { color: rgb(110 231 183); }

.dc-flow-order { margin-top: 10px; }

.dc-flow-order > summary {
  font-size: 12px;
  color: var(--text-muted);
  cursor: pointer;
}

.dc-flow-order-text {
  margin-top: 6px;
  max-height: 240px;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
  font-size: 11px;
  line-height: 1.45;
  color: var(--text-secondary);
  background: var(--dc-sheet);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 8px 10px;
}

/* Modal variant: the same three options rendered by the Generate-proposal
   flow when the headless route refuses for the same reason. */
.dc-credits-modal-card .dc-credits-card {
  border: none;
  background: transparent;
  padding: 0;
  margin: 0;
}

.dc-pr-btn-preview {
  background: var(--dc-raised);
  color: var(--accent);
  border: 1px solid var(--border);
}
.dc-pr-btn-preview:hover { background: var(--bg-tertiary); }
/* Merged sessions: the staging container is gone, so the preview link is
   dead. Render it visibly inert (muted + not-allowed) instead of clickable. */
.dc-pr-btn-preview:disabled {
  opacity: 0.45;
  cursor: not-allowed;
  color: var(--text-muted);
}
.dc-pr-btn-preview:disabled:hover { background: var(--dc-raised); }

.dc-pr-btn-promote { background: var(--accent); color: white; }
.dc-pr-btn-promote:hover { background: var(--accent-light); }
.dc-pr-btn-promote:disabled { background: #3f3f46; cursor: not-allowed; }
/* #558: the shared arc spinner borders in var(--accent) (violet), which is
   near-invisible on this filled violet/dark button while a proposal is in
   flight. Scope a white spinner to this button only so the busy state reads
   clearly; the shared class stays untouched everywhere else. */
.dc-pr-btn-promote .dc-status-spinner-arc {
  border-color: rgba(255, 255, 255, 0.85);
  border-right-color: transparent;
}

/* ── Q/A mode: suggested-answer chips (#32) ─────────────── */

.dc-qa-chips { margin: 8px 0 2px; display: flex; flex-direction: column; gap: 8px; }

.dc-qa-group-label { font-size: 11px; color: var(--text-muted); }

.dc-qa-chip-row { display: flex; gap: 6px; flex-wrap: wrap; }

.dc-qa-chip {
  padding: 5px 12px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  background: var(--dc-raised);
  color: var(--accent);
  border: 1px solid var(--border);
  transition: background 150ms, border-color 150ms;
  max-width: 100%;
  text-align: left;
}
.dc-qa-chip:hover { background: var(--bg-tertiary); }
/* THE TWO STATES SAY DIFFERENT THINGS AND MUST BOTH BE READABLE AT ONCE.
   `-default` is what the model RECOMMENDS; `-selected` is what you CHOSE.
   They are frequently different chips, and a reader comparing the two is
   the whole reason the questionnaire shows both. So the recommendation is
   an outline and the choice is a fill: one hollow, one solid, legible
   side by side rather than two shades of the same idea. */
.dc-qa-chip-default { border-color: var(--accent); }
.dc-qa-chip-selected {
  background: var(--text-primary);
  border-color: var(--text-primary);
  color: var(--bg-primary);
}
.dc-qa-chip-selected .dc-qa-chip-hint { opacity: 0.75; }
.dc-qa-chip-selected:hover { opacity: 0.88; }

/* The escape hatch — the last chip in the row, and the only one that does
   not answer. Quieter than the answers on purpose: it is the way out, not
   an option, and it must not look like a fifth thing the model suggested. */
.dc-qa-escape { color: var(--text-muted); border-style: dashed; }
.dc-qa-escape-open { border-style: solid; border-color: var(--accent); color: var(--accent); }

/* …and the input it opens, scoped to its own group rather than sending the
   reader to the composer. Uncontrolled, committing on blur/Enter — see
   `_onQaTypedCommit`. */
.dc-qa-typed {
  display: block;
  width: 100%;
  margin-top: 6px;
  padding: 7px 11px;
  border-radius: 10px;
  border: 1px solid var(--accent);
  background: var(--dc-sheet);
  color: var(--text-primary);
  font: inherit;
  font-size: 13px;
}
.dc-qa-typed:focus { outline: none; box-shadow: 0 0 0 3px var(--accent-tint); }

/* A QUANTITY, asked as one. */
.dc-qa-number { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dc-qa-step {
  width: 30px;
  height: 30px;
  flex: none;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--dc-raised);
  color: var(--text-primary);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
}
.dc-qa-step:hover { background: var(--bg-tertiary); }
.dc-qa-number-field {
  width: 6.5rem;
  padding: 6px 10px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--dc-sheet);
  color: var(--text-primary);
  font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
  font-size: 13px;
  text-align: center;
}
.dc-qa-number-field:focus { outline: none; border-color: var(--accent); }

/* "suggested", in the model's own quiet voice.
   NOT uppercased. It was, and the stepper's hint is the reason that had to
   stop: `suggested 0.2 m` came out as "SUGGESTED 0.2 M", and a capital M is
   not the symbol for a metre. A hint that renders the value it is hinting
   at cannot own a transform that rewrites the value. Lowercase and muted is
   also what the design asks for, so the correctness fix and the design agree. */
.dc-qa-chip-hint {
  font-size: 10px;
  margin-left: 6px;
  opacity: 0.6;
  font-weight: 400;
}

/* The stepper's hint is the same span, but it is NOT sitting inside a chip:
   it takes the group's inherited ink at 0.6, and in dark that lands at about
   a third of the page's contrast — legible in the light render and very
   nearly not in the dark one. On the stepper it is the only thing naming the
   default, so it gets its own ink rather than an inherited fade. */
.dc-qa-number .dc-qa-chip-hint {
  margin-left: 2px;
  opacity: 1;
  color: var(--text-secondary);
}

.dc-qa-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }

.dc-qa-send {
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  border: none;
  background: var(--accent);
  color: #fff;
  transition: background 150ms;
}
.dc-qa-send:hover { background: var(--accent-light); }

.dc-qa-defaults {
  padding: 6px 12px;
  border-radius: 6px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  background: var(--dc-raised);
  color: var(--text-muted);
  border: 1px solid var(--border);
  transition: background 150ms;
}
.dc-qa-defaults:hover { background: var(--bg-tertiary); }

/* ── Quick-reply pills above the composer (#285) ───────── */

/* A single horizontally-scrollable row that never wraps, so it adds one
   compact line of height and never pushes the text box down on mobile.
   Empty by default (no quickReplies) — gains the -active modifier and its
   bottom margin only when pills are present. */
.dc-quick-replies {
  display: none;
}
.dc-quick-replies.dc-quick-replies-active {
  display: flex;
  flex-wrap: nowrap;
  gap: 6px;
  margin-bottom: 6px;
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none; /* Firefox: hide the scrollbar but stay scrollable */
}
.dc-quick-replies.dc-quick-replies-active::-webkit-scrollbar {
  display: none; /* WebKit: hide the scrollbar */
}

/* A horizontal strip that scrolls without DRAWING a scrollbar over itself.
   Generalised from `.dc-quick-replies` directly above, which has done exactly
   this since it shipped; the app menu's tile strip is the second caller and
   the reason it is a named class rather than a third copy.

   IT IS A DESKTOP PROBLEM, not a phone one. Overlay scrollbars (iOS, Android,
   macOS with the default "when scrolling" setting) fade out and take no
   layout — but a classic scrollbar is ~15px of permanent gutter INSIDE the
   content box, drawn across the bottom of the very row it scrolls. On the app
   menu that landed on the app NAMES under each tile.

   The affordance is not lost with it: a strip that scrolls shows a partial
   tile at its edge, which is what says "there is more" on every touch device
   already, where the bar was never visible at rest either.

   Scrolling itself is untouched — wheel, trackpad, drag and keyboard all
   still work; only the painted bar goes. */
.platform-no-scrollbar {
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* old Edge */
}
.platform-no-scrollbar::-webkit-scrollbar {
  display: none; /* WebKit / Blink */
}

.dc-quick-pill {
  flex: 0 0 auto;
  min-height: 34px;
  padding: 6px 14px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 500;
  cursor: pointer;
  white-space: nowrap;
  background: var(--dc-raised);
  color: var(--accent);
  border: 1px solid var(--border);
  transition: background 150ms, border-color 150ms;
}
.dc-quick-pill:hover { background: var(--bg-tertiary); border-color: var(--accent); }

/* ── The composer's action row (#1353) ─────────────────── */

/* The attach button and the pills, one line above the send row. The
   button is a SIBLING of the scroller rather than a child: inside it,
   it would scroll out of reach the moment there were more pills than
   fit. It also owns the row's bottom margin, because the pills' own
   only exists while there are pills — and the button is always there. */
.dc-composer-actions {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 6px;
}
.dc-composer-actions .dc-quick-replies.dc-quick-replies-active {
  flex: 1 1 auto;
  min-width: 0;
  margin-bottom: 0;
}

/* ── Saved draft messages above the composer (#798) ────── */

/* Hidden until at least one draft exists, so an empty list adds no
   height to the composer block (same pattern as the pills / attach
   strip above). Capped in height with its own scroller: a long list of
   parked notes must never crowd out the text box on a phone. */
.dc-drafts { display: none; }
.dc-drafts.dc-drafts-active {
  display: block;
  margin-bottom: 6px;
  max-height: 132px;
  overflow-y: auto;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--dc-raised);
  padding: 4px;
}

.dc-drafts-head {
  display: flex;
  align-items: baseline;
  gap: 6px;
  padding: 2px 4px 4px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.dc-drafts-hint {
  text-transform: none;
  letter-spacing: 0;
  font-weight: 400;
  font-size: 10px;
}

.dc-draft-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 4px 4px 6px;
  border-radius: 6px;
  background: var(--bg-card);
  border: 1px solid var(--border);
}
.dc-draft-row + .dc-draft-row { margin-top: 4px; }

.dc-draft-text {
  flex: 1 1 auto;
  min-width: 0;
  font-size: 12px;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.dc-draft-actions {
  display: flex;
  align-items: center;
  gap: 4px;
  flex: 0 0 auto;
}

.dc-draft-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  border-radius: 6px;
  cursor: pointer;
  background: var(--dc-raised);
  color: var(--text-muted);
  border: 1px solid var(--border);
  transition: color 150ms, border-color 150ms, background 150ms;
}
.dc-draft-btn:hover:not(:disabled) { background: var(--bg-tertiary); border-color: var(--accent); }
.dc-draft-send:hover:not(:disabled) { color: #10b981; border-color: #10b981; }
.dc-draft-edit:hover:not(:disabled) { color: var(--accent); }
.dc-draft-trash:hover:not(:disabled) { color: #ef4444; border-color: #ef4444; }
.dc-draft-btn:disabled { opacity: 0.4; cursor: not-allowed; }

/* The composer's own save icon lived here, a 34px bordered square beside the
   Send button. It is the one circle's GREEN STATE now (.dc-circle-save):
   while a turn runs, text in the box makes saving the only useful action, so
   it takes the button rather than sitting next to it. The rows' own
   .dc-draft-send above is a different control — it sends a saved draft. */

/* ── File attachments (#450) ───────────────────────────── */

/* Pending strip above the composer. Hidden when empty. */
.dc-attach-strip { display: none; }
.dc-attach-strip.dc-attach-strip-active {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 6px;
}

.dc-attach-item {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: var(--bg-card);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 4px;
  max-width: 100%;
}

.dc-attach-thumb {
  display: block;
  height: 48px;
  max-width: 96px;
  object-fit: contain;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.25);
}

.dc-attach-chip { padding: 4px 8px; font-size: 12px; }

.dc-attach-name {
  color: var(--text-secondary);
  max-width: 180px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.dc-attach-size { color: var(--text-muted); font-size: 11px; }

/* Kind badge on zip/binary chips ("ZIP · 214 files", "BIN"). */
.dc-attach-kind {
  color: var(--text-muted);
  background: var(--bg-tertiary);
  border-radius: 4px;
  padding: 1px 4px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.03em;
  white-space: nowrap;
  flex: 0 0 auto;
}

.dc-attach-remove {
  border: none;
  background: var(--bg-tertiary);
  color: var(--text-muted);
  border-radius: 999px;
  width: 18px;
  height: 18px;
  line-height: 16px;
  font-size: 13px;
  cursor: pointer;
  flex: 0 0 auto;
}
.dc-attach-remove:hover { color: #ef4444; }

.dc-attach-uploading { color: var(--text-muted); font-size: 12px; padding: 0 4px; }

.dc-attach-error {
  color: #ef4444;
  font-size: 12px;
  margin-bottom: 6px;
}

/* Attachments inside a sent user bubble — rendered OUTSIDE the
   markdown pipeline (DOMPurify strips <img> from chat markdown by
   design; these come from the trusted attachments route only). */
.dc-msg-attachments {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 6px;
}

.dc-msg-att-img {
  display: block;
  max-height: 120px;
  max-width: 220px;
  object-fit: contain;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: rgba(0, 0, 0, 0.25);
}
/* Until the picture has drawn, its box is the size pictures are drawn at,
   so a late, lazy picture fills the room it already has rather than growing
   its row (transcript.tsx AttachmentImage, polish board #19). */
.dc-msg-att-img[data-pending] { width: 160px; height: 120px; }

.dc-msg-att-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: var(--bg-card-alt);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 4px 8px;
  font-size: 12px;
  text-decoration: none;
}
.dc-msg-att-chip:hover { border-color: var(--accent); }

/* Group-chat attachment chips (#694): the markdown chip's name is a
   button that opens the side panel; Preview/download are inline
   actions. Shares the dc-attach-* look above. */
button.gc-att-open {
  border: none;
  background: none;
  padding: 0;
  font-size: inherit;
  cursor: pointer;
  text-align: left;
}
button.gc-att-open:hover { color: var(--accent); text-decoration: underline; }

.gc-att-action {
  color: var(--accent);
  text-decoration: none;
  font-size: 12px;
  flex: 0 0 auto;
}
.gc-att-action:hover { text-decoration: underline; }

/* ── Claude Code log (collapsible) ─────────────────────── */

.dc-cc-log {
  margin: 6px 0;
  border: 1px solid var(--border);
  border-radius: 6px;
  overflow: hidden;
}

.dc-cc-log-toggle {
  cursor: pointer;
  padding: 4px 10px;
  font-size: 11px;
  color: var(--text-muted);
  background: var(--bg-code);
  user-select: none;
}
.dc-cc-log-toggle:hover { color: var(--text-faint); }
.dc-cc-log[open] .dc-cc-log-toggle { border-bottom: 1px solid var(--border); }

.dc-cc-log-content {
  padding: 8px 10px;
  font-size: 11px;
  line-height: 1.4;
  color: var(--text-faint);
  background: var(--dc-sheet);
  max-height: 300px;
  overflow-y: auto;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  margin: 0;
}

/* ── Claude Code "is running" — status line + inline log ─
   The status line itself is the disclosure summary; the live log
   streams inline below as plain monospaced text (no border, no
   background). Open by default; click the summary to collapse. */

/* THE RUN IS A CARD. It was a status line with a log hanging off it: the
   summary ran on across three wrapped lines and the log bled to the pane's
   edges with no boundary, so the thing a reader watches longest on this
   screen was the one thing on it with no shape. The card gives the run an
   edge, and the facts inside it become chips (see ProgressChips in
   features/dev-chat/transcript.tsx).

   The surface is --bg-secondary, not a hue. "A coding run is happening" is
   not a state in the platform's vocabulary — --state-progress means
   something is IN FLIGHT toward a merge, and a run is neither good news nor
   bad. Tinting it would put a fifth colour on a screen whose colours already
   mean one thing each; the card's own edge is what separates it. */
.dc-cc-attached {
  margin: 8px 12px;
  border: 1px solid var(--border-light);
  border-radius: 12px;
  background: var(--dc-raised);
  overflow: hidden;
}

.dc-cc-attached-summary {
  /* Reuse dc-status-line for icon/baseline, but make it the
     <summary> of a <details> — suppress the native disclosure
     marker (we already have the spinner / check glyph plus a
     dedicated chevron span on the right). */
  cursor: pointer;
  list-style: none;
  /* A COLUMN now: the head, the chip row and the note stack. It stays a
     <summary> because a collapsed <details> renders nothing else. */
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  padding: 10px 12px;
}
.dc-cc-attached-summary::-webkit-details-marker { display: none; }
.dc-cc-attached-summary::marker { display: none; }
.dc-cc-attached-summary:hover { background: var(--dc-strip); }

/* Row 1 — the glyph, the label, the chevron and the id/timestamp. */
.dc-cc-head {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  font-weight: 600;
  color: var(--text-primary);
}

/* Row 2 — the facts. They WRAP; the old inline run ellipsized instead, and
   the 640px block used to hide the activity snippet outright. */
.dc-cc-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
  min-width: 0;
}
.dc-cc-chips > * {
  flex: none;
  max-width: 100%;
  font-size: 11px;
  line-height: 1.5;
  padding: 2px 8px;
  border-radius: 7px;
  /* An inset: one step back DOWN from the card it sits on, which is the
     sheet's own value. */
  background: var(--dc-sheet);
  border: 1px solid var(--border-light);
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* A chip with no value is still IN the DOM — the spans are addressed by
   class from tests and declared checks, so they render empty rather than
   disappearing. This is what keeps them from drawing an empty pill. */
.dc-cc-chips > :empty { display: none; }

/* Row 3 — the two sentences (the guess, the cohort hint), full width. */
.dc-cc-note {
  display: block;
  min-width: 0;
  font-size: 11px;
  line-height: 1.45;
  color: var(--text-muted);
}
.dc-cc-note:empty { display: none; }

/* Click affordance — sits to the right of "Claude Code is
   running…" before the id/ts span. Two distinct glyphs (▸ when
   the log is collapsed, ▾ when expanded) instead of rotating a
   single triangle: rotation looks subtly off because the
   triangle's bounding box flips between portrait and landscape
   when turned 90°, dragging the visual center off the baseline. */
.dc-cc-attached-chevron {
  display: inline-block;
  margin-left: 6px;
  font-size: 18px;
  line-height: 1;
  color: var(--text-muted);
  transition: color 120ms ease;
  /* Reserve a fixed-width slot so the timestamp doesn't shift
     horizontally when the glyph changes. */
  width: 18px;
  text-align: center;
}
.dc-cc-attached-chevron::before { content: '\25B8'; /* ▸ */ }
/* #1944: a descendant, not a child. The dev chat's card puts the chevron
   inside its head row (`.dc-cc-head`, since the chips), while the shared
   transcript page still draws it as the summary's own child. The
   direct-child form matched only the second, so the dev chat's glyph sat
   on ▸ whether the card was open or closed and the toggle looked dead. */
.dc-cc-attached[open] > .dc-cc-attached-summary .dc-cc-attached-chevron::before {
  content: '\25BE'; /* ▾ */
}
.dc-cc-attached-summary:hover .dc-cc-attached-chevron {
  color: var(--text-faint);
}

/* Admin "View as non-admin" preview banner. The banner element is
   present in the DOM at all times (see public/index.html) but
   hidden by default; the body class set in app.js reveals it. Kept
   as a CSS-driven swap rather than imperative show/hide so a JS
   error elsewhere on the page can never strand an admin in masked
   mode without the visible reminder. */
body.is-view-as-non-admin #view-as-non-admin-banner {
  display: flex;
}

.dc-cc-attached-log {
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
  font-size: 11px;
  line-height: 1.5;
  /* Slightly duller than --text-muted so it sits visually below the
     status line without disappearing into the background. */
  color: var(--text-faint);
  /* Indent under the status icon so log lines align with the
     status text, not the icon. 22px = icon width (14) + gap (6) +
     2px breathing room. */
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 240px;
  overflow-y: auto;
  /* Inside the card now, as its own panel. The 22px indent that used to
     align it under the status icon aligned it under nothing once the icon
     moved into a header row. */
  margin: 0 12px 12px;
  padding: 9px 10px;
  border-radius: 9px;
  background: var(--dc-sheet);
  border: 1px solid var(--border-light);
}

/* ── #50: progress indicator — elapsed ticker + live activity ──
   .dc-status-elapsed shows a per-second ticking timer while a status
   line is active and a static "(took Xm Ys)" once it finishes;
   tabular-nums keeps the ticking digits from jittering the layout.
   .dc-cc-current is the "what is it doing right now" snippet in the
   running summary — ellipsized with a max-width so the right-aligned
   id/timestamp span never shifts as the label changes. */
.dc-status-elapsed {
  color: var(--text-muted);
  font-size: 11px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  flex-shrink: 0;
}

/* The four fact spans take their box from `.dc-cc-chips > *`; what is left
   here is each one's own voice. The `max-width: 40%` these carried is gone
   with the inline row — a chip that wraps needs no share of one line. */
.dc-cc-current {
  font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
  font-size: 10.5px;
}


/* #892: the DETERMINISTIC stage readout (Claude is working / Committing /
   Pushing …). Styled like the step count — upright, not italic — because
   unlike the AI guess beside it this is derived from markers the run
   genuinely emitted and cannot be wrong. */

/* #892: population context ("most runs finish in 2–10 min"). Extra-muted:
   it is background information about how long runs take in general, not a
   claim about this run, so it must not compete with the countdown. */
.dc-cc-cohort {
  display: block;
  opacity: 0.75;
}
.dc-cc-cohort:not(:empty)::before { content: '\00b7\00a0'; }

/* Experimental AI progress estimate (opt-in): italic and extra-muted so
   the guess reads as tentative next to the honest timer/step count. */
.dc-cc-estimate {
  font-style: italic;
}

/* THE 640px BLOCK IS GONE, and its absence is the point.
   It hid `.dc-cc-current` and `.dc-cc-cohort` outright and re-ordered the
   estimate onto its own row, because four muted spans could not share one
   inline line on a phone. Every one of those was a workaround for the
   inline row: chips wrap by construction and the two sentences already have
   a row of their own, so the narrow case now costs a line of height instead
   of a fact. `.dc-cc-current` in particular — the answer to "what is it
   doing right now" — used to vanish at exactly the width where the log
   behind it is hardest to open. */

/* Sibling of .dc-cc-attached-log for the post-turn ccOutput
   markdown summary (rather than a monospaced live-log). Same
   indent + muted color, but body font and a bit more room for
   structured markdown elements (paragraphs, lists, code blocks).
   #3559: it scrolls down, so it scrolled sideways too, and the agent's
   summary is where a long path in backticks turns up: the block slid under
   a finger on a phone. The log beside it breaks its words already; this
   breaks the summary's inside the block, and a fenced code block, which
   never wraps, still scrolls sideways inside its own box. */
.dc-cc-attached-md {
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
  max-height: 360px;
  overflow-y: auto;
  margin: 0 12px 12px;
  padding: 9px 10px;
  border-radius: 9px;
  background: var(--dc-sheet);
  border: 1px solid var(--border-light);
}
/* Tighten markdown spacing inside the muted summary block — the
   default .dc-msg-content treatment is too generous for a summary
   that sits under a status line. */
.dc-cc-attached-md p { margin: 4px 0; }
.dc-cc-attached-md ul, .dc-cc-attached-md ol { margin: 4px 0; padding-left: 20px; }
.dc-cc-attached-md li { margin: 2px 0; }
.dc-cc-attached-md pre.dc-code-block {
  margin: 6px 0;
  font-size: 11px;
}
.dc-cc-attached-md code.dc-inline-code {
  font-size: 11px;
}

/* ── Vote panel ─────────────────────────────────────────── */

/* Action pills: transparent "ghost" base (no gray fill) that picks up a
 * violet accent tint on hover; semantic variants (yes/no/preview/admin)
 * carry a faint resting tint of their own hue — the same tinted-chip
 * language as the Closes-#N chips, bounty pill, and icon chips. */
.gc-vote-btn {
  /* One fixed height for the whole family so a text pill, the icon Preview
     and the ⋯ trigger line up exactly rather than each being sized by its
     own content. box-sizing keeps the 1px border inside that height. */
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 24px;
  padding: 0 10px;
  border-radius: 9999px;
  font-size: 11px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  /* The widget language's action pill (#1191): a FILLED neutral surface, not
     an outlined transparent one. The deck's "open the Change ›" / "read it ›"
     controls read as soft solid objects sitting on the card, and an outline
     on a card that itself no longer has one reads as the louder element of
     the two. Ink steps up to primary to keep contrast on the fill. */
  border: 1px solid transparent;
  /* --bg-tertiary, not --bg-secondary: these pills sit ON a card, and in dark
     --bg-secondary IS the card (#1c1c1e both), so a "filled" pill rendered
     invisible there. --bg-tertiary is one step off the card in BOTH themes —
     #e3e3e6 on white, #2c2c2e on #1c1c1e. */
  background: var(--bg-tertiary);
  color: var(--text-primary);
  transition: all 150ms;
}
.gc-vote-btn:hover {
  background: var(--accent-tint);
  border-color: var(--accent-light);
  color: var(--accent);
}
.gc-vote-btn:disabled {
  opacity: 0.5;
  cursor: default;
  background: transparent;
  border-color: var(--border);
  color: var(--text-muted);
}

.gc-vote-btn-yes {
  color: #22c55e;
  background: rgba(34, 197, 94, 0.10);
  border-color: rgba(34, 197, 94, 0.35);
}
.gc-vote-btn-yes:hover { background: rgba(34, 197, 94, 0.18); border-color: rgba(34, 197, 94, 0.5); color: #22c55e; }
.gc-vote-btn-no {
  color: #ef4444;
  background: rgba(239, 68, 68, 0.10);
  border-color: rgba(239, 68, 68, 0.35);
}
.gc-vote-btn-no:hover { background: rgba(239, 68, 68, 0.18); border-color: rgba(239, 68, 68, 0.5); color: #ef4444; }
.gc-vote-btn-preview {
  color: var(--accent);
  background: rgba(90, 169, 255, 0.10);
  border-color: rgba(90, 169, 255, 0.35);
}
.gc-vote-btn-preview:hover { background: rgba(90, 169, 255, 0.18); border-color: rgba(90, 169, 255, 0.5); color: var(--accent); }
/* #2585: the preview's BUILDING state, as the SAME pill in gray. It used to
   be a bare `.gc-checks-running-badge` span floating in the band, which made
   the card jump the moment the build finished and the real Preview button
   took its place. It composes with .gc-vote-btn, so height, radius and
   padding are the Preview pill's; only the fill and the ink differ, in the
   neutral state tokens .gc-checks-running-badge already speaks in (both have
   a .dark counterpart, so this is light and dark at once).
   It renders as <button disabled>, and .gc-vote-btn:disabled's ghosting is
   deliberately undone here: that treatment is for a control the viewer could
   enable by doing something, and nothing anyone clicks makes a staging build
   finish sooner. A half-transparent pill would read as broken rather than as
   busy. The higher specificity is what beats both :disabled and
   .gc-vote-btn:hover, which still matches a disabled button. */
.gc-vote-btn.gc-vote-btn-building,
.gc-vote-btn.gc-vote-btn-building:disabled {
  opacity: 1;
  cursor: default;
  color: var(--state-neutral);
  background: var(--state-neutral-bg);
  border-color: transparent;
}
/* The ledger's one action (Re-run checks): the band's accent pill — the
   tinted fill Preview wears on the card — so the row's button reads as the
   same kind of control as the card's, not as a second neutral chip. */
.gc-vote-btn-accent { background: var(--accent-tint); border-color: transparent; color: var(--accent); font-weight: 600; }
.gc-vote-btn-accent:hover { background: var(--accent-tint); border-color: var(--accent-light); color: var(--accent); }
/* Admin force-merge / force-undo buttons. Amber instead of red so
 * they don't visually dominate the Yes/No pair, but distinct enough
 * from the regular vote buttons that an admin clicking through the
 * row can tell at a glance which one bypasses voting. The
 * ConfirmModal in castAdminMerge/castAdminUndo is the actual
 * misclick guard; the colour is just the affordance hint. */
.gc-vote-btn-admin {
  color: var(--state-admin);
  background: var(--state-admin-bg);
  border-color: var(--state-admin);
  /* Distinguished by SHAPE as well as hue: a dashed outline, so an
     admin bypass never reads as just-another-amber-warning. */
  border-style: dashed;
  border-width: 1.5px;
}
.gc-vote-btn-admin:hover { background: rgba(245, 158, 11, 0.2); border-color: var(--state-admin); color: var(--state-admin); }
/* The viewer's cast vote: stronger tint + bolder text (color comes from
 * the -yes/-no variant it sits on). */
/* #1688: the kudos slot spelled out on a fresh proposal — "Thank evan for
   putting this up" — accent-tinted so it reads as the invitation it is,
   until the viewer votes or thanks and it becomes the count pill again. */
.gc-vote-btn.dev-thanks-pill {
  gap: 5px;
  padding: 0 11px;
  background: var(--accent-tint);
  border-color: color-mix(in srgb, var(--accent) 25%, transparent);
  color: var(--accent);
  font-weight: 600;
}
.gc-vote-btn.dev-thanks-pill:hover { background: var(--accent-wash); color: var(--accent-wash-ink); }
.gc-vote-active { background: rgba(127, 127, 127, 0.18); font-weight: 600; }
.gc-vote-btn-yes.gc-vote-active { background: rgba(34, 197, 94, 0.22); }
.gc-vote-btn-no.gc-vote-active { background: rgba(239, 68, 68, 0.22); }
/* Read-only "You voted X" box shown in place of the buttons once the viewer
   has cast a vote. Tinted by their choice (green = yes, red = no). */
.gc-vote-voted-box {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
  /* Theme-following. This rule used to hard-code an opaque white with
     no `.dark` counterpart anywhere in the file, so on a dark card it
     painted a white slab. */
  background: var(--bg-primary);
  border: 1.5px solid currentColor;
}
.gc-vote-voted-box-yes { color: var(--state-ok); }
.gc-vote-voted-box-no { color: var(--state-blocked); }

/* #404: a card's action row. Every issue / proposal / governance / merged
 * card routes its buttons through this one container so they all line up the
 * same way — buttons stay inline at their natural width, with a single uniform
 * gap both between buttons and between wrapped rows (so a busy card wraps onto
 * an evenly-spaced second row instead of a scattered jumble). Pure layout; the
 * per-button .gc-vote-btn* styles are unchanged. */
.gc-card-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
}
/* ── The dense (board) action band: exactly one row ────────
   Selected as "the action row that follows a dense status band", because
   .dev-card-status is emitted only by the dense branch of _cardContentHtml
   and the action band is always its immediate next sibling. The detail
   view's own .gc-card-actions list is a different surface — it is allowed
   to wrap to as many rows as it needs and must NOT be capped here.

   Same reserve-and-clip contract as the status band: 24px is .gc-vote-btn's
   fixed height, the buttons may wrap, and the surplus row is hidden whole
   rather than showing a strip of half-height pills. The band holds its row
   open even with no buttons in it (the settled close-issue row has none),
   which is what keeps a column of cards the same height. */
:is(.dev-card-status, .dev-card-facts) + .gc-card-actions {
  min-height: 24px;
  max-height: 24px;
  overflow: hidden;
}
/* The kudos pill arrives wrapped in Kudos.renderButton's positioning span
   (it anchors the absolute .kudos-popover), and that span is an inline-block:
   its height is a LINE box, so it measured 25.5px around a 24px button and
   the extra 1.5px of font descender pushed the button down inside it. In a
   24px band with zero slack that clipped the button's bottom border — the one
   half-cut pill on the whole board. inline-flex makes the wrapper hug the
   button instead of a line box; the popover is absolute, so it stays out of
   the flex sizing. Scoped to the dense band: the detail view's kudos button
   sits in an uncapped row where the phantom 1.5px is harmless. */
:is(.dev-card-status, .dev-card-facts) + .gc-card-actions .kudos-wrap {
  display: inline-flex;
  align-items: center;
}
/* Square icon variant of the action pill — the icon-only Preview
   affordance and the ⋯ overflow trigger. Same 11px pill family and the
   same hover behaviour as .gc-vote-btn (which it composes with), but a
   fixed 24px box so the glyph is centred and the two never drift in
   height from the text pills beside them. */
.gc-vote-btn-icon {
  width: 24px;
  padding: 0;
}
.gc-vote-btn-icon svg { width: 14px; height: 14px; }
/* Non-interactive preview states (building / unavailable) render as a
   <span>, so they get the pill's geometry with no pointer affordance. */
span.gc-vote-btn-icon { cursor: default; }

/* ── The card head: type icon + title, and nothing else ────────────────
   THE indent lives here, and only here. The title is the one thing that
   belongs beside the icon; everything else (.dev-card-meta,
   .dev-card-badges, .gc-card-actions, the issue card's
   claim list) is a sibling of the head, not of the icon, so it starts at
   the card's padding edge and gets the card's full width. The gap is
   12px — the same gap-3 the icon had as a card-level sibling — so the
   title sits exactly where it always did.

   `center`, not `flex-start`: the head is the title's row now, so the
   36px icon centres against the title's one or two lines. It only ever
   floated halfway down the card back when it was a sibling of the whole
   content column and centred against the pill and action rows too — the
   fix for that was moving it in here, not top-aligning it, and now that
   the meta line has moved out there is nothing left below the title for
   `center` to drift toward.

   The menu trigger is not in here either: it is the hamburger at the right
   edge of the action band, with Preview after it — the band is where the
   card's controls are, and the menu is where the pills that do not fit the
   band go. */
.dev-card-head {
  display: flex;
  align-items: center;
  gap: 12px;
}
/* The title's cell. flex-basis:auto + min-width:0 is what lets a long
   title wrap onto extra lines instead of being cut off — it is never
   truncated, unlike the meta line below it. */
.dev-card-head-main {
  flex: 1 1 auto;
  min-width: 0;
}
/* The card's right edge is the tap-through chevron alone now. The column
   that held the ⋯ up top and the preview eye at the bottom (.dev-card-rail)
   is gone with them: both live in the action band. */
.dev-card-menu-btn {
  flex: none;
  color: var(--text-faint);
  /* No border: it is the band's "more", not one of the action pills. */
  border-color: transparent;
}
.dev-card-menu-btn:hover { border-color: var(--accent-light); }
.dev-card-menu-btn svg { width: 16px; height: 16px; }

/* The card's title and meta lines. Extracted from six copies of the same
   Tailwind class list so the type scale is edited in one place. */
.dev-card-title {
  font-size: 13.5px;
  line-height: 1.35;
  color: var(--text-primary);
  overflow-wrap: break-word;
}
/* ── The title wraps in full ──
   It was clamped at two lines and reserved both, so a column of open cards
   kept one rhythm. A column holds one open card among folded rows now, and
   the row's title has always wrapped in full; the card's does the same, so
   the two sizes of one item never disagree about what it is called. */
/* ── The meta line, full width ─────────────────────────────
   "PR#123 · author · 2h ago" — the title's subtitle, and a band of its own
   below the head rather than a second line inside the icon's indent. It
   truncates rather than wrapping (single-line ellipsis, unlike the title),
   so the indent was costing it words it could otherwise show: in a kanban
   column the 36px chip plus its 12px gap is a fifth of the row. No
   margin-top — it reads as the title's second line, which is what it was
   when the two shared a cell.

   The min-height reserves the line even when a card has no subtitle at all:
   an empty div is 0px tall, and one card in a column silently skipping this
   band is exactly the misalignment the reserved bands exist to prevent.
   1.4 x 11.5px = 16.1px.

   max-height pins the other end, the same min===max contract the status and
   action bands use. A subtitle carrying the `#123` link is a mixed-font line
   — the mono anchor's ascent/descent differ from the surrounding sans strut,
   and a line box is the union of both, so those metas measured 17.09px while
   plain-text ones measured 16.09. One pixel, but it moved the status and
   action bands of every linked card down relative to its neighbours, which is
   the entire thing this band exists to prevent. Measured in the browser: the
   tallest glyph box in the mixed line spans y=2..15, so the clip only ever
   removes empty descender leading. */
.dev-card-meta {
  font-size: 11.5px;
  line-height: 1.4;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-height: 16.1px;
  max-height: 16.1px;
}

/* ── Card overflow (⋯) menu ──────────────────────────────
   Body-mounted + position:fixed, copying .attr-popover: the kanban
   columns scroll horizontally (#dev-kanban has overflow-x:auto), so a
   menu nested inside a card would be clipped by its own column. */
.dev-card-menu {
  position: fixed;
  z-index: 2000;
  min-width: 208px;
  max-width: 280px;
  max-height: 340px;
  overflow-y: auto;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
  padding: 4px;
}
.dev-card-menu-item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  text-align: left;
  padding: 7px 10px;
  border: 0;
  border-radius: 8px;
  background: none;
  font: inherit;
  font-size: 12.5px;
  color: var(--text-primary);
  cursor: pointer;
}
/* The leading icon column. FIXED width rather than intrinsic: the
   vocabulary mixes one-em emoji with narrower text glyphs (✕, ↗, @), and
   an intrinsic column would step every label a pixel or two left or right
   of its neighbours — the exact scanning cost the icons were added to
   remove. Decorative, so it carries aria-hidden in the markup. */
.dev-card-menu-icon {
  flex: none;
  width: 16px;
  text-align: center;
  font-size: 12px;
  line-height: 1;
  /* Emoji ignore `color`, but the text glyphs don't — holding them a shade
     back from the label keeps the icon a signpost rather than a shout,
     while a danger row's red still reads straight through. */
  opacity: 0.75;
}
.dev-card-menu-label {
  min-width: 0;
  flex: 1 1 auto;
}
.dev-card-menu-item:hover:not(:disabled) { background: var(--accent-tint); color: var(--accent); }
.dev-card-menu-item:disabled { opacity: 0.45; cursor: default; }
.dev-card-menu-item-danger { color: var(--state-blocked); }
.dev-card-menu-item-danger:hover:not(:disabled) { background: var(--state-blocked-bg); color: var(--state-blocked); }

/* ── In-progress column section dividers ─────────────────
   Replaces the two full grey sentences that used to introduce the
   session groups. A hairline rule with a small uppercase label; the
   longer explanation moved to the label's tooltip. */
.dev-col-divider {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 2px 0;
}
.dev-col-divider-label {
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--text-faint);
  white-space: nowrap;
  cursor: help;
}
.dev-col-divider::after {
  content: '';
  flex: 1 1 auto;
  height: 1px;
  background: var(--border-light);
}

/* A private (not-yet-visible) dev session — the muted/draft treatment
   that replaces the "Only you can see your active sessions." caption as
   the signal that nobody else can see this card. */
.dev-card-muted {
  /* A PRIVATE dev session — nobody else can see it, and that has to read off
     the card itself. It used to say so by switching the shell's hairline to
     dashed; the widget language's card has no hairline to switch, so the
     dashed ring becomes an OUTLINE. Outlines draw outside the box and take no
     layout space, so the muted card still lines up with its neighbours to the
     pixel — which a border re-added here would not. */
  outline: 1px dashed var(--border);
  outline-offset: -1px;
  opacity: 0.82;
}

/* ── The detail view's actions & state block ──────────────
   Cards are pointers now, so every action a card demoted needs somewhere
   to live: this block, between the topic head's card and its body. It is
   also where a blocked proposal's reasons are enumerated in full rather
   than inferred from a row of badges. */
.dev-detail-actions {
  margin-top: 8px;
  padding: 0 4px;
}

.dev-detail-reasons {
  margin-top: 8px;
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
  background: var(--bg-card);
}
.dev-detail-reasons-head {
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--text-muted);
  margin-bottom: 5px;
}
.dev-detail-reasons-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 5px;
  font-size: 12px;
  line-height: 1.45;
  color: var(--text-secondary);
}
/* The label leads each row in the same tone the pill used, so the reason
   the pill named is findable at a glance in the list. */
.dev-detail-reason-label { font-weight: 600; }
.dev-detail-reason-hard .dev-detail-reason-label { color: var(--state-blocked); }
.dev-detail-reason-soft .dev-detail-reason-label { color: var(--state-attention); }

/* Inline vote buttons rendered on a "promoted / voted" group-chat
 * activity row. Sits right after the row text; wraps onto its own line
 * on narrow screens. Empty (no buttons) once the PR leaves the votable
 * set, in which case it collapses to nothing. */
.gc-vote-inline {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  margin-left: 8px;
  vertical-align: middle;
}
.gc-vote-inline:empty { display: none; }
/* #3288: the same card under a message bubble (the Homeroom bot's proposal
   post), on its own line below the text rather than trailing it. */
.gc-vote-inline.gc-vote-inline-block {
  display: flex;
  margin-left: 0;
  margin-top: 6px;
}
.gc-vote-inline.gc-vote-inline-block:empty { display: none; }
/* ── The status band, full width ───────────────────────────
   The state bar, the 💬 count, the Closes-#N pills and the metadata chips
   (priority / assignee / category). These used to ride on the title's own
   line, which put them inside the icon's indent AND made them compete with
   the title for width — in a kanban column two chips already forced a wrap.
   On their own row below the head they start at the card's padding edge like
   every other band, and the same four chips fit on one line. The gaps are
   the ones that row carried as Tailwind utilities (gap-x-2 / gap-y-1), so
   chip spacing is unchanged. */
.dev-card-badges {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px 8px;
  margin-top: 5px;
}
/* ── The dense (board) status band: exactly one row ────────
   Reserved, and clipped rather than shrunk. The band is one row tall, its
   children may wrap, and the overflow is hidden — so a card with more pills
   than fit loses the surplus WHOLE (they wrap onto a second row that is
   simply not shown) instead of showing a strip of half-height pills. That is
   why the height is fixed rather than a max: an empty band still holds the
   row open, which is what keeps the action band below it at the same y on
   every card in a column.

   22px is the state bar's height (.dev-status-pill-block, the tallest thing
   in the band — .dev-badge chips are 20px and centre inside it), so a full
   row exactly fills the band with no dead space.

   Not applied to the detail head, which has no neighbour to align with and
   must show every chip. */
.dev-card-badges.dev-card-status {
  min-height: 22px;
  max-height: 22px;
  overflow: hidden;
}
/* ── …except when it holds nothing at all (#1139) ───────────
   The reserve above buys inter-card alignment with 27px (22px of row + the
   5px margin) of blank band on every card that has no state, no linkage, no
   metadata chip and no messages. On most apps that is EVERY issue card — the
   metadata chips need someone to have voted, and only a third of apps have
   any issue-thread messages at all — so the alignment it bought was between
   uniformly empty rows, and the blank band was the only thing anyone saw.

   The node is RETAINED and hidden rather than omitted from the markup: the
   action band's own one-row cap is written as `.dev-card-status +
   .gc-card-actions` (above), and `display: none` leaves sibling adjacency
   intact where removing the element would silently uncap the action row on
   exactly these cards. Several dapp.json checks walk the same four-band
   chain for the same reason.

   _cardContentHtml stamps the flag from its render inputs, not from the
   emitted HTML — the 💬 badge is present-but-`hidden` at count 0, so neither
   a string test nor `:empty` would ever fire here. bumpThreadBadge clears the
   flag when it reveals that badge in place. */
.dev-card-badges.dev-card-status[data-empty="1"] {
  display: none;
}
/* The two merge-lifecycle text badges predate .dev-badge and size themselves
   from their own line-height, so inside a clipped band they were the one
   child that could shrink, wrap its label to a second line and get cut
   through the middle of the text. Given the chip box here (and ONLY here —
   they are also used as an icon-sized preview state in the action row, and
   in the feed and home strips, all of which own their own geometry). */
.dev-card-status > .gc-merging-badge,
.dev-card-status > .gc-checks-running-badge {
  box-sizing: border-box;
  height: 20px;
  line-height: 1;
  flex-shrink: 0;
}
/* Live "yes / majority" tally shown ahead of the inline buttons, in a
   little boxed pill to match the buttons / "You voted X" box. */
/* ── The composite status pill, as a bar ───────────────────
   On a card the pill is a BAR rather than a capsule: a proportional tally
   reads as a progress bar, where wedged between chips at thumbnail width it
   read as noise. The capsule form (.gc-vote-count without -block) is kept
   for the detail head and for the group-chat / home surfaces that still call
   voteCountPill.

   It leads the status band and FLEXES instead of taking the whole width, so
   the chips that used to sit on a row of their own can share that row: it
   grows into whatever the chips leave, down to a 7rem floor that still fits
   a tally and its fill. It had a row to itself until the card had four bands
   to reserve — at which point "state" and "metadata" as separate rows was
   one band more than the layout could afford, and the pill had never needed
   the entire width, only more of it than a chip. */
.dev-status-pill-block {
  display: flex;
  align-items: center;
  flex: 1 1 auto;
  min-width: 7rem;
  max-width: 100%;
  height: 22px;
  padding: 0 9px;
  border-radius: 7px;
}
/* A tier with no proportional fill (settled, in-flight, blocked) would read
   as an empty outlined bar at full width, so it takes a faint wash of its
   own tone instead. The tally tiers keep the plain surface — their fill IS
   the background, and washing it too would flatten the contrast that makes
   the bar readable. */
.dev-status-pill-block.gc-vote-count-neutral   { background: var(--state-neutral-bg); }
.dev-status-pill-block.gc-vote-count-progress  { background: var(--state-progress-bg); }
.dev-status-pill-block.gc-vote-count-attention { background: var(--state-attention-bg); }
.dev-status-pill-block.gc-vote-count-blocked   { background: var(--state-blocked-bg); }
.dev-status-pill-block.gc-vote-count-ok        { background: var(--state-ok-bg); }
/* Left-aligned in the block form so the label starts at a fixed point and
   the fill sweeps out from under it, rather than the text shifting as the
   tally changes. */
.dev-status-pill-block .gc-vote-count-label {
  display: flex;
  align-items: center;
  width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.gc-vote-count {
  position: relative;
  display: inline-block;
  overflow: hidden;
  padding: 2px 9px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: 600;
  /* Theme-following. This rule used to hard-code an opaque white with
     no `.dark` override, so the tally pill sat as a white box on an
     otherwise dark card. */
  background: var(--bg-primary);
  border: 1.5px solid currentColor;
  white-space: nowrap;
}
/* Outline color by tally state, via the semantic tokens: progress =
   not enough votes yet, ok = Yes reached majority, blocked = No did. */
.gc-vote-count-pending { color: var(--state-progress); }
.gc-vote-count-yes { color: var(--state-ok); }
.gc-vote-count-no { color: var(--state-blocked); }
/* The composite status pill's non-tally tiers (settled / in flight /
   blocked / contested) reuse this same pill shell, tinted by token. */
.gc-vote-count-neutral { color: var(--state-neutral); }
.gc-vote-count-progress { color: var(--state-progress); }
.gc-vote-count-attention { color: var(--state-attention); }
.gc-vote-count-blocked { color: var(--state-blocked); }
.gc-vote-count-ok { color: var(--state-ok); }
/* Proportional fill bars: Yes grows from the LEFT, No from the RIGHT, both
   the pill's full height, so the gap between them is what is still
   undecided. They used to be half-height lanes stacked one above the other
   (Yes top, No bottom), which reads correctly only when both sides have
   votes — and most of the time only one does, so a lone Yes rendered as a
   bar covering half the pill's height and looked like a fault rather than a
   tally. Facing each other, one side alone is simply a shorter bar.
   The widths are computed so the two can meet but never overlap; see
   voteFillWidths in public/js/app-view.js. */
.gc-vote-fill {
  position: absolute;
  top: 0;
  height: 100%;
  pointer-events: none;
}
.gc-vote-fill-yes { left: 0; background: var(--state-ok-fill); }
.gc-vote-fill-no { right: 0; background: var(--state-blocked-fill); }
/* Finalized: one side hit majority, so the whole pill fills with its color. */
.gc-vote-fill-full { left: 0; top: 0; width: 100%; height: 100%; }
.gc-vote-fill-full-yes { background: var(--state-ok-fill); }
.gc-vote-fill-full-no { background: var(--state-blocked-fill); }
/* text-primary because a vote pill's fill stripes sweep UNDER the label,
   and a toned colour on a filled bar is unreadable. A pill with no fill has
   nothing sweeping under it, and forcing it to text-primary is what made
   "Checks failing · 3" red on the folded row and near-black on the open
   card. Where there is no fill the label inherits the tone. */
.gc-vote-count-label { position: relative; z-index: 1; color: var(--text-primary); }
.dev-status-pill-block:not(:has(> .gc-vote-fill)) .gc-vote-count-label { color: inherit; }
/* The pill absorbed the standalone pulsing "Vote" badge, the advisory
   chip and the explicit-approval chip. Their glyphs ride inside the
   label rather than beside the pill. */
.gc-vote-count-dot {
  position: relative;
  display: inline-flex;
  width: 6px;
  height: 6px;
  margin-right: 4px;
  vertical-align: middle;
}
.gc-vote-count-dot-ping {
  position: absolute;
  inset: 0;
  border-radius: 9999px;
  background: var(--state-progress);
  opacity: 0.75;
  animation: ping 1s cubic-bezier(0, 0, 0.2, 1) infinite;
}
/* A vote can stay open for days, and this ring pulses for as long as it does.
   Under reduced motion it rests where it starts, on the dot, as the
   `motion-safe:animate-ping` rings in Messages do. */
@media (prefers-reduced-motion: reduce) {
  .gc-vote-count-dot-ping { animation: none; }
}
.gc-vote-count-dot-core {
  position: relative;
  border-radius: 9999px;
  width: 6px;
  height: 6px;
  background: var(--state-progress);
}
.gc-vote-count-suffix { opacity: 0.65; font-weight: 500; }
.gc-vote-count-lock { margin-left: 3px; opacity: 0.8; }
@keyframes ping {
  75%, 100% { transform: scale(2); opacity: 0; }
}
/* #695: advisory (non-approver) vote chip on invited-approver apps — a
   muted companion rendered AFTER the tally pill, so the headline count
   stays approver-only and the surplus is visibly second-class. */
.gc-vote-advisory {
  display: inline-block;
  margin-left: 4px;
  padding: 2px 7px;
  border-radius: 999px;
  font-size: 10px;
  font-weight: 500;
  color: var(--state-neutral);
  background: var(--state-neutral-bg);
  white-space: nowrap;
  vertical-align: middle;
}
/* #788: "Explicit approval" chip — this proposal changes who can
   administer the app, so no timer can merge it. Amber (informational,
   like the imported-PR badge) rather than red: nothing is wrong, the
   rules are just stricter. Sits beside the tally pill, which still
   shows the app's ordinary threshold — the threshold hasn't changed,
   only the clocks are off. */
.gc-vote-explicit {
  display: inline-block;
  margin-left: 4px;
  padding: 2px 7px;
  border-radius: 999px;
  font-size: 10px;
  font-weight: 600;
  color: var(--state-attention);
  background: var(--state-attention-bg);
  white-space: nowrap;
  vertical-align: middle;
}
/* "Merging…" badge shown next to the vote controls while a PR is merging.
   #405: recoloured emerald → amber so every in-flight "working toward a
   merge" stage (Merging…, Resolving conflicts…) reads amber-with-spinner,
   and green is reserved for done/passing. Shared with group-chat.js rows. */
.gc-merging-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  /* #405 recoloured this emerald -> amber; the semantic-token pass moves
     it to `progress` (violet): "working toward a merge" is something
     happening, not a warning. Amber now means non-blocking attention
     only. */
  color: var(--state-progress);
  white-space: nowrap;
}
/* #405: "Checks running…" — not-yet-started check reads grey/neutral, not
   amber, so it's visibly distinct from the amber in-flight merge stages. */
.gc-checks-running-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--state-neutral);
  white-space: nowrap;
}
/* #405: canonical merge-lifecycle badge/pill (see public/js/merge-status.js).
   `.ms-badge` is the text-style chip used in the feed card + home strip;
   `.ms-pill` is the filled pill used in the dev session header. Tone classes
   carry the colour and keep the four surfaces consistent. */
.ms-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
}
.ms-badge-neutral { color: var(--state-neutral); }
.ms-badge-violet  { color: var(--state-progress); }
.ms-badge-amber   { color: var(--state-attention); }
.ms-badge-green   { color: var(--state-ok); }
.ms-badge-red     { color: var(--state-blocked); }
.ms-pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  white-space: nowrap;
  padding: 2px 8px;
  border-radius: 9999px;
  border: 1px solid transparent;
}
.ms-pill-neutral { color: var(--state-neutral); background: var(--state-neutral-bg); }
/* #695: muted "+N" advisory suffix inside the in-vote pill (dev session
   header) — non-approver votes recorded beside the approver-only tally. */
.ms-advisory { opacity: 0.65; font-weight: 500; }
.ms-pill-violet  { color: var(--state-progress); background: var(--state-progress-bg); }
.ms-pill-amber   { color: var(--state-attention); background: var(--state-attention-bg); }
.ms-pill-green   { color: var(--state-ok); background: var(--state-ok-bg); }
.ms-pill-red     { color: var(--state-blocked); background: var(--state-blocked-bg); }
/* #361: persistent merge-status badges. Mirror .gc-merging-badge's slot
   treatment. Conflict (and conflict-resolution-failed) reads red; behind
   reads amber-neutral so a merely-stale proposal doesn't alarm. */
.gc-conflict-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--state-blocked);
  white-space: nowrap;
}
/* #381: advisory "may break the app" / console-error warning. Same slot
   treatment as the merge-status badges; amber (like behind) rather than
   red (conflict) because it never blocks the vote — it's a heads-up. */
.gc-warning-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--state-attention);
  white-space: nowrap;
}
/* Checks FAILING blocks the merge, so it is `blocked`, not `attention`.
   It used to share .gc-warning-badge's amber, reading like an advisory
   heads-up when it is in fact a hard gate. */
.gc-blocked-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--state-blocked);
  white-space: nowrap;
}
/* Settled "✓ Merged" badge — same slot as the merging badge, shown after a
   PR lands so the row's voting info stays put. */
.gc-merged-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--state-ok);
  white-space: nowrap;
}
/* "✓ Checks passing" — its own class rather than borrowing
   .gc-merged-badge, which is what made the passing badge inherit the
   violet Merged colour. Both are `ok` now, but the classes stay
   separate so a future tone change to one can't silently move the
   other. */
.gc-checks-passing-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--state-ok);
  white-space: nowrap;
}
/* Row text tint by the viewer's vote status: faded once they've voted,
   full-strength purple while their vote is still outstanding so unvoted
   activity rows draw the eye. */
.gc-msg-vote.gc-vote-voted .gc-msg-system-text { color: var(--accent-light); }
.gc-msg-vote.gc-vote-unvoted .gc-msg-system-text { color: var(--accent); font-weight: 600; }

/* ── Developer console ─────────────────────────────────── */

#dev-console-panel {
  font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
  box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
}

.dc-log-entry {
  display: grid;
  grid-template-columns: max-content max-content 1fr;
  gap: 6px;
  padding: 2px 4px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.03);
  white-space: pre-wrap;
  word-break: break-word;
}

.dc-log-time {
  color: #6e6e8a;
  font-size: 10px;
  align-self: center;
}

.dc-log-level {
  font-size: 9px;
  font-weight: 700;
  letter-spacing: 0.04em;
  padding: 1px 5px;
  border-radius: 3px;
  align-self: center;
  text-align: center;
  min-width: 44px;
}

.dc-log-msg { color: #e4e4e7; }

.dc-log-log   .dc-log-level { background: #27272a; color: #8e8e93; }
.dc-log-info  .dc-log-level { background: #1e3a5f; color: #93c5fd; }
.dc-log-debug .dc-log-level { background: #27272a; color: #6e6e8a; }
.dc-log-warn  .dc-log-level { background: #78350f; color: #fde68a; }
.dc-log-warn  .dc-log-msg   { color: #fcd34d; }
.dc-log-error .dc-log-level { background: #7f1d1d; color: #fecaca; }
.dc-log-error .dc-log-msg   { color: #fca5a5; }

#dev-console-log::-webkit-scrollbar { width: 8px; }
#dev-console-log::-webkit-scrollbar-track { background: transparent; }
#dev-console-log::-webkit-scrollbar-thumb { background: #2e2e50; border-radius: 4px; }
#dev-console-log { scrollbar-width: thin; scrollbar-color: #2e2e50 transparent; }

/* ── Group chat messages ───────────────────────────────── */

/* The chat pane is a flex item in the gc-tab-body row. Flex items
   default to min-width: auto, which refuses to shrink below their
   content's intrinsic width — so a long word/URL (or the open spec
   side-panel) pushes the column wider than the viewport and the right
   edge gets clipped. min-width: 0 lets it shrink so message text wraps
   to the available width instead. */
.gc-chat-pane { min-width: 0; }

/* Guard: never let the message list scroll/expand horizontally. Per-
   element wrapping (.gc-msg-content, quoted blocks) is the real fix, but
   this clips any future stray wide child so a narrow screen can't end up
   with a horizontal scrollbar. */
#gc-messages { overflow-x: hidden; }

.gc-msg {
  padding: 6px 12px;
  transition: background 150ms;
  min-width: 0;
}

/* Neutral, not the tint tokens. --bg-card and --bg-card-alt are ACCENT and
   emerald washes: with them, hovering any message and every message you sent
   read as a highlighted state rather than as ordinary transcript. The deck's
   group transcript tints neither — a message is identified by its author's
   name and avatar, which is the whole point of a named-row transcript. */
/* ONE STEP OFF THE BUBBLE'S OWN SURFACE, and which step that is depends on
   the mode: `--bg-secondary` IS `--dc-raised` in light mode, so a hovered or
   tapped row used to be painted the bubble's exact colour and the message box
   vanished into the highlight — outer row and inner bubble reading as one
   flat slab (#2464). `--bg-tertiary` is the step the light palette has room
   for; in dark mode it is the bubble's surface instead, so dark keeps the
   secondary ground it already had. The highlight belongs to the row, so it
   has to be a colour the message box is not made of. */
.gc-msg:hover { background: var(--bg-tertiary); }
.dark .gc-msg:hover { background: var(--bg-secondary); }
.gc-msg-self { background: transparent; }

.gc-msg-header {
  display: flex;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 2px;
}

.gc-msg-username {
  /* The author's name is the transcript's primary identifier, so the language
     gives it reading size and full weight in primary ink — not a 13px
     semibold caption a line above the words. */
  font-size: 1.0625rem;
  font-weight: 700;
  color: var(--text-primary);
}

/* Your own name keeps the accent — the one place it still marks "mine" now
   that the row itself is untinted. */
.gc-msg-username-self { color: var(--accent); }

/* Per-message unread dot: a message this user has an unread mention/
   reply/reaction notification for. Sits at the front of the header;
   tapping the message clears it (POST /api/notifications/read with the
   chat_message_id). Removed in place once cleared. */
.gc-unread-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--accent);
  flex: 0 0 auto;
  align-self: center;
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent);
}

.gc-msg-time {
  font-size: 11px;
  color: var(--text-muted);
}

.gc-msg-content {
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-secondary);
  overflow-wrap: anywhere;
  word-break: break-word;
  /* Honor the user's own line breaks in multi-line messages (general chat
     and threads share this class). overflow-wrap/word-break above still
     wrap long unbroken runs. This is the FALLBACK path (renderMessageBody
     -> renderWithMentions): plain escaped text + mention/ref spans, no block
     children, so pre-wrap preserves the user's newlines. The markdown path
     overrides this below via :has(). */
  white-space: pre-wrap;
}

/* #328: markdown-rendered message bodies. When DevChat.renderMarkdown is
   available, renderMessageBody produces block-level children (<p>/<ul>/<pre>
   …) that manage their own spacing and carry the dev-chat `dc-*` classes.
   marked emits literal newlines between those block tags, so leave the
   container on pre-wrap and they'd render as extra blank lines — switch to
   normal whitespace whenever block markdown is present. The :has() guard
   keeps the inline-only fallback path (above) on pre-wrap. */
.gc-msg-content:has(.dc-p, .dc-ul, .dc-ol, .dc-blockquote, .dc-code-block,
                    .dc-table, .dc-h3, .dc-h4, .dc-h5) {
  white-space: normal;
}

/* Tighten the spec-viewer markdown rhythm for the narrower chat bubble: a
   single-paragraph message must not gain vertical padding (overrides the
   global `.dc-p { margin-top: .5em }`), stacked blocks get a compact gap,
   and code blocks / tables can't overflow the bubble width. */
.gc-msg-content .dc-p { margin: 0; }
.gc-msg-content .dc-ul,
.gc-msg-content .dc-ol,
.gc-msg-content .dc-blockquote { margin: 4px 0; }
.gc-msg-content .dc-code-block { margin: 4px 0; max-width: 100%; }
.gc-msg-content .dc-table { margin: 4px 0; }
.gc-msg-content > :first-child { margin-top: 0; }
.gc-msg-content > :last-child { margin-bottom: 0; }
.gc-msg-content
  :is(.dc-p, .dc-ul, .dc-ol, .dc-blockquote, .dc-code-block, .dc-table,
      .dc-h3, .dc-h4, .dc-h5)
  + :is(.dc-p, .dc-ul, .dc-ol, .dc-blockquote, .dc-code-block, .dc-table,
        .dc-h3, .dc-h4, .dc-h5) {
  margin-top: 6px;
}

/* "edited" marker shown next to a message's timestamp once it has been
   edited. Muted like the time; the full edit timestamp is in its title
   tooltip (revealed on hover/tap). */
.gc-msg-edited {
  font-size: 11px;
  color: var(--text-muted);
  font-style: italic;
  cursor: default;
}

/* Auto-growing composer / inline-editor textarea. Height is driven by JS
   (GroupChat._autoGrowTextarea); this just keeps single-row metrics close
   to the old <input> and prevents the manual resize grabber. */
.gc-composer-input {
  line-height: 1.5;
}

/* Inline message editor (replaces .gc-msg-content while editing own msg). */
.gc-edit {
  margin-top: 2px;
}
.gc-edit-textarea {
  display: block;
  width: 100%;
  box-sizing: border-box;
  resize: none;
  overflow-y: auto;
  font-size: 13px;
  border-radius: 8px;
  padding: 6px 8px;
  background: var(--bg-tertiary);
  border: 1px solid var(--border, rgba(127, 127, 127, 0.3));
  color: var(--text-primary, var(--text-secondary));
}
.gc-edit-textarea:focus {
  outline: none;
  border-color: var(--accent);
}
.gc-edit-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 4px;
}
.gc-edit-save,
.gc-edit-cancel {
  font-size: 12px;
  padding: 3px 10px;
  border-radius: 6px;
  cursor: pointer;
  border: 1px solid transparent;
}
.gc-edit-save {
  background: var(--accent);
  color: #fff;
}
.gc-edit-cancel {
  background: var(--bg-tertiary);
  color: var(--text-secondary);
}
.gc-edit-notice {
  font-size: 11px;
  color: var(--danger, #ef4444);
}

.gc-mention {
  color: var(--accent);
  font-weight: 500;
  text-decoration: none;
}
/* #4029: a person's mention is a link to their page, drawn as the channel
   refs are: underlined on hover where there is a mouse. */
@media (hover: hover) {
  a.gc-mention:hover { text-decoration: underline; }
}
.gc-mention-self {
  background: rgba(31, 134, 255, 0.18);
  padding: 0 4px;
  border-radius: 4px;
  color: var(--accent-light, var(--accent));
  font-weight: 600;
}

/* ── #130: PR / issue reference chips ───────────────────── */

/* Inline chips for PR#N / #N tokens in user messages, also reused as the
   number badge in the reference-autocomplete dropdown so the dropdown
   teaches the rendering. Plain spans (role="link") when the chat names no
   project — so iOS long-press text selection still works around them — and
   real anchors (same classes, no role) where it does (#4668); navigation is
   wired in GroupChat._attachQuoteHandlers → GroupChat._openRef. */
.gc-ref {
  display: inline-flex;
  align-items: center;
  padding: 0 5px;
  border-radius: 5px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.92em;
  font-weight: 600;
  cursor: pointer;
  white-space: nowrap;
}
/* Violet — matches the drawer's existing PR#N anchors. */
.gc-ref-pr {
  color: rgb(90, 169, 255);
  background: rgba(31, 134, 255, 0.12);
}
/* Emerald — clearly distinct from the PR violet (amber already signals
   bounties/warnings in the panel). */
.gc-ref-issue {
  color: rgb(52, 211, 153);
  background: rgba(16, 185, 129, 0.12);
}
@media (hover: hover) {
  .gc-ref:hover { filter: brightness(1.15); text-decoration: underline; }
}

/* #2783: a `#name` channel reference. A real link (`#messages/channel/<name>`),
   not a role="link" span, because it navigates somewhere a plain anchor can
   go — and the accent's tint rather than either ref colour, so a channel
   never reads as an issue or a PR. Deliberately NOT `.gc-ref`: the app
   chat's click handler sends every `.gc-ref` to the activity drawer. */
.gc-channel-ref {
  display: inline-flex;
  align-items: center;
  padding: 0 5px;
  border-radius: 5px;
  font-weight: 600;
  white-space: nowrap;
  color: var(--accent);
  background: var(--accent-tint, rgba(31, 134, 255, 0.12));
  text-decoration: none;
}
@media (hover: hover) {
  .gc-channel-ref:hover { text-decoration: underline; }
}

.gc-msg-system {
  padding: 4px 12px;
  font-size: 11px;
  text-align: center;
  color: var(--text-muted);
  overflow-wrap: anywhere;
  word-break: break-word;
}

.gc-msg-vote { color: var(--accent); }

/* Tap-to-quote affordance: system/vote lines and PR titles get a pointer
   cursor so it reads as interactive (chat bubbles keep the text cursor so
   drag-to-select still feels natural). */
.gc-msg-system { cursor: pointer; }

/* ── The general chat's proposal events ────────────────────────
   A proposal put up for a vote, and a proposal merged, are the two notices
   the general chat draws (features/group-chat/proposal-event.tsx). Each is a
   MESSAGE from whoever did it — the transcript's named row, avatar, name and
   stamp where a person's message puts them — with one box under the header
   in the language of the Workshop's Current status panes: the frosted sheet
   fill and hairline of `.dev-ws-strip`, not a white card with a shadow,
   holding one glyph and one line of simple text. The glyph is the Dev
   board's for the proposal, bare — the general discussion row
   (`.dev-ws-chat-row`) drops the tinted plate the same way, because on a row
   this size the plate is the largest thing in the line. The chevron is the
   door's; a box whose proposal is not known yet is a div and has none. */
.gc-event-box {
  display: flex; align-items: center; gap: 9px;
  max-width: min(100%, 640px);
  margin-top: 4px;
  padding: 9px 12px;
  border-radius: 16px;
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
  font-size: 13px; line-height: 1.4; color: var(--text-secondary);
  text-decoration: none;
}
a.gc-event-box { cursor: pointer; }

/* #2884: a conversation's folded run of shared cards — "… 4 more" under the
   first card, and a tap draws the rest in place (`.messages-card-run`). The
   pill is the Messages sheet's "Load earlier messages" control, at 13px. An
   app's channel no longer folds: it has no proposal cards to fold. */
.messages-card-run-more {
  display: inline-flex;
  align-items: center;
  height: 28px;
  padding: 0 12px;
  border-radius: 9999px;
  color: var(--text-secondary);
  background: var(--bg-tertiary);
  font-size: 13px;
  font-weight: 600;
  transition: background 150ms, color 150ms;
}
.messages-card-run-more:hover { color: var(--text-primary); background: var(--messages-surface-2, var(--bg-tertiary)); }
.messages-card-run-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
a.gc-event-box:hover { text-decoration: none; }
a.gc-event-box:hover > .gc-event-text { color: var(--text-primary); }
/* #1688: after a named merge's sentence, the number and the tally, quieter. */
.gc-event-text > .gc-event-tail { color: var(--text-muted); font-weight: 400; }
/* #1688: the Friday card — the event box's surface, holding lines. */
.gc-event-box.gc-event-weekly {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 8px;
  padding: 12px 14px;
  max-width: min(100%, 640px);
}
.gc-weekly-title { font-size: 13.5px; font-weight: 600; color: var(--text-primary); }
.gc-weekly-section { display: flex; flex-direction: column; gap: 3px; }
.gc-weekly-head { font-weight: 600; }
.gc-weekly-head-live { color: var(--state-ok); }
.gc-weekly-head-open { color: var(--accent); }
.gc-weekly-line { padding-left: 12px; line-height: 1.45; }
.gc-weekly-line-title { color: var(--text-primary); }
.gc-weekly-line-who { color: var(--text-muted); }
.gc-weekly-more { padding-left: 12px; color: var(--text-muted); font-size: 12.5px; }
.gc-weekly-door { align-self: flex-start; font-size: 12.5px; font-weight: 600; color: var(--accent); text-decoration: none; }
.gc-weekly-door:hover { text-decoration: underline; }
a.gc-event-box:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.gc-event-box > .dev-card-icon {
  flex: none; width: 20px; height: 20px;
  background: transparent; border-radius: 0;
}
.gc-event-box > .dev-card-icon > svg { width: 17px; height: 17px; }
.gc-event-box > .gc-event-text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.gc-event-box > svg { flex: none; }
/* A submission whose vote is still open reads in the text ink rather than
   the muted one: the one thing on the line that still wants the reader. */
.gc-event[data-open] .gc-event-box { color: var(--text-primary); }

/* ── Bubbles in the general chat ─────────────────────────────────
   A person's message in the general chat sits in a bubble — the Messages
   screen's own (`.messages-bubble` above: the same corner, surface and
   padding), because that screen is where the product already draws a
   conversation in bubbles — and the viewer's own message sits on the RIGHT
   (ChatMessageRow from="me": the row runs right to left, no avatar), in the
   accent tint the dev chat gives your turns (.dc-msg-user) and the Messages
   screen gives yours (.messages-message-self). The side says who is
   speaking. Only the general chat: a topic thread keeps its flat named rows
   and tints yours in place (`#dev-topic-thread .gc-msg-self` below). */
.gc-bubble {
  display: block;
  max-width: min(78%, 640px);
  border-radius: 20px;
  padding: 8px 14px;
  /* The raised neutral surface, not the Messages screen's white: that
     screen's bubbles sit on a grey ground, and this transcript sits on the
     frosted sheet, where a white bubble is the sheet's own colour and
     disappears. Same surface the dev chat's PR card and the dark-mode
     Messages bubble already use. */
  background: var(--dc-raised);
  color: var(--text-primary);
}
.gc-bubble > .gc-msg-content { color: var(--text-primary); }
/* Opaque, and the same surface as every other bubble with the accent tint
   composited on top of it as a second background layer — not the tint on its
   own. `--accent-tint` is translucent, so a tint-only bubble rendered
   whatever was painted behind the row, and the row's hover/tap highlight
   therefore arrived INSIDE the message box along with it: the other half of
   the doubled highlight in #2464. Layering keeps the tint tracking
   `--accent-tint` in both themes with no second copy of its alpha, and makes
   the two bubble kinds one surface in two tints, which is what the note above
   already says they are. */
.gc-bubble-self {
  background-color: var(--dc-raised);
  background-image: linear-gradient(var(--accent-tint), var(--accent-tint));
}
/* (#2783) A grouped continuation line's clock, in the avatar's column: shown
   on hover, as Discord does, and always on touch where there is no hover. */
.gc-msg[data-grouped] .gc-msg-gutter-time { visibility: hidden; }
.gc-msg[data-grouped]:hover .gc-msg-gutter-time { visibility: visible; }
@media (hover: none) {
  .gc-msg[data-grouped] .gc-msg-gutter-time { visibility: visible; }
}
/* (#2783) Your own row no longer runs right to left — every row is the same
   named row now — so the hover react disc stays at the top-right for yours
   too, and the override that moved it is gone. */

.gc-quote-pr { cursor: pointer; }
@media (hover: hover) {
  .gc-quote-pr:hover { text-decoration: underline; }
}

/* ── #15: reply / quote ─────────────────────────────────── */

/* Quoted block shown above a message that is itself a reply. The same
   accent border opens the composer's "Replying to …" strip below, so the
   two still read as one thing; the strip itself is sized for the composer
   it sits on (#2391). */
.gc-quoted {
  display: flex;
  align-items: center;
  gap: 8px;
  border-left: 3px solid var(--accent);
  border-radius: 4px;
  background: var(--bg-card);
  max-width: min(560px, 100%);
  margin: 2px 0 4px;
  padding: 3px 8px;
  cursor: pointer;
}
.gc-quoted:hover { filter: brightness(1.08); }

/* #2391: the staged-reply strip is the Messages screen's reply draft
   (.messages-reply-draft): full composer width, the accent tint, an open
   rounded edge, and text at the composer's reading size. It was the sent
   quote's 11/12px chip, capped at 560px with 4px corners, under a 22px-radius
   composer card. */
.gc-reply-preview-inner {
  display: flex;
  align-items: center;
  gap: 8px;
  border-left: 3px solid var(--accent);
  border-radius: 0 12px 12px 0;
  background: var(--accent-tint);
  padding: 6px 8px 6px 10px;
}

.gc-quoted-author,
.gc-reply-preview-label {
  font-size: 11px;
  font-weight: 600;
  color: var(--accent);
  white-space: nowrap;
  flex-shrink: 0;
  /* Cap + clip so a long author/label can't push the quote block wider
     than its container (which would force horizontal scroll). */
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gc-reply-preview-label { font-size: 13px; font-weight: 700; }
.gc-quoted-snippet,
.gc-reply-preview-snippet {
  font-size: 12px;
  color: var(--text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
  /* Inside .gc-quoted (a flex column with align-items: flex-start) the
     snippet otherwise sizes to its full nowrap content width and the
     ellipsis never engages — capping at 100% bounds it to the container
     so it truncates instead of overflowing. */
  max-width: 100%;
}
.gc-quoted { flex-direction: column; align-items: flex-start; gap: 0; }
.gc-reply-preview-snippet { font-size: 15px; }

#gc-reply-preview { margin-bottom: 6px; }
.gc-reply-preview-body { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
/* #1927: 44px tap target, overhanging the strip's 6px/8px padding so the
   strip keeps its height. */
.gc-reply-preview-x {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: -6px -8px -6px 0;
  background: none;
  border: none;
  border-radius: 9999px;
  cursor: pointer;
  color: var(--text-muted);
  font-size: 20px;
  line-height: 1;
  padding: 0;
  flex-shrink: 0;
}
.gc-reply-preview-x:hover { color: var(--text-primary); }

/* Brief highlight when you jump to a quoted message's original. */
.gc-msg-flash { animation: gcMsgFlash 1.5s ease-out; }
@keyframes gcMsgFlash {
  0% { background: color-mix(in srgb, var(--accent) 30%, transparent); }
  100% { background: transparent; }
}

/* #130: same brief highlight on a drawer row revealed by a reference chip
   (AppView.revealInDrawer). */
.gc-vote-item-flash { animation: gcMsgFlash 1.5s ease-out; }

/* ── #25: emoji reactions ───────────────────────────────── */

/* Rows need positioning context for the hover react button. */
.gc-msg, .gc-msg-system, .gc-spec-card { position: relative; }

/* Long-press opens the reaction bar (see _attachQuoteHandlers). Suppress
   the iOS long-press callout/magnifier so a stationary hold reacts instead
   of triggering the system text-selection menu. Native text selection
   itself is toggled in JS — re-enabled the instant a drag is detected — so
   drag-to-select still works. */
.gc-msg, .gc-msg-system, .gc-spec-card { -webkit-touch-callout: none; }

/* Hover affordance to open the reaction bar (desktop only — touch uses
   long-press). Hidden until the row is hovered. */
.gc-react-add {
  position: absolute;
  top: 2px;
  right: 6px;
  width: 22px;
  height: 22px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: var(--bg-tertiary);
  color: var(--text-secondary);
  font-size: 12px;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity 120ms;
  z-index: 2;
}
/* On regular messages the button lives inside the header row, so it sits
   just to the right of the first line (username · time) instead of being
   pinned to the far right edge of the chat. It's always laid out (reserving
   its slot so there's no hover reflow) but invisible until the row is
   hovered. System/spec rows keep the absolute top-right fallback above. */
.gc-msg-header .gc-react-add {
  position: static;
  align-self: center;
  width: 20px;
  height: 20px;
  margin-left: -2px;
}

/* System / vote lines (e.g. "alice voted yes on PR #12") are centered with
   no header, so flow the button inline right after the text instead of the
   absolute top-right fallback. Deliberately NOT setting `display` here so
   the `@media (hover: none)` display:none rule still hides it on touch. */
.gc-msg-system > .gc-react-add {
  position: static;
  vertical-align: middle;
  width: 18px;
  height: 18px;
  margin-left: 4px;
}

@media (hover: hover) {
  .gc-msg:hover .gc-react-add,
  .gc-msg-system:hover .gc-react-add,
  .gc-spec-card:hover .gc-react-add { opacity: 0.85; }
  .gc-react-add:hover { opacity: 1; }
}
@media (hover: none) { .gc-react-add { display: none; } }

/* Edit affordance on own ordinary messages — same hover-reveal model as
   the react button. Lives in the header row next to the timestamp. On touch
   (no hover) it's hidden; the long-press reaction bar carries Edit there. */
.gc-msg-edit {
  align-self: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: var(--bg-tertiary);
  color: var(--text-secondary);
  font-size: 11px;
  line-height: 1;
  cursor: pointer;
  opacity: 0;
  transition: opacity 120ms;
}
@media (hover: hover) {
  .gc-msg:hover .gc-msg-edit { opacity: 0.85; }
  .gc-msg-edit:hover { opacity: 1; }
}
@media (hover: none) { .gc-msg-edit { display: none; } }

/* #1280: save (bookmark) a message. Sits beside the react/edit buttons and
   borrows their shape, with two deliberate differences:

   1. It has a RESTING state. The react and edit buttons are invisible until
      hover because they are pure affordances — nothing about the message
      changes when you're not using them. A save is state: a saved message
      has to look saved without being hovered, so `.gc-msg-saved` is fully
      opaque at all times and the unsaved button keeps a faint resting
      opacity so it can be found at all.
   2. It is NOT hidden on touch. The two buttons above hand their action to
      the long-press reaction bar there; this one has no such fallback, and
      hiding it would leave phones with no way to save anything.

   The mark inside is an SVG (GroupChat._bookmarkSvg), hollow when off and
   solid when on, so the PRIMARY state signal is the shape. That is why the
   chip's background stays constant across both states: a filled violet
   mark on the ordinary chip reads cleanly in both themes, whereas the
   accent-pill treatment an emoji needed would put a light-lavender ground
   under a white mark in dark mode. */
.gc-msg-save {
  align-self: center;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  padding: 0;
  border: none;
  border-radius: 999px;
  background: var(--bg-tertiary);
  color: var(--text-secondary);
  line-height: 1;
  cursor: pointer;
  opacity: 0.3;
  transition: opacity 120ms, color 120ms;
}
.gc-msg-save svg { width: 12px; height: 12px; display: block; }
.gc-msg-save.gc-msg-saved { opacity: 1; color: var(--accent); }
@media (hover: hover) {
  .gc-msg:hover .gc-msg-save,
  .gc-msg-system:hover .gc-msg-save,
  .gc-spec-card:hover .gc-msg-save { opacity: 0.85; }
  .gc-msg-save:hover,
  .gc-msg-save.gc-msg-saved { opacity: 1; }
}

/* System / vote lines have no header row, so the button flows inline after
   the text — same treatment the react button gets there. `inline-flex`
   (rather than the base rule's flex context) keeps it on the text baseline
   while still centring the mark. */
.gc-msg-system > .gc-msg-save,
.gc-spec-card > .gc-msg-save {
  vertical-align: middle;
  width: 18px;
  height: 18px;
  margin-left: 4px;
}
.gc-msg-system > .gc-msg-save svg,
.gc-spec-card > .gc-msg-save svg { width: 11px; height: 11px; }
/* A 32px hit area on a touch screen (QA 2026-09-24 Q19). The mark is 18-20px
   and rides the END of a line, often straight after a vote row's Yes/No pair
   with only its 4px margin between them — so the slop is lopsided rather than
   centred: it reaches back no further than that margin and takes its width
   from the free side after the mark (the react button beside it is hidden on
   touch, and the reactions start a new line). It cannot sit under the row's
   content instead: the resting `opacity` makes the button its own stacking
   context, so a `z-index` on the slop would stay inside it. */
@media (pointer: coarse) {
  .gc-msg-save { position: relative; }
  .gc-msg-save::after {
    content: "";
    position: absolute;
    left: -4px;
    width: calc(100% + 18px);
    top: 50%;
    height: max(100%, 32px);
    transform: translateY(-50%);
  }
}

/* Edit button inside the long-press reaction bar (touch). */
.gc-react-bar-edit {
  font-size: 18px;
  line-height: 1;
  background: none;
  border: none;
  cursor: pointer;
  padding: 4px 6px;
}

/* Reaction pills under a message. */
.gc-reactions {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 3px;
}
.gc-reactions:empty { margin-top: 0; }
.gc-msg-system .gc-reactions { justify-content: center; }

/* The pill itself is @/components/ui/feed's `ReactionPill` — surface, radius,
   hover and the "yours" state (`tone="accent"`) all come from the widget
   language now, so `.gc-react-pill`, `.gc-react-mine`, `.gc-react-emoji` and
   `.gc-react-count` are gone with the string renderer that emitted them.
   `.gc-reactions` stays: it is the row's own flex container. */

/* Floating reaction bar (quick row + curated grid). */
.gc-react-bar {
  position: fixed;
  z-index: 1000;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
  padding: 4px 6px;
}
.gc-react-bar.hidden { display: none; }
.gc-react-bar-quick { display: flex; align-items: center; gap: 2px; }
.gc-react-bar-emoji {
  border: none;
  background: none;
  cursor: pointer;
  font-size: 20px;
  line-height: 1;
  padding: 4px;
  border-radius: 8px;
  transition: transform 80ms ease, background 80ms;
}
.gc-react-bar-emoji:hover { transform: scale(1.25); background: var(--bg-tertiary); }
.gc-react-bar-more {
  margin-left: 2px;
  width: 28px;
  height: 28px;
  border: none;
  border-radius: 999px;
  background: var(--bg-tertiary);
  color: var(--text-secondary);
  font-size: 16px;
  cursor: pointer;
}
.gc-react-bar-grid {
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  gap: 2px;
  max-width: 296px;
  /* The full emoji set is long — cap the height and scroll within the bar
     so opening "more" never pushes the picker off-screen. */
  max-height: 244px;
  overflow-y: auto;
  overscroll-behavior: contain;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--border);
}
.gc-react-bar-grid.hidden { display: none; }

/* ── #87: @mention autocomplete dropdown ───────────────── */

.gc-mention-menu {
  position: fixed;
  z-index: 1000;
  background: var(--bg-secondary);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.28);
  padding: 4px;
  max-height: 240px;       /* ~8 rows, then scroll */
  overflow-y: auto;
  overscroll-behavior: contain;
}
.gc-mention-menu.hidden { display: none; }
/* #2145: the Activity feed's reply box (features/dev-board/card/
   feed-thread.tsx) opens this same menu from its own `@`. Its composer is a
   React island with one field per row, so the list is a sibling of the
   textarea inside the form (position: relative there) rather than the
   body-level element group-chat.js measures — placed above the field, as
   the chat composers' are, and stopping short of the 36px send disc and its
   8px gap on the right. `-below` is the flip for a field with no room above
   it (a short thread in the Needs-you comments sheet, whose body scrolls). */
.gc-mention-menu.dev-feed-mention-menu {
  position: absolute;
  left: 0;
  right: 44px;
  bottom: calc(100% + 4px);
}
.gc-mention-menu.dev-feed-mention-menu-below {
  top: calc(100% + 4px);
  bottom: auto;
}
.gc-mention-option {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 6px 10px;
  border-radius: 8px;
  font-size: 14px;
  color: var(--text-primary, inherit);
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gc-mention-option:hover,
.gc-mention-option-active {
  background: color-mix(in srgb, var(--accent) 16%, transparent);
  color: var(--accent);
}
.gc-mention-option-at { color: var(--accent); font-weight: 600; }
/* B9: Homeroom bot's row: its mark at the line's height, before its name. */
.gc-mention-option-mark {
  width: 18px;
  height: 18px;
  margin-right: 6px;
  border-radius: 5px;
  flex-shrink: 0;
}
.gc-mention-option .messages-bot-badge { margin-left: 6px; }

/* #130: reference-autocomplete rows reuse the mention menu/option styling;
   these only add room for the PR#N / #N badge plus a truncating title. */
.gc-ref-option { gap: 8px; }
.gc-ref-option-title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  font-size: 13px;
  color: var(--text-secondary, inherit);
}
.gc-mention-option-you {
  margin-left: auto;
  font-size: 11px;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* `:th` — the emoji shortcode menu (#gc-emoji-menu). Same box and rows as
   the mention menu; a heading repeats the query, and each row is the glyph
   beside its `:code:`. Tall enough for its eight rows without scrolling. */
.gc-mention-menu.gc-emoji-menu { max-height: min(320px, 50vh); }
.gc-emoji-menu-heading {
  padding: 4px 10px 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-muted);
}
.gc-emoji-menu-query { color: var(--text-primary, inherit); }
.gc-emoji-option { gap: 10px; padding: 5px 10px; }
.gc-emoji-option-glyph {
  flex: none;
  width: 24px;
  font-size: 20px;
  line-height: 24px;
  text-align: center;
}
.gc-emoji-option-code {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── Streaming spinner (bouncing dots) ─────────────────── */

.dc-streaming-dots { display: flex; gap: 4px; padding: 4px 0; }
.dc-streaming-dots span {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--text-muted);
  animation: dc-dot-bounce 1.4s ease-in-out infinite;
}
.dc-streaming-dots span:nth-child(2) { animation-delay: 0.16s; }
.dc-streaming-dots span:nth-child(3) { animation-delay: 0.32s; }

/* #990: the trailing activity indicator is the dots on a .dc-status-line row,
   so it sits flush under the step ladder it belongs to instead of indented
   like the retired standalone spinner. Only the doubled vertical padding
   (2px from the row + 4px from the dots) needs trimming.

   #1590: the TOP of that trimmed padding has to clear the bounce. A dot is
   6px and dc-dot-bounce lifts it 6px, so 2px of headroom left it rising 4px
   out of its own row and into the ladder line above — the overlap that was
   reported. 6px is exactly the travel, so the dot lands flush with the row's
   top edge at the peak and nothing crosses it. */
.dc-activity-line { padding: 0 12px 4px; }
.dc-activity-line .dc-streaming-dots { padding: 6px 0 2px; }
.dc-activity-label { font-size: 11px; color: var(--text-muted); }

@keyframes dc-dot-bounce {
  0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
  40% { transform: translateY(-6px); opacity: 1; }
}

/* ── The dev composer's one circle ──────────────────────── */

/* Streamlined Concept. This was a 64x38 rectangle carrying the word "Send",
   with a separate save icon beside it and a shortcut-hint line under the
   pair. It is one 44px circle now, and which action it performs follows the
   turn — see SendButtonView in features/dev-chat/composer-store.ts.

   The SIZE is the point of keeping a fixed box: the label swaps between an
   arrow, a floppy, a square and a spinner, and the row must not reflow under
   it. 44px is also the platform's tap target, which the old 38px height was
   under. */
.dc-send-btn {
  width: 44px;
  height: 44px;
  min-width: 0;
  padding: 0;
  border-radius: 9999px;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
}

/* Idle. A pale fill with a wider, fainter ring of the same hue — the halo is
   what makes a low-contrast circle read as the row's one primary action
   without shouting like a filled accent button would. */
.dc-circle-send {
  background: var(--accent-wash) !important;
  color: var(--accent-wash-ink);
  box-shadow: 0 0 0 6px rgba(31, 134, 255, 0.10);
}
.dc-circle-send:hover { background: var(--accent-light) !important; color: #fff; }

/* A turn is running and there is text to park (#810's rule, moved onto the
   button). Green because it is a SAVE, not a send and not an interrupt: the
   same emerald the product already spends on "done" states. Filled rather
   than washed, because unlike Send it is standing where the red Stop was and
   has to read as a deliberate swap rather than a disabled Stop. */
.dc-circle-save {
  background: #10b981 !important;
  color: #fff;
  box-shadow: 0 0 0 6px rgba(16, 185, 129, 0.14);
}
.dc-circle-save:hover { background: #059669 !important; }

/* ── The card ───────────────────────────────────────────── */

/* One surface holding the field and one row of controls. It floats on the
   pane's ground rather than sitting in a bordered bar, which is why the bar
   around it drops its `border-t` and the card carries the elevation. */
.dc-card {
  /* The elevation is a variable so the focus rule below can add a ring to it
     without restating it — two copies of a shadow list drift the first time
     one of them is tuned. */
  --dc-card-elevation: 0 1px 2px rgba(0, 0, 0, 0.05), 0 10px 28px -8px rgba(0, 0, 0, 0.14);
  background: var(--dc-sheet);
  border-radius: 1.5rem;
  padding: 0.875rem 1rem 0.625rem;
  box-shadow: var(--dc-card-elevation);
  transition: box-shadow 120ms ease;
}
/* Dark mode gets a hairline instead of a shadow: a drop shadow on a dark
   ground is invisible, so the card would lose its edge entirely.
   `0 0 #0000` rather than `none`, because the focus rule appends to this
   value and `none, <shadow>` is not a valid box-shadow list. */
:root.dark .dc-card,
.dark .dc-card {
  --dc-card-elevation: 0 0 #0000;
  background: var(--dc-raised);
  border: 1px solid var(--border);
}

/* FOCUS LIVES ON THE CARD, not on the field.
   The field draws no box of its own — see the `bare` ring in
   @/components/ui/input.tsx — so the only edge that can carry a focus cue is
   the one around everything, and a blinking caret is a thin cue on a surface
   this wide.
   `:focus-within` rather than `:focus`: the card is one control group, and
   reaching the attach button or the model picker by keyboard is still "you
   are in the composer". Those controls keep their own rings on top of this —
   they have no caret, so the card's ring alone would not say which of them
   is live. */
.dc-card:focus-within {
  box-shadow: var(--dc-card-elevation), 0 0 0 2px var(--accent);
}
@media (prefers-reduced-motion: reduce) {
  .dc-card { transition: none; }
}

.dc-card-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 0.75rem;
}

/* The row's bare glyph buttons — attach, and anything that joins it. No fill
   and no border: the card is the box, and a bordered control inside it would
   draw a second one. The 28px box keeps the glyphs on one baseline; the
   first one pulls left so its optical edge lines up with the field's text
   above it rather than its padding box. */
.dc-row-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  background: none;
  border: 0;
  cursor: pointer;
}
.dc-card-row > .dc-row-btn:first-of-type { margin-left: -4px; }

/* The chat-model picker, stripped to its name plus a caret.

   It is a <button> that opens the kit's menu now, not a <select>
   (composer.tsx). `appearance: none` stays because a button has a native box
   of its own to remove, and the caret has moved INSIDE the control — with no
   native dropdown arrow to avoid duplicating, there was no longer a reason
   for half of it to be unclickable. Hence the flex row here, which the
   select could not have had. */
.dc-model-select {
  appearance: none;
  -webkit-appearance: none;
  display: block;
  background: none;
  border: 0;
  padding: 0 1.1rem 0 0;
  max-width: min(100%, 16rem);
  min-width: 0;
  cursor: pointer;
  color: inherit;
  font: inherit;
  text-align: left;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* 32px tall on a touch screen (QA 2026-09-24 Q19). It was the height of its
   13px line, 20px, and a <select> draws no ::after, so the `.un-touch-target`
   slop has nothing to hang from; the control grows instead. It has no box of
   its own to see (no fill, no border), so the only visible change is none:
   the caret is centred on it by its inline-flex wrapper, and every row it sits
   in is already taller than 32px. */
@media (pointer: coarse) {
  .dc-model-select { min-height: 32px; }
}
/* Shared with the proposal-workshop select, which uses this utility to keep
   a long model name from widening its control. */
.dc-model-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.dc-model-caret {
  position: absolute;
  right: 0;
  flex: none;
  pointer-events: none;
}
/* The native select's arrow is replaced so it follows the tuned foreground
   colour in both themes. */
.dc-venue-detail-inline {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-width: 0;
  color: var(--text-primary);
}

/* What still stacks above the card: the runner chip, the saved drafts, the
   pending attachments, the attach error and the suggested replies. Each is
   its own row on the pane's ground, so the card keeps one shape whatever is
   in flight. */
.dc-above-card:empty { display: none; }
.dc-above-card { margin-bottom: 0.5rem; }

/* The three busy surfaces carry their own ink now: the button renders with
   `ink="none"` so that each state class owns both halves of its surface. */
.dc-btn-streaming { background: var(--bg-tertiary) !important; color: var(--text-secondary); cursor: not-allowed; }
.dc-btn-streaming:hover { background: var(--bg-tertiary) !important; }

/* Stop-turn affordance (#28): red button with a square glyph, replaces
   the emerald Send button while the turn is interruptible (mayor1 / cc
   phases). Phase-2 falls back to the spinner state above. */
.dc-btn-stop { background: #dc2626 !important; color: #fff; cursor: pointer; }
.dc-btn-stop:hover { background: #b91c1c !important; }
.dc-stop-icon {
  width: 12px;
  height: 12px;
  background: #fff;
  border-radius: 2px;
  display: inline-block;
}

/* Stop requested, not yet landed (#889): the click has to acknowledge
   itself immediately, and the red square is a lie once pressed — pressing
   it again does nothing. Muted like the wrap-up spinner state (its closest
   sibling) with an explicit label, so the button reads as "working on it"
   rather than "still needs pressing". */
.dc-btn-stopping {
  background: var(--bg-tertiary) !important;
  color: var(--text-secondary);
  cursor: not-allowed;
  gap: 6px;
}
/* The circle has no room for a word beside the spinner. The label survives
   in the button's `aria-label` and `title`, which is where a 44px control
   has to carry it. */
.dc-btn-stopping .dc-btn-stopping-label { display: none; }
.dc-btn-stopping:hover { background: var(--bg-tertiary) !important; }
/* A touch smaller than the button's own text-sm so the widest state of
   this button stays close to the Send/Stop width and the composer beside
   it barely moves. */
.dc-btn-stopping-label { color: var(--text-secondary); font-size: 12px; }

/* Force stop (#937): the escape hatch offered inside the stopping status
   row once an ordinary stop has been pending ~40s and clearly isn't
   landing. Red like .dc-btn-stop — this is the same destructive intent,
   escalated — but sized for a transcript row rather than the composer,
   and it sits before the row's timestamp span (which claims margin-left:
   auto), so a plain inline-block with a little breathing room is enough. */
.dc-force-stop-btn {
  background: #dc2626;
  color: #fff;
  border: none;
  border-radius: 4px;
  padding: 2px 8px;
  margin-left: 8px;
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  flex-shrink: 0;
}
.dc-force-stop-btn:hover { background: #b91c1c; }
.dc-force-stop-btn:disabled {
  background: var(--bg-tertiary);
  color: var(--text-secondary);
  cursor: not-allowed;
}

.dc-send-spinner {
  width: 16px; height: 16px;
  border: 2px solid var(--border);
  border-top-color: var(--text-faint);
  border-radius: 50%;
  animation: dc-spin 0.7s linear infinite;
}

@keyframes dc-spin { to { transform: rotate(360deg); } }

/* ── Header cog ("your work" drawer button) ─────────────── */
/* Spins while the viewer has work in flight (an AI turn running, or a
   proposal merging / resolving / running checks — see frontend/src/features/work-drawer/work-drawer.js
   isWorking). A gear glyph is rotationally symmetric enough that a fast
   spin reads as flicker, so it turns SLOWLY and shifts to the accent
   color — the color carries the state even where the rotation is
   ambiguous (and alone under reduced motion). */
.work-cog-spinning {
  animation: dc-spin 3s linear infinite;
  color: var(--accent);
}
@media (prefers-reduced-motion: reduce) {
  .work-cog-spinning { animation: none; }
}

/* ── Textarea auto-resize ──────────────────────────────── */

.dc-textarea { max-height: 120px; overflow-y: auto; line-height: 1.5; }

/* ── Cross-app active sessions panel ───────────────────── */
/*
 * Sits at the very top of the dev-chat tab's meta block. Shows
 * every session that's currently running CC, even from other
 * apps the user owns. Same iOS-cell visual language as the
 * per-app session list below it: row layout, hover surface,
 * chevron at the end. The pulsing emerald dot is the live
 * indicator — it intentionally mirrors the "active" status
 * color used in the per-app list, so a glance ties the two
 * views together.
 */

.dc-active-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  cursor: pointer;
  transition: background-color 120ms ease;
  font-size: 13px;
  color: var(--text-primary);
}

.dc-active-item:hover,
.dc-active-item:focus-visible {
  background: var(--bg-card);
  outline: none;
}

/* Dot color follows the existing per-app list convention:
 *   - .dc-active-dot-active   → emerald (status='active')
 *   - .dc-active-dot-promoted → violet  (status='promoted')
 * The .dc-active-dot-busy modifier layers a pulse animation on top
 * to flag "CC is mid-turn right now" without changing the base
 * lifecycle color. That way an open session is always green —
 * pulsing if running, solid if idle — and a merged session is
 * always violet.
 */
.dc-active-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: 0 0 auto;
}

.dc-active-dot-active {
  background: rgb(16 185 129); /* emerald-500 */
}
.dc-active-dot-promoted {
  background: rgb(139 92 246); /* violet-500 */
}
.dc-active-dot-paused {
  background: var(--text-faint); /* zinc-400-ish */
  opacity: 0.7;
}

.dc-active-dot-busy.dc-active-dot-active {
  animation: dc-active-pulse-emerald 1.6s ease-out infinite;
}
.dc-active-dot-busy.dc-active-dot-promoted {
  animation: dc-active-pulse-violet 1.6s ease-out infinite;
}

@keyframes dc-active-pulse-emerald {
  0%   { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.45); }
  70%  { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
  100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
@keyframes dc-active-pulse-violet {
  0%   { box-shadow: 0 0 0 0 rgba(31, 134, 255, 0.45); }
  70%  { box-shadow: 0 0 0 6px rgba(31, 134, 255, 0); }
  100% { box-shadow: 0 0 0 0 rgba(31, 134, 255, 0); }
}

/* Primary action button (Pause/Resume). Tinted by intent —
 * Pause is a quiet zinc fade (it's the common case so it doesn't
 * need to shout), Resume is emerald (matches the active-dot color
 * the user is restoring it back to). */
.dc-active-action {
  flex: 0 0 auto;
  font-size: 11px;
  background: transparent;
  border: 1px solid transparent;
  padding: 2px 8px;
  border-radius: 4px;
  cursor: pointer;
  transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
}
.dc-active-action-pause {
  color: var(--text-secondary);
  border-color: var(--border-light);
}
.dc-active-action-pause:hover,
.dc-active-action-pause:focus-visible {
  color: var(--text-primary);
  background: var(--bg-tertiary);
  outline: none;
}
.dc-active-action-resume {
  color: rgb(16 185 129); /* emerald-500 */
  border-color: rgba(16, 185, 129, 0.4);
}
.dc-active-action-resume:hover,
.dc-active-action-resume:focus-visible {
  background: rgba(16, 185, 129, 0.1);
  outline: none;
}
.dc-active-action:disabled {
  opacity: 0.5;
  cursor: default;
}

/* Archive control — same ghost-button pattern, deliberately
 * quieter than Pause/Resume so the destructive action sits in the
 * background unless the user actively reaches for it. */
.dc-active-archive {
  flex: 0 0 auto;
  font-size: 11px;
  color: var(--text-faint);
  background: transparent;
  border: none;
  padding: 2px 6px;
  border-radius: 4px;
  cursor: pointer;
  transition: color 120ms ease, background 120ms ease;
}
.dc-active-archive:hover,
.dc-active-archive:focus-visible {
  color: rgb(239 68 68); /* red-500 */
  background: rgba(239, 68, 68, 0.08);
  outline: none;
}
.dc-active-archive:disabled {
  opacity: 0.5;
  cursor: default;
}

.dc-active-title {
  flex: 1 1 auto;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Pill that names the originating app, only shown when the row
 * belongs to a different app than the one the user is currently
 * viewing. Same-app rows skip it to reduce visual noise. */
.dc-active-app {
  flex: 0 0 auto;
  max-width: 40%;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--bg-tertiary);
  color: var(--text-secondary);
  font-size: 11px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.dc-active-chevron {
  width: 14px;
  height: 14px;
  color: var(--text-faint);
  flex: 0 0 auto;
}

/* ── Inline spec preview card (in chat timeline) ──────── */

.dc-spec-preview-card {
  margin: 4px 12px 8px;
  padding: 10px 12px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  border-left: 3px solid rgb(139 92 246); /* violet-500 */
  border-radius: 6px;
  background: var(--bg-secondary, #18181b);
  cursor: pointer;
  transition: border-color 120ms ease, background 120ms ease;
}

.dc-spec-preview-card:hover,
.dc-spec-preview-card:focus-visible {
  border-color: rgb(139 92 246);
  background: rgba(31, 134, 255, 0.06);
  outline: none;
}

.dc-spec-preview-header {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 6px;
}

.dc-spec-preview-title {
  font-size: 12px;
  font-weight: 500;
  color: var(--text-primary, #e4e4e7);
}

.dc-spec-preview-cta {
  margin-left: auto;
  font-size: 11px;
  color: rgb(139 92 246);
}

.dc-spec-preview-snippet {
  font-size: 12px;
  line-height: 1.45;
  color: var(--text-secondary, #a1a1aa);
  max-height: 6em;
  overflow: hidden;
  position: relative;
  word-break: break-word;
}

/* Soft fade at the bottom so the truncation reads as "more below"
   instead of an abrupt slice through a sentence. The gradient
   matches the card background; on hover the card tint kicks in
   underneath, which is fine — the fade still reads as a fade. */
.dc-spec-preview-snippet::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1.5em;
  background: linear-gradient(to bottom, transparent, var(--bg-secondary, #18181b));
  pointer-events: none;
}

/* Tighten markdown spacing inside the snippet — renderMarkdown's
   default margins (geared toward chat bubbles) are too loose for a
   4-line clipped preview. */
.dc-spec-preview-snippet > :first-child { margin-top: 0; }
.dc-spec-preview-snippet > :last-child  { margin-bottom: 0; }
.dc-spec-preview-snippet .dc-h3,
.dc-spec-preview-snippet .dc-h4,
.dc-spec-preview-snippet .dc-h5,
.dc-spec-preview-snippet .dc-p,
.dc-spec-preview-snippet .dc-ul,
.dc-spec-preview-snippet .dc-ol {
  margin: 2px 0;
}
.dc-spec-preview-snippet .dc-h3 { font-size: 13px; }
.dc-spec-preview-snippet .dc-h4 { font-size: 12px; }
.dc-spec-preview-snippet .dc-h5 { font-size: 12px; }
.dc-spec-preview-snippet .dc-code-block,
.dc-spec-preview-snippet .dc-code-header { display: none; }

/* ── Spec viewer (read-only, side panel / modal) ──────── */

.dc-session-body { /* row layout for chat + viewer */
  flex-direction: row;
}

/* ── Settings on the ladder ─────────────────────────────────────────────
   #settings-screen is the strip and #settings-section-content the sheet
   (features/settings/index.tsx). The sheet carries its own inset and the
   12px of strip shoulder above it; the sections inside keep the reading
   width they had (`max-w-xl`) so a form does not stretch across a wide
   sheet. The headings step up to the language's sizes here rather than in
   eighteen section files. */
.settings-sheet {
  margin-top: 12px;
  padding: 10px 20px 32px;
}
/* THE SHEET RUNS OFF THE BOTTOM, like every other sheet on the ladder
   (QA 2026-09-24 Q24). It had a fixed `min-height: 60vh` under `.dc-lift`'s
   top-only radius, a shape drawn for sheets that run to the screen's foot:
   a short section (Theme, Username, Password) became a mostly empty box
   that stopped mid-screen on a square edge. On a phone the strip behind it
   stopped too, because browser-scroller mode sizes #settings-screen to its
   content (`flex: none; height: auto`), leaving bare wallpaper above the
   tab bar.

   Now the screen, its root, the row and the content column are a column of
   flex boxes that fill the height they are given, and the open sheet takes
   whatever is left: a short section's sheet reaches the bottom edge, a long
   one is as tall as its content, as before. On a phone the sheet also runs
   UNDER the tab bar (the negative margin spends the screen's own
   `.platform-safe-scroll` padding) and carries that padding itself, so its
   last row still clears the bar. All of it is scoped to "a section is
   open": the phone's level-1 menu keeps the layout it had. */
#settings-screen:not(.hidden):has(#settings-section-content:not(.hidden)) {
  display: flex;
  flex-direction: column;
}
html[data-browser-scroller="settings-screen"] #settings-screen:has(#settings-section-content:not(.hidden)) {
  flex: 1 0 auto;
}
#settings-root:has(#settings-section-content:not(.hidden)) {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
  padding-bottom: 0;
}
#settings-root:has(#settings-section-content:not(.hidden)) > div {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
}
@media (min-width: 768px) {
  #settings-root:has(#settings-section-content:not(.hidden)) > div {
    flex-direction: row;
    align-items: stretch;
  }
}
#settings-content-col:has(> #settings-section-content:not(.hidden)) {
  display: flex;
  flex-direction: column;
}
#settings-content-col > .settings-sheet {
  flex: 1 0 auto;
  margin-bottom: calc(-1 * max(var(--platform-tabs-h, 0px), var(--platform-safe-bottom, 0px)));
  padding-bottom: calc(32px + max(var(--platform-tabs-h, 0px), var(--platform-safe-bottom, 0px)));
}
/* Keyboard up: the screen's safe-scroll padding is 0 (see
   `html.un-kb .platform-safe-scroll`), so there is nothing to spend. */
html.un-kb #settings-content-col > .settings-sheet {
  margin-bottom: 0;
  padding-bottom: 32px;
}
/* From md up the sheet sits BESIDE the sidebar, whose search field starts
   at the root's top. The 12px shoulder put the sheet's top edge 12px below
   the field's, so the two columns did not line up; there the root's
   `md:pt-5` (8 for the header's notch, 12 of air) is the step for both. */
@media (min-width: 768px) {
  #settings-content-col > .settings-sheet { margin-top: 0; }
}
.settings-sheet > [data-settings-section] { max-width: 40rem; }
/* A PAGE shows several parts at once since the settings restructure
   (Account is Profile, Username, Email & recovery, Password and Delete
   account). Every part after the first visible one starts below a hairline,
   so each heading reads as the start of its own block rather than as a line
   in the previous part's form. A gated part whose inner node is hidden
   (Language for most viewers) is an empty wrapper, and an empty wrapper
   draws no rule: `:has(> :not(.hidden))` is false for it. */
.settings-sheet > [data-settings-section]:not(.hidden) ~ [data-settings-section]:not(.hidden):has(> :not(.hidden)) {
  margin-top: 28px;
  padding-top: 24px;
  border-top: 1px solid var(--app-sheet-line);
}
/* #5281: Username, Email & recovery and Password are one group of rows
   under the "Sign-in" heading Username carries, each a card of its own so its
   form can open inside it. The two after Username therefore sit 8px under
   the card above, with no rule and no gap of a new block. Same specificity
   as the rule above, and after it, so it wins. */
.settings-sheet > [data-settings-section]:not(.hidden) ~ :is([data-settings-section="email"], [data-settings-section="password"]):not(.hidden):has(> :not(.hidden)) {
  margin-top: 8px;
  padding-top: 0;
  border-top: 0;
}
/* Delete account closes the Account page: more room above it, so it never
   reads as the next field of the password form. Spelled with the same
   sibling chain as the rule above so it outranks that rule's margin. */
.settings-sheet > [data-settings-section]:not(.hidden) ~ [data-settings-section="delete-account"]:not(.hidden):has(> *) {
  margin-top: 40px;
}
.settings-sheet h3 {
  font-size: 22px;
  line-height: 1.2;
  letter-spacing: -0.01em;
  margin-bottom: 4px;
}
.settings-sheet h3 + p { font-size: 15px; line-height: 1.35; margin-bottom: 14px; }
/* The AI-credit row (features/header/ai-budget.tsx) is one row of the
   API-key card here, not the boxed pill it is in the header pane: its own
   fill, hairline and padding come off inside the sheet, and its type steps
   up to the card's 17/15px. */
.settings-sheet #drawer-row-ai-budget {
  background: transparent;
  border: 0;
  border-radius: 0;
  padding: 0;
  margin: 0;
  font-size: 17px;
  line-height: 1.5;
}
.settings-sheet #drawer-row-ai-budget > span:first-child { font-weight: 400; }
.settings-sheet #drawer-row-ai-budget .drawer-meter { font-size: 15px; font-family: inherit; letter-spacing: 0; }
@media (max-width: 767px) {
  #settings-mobile-menu-host { padding: 4px 0 0; }
}

/* ── The bell's sheet on the ladder ─────────────────────────────────────
   #notifications-sheet wears `.dc-lift dc-lift-panel` (features/notifications/
   notifications-sheet.tsx) — the opaque lift, the surface the Improve rail
   and the app menu wear too; see `.dc-lift-panel` for why these three are
   opaque where the dev screen's two planes are glass. The geometry rules
   further down keep their dock and slide; this only restates the desktop
   corner and the hairline, since the lift's own `1.75rem 1.75rem 0 0` is
   the shape of a sheet docked to the FLOOR and this one docks to the right.
   The rows separate with the grouped list's inset hairline.

   The mobile pair of corners used to be restated here too, with
   `!important`, to beat a `1rem` in the `max-width: 639px` block below. That
   block says 1.75rem now — as do the Improve rail's and the app menu's, so
   the three bottom sheets agree by reading the same number rather than by an
   override reaching over one of them. */
#notifications-sheet { border-color: var(--app-sheet-line); }
@media (min-width: 640px) {
  #notifications-sheet {
    border-radius: 1.75rem 0 0 0;
    border-left: 1px solid var(--app-sheet-line);
  }
}
.notifications-row { position: relative; }
.notifications-row:not(:last-child)::after {
  content: '';
  position: absolute;
  left: 4.75rem;
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--border-light);
  pointer-events: none;
}
.dark .notifications-row:not(:last-child)::after { background: rgba(255, 255, 255, 0.08); }
/* #3538: a notification row in the sheet sits in a slot of its own (the
   swipe's kit wrap and the desktop × both live in it; see ScreenRow in
   features/notifications/notifications-sheet.tsx), so whether the ROW is a
   last child says nothing any more: at a desk the × follows it, and on a
   phone the kit makes it the last child of its wrap. The hairline goes by
   the SLOT's place in the list instead, which is the place the row had. */
.notifications-row-slot .notifications-row::after { content: none; }
.notifications-row-slot:not(:last-child) .notifications-row::after {
  content: '';
  position: absolute;
  left: 4.75rem;
  right: 0;
  bottom: 0;
  height: 1px;
  background: var(--border-light);
  pointer-events: none;
}
.dark .notifications-row-slot:not(:last-child) .notifications-row::after { background: rgba(255, 255, 255, 0.08); }

/* #5580: the Unread tab's project stacks (features/notifications/
   notification-stacks.tsx). A stack is its heading and its cards: the shell's
   card (ScreenRow's card form), 12px in from the sheet's edges and 6px apart,
   with no hairline between them, since each is its own surface.

   CLOSED, the cards under the top one sit under it, each as tall as it (the
   stack measures it into --stack-top and --stack-h) and a little narrower,
   so only their edges show below it: the first two peek out and the rest
   wait under the second. They are the real cards, in their edge colour and
   emptied of their words, so opening the stack moves them out rather than
   drawing new ones (ProjectStack's FLIP); the colour and the fade are
   transitions on the same change. On a phone the swipe host is the card's
   surface (it is what slides), so the edge colour is its own there. */
.notifications-stack { position: relative; }
.notifications-stack > .notifications-row-slot { margin: 0 12px; transition: background-color 0.4s ease; }
.notifications-stack > .notifications-row-slot + .notifications-row-slot { margin-top: 6px; }
.notifications-stack .notifications-row-slot .notifications-row::after { content: none; }
.notifications-stack .notifications-card .un-swipe { border-radius: 20px; }
.notifications-stack > .notifications-row-slot > *,
.notifications-stack [data-notif-swipe],
.notifications-stack [data-notif-swipe] > * { transition: opacity 0.3s ease, background-color 0.4s ease; }
.notifications-stack[data-stack-closed][data-stack-depth="1"] { padding-bottom: 8px; }
.notifications-stack[data-stack-closed][data-stack-depth="2"] { padding-bottom: 16px; }
.notifications-stack[data-stack-closed] > [data-stack-top] { z-index: 3; }
.notifications-stack[data-stack-closed] > [data-stack-behind] {
  position: absolute;
  left: 22px;
  right: 22px;
  top: calc(var(--stack-top, 0px) + 8px);
  height: var(--stack-h, 0px);
  margin: 0;
  z-index: 2;
  overflow: hidden;
  pointer-events: none;
  background-color: #efeee7;
  box-shadow: inset 0 0 0 1px var(--app-sheet-line), 0 2px 4px rgba(0, 0, 0, 0.07);
}
.notifications-stack[data-stack-closed] > [data-stack-behind="2"],
.notifications-stack[data-stack-closed] > [data-stack-behind="3"] {
  left: 32px;
  right: 32px;
  top: calc(var(--stack-top, 0px) + 16px);
  z-index: 1;
}
.notifications-stack[data-stack-closed] > [data-stack-behind="3"] { opacity: 0; }
.notifications-stack[data-stack-closed] > [data-stack-behind] [data-notif-swipe] { background-color: #efeee7; }
.dark .notifications-stack[data-stack-closed] > [data-stack-behind],
.dark .notifications-stack[data-stack-closed] > [data-stack-behind] [data-notif-swipe] { background-color: #27282c; }
.notifications-stack[data-stack-closed] > [data-stack-behind] > :not(.un-swipe):not([data-notif-swipe]),
.notifications-stack[data-stack-closed] > [data-stack-behind] [data-notif-swipe] > * { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  .notifications-stack > .notifications-row-slot,
  .notifications-stack > .notifications-row-slot > *,
  .notifications-stack [data-notif-swipe],
  .notifications-stack [data-notif-swipe] > * { transition: none; }
}

/* ── The dev area on the ladder, and the shared composer card ───────────
   The board column is the STRIP and a topic or the Discussion is the SHEET
   (features/dev-board/board-frame.tsx, topic-frame.tsx, chat-frame.tsx,
   group-chat/thread-shell.tsx, general-chat.tsx), through the same three
   lift classes the session view below wears. What this block adds is the
   composer card those two sheets end in — the card Messages draws
   (.messages-composer-card above), restated here for the group-chat
   composer's own class names because features/group-chat/composer.tsx is
   the one component serving both. */
/* The two sheet hosts: a column with the strip's 12px shoulder above the
   sheet that is mounted into them. Padding on the HOST rather than a margin
   on the sheet, so the sheet's `h-full` still fits. */
#dev-topic-thread,
#dev-chat-body {
  display: flex;
  flex-direction: column;
  padding-top: 12px;
}
#dev-topic-thread > .dev-thread,
#dev-chat-body > .dc-lift { min-height: 0; }

.gc-composer-card {
  border-radius: 22px;
  padding: 8px 8px 8px 12px;
  background: #ffffff;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.dark .gc-composer-card { background: var(--dc-raised); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); }
.gc-composer-card .gc-composer-input {
  min-height: 40px;
  max-height: 140px;
  /* #2882: the card is the one edge; the field draws none — see
     `.messages-composer-input`, the same field in the conversation composer. */
  -webkit-appearance: none;
  appearance: none;
  box-shadow: none;
  -webkit-tap-highlight-color: transparent;
}
.gc-composer-card .gc-composer-input::placeholder { color: var(--text-faint); }
.gc-composer-glyph {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border-radius: 9999px;
  color: var(--text-secondary);
  transition: background 150ms;
}
.gc-composer-glyph svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.gc-composer-glyph:hover { background: var(--bg-secondary); }
.gc-send {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  border-radius: 9999px;
  color: var(--accent-ink);
  background: var(--accent);
  transition: background 150ms;
}
.gc-send svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.4;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.gc-send:hover { background: var(--accent-light); }
.gc-send:disabled { opacity: .35; }
/* #3735: the same compact line on a phone as the Messages composer (the
   phone block after `.messages-send` above): a 44px pill, 4px of padding
   round a 36px row, with the paperclip and the send disc drawn at 36px and
   each held to a 44px tap target by a 4px overhang. */
@media (max-width: 767px) {
  .gc-composer-card { padding: 4px 4px 4px 8px; }
  .gc-composer-card .gc-composer-input { min-height: 36px; padding-top: 7px; padding-bottom: 7px; }
  .gc-send { width: 36px; height: 36px; }
  .gc-composer-glyph,
  .gc-send { position: relative; }
  .gc-composer-glyph::after,
  .gc-send::after { content: ''; position: absolute; inset: -4px; }
}

/* ── The dev chat's three-layer lift ──────────────────────
   The move a running app already makes, repeated once per layer. A
   running app's sheet (#app-frame-host) rounds its TOP two corners over
   the platform header's ground, so the header shows through a notch at
   each shoulder. Here the session strip does that over the page
   wallpaper, and the session sheet does it again over the strip: three
   surfaces, each one revealing a shoulder of the one above it.

   The geometry is #app-frame-host's, to the token — a 28px top radius, a
   1px hairline on the TOP EDGE ONLY, and the deck's two-layer blue-black
   shadow. Read that block for why the hairline is `border-top` and not a
   full border: these sheets are as wide as the screen and run off the
   bottom, so the top is the only side that is an edge of anything.

   Neither layer clips to its own radius. The shoulder of a sheet is the
   area INSIDE its border box but OUTSIDE its arc, so `overflow: hidden`
   would remove precisely the thing this is for. */
.dc-lift {
  border-radius: 1.75rem 1.75rem 0 0;
  border-top: 1px solid var(--app-sheet-line);
  /* NAMED, because the panes have to RESTATE it. box-shadow does not cascade
     additively: `.dc-lift-panel` appends the scrim as a third layer and must
     therefore repeat the two the lift draws. A token is what keeps the second
     copy from drifting from this one. */
  box-shadow: var(--dc-lift-shadow);
  /* BOTH LAYERS ARE POSITIONED, and that is what puts the session sheet
     ON the strip rather than under it. Positioned siblings paint after
     unpositioned ones and among themselves in tree order, so with only
     one of the two positioned the strip would paint over the sheet —
     drawing its own bottom edge as a hard line across it and hiding the
     very corners this is for. No z-index: tree order already says it. */
  position: relative;
}

/* The two surfaces, in the platform's own tokens: the strip is the
   secondary ground and the sheet is the primary one, the same pairing a
   card gets on a page. Before this the whole screen sat on the bare
   wallpaper. */
/* GLASS, NOT PAINT. Both planes were opaque neutrals — #f5f5f7 over #ffffff
   in light, #131316 over #1c1c1e in dark — and between them they covered the
   platform's wallpaper (the cream, the three washes, the star) with a flat
   white or a flat black across the whole session. The lift solved the depth
   problem by throwing the ground away; the comment two blocks up even says
   so — "before this the whole screen sat on the bare wallpaper".

   They are translucent now, over a blur. The ladder still reads, because the
   two alphas differ and the sheet's hairline and shadow are untouched: the
   sheet is denser than the strip, which is denser than the bare page.

   `backdrop-filter` needs a transparent ancestor chain to have anything to
   filter. It has one — #app-content carries `position: relative` and no
   background, and the body's wallpaper rule is on for every #app-view route
   — so the backdrop here is the wallpaper itself. */
.dc-lift-strip {
  background-color: var(--dc-strip-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
.dc-lift-session {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* #1943: the session strip keeps THIS surface when the chat scrolls.
   attachScreenFx (dev-chat.js) makes #dc-session-header a
   `.platform-chat-header`, and the kit adds `.un-scrolled` once the
   transcript moves; `.platform-chat-header.un-scrolled` (near the top of
   this file) is more specific than `.dc-lift-strip` and repainted the strip
   with the nav-bar white — the bar visibly turned white on scroll. The id
   outranks it; the hairline it adds is the scroll cue and stays. */
#dc-session-header.un-scrolled {
  background-color: var(--dc-strip-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* NO BLUR, SAME FILL. The planes used to fall back to the opaque --dc-strip /
   --dc-sheet wherever the filter was off (unsupported browsers, then the iOS
   app), on the argument that the fill alone shows the star and the washes
   unblurred behind body text. It made two colour schemes: neutral grey and
   white on those, warm glass everywhere else. The washes are soft shapes
   either way, and measured against the glass the fill alone lands within a
   few points of it, so every platform now draws the fill and nothing else
   (see --dc-frost). That includes the scrolled session header: it sits in
   flow above #dc-messages, so the transcript never passes behind it. */

/* Mounted panes retain their DOM and state, but stop painting after exit.
   The separate overlay-scrim paint layer cuts out the rounded surface. It is
   immediately AFTER that surface at the SAME z-index: glass samples the
   undimmed page, later overlays still cover it, and existing backdrops keep
   their original hit testing. See lib/overlay-scrim.js. */
.dc-lift-panel {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  box-shadow: var(--dc-lift-shadow);
  visibility: visible;
}
.dc-lift-panel:not([data-open]):not(.platform-sheet-adopted) {
  opacity: 0;
  visibility: hidden;
  transition: transform 200ms ease-out, box-shadow 200ms ease-out,
    opacity 0s linear 200ms, visibility 0s linear 200ms;
}
/* A VIEWPORT OF OVERSCAN above and below (#2822). With the on-screen keyboard
   up, iOS pans the visual viewport inside the layout one, and the band on
   screen is not reliably inside this fixed box: the dim stopped short of it —
   the bottom once (#2765), the top after that. lib/overlay-scrim.js measures
   this layer and cuts the hole relative to it, so however far it reaches, the
   cutout still lands on the dialog; the extra reach only guarantees there is
   dim wherever the screen has been panned to. Nothing hit-tests it. */
.overlay-scrim {
  position: fixed;
  inset: -100vh 0;
  pointer-events: none;
  visibility: hidden;
  background: var(--pane-scrim);
}

/* NO BLUR, NO GLASS — the same fallback the two planes above take, and for
   the same reason: without the filter the alphas leave the page sitting
   unblurred behind body text. The separate scrim is unaffected; only the pane goes opaque. */
/* It floats over the page, so it is solid (see --dc-sheet-solid). */
.dc-lift-panel { background-color: var(--dc-sheet-solid); }

/* Shell-only frosted kit surfaces. PlatformUI attaches the same cutout
   paint layer used by the legacy panes. Kit opacity/presence and teardown
   remain authoritative, including dragging and spring overshoot. The kit's
   ordinary backdrops remain in place for dismissal and modal lifecycle. */
.un-backdrop:has(+ .un-modal),
.un-backdrop:has(+ .un-sheet),
.un-backdrop:has(+ .un-panel) {
  background: transparent;
}
/* …UNLESS THE SURFACE IS OPAQUE. With no backdrop filter on the surface (every
   kit sheet, panel and dialog, on every platform) there is no glass to keep
   undimmed, so lib/overlay-scrim.js skips the cutout layer and its per-frame
   work and marks the backdrop instead. The backdrop then paints the scrim's
   colour itself; its opacity is the kit's, which rides a sheet's or panel's
   position 1:1 and fades with a dialog's card. It takes the scrim's reach as
   well, a viewport above and below (see `.overlay-scrim`): with the keyboard
   up iOS pans the visual viewport inside the layout one, and a dim that stops
   at the fixed box leaves the band on screen undimmed. The reach is off
   screen, so it changes nothing about what a tap on the dim lands on. `html`
   puts it above the transparent rule whatever the order. */
html .un-backdrop.platform-backdrop-dim {
  inset: -100vh 0;
  background: var(--pane-scrim);
}
.un-modal {
  background-color: var(--dc-float-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  box-shadow: var(--dc-lift-shadow);
}
.un-sheet {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.22);
}
/* The overscroll extension below the sheet (issue #789 in native.css) is
   more sheet, so it is the same glass. */
.un-sheet::after {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
.un-panel {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
.un-panel[data-un-side="right"] {
  box-shadow: -8px 0 32px rgba(0, 0, 0, 0.22);
}
.un-panel[data-un-side="left"] {
  box-shadow: 8px 0 32px rgba(0, 0, 0, 0.22);
}
/* The panel's overshoot extension (the `.un-sheet::after` story on the x
   axis) is more panel. */
.un-panel::after {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* The platform mark's menu floats over the app, like a dialog (#2887): the
   desktop dropdown is the panel itself, and below `sm` the kit's sheet it is
   adopted into is the surface. Ids / `:has()` outrank `.dc-lift-panel` and
   `.un-sheet` above; the blur they set is kept. */
#apps-switcher-sheet,
.un-sheet:has(#apps-switcher-sheet),
.un-sheet:has(#apps-switcher-sheet)::after {
  background-color: var(--dc-float-fill);
}
/* NO BLUR, NO GLASS: the kit's opaque surface comes back, in the shell's
   own sheet colour. The scrim is a shadow, not a filter, so it survives. */
/* SOLID ON EVERY PLATFORM. These float over other UI, so with no blur a
   see-through fill would show the page's own text behind theirs. The dialog
   was a 95% fill, so it takes the opaque sheet; sheets and panels take the
   plane colour they showed over the wallpaper (--dc-sheet-solid). Being
   opaque is also what lets lib/overlay-scrim.js drop the per-frame cutout
   and dim with the kit's own backdrop. */
.un-modal { background-color: var(--dc-sheet); }
/* Send Feedback wears the plane colour the sheets do, not the neutral white:
   it is the dialog people open from the same menus as those sheets, and it
   read as a different material beside them. */
.un-modal:has(#feedback-form) {
  background-color: var(--dc-sheet-solid);
  /* #4542: focusing a field scrolls it in above the sticky Cancel/Post row,
     not under it. 76px is the row (12px top padding, a 36px button, 20px
     foot) and an 8px gap. */
  scroll-padding-bottom: 76px;
}
/* CANCEL AND POST STAY ON SCREEN (#4033). The kit modal is the scroller
   (native.css `.un-modal`: overflow-y auto), so a long description or the
   kudos row pushed the two buttons below its bottom edge and they had to be
   scrolled to. The row sticks to that edge instead and the form scrolls
   under it, on the same solid plane colour so nothing shows through. It
   reaches down through the modal's 20px bottom padding (negative margin,
   matching padding), so the buttons sit where they always did when the
   form is short (4px + 12px is the 16px gap `mt-4` gave), and the hairline marks the edge the fields pass under.
   #4542: `scroll-padding-bottom` on the scroller above keeps a focused field clear of the row. */
.un-modal .feedback-actions {
  position: sticky;
  bottom: 0;
  z-index: 1;
  margin-top: 4px;
  margin-bottom: -20px;
  padding: 12px 0 20px;
  background-color: var(--dc-sheet-solid);
  box-shadow: 0 -1px 0 var(--app-sheet-line);
}
/* THE FEEDBACK FORM AS A PHONE SHEET (#4554). On a phone the centred kit
   modal is what gets squeezed and cut off once the keyboard is up, so
   lib/static-modal.ts's `phoneSheet` presentation leaves the card in its
   root and bottoms it instead, under two runtime attributes a frame apart
   (`data-dialog-sheet` on the reveal, `data-dialog-sheet-shown` on the next
   frame, which is the slide-up). The kit's own sheet is not usable for a
   form: `.un-sheet` takes every vertical drag, so the card could not scroll
   its fields. Sizing is the `.platform-kb-sheet` arithmetic: with the
   keyboard up the card sits in the band the reader can actually see, so
   Cancel and Post request stay just above the keys and the card scrolls
   inside itself. Escaped on any wider or non-touch screen, where the centred
   modal stays exactly as it was. */
.feedback-sheet-grabber { display: none; }

@media (max-width: 767px) and (pointer: coarse) {
  /* The root does not scroll in sheet mode; the card is the scroller. */
  [data-dialog-sheet] { overflow: hidden; }
  [data-dialog-sheet] [data-modal-backdrop] {
    align-items: flex-end;
    padding: 0;
  }
  [data-dialog-sheet] [data-modal-backdrop] > div {
    width: 100%;
    max-width: none;
    border-radius: 20px 20px 0 0;
    background-color: var(--dc-sheet-solid);
    box-shadow: inset 0 0 0 1px var(--app-sheet-line);
    max-height: min(92%, calc(100% - var(--platform-safe-top, 0px) - 8px));
    overflow-y: auto;
    overscroll-behavior: contain;
    transform: translateY(100%);
    transition: transform 200ms ease-out;
  }
  [data-dialog-sheet][data-dialog-sheet-shown] [data-modal-backdrop] > div {
    transform: translateY(0);
  }
  /* Its top stays under the status bar, as the comment sheet's does: with the
     keys up a long form grew past it, and the Form / Comment switch and
     "See your requests" went under the clock and the Dynamic Island, where a
     tap never reaches the page (iPhone 17 Pro simulator, 12 Oct 2026). */
  html.platform-kb-open [data-dialog-sheet] [data-modal-backdrop] > div {
    bottom: var(--platform-kb-cover, 0px);
    max-height: calc(100% - var(--platform-vv-top, 0px) - var(--platform-kb-cover, 0px) - max(12px, var(--platform-safe-top, 0px) + 8px));
  }
  [data-dialog-sheet] .feedback-sheet-grabber {
    display: block;
    width: 36px;
    height: 4px;
    margin: -10px auto 12px;
    border-radius: 2px;
    background: var(--border);
    flex: none;
  }
  /* Cancel and Post stay at the sheet's foot while the form above scrolls,
     the same rule the kit modal carries (.un-modal .feedback-actions, #4033)
     — here the card is the scroller, and its bottom padding is 24px. */
  [data-dialog-sheet] .feedback-actions {
    position: sticky;
    bottom: 0;
    z-index: 1;
    margin-top: 4px;
    margin-bottom: -24px;
    padding: 12px 0 24px;
    background-color: var(--dc-sheet-solid);
    box-shadow: 0 -1px 0 var(--app-sheet-line);
  }
}

@media (max-width: 767px) and (pointer: coarse) and (prefers-reduced-motion: reduce) {
  [data-dialog-sheet] [data-modal-backdrop] > div { transition: none; }
}

/* THE COMMENT SHEET (#4554): the comment box on a phone, a slide-up sheet of
   its own (features/comment-pin/comment-pin.tsx renders it with the same
   plane fill and hairline as the sign-in sheet's). It slides up once, on
   mount, by keyframe — its open state is React's, not a class toggle. */
.comment-sheet-in { animation: comment-sheet-up 200ms ease-out; }
@keyframes comment-sheet-up {
  from { transform: translateY(100%); }
  to { transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .comment-sheet-in { animation: none; }
}

/* THE VIEW PICTURE'S ENTRANCE (#4665): while the first view picture is
   drawn, comment mode leaves the live page on show, and when the picture
   lands it mounts together with the grey ground and the word that names it,
   "Screenshot", which leads the bar above the picture (#4774). The picture
   starts exactly over the viewport (--comment-shot-from, the inverse of its
   inset frame) and shrinks into its frame, the ground fading in behind it
   and the word after. Mount-only keyframes: the classes stay
   on the elements, so a retake's new src replays nothing. */
.comment-shot-in { animation: comment-shot-in 320ms ease-out; transform-origin: 0 0; }
@keyframes comment-shot-in {
  from { transform: var(--comment-shot-from, none); border-radius: 0; }
}
.comment-shot-ground-in { animation: comment-shot-ground-in 320ms ease-out; }
@keyframes comment-shot-ground-in {
  from { opacity: 0; }
}
.comment-shot-label-in { animation: comment-shot-label-in 160ms ease-out 200ms both; }
@keyframes comment-shot-label-in {
  from { opacity: 0; }
}
/* The bar comes in with the picture rather than ahead of it: drawn over the
   live page while the first picture drew, it covered the page's own header
   for a few frames (iPhone 17 Pro simulator, 12 Oct 2026). */
.comment-bar-in { animation: comment-shot-ground-in 320ms ease-out; }

/* THE MODE LEAVES THE WAY IT CAME (12 Oct 2026). Done, Esc and Form used to
   unmount it in a frame: the picture and its grey ground vanished and the
   form slid up over a page that had just jumped. Now the picture grows back
   over the page it shows (the entrance played backwards, to the same
   --comment-shot-from), the ground fades with it, and everything else the
   mode drew fades a little faster, before the layer goes
   (comment-pin.tsx `leave`, 200ms). Transform and opacity only. */
.comment-mode-leaving .comment-shot-in { animation: comment-shot-out 200ms ease-in forwards; }
.comment-mode-leaving .comment-shot-ground-in { animation: comment-fade-out 200ms ease-in forwards; }
.comment-mode-leaving > :not(.comment-shot-in):not(.comment-shot-ground-in) { animation: comment-fade-out 120ms ease-in forwards; }
@keyframes comment-shot-out {
  to { transform: var(--comment-shot-from, none); border-radius: 0; }
}
@keyframes comment-fade-out {
  to { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .comment-shot-in,
  .comment-shot-ground-in,
  .comment-shot-label-in,
  .comment-bar-in { animation: none; }
}
/* The sheet's grabber, the same handle the feedback sheet's carries — the
   component renders it only on a phone, so no media query here. */
.comment-sheet-grabber {
  width: 36px;
  height: 4px;
  margin: 0 auto 10px;
  border-radius: 2px;
  background: var(--border);
  flex: none;
}
/* THE COMMENT SHEET STAYS UNDER THE BAR (iPhone 17 simulator, 12 Oct 2026).
   With the keyboard up the sheet grew past the top of the screen, over the
   bar and its Done and Form, under the status bar, and nothing was left to
   tap to put the keys away: the only ways out were Post or quitting the
   app. Its height is now capped to what lies between the band the bar sits
   in (`--comment-band`, the status bar plus the bar's 60px, which the
   component writes on its root) and its foot, the keys' top with them up
   (`--platform-kb-cover`, lib/keyboard-open.ts, which `.platform-kb-sheet`
   already stands it on). The id outranks `.platform-kb-sheet`'s own caps.
   With the keys down its foot clears the home indicator. */
#comment-pin-box.comment-sheet {
  --comment-sheet-pb: calc(0.75rem + var(--platform-safe-bottom, 0px));
  max-height: min(85%, calc(100% - var(--comment-band, 0px) - 8px));
  padding-bottom: var(--comment-sheet-pb);
}
html.platform-kb-open #comment-pin-box.comment-sheet {
  /* The home indicator is behind the keys. */
  --comment-sheet-pb: 1.25rem;
  max-height: calc(100% - var(--comment-band, 0px) - 8px - var(--platform-vv-top, 0px) - var(--platform-kb-cover, 0px));
}
/* Capped like that, the sheet is shorter than what it holds while the keys
   are up, and Post was below its fold. The row with Post stays at the
   sheet's foot while the rest scrolls, the feedback sheet's rule
   (`[data-dialog-sheet] .feedback-actions`, #4033): its fill runs down over
   the sheet's bottom padding, so nothing scrolls through under it. A sticky
   box stops at the scroller's padding, so it is held that far lower, or the
   row above it showed through the last 20px. */
#comment-pin-box.comment-sheet .comment-sheet-actions {
  position: sticky;
  bottom: calc(-1 * var(--comment-sheet-pb));
  z-index: 1;
  margin-bottom: calc(-1 * var(--comment-sheet-pb));
  padding-bottom: var(--comment-sheet-pb);
  background-color: var(--dc-sheet-solid);
}
/* And its head the same at the top (14 Oct 2026): the grabber, where the
   request goes, Minimize and ✕ stay at the sheet's top edge while the rest
   scrolls under them. Under the close-up and scrolled away with the keys
   up, the ✕ was hard to find. Held the sheet's top padding (p-3) higher,
   for the same reason the actions row is held lower. */
#comment-pin-box.comment-sheet .comment-sheet-head {
  position: sticky;
  top: -0.75rem;
  z-index: 1;
  flex: none;
  margin-top: -0.75rem;
  padding-top: 0.75rem;
  background-color: var(--dc-sheet-solid);
}
/* The phone's small box (the comment before its sheet) comes in beside its
   pin rather than appearing in a frame. Opacity and transform only. */
.comment-small-in { animation: comment-small-in 160ms ease-out; }
@keyframes comment-small-in {
  from { opacity: 0; transform: translateY(4px) scale(0.97); }
}
@media (prefers-reduced-motion: reduce) {
  .comment-small-in { animation: none; }
}
/* THE PAPERCLIP'S POPOVER (#4127): Photo and Video, in the vote popover's
   frame (.dev-vote-pop / .messages-new-pop: same radius, surface, hairline
   and shadow) and the "+" menu's rows. Absolute under the paperclip rather
   than body-mounted and fixed: it lives inside the kit modal, which is a
   transformed scroller, and scrolls with the field it hangs from. Above the
   sticky Cancel/Post row (z-index 1), which it can overlap on a short form. */
.feedback-attach-pop {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 2;
  width: 168px;
  box-sizing: border-box;
  padding: 6px;
  border-radius: 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.feedback-attach-option {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  min-height: 44px;
  padding: 8px 10px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: var(--text-primary);
  font-size: 14px;
  font-weight: 600;
  text-align: left;
  cursor: pointer;
}
.feedback-attach-option:hover, .feedback-attach-option:focus-visible { background: var(--bg-tertiary); outline: none; }
.feedback-attach-option:disabled { opacity: .5; cursor: default; }
.feedback-attach-option svg { flex: 0 0 auto; width: 20px; height: 20px; color: var(--brand-ink); }
.un-sheet, .un-sheet::after, .un-panel, .un-panel::after,
#apps-switcher-sheet, .un-sheet:has(#apps-switcher-sheet),
.un-sheet:has(#apps-switcher-sheet)::after {
  background-color: var(--dc-sheet-solid);
}

/* A DIALOG STAYS ON THE PART OF THE SCREEN THE KEYBOARD LEAVES (#2765).

   The kit centres its modal in the strip above the on-screen keyboard as
   `50% - var(--un-kb-inset) / 2`, which is the middle of what is visible
   only while the visual viewport starts at the top of the layout viewport.
   iOS pans it down to reveal a focused field, and a fixed box stays behind:
   Give feedback, re-centred after that pan, landed pressed against the top
   edge with its heading cut off, and opened with the keys already up it was
   almost entirely above the screen. `--platform-vv-top` is the pan
   (lib/visual-viewport.ts), so adding it moves the kit's centre into the
   visible band. Nothing else is needed: the kit already caps the height at
   that band less its margins (100dvh less the inset IS the visual viewport's
   height) and scrolls the overflow, so every field and Submit stay reachable.

   0px whenever the visual viewport is not panned — no keyboard, desktop, and
   Android, where the kit measured no pan — and while pinch-zoomed, so there
   the kit's own placement is untouched. Every kit modal in the shell takes
   it: each dialog `useDialog` presents, the username and terms gates,
   session options and the build log. The alert card (`.un-alert`) is centred
   the same way but is not a `.un-modal`, and is left to the kit.

   THE PAN IS A `translate`, NOT A TERM OF `top`, AND IT NEVER EASES. iOS
   moves the page by the whole pan in one frame, both ways: when it reveals
   a focused field, and back again the moment the field blurs. A `translate`
   by the same amount, in the same frame, cancels that jump exactly, so the
   dialog holds still while the kit eases `top` and the height (native.css
   `.un-modal`, 250ms) toward the new band. As a term of `top` the pan eased
   with them instead: the page had already jumped and the dialog followed a
   quarter of a second late, thrown down by the whole pan on blur (300px
   behind a tall form) and dipping on focus. The same frame is the kit's
   doing: it reads the keyboard in the viewport's own resize event and
   clears it in the blur, and lib/visual-viewport.ts answers both at once.
   The kit's own modals rarely pan at all now (native.js
   attachModalFieldFocus); this is for a field focused from code.

   Only while the keyboard is up, the one time there is a pan: any
   `translate` but `none` keeps the card on a transformed layer, which the
   Android create screen below is anchored precisely to avoid. */
html.un-kb .un-modal {
  translate: 0 var(--platform-vv-top, 0px);
}

/* THE OVERLAP MUST BE A FULL RADIUS, and it has to come from whatever is
   directly above the sheet.

   The sheet's arc reaches 28px down before it meets the sheet's own left
   and right edges. If the layer above stops at the sheet's top edge, the
   page wallpaper reappears through those 28px of shoulder and the effect
   inverts — the sheet reads as a hole in the page rather than as a card
   on the strip. So the layer above extends one full radius DOWN BEHIND
   the sheet, as surface that exists only to be covered and to show
   through the shoulders.

   IT IS A PSEUDO-ELEMENT, not padding plus a negative margin. Padding
   would have to be re-derived for every element that can occupy this slot
   (the strip and five banner shells, each with its own vertical padding),
   and a negative margin would drag the next sibling up with it. This
   takes no layout at all, and `background-color: inherit` means the
   shoulders show whichever surface is actually being sat on — the strip's
   grey normally, a banner's tint when a banner is up.

   TWO SELECTORS, BECAUSE THE HOST IS NOT ALWAYS THE STRIP. #dc-banners is
   `display: contents` (see view.tsx), so a sync / credits / new-change
   banner paints as a flex child of #dc-view and stands between the strip
   and the sheet whenever one is up.

   `:has(+ .dc-session-body)` cannot express that, and silently: an element
   with `display: contents` generates no box, but it is STILL A SIBLING —
   the property changes box generation, not the DOM. So #dc-banners itself
   matched that selector in both states, its ::after inherited the host's
   transparent background, and the extension never painted at all. Hence
   the two real hosts, named:

     - the strip, when #dc-banners is empty (which is the ordinary case), and
     - the LAST banner inside it, when it is not.

   A banner needs `position: relative` for the same reason both lift layers
   do: the sheet has to paint over the overhang, and positioned siblings
   paint after unpositioned ones. */
#dc-view > #dc-banners > * { position: relative; }
/* …AND THE LAST ONE DROPS ITS OWN BOTTOM EDGE, for the same reason the
   strip did when it became a lift layer. A banner shell carries `border-b`
   (see features/dev-chat/banners.tsx) to divide itself from the transcript
   below. The sheet draws its own `border-top` now, so that hairline became a
   SECOND rule at the same y — and, unlike the sheet's, one that does not
   follow the 28px arc, so it ran straight out across both shoulders and
   read as an amber box drawn around the banner. Worst in dark, where
   `dark:border-amber-900/50` on the near-black ground is the brightest
   thing on that line. Borders BETWEEN stacked banners are untouched; only
   the one that now abuts the sheet goes.

   `border-bottom-width`, not a colour override, so the sheet meets the
   banner with no seam at all. It beats Tailwind's `.border-b` on
   specificity (two ids and a pseudo-class against one class), which is what
   it takes here — /css/tailwind.css is linked AFTER this file, so an
   equal-specificity rule would lose on source order. */
#dc-view > #dc-banners > :last-child { border-bottom-width: 0; }
#dc-view > #dc-session-header:has(+ #dc-banners:empty)::after,
#dc-view > #dc-banners > :last-child::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 1.75rem;
  background-color: inherit;
}

.dc-chat-pane {
  min-width: 0; /* allow flex shrink so the chat pane truncates rather than overflows when the viewer is open */
}

.dc-spec-viewer {
  display: none;
  flex-direction: column;
  background: var(--bg-primary, #0b0b0e);
  color: var(--text-primary, #e4e4e7);
}

/* Wide viewports: side panel beside the chat. The width is set
   inline by JS (persisted from the previous drag) but clamped here
   so any stale localStorage value can't strand the chat at < 320px
   wide. */
.dc-spec-viewer.dc-spec-viewer-open {
  display: flex;
  width: 480px;
  min-width: 280px;
  max-width: calc(100vw - 320px);
  flex-shrink: 0;
}

/* Narrow viewports: fullscreen modal that floats over the chat. The
   inline `width` set by JS from the previous side-panel drag is
   overridden by `width: auto` so the modal still fills the screen.

   `inset: 0` means this panel covers the WHOLE screen — it escapes
   #app-view and the platform header alike — so it owns both insets
   itself. padding-top keeps the header row (version picker, the two
   Share buttons, ✕) out from under the status bar and the notch; the
   bottom half is carried by .dc-spec-viewer-body-wrap /
   .dc-spec-viewer-build-hint below, which need it in the side-panel
   layout too. */
@media (max-width: 1023px) {
  .dc-spec-viewer.dc-spec-viewer-open {
    position: fixed;
    inset: 0;
    padding-top: var(--platform-safe-top);
    width: auto !important;
    max-width: none;
    z-index: 50;
  }
}

/* Draggable divider between chat and viewer. Hidden by default,
   only visible on wide viewports when the viewer is open. */
.dc-spec-resizer {
  display: none;
  width: 4px;
  flex-shrink: 0;
  cursor: col-resize;
  background: var(--border, rgba(255,255,255,0.12));
  transition: background 120ms ease;
  touch-action: none;
}

.dc-spec-resizer:hover,
.dc-spec-resizer-active {
  background: rgb(139 92 246);
}

@media (min-width: 1024px) {
  .dc-spec-resizer-open { display: block; }
}

/* ── Staging preview side panel (#771) ────────────────────────────
   #dc-staging-panel is an EMPTY placeholder flex sibling of the chat
   pane: it only reserves layout space beside the chat. The preview
   itself stays in the fixed #staging-overlay, geometry-synced onto
   this slot by AppView.rebindStagingDock — mounting the iframe inside
   the re-rendered chat DOM would reload it on every renderChatView.
   Desktop-only: below 1024px the slot renders nothing and the preview
   uses the fullscreen overlay instead (AppView flips the mode via a
   matchMedia listener). */
.dc-staging-panel { display: none; }

@media (min-width: 1024px) {
  .dc-staging-panel.dc-staging-panel-open {
    display: block;
    width: 480px;
    min-width: 320px;
    max-width: calc(100vw - 320px);
    flex-shrink: 0;
  }
}

/* Draggable divider between chat and the docked staging preview —
   same behaviour as the spec-viewer resizer above. */
.dc-staging-resizer {
  display: none;
  width: 4px;
  flex-shrink: 0;
  cursor: col-resize;
  background: var(--border, rgba(255,255,255,0.12));
  transition: background 120ms ease;
  touch-action: none;
}

.dc-staging-resizer:hover,
.dc-staging-resizer-active {
  background: rgb(139 92 246);
}

@media (min-width: 1024px) {
  .dc-staging-resizer-open { display: block; }
}

/* Docked mode for the staging overlay (#771): the same fixed element,
   pinned over the placeholder slot instead of the whole screen. The
   inline top/left/width/height come from the geometry sync
   (AppView._syncStagingDockGeometry). Drops below platform modals /
   popovers (z-30 vs the fullscreen z-40) since it no longer needs to
   cover the app header. */
#staging-overlay.staging-overlay-docked {
  inset: auto;
  z-index: 30;
  border-left: 1px solid var(--border, rgba(255,255,255,0.12));
}

/* Fullscreen, the overlay is `inset: 0` — it covers the status bar, so
   its own chrome bar has to clear the notch. Docked, it is pinned over
   the in-page slot by the geometry sync and needs no inset at all,
   hence the :not(). Under-chrome is excluded for the same reason docked
   is: the platform header is above it and already holds the notch. */
#staging-overlay:not(.staging-overlay-docked):not(.staging-overlay-under-chrome) .staging-chrome-bar {
  padding-top: calc(0.5rem + var(--platform-safe-top));
}

/* THE STATUS-BAR BAND, UNTIL THE APP TAKES A TONE (#26). Inside the Homeroom
   app the status bar's clock and battery are drawn from the app's theme,
   which is the shell's appearance: dark glyphs on the light shell, which
   nobody could read on this bar's near-black. An app build that takes
   `setStatusBarTone` is told the ground here is dark and draws light glyphs
   (public/js/native-chrome.js); the first tone it accepts puts
   .native-status-bar-tone on <html>, and this rule stands down. Until then
   the band under the status bar is painted in the shell's own ground, the
   colour those glyphs were picked for. The two grounds are the head's
   critical <style> values (frontend/src/head.html): keep them in step.
   Only in the app (.in-native-webview): a browser's or an installed web
   app's status bar is not drawn from this theme. */
html.in-native-webview:not(.native-status-bar-tone) #staging-overlay:not(.staging-overlay-docked):not(.staging-overlay-under-chrome) .staging-chrome-bar {
  background-image: linear-gradient(#f4f2e4 var(--platform-safe-top), transparent var(--platform-safe-top));
}
html.dark.in-native-webview:not(.native-status-bar-tone) #staging-overlay:not(.staging-overlay-docked):not(.staging-overlay-under-chrome) .staging-chrome-bar {
  background-image: linear-gradient(#0b0d1b var(--platform-safe-top), transparent var(--platform-safe-top));
}

/* ── A session's preview sits UNDER that session's own bars ───────────
   The Streamlined Concept board's APP PREVIEW frame keeps the header and
   the session strip on screen and puts the preview below them, which is
   also what makes the strip's doing<->seeing switch a real toggle rather
   than a one-way door: the pencil segment has to be reachable from here.

   `top` is set by the component, which measures the strip's bottom edge;
   this rule owns everything that does not need a number. The z-index pair
   is deliberate and deliberately LOW: the chrome goes to 30 rather than the
   overlay to something below 0, and 30 is under the sheets' 40/50 so a
   Notifications or Messages backdrop still dims the header the way it dims
   everything else. */
#staging-overlay.staging-overlay-under-chrome {
  z-index: 20;
}
body:has(#staging-overlay.staging-overlay-under-chrome) #platform-header,
body:has(#staging-overlay.staging-overlay-under-chrome) #dc-session-header {
  position: relative;
  z-index: 30;
}

/* Same call the docked mode makes, for the same reason: "Back to session"
   is meaningless beside a session that never left the screen. The strip's
   pencil segment is the way back.

   THE `:has()` IS A SAFETY NET, NOT A REFINEMENT. Under-chrome hides this
   button AND leaves .staging-dock-only hidden, so the ONLY way out of the
   preview is the pencil in #dc-session-header. If that strip is not on
   screen there is no way out at all — which is exactly what happened while
   the under-chrome class was gated on `previewActive` alone, a flag
   ensureStaging publishes for every preview and not only a session's
   (features/staging/staging-overlay.tsx now gates on the route too).
   Hiding the last exit is worth making structurally impossible rather than
   conditional on two owners agreeing: no strip, no hide. */
body:has(#dc-session-header) #staging-overlay.staging-overlay-under-chrome #staging-back {
  display: none;
}

/* Docked chrome: "Back to session" makes no sense beside the still-
   visible session — the ×-close (.staging-dock-only) replaces it. */
#staging-overlay.staging-overlay-docked #staging-back { display: none; }
.staging-dock-only { display: none; }
#staging-overlay.staging-overlay-docked .staging-dock-only { display: inline-flex; }

/* The docked header is narrow — let the url label give way first. */
#staging-overlay.staging-overlay-docked #staging-url-label { min-width: 0; }

/* The "How to test" panel's sm:w-96 cap can exceed a 320px panel. */
#staging-overlay.staging-overlay-docked #staging-testing-panel { max-width: calc(100% - 16px); }

.dc-spec-viewer-header {
  display: flex;
  align-items: center;
  /* Wrap so the share buttons drop to a second line on narrow (phone)
     widths instead of overflowing horizontally and shoving the close
     button off-screen. Harmless on the wide side-panel layout where the
     row already fits. (#86 spec-viewer header overflow fix) */
  flex-wrap: wrap;
  gap: 8px;
  row-gap: 6px;
  padding: 8px 12px;
  border-bottom: 1px solid var(--border, rgba(255,255,255,0.12));
  flex-shrink: 0;
}

.dc-spec-viewer-close {
  /* margin-left:auto pins the close to the trailing edge of the first
     row so it stays top-right no matter what else wraps; flex-shrink:0
     guarantees it is never compressed or displaced off-screen. */
  margin-left: auto;
  flex-shrink: 0;
  font-size: 20px;
  line-height: 1;
  /* ~44px tap target for thumbs; padding keeps the glyph visually small. */
  width: 44px;
  height: 44px;
  padding: 8px;
  border: none;
  border-radius: 4px;
  background: transparent;
  color: var(--text-secondary, #a1a1aa);
  cursor: pointer;
}

.dc-spec-viewer-close:hover {
  color: var(--text-primary, #e4e4e7);
  background: var(--bg-secondary, #18181b);
}

/* Let the version dropdown shrink and ellipsise rather than forcing the
   header wide — a <select> won't shrink below its content's intrinsic
   width without min-width:0. Long labels (vN (latest) · date · PR #N)
   get a trailing "…" instead of overflowing the row. */
#dc-spec-viewer-version {
  min-width: 0;
  flex: 0 1 auto;
  max-width: 220px;
  text-overflow: ellipsis;
}

.dc-spec-viewer-body-wrap {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 12px;
}

/* The bottom inset belongs to whichever of these two is LAST — the
   build hint is conditional (only a non-empty latest version renders
   it, see _renderSpecViewer), so both need a rule and neither may
   double up. `:last-child` is what picks the right one: with the footer
   present the scroller keeps its plain 12px and the footer's own
   background fills the strip; with no footer the scroller carries it.
   Unconditional, not inside the fullscreen media query: since #app-view
   stopped padding, the side-panel layout reaches the true bottom edge
   too. */
.dc-spec-viewer-body-wrap:last-child {
  padding-bottom: calc(12px + var(--platform-safe-bottom));
}

/* #196: two-half spec tabs (User-facing / Technical). Rendered only
   when the doc contains both marker headings — legacy specs keep the
   single untabbed body (see public/js/spec-sections.js). */
.dc-spec-viewer-preamble {
  margin-bottom: 12px;
}

.dc-spec-viewer-tabs {
  display: flex;
  gap: 2px;
  margin-bottom: 12px;
  border-bottom: 1px solid var(--border, rgba(255,255,255,0.12));
}

.dc-spec-viewer-tab {
  font-size: 12px;
  font-weight: 500;
  padding: 6px 10px;
  border: none;
  background: transparent;
  color: var(--text-secondary, #a1a1aa);
  cursor: pointer;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}

.dc-spec-viewer-tab:hover {
  color: var(--text-primary, #e4e4e7);
}

.dc-spec-viewer-tab-active {
  color: var(--accent);
  border-bottom-color: currentColor;
  font-weight: 600;
}

.dc-spec-tab-empty {
  font-size: 12px;
  font-style: italic;
  color: var(--text-secondary, #a1a1aa);
}

/* Footer bar reminding the user that a spec is a plan, not a built
   change — they still need to ask the AI in chat to build it. Only
   rendered while viewing a non-empty draft (see _renderSpecViewer). */
.dc-spec-viewer-build-hint {
  flex-shrink: 0;
  /* Last element in the panel when it renders, so it owns the bottom
     inset — and it has a background of its own, which is what makes the
     strip read as an extension of the footer rather than a gap. */
  padding: 8px 12px calc(8px + var(--platform-safe-bottom));
  font-size: 12px;
  line-height: 1.4;
  color: var(--text-secondary, #a1a1aa);
  border-top: 1px solid var(--border, rgba(255,255,255,0.12));
  background: var(--bg-secondary, #18181b);
}

.dc-spec-viewer-body {
  font-size: 13px;
  line-height: 1.55;
  word-break: break-word;
  color: var(--text-primary, #e4e4e7);
}

.dc-spec-viewer-body > :first-child { margin-top: 0; }
.dc-spec-viewer-body > :last-child  { margin-bottom: 0; }
.dc-spec-viewer-body .dc-h3 { margin: 16px 0 6px; }
.dc-spec-viewer-body .dc-h4 { margin: 14px 0 5px; }
.dc-spec-viewer-body .dc-h5 { margin: 12px 0 4px; }
.dc-spec-viewer-body .dc-p,
.dc-spec-viewer-body .dc-ul,
.dc-spec-viewer-body .dc-ol { margin: 6px 0; }
.dc-spec-viewer-body .dc-blockquote {
  margin: 8px 0;
  padding: 4px 10px;
  border-left: 3px solid var(--border, rgba(255,255,255,0.18));
  color: var(--text-secondary, #a1a1aa);
}

.dc-spec-action-btn {
  font-size: 12px;
  padding: 6px 12px;
  border-radius: 6px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  background: var(--bg-secondary, #18181b);
  color: var(--text-primary, #e4e4e7);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}

.dc-spec-action-btn:hover:not(:disabled) {
  border-color: rgb(139 92 246);
}

.dc-spec-action-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* (#1012) The copy button's label swaps to "Copied!" / "Copy failed" for
   1.5s. Reserve the wider label's width so the flash can't reflow — or
   re-wrap, on a phone-width header — the row around it. */
.dc-spec-copy-btn {
  min-width: 108px;
  text-align: center;
}

/* (#86) "Share to user" popover, anchored under the spec-viewer header. */
.dc-spec-viewer-header {
  position: relative;
}

.dc-spec-share-pop {
  position: absolute;
  top: 100%;
  right: 12px;
  z-index: 30;
  width: 240px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  border-radius: 8px;
  background: var(--bg-primary, #09090b);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}

.dc-spec-share-pop.hidden {
  display: none;
}

.dc-spec-share-input {
  font-size: 12px;
  padding: 6px 8px;
  border-radius: 6px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  background: var(--bg-secondary, #18181b);
  color: var(--text-primary, #e4e4e7);
  outline: none;
}

.dc-spec-share-input:focus {
  border-color: rgb(139 92 246);
}

.dc-spec-share-suggestions {
  display: flex;
  flex-direction: column;
  max-height: 160px;
  overflow-y: auto;
}

.dc-spec-share-sug {
  font-size: 12px;
  text-align: left;
  padding: 5px 8px;
  border: none;
  border-radius: 4px;
  background: transparent;
  color: var(--text-primary, #e4e4e7);
  cursor: pointer;
}

.dc-spec-share-sug:hover {
  background: var(--bg-secondary, #18181b);
}

.dc-spec-share-error {
  font-size: 11px;
  color: rgb(248 113 113);
}

.dc-spec-share-error.hidden {
  display: none;
}

/* ── Group-chat spec_share card ────────────────────────── */

.gc-spec-card {
  /* Capped width so the card looks like an attachment, not a wall.
     560px is roughly the natural reading width for the title +
     attribution + ~4 lines of preview. The card stays full-width on
     narrow viewports because of the min(). */
  max-width: min(560px, 100%);
  margin: 6px 0;
  padding: 10px 12px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  border-left: 3px solid rgb(139 92 246);
  border-radius: 6px;
  background: var(--bg-secondary, #18181b);
}

.gc-spec-card-header {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 14px;
  font-weight: 600;
  margin-bottom: 4px;
}

.gc-spec-card-icon { font-size: 14px; }

.gc-spec-card-title {
  color: var(--text-primary, #e4e4e7);
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.gc-spec-card-attribution {
  font-size: 11px;
  color: var(--text-secondary, #a1a1aa);
  margin-bottom: 8px;
}

.gc-spec-card-snippet {
  /* Snippet sits flush in the card (no inset background) so rendered
     markdown — headings, lists, paragraphs — looks like document
     preview content rather than a code block. The bottom fade hides
     the truncation seam against the card's own background. */
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-secondary, #a1a1aa);
  margin-bottom: 8px;
  word-break: break-word;
  max-height: 7em;
  overflow: hidden;
  position: relative;
}

/* Tighten markdown spacing inside the snippet (the renderMarkdown
   helper adds margins geared toward chat bubbles). */
.gc-spec-card-snippet > :first-child { margin-top: 0; }
.gc-spec-card-snippet > :last-child  { margin-bottom: 0; }
.gc-spec-card-snippet .dc-h3,
.gc-spec-card-snippet .dc-h4 { font-size: 12px; margin: 2px 0; }
.gc-spec-card-snippet .dc-p,
.gc-spec-card-snippet .dc-ul,
.gc-spec-card-snippet .dc-ol { margin: 2px 0; }
.gc-spec-card-snippet .dc-code-block,
.gc-spec-card-snippet .dc-code-header { display: none; }

.gc-spec-card-snippet::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1.4em;
  background: linear-gradient(to bottom, transparent, var(--bg-secondary, #18181b));
  pointer-events: none;
}

.gc-spec-card-actions { display: flex; gap: 6px; }

.gc-spec-card-view {
  font-size: 11px;
  padding: 4px 10px;
  border-radius: 4px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  background: transparent;
  color: var(--text-primary, #e4e4e7);
  cursor: pointer;
}

.gc-spec-card-view:hover:not(:disabled) {
  border-color: rgb(139 92 246);
  color: rgb(139 92 246);
}

.gc-spec-pr {
  color: rgb(139 92 246);
  text-decoration: none;
}
.gc-spec-pr:hover { text-decoration: underline; }

/* Group-chat spec side-panel. Mirrors the dev-chat spec viewer
   (.dc-spec-viewer): the slot is rendered as a sibling of the chat
   pane inside the gc-tab-body flex row, so it occupies space within
   the chat tab — never covering the app header or the vote/issue
   panel that sits above the row.

   Two layouts driven by viewport width:
     ≥ 1024px → inline side panel beside the chat pane (flex-shrink:0,
                width = min(560px, 50vw)). Composer + typing
                indicator stay fully usable on the left.
     < 1024px → fullscreen overlay covering only the gc-tab-body
                area (`position: absolute; inset: 0`). The vote/issue
                panel + tab bar above stay visible; the chat
                composer is hidden underneath the panel until close.

   The slot itself is `display: none` until .gc-spec-side-panel-open
   is added, so an empty slot doesn't reserve any layout space. */
.gc-spec-side-panel {
  display: none;
  flex-direction: column;
  background: var(--bg-primary, #0a0a0a);
  color: var(--text-primary, #e4e4e7);
}

/* Wide viewports: side panel beside the chat. Width can be inline-
   styled by the drag handler (persisted to localStorage); CSS clamps
   so a stale value can't strand the chat too narrow. */
.gc-spec-side-panel.gc-spec-side-panel-open {
  display: flex;
  width: min(560px, 50vw);
  min-width: 280px;
  max-width: calc(100vw - 320px);
  flex-shrink: 0;
  border-left: 1px solid var(--border, rgba(255,255,255,0.12));
}

@media (max-width: 1023px) {
  /* Need a positioning context on the body row so absolute can pin
     to it instead of escaping to the viewport. Keeps the app header
     + vote panel visible above. */
  .gc-tab-body { position: relative; }

  /* Modal mode: the inline width set by JS from a previous side-
     panel drag is overridden so the modal still fills the available
     area. */
  .gc-spec-side-panel.gc-spec-side-panel-open {
    position: absolute;
    inset: 0;
    width: auto !important;
    min-width: 0;
    max-width: none;
    border-left: none;
    z-index: 50;
  }
}

/* Horizontal divider between the "App information and activity" panel
   and the chat below it. Renders as a plain 1px border when the panel
   is collapsed; the .gc-panel-resizer-draggable modifier (added when
   the panel is open) turns it into a 5px row-resize handle. */
.gc-panel-resizer {
  flex-shrink: 0;
  height: 1px;
  background: var(--border, rgba(255,255,255,0.12));
}

.gc-panel-resizer-draggable {
  height: 5px;
  cursor: row-resize;
  touch-action: none;
  transition: background 120ms ease;
}

.gc-panel-resizer-draggable:hover,
.gc-panel-resizer-active {
  background: rgb(139 92 246);
}

/* Draggable divider between chat pane and spec panel. Hidden by
   default, only visible when the panel is open AND the viewport is
   wide enough for side-panel layout. Same shape as the dev-chat
   spec-resizer for visual consistency. */
.gc-spec-resizer {
  display: none;
  width: 4px;
  flex-shrink: 0;
  cursor: col-resize;
  background: var(--border, rgba(255,255,255,0.12));
  transition: background 120ms ease;
  touch-action: none;
}

.gc-spec-resizer:hover,
.gc-spec-resizer-active {
  background: rgb(139 92 246);
}

@media (min-width: 1024px) {
  .gc-spec-resizer-open { display: block; }
}

.gc-spec-panel-header {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 12px 14px;
  border-bottom: 1px solid var(--border, rgba(255,255,255,0.12));
  flex-shrink: 0;
}

.gc-spec-panel-titlewrap { flex: 1; min-width: 0; }
.gc-spec-panel-title {
  font-weight: 600;
  font-size: 15px;
  color: var(--text-primary, #e4e4e7);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gc-spec-panel-subtitle {
  font-size: 11px;
  color: var(--text-secondary, #a1a1aa);
  margin-top: 2px;
}

.gc-spec-panel-close {
  background: transparent;
  border: none;
  color: var(--text-secondary, #a1a1aa);
  font-size: 22px;
  line-height: 1;
  cursor: pointer;
  padding: 0 6px;
  flex-shrink: 0;
}
.gc-spec-panel-close:hover { color: var(--text-primary, #e4e4e7); }

/* (#1012) "Copy markdown" in the shared-spec panel header. Matches
   .dc-spec-action-btn so the two spec surfaces read as one family; the
   min-width reserves room for the "Copy failed" label flash. */
.gc-spec-panel-copy {
  font-size: 12px;
  padding: 6px 12px;
  border-radius: 6px;
  border: 1px solid var(--border, rgba(255,255,255,0.12));
  background: var(--bg-secondary, #18181b);
  color: var(--text-primary, #e4e4e7);
  cursor: pointer;
  flex-shrink: 0;
  min-width: 108px;
  text-align: center;
  transition: background 120ms ease, border-color 120ms ease;
}

.gc-spec-panel-copy:hover { border-color: rgb(139 92 246); }

.gc-spec-panel-body {
  flex: 1 1 auto;
  min-height: 0;
  /* Last (and only) child of the panel, so it carries the bottom inset.
     Below 1024px the panel is `position: absolute; inset: 0` over
     .gc-tab-body, whose bottom edge is now the screen's. */
  padding: 14px 16px calc(14px + var(--platform-safe-bottom));
  overflow-y: auto;
  font-size: 13px;
  line-height: 1.6;
  word-break: break-word;
  color: var(--text-primary, #e4e4e7);
}

/* Markdown styling — same spacing as the dev-chat spec viewer. */
.gc-spec-panel-body > :first-child { margin-top: 0; }
.gc-spec-panel-body > :last-child  { margin-bottom: 0; }
.gc-spec-panel-body .dc-h3 { margin: 16px 0 6px; }
.gc-spec-panel-body .dc-h4 { margin: 12px 0 4px; }
.gc-spec-panel-body .dc-p,
.gc-spec-panel-body .dc-ul,
.gc-spec-panel-body .dc-ol { margin: 6px 0; }
.gc-spec-panel-body .dc-blockquote {
  margin: 8px 0;
  padding: 4px 10px;
  border-left: 3px solid var(--border, rgba(255,255,255,0.18));
  color: var(--text-secondary, #a1a1aa);
}

.gc-spec-panel-error {
  font-size: 13px;
  color: var(--text-secondary, #a1a1aa);
  font-style: italic;
}

/* ── Keyboard shortcut hints ───────────────────────────── */

.dc-kbd {
  /* --bg-tertiary and the primary ink: --bg-secondary IS the card in dark
     (#1c1c1e both) and the chip's inherited muted ink measured 1.9:1 on it. A
     keycap has to read as a key. */
  background: var(--bg-tertiary);
  color: var(--text-primary);
  border: 1px solid var(--border);
  border-radius: 3px;
  padding: 0 4px;
  font-size: 10px;
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
}

/* ── Scrollbar styling ─────────────────────────────────── */

.dc-messages-container::-webkit-scrollbar,
#gc-messages::-webkit-scrollbar,
#app-list::-webkit-scrollbar,
#home-screen::-webkit-scrollbar { width: 6px; }

.dc-messages-container::-webkit-scrollbar-track,
#gc-messages::-webkit-scrollbar-track,
#app-list::-webkit-scrollbar-track,
#home-screen::-webkit-scrollbar-track { background: transparent; }

.dc-messages-container::-webkit-scrollbar-thumb,
#gc-messages::-webkit-scrollbar-thumb,
#app-list::-webkit-scrollbar-thumb,
#home-screen::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }

.dc-messages-container::-webkit-scrollbar-thumb:hover,
#gc-messages::-webkit-scrollbar-thumb:hover,
#app-list::-webkit-scrollbar-thumb:hover,
#home-screen::-webkit-scrollbar-thumb:hover { background: var(--border-light); }

.dc-messages-container, #gc-messages, #app-list, #home-screen {
  scrollbar-width: thin;
  scrollbar-color: var(--border) transparent;
}

/* ── #21 Live app version pill ─────────────────────────── */

/* Sits in the shared header next to the app title. Low-emphasis chip
   showing which commit is currently deployed to prod; clicks open the
   associated PR on GitHub. Idle state is transparent + border-only so
   it sits quietly next to the app name instead of competing with it
   for visual weight; the --deploying / --stale modifiers below
   override `background` so their tinted states still draw the eye. */
.app-version-pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 3px 9px;
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  font-size: 11px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  text-decoration: none;
  line-height: 1;
  border: 1px solid var(--border-light);
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.app-version-pill:hover {
  /* Hover stays background-free; we promote the border + text to
     full strength so it still feels clickable without filling. */
  color: var(--text-primary);
  border-color: var(--border);
}

/* Home-tile per-app pill: stripped down to a barely-there caption
   in the bottom-right of the card. No border, no padding, no
   background, no internal status dot (the renderer's `quiet` flag
   already drops that — see AppView.renderAppVersionPillHTML). The
   tile's own status dot at the top covers the lifecycle signal, so
   this just whispers "<slug> · <sha>" in muted gray. Scoped via
   .app-card so the AppView header pill and platform pill keep their
   full chrome. */
.app-card .app-version-pill,
.app-card .app-version-pill:hover {
  background: transparent;
  border: none;
  padding: 0;
  color: var(--text-muted);
}
.app-card .app-version-pill:hover {
  /* Tiny lift on hover so the link target is still discoverable
     without re-introducing the heavier idle chrome. */
  color: var(--text-secondary);
}
.app-card .app-version-pill-name {
  color: var(--text-muted);
  font-weight: 400;
}
.app-card .app-version-pill-sep {
  color: var(--text-faint);
}
.app-version-pill-dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #10b981;
  box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.18);
}
.app-version-pill-label {
  letter-spacing: 0.02em;
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
}
/* Slug part of the per-app pill — slightly brighter than the SHA so the
   pill reads as "<app> · <commit>" at a glance and can't be mistaken
   for the platform-version pill sitting next to it. */
.app-version-pill-name {
  color: var(--text-primary);
  font-weight: 600;
}
.app-version-pill-sep {
  color: var(--border);
}

/* ── Platform-version pill states ─────────────────────────
   Same base shape as .app-version-pill; modifiers for the
   two non-idle states the platform pill can be in. */

/* "A new deploy is currently rolling out." Yellow tint + spinner. */
.app-version-pill--deploying {
  background: rgba(234, 179, 8, 0.12);
  border-color: rgba(234, 179, 8, 0.45);
  color: #facc15;
}
.app-version-pill--deploying:hover {
  background: rgba(234, 179, 8, 0.18);
  border-color: rgba(234, 179, 8, 0.6);
  color: #fde047;
}
.app-version-pill-spinner {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  border: 1.5px solid rgba(234, 179, 8, 0.25);
  border-top-color: #facc15;
  animation: app-version-pill-spin 0.9s linear infinite;
}
@keyframes app-version-pill-spin {
  to { transform: rotate(360deg); }
}

/* "Platform has rolled past the SHA this tab loaded against — reload to
   pick up the new client code." Click to reload; styled as a button. */
.app-version-pill--stale {
  background: rgba(31, 134, 255, 0.12);
  border-color: rgba(31, 134, 255, 0.45);
  color: var(--accent);
  cursor: pointer;
  font: inherit;
}
.app-version-pill--stale:hover {
  background: rgba(31, 134, 255, 0.2);
  border-color: rgba(31, 134, 255, 0.65);
  color: var(--accent-light);
}
.app-version-pill--stale .app-version-pill-dot {
  background: var(--accent);
  box-shadow: 0 0 0 2px rgba(31, 134, 255, 0.22);
  animation: app-version-pill-pulse 1.6s ease-in-out infinite;
}
@keyframes app-version-pill-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.5; }
}

/* ── Header height invariant ──────────────────────────────
   The platform shell has two authored top bars — #platform-header
   (signed in: home, app view, leaderboard, profile, settings, the admin
   console, …) and #landing-header (the anonymous shell) — and they must
   be the SAME height on every screen. Both are built the same way:

     pt-2 / pb-4 (8px above, 16px below) around a 28px CONTENT ROW = 52px,
     plus env(safe-area-inset-top) via .un-safe-top-extend (native.css)
     on the padding-top — whose kit value (0.75rem + inset) the #2305 rule
     at the end of this block trims to 0.5rem + inset for these two bars.

   #2305: "the top bar steals too much vertical space the applications
   can use". Two 4px cuts, identical on both bars so parity holds, and
   constant (the inset is added on top, as before) so --platform-header-h
   still describes the bar on every device: 12px → 8px above the controls,
   and 20px → 16px below them — still double the 4px clearance whose
   absence made pb-5 necessary (below). 60px → 52px.

   THE TWO PADDINGS DIFFER ON PURPOSE, and this block used to say `py-3
   around a 48px row = 72px` while the bar measured 52. Both halves of
   that were wrong: the row is 28px, and the padding is no longer
   symmetric.

   It is not symmetric because the bar carries `-mb-2`. That negative
   margin pulls whatever follows 8px UP into the bar, which is how the
   `rounded-b-2xl` notch gives every platform surface below it a rounded
   top for free (see the app-sheet block near #app-frame-host). The cost
   comes out of the bottom padding and nothing else: 12px of padding minus
   an 8px overlap left FOUR pixels between the controls and the next thing
   on the screen. A transparent screen root hid that. A raised sheet does
   not — inside an app and in a proposal session the chip, the bell and
   Improve sat on the lip of a 28px radius with a shadow coming up to meet
   them. `pb-5` restored 12px; #2305 settled on `pb-4`, which leaves 8px.

   Symmetry is not the invariant; PARITY is. Both bars carry the same two
   paddings, so both are the same height, and neither jumps as you sign in.

   It was 53px until the reskin: both bars carried a 1px `border-b`
   hairline, and the border box counted it. The widget language draws no
   rule under a top bar — the page ground runs to the top of the screen
   and the controls float on it — so the hairline came off BOTH bars in
   the same commit. Parity is the invariant here, not the constant.

   Neither header sets its own height — it's a flex row, so the row is
   `max(child heights)`. That made the height follow whichever child
   happened to be present, and it varied per screen: 53px on home, 55px
   inside an app (the App/Dev switch was 30px), 61px on a wide landing
   screen (the CTAs were 36px at `sm`), and only 45px on the native
   home screen, where html.in-native-webview hides #header-title (the
   28px `text-lg` line box that was silently holding the row open).

   So the row is pinned from BOTH directions, in the markup:

   - FLOOR: the lead back-button wrapper (the one child present in every
     state of both headers) carries `h-7` = 28px, which survives the
     title being display:none. On the landing bar it is also fixed-width
     (`w-7`): frontend/src/features/header/use-header-layout.ts measures
     it as the title's left side group.
   - CEILING: no direct child of either header may exceed 28px, and the
     children that could grow past it state their height OUTRIGHT rather
     than adding up to it — the Homeroom mark is `h-7` around a 26px
     tile (as the Improve pill it replaced was, #2718), the landing CTAs
     are `h-7 inline-flex items-center`. Padding arithmetic is what
     broke this twice back when the row was 28px: the old App/Dev
     switch's `py-1` segments + `p-0.5` track came to 28px only if you
     forgot its 1px border (it was 30px), and the bordered "Join
     waitlist" CTA was 30px next to its borderless 28px siblings for the
     same reason. Anything new added to a header gets the same 28px
     budget — declare `h-7` and centre inside it.

   Absolutely-positioned badges and dots (#notifications-badge,
   #improve-working-dot, #dev-console-badge, #header-menu-deploy-dot)
   are outside flow, contribute no height, and are MEANT to overflow
   the row — never add overflow:hidden here.

   tests/header-height-parity.test.js pins all of the above. */

/* #2305: the top padding the kit's .un-safe-top-extend sets (0.75rem + the
   inset) is trimmed to 0.5rem + the inset (the markup's pt-2) for the two
   platform bars only — the kit class stays on
   them (dapp.json and the safe-area tests select on it) and every other
   consumer keeps its 0.75rem. An id outranks the kit's class, and both are
   !important. */
@supports (padding: env(safe-area-inset-top)) {
  #platform-header.un-safe-top-extend,
  #landing-header.un-safe-top-extend {
    padding-top: calc(0.5rem + var(--un-safe-inset-top, env(safe-area-inset-top, 0px))) !important;
  }
}

/* #2509: the page-owned status-bar reservation is an INSTALLED-WEB-APP
   behavior, not a browser-tab one. Chrome on Android reports a nonzero
   env(safe-area-inset-top) inside a normal tab, where the strip already
   belongs to the browser chrome and the page never owns it — so the
   header sat on top of a doubled reservation and the controls floated
   one status bar's worth of blank page below the true top edge. The
   head's inline guard tags <html class="web-browser-chrome"> for every
   non-standalone, non-WebView load (see frontend/src/head.html), and
   this is the CSS half: zero the token the whole shell's top-inset
   arithmetic reads, and clamp the two platform bars to their ordinary
   8px pad. Standalone display modes and the native WebView (which the
   same guard excludes) keep the real reservation and behave exactly as
   before — installed PWA, fullscreen and minimal-ui never get the
   class, so nothing here can reach them. */
html.web-browser-chrome {
  --platform-safe-top: 0px;
}
@supports (padding: env(safe-area-inset-top)) {
  html.web-browser-chrome #platform-header.un-safe-top-extend,
  html.web-browser-chrome #landing-header.un-safe-top-extend {
    padding-top: 0.5rem !important;
  }
}

/* ONE ROW, CENTRED, ON EVERY DESKTOP SCREEN (#20 of the 19 Oct polish
   board). A community's pages already split the bar's 52px 12 and 12
   (#4237, near `.dev-ws-tabs`), and every other screen kept pt-2/pb-4, so
   the toggle, the bell, the H and the title sat 4px above the middle there
   and jumped 4px each time you went into a project or came out. From 768px
   every bar is 12 and 12, signed out as signed in, so parity holds and
   nothing moves.

   EXCEPT OVER A RUNNING APP: its sheet comes 8px up under the bar (-mb-2),
   and 12 under the controls is what leaves them 8px clear of its lip (the
   invariant above). A phone is untouched.

   THE TITLE IS THE ROW'S 28px, ITS WORDS CENTRED IN IT. They sit in an
   inline-flex box on `vertical-align: middle`: the wordmark's 20px box
   fitted the 24px line, a 24px line of words grew it to 25.7px, and the
   title moved 1px between Home and Messages. A taller line does not help
   (measured: 29.7px in a 28px line), so the title centres its one child,
   as the row centres the buttons; the child's own max-width still ends a
   long name in "…". */
@media (min-width: 768px) {
  body:not(:has(#app-view:not(.hidden)[data-app-surface="app"])) :is(#platform-header, #landing-header) {
    padding-bottom: 0.75rem;
  }
  #header-title {
    display: flex;
    align-items: center;
    height: 1.75rem;
  }
}
@media (min-width: 768px) {
  @supports (padding: env(safe-area-inset-top)) {
    body:not(:has(#app-view:not(.hidden)[data-app-surface="app"])) :is(#platform-header, #landing-header).un-safe-top-extend {
      padding-top: calc(0.75rem + var(--un-safe-inset-top, env(safe-area-inset-top, 0px))) !important;
    }
    html.web-browser-chrome body:not(:has(#app-view:not(.hidden)[data-app-surface="app"])) :is(#platform-header, #landing-header).un-safe-top-extend {
      padding-top: 0.75rem !important;
    }
  }
}

/* A FLAT TOP EDGE FOR THE INSTALLED LANDING. From iOS 26 an installed web app
   whose status bar is black-translucent (frontend/src/head.html, #1929) gets
   a Liquid Glass blur over the strip, and it runs about 38pt below the clock
   — straight through this bar's content row, which blurred the wordmark. The
   blur is reported to appear only when iOS cannot sample ONE colour at the
   page's top edge, and the landing root paints the grain, the washes and the
   star right up to it. So in installed mode the bar paints the ground solid
   through the inset and its 8px pad, then fades out by its bottom edge: the
   top edge is one colour and the wallpaper below loses no texture and gains
   no seam. Browser tabs (the wash in browser-scroller mode above) and the
   native WebView are not standalone display modes, so nothing here reaches
   them.

   THE BAR OVER A RUNNING APP IS CLEAR TOO (it takes the app's tone, #1945),
   so an app opened from its own home-screen icon, which keeps the header
   (restoreFromHash), had the wallpaper's washes at its top edge and the same
   blur through the H and the app's name. It gets the same band.
   --home-ground follows the app's tone (the wallpaper rules repaint it for a
   dark app under the light shell), so the band is the strip's colour. */
@media (display-mode: standalone) {
  #landing-header,
  body:has(#app-view:not(.hidden)[data-app-surface="app"]) #platform-header {
    background-image: linear-gradient(
      var(--home-ground) calc(var(--un-safe-inset-top, env(safe-area-inset-top, 0px)) + 0.5rem),
      transparent
    );
  }
}

/* ── Header title centering ───────────────────────────────
   `#header-title` is in flex flow by default (left-aligned, truncating
   from the right when the right group eats its width). When there's
   enough horizontal room for it to be perfectly viewport-centered
   without overlapping the back-btn area or the right group,
   frontend/src/features/header/use-header-layout.ts toggles `.is-centered`
   (it was public/js/header-layout.js) and the title flips
   to absolute positioning at exactly 50% of the header. The user-facing
   contract is: "centered if room, left-aligned otherwise". No
   in-between state where the title is truncated *while* centered. */
#header-title.is-centered {
  position: absolute;
  left: 50%;
  /* `top: auto`, not 50%: the bar is pt-2/pb-4 (8px over the row, 16px
     under it), so its middle is 4px below the row's and a title centred on
     the box sat 4px under the bell and the mark beside it (#4237). Left at
     auto, an absolute child of a flex container takes the row's own
     `items-center` position, safe-area padding included. */
  top: auto;
  transform: translateX(-50%);
  flex: none;
  text-align: center;
}

/* ── NOT WHILE THE DESKTOP RAIL IS UP (#2718) ──────────────────────────
   The hook above centres on the room it measures, and on a 1280px desktop
   with an empty left group there is room for anything — so the Homeroom
   wordmark landed in the middle of the window, floating over the content
   column with the rail's 224px to its left. It read as a mistake, because it
   is one: what the hook is centring against is the WINDOW, and the window
   stopped being the content area the moment a rail took a fifth of it.
   Left-aligned, the wordmark sits over the rail it belongs to and the bar
   reads the way the design draws it: mark left, alert and menu right.
   Expressed here rather than in use-header-layout.ts on purpose. The hook's
   job is "is there room", which is a measurement; "is there a rail" is a
   layout fact the stylesheet already knows, and a media query cannot get out
   of step with the one that draws the rail.

   AND NOT INSIDE AN APP EITHER (#2718 review). The rule above was keyed on
   the rail being up, which left exactly one live case for centring: a
   desktop window with no rail, which is a running app. So an app's name
   floated in the middle of the bar while every platform screen's sat left —
   the two states of one bar disagreeing about where a title goes, on the one
   navigation where they alternate. "Keep App titles left justified in the
   header, like the platform" is the whole of it, and dropping the `:has()`
   is the whole of the fix.

   WHICH LEAVES `.is-centered` INERT AT DESKTOP, and dead below it — the hook
   never sets it under CENTER_MIN_WIDTH_PX. The hook and its class stay for
   now rather than being ripped out mid-review: they are pinned by
   tests/header-title-centering.test.js and by the measurement contract
   use-header-layout.ts documents, and retiring a measured seam is its own
   change with its own evidence. Worth doing, and named here so it is not
   forgotten. */
@media (min-width: 768px) {
  #header-title.is-centered {
    position: static;
    transform: none;
    flex: 1 1 auto;
    text-align: left;
  }
}

/* THE IN-PAGE TITLE IS SHOWN IN THE NATIVE WEBVIEW TOO.
   It was hidden here on the reasoning that "the native shell already
   shows the screen name in its own chrome, so showing ours below it
   would just duplicate it" — and App.setHeaderTitle does post a
   `titleChanged` message to the shell's AppBar, so that was a real
   arrangement rather than a guess.
   On a device it reads as a screen with no title at all, which is the
   report that removed this rule. The in-page title is the one this
   codebase can see and style; the AppBar's is the app's, and if it is
   showing the name as well that is a duplicate worth fixing on the side
   that can be measured from here.
   NOTHING ELSE CHANGES. `.in-native-webview` still drives the safe-area
   and performance rules around it, `titleChanged` still goes out, and
   the title stays LEFT-ALIGNED on a phone by the ordinary geometry:
   use-header-layout.ts centres only when
   `titleNaturalW <= headerW - 2*(max(sideGroup) + gap)`, which a 390px
   viewport carrying the Improve button and the hamburger never
   satisfies for a real title. */

/* ── Mobile pill collapse ─────────────────────────────────
   On narrow viewports the per-card pills on Home eat into the space
   the app name needs, producing visible overlap. Hide them by default
   and only reveal a compact yellow spinner while a deploy is in
   flight, so users still see "this app is updating right now" feedback
   next to the name.

   HEADER SLIM-DOWN: this used to also collapse
   #platform-version-pill-slot, because it sat in the header and shoved the
   title around. It lives in the drawer FOOTER now (as plain .drawer-ver
   text), where there is room at every width, so it is deliberately NOT in
   the selector
   below — that collapse was the reason mobile users could never see
   build status at all. Only the home-card class slot still collapses. */
@media (max-width: 640px) {
  /* Collapse the slot itself (not just its inner pill) so the
     gap-2 on home-card rows goes away too — otherwise the app name
     would still be shoved over by the now-empty slot's margin. */
  .app-version-pill-slot:not(:has(.app-version-pill--deploying)) {
    display: none;
  }
  /* When a home-card pill IS rendered (deploying state), shed the
     chrome and the SHA label so only the yellow spinner remains —
     that lone glyph is the redeploying indicator. Scoped to the card
     slot so the drawer's full-fidelity pills are untouched. */
  .app-version-pill-slot .app-version-pill--deploying {
    padding: 0;
    border: none;
    background: transparent;
  }
  .app-version-pill-slot .app-version-pill--deploying .app-version-pill-label {
    display: none;
  }

  /* #122: no per-breakpoint icon hiding anymore — the secondary header
     actions live in the slide-out drawer at every width, and the
     remaining icons (dev console, feedback, work cog, notifications,
     hamburger) are visible at every width too. */
}

/* ── Drawer status pane ───────────────────────────────────
   Kudos + AI credit. The build/version lines used to live here too and
   moved to #drawer-footer (see below); what's left is two metered
   values that must not push their row label out of the 15rem panel.

   The row's text label never shrinks; the value slot absorbs the
   squeeze. Without min-width:0 a flex item refuses to shrink below its
   content's intrinsic width and would push the pane wider than the
   panel. */
#drawer-status-pane .text-sm {
  flex-shrink: 0;
}
/* Kudos / AI-credit values: plain text, no pill (a metered figure you
   can read at a glance shouldn't wear a badge). Mono digits so the
   numbers don't jitter as they tick.

   Wrapping is allowed on the value as a whole but forbidden INSIDE a
   figure: each part is a .drawer-meter-part with `nowrap`, so
   "limit $13.60/$20.00 · your key $4.50" — which cannot fit the 15rem
   panel on one line — breaks at the "·" instead of mid-number or being
   ellipsised into "your key $4…". The row's own `flex-wrap` drops the
   whole value below its label first; this is the second-level fallback.

   Horizontal alignment is NOT set here: it's inherited from the slot
   (see #ai-budget-slot's `text-right` in index.html), so a value that
   wraps stays right-aligned to the panel edge line by line instead of
   falling flush left under the row icon. */
.drawer-meter {
  /* inline-block, NOT inline-flex: the value is composed of several
     coloured <span>s and must stay ONE run of text. As flex items those
     spans become separate boxes, which is what makes
     `element.innerText` come back as "limit\n$13.60\n/$20.00" — a
     hazard for anything reading the rendered text (accessibility tools,
     the platform's own expectText checks). Inline flow keeps it a
     single string and lays out identically here. */
  display: inline-block;
  font-size: 11.5px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  line-height: 1.4;
  letter-spacing: 0.01em;
  text-decoration: none;
  max-width: 100%;
}
.drawer-meter-part {
  white-space: nowrap;
}
.drawer-meter-dim {
  color: var(--text-muted);
}
a.drawer-meter:hover .drawer-meter-strong {
  text-decoration: underline;
}

/* ── Drawer footer (bottom-anchored) ──────────────────────
   Web revision, installed mobile-app version, fork lineage, then the two app
   link rows. A particular dApp's SHA does not belong in this block.
   Version values are TEXT, not pills: the old "usernode · 1a2b3c4"
   pill overflowed the 15rem panel, and the project name was redundant
   next to an already-labelled row. A label + a mono value, right-aligned,
   reads as a footer signature, not a control. */
.drawer-ver-row {
  min-height: 26px;
  padding-top: 3px;
  padding-bottom: 3px;
}
/* The label names what the value IS ("Web revision" / "Mobile app version")
   in sentence case, not an uppercase-tracked micro-caption:
   at 10px + 0.06em the old "PLATFORM" read as decoration and left the
   value looking like a stray word rather than a version. */
.drawer-ver-label {
  flex-shrink: 0;
  font-size: 11px;
  font-weight: 500;
  color: var(--text-muted);
}
.drawer-ver-value {
  min-width: 0;
}
/* The value carries the weight here, so it must be READABLE — the
   earlier muted 11.5px rendering of a short value like `dev` was so
   faint on a phone that the row looked like a label with nothing after
   it. 12px, secondary text colour, and the mono face doing the "this is
   a version" work. */
.drawer-ver {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  max-width: 100%;
  padding: 0;
  border: none;
  background: none;
  font-size: 12px;
  font-weight: 500;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  line-height: 1.3;
  letter-spacing: 0.02em;
  color: var(--text-secondary);
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  transition: color 0.15s ease;
}
a.drawer-ver:hover {
  color: var(--text-primary);
  text-decoration: underline;
}
/* No-SHA builds (`dev`, or `staging` on a platform staging preview).
   Deliberately NOT muted: it's still the answer to "which version am I
   looking at?", and dimming it is what made the row look empty. */
.drawer-ver--dev {
  color: var(--text-secondary);
}
/* "A new deploy is currently rolling out" — amber + the shared spinner
   glyph, same signal vocabulary as .app-version-pill--deploying (which
   still dresses the home-tile pills) minus the pill chrome. */
.drawer-ver--deploying {
  color: #d97706;
}
.dark .drawer-ver--deploying {
  color: #facc15;
}
/* "Platform rolled past the SHA this tab loaded against" — a violet
   reload button, borderless like its siblings but underlined on hover
   so it still reads as actionable. */
button.drawer-ver--stale {
  color: var(--accent-light);
  cursor: pointer;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.dark button.drawer-ver--stale {
  color: var(--accent);
}
button.drawer-ver--stale:hover {
  text-decoration: underline;
}
/* The same "you are behind" violet, while the new build is still coming
   down — a SPAN, not a button, because a reload before the shell cache holds
   the update is the exact click that used to serve the old document back.
   Same class as the button so ImproveStatus.refreshDeployDot still lights the
   Improve dot: the platform HAS moved on, only the offer to switch is
   waiting. */
.drawer-ver--fetching {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  color: var(--accent-light);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.dark .drawer-ver--fetching {
  color: var(--accent);
}
/* The shared spinner rings in amber for the deploying state; here it has to
   ring in whatever the row's own colour is. */
.drawer-ver--fetching .drawer-ver-spinner {
  border-color: color-mix(in srgb, currentColor 30%, transparent);
  border-top-color: currentColor;
}
/* Fork lineage ("⑂ Forked from <name>") — amber, and NOT monospaced:
   it's a name, not a hash. */
.drawer-ver--fork {
  font-family: inherit;
  font-size: 11px;
  color: #b45309;
}
.dark .drawer-ver--fork {
  color: #fbbf24;
}
a.drawer-ver--fork:hover {
  color: #92400e;
}
.dark a.drawer-ver--fork:hover {
  color: #fcd34d;
}
.drawer-ver-spinner {
  flex-shrink: 0;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  border: 1.5px solid rgba(234, 179, 8, 0.3);
  border-top-color: currentColor;
  animation: app-version-pill-spin 0.9s linear infinite;
}
#drawer-footer #app-fork-badge-slot > * {
  max-width: 100%;
}

/* Slide-out drawer panel transition. The panel starts off-screen to
   the right; adding `data-open` slides it into view. The overlay
   fades in on the same timer via the sibling rule below. */
#header-menu-panel {
  /* Slides from the LEFT (Streamlined Concept) — it mirrors the hamburger,
     which leads the header's left group. */
  transform: translateX(-100%);
}
#header-menu-panel[data-open] {
  transform: translateX(0);
}
.header-menu-panel-transition {
  transition: transform 200ms ease-out;
}

/* ── The Improve panel is RETIRED (#2718 review) ───────────────────────
   Every rule that drew it stood here: a right-edge rail over the header on
   desktop, a bottom sheet below `sm`, its overlay's fade, and the corner and
   hairline the rail shared with the bell's. It was one element, two idioms,
   and its whole remaining content was two buttons — the session list the
   Workshop took earlier in this issue, and a build notice. A drawer you open
   to press a button is a tap to reach a tap, so the buttons, the notice and
   the view strip went up into the mark's menu and the drawer went with its
   CSS. `#apps-switcher-sheet` below is where they draw now.

   Two things it left behind on purpose. `.platform-sheet-adopted` still
   neutralises the fixed chrome by CLASS rather than by id, which is what
   made this block removable without touching the kit path at all; and the
   dim's geometry, which was never this panel's:

   ── THE HEADER IS INSIDE THE DIM ──────────────────────────────────────
   Every backdrop ships `inset-0`, so the bar is dimmed along with the page
   and a click on it dismisses the surface like a click anywhere else. There
   is no rule here, and that is the point: `inset-0` is what the markup
   already says, and a desktop override that lifted the dim off the bar was
   the whole of an arrangement this reverted.

   WHAT THAT COSTS, because it is not nothing. The backdrop is `z-40` over
   the header's `z-10`, so with a sheet open a press on the bell lands on
   the backdrop: the sheet closes and nothing else happens. Moving between
   two of them is two clicks, and the first one looks like it did nothing.
   That was the reported problem the offset fixed, and it comes back with it.

   It is the right trade anyway. A dim that stops 60px short of the top of
   the screen leaves a lit band of chrome above an open sheet, and a bar
   that is live while a modal surface is up is a claim the rest of the
   product does not make. One click to dismiss, one to open the next, is
   what a scrim means. */

/* ── App-context sheet (Streamlined Concept) ──────────────────────
   The surface behind the header's Homeroom mark. THREE presentations, one
   always-mounted element with the same `data-open` mechanics: a real kit
   sheet on touch, a bottom sheet below `sm`, and — at `sm`+ for a mouse — a
   POPOVER anchored under the mark that opened it. Only the last of those
   differs from the notifications rail below; read that block's comment for
   the mechanics, which transfer wholesale. */
/* IT HANGS FROM THE MARK, LIKE THE VOTE POPOVER (#2784).
   It was a right-edge rail, then a dropdown centred under the header's title
   chip — which was right while the chip was the trigger. The trigger moved to
   the Homeroom mark at the far right of the bar, and the panel stayed in the
   middle of the screen, dimming the page, a foot away from the button that
   opened it on a wide display. A menu belongs under its trigger, so this one
   now hangs off the mark the way the dev board's vote popover hangs off its
   button: right edges aligned, 6px below, no dim.
   THE POSITION IS MEASURED, not restated. features/app-context/index.tsx
   reads the mark's rect when the menu opens (and on resize) through the same
   lib/anchor-popover.ts arithmetic the vote popover uses, and writes the two
   custom properties below. The fallbacks are only what a frame would show if
   the mark were somehow missing: under the header, at its right content edge.
   NO DIM, AND THE HEADER STAYS LIVE. The mini-app hosts this menu imitates
   (Telegram Desktop/Web's ⋮, the kit's own `.un-popover`) drop an undimmed
   menu under the control on desktop; the scrim is what made this read as a
   dialog in the middle of the screen. The backdrop element stays mounted —
   below `sm` it is still the bottom sheet's dismissal target — but here it
   takes no pointer, so an outside click lands on whatever was clicked and the
   island's capture listener closes the menu on the way (the bell opens in one
   click, not two). lib/overlay-scrim.js paints nothing for it at this width.
   The height cap comes with the shape: the popover sits over content, so it
   stops above the fold and lets its own scroller (`#switcher-nav`, the one
   `flex-1 min-h-0` child) take the rest. It never flips above the mark —
   above the header is off the screen. */
@media (min-width: 640px) {
  #apps-switcher-sheet {
    top: var(--menu-anchor-top, calc(var(--platform-header-h) + var(--platform-safe-top)));
    left: var(--menu-anchor-left, calc(100vw - 25rem));
    right: auto;
    bottom: auto;
    /* 24rem, NARROWER than the 26rem notifications rail, on purpose. It was
       36rem while the menu headed with a horizontal strip of app tiles — the
       only part of it that used width. The strip retired, and what is left
       is rows (an icon, a word and a chevron pinned right), where the extra
       width was only a longer run from the label to its chevron. 24rem is a
       menu's width, a little wider than the vote popover's 312px because the
       rows carry longer labels ("Go to app discussion").
       max-width keeps it inside a narrow window with the 8px the placement
       clamps to on either side. */
    width: 24rem;
    max-width: calc(100vw - 1rem);
    max-height: calc(100dvh - var(--menu-anchor-top, calc(var(--platform-header-h) + var(--platform-safe-top))) - 0.75rem);
    border-width: 1px;
    border-color: var(--brand-line);
    /* The kit's own desktop menu radius (--un-radius-card, native.css), which
       is what this surface now is. */
    border-radius: 0.75rem;
    /* It grows out of the corner under the mark, so a small drop reads as
       coming from the button rather than from the header at large. */
    transform-origin: top right;
    /* CLOSED IS OPACITY, NOT AN EDGE. A rail hides by sliding off the side it
       is attached to; a popover has no edge to hide behind, and it paints
       ABOVE the bar (the bar is `z-10`, this is `z-50`). `visibility` is what
       takes the invisible panel out of hit-testing — opacity alone leaves a
       384px click-eater over the page — and it is transitioned at 0s so it
       lands after the fade on the way out and before it on the way in. */
    opacity: 0;
    visibility: hidden;
    transform: translateY(-0.25rem) scale(0.98);
    transition: transform 140ms ease-out, opacity 120ms ease-out,
      visibility 0s linear 140ms;
  }
  #apps-switcher-sheet[data-open] {
    opacity: 1;
    visibility: visible;
    transform: none;
    transition: transform 140ms ease-out, opacity 120ms ease-out,
      visibility 0s linear 0s;
  }
}
/* The BOTTOM SHEET's motion, and only its: the dropdown states a transition of
   its own (transform, opacity and the stepped visibility together), and an id
   selector outranks this class whatever the order. Kept on the element rather
   than folded into the `max-width: 639px` block because the kit-adopted path
   reads it too — `.platform-sheet-adopted` sets `transition: none`, which has
   to have something to turn off. */
/* `box-shadow` rides along with the slide because the modal dim is one of this
   element's shadow layers now (see `.dc-lift-panel`). Without it here the dim
   would snap on at the start of the open and off at the start of the close,
   which is the exact complaint the kit-adopted backdrop note records. */
.app-context-transition {
  transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}
#apps-switcher-overlay {
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease-out;
}
#apps-switcher-overlay[data-open] {
  opacity: 1;
  pointer-events: auto;
}
/* THE BACKDROP DISMISSES THE BOTTOM SHEET, AND ONLY THE BOTTOM SHEET.
   Below `sm` this is a sheet over the page and its backdrop is the tap
   target that closes it, exactly like the bell's. At `sm`+ the menu is an
   undimmed popover under the Homeroom mark (#2784, see the block above), and
   a backdrop that caught the pointer there would make every click outside
   the menu a dead click — the bell would take two presses. It stays mounted
   and keeps its `data-open` state; it just lets the pointer through, and
   features/app-context/index.tsx closes the menu on the outside click. This
   is a later, separate block rather than a line in the one above because
   the `[data-open]` rule it overrides has the same specificity, so source
   order decides. The kit's own desktop menus have no backdrop at all (the
   .un-popover block in native.css); this is that precedent, taken. */
@media (min-width: 640px) {
  #apps-switcher-overlay[data-open] {
    pointer-events: none;
  }
}
/* Below `sm` it is a bottom sheet, and these are its own geometry rather than
   overrides of the block above: the two media queries are disjoint now that
   the dropdown states its own, so nothing here is undoing anything. */
@media (max-width: 639px) {
  #apps-switcher-sheet {
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    /* GROWS WITH ITS CONTENT, up to a cap. A short sheet is short; a long one
       climbs to this cap and then its own scroller takes over (see
       .nav-sheet-scroll below). `dvh`, not `vh`: a mobile browser's collapsing
       toolbar must not push the foot of the sheet under itself.
       The cap used to be "nearly the whole screen" (3rem of ground showing),
       and a full app strip plus the destination rows reached it — a menu that
       comes up far enough to read as a screen rather than as an overlay. The
       `min()` keeps that safe-area subtraction as a ceiling while holding the
       sheet to 80% of the viewport, so the page behind it stays visible. */
    max-height: min(80dvh, calc(100dvh - env(safe-area-inset-top, 0px) - 3rem));
    border-top-width: 1px;
    border-top-left-radius: 1.75rem;
    border-top-right-radius: 1.75rem;
    transform: translateY(100%);
  }
  #apps-switcher-sheet[data-open] {
    transform: translateY(0);
  }
}

/* ── Notifications and Messages sheets ────────────────────────────────
   The bell and the speech bubble present the way the retired Improve panel
   did — right-edge slide-over at `sm`+ for a mouse, a bottom sheet below it,
   a real kit sheet on touch — because they stopped being screens. A screen reachable from
   every route has to answer "back to where?", and both of these answered
   "home", which was wrong every time they were opened from somewhere else.

   THEY ARE RAILS AND THE APP MENU IS NOT, which is the one place these three
   surfaces part company. Read the #apps-switcher-sheet block above for why:
   an inbox is a surface you dwell in and holds as many rows as there are, so
   a full-height column is the right shape for it; a menu of six destinations
   opened from a control in the middle of the bar belongs under that control.
   These take the same near-full-height cap the Improve panel does and let
   their own scroller take over. */
/* Over the bar, like the Improve rail — the two full-height rails cover the
   header and the app menu, being a dropdown anchored to the chip, does not.
   See the backdrop block above for what that costs and why.
   `#messages-sheet` is DEAD in this selector and is left alone rather than
   quietly removed here: Messages went back to being a screen (#messages-screen,
   see tests/shell-id-inventory.test.js), so the id matches nothing in the
   document any more. It survives in this file and nowhere else. Retiring it is
   a tidy-up of its own, not something to smuggle into a geometry change. */
#notifications-sheet,
#messages-sheet {
  top: 0;
  right: 0;
  bottom: 0;
  /* 26rem, NOT 24 (#2718 review). The filter strip grew a fourth tab —
     Agents, for what is running — and four of them plus the unread count's
     own parenthesis ran the last one, All, flush against the panel's edge.
     The strip scrolls (its scrollbar is hidden), so nothing was unreachable;
     it just read as cut off, which is the same thing to a reader. 2rem is
     what puts air back after the last tab at a two-digit count, and `max-width`
     still keeps the panel off a narrow window's far edge. */
  width: 26rem;
  max-width: 90vw;
  border-left-width: 1px;
  transform: translateX(100%);
}
#notifications-sheet[data-open],
#messages-sheet[data-open] {
  transform: translateX(0);
}
/* `box-shadow` rides along with the slide because the modal dim is one of this
   element's shadow layers now (see `.dc-lift-panel`). Without it here the dim
   would snap on at the start of the open and off at the start of the close,
   which is the exact complaint the kit-adopted backdrop note records. */
.nav-sheet-transition {
  transition: transform 200ms ease-out, box-shadow 200ms ease-out;
}
#notifications-sheet-overlay,
#messages-sheet-overlay {
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms ease-out;
}
#notifications-sheet-overlay[data-open],
#messages-sheet-overlay[data-open] {
  opacity: 1;
  pointer-events: auto;
}
@media (max-width: 639px) {
  #notifications-sheet,
  #messages-sheet {
    top: auto;
    left: 0;
    right: 0;
    bottom: 0;
    width: auto;
    max-width: none;
    /* `dvh`, not `vh`: a mobile browser's collapsing toolbar must not push
       the foot of the sheet under itself.
       Full height less the status bar and a sliver, matching the kit-adopted
       rule near the top of this file. See the note there for why the bell
       in particular gets a DEFINITE height rather than a cap, and why it
       stopped being two thirds (#5289). The same height on both paths is
       what keeps the surface one object at every width. */
    height: calc(100dvh - env(safe-area-inset-top, 0px) - 0.75rem);
    border-left-width: 0;
    border-top-width: 1px;
    border-top-left-radius: 1.75rem;
    border-top-right-radius: 1.75rem;
    transform: translateY(100%);
  }
  #notifications-sheet[data-open],
  #messages-sheet[data-open] {
    transform: translateY(0);
  }
}

#header-menu-overlay {
  opacity: 0;
  transition: opacity 200ms ease-out;
}
#header-menu-overlay[data-open] {
  opacity: 1;
}

/* ── Drawer theme selector: 3-segment toggle + sliding caret ──────
   The first control in the drawer. Segment faces reuse the segmented
   language the retired create-app modal's mode pills had verbatim, so
   the platform has ONE segmented look; the caret underneath is what's
   new here, and it's the affordance that says "this one is selected"
   without relying on the raised face alone.

   Positioning is entirely CSS off --theme-caret-index (0|1|2), which
   App.HeaderMenu._renderThemeButtons writes on the track. A
   thirds-width element translated by index * 100% lands centred under
   segment N at ANY track width — which matters because the panel is
   15rem as a desktop slide-over and --un-panel-width as a touch side
   drawer, and the index is written before the kit panel resizes it.
   Never replace this with an offsetLeft measurement. */
#drawer-theme-track {
  --theme-caret-index: 0;
}
.theme-seg {
  color: var(--text-muted);
  cursor: pointer;
}
.theme-seg:hover {
  color: var(--text-secondary);
}
/* #5285: the chosen segment wears the lit tokens, the palette's "where
   you are" (the lit tab, the rail row). It was a white face on the light
   track, white on zinc-100, which hardly showed. The tint fills it, the
   lit line rings it and the lit ink names it, in both themes: the dark
   tokens are a translucent blue on zinc-800 with a light blue label. */
.theme-seg.theme-seg-active {
  background: var(--lit-tint);
  color: var(--lit-ink);
  box-shadow: inset 0 0 0 1px var(--lit-line), 0 1px 2px rgba(0, 0, 0, 0.06);
  cursor: default;
}
/* The header App/Dev switch (#app-mode-switch) used to be styled here, in
   the same recessed-track / raised-face system as .theme-seg above. THE UI
   OVERHAUL retired the control, so the rules went with it — the theme
   selector is the platform's one segmented control again. */

/* Caret rail: spans the track, sits just below it. The track's parent
   row reserves the height with its pb-2.5 so the arrow can never collide
   with the row's bottom border. */
#drawer-theme-caret-track {
  position: absolute;
  left: 0;
  top: 100%;
  width: 100%;
  display: block;
  pointer-events: none;
}
#drawer-theme-caret {
  display: block;
  width: calc(100% / 3);
  height: 4px;
  transform: translateX(calc(var(--theme-caret-index) * 100%));
  transition: transform 180ms ease-out;
}
/* The arrow itself — an upward CSS triangle centred in the third.
   Never the segment's old white face, which vanished on a white surface
   in light mode. #5285 colours it with the lit ink, like the segment it
   points at, which reads against the page in both themes. */
#drawer-theme-caret::after {
  content: '';
  display: block;
  width: 0;
  height: 0;
  margin: 0 auto;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-bottom: 4px solid var(--lit-ink);
}
@media (prefers-reduced-motion: reduce) {
  #drawer-theme-caret {
    transition: none;
  }
}

/* The Members modal's visibility pills: the recessed-track look the
   retired create dialog wore before #1910 restyled it. The Members modal is
   not in the pane language yet. */
.members-vis-pill {
  color: var(--text-muted);
  cursor: pointer;
}
.members-vis-pill:hover {
  color: var(--text-secondary);
}
.members-vis-pill.active {
  background: var(--bg-primary);
  color: var(--text-primary);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.members-vis-pill:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The Members modal's invite status line: ok and err tones, which
   members-controller.js sets as classes. */
#members-invite-status.import-status--ok  { color: #10b981; }
#members-invite-status.import-status--err { color: #f87171; } }

/* ── Priority / assigned-person chips + dropdown (community-voted) ───── */
/* Strip the <button> UA chrome so an interactive chip is pixel-identical
   to the sibling <span> badge pills (💬 / ★ / #N): inherit the surrounding
   font FAMILY (a <button> otherwise brings its own), drop the native
   appearance/border, and let the shared `.dev-badge` box own the height,
   padding and type size. The utility `bg-…` class supplies the tint, so
   background stays none here. */
/* ── One geometry for every chip in a card's badge row ──────────────
   The row used to mix three sizes: Tailwind-sized attribute chips, the 💬
   badge, and the linked-issue pills, each computing its own height from
   its own padding + line-height. They now share ONE box so a row of them
   sits on a single baseline. The tint (bg + text colour) still comes from
   the per-value utility classes; only the geometry lives here. */
.dev-badge {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  gap: 2px;
  height: 20px;
  padding: 0 6px;
  border-radius: 6px;
  font-size: 10.5px;
  font-weight: 500;
  line-height: 1;
  white-space: nowrap;
  flex-shrink: 0;
}
button.dev-badge { cursor: pointer; }
.dev-badge:disabled { cursor: default; }
/* A long username must not take the whole badge row on its own — a kanban
   column gives the row about 165px, and "@some-long-username" alone can
   exceed that, pushing every sibling chip onto its own line. */
.dev-badge-name {
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 11ch;
}

/* NOTE the property here is `font-family`, NOT the `font` shorthand. The
   shorthand resets font-size / font-weight / line-height to their initial
   values, and this rule sits AFTER `.dev-badge` at equal specificity — so
   `font: inherit` silently threw away the badge geometry's 10.5px/500/1 and
   let every attribute chip inherit the card's 14px body text. It went
   unnoticed for exactly one release because the chips used to carry a
   Tailwind `text-[0.65rem]` utility, and tailwind.css loads last. Only the
   <button>'s UA font FAMILY needs overriding; the size, weight and
   line-height belong to `.dev-badge`. */
.attr-chip {
  cursor: default;
  font-family: inherit;
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  background: none;
}
button.attr-chip { cursor: pointer; }
/* A 32px-tall hit area on a touch screen (QA 2026-09-24 Q19). The chips are
   19px on a card and 24px in a change's hero, and they wrap into rows only a
   few px apart, so `.un-touch-target`'s 44px box would reach over the chip in
   the next row and steal its taps (the later chip's ::after paints on top).
   So the slop sits UNDER every chip: `z-index: -1` puts it in the row's own
   stacking context, below the chips themselves, which are positioned, and
   `isolation` makes the row that context. A chip's visible box always wins,
   and the slop only answers taps that land in the gaps around it. Only the
   height grows; a chip is never narrower than 32px. */
@media (pointer: coarse) {
  :is(.dev-card-meta, .dev-topic-hero-chips) { isolation: isolate; }
  button.attr-chip { position: relative; }
  button.attr-chip::after {
    content: "";
    position: absolute;
    z-index: -1;
    left: 0;
    right: 0;
    top: 50%;
    height: max(100%, 32px);
    transform: translateY(-50%);
  }
}

/* #489: the assignee initial-avatar inside a chip — a tiny circle carrying
   the assignee's first letter. The tint (bg + text colour) comes from a
   utility class chosen per-username in JS; sizing/centring live here. */
.attr-avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1rem;
  height: 1rem;
  border-radius: 9999px;
  font-size: 0.6rem;
  font-weight: 600;
  line-height: 1;
  flex-shrink: 0;
}
/* Unassigned placeholder — a dashed grey outline circle, no letter. */
.attr-avatar-empty {
  border: 1px dashed currentColor;
  opacity: 0.6;
}

/* Floating dropdown anchored under a chip. Body-mounted + position:fixed
   so it escapes the card's overflow clipping and survives feed re-renders. */
.attr-popover {
  z-index: 2000;
  min-width: 200px;
  max-width: 260px;
  max-height: 320px;
  overflow-y: auto;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
  padding: 4px;
}
.attr-pop-head {
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--text-muted);
  padding: 4px 8px 2px;
}
.attr-opt {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  padding: 6px 8px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--text-secondary);
  font-size: 12px;
  cursor: pointer;
  text-align: left;
}
.attr-opt:hover { background: var(--accent-tint); }
/* #780: custom category labels are free text up to 24 chars, so clip rather
   than wrap inside the 260px-max-width popover. The dot is a flex child and
   keeps its size; only the text span truncates. */
.attr-opt-label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* #780: separates the app's custom categories from the built-in block. */
.attr-pop-head-divided { border-top: 1px solid var(--border-light); margin-top: 4px; }
.attr-opt-right { display: inline-flex; align-items: center; gap: 6px; flex-shrink: 0; }
.attr-opt-count {
  font-size: 11px;
  color: var(--text-muted);
  background: var(--bg-secondary);
  border-radius: 9999px;
  padding: 0 6px;
}
.attr-dot { width: 8px; height: 8px; border-radius: 9999px; display: inline-block; }
.attr-pop-add { position: relative; padding: 6px; border-top: 1px solid var(--border-light); margin-top: 4px; }
.attr-pop-input {
  width: 100%;
  box-sizing: border-box;
  padding: 6px 8px;
  font-size: 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-input);
  color: var(--text-primary);
}
.attr-pop-addbtn {
  margin-top: 6px;
  width: 100%;
  padding: 6px;
  font-size: 12px;
  font-weight: 500;
  border: 0;
  border-radius: 8px;
  background: var(--accent);
  color: #fff;
  cursor: pointer;
}
.attr-pop-addbtn:hover { background: var(--accent-light); }
.attr-pop-suggest {
  margin-top: 4px;
  border: 1px solid var(--border-light);
  border-radius: 8px;
  overflow: hidden;
}
.attr-suggest-item {
  display: block;
  width: 100%;
  text-align: left;
  padding: 5px 8px;
  font-size: 12px;
  border: 0;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
}
.attr-suggest-item:hover { background: var(--accent-tint); }

/* "How voting works" explainer — the circular "?" button + inline link in
   the focused proposal view, and the popover they open. The popover reuses
   the .attr-popover visual treatment (border/radius/shadow, .attr-pop-head)
   so the two read as one component family. */
.voting-help-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  border-radius: 9999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  vertical-align: middle;
}
.voting-help-btn:hover { background: var(--accent-tint); color: var(--accent); border-color: var(--accent); }
.voting-help-link {
  border: 0;
  background: transparent;
  padding: 0;
  color: var(--accent);
  font: inherit;
  cursor: pointer;
  text-decoration: underline;
}
.voting-help-link:hover { color: var(--accent-light); }
.voting-help-hint { color: var(--text-muted); }

.voting-help-popover {
  z-index: 2000;
  box-sizing: border-box;
  width: 320px;
  max-width: calc(100vw - 16px);
  /* max-height is set inline by _openVotingHelpPopover to exactly the room
     available above/below the anchor, so the body scrolls internally rather
     than spilling off the viewport. This 70vh is only a fallback. */
  max-height: 70vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
  padding: 8px 10px 10px;
}
/* Keep the title anchored while the rules list scrolls under it. */
.voting-help-popover .attr-pop-head {
  position: sticky;
  top: 0;
  background: var(--bg-primary);
  padding: 2px 2px 4px;
}
.vh-live {
  border: 1px solid var(--border-light);
  border-radius: 8px;
  padding: 8px;
  margin-bottom: 8px;
  background: var(--bg-secondary);
}
.vh-live-title {
  font-size: 10px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-muted);
  margin-bottom: 3px;
}
.vh-live-body { font-size: 12.5px; color: var(--text-primary); line-height: 1.4; }
.vh-rules { font-size: 12px; color: var(--text-secondary); }
.voting-help-rules { list-style: disc; padding-left: 16px; margin: 0; display: flex; flex-direction: column; gap: 5px; line-height: 1.4; }
.voting-help-rules strong { color: var(--text-primary); font-weight: 600; }

/* ── The board's columns (#814, #4486) ──────────────────────────────
   Four columns on ONE grid, the same one the stage strip above them is laid
   on (`.dev-kanban-stages`, in All items' pinned head), so each step sits
   over its own column: 220px at the least and an equal share of the rest.
   When the window is too narrow for four columns of 220px the board scrolls
   sideways rather than squeezing them further (#4486), and the strip follows
   its scroll (card/dev-kanban.tsx `syncStrip`).

   Below 640px the board shows ONE column at a time, the one the strip's tab
   is on: presentation only, the markup always carries all four columns and
   JS only ever moves the `dev-kanban-col-active` class.

   `.dev-kanban-col` keeps its flex sizing for the board route's loading
   skeleton (card/skeleton.tsx), which lays the same classes out in a row
   before <DevKanban/> replaces it. */
.dev-kanban-col {
  flex: 1 1 0;
  min-width: 14rem;
}
#dev-kanban {
  display: grid;
  grid-template-columns: repeat(4, minmax(220px, 1fr));
  column-gap: 20px;
  align-items: start;
  overflow-x: auto;
  padding-bottom: 8px;
}
#dev-kanban > .dev-kanban-col { min-width: 0; }

@media (max-width: 639px) {
  #dev-kanban { display: block; overflow-x: hidden; }
  /* The floor lifts so the single visible column still fits a phone. */
  .dev-kanban-col {
    display: none;
    min-width: 0;
  }
  .dev-kanban-col.dev-kanban-col-active { display: block; }
}

/* ── Shared dev-chat transcript (read-only) ─────────────────────────────
   The "Dev chat" section on a shared session's / a proposal's topic page,
   painted by public/js/session-transcript.js. It reuses the dev chat's
   dc-* message and status classes so a shared transcript reads like the
   real chat; everything here is the read-only frame around them.

   Deliberately no composer/send styling exists for this surface — there
   is no composer to style (see the renderer's rule 2). */

.st-section {
  margin-top: 8px;
  border: 1px solid var(--border);
  border-radius: 10px;
  background: var(--bg-card-alt);
  overflow: hidden;
}

/* #2605: the dev session page in its READ-ONLY form. The Build door sends a
   reader who does not own the session here, and what they get is the
   published chat on the session's own page rather than a sheet folded under
   a card's discussion. `#dev-section` is a fixed, overflow-hidden flex
   column, so this — its only child — owns the scroll. */
.dev-session-read {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 12px 12px 0;
}
.dev-session-read > .st-section { margin-top: 0; }
.dev-session-read-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
}

.st-section-head {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px 10px;
  background: transparent;
  border: none;
  cursor: pointer;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-secondary);
  text-align: left;
}
.st-section-head:hover { background: var(--bg-code); color: var(--text-primary); }

/* Two glyphs rather than a rotated one, matching .dc-cc-attached-chevron's
   reasoning (a rotated triangle's bounding box flips and drags its visual
   center off the baseline). */
.st-caret { font-size: 16px; line-height: 1; color: var(--text-muted); width: 14px; text-align: center; }
.st-caret::before { content: '\25B8'; /* ▸ */ }
/* The section is a div + button (not a <details>), so aria-expanded on the
   header — which _toggleTranscript keeps in sync — is what flips the glyph. */
.st-section-head[aria-expanded="true"] .st-caret::before { content: '\25BE'; /* ▾ */ }

.st-readonly-tag {
  margin-left: auto;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
  white-space: nowrap;
}

.st-body { padding: 4px 10px 10px; }
.st-body[hidden] { display: none; }

.st-timeline { display: flex; flex-direction: column; }

/* Messages sit full-width here (no 92% inset): the topic page column is
   already narrower than the dev-chat pane, and the alignment asymmetry is
   what distinguishes the two speakers. */
.st-msg { max-width: 100%; }
.st-msg-who { color: var(--text-secondary); font-weight: 600; }
.st-msg-when { color: var(--text-muted); font-size: 10px; }

.st-agent-details { margin: 2px 0 2px 2px; }
.st-agent-body { padding: 2px 0 4px 8px; border-left: 1px solid var(--border-light); margin-left: 6px; }
.st-cc-summary { font-size: 12px; color: var(--text-secondary); padding: 2px 0 0 14px; }

.st-spec-card {
  border: 1px solid var(--border-light);
  border-radius: 8px;
  padding: 6px 8px;
  margin: 4px 0 4px 22px;
  background: var(--bg-card);
}
.st-spec-head { font-size: 0.9375rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); }
.st-spec-snippet { font-size: 12px; color: var(--text-secondary); }

/* Attachment chips are inert here — names only, no bytes (the sanitiser
   strips the ids). Cursor stays default so they don't read as links. */
.st-att-chip { cursor: default; }
.st-att-chip:hover { border-color: var(--border); }

.st-truncated,
.st-empty,
.st-error {
  font-size: 11px;
  color: var(--text-muted);
  font-style: italic;
  padding: 4px 0;
}

/* ────────────────────────────────────────────────────────────────────────
   Admin console — folded-in section styles (#860)

   The seven standalone admin pages each carried their own inline <style>
   block. Folded into the #admin console they share one document with the
   rest of the SPA, so every rule below is SCOPED to the section root that
   owns it. Nothing here is global.
   ──────────────────────────────────────────────────────────────────────── */

/* ── Health & status (was public/status.html) ────────────────────────── */

#admin-status-root .pill {
  display: inline-flex;
  align-items: center;
  gap: 0.375rem;
  padding: 0.125rem 0.5rem;
  border-radius: 9999px;
  font-size: 0.7rem;
  font-weight: 600;
  white-space: nowrap;
}
#admin-status-root .pill .dot { width: 0.5rem; height: 0.5rem; border-radius: 9999px; }
#admin-status-root .pill-running { background: rgba(16, 185, 129, 0.1); color: #34d399; }
#admin-status-root .pill-running .dot { background: #10b981; box-shadow: 0 0 6px #10b981; }
#admin-status-root .pill-stopped { background: rgba(245, 158, 11, 0.1); color: #fbbf24; }
#admin-status-root .pill-stopped .dot { background: #f59e0b; }
#admin-status-root .pill-missing { background: rgba(239, 68, 68, 0.1); color: #f87171; }
#admin-status-root .pill-missing .dot { background: #ef4444; }
#admin-status-root .pill-creating { background: rgba(99, 102, 241, 0.1); color: #818cf8; }
#admin-status-root .pill-creating .dot { background: #6366f1; }

/* Light-mode pill overrides — topochain's badge tints (50-shade bg,
   700-shade text). The shell toggles `.dark` on <html>, so target the
   light state via html:not(.dark). Dots stay solid and saturated. */
html:not(.dark) #admin-status-root .pill-running { background: #ecfdf5; color: #047857; }
html:not(.dark) #admin-status-root .pill-stopped { background: #fffbeb; color: #b45309; }
html:not(.dark) #admin-status-root .pill-missing { background: #fef2f2; color: #b91c1c; }
html:not(.dark) #admin-status-root .pill-creating { background: #eef2ff; color: #4338ca; }

#admin-status-root .mono,
#admin-node-root .mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
    "Liberation Mono", "Courier New", monospace;
}

/* The admin-only progressive-disclosure flag. On the standalone page this
   was `body:not(.is-admin) .admin-only`, which inside the SPA would hide
   .admin-only markup anywhere in the document — so the class now lives on
   #admin-status-root itself (set by AdminStatus.refresh from the payload's
   isAdmin), and the rule is scoped to that subtree. The DATA is already
   withheld server-side by src/services/status.js redact(); this only hides
   the now-empty headings. */
#admin-status-root:not(.is-admin) .admin-only { display: none !important; }

/* ── Analytics (was public/dashboard.html) + Estimator accuracy ──────── */
/* Both console sections share the .dc-* chart idiom: the estimator card was
   split out of Analytics into #admin/estimator (#898) and carries the same
   hover rects and (?) icons, so every selector below covers both roots. */

/* Transparent per-column hover targets in the SVG charts. The faint fill on
   hover indicates which column the tooltip is describing. */
#admin-analytics-root .dc-hover,
#admin-estimator-root .dc-hover { cursor: pointer; }
#admin-analytics-root .dc-hover:hover,
#admin-estimator-root .dc-hover:hover { fill-opacity: 0.10 !important; }

/* (?) info icon next to each chart heading. Hover/focus shows the floating
   explanatory tooltip (wired in admin-analytics.js / admin-estimator.js). */
#admin-analytics-root .dc-info,
#admin-estimator-root .dc-info {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1rem;
  height: 1rem;
  margin-left: 0.4rem;
  border-radius: 9999px;
  font-size: 0.7rem;
  line-height: 1;
  font-weight: 700;
  cursor: help;
  user-select: none;
  vertical-align: middle;
  background: rgba(31, 134, 255, 0.18);
  color: var(--accent);
}
#admin-analytics-root .dc-info:hover,
#admin-analytics-root .dc-info:focus,
#admin-estimator-root .dc-info:hover,
#admin-estimator-root .dc-info:focus { background: rgba(31, 134, 255, 0.32); outline: none; }

/* ── Merge debug (was public/debug.html) ─────────────────────────────── */

#admin-merges-root .step-detail { white-space: pre-wrap; word-break: break-word; }
#admin-merges-root .spin { animation: admin-merges-spin 1s linear infinite; }
@keyframes admin-merges-spin { to { transform: rotate(360deg); } }

/* ── App launch surface (#931) ────────────────────────────────────────

   Tapping an app used to animate in an empty #app-view (opaque zoom
   background) and then pop the app in once two round trips had resolved.
   The frame is now mounted and loading before the zoom's first frame,
   behind this cover: the app's own icon and name on the theme
   background, so the animation has a stable surface to expand and the
   user sees the app they tapped rather than a white rectangle.

   Opacity-only transitions, no blur / backdrop-filter (#787) — this
   runs concurrently with the kit's transform animation on mid-range
   phones, so it must not add a compositing layer that can't be
   promoted cheaply. */
.app-launch-host {
  position: relative;
}
.app-launch-cover {
  position: absolute;
  inset: 0;
  /* Above the frame it covers; both are the only children of the host. */
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  padding: 1rem;
  text-align: center;
  background-color: var(--bg-primary);
  transition: opacity 160ms linear;
}
.app-launch-cover--out {
  opacity: 0;
  /* The app underneath is already interactive at this point — don't let
     the fading cover swallow the first tap. */
  pointer-events: none;
}
/* Size and layout only: the face (background, ring, letter colour) comes
   from .app-icon-tile, which owns every icon tile on the platform. */
.app-launch-cover-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 4rem;
  height: 4rem;
  border-radius: 0.75rem;
  font-size: 1.5rem;
  font-weight: 600;
  overflow: hidden;
}
.app-launch-cover-name {
  font-size: 0.9375rem;
  font-weight: 600;
  color: var(--text-primary);
}
.app-launch-cover-note {
  font-size: 0.8125rem;
  color: var(--text-faint);
}
/* The spinner keeps its box while hidden. The column is centred, so a
   spinner that arrived at 500ms by leaving `display: none` lifted the icon,
   the name and the note by half its height mid-zoom. Invisible and still,
   it holds the room it will use. Two classes, so it outranks Tailwind's
   `.hidden`, which loads after this sheet. */
.app-launch-cover-spinner.hidden {
  display: block;
  visibility: hidden;
  animation: none;
}
/* Both app frames the cover can sit over: the signed-in App tab and the
   anonymous landing viewer. */
#app-iframe,
#app-viewer-frame {
  transition: opacity 140ms linear;
}
/* Reduced motion keeps the cover (it's information, not decoration) but
   swaps both cross-fades for an instant swap — _revealLaunch removes the
   cover outright on this path rather than waiting out a fade. */
@media (prefers-reduced-motion: reduce) {
  .app-launch-cover,
  #app-iframe,
  #app-viewer-frame {
    transition: none;
  }
}

/* ── Dev-chat "Run on" runner controls (#907) ─────────────────────────────
   The selector + chip that appear in the composer's status row when a
   local coding agent has attached to this session. Painted by
   DevChat._renderRunnerControls(); the host span is always in the markup
   and is empty (and therefore invisible, no gap, no min-height) for every
   session that has never had a machine attached. */
.dc-runner {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
}
.dc-runner:empty { display: none; }

/* Green so it reads as "connected", not as a warning: a turn running on
   the user's own machine is the state they asked for. */
.dc-runner-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  max-width: 220px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  padding: 2px 8px;
  border-radius: 999px;
  border: 1px solid rgba(34, 197, 94, 0.35);
  background: rgba(34, 197, 94, 0.12);
  color: var(--text-secondary);
  font-size: 11px;
  line-height: 1.4;
}
.dc-runner-chip::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #22c55e;
  flex: none;
}

/* The machine has gone. Same shape, drained of colour, and the dot stops
   claiming something live is happening. */
.dc-runner-chip-past {
  border-color: var(--border);
  background: var(--bg-code);
  color: var(--text-muted);
}
.dc-runner-chip-past::before { background: var(--text-faint); }

/* ── Offline mode: the visible state (#1021) ──────────────────────────
   `body.is-offline` is toggled by Offline._set() and is the single hook
   for every offline affordance. Before this, the only signal was an
   in-flow amber strip that the auth screens — `fixed inset-0 z-40`,
   fully opaque — painted straight over, so the exact moment a user most
   needed to know they were offline (staring at a sign-in form that
   cannot possibly succeed) was the one moment the strip was invisible. */

/* Height of the strip's text row; body reserves exactly this much so the
   shell below is not covered. The strip's own safe-area padding then
   lands on the header's safe-area extension — background over
   background, no content hidden — instead of shifting the whole shell. */
body.is-offline {
  --offline-strip-h: 1.75rem;
  padding-top: var(--offline-strip-h);
}

body.is-offline #offline-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Above the auth overlays (z-40) AND the drawer panel (z-50). */
  z-index: 60;
  padding-top: calc(var(--platform-safe-top) + 0.375rem);
  padding-bottom: 0.375rem;
  /* Opaque: it now floats over content instead of displacing it, and a
     translucent amber wash over a scrolling form is unreadable. */
  background-color: #fef3c7;
  color: #92400e;
}
/* darkMode: 'class' — the flag lives on <html>, put there before first paint
   by the inline window.Theme module in frontend/src/head.html. */
.dark body.is-offline #offline-banner {
  background-color: #451a03;
  color: #fcd34d;
}

/* The auth screens' corner Back link is pinned with an inline `top`, so
   clearing the strip needs the override. */
body.is-offline [data-auth-back] {
  top: calc(env(safe-area-inset-top, 0px) + 1rem + var(--offline-strip-h)) !important;
}

/* ── The mobile install strip (#1372) ─────────────────────────────────
   Offers the native app to a phone-browser visitor. Same shape as the
   offline strip above and for the same reason: the auth screens are
   `fixed inset-0 z-40` and fully opaque, so an in-flow strip would be
   painted over on exactly the screens an anonymous visitor arrives on.

   Everything here keys off `:not(.hidden)` rather than the body class,
   so the ONE thing that decides whether the strip is up is the `hidden`
   class the island toggles through its ref. The body class exists only
   to reserve the space, which is allowed to settle a frame later — a
   padding that lags cannot produce a wrongly-displayed strip, and a
   second display switch could. */
#mobile-install-banner:not(.hidden) {
  display: flex;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  /* Between the two layers, deliberately. ABOVE the anonymous screens
     (z-40) so the strip is visible on the landing screen — which is
     where an install offer is aimed — and BELOW the drawer and Improve
     panels (z-50) and the offline banner (z-60), because an install
     offer is the least important thing on the page and must never sit
     on top of a surface the user deliberately opened. */
  z-index: 45;
  box-sizing: border-box;
  /* Same trick as the offline strip: the safe-area padding lands on the
     header's safe-area extension — background over background — so only
     the text row has to be reserved below. */
  height: calc(var(--platform-safe-top) + var(--install-strip-h));
  padding-top: var(--platform-safe-top);
}

body.has-install-strip {
  padding-top: var(--install-strip-h);
}

/* Both strips up: this one sits below the offline banner, which has
   already covered the safe area. */
body.is-offline #mobile-install-banner:not(.hidden) {
  top: calc(var(--platform-safe-top) + var(--offline-strip-h));
  height: var(--install-strip-h);
  padding-top: 0;
}

body.is-offline.has-install-strip {
  padding-top: calc(var(--offline-strip-h) + var(--install-strip-h));
}

/* The six anonymous screens (#auth-landing-screen and its siblings) are
   `fixed inset-0 z-40`, so the `body` padding above does nothing for
   them: without this the strip would land squarely on the landing
   header's title and its Sign in / Join waitlist buttons — on exactly
   the screens an install offer is aimed at. Move the overlay instead.

   The offset is the strip's CONTENT row only, and deliberately not its
   full height. Each of these screens opens with a `.un-safe-top-extend`
   header, so the screen supplies its own notch band directly beneath the
   offset — and the strip, being opaque and above it, covers exactly that
   band. Content therefore starts where the strip ends: no overlap, and
   no blank inset-high gap either. Offsetting by the full height would
   make the screen re-reserve a notch the strip has already consumed.

   Reading --install-strip-h (never a bare env()) is what keeps this in
   step with the strip's own height, per the safe-area tokens at the top
   of this file. */
body.has-install-strip #auth-landing-screen,
body.has-install-strip #auth-login-screen,
body.has-install-strip #auth-register-screen,
body.has-install-strip #auth-waiting-screen,
body.has-install-strip #auth-waitlist-screen,
body.has-install-strip #auth-more-screen {
  top: var(--install-strip-h);
}

body.is-offline.has-install-strip #auth-landing-screen,
body.is-offline.has-install-strip #auth-login-screen,
body.is-offline.has-install-strip #auth-register-screen,
body.is-offline.has-install-strip #auth-waiting-screen,
body.is-offline.has-install-strip #auth-waitlist-screen,
body.is-offline.has-install-strip #auth-more-screen {
  top: calc(var(--offline-strip-h) + var(--install-strip-h));
}

/* The corner Back link needs NO offset of its own once the strip is up:
   it is pinned inside a screen that has already moved clear, and lands
   1rem below the strip on its own. The `body.is-offline` rule above
   exists for a banner overlapping a screen that stayed put, so in the
   combined case it double-counts the offline strip. Put the link back on
   its authored inline value. */
body.is-offline.has-install-strip [data-auth-back] {
  top: calc(env(safe-area-inset-top, 0px) + 1rem) !important;
}

/* Blocks that exist only to explain the offline state. Hidden — and
   inert — the rest of the time. */
.offline-only {
  display: none;
}
body.is-offline .offline-only {
  display: block;
}

/* Controls that cannot work without a network. Greyed and click-through-
   proof rather than removed, so the form the user already knows stays
   where they expect it and the reason is obvious. The submit handlers
   guard independently (auth-screens.js) — this is the visible half. */
body.is-offline [data-offline-disabled] {
  opacity: 0.45;
  pointer-events: none;
  cursor: not-allowed;
}

/* ── The venue selector in the session header (#1348) ─────────────────
   Where this session is built, stated and changed from the top right of
   the session area. It began (#1086) as a caption above the composer —
   "Building in X" with a "Change how this is built" link — which put it in
   the same strip as the meter, the runner and the budget menu and made it
   read as one more piece of composer furniture. In the header it is where
   a "what kind of session is this?" control is looked for, and it is
   outside the launchpad swap (#1281) that hides the composer entirely for
   the three hand-off venues.

   Quiet, not loud: a bordered chip in muted text, the venue name carrying
   the only emphasis. It shares the header with the session title, so it
   caps its own width and truncates rather than pushing the title out. */
.dc-venue-select {
  display: inline-flex;
  align-items: center;
  flex: none;
  gap: 0.25rem;
  max-width: min(45%, 14rem);
  padding: 0.1rem 0.4rem;
  border: 1px solid var(--border);
  border-radius: 0.375rem;
  background: none;
  color: var(--text-muted);
  font: inherit;
  font-size: 11px;
  line-height: 1.5;
  cursor: pointer;
}

.dc-venue-select:hover {
  border-color: var(--accent-light);
  color: var(--text-primary);
}

.dc-venue-select:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px rgba(31, 134, 255, 0.35);
}

/* Mid-turn the venue cannot change — the running turn is already in one.
   Keep the control's shape and contrast instead of fading it into an inert
   dropdown: the lock and status below explain why it cannot be opened. */
.dc-venue-select:disabled {
  background: var(--dc-raised);
  color: var(--text-muted);
  cursor: not-allowed;
}

.dc-venue-select:disabled:hover {
  border-color: var(--border);
  color: var(--text-muted);
}

.dc-venue-select:disabled .dc-venue-name {
  color: var(--text-muted);
}

/* On a phone the header already carries the back arrow, the title, the PR
   pill and the status pill, and 45% of 390px is more than the venue needs.
   10.5rem is not an aesthetic round number: it is the narrowest width at
   which the two "Your computer · …" venues are still told apart — below it
   both ellipsize to the same string, and the control stops answering the
   question it exists to answer.

   #1941: the second term is the strip's SECOND LINE. Below `sm` the strip
   wraps, and this control shares its line with the mode switch (7.5rem
   with its label) and the ⋯ (1.75rem), plus the two gaps between them
   (1rem): 10.25rem. A flex line is composed from hypothetical sizes, so a
   control that would only fit by shrinking does not shrink, it wraps — and
   the ⋯ would start a THIRD line. At 375px `100% - 10.5rem` is 11.4rem and
   the first term wins; the cap only bites below about 350px, where it
   trades a little of the venue's name for keeping the strip at two lines
   on the narrowest phones. */
@media (max-width: 30rem) {
  .dc-venue-select {
    max-width: min(10.5rem, calc(100% - 10.5rem));
  }
}

/* The venue's own name is the part worth reading; it is also the part
   that has to give way when the header is narrow. */
.dc-venue-name {
  min-width: 0;
  overflow: hidden;
  color: var(--text-primary);
  font-weight: 600;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.dc-venue-caret {
  flex: none;
  font-size: 9px;
  line-height: 1;
  opacity: 0.7;
}

/* Below Tailwind's sm the session name may take two lines, so a long name
   has room beside the provider and the PR link without a very long title
   consuming the chat. (#1940 put those controls back inline at every width;
   the full branch name stays on the title's tooltip.)

   #1941: THE STRIP IS TWO LINES ON A PHONE, and this basis is what breaks
   it. The strip wraps below `sm` (view.tsx). The title's hypothetical size
   is `100% - 4rem` — the strip's width less room for the PR number beside
   it — so the name and the PR number fill the first line and the first
   control that does not fit, the venue, starts the second. With `flex-1`'s
   zero basis every child fit on one line and the title took the leftovers,
   which at 375px were none: the venue (10.5rem), the mode switch and the ⋯
   are wider than the strip between them.

   The second line's own cap is on `.dc-venue-select` below (the 30rem
   rule): wrapping is decided on hypothetical sizes, so a control that
   would only fit by shrinking does not shrink — it wraps. */
@media (max-width: 639px) {
  #dc-session-header > .dc-session-title {
    white-space: normal;
    overflow-wrap: anywhere;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    flex-basis: calc(100% - 4rem);
  }
}

.dc-venue-busy {
  display: inline-flex;
  align-items: center;
  flex: none;
  gap: 0.15rem;
  color: var(--text-muted);
  font-size: 9px;
  font-weight: 600;
  line-height: 1;
}

.dc-venue-busy-icon {
  width: 0.7rem;
  height: 0.7rem;
  flex: none;
}

/* The slot above the composer, which now carries the fallback sentence
   and nothing else — empty on every session that got the venue it asked
   for, and collapsed rather than left as a gap above the composer. */
.dc-venue-slot {
  padding: 0.25rem 0.1rem 0.35rem;
}

.dc-venue-slot:empty {
  display: none;
}

/* "Your OpenRouter key is not set, so this turn ran on Homeroom · Claude"
   — the one place a venue the user did not choose is confessed. A whole
   sentence, so it lives above the composer where it can wrap, rather than
   in the header control that names the venue. */
.dc-venue-note {
  color: var(--text-muted);
  font-size: 11px;
  line-height: 1.4;
}

/* The grouped model/key selector. Both in-chat providers render it in the
   card; the other four venues have nothing to choose here. */
.dc-venue-detail {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  padding: 0 0.1rem 0.25rem;
}

/* It shares the card row with the runner and meter, so it brings no padding
   of its own. */
.dc-venue-detail-inline {
  padding: 0;
}

/* The session-card chip on the dev feed (#1087 card actions row). Names
   the venue a session is being built in, so the answer is visible from
   the list and not only from inside the chat. Imported proposals carry
   no chip — they have no session to build in. */
.dc-venue-chip {
  display: inline-flex;
  align-items: center;
  flex: none;
  max-width: 12rem;
  padding: 0.05rem 0.35rem;
  border-radius: 0.375rem;
  background: var(--bg-code);
  color: var(--text-muted);
  font-size: 0.65rem;
  line-height: 1.5;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── The Workshop's unfolded rows are the Activity feed's entries ────
   The Activity feed retired in favour of the Workshop (the Dev screen's
   lander, features/dev-board/workshop/workshop.tsx); its entry — the card,
   the replies under it and the reply box, as one sheet — is what a Workshop
   row unfolds into, so the rules below moved host (#dev-feed became
   #dev-workshop) and nothing else.
   Everything here is scoped to #dev-workshop on purpose. The row renderers
   (_renderIssueRow, _renderProposalCard, _renderMergedCard, …) are SHARED
   with the kanban columns, where a bordered tile is exactly right — a
   column of cards is what a board is. Scoping by ancestor is what lets one
   renderer draw a tile on the board and a stream row here with no branch
   in the JS and no second set of markup to keep in step.

   THE ROWS ARE CARDS, like every other row on this screen.

   They were full-bleed: the wrapper cancelled #dev-body's `px-3` so a
   separator could run edge to edge, and the three rules below stripped the
   row's border, radius and fill — on the argument that a stack of tiles
   reads better as a stream. On screen it did not. dev-feed.tsx renders
   `v.block` (the "Yours" section) OUTSIDE these wrappers, so those rows kept
   their card face and appeared directly above a de-carded stream: one list,
   two visual languages, a few pixels apart. The de-carded half also gave up
   the white surface this language separates figure from ground with, which
   on the #eaeaea page ground left the rows reading as bare text.

   So the utilities the row already carries — `rounded-2xl bg-white
   dark:bg-zinc-900 px-3.5 py-3 hover:bg-zinc-50` — now draw it, unopposed.

   ── Each entry is a SHEET ───────────────────────────────────────────────
   The card, the replies under it and the reply box are one thing, and they
   read as one thing now: the entry wears the session sheet's plane (the
   same 50% white frost the Settings and Messages panes lift on) with the
   card inside it. Before this the bubbles and the box lay directly on the
   strip, and where one item's thread ended and the next item's card began
   was the hardest line on the screen to see. 4px between sheets — the
   sheets carry the separation, the gap only has to admit there are two.

   A LANDED CHANGE is a plain card too. It used to wear an emerald wash and
   a 3px left edge as the feed's "the stream crossed a line" marker; its bar
   says "✓ Live" and its edge is green (the card's own `[data-edge="ok"]`,
   #3873), which is the same fact said once instead of three times. */
:is(#dev-workshop, #dev-kanban) .dev-feed-entry {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  border-radius: 26px;
  padding: 10px 10px 12px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
}
:is(#dev-workshop, #dev-kanban) .dev-feed-entry + .dev-feed-entry {
  margin-top: 4px;
}
/* #1885: THE CARD SITS FLUSH in its sheet. The sheet's padding is for what
   hangs UNDER the card — the thread, its reply box, "Open on its own page" —
   and those keep it (their own margins are measured from it). The card itself
   is pulled out to the sheet's edges, so opening a row no longer drops it into
   a frosted frame, and it takes the sheet's corners so the two outlines are
   one. With nothing under it the card is the whole sheet.

   #1884: the sheet is the Board's too now. Its cards carry the thread and the
   comment tail, and a reply box hanging on a column's background with no
   container around it is not the same control the Workshop offers — it is a
   third presentation of it. The `:only-child` rule is what keeps the Board's
   quiet cards looking untouched: a card with nothing under it still covers
   every edge of the sheet, so the frosting behind it never shows. What it
   does pick up is the sheet's 26px corner in place of its own 22px, which is
   the Workshop's open card exactly — one card, one open state. */
:is(#dev-workshop, #dev-kanban) .dev-feed-entry > div:is(.dev-card-dense, .dev-card-topic):first-child {
  width: calc(100% + 20px);
  margin: -10px -10px 0;
  border-radius: inherit;
}
:is(#dev-workshop, #dev-kanban) .dev-feed-entry > div:is(.dev-card-dense, .dev-card-topic):only-child {
  margin-bottom: -12px;
}
/* A reading column. The stream is one column of cards each with a thread
   under it; past ~760px a card's meta line and its bubbles were sentences
   too wide to read in one saccade, and the board strip beside them was
   empty. The strip's own padding closes to 4px at the sides so the sheets
   sit almost edge to edge on a phone, as the Messages list does.
   `width: 100%` for the LOADING state (#2880): `#dev-workshop:has(> .dev-ws)`
   below explains why a flex item centred by auto margins needs it, but that
   rule only matches once the Workshop has rendered. Until then the host holds
   the skeleton, and as a flex item it shrank to the skeleton's content — a
   72px sliver of grey tiles between the blank page and the Workshop. */
#dev-workshop { max-width: 760px; margin: 0 auto; width: 100%; }
/* The bottom padding is THE SAME GAP the rail sticks to, and that is what
   makes the two resting cases agree. When the lander fills the scroller the
   rail sits at its bottom edge, so the air below it is whatever `#dev-body`
   leaves; when the lander overflows, the air is `bottom` on the sticky rail.
   At 12 against 8 those differed by 4px depending on the tab — the same kind
   of "it moves between tabs" the gap token was introduced to end, just
   smaller. One value, both cases. */
#dev-body:has(> #dev-workshop) { padding: 8px 4px var(--ws-gap, 8px); }

/* ── Inline comments ───────────────────────────────────────────
   The tail of a thread, indented under the row it belongs to, with no box
   of its own — the indent and the muted type are the whole treatment. A
   bordered comment card inside a de-carded row would put the chrome back
   one level down.

   Scoped to BOTH card hosts (#1884). The comment tail and the reply thread
   below it used to be the Workshop's alone, so the same card opened from the
   Board had neither; both surfaces draw them now, from the same fold, and
   `:is()` takes its specificity from its most specific argument — so these
   rules land at exactly the weight they had when the id stood alone, and
   nothing in the cascade below them moves. */
:is(#dev-workshop, #dev-kanban) .dev-feed-comments:empty { display: none; }
:is(#dev-workshop, #dev-kanban) .dev-feed-comments {
  padding: 0 0.75rem 0.5rem 22px;
}
:is(#dev-workshop, #dev-kanban) .dev-feed-comment {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-size: 0.75rem;
  line-height: 1.5;
  color: rgb(82 82 91); /* zinc-600 */
  padding: 0.15rem 0;
}
.dark :is(#dev-workshop, #dev-kanban) .dev-feed-comment { color: rgb(212 212 216); /* zinc-300 */ }
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-main {
  flex: 1;
  min-width: 0;
}
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-author {
  font-weight: 500;
  color: rgb(63 63 70); /* zinc-700 */
}
.dark :is(#dev-workshop, #dev-kanban) .dev-feed-comment-author { color: rgb(228 228 231); }
/* The body sits INLINE after the author, like a chat line. Its markdown
   arrives as block elements, so they are flattened back to inline here —
   a paragraph per comment would otherwise put the text on its own line and
   undo the compactness that makes this worth showing inline at all. */
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-body { display: inline; }
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-body > * { display: inline; margin: 0; }
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-time {
  flex-shrink: 0;
  margin-left: auto;
  font-size: 0.6875rem;
  color: rgb(161 161 170); /* zinc-400 */
}
.dark :is(#dev-workshop, #dev-kanban) .dev-feed-comment-time { color: rgb(113 113 122); /* zinc-500 */ }
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-more {
  font-size: 0.6875rem;
  color: rgb(161 161 170); /* zinc-400 */
  padding: 0.15rem 0;
}
/* A long comment shows four lines and a "Show more" under them (#2556).
   -webkit-line-clamp is the only cross-browser way to ellipsise at a line
   COUNT; the standard `line-clamp` is listed alongside it, the same pair
   this stylesheet already writes elsewhere.

   The clamp is on a span WRAPPING the author and the body, not on the body:
   the body renders inline after the name here (see the rule above), so
   clamping it alone would count its first line from the wrong left edge.
   The React comment surfaces reach the same four lines through Tailwind's
   `line-clamp-4`; this one is hand-written because the slot is filled by
   innerHTML from public/js/app-view.js, which is not React's to reconcile. */
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-clamp {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 4;
  line-clamp: 4;
  overflow: hidden;
}
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-clamp.is-expanded {
  display: block;
  -webkit-line-clamp: unset;
  line-clamp: unset;
  overflow: visible;
}
/* The control ships `hidden` and only a measurement reveals it, so the rule
   that hides it has to be here rather than left to a utility that may or may
   not win against the flex layout above. */
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-toggle[hidden] { display: none; }
:is(#dev-workshop, #dev-kanban) .dev-feed-comment-toggle {
  margin-top: 4px;
  cursor: pointer;
}

/* ── The Activity feed's inline discussion ─────────────────────────────
   Indented to the same 22px as #dev-workshop .dev-feed-comments above it, so
   the GitHub preview and the app's own thread read as one comment area under
   the row rather than as two separate blocks at different depths. The reply
   box is the last line of it, at the same indent as the bubbles — the
   viewer's reply joins the conversation it sits under. On a phone the
   thread takes the sheet's full width. */
:is(#dev-workshop, #dev-kanban) .dev-feed-thread {
  margin: 6px 6px 0 22px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
@media (max-width: 639px) {
  :is(#dev-workshop, #dev-kanban) .dev-feed-thread { margin-left: 0; margin-right: 0; }
  :is(#dev-workshop, #dev-kanban) .dev-feed-comments { padding-left: 0; padding-right: 0; }
}
/* "N earlier replies", first in the thread and aligned with the bubbles
   (22px avatar + 8px gap), so it reads as the top of the conversation. */
:is(#dev-workshop, #dev-kanban) .dev-feed-earlier { padding-left: 30px; }
/* One reply, as a bubble — the Messages row at the feed's scale (see
   card/feed-thread.tsx MessageLine). The bubble is the card's white on the
   sheet; the viewer's own take the accent tint, as their sent messages do. */
.dev-feed-msg {
  display: flex;
  gap: 8px;
  align-items: flex-start;
  min-width: 0;
  font-size: 12.5px;
  line-height: 1.4;
}
.dev-feed-msg-avatar {
  flex: none;
  width: 22px;
  height: 22px;
  margin-top: 4px;
  border-radius: 999px;
  color: #fff;
  font-size: 10px;
  font-weight: 700;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.dev-feed-msg-bubble {
  min-width: 0;
  max-width: 85%;
  padding: 7px 12px 8px;
  border-radius: 16px;
  background: var(--bg-primary);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.dev-feed-msg-mine .dev-feed-msg-bubble { background: var(--accent-tint); }
.dev-feed-msg-head {
  display: flex;
  flex-wrap: wrap;
  gap: 0 6px;
  align-items: baseline;
  min-width: 0;
  margin-bottom: 1px;
}
/* QA 2026-09-24 Q22: the head stays inside its bubble. A GitHub comment's
   head is a name, a tag and a stamp that can carry a year ("Mar 5, 2024,
   09:15 AM"), and on a 360px phone the three in one unbreakable row ran past
   the bubble and widened the page. Now the name ends in an ellipsis before
   the stamp is pushed out, and past the 6em basis the stamp takes a second
   line of the head rather than leaving it. */
.dev-feed-msg-author {
  flex: 1 1 6em;
  min-width: 0;
  max-width: max-content;
  overflow: hidden;
  text-overflow: ellipsis;
  font-weight: 600;
  color: var(--text-primary);
  white-space: nowrap;
}
.dev-feed-msg-time { color: var(--text-muted); white-space: nowrap; }
.dev-feed-msg-text { color: var(--text-primary); }
/* The reply box: a 36px white pill field and the dev session's send disc
   (36px, filled accent, up-arrow), the pair every composer on the platform
   now shares. The field's own utilities (input.tsx `activityReply`) draw a
   bordered r16 box; this is the widget-language reading of the same field,
   and the host scope keeps it to the places it is read that way — the two
   card surfaces, and nowhere else. */
:is(#dev-workshop, #dev-kanban) .dev-feed-thread textarea {
  min-height: 36px;
  padding: 8px 14px;
  border-radius: 18px;
  border-color: transparent;
  font-size: 13px;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.dev-feed-send {
  width: 36px;
  height: 36px;
  border-radius: 9999px;
  background: var(--accent);
  color: var(--accent-ink);
  transition: background 150ms;
}
.dev-feed-send svg { width: 18px; height: 18px; stroke-width: 2.4; }
.dev-feed-send:hover:not(:disabled) { background: var(--accent-light); }
/* Nothing loaded and no box to draw (a viewer who cannot post) — collapse
   rather than leaving a gap under every row. */
:is(#dev-workshop, #dev-kanban) .dev-feed-thread:empty {
  display: none;
}


/* ═══════════════════════════════════════════════════════════════════════
   THE BOARD CARD, ROUND THREE — the dense card, on every board surface.

   Three ink levers, chosen against a survey of the alternatives: a GLYPH
   where there was a tinted tile, TWO type sizes (14px title, 12.5px for
   everything else) where there were five, and the metadata CHIPS as one
   muted text line where there were four filled boxes. Plus the two things
   that came out of the same sessions: the vote pair as ONE button beside
   the state bar, and a coloured left EDGE keyed to the bar's tone. What
   stays: the four bands, their order, every id and hook a declared check
   walks, and the reserve-and-clip contract on the action band — it is
   one line still, but a pill that does not fit now folds into ⋯ instead
   of vanishing (card/dev-card.tsx useFoldedActions).

   `div:is(.dev-card-dense, .dev-card-topic)`, not `.dev-card-dense`: the card's own geometry
   rides in as Tailwind utilities (`rounded-2xl px-3.5 py-3`) and
   tailwind.css loads last, so an equal-specificity class here would lose.
   ═══════════════════════════════════════════════════════════════════════ */
div:is(.dev-card-dense, .dev-card-topic) {
  /* Concentric with its controls: card r22, bar r14, pills r10/r999. */
  border-radius: 22px;
  padding: 14px 14px 12px;
  padding-left: calc(14px + var(--dev-edge-w));
  /* The edge is an inset shadow, so it follows the corner arc and takes no
     layout — the padding above is what keeps the content off it. A third
     strength at 4px was the pair chosen: enough colour to find the red one
     in a column without reading, not enough to tint the card. */
  --dev-edge-w: 4px;
  --dev-edge: transparent;
  box-shadow:
    inset var(--dev-edge-w) 0 0 color-mix(in srgb, var(--dev-edge) 33%, transparent),
    0 1px 2px rgba(0, 0, 0, 0.06);
  align-items: stretch;
}
/* The key: the bar's tone, and the card's TYPE where it has no bar
   (card/dev-card.tsx edgeFor). */
div:is(.dev-card-dense, .dev-card-topic)[data-edge="vote"]      { --dev-edge: var(--accent); }
div:is(.dev-card-dense, .dev-card-topic)[data-edge="progress"]  { --dev-edge: var(--state-progress); }
div:is(.dev-card-dense, .dev-card-topic)[data-edge="attention"] { --dev-edge: var(--state-attention); }
div:is(.dev-card-dense, .dev-card-topic)[data-edge="blocked"]   { --dev-edge: var(--state-blocked); }
div:is(.dev-card-dense, .dev-card-topic)[data-edge="ok"]        { --dev-edge: var(--state-ok); }
div:is(.dev-card-dense, .dev-card-topic)[data-edge="neutral"]   { --dev-edge: var(--state-neutral); }

/* ── Glyph, not tile ──
   The 36px tinted square becomes an 18px glyph in the type's colour. The
   tint utilities stay on the element (they carry the glyph's colour); only
   the box and the fill go. Top-aligned against a two-line title. */
:is(.dev-card-dense, .dev-card-topic) .dev-card-head { gap: 8px; align-items: flex-start; }
:is(.dev-card-dense, .dev-card-topic) .dev-card-head > .dev-card-icon {
  width: 22px;
  height: 22px;
  background: transparent;
  border-radius: 0;
}
:is(.dev-card-dense, .dev-card-topic) .dev-card-head > .dev-card-icon > svg { width: 18px; height: 18px; }

/* ── Two type sizes ──
   14px title, 12.5px for the meta line, the bar, the facts, the pills — the
   row's sizes, so the two sizes of one card share one typography. */
:is(.dev-card-dense, .dev-card-topic) .dev-card-title { font-size: 14px; }
:is(.dev-card-dense, .dev-card-topic) .dev-card-meta {
  font-size: 12.5px;
  /* The tags (priority, assignee, category) ride here beside the number and
     the author, and the line may wrap to a second one for them: the old
     one-line clamp (min/max 17.5px) is gone. 21px lines so a 19px chip sits
     on the line without pushing it. */
  min-height: 21px; max-height: none; line-height: 21px;
  white-space: normal; overflow: visible; text-overflow: clip;
}
:is(.dev-card-dense, .dev-card-topic) .dev-card-meta > .dev-badge {
  height: 19px; font-size: 11.5px; vertical-align: middle; margin-left: 4px;
}
/* Tabbed in under the title, as the folded row's meta line is: the row keeps
   its icon in a column of its own and the title and meta share the next, so
   the two sizes of one card put the number and the tags on the same left
   edge (#1787). The indent is the head's — the 22px glyph plus its 8px gap
   — and only a head that has a glyph earns it. The bands below stay at the
   padding edge: the bar wants the card's full width beside its vote button,
   and the pills already fold to fit. */
:is(.dev-card-dense, .dev-card-topic) .dev-card-head:has(> .dev-card-icon) + .dev-card-meta { padding-left: 30px; }
:is(.dev-card-dense, .dev-card-topic) .dev-card-meta > .dev-badge.font-mono { font-size: 11px; }
:is(.dev-card-dense, .dev-card-topic) .dev-card-meta > .dev-badge .attr-avatar { width: 14px; height: 14px; }

/* ── The status row, then the facts row ──
   Two rows, each its own element (card/dev-card.tsx), where one wrapping
   band with a break in it used to hold both. The status row is the bar with
   the vote button at its right end, spanning the card — the row's mini of
   it is a chip with the vote at the row's bottom-right. The facts row under
   it — the state chips and the message count — is tabbed in with the meta
   line, as the row's last line is, and clips at its one line. */
:is(.dev-card-dense, .dev-card-topic) .dev-card-badges { margin-top: 8px; gap: 4px 6px; }
:is(.dev-card-dense, .dev-card-topic) .dev-card-badges.dev-card-status {
  min-height: 0;
  max-height: 30px;
  flex-wrap: nowrap;
}
:is(.dev-card-dense, .dev-card-topic) .dev-card-badges.dev-card-facts {
  margin-top: 6px;
  min-height: 0;
  max-height: 22px;
  flex-wrap: nowrap;
}
:is(.dev-card-dense, .dev-card-topic) .dev-card-head:has(> .dev-card-icon) ~ .dev-card-facts { padding-left: 30px; }
/* The bar: 30px, r14, no outline (the wash is the surface), and its label
   at the second type size. `flex-basis: 0`, not auto: the band WRAPS, and a
   wrapping flex line breaks on each item's hypothetical size — with basis
   auto the bar's basis was its full label ("Resolving conflicts…"), which
   pushed the vote button onto a second, clipped row in a kanban column.
   From 0 it grows into what the button leaves, and the label ellipsises. */
:is(.dev-card-dense, .dev-card-topic) .dev-status-pill-block {
  flex: 1 1 0;
  min-width: 5rem;
  height: 30px;
  padding: 0 10px;
  border: 0;
  border-radius: 14px;
  font-size: 12.5px;
  font-weight: 500;
}
/* The label ellipsises as a BLOCK: `text-overflow` acts on a block
   container's inline content, not on a flex container's items, so the
   capsule's flex label clipped "Conflict resolution failed" mid-letter. */
:is(.dev-card-dense, .dev-card-topic) .dev-status-pill-block .gc-vote-count-label {
  display: block;
  line-height: 30px;
}
/* An open vote is the accent, not the pipeline's violet: blue while the
   group is deciding, the accent fill sweeping under the label as Yes votes
   arrive. The tier's `progress` tone still names the state; only the
   surface changes. */
:is(.dev-card-dense, .dev-card-topic) .dev-status-pill-block.dev-status-pill-vote { background: var(--accent-tint); }
:is(.dev-card-dense, .dev-card-topic) .dev-status-pill-vote .gc-vote-fill-yes {
  background: color-mix(in srgb, var(--accent) 18%, transparent);
}
:is(.dev-card-dense, .dev-card-topic) .dev-status-pill-vote .gc-vote-count-dot-ping,
:is(.dev-card-dense, .dev-card-topic) .dev-status-pill-vote .gc-vote-count-dot-core { background: var(--accent); }

/* ── The vote button ──
   One 30px r14 button beside the bar, and the accent FILLED until the viewer
   has voted: it is the one thing on a promoted card a member is meant to do,
   and as a neutral well it read as one more chip. The same fill the + and
   Improve buttons wear, on the board, the Workshop and the proposal page
   alike. A cast Yes recedes to the accent tint (done, not to-do), No wears
   the blocked tint, and a caret rides every state because the vote can
   always be changed. */
.dev-vote-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 5px;
  height: 30px;
  /* As wide as its "✓ Yes" face, so casting a vote does not change the
     button's width. It did, and the band re-measured around the new width
     and folded or unfolded a pill beside it: the row moved under the tap. */
  min-width: 78px;
  padding: 0 8px 0 11px;
  border: 0;
  border-radius: 14px;
  background: var(--accent);
  color: var(--accent-ink);
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  cursor: pointer;
  transition: background 150ms, color 150ms;
}
.dev-vote-btn svg { width: 14px; height: 14px; }
/* B7: on a project that is just yours, "Approved" is the one face with no
   caret (nothing is left to pick), so its sides are even. "Approve" opens the
   picker like any vote and keeps the caret's padding (#3977). */
.dev-vote-btn-approve[data-vote-btn="approved"] { padding: 0 11px; }
.dev-vote-btn .dev-vote-caret { width: 12px; height: 12px; opacity: 0.7; }
.dev-vote-btn:hover { background: var(--accent-light); }
.dev-vote-btn-yes, .dev-vote-btn-yes:hover { background: var(--accent-tint); color: var(--accent); }
.dev-vote-btn-no, .dev-vote-btn-no:hover { background: var(--state-blocked-bg); color: var(--state-blocked); }
/* #3984: a vote on its way reads "Sending…" and takes no press until the
   server answers. */
.dev-vote-btn[aria-busy="true"] { cursor: progress; opacity: 0.75; }
/* The picker: body-mounted and fixed, like .dev-card-menu, because the
   kanban columns scroll sideways and would clip it. */
.dev-vote-pop {
  position: fixed;
  z-index: 2000;
  width: 312px;
  box-sizing: border-box;
  padding: 10px 10px 8px;
  border-radius: 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
/* The picker's top: a "Your vote" header, then one two-way switch across
   its width, the chosen half filled — accent for Yes, the blocked tint for
   No — each carrying its tally. Yes is on when the picker opens (the
   viewer's own vote when they have one), so a Yes is one click from here.
   The header is the twin of the label over the box (same size, weight and
   ink), 2px further in because that label sits inside the reason block's
   own inset, so the two line up. */
.dev-vote-switch-label {
  display: block;
  padding: 0 6px 4px;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--text-muted);
}
/* One line under "Your vote" (VotePicker) and beside the legacy vote rows
   (AppView._uncountedVoteNoteHtml): a test account's vote on an app a real
   person made is recorded but not counted, and — #4747 — a viewer outside
   the people who decide on a project that only some decide has an advisory
   vote: recorded and shown, but it does not decide the change. */
.dev-vote-uncounted {
  margin: 0;
  padding: 0 6px 6px;
  font-size: 11.5px;
  color: var(--text-muted);
}
.dev-vote-sheet .dev-vote-uncounted { padding: 0 4px 8px; font-size: 14px; }
.gc-vote-uncounted {
  display: inline-block;
  margin-left: 6px;
  font-size: 12px;
  color: var(--text-muted);
}
.dev-vote-switch {
  display: flex;
  gap: 2px;
  padding: 2px;
  border-radius: 9999px;
  background: var(--bg-tertiary);
}
.dev-vote-switch-opt {
  flex: 1 1 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  height: 34px;
  padding: 0 10px;
  border: 0;
  border-radius: 9999px;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  font-size: 13px;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
}
.dev-vote-switch-opt svg { width: 14px; height: 14px; }
.dev-vote-switch-opt .dev-vote-n {
  font-size: 11px;
  font-weight: 500;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
.dev-vote-switch-opt:not([aria-pressed="true"]):hover { background: var(--bg-primary); }
.dev-vote-switch-yes[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); }
.dev-vote-switch-yes[aria-pressed="true"] .dev-vote-n { color: var(--accent-ink); opacity: 0.75; }
.dev-vote-switch-no[aria-pressed="true"] { background: var(--state-blocked-bg); color: var(--state-blocked); }
.dev-vote-switch-no[aria-pressed="true"] .dev-vote-n { color: var(--state-blocked); }

/* ── #1688: the line under the switch ──
   Always there, never a second step: the label above the box says what to
   write for the side that is on, the box holds a sentence, and Cancel sits
   beside the one button that sends the vote — "Vote yes" or "Vote no" with
   the switch. A No's button stays dim until there is a line, so nothing has
   to say "required". */
.dev-vote-reason {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 8px;
  padding: 0 2px;
}
.dev-vote-reason-label {
  display: block;
  padding: 0 4px 2px;
  font-size: 11.5px;
  font-weight: 500;
  color: var(--text-muted);
}
.dev-vote-reason-box {
  display: block;
  width: 100%;
  box-sizing: border-box;
  resize: none;
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 8px 10px;
  background: var(--bg-input);
  color: var(--text-primary);
  font: inherit;
  font-size: 13px;
  line-height: 1.35;
}
.dev-vote-reason-box:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.dev-vote-reason-actions {
  display: flex;
  gap: 6px;
  justify-content: flex-end;
  padding: 8px 0 0;
}
.dev-vote-reason-cancel, .dev-vote-reason-send {
  height: 28px;
  padding: 0 12px;
  border: 0;
  border-radius: 10px;
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
}
.dev-vote-reason-cancel { background: transparent; color: var(--text-muted); }
.dev-vote-reason-cancel:hover { background: var(--bg-tertiary); }
.dev-vote-reason-send-yes { background: var(--accent); color: var(--accent-ink); }
.dev-vote-reason-send-no { background: var(--state-blocked-bg); color: var(--state-blocked); }
.dev-vote-reason-send:disabled { opacity: 0.5; cursor: default; }
/* The same picker as a kit bottom sheet on touch (#1688 follow-up): the two
   rows at tap-target size and the line box INLINE under them, in the one
   sheet — not a native action sheet followed by a second prompt card. The
   box is 16px so iOS does not zoom on focus; the kit's `--un-kb-inset`
   lifts the sheet above the keyboard. */
.dev-vote-sheet { display: flex; flex-direction: column; gap: 4px; padding: 4px 12px 8px; }
.dev-vote-sheet .dev-vote-switch-opt { height: 44px; padding: 0 14px; font-size: 15px; }
.dev-vote-sheet .dev-vote-switch-opt svg { width: 16px; height: 16px; }
.dev-vote-sheet .dev-vote-switch-opt .dev-vote-n { font-size: 12.5px; }
.dev-vote-sheet .dev-vote-switch-label { padding: 0 4px 4px; font-size: 14px; }
.dev-vote-sheet .dev-vote-reason { margin-top: 10px; gap: 6px; }
.dev-vote-sheet .dev-vote-reason-label { padding: 0 2px 2px; font-size: 14px; }
.dev-vote-sheet .dev-vote-reason-box { padding: 10px 12px; border-radius: 12px; font-size: 16px; }
.dev-vote-sheet .dev-vote-reason-actions { padding-top: 10px; gap: 8px; }
.dev-vote-sheet .dev-vote-reason-cancel, .dev-vote-sheet .dev-vote-reason-send { height: 44px; padding: 0 18px; border-radius: 12px; font-size: 15px; }
/* The Needs you feed's vote sheet (#3613) draws the same picker inline, at
   the same tap-target size: the switch, the line box, Cancel and the send. */
.dev-ws-vote-form { display: flex; flex-direction: column; gap: 4px; }
.dev-ws-vote-form .dev-vote-switch-opt { height: 44px; padding: 0 14px; font-size: 15px; }
.dev-ws-vote-form .dev-vote-switch-opt svg { width: 16px; height: 16px; }
.dev-ws-vote-form .dev-vote-switch-opt .dev-vote-n { font-size: 12.5px; }
.dev-ws-vote-form .dev-vote-switch-label { padding: 0 4px 4px; font-size: 14px; }
.dev-ws-vote-form .dev-vote-reason { margin-top: 10px; gap: 6px; }
.dev-ws-vote-form .dev-vote-reason-label { padding: 0 2px 2px; font-size: 14px; }
.dev-ws-vote-form .dev-vote-reason-box { padding: 10px 12px; border-radius: 12px; font-size: 16px; }
.dev-ws-vote-form .dev-vote-reason-actions { padding-top: 10px; gap: 8px; }
.dev-ws-vote-form .dev-vote-reason-cancel, .dev-ws-vote-form .dev-vote-reason-send { height: 44px; padding: 0 18px; border-radius: 12px; font-size: 15px; }
/* "Still yes?": the viewer's Yes was on an earlier version of the proposal
   (#1688). Amber, because it is a question the card is asking them. */
.dev-vote-btn-prior, .dev-vote-btn-prior:hover { background: var(--state-attention-bg); color: var(--state-attention); }
/* The roster's lines (#1688): each voter's sentence under the names, and the
   earlier-version note under those. */
.dev-ledger-reason { display: block; padding-left: 12px; font-size: 12.5px; color: var(--text-muted); }
.dev-ledger-earlier { display: block; font-size: 12.5px; color: var(--state-attention); }

/* ── The facts, as one line ──
   Linked issue · priority · assignee · category · 💬 N — the chips' own
   markup and hooks (button.attr-chip[data-attr-field], the issue chip, the
   chat badge), drawn as muted text with a dot between. The mono issue
   number keeps its family at 12px. */
/* These used to be stripped to muted text with a "·" between them, so
   "Closes #1575 · @alice · design" read as a byline. The Workshop's folded
   row kept them as the chips they are, and putting the two sizes beside each
   other settled which was right: on a proposal card, what it closes and who
   has it are the two facts most worth not skipping, and a chip is not
   skipped. The card takes the ROW's treatment now — one vocabulary across
   both sizes instead of two. `.dev-badge`'s own tinted pill is what shows
   through; all that is left here is the fit. */
:is(.dev-card-dense, .dev-card-topic) :is(.dev-card-status, .dev-card-facts) > .dev-badge {
  height: 19px;
  font-size: 11.5px;
}
:is(.dev-card-dense, .dev-card-topic) :is(.dev-card-status, .dev-card-facts) > .dev-badge.font-mono { font-size: 11px; }
:is(.dev-card-dense, .dev-card-topic) :is(.dev-card-status, .dev-card-facts) > button.dev-badge:hover {
  box-shadow: 0 0 0 1px var(--border) inset;
}
:is(.dev-card-dense, .dev-card-topic) :is(.dev-card-status, .dev-card-facts) > .dev-badge .attr-avatar { width: 14px; height: 14px; }

/* ── The action band: one line, folding into ⋯ ──
   28px pills at the second type size and the labelled Preview pill at the
   right end. The band still wraps and clips at one row (the reserve-and-clip
   contract); what changed is that a pill the row cannot fit is now marked
   `data-folded` by card/dev-card.tsx and listed in the ⋯ menu, and `order`
   sends it to the clipped row AFTER the Preview pill so the preview keeps
   the visible row's right end. It stays rendered rather than hidden — see
   useFoldedActions for why. A band with nothing in it takes no space (the
   settled close-issue row), where it used to hold an empty row open. */
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions {
  min-height: 28px;
  max-height: 28px;
  margin-top: 8px;
  gap: 6px;
  overflow: hidden;
}
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > [data-folded] { order: 1; }
/* ── Before the first measurement ──
   useFoldedActions stamps `data-band-measured` once it has run. Until then
   every foldable pill sits on the clipped row, which is the steady state in
   a kanban column. It used to be the opposite: the first paint drew every
   pill, so a board card opened showing "Create proposal" at the band's left
   and swapped it for "Open card" a frame later — the flicker reported
   against #1787 twice. A pill that fits still arrives in the same frame,
   because the measurement is a layout effect. */
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions:not([data-band-measured]) > [data-fold] { order: 1; }
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions:empty { display: none; }
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn {
  height: 28px;
  padding: 0 10px;
  font-size: 12px;
  font-weight: 600;
  /* The Vote button's "Yes" state (.dev-vote-btn-yes): the accent on its
     tint. The band's pills were the neutral grey fill, which read as a
     third colour beside the blue Vote and the blue Preview.
     The INK is --accent-tint-ink, the accent one step darker in light, since
     the accent itself is 4.34:1 on its own tint (QA 2026-09-24 Q20). */
  background: var(--accent-tint);
  color: var(--accent-tint-ink);
  border-color: transparent;
}
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn:hover {
  background: var(--accent-tint);
  border-color: var(--accent-light);
  color: var(--accent-tint-ink);
}
/* The menu trigger: a hamburger at the far right of the band, the row's own
   "more" — that is where the pills that do not fit the line go. Preview,
   when there is one, sits just before it: the pair is pushed to the band's
   right edge by whichever of the two comes first, and the second keeps the
   band's gap (the override below), or two auto margins would split the free
   space between them. */
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .dev-card-menu-btn {
  margin-left: auto;
  width: 28px;
  padding: 0;
  color: var(--accent);
}
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .dev-card-menu-btn svg { width: 16px; height: 16px; }
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn-preview {
  margin-left: auto;
  gap: 5px;
  padding: 0 10px 0 9px;
  color: var(--accent-tint-ink);
  background: var(--accent-tint);
  border-color: transparent;
}
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn-preview:hover { background: var(--accent-tint); border-color: var(--accent-light); }
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn-preview + .dev-card-menu-btn { margin-left: 0; }
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn-preview svg { width: 16px; height: 16px; }
/* ── The kudos slot gives way; Open card, Preview and ⋯ never do ──
   The slot is the band's one pill that cannot fold like the others: a
   legacy-filled host with no box of its own. useFoldedActions measures the
   pill through it and, with the room left after the fixed controls, keeps
   the widest of its faces that fits — the whole line; the name alone
   (`data-thanks="short"` on the band); the clap alone (`"clap"`, a square
   of the pill's own height, like the ⋯ button) — and with no room for even
   that marks the host `data-folded`, which sends the pill to the clipped
   row like any folded pill and lists it in ⋯ (_kudosMenuItem). The band's
   measurement writes these three; nothing else does. */
.gc-card-actions[data-thanks="short"] .dev-thanks-tail { display: none; }
.gc-card-actions[data-thanks="clap"] .kudos-wrap .dev-thanks-label,
.gc-card-actions[data-thanks="clap"] .kudos-wrap [data-kudos-count] { display: none; }
.gc-card-actions[data-thanks="clap"] .kudos-wrap > .gc-vote-btn {
  aspect-ratio: 1 / 1;
  width: auto;
  padding: 0;
  gap: 0;
  justify-content: center;
}
.gc-card-actions > [data-kudos-host][data-folded] > .kudos-wrap { order: 1; }
/* The two badge states of the preview, in the band at the same height. */
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-checks-running-badge,
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-conflict-badge {
  margin-left: auto;
  height: 28px;
  display: inline-flex;
  align-items: center;
  flex: none;
}
/* #2585: the building state is no longer one of those badges — it is the
   Preview pill in gray — so it takes the PILL's band geometry instead, the
   same block as .gc-vote-btn-preview above. The height is restated because
   the rule right above still reaches it through .gc-checks-running-badge,
   and 28px there against the pill's 24px is exactly the shift this removes.
   The `+ .dev-card-menu-btn` line matters as much: without it the hamburger
   keeps its own `margin-left: auto` and the two share the free space, which
   is what left "Preview building…" floating mid-band. */
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn-building {
  margin-left: auto;
  gap: 5px;
  padding: 0 10px 0 9px;
  height: 24px;
}
:is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .gc-vote-btn-building + .dev-card-menu-btn { margin-left: 0; }
/* ── On a touch screen the band is 32px (QA 2026-09-24 Q19) ──
   The band's pills were 28px, under the 32px a phone needs, and this is the
   one place the `.un-touch-target` hit-slop cannot fix it: the band clips at
   one row (`overflow: hidden`, the reserve-and-clip contract above), so a
   hit area hung off a pill would be cut at the band's edge. On a coarse
   pointer the ROW is 32px instead and every pill in it fills the row — Start
   work, Claim, Open card, Preview, ⋯, the kudos pill and the hero's Vote.
   useFoldedActions reads the band's clientHeight, not a constant, so the
   fold arithmetic is unchanged. A mouse keeps the 28px band. */
@media (pointer: coarse) {
  :is(.dev-card-dense, .dev-card-topic) .gc-card-actions { min-height: 32px; max-height: 32px; }
  :is(.dev-card-dense, .dev-card-topic) .gc-card-actions > :is(.gc-vote-btn, .dev-vote-btn),
  :is(.dev-card-dense, .dev-card-topic) .gc-card-actions .kudos-wrap > .gc-vote-btn { height: 32px; }
  :is(.dev-card-dense, .dev-card-topic) .gc-card-actions > .dev-card-menu-btn { width: 32px; }
}

/* Cards with 22px corners want a little more air between them than the
   column's space-y-2 gives. */
#dev-kanban .dev-kanban-col > .space-y-2 > :not([hidden]) ~ :not([hidden]) { margin-top: 4px; }


/* ═══════════════════════════════════════════════════════════════════════
   THE TOPIC PAGE — a proposal, an issue, a session or a governance vote,
   opened. Four sheets on the board strip, the way Activity stacks its
   entries: the CARD (the round-three board card at full width — the class
   is `.dev-card-topic`, and it shares every rule above), WHERE IT STANDS
   (the ledger: one row per fact, in the bar's tones), ABOUT (the words,
   the before/after tiles, the full PR body behind a disclosure line) and
   DISCUSSION (the thread, as bubbles). The page used to say the same
   thing in four places in five box styles; each sheet says one thing.

   The thread's scroll region (`#gc-thread-scroll`, group-chat/thread-shell)
   holds the head and the messages together, so the sheets are children of
   the head and the messages host is dressed as the last sheet. The sheet
   the thread panel itself wore (`.dc-lift-session`) goes flat: the sheets
   are the lift now.
   ═══════════════════════════════════════════════════════════════════════ */
#dev-topic-thread > .dev-thread {
  background: transparent;
  border-top: 0;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
#dev-topic-thread > .dev-thread > #gc-thread-scroll {
  padding: 0 4px;
}
/* #1901: the reply bar sits in the THREAD'S column, not the window's. The
   topic card and the Discussion sheet are capped at 900px and centred
   (`.dev-topic`, `#gc-thread-messages` below), but the typing line and the
   composer bar under them spanned the whole pane — so on a wide desktop the
   "Reply in thread" box stretched far past the thread it answers and read as
   floating loose on the page. Same cap, same centring, so its edges line up
   with the sheet above it. A no-op below 900px + gutters. */
#dev-topic-thread > .dev-thread > #gc-thread-typing,
#dev-topic-thread > .dev-thread > .platform-safe-bar {
  width: 100%;
  max-width: 900px;
  margin-left: auto;
  margin-right: auto;
}
.dev-topic {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-width: 900px;
  margin: 0 auto;
}
/* #2916: the topic page's one back control, "‹ Workshop"
   (features/dev-board/topic/topic-back.tsx). It is the first child of
   `.dev-topic`, so it sits at the top-left of the pane on the card's own
   left edge and scrolls away with the page; the header draws no arrow on
   these routes. Dressed as the header's back disc was (periwinkle ink on the
   periwinkle tint, a hairline one step darker), stretched into a pill so it
   can carry the destination's name. Hover steps the fill to the hairline's
   shade; focus gets the shell's usual accent ring. `align-self` keeps the
   column's stretch from widening it to the card. */
.dev-topic-back {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  align-self: flex-start;
  height: 30px;
  padding: 0 12px 0 6px;
  margin: 2px 0 6px;
  border-radius: 999px;
  border: 1px solid var(--brand-line);
  background: var(--brand-tint);
  color: var(--brand-ink);
  font-size: 14px;
  font-weight: 600;
  line-height: 1;
  white-space: nowrap;
  text-decoration: none;
  transition: background-color 120ms ease;
}
.dev-topic-back:hover { background: var(--brand-line); }
.dev-topic-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-topic-back-icon { width: 18px; height: 18px; flex-shrink: 0; }
/* #3691: a card opened from Messages carries two chips, "‹ Messages" and
   "‹ Workshop", side by side in the one chip's place. The row takes over the
   chip's left alignment; each chip keeps its own margins, so the row adds
   none, and it wraps rather than overflowing a narrow phone. */
.dev-topic-backs {
  display: flex;
  flex-wrap: wrap;
  column-gap: 8px;
  align-self: flex-start;
}
.dev-change-overview {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 4px 12px 24px;
}
.dev-change-workspace {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}
.dev-change-overview[hidden], .dev-change-workspace[hidden] { display: none; }
.dev-change-issues {
  margin: 8px 4px 12px;
  padding: 12px 14px;
  background: var(--dc-sheet-fill);
  border-radius: 16px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
}
/* #2193: the linked-issue rows are flex (chip + one truncating title line),
   laid out by their own utilities. A `display: block` here outranked the
   rows' `flex`, so the title's nowrap ran past the card on a phone and
   scrolled it sideways. Only the wrap for stray long tokens stays. */
.dev-change-issues { min-width: 0; }
.dev-change-issues a { min-width: 0; overflow-wrap: anywhere; }
.dev-change-overview .dev-topic { width: 100%; }
/* The Discussion sheet (topic/conversation.tsx), and nothing under it. The
   tabs went first — Activity was three timestamps the card's meta line
   carries — and the Build sheet followed (#2605): the "Open build" /
   "Continue building" / "Read the build" pill leaves this page for the
   change's own dev session. */
.dev-conversation { min-width: 0; scroll-margin-top: 12px; }
.dev-conversation-audience { margin: 0 4px 8px; }
.dev-conversation-chat { height: clamp(300px, 50dvh, 540px); }
/* The thread's scroller wears `overscroll-contain` for the full-height
   thread screens, where the page behind it must not move. Here the thread is
   one sheet in a scrolling page, and the utility trapped the wheel: a reader
   whose pointer rested on the Discussion could not scroll the page even with
   the thread at its end. Let the scroll chain to the page. */
.dev-conversation-chat #gc-thread-scroll { overscroll-behavior: auto; }
.dev-conversation .dev-thread { border: 0; border-radius: 0; background: transparent; box-shadow: none; }
.dev-conversation .dev-thread::before, .dev-conversation .dev-thread::after { content: none; }
#dev-topic-thread .dev-conversation #gc-thread-messages {
  margin: 0;
  padding: 0;
  max-width: none;
  border-radius: 0;
  box-shadow: none;
  background: transparent;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}
#dev-topic-thread .dev-conversation #gc-thread-messages::before { content: none; }
.dev-topic-sheet {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  border-radius: 26px;
  padding: 12px 14px 14px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
}
.dev-topic-sheet.dev-topic-card { padding: 6px; }
/* #4244: a closed request's one status band, the first thing in its card
   (topic-head.tsx `ClosedBand`). Inset to the card's 6px padding, so its
   radius is the card's 26px less that. Emerald when a merged change closed
   it, the neutral grey when a vote or an admin did. */
.dev-issue-closed-band {
  display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px;
  min-width: 0; margin-bottom: 6px; padding: 9px 14px;
  border-radius: 20px; font-size: 13px; line-height: 1.35;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-issue-closed-band[data-tone="merged"] { background: var(--state-ok-bg); color: var(--state-ok); }
.dev-issue-closed-band-glyph { width: 16px; height: 16px; flex: none; stroke-width: 2.5; }
.dev-issue-closed-band-k { font-weight: 700; }
.dev-issue-closed-band-dot { opacity: .6; }
.dev-issue-closed-band .dev-topic-issue { min-width: 0; }
.dev-topic-sheet:empty { display: none; }
.dev-topic-h {
  margin: 0 0 8px;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
/* The About sheet's inner label. A peer of the "Technical details" disclosure
   below it, so the two halves of the sheet read as two sections rather than as
   a paragraph with a link under it. Quieter than .dev-topic-h, which names the
   sheet itself. */
.dev-topic-sub {
  margin: 0 0 4px;
  font-size: 11px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
.dev-topic-sub + .dev-topic-about-body { margin-top: 0; }

/* ── The About sheet's parts ─────────────────────────────────────────────
   "What changes for you" and "Addresses issue" are two parts of one sheet,
   in one heading style: a sentence-case semibold line in the text ink, a
   step below the sheet's uppercase eyebrow. Both indent the same 6px. */
.dev-topic-part { padding: 0 6px; }
.dev-topic-part-h { margin: 0 0 6px; font-size: 13.5px; font-weight: 600; line-height: 1.3; color: var(--text-primary); }
/* The fold: one line of the summary with a fade, and a Show more under it.
   `flow-root` keeps the children's margins inside the box in BOTH states —
   with only `overflow: hidden` on the folded state, the first paragraph's
   margin collapsed through the box when unfolded and the text jumped. */
.dev-topic-fold { display: flow-root; }
.dev-topic-fold > .dev-topic-about-body > .dev-issue-body > :first-child,
.dev-topic-fold > .dev-topic-about-body > :first-child { margin-top: 0; }
.dev-topic-fold[data-folded] {
  max-height: 3em;
  overflow: hidden;
  cursor: pointer;
  -webkit-mask-image: linear-gradient(to bottom, #000 35%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 35%, transparent 100%);
}
.dev-topic-fold-more {
  position: relative;
  margin-top: 4px;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--accent);
  cursor: pointer;
}
.dev-topic-fold-more:hover { text-decoration: underline; }
.dev-topic-fold[data-folded] + .dev-topic-fold-more { margin-top: -0.5em; }
/* What the change claims a reader will see, as bullets under the summary,
   then the shots run's state as one strip — inside the fold. */
.dev-topic-claims-h { margin-top: 12px; }
.dev-topic-claims { margin: 0; padding-left: 20px; list-style: disc; font-size: 13.5px; line-height: 1.5; color: var(--text-primary); }
.dev-topic-claims li + li { margin-top: 4px; }
.dev-topic-fold > .dev-topic-visuals { margin-top: 12px; }
.dev-topic-shots {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 12px;
  padding: 8px 10px;
  border-radius: 12px;
  background: var(--bg-primary);
  font-size: 12.5px;
  line-height: 1.4;
  color: var(--text-muted);
}
.dev-topic-shots > .dev-badge { flex: none; }
/* Addresses issue, as the sheet's second part: the grey box goes, a hairline
   parts it from the summary, and each issue is a row in the Discussion's
   event-box language — the number where the glyph goes, the title, the
   chevron — tighter than the chat's box. */
.dev-topic-about .dev-change-issues {
  margin: 10px 0 0;
  padding: 10px 6px 0;
  border: 0;
  border-top: 1px solid var(--app-sheet-line);
  border-radius: 0;
  background: none;
  box-shadow: none;
}
.dev-topic-about .dev-change-issues .dev-topic-part-h { margin: 0; }
.dev-topic-about .dev-change-issues > .mt-2 { margin-top: 8px; }
.dev-issue-ref { max-width: none; margin-top: 0; padding: 6px 10px; gap: 9px; font-size: 13.5px; }
/* ── More about this change ──────────────────────────────────────────────
   Technical details and Testing instructions, after the ledger, as two
   collapsed accordion rows: the label on the left, a chevron on the right
   that turns when the row opens, a hairline between them. */
.dev-topic-acc { display: flex; flex-direction: column; }
.dev-topic-acc > .dev-topic-details { margin-top: 0; }
.dev-topic-acc > .dev-topic-details + .dev-topic-details { border-top: 1px solid var(--app-sheet-line); }
.dev-topic-acc > .dev-topic-details > .dev-topic-details-summary {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 2px;
  font-size: 13.5px;
  color: var(--text-primary);
}
/* Both states: the base rules set the ▸/▾ glyph per state further down the
   sheet, and the open one outranks a plain descendant rule here. */
.dev-topic-acc > .dev-topic-details > .dev-topic-details-summary::before,
.dev-topic-acc > .dev-topic-details[open] > .dev-topic-details-summary::before { content: none; }
.dev-topic-acc > .dev-topic-details > .dev-topic-details-summary::after {
  content: "";
  width: 8px;
  height: 8px;
  margin-right: 6px;
  border-right: 1.5px solid var(--text-muted);
  border-bottom: 1.5px solid var(--text-muted);
  transform: rotate(45deg);
  transition: transform 150ms;
}
.dev-topic-acc > .dev-topic-details[open] > .dev-topic-details-summary::after { transform: rotate(-135deg); }
.dev-topic-acc > .dev-topic-details > .dev-topic-details-body,
.dev-topic-acc > .dev-topic-details > .dev-topic-note { margin: 0 2px 10px; }
@media (prefers-reduced-motion: reduce) {
  .dev-topic-acc > .dev-topic-details > .dev-topic-details-summary::after { transition: none; }
}

/* The card at full width: the title is not clamped, and the facts line may
   hold every chip (the head shows the unset ones as "Set priority"). */
div.dev-card-topic .dev-card-title { font-size: 15px; line-height: 1.3; }
div.dev-card-topic .dev-card-badges.dev-card-status { max-height: none; overflow: visible; }
div.dev-card-topic .dev-card-status > .dev-badge .dev-badge-name { max-width: 18ch; }
div.dev-card-topic .dev-card-meta .dev-topic-gh { color: var(--accent); text-decoration: none; }
div.dev-card-topic .dev-card-meta .dev-topic-gh:hover { text-decoration: underline; }
/* The topic card's chips live in a plain `.dev-card-badges` row (the head
   never reserves a status band), so the facts-line rules are restated for
   that row: the same muted text with dots between. */
div.dev-card-topic .dev-card-badges { margin-top: 8px; gap: 4px 6px; }
div.dev-card-topic .dev-card-badges > .dev-badge {
  height: auto;
  padding: 0;
  gap: 4px;
  border-radius: 0;
  background: transparent;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.4;
  color: var(--text-muted);
}
div.dev-card-topic .dev-card-badges > .dev-badge.font-mono { font-size: 12px; }
div.dev-card-topic .dev-card-badges > .dev-badge + .dev-badge::before { content: "·"; margin-right: 2px; opacity: 0.6; }
div.dev-card-topic .dev-card-badges > button.dev-badge:hover { color: var(--accent); }
div.dev-card-topic .dev-card-badges > .dev-badge .attr-avatar { width: 14px; height: 14px; }
/* The head's work note and the admin claim list, in the second type size. */
div.dev-card-topic [data-work-note] { font-size: 12.5px; line-height: 1.45; margin-top: 8px; }

/* ── The ledger ── */
.dev-ledger { display: flex; flex-direction: column; }
.dev-ledger-row {
  display: grid;
  grid-template-columns: 22px 118px minmax(0, 1fr);
  gap: 4px 10px;
  align-items: baseline;
  padding: 8px 0;
  border-top: 1px solid var(--app-sheet-line);
  font-size: 13px;
  line-height: 1.45;
  color: var(--text-primary);
}
.dev-ledger-row:first-child { border-top: 0; padding-top: 2px; }
.dev-ledger-dot {
  width: 18px;
  height: 18px;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: start;
  margin-top: 1px;
  font-size: 11px;
  font-weight: 700;
  line-height: 1;
}
.dev-ledger-dot .dc-status-spinner-arc { margin: 0; }
.dev-ledger-bad .dev-ledger-dot { background: var(--state-blocked-bg); color: var(--state-blocked); }
.dev-ledger-warn .dev-ledger-dot { background: var(--state-attention-bg); color: var(--state-attention); }
.dev-ledger-ok .dev-ledger-dot { background: var(--state-ok-bg); color: var(--state-ok); }
.dev-ledger-vote .dev-ledger-dot { background: var(--accent-tint); color: var(--accent); }
.dev-ledger-progress .dev-ledger-dot { background: var(--state-progress-bg); color: var(--state-progress); }
.dev-ledger-mute .dev-ledger-dot { background: var(--state-neutral-bg); color: var(--state-neutral); }
/* The label track is a fixed 118px, so `nowrap` sent anything longer than
   about two short words straight across the gap and into the value column —
   "Conflicts with main" overlapped its own sentence. Labels wrap now; they
   are two or three words, so this costs a second line only in the cases
   that were previously broken. */
.dev-ledger-k { font-weight: 600; white-space: normal; overflow-wrap: break-word; }

/* ── The path checkboxes ────────────────────────────────────────────────
   1 / 2 / 3 says the steps are ORDERED. It does not say they are a GATE,
   and a reader can take the panel as a menu and pick the step that looks
   nearest. A checklist says it without a word: a squared, ringed box per
   step, holding its number while it is outstanding and a tick once it is
   cleared. It replaced a rail drawn between the dots, which read as a line
   behind the numbers rather than as a connection, and which had to derive
   its geometry from the dot size and the row padding to land at all.

   The tone still colours the box, so the blocking step stays the loud one. */
.dev-ledger-row[data-step] .dev-ledger-dot {
  border-radius: 5px;
  box-shadow: inset 0 0 0 1.5px currentColor;
  font-size: 10.5px;
}
.dev-ledger-row[data-step][data-step-done] .dev-ledger-dot {
  background: var(--state-ok-bg);
  color: var(--state-ok);
  font-size: 11px;
}

.dev-check-flaky {
  margin-left: 4px;
  padding: 0 4px;
  border-radius: 4px;
  font-size: 10.5px;
  font-weight: 600;
  white-space: nowrap;
  background: var(--state-attention-bg);
  color: var(--state-attention);
}

.dev-ledger-k small {
  display: block;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
  margin-top: 1px;
  white-space: normal;
}
.dev-ledger-v { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.dev-ledger-text { color: var(--text-primary); }
.dev-ledger-foot { color: var(--text-muted); font-size: 12.5px; }
.dev-ledger-foot-warn { color: var(--state-attention); }
.dev-ledger-foot .font-medium { color: var(--text-primary); }
.dev-ledger-list {
  margin: 2px 0 0;
  padding-left: 16px;
  list-style: disc;
  font-size: 12px;
  color: var(--text-muted);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.dev-ledger-list code, .dev-ledger-list .font-mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11.5px;
  overflow-wrap: anywhere;
}
/* A failing test: its own small card inside the row, name then reason. */
.dev-ledger-fails {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 12px;
}
.dev-ledger-fails > li {
  padding: 6px 10px 0;
  border-radius: 10px 10px 0 0;
  background: var(--bg-primary);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
  color: var(--text-primary);
}
.dev-ledger-fails > li:has(+ div), .dev-ledger-fails > li:has(+ ul) { padding-bottom: 0; }
.dev-ledger-fails > li:not(:has(+ div)):not(:has(+ ul)) { padding-bottom: 6px; border-radius: 10px; }
.dev-ledger-fails > div, .dev-ledger-fails > ul {
  margin: 0;
  padding: 2px 10px 6px 10px;
  background: var(--bg-primary);
  color: var(--text-muted);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11px;
  overflow-wrap: anywhere;
}
.dev-ledger-fails > div:not(:has(+ ul)), .dev-ledger-fails > ul { border-radius: 0 0 10px 10px; }
.dev-ledger-fails > ul { list-style: disc; padding-left: 24px; }
.dev-ledger-ops { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 4px; }
.dev-ledger-ops .gc-vote-btn { margin-top: 0; height: 26px; font-size: 12px; font-weight: 600; }
.dev-ledger-passes { display: contents; }
.dev-ledger-passes > summary { list-style: none; cursor: pointer; background: transparent; color: var(--accent); padding: 0 4px; }
.dev-ledger-passes > summary::-webkit-details-marker { display: none; }
.dev-ledger-passes > summary::after { content: " ▾"; }
.dev-ledger-passes[open] > summary::after { content: " ▴"; }
.dev-ledger-passes > ul { flex-basis: 100%; width: 100%; margin-top: 4px; }
.dev-ledger-passes > ul > li { padding: 4px 10px; border-radius: 10px; }
/* The checks row's live progress bar (a run in flight). One track, two
   filled segments — passed, then failed — sized against the declared count.
   The counts are also in the row's `sub` text, so the bar is decorative
   (aria-hidden) and never the only carrier of the numbers. */
.dev-ledger-progress {
  display: block;
  position: relative;
  height: 0.375rem;
  margin: 0.375rem 0 0.25rem;
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 12%, transparent);
  overflow: hidden;
}
.dev-ledger-progress-pass,
.dev-ledger-progress-fail {
  display: block;
  position: absolute;
  top: 0;
  bottom: 0;
  transition: width 240ms ease-out;
}
.dev-ledger-progress-pass { left: 0; background: var(--dc-ok, #16a34a); }
.dev-ledger-progress-fail { right: 0; background: var(--dc-bad, #dc2626); }
/* The unit suite's own track under the checks bar, labelled so the two
   are never mistaken for one run. Before its first test line the track
   pulses (cloning, installing) rather than sitting empty. */
.dev-ledger-progress-unit { display: block; margin-top: 0.125rem; }
.dev-ledger-progress-unit .dev-ledger-progress { margin-bottom: 0; }
.dev-ledger-progress-unit-k {
  display: block;
  margin-top: 0.125rem;
  font-size: 11px;
  line-height: 1.2;
  color: var(--text-muted);
}
/* The build half as a step row: finished steps carry their time, the
   running one is emphasised, the ones ahead are faded. */
.dev-ledger-progress-build {
  display: flex;
  flex-wrap: wrap;
  /* The middle dots between labels carry the separation now (see
     BuildSteps), so the column gap only has to breathe around them. */
  gap: 0.25rem 0.125rem;
  margin: 0.25rem 0;
  font-size: 12px;
  line-height: 1.3;
  color: var(--text-muted);
}
.dev-ledger-build-step small { margin-left: 0.25rem; font-size: 11px; opacity: 0.8; }
.dev-ledger-build-step.is-now { color: var(--text-primary); font-weight: 600; animation: dev-ledger-progress-pulse 1.4s ease-in-out infinite; }
.dev-ledger-build-step.is-todo { opacity: 0.55; }
.dev-ledger-build-sep { opacity: 0.45; }
/* The four-step pipeline as one bar, a segment per step. Equal widths on
   purpose (see BuildSteps): it says WHERE the build is, not how long is
   left. */
.dev-ledger-build-bar {
  flex-basis: 100%;
  display: flex;
  gap: 2px;
  height: 0.375rem;
  margin: 0.125rem 0 0.25rem;
}
.dev-ledger-build-seg {
  flex: 1 1 0;
  border-radius: 999px;
  background: color-mix(in srgb, currentColor 12%, transparent);
}
.dev-ledger-build-seg.is-done { background: var(--dc-ok, #16a34a); }
.dev-ledger-build-seg.is-now {
  background: color-mix(in srgb, var(--dc-ok, #16a34a) 55%, transparent);
  animation: dev-ledger-progress-pulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .dev-ledger-build-seg.is-now { animation: none; }
}
/* Inside the image step: the buildpack lifecycle phases on their own row,
   indented, with the running phase's last log line under them in mono. */
.dev-ledger-build-phases {
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  gap: 0.125rem 0.125rem;
  padding-left: 0.75rem;
  border-left: 2px solid color-mix(in srgb, currentColor 18%, transparent);
  font-size: 11px;
}
.dev-ledger-build-phase small { margin-left: 0.2rem; opacity: 0.8; }
.dev-ledger-build-phase.is-now { color: var(--text-primary); font-weight: 600; }
.dev-ledger-build-phase.is-todo { opacity: 0.55; }
.dev-ledger-build-detail {
  flex-basis: 100%;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 11px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
@media (prefers-reduced-motion: reduce) {
  .dev-ledger-build-step.is-now { animation: none; }
}
.dev-ledger-progress-busy {
  background: color-mix(in srgb, currentColor 18%, transparent);
  animation: dev-ledger-progress-pulse 1.4s ease-in-out infinite;
}
@keyframes dev-ledger-progress-pulse {
  0%, 100% { opacity: 0.45; }
  50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .dev-ledger-progress-busy { animation: none; }
}

.dev-ledger-roster { color: var(--text-primary); }
.dev-ledger-yes { color: var(--state-ok); font-weight: 600; }
.dev-ledger-no { color: var(--state-blocked); font-weight: 600; }
.dev-ledger-needs { color: var(--text-muted); }
/* ── The rows' first word ──────────────────────────────────────────────
   Each row leads with its STATE — "Syncing.", "Failing.", "Approved" — bold
   in the row's tone, so a scan down the column reads the path without the
   sentences. The tone classes mirror the dot's. */
.dev-ledger-lead { font-weight: 700; }
.dev-ledger-lead-bad { color: var(--state-blocked); }
.dev-ledger-lead-warn { color: var(--state-attention); }
.dev-ledger-lead-ok { color: var(--state-ok); }
.dev-ledger-lead-vote { color: var(--accent); }
.dev-ledger-lead-progress { color: var(--state-progress); }
.dev-ledger-lead-mute { color: var(--text-muted); }
/* The row that blocks the merge is washed in the blocked tint, bleeding
   8px past the sheet's text edge on both sides so the wash reads as a band
   behind the row rather than a box drawn inside it. */
.dev-ledger-row.dev-ledger-bad {
  margin: 0 -8px;
  padding-left: 8px;
  padding-right: 8px;
  border-radius: 12px;
  background: var(--state-blocked-bg);
}
.dev-ledger-row.dev-ledger-bad + .dev-ledger-row { border-top-color: transparent; }
/* ── One line per check ─────────────────────────────────────────────────
   A check's name is one line, however long the declared test's name runs,
   ending in an ellipsis; the reason sits behind a "Why it failed" disclosure
   on the line's right. A check that passed on retry keeps the same door,
   reading "Passed on retry". The passes list reuses the row. */
.dev-ledger-fails > .dev-ledger-check {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;
  padding: 5px 10px;
  border-radius: 10px;
}
.dev-ledger-fails > .dev-ledger-check-why { display: block; }
.dev-ledger-check-glyph { flex: none; }
.dev-ledger-check-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dev-ledger-check-path { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11.5px; color: var(--text-muted); }
.dev-ledger-check .dev-check-flaky { flex: none; }
.dev-ledger-why { margin: 0; }
.dev-ledger-check-line {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;
  cursor: pointer;
  list-style: none;
  font: inherit;
  color: inherit;
}
.dev-ledger-check-line::-webkit-details-marker { display: none; }
.dev-ledger-check-open { flex: none; font-size: 12px; font-weight: 600; color: var(--accent); }
.dev-ledger-check-line:hover .dev-ledger-check-open { text-decoration: underline; }
.dev-ledger-why[open] > .dev-ledger-check-line .dev-ledger-check-open::after { content: " ▴"; }
.dev-ledger-why-body {
  margin-top: 4px;
  padding-bottom: 2px;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 11.5px;
  line-height: 1.45;
  color: var(--text-muted);
  overflow-wrap: anywhere;
}
.dev-ledger-why-errors { margin: 4px 0 0; padding-left: 16px; list-style: disc; }
/* The unit-suite row's per-test excerpts (#3978): each failing test's name,
   its file, and the captured error text on its own block, pre-wrap so the
   expected/actual and stack lines keep their shape. */
.dev-ledger-why-details { margin: 6px 0 0; padding-left: 16px; list-style: disc; }
.dev-ledger-why-details > li { margin: 4px 0; }
.dev-ledger-why-test { color: var(--text-primary); font-weight: 600; }
.dev-ledger-why-file { color: var(--text-muted); }
.dev-ledger-why-excerpt {
  display: block;
  margin-top: 3px;
  padding: 6px 8px;
  border-radius: 8px;
  background: var(--dc-sheet-fill, rgba(113, 113, 122, 0.08));
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
/* ── The Review line ────────────────────────────────────────────────────
   "Approved by @alice ✓", and at its right end the "How voting works" link
   and the "?" — this is the row they explain, so they sit on it. */
.dev-ledger-review-line { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 12px; }
.dev-ledger-review-line > .dev-ledger-roster { flex: 1 1 auto; min-width: 0; }
.dev-ledger-help { margin-top: 8px; font-size: 12px; color: var(--text-muted); }
.dev-ledger-review-line > .dev-ledger-help { margin: 0 0 0 auto; display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
@media (max-width: 480px) {
  .dev-ledger-row { grid-template-columns: 22px minmax(0, 1fr); }
  .dev-ledger-v { grid-column: 2; }
  .dev-ledger-check-path { display: none; }
}

/* ── About ── */
.dev-topic-about-body { font-size: 13.5px; line-height: 1.5; color: var(--text-primary); }
.dev-topic-about-body .dev-issue-body { font-size: inherit; }
.dev-topic-visuals { margin-top: 10px; }
.dev-topic-visuals:first-child, .dev-topic-h + .dev-topic-visuals { margin-top: 0; }

/* Before & after shots (AppView.shotsHtml): one screen at a time, in a frame
   that keeps its size. Radios before the screens hold the state (the side,
   one pair for every screen, and which screen is showing) and labels on each
   screen set it, so it needs no script; :has() reads it. Every screen sits in
   the same 16:10 stage, a phone one in the middle at the same zoom as a
   desktop one, and the screens share one grid cell, so the card is as tall as
   its tallest description and never changes size. The outlines are where the
   run found the two screens differ (services/shots-diff.js): red where it
   was, green where it is now, dashed grey for a difference no declared change
   accounts for. */
.shots-viewer { position: relative; container-type: inline-size; border-radius: 14px; overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(24, 24, 27, 0.1); }
.dark .shots-viewer { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); }
.shots-picks { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.shots-views { display: grid; }
.shots-view { grid-area: 1 / 1; margin: 0; min-width: 0; visibility: hidden; }
.shots-views-one > .shots-view,
.shots-viewer:has(.shots-screen-pick:nth-of-type(1):checked) .shots-view:nth-of-type(1),
.shots-viewer:has(.shots-screen-pick:nth-of-type(2):checked) .shots-view:nth-of-type(2),
.shots-viewer:has(.shots-screen-pick:nth-of-type(3):checked) .shots-view:nth-of-type(3),
.shots-viewer:has(.shots-screen-pick:nth-of-type(4):checked) .shots-view:nth-of-type(4),
.shots-viewer:has(.shots-screen-pick:nth-of-type(5):checked) .shots-view:nth-of-type(5),
.shots-viewer:has(.shots-screen-pick:nth-of-type(6):checked) .shots-view:nth-of-type(6) { visibility: visible; }

/* The toolbar holds the same controls on every screen, so the arrows at its
   end never move. */
.shots-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 8px; }
.shots-seg { display: inline-flex; gap: 2px; padding: 2px; border-radius: 999px; background: rgba(24, 24, 27, 0.06); }
.dark .shots-seg { background: rgba(255, 255, 255, 0.08); }
.shots-seg-btn { display: inline-flex; align-items: center; gap: 6px; min-height: 30px; padding: 0 11px; border-radius: 999px;
  cursor: pointer; user-select: none; white-space: nowrap; font-size: 13px; font-weight: 600; line-height: 1; color: #48484a; }
.dark .shots-seg-btn { color: #c7c7cc; }
.shots-seg-btn:hover { background: rgba(24, 24, 27, 0.06); }
.dark .shots-seg-btn:hover { background: rgba(255, 255, 255, 0.08); }
.shots-seg-btn svg { width: 16px; height: 16px; flex: none; }
.shots-seg-on, .shots-seg-on:hover { background: #fff; color: #1c1c1e; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
.dark .shots-seg-on, .dark .shots-seg-on:hover { background: #3a3a3c; color: #f5f5f7; }
.shots-viewer:has(.shots-side-before:checked) .shots-seg-before,
.shots-viewer:has(.shots-side-after:checked) .shots-seg-after { color: #fff; background: #15803d; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
.shots-viewer:has(.shots-side-before:checked) .shots-seg-before { background: #dc2626; }
.shots-viewer:has(.shots-side-pick:focus-visible) .shots-seg-side,
.shots-viewer:has(.shots-screen-pick:focus-visible) .shots-seg-size,
.shots-viewer:has(.shots-screen-pick:focus-visible) .shots-screen-nav { outline: 2px solid var(--accent, #2563eb); outline-offset: 2px; }
.shots-screen-nav { margin-left: auto; display: inline-flex; align-items: center; gap: 4px; font-variant-numeric: tabular-nums; }
.shots-screen-step { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 999px; cursor: pointer; user-select: none;
  font-size: 18px; line-height: 1; color: #3f3f46; background: rgba(24, 24, 27, 0.06); }
.shots-screen-step:not(.shots-screen-step-off):hover { background: rgba(24, 24, 27, 0.12); }
.dark .shots-screen-step { color: #e4e4e7; background: rgba(255, 255, 255, 0.08); }
.dark .shots-screen-step:not(.shots-screen-step-off):hover { background: rgba(255, 255, 255, 0.14); }
.shots-screen-step-off { opacity: 0.35; cursor: default; }
.shots-screen-count { min-width: 3.4em; text-align: center; font-size: 13px; font-weight: 600; color: #52525b; }
.dark .shots-screen-count { color: #d4d4d8; }
/* The mode flip (#4459): the same 30px round grey disc as the screen arrows,
   pushed to the bar's right end. One of the two labels is always hidden by
   the theme pick (the one naming the mode already showing), so the bar ends
   with one moon or one sun. */
.shots-theme-flip { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 999px; cursor: pointer;
  user-select: none; margin-left: auto; color: #3f3f46; background: rgba(24, 24, 27, 0.06); }
.shots-theme-flip:hover { background: rgba(24, 24, 27, 0.12); }
.dark .shots-theme-flip { color: #e4e4e7; background: rgba(255, 255, 255, 0.08); }
.dark .shots-theme-flip:hover { background: rgba(255, 255, 255, 0.14); }
.shots-theme-flip svg { width: 16px; height: 16px; flex: none; }
.shots-theme-flip ~ .shots-screen-nav { margin-left: 0; }
.shots-viewer:has(.shots-theme-pick:focus-visible) .shots-theme-flip { outline: 2px solid var(--accent, #2563eb); outline-offset: 2px; }
/* The expand button (#5691): the same 30px round grey disc as the screen
   arrows and the mode flip, at the bar's end. The viewer itself is its
   popover, so the button opens and closes it without script; the arrow
   pair points out while it is closed and in while it is open. */
.shots-expand { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 999px; cursor: pointer;
  user-select: none; margin-left: auto; font: inherit; color: #3f3f46; background: rgba(24, 24, 27, 0.06); }
.shots-expand:hover { background: rgba(24, 24, 27, 0.12); }
.dark .shots-expand { color: #e4e4e7; background: rgba(255, 255, 255, 0.08); }
.dark .shots-expand:hover { background: rgba(255, 255, 255, 0.14); }
.shots-expand svg { width: 16px; height: 16px; flex: none; }
.shots-theme-flip ~ .shots-expand { margin-left: 0; }
.shots-expand ~ .shots-screen-nav { margin-left: 0; }
.shots-expand:focus-visible { outline: 2px solid var(--accent, #2563eb); outline-offset: 2px; }
.shots-expand-out, .shots-expand-in { display: inline-flex; align-items: center; }
.shots-expand-in { display: none; }
.shots-viewer:popover-open .shots-expand-out { display: none; }
.shots-viewer:popover-open .shots-expand-in { display: inline-flex; }
/* The word is the button's spoken name, and only the shown one counts. */
.shots-expand-word { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@supports not selector(:popover-open) { .shots-expand { display: none; } }
/* The viewer as a popover: closed it stays the block it always was (the UA
   hides a closed popover and fixes an open one to the viewport, so both
   boxes are neutralised); open it is drawn in the top layer at nearly the
   window's size, over a dim on the page. It must be a popover rather than
   position: fixed, because the reply pane and the app sheet it sits in
   carry a backdrop-filter, which would trap a fixed viewer in the pane. */
.shots-viewer[popover] { display: block; position: relative; inset: auto; margin: 0; padding: 0; border: 0; width: auto; height: auto;
  overflow: hidden; color: inherit; background: transparent; }
.shots-viewer:popover-open { position: fixed; inset: 0; margin: auto; height: fit-content; overflow: auto; overscroll-behavior: contain;
  width: min(calc(100vw - 48px), calc((100vh - 200px) * 1.6)); max-height: calc(100vh - 48px); background: #fff; }
.dark .shots-viewer:popover-open { background: #1c1c1e; }
@supports (height: 100dvh) {
  .shots-viewer:popover-open { width: min(calc(100vw - 48px), calc((100dvh - 200px) * 1.6)); max-height: calc(100dvh - 48px); }
}
.shots-viewer::backdrop { background: rgba(0, 0, 0, 0.55); }
.shots-viewer:has(.shots-theme-dark:checked) .shots-theme-to-dark,
.shots-viewer:has(.shots-theme-light:checked) .shots-theme-to-light { display: none; }
/* Dark pick: a side that has a dark shot shows it instead of its light one;
   one that has not keeps its light shot in both modes. */
.shots-viewer:has(.shots-theme-dark:checked) .shots-flip-side:has(.shots-img-dark) .shots-img-light,
.shots-viewer:has(.shots-theme-light:checked) .shots-img-dark { display: none; }
@container (max-width: 430px) {
  .shots-seg-label { display: none; }
  .shots-seg-btn { padding: 0 9px; }
  .shots-screen-count { min-width: 2.8em; }
}

/* The stage: a fixed 16:10 box; each side fits inside it at its own shape. */
.shots-stage { position: relative; container-type: size; aspect-ratio: 16 / 10; display: grid; place-items: center; overflow: hidden;
  user-select: none; background: rgba(24, 24, 27, 0.04); border-block: 1px solid rgba(24, 24, 27, 0.08); }
.dark .shots-stage { background: rgba(255, 255, 255, 0.04); border-color: rgba(255, 255, 255, 0.1); }
.shots-stage > .shots-flip-side { grid-area: 1 / 1; position: relative; display: block;
  width: min(100cqw, calc(100cqh * (var(--shots-shape, 16 / 10)))); aspect-ratio: var(--shots-shape, 16 / 10); }
.shots-stage > .shots-flip-missing { width: 100%; height: 100%; aspect-ratio: auto; display: grid; place-items: center;
  font-size: 12px; color: rgba(113, 113, 122, 1); }
.shots-flip-side img { display: block; width: 100%; height: 100%; object-fit: contain; }
.shots-viewer:has(.shots-side-after:checked) .shots-flip-before,
.shots-viewer:has(.shots-side-before:checked) .shots-flip-after { visibility: hidden; }
.shots-flip-to { position: absolute; inset: 0; z-index: 1; cursor: pointer; }
.shots-viewer:has(.shots-side-before:checked) .shots-flip-to-before,
.shots-viewer:has(.shots-side-after:checked) .shots-flip-to-after { display: none; }
.shots-flip-chip { position: absolute; top: 10px; left: 10px; z-index: 2; pointer-events: none; padding: 5px 9px; border-radius: 999px;
  font-size: 12px; font-weight: 700; line-height: 1; color: #fff; background: #15803d; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); }
.shots-flip-chip-before { background: #dc2626; }
.shots-viewer:has(.shots-side-after:checked) .shots-flip-chip-before,
.shots-viewer:has(.shots-side-before:checked) .shots-flip-chip-after { display: none; }
.shots-box { position: absolute; border: 2px solid #16a34a; background: rgba(22, 163, 74, 0.1); border-radius: 6px; pointer-events: none;
  transition: opacity 120ms ease; }
.shots-flip-before .shots-box { border-color: #dc2626; background: rgba(220, 38, 38, 0.1); }
.shots-stage .shots-box-other { border: 1.5px dashed rgba(113, 113, 122, 0.9); background: transparent; }
/* Lifted by its own width, so a line at the very bottom of a shot shows. */
.shots-mark { position: absolute; height: 0; margin-top: -2px; border-top: 2px dashed #16a34a; pointer-events: none; transition: opacity 120ms ease; }
.shots-flip-before .shots-mark { border-top-color: #dc2626; }
.shots-box-n { position: absolute; left: -10px; top: -10px; width: 20px; height: 20px; border-radius: 999px; display: grid;
  place-items: center; font-size: 11px; font-weight: 700; line-height: 1; color: #fff; background: #16a34a; box-shadow: 0 0 0 2px #fff; }
.shots-flip-before .shots-box-n { background: #dc2626; }
.dark .shots-box-n { box-shadow: 0 0 0 2px #18181b; }
/* Pointing at a change in the description picks out its outline. */
.shots-view:has(.shots-change:hover) .shots-box,
.shots-view:has(.shots-change:hover) .shots-mark { opacity: 0.2; }
.shots-view:has(.shots-change[data-shots-n="1"]:hover) .shots-stage [data-shots-n="1"],
.shots-view:has(.shots-change[data-shots-n="2"]:hover) .shots-stage [data-shots-n="2"],
.shots-view:has(.shots-change[data-shots-n="3"]:hover) .shots-stage [data-shots-n="3"] { opacity: 1; }
@media (prefers-reduced-motion: reduce) { .shots-box, .shots-mark { transition: none; } }

/* Under the screen: only the changes on it, numbered as their outlines. */
.shots-view-notes { display: grid; gap: 10px; align-content: start; padding: 12px; }
.shots-changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.shots-change { display: flex; gap: 10px; align-items: flex-start; margin: -4px; padding: 4px; border-radius: 8px; }
.shots-change:hover { background: rgba(24, 24, 27, 0.04); }
.dark .shots-change:hover { background: rgba(255, 255, 255, 0.05); }
.shots-change-n { flex: none; width: 20px; height: 20px; margin-top: 1px; border-radius: 999px; display: grid; place-items: center;
  font-size: 11px; font-weight: 700; line-height: 1; color: #fff; background: #16a34a; font-variant-numeric: tabular-nums; }
.shots-viewer:has(.shots-side-before:checked) .shots-change-n { background: #dc2626; }
.shots-keys, .shots-view-meta { display: flex; flex-wrap: wrap; gap: 4px 12px; font-size: 12px; line-height: 1.4; color: #68686c; }
.dark .shots-keys, .dark .shots-view-meta { color: #8e8e93; }
.shots-key { display: inline-flex; align-items: center; gap: 6px; }
.shots-key-box { flex: none; width: 16px; height: 10px; border: 1.5px dashed rgba(113, 113, 122, 0.95); border-radius: 3px; }
.shots-key-line { flex: none; width: 16px; height: 0; border-top: 2px dashed rgba(113, 113, 122, 0.95); }
.shots-claims { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.shots-claim { display: flex; gap: 10px; align-items: flex-start; }
.shots-claim-n { flex: none; width: 20px; height: 20px; margin-top: 1px; border-radius: 999px; display: grid; place-items: center;
  font-size: 11px; font-weight: 700; line-height: 1; color: #fff; background: #3f3f46; font-variant-numeric: tabular-nums; }
.dark .shots-claim-n { color: #18181b; background: #d4d4d8; }
/* Also noticed: problems the shots agent saw on the after build besides the
   declared changes. Quiet on purpose: the small-caps label every group label
   in the shell uses, then plain lines with no fill or colour, because they
   decide nothing about the change. */
.shots-noticed { display: grid; gap: 6px; }
.shots-noticed-head { margin: 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
.shots-noticed-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.shots-noticed-text { margin: 0; font-size: 14px; line-height: 1.4; color: var(--text-primary); }
.shots-noticed-meta { margin-top: 2px; font-size: 12px; line-height: 1.4; color: var(--text-muted); }

/* An HTML spec's screens (#3699) use the same viewer (AppView._shotsViewerHtml)
   with three things the card leaves off. Each is radios read by :has(), like
   the rest of the viewer:
     - Side by side: a third side option, before and after at once in the
       same fixed stage, before on the left.
     - Auto: the spec opens side by side when the viewer has room for that
       screen's size (420px for a phone screen, 720px for a desktop one) and
       on After when it has not. A label-less radio that starts checked; any
       choice replaces it.
     - Close-up / Whole screen: the radios are here; frontend/src/lib/
       spec-html.ts lays the frames out, because a frame's scale is a number
       CSS cannot divide out of a width.
   A side is a sandboxed frame drawn at its design size and scaled to fit. */
.spec-frame-side { overflow: hidden; background: #fff; }
.dark .spec-frame-side { background: #0b0b0c; }
.spec-frame-side > iframe { position: absolute; left: 0; top: 0; border: 0; transform-origin: 0 0; pointer-events: none; background: transparent; }
.shots-viewer:has(.shots-side-both:checked) .shots-stage { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 12px; }
.shots-viewer:has(.shots-side-both:checked) .shots-stage > .shots-flip-side { width: min(calc(50cqw - 6px), calc(100cqh * (var(--shots-shape, 16 / 10)))); }
.shots-viewer:has(.shots-side-both:checked) .shots-stage > .shots-flip-after { grid-area: 1 / 2; }
.shots-viewer:has(.shots-side-both:checked) .shots-flip-to { display: none; }
.shots-viewer:has(.shots-side-both:checked) .shots-flip-chip-after { left: calc(50% + 16px); }
.shots-viewer:has(.shots-side-both:checked) .shots-stage::after { content: ""; position: absolute; top: 12px; bottom: 12px; left: 50%; width: 1px; background: rgba(24, 24, 27, 0.12); }
.dark .shots-viewer:has(.shots-side-both:checked) .shots-stage::after { background: rgba(255, 255, 255, 0.14); }
.shots-viewer:has(.shots-side-both:checked) .shots-seg-both,
.shots-viewer:has(.shots-zoom-close:checked) .shots-seg-close,
.shots-viewer:has(.shots-zoom-whole:checked) .shots-seg-whole { background: #fff; color: #1c1c1e; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
.dark .shots-viewer:has(.shots-side-both:checked) .shots-seg-both,
.dark .shots-viewer:has(.shots-zoom-close:checked) .shots-seg-close,
.dark .shots-viewer:has(.shots-zoom-whole:checked) .shots-seg-whole { background: #3a3a3c; color: #f5f5f7; }
.shots-viewer:has(.shots-side-both:checked) .shots-change-n { background: linear-gradient(90deg, #dc2626 50%, #16a34a 50%); }
/* #5063: side by side crops both sides to the screen's declared changes
   (the numbered outlines with a margin around them), drawn with CSS over
   the existing stills: no new image. Nothing here matches when only one
   side shows, so Before and After keep the whole screen. The crop wrapper
   keeps the shot's own shape, so the img fills it exactly and the offsets
   move the wanted part into view. */
.shots-crop { position: absolute; inset: 0; display: block; }
.shots-viewer:has(.shots-side-both:checked) .shots-stage > .shots-flip-side[data-shots-crop] { overflow: hidden; aspect-ratio: var(--shots-crop-shape, 16 / 10); width: min(calc(50cqw - 6px), calc(100cqh * (var(--shots-crop-shape, 16 / 10)))); }
.shots-viewer:has(.shots-side-both:checked) .shots-flip-side[data-shots-crop] > .shots-crop { inset: auto; left: var(--shots-crop-x, 0); top: var(--shots-crop-y, 0); width: var(--shots-crop-w, 100%); height: auto; aspect-ratio: var(--shots-shape, 16 / 10); }
/* A crop wider than it is tall: before above after. */
.shots-viewer:has(.shots-side-both:checked) .shots-stage[data-shots-stack] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); column-gap: 0; row-gap: 12px; }
.shots-viewer:has(.shots-side-both:checked) .shots-stage[data-shots-stack] > .shots-flip-side { overflow: hidden; aspect-ratio: var(--shots-crop-shape, 16 / 10); width: min(100cqw, calc((50cqh - 6px) * (var(--shots-crop-shape, 16 / 10)))); }
.shots-viewer:has(.shots-side-both:checked) .shots-stage[data-shots-stack] > .shots-flip-after { grid-area: 2 / 1; }
.shots-viewer:has(.shots-side-both:checked) .shots-stage[data-shots-stack] .shots-flip-chip-after { left: 10px; top: calc(50% + 16px); }
.shots-viewer:has(.shots-side-both:checked) .shots-stage[data-shots-stack]::after { top: 50%; bottom: auto; left: 12px; right: 12px; width: auto; }
.shots-viewer:has(.shots-zoom-pick:focus-visible) .shots-seg-zoom { outline: 2px solid var(--accent, #2563eb); outline-offset: 2px; }
.shots-viewer:has(.shots-zoom-whole:checked) .shots-close-only,
.shots-viewer:has(.shots-zoom-close:checked) .shots-whole-only { display: none; }
/* Auto, with no room: After. */
@container (max-width: 419.98px) {
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-flip-before { visibility: hidden; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-flip-chip-before,
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-flip-to-after { display: none; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-seg-after { color: #fff; background: #15803d; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
}
@container (max-width: 719.98px) {
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-flip-before { visibility: hidden; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-flip-chip-before,
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-flip-to-after { display: none; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-seg-after { color: #fff; background: #15803d; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
}
/* Auto, with room: side by side. */
@container (min-width: 420px) {
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 12px; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage > .shots-flip-side { width: min(calc(50cqw - 6px), calc(100cqh * (var(--shots-shape, 16 / 10)))); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage > .shots-flip-after { grid-area: 1 / 2; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-flip-to { display: none; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-flip-chip-after { left: calc(50% + 16px); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-change-n { background: linear-gradient(90deg, #dc2626 50%, #16a34a 50%); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-seg-both { background: #fff; color: #1c1c1e; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
  .dark .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-seg-both { background: #3a3a3c; color: #f5f5f7; }
  /* #5063: the change page's crop and stacked layout, as side by side has. */
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage > .shots-flip-side[data-shots-crop] { overflow: hidden; aspect-ratio: var(--shots-crop-shape, 16 / 10); width: min(calc(50cqw - 6px), calc(100cqh * (var(--shots-crop-shape, 16 / 10)))); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-flip-side[data-shots-crop] > .shots-crop { inset: auto; left: var(--shots-crop-x, 0); top: var(--shots-crop-y, 0); width: var(--shots-crop-w, 100%); height: auto; aspect-ratio: var(--shots-shape, 16 / 10); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage[data-shots-stack] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); column-gap: 0; row-gap: 12px; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage[data-shots-stack] > .shots-flip-side { overflow: hidden; aspect-ratio: var(--shots-crop-shape, 16 / 10); width: min(100cqw, calc((50cqh - 6px) * (var(--shots-crop-shape, 16 / 10)))); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage[data-shots-stack] > .shots-flip-after { grid-area: 2 / 1; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage[data-shots-stack] .shots-flip-chip-after { left: 10px; top: calc(50% + 16px); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="phone"] .shots-stage[data-shots-stack]::after { top: 50%; bottom: auto; left: 12px; right: 12px; width: auto; }
}
@container (min-width: 720px) {
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 12px; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage > .shots-flip-side { width: min(calc(50cqw - 6px), calc(100cqh * (var(--shots-shape, 16 / 10)))); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage > .shots-flip-after { grid-area: 1 / 2; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-flip-to { display: none; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-flip-chip-after { left: calc(50% + 16px); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-change-n { background: linear-gradient(90deg, #dc2626 50%, #16a34a 50%); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-seg-both { background: #fff; color: #1c1c1e; box-shadow: 0 1px 2px rgba(0, 0, 0, 0.14); }
  .dark .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-seg-both { background: #3a3a3c; color: #f5f5f7; }
  /* #5063: the change page's crop and stacked layout, as side by side has. */
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage > .shots-flip-side[data-shots-crop] { overflow: hidden; aspect-ratio: var(--shots-crop-shape, 16 / 10); width: min(calc(50cqw - 6px), calc(100cqh * (var(--shots-crop-shape, 16 / 10)))); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-flip-side[data-shots-crop] > .shots-crop { inset: auto; left: var(--shots-crop-x, 0); top: var(--shots-crop-y, 0); width: var(--shots-crop-w, 100%); height: auto; aspect-ratio: var(--shots-shape, 16 / 10); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage[data-shots-stack] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); column-gap: 0; row-gap: 12px; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage[data-shots-stack] > .shots-flip-side { overflow: hidden; aspect-ratio: var(--shots-crop-shape, 16 / 10); width: min(100cqw, calc((50cqh - 6px) * (var(--shots-crop-shape, 16 / 10)))); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage[data-shots-stack] > .shots-flip-after { grid-area: 2 / 1; }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage[data-shots-stack] .shots-flip-chip-after { left: 10px; top: calc(50% + 16px); }
  .shots-viewer:has(.shots-side-auto:checked) .shots-view[data-shots-viewport="desktop"] .shots-stage[data-shots-stack]::after { top: 50%; bottom: auto; left: 12px; right: 12px; width: auto; }
}
/* The spec's toolbar has a third switch, so its labels give way sooner. */
@container (max-width: 560px) {
  .shots-viewer-spec .shots-seg-label { display: none; }
  .shots-viewer-spec .shots-seg-btn { padding: 0 9px; }
}
/* The prose around the screens: diagrams and tables written into an HTML
   spec. A diagram is inline SVG drawn with these few classes, so it follows
   light and dark mode; a literal fill still works, in one theme. */
.spec-html figure { margin: 12px 0; }
.spec-html figure > svg { display: block; width: 100%; max-width: 100%; height: auto; }
.spec-html figcaption { margin-top: 4px; font-size: 12px; color: var(--text-muted, #68686c); }
.spec-html .spec-diagram-wrap { overflow-x: auto; border: 1px solid var(--border-light, #e3e3e6); border-radius: 10px; padding: 10px; background: var(--bg-secondary, #f5f5f7); }
.spec-html svg text { font-family: inherit; fill: var(--text-primary, #0a0a0a); }
.spec-html svg .spec-muted { fill: var(--text-muted, #68686c); }
.spec-html svg .spec-box { fill: var(--bg-primary, #fff); stroke: var(--border, #d1d1d6); stroke-width: 1; }
.spec-html svg .spec-box-changed { fill: var(--bg-primary, #fff); stroke: var(--accent, #0a6ee0); stroke-width: 1.5; }
.spec-html svg .spec-box-new { fill: var(--accent-tint, rgba(10, 110, 224, 0.08)); stroke: var(--accent, #0a6ee0); stroke-width: 1.5; stroke-dasharray: 4 3; }
.spec-html svg .spec-line { fill: none; stroke: var(--text-faint, #8e8e93); stroke-width: 1.2; }
.spec-html svg .spec-arrow { fill: var(--text-faint, #8e8e93); }
.spec-html svg .spec-accent { fill: var(--accent, #0a6ee0); }
.spec-html svg .spec-good { fill: #16a34a; }
.spec-html svg .spec-bad { fill: #dc2626; }
.spec-html .spec-screens { margin: 12px 0; }
.spec-html .spec-screens-caption { margin-top: 6px; font-size: 12px; color: var(--text-muted, #68686c); }
.dev-topic-details { margin-top: 8px; }
.dev-topic-details-summary {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
  list-style: none;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--accent);
  user-select: none;
}
.dev-topic-details-summary::-webkit-details-marker { display: none; }
.dev-topic-details-summary::before { content: "▸"; font-size: 11px; }
.dev-topic-details[open] > .dev-topic-details-summary::before { content: "▾"; }
.dev-topic-details-body {
  margin-top: 8px;
  padding: 10px 12px;
  border-radius: 14px;
  background: var(--bg-primary);
  font-size: 13px;
  line-height: 1.5;
  color: var(--text-primary);
}
.dev-topic-note { font-size: 13px; line-height: 1.5; color: var(--text-muted); }
.dev-topic-about-body + .dev-topic-note { margin-top: 8px; }
/* #2603: a governance proposal's vote roster, under the About sheet's
   words. The ledger's review line rendered outside the ledger, so it takes
   the note's size and its own top margin rather than the row grid's. */
.dev-topic-roster { margin-top: 10px; font-size: 13px; line-height: 1.5; }
.dev-topic-transcript .st-section { margin: 0; }

/* ── Discussion ──
   The app's own thread — `#gc-thread-messages`, the group chat's transcript
   host — is dressed as the sheet after the head. A request's page is its own
   layout (#4453, `.dev-request` below): its GitHub comments are rows of that
   thread. */
#gc-thread-head { display: block; }

/* ── The change page as the Workshop's Needs-you item ──────────────────
   topic/topic-head.tsx ChangeHero, StepsSheet and DetailsSheet. The hero
   borrows the item's pieces by class — the eyebrow, the title, the by-line
   and its avatar, the chips — and adds only what a page needs that a swipe
   card does not: no fixed height and no snap, the title unclamped, the
   chips under the by-line rather than beside it, and the card's action
   band under them. */
.dev-topic-hero {
  position: relative;
  padding: 16px 20px 18px;
  overflow: hidden;
}
.dev-topic-hero[data-ws-tint="a"] { background-image: linear-gradient(165deg, rgba(139, 92, 246, .10), rgba(255, 255, 255, 0) 55%, rgba(244, 114, 182, .08)); }
.dev-topic-hero[data-ws-tint="b"] { background-image: linear-gradient(165deg, rgba(59, 130, 246, .10), rgba(255, 255, 255, 0) 55%, rgba(251, 191, 36, .08)); }
.dark .dev-topic-hero[data-ws-tint="a"] { background-image: linear-gradient(165deg, rgba(139, 92, 246, .16), rgba(0, 0, 0, 0) 55%, rgba(244, 114, 182, .10)); }
.dark .dev-topic-hero[data-ws-tint="b"] { background-image: linear-gradient(165deg, rgba(59, 130, 246, .16), rgba(0, 0, 0, 0) 55%, rgba(251, 191, 36, .08)); }
.dev-topic-hero-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 20px; }
.dev-topic-hero-eyebrow { display: inline-flex; align-items: center; gap: 4px; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dev-topic-hero-eyebrow > a { color: var(--accent); text-decoration: none; letter-spacing: 0; text-transform: none; font-weight: 700; }
.dev-topic-hero-eyebrow > a:hover { text-decoration: underline; }
/* The item's title, unclamped and at page size: a page has room for the
   whole line, where the swipe card's three-line clamp hid the end of a
   long one behind an ellipsis nobody could open. */
.dev-topic-hero > .dev-topic-hero-title {
  margin: 10px 0 0; max-width: none; display: block; -webkit-line-clamp: unset; overflow: visible;
  font-size: 24px; line-height: 1.2; overflow-wrap: anywhere;
}
.dev-topic-hero > .dev-topic-hero-by { margin-top: 10px; max-width: none; }
/* The card's tags in the Needs-you chip's shape. Each keeps the tint and
   ink utilities it arrived with — the card's own colours — and those
   outrank the chip's neutral fill because tailwind.css loads last. (The
   selectors here are two classes deep on purpose: the item's own rules,
   and its wide-layout ones further down, are one class, and the hero's
   must win whichever comes later in the file.) */
.dev-topic-hero > .dev-topic-hero-chips { margin-top: 10px; flex: none; justify-content: flex-start; }
.dev-topic-hero-chips > .dev-badge { height: 24px; padding: 0 9px; gap: 6px; border-radius: 9999px; font-size: 12px; font-weight: 600; }
.dev-topic-hero-chips > .dev-badge.font-mono { font-size: 11.5px; }
.dev-topic-hero-chips > .dev-badge .attr-avatar { width: 16px; height: 16px; font-size: 0.65rem; }
/* The band wears the card's class for the band rules above — the one-line
   fold into ⋯, the accent pills, Preview and the hamburger at the right;
   the card's own box (its radius, padding and edge) comes off here. */
.dev-topic-hero > div.dev-topic-hero-actions.dev-card-topic {
  margin-top: 12px; padding: 0; border-radius: 0; box-shadow: none; background: none; --dev-edge-w: 0px;
}
/* QA 2026-09-24 Q26: THE HERO'S BAND IS AS TALL AS ITS VOTE BUTTON. The band
   reserves and clips one 28px row (the card rule above), which is right for
   the 28px pills, but the hero also carries the Vote button, and that is
   30px (`.dev-vote-btn`). So the row was 30px inside a 28px window: the Vote
   pill lost its bottom 2px and the round menu button its last pixel, and on
   a phone both read as cut flat along the bottom. 30px here fits the tallest
   thing the hero's band holds; a wrapped second row still starts at 36px
   (30 plus the 6px row gap) and stays clipped, and useFoldedActions' "off the
   row" test reads the band's own height, so the fold is unchanged. Only the
   hero: on a card the Vote button sits in the status row, not in this band. */
.dev-topic-hero .gc-card-actions { margin-top: 0; min-height: 30px; max-height: 30px; }
.dev-topic-hero .gc-card-actions > .dev-vote-btn { margin-right: 4px; }
/* The summary at the item's reading size, the whole of it. */
.dev-topic-hero-summary { margin: 14px 0 0; font-size: 17px; line-height: 1.45; color: var(--text-secondary); }
.dev-topic-hero-summary > :first-child { margin-top: 0; }
.dev-topic-hero-summary > :last-child { margin-bottom: 0; }
/* "Addresses", then the issue as a chip: the number bold, the title after
   it, ellipsised rather than wrapped; the owner's pencil at the line's end. */
.dev-topic-hero-issues { margin-top: 12px; min-width: 0; }
.dev-topic-hero-issues-line { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 8px; font-size: 13px; color: var(--text-muted); }
.dev-topic-hero-issues-k { font-weight: 600; }
.dev-topic-issue { max-width: 100%; height: auto; min-height: 24px; padding: 2px 10px 2px 8px; gap: 6px; text-decoration: none; }
.dev-topic-issue:hover { text-decoration: none; filter: brightness(.96); }
.dev-topic-issue > b { font-weight: 700; flex: none; }
.dev-topic-issue > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
.dev-topic-hero-issues-edit { margin-left: auto; }
.dev-topic-hero-issues form { margin-top: 4px; }
/* The picture, or the line that says it is coming. */
.dev-topic-hero .dev-topic-visuals { margin-top: 14px; }
.dev-topic-hero .dev-topic-shots { margin-top: 12px; }
.dev-topic-hero-shots { display: inline-flex; align-items: center; gap: 8px; margin: 14px 0 0; font-size: 13px; color: var(--text-muted); }
.dev-topic-hero-shots > .dc-status-spinner-arc { flex: none; margin: 0; }
.dev-topic-hero > .dev-topic-note { margin-top: 12px; }

/* ── The steps: the card's grey strip, expanded ──
   The same panel as card/dev-card.tsx RequirementsRow — its tints ride in
   as the same utilities — with room to say, under each gate, what the
   ledger row for it said. */
.dev-topic-steps { padding: 8px; }
.dev-steps { overflow: hidden; }
.dev-steps-head { display: flex; align-items: baseline; gap: 6px; padding: 10px 14px; font-size: 13px; line-height: 1.35; color: var(--text-secondary); }
.dev-steps-headline { font-weight: 700; color: var(--text-primary); white-space: nowrap; }
.dev-steps-detail { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-muted); }
.dev-steps-count { margin-left: auto; font-variant-numeric: tabular-nums; font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.dev-steps-list { list-style: none; margin: 0; padding: 4px 0; }
.dev-step { display: grid; grid-template-columns: 22px 1fr auto; column-gap: 10px; align-items: baseline; padding: 9px 14px; font-size: 13.5px; line-height: 1.4; }
.dev-step + .dev-step { border-top: 1px solid var(--app-sheet-line); }
/* The mark: a ring, filled ok for a cleared step and blocked for a red one;
   the step waiting on a person wears the attention tone, and the one in
   flight spins the shell's arc. */
.dev-step-mark {
  display: inline-flex; align-items: center; justify-content: center; align-self: start; margin-top: 1px;
  width: 20px; height: 20px; border-radius: 9999px; font-size: 11px; font-weight: 800;
  box-shadow: inset 0 0 0 1.5px currentColor; color: var(--text-muted); background: transparent;
}
.dev-step-mark-done { background: var(--state-ok); color: #fff; box-shadow: none; }
.dev-step-mark-blocked { background: var(--state-blocked); color: #fff; box-shadow: none; }
.dev-step-mark-waiting { color: var(--state-attention); }
.dev-step-mark-active { box-shadow: none; color: var(--accent); }
.dev-step-mark-active > .dc-status-icon { margin: 0; }
.dev-step-label { font-weight: 600; color: var(--text-primary); }
.dev-step-done .dev-step-label { color: var(--text-secondary); }
.dev-step-pending .dev-step-label { color: var(--text-muted); }
.dev-step-actor { font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.dev-step-body { grid-column: 2 / 4; display: flex; flex-direction: column; gap: 3px; margin-top: 2px; min-width: 0; font-size: 12.5px; color: var(--text-muted); }
.dev-step-body .dev-ledger-text { color: var(--text-secondary); }
.dev-step-blocked .dev-step-body .dev-ledger-text { color: var(--text-primary); }
.dev-step-note { color: var(--text-muted); }
.dev-step-when { color: var(--text-muted); }
.dev-step-body .dev-ledger-fails { font-size: 12px; }
.dev-step-body .dev-ledger-ops { margin-top: 4px; }
.dev-step-body .dev-ledger-help { margin: 0; }
/* The vote's line: the bar to the threshold, the card's pill, the counts. */
.dev-step-vote { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 10px; }
.dev-step-vote-bar { flex: 0 0 160px; height: 8px; border-radius: 999px; background: var(--state-neutral-bg); overflow: hidden; }
.dev-step-vote-bar > i { display: block; height: 100%; border-radius: 999px; background: var(--state-ok); }
.dev-step-vote-tally { color: var(--text-primary); font-weight: 600; font-size: 12.5px; }
/* #3234: the threshold moved during the vote; muted, beside the counts. */
.dev-step-vote-was { color: var(--text-muted); font-size: 12.5px; }

/* ── A merge gate's step: one short line ──
   The mark, the label and the line on one row (app-view.js _stepLine), a
   button only for whoever can clear it. The line takes the step's tone when
   it is waiting on somebody or blocked — the one word a scan is after. */
.dev-step-main {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0;
  grid-column: 2 / 4;
}
.dev-step-line { font-size: 12.5px; color: var(--text-muted); min-width: 0; }
.dev-step-active .dev-step-line { color: var(--text-secondary); }
.dev-step-waiting .dev-step-line { color: var(--state-attention); font-weight: 600; }
.dev-step-blocked .dev-step-line { color: var(--state-blocked); font-weight: 600; }
.dev-step-main .dev-ledger-review-line { gap: 4px 8px; }
/* Who voted is a list of names, not the step's state: it keeps the muted ink
   whatever the mark says. */
.dev-step-main .dev-ledger-roster.dev-step-line { color: var(--text-muted); font-weight: 400; }
.dev-step-main .dev-ledger-help { margin: 0; }
.dev-step-main .dev-step-vote-was { flex-basis: 100%; font-weight: 400; }
/* The Checks step is a disclosure: the whole row is its control. */
.dev-step-toggle {
  border: 0; background: none; padding: 0; margin: 0; font: inherit; color: inherit; text-align: left;
  cursor: pointer; width: 100%;
}
.dev-step-toggle:hover .dev-step-label { color: var(--accent); }
.dev-step-chev {
  width: 14px; height: 14px; margin-left: auto; align-self: center; flex: none;
  color: var(--text-muted); transition: transform 150ms;
}
.dev-step-toggle[aria-expanded="true"] .dev-step-chev { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .dev-step-chev { transition: none; } }
/* The run, open: a row per stage — its name, its bar, its count. */
.dev-step-run {
  display: grid; grid-template-columns: 5.5rem minmax(0, 1fr) auto; gap: 7px 10px; align-items: center;
  margin-top: 6px; padding: 10px 12px; border-radius: 10px; background: var(--bg-secondary);
  font-size: 12px; color: var(--text-muted);
}
.dev-step-run-row { display: contents; }
.dev-step-run-k { color: var(--text-secondary); font-weight: 600; white-space: nowrap; }
.dev-step-run-v { font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap; }
.dev-step-run-v.is-bad { color: var(--state-blocked); font-weight: 600; }
.dev-step-run .dev-ledger-build-bar { flex-basis: auto; height: 8px; margin: 0; gap: 3px; }
.dev-step-run .dev-ledger-build-seg { background: var(--state-neutral-bg); }
.dev-step-run .dev-ledger-build-seg.is-done { background: var(--state-ok); }
.dev-step-run .dev-ledger-build-seg.is-now { background: var(--accent); }
.dev-step-run-track {
  position: relative; display: block; height: 8px; border-radius: 999px; overflow: hidden;
  background: var(--state-neutral-bg);
}
.dev-step-run-track > i { position: absolute; top: 0; bottom: 0; left: 0; display: block; }
.dev-step-run-track > .is-pass { background: var(--state-ok); }
.dev-step-run-track > .is-fail { background: var(--state-blocked); }
.dev-step-run-track.is-busy { animation: dev-ledger-progress-pulse 1.4s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .dev-step-run-track.is-busy { animation: none; } }
.dev-step-run > .dev-ledger-fails { grid-column: 1 / -1; margin: 2px 0 0; }
.dev-step-run-note { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--text-muted); }
@media (max-width: 520px) {
  .dev-step-run { grid-template-columns: 4.25rem minmax(3rem, 1fr) minmax(0, max-content); gap: 7px 8px; }
  .dev-step-run-v { white-space: normal; }
}

/* The hero's status row: the card's pill and Vote button, above the band. */
.dev-topic-hero .dev-card-badges.dev-topic-hero-status { margin-top: 0; }
.dev-topic-hero .dev-topic-hero-status + .gc-card-actions { margin-top: 8px; }

/* ── Technical details, as a sheet over the page ──
   Opened from the ⋯ menu (DetailsSheet); body-mounted, so no frosted sheet
   contains it. A bottom sheet on a phone, a centred card above 720px. */
.dev-details-scrim {
  position: fixed; inset: 0; z-index: 70;
  display: flex; align-items: flex-end; justify-content: center;
  background: rgba(20, 20, 26, .28); backdrop-filter: blur(2px); -webkit-backdrop-filter: blur(2px);
}
.dev-details-card {
  width: min(680px, 100%); max-height: 78vh; overflow: auto; box-sizing: border-box;
  padding: 14px 18px 20px; border-radius: 22px 22px 0 0;
  background: var(--bg-primary); color: var(--text-primary);
  box-shadow: 0 -12px 40px rgba(0, 0, 0, .22);
}
@media (min-width: 720px) {
  .dev-details-scrim { align-items: center; }
  .dev-details-card { border-radius: 22px; }
}
.dev-details-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.dev-details-head > .dev-topic-h { margin: 0; }
.dev-details-close {
  display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px;
  border: 0; border-radius: 9999px; background: var(--bg-tertiary); color: var(--text-primary); cursor: pointer;
}
.dev-details-card > .dev-topic-details-body { margin: 0; padding: 0; background: none; font-size: 13.5px; line-height: 1.5; }
/* B10b: Details stays mounted while shut (the steps it carries stay on the
   page); the scrim's flex would otherwise outrank the hidden attribute. */
.dev-details-scrim[hidden] { display: none; }
.dev-details-pr { display: flex; align-items: baseline; gap: 10px; margin: 0 0 10px; font-size: 13px; color: var(--text-secondary); }
.dev-details-pr > a { font-weight: 600; color: var(--accent); }
/* The steps sheet sits in the card as its own panel: the card is the
   surface, so the sheet's frost and hairline step aside. */
.dev-details-card .dev-topic-steps { padding: 0; background: none; backdrop-filter: none; -webkit-backdrop-filter: none; box-shadow: none; border-radius: 0; }
.dev-details-part { margin-top: 14px; }
.dev-details-sub { margin: 0 0 6px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
.dev-details-part > .dev-topic-details-body { margin: 0; padding: 0; background: none; font-size: 13.5px; line-height: 1.5; }
/* B10b: the hero's Tested line, in the steps' own marks. A tap opens
   Details at the Checks part. */
.dev-topic-tested {
  display: inline-flex; align-items: center; gap: 8px; margin: 12px 0 0; padding: 0;
  border: 0; background: none; cursor: pointer; font: inherit; font-size: 13.5px; font-weight: 600; color: var(--text-secondary);
}
.dev-topic-tested[data-tested="failed"] { color: var(--state-blocked); }
.dev-topic-tested-mark {
  display: inline-flex; align-items: center; justify-content: center; flex: none;
  width: 20px; height: 20px; border-radius: 9999px; font-size: 11px; font-weight: 800;
  box-shadow: inset 0 0 0 1.5px currentColor; color: var(--text-muted);
}
.dev-topic-tested-mark-passed { background: var(--state-ok); color: #fff; box-shadow: none; }
.dev-topic-tested-mark-failed { background: var(--state-blocked); color: #fff; box-shadow: none; }
.dev-topic-tested-mark-broken { color: var(--state-attention); }
.dev-topic-tested-mark-running { box-shadow: none; color: var(--accent); }
.dev-topic-tested-mark-running > .dc-status-icon { margin: 0; }
.dev-description-dialog { margin: auto; border: 0; border-radius: 20px; width: min(680px, calc(100% - 24px)); max-height: 85dvh; }
.dev-description-dialog::backdrop { background: rgba(20, 20, 26, .5); }
@media (max-width: 480px) {
  .dev-topic-hero { padding: 14px 14px 16px; }
  .dev-topic-hero-title { font-size: 22px; }
  .dev-topic-hero-summary { font-size: 16px; }
  .dev-step-vote-bar { flex-basis: 120px; }
}
/* The Workshop row's two-line preview names the spec after the sentence. */
.dev-feed-comment-spec { display: block; margin-top: 2px; font-weight: 600; color: var(--text-primary); }
#dev-topic-thread #gc-thread-messages {
  margin: 4px 0 0;
  max-width: 900px;
  margin-left: auto;
  margin-right: auto;
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  border-radius: 26px;
  padding: 12px 14px 14px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
#dev-topic-thread #gc-thread-messages::before {
  content: "Discussion";
  display: block;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
/* "Load earlier" as a quiet pill; the empty line as a muted centred note. */
#dev-topic-thread #gc-thread-earlier {
  height: 26px;
  padding: 0 12px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
}
#dev-topic-thread #gc-thread-messages > .text-xs { text-align: center; color: var(--text-muted); }
/* A message as a bubble: the swatch avatar outside, the white bubble with
   the author and the age on its first line, the viewer's own in the accent
   tint — the Activity feed's row, at reading size. */
#dev-topic-thread .gc-msg {
  padding: 0;
  gap: 8px;
  align-items: flex-start;
}
#dev-topic-thread .gc-msg:hover { background: transparent; }
#dev-topic-thread .gc-msg > .min-w-0 {
  flex: 0 1 auto;
  max-width: 85%;
  padding: 8px 12px 9px;
  border-radius: 16px;
  background: var(--bg-primary);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
#dev-topic-thread .gc-msg-self > .min-w-0 { background: var(--accent-tint); }
/* A change's Discussion speaks the general chat's language (group-chat.js
   `language: 'chat'`): the transcript draws the bubbles itself, the
   viewer's own in the accent bubble, so the topic page's own bubble dressing
   above stands down there — the row is the Messages screen's row. */
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg { padding: 8px 16px; gap: 12px; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg > .min-w-0 {
  flex: 1 1 0%;
  max-width: 100%;
  padding: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
}
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg-self > .min-w-0 { background: transparent; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg > .min-w-0 > .flex { gap: 8px; margin-bottom: 0; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg > .min-w-0 > .flex > .truncate { font-size: 17px; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg > .min-w-0 > .flex > .shrink-0 { font-size: 15px; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg > .min-w-0 > .leading-snug { font-size: 17px; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg > [aria-hidden="true"] { width: 44px; height: 44px; font-size: 17px; border-radius: 12px; margin-top: 0; }
/* The editor needs the row's available width, not the bubble's intrinsic
   text width. Save and Cancel clear data-editing and restore bubble sizing. */
#dev-topic-thread .gc-msg[data-editing="1"] > .min-w-0 {
  flex: 1 1 0%;
  max-width: 100%;
}
#dev-topic-thread .gc-msg > .min-w-0 > .flex { gap: 6px; margin-bottom: 2px; }
#dev-topic-thread .gc-msg > .min-w-0 > .flex > .truncate { font-size: 14px; }
#dev-topic-thread .gc-msg > .min-w-0 > .flex > .shrink-0 { font-size: 12.5px; }
#dev-topic-thread .gc-msg > .min-w-0 > .leading-snug { font-size: 15px; }
#dev-topic-thread .gc-msg > [aria-hidden="true"] { width: 26px; height: 26px; font-size: 11px; border-radius: 999px; margin-top: 6px; }
/* (#5165) Grouped gutter follows the 26px bubble avatar, so a continuation bubble lines up under the first one. */
#dev-topic-thread .gc-msg[data-grouped] > .gc-msg-gutter { width: 26px; }
#dev-topic-thread #gc-thread-messages[data-language="chat"] .gc-msg[data-grouped] > .gc-msg-gutter { width: 44px; }
#dev-topic-thread .gc-msg-system { padding: 2px 12px; font-size: 12px; }
#dev-topic-thread .gc-msg-system-repeat { color: var(--text-faint); }
@media (max-width: 639px) {
  #dev-topic-thread .gc-msg > .min-w-0 { max-width: 100%; }
}
/* ═══════════════════════════════════════════════════════════════════════
   #4453: A REQUEST'S PAGE, drawn as a Messages reply thread
   (group-chat/thread-shell.tsx `RequestShell`). The "‹ Workshop" chip, then
   ONE sheet, the one Messages opens a reply thread in: its header ("Request
   #N", the category, ⋯), a scroller with the request as the root post
   (dev-board/topic/request-head.tsx) and its replies under "N replies"
   (group-chat/transcript.tsx `RequestRows`), and the thread's composer at
   its foot.

   Messages' tokens are only defined on `.messages-layout` and
   `.dev-ws-discussion`, so they are defined here too, with the same values.
   ═══════════════════════════════════════════════════════════════════════ */
.dev-request {
  --messages-surface: #ffffff;
  --messages-surface-2: var(--bg-secondary);
  --messages-hair: var(--border-light);
  --messages-disc-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
  display: flex;
  flex-direction: column;
  width: 100%;
  max-width: 780px;
  height: 100%;
  min-height: 0;
  margin: 0 auto;
  padding: 0 12px;
  box-sizing: border-box;
}
.dark .dev-request {
  --messages-surface: var(--dc-raised);
  --messages-surface-2: #3a3a3c;
  --messages-hair: rgba(255, 255, 255, 0.08);
  --messages-disc-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
}
.dev-request-back { flex: 0 0 auto; display: flex; padding-left: 4px; }
.dev-request-back:empty { display: none; }
.dev-request-back .dev-topic-back { margin: 0 0 12px; }
.dev-request-sheet { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.dev-request-bar { flex: 0 0 auto; }
.dev-request-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.dev-request-bar .messages-thread-action { position: relative; }

/* The root post: the request itself. */
.dev-request-root { padding-top: 4px; padding-bottom: 14px; }
.dev-request-title {
  margin: 4px 0 6px;
  font-size: 20px;
  line-height: 1.25;
  font-weight: 700;
  color: var(--text-primary);
  text-wrap: balance;
  overflow-wrap: anywhere;
}
/* The asker's own words: a quote, set apart from what the page says. */
.dev-request-ask {
  margin: 2px 0 0;
  padding: 2px 0 2px 12px;
  border-left: 3px solid color-mix(in srgb, var(--text-muted) 40%, transparent);
}
.dev-request-ask-text { font-size: 15px; line-height: 1.5; color: var(--text-secondary); overflow-wrap: anywhere; }
.dev-request-ask-text .dev-issue-body { font-size: inherit; color: inherit; }
.dev-request-ask-text p,
.dev-request-ask-text .dc-p { margin: 0 0 4px; }
.dev-request-ask-text ul { margin: 0 0 4px; padding-left: 20px; list-style: disc; }
.dev-request-ask-text ol { margin: 0 0 4px; padding-left: 20px; list-style: decimal; }
.dev-request-ask-text strong { color: var(--text-primary); }
/* The hub's own reveal, in the quote's text column; the caret turns over. */
.dev-request-more.dev-ws-reveal-start { margin-top: 4px; padding-left: 0; width: auto; }
.dev-request-more .dev-ws-reveal-chev { transition: transform 240ms cubic-bezier(.32, .72, 0, 1); }
@media (prefers-reduced-motion: reduce) {
  .dev-request-more .dev-ws-reveal-chev { transition: none; }
}

/* #4481 and #4482: a request's screenshots (dev-board/topic/request-shots.ts).
   With the experimental C switch on, the blocks that hold one leave the fold
   for `.dev-request-shots`, set like the words. A picture whose link carries
   a C comment's pin (comment-pin/pin-data.ts) shows the comment over it as a
   layer: the pin's square corner on the point, the words in a card beside
   it, and a button on the picture's corner that hides both. The picture
   itself stays clean. `.pin-shot` takes the picture link's box and margin,
   so the layer's percentages land on the image. */
.dev-request-shots { margin-top: 6px; font-size: 15px; line-height: 1.5; color: var(--text-secondary); overflow-wrap: anywhere; }
.dev-request-shots .dev-issue-body { font-size: inherit; color: inherit; }
.dev-request-shots p,
.dev-request-shots .dc-p { margin: 0 0 4px; }
.dev-request-shots strong { color: var(--text-primary); }
.pin-shot { position: relative; display: block; width: fit-content; max-width: 100%; margin: 6px 0; }
.pin-shot > .dc-inline-img-link,
.pin-shot > .dc-inline-img { margin: 0; }
.pin-shot-layer {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  transition: opacity 160ms ease, visibility 160ms;
}
/* One mark per pin; a request of several comments numbers them. */
.pin-shot-mark { position: absolute; inset: 0; }
/* The button's `data-shown` is the one record of whether it is shown; its words say which press it is (not `aria-pressed`, whose name must not change). */
.pin-shot-toggle[data-shown="false"] + .pin-shot-layer { opacity: 0; visibility: hidden; }
.pin-shot-pin {
  position: absolute;
  left: var(--pin-x);
  top: var(--pin-y);
  width: 24px;
  height: 24px;
  box-sizing: border-box;
  border: 2px solid #ffffff;
  border-radius: 50% 50% 50% 0;
  background: var(--accent);
  color: var(--accent-ink);
  display: grid;
  place-items: center;
  font-size: 11px;
  font-weight: 800;
  line-height: 1;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35);
  transform: translateY(-100%);
}
.pin-shot-note {
  position: absolute;
  box-sizing: border-box;
  width: max-content;
  max-width: max(120px, min(240px, calc(100% - var(--pin-x) - 34px)));
  padding: 6px 10px;
  border-radius: 12px;
  background: var(--dc-sheet);
  color: var(--text-primary);
  font-size: 13px;
  font-weight: 500;
  line-height: 1.35;
  text-align: start;
  overflow-wrap: anywhere;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18), 0 0 0 1px var(--app-sheet-line);
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 6;
  line-clamp: 6;
  overflow: hidden;
}
/* Right of the pin, or left of the point in the picture's right part; its
   foot level with the point, or its head there near the picture's top. */
.pin-shot-mark[data-side="right"] .pin-shot-note { left: calc(var(--pin-x) + 30px); }
.pin-shot-mark[data-side="left"] .pin-shot-note {
  right: calc(100% - var(--pin-x) + 6px);
  max-width: max(120px, min(240px, calc(var(--pin-x) - 10px)));
}
.pin-shot-mark[data-rise="up"] .pin-shot-note { bottom: calc(100% - var(--pin-y)); }
.pin-shot-mark[data-rise="down"] .pin-shot-note { top: var(--pin-y); }
.pin-shot-toggle {
  position: absolute;
  top: 6px;
  right: 6px;
  z-index: 2;
  appearance: none;
  border: 0;
  cursor: pointer;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  line-height: 1;
  padding: 5px 9px;
  border-radius: 999px;
  color: var(--text-primary);
  background: var(--dc-sheet);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2), 0 0 0 1px var(--app-sheet-line);
}
.pin-shot-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dark .pin-shot-note,
.dark .pin-shot-toggle { background: var(--dc-raised); }
@media (prefers-reduced-motion: reduce) {
  .pin-shot-layer { transition: none; }
}

/* A request's track card drawn from the board's older row keeps its place
   unseen until the fresh row says where the work is (request-head.tsx
   `trackPending`, polish board #12). */
.dev-request-track[data-request-track="pending"] { visibility: hidden; }
/* Where it stands: a card on the sheet, the stepper, a sentence, the next step. */
.dev-request-status {
  margin-top: 10px;
  max-width: 560px;
  border-radius: 16px;
  padding: 14px 14px 12px;
  background: var(--messages-surface);
}
.dev-request-steps {
  list-style: none;
  margin: 0 0 12px;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(4, 1fr);
}
.dev-request-step { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 5px; min-width: 0; }
.dev-request-step::before {
  content: '';
  position: absolute;
  top: 10px;
  left: 24px;
  right: 4px;
  height: 2px;
  border-radius: 2px;
  background: var(--messages-hair);
}
.dev-request-step:last-child::before { display: none; }
.dev-request-step[data-state="done"]::before { background: color-mix(in srgb, var(--text-muted) 45%, transparent); }
.dev-request-step-mark {
  position: relative;
  z-index: 1;
  width: 22px;
  height: 22px;
  box-sizing: border-box;
  border-radius: 999px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 2px solid var(--messages-hair);
  background: var(--messages-surface);
  color: var(--text-muted);
}
.dev-request-step-mark svg { width: 13px; height: 13px; stroke-width: 2.5; }
/* Done: a muted check ring, no fill. Now: the lit ring and dot. Next: a hairline. */
.dev-request-step[data-state="done"] .dev-request-step-mark { border-color: color-mix(in srgb, var(--text-muted) 55%, transparent); }
.dev-request-step[data-state="now"] .dev-request-step-mark { border-color: var(--lit-ink); background: var(--lit-tint); }
.dev-request-step[data-state="now"] .dev-request-step-mark::after {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 999px;
  background: var(--lit-ink);
}
.dev-request-step-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
.dev-request-step[data-state="now"] .dev-request-step-label { color: var(--text-primary); font-weight: 700; }
.dev-request-say { margin: 0; font-size: 15px; font-weight: 600; color: var(--text-primary); }
.dev-request-fine { margin: 2px 0 0; font-size: 13px; color: var(--text-muted); }
.dev-request-actions { margin-top: 12px; display: flex; gap: 8px; }
.dev-request-primary {
  appearance: none;
  display: inline-flex;
  align-items: center;
  border: 0;
  cursor: pointer;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: var(--accent-ink);
  background: var(--accent);
  padding: 8px 16px;
  border-radius: 999px;
  text-decoration: none;
}
.dev-request-primary:hover { background: var(--accent-light); }
.dev-request-primary:disabled { opacity: .55; cursor: default; }

/* Each spec at its newest version, hanging off the request as a shared item. */
.dev-request-specs.messages-object-list { margin-top: 8px; max-width: 560px; }
.dev-request-read {
  appearance: none;
  flex: 0 0 auto;
  border: 0;
  cursor: pointer;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
  background: var(--messages-surface-2);
  padding: 7px 14px;
  border-radius: 999px;
}
.dev-request-read:disabled { opacity: .6; cursor: default; }

/* The stream: no sheet of its own (the sheet is the thread's), Messages'
   row metrics, and the viewer's own replies not set apart. */
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) {
  margin: 0;
  max-width: none;
  padding: 0 0 8px;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  display: block;
}
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"])::before { content: none; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg { padding: 8px 16px; gap: 12px; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg[data-grouped] { padding-top: 1px; padding-bottom: 1px; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 {
  flex: 1 1 0%;
  max-width: 100%;
  padding: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
}
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg-self > .min-w-0 { background: transparent; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg-username-self { color: inherit; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .flex { gap: 8px; margin-bottom: 0; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .flex > .truncate { font-size: 17px; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .flex > .shrink-0 { font-size: 15px; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .leading-snug { font-size: 17px; line-height: 1.3; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > [aria-hidden="true"] {
  width: 36px;
  height: 36px;
  font-size: 13px;
  border-radius: 12px;
  margin-top: 0;
}
/* (#5165) The grouped row's gutter span keeps Tailwind's w-11 (44px) while
   this surface draws a 36px avatar, so every line after the first is indented
   8px. Match the gutter to the avatar so the text lines up under it. */
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg[data-grouped] > .gc-msg-gutter { width: 36px; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg-time { font-size: inherit; }
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg-content {
  font-size: 17px;
  line-height: 1.3;
  color: var(--text-primary);
}
/* Messages' own desktop scale: 15 over 13, the words at 15. */
@media (min-width: 768px) {
  #dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .flex > .truncate { font-size: 15px; }
  #dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .flex > .shrink-0 { font-size: 13px; }
  #dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg > .min-w-0 > .leading-snug,
  #dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .gc-msg-content { font-size: 15px; line-height: 1.4; }
}
.gc-msg-via { color: var(--text-muted); white-space: nowrap; }
/* Something that happened: one muted line, its glyph in the avatar column. */
#dev-topic-thread #gc-thread-messages:is([data-language="request"], [data-language="change"]) .dev-request-event,
.dev-request-event {
  display: flex;
  align-items: baseline;
  gap: 12px;
  padding: 6px 16px;
  text-align: left;
  font-size: 15px;
  color: var(--text-muted);
}
.dev-request-event-glyph { flex: 0 0 36px; text-align: center; font-size: 15px; }
.dev-request-event-text { min-width: 0; overflow-wrap: anywhere; }
.dev-request-event-text b { color: var(--text-primary); font-weight: 600; }
.dev-request-event-time { white-space: nowrap; }
.dev-request-event-link {
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
  font: inherit;
  color: var(--accent);
  font-weight: 600;
  text-decoration: none;
}
.dev-request-event-link:hover { text-decoration: underline; }
@media (max-width: 640px) {
  .dev-request { padding: 0 8px; }
  .dev-request-title { font-size: 18px; }
  .dev-request-step-label { font-size: 12px; }
}

/* ═══════════════════════════════════════════════════════════════════════
   #4455: A CHANGE'S PAGE, the same thread (`.dev-request.dev-change`), with
   the change as its root post (dev-board/topic/change-head.tsx): the title,
   the summary as the request page's quote, one row with the requests it
   addresses and the thanks, then two cards one above the other (Votes, then
   Testing: each its name and figure over its bar, its button beside them and
   one line under), then the Before and after card. Its stream is the
   request page's (`[data-language="change"]` above).

   A bar still moving is drawn in the lit ink; a finished one is full and
   green, with a green check and figure (AGENTS.md, "a finished progress bar
   may be green"): done is good news here, not a state to quieten.
   ═══════════════════════════════════════════════════════════════════════ */
.dev-change-via { margin-left: -4px; color: var(--text-muted); }
.dev-change-stale { margin: 6px 0 0; font-size: 13px; color: var(--text-muted); }
.dev-change-root .dev-topic-hero-more { margin-top: 6px; }
.dev-change-root .dev-change-issues { margin-top: 10px; }

/* The requests it addresses, and the thanks: one row. */
.dev-change-addresses { margin-top: 10px; }
.dev-change-chips {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  column-gap: 6px;
  row-gap: 6px;
}
.dev-change-chips-lead { grid-area: 1 / 1; font-size: 13px; color: var(--text-muted); }
.dev-change-chips-first { grid-area: 1 / 2; min-width: 0; display: flex; align-items: center; gap: 6px; }
.dev-change-chips .dev-topic-issue { flex: 0 1 auto; min-width: 0; max-width: 100%; }
.dev-change-reqs-more { flex: none; }
/* The thanks host is `display: contents`, so the button's wrap is the cell. */
.dev-change-chips .dev-change-thanks { grid-area: 1 / 3; justify-self: end; margin-left: 4px; }
.dev-change-chips:not(:has(.dev-change-chips-first)) .dev-change-thanks { grid-area: 1 / 1 / 2 / -1; justify-self: start; margin-left: 0; }
.dev-change-chips-rest {
  grid-area: 2 / 2 / 3 / -1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  min-width: 0;
}

/* Votes, then Testing: two cards in the Messages surface. */
.dev-change-pair { margin-top: 12px; display: grid; gap: 8px; }
.dev-change-gate {
  min-width: 0;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  column-gap: 16px;
  padding: 12px 12px 12px 14px;
  border-radius: 16px;
  background: var(--messages-surface);
}
.dev-change-gate-main { min-width: 0; }
.dev-change-gate-act { display: flex; align-items: center; gap: 8px; }
.dev-change-gate-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; line-height: 1.3; }
.dev-change-gate-name { display: inline-flex; align-items: center; gap: 6px; font-size: 15px; font-weight: 600; color: var(--text-primary); }
.dev-change-gate-check { width: 14px; height: 14px; color: var(--state-ok); stroke-width: 2.5; }
.dev-change-gate-figure { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-align: right; }
.dev-change-gate[data-tone="ask"] .dev-change-gate-figure { color: var(--accent); font-weight: 600; }
.dev-change-gate[data-tone="done"] .dev-change-gate-figure { color: var(--state-ok); font-weight: 600; }
.dev-change-gate[data-tone="bad"] .dev-change-gate-figure { color: var(--state-blocked); font-weight: 600; }
.dev-change-gate[data-tone="warn"] .dev-change-gate-figure { color: var(--state-attention); font-weight: 600; }
.dev-change-bar { margin-top: 8px; height: 8px; display: flex; gap: 3px; }
.dev-change-bar-seg {
  position: relative;
  height: 100%;
  min-width: 6px;
  border-radius: 999px;
  overflow: hidden;
  background: color-mix(in srgb, var(--text-muted) 16%, transparent);
}
.dev-change-bar-fill { position: absolute; inset: 0 auto 0 0; border-radius: inherit; transition: width 400ms cubic-bezier(.32, .72, 0, 1); }
.dev-change-bar-fill[data-state="moving"],
.dev-change-gate[data-done="false"] .dev-change-bar-fill[data-state="done"] { background: var(--lit-ink); }
.dev-change-gate[data-done="true"] .dev-change-bar-fill[data-state="done"] { background: var(--state-ok); }
.dev-change-bar-fill[data-state="bad"] { background: var(--state-blocked); }
.dev-change-gate-note { grid-column: 1 / -1; margin: 8px 0 0; font-size: 13px; line-height: 1.4; color: var(--text-muted); }
.dev-change-gate-link {
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  cursor: pointer;
  font: inherit;
  font-weight: 600;
  color: var(--accent);
}
.dev-change-gate-link:hover { text-decoration: underline; }
@media (prefers-reduced-motion: reduce) {
  .dev-change-bar-fill { transition: none; }
}

/* The cards' buttons at Messages' size: Preview a neutral pill, the
   re-run the accent one; Vote is the card's own picker button. */
.dev-change-gate-act .gc-vote-btn {
  height: 34px;
  padding: 0 14px;
  gap: 6px;
  font-size: 14px;
  font-weight: 600;
}
.dev-change-gate-act .gc-vote-btn svg { width: 16px; height: 16px; }
.dev-change-gate-act .gc-vote-btn-preview {
  color: var(--text-primary);
  background: var(--messages-surface-2);
  border-color: transparent;
}
.dev-change-gate-act .gc-vote-btn-preview:hover { background: var(--accent-tint); color: var(--accent); }
.dev-change-gate-act .gc-conflict-badge { font-size: 13px; }
/* The thanks, on the Addresses row: a quiet chip, as the row's other chips. */
.dev-change-chips .dev-change-thanks > .gc-vote-btn {
  height: 26px;
  padding: 0 10px;
  gap: 4px;
  font-size: 13px;
  font-weight: 500;
  color: var(--text-secondary);
  background: var(--messages-surface);
  border-color: var(--messages-hair);
}
.dev-change-chips .dev-change-thanks > .gc-vote-btn:hover { color: var(--accent); background: var(--accent-tint); }
.dev-change-chips .dev-thanks-tail { display: none; }
/* The zero chip stays out of the row: kudos.js sets [hidden] on the wrap
   while the count is 0, but its Tailwind inline-block would beat the
   preflight rule, so restate it at this specificity. */
.dev-change-chips .dev-change-thanks[hidden] { display: none; }

/* Before and after: the viewer specs use, its switch beside the title. */
.dev-change-card-name { flex: 1 1 auto; min-width: 0; font-size: 15px; font-weight: 600; color: var(--text-primary); }
.dev-change-shots { margin-top: 8px; border-radius: 16px; overflow: hidden; background: var(--messages-surface); }
.dev-change-shots .shots-viewer { border-radius: 0; box-shadow: none; background: transparent; }
.dev-change-shots .shots-bar { padding: 10px 10px 8px 14px; }
.dev-change-shots .shots-stage { margin: 0 10px; border-radius: 12px; border: 0; }
.dev-change-shots .shots-view-notes { padding: 12px 14px 4px; }
.dev-change-shots .shots-claims { padding: 0 14px 12px; }
.dev-change-shots .shots-noticed { padding: 4px 14px 14px; }
.dev-change-shots-plain { padding: 12px 14px 14px; }
.dev-change-shots-line { margin: 8px 0 0; display: flex; align-items: center; gap: 8px; font-size: 14px; color: var(--text-muted); }
.dev-change-shots-tiles { margin-top: 8px; }
.dev-change-closed-note { margin: 0; padding: 6px 16px 12px; font-size: 15px; color: var(--text-muted); }
/* #5007: a change built for a request points its replies at the request
   where its conversation lives. The `p` variant sits above the "N replies"
   row; the `div` one replaces the "No replies yet" row and keeps that
   row's hairline. Same spacing and type as the reply-count row. */
p.dev-change-issue-pointer {
  margin: 0;
  padding: 2px 16px 4px;
  color: var(--text-muted);
  font-size: 13px;
}
.dev-change-issue-link { color: var(--accent); font-weight: 600; text-decoration: none; }
.dev-change-issue-link:hover { text-decoration: underline; }

/* Shot details, in the Details sheet. */
.dev-shot-details-claims { margin: 6px 0 0; padding-left: 18px; display: flex; flex-direction: column; gap: 8px; font-size: 13px; }
.dev-shot-details-meta, .dev-shot-details-flow { margin-top: 2px; color: var(--text-muted); }
.dev-shot-details-builds { margin: 10px 0 0; display: flex; flex-wrap: wrap; gap: 6px; font-size: 12px; color: var(--text-muted); }

@media (max-width: 640px) {
  /* The requests take the row's width; the thanks goes on a line of its own. */
  .dev-change-chips { grid-template-columns: auto minmax(0, 1fr); }
  .dev-change-chips .dev-change-thanks { grid-area: 3 / 1 / 4 / -1; justify-self: start; margin-left: 0; }
  .dev-change-chips:not(:has(.dev-change-chips-first)) .dev-change-thanks { grid-area: 1 / 1 / 2 / -1; }
  /* The post's body takes the sheet's width: a small avatar beside the
     name, then the title, the summary and its cards at full width. */
  .dev-change-root { position: relative; display: block; }
  .dev-change-root > [aria-hidden="true"]:first-child {
    position: absolute;
    left: 12px;
    top: 4px;
    width: 24px;
    height: 24px;
    border-radius: 8px;
    font-size: 11px;
  }
  .dev-change-root .messages-message-head { padding-left: 32px; min-height: 24px; align-items: center; }
  /* A taller frame for a shot on a phone. */
  .dev-change-shots .shots-stage { margin: 0 8px; aspect-ratio: 4 / 5; }
  /* The card's buttons take a row of their own, under its line, so the
     name, figure and bar keep the card's width. */
  .dev-change-gate { grid-template-columns: minmax(0, 1fr); }
  .dev-change-gate-act { order: 1; flex-wrap: wrap; margin-top: 10px; }
  .dev-change-gate-head { flex-wrap: wrap; row-gap: 2px; }
  .dev-change-gate-name { min-width: 0; }
  .dev-change-gate-figure { white-space: nowrap; }
}

/* ── The Workshop ──────────────────────────────────────────────────
   features/dev-board/workshop/workshop.tsx. The lander's own chrome:
   the strips above the themes, the theme cards, the folded rows inside
   them. An UNFOLDED row is `.dev-feed-entry` and takes the sheet rules
   above unchanged; what follows is only what surrounds it. Colours come
   from the same tokens the session sheet and the cards read, so the
   Workshop is the board's language and not a second one. */
.dev-ws { display: flex; flex-direction: column; gap: 10px; }
.dev-ws-eyebrow {
  font-size: 11px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
  color: var(--text-muted);
}
.dev-ws-strip {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
  border-radius: 22px;
  padding: 12px 14px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
  display: flex; flex-direction: column; gap: 8px;
}
/* CENTRE, not baseline. Baseline was right while the only thing beside the
   eyebrow was a 22px pill; with a 30px ring in the line the eyebrow's
   baseline aligned to the bottom of the ring and the two read as two rows. */
.dev-ws-strip-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.dev-ws-strip-text { margin: 0; font-size: 14px; color: var(--text-primary); }
/* ── ONE HEADING, FOR EVERY SECTION ON THE TAB ───────────────────────────
   There were two vocabularies doing one job: three panes wore
   `.dev-ws-eyebrow` — 11px, caps, letterspaced, muted — and the fourth wore
   a sentence-case label with a count beside it and an action at its far
   end. The second is the one that reads as a heading; the caps one reads as
   a tag stuck on a box. So it is this, everywhere, and the sections are
   told apart by what they HOLD rather than by four weights of label.

   `.dev-ws-since-head` keeps its own class because it also carries Clear
   and the disabled state that goes with it; the two match by value. */
.dev-ws-head { display: flex; align-items: center; gap: 8px; }
.dev-ws-head-title { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.dev-ws-head-n {
  height: 19px; min-width: 19px; padding: 0 6px; border-radius: 999px;
  background: var(--brand-tint); color: var(--brand-ink);
  font-size: 11.5px; font-weight: 700;
  display: grid; place-items: center;
  font-variant-numeric: tabular-nums;
}
/* A heading's count in words, muted, beside it ("1 open", "2 since your
   last visit"): a phrase, not a pill (After-Workshop-B). */
.dev-ws-head-sub { font-size: 13px; color: var(--text-muted); }
/* ── The general discussion, at the foot of the dashboard pane ───────────
   No pill and no card. A rounded capsule on a rounded pane is a shape
   inside a shape, and this pane already has an internal rhythm — hairline,
   block, hairline — that the weeks above it use. The row joins it: one more
   rule, then the row, full-bleed to the pane's own padding. */
.dev-ws-chat-row {
  display: flex; align-items: center; gap: 9px; width: 100%;
  margin-top: 12px; padding: 12px 0 0;
  border: 0; border-top: 1px solid var(--app-sheet-line);
  background: none; cursor: pointer;
  font: inherit; font-size: 13px; color: var(--text-secondary); text-align: left;
}
.dev-ws-chat-row:hover .dev-ws-chat-said { color: var(--text-primary); }
.dev-ws-chat-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
/* The glyph keeps the card's chip geometry but drops its tinted plate: on a
   row this size the plate was the largest object in the line. */
.dev-ws-chat-row > .dev-card-icon {
  flex: none; width: 20px; height: 20px;
  background: transparent; border-radius: 0;
}
.dev-ws-chat-row > .dev-card-icon > svg { width: 17px; height: 17px; }
/* TWO LINES: what this is, then the last thing said. The title is the
   row's own name and wraps never; the saying under it is somebody else's
   sentence of any length, and a row that grows to hold it stops being a
   row — so that one clips. */
.dev-ws-chat-text {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; gap: 1px;
}
.dev-ws-chat-title { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.dev-ws-chat-said {
  min-width: 0; font-size: 12.5px; color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The glyph aligns to the TITLE rather than to the middle of a two-line
   block, which is where a centred row would put it. */
.dev-ws-chat-row { align-items: flex-start; }
.dev-ws-chat-row > .dev-card-icon { margin-top: 1px; }
/* A dated week IS its dates (the since list's headings, workshop.tsx
   SinceWeekBlock), so they take the heading's own weight and ink; the live
   week wears its range as a muted gloss. `tabular-nums` because a column
   of ranges is read down. */
.dev-ws-card-dates { font-variant-numeric: tabular-nums; }
.dev-ws-card-range {
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0;
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
/* ── The pane's reveal control ──────────────────────────────────
   ONE style for "put more of this pane on screen": the step backwards
   through the weeks, and the since-list's "Show 8". They are the same act
   and they were two different objects — the first a left-aligned grey pill,
   the second a grey pill in a lane of left-aligned text — and in both cases
   the pill made the most OPTIONAL thing in its block the most solid-looking
   thing in it.

   Quiet and centred instead: no border, no fill, muted 12px, the accent on
   hover, and a caret pointing the way the content arrives. Centred over
   full-width content rather than hung off one side of it. */
.dev-ws-reveal {
  display: flex; align-items: center; justify-content: center; gap: 5px;
  width: 100%; padding: 3px 8px;
  border: 0; background: transparent;
  font-size: 12px; font-weight: 600; letter-spacing: .01em;
  color: var(--text-muted);
  cursor: pointer;
}
.dev-ws-reveal:hover { color: var(--accent); }
.dev-ws-reveal:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
/* There is no ChevronUpIcon in the kit and one caret does not earn a 22nd
   module, so UP is ChevronDownIcon turned over — the same trick the lane
   headings already use on their own chevron.

   Which way it points is which way the content arrives: the week walk grows
   UPWARDS (each press puts an older window above the one you were reading),
   so its caret is always up; the since-list opens DOWNWARDS, so its caret
   points down and turns over once the rows are showing. */
/* Left-aligned, in the lane's own text column.
   Centring is right for the week walk: it sits under a stack of full-width
   cards and belongs to all of them. The since-list's control sits in a lane
   of left-aligned type — a bulleted heading and its note — and centred it
   read as a third, unrelated thing floating mid-lane. Indented to the
   note's 16px so the two share a left edge. */
.dev-ws-reveal-start { justify-content: flex-start; padding-left: 16px; }
.dev-ws-reveal-chev { width: 14px; height: 14px; }
/* ── Since your last visit ───────────────────────────────────
   A HEADING over rows, not a disclosure. It was one collapsed line with a
   caret, on the reasoning that most visits do not need the fact — and what
   that produced was a strip nobody opened: the count said something had
   moved and the rows saying what were behind a press. The bargain is the
   LIST'S LENGTH now (three a week, then a few more a press, and `Show an
   earlier week`), not its existence, which is what the week walk one pane
   up already does with its own history.

   `.dev-ws-since-head` is what `.dev-ws-since-row` was, minus everything
   that said "press me": no pill radius, no pointer, no hover ink, no caret.
   It is the pane's eyebrow, with a count riding it.

   These rules live beside the reveal control the strip shares, not under
   the tab they were first written for. They were born inside the old
   Needs-you block, and #2080, rewriting that tab into a feed, took them out
   with the rest of it — while the strip had already moved to Current status
   in #2065 and its markup went on emitting the classes. Two bare inline
   spans were what was left: "Since your last visit2" (#2097). */
.dev-ws-since-head {
  display: flex; align-items: center; gap: 8px;
  width: 100%; padding: 2px 2px 0;
  font-size: 13px; font-weight: 600;
  color: var(--text-muted); text-align: left;
}
.dev-ws-since-label { flex: 1 1 auto; min-width: 0; }
.dev-ws-since-n {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
  background: var(--brand-tint); color: var(--brand-ink);
  font-size: 11px; font-variant-numeric: tabular-nums;
}
/* `Show an earlier week` steps the since list back a week: `.dev-ws-reveal`,
   centred, caret down at what it is about to reveal, under a stack of
   full-width rows, so it takes the centred form rather than
   `.dev-ws-reveal-start`'s lane indent.
   #3524: under a week it also takes the week's own hairline, the rule the
   week it reveals will arrive under. It sat bare beneath that week's "N
   more new" before, and the two read as one control said twice; the rule
   puts it outside the week, and the week's own reveal stays inside it
   (`.dev-ws-since-week-more`). "N new" further back rides it in the accent:
   a number that asks for the reader. */
.dev-ws-since-more { margin-top: 2px; }
.dev-ws-since-week + .dev-ws-since-more {
  margin-top: 6px; padding-top: 10px;
  border-top: 1px solid var(--app-sheet-line);
}
.dev-ws-since-more-new { color: var(--accent); }
/* #2183: the control is always drawn and DISABLED when the walk is over,
   rather than leaving — so it reads as spent, not pressable. */
.dev-ws-reveal:disabled { color: var(--text-muted); opacity: .45; cursor: default; }
.dev-ws-reveal:disabled:hover { color: var(--text-muted); }
/* `Clear` rides the far end of the heading row (#2183) — the notifications
   sheet's "Mark all read", drawn the same size as the heading's own type
   and in its muted ink, the accent on hover. Pushed right by its own
   margin so the label and the count keep the gap they had. Disabled when
   there is nothing to clear rather than absent, so the row does not reflow
   between a quiet visit and a busy one. */
.dev-ws-since-clear {
  flex: none; margin-left: auto; padding: 2px 6px;
  border: 0; border-radius: 6px; background: transparent;
  font-size: 12px; font-weight: 600; letter-spacing: .01em;
  color: var(--text-muted);
  cursor: pointer;
}
.dev-ws-since-clear:hover { color: var(--accent); }
.dev-ws-since-clear:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-since-clear:disabled { opacity: .45; cursor: default; }
.dev-ws-since-clear:disabled:hover { color: var(--text-muted); }
/* The line across the list (#2183): where a week's rows cross from what is
   new into what the reader has already seen (#3524: in the same column,
   under the same one reveal, rather than behind a fold of their own). The heading's own shape, one
   size down, with the count of the whole rest riding it in plain ink rather
   than the brand tint — these rows are not news. */
.dev-ws-since-seen {
  display: flex; align-items: center; gap: 8px;
  width: 100%; margin-top: 6px; padding: 2px 2px 0;
  font-size: 12px; font-weight: 600;
  color: var(--text-muted); text-align: left;
}
.dev-ws-since-seen-label { flex: 1 1 auto; min-width: 0; }
.dev-ws-since-seen-n {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  min-width: 20px; height: 20px; padding: 0 6px; border-radius: 999px;
  background: var(--bg-secondary); color: var(--text-muted);
  font-size: 11px; font-variant-numeric: tabular-nums;
}

/* ── The three destinations ──────────────────────────────────
   A segmented rail pinned to the FOOT of the lander. Sticky rather than
   fixed: it belongs to this surface, releases with it, and `bottom: 0`
   works against #dev-forum-scroll for the same reason the pane head's
   `top: 0` does — that scroller is the reading area.

   `.platform-safe-bar` (app.css, top) adds the device's home-indicator
   inset, which is why no bottom padding is written here. */
/* AGAINST THE BOTTOM, always — and neither `sticky` nor `fixed` does that on
   its own here. Sticky pins a bar only while its own container is taller
   than the view, so on a short Current status it sat wherever the content
   happened to end, halfway up the screen. Fixed was worse: an ancestor of
   this surface carries `backdrop-filter`, which makes it the containing
   block for fixed descendants, so "bottom: 0" meant the bottom of a frosted
   panel rather than of the screen.

   So the LANDER is given the reading area's height (the same viewport
   arithmetic the deck uses) and the bar is pushed to its foot with
   `margin-top: auto`. Sticky on top of that keeps it in view while a long
   tab scrolls under it. */
/* `max(100%, …)`, because neither half works alone. In the NATIVE layout
   #dev-forum-scroll has a definite height, so 100% is exactly the reading
   area. In the BROWSER layout it does not (app.css forces #app-view to
   `height: auto` so the page scrolls), and a percentage min-height against
   an indefinite parent resolves to 0 — which is what left the bar floating
   halfway up a short tab. The viewport arithmetic is the floor under that.

   It subtracts the header and the insets and NOT the tab rail: the rail is
   inside this box, pushed to its foot by the tab body's `flex: 1`.
   Subtracting it here as well was the bug — the lander came out one rail
   short of the space it had, and the bar stopped that far above the
   bottom. */
/* ── The lander fills what is left of the scroller ─────────────────────
   `min-height: max(100%, var(--ws-area))` is what this replaces, and it was
   wrong in a way no amount of tuning the rail could reach: in FIREFOX the
   floor is simply not applied. Measured on the real app (Gecko 155, the
   native layout): `.dev-ws` computed `min-height: max(100%, 806.5px)` and
   used a height of 649px, leaving 121px of unused scroller below it. A
   sticky bar cannot sit below its own parent's bottom edge, so the rail
   rested there and every `--ws-gap` adjustment moved a number that was never
   the problem. Chromium applies the same declaration correctly, which is
   exactly why a headless harness reported 8px while the app floated — the
   percentage inside the `max()` is indefinite here, and the two engines
   disagree about what that makes the whole expression.

   So: NO PERCENTAGE, and two mechanisms, one per layout.

   NATIVE — the scroller has a definite height, so the chain flexes and the
   lander takes what is left of it. Nothing is computed from the viewport, so
   nothing depends on `--platform-header-h` being accurate or on how much
   chrome sits above `#dev-body`; measured, that offset is 47px, which a
   viewport subtraction would have to know and does not. Scoped by `:has()`
   so the other Dev views, which share this scroller, keep their block flow.

   BROWSER-SCROLLER — the scroller is auto-height (app.css forces
   `#app-view { height: auto }` so the page scrolls), so there is no
   remaining space to take and flex gives content height. The viewport floor
   is the right tool there, as a PLAIN LENGTH.

   Both verified against the live app rather than a replica: the same two
   mechanisms, applied by hand in the page, closed the gap from 121px to 9px. */
#dev-forum-scroll:has(> #dev-body > #dev-workshop) { display: flex; flex-direction: column; }
#dev-body:has(> #dev-workshop) {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column;
}
/* `width: 100%` IS LOAD-BEARING, not belt and braces. `#dev-workshop` centres
   itself with `margin: 0 auto`, and auto margins on the cross axis of a flex
   column ABSORB the free space instead of stretching — so as a flex item it
   shrank to its content. Measured: the pane went 760 to 640 on By category
   and 1432 to 310 on By stage, which is the column collapsing, not widening.
   With an explicit width the auto margins go back to centring a full-width
   box inside its `max-width`, which is what they did as a block. */
#dev-workshop:has(> .dev-ws) {
  flex: 1 1 auto; min-height: 0; width: 100%;
  display: flex; flex-direction: column;
}
#dev-workshop > .dev-ws { flex: 1 1 auto; width: 100%; }
/* ...AND THE FITTED TAB HAS TO BE ABLE TO SHRINK INSIDE IT. `.dev-ws` is a
   flex ITEM of the chain above, and a flex item's automatic minimum size is
   its CONTENT — every other link here carries `min-height: 0` for that
   reason and this one did not. So on Needs you a long summary did not make
   `.dev-ws-needs-scroll` scroll: the deck refused to shrink, the lander grew
   instead, and the answers and the ask box went down with it. Measured at
   402x874 against a nine-paragraph summary: `.dev-ws` 1803px tall with the
   composer 910px below the fold, in the NATIVE layout — the one whose
   definite scroller was supposed to bound it — and at a 34px inset as well
   as at none.
   ONLY THAT TAB. The other two are meant to grow: #dev-forum-scroll is the
   scroller they scroll in, and `.dev-ws-tabbody` is not a scroller itself,
   so shrinking them to the viewport would clip what they hold rather than
   make it reachable. */
#dev-workshop > .dev-ws[data-ws-tab="needs"],
#dev-workshop > .dev-ws[data-ws-tab="discussion"],
#dev-workshop > .dev-ws[data-ws-tab^="c:"] { min-height: 0; }
/* …AND ON THE TWO TABS THAT GROW, THE LINKS ABOVE MUST NOT SHRINK (#3053).
   `min-height: 0` on #dev-body and #dev-workshop is what lets Needs you be
   bounded; on Current status and All items it let both shrink to the
   scroller's height while `.dev-ws` kept its content height and overflowed
   them. A scroller's block-end padding is added after its in-flow CHILDREN,
   not after a descendant that overflows one, so `.platform-safe-scroll` on
   #dev-forum-scroll reserved nothing: at max scroll the last item's foot sat
   exactly at the screen's bottom edge, under the fixed tab bar, with no way
   to scroll it clear. Native layout only — in the browser layout the
   scroller is auto-height and nothing shrinks. `flex-shrink: 0` keeps the
   grow (the lander still fills a short tab) and gives the padding back. */
#dev-body:has(> #dev-workshop > .dev-ws:not([data-ws-tab="needs"]):not([data-ws-tab="discussion"]):not([data-ws-tab^="c:"])),
#dev-workshop:has(> .dev-ws:not([data-ws-tab="needs"]):not([data-ws-tab="discussion"]):not([data-ws-tab^="c:"])) { flex-shrink: 0; }
html[data-browser-scroller] .dev-ws { min-height: var(--ws-area); }
/* ── A SHORT TAB STILL HAS SOMETHING TO SCROLL (#3520) ──────────────────
   "Unable to over scroll down on hub page (but can on workshop)", in the
   installed iPhone app: on the Hub neither end rubber-banded and a pull from
   the top did not stretch, while the Workshop tab of the same page did both.

   Nothing on the Hub turns the gesture off. Measured in the installed layout
   at 390x844 (standalone, an iPhone's agent): both tabs scroll in the one
   #dev-forum-scroll, with the same overflow (auto), overscroll (contain) and
   touch-action (auto) on it and on every ancestor; neither holds a nested
   scroller, a touch-action or an overscroll rule of its own; and the kit's
   pull binds to the scroller on both. What differs is the RANGE. The chain
   above fills the scroller whenever a growing tab is shorter than the screen
   ("the lander still fills a short tab"), to exactly its height and not a
   pixel more: the Hub measured scrollHeight 799 against clientHeight 799,
   nothing to scroll, and so did a fresh project's Workshop tab. The Workshop
   tab the report compares against is the one that runs past the screen once
   a project has history. iOS only rubber-bands a box that can scroll; one
   with no range hands the drag to the document, which the installed shell
   holds still on purpose (`html, body`: `overflow: hidden`,
   `overscroll-behavior-y: none`), so neither end moved.

   So a growing tab is always ONE PIXEL taller than the scroller: a 1px box
   hung 1px below the scroller's bottom edge, which puts that pixel into its
   scrollable overflow whatever the content does. The same range a long tab
   has, so the same bounce at both ends and the same pull at the top as the
   Workshop's. Past the fold it is a transparent pixel in the middle of the
   content and changes nothing.

   ABSOLUTE, AND OFFSET FROM THE SCROLLER'S OWN EDGE, because the two
   obvious ways cannot work here. A 1px spacer in flow is a flex item of this
   chain, and the grow above takes it back: the lander fills what is left and
   the total stays the scroller's height. `min-height: calc(100% + 1px)` is a
   percentage floor in this chain, which is exactly what Firefox dropped (the
   note above). An absolute box's offsets are against its containing block's
   padding box, which is definite in every engine, so the scroller becomes
   that block (`position: relative`, no z-index: no new stacking context; and,
   measured on all three growing tabs, nothing inside it was positioned
   against anything further out). Nor does
   it touch the scroller's bottom padding, the tab-bar clearance #3053 relies
   on.

   ONLY WHERE IT BOUNCES AND ONLY WHERE THIS IS THE SCROLLER: a phone
   (`pointer: coarse` under 700px; a pixel of range would put a scrollbar on
   a desktop window, and from 700px a scrolled strip draws its pinned band),
   and not in a phone browser, where the document scrolls
   (`data-browser-scroller`) and the pixel would lengthen the page. Not Needs
   you or Discussion either: those are fitted on purpose and scroll inside. */
@media (max-width: 699.98px) and (pointer: coarse) {
  html:not([data-browser-scroller]) #dev-forum-scroll:has(> #dev-body > #dev-workshop > .dev-ws:not([data-ws-tab="needs"]):not([data-ws-tab="discussion"]):not([data-ws-tab^="c:"])) {
    position: relative;
  }
  html:not([data-browser-scroller]) #dev-forum-scroll:has(> #dev-body > #dev-workshop > .dev-ws:not([data-ws-tab="needs"]):not([data-ws-tab="discussion"]):not([data-ws-tab^="c:"]))::after {
    content: '';
    position: absolute; left: 0; bottom: -1px;
    width: 1px; height: 1px;
    pointer-events: none;
  }
}
/* The tab body takes the space the tabs above it leave. */
.dev-ws-tabbody {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column; gap: 10px;
}
.dev-ws-tabs {
  /* AT THE HEAD OF THE PAGE, IN FLOW (#2767). On a phone this was a pill
     FIXED to the foot of the window, which took a portal out of the Dev
     frame's frost to achieve, a token for its box, a token for the air under
     it and a clearance on every tab so content could scroll out from beneath
     it. The owner moved it to the top, under the header, where the app
     switcher drops down from — and there it is simply the first thing in the
     column, taking its own space, overlaying nothing.

     That also ended #2769: the portal's anchor lived outside #app-view, so
     hiding the app view for Messages, Discover or Me left the pill on screen.

     RELATIVE, not static: the bar is the containing block for the selection
     marker and the element workshop.tsx measures `offsetLeft`/`offsetTop`
     against. Not sticky: the pane head below it is the part that pins, and
     two sticky bands at `top: 0` would stack on each other.

     A ROW, NOT A SURFACE (#2934). This was the pill itself, with the "+" as
     its last cell, and the "+" read as a fourth tab. The pill's material
     moved onto its two items (the tab list and the "+", below), and this gap
     is the air between them. */
  order: 0;
  position: relative;
  display: flex; gap: 6px;
}
/* A GLYPH BESIDE ITS LABEL, 40px tall. It was a glyph OVER its label, 58px,
   while this was a thumb bar at the foot of the window; at the head of the
   page it is a segmented control — the shape the wide layout already draws —
   and a 72px band there would spend a sixth of a phone's height before the
   first card. 40 still clears the shell's 44px minimum once the pill's own
   padding is counted, and the glyph still carries the scanning if a label
   has to ellipsize on the narrowest phone. */
.dev-ws-tab {
  /* CONTENT-WEIGHTED, not equal thirds: "Current status" is twice "All
     items", and three equal cells ellipsized it at 390px while the other two
     sat in air. The spare width is still shared out, so the pill stays full.
     THE "+" TAKES A 50px CIRCLE AFTER THE PILL, 6px off it (#2934; it was a
     40px cell inside it), and the tabs pay for it out of their own padding
     (8px to 4px a side) and glyph gap (5px to 4px), not out of their labels:
     at 400px, the narrowest width that draws the glyphs, measured in the
     headless harness's DejaVu Sans — wider than SF Pro or Roboto — the three
     labels need 316px at rest and the pill leaves them 322, so "Current
     status" is still whole beside the "+" and the spare 6px is shared back
     out. */
  flex: 1 1 auto; min-width: 0;
  display: inline-flex; flex-direction: row;
  align-items: center; justify-content: center; gap: 4px;
  height: 40px; padding: 0 4px;
  border: 0; border-radius: 999px;
  background: transparent;
  color: var(--text-muted);
  font: inherit; font-size: 12px; font-weight: 600; letter-spacing: .01em;
  white-space: nowrap; overflow: hidden;
  cursor: pointer;
}
/* THE TRACK IS INERT ON A PHONE. `.dev-ws-tabs` is itself the full-width
   row there, so the wrapper the desktop layout needs must not introduce a
   box. `display: contents` removes it from the layout entirely and its two
   children — the tab list and the "+" — are the nav's flex items. */
.dev-ws-tabtrack { display: contents; }
/* THE TAB LIST takes the row's width less the "+" and the gap before it. A
   real box at both widths (the `tablist` role lives here, and a role on a
   `display: contents` node is one some screen readers drop), and not
   positioned: the tabs' `offsetLeft` must keep resolving against the nav,
   where the marker is. 2px between segments, the desktop track's own step. */
.dev-ws-tablist { flex: 1 1 auto; min-width: 0; display: flex; gap: 2px; }
/* ── The "+", ending the strip ───────────────────────────────────────
   The prototype's `.tplus`: the last item of the tab row, a bare glyph in the
   selected tab's ink, drawn in the strip's own material so it reads as part
   of the strip rather than as a floating action (it was a violet filled
   square at the end of All items' search row). The tab's own height and
   radius; the MARKER's tint and ring when its menu is open, which is how the
   strip already says "this one is active".
   The wrapper is the dropdown's containing block (actions-row.tsx hangs
   `#dev-plus-menu` off its right edge). */
.dev-ws-plus { position: relative; flex: none; display: flex; }
/* TWO SURFACES, ONE ROW (#2934). The pill's material — padding, hairline,
   fill, rounding — is drawn by the tab list and by the "+"'s wrapper rather
   than by the nav, so the "+" is a round button of its own, a gap after the
   pill, instead of a fourth cell inside it. Everything else is #2914's: the
   same row, the same place, the same metrics. The "+" still sits in a 4px
   ring as a tab does, its tint lands where the marker would, and the row is
   as tall as the pill was (1 + 4 + 40 + 4 + 1).
   ONLY THE STRIP'S "+": the unreachable Board still renders the same
   wrapper at the end of `#dev-actions`, and that surface stays unchanged.
   NO FROST HERE, on purpose: a backdrop-filter makes the tab list a
   stacking context, and the marker, painted before it at `z-index: 0`,
   would go under its fill. `--dc-frost` is `none` everywhere anyway. */
.dev-ws-tablist, .dev-ws-tabtrack > .dev-ws-plus {
  padding: 4px;
  border-radius: 999px;
  border: 1px solid var(--app-sheet-line);
  background-color: var(--dc-sheet-fill);
}
.dev-ws-plus-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; padding: 0;
  border: 0; border-radius: 999px;
  background: transparent;
  color: var(--lit-ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* Hover only where there IS hover: on touch a tapped button keeps `:hover`
   until the next tap elsewhere, which would leave the "+" tinted after the
   action sheet closes. */
@media (hover: hover) {
  .dev-ws-plus-btn:hover { background: var(--lit-tint); }
}
.dev-ws-plus-btn[aria-expanded="true"] {
  background: var(--lit-tint);
  box-shadow: inset 0 0 0 1px var(--lit-line);
}
.dev-ws-plus-glyph { width: 20px; height: 20px; stroke-width: 2.2; flex: none; }
/* Size lives here, not on the component: icons.tsx has no size variant by
   design, so every call site passes its own class. `flex: none` so a label
   that is trying to fit squeezes itself, not the glyph. */
.dev-ws-tab-glyph { width: 16px; height: 16px; flex: none; }
/* UNDER 400px THE LABELS WIN THE WIDTH. The three labels and their glyphs
   need 316px beside the "+", which a 400px phone has and a 360-390px one
   does not: there "Current status" came out "Current sta…" with its glyph
   still whole, which spends the width on the half a reader cannot read.
   Without the glyphs the labels fit whole down to 340px in DejaVu Sans, and
   down to 320px in Arial's metrics, which are close to SF Pro's and Roboto's. */
@media (max-width: 399px) {
  .dev-ws-tab-glyph { display: none; }
}
.dev-ws-tab-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* #2915: THE DOT ON ALL ITEMS, while its search or filters are on. They
   narrow that tab alone, so from the other two this is the only sign a search
   is still waiting there. The accent, not the tab's ink: it is a state of the
   tab rather than part of its label, and it has to read on the selected tab's
   tint as well as on the bare track.
   ON A PHONE IT IS A BADGE ON THE GLYPH, AND TAKES NO WIDTH. The pill leaves
   the three labels 8px to spare at 390px (see `.dev-ws-tab`), and a dot after
   the label costs 10: every label lost its last letters to an ellipsis. So it
   is moved to the head of the tab (`order`), its own width and the gap after
   it are handed back (`margin-right`), which lays the glyph over it, and it is
   drawn at the glyph's top-right corner. A transform, so none of that moves
   anything else. Above 700px it goes back after the label, where the segment
   hugs its content and has the room. */
.dev-ws-tab-dot {
  flex: none;
  width: 6px; height: 6px;
  border-radius: 999px;
  background: var(--accent);
  order: -1;
  margin-right: -10px;
  transform: translate(12px, -8px);
}
/* THE ACCENT, not black and no longer the header's periwinkle: `--lit-ink`
   on `--lit-tint` with a hairline of `--lit-line`, the phone's tab bar's own
   lit state, because this is the same kind of thing: where you are, among
   the places you could be. A black pill was the only flat-black object on the
   screen and read as a different product; the periwinkle beside the action
   blue was a second blue for nearly the same meaning.
   The tokens carry their own dark values, so there is no dark variant. */
/* THE INK STAYS ON THE TAB; THE FILL MOVED TO THE MARKER. A background drawn
   by the selected tab itself cannot travel — it can only appear on one and
   vanish from another, which is the snap this replaces. One element that slides
   is the whole idea, so the tab is left with the colour change, which has
   nothing to animate between. */
.dev-ws-tab[aria-selected="true"] {
  color: var(--lit-ink);
}
/* Above the marker, which is painted first and sits at the floor of the
   stacking context. `position: relative` is what puts them on that ladder at
   all — without it the tabs and the marker are both static and the marker,
   coming later in paint order for positioned elements, would cover the
   labels. */
.dev-ws-tab { position: relative; z-index: 1; }
/* WHILE THE "+" MENU IS OPEN the strip outranks the pane head below it. The
   dropdown hangs out of the strip over the tab body, and the strip is a
   stacking context (sticky at `z-index: 30` above 700px), so the menu's own
   `z-30` counts only inside it — and the pane head, which pins at `z-index:
   31` wide and 20 narrow, painted over the menu's rows on All items. Only
   while it is open: at rest the head keeps its place above the strip. A
   click there while the menu is up lands on the strip and dismisses the
   menu, like any outside click. */
.dev-ws-tabs:has(#dev-plus-menu:not(.hidden)) { z-index: 32; }
/* THE MARKER. Every number it uses is measured off the live tab (see
   workshop.tsx), so this rule carries no geometry — which is what lets it
   serve the phone's 58px equal-width pill and the desktop strip's 32px
   content-width segments without a breakpoint of its own.

   `top: 0; left: 0` and then translated: the transform is the only thing that
   moves, and a transform is the one property that animates without asking the
   layout engine anything. */
.dev-ws-tab-marker {
  position: absolute;
  top: 0; left: 0;
  z-index: 0;
  border-radius: 999px;
  background: var(--lit-tint);
  box-shadow: inset 0 0 0 1px var(--lit-line);
  pointer-events: none;
  /* Hidden until measured — see the note in workshop.tsx. */
  opacity: 0;
}
.dev-ws-tab-marker[data-ws-marker-at] { opacity: 1; }
/* THE TRANSITION IS ON A SECOND ATTRIBUTE, and `data-ws-marker-slide` is not
   `data-ws-marker-at`. `at` means MEASURED, which is what the opacity above
   waits for; `slide` means the measurement was caused by the SELECTION
   CHANGING. Only the second is worth animating, and keeping them apart is
   what stops a re-measure of the tab you are already on from replaying the
   slide — see useTabMarker in workshop.tsx, which owns the distinction.

   Both were one attribute, and that conflated three things the marker does.
   The first placement was one: `at` arrives in the same commit as the
   transform, so the transition was live for a change from `none` at zero
   width, and the marker slid in from the nav's left edge on every cold open
   of the All items tab — the exact slide-in the null-until-measured render
   was written to avoid, reintroduced by the rule meant to enforce it. A
   re-measure was the other: any layout shift under the bar re-expressed an
   unmoved tab in new coordinates and the marker animated to where it already
   was. Neither is a selection changing, and neither carries `slide`.

   `width` and `height` are in the list because the desktop segments are
   content-width: "Current status" and "Needs you" are different sizes, so a
   transform alone would slide a box of the wrong width. On the phone all
   three match and those two properties simply never change. */
@media (prefers-reduced-motion: no-preference) {
  .dev-ws-tab-marker[data-ws-marker-slide] {
    transition:
      transform .26s cubic-bezier(.32, .72, 0, 1),
      width .26s cubic-bezier(.32, .72, 0, 1),
      height .26s cubic-bezier(.32, .72, 0, 1);
  }
}

/* ── Needs you: one question, the whole screen ───────────────
   SIZED FROM THE VIEWPORT, not from its ancestors — and that is the second
   attempt. The first was a `height: 100%` chain down from #dev-forum-scroll,
   which has a definite height in the native layout and resolves perfectly
   there. It resolves to nothing in the BROWSER layout: when the root carries
   `data-browser-scroller="dev-forum-scroll"` the rule near the top of this
   file forces `#app-view` to `height: auto !important` so the page itself
   scrolls, and every 100% below it collapses to content height. Two shipped
   layouts, and the chain only worked in one.

   So the deck measures the viewport and subtracts the chrome it can name:
   the platform header, the device insets and the platform's own tab bar.
   `min-height` keeps it usable on a short window rather than letting the
   subtraction win. */
.dev-ws {
  /* THE AIR AT THE FOOT, decided once. The device inset is NOT in this
     number: `#dev-forum-scroll` carries `.platform-safe-scroll`, whose
     padding already reserves the larger of the home-indicator strip and the
     platform's tab bar, and counting either again here is the double spend
     that once left a whole indicator of dead air on a real iOS PWA (the
     headless harnesses report a zero inset and could not see it).

     WHAT IS NOT SUBTRACTED ANY MORE: a Workshop tab bar. On a phone that bar
     floated fixed at the foot of the window, out of flow, so this floor took
     its box and the air under it off by hand. It sits at the head of the page
     now (#2767), in flow INSIDE this box, so the flex chain accounts for it
     and the floor must not take it off a second time — the arithmetic the
     wide layout has always used, at every width.

     THE FIXED STRIPS ABOVE THE HEADER ARE CHROME TOO (QA 2026-09-24 Q8).
     `--browser-banner-h` is the install strip plus the offline strip,
     whichever are up. Body padding reserves them at the head of the page,
     so the page starts that much lower and this floor has to give the same
     length back. Without it a phone browser showing "Get the app" pushed
     the foot of every Needs you card, its chips and the rail's More, under
     the tab bar. The token is declared only in the browser-scroller layout,
     the only layout that reads this number (the native layout's flex chain
     already sits inside the padded body), hence the 0px fallback. */
  --ws-area: calc(
    100dvh - var(--browser-banner-h, 0px)
    - var(--platform-header-h, 56px) - var(--platform-safe-top, 0px)
    - var(--ws-gap) - max(var(--platform-tabs-h, 0px), var(--platform-safe-bottom, 0px))
  );
  /* THE FITTED BOX Needs you takes as a definite height in the browser
     layout (below). While a pill floated over the foot of every tab this was
     a different number from the floor above — the pill overlaid the last
     stretch of it and the tab body's clearance held content off it. Nothing
     overlays the lander now, so the box and the floor are the same length. */
  --ws-fit: var(--ws-area);
}
/* ── Needs you is a FITTED SCREEN on a phone, in both layouts ──────────
   In the NATIVE one #dev-forum-scroll has a definite height, the flex chain
   bounds the deck, and `.dev-ws-needs-scroll` is what takes a long card — so
   the answers, the page arrows and the ask box are pinned.

   In a mobile BROWSER the page itself scrolls (`html[data-browser-scroller]`,
   see browser-scroll.ts), #dev-forum-scroll is `height: auto`, and every
   height below it resolves to content. `--ws-area` is only a FLOOR there, so
   it could not stop a long card growing the deck. So the tab takes `--ws-fit`
   as a definite HEIGHT — the box the native chain arrives at on its own.

   Scoped to the phone as well as to the layout: `data-browser-scroller` is
   set on any coarse pointer (MOBILE_PAGE_QUERY), tablets included, and above
   700px the deck is content-sized at the top of the column instead. 700px is
   the same decision as the `@media (min-width: 700px)` block below and as
   workshop.tsx's `WIDE_QUERY`. */
@media (max-width: 699.98px) {
  html[data-browser-scroller] .dev-ws[data-ws-tab="needs"] {
    height: var(--ws-fit);
    min-height: var(--ws-fit);
  }
}
/* ═══════════════════════════════════════════════════════════════════════
   NEEDS YOU: A FEED OF DECISIONS. One item per screen, the rail of
   actions at the right edge, sheets over the item. workshop.tsx's NeedsFeed
   header is the argument; this is the geometry.

   THE LANDER IS THE STAGE. `.dev-ws-needs` takes what is left between the
   head of the tab and the pill's clearance (the flex chain above, with
   `--ws-fit` on a phone), and the SCROLLER inside it is the snap container:
   every item is exactly its height, `scroll-snap-type: y mandatory` finishes
   any drag on an item boundary, and nothing inside an item scrolls — a long
   summary clamps and the title links to the full page.
   ═══════════════════════════════════════════════════════════════════════ */
.dev-ws-needs {
  flex: 1 1 auto; min-height: 320px;
  position: relative;
  display: flex; flex-direction: column;
}
.dev-ws-needs-scroll {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overscroll-behavior: contain;
  /* #3052: a swiped card travels sideways; the feed must not scroll with it. */
  overflow-x: hidden;
  scroll-snap-type: y mandatory;
  scrollbar-width: none;
  border-radius: 20px;
}
.dev-ws-needs-scroll::-webkit-scrollbar { display: none; }
@media (prefers-reduced-motion: no-preference) {
  .dev-ws-needs-scroll { scroll-behavior: smooth; }
}
/* ── The item: a card of the page's own ───────────────────────────────
   ONE CARD, IN THE PAGE'S SCHEME, in the light theme and the dark alike,
   like the hub's and the Workshop's grouped cards are: the plane colour
   (--dc-sheet-solid), the 20px corners and the one hairline
   (--app-sheet-line). The picture fills it, and the caption is laid over
   its foot (workshop.tsx FeedItem); the rail stands over the card's right
   edge on a phone, so the caption keeps its lane (80px) free. Everything
   inside reads the theme's own tokens --text-*, --accent, --state-* and
   --app-sheet-line, so the pictures (lib/diagram, What it touches, the
   shots' switch) and the facts follow the viewer's theme without a second
   copy of their rules. The end card is an item too, and keeps the page's
   own scheme. */
.dev-ws-item {
  position: relative;
  height: 100%; box-sizing: border-box;
  scroll-snap-align: start; scroll-snap-stop: always;
  display: flex; flex-direction: column;
  padding: 0;
  border-radius: 20px;
  overflow: hidden;
  background-color: var(--dc-sheet-fill);
}
.dev-ws-item:not(.dev-ws-needs-done) {
  background-color: var(--dc-sheet-solid);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
  color: var(--text-primary);
}
/* #4754: THE STAGE. The strip and the scroller share one column, so the
   strip is as wide as the card and sits over it, and neither scrolls. */
.dev-ws-needs-stage {
  flex: 1 1 auto; min-height: 0;
  display: flex; flex-direction: column;
}
/* Where you are, on the page: one segment an item, lit up to the one in
   view. The gaps are a mask, so the count is a number on the line
   (`--ws-n`) rather than an element an item. The page's own colours: the
   base is the sheet hairline and the lit part the ink that marks where you
   are (--lit-ink), where the card drew the line white on the dark. */
.dev-ws-needs-progress {
  flex: 0 0 auto;
  display: flex; align-items: center; gap: 12px;
  height: 20px; margin: 0 6px 6px;
}
.dev-ws-needs-progress-line {
  flex: 1 1 auto; height: 3px; border-radius: 3px;
  background: var(--app-sheet-line);
  -webkit-mask-image: repeating-linear-gradient(90deg, #000 0 calc(100% / var(--ws-n, 1) - 4px), transparent calc(100% / var(--ws-n, 1) - 4px) calc(100% / var(--ws-n, 1)));
  mask-image: repeating-linear-gradient(90deg, #000 0 calc(100% / var(--ws-n, 1) - 4px), transparent calc(100% / var(--ws-n, 1) - 4px) calc(100% / var(--ws-n, 1)));
}
.dev-ws-needs-progress-line > i { display: block; height: 100%; background: var(--lit-ink); }
.dev-ws-needs-of {
  flex: none;
  font-size: 11px; font-weight: 700; letter-spacing: .06em; color: var(--text-muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.dev-ws-item-top {
  position: relative; z-index: 2;
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  min-height: 20px; margin: 22px 14px 0;
}
.dev-ws-item-top > .dev-ws-eyebrow { font-size: 12px; font-weight: 700; letter-spacing: .06em; color: var(--text-muted); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* #3517: the way back on a phone, at the right end of the card's top line,
   where the counter sat before it moved to the strip (workshop.tsx draws it
   there only, from the second item on). A small round, the rail's own fill,
   drawn at 26px but held to the row's 20px by negative margins.
   `margin-left: auto` takes the row's free space, so it rides at the row's
   right end rather than in the middle of it; the tap target is the 44px
   square around it. */
.dev-ws-item-prev {
  position: relative; flex: none;
  width: 26px; height: 26px; margin: -3px -4px -3px auto; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 9999px;
  background-color: var(--state-neutral-bg);
  color: var(--text-primary); cursor: pointer;
}
.dev-ws-item-prev::after { content: ''; position: absolute; inset: -9px; }
.dev-ws-item-prev-icon { width: 16px; height: 16px; }
/* After a vote the eyebrow IS the confirmation: the same line, in the ok
   tone, with a tick — no toast over the title. */
.dev-ws-item-done {
  display: inline-flex; align-items: center; gap: 7px; min-width: 0;
  font-size: 13px; font-weight: 700; color: var(--state-ok);
}
.dev-ws-item-tick {
  width: 18px; height: 18px; flex: none; padding: 3px; box-sizing: border-box;
  border-radius: 9999px; background: var(--state-ok); color: #fff;
}
/* A no vote (#4568): the confirmation, and its tick, in the blocked red
   instead of the ok green. Yes keeps the rules above. */
.dev-ws-item-done[data-ws-voted="no"] { color: var(--state-blocked); }
.dev-ws-item-done[data-ws-voted="no"] .dev-ws-item-tick { background: var(--state-blocked); }
/* THE PICTURE takes the card from under the counter to its foot, edge to
   edge; the scrim and the caption are laid over its lower part. */
.dev-ws-item-spacer { flex: 1 1 auto; min-height: 0; }
/* What keeps the caption legible over any picture: a fade up from the
   card's foot, into the card's own colour. Open, the caption has the whole
   card, and the picture is washed out in the card's colour under it. Taps
   go through to the picture. */
.dev-ws-item-scrim {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 1;
  height: 300px; max-height: 75%;
  background: linear-gradient(to top, var(--dc-sheet-solid), transparent);
  pointer-events: none;
}
.dev-ws-item[data-ws-open] > .dev-ws-item-scrim { top: 0; height: auto; max-height: none; background: var(--dc-sheet-solid); opacity: .94; }
/* THE CAPTION, bottom-left, as on a reel. Open ("more") it grows up the
   card and scrolls once it meets the counter. */
.dev-ws-item-caption {
  position: absolute; left: 14px; right: 80px; bottom: 16px; z-index: 2;
  max-height: calc(100% - 64px); box-sizing: border-box;
  display: flex; flex-direction: column; gap: 7px;
}
.dev-ws-item[data-ws-open] > .dev-ws-item-caption {
  overflow-y: auto; overscroll-behavior: contain; scrollbar-width: none;
}
/* #5039: a diagram's picture is words too, so its caption sits below it in
   the flow, still clear of the rail. Open ("more") it keeps the overlay. */
.dev-ws-item:not([data-ws-open]):has(> .dev-ws-diagram) > .dev-ws-item-caption {
  position: relative; left: auto; right: auto; bottom: auto;
  flex: none; margin: 12px 80px 16px 14px;
}
.dev-ws-item-by {
  display: flex; align-items: center; gap: 8px; margin: 0; min-width: 0;
  font-size: 13px; color: var(--text-muted);
}
.dev-ws-item-by-text { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dev-ws-item-by b { color: var(--text-primary); font-size: 15px; font-weight: 650; }
.dev-ws-item-avatar {
  width: 30px; height: 30px; border-radius: 9999px; flex: none;
  display: grid; place-items: center;
  color: #fff; font-size: 12.5px; font-weight: 700;
}
.dev-ws-item-caption .dev-ws-item-avatar { box-shadow: 0 0 0 2px var(--dc-sheet-solid); }
.dev-ws-item-title {
  margin: 0;
  font-size: 18px; line-height: 1.22; font-weight: 750; letter-spacing: -.01em;
  color: var(--text-primary);
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
  text-wrap: balance;
}
.dev-ws-item-title > a { color: inherit; text-decoration: none; }
.dev-ws-item-title > a:hover { text-decoration: underline; text-underline-offset: 3px; }
.dev-ws-item[data-ws-open] .dev-ws-item-title { display: block; overflow: visible; }
/* THE SUMMARY in two lines, its "more" at the end of the second. The
   button floats right, first in the paragraph, held down to the last line
   by a float as tall as the paragraph less one line (the paragraph is a
   flex item, so its height is definite for the float's percentage), and
   the clamp's ellipsis falls just before it. Short, it sits at the end of
   the text. */
.dev-ws-item-summary { display: flex; margin: 0; }
.dev-ws-item-summary-text {
  margin: 0; min-width: 0;
  font-size: 15px; line-height: 1.4; color: var(--text-secondary);
  white-space: pre-line; overflow-wrap: anywhere;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.dev-ws-item-summary-text::before { content: ''; float: right; height: calc(100% - 1.4em); }
.dev-ws-item-more {
  margin: 0 0 0 6px; padding: 0; border: 0; background: none; cursor: pointer;
  font: inherit; font-style: normal; font-weight: 700; color: var(--text-primary);
}
.dev-ws-item-more-end { float: right; clear: both; }
.dev-ws-item-nosummary { font-style: italic; color: var(--text-muted); }
.dev-ws-item-summary-open { display: block; }
.dev-ws-item-summary-open > .dev-ws-item-summary-text { display: block; overflow: visible; }
.dev-ws-item-summary-open > .dev-ws-item-summary-text::before { content: none; }
.dev-ws-item-words { font-size: 14px; line-height: 1.45; color: var(--text-secondary); overflow-wrap: anywhere; }
.dev-ws-item-words > * { margin: 0 0 8px; }
.dev-ws-item-words > :last-child { margin-bottom: 0; }
.dev-ws-item-words :is(h1, h2, h3, h4, h5, h6) { font-size: 15px; font-weight: 700; color: var(--text-primary); }
.dev-ws-item-words :is(ul, ol) { padding-left: 20px; }
.dev-ws-item-words a { color: var(--text-primary); text-decoration: underline; text-underline-offset: 2px; }
.dev-ws-item-words code { padding: 0 4px; border-radius: 4px; background: var(--state-neutral-bg); font-size: 13px; }
/* A CHANGE WITH NO PICTURE AT ALL (workshop.tsx `pictureless`): the card is
   its words. The caption takes the card from under the counter to its foot
   and sits in the middle of it (auto margins, which give way to the top
   when the words run long), in a larger size, and the summary runs to eight
   lines before "more" is offered. No art stands in for the picture. */
.dev-ws-item[data-ws-picture="none"] > .dev-ws-item-caption { top: 56px; }
/* #5039: the caption's words sit on a solid panel of the card's own colour
   with a hairline, so they read over any picture, light or dark. Every
   pictured card gets it; a pictureless card, where the words fill the card
   and nothing sits behind them, does not. */
.dev-ws-item:not([data-ws-picture="none"]) > .dev-ws-item-caption {
  padding: 10px 12px; border-radius: 16px;
  background: var(--dc-sheet-solid);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
}
.dev-ws-item[data-ws-picture="none"] > .dev-ws-item-caption > :first-child { margin-top: auto; }
.dev-ws-item[data-ws-picture="none"] > .dev-ws-item-caption > :last-child { margin-bottom: auto; }
.dev-ws-item[data-ws-picture="none"] .dev-ws-item-title { font-size: 22px; line-height: 1.2; -webkit-line-clamp: 4; }
.dev-ws-item[data-ws-picture="none"] .dev-ws-item-summary-text { font-size: 16px; line-height: 1.45; -webkit-line-clamp: 8; }
.dev-ws-item[data-ws-picture="none"] .dev-ws-item-summary-text::before { height: calc(100% - 1.45em); }
/* What "more" adds under the summary: the declared changes, numbered as
   the picture's outlines are, and what the change touches. */
.dev-ws-item-part { display: flex; flex-direction: column; gap: 6px; }
.dev-ws-item-part-head { margin: 2px 0 0; font-size: 13px; font-weight: 650; color: var(--text-primary); }
.dev-ws-shot-changes.dev-ws-item-changes { margin: 0; }
.dev-ws-item-changes .dev-ws-shot-change { font-size: 14px; font-weight: 500; line-height: 1.45; color: var(--text-secondary); }
.dev-ws-item-touched { margin: 0; font-size: 13px; line-height: 1.45; color: var(--text-muted); }
.dev-ws-item-less {
  align-self: flex-start; margin: 0; padding: 0; border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 14px; font-weight: 700; color: var(--text-primary);
}
/* A request's number on a change's page (topic-head.tsx) is a chip. */
.dev-ws-chip {
  display: inline-flex; align-items: center; height: 24px; padding: 0 9px;
  border-radius: 9999px; font-size: 12px; font-weight: 600; white-space: nowrap;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-ws-chip-info { background: var(--accent-tint); color: var(--accent-wash-ink); }
/* THE FACTS: where the vote stands, "your yes puts it live", the status and
   the topic. A line that does not fit wraps to a second one rather than
   losing its end (the topic is last). On a phone the whole line is one quiet
   pill with the people glyph; on a wide window the vote's words are the pill
   and the topic follows it (the wide block). */
.dev-ws-item-facts {
  display: block; align-self: flex-start; max-width: 100%; box-sizing: border-box; margin: 2px 0 0;
  padding: 4px 10px; border-radius: 12px; background: var(--state-neutral-bg);
  font-size: 12px; font-weight: 600; line-height: 16px; color: var(--text-primary);
  white-space: normal; overflow-wrap: anywhere;
}
.dev-ws-item-facts-icon { display: inline-block; width: 12px; height: 12px; margin-right: 6px; vertical-align: -2px; }
.dev-ws-fact + .dev-ws-fact::before,
.dev-ws-item-facts-vote + .dev-ws-fact::before { content: " · "; font-weight: 400; color: var(--text-muted); }
.dev-ws-fact.dev-ws-fact-ok { color: var(--state-ok); }
.dev-ws-fact.dev-ws-fact-progress { color: var(--state-progress); }
.dev-ws-fact.dev-ws-fact-warn { color: var(--state-attention); }
.dev-ws-fact.dev-ws-fact-blocked { color: var(--state-blocked); }
/* The Communities screen's two views (Current status | Needs you,
   `.workshop-scope-tabs`) are gone with the UI overhaul: the list is the
   page, and Needs you is a row at its top that opens the feed below. */
/* ── The Communities screen's Needs you feed (#3270, workshop/needs-reel.tsx) ──
   A project's own Needs you feed since #3488 (NeedsFeed, the stage and the
   item above), in a box a screen tall less the bars around it: the page head
   over it and the tab bar under it. Each item leads with the project it
   belongs to, its tile and its name, a door to its hub. */
.workshop-needs-feed {
  /* NO HEIGHT HERE ANY MORE (#3516): the phone takes it from the flex chain
     below and the desktop from its own rule after that. `min-height` is the
     floor for both: a feed shorter than this is not usable, and on a window
     that short the screen scrolls instead. */
  min-height: 360px;
  margin: 4px 16px 0;
  display: flex; flex-direction: column;
}
/* ON A PHONE THE FEED ENDS WHERE #workshop-screen'S CLEARANCE BEGINS (#3516).

   It was `height: calc(100dvh - 180px - var(--browser-banner-h, 0px))`: the
   page head and the header over it (116px) and a 56px tab bar with 8px of air
   under it, measured once in a browser with no insets and nothing parked. Three
   things at the foot of a phone were not in that number, and each one put the
   feed's foot under the bar:

   - THE PARKED APP'S STRIP. `--platform-tabs-h` counts it (52px more) the
     moment an app is parked; this number never did. Measured at 390x844 with
     an app parked: the feed ran to 828px against a strip whose top was at
     736 in a phone browser, and to 827 against 716 in the installed layout,
     so the rail's Try it and More and the item's caption sat under the
     strip and the bar.
   - THE INSTALLED APP'S INSETS. An iPhone install reserves the status bar
     at the top (`--platform-safe-top`, 47-62px) and the bar spends part of
     the home indicator at the foot (`--platform-tabs-inset`). Nothing
     parked, the feed still ended 60px under the bar there.
   - THE "GET THE APP" BANNER in a phone browser. `--browser-banner-h` is
     declared only on `html[data-browser-scroller]` routes, and this screen is
     not one (browser-scroll.ts `PAGES`): its root scrolls itself in every
     layout, so the token read 0 here while the banner pushed the page 48px
     down.

   And the foot could not be scrolled into view. `.dev-ws-needs-scroll` is
   `overscroll-behavior: contain`, so a drag on the feed moves between items
   and never reaches the screen behind it; only a drag on the page head moved
   the page.

   SO NO ARITHMETIC: THE SCROLLER'S OWN CLEARANCE BOUNDS IT. #workshop-screen
   is a flex child of the fixed-height body column in both layouts, so it has
   a definite height, and it carries `.platform-safe-scroll`, whose padding is
   `max(--platform-tabs-h, --platform-safe-bottom)`: the tab bar, the parked
   strip when there is one and the home indicator, each counted once, by the
   same token every other screen clears them with. While the feed is up the
   screen, its column and the pane become a flex chain and the feed takes
   what is left above that padding, less `--ws-gap` of air — the line a
   project's own Needs you deck ends on (`.dev-ws`'s `--ws-area`), whatever
   the header, the insets, the banner or the strip add up to. The same
   mechanism the project page uses in the installed layout (see "The lander
   fills what is left of the scroller"), for the same reason: nothing here
   depends on a header height being accurate.

   `flex-shrink: 0` on every link, never `min-height: 0`. On a window too
   short for the 360px floor the column must stay its content's height and
   make the screen scroll: a link allowed to shrink leaves the feed
   overflowing it, and a scroller's block-end padding follows its in-flow
   children, not a descendant that overflows one, so the clearance would
   reserve nothing (#3053 is the same trap on a project page).

   `:not(.hidden)` on the root is load-bearing. The needs pane stays mounted
   while you are on another screen, and an id-specific `display` here would
   otherwise outrank Tailwind's `.hidden` and draw Communities over it.
   `width: 100%` on the column because auto margins on a flex column's cross
   axis shrink an item to its content (`#dev-workshop` above has the same
   note); `max-w-2xl mx-auto` still caps and centres it. Its `pb-8` gives way
   to `--ws-gap`, which is the air this line has always had above the bar.

   Below 768px only: from there the bar is a rail down the side, nothing is
   parked at the foot (the strip gives way to Recents), and the rule below
   keeps the desktop's own line. */
@media (max-width: 767.98px) {
  #workshop-screen:not(.hidden):has(> div > [data-workshop-pane="needs"]) {
    display: flex; flex-direction: column;
  }
  #workshop-screen > div:has(> [data-workshop-pane="needs"]) {
    flex: 1 0 auto; width: 100%;
    display: flex; flex-direction: column;
    padding-bottom: var(--ws-gap);
  }
  #workshop-screen [data-workshop-pane="needs"] {
    flex: 1 0 auto;
    display: flex; flex-direction: column;
  }
  #workshop-screen [data-workshop-pane="needs"] > .workshop-needs-feed { flex: 1 0 0px; }
}
@media (min-width: 768px) {
  /* No tab bar: the stage runs to 8px off the floor, as a project's does, so
     the rail and its arrows clear the key legend under it. */
  .workshop-needs-feed { height: calc(100dvh - 124px - var(--browser-banner-h, 0px)); }
  /* The key legend is centred under the STAGE, not under the stage and the
     rail: this screen's column is narrower than a project's panel, so the
     legend is wider than the stage and, centred on both, ran under the
     rail's arrows. 102px is the rail and its gap. */
  .workshop-needs-feed .dev-ws-keys { right: 102px; }
}
/* Where the window has the room, the feed takes a project's stage width
   (720px) out of the screen's 672px column, as far as that column's own
   margins go. */
@media (min-width: 1180px) {
  .workshop-needs-feed { margin-left: -80px; margin-right: -80px; }
}
.workshop-reel-loading { background: var(--dc-sheet-solid); border-radius: 20px; }
.workshop-reel-app {
  display: inline-flex; align-items: center; gap: 8px; min-width: 0;
  font-size: 13px; font-weight: 650; color: var(--text-primary); text-decoration: none;
}
.dev-ws-item-caption > .workshop-reel-app { align-self: flex-start; max-width: 100%; color: var(--text-primary); }
.workshop-reel-tile {
  width: 26px; height: 26px; flex: none; border-radius: 8px; overflow: hidden;
  display: flex; align-items: center; justify-content: center; font-size: 13px; font-weight: 700;
}
/* A group decision's vote sheet opens its page: the answer button's shape,
   as a link, in the accent. */
.dev-ws-answer-open {
  display: flex; align-items: center; justify-content: center;
  border-color: transparent; background: var(--accent); color: #fff; text-decoration: none;
}
/* ── THE PROJECT HUB'S OWN CARDS (features/dev-board/workshop/hub-cards.tsx) ──
   Under the hero, each part under its small-caps label (SectionHeader):
   For you and Recently live. They are `.dev-ws-strip` cards, and what is
   new here is only what they hold: doors and three pictures. */
.dev-ws-head-end, .dev-ws-hub-head-end { margin-left: auto; }
.dev-ws-hub-head-end { display: inline-flex; align-items: center; gap: 8px; }
.dev-ws-hub-open {
  display: inline-flex; align-items: center; gap: 2px;
  font-size: 13px; font-weight: 600; color: var(--accent);
  background: none; border: 0; padding: 0; cursor: pointer;
}
.dev-ws-hub-needs-join { margin: 0; font-size: 12.5px; color: var(--text-muted); }
/* A part of the hub: its label over its card. A label with a door beside
   it ("All in Workshop") is one row, the door at its far end. */
.dev-ws-hub-section { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.dev-ws-hub-sechead { display: flex; align-items: center; gap: 8px; padding-right: 4px; }
.dev-ws-hub-sechead > .dev-ws-hub-open { margin-left: auto; padding-top: 8px; }
/* FOR YOU (ForYouCard): one card of doors, a hairline between them. Each
   door is the grouped list's row (ListRow, 15 over 13) laid on the card's
   own padding, with at most one pill: a number that asks for you, in the
   accent, or a count in words, muted. */
.dev-ws-strip.dev-ws-foryou { gap: 0; padding: 2px 14px; }
.dev-ws-foryou > * + * { border-top: 1px solid var(--app-sheet-line); }
.dev-ws-foryou .dev-ws-foryou-row { gap: 12px; padding: 12px 2px; background: transparent; }
/* No vote owed: the quiet line in the Needs you row's place, at a row's
   second-line size, from the rows' own edge. */
.dev-ws-foryou > .dev-ws-week-note { margin: 0; padding: 14px 2px; font-size: 13px; text-align: left; }
.dev-ws-foryou-item > .dev-ws-hub-needs-join { padding: 0 2px 12px 46px; margin-top: -6px; }
.dev-ws-foryou-pill {
  flex: none; height: 22px; padding: 0 9px; border-radius: 999px;
  display: inline-grid; place-items: center; white-space: nowrap;
  background: var(--accent); color: #fff;
  font-size: 12.5px; font-weight: 700; font-variant-numeric: tabular-nums;
}
.dev-ws-foryou-count { flex: none; font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.dev-ws-foryou-handle { font-weight: 500; color: var(--text-muted); }
/* RECENTLY LIVE (RecentlyLive): three picture cards, each a door to its
   change: its after shot (or a plain tile), its title, who and when. A row
   that scrolls sideways on a phone, three across on a wide window. */
.dev-ws-strip.dev-ws-recent { padding: 14px 0; }
.dev-ws-recent-list {
  list-style: none; margin: 0; padding: 0 14px;
  display: flex; gap: 12px; overflow-x: auto; scroll-snap-type: x proximity;
  scrollbar-width: none; overscroll-behavior-x: contain;
}
.dev-ws-recent-list::-webkit-scrollbar { display: none; }
.dev-ws-recent-item { flex: 0 0 210px; min-width: 0; scroll-snap-align: start; }
@media (min-width: 768px) {
  .dev-ws-recent-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); overflow: visible; }
  .dev-ws-recent-item { flex: none; }
}
.dev-ws-recent-card {
  display: flex; flex-direction: column; gap: 6px; min-width: 0;
  color: inherit; text-decoration: none; -webkit-tap-highlight-color: transparent;
}
.dev-ws-recent-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 12px; }
/* The app's own card, before any change has gone live (RecentlyLive `own`):
   a button that opens the app, its tile where a change's picture would be. */
button.dev-ws-recent-card {
  width: 100%; margin: 0; padding: 0; border: 0; background: none;
  font: inherit; text-align: left; cursor: pointer;
}
.dev-ws-recent-app {
  width: 52px; height: 52px; overflow: hidden; display: grid; place-items: center;
  border-radius: 16px; font-size: 26px; font-weight: 700;
}
.dev-ws-recent-app > img { width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
.dev-ws-recent-app > span { font-size: inherit; line-height: 1; }
.dev-ws-recent-pic {
  position: relative; display: grid; place-items: center; aspect-ratio: 16 / 10; overflow: hidden;
  border-radius: 12px; background: var(--bg-tertiary);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
}
.dev-ws-recent-pic > img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; object-position: top center;
}
.dev-ws-recent-glyph { width: 26px; height: 26px; color: var(--text-muted); opacity: .6; }
/* A change with no picture, and the app's own card: the project's tile on its
   community's colour, the band's own `--community-tint` (polish board N13),
   where a grey box with a check was. */
.dev-ws-recent-pic:is([data-ws-recent-pic="plain"], [data-ws-recent-pic="app"]) {
  background: var(--community-tint, var(--bg-tertiary));
}
.dev-ws-recent-title {
  font-size: 14px; font-weight: 650; line-height: 1.3; color: var(--text-primary);
  display: -webkit-box; -webkit-line-clamp: 3; line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
  overflow-wrap: anywhere;
}
.dev-ws-recent-meta { font-size: 12.5px; color: var(--text-muted); }
.dev-ws-recent-going { color: var(--lit-ink); }
/* ── A page's head: the way back, and the page's name ──────────────────
   workshop.tsx's PageBack, in the bar a page leads with (the Workshop,
   Needs you, All items; the hub has none). The back is the header's own
   back disc, in the header's brand ink, since it does the header's job one
   level down; the name is the page's one heading, under the name of where
   the disc goes. */
.dev-ws-pagehead { display: flex; align-items: center; gap: 10px; min-width: 0; }
.dev-ws-page-back {
  width: 36px; height: 36px; flex: none; padding: 0;
  display: grid; place-items: center;
  border: 0; border-radius: 999px; cursor: pointer;
  background: var(--brand-tint); color: var(--brand-ink);
  -webkit-tap-highlight-color: transparent;
}
.dev-ws-page-back:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-page-back-glyph { width: 18px; height: 18px; stroke-width: 2.2; }
.dev-ws-pagehead-text { display: flex; flex-direction: column; min-width: 0; }
.dev-ws-pagehead-over {
  font-size: 12px; line-height: 16px; color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dev-ws-pagehead-title {
  margin: 0; font-size: 20px; font-weight: 700; line-height: 24px;
  letter-spacing: -.01em; color: var(--text-primary);
}

/* #2915: a search or filter left on in All items, said where it is out of
   sight (the Workshop's door, All items' See all): the accent, a 6px round. */
.dev-ws-filter-dot { flex: none; width: 6px; height: 6px; border-radius: 999px; background: var(--accent); }
.dev-ws-hub-open .dev-ws-filter-dot { margin: 0 2px; }
/* First version (hub-cards.tsx FirstVersionCard, #4045): the project's
   thumbnail drawn small (sketch-card.tsx ThumbRow: its icon on its colour,
   its name and one line), with "See the plan" as a quiet link at the row's
   end for a member, or one button under the row. Open app wears the hero's
   Open app, in the community's colour, a full row wide. */
.dev-ws-hub-first-row { display: flex; align-items: center; gap: 10px; min-width: 0; }
.dev-ws-hub-first-row > [data-thumb-row] { flex: 1 1 auto; }
/* The ⋯ menu's settings (actions-row.tsx, #4045): behind one "Settings &
   rules" row. On desktop the dropdown turns to them in place, on top of
   the menu's own rows, until its back row or a close turns it back. */
#dev-plus-menu .dev-plus-settings { display: none; }
#dev-plus-menu[data-plus-view="settings"] > :not(.dev-plus-settings) { display: none; }
#dev-plus-menu[data-plus-view="settings"] > .dev-plus-settings { display: block; }

.dev-ws-hub-first-see {
  flex: none; display: inline-flex; align-items: center; min-height: 44px; padding: 0 2px;
  border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 15px; font-weight: 500; white-space: nowrap; color: var(--accent);
}
.dev-ws-hub-first .dev-ws-hub-first-open { width: 100%; height: 40px; justify-content: center; font-size: 16px; font-weight: 600; }

/* The plan, for the people who joined (plan-page.tsx, #4074): a page under
   the Hub. Its head is the disc back to the Hub and Homeroom bot's face and
   name; then the plan as a card, read only; then who decides, and the way
   to the Discussion tab. */
.dev-ws-plan { display: flex; flex-direction: column; gap: 12px; }
.dev-ws-planhead { display: flex; align-items: center; gap: 10px; min-height: 44px; }
.dev-ws-plan-bot { width: 30px; height: 30px; border-radius: 8px; flex: none; object-fit: cover; }
.dev-ws-plan-bot-name { font-size: 17px; font-weight: 650; color: var(--text-primary); }
.dev-ws-plan-ai { font-size: 12px; font-weight: 600; color: var(--text-muted); }
.dev-ws-plan-head { display: flex; flex-direction: column; gap: 2px; padding: 4px 4px 0; }
.dev-ws-plan-title { margin: 0; font-size: 20px; line-height: 26px; font-weight: 700; color: var(--text-primary); }
.dev-ws-plan-step { margin: 0; font-size: 13px; line-height: 18px; color: var(--text-muted); }
/* One row per line of the plan, a grouped list's rows: 15px on a 22px line,
   12px above and below, a hairline between rows from the text's edge. */
.dev-ws-plan-row {
  position: relative; padding: 12px 16px;
  font-size: 15px; line-height: 22px; color: var(--text-primary);
}
.dev-ws-plan-row:not(:last-child)::after {
  content: ''; position: absolute; left: 16px; right: 0; bottom: 0;
  height: 1px; background: var(--app-sheet-line);
}
.dev-ws-plan-q { display: flex; flex-direction: column; gap: 2px; }
.dev-ws-plan-qq { margin: 0; font-size: 13px; line-height: 18px; color: var(--text-muted); }
.dev-ws-plan-qa { margin: 0; font-size: 15px; line-height: 22px; color: var(--text-primary); }
.dev-ws-plan-foot { display: flex; flex-direction: column; align-items: center; gap: 2px; text-align: center; }
.dev-ws-plan-who { margin: 0; font-size: 14px; line-height: 20px; color: var(--text-muted); }
.dev-ws-plan-talk {
  display: inline-flex; align-items: center; min-height: 44px; padding: 0 4px;
  border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 15px; font-weight: 500; color: var(--accent);
}

/* ── Since your last visit, week by week (the Workshop page) ──────────
   Each week is a division of the pane, as the walk's windows were: a
   hairline, the week's heading and range, its line, and what moved in it
   nested under a rule at the left, so the rows read as the line's
   evidence. A week's rows past the first three, new and then already seen,
   come out a few at a time under ONE quiet reveal (#3524). */
.dev-ws-since-week {
  display: flex; flex-direction: column;
  border-top: 1px solid var(--app-sheet-line);
  margin-top: 4px; padding-top: 11px;
}
.dev-ws-since-week-head {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 10px; margin: 0;
  font-size: 13px; font-weight: 600; letter-spacing: 0.01em; color: var(--text-primary);
}
.dev-ws-since-week-n { font-size: 12px; font-weight: 500; letter-spacing: 0; color: var(--text-muted); }
.dev-ws-since-week-line { margin: 6px 0 0; font-size: 14px; line-height: 1.45; color: var(--text-primary); }
.dev-ws-since-nest {
  display: flex; flex-direction: column; gap: 4px;
  margin: 10px 0 0; padding-left: 10px;
  border-left: 2px solid var(--app-sheet-line);
}
/* #3524: the week's one reveal. It was two grey 36px pill rows stacked
   here ("N more new" in the accent, "N more you have seen" with a check),
   the most solid-looking things in the week and two different chevrons
   side by side. Now it is the pane's own `.dev-ws-reveal`, the gesture
   every other "more" in the pane uses, set to the LEFT, inside the week's
   rule, so it belongs to the rows above it and not to `Show an earlier
   week` below. The count still hidden rides it in the lighter weight. */
.dev-ws-since-week-more { justify-content: flex-start; padding: 4px 12px 2px; }
.dev-ws-since-week-left { font-weight: 500; }
.dev-ws-since-nest > .dev-ws-since-seen { margin-top: 2px; padding-left: 0; }

/* ── Share it (community-card.tsx ShareItCard) ──────────────────────
   A project that is just yours: its two ways to grow, side by side. The
   "Open it up" question hangs from its button's LEFT here, since the
   buttons lead the row and a popup opening leftward would leave the screen.
   Once a link is out, one muted line over the card's text says so, who
   came in and how long it has left (inviteSentLine), at the rows' 13. */
.dev-ws-share-sent { margin: 0; font-size: 13px; line-height: 18px; color: var(--text-muted); }
.dev-ws-share-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
/* The ✕ that closes Share it, or "What do you want to change?", for good
   (community-card.tsx HubCardClose): at the far end of the card's head,
   muted, a circle that shows only when pressed or hovered. */
.dev-ws-card-close {
  margin: -6px -6px -6px auto; flex: none;
  width: 30px; height: 30px; border-radius: 999px;
  display: grid; place-items: center;
  color: var(--text-muted); background: transparent;
}
.dev-ws-card-close:hover { background: var(--bg-tertiary); color: var(--text-primary); }
.dev-ws-card-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.dev-ws-card-close svg { width: 16px; height: 16px; }
.dev-ws-share .dev-ws-join-anchor { margin-left: 0; }
.dev-ws-share .dev-ws-join-pop { left: -6px; right: auto; }
.dev-ws-share .dev-ws-join-pop::before { left: 26px; right: auto; }
@media (prefers-reduced-motion: no-preference) {
  .dev-ws-share .dev-ws-join-pop { transform-origin: 32px 0; }
}
.dev-ws-share:has(.dev-ws-join-pop) { position: relative; z-index: 5; }

/* ── First look (hub-cards.tsx FirstLookCard, newcomer run six) ──────
   The maker's, under Share it, while their first version is built: the
   picture Homeroom bot drew of its main screen, small and phone-shaped
   (cropped from the top, as a phone shows a screen), beside a small-caps
   kicker, the card's sentence-case title and line in Share it's own type,
   and its one button. The picture's box is fixed, so nothing moves as it
   loads. */
.dev-ws-strip.dev-ws-first-look { position: relative; flex-direction: row; align-items: flex-start; gap: 14px; }
/* #5618: the picture and the words open the maker's chat with Homeroom
   bot. The door is an empty button over the card, under its Watch button,
   so the card keeps its look: only a hover tint and the focus ring show it. */
.dev-ws-first-look-open {
  position: absolute; inset: 0; z-index: 1; border: 0; padding: 0;
  background: transparent; border-radius: inherit; cursor: pointer;
}
.dev-ws-first-look-open:hover { background: rgba(10, 10, 10, 0.03); }
.dark .dev-ws-first-look-open:hover { background: rgba(255, 255, 255, 0.04); }
.dev-ws-first-look-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-first-look-shot {
  flex: none; width: 64px; height: 138px; border-radius: 12px;
  object-fit: cover; object-position: top;
  background: var(--bg-secondary);
  box-shadow: 0 0 0 1px var(--app-sheet-line);
}
.dev-ws-first-look-body {
  flex: 1 1 auto; min-width: 0;
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
}
.dev-ws-first-look-kicker {
  margin: 0; font-size: 12px; font-weight: 700; line-height: 16px;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted);
}
.dev-ws-first-look-body .dev-ws-head-title { margin: 0; }
.dev-ws-first-look-body .dev-ws-strip-text { color: var(--text-muted); }
/* Watch keeps its own tap, over the door (#5618). */
.dev-ws-first-look-body > button { position: relative; z-index: 2; margin-top: 8px; }

/* ── Approval rules (community-card.tsx ApprovalRules) ─────────────── */
.dev-ws-rules-line { margin: 0; font-size: 14px; line-height: 1.45; color: var(--text-primary); }

/* ── Swipe to vote, on a phone (#3052) ─────────────────────────────────
   workshop.tsx's `useSwipeVote` is the gesture; this is how it looks. A
   card the viewer can vote on is `[data-ws-swipeable]`, and while a drag is
   on it carries `--ws-swipe-x` (px, right is Yes), `--ws-swipe-p` (0 to 1,
   how near the line) and `[data-ws-swipe]` naming the side. Both come off
   once it is back at rest, so an untouched card has no transform.

   `touch-action: pan-y` hands the vertical drag to the scroller, as ever,
   and keeps the sideways one for the gesture. Anything narrower would take
   the pan the feed pages on away from every touch that starts on a card,
   the trap #1762 fell into on Home.

   The hints are stamps half way down the card, on the side the card is
   leaving from, where the title and summary are not: the text sits at the
   top and the middle is the picture or air. Each is laid on the page
   colour, so it reads the same over a dark capture as over the tint, and it
   fades in with the drag, never past 0.9. Where motion is unwelcome the
   card itself does not travel or spring: only the hint says what letting
   go would do. */
.dev-ws-swipe-hint {
  position: absolute; top: 50%; z-index: 2;
  padding: 3px 14px; border: 2px solid currentColor; border-radius: 12px;
  font-size: 28px; font-weight: 800; line-height: 1.2; letter-spacing: .05em;
  text-transform: uppercase;
  background-color: var(--bg-primary);
  opacity: 0; pointer-events: none;
}
.dev-ws-swipe-yes {
  left: 20px; color: var(--state-ok);
  background-image: linear-gradient(var(--state-ok-bg), var(--state-ok-bg));
  transform: translateY(-50%) rotate(-10deg);
}
.dev-ws-swipe-no {
  right: 90px; color: var(--state-blocked);
  background-image: linear-gradient(var(--state-blocked-bg), var(--state-blocked-bg));
  transform: translateY(-50%) rotate(10deg);
}
.dev-ws-item[data-ws-swipe="yes"] > .dev-ws-swipe-yes,
.dev-ws-item[data-ws-swipe="no"] > .dev-ws-swipe-no { opacity: calc(var(--ws-swipe-p, 0) * .9); }
@media (max-width: 699.98px) {
  .dev-ws-needs-scroll { overflow-x: hidden; }
  .dev-ws-item[data-ws-swipeable] { touch-action: pan-y; }
  .dev-ws-item[data-ws-swiping] { user-select: none; -webkit-user-select: none; }
}
@media (max-width: 699.98px) and (prefers-reduced-motion: no-preference) {
  .dev-ws-item[data-ws-swipe] { transform: translateX(var(--ws-swipe-x, 0px)); }
  .dev-ws-item[data-ws-swipe]:not([data-ws-swiping]) { transition: transform .26s cubic-bezier(.2, .8, .2, 1); }
  .dev-ws-item[data-ws-swipe]:not([data-ws-swiping]) > .dev-ws-swipe-hint { transition: opacity .2s ease; }
}

/* ── The picture: the two stills, cropped to what changed ──────────────
   The view is the box the still is placed in; the still keeps its natural
   size and is moved and scaled with a transform (workshop.tsx computes the
   crop), so the outline can sit on the changed region in the same
   coordinates. It fills the card from under its switch to its foot, edge
   to edge, under the caption, and never scrolls. */
.dev-ws-media {
  position: relative;
  flex: 1 1 auto; min-height: 120px;
  margin: 12px 0 0;
  display: flex; flex-direction: column;
}
.dev-ws-media-view {
  position: relative; flex: 1 1 auto; min-height: 0;
  overflow: hidden;
  background: #111114;
}
.dev-ws-media-img { position: absolute; left: 0; top: 0; max-width: none; transform-origin: 0 0; display: block; }
.dev-ws-media-spot {
  position: absolute; pointer-events: none;
  border: 2px solid #16a34a; border-radius: 8px;
  box-shadow: 0 0 0 3px rgba(22, 163, 74, .25), 0 0 22px rgba(22, 163, 74, .35);
}
.dev-ws-media-wait {
  position: absolute; inset: 0;
  background: linear-gradient(90deg, rgba(255, 255, 255, .04), rgba(255, 255, 255, .10), rgba(255, 255, 255, .04));
}
/* The switch and the way out sit in a bar ABOVE the picture, never on it:
   laid over the still they covered the corner a change often is. */
.dev-ws-media-bar {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  margin: 0 14px 8px; min-height: 30px;
}
.dev-ws-media-full {
  flex: none; padding: 6px 11px; border: 0; border-radius: 9999px; cursor: pointer;
  font: inherit; font-size: 12px; font-weight: 600; line-height: 1.2;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-ws-seg {
  display: inline-flex; flex: none; padding: 2px; border-radius: 9999px;
  background: var(--state-neutral-bg);
}
.dev-ws-seg-btn {
  padding: 5px 12px; border: 0; border-radius: 9999px; cursor: pointer;
  font: inherit; font-size: 12px; font-weight: 700;
  background: none; color: var(--text-secondary);
}
.dev-ws-seg-btn[aria-pressed="true"] { background: #fff; color: #14171f; box-shadow: 0 1px 2px rgba(0, 0, 0, .14); }
.dev-ws-seg-one { padding: 5px 12px; font-size: 12px; font-weight: 700; color: var(--text-secondary); }
/* With a run's outlines the switch wears their colours: red where it was,
   green where it is now. */
.dev-ws-seg-btn.dev-ws-seg-before[aria-pressed="true"] { background: #dc2626; color: #fff; }
.dev-ws-seg-btn.dev-ws-seg-after[aria-pressed="true"] { background: #15803d; color: #fff; }
.dev-ws-media-size { font-size: 12px; font-weight: 600; color: var(--text-muted); }

/* ── The picture from a before & after run (workshop.tsx ShotsPicture) ──
   One screen, cropped to the areas the run outlined. Both sides are laid in
   the view with the same transform and one is hidden, so a flip never moves
   the crop; the outlines are drawn in the view's own pixels over them, so a
   line and a number stay crisp whatever the scale. */
.dev-ws-media-shots .dev-ws-media-view { cursor: pointer; }
.dev-ws-media-shots .dev-ws-media-view:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-shot-side { position: absolute; left: 0; top: 0; transform-origin: 0 0; }
.dev-ws-shot-side > img { display: block; width: 100%; height: 100%; max-width: none; }
.dev-ws-media-shots[data-side="after"] .dev-ws-shot-before,
.dev-ws-media-shots[data-side="before"] .dev-ws-shot-after,
.dev-ws-media-shots[data-side="after"] .dev-ws-shot-on-before,
.dev-ws-media-shots[data-side="before"] .dev-ws-shot-on-after { visibility: hidden; }
.dev-ws-shot-box { position: absolute; pointer-events: none; border: 2px solid #16a34a; border-radius: 6px; background: rgba(22, 163, 74, .1); }
.dev-ws-shot-box.dev-ws-shot-on-before { border-color: #dc2626; background: rgba(220, 38, 38, .1); }
.dev-ws-shot-box.dev-ws-shot-box-other { border: 1.5px dashed rgba(161, 161, 170, .95); background: transparent; }
/* Lifted by its own width, so a line at the very foot of a shot shows. */
.dev-ws-shot-mark { position: absolute; pointer-events: none; height: 0; margin-top: -2px; border-top: 2px dashed #16a34a; }
.dev-ws-shot-mark.dev-ws-shot-on-before { border-top-color: #dc2626; }
.dev-ws-shot-n {
  flex: none; width: 20px; height: 20px; border-radius: 9999px; display: inline-grid; place-items: center;
  font-size: 11px; font-weight: 700; line-height: 1; color: #fff; background: #16a34a; font-variant-numeric: tabular-nums;
}
.dev-ws-shot-box > .dev-ws-shot-n, .dev-ws-shot-mark > .dev-ws-shot-n {
  position: absolute; left: -9px; top: -9px; box-shadow: 0 0 0 2px #111114;
}
.dev-ws-media-shots[data-side="before"] .dev-ws-shot-n { background: #dc2626; }
/* The changes its outlines are numbered by: the caption's "more" lists
   them. */
.dev-ws-shot-changes { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.dev-ws-shot-change { display: flex; align-items: flex-start; gap: 8px; font-size: 15px; font-weight: 650; line-height: 1.3; color: var(--text-primary); }
.dev-ws-shot-change > .dev-ws-shot-n { margin-top: 1px; }
.dev-ws-shot-text { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }

/* ── A change's diagram and "What it touches" (#4490, lib/diagram) ──
   The picture a change shows when it has no before & after shots: a
   rounded sheet on a change's page, the card's type scale (15 over 13,
   small caps for the one label) and the one accent. The new word or step is
   in the accent blue, the old one muted and struck through; nothing is
   filled green or red. A long word wraps; the picture never scrolls inside
   the card. On a Needs-you card it is drawn straight on the dark, larger,
   from the top of the picture's space down (the caption covers the foot). */
.dev-ws-diagram {
  display: flex; flex-direction: column; gap: 12px; min-width: 0;
  padding: 16px; border-radius: 18px;
  background: var(--dc-sheet-solid);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
  color: var(--text-primary); overflow-wrap: anywhere;
}
.dev-ws-item > .dev-ws-diagram {
  flex: 1 1 auto; min-height: 0; margin: 16px 0 0; padding: 0 80px 16px 24px; overflow: hidden;
  gap: 14px; border-radius: 0; background: none; box-shadow: none;
}
.dev-ws-item > .dev-ws-diagram .dev-ws-diagram-src { margin: 2px 0 0; font-size: 12.5px; }
.dev-ws-item > .dev-ws-diagram .dev-ws-diagram-rows { gap: 12px; }
.dev-ws-item > .dev-ws-touches .dev-ws-diagram-what { font-size: 15px; font-weight: 650; }
.dev-ws-item > .dev-ws-touches .dev-ws-diagram-bar { height: 9px; }
.dev-ws-item > .dev-ws-diagram[role="button"] { cursor: pointer; }
.dev-ws-diagram[role="button"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-diagram-kicker {
  font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted);
}
.dev-ws-diagram-body { display: flex; flex-direction: column; gap: 12px; min-height: 0; }
.dev-ws-diagram-src { margin: auto 0 0; font-size: 12px; line-height: 1.35; color: var(--text-muted); }
.dev-ws-diagram-arrow { flex: none; color: var(--text-muted); }
.dev-ws-diagram-old { color: var(--text-muted); text-decoration: line-through; text-decoration-thickness: 2px; }
.dev-ws-diagram-new { color: var(--accent); }
/* Rename: a big "old → new", then the places it changes. */
.dev-ws-diagram-rename {
  display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 10px 14px; padding: 8px 0 2px;
}
.dev-ws-diagram-rename > .dev-ws-diagram-arrow { font-size: 26px; }
.dev-ws-diagram-word {
  font-size: 30px; line-height: 1.15; font-weight: 750; letter-spacing: -.02em;
  padding: 6px 14px; border-radius: 14px; text-align: center; max-width: 100%;
}
.dev-ws-diagram-word.dev-ws-diagram-old { background: var(--state-neutral-bg); }
.dev-ws-diagram-word.dev-ws-diagram-new { background: var(--accent-tint); }
.dev-ws-diagram-places { display: flex; flex-wrap: wrap; gap: 6px; }
.dev-ws-diagram-place {
  font-size: 13px; font-weight: 600; padding: 4px 10px; border-radius: 9999px;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-ws-diagram-note { margin: 0; font-size: 13px; line-height: 1.4; color: var(--text-muted); }
/* Before → after: two rows of steps, the ones that differ marked. */
.dev-ws-diagram-flow { display: flex; flex-direction: column; gap: 12px; }
.dev-ws-diagram-flow-row { display: flex; flex-direction: column; gap: 6px; }
.dev-ws-diagram-flow-label { font-size: 13px; color: var(--text-muted); }
.dev-ws-diagram-flow-steps { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.dev-ws-diagram-flow-item { display: inline-flex; align-items: center; gap: 6px; min-width: 0; }
.dev-ws-diagram-step {
  font-size: 15px; font-weight: 650; line-height: 1.25; padding: 5px 10px; border-radius: 10px;
  box-shadow: inset 0 0 0 1px var(--app-sheet-line); color: var(--text-primary);
}
.dev-ws-diagram-step.dev-ws-diagram-new { color: var(--accent); background: var(--accent-tint); box-shadow: inset 0 0 0 1.5px var(--accent); }
/* What changes / Numbers / What it touches: rows of 15 over 13. */
.dev-ws-diagram-rows { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.dev-ws-diagram-change { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; align-items: baseline; }
.dev-ws-diagram-op { font-size: 13px; color: var(--text-muted); }
.dev-ws-diagram-what { display: flex; flex-direction: column; gap: 2px; min-width: 0; font-size: 15px; font-weight: 650; line-height: 1.3; color: var(--text-primary); }
.dev-ws-diagram-detail { font-size: 13px; font-weight: 400; color: var(--text-muted); }
.dev-ws-diagram-change[data-op="added"] .dev-ws-diagram-what > .dev-ws-diagram-new { color: var(--accent); }
.dev-ws-diagram-number { display: flex; flex-direction: column; gap: 6px; }
.dev-ws-diagram-number-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.dev-ws-diagram-figures { display: inline-flex; align-items: baseline; gap: 6px; flex: none; font-size: 15px; font-weight: 650; }
.dev-ws-diagram-old-figure { color: var(--text-muted); font-weight: 500; }
.dev-ws-diagram-new-figure { color: var(--accent); }
.dev-ws-diagram-bars { display: flex; flex-direction: column; gap: 4px; }
.dev-ws-diagram-bar { display: block; height: 8px; border-radius: 9999px; background: var(--state-neutral-bg); overflow: hidden; }
.dev-ws-diagram-bar > i { display: block; height: 100%; border-radius: 9999px; background: var(--accent); }
.dev-ws-diagram-bar-before > i { background: var(--text-muted); opacity: .45; }
.dev-ws-touches-row {
  display: grid; grid-template-columns: 84px minmax(0, 1fr) 64px; gap: 10px; align-items: center; opacity: .45;
}
.dev-ws-touches-row[data-touched] { opacity: 1; }
.dev-ws-touches-row > .dev-ws-diagram-what { font-size: 15px; overflow-wrap: normal; }
.dev-ws-touches-n { font-size: 13px; color: var(--text-muted); text-align: right; white-space: nowrap; }
/* #5039: where the browser can subgrid, the name and count columns are as
   wide as the longest entry in any row, so the bars stay aligned and an
   area's name is never cut ("Database" whole, whatever the text size). */
@supports (grid-template-columns: subgrid) {
  .dev-ws-touches > .dev-ws-diagram-rows { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; }
  .dev-ws-touches-row { grid-column: 1 / -1; grid-template-columns: subgrid; }
}
/* Mermaid: the library's SVG, scaled to the sheet. */
.dev-ws-diagram-svg { display: flex; justify-content: center; min-height: 0; }
.dev-ws-diagram-svg > svg { display: block; max-width: 100%; height: auto; max-height: 360px; }
.dev-ws-diagram-wait { display: block; min-height: 120px; }
/* A change's page leads with it, above the description. */
.dev-change-diagram { margin: 12px 0 4px; }
.dev-change-diagram-failed { margin: 12px 0 4px; font-size: 13px; color: var(--text-muted); }
.dev-change-diagram-failed > p { margin: 0 0 4px; }
.dev-change-diagram-failed pre { margin: 6px 0 0; padding: 10px; border-radius: 10px; font-size: 12px; white-space: pre-wrap; background: var(--state-neutral-bg); color: var(--text-secondary); }

/* ── The rail ─────────────────────────────────────────────────────────
   One column of round buttons at the right edge, bottom-aligned with the
   caption. On a phone it stands over the card's right edge, so each disc
   is solid in the card's own colour with a hairline and a small drop, and
   the words under it get a soft halo in that colour: they read over any
   picture, light or dark (#5298: Comments nearly disappeared over a light
   shot). Vote is filled in the accent: it is the thing to press. Beside
   the card on a wide window (the wide block) it is drawn on the page. */
.dev-ws-rail {
  position: absolute; right: 8px; bottom: 10px; z-index: 3;
  display: flex; flex-direction: column; align-items: center; gap: 10px;
}
.dev-ws-rail-btn {
  display: flex; flex-direction: column; align-items: center; gap: 4px;
  width: 60px; margin: 0; padding: 0; border: 0; background: none;
  color: var(--text-secondary); font: inherit; cursor: pointer;
}
.dev-ws-rail-btn:disabled { opacity: .4; cursor: default; }
.dev-ws-rail-ic {
  width: 44px; height: 44px; border-radius: 9999px;
  display: grid; place-items: center;
  background-color: var(--dc-sheet-solid);
  color: var(--text-primary);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line), 0 1px 3px rgba(0, 0, 0, .12);
}
.dev-ws-rail-ic > svg { width: 23px; height: 23px; }
.dev-ws-rail-lab { font-size: 11.5px; font-weight: 650; line-height: 1; color: var(--text-primary); text-shadow: 0 0 3px var(--dc-sheet-solid), 0 0 6px var(--dc-sheet-solid); white-space: nowrap; font-variant-numeric: tabular-nums; }
.dev-ws-rail-vote .dev-ws-rail-ic { width: 48px; height: 48px; background-color: var(--accent); color: #fff; box-shadow: 0 2px 12px rgba(10, 110, 224, .45); }
.dev-ws-rail-vote.is-on .dev-ws-rail-ic { background-color: var(--state-ok); border-color: var(--state-ok); box-shadow: 0 2px 8px rgba(21, 128, 61, .28); }
/* The same no vote (#4568): the rail button red, with the red glow. */
.dev-ws-rail-vote.is-on[data-ws-voted="no"] .dev-ws-rail-ic { background-color: var(--state-blocked); border-color: var(--state-blocked); box-shadow: 0 2px 8px rgba(220, 38, 38, .28); }
/* The sheet a button opens is up: its round turns over to the page's ink. */
.dev-ws-rail-btn[aria-expanded="true"] .dev-ws-rail-ic { background-color: var(--text-primary); color: var(--dc-sheet-solid); }
.dev-ws-rail-vote[aria-expanded="true"] .dev-ws-rail-ic { background-color: var(--accent); color: #fff; box-shadow: 0 0 0 3px var(--accent-wash); }
.dev-ws-move { display: none; }
.dev-ws-keys { display: none; }

/* ── The Needs-you deck's sheets ──────────────────────────────────────
   Fixed to the viewport, over the whole screen: the header, the tab bar and
   the Resume strip go under the scrim with everything else (see "ABOVE THE
   BARS" below).

   KEYED ON `.dev-ws-sheet-modal`, AND NOT ON `.dev-ws-sheet`, WHICH IS A
   DIFFERENT OBJECT. `.dev-ws-sheet` is the OPEN CARD — the sheet a folded
   row unfolds into (card/fold.tsx, `.dev-ws-rowwrap-open > .dev-ws-sheet >
   .gc-vote-item`), and it has carried that name since the Workshop shipped;
   two declared checks select it that way. #2080 gave the deck's three
   dialogs the same bare class, so this rule landed on every unfolded card on
   every Workshop surface: the card left its column or its strip, became a
   full-viewport `position: fixed` box at `z-index: 30` painting its own fill
   over the board, and swallowed every click underneath it. That is "the
   whole board goes opaque and stops responding" on By stage and By category,
   and "the card opens but the layout is wrong" on Current status — one rule,
   three screens.

   So the deck's dialogs carry a base class of their own and the bare name
   stays the fold's. The `.dev-ws-sheet-*` PARTS below (card, handle, head,
   title, sub, x, body) are the deck's either way — they appear in no folded
   card — as are the breakpoint rules further down, which were already keyed
   on the -vote / -ask / -comments modifiers. */
/* The floor lifts by what the on-screen keyboard took, so the field being
   typed into is above the keys rather than under them.

   THE NUMBER IS THE KIT'S, not this screen's. workshop.tsx used to measure the
   visual viewport itself and publish `--ws-kb` — a third copy of
   `innerHeight - vv.height - vv.offsetTop`, which is the expression #1938
   found to be wrong on iOS: `innerHeight` collapses to the visual viewport
   there, so it came out negative, the `Math.max(0, …)` around it clamped to 0,
   and this sheet never moved on an iPhone. `--un-kb-inset` is one tracker's
   answer for the whole page (usernode-native/v1/native.js), which is what
   keeps this surface and the composer columns from drifting apart again.

   It resolves to 0 with no keyboard, so no `html.un-kb` guard is needed here.
   The wide block below makes these panels `position: relative; inset: auto`,
   which drops this `bottom` with it — a docked panel has no floor to lift.

   ABOVE THE BARS (run-through in the iOS app, 5 October 2026). On a phone a
   sheet is a dialog over the whole screen, and the tab bar and the Resume
   strip are part of that screen: they belong under its scrim, as they are
   under the kit's own sheets (`.un-sheet`, z-index 9991). This was z-index
   30, the tab bar's own number, so the tie went to the bar, which comes
   later in <body>; and the vote sheet, rendered inside the rail, was held
   at the rail's 3 (the rule after the card's). Either way the bar and the
   strip were drawn over the sheet's foot: Cancel and Vote yes sat under
   them and a tap there opened Me (at 375x812, elementFromPoint at Vote
   yes's centre was #platform-tab-me). 40 is the tier `.platform-tabs`
   leaves for panels, sheets and dialogs. The wide block gives every variant
   a z-index of its own, so a docked panel and the popover are unchanged. */
.dev-ws-sheet-modal { position: fixed; inset: 0; z-index: 40; bottom: var(--un-kb-inset, 0px); display: flex; flex-direction: column; justify-content: flex-end; }
.dev-ws-scrim {
  position: absolute; inset: 0; border: 0; padding: 0; margin: 0; cursor: default;
  background: var(--pane-scrim);
}
.dev-ws-sheet-card {
  position: relative;
  display: flex; flex-direction: column; gap: 8px;
  max-height: 66%; min-height: 0;
  padding: 8px 16px calc(12px + var(--platform-safe-bottom, 0px)); box-sizing: border-box;
  border-radius: 22px 22px 0 0;
  background-color: var(--dc-sheet);
  box-shadow: 0 -10px 30px rgba(24, 39, 75, .2);
}
.dev-ws-sheet-vote .dev-ws-sheet-card { padding-bottom: calc(22px + var(--platform-safe-bottom, 0px)); }
/* THE VOTE SHEET LIVES IN THE RAIL (on a wide window it is a popover on the
   rail's button), and on a phone the rail is `z-index: 3`, a stacking
   context: whatever the sheet's own z-index said, it painted at the rail's
   level, under the bars and the header. While it is up the rail is not one,
   so the sheet stacks with the page. Its buttons are under the scrim then,
   so their order against the item no longer shows. (Wide, the rail is
   `z-index: auto` already.) */
.dev-ws-needs[data-ws-sheet="vote"] > .dev-ws-rail { z-index: auto; }
/* With the keyboard up the sheet is short; let the card have all of it, short
   of the status bar. `[data-ws-kb]` is workshop.tsx's, set while the kit's
   `un-kb` or the page's own `platform-kb-open` is on (lib/keyboard-open.ts:
   in the Homeroom app the web view ends at the keys and `un-kb` never comes
   on). The home indicator is behind the keys, so the foot keeps 12px rather
   than 12px plus the inset. */
.dev-ws-needs[data-ws-kb] .dev-ws-sheet-card { max-height: calc(100% - var(--platform-safe-top, 0px)); padding-bottom: 12px; }
/* A SHEET TALLER THAN THE SCREEN SCROLLS, AND THE VOTE KEEPS ITS BUTTONS.
   The card was capped and its content was not: past the cap it ran out of
   the card's foot, off the screen, and nothing scrolled. In the Homeroom app
   with the keyboard up (the web view ends at the keys: 494px at 402x874) the
   vote form is taller than the cap, and Cancel and Vote no hung below the
   page's edge, behind the keys.

   So the card scrolls, and on the vote sheet the action row is sticky at its
   foot: whatever the form's height, Cancel and the send are on screen and
   the rest scrolls under them. The row takes over the clearance the card's
   foot had (`--ws-vote-foot`: the home indicator, or 12px with the keys up)
   and draws on the sheet's own fill, so what scrolls under it never shows
   below the buttons. The card's scroll padding is the row's height (its
   10px top padding, a 44px button and the foot), so a field focused or
   scrolled into view lands above the row rather than under it. `:has()`
   because the group decision's fallback (Open to decide, Decide later) has
   no such row and keeps the card's own clearance.

   Phone only: on a wide window the vote is a popover whose arrow hangs
   outside its card, which a scroller would clip, and the panels scroll
   their own bodies. */
@media (max-width: 699.98px) {
  .dev-ws-sheet-card { overflow-y: auto; overscroll-behavior: contain; }
  .dev-ws-sheet-vote .dev-ws-sheet-card:has(> .dev-ws-vote-form) {
    --ws-vote-foot: calc(22px + var(--platform-safe-bottom, 0px));
    padding-bottom: 0;
    scroll-padding-bottom: calc(54px + var(--ws-vote-foot));
  }
  .dev-ws-needs[data-ws-kb] .dev-ws-sheet-vote .dev-ws-sheet-card:has(> .dev-ws-vote-form) { --ws-vote-foot: 12px; }
  .dev-ws-sheet-vote .dev-ws-vote-form > .dev-vote-reason-actions {
    position: sticky; bottom: 0; z-index: 1;
    padding-bottom: var(--ws-vote-foot);
    background-color: var(--dc-sheet);
  }
}
/* ── Open and close MOVE. The card rises from the floor and the scrim fades
   in; on the way out (`[data-ws-leaving]`, which workshop.tsx keeps mounted
   for exactly this long) both reverse. The names are variables so the wide
   block can swap them for a panel's slide and a popover's pop without a
   second reduced-motion query of its own. */
.dev-ws-sheet-modal { --ws-sheet-in: dev-ws-sheet-up; --ws-sheet-out: dev-ws-sheet-down; }
/* A leaving sheet catches nothing: its scrim (transparent and full-screen
   behind the wide popover) and its buttons would otherwise swallow the next
   tap for the 220ms it stays mounted. Outside the motion query on purpose. */
.dev-ws-sheet-modal[data-ws-leaving] { pointer-events: none; }
@media (prefers-reduced-motion: no-preference) {
  .dev-ws-sheet-modal > .dev-ws-scrim { animation: dev-ws-scrim-in .22s ease-out both; }
  .dev-ws-sheet-modal > .dev-ws-sheet-card {
    animation-name: var(--ws-sheet-in); animation-duration: .28s;
    animation-timing-function: cubic-bezier(.32, .72, 0, 1); animation-fill-mode: both;
  }
  .dev-ws-sheet-modal[data-ws-leaving] > .dev-ws-scrim { animation: dev-ws-scrim-out .2s ease-in both; }
  .dev-ws-sheet-modal[data-ws-leaving] > .dev-ws-sheet-card {
    animation-name: var(--ws-sheet-out); animation-duration: .2s;
    animation-timing-function: ease-in; animation-fill-mode: both;
  }
}
@keyframes dev-ws-sheet-up { from { transform: translateY(100%); } to { transform: none; } }
@keyframes dev-ws-sheet-down { from { transform: none; } to { transform: translateY(100%); } }
@keyframes dev-ws-scrim-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes dev-ws-scrim-out { from { opacity: 1; } to { opacity: 0; } }
@keyframes dev-ws-panel-in { from { opacity: 0; transform: translateX(28px); } to { opacity: 1; transform: none; } }
@keyframes dev-ws-panel-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateX(28px); } }
@keyframes dev-ws-pop-in { from { opacity: 0; transform: translateX(10px) scale(.96); } to { opacity: 1; transform: none; } }
@keyframes dev-ws-pop-out { from { opacity: 1; transform: none; } to { opacity: 0; transform: translateX(10px) scale(.96); } }
.dev-ws-sheet-handle { width: 36px; height: 4px; border-radius: 2px; background: var(--border); margin: 0 auto 4px; flex: none; }
.dev-ws-sheet-x {
  width: 32px; height: 32px; padding: 0; border: 0; border-radius: 9999px; cursor: pointer;
  display: grid; place-items: center;
  background: none; color: var(--text-muted);
}
.dev-ws-sheet-x:hover { background: var(--state-neutral-bg); color: var(--text-primary); }
.dev-ws-sheet-x-ic { width: 18px; height: 18px; }
.dev-ws-sheet-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 10px; padding-top: 4px; }
/* ── Comments and Ask: one sheet, two tabs (workshop.tsx) ──────────────
   On a phone a tall sheet from the floor, the same height whichever tab is
   up, so switching never jumps: the two tabs over the tab's own body, a
   round Close at the right of them, and the body scrolling under its own
   composer. The head that repeats the card's caption is the wide panel's
   alone (the wide block); here the card's top still shows above the sheet.
   Both tabs stay mounted while the sheet is up, the other one `hidden`. */
.dev-ws-sheet-talk > .dev-ws-sheet-card {
  height: 66%; padding-left: 0; padding-right: 0; gap: 0; overflow: hidden;
}
.dev-ws-needs[data-ws-kb] .dev-ws-sheet-talk > .dev-ws-sheet-card { height: calc(100% - var(--platform-safe-top, 0px)); }
.dev-ws-talk-head { display: none; }
.dev-ws-talk-tabs {
  position: relative; flex: none;
  display: flex; align-items: center; justify-content: center;
  padding: 0 52px; border-bottom: 1px solid var(--border-light);
}
.dev-ws-talk-tablist { display: flex; gap: 24px; }
.dev-ws-talk-tab {
  display: inline-flex; align-items: center; gap: 5px;
  height: 44px; margin-bottom: -1px; padding: 0 2px;
  border: 0; border-bottom: 2px solid transparent; background: none; cursor: pointer;
  font: inherit; font-size: 15px; font-weight: 600; color: var(--text-muted);
}
.dev-ws-talk-tab[aria-selected="true"] { border-bottom-color: var(--text-primary); color: var(--text-primary); }
.dev-ws-talk-n { margin-left: 4px; font-weight: 500; color: var(--text-muted); }
.dev-ws-talk-tab-ic { width: 15px; height: 15px; }
.dev-ws-talk-tabs > .dev-ws-sheet-x { position: absolute; right: 10px; top: 6px; }
.dev-ws-talk > .dev-ws-sheet-body { padding: 4px 16px 0; }
/* The thread fills the tab and its reply box stays at the foot, on the
   sheet's own fill, as a comment box under a reel does. */
.dev-ws-talk > .dev-ws-sheet-body > .dev-feed-thread { flex: 1 0 auto; }
.dev-ws-talk > .dev-ws-sheet-body > .dev-feed-thread > form {
  position: sticky; bottom: 0; margin-top: auto; padding: 8px 0 4px;
  background-color: var(--dc-sheet);
}
.dev-ws-talk > .dev-ws-ask { flex: 1 1 auto; min-height: 0; padding: 0 16px; }
.dev-ws-talk > [role="tabpanel"][hidden] { display: none; }
/* The vote sheet's own lines. */
.dev-ws-ask-q { margin: 6px 0 0; font-size: 20px; font-weight: 700; letter-spacing: -.01em; color: var(--text-primary); }
.dev-ws-vote-sub { margin: 0 0 8px; font-size: 14px; line-height: 1.4; color: var(--text-muted); }
.dev-ws-answer-row { display: flex; gap: 10px; }
.dev-ws-answer-btn {
  flex: 1 1 0; min-width: 0; height: 56px;
  border: 1.5px solid currentColor; border-radius: 14px; cursor: pointer;
  font: inherit; font-size: 18px; font-weight: 700;
}
.dev-ws-answer-btn:disabled { opacity: .4; cursor: default; }
.dev-ws-answer-yes { background: var(--state-ok-bg); color: var(--state-ok); }
.dev-ws-answer-no { background: var(--state-blocked-bg); color: var(--state-blocked); }
.dev-ws-vote-later {
  margin: 6px auto 0; border: 0; background: none; padding: 6px 12px; cursor: pointer;
  font: inherit; font-size: 14px; font-weight: 600; color: var(--text-muted);
}
.dev-ws-keys-hint { display: none; }

/* ── Join, under the Join button (communities) ─────────────────────────
   The community card's question (features/dev-board/workshop/
   community-card.tsx), asked the way the vote is asked: the vote popover's
   question, its muted line, an answer button and a quiet "later" under it
   — the classes above — in a popover that hangs from the button rather than
   from a rail. At every width, because the button is inside a card, not at
   the floor of a feed: it opens where the thumb already is.

   Right-aligned to the button, which ends the card's first row, with the
   arrow over the button's middle. The width stops short of the viewport on
   a phone, so the popover never meets the screen edge. */
.dev-ws-join-anchor { position: relative; margin-left: auto; flex: none; }
.dev-ws-join-pop {
  position: absolute; top: calc(100% + 12px); right: -6px; z-index: 7;
  width: min(300px, calc(100vw - 56px));
  display: flex; flex-direction: column; gap: 8px;
  padding: 16px 16px 10px; box-sizing: border-box;
  border-radius: 18px; border: 1px solid var(--app-sheet-line);
  background-color: var(--dc-sheet);
  box-shadow: 0 18px 50px rgba(24, 39, 75, .22);
  text-align: left;
}
.dev-ws-join-pop::before {
  content: ""; position: absolute; top: -8px; right: 26px; width: 14px; height: 14px;
  background: var(--dc-sheet);
  border-left: 1px solid var(--app-sheet-line); border-top: 1px solid var(--app-sheet-line);
  transform: rotate(45deg);
}
.dev-ws-join-pop .dev-ws-ask-q { margin: 0; font-size: 18px; }
.dev-ws-join-pop .dev-ws-vote-sub { margin: 0 0 4px; font-size: 13px; }
.dev-ws-join-pop .dev-ws-answer-btn { height: 46px; font-size: 15px; border-radius: 12px; }
.dev-ws-join-pop .dev-ws-vote-later { margin-top: 0; }
.dev-ws-answer-join { background: var(--brand-tint); color: var(--brand-ink); }
@media (prefers-reduced-motion: no-preference) {
  .dev-ws-join-pop { animation: dev-ws-join-pop-in .18s ease-out both; transform-origin: calc(100% - 32px) 0; }
}
@keyframes dev-ws-join-pop-in { from { opacity: 0; transform: translateY(-4px) scale(.96); } to { opacity: 1; transform: none; } }

/* ── The project page's hero (community-card.tsx) ──────────────────────
   On the ground, not in a card: who is here and who it is for, what it is,
   what you can do, and how lively it has been. The name and tile are the
   coloured header's (#852), so the hero has no heading of its own. The type
   is the scale AGENTS.md "Type and colour" sets: the rows' 15/13. */
.dev-ws-hero { display: flex; flex-direction: column; gap: 12px; padding: 6px 4px 4px; }
.dev-ws-hero-actions { display: flex; align-items: center; gap: 8px; }
.dev-ws-hero-actions:empty { display: none; }
.dev-ws-hero-desc { margin: 0; font-size: 15px; line-height: 21px; color: var(--text-primary); }
/* Two lines at most (#3572), with an ellipsis: the measure the create
   screen's limit was taken against (services/create-options.js
   DESCRIPTION_MAX), so a description made there shows whole on a phone,
   and a longer one from a repository's own dapp.json no longer pushes the
   hub's actions down the screen. The same three-property clamp as
   `.home-discover-blurb`. */
.dev-ws-hero-desc {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}
.dev-ws-hero-line { margin: 0; font-size: 13px; line-height: 18px; color: var(--text-muted); }
/* WHO IS HERE, IN THE HERO (#3268). The people row: up to five faces, then
   "Public community · 23 members". A public or private community draws it as
   the pulse below, with this week's activity under the count; the row is
   Just you's, and the invite preview's.

   THE ROW WRAPS (#3276). Nothing in it can shrink: the faces are a fixed
   stack and the count is `nowrap`. So when five faces and the count come to
   more than a phone's width, the count drops to a line of its own rather than
   running past the screen's edge and scrolling the hub sideways. */
.dev-ws-hero-people { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; min-height: 32px; }
.dev-ws-hero-faces { display: inline-flex; align-items: center; flex: none; }
.dev-ws-hero-face {
  width: 26px; height: 26px; margin-right: -6px; border-radius: 9999px;
  display: grid; place-items: center;
  color: #fff; font-size: 11.5px; font-weight: 700;
  box-shadow: 0 0 0 2px var(--home-ground, var(--bg-primary));
}
.dev-ws-hero-face:last-child { margin-right: 0; }
.dev-ws-hero-count { font-size: 14px; font-weight: 650; color: var(--text-primary); white-space: nowrap; }
/* WHO IS AROUND THIS WEEK (community-card.tsx HeroActive): up to five faces
   and "+N", then "40 active this week" over their names, the rows' 15 over
   13. The names keep to one line, cut short with an ellipsis on a phone. */
.dev-ws-hero-active { display: flex; align-items: center; gap: 12px; min-width: 0; }
.dev-ws-hero-active .dev-ws-hero-face {
  width: 32px; height: 32px; margin-right: -8px; font-size: 12.5px;
}
.dev-ws-hero-active .dev-ws-hero-face:last-child { margin-right: 0; }
.dev-ws-hero-active .dev-ws-hero-face-more { background: var(--bg-tertiary); color: var(--text-primary); font-size: 11px; }
.dev-ws-hero-active-words { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.dev-ws-hero-active-n { font-size: 15px; font-weight: 650; line-height: 20px; color: var(--text-primary); }
.dev-ws-hero-active-names {
  font-size: 13px; line-height: 18px; color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* A change to who it is for, up for a vote: the one line on the hero that
   asks for something, so it carries the accent and goes to the proposal. */
a.dev-ws-hero-pending {
  display: inline-flex; align-items: center; gap: 2px; align-self: flex-start;
  color: var(--accent); font-weight: 600; text-decoration: none;
}
.dev-ws-audience-error { margin: 0; font-size: 12.5px; color: var(--state-blocked); }
/* THE ⋯ ENDS THE MEMBERS ROW, Invite's height and fill (actions-row.tsx
   draws the fill), a circle. Its menu hangs below it over the cards under
   the hero, so the hero lifts while the menu is open, as it does for the
   Join popup. */
.dev-ws-hero .dev-ws-plus-btn { width: 32px; height: 32px; }
.dev-ws-hero .dev-ws-plus-glyph { width: 18px; height: 18px; }
.dev-ws-hero:has(#dev-plus-menu:not(.hidden)) { position: relative; z-index: 5; }
/* It hangs from the ⋯'s LEFT edge and opens rightward, into the column: the
   ⋯ is near the row's start (after Open app and Invite), and a menu opening
   leftward from there ran under the sidebar. */
.dev-ws-hero #dev-plus-menu { left: 0; right: auto; }
/* THE HUB'S HEAD (community-card.tsx, `.dev-ws-hero-summary`, on every
   project from its first day): the tile and the name over who it is for,
   the description, who is around, then what you can do. On a wide window the
   actions sit across from the name: the grid packs the row of actions into
   the hole beside the name (`dense`), whatever came before them, and
   everything else takes the full width in the order it is written. Its ⋯
   menu then hangs from the ⋯'s right edge and opens leftward, into the
   column. */
.dev-ws-hero-summary { gap: 14px; padding: 10px 8px 8px; }
.dev-ws-hero-id { display: flex; align-items: center; gap: 12px; min-width: 0; }
.dev-ws-hero-tile {
  flex: none; width: 52px; height: 52px; overflow: hidden;
  display: grid; place-items: center;
  border-radius: 16px; font-size: 26px; font-weight: 700;
}
.dev-ws-hero-tile > img { width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
.dev-ws-hero-tile > span { font-size: inherit; line-height: 1; }
.dev-ws-hero-id-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.dev-ws-hero-name {
  margin: 0; font-size: 24px; font-weight: 750; line-height: 1.15; letter-spacing: -0.02em;
  color: var(--text-primary); overflow-wrap: anywhere;
}
.dev-ws-hero-id-sub { font-size: 13px; line-height: 18px; color: var(--text-muted); }
@media (min-width: 768px) {
  .dev-ws-hero-summary {
    display: grid; grid-template-columns: minmax(0, 1fr) auto; grid-auto-flow: row dense;
    column-gap: 16px; padding: 4px 4px 8px;
  }
  .dev-ws-hero-summary > * { grid-column: 1 / -1; }
  .dev-ws-hero-summary > .dev-ws-hero-id { grid-column: 1; }
  .dev-ws-hero-summary > .dev-ws-hero-row { grid-column: 2; align-self: center; }
  .dev-ws-hero-summary #dev-plus-menu { left: auto; right: 0; }
  .dev-ws-hero-tile { width: 60px; height: 60px; border-radius: 18px; font-size: 30px; }
  .dev-ws-hero-name { font-size: 28px; }
  .dev-ws-hero-id-sub { font-size: 14px; line-height: 20px; }
}

/* ── The project's colour: its header and its tabs ─────────────────────
   Inside a project, the bar wears its community's colour (lib/community-
   color.ts: dapp.json's `icon.color`, else one read off its icon, always dark
   enough for white text), so which community you are in reads before any
   word does. The header puts it on <html> as `--community-tint`
   (`data-community-tint`, features/header/community-tint.ts) on the
   project's pages only, never in the running app, whose bar stays the
   standard one and takes the app's tone (#1945); and it holds the colour
   across a switch from one community to the next.

   THE HEADER. On the project's pages its fill is the faked-glass layer
   ("FAKED GLASS" below), whose tint is `--fake-glass-tint`; an opaque tint
   covers the washes, so the bar is the colour, flat. The background is set
   as well, for a frame where the layer is not drawn. Its controls re-ink
   white through the same tokens the app-tone rules move (--brand-*), and its
   rounded foot squares off, because the band under it continues it. */
html[data-community-tint] body:has(#app-view:not(.hidden)) #platform-header {
  --fake-glass-tint: var(--community-tint);
  --brand-ink: #fff;
  --brand-tint: rgba(255, 255, 255, .16);
  --brand-line: rgba(255, 255, 255, .32);
  background-color: var(--community-tint);
  color: #fff;
  clip-path: inset(0);
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
}
/* The tile's light ring is drawn INSIDE its edge (#4237). It was an outer
   box-shadow, and the h1 around the tile is `truncate` (overflow: hidden)
   with a line box exactly the tile's 28px, so the ring was clipped on the
   top, bottom and left and only its right edge showed: the tile read as
   nudged off its place. An outline pulled in by its own width paints over
   the icon's edge, inside the box nothing clips. */
html[data-community-tint] body:has(#app-view:not(.hidden)) #platform-header #header-app-tile:not(:focus-visible) {
  outline: 1.5px solid rgba(255, 255, 255, .55);
  outline-offset: -1.5px;
}
/* ON ITS OWN, THE BAR'S ROW IS CENTRED (#4237). The band under the bar is
   the page's own ink at every width now (#4703: on a phone the band is gone
   and the header is the one coloured row), so the tinted bar ends at its own
   52px foot everywhere, and its pt-2/pb-4 (8px over the row, 16px under) put
   every control in it 4px above its middle. The same 52px, split 12 and 12.
   The top keeps the safe-area inset, and the browser-tab clamp (#2509) keeps
   its zero inset. */
html[data-community-tint] body:has(#app-view:not(.hidden)) #platform-header {
  padding-bottom: 0.75rem;
}
@supports (padding: env(safe-area-inset-top)) {
  html[data-community-tint] body:has(#app-view:not(.hidden)) #platform-header.un-safe-top-extend {
    padding-top: calc(0.75rem + var(--un-safe-inset-top, env(safe-area-inset-top, 0px))) !important;
  }
  html.web-browser-chrome[data-community-tint] body:has(#app-view:not(.hidden)) #platform-header.un-safe-top-extend {
    padding-top: 0.75rem !important;
  }
}
/* THE BAND: the place bar (#4417, place-bar.tsx; it held the four tabs,
   Hub, Discussion, Needs you, Workshop, until the places became one list),
   under the header, in its colour. It keeps the old strip's box (`.dev-ws-tabs` > `.dev-ws-tabtrack`)
   for the two hooks that measure it, and bleeds to the column's edges and
   up under the header's overlap (`-mb-2` on the bar, plus the body's own
   top padding), so the colour runs unbroken from the top of the screen to
   the tabs' underline.

   THE SIDES CANCEL 4px, NOT 12 (#3499). `#dev-body` is `px-3`, but holding
   the Workshop it narrows to 4px at the sides (`#dev-body:has(>
   #dev-workshop)`), and the band's bleed was written against the 12. It ran
   8px past both edges of the screen: harmless where the page itself scrolls
   (a mobile browser clips #dev-forum-scroll's x-axis), but in an installed
   app or the native WebView #dev-forum-scroll is the scroller, and its
   `overflow-y: auto` made those 8px a sideways scroll of the whole hub. */
.dev-ws-tabs.dev-ws-band {
  margin: -18px -4px 0;
  /* 18px, not 8: the header's foot overlaps the band's top by ten, and the
     count's badge rises above its word into that strip. */
  padding: 18px 8px 0;
  background: var(--community-tint, #2a2e34);
  gap: 0;
}
/* #4417: THE BAND HOLDS THE PLACE BAR (place-bar.tsx): the tray's button
   and the place's name, one row. The four cells it held are retired with
   the tabs (project-band.tsx); the places are one list now. */
/* On a phone the band is the page's own: no pinned sheet behind it (the
   strip's band below is for the pill it replaced). */
.dev-ws-tabs.dev-ws-band::before { content: none; }

/* ON A PHONE THE BAND PINS UNDER THE HEADER (#3522). It scrolled away with
   the page, so a long Hub or Workshop left you nowhere to change tab without
   scrolling back up. It is sticky now at exactly where it rests, so it never
   moves: the header's coloured foot and the band stay one piece while the
   page slides under them. From 700px the strip's own pinning takes over (the
   `min-width: 700px` block, with the pane head measured under it).

   WHERE IT RESTS depends on which element scrolls. In the installed app and
   the native WebView #dev-forum-scroll does, and its top edge is the
   scrollport's: the band rests 18px above the HEADER'S MEASURED FOOT less
   #dev-body's 8px top padding, the part the header covers (that foot is
   `--dev-ws-head-foot`, published by workshop.tsx; see #3726 below). On   a phone browser the document scrolls, under the sticky header, so the band
   rests the header's height less its 17px tuck below the viewport's top.
   `--ws-band-top` carries whichever it is, and the pane head on All items,
   which pins too, comes to rest under the band (58px: its 18px tuck and its
   40px row) rather than sliding beneath it.

   Z-INDEX 29: over the cards that scroll under it, and under the header (30)
   whose foot overlaps its top. */
@media (max-width: 699.98px) {
  /* #3726: THE BAND RESTS ON THE HEADER'S FOOT, MEASURED. The two literals
     below are the same fact when the header is exactly 3.25rem and its foot
     overlaps the scroller's top by 7px, which is the ordinary case: -10px is
     the band's 18px tuck less #dev-body's 8px padding, and the 7px overlap
     comes out of the scroller top being 45. In-flow chrome between the header
     and the frame (a strip that is not fixed, the non-admin reminder) moves
     the scroller's top without moving the header, and the literal no longer
     reaches the foot: a gap opens and the tabs separate. `--dev-ws-head-foot`
     is the header's measured foot in the scroller's frame, published by
     workshop.tsx, and the band rests 18px above it less the body's 8px. The
     calc is the fallback for the frame before the first measurement, and the
     phone-browser form (the document scrolls, so the property is absent) keeps
     its own arithmetic. */
  #dev-workshop { --ws-band-top: calc(var(--dev-ws-head-foot, 7px) - 17px); }
  html[data-browser-scroller="dev-forum-scroll"] #dev-workshop {
    --ws-band-top: calc(var(--browser-banner-h) + var(--platform-header-h) + var(--platform-safe-top) - 17px);
  }
  .dev-ws-tabs.dev-ws-band {
    position: sticky;
    top: var(--ws-band-top);
    z-index: 29;
  }
  /* #4703: the band is 18px on a phone now — its tuck, with the row it held
     gone into the header — so the head comes to rest right under the
     header's foot (the band's bottom edge, where its box still ends). */
  #dev-workshop .dev-ws-pane-head { top: calc(var(--ws-band-top) + 18px); }
}
/* A PULL TO REFRESH MOVES ONLY THE PAGE UNDER THE TABS (pull-to-refresh
   under the tabs, evan, 2026-10-01). "When you pull down on a community
   page, the tabs move down too? I think the tabs should be fixed, and only
   the page under them move and reveal the refresh."

   The kit's pull slid the whole of #dev-forum-scroll, and the band lives in
   it, so the header stayed and the band rode down with the page. #3514 then
   painted the gap that opened between them in the community's colour (a
   `--ptr-gap` that workshop.tsx read back off the kit's transform), so the
   two read as one band stretching. That rule is gone with the gap it
   painted: nothing opens under the header any more.

   app-view.js now opts the scroller into the kit's `pullProperty`: the
   scroller holds still and carries the pull as `--dev-ptr-pull`, and this
   slides what comes after the band (the tab body, and All items' back bar
   with it) by exactly that much. The header, the band and anything above
   the band stay where they are. The gap opens between the band and the
   page, and shows the page's own plane, as the air around its cards does:
   every box from the tab body up to the scroller is transparent, so the
   kit's layer behind them (anchored at the band's bottom edge, where the
   spinner hangs) is what shows, and it paints only the spinner.

   UNSET AT REST, so at rest this is no rule at all: `var()` with no
   fallback and nothing to read is invalid at computed-value time, which
   leaves `transform: none`, so no stacking context and no containing block
   where there was none before. A fallback of 0px would make both permanent
   on the tab body (none of these boxes has a transform of its own for this
   to override).

   NO BAND (the Workshop still loading, an app that could not load): the
   kit hangs the spinner from the scroller's top instead (app-view.js's
   `topEl` finds no band), and everything in the scroller slides, as it all
   did. AT EVERY WIDTH, outside the phone block above: the kit only pulls on
   a touch device, and an iPad's row of tabs holds still the same way (the
   row the band becomes from 768px is the same `.dev-ws-band`).

   A CLASS, NOT `:has()`. This was `#dev-forum-scroll:not(:has(.dev-ws-band))
   > *`, and a `:has()` on the scroller that picks its children is a question
   the browser has to ask again whenever anything is added inside it: every
   comment the board filled in re-applied the stylesheet to the whole board
   (about 9,000 elements with every card open, 67ms a time). The Workshop
   sets `dev-ws-has-band` while its band is on screen and the pull re-reads
   it when it starts (workshop.tsx `useWorkshopHostState`, app-view.js
   `topEl`). The same holds for `dev-ws-has-board` and `dev-ws-on-needs` on
   #dev-workshop further down. Each selector weighs what its `:has()` form
   did, except the Needs release of the column, which now weighs what the
   board's has always weighed. */
#dev-forum-scroll .dev-ws > .dev-ws-band ~ *,
#dev-forum-scroll:not(.dev-ws-has-band) > * {
  transform: translateY(var(--dev-ptr-pull));
}

/* ON A WIDE WINDOW THE BAR IS THE PANEL'S FIRST ROW (#852 review: the tabs
   sat in the coloured header's row for a round). Not a band: the header
   above already says whose page this is, so the row is the page's own ink,
   and since #4417 it is the page's title, naming the place (the list is
   nested under the community's row in the sidebar). It pins under the header's edge as the
   old strip did, on the sheet band that strip drew while pinned
   (`data-ws-pinned`, usePinnedStrip). */
@media (min-width: 768px) {
  .dev-ws-tabs.dev-ws-band {
    margin: 0 0 4px;
    padding: 0;
    background: none;
    box-shadow: inset 0 -1px 0 var(--app-sheet-line);
  }
  .dev-ws-tabs.dev-ws-band::before { content: ''; }
}

/* ── #4417: A PROJECT'S PLACES (project-places.tsx) ───────────────────
   One list where the four tabs were: Hub, Needs you, Workshop, a line, then
   Channels (#general) and Topics, each under its own small-caps heading.
   Rows are the left rail's: 40px, a 20px glyph, 15px words, and the place
   you are on in the rail's lit-row treatment (`--lit-tint`, `--lit-ink`).
   A count is the accent, a number that asks for you; a zero is not drawn. */
.dev-ws-places { display: flex; flex-direction: column; min-height: 100%; }
.dev-ws-places-nav { display: flex; flex-direction: column; gap: 2px; }
.dev-ws-place {
  display: flex; align-items: center; gap: 10px;
  width: 100%; height: 40px; padding: 0 10px; border-radius: 10px;
  border: 0; background: none; text-align: left;
  font: inherit; font-size: 15px; line-height: 20px; white-space: nowrap;
  color: var(--text-primary); text-decoration: none; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
@media (hover: hover) {
  .dev-ws-place:not([aria-current="page"]):hover { background: var(--bg-secondary); }
}
.dev-ws-place:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.dev-ws-place[aria-current="page"] { background: var(--lit-tint); color: var(--lit-ink); font-weight: 600; }
.dev-ws-place-glyph { width: 20px; height: 20px; flex: 0 0 20px; color: var(--text-muted); }
.dev-ws-place-hash { width: 18px; height: 18px; margin: 0 1px; }
.dev-ws-place[aria-current="page"] .dev-ws-place-glyph { color: inherit; }
.dev-ws-place-label { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* The App row's "still open" dot (features/app-frame/live-apps.tsx), at the
   row's end where a count sits, without the ring a launcher tile needs. */
.dev-ws-place-live { width: 8px; height: 8px; margin-right: 4px; box-shadow: none; }
.dev-ws-place-count {
  flex-shrink: 0; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box;
  border-radius: 999px; background: var(--accent); color: #fff;
  font-size: 12px; font-weight: 700; line-height: 20px; text-align: center;
}
/* A channel's count is new to read, not waiting on you (#18): the grey the
   tab bar's unread count wears. Needs you keeps the accent. */
.dev-ws-place:is([data-place="discussion"], [data-place^="c:"]) .dev-ws-place-count {
  background: var(--text-faint, #8e8e93);
}
.dev-ws-place .dev-ws-filter-dot { position: static; flex-shrink: 0; background: var(--accent); }
.dev-ws-places-line { height: 1px; margin: 8px 10px; background: var(--app-sheet-line); }
.dev-ws-places-group { display: flex; flex-direction: column; gap: 2px; }
.dev-ws-places-head {
  padding: 4px 10px; font-size: 12px; font-weight: 700; line-height: 16px;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-muted);
}
.dev-ws-places-group + .dev-ws-places-group .dev-ws-places-head { padding-top: 12px; }
.dev-ws-places-foot { margin-top: auto; padding-top: 12px; }

/* THE PLACE BAR (place-bar.tsx): the band's box, one row: the tray's
   button, then the place's name. On a phone it is the band, in the
   community's colour; its 44px button sits at the band's left edge. */
.dev-ws-placebar .dev-ws-tabtrack {
  display: flex; align-items: center; gap: 4px; min-width: 0; height: 44px;
}
.dev-ws-places-btn {
  position: relative; flex-shrink: 0;
  display: inline-flex; align-items: center; justify-content: center;
  width: 44px; height: 44px; margin-left: -6px;
  border: 0; background: none; border-radius: 10px;
  color: #fff; cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.dev-ws-places-btn:focus-visible { outline: 2px solid #fff; outline-offset: -4px; }
.dev-ws-places-btn-glyph { width: 22px; height: 22px; }
/* Something waits in the places (votes, unread talk). WHITE on the
   community's colour, not red (#18 of the 19 Oct polish board): red is the
   bell's alone, and the places' own counts say which it is when opened. */
.dev-ws-places-dot {
  position: absolute; top: 9px; right: 8px; width: 9px; height: 9px; border-radius: 999px;
  background: #fff; box-shadow: 0 0 0 2px var(--community-tint, #2a2e34);
}
.dev-ws-place-title {
  min-width: 0; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: 17px; font-weight: 650; letter-spacing: -0.01em; line-height: 22px; color: #fff;
}
.dev-ws-place-title-hash { margin-right: 3px; opacity: 0.6; }
@media (min-width: 768px) {
  .dev-ws-placebar .dev-ws-tabtrack { height: 52px; }
  .dev-ws-places-btn { color: var(--text-muted); margin-left: -10px; }
  .dev-ws-places-btn:focus-visible { outline-color: var(--accent); }
  @media (hover: hover) { .dev-ws-places-btn:hover { background: var(--bg-secondary); color: var(--text-primary); } }
  .dev-ws-places-dot { background: var(--text-muted); box-shadow: 0 0 0 2px var(--bg-primary); }
  .dev-ws-place-title { font-size: 20px; font-weight: 700; color: var(--text-primary); }
}

/* THE MERGED HEADER (#4703): on a phone the place bar's contents live in the
   platform header, and the second coloured row is gone. The band keeps its
   box — the margin, the padding and the 18px tuck the pinned head and the
   pull to refresh are measured against — but it paints nothing and takes no
   taps: its track is collapsed to nothing, the h2 stays in the DOM for
   screen readers and the declared checks, and the band's button is hidden.
   The header draws its own button (place-bar.tsx HeaderPlace), 28px like the
   header's other controls, and the place's name beside the switcher as a
   small quiet tag (below); the tint they sit on and the dot's ring are the
   community's, as the band's were. The place name holds its ground (`flex-shrink: 0`, capped) so a long
   community name shortens in the switcher first, which is the name the old
   second row did not show. On Homeroom's own pages that name is the
   wordmark drawing, which has no shorten-with-ellipsis of its own: it scales
   into the room left instead of clipping mid-letter. */
/* The name is phone-only, and the phone-ness is CSS, not a width query in
   the header's code (#3271): hidden at every width, shown on a phone only,
   where the band's row is collapsed. */
#platform-header .header-place-name { display: none; }
@media (max-width: 767.98px) {
  .dev-ws-tabs.dev-ws-band { background: none; pointer-events: none; }
  .dev-ws-placebar .dev-ws-tabtrack { height: 0; overflow: hidden; }
  .dev-ws-placebar .dev-ws-places-btn { display: none; }
  #platform-header .header-places-btn { width: 28px; height: 28px; margin-left: 0; }
  #platform-header .header-places-btn .dev-ws-places-dot { top: 1px; right: 0; }
  #platform-header:has(.header-place-name) #header-title-name > svg {
    width: 100%; height: auto;
  }
  #platform-header .header-place-name {
    display: inline-block;
    flex-shrink: 0; min-width: 0; max-width: 45%; overflow: hidden;
    text-overflow: ellipsis; white-space: nowrap;
    /* A SMALL QUIET TAG, CENTRED ON THE TITLE'S LINE (newcomer run six,
       14 Oct 2026). It was a subscript dropped to the chip's foot, 11px
       with `align-self: flex-end`, and "Club Votes ⌄ Hub" read as a word
       that had slipped a few points under the name's baseline. Now it is a
       pill in the header's own ink, as the bell and the back disc are
       (`--brand-tint` / `--brand-ink`: white on the community's tint
       here), centred on the row beside the switcher, so it reads as a
       label on the name rather than a second line. */
    align-self: center;
    padding: 2px 8px; border-radius: 999px;
    background: var(--brand-tint); color: var(--brand-ink);
    font-size: 12px; font-weight: 600; line-height: 16px;
  }
}

/* THE TRAY (places-tray.tsx): the list, slid in from the left over a
   scrim, on a phone. #5384: it opens UNDER the header, which stays as it
   is, undimmed and pressable, its bottom edge the offset the other layers
   under it use; it reaches down over the tab bar (z 60), under a dialog.
   The header covers the status bar, so the list keeps only its own 16px. */
.dev-ws-tray-root {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 60;
  top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h) + var(--platform-safe-top, 0px));
}
.dev-ws-tray-scrim { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.45); animation: dev-ws-tray-fade 160ms ease-out; }
.dev-ws-tray {
  position: absolute; top: 0; bottom: 0; left: 0;
  width: min(296px, calc(100vw - 56px));
  padding: 16px 12px calc(16px + var(--platform-safe-bottom, 0px));
  overflow-y: auto; overscroll-behavior: contain;
  background: var(--dc-sheet-solid, var(--bg-primary));
  box-shadow: 0 0 40px rgba(0, 0, 0, 0.25);
  animation: dev-ws-tray-in 200ms cubic-bezier(.2, .8, .2, 1);
}
@keyframes dev-ws-tray-in { from { transform: translateX(-100%); } to { transform: none; } }
@keyframes dev-ws-tray-fade { from { opacity: 0; } to { opacity: 1; } }
/* The close (#5386): the open in reverse. The leaving tray takes no presses
   (they go to the page under it) and the card calls its own end. */
.dev-ws-tray-root.is-closing { pointer-events: none; }
.dev-ws-tray-root.is-closing .dev-ws-tray { animation: dev-ws-tray-out 200ms cubic-bezier(.2, .8, .2, 1) forwards; }
.dev-ws-tray-root.is-closing .dev-ws-tray-scrim { animation: dev-ws-tray-fade-out 160ms ease-out forwards; }
@keyframes dev-ws-tray-out { from { transform: none; } to { transform: translateX(-100%); } }
@keyframes dev-ws-tray-fade-out { from { opacity: 1; } to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .dev-ws-tray, .dev-ws-tray-scrim { animation: none; }
  .dev-ws-tray-root.is-closing .dev-ws-tray, .dev-ws-tray-root.is-closing .dev-ws-tray-scrim { animation: none; }
}

/* ── THE PHONE'S NAVIGATION DRAWER (features/nav-drawer/) ─────────────
   Evan's design (18 Oct 2026): under 768px, a community's page is the
   deepest of three levels on one strip, fixed between the header's foot
   and the tab bar (the tray's offset): your communities at 85%, the
   community's places at 80% (the tray's list, in its colour), then the
   page itself, #app-view, which the drawer moves with a transform while it
   is off its rest and leaves untransformed on it. Each level peeks in at
   the right of the one before it, a little dimmed, and a press on it goes
   there. Transform and opacity only; the widths change at once, never by
   transition. Nothing here draws at 768px and up: the island renders
   nothing there, and the header's rules below are the phone's. */
.nav-drawer {
  position: fixed; left: 0; right: 0; z-index: 20;
  top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h) + var(--platform-safe-top, 0px));
  bottom: var(--platform-tabs-h, 0px);
  overflow: hidden;
  /* Up and down are the lists' own scrolls; sideways is the pan's. */
  touch-action: pan-y;
}
.nav-drawer.is-resting { visibility: hidden; pointer-events: none; }
/* touch-action stops at a scroller: a list inside says pan-y itself, or the
   browser takes a sideways drag that starts on it (a pointercancel, and the
   strip never moves). The page says it too while the drawer holds it, so a
   swipe right on it is the drawer's; its own sideways scrollers and
   swipe-to-vote cards say their own, and keep theirs
   (features/nav-drawer/sideways.ts). */
html[data-nav-drawer] #app-view { touch-action: pan-y; }
.nav-drawer-strip { position: absolute; inset: 0; will-change: transform; }
.nav-drawer-outer {
  position: absolute; top: 0; bottom: 0; left: 0; width: 85%;
  display: flex; flex-direction: column;
}
.nav-drawer.is-searching .nav-drawer-outer { width: 100%; }
.nav-drawer-places {
  position: absolute; top: 0; bottom: 0; left: 85%; width: 80%;
  overflow: hidden;
  background: var(--dc-sheet-solid, var(--bg-primary));
  box-shadow: -1px 0 0 var(--app-sheet-line);
  transition: transform 300ms cubic-bezier(0.33, 1, 0.68, 1);
}
/* The search widens level 0 to the screen: the places slide off its edge. */
.nav-drawer.is-searching .nav-drawer-places { transform: translate3d(15vw, 0, 0); }
.nav-drawer-places-scroll {
  position: absolute; inset: 0;
  overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y;
  padding: 16px 12px 24px;
}
.nav-drawer-places.is-peek { cursor: pointer; }
.nav-drawer-places.is-peek * { pointer-events: none; }
.nav-drawer-places.is-peek::after,
.nav-drawer[data-nav-drawer-level="1"] .nav-drawer-cover::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: rgba(0, 0, 0, 0.05);
}
.nav-drawer-who { padding: 0 10px 12px; }
.nav-drawer-who-name {
  display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: 17px; font-weight: 650; line-height: 22px; color: var(--text-primary);
}
.nav-drawer-who-line { display: block; font-size: 13px; line-height: 18px; color: var(--text-muted); }
/* The page's edge, peeking: what a press goes back to, and the shadow it
   casts on the places. It moves with #app-view, over it. */
.nav-drawer-cover {
  position: absolute; top: 0; bottom: 0; left: 0; width: 100%;
  margin: 0; padding: 0; border: 0; background: transparent;
  box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08);
  cursor: pointer; -webkit-tap-highlight-color: transparent;
  will-change: transform;
}
.nav-drawer-cover:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
:is(.dark) .nav-drawer-places.is-peek::after,
:is(.dark) .nav-drawer[data-nav-drawer-level="1"] .nav-drawer-cover::after { background: rgba(0, 0, 0, 0.22); }

/* Level 0: your communities, the search at its top. The field's text is
   16px so iOS does not zoom the page to it. */
.nav-drawer-search { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 16px 4px; }
.nav-drawer-search-box {
  flex: 1 1 auto; min-width: 0; height: 36px; padding: 0 12px;
  display: flex; align-items: center; gap: 8px;
  border-radius: 12px; background: var(--dc-sheet-solid, var(--bg-primary));
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
  color: var(--text-muted);
}
.nav-drawer-search-box:focus-within { box-shadow: inset 0 0 0 2px var(--accent); }
.nav-drawer-search-glyph { flex: none; width: 18px; height: 18px; }
.nav-drawer-search-input {
  flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: 0; outline: none;
  background: transparent; color: var(--text-primary);
  font: inherit; font-size: 16px;
  -webkit-appearance: none; appearance: none;
}
.nav-drawer-search-input::placeholder { color: var(--text-muted); }
.nav-drawer-search-input::-webkit-search-cancel-button { -webkit-appearance: none; display: none; }
.nav-drawer-search-cancel {
  flex: none; padding: 0 2px; border: 0; background: none; cursor: pointer;
  font: inherit; font-size: 15px; font-weight: 600; color: var(--accent);
}
.nav-drawer-outer-list {
  flex: 1 1 auto; min-height: 0;
  overflow-y: auto; overscroll-behavior: contain; touch-action: pan-y;
  padding-bottom: 24px;
}
.nav-drawer-outer-list:focus { outline: none; }
.nav-drawer-outer-none { margin: 20px 20px 0; font-size: 15px; line-height: 20px; color: var(--text-muted); }
/* The community the drawer is on: its tile ringed in "where you are". */
.nav-drawer-outer [aria-current="true"] .app-icon-tile { outline: 2px solid var(--lit-ink); outline-offset: 2px; }

/* THE HEADER WITH THE DRAWER (header-drawer.tsx). The arrow is the bar's
   back disc; the titles are absolutely placed in the h1, so the h1 lets
   "Communities" sit out past its edge where the arrow is not, and each
   title shortens itself. The bar's colour: the tinted rules above still
   paint it, and DrawerUntint lays the bar as it is off a community over
   them, faded out with depth (`--nav-k`, 0 on your communities, 1 from the
   places in); the ink goes from the bar's own to white with it. At rest on
   a page the bar is exactly the tinted bar. */
@media (max-width: 767.98px) {
  html[data-nav-drawer] body {
    --nav-brand-ink: var(--brand-ink);
    --nav-brand-tint: var(--brand-tint);
    --nav-brand-line: var(--brand-line);
    --nav-ink: var(--text-primary);
  }
  html[data-nav-drawer][data-community-tint] body:has(#app-view:not(.hidden)) #platform-header {
    --brand-ink: color-mix(in srgb, #fff calc(var(--nav-k, 1) * 100%), var(--nav-brand-ink));
    --brand-tint: color-mix(in srgb, rgba(255, 255, 255, 0.16) calc(var(--nav-k, 1) * 100%), var(--nav-brand-tint));
    --brand-line: color-mix(in srgb, rgba(255, 255, 255, 0.32) calc(var(--nav-k, 1) * 100%), var(--nav-brand-line));
    color: color-mix(in srgb, #fff calc(var(--nav-k, 1) * 100%), var(--nav-ink));
  }
  .nav-drawer-untint {
    position: fixed; inset: 0; z-index: -1; pointer-events: none; opacity: 0;
    background:
      linear-gradient(var(--dc-sheet-fill), var(--dc-sheet-fill)),
      var(--home-washes, var(--dc-sheet-solid));
  }
  #header-title:has(> .nav-drawer-titles) { overflow: visible; }
  .nav-drawer-back { will-change: transform, opacity; }
  .nav-drawer-titles { position: relative; display: block; width: 100%; height: 28px; }
  .nav-drawer-title {
    position: absolute; left: 0; right: 0; top: 50%; margin-top: -17px;
    display: flex; flex-direction: column; align-items: flex-start;
    line-height: 1.15; white-space: nowrap;
    will-change: transform, opacity;
  }
  .nav-drawer-title:first-child { opacity: 0; }
  .nav-drawer-title-name {
    max-width: 100%; overflow: hidden; text-overflow: ellipsis;
    font-size: 17px; font-weight: 650; letter-spacing: -0.01em;
  }
  .nav-drawer-title-mark { display: block; width: 77.5px; height: 20px; }
  .nav-drawer-title-page {
    max-width: 100%; overflow: hidden; text-overflow: ellipsis;
    font-size: 12px; font-weight: 600; opacity: 0.78;
  }
}
@media (prefers-reduced-motion: reduce) {
  .nav-drawer-places { transition: none; }
}

/* ── MESSAGES ON THE SAME DRAWER (features/messages/phone-drawer.tsx) ──
   Evan's design (18 Oct 2026), on a phone: the conversation list at 85% of
   the screen in the column's styling (the desktop's sidebar rows: no cards,
   the conversation you were in lit), the last conversation peeking at its
   right, and the conversation over the whole screen, moved by transform
   between the two. The panes stay the inbox's own (#messages-screen >
   .messages-layout > …), so the declared checks and the wide window's
   rules are untouched; `messages-drawer` is only ever set under 768px. */
@media (max-width: 767.98px) {
  .messages-layout.messages-drawer { position: relative; overflow: hidden; touch-action: pan-y; }
  /* touch-action stops at a scroller: the list and the conversation say
     pan-y themselves, so a sideways drag on them is the drawer's. */
  .messages-layout.messages-drawer .messages-list-scroll,
  .messages-layout.messages-drawer .messages-thread-scroll { touch-action: pan-y; }
  /* An agent session's bar names it in the pane, over its pills; on the
     drawer the header does (messages/index.tsx agentThreadWords), so the
     pane leaves its own copy out, as a conversation's sheet does. */
  .messages-layout.messages-drawer [data-agent-session-bar] > div:first-child:not([data-agent-session-pills]) { display: none; }
  .messages-layout.messages-drawer > .messages-list-pane {
    position: absolute; top: 0; bottom: 0; left: 0; width: 85%;
    will-change: transform;
  }
  .messages-layout.messages-drawer.messages-drawer-alone > .messages-list-pane { width: 100%; }
  .messages-layout.messages-drawer > .messages-thread-pane {
    position: absolute; top: 0; bottom: 0; left: 0; width: 100%;
    margin-top: 0;
    box-shadow: -8px 0 24px rgba(0, 0, 0, 0.08);
    will-change: transform;
  }
  .messages-layout.messages-drawer > .messages-thread-pane.messages-no-selection { display: none; }
  /* The list's rows: the column's, one name a row with its last line small under it. */
  .messages-layout.messages-drawer > .messages-list-pane .messages-toolbar { margin: 12px 12px 4px; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-list-scroll { padding: 0 8px 12px; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-section-head { padding: 12px 10px 4px; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-section-card {
    display: flex; flex-direction: column; gap: 2px;
    margin: 0; overflow: visible; border-radius: 0; background: none; box-shadow: none;
  }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row {
    min-height: 48px; gap: 10px; padding: 4px 10px; border-radius: 10px;
  }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row::after { display: none; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row > :first-child {
    flex: 0 0 28px; width: 28px; height: 28px; border-radius: 8px; font-size: 11px;
  }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row > .messages-group-faces > :first-child {
    position: static; width: 28px; height: 28px; border-radius: 8px; font-size: 11px;
  }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row > .messages-group-faces > :last-child { display: none; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row .messages-row-time,
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row .messages-kind-pill,
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row .messages-group-tag { display: none; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row .messages-row-name { font-size: 15px; font-weight: 400; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-row .messages-row-preview { font-size: 12px; line-height: 16px; }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-active { background: var(--lit-tint); color: var(--lit-ink); }
  .messages-layout.messages-drawer > .messages-list-pane .messages-conversation-active .messages-row-name { color: inherit; font-weight: 600; }
  /* The peek: a picture of the conversation, pressed as one. */
  .messages-thread-peek { overflow: hidden; }
  .messages-thread-peek > .messages-peek-body { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; }
  .messages-thread-peek .messages-thread-scroll { overflow: hidden; }
  .messages-thread-peek .messages-peek-head { flex: none; display: flex; padding: 14px 16px 8px; }
  .messages-thread-peek .messages-message > :first-child { visibility: hidden; }
  .messages-peek-open {
    position: absolute; inset: 0; margin: 0; padding: 0; border: 0;
    background: rgba(0, 0, 0, 0.05); cursor: pointer; -webkit-tap-highlight-color: transparent;
  }
  :is(.dark) .messages-peek-open { background: rgba(0, 0, 0, 0.22); }
  .messages-peek-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -4px; }
  /* In a conversation the header names it, so its sheet keeps only its discs,
     and they take no row of their own (polish N15: a 48px row held the ⋯
     alone). They float at the conversation's top-right corner, over the
     Homeroom bot's Activity strip, which leaves them its right end. */
  .messages-layout.messages-drawer > .messages-thread-pane > .messages-thread-header {
    position: absolute; top: 4px; right: 12px; z-index: 25; padding: 0; gap: 8px;
  }
  .messages-layout.messages-drawer > .messages-thread-pane > .messages-thread-header .messages-thread-action { width: 32px; height: 32px; }
  .messages-layout.messages-drawer > .messages-thread-pane > .messages-thread-header .messages-thread-action svg { width: 18px; height: 18px; }
  .messages-layout.messages-drawer > .messages-thread-pane [data-bot-strip-toggle] { padding-right: 56px; }
}

/* ── The hero's rows (community-card.tsx) ──
   Who is here: the faces, then "Public community · 23 members" as one
   phrase, the audience's glyph leading it. Then one row of what you can do:
   Open app in the community's colour, Invite, the ⋯, and Join at the far
   end. Leave is a row of the ⋯ (#4045). */
/* The words sit on the line's own baseline, so "Public community" and
   " · 23 members" read as one line; only the glyph is centred. A
   `vertical-align` nudge here dropped the audience 3px below the count. */
.dev-ws-hero-audience { display: inline-flex; align-items: baseline; gap: 4px; }
.dev-ws-hero-audience b { font-weight: 650; }
.dev-ws-hero-audience svg { width: 16px; height: 16px; flex: none; align-self: center; color: var(--text-muted); }
.dev-ws-hero-row { display: flex; align-items: center; gap: 8px; min-width: 0; min-height: 32px; }
.dev-ws-hero-row > .dev-ws-hero-actions { flex: 1 1 auto; min-width: 0; flex-wrap: wrap; }
.dev-ws-hero-member { margin-left: auto; flex: none; display: flex; }
.dev-ws-open-app {
  display: inline-flex; align-items: center; gap: 6px; flex: none;
  height: 32px; padding: 0 14px;
  border: 0; border-radius: 999px;
  background: var(--community-tint, var(--accent)); color: #fff;
  font: inherit; font-size: 14px; font-weight: 650; white-space: nowrap;
  cursor: pointer;
}
@media (hover: hover) {
  .dev-ws-open-app:hover { filter: brightness(1.1); }
}
/* Join's popup hangs from the RIGHT: Join ends the row, so a popup opening
   rightward would leave the screen. The arrow moves with it. */
.dev-ws-hero .dev-ws-join-anchor { margin-left: 0; }
.dev-ws-hero .dev-ws-hero-member .dev-ws-join-pop { left: auto; right: -6px; }
.dev-ws-hero .dev-ws-hero-member .dev-ws-join-pop::before { left: auto; right: 26px; }
@media (prefers-reduced-motion: no-preference) {
  .dev-ws-hero .dev-ws-hero-member .dev-ws-join-pop { transform-origin: calc(100% - 32px) 0; }
}
/* ── Opened from an invite link (#3700, invite-offer.ts) ──
   Who invited them, their note, and one Join as the screen's primary
   button, first in the hero, so the decision sits on the page it is about
   and is on screen without scrolling on a phone. A card on the hero's
   ground, in the Join question's own surface. The button fills the card,
   and a question a refusal asks hangs from its right edge as from the row's
   Join. */
.dev-ws-invite {
  display: flex; flex-direction: column; gap: 10px;
  padding: 14px;
  border-radius: 18px;
  background-color: var(--dc-sheet);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
}
.dev-ws-invite-from { margin: 0; font-size: 16px; line-height: 21px; font-weight: 650; color: var(--text-primary); }
.dev-ws-invite-note { margin: 0; font-size: 15px; line-height: 21px; color: var(--text-primary); }

/* ── "Your communities" (features/workshop/community-switcher.tsx) ──
   The switcher behind the Communities tab: a sheet from the floor on a
   phone (the kit's own when it adopts it; this scrim and panel when not),
   and a menu hung off whatever opened it on a wide window. Rows are the
   shell's 15 over 13, each community's tile at 40px, and the one you are on
   is tinted in its colour with a tick (set inline, since the colour is the
   community's). */
.community-switcher-scrim {
  position: fixed; inset: 0; z-index: 60;
  background: rgba(0, 0, 0, .32);
  display: flex; align-items: flex-end;
}
.community-switcher {
  box-sizing: border-box;
  display: flex; flex-direction: column;
  background: var(--dc-sheet);
  color: var(--text-primary);
}
.community-switcher-sheet {
  width: 100%; max-height: 82dvh;
  padding: 6px 8px calc(12px + var(--platform-safe-bottom, 0px));
  border-radius: 22px 22px 0 0;
  box-shadow: 0 -8px 30px rgba(0, 0, 0, .18);
}
.community-switcher-grab {
  align-self: center; flex: none;
  width: 36px; height: 5px; margin: 2px 0 8px; border-radius: 3px;
  background: var(--border, rgba(0, 0, 0, .15));
}
.community-switcher-menu {
  position: fixed; z-index: 60;
  /* 364px: "All communities" whole beside its "34 to vote" and the tick. */
  width: 364px; max-height: min(70vh, 560px);
  padding: 6px;
  border-radius: 16px;
  box-shadow: 0 0 0 1px var(--app-sheet-line), 0 14px 40px rgba(0, 0, 0, .18);
}
.community-switcher-head { padding: 8px 12px 6px; }
.community-switcher-title { margin: 0; font-size: 17px; font-weight: 700; line-height: 22px; }
.community-switcher-menu .community-switcher-title { font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); }
/* #5290: the search first, as the phone drawer's search box draws it. */
.community-switcher-search {
  flex: none; height: 38px; margin: 2px 4px 6px; padding: 0 12px;
  display: flex; align-items: center; gap: 8px;
  border-radius: 12px; background: var(--bg-secondary);
  color: var(--text-muted);
}
.community-switcher-search:focus-within { box-shadow: inset 0 0 0 2px var(--accent); }
.community-switcher-search-glyph { flex: none; width: 18px; height: 18px; }
.community-switcher-search-input {
  flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: 0; outline: none;
  background: transparent; color: var(--text-primary);
  font: inherit; font-size: 16px;
  -webkit-appearance: none; appearance: none;
}
.community-switcher-search-input::placeholder { color: var(--text-muted); }
.community-switcher-search-input::-webkit-search-cancel-button { -webkit-appearance: none; display: none; }
.community-switcher-list {
  display: flex; flex-direction: column; gap: 2px;
  min-height: 0; overflow-y: auto; overscroll-behavior: contain;
}
.community-switcher-row {
  display: flex; align-items: center; gap: 12px;
  width: 100%; min-height: 60px; padding: 8px 12px; box-sizing: border-box;
  border: 0; border-radius: 14px; background: transparent;
  color: inherit; font: inherit; text-align: left; cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.community-switcher-menu .community-switcher-row { min-height: 52px; border-radius: 10px; }
@media (hover: hover) {
  .community-switcher-row:not([aria-current="true"]):hover { background: var(--bg-secondary); }
}
.community-switcher-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.community-switcher-tile {
  flex: none; width: 40px; height: 40px; overflow: hidden;
  display: grid; place-items: center;
  border-radius: 11px; font-size: 20px; font-weight: 700;
}
.community-switcher-menu .community-switcher-tile { width: 34px; height: 34px; border-radius: 9px; font-size: 17px; }
.community-switcher-tile > img { width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
/* An emoji tile's glyph at the tile's own size, not the launcher's. */
.community-switcher-tile > span, .platform-tab-tile > span { font-size: inherit; line-height: 1; }
.platform-tab-tile > img { width: 100%; height: 100%; object-fit: cover; border-radius: 0; }
/* ALL COMMUNITIES WEARS THE TAB'S FACE (#3663): the people glyph in a square
   ring, in the row's own ink, as `.platform-tab-ring-all` draws it on the
   phone's bar. It was a filled near-black tile, which in the same list as
   Homeroom's own (a cream H on near-black) read as a second Homeroom. The
   ring is the tab's 2px at the tile's size, and the glyph is the tab's
   share of it (18px in 28px) with the tab's 1.8 stroke. */
.community-switcher-tile-all {
  background: transparent; color: inherit;
  box-shadow: inset 0 0 0 2px currentColor;
}
.community-switcher-tile-all > svg { width: 26px; height: 26px; stroke-width: 1.8; }
.community-switcher-menu .community-switcher-tile-all > svg { width: 22px; height: 22px; }
.community-switcher-tile-add {
  background: transparent; color: var(--text-muted);
  box-shadow: inset 0 0 0 1.5px var(--app-sheet-line);
}
.community-switcher-text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.community-switcher-name {
  font-size: 15px; font-weight: 650; line-height: 20px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.community-switcher-sub { font-size: 13px; line-height: 17px; color: var(--text-muted); }
.community-switcher-waiting {
  flex: none; height: 22px; padding: 0 8px; border-radius: 999px;
  display: inline-flex; align-items: center;
  background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent);
  font-size: 12px; font-weight: 650; white-space: nowrap;
}
.community-switcher-tick { flex: none; width: 20px; display: grid; place-items: center; color: var(--text-primary); }
.community-switcher-note { margin: 0; padding: 10px 12px; font-size: 13px; color: var(--text-muted); }
/* A small caps label over a part of the switcher (Recent, #5290), as the
   Communities screen labels its sections. */
.community-switcher-section {
  display: flex; align-items: center; gap: 6px;
  margin: 0; padding: 12px 12px 4px;
  font-size: 12px; font-weight: 700; line-height: 16px;
  text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted);
}

/* THE DISCUSSION TAB (project-discussion.tsx): the channel's own pane, a
   FITTED SCREEN like Needs you, so its composer rests on the foot of the
   reading area and the stream scrolls inside it rather than the page. It
   was a guessed `calc(100dvh - 210px)`, which stopped short of the bottom
   (#852 review). The flex chain above bounds it in the native layout;
   in a mobile browser, where the page scrolls, the tab takes `--ws-fit` as
   a definite height, at every width. The pane draws its own sheet
   (general-chat.tsx's `dc-lift dc-lift-session`), which runs off the
   bottom as it does in Messages, so on a phone it spans the band's width:
   the screen's, by cancelling the same 4px of `#dev-body` the band does
   (#3499), not 12. */
html[data-browser-scroller] .dev-ws[data-ws-tab="discussion"],
html[data-browser-scroller] .dev-ws[data-ws-tab^="c:"] {
  height: var(--ws-fit);
  min-height: var(--ws-fit);
}
.dev-ws-discussion { flex: 1 1 auto; min-height: 320px; display: flex; flex-direction: column; }
.dev-ws-discussion-host { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.dev-ws-discussion-host > * { flex: 1 1 auto; min-height: 0; }
@media (max-width: 767.98px) {
  .dev-ws-discussion { margin: 0 -4px; }
}
/* WITH THE KEYBOARD UP IN A PHONE BROWSER, THE ROOM IS WHAT IS SEEN
   (iOS Safari, 5 October 2026: an invitee opened a project's Discussion to
   talk, tapped the message box, and saw a blank cream page with the keys
   up; no messages, no composer, no header). In a phone browser the page is
   the scroller (#1518), so this fitted box sits in the document's flow, and
   the fixed-shell physics every other chat relies on did not hold here:
   iOS scrolled the page to put the message box over the keys, then the
   column's keyboard reservation (`.platform-kb-column`, `--un-kb-inset`)
   lifted the box a keyboard's height again inside a page that had already
   moved by it, and the kit's pan reset only runs in a bounded shell. What
   was left on screen was the column's empty padding.

   So while the keyboard is open (`platform-kb-open`, lib/keyboard-open.ts)
   the room is laid on the band of the page that is actually visible: from
   the pan (`--platform-vv-top`) to the top of what covers the foot
   (`--platform-kb-cover`: the keys, and Safari's pill and form bar over
   them). Its column reserves nothing more, the transcript fills what is
   left above the composer, and the conversation's own stick-to-bottom
   (with its keyboard hold, lib/keyboard-hold.ts) keeps the newest line in
   view as it shrinks. The tab's box keeps its height in the page, so the
   page under it does not move, and the room drops back into it when the
   keys go. In the bounded shell (the app, an installed app) none of this
   applies: there is no `data-browser-scroller`. */
@media (max-width: 767px) {
  html[data-browser-scroller].platform-kb-open .dev-ws-discussion {
    position: fixed;
    left: 0;
    right: 0;
    top: var(--platform-vv-top, 0px);
    bottom: var(--platform-kb-cover, 0px);
    z-index: 35;
    min-height: 0;
    margin: 0;
  }
  html[data-browser-scroller].platform-kb-open .dev-ws-discussion .platform-kb-column {
    padding-bottom: 0;
  }
}
/* THE COMPOSER'S FOOT IS ALREADY CLEAR. `.platform-safe-bar` adds the tab
   bar's height under every composer, on the rule that inside #app-view the
   bar is hidden; a project page under the Communities tab shows it, and
   this fitted box already ends above it, so that reserve left a strip of
   empty pane under the composer. The bar's own 8px base gap stays. */
.dev-ws-discussion .platform-safe-bar { padding-bottom: 0.5rem !important; }
/* HOMEROOM'S ROOM, #general, IN PLACE TOO (#3494). It is Messages' own
   thread (EmbeddedConversation) rather than the group chat's pane, so it
   drops the margins it keeps on the Messages strip and fills the tab edge to
   edge, as a project's own chat does. A reply thread opens beside it on a
   desktop and over it on a phone, as it does in Messages. */
.dev-ws-discussion > .messages-layout { flex: 1 1 auto; height: auto; min-height: 0; }
.messages-layout-embedded > .messages-thread-pane,
.messages-layout-embedded > .messages-reply-pane { margin: 0; }
@media (min-width: 768px) {
  .messages-layout-embedded > .messages-reply-pane { margin-left: 8px; }
}
/* A PROJECT'S OWN CHANNEL'S REPLY THREAD, IN PLACE TOO (#3653). The pane
   Messages opens beside an app channel (AppReplyThreadPane) opens beside the
   group chat's pane here: to its right on a desktop, over it on a phone,
   where its ✕ brings the room back. The host itself is never restyled from
   outside: only the section's class says a thread is open. */
.dev-ws-discussion-threaded > .messages-reply-pane { margin: 0; }
@media (max-width: 767.98px) {
  .dev-ws-discussion-threaded > .dev-ws-discussion-host { display: none; }
}
@media (min-width: 768px) {
  .dev-ws-discussion-threaded { flex-direction: row; }
  .dev-ws-discussion-threaded > .dev-ws-discussion-host { min-width: 0; }
  .dev-ws-discussion-threaded > .messages-reply-pane { margin-left: 8px; }
}
/* A TOPIC'S CHANNEL (#4417, project-discussion.tsx TopicChannel). The same
   fitted pane as #general, with one line above the room: what the topic is
   for, and how many requests are filed under it as a link to them
   (topic-head.tsx). 13px muted, like every fact under a title; the link is
   the accent, because it is an action. A retired topic's line says what
   became of it, and the room under it takes no messages. */
.dev-ws-topic-head {
  margin: 0 0 8px; padding: 0 4px;
  font-size: 13px; line-height: 18px; color: var(--text-muted);
  overflow-wrap: anywhere;
}
.dev-ws-topic-head-link { color: var(--accent); text-decoration: none; white-space: nowrap; }
.dev-ws-topic-head-link:hover { text-decoration: underline; }
/* #4417 follow-up: A TOPIC'S REPLY THREAD OPENS AS #general's DOES, beside
   the room on a desktop and over it on a phone (the rules above). The
   topic's line sits over the room, so beside a thread it runs over both:
   two rows, the room and the thread side by side in the second, the thread
   as wide as it is beside an app channel. On a phone it goes with the room. */
@media (max-width: 767.98px) {
  .dev-ws-topic-channel.dev-ws-discussion-threaded > .dev-ws-topic-head { display: none; }
}
@media (min-width: 768px) {
  .dev-ws-topic-channel.dev-ws-discussion-threaded {
    display: grid;
    grid-template-columns: minmax(0, 1fr) min(420px, 42%);
    grid-template-rows: auto minmax(0, 1fr);
  }
  .dev-ws-topic-channel.dev-ws-discussion-threaded > .dev-ws-topic-head { grid-column: 1 / -1; }
}
/* #5303: #general's LINE (channel-head.tsx). The topic's line, for #general:
   what the channel is for and how many members it reaches, with Mute at its
   end. Over a reply thread it does what a topic's line does: over both on a
   desktop, gone with the room on a phone. */
.dev-ws-channel-head {
  display: flex; align-items: center; gap: 12px;
  margin: 0 0 8px; padding: 0 4px;
}
.dev-ws-channel-head > .dev-ws-channel-head-line { flex: 1 1 auto; min-width: 0; margin: 0; padding: 0; }
@media (max-width: 767.98px) {
  .dev-ws-general-channel.dev-ws-discussion-threaded > .dev-ws-channel-head { display: none; }
}
@media (min-width: 768px) {
  .dev-ws-general-channel.dev-ws-discussion-threaded {
    display: grid;
    grid-template-columns: minmax(0, 1fr) min(420px, 42%);
    grid-template-rows: auto minmax(0, 1fr);
  }
  .dev-ws-general-channel.dev-ws-discussion-threaded > .dev-ws-channel-head { grid-column: 1 / -1; }
}
/* A TOPIC'S FIGURES (topic-figures.tsx; services/topic-figures.js): between
   the topic's line and its room, the numbers its dapp.json entry names. The
   label over the card is the section header; the card is the plane colour,
   20px, one hairline (AGENTS.md, "Type and colour"), one cell per figure
   with a hairline between them (the card's own fill showing through a 1px
   gap). A split figure lists Homeroom over Other projects, each name with
   its number, the number falling under its name where the cell is too
   narrow for both. Colour only for a figure off its target: the attention
   ink, with its glyph, on the line or on the side that is off. With under
   560px of room the figures sit two by two, and the bot's grid puts each
   row's name across its row. Beside a thread it goes where the topic's
   line goes. */
.dev-ws-figs { flex: none; margin: 0 0 10px; container-type: inline-size; }
.dev-ws-figs-head {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  min-height: 32px; padding: 0 4px 2px;
}
.dev-ws-figs-card {
  display: grid; gap: 1px; overflow: hidden;
  border: 1px solid var(--app-sheet-line); border-radius: 20px;
  background: var(--app-sheet-line);
}
.dev-ws-figs-tiles { grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
.dev-ws-fig {
  display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-width: 0;
  padding: 12px 16px 14px;
  background: var(--dc-sheet-solid);
}
.dev-ws-fig-label { font-size: 13px; line-height: 18px; font-weight: 500; color: var(--text-muted); }
.dev-ws-fig-label-end { white-space: nowrap; }
.dev-ws-fig-info {
  position: relative; display: inline-flex; vertical-align: -3px;
  width: 16px; height: 16px; margin-left: 4px; padding: 0;
  border: 0; border-radius: 50%; background: none; color: var(--text-muted); cursor: help;
}
/* The glyph is small; the place to press is not. */
.dev-ws-fig-info::after { content: ''; position: absolute; inset: -12px; }
.dev-ws-fig-info svg { width: 16px; height: 16px; }
.dev-ws-fig-info:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.dev-ws-fig-value {
  font-size: 22px; line-height: 28px; font-weight: 650; letter-spacing: -0.01em;
  color: var(--text-primary); overflow-wrap: anywhere;
}
.dev-ws-fig-value-muted { font-size: 15px; font-weight: 600; letter-spacing: 0; color: var(--text-muted); }
.dev-ws-fig-sub { display: inline-flex; align-items: flex-start; gap: 4px; font-size: 12px; line-height: 16px; color: var(--text-muted); }
.dev-ws-fig-sub-warn { font-weight: 600; color: var(--state-attention); }
.dev-ws-fig-warn { flex: none; width: 13px; height: 13px; margin-top: 1px; }
.dev-ws-fig-sides { display: flex; flex-direction: column; gap: 4px; align-self: stretch; margin: 4px 0 2px; }
.dev-ws-fig-side {
  display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between;
  column-gap: 8px; min-width: 0;
}
.dev-ws-fig-side-name { min-width: 0; font-size: 13px; line-height: 20px; color: var(--text-secondary); }
.dev-ws-fig-side-value {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 17px; line-height: 22px; font-weight: 650; white-space: nowrap;
  font-variant-numeric: tabular-nums; color: var(--text-primary);
}
.dev-ws-fig-side-value-muted { font-size: 13px; line-height: 20px; font-weight: 600; color: var(--text-muted); }
.dev-ws-fig-side-value-warn { color: var(--state-attention); }
.dev-ws-fig-side-value-warn .dev-ws-fig-warn { margin-top: 0; }
.dev-ws-fig-side-detail { flex-basis: 100%; font-size: 12px; line-height: 16px; color: var(--text-muted); }
/* The Homeroom bot's grid: a corner and the three column heads, then a row
   per group, its name first, and the rows a group runs on into under it. */
.dev-ws-figs-grid { grid-template-columns: 140px repeat(3, minmax(0, 1fr)); }
.dev-ws-figs-row { display: contents; }
.dev-ws-figs-corner,
.dev-ws-figs-colhead,
.dev-ws-figs-rowhead { background: var(--dc-sheet-solid); }
.dev-ws-figs-colhead { padding: 8px 16px 6px; font-size: 12px; line-height: 16px; font-weight: 700; color: var(--text-muted); }
.dev-ws-figs-rowhead { display: flex; flex-direction: column; justify-content: center; gap: 2px; padding: 12px 16px; }
.dev-ws-figs-rowname { font-size: 15px; line-height: 20px; font-weight: 650; color: var(--text-primary); }
.dev-ws-figs-rowabout { font-size: 12px; line-height: 16px; color: var(--text-muted); }
.dev-ws-figs-note { margin: 6px 4px 0; font-size: 12px; line-height: 16px; color: var(--text-muted); }
@container (max-width: 560px) {
  .dev-ws-figs-tiles { grid-auto-flow: row; grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .dev-ws-figs-tiles > .dev-ws-fig:last-child:nth-child(odd) { grid-column: 1 / -1; }
  .dev-ws-fig { padding: 10px 12px 12px; }
  .dev-ws-fig-label { font-size: 12px; line-height: 16px; }
  .dev-ws-fig-value { font-size: 20px; line-height: 26px; }
  .dev-ws-fig-value-muted { font-size: 14px; }
  .dev-ws-figs-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .dev-ws-figs-corner { display: none; }
  .dev-ws-figs-colhead { padding: 8px 10px 6px; }
  .dev-ws-figs-rowhead { grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; align-items: baseline; gap: 6px; padding: 8px 10px 4px; }
  .dev-ws-figs-rowhead-more { display: none; }
  .dev-ws-figs-rowname { font-size: 13px; line-height: 18px; }
  .dev-ws-figs-grid .dev-ws-fig { padding: 6px 10px 10px; }
  .dev-ws-figs-grid .dev-ws-fig-value { font-size: 18px; line-height: 24px; }
  .dev-ws-figs-grid .dev-ws-fig-sub { font-size: 11px; line-height: 15px; }
  .dev-ws-fig-side-name { font-size: 12px; line-height: 18px; }
  .dev-ws-fig-side-value { font-size: 15px; line-height: 20px; }
}
/* What a figure means: drawn on the body, under its glyph, so the card's
   rounded edge does not cut it off. */
.dev-ws-fig-tip {
  position: fixed; z-index: 60; box-sizing: border-box;
  padding: 10px 12px; border-radius: 12px;
  background: #ffffff;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.14), 0 0 0 1px var(--app-sheet-line);
  font-size: 13px; line-height: 18px; color: var(--text-secondary);
  pointer-events: none;
}
.dark .dev-ws-fig-tip { background: var(--dc-raised); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--app-sheet-line); }
@media (max-width: 767.98px) {
  .dev-ws-topic-channel.dev-ws-discussion-threaded > .dev-ws-figs { display: none; }
}
@media (min-width: 768px) {
  .dev-ws-topic-channel.dev-ws-discussion-threaded:has(> .dev-ws-figs) { grid-template-rows: auto auto minmax(0, 1fr); }
  .dev-ws-topic-channel.dev-ws-discussion-threaded > .dev-ws-figs { grid-column: 1 / -1; }
}
/* "Discuss in #name ›" at the foot of a topic's card in By category. */
.dev-ws-topic-link { display: inline-block; margin-top: 6px; font-size: 13px; font-weight: 600; }
/* AFTER A MERGE, A CARD MARKS IT (merged-topic-card.tsx): a row in the
   survivor's history where the merge applied, drawn like a date divider
   rather than a message: no author, no bubble, a hairline box on the room. */
.dev-ws-merged-card {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  margin: 10px 12px; padding: 9px 12px; border-radius: 12px;
  border: 1px solid var(--messages-hair, var(--border-light));
  font-size: 13px; line-height: 18px; color: var(--text-muted);
}
.dev-ws-merged-card-text { min-width: 0; overflow-wrap: anywhere; }
.dev-ws-merged-card-link { flex-shrink: 0; color: var(--accent); font-weight: 600; text-decoration: none; }
.dev-ws-merged-card-link:hover { text-decoration: underline; }
/* The Topics dialog (features/dialogs/topics.tsx): a row's ⋯, and the three
   changes it offers, as a row of quiet pills under it. */
.topics-row-more {
  display: inline-flex; align-items: center; justify-content: center;
  width: 36px; height: 36px; flex-shrink: 0; border-radius: 999px;
  font-size: 18px; line-height: 1; color: var(--text-muted);
}
.topics-row-more:hover, .topics-row-more[aria-expanded="true"] { background: var(--bg-secondary); }
.topics-row-actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 16px 12px 64px; }
.topics-row-actions > button {
  min-height: 32px; padding: 0 12px; border-radius: 999px;
  font-size: 13px; font-weight: 600;
  background: var(--bg-secondary); color: var(--text-primary);
}
.topics-into {
  width: 100%; min-height: 40px; padding: 0 10px; border-radius: 10px;
  border: 1px solid var(--border-light); background: transparent;
  font-size: 15px; color: var(--text-primary);
}

/* ── The ask pane, the sheet's Ask tab ─────────────────────────────── */
.dev-ws-ask { display: flex; flex-direction: column; gap: 8px; }
.dev-ws-ask-private { margin: 8px 0 0; font-size: 12.5px; line-height: 1.35; color: var(--text-muted); }
.dev-ws-ask-log {
  flex: 1 1 auto; min-height: 0; overflow-y: auto;
  display: flex; flex-direction: column; gap: 8px; padding: 6px 0 2px;
}
.dev-ws-ask-hint { margin: 0; font-size: 13px; color: var(--text-muted); font-style: italic; }
.dev-ws-ask-msg {
  margin: 0; max-width: 88%; padding: 9px 13px; border-radius: 17px;
  font-size: 15px; line-height: 1.36; white-space: pre-wrap;
}
.dev-ws-ask-you { align-self: flex-end; background: var(--accent); color: #fff; border-bottom-right-radius: 6px; }
.dev-ws-ask-ai { align-self: flex-start; background: var(--bg-secondary); color: var(--text-primary); border-bottom-left-radius: 6px; }
/* The answer on its way: a breathing bubble, still while it grows. */
.dev-ws-ask-pending {
  color: var(--text-muted);
  animation: dev-ws-ask-breathe 1.6s ease-in-out infinite;
}
@keyframes dev-ws-ask-breathe { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
@media (prefers-reduced-motion: reduce) {
  .dev-ws-ask-pending { animation: none; }
}
.dev-ws-ask-failed { color: var(--state-blocked); background: var(--state-blocked-bg); }
/* THE COMPOSER is the dev session's (`.dc-card`), padded evenly so its
   resting line sits on its own middle — `.dc-card`'s 14-over-10 is for a
   tall field with controls under it. */
.dev-ws-ask-composer { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; flex: none; }
.dev-ws-ask-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.dev-ws-ask-line > .dev-ws-ask-input { flex: 1 1 auto; }
.dev-ws-ask-input {
  min-width: 0; border: 0; background: none; padding: 6px 0;
  font: inherit; font-size: 16px; color: var(--text-primary);
}
.dev-ws-ask-input:focus { outline: none; }
.dev-ws-ask-input::placeholder { color: var(--text-muted); }
.dev-ws-ask-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.dev-ws-ask-row > .dev-ws-ask-send { margin-left: auto; }
.dev-ws-ask-model { display: flex; align-items: center; gap: 3px; min-width: 0; max-width: 100%; }
.dev-ws-ask-model > select { min-width: 0; max-width: 100%; flex: 0 1 auto; }
.dev-ws-ask-model-chev { flex: none; width: 14px; height: 14px; color: var(--text-muted); }
.dev-ws-ask-send { width: 34px; height: 34px; }
.dev-ws-ask-send-icon { width: 17px; height: 17px; }
.dev-ws-ask-send:disabled { opacity: .45; cursor: default; }

/* ── The end of the feed ──────────────────────────────────────────────
   One card past the last item, always (#2172): the same snap slot as an
   item, in the page's own scheme rather than the reels' dark, so the swipe
   that lands here reads as arriving somewhere rather than as one more
   decision. On top, a note when your Yes met a change's rule; under it the
   card itself, in the plane colour with one hairline: the tick, the
   headline, what the pass did, and the buttons at its foot. */
.dev-ws-needs-done {
  gap: 8px; padding: 0;
  background-color: transparent;
}
.dev-ws-done-over {
  flex: none; display: flex; align-items: flex-start; gap: 10px;
  padding: 12px 14px; border-radius: 18px;
  background-color: var(--dc-sheet-solid);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line), 0 6px 20px rgba(0, 0, 0, .08);
}
.dev-ws-done-over-ic {
  flex: none; width: 28px; height: 28px; border-radius: 9999px;
  display: grid; place-items: center;
  background: var(--state-ok-bg); color: var(--state-ok);
}
.dev-ws-done-over-ic > svg { width: 16px; height: 16px; }
.dev-ws-done-over-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.dev-ws-done-over-line { font-size: 15px; font-weight: 650; line-height: 1.3; color: var(--text-primary); }
.dev-ws-done-over-sub { font-size: 13px; line-height: 1.35; color: var(--text-muted); }
.dev-ws-done-body {
  flex: 1 1 auto; min-height: 0; box-sizing: border-box; overflow-y: auto;
  display: flex; flex-direction: column; align-items: center; gap: 8px;
  padding: 26px 18px 16px; border-radius: 22px; text-align: center;
  background-color: var(--dc-sheet-solid);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
}
.dev-ws-done-tick {
  flex: none; width: 56px; height: 56px; border-radius: 18px;
  display: grid; place-items: center;
  background: var(--state-ok-bg); color: var(--state-ok);
}
.dev-ws-done-tick > svg { width: 28px; height: 28px; }
.dev-ws-needs-done-line { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -.02em; color: var(--text-primary); }
.dev-ws-needs-done-sub { margin: 0; max-width: 34ch; font-size: 15px; line-height: 1.4; color: var(--text-secondary); }
/* What your votes did: a row each, its title cut short beside its status. */
.dev-ws-done-did { align-self: stretch; display: flex; flex-direction: column; margin-top: 10px; text-align: left; }
.dev-ws-done-did-head { margin: 0 0 2px; font-size: 13px; font-weight: 650; color: var(--text-primary); }
.dev-ws-done-did-list { list-style: none; margin: 0; padding: 0; }
.dev-ws-done-did-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; }
.dev-ws-done-did-row + .dev-ws-done-did-row { border-top: 1px solid var(--app-sheet-line); }
.dev-ws-done-did-title {
  flex: 1 1 auto; min-width: 0;
  font-size: 15px; font-weight: 600; line-height: 1.3; color: var(--text-primary);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dev-ws-done-status {
  flex: none; display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 8px;
  border-radius: 9999px; font-size: 12px; font-weight: 600; white-space: nowrap;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-ws-done-status-ok { background: var(--state-ok-bg); color: var(--state-ok); }
.dev-ws-done-status-progress { background: var(--accent-tint); color: var(--accent-wash-ink); }
.dev-ws-done-status-blocked { background: var(--state-blocked-bg); color: var(--state-blocked); }
.dev-ws-done-status-ic { width: 12px; height: 12px; flex: none; }
.dev-ws-done-actions { align-self: stretch; display: flex; gap: 8px; margin-top: auto; padding-top: 14px; }
.dev-ws-done-cta, .dev-ws-done-hub {
  flex: 1 1 0; min-width: 0; height: 46px; padding: 0 14px; border: 0; border-radius: 9999px; cursor: pointer;
  font: inherit; font-size: 15px; font-weight: 650;
}
.dev-ws-done-cta { background: var(--accent); color: #fff; }
.dev-ws-done-hub { background: var(--dc-sheet); color: var(--text-primary); box-shadow: inset 0 0 0 1px var(--border); }
/* The way back to what was passed over: a quiet action under the two,
   drawn only when there is something above to go back to. */
.dev-ws-done-back {
  flex: none; height: 40px; padding: 0 16px; border: 0; border-radius: 9999px; cursor: pointer;
  font: inherit; font-size: 14px; font-weight: 600;
  background: transparent; color: var(--accent);
}
.dev-ws-done-back:hover { background: var(--accent-wash); }

/* ── The deck at the top, on a wide screen ───────────────────
   On a phone the deck fills the reading area and the answers sit at the
   floor, which is where the thumb is. On a desktop that same rule spends
   half a 900px window on the gap between a two-line summary and three
   buttons, and empty space that large reads as something failing to render
   rather than as breathing room.

   So above 700px the deck is content-sized and sits at the top: the
   question and its answers follow the card immediately. The tab rail is
   unaffected — it is pushed to the foot of the lander by `margin-top:
   auto`, not by the deck's height. The ask log takes a cap instead of a
   fraction of the screen for the same reason. */
@media (min-width: 700px) {

  /* ── A wide window reads the tabs at the TOP, as a segmented control ───
     A pill pinned to the floor of a 900px window puts the switch as far from
     the reading as the window allows, and the eye crosses the whole height to
     use it — a phone convention wearing a desktop out. Above 700px the bar
     comes off the floor and sits at the head of the column.

     `order: 0` is the move that gets it there. The nav is already first in the
     DOM, so dropping the phone's `order: 1` paints it where it is written —
     and on the width where a keyboard is likeliest, focus order and reading
     order agree.

     WHAT IT IS NOT, ANY MORE: three underlined words. That shape separates by
     RULE, and this product separates by FIGURE AND GROUND — the distinction
     @/components/ui/tabs.tsx spells out where it retired the same underline
     from the Leaderboard's own strip. An underlined row is also the idiom of
     code-hosting tools and admin consoles, which is what it read as here: a
     developer surface bolted to a consumer one. The geometry below is
     SECTION_TABS_LIST / SECTION_TAB_BASE transcribed — 2px track, 32px
     segments, 16px of side padding, fully round.

     THE SELECTED STATE IS NOT RESTATED HERE, deliberately. The phone rule's
     periwinkle carries straight through, so the two widths are one control at
     two sizes rather than two ideas: the note beside it explains why that is
     the tint and not the primitive's near-black, and nothing about a wider
     window changes that argument.

     The track is `--dc-sheet-raise` and carries no frost. Frost is for the
     phone's bar, which floats over what scrolls beneath it; this one sits in
     the flow with nothing passing behind, so it wants the opaque raised
     surface the cards already use — the same reason tabs.tsx's track is white
     rather than the recessed grey a control gets on a card. */

  /* #3583: WHERE THE BAND PINS, which is the header's foot, and how far that
     is from the top of whatever scrolls the page.

     On a computer, and in the installed app and the native WebView, that is
     #dev-forum-scroll, and the header's foot overlaps its top edge by 7px
     (the header's `-mb-2`, 8px, less `.dc-lift`'s 1px hairline). The strip
     pinned at 0 rose into the header by that much, and at z-index 30 over
     the header's 10 its band painted there: a white slab biting the
     coloured header, which is half of what #3583's screenshot shows.

     Where the DOCUMENT scrolls instead (a tablet in its browser, a window
     under 768px: lib/browser-scroll.ts) the scrollport is the whole window
     and the header is sticky over it, so 0 was the top of the SCREEN: the
     strip pinned there drew straight over the header and hid it. There it
     is the header's own height, as everything else that sticks under the
     header in that mode is measured (#browse-search-bar). */
  #dev-workshop { --ws-pin-top: var(--dev-ws-head-foot, 7px); }
  html[data-browser-scroller="dev-forum-scroll"] #dev-workshop {
    --ws-pin-top: calc(var(--browser-banner-h) + var(--platform-header-h) + var(--platform-safe-top));
  }
  /* Under 768px the strip is still the coloured band, tucked 18px up under
     the header's foot, so it rests 15px above the foot (that tuck, less
     #dev-body's 10px, plus the 7px the foot overlaps the page), and it pins
     exactly there, the way a phone's does (#3522): it never moves, and its
     tabs never drop. */
  @media (max-width: 767.98px) {
    .dev-ws-tabs.dev-ws-band { top: calc(var(--ws-pin-top, 0px) - 15px); }
  }

  /* EVERYTHING PINS UP HERE, as one band: the project's tabs and, on All
     items, the head under them, with the grouping, the search and the
     filters. The controls belong WITH the list you are
     narrowing, so they stay reachable however far down it you are.

     ALL ITEMS HAS NO BACK BAR OF ITS OWN (#4486). It was a second
     `.dev-ws-tabs` box (#3651), which the strip's rule below made sticky at
     the tabs' own offset, so two bars pinned in one place; then it scrolled
     away under the tabs. The way back and the page's name are the first
     things in the head's one row now (workshop.tsx `.dev-ws-allbar`), which
     pins here with the rest of the head. The tabs are the bar the head and
     its band are measured against on every page (workshop.tsx, `setBar`).

     THE HEAD KEEPS THE BASE RULE'S `position: sticky` and only changes where
     it comes to rest: under the pinned strip, at the strip's measured height
     plus the column gap, counted from where the strip pins (`--ws-pin-top`,
     #3583). `top: 0` would slide it under the strip.

     ABOVE THE STRIP IN PAINT ORDER at rest. The grouping tabs used to hang
     from the head into the strip's band as an "ear" (gone with #852), which
     is where the 31 came from; it is kept so the head and the strip keep the
     one order the "+" menu's rule below is written against.

     Scoped by `#dev-workshop`: the base rule is `.dev-ws-pane-head` six
     hundred lines BELOW this block, and on equal specificity the later one
     wins — a bare-class override here would apply to nothing. */
  #dev-workshop .dev-ws-pane-head {
    top: calc(var(--ws-pin-top, 0px) + var(--dev-ws-head-top, 0px));
    z-index: 31;
  }
  /* In the tablet window between 700px and the wide layout, as on a phone
     (#4703): the band is its 18px tuck, the row it held gone into the
     header, so the head rests on the band's foot: where the band pins, plus
     its 18px. */
  @media (max-width: 767.98px) {
    #dev-workshop .dev-ws-pane-head { top: calc(var(--ws-pin-top, 0px) - 15px + 18px); }
  }
  .dev-ws-tabs {
    order: 0;
    /* RELATIVE, NOT STATIC. There is nothing to stick to at the head of a
       column — that part is unchanged — but the bar has to remain the
       CONTAINING BLOCK for the selection marker, and the element `offsetLeft`
       and `offsetTop` resolve against when workshop.tsx measures a tab.
       `static` would send both the marker and the measurement to whatever
       ancestor happened to be positioned. */
    /* STICKY, NOT MERELY RELATIVE. Relative kept the containing block the
       selection marker and `offsetLeft` need, which is all this rule used to
       want; pinning needs the same thing and keeps it, since a sticky element
       is positioned too. The offset itself is set BELOW `inset: auto`, which
       would otherwise reset it — see the note on it. */
    position: sticky;
    /* THE PHONE BAR'S OFFSETS ARE NOT THIS BAR'S, and leaving them in place
       is what made the strip sit crooked in both axes.

       `left: 10px; right: 10px; bottom: var(--ws-lift)` size and place a
       FIXED pill against the viewport. `position: relative` does not ignore
       them — it SHIFTS the element by them — so the strip was displaced 10px
       right of the reading column it is supposed to line up with, and lifted
       `--ws-lift` (8px) off its own place in the flow. Measured at 1280px
       wide: 2px of air above the strip and 18px below, against the 10 and 10
       the two rules below actually declare. Every fix aimed at that air had
       been aimed at the wrong pair of numbers.

       `max-width` and `margin-inline` leak the same way and cost the
       alignment below: 740px with auto side margins is a shrink-to-fit box
       centred in the column, and a row cannot be left-aligned inside a box
       that has already centred itself. The board pane re-states both at
       higher specificity (`#dev-workshop.dev-ws-has-board > .dev-ws > …`),
       which is the one place they are wanted up here. */
    inset: auto;
    /* AFTER `inset: auto`, and that ordering is the whole of it. Written
       above, `top: 0` was reset to `auto` by the shorthand three lines later:
       the strip stayed `sticky`, pinned to nothing, and scrolled away while
       the head it is supposed to travel with stayed. Measured — the strip's
       top went to -890 on a 900px scroll. It was `top: 0`, the scroller's
       own top, on the reasoning that there was no chrome inside it to clear;
       there is, the header's foot (#3583, `--ws-pin-top` above). */
    top: var(--ws-pin-top, 0px);
    /* QA 2026-09-24 Q7: ABOVE THE CARDS, BELOW THE HEAD. Pinned with no
       z-index, the strip sat at the floor of the stacking order, and the
       pane and its cards come later in the tree, so everything that scrolled up past it painted over
       the tabs and took their clicks: the three tabs and the "+" went under
       the list. 30 puts it over the list and under the pane head's 31. The
       open "+" menu still lifts the strip to 32 (above). */
    z-index: 30;
    max-width: none;
    /* LEFT-ALIGNED, not centred. The pill is `.dev-ws-tabtrack` inside this
       nav and it is content-width, so whatever positions the pill is what
       decides where the strip appears — and the nav now spans the same
       reading column the pane below it does, so `flex-start` puts the strip's
       left edge on the pane's left edge. Centring put it on the WINDOW's
       centre line, which belongs to nothing on the screen: the strip drifted
       right as the column widened and lined up with no edge at any width.
       `flex` rather than `text-align: center`, because an inline-level box
       positioned by an INHERITED property is one stray `text-align` on any
       ancestor away from moving on its own.

       IT ALSO SETTLES THE SELECTION MARKER. The marker is placed from
       `offsetLeft` against this nav (see workshop.tsx), and under
       `justify-content: center` that number is `(nav - pill) / 2` plus the
       tab's own offset — so it MOVED whenever the nav's width did, while the
       tab stayed exactly where it was on screen. Switching By category →
       By stage widens the nav from the pill's width to the full 760 and the
       marker's coordinate jumped 158px for a tab that had not moved a pixel,
       which the transition then animated. Against `flex-start` the pill sits
       at offset 0 and the number is the tab's own, whatever the nav's width.
       (workshop.tsx no longer animates a re-measure either; this is the half
       that stops the coordinate changing in the first place.) */
    display: flex;
    justify-content: flex-start;
    gap: 0; padding: 0;
    border: 0; border-radius: 0;
    background: none; backdrop-filter: none; -webkit-backdrop-filter: none;
    /* NO MARGIN OF ITS OWN. `.dev-ws` is a flex column with `gap: 10px`, so a
       margin here stacks on top of it: 14px below the strip against the 8px
       of `#dev-body` padding above it, which is the unbalanced air. The gap
       alone sets both sides, once the padding below matches it. `margin: 0`
       rather than `margin-bottom: 0` because the side halves are the phone
       rule's `margin-inline: auto`, retired with the rest of its box above. */
    margin: 0;
  }
  /* The other half of that balance: 10px above, to equal the column gap
     below. The 8px here is the phone's, where the strip is `position: fixed`
     at the foot and this padding is just the top of the page.
     Both numbers are finally what lands: with the leaked `bottom` gone the
     strip no longer floats 8px up out of the air it was given, which is the
     "too little above, too much below" this pair always claimed to prevent. */
  #dev-body:has(> #dev-workshop) { padding-top: 10px; }
  /* QA 2026-09-24 Q7: THE BAND BEHIND THE PINNED STRIP. With the strip on top
     of the list, the pill itself is opaque, but the air around it was not:
     the column gap down to the pane head and, on By stage, the board columns either side of the reading column all showed
     the cards scrolling underneath, between the controls. So once the strip
     is pinned (`data-ws-pinned`, set by workshop.tsx's usePinnedStrip) a
     band runs behind it, from the scroller's top down to where the head pins
     (`--dev-ws-head-top`) and across the PANE's width (`--dev-ws-band-*`,
     measured by useStripInsets), and the strip and the head read as one
     pinned header.

     SOLID, NOT FROSTED. The frost cannot blur anything here: this whole
     column sits inside the dev frame's `.dc-lift`, which has a
     backdrop-filter of its own, and Chromium does not blur content through
     a backdrop-filter nested inside another one. What was left was 50%
     white, which is exactly the "cards showing through" this fixes (and
     why the search row looked washed rather than frosted over the list).
     So while pinned the band takes the pane's own opaque colour, the one
     its head and body already wear (`--dc-sheet-solid`, the fallback below
     the frost rules).

     THE PANE'S COLOUR, NOT `--dc-sheet` (#3651). The band and the pinned
     head used to switch to `--dc-sheet`, which is white where the pane is
     the warm `--dc-sheet-solid`: the head changed colour the moment it
     pinned, and a white slab sat over a cream pane, its edge reading as a
     gap down both sides and along the foot. The head keeps its colour now
     and the band matches it, so pinned and at rest are one material.

     ONLY WHILE PINNED. At rest the bar sits on the page, and a band there
     would draw a slab of sheet colour around it for nothing.

     Behind the nav's own content (`z-index: -1` inside the strip's stacking
     context), so the bar draws over it. The pinned head squares its top
     corners (`data-ws-head-pinned`, below the pane's base rule). */
  .dev-ws-tabs::before {
    content: '';
    position: absolute;
    z-index: -1;
    top: 0;
    height: var(--dev-ws-head-top, calc(100% + 10px));
    left: var(--dev-ws-band-left, 0px);
    right: var(--dev-ws-band-right, 0px);
    background-color: var(--dc-sheet-solid);
    pointer-events: none;
    visibility: hidden;
  }
  .dev-ws[data-ws-pinned] > .dev-ws-tabs::before { visibility: visible; }
  /* THE TRACK HOLDS THE PILL AND THE "+", 6px apart, and draws neither
     (#2934): it hugs the two. The surface is the phone rule's two items at
     this width's metrics — a 2px ring in the raised sheet, no hairline — so
     the pill is 36px as the track was, and the "+" a 36px circle beside it. */
  .dev-ws-tabtrack {
    display: inline-flex; align-items: center; gap: 6px;
  }
  .dev-ws-tablist, .dev-ws-tabtrack > .dev-ws-plus {
    padding: 2px;
    border: 0;
    border-radius: 9999px;
    background-color: var(--dc-sheet-raise);
  }
  .dev-ws-tab {
    flex: 0 0 auto; flex-direction: row; gap: 7px;
    height: 32px; padding: 0 16px;
    border-radius: 9999px; font-size: 14px;
  }
  .dev-ws-tab-glyph { width: 16px; height: 16px; }
  /* #2915: the dot follows the label here, a little further off it than the
     tab's own gap, and centred on it. */
  .dev-ws-tab-dot { order: 0; margin: 0 0 0 6px; transform: none; }
  /* The tab list hugs its three segments, and the "+" is the track's last
     item: a 32px round button, the tabs' own height, in its own circle after
     the pill — Current status · Needs you · All items   +. */
  .dev-ws-tablist { flex: 0 0 auto; }
  .dev-ws-plus-btn { width: 32px; height: 32px; }
  .dev-ws-plus-glyph { width: 18px; height: 18px; }

  /* ── THE FEED ON A WIDE WINDOW: THE STAGE ──
     Above 700px the lander is a ROW: the reel as a 440px card at the
     centre, the rail standing beside it on the page, its buttons at the
     card's foot, and the arrows to the rail's right doing what the wheel
     does. Comments and Ask are a PANEL to the right, with the card's caption
     repeated at its top while the card keeps only its by-line, so the
     picture stays in view while you use it; the vote is a popover on its own
     button. The Workshop column widens past its 760px reading measure for
     this tab alone — the same release the Board pane has. */
  #dev-workshop.dev-ws-on-needs { max-width: none; }
  /* The tab strip keeps the reading column while the stage takes the window
     (the same re-statement the board pane makes for itself below): a
     left-aligned strip inside a 1440px nav would sit at the window's edge,
     lined up with nothing, while the card it belongs to sits at the centre. */
  #dev-workshop.dev-ws-on-needs > .dev-ws[data-ws-tab="needs"] > :not(.dev-ws-tabbody) {
    width: 100%; max-width: 760px; margin-left: auto; margin-right: auto;
  }
  /* `place-content`, not `justify-content: center`, here and on the key
     legend below: on a single-line row the two are the same centring, and the
     wide block is held to never spelling the latter (the tab strip's test)
     because a centred STRIP moved the selection marker; a centred stage
     moves nothing. */
  .dev-ws-needs {
    flex-direction: row; place-content: center; align-items: stretch;
    gap: 18px; min-height: 480px;
    /* A line of its own under the stage for the key legend, so the legend
       never lies across the card's caption or the arrows beside the rail. */
    padding-bottom: 34px;
  }
  /* The stage takes the card's width, so the strip above it is as wide as
     the card and not as wide as the rail beside it. The scroller fills the
     stage (its base rule is already `flex: 1 1 auto; min-height: 0`). */
  .dev-ws-needs-stage { flex: 0 1 440px; max-width: 440px; }
  .dev-ws-needs-scroll { border-radius: 20px; }
  .dev-ws-needs-progress { margin: 0 4px 8px; }
  .dev-ws-item-top { margin: 26px 16px 0; }
  .dev-ws-item-caption { left: 18px; right: 18px; bottom: 20px; gap: 8px; }
  /* #5039: beside the card, the caption below a diagram needs no rail lane. */
  .dev-ws-item:not([data-ws-open]):has(> .dev-ws-diagram) > .dev-ws-item-caption { margin: 12px 18px 20px; }
  .dev-ws-item-avatar { width: 32px; height: 32px; font-size: 13px; }
  .dev-ws-item-by b { font-size: 15px; }
  .dev-ws-item-title { font-size: 21px; line-height: 1.2; }
  .dev-ws-item-summary-text { font-size: 15px; }
  .dev-ws-item[data-ws-picture="none"] > .dev-ws-item-caption { top: 64px; }
  .dev-ws-item[data-ws-picture="none"] .dev-ws-item-title { font-size: 26px; line-height: 1.18; }
  .dev-ws-item[data-ws-picture="none"] .dev-ws-item-summary-text { font-size: 17px; -webkit-line-clamp: 10; }
  /* The facts: the vote's words are the pill, the topic follows it. */
  .dev-ws-item-caption > .dev-ws-item-facts { padding: 0; border-radius: 0; background: none; font-size: 12.5px; line-height: 26px; color: var(--text-muted); }
  .dev-ws-item-caption .dev-ws-item-facts-vote { display: inline-block; padding: 0 10px; border-radius: 9999px; background: var(--state-neutral-bg); color: var(--text-primary); }
  .dev-ws-item-caption .dev-ws-item-facts-vote + .dev-ws-fact { margin-left: 8px; }
  .dev-ws-item-caption .dev-ws-item-facts-vote + .dev-ws-fact::before { content: none; }
  .dev-ws-media-bar { margin: 0 16px 8px; }
  .dev-ws-item > .dev-ws-diagram { padding: 0 32px 16px; gap: 16px; }
  .dev-ws-item > .dev-ws-touches .dev-ws-diagram-what { font-size: 15px; font-weight: 650; }
  .dev-ws-item > .dev-ws-touches .dev-ws-diagram-bar { height: 10px; }
  /* With the panel up the card keeps its picture and its by-line: the rest
     of the caption is at the panel's top. */
  .dev-ws-needs:is([data-ws-sheet="comments"], [data-ws-sheet="ask"]) .dev-ws-item-caption > :not(.dev-ws-item-by) { display: none; }
  .dev-ws-needs:is([data-ws-sheet="comments"], [data-ws-sheet="ask"]) .dev-ws-item-scrim { height: 180px; }
  .dev-ws-needs:is([data-ws-sheet="comments"], [data-ws-sheet="ask"]) .dev-ws-item[data-ws-picture="none"] > .dev-ws-item-caption { top: auto; }
  .dev-ws-needs:is([data-ws-sheet="comments"], [data-ws-sheet="ask"]) .dev-ws-needs-stage { flex-basis: 400px; }
  .dev-ws-rail {
    position: relative; right: auto; bottom: auto; z-index: auto;
    flex: 0 0 64px; justify-content: flex-end; padding-bottom: 20px; gap: 16px;
  }
  .dev-ws-rail-btn { width: 64px; }
  .dev-ws-rail-ic { width: 48px; height: 48px; }
  .dev-ws-rail-lab { font-size: 12.5px; color: var(--text-secondary); text-shadow: none; }
  .dev-ws-rail-vote .dev-ws-rail-ic { width: 52px; height: 52px; box-shadow: 0 4px 12px rgba(10, 110, 224, .3); }
  .dev-ws-rail-vote .dev-ws-rail-lab { color: var(--accent); }
  .dev-ws-rail-vote.is-on .dev-ws-rail-lab { color: var(--state-ok); }
  .dev-ws-rail-vote.is-on[data-ws-voted="no"] .dev-ws-rail-lab { color: var(--state-blocked); }
  /* The arrows: a column of their own to the rail's right, at the stage's
     middle, out of the row's centring. Gone while the panel is up. */
  .dev-ws-move {
    position: absolute; left: calc(100% + 12px); top: 50%; transform: translateY(-50%);
    display: flex; flex-direction: column; align-items: center; gap: 10px;
  }
  .dev-ws-needs:is([data-ws-sheet="comments"], [data-ws-sheet="ask"]) .dev-ws-move { display: none; }
  .dev-ws-move-btn {
    width: 42px; height: 42px; border-radius: 9999px; cursor: pointer;
    border: 0; background: var(--state-neutral-bg); color: var(--text-primary);
    display: grid; place-items: center;
  }
  .dev-ws-move-btn:disabled { opacity: .35; cursor: default; }
  .dev-ws-move-icon { width: 18px; height: 18px; }
  .dev-ws-keys {
    display: flex; place-content: center; align-items: center; gap: 8px 12px;
    position: absolute; left: 0; right: 0; bottom: 0; height: 34px; margin: 0;
    white-space: nowrap;
    font-size: 13px; color: var(--text-muted); font-family: ui-monospace, Menlo, monospace;
  }
  .dev-ws-keys kbd {
    display: inline-grid; place-items: center; min-width: 20px; height: 22px; box-sizing: border-box; padding: 0 4px; margin: 0 2px; border-radius: 5px;
    border: 1px solid var(--border); background: var(--dc-sheet); color: var(--text-primary);
    font: inherit; font-size: 11px; font-weight: 600;
  }
  /* THE PANEL: Comments and Ask beside the rail, the stage's full height, in
     the plane colour with one hairline. It slides in from the right rather
     than rising from a floor it does not sit on (the names feed the base
     block's one animation rule). Its head is the card's caption again —
     who, the title, the description in full and the facts — and scrolls on
     its own if it runs long; under it the tabs, then the tab's body. */
  .dev-ws-sheet-talk {
    position: relative; inset: auto; flex: 0 1 380px; min-width: 300px; z-index: auto;
    display: flex; flex-direction: column;
    --ws-sheet-in: dev-ws-panel-in; --ws-sheet-out: dev-ws-panel-out;
  }
  .dev-ws-sheet-talk > .dev-ws-scrim { display: none; }
  .dev-ws-sheet-talk > .dev-ws-sheet-card {
    flex: 1 1 auto; height: auto; max-height: none; min-height: 0;
    padding: 0 0 12px;
    border-radius: 20px;
    background-color: var(--dc-sheet-solid);
    box-shadow: inset 0 0 0 1px var(--app-sheet-line), 0 8px 24px rgba(0, 0, 0, .06);
  }
  .dev-ws-talk-head {
    display: flex; flex-direction: column; gap: 8px; flex: 0 1 auto; max-height: 45%; overflow-y: auto;
    padding: 16px 52px 14px 18px; border-bottom: 1px solid var(--border-light);
  }
  .dev-ws-talk-head .dev-ws-item-avatar { width: 28px; height: 28px; font-size: 12px; }
  .dev-ws-talk-head .dev-ws-item-by b { font-size: 14px; font-weight: 650; }
  .dev-ws-talk-title { margin: 0; font-size: 17px; font-weight: 750; line-height: 1.25; color: var(--text-primary); }
  .dev-ws-talk-words { margin: 0; font-size: 14px; line-height: 1.45; color: var(--text-secondary); white-space: pre-line; overflow-wrap: anywhere; }
  .dev-ws-talk-words > * { margin: 0 0 8px; }
  .dev-ws-talk-words > :last-child { margin-bottom: 0; }
  .dev-ws-talk-head .dev-ws-item-facts { background: var(--state-neutral-bg); color: var(--text-secondary); }
  .dev-ws-talk-tabs { position: static; justify-content: flex-start; padding: 0 18px; }
  .dev-ws-talk-tablist { gap: 22px; }
  .dev-ws-talk-tab { height: 42px; font-size: 14.5px; }
  .dev-ws-talk-tabs > .dev-ws-sheet-x { right: 12px; top: 12px; }
  .dev-ws-talk > .dev-ws-sheet-body { padding: 4px 18px 0; }
  .dev-ws-talk > .dev-ws-sheet-body > .dev-feed-thread > form { background-color: var(--dc-sheet-solid); }
  .dev-ws-talk > .dev-ws-ask { padding: 0 18px; }
  .dev-ws-sheet-handle { display: none; }
  /* The vote: a popover on its button, arrow towards it. The button's top in
     the rail is measured (`--ws-vote-top`, workshop.tsx), because the rail's
     buttons sit at its foot. */
  .dev-ws-sheet-vote { position: absolute; inset: auto calc(100% + 12px) auto auto; top: calc(var(--ws-vote-top, 0px) - 8px); width: 340px; z-index: 6; display: block; }
  .dev-ws-sheet-vote { --ws-sheet-in: dev-ws-pop-in; --ws-sheet-out: dev-ws-pop-out; }
  .dev-ws-sheet-vote > .dev-ws-scrim { position: fixed; background: transparent; }
  .dev-ws-sheet-vote > .dev-ws-sheet-card {
    max-height: none; overflow: visible; padding: 18px 18px 16px;
    border-radius: 18px; border: 1px solid var(--app-sheet-line);
    box-shadow: 0 18px 50px rgba(24, 39, 75, .22);
  }
  .dev-ws-sheet-vote > .dev-ws-sheet-card::after {
    content: ""; position: absolute; right: -8px; top: 24px; width: 16px; height: 16px;
    background: var(--dc-sheet); border-right: 1px solid var(--app-sheet-line); border-top: 1px solid var(--app-sheet-line);
    transform: rotate(45deg);
  }
  .dev-ws-ask-q { font-size: 18px; }
  .dev-ws-vote-sub { font-size: 13px; }
  .dev-ws-answer-btn { height: 46px; font-size: 15px; border-radius: 12px; }
  .dev-ws-keys-hint { display: block; align-self: center; margin: 4px 0 0; font-size: 11px; color: var(--text-faint); font-family: ui-monospace, Menlo, monospace; }
  .dev-ws-ask-msg { font-size: 14px; }
  .dev-ws-ask-log { max-height: none; }
}

/* Between the breakpoint and a wide desktop the panel would push the card
   off the stage, so it OVERLAYS the stage's right side instead. */
@media (min-width: 700px) and (max-width: 1179.98px) {
  .dev-ws-sheet-talk {
    position: absolute; right: 0; top: 0; bottom: 34px; width: min(380px, 60%); z-index: 8;
  }
}
.dev-ws-reveal[aria-expanded="true"] .dev-ws-reveal-chev { transform: rotate(180deg); }
/* A note under a list: the since list's "nothing has changed" and its
   floor, and the hub's quiet lines. Held off what it follows by its own
   margin. */
.dev-ws-week-note {
  margin: 11px 0 0; font-size: 12px; color: var(--text-muted); text-align: center;
}
/* ── One offer at a time, paged sideways ───────────────────────
   The track is the scroller and each item is exactly its width, so a swipe
   lands on a card rather than between two. `scroll-snap-type: x mandatory`
   and not `proximity`: a half-scrolled pair is the state this layout exists
   to prevent.

   `align-items: flex-start` matters more than it looks: a row UNFOLDS into
   the full card in place, so one item in the track can be several times the
   height of its neighbours, and stretched items would make every card as
   tall as the tallest. */
.dev-ws-pager { display: flex; flex-direction: column; gap: 6px; }
.dev-ws-pager-track {
  display: flex;
  align-items: flex-start;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  /* BOTH AXES. `.home-discover-rail` carries the post-mortem (#1762): a rail
     that declares `touch-action: pan-x` DENIES vertical panning for every
     touch that starts on it, so on a phone it becomes a band you cannot
     scroll the page from. Failing to page the deck costs one retry; failing
     to scroll the lander costs the screen. */
  touch-action: pan-x pan-y;
  -webkit-overflow-scrolling: touch;
  /* `mandatory`, where the discover rail uses `proximity` — a deliberate
     deviation. That rail is a strip of small tiles where resting between two
     is a fine place to be; this one shows exactly one card at a time, and a
     deck resting half-way between two cards is the state it exists to
     prevent. */
  scroll-snap-type: x mandatory;
  /* The cards carry a real shadow; a track clipped to the content box would
     shave it off both sides on every page. */
  padding: 3px;
  margin: -3px;
  scrollbar-width: none;
}
.dev-ws-pager-track::-webkit-scrollbar { display: none; }
.dev-ws-pager-item {
  flex: 0 0 100%;
  min-width: 0;
  scroll-snap-align: start;
  /* The gutter between cards has to come from INSIDE the item: a `gap` on
     the track would offset every snap point by one gap and the last card
     would never reach its own. */
  padding-right: 8px;
  box-sizing: border-box;
}
/* ON THE HEADING ROW, at its right end. Under the deck it was a row of
   three small things floating between a card and the next heading, reading
   as a caption for neither; beside the lane's own title it is unambiguously
   that lane's control, and the deck sits directly under the heading it
   belongs to. Smaller than it was, because the heading row is 12px type and
   a 28px control beside it dominated the words. */
.dev-ws-lane-head {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
}
/* The title keeps its own bottom margin; the row must not inherit it twice. */
.dev-ws-lane-head > .dev-ws-lane-title { margin-bottom: 0; }
.dev-ws-pager-ctl {
  display: flex; align-items: center; gap: 4px; flex: none;
  margin-bottom: 4px;
}
.dev-ws-pager-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; border-radius: 999px;
  border: 1px solid var(--app-sheet-line);
  background-color: var(--dc-sheet);
  color: var(--text-primary);
  line-height: 1;
  cursor: pointer;
}
.dev-ws-pager-btn:disabled { opacity: 0.35; cursor: default; }
.dev-ws-pager-chev { width: 13px; height: 13px; }
.dev-ws-pager-n {
  font-size: 11px; color: var(--text-muted); font-variant-numeric: tabular-nums;
  /* The heading above it is uppercase tracked-out small type; the counter is
     a number and stays a number. */
  text-transform: none; letter-spacing: 0; white-space: nowrap;
  padding: 0 2px;
}
/* The since lane needs no rule of its own: it is the same heading, note and
   `.dev-ws-lane-btn` the two lanes above it use.
   It briefly had one — `.dev-ws-since-btn`, a full-width bordered row that
   carried the whole sentence as its label — written because that sentence
   cannot live inside `.gc-vote-btn`, a 24px fixed-height pill that made it
   set a min-content width wider than a phone. Splitting the sentence out
   into the lane's note removes the problem rather than styling around it:
   the label is "Show 8" now, which is exactly what that pill is for. */
/* ── The Workshop's orientation pane ─────────────────────────
   "Since your last visit" was a line inside "Where the app is" (`.dev-ws-
   since-line`, retired): the description led and the personal line sat under
   it as a footnote. It is a BUTTON in "What needs you" now — see
   `.dev-ws-since-btn` above — because a list of things that moved for this
   reader belongs with the other things addressed to them, not under the
   app's own numbers. */
/* A control at the end of a lane — "N more waiting on you". `.dev-ws-link`
   set `font: inherit` with no size of its own, so it took whatever its parent
   had: 16px in the two strip heads, larger than every other control on the
   Dev screen and in a weight nothing else there wears. `.gc-vote-btn` is the
   platform's small action pill and is already what this screen uses. */
.dev-ws-lane-btn { margin: 2px 0 0 4px; }
/* The offer under a lane's heading, where the heading states the fact. */
.dev-ws-lane-note { margin: -2px 0 6px 16px; font-size: 12px; color: var(--text-muted); }
.dev-ws-pill {
  display: inline-flex; align-items: center; height: 22px; padding: 0 9px; border-radius: 999px;
  font-size: 12px; font-weight: 600; white-space: nowrap;
}
.dev-ws-pill-warn { background: var(--state-attention-bg); color: var(--state-attention); }
.dev-ws-pill-good { background: var(--state-ok-bg); color: var(--state-ok); }
.dev-ws-link {
  background: none; border: 0; padding: 0; font: inherit; font-size: inherit;
  color: var(--accent); cursor: pointer; text-decoration: none;
}
.dev-ws-link:hover { text-decoration: underline; }

/* The dashboard's four numbers.
   They were a sentence, and four integers read out as prose is the slowest
   form they can take: the reader parses a clause to learn something a glance
   should give. So they are tiles, on their own ground inside the pane, and
   the paragraph under them is freed to say the things a number cannot. */
.dev-ws-dash {
  /* ONE RULED ROW, NOT FOUR CARDS. Each figure used to carry the pane's own
     surface, a hairline and the two-layer lift the week cards had — four
     objects inside an object, above a fifth holding the summary line. The
     figures are the pane's content, so they sit on the pane: hairlines
     between them and nothing else.
     FOUR ACROSS AT EVERY WIDTH (5 Oct 2026). Under 420px they were two up,
     a 2x2 grid, and the owner asked for one row on an iPhone 13 mini. A
     phone keeps the row by drawing the figures closer and a size smaller
     (below), never by cutting a label short: a label wraps instead. At
     375px the pane's content box is 339px (375, less #dev-body's 4px a
     side, less the strip's 14px a side), so each column is 84.75px, and
     the narrowest text column, the third figure's beside its dot, is
     84.75 - 1 - 8 - 8 - 6 - 5 = 56.75px. In the system font at 11.5px
     its longest word, "waiting", is about 39px, and every label takes two
     lines at most; at the wider 14px spacing "waiting on votes" would
     take three. */
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  margin-top: 4px;
}
.dev-ws-dash-cell {
  min-width: 0;
  padding: 2px 8px;
  border-left: 1px solid var(--app-sheet-line);
  font-size: 11.5px;
  color: var(--text-muted);
  line-height: 1.3;
}
@media (min-width: 420px) {
  .dev-ws-dash-cell { padding-left: 14px; padding-right: 14px; }
}
.dev-ws-dash-cell:first-child { border-left: 0; padding-left: 2px; }
.dev-ws-dash-cell:last-child { padding-right: 2px; }
.dev-ws-dash-cell b {
  display: block;
  /* THE TEXT TOKEN, always. Tone used to be painted on the integer itself —
     a green 6, an amber 3 — which is state carried by hue alone and unread
     by anyone who cannot separate the two, and it put a status colour on
     text where the rest of the product keeps text in text ink. The mark
     moved to the label; the number is a number.
     20px on a phone, where four share the row; 24px from 420px. */
  font-size: 20px; font-weight: 650; line-height: 1.1;
  letter-spacing: -.02em; color: var(--text-primary);
}
@media (min-width: 420px) {
  .dev-ws-dash-cell b { font-size: 24px; }
}
/* A GRID, not an inline run. The label wraps at phone widths, and under
   `align-items: center` the dot floated to the middle of a two-line label
   while its second line ran back underneath it. Two columns keep the mark
   on the first line and the wrapped text in its own column. */
.dev-ws-dash-label {
  display: grid;
  grid-template-columns: auto 1fr;
  align-items: start;
  gap: 0 5px;
  margin-top: 2px;
}
.dev-ws-dash-label:not(:has(> .dev-ws-dash-dot)) { grid-template-columns: 1fr; }
/* Only the two figures that are a CALL wear one. A zero is not a warning
   (the component leaves the class off), and "nobody on them" is a fact
   about the backlog rather than an alarm — it had no tone before and gains
   none here. */
.dev-ws-dash-dot { width: 6px; height: 6px; border-radius: 999px; margin-top: 5px; }
.dev-ws-dash-dot-good { background: var(--state-ok); }
.dev-ws-dash-dot-warn { background: var(--state-attention); }
/* AND THE FIGURE TAKES THE TONE BACK. The objection that moved it to the
   label was to state carried by hue ALONE — unreadable to anyone who cannot
   separate the two, and a status colour sitting on text. The dot answers
   that: the mark is the non-colour channel, so the colour on the number is
   now redundant rather than load-bearing, which is what makes it free. The
   tone still appears only where the component put a dot, so a zero — not a
   call — stays in text ink. */
.dev-ws-dash-cell-good b { color: var(--state-ok); }
.dev-ws-dash-cell-warn b { color: var(--state-attention); }
/* #4745: AND WHEN SOMETHING WAITS, THE VOTES FIGURE IS A BUTTON. The reset
   takes the browser's button chrome off without taking the cell's own look
   with it: the left border is the hairline between figures and stays
   (:first-child still removes it), and font-size, line-height and colour all
   keep coming from .dev-ws-dash-cell above — which is why the font reset is
   the family and weight longhands, not the `font:` shorthand, whose
   font-size would override the cell's. */
button.dev-ws-dash-cell {
  appearance: none;
  background: none;
  border-top: 0;
  border-right: 0;
  border-bottom: 0;
  font-family: inherit;
  font-weight: inherit;
  text-align: left;
  cursor: pointer;
  /* Square, as the spans beside it are (polish board #24): a radius bent
     this cell's hairline into a faint rounded outline where the other
     figures have straight dividers. */
  border-radius: 0;
  box-shadow: none;
}
button.dev-ws-dash-cell:hover .dev-ws-dash-label { color: var(--accent); }
button.dev-ws-dash-cell:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* #5369, revised in the change's discussion: the three stage figures are
   NOT buttons. They stay the spans they always were, as unstyled clickable
   wrappers — the pointer cursor and a keyboard focus ring are the whole
   affordance, and nothing repaints on hover, so nothing on the strip looks
   like a button or a link. Votes keeps the button #4745 made it, above. */
span.dev-ws-dash-cell[data-ws-dash-open] { cursor: pointer; }
span.dev-ws-dash-cell[data-ws-dash-open]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The grouping tabs. A SEGMENTED strip, not two loose chips: the two panes
   are alternatives over one board, and a shared rail says that where a gap
   between pills would read as two unrelated toggles. Deliberately NOT
   .dev-ws-chip — those are the sort order WITHIN the category pane and sit
   one row below, so the two controls have to look like different kinds of
   thing or the row reads as five options over one list.

   IT IS THE SAME CONTROL AS THE LANDER'S OWN TAB STRIP, and now says so.
   The geometry was already `.dev-ws-tabtrack`'s — a fully round rail, 2px
   between the halves — but the surface and the selected state were a second
   answer to a question the bar above had already answered: a recessed
   `--dc-strip` rail with a hairline, and a selected half painted in the
   plain sheet with a drop shadow, against the bar's raised track and its
   periwinkle. Two tab strips two rows apart on one screen, in two
   vocabularies. So the track is `--dc-sheet-raise` with no ring, and the
   selected half is `--lit-tint` / `--lit-ink` / `--lit-line` — the
   bar's own three tokens, for the reason its note gives: this is where you
   are, drawn the way both tab bars draw where you are.

   TWO THINGS STAY DIFFERENT, both on purpose. The WIDTH, which is the real
   distinction from the sort chips a row below. And the selected state is
   painted on the HALF rather than on a marker that slides between them
   (#2063): the slide is worth its measuring machinery across three tabs of
   unequal width, and a two-state switch has neither that machinery nor a
   use for it. The colours are the same either way, which is what a reader
   sees. */
.dev-ws-group {
  display: flex; align-items: stretch; gap: 2px;
  padding: 2px; border-radius: 999px; background: var(--dc-sheet-raise);
}
/* Full width of the reading column, the two panes splitting it evenly — the
   same edge-to-edge the General-discussion card above it takes. Shrunk to
   its labels the strip read as a pair of loose chips floating at the left
   margin; at full width it reads as one control with two states, which is
   what it is. `flex: 1 1 0` (not `1 1 auto`) so the halves are equal rather
   than proportional to their label lengths. */
.dev-ws-group-tab {
  flex: 1 1 0; height: 32px; padding: 0 14px; border: 0; border-radius: 999px;
  background: transparent; color: var(--text-muted);
  font: inherit; font-size: 13px; font-weight: 600; cursor: pointer;
  white-space: nowrap;
  display: inline-flex; align-items: center; justify-content: center;
  position: relative;
}
/* #4916: each tab is a glyph now (a tag, the board's columns), its words in
   the tooltip and an sr-only copy. 16px at the shell's 2px stroke, the size
   the filter chips beside it draw theirs. */
.dev-ws-group-icon { width: 16px; height: 16px; flex: none; }
/* The tokens carry their own dark values, so there is no dark variant —
   the same note `.dev-ws-tab[aria-selected="true"]` carries. */
.dev-ws-group-tab[aria-selected="true"] {
  background: var(--lit-tint); color: var(--lit-ink);
  box-shadow: inset 0 0 0 1px var(--lit-line);
}

/* ── The working pane ──────────────────────────────────────────────────
   The controls, the switch and the thing they act on, as one card — the same
   sheet the "Where the app is" and "What needs you" strips above it wear, so
   the page reads as a stack of panes rather than a pane, two loose control
   rows and a list.

   `overflow: visible` is load-bearing: the vote sheet is absolutely
   positioned inside this subtree and the sticky head below depends on no
   ancestor clipping. A `overflow: hidden` here to clip the radius would do
   both kinds of damage at once. (The "+" menu used to be the other thing
   escaping; it hangs off the tab strip now.) */
.dev-ws-pane {
  display: flex; flex-direction: column;
  border-radius: 22px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
  overflow: visible;
}
/* ── One face, painted by the two PARTS and not by the pane ───────────
   The pane used to carry the fill and the sticky head carried a second one
   on top of it. 50% over 50% is ~75%: a lighter band across the controls,
   measurably (251,251,252 against the body's 250,250,252) and visibly. And
   because a backdrop-filter makes an element a backdrop root, the head's
   frost was a SECOND frost applied over the pane's — so the two could not be
   made equal by tuning the fill either.

   So the pane paints nothing. Head and body carry the SAME fill over the SAME
   backdrop — the page — which makes them the same colour by construction
   rather than by a value chosen to look close. They abut (the pane is a flex
   column with no gap), so the join does not show.

   The head's frost still does its job: the body is its SIBLING, so the rows
   scrolling inside the body pass through the head's backdrop and smear.

   `overflow: visible` on the pane is load-bearing: the vote sheet is
   absolutely positioned in this subtree, and sticky does not work under a
   clipping ancestor. */
.dev-ws-pane-head, .dev-ws-pane-body {
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* The head pins to the top of #dev-forum-scroll while the pane is in view and
   releases with it. `top: 0` and not an offset: the scroller's own top IS the
   top of the reading area — the platform header is outside it. */
.dev-ws-pane-head {
  position: sticky; top: 0; z-index: 20;
  display: flex; flex-direction: column; gap: 8px;
  padding: 8px 10px;
  border-radius: 22px 22px 0 0;
}
/* PINNED, THE HEAD IS SQUARE ON TOP, at every width (#3651). Its rounded
   corners are the pane's at rest; pinned, it meets the flat foot of the tabs
   above it, and each curve left a notch between the two that the cards
   scrolling by showed through. Only the top-left was squared, and only from
   700px; the top-right kept its notch, and a phone kept both.
   `data-ws-head-pinned` is workshop.tsx's usePinnedStrip: set once the head
   has left the top of its pane, which is the moment it sticks, so the pane
   keeps its rounded top all the while it is still scrolling up. */
.dev-ws[data-ws-head-pinned] .dev-ws-pane-head { border-top-left-radius: 0; border-top-right-radius: 0; }
.dev-ws-pane-body {
  display: flex; flex-direction: column; gap: 8px;
  padding: 8px 10px 12px;
  border-radius: 0 0 22px 22px;
}
/* Where there is no backdrop-filter the fill is all there is, and 50% white
   with rows sliding CRISPLY under the pinned head is the rendering fault the
   frost prevents — not a slightly flatter bar. Both halves go opaque together,
   because they must stay the same colour as each other above all. */
/* SOLID, BOTH TOGETHER, on every platform. The body scrolls under the
   sticky head, so with no blur the head has to be opaque; and the head and
   the body read as one pane, so staying the same colour as each other
   matters more than the body's translucency. */
.dev-ws-pane-head, .dev-ws-pane-body { background-color: var(--dc-sheet-solid); }
/* Inside the pane the toolbar is a plain row: the frame's own `px-3 pt-2`
   placed it against the scroller's edge, and here the pane supplies both. */
.dev-ws-pane .dev-ws-pane-head #dev-actions { padding: 0; }

/* ── The stage pane runs edge to edge ──────────────────────────────────
   `#dev-workshop` is a 760px reading column (below), which is right for a
   list of one-line rows and wrong for four side-by-side columns: bounded to
   760px the board is a horizontal scroller before it is a board, and the
   whole point of reading by stage is seeing the stages AT ONCE.

   So when the stage pane is up the column bound comes OFF the workshop and
   goes back on to every child except the board. The alternative — a
   `100vw` full-bleed with negative margins — was rejected on purpose:
   `100vw` counts the scrollbar that `#dev-forum-scroll` always has, so it
   overflows by its width and adds a horizontal scrollbar to the page.
   Re-parenting the bound has no such edge. */
#dev-workshop.dev-ws-has-board { max-width: none; }
/* THE TAB BODY IS A LEVEL THIS RULE HAS TO REACH THROUGH. It used to bound
   every direct child of `.dev-ws` except the pane; the tabs put the pane one
   level deeper, inside `.dev-ws-tabbody`, so the bound landed on the WRAPPER
   and the board was cramped to the reading column from outside. Bound the tab
   body's children instead, and leave the body itself unbounded — the rail is
   still a direct child of `.dev-ws`, so it keeps the column width, which is
   what it wants: a pill spanning 1440px is not a tab bar. */
#dev-workshop.dev-ws-has-board > .dev-ws > :not(.dev-ws-tabbody),
#dev-workshop.dev-ws-has-board > .dev-ws > .dev-ws-tabbody > :not(.dev-ws-pane) {
  width: 100%; max-width: 760px; margin-left: auto; margin-right: auto;
}
/* On By stage the pane WIDENS AND STAYS A PANE. An earlier cut stripped its
   sheet, radius and padding here, on the argument that a card face is a frame
   drawn around the whole window. What that actually produced was the pane
   vanishing and the head's own fill left behind as a bare rectangle sitting
   over the controls alone — the board below it belonging to no pane at all.
   The pane is the unit; widening it must not dissolve it. So nothing is
   stripped: the same sheet, the same 22px, the same gutter, around a wider
   box, with the board filling it.

   The BOARD is what wanted the window, not the controls above it: a search
   field and a two-tab strip stretched across 1200px are a worse control than
   the same pair at reading width — the strip in particular reads as pulled
   apart rather than as a segmented pair. So the head still spans the pane, as
   a pinned edge must, while what sits IN it keeps the reading column. */
#dev-workshop.dev-ws-has-board .dev-ws-pane-head > :not(.dev-ws-allbar, .dev-kanban-stages) {
  /* 760px is the reading column; the 20px is the head's own left+right
     padding. Bounding to the column's OUTER width instead made the controls
     20px wider on By stage than on By category — a toolbar that grew slightly
     when you switched panes, which is the kind of drift nobody can name but
     everybody notices. Measured: 740 against 760.

     #4486: NOT the one-row head or the pipeline. The row is the page's own
     bar and runs the width of the board it heads, and the steps sit over
     their columns, so both take the board's width. */
  width: 100%; max-width: calc(760px - 20px);
  margin-left: auto; margin-right: auto;
}
/* The board brings its own horizontal scroller (#dev-kanban), so this
   wrapper only has to stop the columns sitting flush against what is
   around them. The 8px sides are not arbitrary: `#dev-body` drops to 4px
   of side padding when it holds the Workshop (below), which is right for a
   760px column centred in far more room than that and reads as a clipped
   edge once the board spans the window — 4 + 8 restores the 12px the
   standalone board gets from `#dev-body`'s own `px-3`, so the two surfaces
   sit on the same gutter. */
.dev-ws-board { padding: 2px 8px 0; }

/* The pane's title, above the tabs. Indented to the same 4px the sort row
   and the tab strip sit at, so the head reads as one column. */
/* `space-between` with the eyebrow gone leaves the chips alone at the START
   of the row, which is where they belong: on a healthy board there is no
   grouping state to report and the row is just the sort control. */
.dev-ws-sort { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 2px 4px 0; }
.dev-ws-sort-opts { display: flex; gap: 4px; flex: none; }
/* The eyebrow's grouping note comes and goes ("· drafting categories…"),
   and a longer eyebrow used to push the chips onto a second line and the
   whole list down with them. It gives way instead: on one line it shortens
   with an ellipsis, and where the row cannot hold both (a phone) it always
   has its own line, so the chips never move. */
.dev-ws-sort > .dev-ws-eyebrow { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 639px) {
  .dev-ws-sort { flex-wrap: wrap; row-gap: 6px; }
  .dev-ws-sort > .dev-ws-eyebrow { flex-basis: 100%; }
}
.dev-ws-chip {
  height: 26px; padding: 0 10px; border-radius: 999px; border: 1px solid var(--app-sheet-line);
  background: var(--dc-sheet); color: var(--text-muted); font: inherit; font-size: 12px; cursor: pointer;
}
.dev-ws-chip[aria-pressed="true"] { background: var(--text-primary); color: var(--dc-sheet); border-color: var(--text-primary); }

.dev-ws-themes { display: flex; flex-direction: column; gap: 8px; }
.dev-ws-theme {
  /* Raised, not frosted: see `--dc-sheet-raise`. The frost was doing nothing
     here anyway — it blurs what is BEHIND, and what is behind is a pane of
     the same colour, so it cost a compositing layer per card to blur a flat
     field. */
  background-color: var(--dc-sheet-raise);
  border-radius: 22px;
  box-shadow: 0 0 0 1px var(--app-sheet-line) inset;
  overflow: hidden;
}
.dev-ws-theme-head {
  cursor: pointer; padding: 14px 14px 12px;
  display: grid; grid-template-columns: 1fr auto; gap: 4px 12px; align-items: start;
}
.dev-ws-theme-head:hover { background: var(--state-neutral-bg); }
.dev-ws-theme-head:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
.dev-ws-theme-name { font-size: 17px; font-weight: 600; letter-spacing: -.01em; color: var(--text-primary); text-wrap: balance; }
/* The theme's glyph, inline with its name so a long name still wraps under
   itself rather than around a floated square. Sized to the cap height of the
   name beside it. */
.dev-ws-theme-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; margin-right: 7px; border-radius: 7px;
  font-size: 15px; line-height: 1; vertical-align: -5px;
  background: var(--state-neutral-bg);
}
/* The no-icon fallback: the initial on the name's own swatch — the same
   colour `Faces` gives that name — so it reads as chosen, not as missing. */
.dev-ws-theme-icon-letter { color: #fff; font-size: 12px; font-weight: 700; }
/* Two stats side by side — people, then how many items are still open in
   here — divided by a hairline rather than a glyph, so the pair reads as one
   block and the numbers stay the loudest thing in it. */
.dev-ws-theme-people { text-align: right; color: var(--text-muted); font-size: 12px; line-height: 1.2; display: flex; gap: 10px; }
.dev-ws-stat + .dev-ws-stat { padding-left: 10px; border-left: 1px solid var(--app-sheet-line); }
.dev-ws-theme-people b { display: block; font-size: 20px; font-weight: 600; color: var(--text-primary); letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
.dev-ws-theme-say { grid-column: 1 / -1; margin: 2px 0 0; font-size: 14px; color: var(--text-primary); }
/* A shut theme's summary is re-drafted as the board changes, and its length
   moved every theme below it. Shut, it holds to three lines; open, it reads
   in full. */
.dev-ws-theme:not(.dev-ws-theme-open) .dev-ws-theme-say {
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
}
.dev-ws-theme-counts { grid-column: 1 / -1; display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
.dev-ws-cnt {
  display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 8px; border-radius: 8px;
  background: var(--state-neutral-bg); font-size: 12px; color: var(--text-muted);
}
.dev-ws-cnt b { color: var(--text-primary); font-weight: 600; font-variant-numeric: tabular-nums; }
/* The chip's label in dark (QA 2026-09-24 Q20). --state-neutral-bg over a
   category card lands at #3e3f43, and the muted ink that reads fine on the
   card itself is 3.66:1 on the chip. One step brighter clears 4.76, and the
   bold count beside it is still the loudest thing in the chip. Light passes
   as it is (4.72), so this is dark-only. */
.dark .dev-ws-cnt { color: #aeaeb2; }
.dev-ws-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--state-neutral); flex-shrink: 0; }
.dev-ws-cnt-underway .dev-ws-dot, .dev-ws-lane-underway .dev-ws-dot { background: var(--accent); }
.dev-ws-cnt-review .dev-ws-dot, .dev-ws-lane-review .dev-ws-dot { background: var(--state-attention); }
.dev-ws-cnt-shipped .dev-ws-dot, .dev-ws-lane-shipped .dev-ws-dot { background: var(--state-ok); }
.dev-ws-cnt-fresh { background: var(--accent-tint); color: var(--accent); }
.dev-ws-cnt-fresh b { color: var(--accent); }
.dev-ws-theme-foot { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; margin-top: 10px; font-size: 13px; color: var(--text-muted); position: relative; }
.dev-ws-chev { width: 16px; height: 16px; color: var(--text-muted); flex-shrink: 0; transition: transform .18s ease; }
.dev-ws-theme-open > .dev-ws-theme-head .dev-ws-chev, .dev-ws-row-open > .dev-ws-chev { transform: rotate(90deg); }
@media (prefers-reduced-motion: reduce) { .dev-ws-chev { transition: none; } }

.dev-ws-faces-wrap { position: relative; display: inline-flex; }
.dev-ws-faces { display: flex; border: 0; background: transparent; padding: 2px; margin: -2px; border-radius: 999px; cursor: pointer; }
.dev-ws-faces:hover, .dev-ws-faces[aria-expanded="true"] { background: var(--state-neutral-bg); }
.dev-ws-faces:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.dev-ws-face {
  width: 22px; height: 22px; border-radius: 50%; border: 2px solid var(--dc-sheet);
  display: grid; place-items: center; font-size: 10px; font-weight: 700; color: #fff; margin-left: -6px;
}
.dev-ws-face:first-child { margin-left: 0; }
.dev-ws-face-more { background: var(--bg-tertiary); color: var(--text-muted); font-size: 9px; }
.dev-ws-roster {
  position: absolute; left: 0; top: 30px; z-index: 6; min-width: 160px;
  background: var(--dc-sheet); border: 1px solid var(--app-sheet-line); border-radius: 12px;
  box-shadow: 0 6px 20px rgba(0,0,0,.12); padding: 6px 10px; display: flex; flex-direction: column; gap: 2px;
}
.dev-ws-roster-row { font-size: 13px; color: var(--text-primary); white-space: nowrap; }

.dev-ws-theme-body { border-top: 1px solid var(--app-sheet-line); padding: 10px 10px 12px; display: flex; flex-direction: column; gap: 10px; }
.dev-ws-lane-title {
  margin: 0 0 4px; padding: 0 4px; font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
  color: var(--text-muted); display: flex; gap: 6px; align-items: center;
}
/* "Shipped this week" folds — it is the one lane nothing is waiting on. Its
   count rides in the heading so the fold never hides how much is in there. */
.dev-ws-lane-title[role="button"] { cursor: pointer; }
.dev-ws-lane-title[role="button"]:hover { color: var(--text-primary); }
.dev-ws-lane-title[role="button"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.dev-ws-lane-n { font-variant-numeric: tabular-nums; color: var(--text-primary); }
.dev-ws-lane-title[aria-expanded="true"] > .dev-ws-chev { transform: rotate(90deg); }
.dev-ws-lane-title > .dev-ws-chev { width: 14px; height: 14px; }
.dev-ws-rowwrap { margin-bottom: 4px; }
/* ── The row IS the card, folded ─────────────────────────────
   It used to be a grey strip on the sheet, which made an unfolded row read
   as two objects: the strip, and a whole card painted under it repeating the
   same title, number and author. Same sheet, border and radius as the entry
   below it now, so opening one GROWS the thing you clicked (#1787). */
.dev-ws-row {
  /* A COLUMN, as the card is: the head (glyph + title) on one line and the
     bar row under it spanning the whole card. It was a single row with the
     glyph in its own column, which put the bar 30px in. */
  width: 100%; text-align: left; display: flex; flex-direction: column; align-items: stretch;
  /* The card's own chrome, value for value (`div:is(.dev-card-dense,
     .dev-card-topic)` above): r22, 14px sides and top, 12px bottom, the 8px
     glyph gap, no border, the same soft drop shadow. The row had a hairline
     border, 9x10 padding, r12 and a 10px gap — four small differences that
     made the two sizes of one item read as two objects. */
  padding: 14px 14px 12px; border-radius: 22px; border: 0;
  background: var(--dc-sheet);
  color: var(--text-primary); font: inherit; cursor: pointer;
}
/* The hover FILL is the card's, applied as the card applies it: the same
   `hover:bg-zinc-50 dark:hover:bg-zinc-800` utilities, on the row itself
   (workshop.tsx). Two sizes of one object hovering to two different greys is
   the same mismatch as two icons or two chip styles, and hard-coding the
   colour here could not have fixed it — `zinc` is overridden in
   tailwind.config.js, so a hex copied from the stock palette would be a
   third grey rather than the card's. Only the border stays here. */
.dev-ws-row:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
/* -- The press ----------------------------------------------
   Both sizes toggle on a click, and neither said so while the button was
   down: the hover fill arrived on entry and nothing changed on press, so
   closing an open card gave no feedback until it had already closed.

   The exclusion list mirrors the two click guards in card/fold.tsx -- the
   row ignores a press that started on an anchor or a button, and the open
   card also ignores the thread, the comment list and the detail sections.
   :active matches every ancestor of the pressed element, so without the
   :not(:has(...)) a press on the Vote button would tint the whole card
   behind it, for a press that toggles nothing. */
/* The kit's scale-press, opted out of — like the list of opt-outs up at
   `.members-vis-pill:active`, for a different reason. The
   kit gives every button an instant scale on press; the row carries
   role="button" so a keyboard can reach it and the open card is a plain
   div, so the scale landed on ONE of the two sizes. That is the inset a
   pressed folded row showed and a pressed open card did not. The two sizes
   are one object: under press the fill changes and nothing else does, at
   both. The brightness dim stays, as it does for the pills. */
.dev-ws-row:active { transform: none; }
.dev-ws-row:active:not(:has(a:active, button:active, input:active, textarea:active, select:active)),
.dev-ws-rowwrap-open .gc-vote-item:active:not(:has(
  a:active, button:active, input:active, textarea:active, select:active,
  .dev-feed-thread:active, .dev-feed-comments:active
)) {
  background: var(--dev-press);
  /* Instant. The open card carries transition-colors, so the press colour
     eased in over ~150ms while the row snapped to it -- a quick click was
     released before the card had finished tinting, which is what made the
     press read as no feedback and the release read as a flash. The return
     to hover still eases, because that one is not feedback about a gesture. */
  transition: none;
}
/* -- The press, on touch: held back until the finger settles (#1928) ------
   On a phone a card list is mostly scrolled, not tapped, and every scroll
   starts as a touch on some card. Both press treatments above engage
   instantly -- the fill here, and the kit's scale + brightness dim on every
   role="button" card (the folded row, the session rows) -- so a flick down
   the board flashed a pressed state on whichever card the finger landed on,
   for a gesture that opened nothing. iOS lists wait a beat before
   highlighting, so a touch that becomes a scroll never lights up.

   Same here: the press still engages with no easing, but after 120ms. A
   scroll clears :active well inside that, so the pending change is
   cancelled and nothing is drawn; a real tap is held longer and tints as
   before. `transition` on the :active rule is the one read on the way IN;
   the way out keeps each element's own easing. Touch only -- a mouse press
   is never a scroll, so desktop keeps the instant feedback the rule above
   exists for. Real buttons inside the cards (Vote, Preview) are untouched:
   they are pressed on purpose. */
@media (hover: none) {
  .dev-ws-row:active:not(:has(a:active, button:active, input:active, textarea:active, select:active)),
  .dev-ws-rowwrap-open .gc-vote-item:active:not(:has(
    a:active, button:active, input:active, textarea:active, select:active,
    .dev-feed-thread:active, .dev-feed-comments:active
  )),
  #dev-forum-scroll [role="button"]:active {
    transition: background-color 0s linear 120ms, transform 0s linear 120ms, filter 0s linear 120ms;
  }
}
/* #1918: the challenge cards (the Challenges tab's grid and Home's
   Challenges block) are role="button" now, so the kit's scale + dim press
   reaches them. They sit in scrolled lists too, so on touch they take the
   same 120ms hold-back as the Dev board above. */
@media (hover: none) {
  .tc-se-card[role="button"]:active,
  .home-challenge-card[role="button"]:active {
    transition: transform 0s linear 120ms, filter 0s linear 120ms;
  }
}
/* Home's app tiles are role="button" too (features/home/app-grid.tsx), in the
   grid a phone scrolls most, so a flick down Home pressed whichever tile the
   finger started on. Same hold-back; the tile has no pressed fill of its own
   (its hover fill is `(hover: hover)` only, #2892), so it is the kit's scale
   and dim that wait. A held tile still presses before its long-press menu. */
@media (hover: none) {
  .app-card[role="button"]:active {
    transition: transform 0s linear 120ms, filter 0s linear 120ms;
  }
}
.tc-se-card[role="button"]:focus-visible,
.home-challenge-card[role="button"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The row's icon is the CARD's icon, to the pixel: the same 22px box with no
   tile and no fill, and the same 18px glyph. It used to be a 28px tinted
   tile, which was the one part of the folded row that did not appear on the
   card at all — so the two sizes of one item opened on a different mark.
   The colour that tile was carrying is not lost: it moved to the left edge
   below, which is where the card carries it. */
/* The head: the card's `.dev-card-head`, at the row's gap. */
.dev-ws-row-head { display: flex; align-items: center; gap: 8px; }
.dev-ws-row-head > .dev-card-icon {
  /* No margin: the card's glyph sits flush on the padding edge, and a 1px
     nudge here made the two sizes of one item start on different pixels. */
  flex-shrink: 0; align-self: flex-start;
  width: 22px; height: 22px; background: transparent; border-radius: 0;
}
.dev-ws-row-head > .dev-card-icon > svg { width: 18px; height: 18px; }

/* ── The row's left edge ──
   The card's own spine (`div:is(.dev-card-dense, .dev-card-topic)[data-edge]`
   above), on the row, from the same model field and the same tokens: blue
   while a vote is open, violet while merging, amber behind main, red on a
   conflict, green once merged. A folded row and its card now agree on their
   colour, which is what makes the fold read as a fold. */
.dev-ws-row {
  --dev-edge-w: 4px;
  --dev-edge: transparent;
  padding-left: calc(14px + var(--dev-edge-w));
  /* An inset shadow and a 33% mix, exactly as the card draws it: the same
     4px at the same strength, following the same corner arc, taking no
     layout, over the same drop shadow. A border here would have been a
     different mark at a different weight — which is the whole thing this
     row is trying to stop being. */
  box-shadow:
    inset var(--dev-edge-w) 0 0 color-mix(in srgb, var(--dev-edge) 33%, transparent),
    0 1px 2px rgba(0, 0, 0, 0.06);
}
.dev-ws-row[data-edge="vote"]      { --dev-edge: var(--accent); }
.dev-ws-row[data-edge="progress"]  { --dev-edge: var(--state-progress); }
.dev-ws-row[data-edge="attention"] { --dev-edge: var(--state-attention); }
.dev-ws-row[data-edge="blocked"]   { --dev-edge: var(--state-blocked); }
.dev-ws-row[data-edge="ok"]        { --dev-edge: var(--state-ok); }
.dev-ws-row[data-edge="neutral"]   { --dev-edge: var(--state-neutral); }
/* The title takes the head's remaining width, as `.dev-card-title` does
   on the card. It used to sit in a `.dev-ws-row-main` column with the meta
   line, which is what held the meta line 30px in: the card puts its meta
   line at the padding edge, under the glyph, not beside it. */
.dev-ws-row-head > .dev-ws-row-title { flex: 1; min-width: 0; }
/* 1.35, the dense card's leading (:is(.dev-card-dense, .dev-card-topic)
   .dev-card-title above). The row was 1.3, so a two-line title set slightly
   tighter folded than open — the same words on a different grid. */
.dev-ws-row-title { font-size: 14px; line-height: 1.35; }
/* The card's own meta line (dev-card.tsx metaLineNodes), at the card's
   sizes: 12.5px on 21px lines, wrapping, the number a link, the tags and
   the linked-issue chips in the card's chip geometry. Keep these in step
   with `:is(.dev-card-dense, .dev-card-topic) .dev-card-meta` above. */
/* The card's own tab, by the card's own rule: `:is(.dev-card-dense,
   .dev-card-topic) .dev-card-head:has(> .dev-card-icon) + .dev-card-meta`
   above sets the meta line 30px in so it starts under the title rather than
   under the glyph — and only when there IS a glyph. Same 30px, same
   condition, so a change to one keeps the other. The status row below takes
   no tab, at either size: it is the full-width line. */
.dev-ws-row-head:has(> .dev-card-icon) + .dev-ws-row-meta { padding-left: 30px; }
.dev-ws-row-meta { display: block; font-size: 12.5px; line-height: 21px; color: var(--text-muted); min-width: 0; margin-top: 0; white-space: normal; }
.dev-ws-row-meta > .dev-badge { height: 19px; font-size: 11.5px; vertical-align: middle; margin-left: 4px; }
.dev-ws-row-meta > .dev-badge.font-mono { font-size: 11px; }
.dev-ws-row-meta > .dev-badge .attr-avatar { width: 14px; height: 14px; }
/* ── The row's last line ─────────────────────────────────────
   The card's status row and facts row in one: a chip of the bar's state,
   the state chips, the message count, and the vote button at the right end
   — where the card's bar puts it — so the two sizes of one item keep one
   control in one corner. ONE line, clipped: a card with more state than
   fits loses the surplus whole rather than growing the row and breaking
   the column's rhythm; the state chip ellipsises first. */
.dev-ws-row-band {
  /* 8px, the card's own `.dev-card-status` margin. The row had 4px plus a
     1px column gap, so the gap above the one line that carries the state
     was 5px folded and 8px open. */
  display: flex; flex-wrap: nowrap; align-items: center; gap: 4px 6px;
  min-height: 18px; overflow: hidden; margin-top: 8px;
}
/* The bar fills the line, as the card's does. The card's pill is a flex
   item with basis 0 that grows into whatever the Vote button leaves; the
   row's chip was inline-block at its natural width, so on a 540px Workshop
   sheet it ended 221px short of the vote and the two sizes drew the same
   state at two lengths. */
.dev-ws-row-band > .dev-ws-row-state { flex: 1 1 0; }
.dev-ws-row-band > .dev-ws-row-trailing { margin-left: auto; }
/* The card's facts-row geometry (:is(.dev-card-dense, .dev-card-topic)
   .dev-card-meta > .dev-badge), so "In review" and "@maya" are the same chip
   at both sizes. Without this they fell back to the base .dev-badge —
   10.5px on a 20px chip against the card's 11.5px on 19px. */
.dev-ws-row-band > .dev-badge { flex: none; height: 19px; font-size: 11.5px; }
/* The composite pill, wearing the tone it has always carried. Tones are
   `statusPillState`'s vocabulary (neutral / ok / attention / blocked /
   progress), not the merge badge's colour names — an unknown one falls
   through to the neutral base rather than rendering unstyled. */
/* At the card's bar's height and shape (30px, r14, 12.5px — the dense
   `.dev-status-pill-block` above) rather than a 10px chip: the row's last
   line is 30px tall whenever it carries the vote button, and a chip that
   grows into the bar when the card opens was the one thing on that line
   that still jumped. Content-width, ellipsising, where the card's bar
   flexes to fill. */
.dev-ws-row-state {
  display: inline-block; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  height: 30px; line-height: 30px; box-sizing: border-box;
  font-size: 12.5px; font-weight: 500; white-space: nowrap;
  padding: 0 10px; border-radius: 14px;
  background: var(--state-neutral-bg); color: var(--state-neutral);
}
.dev-ws-row-state-blocked   { background: var(--state-blocked-bg);   color: var(--state-blocked); }
.dev-ws-row-state-ok        { background: var(--state-ok-bg);        color: var(--state-ok); }
.dev-ws-row-state-attention { background: var(--state-attention-bg); color: var(--state-attention); }
.dev-ws-row-state-progress  { background: var(--state-progress-bg);  color: var(--state-progress); }
/* ── The fold mark ───────────────────────────────────────────
   Which SIZE the item is at, at the top right of the row and of the card
   (card/fold.tsx FoldMark). The same two chevrons on both; on the open card
   they are pushed apart with a bar between them, so the mark stretches when
   the card does and nothing rotates: a chevron that turns promises a
   direction (the accordion's down → up, the tree's right → down) and the
   Vote button's ▾ on this same card already means a menu. Absolutely placed
   so it takes no width from the row's last line — the Vote button there
   reaches the right edge, as the card's bar row does — and only the title
   keeps clear of it. right: 14px puts its right edge over the hamburger's
   in the band below; top: 15px centres it on the title's first line. */
.dev-ws-row, .dev-ws-sheet > div:is(.dev-card-dense, .dev-card-topic) { position: relative; }
.dev-ws-row-title, .dev-ws-sheet .dev-card-title { padding-right: 22px; }
.dev-fold-mark {
  position: absolute; top: 15px; right: 14px; width: 16px; height: 16px; display: block;
  padding: 0; margin: 0; border: 0; background: transparent; color: var(--text-faint); line-height: 0;
}
button.dev-fold-mark { cursor: pointer; color: var(--accent); border-radius: 4px; }
button.dev-fold-mark:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-fold-mark > svg { width: 16px; height: 16px; display: block; }
/* The geometry is transforms on the three paths, in the SVG's own units (a
   CSS px is one user unit on an SVG element): open, the chevrons sit 2
   units further out and the bar between them is drawn; closed, the bar is
   scaled to nothing about its middle. Declared as end states, and ALSO as
   keyframes on the open card, so the stretch plays once when it mounts. */
.dev-fold-mark path { transform-box: view-box; transform-origin: 12px 12px; }
.dev-fold-mark .dev-fold-bar { transform: scaleY(0); }
.dev-fold-mark[data-open="1"] .dev-fold-top { transform: translateY(-2px); animation: dev-fold-top .15s ease both; }
.dev-fold-mark[data-open="1"] .dev-fold-bottom { transform: translateY(2px); animation: dev-fold-bottom .15s ease both; }
.dev-fold-mark[data-open="1"] .dev-fold-bar { transform: scaleY(1); animation: dev-fold-bar .15s ease both; }
@keyframes dev-fold-top { from { transform: translateY(0); } to { transform: translateY(-2px); } }
@keyframes dev-fold-bottom { from { transform: translateY(0); } to { transform: translateY(2px); } }
@keyframes dev-fold-bar { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { .dev-fold-mark path { animation: none; } }

.dev-ws-new {
  margin-left: 6px; font-size: 10px; font-weight: 700; color: var(--accent); background: var(--accent-tint);
  padding: 1px 5px; border-radius: 6px; vertical-align: middle;
}
/* A card the server has themes for but has not placed yet: on its way, not unfit. */
.dev-ws-placing {
  margin-left: 6px; font-size: 10px; font-weight: 600; color: var(--text-muted);
  border: 1px dashed currentColor; padding: 0 5px; border-radius: 6px; vertical-align: middle; opacity: .8;
}
/* ── Open: the card, at the size the Board draws it ─────────
   There is nothing to join here any more. The wrapper renders EITHER the
   compressed row or this entry, never both, so the entry keeps the ordinary
   `#dev-workshop .dev-feed-entry` sheet (above) and the dense card inside it
   keeps every bit of chrome the Board gives it: the 36px icon, the meta line,
   the full-width status bar, the facts line, the menu.

   #1799 did the opposite — it kept the row as a head and hid the card's head,
   meta and status band so they would not repeat it. That made the open state a
   third object belonging to neither size. Those four rules are gone rather
   than adjusted; the row IS the compressed representation of this card, and
   the way to show the card is to show the card. */
/* ── "Open card", and what it opens ────────────────────────
   The toggle sits in the card's action band, after its own pills and before
   the hamburger (card/dev-card.tsx's `actionEnd`), on the Workshop and the
   Board alike; on the Board it is a link to the item's page.

   On the Workshop it reveals the topic screen's own sections — the ledger,
   the About sheet with its before/after tiles, the transcript — drawn by the
   same components from the same view model, indented under the card so a
   reader can see where the item ends. Not the comment threads: the sheet
   already carries both, above and below this block. */
.dev-ws-open-btn { white-space: nowrap; text-decoration: none; }
/* The "Open session" link under a card about the viewer's own session (#1887). */
.dev-ws-sheet-actions { display: flex; align-items: center; gap: 8px; margin-top: 10px; font-size: 13px; }
.dev-ws-more { font-size: 13px; color: var(--text-muted); padding: 2px 10px; }
.dev-ws-theme-more { font-size: 13px; color: var(--text-muted); padding: 4px 10px 0; }
/* The row's vote button, at the right end of its last line (.dev-ws-row-band). */
.dev-ws-row-trailing { display: inline-flex; align-items: center; flex-shrink: 0; }
/* ── The Board's columns fold their cards too (#1787) ─────────
   The same row, the same sheet and the same fold (card/fold.tsx), inside a
   kanban column. The column's own `space-y-2` (4px, see #dev-kanban above)
   is the rhythm between rows, so the wrapper's 4px is not added to it. The
   open card is the column's tile as it always was; the sheet around it is
   transparent here — the Workshop's frosted plane is scoped to #dev-workshop
   on purpose — and it draws nothing else: the Board's "Open card" is itself
   the link to the item's page, so the sheet has no line under the card. */
#dev-kanban .dev-ws-rowwrap { margin-bottom: 0; }
#dev-kanban[data-cards-open] > .dev-kanban-col > .dev-kanban-lead { margin: 0 2px 8px; }
.dev-ws-foot-note { font-size: 12px; color: var(--text-muted); text-align: center; padding: 6px 0 2px; }

/* Illustration variants share one frame; only visibility changes with theme. */
.illustration-dark { display: none; }
.dark .illustration-light { display: none; }
.dark .illustration-dark { display: block; }
.home-discover-card[data-preview-theme="light"] .illustration-light { display: block; }
.home-discover-card[data-preview-theme="light"] .illustration-dark { display: none; }
.home-discover-card[data-preview-theme="dark"] .illustration-light { display: none; }
.home-discover-card[data-preview-theme="dark"] .illustration-dark { display: block; }
.home-discover-card[data-preview-theme="light"] .home-discover-name { color: #1c1c1e; }
.home-discover-card[data-preview-theme="light"] :is(.home-discover-blurb, .home-discover-meta, .home-discover-facts) { color: #48484a; }
.home-discover-card[data-preview-theme="dark"] .home-discover-name { color: #eaeaea; }
.home-discover-card[data-preview-theme="dark"] :is(.home-discover-blurb, .home-discover-meta, .home-discover-facts) { color: #8e8e93; }
.home-discover-card[data-preview-theme="light"] .card-add-btn:not([data-join="joined"]) { background: white; color: #0062cc; border-color: #e3e3e6; }
.home-discover-card[data-preview-theme="dark"] .card-add-btn:not([data-join="joined"]) { background: #2c2c2e; color: #5aa9ff; border-color: #48484a; box-shadow: none; }

/* ═══════════════════════════════════════════════════════════════════
   #platform-tabs — the shell's five sections, as a permanent bar
   ═══════════════════════════════════════════════════════════════════

   The markup is frontend/src/features/nav/tab-bar.tsx, and its header
   carries the product argument: every host this shell is modelled on keeps
   a flat per-mini-app menu AND a permanent bar of its own sections, and
   this shell had only the menu, so the platform's places were sitting in
   the app's list for want of anywhere else to put them.

   What lives HERE rather than in the component is everything that has to
   agree with a second rule somewhere else: the bar is out of flow, so
   nothing reserves its space automatically, and the space it covers is
   held open by the screens above it. Two rules, one number. */

/* THE BAND THE BAR COVERS, read by the screens above it.

   Declared on `body` in both branches, NOT on `:root`, and that is not a
   style choice. A custom property's `var()`s are substituted at
   computed-value time on the element the property is DECLARED on, and the
   result is what inherits — so a token declared on `:root` that reads
   `--platform-tabs-h` would bake in `:root`'s value and never see the
   override below. Both declarations sit on the same element so the cascade
   picks between them the ordinary way.

   `body:has(#platform-tabs:not(.hidden))` is the route test, and it is the
   same shape as the wallpaper's a few thousand lines up: the bar's own
   `hidden` class IS the fact, published once by the visibility store and
   applied by the island (features/nav/tab-bar.tsx), so no second flag has
   to be kept in step with it. A `:has()` that watches one id for one class
   is as cheap as that selector gets.

   IT INCLUDES THE INSET, unlike `--ws-bar` above, and for the opposite
   reason. The Workshop's pill floats clear of the bottom edge, so the strip
   beneath it is held open by the scroller and counting it twice was the
   #4149 bug. This bar is fused to the edge — it spends the inset itself, as
   its own lower padding — so the clearance a screen needs above it is the
   bar's FULL outer height. Every consumer therefore takes
   `max(--platform-tabs-h, --platform-safe-bottom)`: the bar's box when the
   bar is there (its padding already covers the strip), the bare strip when
   it is not. One expression, both states, no double count either way.

   `html:not(.un-kb)` because the keyboard takes the bar with it — see the
   display rule below. */
body {
  /* The BAR's own outer height, which the parked strip rests on. */
  --platform-bar-h: 0px;
  /* Everything at the foot of the screen, stacked — what a surface above has
     to clear. With no strip it is the bar; with one it is both. */
  --platform-tabs-h: 0px;
  /* HOW WIDE THE RAIL IS, which is not the same question as how much width
     it RESERVES — and keeping the two apart is what lets it float. A PEEKED
     rail (over an app, or over a screen whose rail is folded) is drawn at its
     full width while the content under it reserves nothing; a rail that had
     one number for both was 17px of padding and a border, because the
     reserved figure was 0. A constant, never overridden.
     #4417: 76px, THE STRIP. It was a 224px rail of rows and Recents. From
     768px the sidebar of rows sets it again (212px, its own block below),
     and what the strip's column beside it held is nested in the sidebar. */
  --platform-rail-full: 76px;
  /* WHAT IT RESERVES. 0 on a phone, where the five tabs are a bar at the foot
     and cost width nothing; 0 again on a desktop while the rail is folded or
     peeking, where the content takes the width back. */
  --platform-rail-w: 0px;
  --platform-gutter: 0px;
}
/* `:not(.platform-tabs-peek)` because a PEEKING bar reserves nothing: the
   router still says the bar is hidden (see features/nav/tab-bar.tsx), the app
   under it is full width, and a rail that claimed 224px on the way in would
   reflow the app under the pointer that revealed it. */
html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek)) {
  --platform-bar-h: calc(56px + var(--platform-tabs-inset, 0px));
  --platform-tabs-h: calc(56px + var(--platform-tabs-inset, 0px));
}
/* THE PARKED STRIP ADDS ITS OWN BAND, and only while the bar is there to sit
   on: the strip is offered on the platform's screens and never inside a
   running app, which is the one route where resuming that app would be a
   shortcut to where you already are. Both terms are spelled out rather than
   summed from the token above, because a custom property's var()s resolve on
   the element the property is DECLARED on — see the note at the head of this
   section — so `calc(52px + var(--platform-bar-h))` here would read the 0px
   from the plain `body` rule rather than the value the selector above sets. */
html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek)):has(#platform-parked:not(.hidden)) {
  --platform-tabs-h: calc(52px + 56px + var(--platform-tabs-inset, 0px));
}

/* KEYBOARD UP, BAR GONE. The same reasoning `html.un-kb .platform-safe-scroll`
   states for the home-indicator strip: a bar pinned to the bottom of the
   viewport with a keyboard over it is either behind the keyboard or riding
   on top of it, and neither is navigation. `display: none` rather than the
   `hidden` class because the class has an owner (the island) and this is a
   presentation rule, not a route fact — the bar comes back with no state to
   restore the moment the keyboard drops. The reservation above is already
   off, because its selector excludes this state. */
html.un-kb #platform-tabs {
  display: none;
}

/* KEYBOARD UP IN THE HOMEROOM APP, BAR AND STRIP GONE THERE TOO (first-session
   run, 4 October 2026). `un-kb` is the kit's answer to "do the keys COVER the
   page", and in the phone app they never do: its Flutter shell resizes the
   web view to end at the keyboard, so the page just gets shorter, the kit
   measures nothing covered, and `un-kb` stays off. The bar and the Resume
   strip, fixed to the foot of a page that now ends at the keys, were drawn on
   the keyboard and over the composer, half covering Send, while every screen
   still reserved their band. `platform-kb-open` is the page's own answer
   (frontend/src/lib/keyboard-open.ts: a text field focused and the visible
   page shorter than at rest by a keyboard's height), written on a phone
   layout only, and it takes the same two bars away so the composer sits
   directly on the keys. `!important` on the band for the reason the side
   panel's document gives below: the rules that reserve it key off the bars'
   own classes, not this one. The home-indicator strip is behind the keys as
   well, so the safe-area clearances drop exactly as they do under `un-kb`.
   Everything returns the moment the field lets go. */
@media (max-width: 767px) {
  html.platform-kb-open body {
    --platform-bar-h: 0px !important;
    --platform-tabs-h: 0px !important;
  }
  html.platform-kb-open #platform-tabs,
  html.platform-kb-open #platform-parked {
    display: none;
  }
  html.platform-kb-open .platform-safe-scroll {
    padding-bottom: 0 !important;
  }
  html.platform-kb-open .platform-safe-bar {
    padding-bottom: 0.5rem !important;
  }
}

/* A FULL-SCREEN SURFACE KEEPS ITS FIELD AND ITS BUTTON ABOVE THE KEYS
   (iPhone 17 simulator, iOS 26 Safari, 5 October 2026). The sign-in sheet's
   Sign in button sat behind the keyboard with the password field half under
   the keyboard's floating bar, and the make screen's "Make it" sat under the
   same bar. Both are `fixed inset-0`, which Safari leaves at the page's full
   height with the keys, the address pill and the form bar over its foot, and
   which iOS pans up to reveal a tapped field. The band of the page that is
   actually visible while the keyboard is open is the pan above it
   (`--platform-vv-top`, lib/visual-viewport.ts) and `--platform-kb-cover`
   below it (lib/keyboard-open.ts; the visual viewport ends above the pill
   and the bar, so whatever iOS draws over the keys is in the cover, and
   nothing here assumes a height for it).

   - `.platform-kb-surface`, a full-screen column (the make screen): padded
     into the band, so its bar stays at the top of what is seen and its
     scroller ends where the keys begin. The ground still paints the whole
     box, so the pill and the bar float over the screen's own wallpaper.
   - `.platform-kb-sheet`, a bottom sheet inside a full-screen root (the
     sign-in sheet): its foot on the band's foot and its height capped to the
     band, so it rides on the keys and scrolls inside.

   In the Homeroom app the web view itself ends at the keys, so both are 0
   and nothing moves. Phone only, like the rest of this block. */
@media (max-width: 767px) {
  html.platform-kb-open .platform-kb-surface {
    padding-top: var(--platform-vv-top, 0px);
    padding-bottom: var(--platform-kb-cover, 0px);
  }
  html.platform-kb-open .platform-kb-sheet {
    bottom: var(--platform-kb-cover, 0px);
    max-height: calc(100% - var(--platform-vv-top, 0px) - var(--platform-kb-cover, 0px) - 12px);
    /* The home indicator is behind the keys. */
    padding-bottom: 1.25rem;
  }
}

/* THE SHEET'S BUTTON CLEARS SAFARI'S TOOLBAR WITH THE KEYS DOWN (#4593).
   Mobile Safari, the release mail's "Create my account": the sign-in sheet
   opened over the story with its Continue below the fold. The sheet's root
   is `fixed inset-0`, and Safari 26 runs that box on under its floating
   toolbar whenever the toolbar is tucked away or drawn over the page, so the
   panel's foot (`bottom-0`), where the step's button is, sat behind it. The
   story reserves the same toolbar under its own foot (`100lvh - 100svh`,
   above). Here the panel stands on the small viewport's foot instead: its
   `bottom` is however far the root runs past `100svh` (the root's height
   less the viewport with every toolbar showing, 0 when the root already
   ends above the toolbar), and its height is capped to 92% of that same
   small viewport. Where no toolbar comes and goes (the Homeroom app, an
   installed app, Android with its bar at the top) `100svh` is the root's
   height and nothing moves. With the keys up, the rule above (more
   specific) still puts the foot on the keys. Phone only: the desktop card
   is centred. */
@media (max-width: 767px) {
  @supports (height: 100svh) {
    html .platform-kb-sheet {
      bottom: max(0px, calc(100% - 100svh));
      max-height: 92svh;
    }
  }
}
/* THE SIGN-IN SHEET RUNS TO THE FOOT OF THE SCREEN (Evan, 10 Oct 2026,
   iPhone Safari). Stood on the small viewport's foot (above), the panel ended
   over a band of the dim, and under that the page showed undimmed beneath
   Safari's toolbar: a card floating over the screen's foot rather than a
   sheet rising from it. Its own fill now carries on below it to past the
   foot of the screen, as a shadow (the panel scrolls, and a scroller clips
   anything drawn inside it, but not its own shadow): offset 20px down so
   the rounded corners stay round, and spread wide enough to clear the screen
   below and at both sides. Nothing in the panel moves. The hairline is drawn
   along the top only, where the sheet meets the dim. Phone only, as above. */
@media (max-width: 767px) {
  [data-sign-in-sheet] > .platform-kb-sheet {
    box-shadow: inset 0 1px 0 var(--app-sheet-line), 0 calc(50vh + 20px) 0 50vh var(--dc-sheet-solid);
  }
}

/* A FULL-SCREEN SURFACE THAT LEAVES THE PLATFORM HEADER SHOWING (#4195).
   The make and made screens opened from Create are `fixed` from the header's
   foot down rather than `inset-0` over it, so the header's back, bell and
   menus stay where they always are (first-session/make.tsx MAKE_ROOT). The
   tab bar stays covered. With the keyboard up, the band the
   `.platform-kb-surface` rule above pads into starts at the pan
   (`--platform-vv-top`) from the page's top, and this box starts the
   header's height further down, so only the part of the pan past that is
   padding here. */
.platform-under-header {
  top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h) + var(--platform-safe-top));
}
@media (max-width: 767px) {
  html.platform-kb-open .platform-kb-surface.platform-under-header {
    padding-top: max(0px, calc(var(--platform-vv-top, 0px) - var(--browser-banner-h, 0px) - var(--platform-header-h) - var(--platform-safe-top)));
  }
}
/* THE MAKE AND MADE SCREENS RUN ON UNDER SAFARI'S TOOLBAR (Evan, 10 Oct
   2026, iPhone Safari). Both are `fixed` boxes with their own scroller, and
   with Safari's toolbar showing such a box ends above it: what scrolled was
   cut off there, Make it in half, with the page's plain ground below. Now
   the box runs on 200px past its foot, under the toolbar and off the foot of
   the screen, so what scrolls carries on under the toolbar as a page does,
   and the scroller keeps those 200px after its last line, so the end still
   scrolls up to where it stopped before. A fixed length, not the viewport
   units: on an iPhone 13 mini in Safari 26 `100lvh` came out shorter than
   the box itself, so `100% - 100lvh` extended nothing (#4691's first try).
   200px is more than the toolbar and the home indicator together. In a
   phone browser on iOS, with the keys down: the keyboard surface measures
   its padding against the box as it was.

   THOSE 200PX ARE A SPACER, NOT PADDING (onboarding run two, 12 Oct 2026,
   iPhone 17 simulator, Safari 26, measured on a copy of the screen): Safari
   paints a fixed box only down to the foot of its layout viewport (714px
   tall there, ending above the toolbar), whatever runs on past it, so what
   lies in the last 200px is never seen. And WebKit leaves a flex
   scroller's end padding out of what it scrolls: the scroller was 862px
   with 862px to scroll, so Make it, once a starting idea had grown the
   form, sat under the toolbar with nothing to scroll it up. The page lock
   (#4909) also keeps Safari from collapsing its toolbar, which a drag on
   the page used to. An empty last item is counted: with it the same
   screen scrolled 962px, and Make it came up clear of the toolbar. */
@media (max-width: 767px) {
  html.un-ios.web-browser-chrome:not(.platform-kb-open) :is([data-first-session-make], [data-first-session-made]) {
    bottom: -200px;
  }
  html.un-ios.web-browser-chrome:not(.platform-kb-open) :is([data-first-session-make-scroll], [data-first-session-made])::after {
    content: '';
    flex: 0 0 200px;
  }
}
/* AND OVER THEIR TOP, UNDER THE STATUS BAR (onboarding run, 12 Oct 2026,
   iPhone 17 Safari, #4893). Safari draws the page behind the status bar,
   above these boxes as it does under its toolbar below them, and what showed
   there, behind the clock, was Home's cards once it had scrolled. The page
   holds still under them now (`platform-page-locked`, above), and their
   ground also runs on 200px above them, as the box's own shadow in the
   ground's colour: a scroller (the made screen is one) clips what it holds
   but not its own shadow, as the sign-in sheet's foot has it, and nothing in
   the box moves, so the wallpaper's star and washes stay where they are.
   Where a box already starts at the top of the glass the shadow is off the
   screen. Not from Create, whose screens begin under the platform header. */
@media (max-width: 767px) {
  html.un-ios.web-browser-chrome :is([data-first-session-make], [data-first-session-made], [data-first-session-welcome]):not(.platform-under-header) {
    box-shadow: 0 -200px 0 0 var(--home-ground, #f4f2e4);
  }
}

.platform-tabs {
  /* FIXED TO THE REAL VIEWPORT. `.dev-ws-tabs` above documents the three
     rounds this saves: `sticky` puts a bar's resting place at the mercy of
     whatever container it sits in, and every one of those inputs behaves
     differently in a real iOS PWA than in a headless Chromium. This one has
     a second reason on top — on `html[data-browser-scroller]` routes the
     DOCUMENT is the scroller and `body` is `height: auto`, so a flex child
     at the end of the column scrolls away with the page.

     No portal, unlike the Workshop's bar: this is a direct child of <body>
     and nothing between here and the viewport carries a `backdrop-filter`,
     a `transform` or a `filter`, so `fixed` already means the screen. */
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  /* UNDER the panels, sheets and dialogs (which sit at 40 and above) and
     OVER the screens. A tab bar that a sheet does not cover reads as part
     of the sheet; one the screens paint over is not a bar at all. */
  z-index: 30;

  display: grid;
  grid-template-columns: repeat(5, 1fr);
  align-items: stretch;
  padding: 0 4px;
  /* THE INSET, SPENT EXACTLY ONCE, here. `--platform-tabs-h` above counts on
     this and the screens count on that. `--platform-tabs-inset` is the inset
     itself everywhere but iOS, where the bar sits lower — see below. */
  padding-bottom: var(--platform-tabs-inset);
  /* A DRAG THAT STARTS ON THE BAR MOVES NOTHING (#2771). Every tab is a tap;
     nothing here scrolls. In an installed iOS app a vertical drag that begins
     on a surface with no scroller under it rubber-bands the whole document,
     and the bar — fixed to that document's viewport — rides up with it,
     showing the page's foot beneath it. Taps still land: `none` only
     withholds panning and zooming. */
  touch-action: none;

  /* EDGE TO EDGE AND FROSTED, not the Workshop's floating pill. The two bars
     are different kinds of thing and the shapes should say so: the Workshop's
     switches between three views OF one screen, so it floats over that
     screen's content; this one switches between screens, so it is a wall at
     the bottom of the app with the content ending against it. Fusing it to
     the edge is also what lets it spend the home-indicator inset itself,
     which is what keeps the arithmetic above to one number. */
  border-top: 1px solid var(--app-sheet-line);
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* FAKED GLASS: THE TAB BAR (and the header, "…AND THE HEADER" above). The
   two places the glass carries the look, so they keep it, without a filter.
   The wallpaper is fixed, so what the glass showed over it never changed:
   the tint over the washes behind the bar (a 24px blur erases the grain and
   the star, and the washes are soft already). So the bar paints exactly
   that, once: a viewport-sized fixed layer of the same `--home-washes` the
   wallpaper draws, so it lines up with the body's pixel for pixel, under the
   glass tint, and clipped to the bar by `clip-path`, which, unlike
   `overflow`, also clips a fixed descendant (the usual stand-in for
   `background-attachment: fixed`, which iOS ignores). Nothing is sampled per
   frame, so scrolling costs nothing. The one thing it gives up is page
   content showing through as it passes under: that is the part that cost a
   filter pass on every frame. Nothing between the bar and the viewport has a
   transform or a filter (see `.platform-tabs` above), so `fixed` is the
   screen. */
.platform-tabs {
  background-color: transparent;
  clip-path: inset(0);
}
/* THE ONE FAKED LAYER, for every surface that fakes its glass: the tab bar,
   the parked strip, the header and the signed-out landing header. Each sets
   the transparent background and the clip on itself; `--fake-glass-tint` is
   its glass fill (the pane fill unless it says otherwise). Resolved here, on
   the surface, so it reads the route's --home-washes (set on <body>); a
   route with no wallpaper gets the solid plane colour. */
.platform-tabs::before,
.platform-parked::before,
body:has(:is(#home-screen, #leaderboard-screen, #profile-screen, #browse-screen,
             #settings-screen, #messages-screen, #workshop-screen,
             #global-chat-screen, #agent-session-screen,
             #profile-proposals-screen):not(.hidden)) #platform-header::before,
body:has(#app-view:not(.hidden)[data-app-surface="platform"]) #platform-header::before,
html[data-browser-scroller] #landing-header::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    linear-gradient(var(--fake-glass-tint, var(--dc-sheet-fill)), var(--fake-glass-tint, var(--dc-sheet-fill))),
    var(--home-washes, var(--dc-sheet-solid));
}
/* DISCOVER'S SEARCH BAND FROM 768px (polish N12), the same faked layer.
   It pinned on a flat `--home-ground` fill: the washes behind the page do
   not reach it, so it read as a band of a different colour across them, and
   with no edge the cards slid under it into nothing. Now it paints the
   wallpaper's own grain and washes, fixed so they line up with the body's
   pixel for pixel: the band is the page, opaque, and only its hairline
   says where the cards pass under. The phone's head is not sticky
   (`browse-pane-head` above) and keeps its transparent fill. */
@media (min-width: 768px) {
  #browse-search-bar {
    background: transparent;
    clip-path: inset(0);
  }
  #browse-search-bar::before {
    content: '';
    position: fixed;
    inset: 0;
    z-index: -1;
    pointer-events: none;
    background:
      var(--home-grain, none) 0 0 / 240px 240px repeat,
      var(--home-washes, var(--dc-sheet-solid));
  }
  #browse-search-bar::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 1px;
    background: var(--app-sheet-line);
    pointer-events: none;
  }
}
/* THE HAIRLINE GOES BACK ON TOP. The layer paints over its surface's own
   border (a stacking context's negative layer comes after its background and
   border), so a bar whose edge is a border redraws it above the layer. */
@media (max-width: 767px) {
  .platform-tabs::after,
  .platform-parked::after {
    content: '';
    position: absolute;
    top: -1px;
    left: 0;
    right: 0;
    height: 1px;
    background: var(--app-sheet-line);
    pointer-events: none;
  }
}
/* THE DOCKED DESKTOP RAIL NEEDS NO FAKE. The page is laid out beside it, not
   under it, so only the fixed wallpaper is ever behind it and its own
   translucent fill is exactly the glass without the blur; its edge borders
   draw as they always did. (The peeked rail floats over the page, so it is
   solid; see `.platform-tabs.platform-tabs-peek`.) The parked row sits in
   the rail here and is transparent there, so it drops its layer too. */
@media (min-width: 768px) {
  .platform-tabs {
    background-color: var(--dc-sheet-fill);
    clip-path: none;
  }
  .platform-tabs::before,
  .platform-parked::before { content: none; }
  .platform-parked { clip-path: none; }
}

/* THE BAR SITS LOWER ON iOS (#2766). The inset under it there is the
   home-indicator strip, 34px on a Face ID iPhone, and the indicator is a 5px
   line drawn in the bottom 13px of it: nothing is under the upper part but
   more of the page. Spending all 34 as padding floated the five tabs a full
   strip above the edge, well above where the system's own tab bars put
   theirs. This spends all but 14px of it, which keeps the glyph and label
   15px clear of the indicator and drops the bar by that much. `max()` so a
   thin or absent inset (Safari with its toolbar up, a landscape phone) is
   never driven negative.

   iOS ONLY, and not by accident. On Android the same token measures the
   system navigation bar — three real buttons, or a gesture handle the
   system keeps clear of content — so borrowing from it would put the tabs
   under the system's own controls, which is the #2755 report in reverse.

   Declared on <html> (the kit's `un-ios` class is on it by the first paint)
   so it resolves beside `:root`'s inset; the bar's padding and both band
   tokens above read it, so the screens clear exactly the bar that is drawn. */
html.un-ios {
  --platform-tabs-inset: max(0px, calc(var(--platform-safe-bottom, 0px) - 14px));
}

/* THE WHOLE BAR ON AN INSTALLED ANDROID APP (#2755). An installed Android
   web app can be drawn edge to edge, under the system navigation bar, where
   Chrome's `safe-area-inset-bottom` can read 0 or arrive late while its
   `safe-area-max-inset-bottom` (Chrome 135+) carries the bar's full height. With
   an inset of 0 the bar's padding is 0 and the navigation bar paints over its
   lower half: the truncated bar in the report. Taking the larger of the two
   is exact where the inset is right (they agree, there is no browser chin in
   an installed app) and whole where it is not. A browser without the second
   variable falls back to 0px, which leaves the first untouched.

   STANDALONE ONLY. In a browser tab the max variable is the height the inset
   grows to when Chrome's bottom chin collapses, and reserving it while the
   chin is still up would stack the bar on top of a strip the browser already
   keeps clear. Both declarations restate the kit form so `?shot=safe-bottom`
   still drives them; the second is the platform surface's own restatement
   (see `#app-view[data-app-surface="platform"]`), which would otherwise win
   inside it. */
@media (display-mode: standalone), (display-mode: fullscreen) {
  html.un-android,
  html.un-android #app-view[data-app-surface="platform"] {
    --platform-safe-bottom: max(var(--un-safe-inset-bottom, env(safe-area-inset-bottom, 0px)), env(safe-area-max-inset-bottom, 0px));
  }
}

/* A GLYPH OVER ITS LABEL, 56px tall: 8 of padding, 24 of glyph, 3 of gap, a
   13px line, 8 again. Above the 44px minimum the shell holds every phone
   target to, which this has to clear by a margin — it is the platform's
   primary navigation and it is the control nearest the bottom edge, where a
   thumb is least precise.

   THE LABEL STAYS. A five-icon bar with no words asks the viewer to learn
   five glyphs, and two of these five (Workshop, Discover) name things that
   have no conventional icon at all. The same argument `.dev-ws-tab` makes
   one screen down: at 11px under a glyph the glyph carries the scanning and
   the word settles what the glyph cannot. */
.platform-tab {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-width: 0;
  height: 56px;
  padding: 0 2px;
  color: var(--text-muted);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: .01em;
  line-height: 13px;
  text-decoration: none;
  white-space: nowrap;
  overflow: hidden;
  -webkit-tap-highlight-color: transparent;
}

/* THE ACCENT, the `--lit-*` tokens: where you are, among the places you
   could be. It was the header's periwinkle; the accent is the product's one
   blue, and the lit tab is the one place on the bar that says "here".
   `aria-current` is the only marker the component renders, so it is the only
   one this reads. */
.platform-tab[aria-current="page"] {
  color: var(--lit-ink);
}

/* THE LIT TAB'S MARKER (#2824) — the Workshop's sliding pill, on the phone's
   bar. The colour above was the only thing that said which tab you were on,
   and at 11px under a glyph that is easy to miss; the Workshop's strip one
   screen down already answers the same question with a blue fill that slides
   between tabs, so this is that answer with the same tokens and the same
   curve (`.dev-ws-tab-marker`).

   Its geometry is measured off the lit tab's GLYPH AND LABEL (useTabMarker
   and markerBoxFor in frontend/src/features/nav/tab-bar.tsx), so this rule
   carries none: the transform is the only thing that moves. It hugs them,
   5px over the glyph, 8px either side of the wider of the two and 3px under
   the label, and the tab under it stays the full cell, so the pill is sized
   by what it holds and the tap target by the bar. `.platform-tabs` is `position:
   fixed`, which makes it the containing block the offsets are measured in.

   Each tab is raised one step so the marker, rendered before them, paints
   behind the glyph and label rather than over them. */
.platform-tab {
  position: relative;
  z-index: 1;
}
.platform-tabs-marker {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 0;
  border-radius: 16px;
  background: var(--lit-tint);
  box-shadow: inset 0 0 0 1px var(--lit-line);
  pointer-events: none;
  /* The slide scales from the corner the transform places (tab-bar.tsx,
     slideKeyframes). */
  transform-origin: 0 0;
  /* Hidden until measured, and while nothing is lit. */
  opacity: 0;
}
.platform-tabs-marker[data-marker-at] { opacity: 1; }
/* ONLY A SELECTION CHANGE SLIDES, as on the Workshop's strip: the first
   placement and a re-measure of the same tab land where they are.

   THE SLIDE IS NOT A TRANSITION HERE (#3259). It is an animation useTabMarker
   plays over the resting style (MARKER_SLIDE in tab-bar.tsx): the Workshop
   strip's duration and curve, but transform only. A transition of width
   and height ran on the main thread, and a press now starts the slide
   before the screen swap, whose long frames froze them mid-slide. Reduced
   motion is read there too, and gets no slide.

   IT SLIDES THROUGH A VIEW TRANSITION TOO. A tab change is a screen swap,
   and the swap runs as a view transition in which #platform-tabs is its own
   group whose OLD image is dropped and whose NEW image is shown at once
   (see the `platform-tabs` group near the top of this file). The new image
   is a live rendering of the element, not a still, so an animation started
   by the press plays inside it; this is the one part of the bar that moves
   on a tab change, and the label colour still pops, as it should. */

/* The badge's anchor. `position: relative` on the glyph's wrapper rather
   than on the whole tab, so the count sits on the ICON's corner and not on
   the tab's — the tab is 56px tall and a badge pinned to its top-right
   would float in the air above the glyph, away from the thing it is about.
   That is the PHONE's bar; the desktop rail dissolves this wrapper and draws
   the count at the row's end instead (#2912, in the desktop block below). */
.platform-tab-mark {
  position: relative;
  display: inline-flex;
  flex: none;
}
/* THE RAIL'S SETTINGS COG (#3120) is desktop-only: the phone's bar is five
   equal cells and Settings stays a row of the Profile screen there. The
   desktop block below is the only place that gives it a box. */
.platform-rail-settings {
  display: none;
}
.platform-tab-glyph {
  width: 22px;
  height: 22px;
  flex: none;
  /* 1.8, not the icon set's 2: at 22px a 2px stroke reads heavier than the
     11px label under it, and the glyph is the half of the tab that scans. */
  stroke-width: 1.8;
}
.platform-tab-label {
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  /* A LINE BOX THAT TRACKS THE FONT. `.platform-tab` sets `line-height: 13px`
     as an absolute, which is right for the 11px caption under a phone glyph
     and two pixels too short for the rail's 15px row — so with `overflow:
     hidden` on this element the tail of a `g` or a `p` was sliced off flat.
     Measured at 1280: an 18px ink box in a 13px line box, 2px over the edge.
     A unitless multiplier is the one value that is correct at both sizes. */
  line-height: 1.35;
}

/* "COMMUNITIES" IS THE BAR'S LONGEST WORD. Eleven letters at 11px is about
   a phone's fifth of the screen — a hair over it in some fonts — so at 390px
   it was clipped to "Communi…". This one label tightens its tracking and is
   let out of its cell rather than cut: it is centred, and its seat is wider
   than the others. Narrower than 375px it drops half a point as well. The
   rail has room and is left alone. */
@media (max-width: 767.98px) {
  /* Communities' seat, the fourth, is a little wider than the other four:
     it holds the longest word, and the bar's other labels (Messages in the
     middle, your name last, which ellipsizes) have room to spare. */
  #platform-tabs { grid-template-columns: 1fr 1fr 1fr 1.3fr 1fr; }
  #platform-tab-workshop { overflow: visible; }
  #platform-tab-workshop .platform-tab-label {
    letter-spacing: -.02em;
    overflow: visible;
    text-overflow: clip;
  }
}
@media (max-width: 374.98px) {
  /* The smallest phones: every label drops half a point and tightens, the
     cells lose their 2px sides, and Communities' seat widens a little less. */
  #platform-tabs { grid-template-columns: 1fr 1fr 1fr 1.2fr 1fr; }
  .platform-tab { padding: 0; }
  .platform-tab .platform-tab-label { font-size: 10.5px; letter-spacing: -.02em; }
}

/* A QUIET GREY DISC, NOT THE BELL'S RED (#2912). This used to be the same
   red disc as #notifications-badge, on the argument that two counts on one
   screen should look alike. But an unread message was ALSO counted in the
   bell, so the same news arrived twice in the loudest colour on the screen,
   and the second red drew the eye away from the one that is the alarm. The
   bell keeps the red; this only says "there is something here".

   ONE MEANING PER BADGE since the 19 Oct 2026 polish board (#18): grey is
   NEW TO READ wherever it is (this, the channels' count beside it, a chat's
   row), the accent is WAITING ON YOU (votes), and red is the bell alone,
   which no longer counts plain messages at all: this count is theirs.

   Same size and place on the phone's bar, on the glyph's corner, with white
   ink. The fill is `--text-faint`, the neutral ramp's lightest step, which
   is #8e8e93 in BOTH themes: a mid grey that reads as a mark on the light
   bar and on the dark one without competing with either. The desktop rail
   moves the count off the glyph altogether (the desktop block below). */
.platform-tab-badge {
  position: absolute;
  top: -3px;
  left: calc(100% - 7px);
  min-width: 1.1rem;
  height: 1.1rem;
  padding: 0 4px;
  box-sizing: border-box;
  border-radius: 999px;
  background: var(--text-faint, #8e8e93);
  color: #fff;
  font-size: 0.65rem;
  font-weight: 700;
  line-height: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* THE COMMUNITIES TAB'S FACE (tab-bar.tsx CommunityTabFace). On the phone's
   bar, a square ring, an app tile's own shape, around the community the tab
   is on (in its colour) or, on All communities, the tab's own people glyph
   (in the tab's ink, so it lights with the tab). The ring is what says the
   tab switches. The desktop rail draws no ring: its row goes back to All
   communities, and the header's name is the switcher there. */
.platform-tab-ring {
  width: 28px; height: 28px; box-sizing: border-box; flex: none;
  display: inline-grid; place-items: center;
  border-radius: 9px;
  box-shadow: inset 0 0 0 2px var(--ring, currentColor);
}
.platform-tab-ring-all .platform-tab-glyph { width: 18px; height: 18px; }
.platform-tab-tile {
  width: 20px; height: 20px; border-radius: 6px; overflow: hidden;
  display: grid; place-items: center;
  font-size: 12px; font-weight: 700; line-height: 1;
}
@media (min-width: 768px) {
  .platform-tab-ring { width: auto; height: auto; border-radius: 0; box-shadow: none; }
  .platform-tab-ring-all .platform-tab-glyph { width: 22px; height: 22px; }
  .platform-tab-tile { width: 22px; height: 22px; }
}
/* THE VOTES IT IS WAITING ON YOU FOR: the accent, because it asks for you.
   The quiet unread-channels count gives way to it on the same glyph. */
.platform-tab-votes {
  position: absolute; top: -4px; left: calc(100% - 8px);
  min-width: 1.1rem; height: 1.1rem; padding: 0 4px; box-sizing: border-box;
  border-radius: 999px; background: var(--accent); color: #fff;
  font-size: 0.65rem; font-weight: 700; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
}
.platform-tab-mark:has(.platform-tab-votes) > #platform-tabs-badge-communities { display: none; }

/* THE MARK'S TILE (#2718). The artwork is a near-black squircle, which is a
   shade the dark bar behind it is also wearing — `bg-zinc-900` on
   #platform-header — so at 26px the tile's own edge disappears and the figure
   floats. The inset hairline is what draws it back, and it is the same
   hairline `.app-icon-tile` puts under an app's artwork one screen down, for
   exactly this reason. Light mode does not need it and gets it anyway: at 6%
   white over a near-black tile it is invisible on a light ground, so one rule
   is correct in both rather than two rules being correct in one each. */
.platform-mark-tile {
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
  /* The file is 261px square drawn at 26: tell the decoder so, rather than
     letting it pick. */
  object-fit: cover;
}




/* ── #platform-parked — the app you left ─────────────────────────────
   frontend/src/features/nav/parked-store.js carries the argument. What lives
   here is the geometry: a 52px strip resting ON the tab bar, out of flow like
   it, with its band reserved by the same token the bar's is. */
.platform-parked {
  position: fixed;
  left: 0;
  right: 0;
  /* ON the bar, not above the screen's edge: the bar already spends the
     home-indicator inset, so the strip's floor is the bar's ceiling and
     neither reserves the strip twice. */
  bottom: var(--platform-bar-h, 0px);
  /* UNDER the bar's z-index by one. They never overlap, so this only decides
     what a shadow falls across — and a temporary offer casting over permanent
     furniture is the wrong way round. */
  z-index: 29;

  display: flex;
  align-items: center;
  gap: 4px;
  height: 52px;
  padding: 0 8px 0 4px;
  border-top: 1px solid var(--app-sheet-line);
  background-color: var(--dc-sheet-fill);
  backdrop-filter: var(--dc-frost);
  -webkit-backdrop-filter: var(--dc-frost);
}
/* Pages scroll under the strip, which sits on the tab bar: faked glass, the
   same layer as the bar (see "THE ONE FAKED LAYER"), so the two read as one
   material on every platform. */
.platform-parked {
  background-color: transparent;
  clip-path: inset(0);
}

/* THE WHOLE STRIP IS THE TARGET. A shortcut whose tappable area is a 60px
   pill inside a full-width bar is a shortcut you miss; the pill is a LABEL
   inside this anchor rather than a button beside it, which is also the only
   valid markup (a button inside an anchor is not). */
.platform-parked-open {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  height: 44px;
  padding: 0 4px 0 8px;
  border-radius: 12px;
  color: var(--text-primary);
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
}
.platform-parked-tile {
  width: 32px;
  height: 32px;
  flex: none;
  border-radius: 9px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 15px;
  font-weight: 700;
}
/* THE APP'S NAME IN FULL, not a description of it. It read "A chat is
   working" for a round, which is a sentence about a session rather than the
   thing you are going back to — and the answer to "what is this" here is the
   app's own name, which is what you were looking at a moment ago. */
.platform-parked-name {
  flex: 1 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 15px;
  font-weight: 600;
}
/* RESUME IS AN ACTION, so it is drawn in the accent, filled (#3618). It was
   the header's periwinkle, which is the header's own ink and nothing else's,
   at a tint light enough that the strip's one action read as a tag. */
.platform-parked-pill {
  flex: none;
  display: inline-flex;
  align-items: center;
  height: 28px;
  padding: 0 12px;
  border-radius: 999px;
  border: 0;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 13px;
  font-weight: 600;
}
/* FORGET, not hide. A strip that comes back on the next screen swap is a
   strip you cannot get rid of, and the handle's promise is that it is there
   until you are done with it. */
.platform-parked-x {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
/* KEYBOARD UP, STRIP GONE (#2762), for the reason `html.un-kb #platform-tabs`
   gives for the bar under it: with the bar gone the strip's floor
   (`--platform-bar-h`) drops to 0 and it would ride on top of the keyboard,
   over the very field being typed into. It comes back with the bar. Not the
   `hidden` class, which the island owns — this is presentation. */
html.un-kb #platform-parked {
  display: none;
}

/* IN AND OUT BY THE BAR (#3376), on the phone. Parked, the strip rises out
   from behind the tab bar (it sits one z-index under it); forgotten, it sinks
   back in, a little quicker, and ends clipped until `hidden` lands. The ROOT
   animates its clip and never a transform: a transform would make it the
   containing block for its fixed `::before` glass layer, which would draw at
   strip size and snap back at the end. The children carry the slide. The
   band the strip reserves does not ease — it feeds every screen's padding —
   so content already ends above where the strip is arriving. */
@media (max-width: 767px) {
  .platform-parked-enter {
    animation: platform-parked-reveal 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
  }
  .platform-parked-enter > * {
    animation: platform-parked-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
  }
  .platform-parked-leave {
    pointer-events: none;
    animation: platform-parked-conceal 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
  }
  .platform-parked-leave > * {
    animation: platform-parked-sink 160ms cubic-bezier(0.4, 0, 1, 1) forwards;
  }
}
@keyframes platform-parked-reveal {
  from { clip-path: inset(100% 0 0 0); }
  to   { clip-path: inset(0); }
}
@keyframes platform-parked-conceal {
  from { clip-path: inset(0); }
  to   { clip-path: inset(100% 0 0 0); }
}
@keyframes platform-parked-rise {
  from { transform: translateY(52px); opacity: 0; }
  to   { transform: translateY(0); opacity: 1; }
}
@keyframes platform-parked-sink {
  from { transform: translateY(0); opacity: 1; }
  to   { transform: translateY(52px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .platform-parked-enter, .platform-parked-leave,
  .platform-parked-enter > *, .platform-parked-leave > * { animation: none; }
}

/* ── THE APP YOU CLOSED, ON ITS WAY INTO RESUME (#3618) ──────────────
   frontend/src/features/nav/resume-motion.ts carries the argument: a
   stand-in for the app (its page colour, its icon) pinned where the app was,
   shrinking into the Resume control in the rail or the strip. Above the page
   and the bars, at the kit's zoom layer and under every kit overlay; never a
   target; gone when the motion ends. The script never runs it with reduced
   motion; the rule below is the belt to that. */
.resume-ghost {
  position: fixed;
  z-index: 9960;
  pointer-events: none;
  transform-origin: 0 0;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  /* The app's own page colour when its frame reported one (set inline);
     the page ground otherwise — a card lifted off whatever is behind it,
     never the wallpaper it would vanish into. */
  background: var(--bg-primary);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line), 0 12px 40px rgba(0, 0, 0, .18);
  will-change: transform, opacity;
}
.resume-ghost-tile {
  width: 72px;
  height: 72px;
  border-radius: 18px;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 36px;
  font-weight: 700;
}
.resume-ghost-tile > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
@media (prefers-reduced-motion: reduce) {
  .resume-ghost { display: none; }
}


/* ═══════════════════════════════════════════════════════════════════
   THE SAME FIVE TABS, STANDING UP — desktop from 768px (#2718)
   ═══════════════════════════════════════════════════════════════════

   A bottom bar is a PHONE shape, and it does not survive the trip: at
   1280px the five tabs sit 250px apart along the foot of the screen, which
   is a row of unrelated buttons rather than a set of places, and it spends
   the one dimension a desktop has plenty of to save the one it does not.

   Every host in the study does the same thing here, and it is not a
   compromise — it is what their desktop apps have always been. Slack,
   Discord, Teams and Telegram Desktop all turn their phone tab bar into a
   LEFT RAIL: the sections stand up, get their labels beside their glyphs
   instead of under them, and the content takes the width back.

   UNDER THE HEADER, not beside it. That is this shell's own arrangement
   rather than a borrowed one, and it is the half of the design the product
   owner chose: the bar names where you are and carries the platform's own
   controls, so it spans the window and the rail hangs below it. It also
   means the header is the SAME OBJECT on both sides of every navigation,
   so nothing about it animates when a screen swaps.

   768px because that is `md`, which is where every other two-level layout in
   this file switches (the Settings sidebar, the Admin console's nav, the
   Messages list-and-thread split). One breakpoint, not a fourth opinion. */

/* ── THE RAIL'S OWN SWITCH (#2718 review) ───────────────────────────
   #sidebar-toggle, in the header's left group. The component carries the
   argument; what lives here is the one fact React must not decide, because
   it does not know the viewport and asking would put a measurement in the
   render path: the control exists only where there is a rail to fold.

   A phone's bar is at the FOOT of the screen and is the only navigation
   there is. Folding it would leave a reader with nothing and no hover to
   bring it back, so below the breakpoint the button is not there — and
   because it is not there, `railOpen` can never leave `true` on a phone. */
.platform-sidebar-toggle {
  display: none;
}

/* ── WHAT REACT STOPPED DECIDING (#2718 review) ──────────────────────
   The toggle's markup is constant and so is its group's class string. Both
   used to be computed from `useVisibility('platform-tabs')` during render,
   and that store is published by public/js/app.js — a classic script, which
   runs BEFORE the deferred island bundle hydrates. The prerender used the
   default and the first client render used the router's real answer, so the
   two disagreed and React threw #418 on every route. A console error on any
   route fails every declared check; 291 of them, which is all of them.

   All three questions are CSS's now, and none of them is part of hydration:

     does the rail exist   → `#platform-tabs.hidden`, which the island
                             publishes through useHiddenClass — and not
                             `.platform-tabs-route-hidden`, which says the
                             ROUTE has none and only the peek is showing it
     is there room for it  → the media query
     has the group anything→ `:has(> #back-btn.hidden)` plus the two above

   #4703 adds a fourth thing the group can hold: the merged header's places
   button, on a phone, on a project page — where the back slot is empty and
   the group used to be gone with it. A group holding it is not empty, so
   the `:not(:has(...))` asks. The phone's drawer's back arrow
   (features/nav-drawer/header-drawer.tsx) takes that button's seat, and is
   asked about the same way.

   THE GROUP GOES WHEN IT IS EMPTY, and that is not cosmetic: an
   empty-but-present flex item still reserves the header's own `gap-4`, which
   put the wordmark 16px in from the edge of every root screen the first time
   this was tried. Scoped through `#platform-header` for specificity, not for
   reach — the group carries Tailwind's `flex`, app.css loads BEFORE
   tailwind.css, and the utilities win every equal-specificity conflict in
   this file (#938). */
#platform-header .platform-header-left:has(> #back-btn.hidden):not(:has(> .header-places-btn)):not(:has(> .nav-drawer-back)) {
  display: none;
}
@media (min-width: 768px) {
  /* …unless the toggle is in it, which needs both a rail and the width. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-route-hidden)) #platform-header
    .platform-header-left:has(> #back-btn.hidden) {
    display: flex;
  }
}

@media (min-width: 768px) {
  /* NO DISC AT REST, EITHER WAY (#2798). The button used to wear the bar's
     periwinkle disc for as long as the rail was up, which read as a control
     permanently highlighted. The disc is a HOVER state now (the component's
     `hover:` classes); what still tells the two states apart is the ink —
     periwinkle while the rail is up, muted while it is folded. Colour only:
     the button does not move, because it is the fixed thing the layout
     pivots around when the whole content column slides.

     `body:has(...)` is what replaces the `return null` this component used
     to do for itself: a route with no rail has no fold to offer, and a dead
     control is worse than none.

     A PEEK IS NOT A RAIL THE ROUTE HAS. Over a running app the peek takes
     `hidden` off the bar, and this rule used to answer that alone: pointing
     at the window's left edge inside an app put the toggle into the app's
     own strip, pushed ✕, the tile and the name 34px right, and pressing it
     folded the docked rail behind the app. `.platform-tabs-route-hidden` is
     the route's answer published beside the peek's (features/nav/
     tab-bar.tsx), so the toggle stays out of the app layer and the peek
     still works. A rail the viewer FOLDED is still the route's, so its
     toggle stays — it is how the fold comes undone. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-route-hidden)) .platform-sidebar-toggle {
    display: inline-flex;
    transition: background-color 140ms ease, color 140ms ease,
                border-color 140ms ease;
  }
  .platform-sidebar-toggle[aria-pressed='false'] {
    background: transparent;
    color: var(--text-muted);
    border-color: transparent;
  }
  .platform-sidebar-toggle[aria-pressed='false']:hover {
    background: var(--brand-tint);
    color: var(--brand-ink);
    border-color: var(--brand-line);
  }
}

@media (prefers-reduced-motion: reduce) {
  .platform-sidebar-toggle { transition: none; }
}

@media (min-width: 768px) {
  /* THE BAND AT THE FOOT GOES AWAY ENTIRELY. What a screen has to clear
     below it is the home-indicator inset and nothing else — on a desktop
     that is 0, and the expression keeps it correct on a tablet that has one.
     The rail's width takes its place, as padding rather than as a band. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden)) {
    --platform-rail-w: var(--platform-rail-full);
    /* The air between the rail and the content, spent again on the right —
       see the padding rule below. 1.5rem is the shell's own outer gutter
       (`px-4` is 1rem and every pane steps out to 1.5rem at `sm`), so this
       is the page's existing rhythm rather than a number for this one edge. */
    --platform-gutter: 1.5rem;
  }

  /* THE BAND TOKENS NEED THE OTHER RULE'S SELECTOR, EXACTLY (#2718 review).
     `--platform-bar-h` and `--platform-tabs-h` were declared in the block
     above and never once applied. `:has()` takes the specificity of its most
     specific argument, so the phone rule near the head of this section —
     `body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek))`, whose
     argument carries one class more — outranks it and kept winning at every
     width. Measured at 1280: `--platform-bar-h` read `calc(56px + 0px)` and
     `--platform-tabs-h` read `calc(52px + 56px + 0px)`.

     Two things were wrong because of it. The parked strip is placed at
     `bottom: var(--platform-bar-h)`, so the app you left floated 56px above
     the bottom-left corner instead of resting in it. And every desktop
     screen reserved 108px of band at its foot for a bar that is a rail down
     the side.

     Matching the selector character for character is the fix, and it is the
     right selector on its own terms: a PEEKING rail reserves nothing, which
     is what that `:not()` has always meant. Source order decides between two
     rules of equal specificity, and this one is thousands of lines later. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek)) {
    --platform-bar-h: 0px;
    --platform-tabs-h: var(--platform-safe-bottom);
  }
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek)):has(#platform-parked:not(.hidden)) {
    --platform-tabs-h: var(--platform-safe-bottom);
  }

  /* A FOLDED RAIL SPENDS NO WIDTH (#2718 review, #sidebar-toggle).
     Same specificity as the rule above and immediately after it, so source
     order decides — which is the whole of the arrangement: the rail's other
     tokens (`--platform-bar-h: 0px`, the tabs band) stay exactly as they are,
     because folding changes where the navigation IS and not whether this is
     the desktop layout. The gutter deliberately survives: content that ran to
     the window's edge the moment the rail went away would make folding it a
     bigger change than the reader asked for.

     INSIDE THE MEDIA QUERY AND NOWHERE ELSE. Below 768px the bar is the
     phone's bottom bar and the only navigation there is; the class is still
     on the element after a resize, and this is what keeps it inert there. */
  html:not(.un-kb) body:has(#platform-tabs.platform-tabs-folded) {
    --platform-rail-w: 0px;
  }

  /* ── WHERE THE BAR MEETS THE RAIL (#2718 review) ──────────────────
     The header is `rounded-b-2xl`, which is right while it floats over the
     page with nothing under its corners. The rail arrives directly beneath
     its left one, so that 1rem curve cut a bite out of the top of the rail
     and the two surfaces — the same material since this review — read as one
     pane with a chip out of it. Square on the left only: the right corner
     still has page under it and keeps its curve.

     AND A HAIRLINE ACROSS THE SEAM, which is the same line the rail already
     draws down its right edge, so the rail is outlined on the two sides that
     touch something rather than on one. It is on the RAIL rather than the
     header because the rail is what it bounds: a border under the header
     would run the whole width of the window and divide the bar from the page
     as well, which is a different statement and not the one asked for.

     Both are keyed off the rail actually occupying its column. A FOLDED rail
     is not there, so the corner keeps its curve; a folded rail PEEKING is an
     overlay with the page still underneath, and the curve is right there for
     the same reason it is right over an app. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-folded)) #platform-header {
    border-bottom-left-radius: 0;
  }
  .platform-tabs:not(.platform-tabs-folded) {
    border-top: 1px solid var(--app-sheet-line);
  }

  /* THE SCREENS MOVE OVER, and they move over by PADDING rather than by a
     layout change: every root is a `flex-1` child of the body column and
     stays one, so nothing about the flex chain, the scroll containers or the
     view transitions is touched. A root's own centred column then centres in
     what is left, which is what it should do.

     #app-view is deliberately absent: an app covers the rail, which is what
     "the app is the whole window" means, and the rail is hidden there anyway
     so the token would be 0 even if it were listed.

     #agent-session-screen was absent by omission (QA 2026-09-24 Q23): it
     arrived after this list, drew from the window's left edge, and its app
     and change chips, the reply suggestions and the start of the composer sat
     under the rail where they could not be clicked.

     #profile-proposals-screen likewise (#3498): Your requests and Your votes
     centred in the whole window, so from a laptop width down their cards
     began under the rail. */
  :is(#home-screen, #browse-screen, #workshop-screen, #leaderboard-screen,
      #profile-screen, #admin-screen, #settings-screen, #messages-screen,
      #global-chat-screen, #agent-session-screen, #profile-proposals-screen) {
    /* A GUTTER AFTER THE RAIL, MIRRORED ON THE FAR EDGE (#2718 review).
       The rail's hairline was the content's left margin, so a card began
       where the rail ended and the page had air on the right and none on the
       left. Spending the same figure on both sides is what makes a centred
       column inside actually centre in the window rather than sit a gutter's
       width to the left of centre. */
    padding-left: calc(var(--platform-rail-w, 0px) + var(--platform-gutter));
    padding-right: var(--platform-gutter);
  }

  /* …AND #app-view JOINS THEM WHILE ITS RAIL IS UP (#2718 review). It is
     absent from the list above because that list is keyed on the screen id
     and this screen is TWO screens behind one id: the running app on the
     `app` tab, which takes the whole window, and on `dev` the platform's own
     Workshop for it, which is a platform screen like the others.

     Keyed off the RAIL rather than off the tab, because the rail is already
     the answer to that question — App._syncPlatformTabs publishes it — and a
     second flag saying the same thing is a second flag to keep in step. The
     peeking and folded cases are excluded for the reason they always are: a
     rail that claimed 224px on the way in would reflow the page under the
     pointer that revealed it. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek):not(.platform-tabs-folded)) #app-view {
    padding-left: calc(var(--platform-rail-w, 0px) + var(--platform-gutter));
    padding-right: var(--platform-gutter);
  }

  /* …AND A RUNNING APP, WHOSE RAIL STAYS BESIDE IT (#3319). On by default
     now, and Settings → Theme → "Keep sidebar open in apps" switches it off
     (App._syncPlatformTabs publishes it and puts `rail-pinned` on the body;
     the header's corner is then the sidebar toggle, not a ✕). Folded, the
     rule above stands aside and the app has the whole window.
     The frame steps out of the rail's column and nothing more: the gutters
     are a platform page's rhythm, and somebody's program still runs edge to
     edge in what is left. One class more than the rule above, so it wins.
     Inside this block only — a phone never gets the class, and a window
     narrowed past 768px with it set keeps the full-screen app. */
  html:not(.un-kb) body.rail-pinned:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek):not(.platform-tabs-folded)) #app-view {
    padding-left: var(--platform-rail-w, 0px);
    padding-right: 0;
  }

  /* THE RAIL STAYS OVER AN APP THAT IS ZOOMING. Opening an app out of its
     tile (and shrinking it back into one) pins #app-view as a fixed card at
     z-index 9960 over its whole box, wallpaper and all, and that box runs
     under the rail: the rail's room is the padding above. So with the rail
     beside the app it vanished for the length of the zoom and snapped back
     when the card was lifted. While the card is pinned (PlatformUI.markZoom
     puts `data-zooming` on it for exactly that long) the docked rail and its
     foot sit over the card instead; the part of the card under them is only
     the padding. Still under the kit's
     sheets (9990 and up). A route whose rail is hidden is untouched. */
  body:has(#app-view[data-zooming]) :is(#platform-tabs, #platform-parked):not(.hidden) {
    z-index: 9961;
  }

  .platform-tabs {
    /* FROM UNDER THE HEADER TO THE FLOOR. The header is `position: relative`
       in normal flow, so its height is a token rather than something to
       measure — `--platform-header-h`, which the bar's own py-3 arithmetic
       keeps true, plus whatever strips are above it. */
    top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h)
      + var(--platform-safe-top, 0px));
    right: auto;
    bottom: 0;
    /* THE FULL WIDTH, not the reserved one: this is the element being drawn,
       and while it peeks the reserved figure is deliberately 0. */
    width: var(--platform-rail-full);

    /* A COLUMN THAT DOES NOT STRETCH. `grid-template-rows: repeat(5, 1fr)`
       would spread five rows over 800px of rail, which is the bottom bar's
       mistake turned on its side.

       FLEX RATHER THAN GRID since #2718's review, for one reason: Me sits at
       the FOOT of this rail now, and `margin-top: auto` is how an item claims
       the leftover space in a flex column. In a grid with `align-content:
       start` the rows are packed at the top and an item's auto margin only
       moves it inside its own `max-content` row, which is no movement at all.
       Everything else is the same arrangement said the other way: a column of
       rows at their natural height, packed from the top. */
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 2px;
    /* #4417: 4px at the sides in a 76px strip, so a label has 68px. */
    padding: 8px 4px;
    /* WHAT THE PARKED STRIP RESTS ON. That strip is `position: fixed` to the
       foot of this column and overlays it, so the rail owes it a floor's
       worth of room or the last row reads through it. `--platform-bar-h` is
       0 here (the band at the foot of a phone is a rail at the side of a
       desktop), which is why this is spelled out rather than borrowed. */
    padding-bottom: calc(8px + var(--platform-safe-bottom, 0px));

    border-top: 0;
    border-right: 1px solid var(--app-sheet-line);
  }

  /* ── ME SITS AT THE FOOT (#2718 review) ──────────────────────────────
     "Move Me to the bottom left of the sidebar, like on the prototype." It
     is the one tab that is not a place in the product — it is the reader —
     and every rail in the study puts the account at the bottom for exactly
     that reason: Slack, Discord, Linear, Notion, VS Code. The other four are
     a list of sections; this is who is looking at them.

     An auto top margin and nothing else: no reordering in the markup, no
     second element, no id moved. The margin is on the rule drawn above Me
     now (below), which pins the pair to the foot. The DOM order is still the reading order a
     screen reader announces and the order the phone's bar lays out, and this
     rule lives inside the desktop media query because on a phone the five
     are a row across the foot and an auto margin there would drop Me out of
     line with its four neighbours.

     THE PARKED STRIP SITS ABOVE IT, which is the prototype's order: the
     sections, then the app you left, then you. */
  /* A THIN RULE ABOVE ME (#2800, #2878): the sections and the history above
     it, you below it. It is the rail's own `::after` rather than an element
     in the markup, drawn before Me with `order` — so the DOM, the reading
     order and the shell's id inventory are untouched, and the phone's bar
     (where none of this media query applies) never sees it. Me used to carry
     the `margin-top: auto`; the rule carries it now, and Me follows it. */
  .platform-tabs::after {
    content: "";
    order: 1;
    flex: none;
    height: 1px;
    margin: auto 4px 6px;
    background: var(--app-sheet-line);
  }
  /* SETTINGS, THEN YOU, AT THE FOOT (#3120, #4417): after the rule, your
     settings next to you, and you last. The markup does not move (the cog
     follows Me there); `order` places them after the rule's spacer. The
     sidebar of rows puts the cog at the end of your row (its block below). */
  .platform-rail-settings {
    order: 2;
  }
  #platform-tab-me {
    order: 3;
  }
  /* The cog: the ink, radius, lit fill and hover are a tab's, so it reads as
     one more place in the rail rather than a toolbar button.
     ONE STEP ABOVE THE TABS (#4 of the 19 Oct polish board). A flex item
     paints in `order`, not in markup order, so Me (`order: 3`) painted over
     the cog (`order: 2`) at the same z-index 1. In the sidebar of rows Me's
     row runs under the cog, and its padding left room to see the cog but
     not to press it: every click on the cog opened Profile, and Me's lit
     fill covered it on Profile, Settings and the leaderboard. */
  .platform-rail-settings {
    position: relative;
    z-index: 2;
    align-self: center;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 40px;
    margin-bottom: 2px;
    border-radius: 12px;
    color: var(--text-muted);
    text-decoration: none;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 120ms ease, color 120ms ease;
  }
  .platform-rail-settings[aria-current="page"] {
    background: var(--lit-tint);
    color: var(--lit-ink);
  }
  .platform-rail-settings-glyph {
    width: 22px;
    height: 22px;
    flex: none;
  }
  @media (hover: hover) {
    .platform-rail-settings:not([aria-current="page"]):hover {
      background: color-mix(in srgb, var(--lit-tint) 55%, transparent);
      color: var(--lit-ink);
    }
  }
  /* The rail keeps its own fill on the lit row; the phone's sliding marker
     is not drawn here (#2824). */
  .platform-tabs-marker {
    display: none;
  }
  /* A TAB ANSWERS THE POINTER (#2798). The rail's rows had no hover at all,
     so nothing said a row was a target until it was pressed. The hover is the
     same periwinkle as the lit tab at about half the strength, so the tab you
     are on stays the strongest thing in the rail and the one under the
     pointer reads as "this one, if you press". Only where there IS a pointer:
     a touch screen's sticky hover would leave a tapped tab half lit. */
  .platform-tab {
    transition: background-color 120ms ease, color 120ms ease;
  }
  @media (hover: hover) {
    .platform-tab:not([aria-current="page"]):hover {
      background: color-mix(in srgb, var(--lit-tint) 55%, transparent);
      color: var(--lit-ink);
    }
  }

  /* THE RESUME STRIP GIVES WAY TO THE SIDEBAR ON THE DESKTOP (#2802,
     #4417). A running app keeps its App row, with its dot, under its
     community there (features/nav/rail-communities.tsx), the same offer for
     every app still open, so a second copy at its foot would say it twice.
     The phone has no sidebar and keeps it. By id so no later
     `.platform-parked` rule can bring it back. */
  #platform-parked {
    display: none;
  }
}

/* ── D: THE SIDEBAR OF ROWS, ON EVERY DESKTOP WINDOW (18 Oct 2026) ───────
   features/nav/sidebar-rows.ts carries the argument. From 768px the rail
   is one 212px sidebar for the whole platform: Home, Discover and Messages
   as rows, then COMMUNITIES (features/nav/rail-communities.tsx: the five you
   opened last, then the Communities tab drawn as "All 13 communities"),
   then PINNED (only while you have pins) and RECENT APPS (./strip-apps.tsx),
   and you at the foot with Settings beside you, a community's places and
   the inbox's latest conversations nested under their rows (v3). It began
   at 1024px with the 76px strip and its 264px column below that, 340px
   where this is 212px; from 768px it is the one desktop rail, and the
   phone's bar never sees any of this. The block comes after the rail's own
   768px block, so at the same width it wins, and what that block still
   says about the strip is what this one does not override.

   The parts only the sidebar draws are in the markup at every width and
   drawn only here. */
.platform-rail-communities,
.platform-rail-conversations,
.platform-tab-all-glyph,
.platform-tab-all-label,
.platform-tab-all-votes,
.platform-rail-pinned {
  display: none;
}

/* ── ALL COMMUNITIES: ONE LIST, UNDER A SEARCH AND ITS CHIPS (#5296) ──
   features/workshop/index.tsx. The search heads the list at every width
   (D drew it from 768px only); the sidebar's Communities heading opens it
   lit. A search's answer is the list itself, so nothing here hides
   anything while it is up: the screen leaves Join and Start out itself. */
.workshop-search {
  margin: 0 16px 10px;
}
.workshop-search .messages-search {
  margin: 0;
}
/* LIT, from the sidebar's search (features/workshop/communities-search.ts),
   until focus leaves it: the place's own tint, so the eye lands on it. */
.workshop-search[data-lit] .messages-search-input {
  border-color: var(--lit-ink);
  background: var(--lit-tint);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--lit-ink) 22%, transparent);
}
.workshop-search-none {
  margin: 0;
  padding: 12px 22px;
  color: var(--text-muted);
  font-size: 14px;
}

@media (min-width: 768px) {
  /* THE ONE NUMBER THE REST FOLLOWS. Everything the strip's width placed
     (the screens' padding among it) reads this token, so the sidebar
     moves them all at once. */
  body {
    --platform-rail-full: 212px;
  }
  .platform-tabs {
    gap: 1px;
    padding: 10px 6px calc(10px + var(--platform-safe-bottom, 0px));
  }

  /* A ROW: the glyph, then the words, then the count at the far end. The
     glyph's wrapper dissolves so its counts can sit at the row's end, as the
     places column's counts do. */
  .platform-tab {
    flex: none;
    flex-direction: row;
    justify-content: flex-start;
    gap: 9px;
    height: 36px;
    padding: 0 8px;
    border-radius: 10px;
    color: var(--text-primary);
    font-size: 14px;
    font-weight: 500;
    letter-spacing: 0;
    line-height: 18px;
  }
  .platform-tab[aria-current="page"] {
    background: var(--lit-tint);
    color: var(--lit-ink);
    font-weight: 600;
  }
  .platform-tab-mark {
    display: contents;
  }
  .platform-tab-glyph {
    width: 20px;
    height: 20px;
    color: var(--text-muted);
  }
  .platform-tab[aria-current="page"] .platform-tab-glyph {
    color: inherit;
  }
  .platform-tab-label {
    flex: 1 1 auto;
    text-align: left;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .platform-tab-badge,
  .platform-tab-votes,
  .platform-tab-all-votes {
    position: static;
    order: 2;
    flex: none;
    min-width: 18px;
    height: 18px;
    padding: 0 5px;
    font-size: 11px;
  }

  /* THE COMMUNITIES TAB IS THE SECTION'S ALL ROW: the grid and "All 13
     communities" in place of the ring and the word, and on it every vote
     waiting on you (`.platform-tab-all-votes`, the tab's own figure: #18 of
     the 19 Oct polish board; it used to be only the votes in the rows not
     listed, which read as a total and was not one). The grey count of
     unread channels stays until a vote takes its seat, as on the strip:
     one number per row. */
  #platform-tab-workshop .platform-tab-ring,
  #platform-tab-workshop .platform-tab-label,
  #platform-tab-workshop .platform-tab-votes {
    display: none;
  }
  #platform-tab-workshop .platform-tab-all-glyph {
    display: block;
  }
  #platform-tab-workshop .platform-tab-all-label {
    display: block;
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  #platform-tab-workshop .platform-tab-all-votes {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-weight: 700;
    line-height: 1;
  }
  #platform-tab-workshop .platform-tab-mark:has(.platform-tab-votes) > #platform-tabs-badge-communities:not(.hidden) {
    display: inline-flex;
  }
  /* As heavy as the rule above, and after it, so it wins (#18): it was
     (2,1,0) against (2,3,0), and the All row drew a grey 1 beside a blue 28. */
  #platform-tab-workshop:has(.platform-tab-all-votes) .platform-tab-mark > #platform-tabs-badge-communities:not(.hidden) {
    display: none;
  }
  /* ON A COMMUNITY'S PAGE ITS OWN ROW IS LIT, NOT ALL. The tab keeps
     `aria-current` (the router lights Communities there, and the declared
     checks read it); only its fill gives way to the row that says which. */
  .platform-tabs:has(.platform-rail-community[aria-current="page"]) #platform-tab-workshop[aria-current="page"] {
    background: transparent;
    color: var(--text-primary);
    font-weight: 500;
  }
  .platform-tabs:has(.platform-rail-community[aria-current="page"]) #platform-tab-workshop[aria-current="page"] .platform-tab-glyph {
    color: var(--text-muted);
  }

  /* ── THE HEADINGS: the places column's small capitals ── */
  .platform-rail-head {
    position: relative;
    flex: none;
    display: flex;
    align-items: center;
    height: 28px;
    margin-top: 12px;
    padding: 0 8px;
    color: var(--text-muted);
    font-size: 12px;
    font-weight: 700;
    line-height: 16px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    white-space: nowrap;
  }
  .platform-rail-head-label {
    flex: 1 1 auto;
    min-width: 0;
    padding-right: 28px;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  /* THE SEARCH BESIDE "COMMUNITIES", while the pointer is over the section
     (its heading, its rows or All under them) or the search has focus. A
     touch screen has no hover to find it with, so there it stays. */
  .platform-rail-search {
    position: absolute;
    top: 50%;
    right: 2px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 26px;
    height: 26px;
    border-radius: 8px;
    color: var(--text-muted);
    opacity: 0;
    transform: translateY(-50%);
    text-decoration: none;
    transition: opacity 120ms ease, background-color 120ms ease, color 120ms ease;
    -webkit-tap-highlight-color: transparent;
  }
  .platform-rail-search-glyph {
    width: 16px;
    height: 16px;
    stroke-width: 2;
  }
  .platform-rail-communities:hover .platform-rail-search,
  .platform-rail-communities:has(+ #platform-tab-workshop:hover) .platform-rail-search,
  .platform-rail-search:focus-visible {
    opacity: 1;
  }
  .platform-rail-search:hover,
  .platform-rail-search:focus-visible {
    background: color-mix(in srgb, var(--lit-tint) 70%, transparent);
    color: var(--lit-ink);
  }
  .platform-rail-search:focus-visible {
    outline: 2px solid var(--lit-ink);
    outline-offset: 1px;
  }
  @media (hover: none) {
    .platform-rail-search { opacity: 1; }
  }

  /* ── YOUR COMMUNITIES (features/nav/rail-communities.tsx) ── */
  .platform-rail-communities {
    display: flex;
    flex: none;
    flex-direction: column;
    gap: 1px;
  }
  /* Until its rows are drawn (the heading is all it holds: the prerender,
     and a cold load until the list is read), it keeps the height this device
     last drew it at, so "All 31 communities" under it does not drop 185px as
     they arrive (#10; `first-paint-reserved`, as Home's areas). */
  html.first-paint-reserved .platform-rail-communities:has(> .platform-rail-head:last-child) {
    min-height: var(--reserve-rail-communities);
  }
  .platform-rail-community {
    position: relative;
    z-index: 1;
    display: flex;
    align-items: center;
    gap: 9px;
    height: 36px;
    padding: 0 8px;
    border-radius: 10px;
    color: var(--text-primary);
    font-size: 14px;
    font-weight: 500;
    line-height: 18px;
    text-decoration: none;
    white-space: nowrap;
    -webkit-tap-highlight-color: transparent;
    transition: background-color 120ms ease, color 120ms ease;
  }
  .platform-rail-community[aria-current="page"] {
    background: var(--lit-tint);
    color: var(--lit-ink);
    font-weight: 600;
  }
  .platform-rail-tile {
    flex: none;
    width: 22px;
    height: 22px;
    border-radius: 6px;
    overflow: hidden;
    display: grid;
    place-items: center;
    font-size: 12px;
    font-weight: 700;
    line-height: 1;
  }
  .platform-rail-tile > span { font-size: 13px; }
  .platform-rail-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .platform-rail-votes {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    min-width: 18px;
    height: 18px;
    padding: 0 5px;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 11px;
    font-weight: 700;
    line-height: 1;
  }
  @media (hover: hover) {
    .platform-rail-community:not([aria-current="page"]):hover,
    .platform-tabs:has(.platform-rail-community[aria-current="page"]) #platform-tab-workshop:hover {
      background: color-mix(in srgb, var(--lit-tint) 55%, transparent);
      color: var(--lit-ink);
    }
  }

  /* ── THE COMMUNITIES YOU PINNED (features/nav/rail-pinned.tsx) ──
     Above your communities, under their own heading, drawn as they are. */
  .platform-rail-pinned:not(.hidden) {
    display: flex;
    flex: none;
    flex-direction: column;
    gap: 1px;
  }
  .platform-rail-pinned > .platform-rail-nest {
    margin-top: -1px;
  }
  /* THE "PIN HERE" SLOT (#5323): the one row the section draws while a My
     apps tile is in the air, after the pins. A dashed tile in the lit colour
     says "here"; a drop that would be refused says so in words instead (the
     muted states). */
  .platform-rail-pin-drop {
    display: flex;
    flex: none;
    align-items: center;
    gap: 9px;
    height: 36px;
    padding: 0 8px;
    border-radius: 10px;
    color: var(--text-muted);
    font-size: 14px;
    font-weight: 600;
  }
  .platform-rail-pin-drop[data-drop-state='ok'] {
    background: color-mix(in srgb, var(--lit-tint) 70%, transparent);
    color: var(--lit-ink);
  }
  .platform-rail-pin-drop-tile {
    flex: none;
    width: 22px;
    height: 22px;
    border-radius: 6px;
    border: 1.5px dashed currentColor;
    box-sizing: border-box;
  }
  .platform-rail-pin-drop-label {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  /* THE DRAGGED PINNED ROW'S TILE (#5323): the ghost that follows the pointer
     while a pinned community is dragged back out of the sidebar. Portaled to
     the body (the sidebar clips), pointer-events:none so it never becomes the
     hit-test, and it carries the "Unpin" caption only while it is outside the
     sidebar: the state whose release takes the pin off. */
  .platform-rail-pin-ghost {
    position: fixed;
    left: 0;
    top: 0;
    z-index: 80;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 3px;
    pointer-events: none;
    transform: translate(-50%, -50%) scale(1.05);
  }
  .platform-rail-pin-ghost .platform-rail-tile {
    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
  }
  .platform-rail-pin-ghost-caption {
    padding: 2px 8px;
    border-radius: 8px;
    background: var(--lit-ink);
    color: var(--bg-primary);
    font-size: 10px;
    font-weight: 600;
    white-space: nowrap;
  }
  @media (prefers-reduced-motion: reduce) {
    .platform-rail-pin-ghost { transform: translate(-50%, -50%); }
  }

  /* ── YOU AT THE FOOT, SETTINGS AT THE END OF YOUR ROW ── */
  .platform-tabs::after {
    margin: auto 6px 6px;
  }
  #platform-tab-me {
    padding-right: 46px;
  }
  .platform-rail-settings {
    position: absolute;
    right: 6px;
    bottom: calc(10px + var(--platform-safe-bottom, 0px));
    width: 36px;
    height: 36px;
    margin: 0;
    border-radius: 10px;
  }
  .platform-rail-settings-glyph {
    width: 20px;
    height: 20px;
  }

  /* ── v3: WHAT THE SECOND COLUMN HELD, NESTED UNDER ITS ROW ────────────
     features/nav/rail-nest.tsx. The community whose page is up opens its
     places under its row (features/nav/rail-communities.tsx: the section
     column's own list, ../dev-board/workshop/project-places.tsx), and the
     inbox opens its five latest conversations and All messages under
     Messages (features/nav/rail-conversations.tsx). So there is no second
     column from here: the page has the 264px it took.

     ONE INDENT A LEVEL, ONE LINE. A nested row's glyph starts 30px in; a
     group under a sub-header (Channels, Topics, Agents, People) is tabbed in
     a step further, its sub-header where the rows above it start. One
     hairline runs unbroken from under the parent's tile to the last row,
     past the sub-headers, All messages included. The sub-headers are
     sentence case: the section headings' small capitals are a level up.

     THE PARENT ROW GIVES ITS FILL TO THE ROW LIT UNDER IT. It keeps
     `aria-current` (the declared checks and a screen reader read it), and is
     named in the ink and a heavier weight instead, so one row is lit. */
  .platform-rail-conversations {
    display: flex;
    flex: none;
    flex-direction: column;
  }
  /* The part's one grid row is what features/nav/rail-nest.tsx animates
     (`0fr` to `1fr`, which is the rows' own height on every frame). */
  /* ITS ONE COLUMN IS THE RAIL'S WIDTH (N19). With no column size the
     implicit track grew to the widest row, which never wraps (350px inside
     199px), so a long name ran past the edge and was clipped by the
     overflow with no ellipsis. Sized to the rail, each label ends in "…". */
  .platform-rail-nest {
    flex: none;
    display: grid;
    grid-template-rows: minmax(0, 1fr);
    grid-template-columns: minmax(0, 1fr);
    overflow: hidden;
  }
  .platform-rail-nest[data-nest="closing"] {
    pointer-events: none;
  }
  /* Under a community's row the part is a child of the section, whose 1px
     gap would arrive with it and go with it: a step in the rows below on
     both ends of the move. */
  .platform-rail-communities > .platform-rail-nest {
    margin-top: -1px;
  }
  .platform-rail-nest-in {
    position: relative;
    display: flex;
    flex-direction: column;
    min-height: 0;
    padding: 1px 0 4px;
  }
  .platform-rail-nest-in::before {
    content: "";
    position: absolute;
    top: 2px;
    bottom: 6px;
    left: 18px;
    width: 1px;
    background: color-mix(in srgb, var(--text-muted) 38%, transparent);
    pointer-events: none;
  }
  .platform-rail-nest .dev-ws-places {
    min-height: 0;
  }
  .platform-rail-nest :is(.dev-ws-places-nav, .dev-ws-places-group, .platform-rail-group) {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }
  .platform-rail-nest .dev-ws-places-line {
    display: none;
  }
  .platform-rail-nest :is(.dev-ws-place, .platform-rail-sub) {
    position: relative;
    display: flex;
    align-items: center;
    gap: 8px;
    box-sizing: border-box;
    width: auto;
    height: 30px;
    margin-left: 24px;
    padding: 0 8px 0 6px;
    border-radius: 8px;
    color: var(--text-primary);
    font-size: 13.5px;
    font-weight: 500;
    line-height: 18px;
    text-decoration: none;
    white-space: nowrap;
    -webkit-tap-highlight-color: transparent;
  }
  .platform-rail-nest :is(.dev-ws-places-group .dev-ws-place, .platform-rail-group[data-rail-headed] .platform-rail-sub) {
    margin-left: 36px;
  }
  .platform-rail-nest :is(.dev-ws-place, .platform-rail-sub)[aria-current="page"] {
    background: var(--lit-tint);
    color: var(--lit-ink);
    font-weight: 600;
  }
  @media (hover: hover) {
    .platform-rail-nest :is(.dev-ws-place, .platform-rail-sub):not([aria-current="page"]):hover {
      background: color-mix(in srgb, var(--lit-tint) 55%, transparent);
      color: var(--lit-ink);
    }
  }
  .platform-rail-nest :is(.dev-ws-place-glyph, .platform-rail-sub-glyph) {
    flex: 0 0 16px;
    width: 16px;
    height: 16px;
    margin: 0;
    color: var(--text-muted);
  }
  .platform-rail-nest [aria-current="page"] :is(.dev-ws-place-glyph, .platform-rail-sub-glyph) {
    color: inherit;
  }
  .platform-rail-face {
    flex: none;
    display: grid;
    width: 18px;
    height: 18px;
    place-items: center;
  }
  /* Over the avatar's own size utilities, which load after this file (#938). */
  .platform-rail-nest .platform-rail-face > * {
    width: 18px;
    height: 18px;
    border-radius: 5px;
    font-size: 9px;
  }
  .platform-rail-face .messages-inbox-agent-tile {
    background: none;
    border: 0;
    color: var(--text-muted);
  }
  .platform-rail-face svg {
    width: 16px;
    height: 16px;
  }
  .platform-rail-nest :is(.dev-ws-place-label, .platform-rail-sub-name) {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .platform-rail-nest :is(.dev-ws-place-count, .platform-rail-sub-count) {
    flex: none;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    min-width: 16px;
    height: 16px;
    padding: 0 4px;
    border-radius: 999px;
    background: var(--accent);
    color: #fff;
    font-size: 10px;
    font-weight: 700;
    line-height: 1;
  }
  /* A conversation's and a channel's count is new to read (#18): grey, as
     the Messages tab's is. Needs you's votes keep the accent. */
  .platform-rail-nest :is(.platform-rail-sub-count, :is([data-place="discussion"], [data-place^="c:"]) > .dev-ws-place-count) {
    background: var(--text-faint, #8e8e93);
  }
  .platform-rail-nest .dev-ws-places-more {
    padding-left: 30px;
    color: var(--text-muted);
    font-size: 12.5px;
  }
  .platform-rail-nest :is(.dev-ws-places-head, .platform-rail-subhead),
  .platform-rail-nest .dev-ws-places-group + .dev-ws-places-group .dev-ws-places-head {
    flex: none;
    padding: 8px 8px 3px 30px;
    color: var(--text-muted);
    font-size: 12px;
    font-weight: 600;
    line-height: 16px;
    letter-spacing: 0;
    text-transform: none;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  .platform-rail-nest-in > :first-child > .platform-rail-subhead {
    padding-top: 3px;
  }
  .platform-rail-nest .platform-rail-group:not([data-rail-headed]) {
    padding-top: 4px;
  }
  /* A COMMUNITY'S APP, ITS FIRST ROW (features/nav/rail-communities.tsx),
     with the green "still open" dot at its end while the app runs: the dot
     the launcher's tile wears, without the ring a tile needs. Its places sit
     in a part inside the one that holds it, so leaving a community whose app
     is still running folds them away and keeps App. The outer part draws the
     one line and the padding round both. */
  .platform-rail-nest .platform-rail-live {
    width: 8px;
    height: 8px;
    margin-right: 3px;
    box-shadow: none;
  }
  /* The dot's close control (#5525): an X in a small round disc, sitting
     exactly over the dot (the anchor's 8px right padding, the dot 3px in
     from that, its centre 15px from the row's edge) and shown only on the
     disc's hover or keyboard focus. The dot is hidden while the X shows, so
     the two are never drawn together. */
  .platform-rail-nest .platform-rail-app-row {
    position: relative;
  }
  .platform-rail-nest .platform-rail-live-stop {
    position: absolute;
    top: 50%;
    right: 6px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-sizing: border-box;
    width: 18px;
    height: 18px;
    padding: 0;
    border: 0;
    border-radius: 999px;
    background: color-mix(in srgb, var(--lit-tint) 55%, transparent);
    color: var(--text-primary);
    opacity: 0;
    cursor: pointer;
    transform: translateY(-50%);
  }
  .platform-rail-nest .platform-rail-live-stop:hover,
  .platform-rail-nest .platform-rail-live-stop:focus-visible {
    opacity: 1;
  }
  .platform-rail-nest .platform-rail-live-stop-glyph {
    width: 11px;
    height: 11px;
  }
  .platform-rail-nest .platform-rail-app-row:has(.platform-rail-live-stop:is(:hover, :focus-visible)) .platform-rail-live {
    visibility: hidden;
  }
  .platform-rail-nest .platform-rail-places > .platform-rail-nest-in {
    padding: 0;
  }
  .platform-rail-nest .platform-rail-app-row + .platform-rail-places > .platform-rail-nest-in {
    padding-top: 1px;
  }
  .platform-rail-nest .platform-rail-places > .platform-rail-nest-in::before {
    display: none;
  }
  /* The parent: lit by its page, filled by none, its count said under it.
     Messages only while a row under it is lit (#21): a conversation that is
     not among the five adds no row now, and Messages keeps its fill. */
  .platform-rail-community[data-rail-open][aria-current="page"],
  .platform-tabs:has(#platform-rail-conversations > [data-nest="open"] [aria-current="page"]) #platform-tab-messages[aria-current="page"] {
    background: transparent;
    color: var(--text-primary);
    font-weight: 650;
  }
  .platform-tabs:has(#platform-rail-conversations > [data-nest="open"] [aria-current="page"]) #platform-tab-messages[aria-current="page"] .platform-tab-glyph {
    color: var(--text-primary);
  }
  /* The places are in the sidebar, so the place bar's button to the tray has
     nothing to open beside it. Folded, the sidebar is away and the button
     is the way to the places again. */
  html:not(.un-kb) body:has(#platform-tabs:not(.hidden):not(.platform-tabs-peek):not(.platform-tabs-folded)) .dev-ws-places-btn {
    display: none;
  }

  /* ── MESSAGES, ONE PANE AT A TIME (v3) ───────────────────────────────
     features/messages/index.tsx. The list is in the sidebar, so an open
     conversation has the page to itself (`messages-list-in-rail`: the list
     and its full-width toggle step aside, as on a strip too narrow for
     both), and with nothing open the whole list is the page
     (`messages-list-page`, All messages): its search, its headings, its rows
     with their previews and times, in a readable column. */
  .messages-layout.messages-list-in-rail > .messages-list-pane {
    display: none;
  }
  .messages-layout.messages-list-in-rail .messages-list-toggle {
    display: none;
  }
  .messages-layout.messages-list-in-rail > .messages-thread-pane {
    margin-left: 0;
  }
  .messages-layout.messages-list-page > .messages-thread-pane {
    display: none;
  }
  .messages-layout.messages-list-page > .messages-list-pane {
    flex: 1 1 auto;
    width: auto;
    max-width: 720px;
    margin: 0 auto;
    padding-right: 0;
  }
}

/* ── Messages is one inbox, and says which kind each row is (#2718) ──
   frontend/src/features/messages/inbox.ts carries the argument: people, app
   discussions and agent chats in one list, ordered on one clock, told apart
   by a mark rather than by three sidebars — which is what Slack and Teams do
   with a channel, a DM and a bot thread. What lives here is the filter row
   and the two shapes the list grew. */

/* THE FILTER ROW, with the control that starts something at its far end.
   The plus moved here from beside the title: a title row is where a screen
   says what it IS, a filter row is where it says what it is SHOWING, and the
   thing that adds to what is shown belongs on the line with the thing that
   narrows it. The Workshop's tab strip now wears the same arrangement, which
   is what makes the two screens read as one product. */
/* ── THE INBOX'S OWN CHROME (#2718 review) ────────────────────────────
   A search, a segmented strip, and what starts something — in that order,
   which is the order the questions come in: what am I looking for, which
   kinds am I looking through, and what if it is not here yet. */

/* THE SEARCH, at the head of the pane where the screen's own <h2> used to
   sit. The bar above already says Messages; a list that runs to hundreds of
   rows and holds three kinds of thing needed the space for this instead. */
.messages-search {
  position: relative;
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  /* 20px AT THE TOP, NOT 12: the header is `rounded-b-2xl -mb-2`, so this
     pane starts 8px under its bottom edge and 12 of those were spent getting
     back to level — 4px of air under the bar, which reads as none. The 8 the
     notch owes plus 12 of air, matching the step the Workshop's chip takes
     for the same reason. */
  margin: 20px 16px 10px;
}
.messages-search-glyph {
  position: absolute;
  left: 12px;
  color: var(--text-muted);
  pointer-events: none;
}
.messages-search-input {
  width: 100%;
  height: 40px;
  padding: 0 12px 0 40px;
  border: 1px solid var(--app-sheet-line);
  border-radius: 999px;
  background: var(--dc-sheet-raise);
  color: var(--text-primary);
  font: inherit;
  font-size: 15px;
}
.messages-search-input::placeholder { color: var(--text-muted); }
.messages-search-input:focus {
  outline: none;
  border-color: var(--brand-line);
  box-shadow: 0 0 0 3px var(--brand-tint);
}
/* The UA's own clear affordance, which draws a second glyph of its own in a
   size and colour this shell does not choose. */
.messages-search-input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* THE SEARCH AND THE "+" ON ONE LINE (16 Oct 2026). The filter strip the
   "+" ended (#2778) went: the list's sections are headed (Homeroom bot,
   Agents, People), so the headings say which is which. */
.messages-toolbar {
  display: flex;
  flex: 0 0 auto;
  align-items: center;
  gap: 8px;
  margin: 20px 16px 10px;
}
.messages-toolbar .messages-search {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
}
.messages-new-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid var(--brand-line);
  border-radius: 9999px;
  background: var(--brand-tint);
  color: var(--brand-ink);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.messages-new-btn svg { width: 18px; height: 18px; stroke-width: 2; }
.messages-new-btn[aria-expanded="true"] { background: var(--brand-ink); color: var(--bg-primary); }
/* The popover: the vote picker's frame (.dev-vote-pop) — body-mounted,
   fixed, placed from the button's rect by lib/anchor-popover.ts. */
.messages-new-pop {
  position: fixed;
  z-index: 2000;
  width: 240px;
  box-sizing: border-box;
  padding: 6px;
  border-radius: 16px;
  background: var(--bg-primary);
  border: 1px solid var(--border);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.messages-new-option {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 8px 10px;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.messages-new-option:hover, .messages-new-option:focus-visible { background: var(--bg-tertiary); outline: none; }
.messages-new-option svg { flex: 0 0 auto; width: 20px; height: 20px; color: var(--brand-ink); }
.messages-new-option-label { display: block; font-size: 14px; font-weight: 600; }
.messages-new-option-hint { display: block; font-size: 12px; color: var(--text-muted); }

/* THE SECTION HEADS (#2783): "Chats" over the people and agents, "Channels"
   over #general and the apps — Discord's two headings, small and muted so
   they divide the list without competing with its rows. */
.messages-section-head {
  display: flex;
  align-items: center;
  margin: 0;
  padding: 16px 16px 8px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--text-muted);
}
/* 16 Oct 2026: Agents and People fold from their heading, which is one
   button the width of the list, so the whole line is the target. What in
   the section asks for you ("1 working", "3 unread") sits at its trailing
   edge in the row's own case, and stays there folded; a count of unread
   messages from people is the accent, a number that asks for the viewer. */
.messages-section-toggle {
  display: flex;
  flex: 1 1 auto;
  align-items: center;
  gap: 6px;
  min-width: 0;
  margin: -6px 0;
  padding: 6px 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  text-align: left;
  text-transform: inherit;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.messages-section-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
.messages-section-name { min-width: 0; }
.messages-section-aside {
  margin-left: auto;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0;
  text-transform: none;
  white-space: nowrap;
}
.messages-section-aside-ask { color: var(--accent); }
.messages-section-chevron {
  flex: 0 0 auto;
  width: 14px;
  height: 14px;
  stroke-width: 2.5;
  transition: transform 150ms;
}
.messages-section-name + .messages-section-chevron { margin-left: auto; }
@media (prefers-reduced-motion: reduce) { .messages-section-chevron { transition: none; } }
/* A SECTION'S ROWS ARE A CARD: the grouped list's shape (@/components/ui/
   grouped-list.tsx), spelled here because these rows are app.css's. The same
   plane colour, the same 20px radius and the same inset hairline in the
   sheets' edge colour, so Messages, Workshop and Discover read as one
   material. `overflow: hidden` clips a row's hover and selected fill to the
   card's corners. */
.messages-section-card {
  margin: 0 16px 8px;
  overflow: hidden;
  border-radius: 20px;
  background: var(--dc-sheet-solid);
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
}
/* A channel's tile is the `#` it is named with. */
.messages-channel-tile {
  background: var(--bg-tertiary);
  color: var(--text-muted);
  font-size: 22px;
  font-weight: 700;
}
.messages-thread-channel-tile { width: 36px; height: 36px; font-size: 18px; }
/* An app channel's handle, muted after its name. */
.messages-channel-handle {
  margin-left: 6px;
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 500;
}

/* THE KIND MARK. People get none — they are the overwhelming majority of an
   inbox, and a pill on every row is a pill that says nothing. It rides
   INSIDE the name line rather than on the trailing edge, because the
   trailing edge is the time-and-count column and a third thing in it turns
   two readable columns into three cramped ones. */
.messages-kind-pill {
  display: inline-flex;
  align-items: center;
  height: 17px;
  margin-left: 6px;
  padding: 0 6px;
  border-radius: 999px;
  vertical-align: 1px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .02em;
  text-transform: uppercase;
  background: var(--brand-tint);
  color: var(--brand-ink);
  border: 1px solid var(--brand-line);
}

/* The two new rows' leading tiles, at the `lg` square UserAvatar's 44px box
   so all three kinds line up down the left edge. `.app-icon-tile` draws the
   app face; this call site adds only the geometry, which is the rule app.css
   states for every tile call site. */
.messages-inbox-tile {
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 18px;
  font-weight: 700;
}
/* An agent chat has no artwork of its own — it is a thread with the thing
   that builds, not with an app — so it wears the accent tint and the same
   sparkle the Improve panel's own chat rows use. */
.messages-inbox-agent-tile {
  background: var(--brand-tint);
  color: var(--brand-ink);
  border: 1px solid var(--brand-line);
}
/* A change in flight (16 Oct 2026): an agent's pencil on a tint of its
   project's colour (--change-ink, lib/community-color.ts), in the place of
   the project's own icon, which for Homeroom is the bot's face. #5378: at
   the avatars' 44px and 12px radius, so the column's tiles line up. */
.messages-inbox-change-tile {
  display: inline-flex;
  flex: 0 0 auto;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border-radius: 12px;
  color: var(--change-ink, var(--text-muted));
  background: color-mix(in srgb, var(--change-ink, var(--text-muted)) 14%, transparent);
}
.messages-inbox-change-tile svg { width: 22px; height: 22px; }
html.dark .messages-inbox-change-tile {
  color: color-mix(in srgb, var(--change-ink, var(--text-muted)) 45%, white);
  background: color-mix(in srgb, var(--change-ink, var(--text-muted)) 35%, transparent);
}


/* ── The rail, peeked back over a folded rail (#2718, #2764) ─────────
   frontend/src/features/nav/tab-bar.tsx carries the argument: a rail the
   viewer folded comes back on hover and stops spending width while they
   work — an auto-hiding dock and Slack's own collapsed rail are this move.
   NOT over a running app (#3138): an app's own controls put the pointer at
   the left edge all the time, so the hot zone is not rendered there. A rail
   folded beside an app comes back from #sidebar-toggle, in the header. */

/* THE HOT ZONE, and the only thing that can start a peek. Narrow enough that
   it never intercepts a pointer heading for the app's own left edge, wide
   enough to be findable without aiming. */
.platform-rail-peek {
  position: fixed;
  left: 0;
  top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h)
    + var(--platform-safe-top, 0px));
  bottom: 0;
  width: 18px;
  /* UNDER the peeked rail and OVER the app, so the pointer can cross from one
     to the other without passing through the app's own hover targets. */
  z-index: 39;
}
/* A PHONE HAS NO POINTER TO HOVER WITH, and an invisible strip down the left
   edge of a touch screen eats the swipe that goes back. Below the desktop
   breakpoint the zone is not there at all — which is also why the peek can
   only ever happen on the layout that has a rail to peek. */
@media (max-width: 767px) {
  .platform-rail-peek { display: none; }
}

@media (min-width: 768px) {
  /* FOLDED BY HAND: gone, but coming back on hover. Not `hidden` — that is
     the ROUTER's answer about whether this route has a rail, and the two have
     to stay tellable apart (frontend/src/features/nav/tab-bar.tsx). A peeking
     bar keeps both classes and this rule stands aside for it, which is what
     makes the hot zone at the window's edge the way back. It stands aside
     too while the rail slides away (`platform-tabs-leaving`, N21:
     frontend/src/features/nav/rail-slide.ts). */
  .platform-tabs.platform-tabs-folded:not(.platform-tabs-peek):not(.platform-tabs-leaving) {
    display: none;
  }
  /* …AND ITS FOOTER GOES WITH IT (#2762). The parked strip is fixed to the
     rail's corner rather than drawn inside the rail, so a folded rail would
     otherwise leave it floating alone over the foot of the page. */
  body:has(#platform-tabs.platform-tabs-folded:not(.platform-tabs-peek)) .platform-parked {
    display: none;
  }

  /* THE PEEKED RAIL: the same element, over the app instead of beside it.
     Everything about its inside — the rows, the labels, the lit tab — is the
     rail's own, because it IS the rail; what changes is that it floats. */
  .platform-tabs.platform-tabs-peek {
    z-index: 40;
    /* THE SAME FROSTED SURFACE AS THE DOCKED RAIL (#2795). This was the solid
       `--dc-sheet`, so the rail came back over the page as a white panel
       rather than as the rail. `.platform-tabs` already carries the frost;
       restating the fill is what stops the solid one winning, and the
       peeked rail floats over the page, so with no blur it is solid in the
       plane colour, and unclipped, so its shadow still falls. */
    background-color: var(--dc-sheet-solid);
    backdrop-filter: var(--dc-frost);
    -webkit-backdrop-filter: var(--dc-frost);
    border-right: 1px solid var(--app-sheet-line);
    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
    /* A FADE, NOT A SLIDE. A rail that slides in from the edge races the
       pointer that summoned it and arrives under it; one that fades is
       already where it is going to be. It was tried both ways in the design
       study and this is the one that stopped feeling like a mistake. */
    animation: platform-rail-peek-in 140ms ease-out;
    /* On the peek itself, so the way back in from a half-finished fade is
       the same curve as the way out. */
    transition: opacity 200ms ease-in;
  }
  /* …AND IT FADES OUT THE SAME WAY (#2795). It used to snap: the grace
     period ran out and the rail went straight to `display: none`. The peek
     now stays up for 200ms with this class on (frontend/src/features/nav/
     rail-peek.ts, PEEK_FADE_MS), and pointing back at it during the fade
     takes the class off, so the rail comes back from wherever it had got to. */
  .platform-tabs.platform-tabs-peek.platform-tabs-peek-out {
    opacity: 0;
  }
  /* The peeked rail's footer: the parked strip rides ON the rail rather than
     under it (a peeking rail is 40, above the strip's 31 beside a docked one)
     and fades in with it. Only a platform screen's folded rail can carry one — over a
     running app the strip is hidden with the bar (features/nav/parked-strip.tsx). */
  body:has(#platform-tabs.platform-tabs-peek) .platform-parked {
    z-index: 41;
    animation: platform-rail-peek-in 140ms ease-out;
  }

}

@keyframes platform-rail-peek-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .platform-tabs.platform-tabs-peek,
  body:has(#platform-tabs.platform-tabs-peek) .platform-parked { animation: none; }
  .platform-tabs.platform-tabs-peek { transition: none; }
}

/* ── #platform-side-panel — THE PANEL BESIDE A RUNNING APP ───────────────
   On a desktop-width window, while an app runs on its App tab, the pages
   about it (its Workshop, its discussion, a proposal, an issue, a change, a
   conversation) open in a panel beside it rather than replacing it —
   frontend/src/features/side-panel/, whose controller carries the why.

   THE APP IS NEVER RE-PARENTED, HIDDEN OR RE-SOURCED: its frame lives in
   #app-view, and the only thing that changes about #app-view is its SIZE — a
   right margin the width of the panel, keyed off `<html data-side-panel>`,
   which the panel's mount writes while the panel is on screen. <html> is not
   React-owned (main.tsx hydrates <body>), and a margin on a flex item is a
   resize, which an iframe survives with its document intact.

   THE BREAKPOINT IS 1024px, not the rail's 768. The panel is
   clamp(360px, 36vw, 560px) wide and the app keeps about 480px beside it; at
   768px the app would be left 408, and 1024 (`lg`, used above already) is the
   first breakpoint where both fit — 655px of app at the narrowest. Below it
   the panel is not drawn and the app takes the width back, so a window that
   shrinks while the panel is open simply hides it, navigating nothing; every
   link below the breakpoint navigates as it always has (the controller's
   DESKTOP_QUERY is the same figure). */
:root {
  --side-panel-w: clamp(360px, 36vw, 560px);
}

.side-panel {
  /* From the top of the app's sheet to the floor, down the right edge. The
     same arithmetic the rail uses for "under the header", less the half rem
     the header's rounded lower edge overlaps the screen below it — which is
     where #app-view, and so the app's sheet, begins. */
  position: fixed;
  top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h)
    + var(--platform-safe-top, 0px) - .5rem);
  right: 0;
  bottom: 0;
  width: var(--side-panel-w);
  /* Over the app view and the header's transparent lower edge, under every
     sheet, menu and rail (30 and up). */
  z-index: 20;
  display: flex;
  flex-direction: column;
  /* THE APP'S SHEET, CONTINUED. The running app arrives as a raised sheet with
     a rounded top and a hairline (see "THE APP SHEET" above); the panel is the
     rest of that sheet, split from it by one hairline, and it takes the
     sheet's outer corner — the app's own top-right corner goes square while
     the panel is up (below). */
  background: var(--home-ground);
  border-top: 1px solid var(--app-sheet-line);
  border-left: 1px solid var(--app-sheet-line);
  border-top-right-radius: 1.75rem;
  overflow: hidden;
}

/* THE DIVIDER (#2886) — the panel's left edge, as a handle. It sits over the
   panel's own hairline (the border-left above), inside the panel because the
   panel clips its corner, and shows itself only under the pointer, on focus
   and while held: a brand-coloured bar where the hairline is. The width it
   sets is `--side-panel-w` on <html> (features/side-panel/resize.ts), so the
   app beside it follows through the margin rule below. */
.side-panel-divider {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 10px;
  z-index: 2;
  cursor: col-resize;
  touch-action: none;
  outline: none;
}
.side-panel-divider::after {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 3px;
  background: var(--brand-ink);
  opacity: 0;
  transition: opacity 120ms ease;
}
.side-panel-divider:hover::after,
.side-panel-divider:focus-visible::after,
html.side-panel-resizing .side-panel-divider::after {
  opacity: .45;
}
/* WHILE HELD: the two frames would take the pointer the moment it crossed
   them and the drag would stall, so they stand aside; and nothing under the
   pointer selects or shows another cursor. */
html.side-panel-resizing iframe {
  pointer-events: none;
}
html.side-panel-resizing,
html.side-panel-resizing * {
  cursor: col-resize !important;
  user-select: none;
  -webkit-user-select: none;
}
@media (prefers-reduced-motion: reduce) {
  .side-panel-divider::after { transition: none; }
}

.side-panel-head {
  /* Back · title · Expand · Close, in the header's own disc controls. */
  height: 3rem;
  padding: 0 0.875rem 0 0.75rem;
  border-bottom: 1px solid var(--app-sheet-line);
}
/* Back's display, which its className cannot carry beside the `hidden` it
   ships with (see side-panel.tsx). Keyed on NOT hidden, so the utility still
   hides it whenever there is nowhere for it to go. */
.side-panel-head > #side-panel-back:not(.hidden) {
  display: inline-flex;
}

.side-panel-body {
  position: relative;
  flex: 1;
  min-height: 0;
}

/* The panel's own document. Transparent until it has routed its first page —
   a shell booting in a frame paints its boot screen first — with the spinner
   behind it; then it fades in once and stays. */
.side-panel-frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  background: transparent;
  opacity: 0;
  transition: opacity 160ms ease;
}
.side-panel-frame-ready {
  opacity: 1;
}
.side-panel-loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.side-panel-spinner {
  width: 20px;
  height: 20px;
}

@media (prefers-reduced-motion: reduce) {
  .side-panel-frame { transition: none; }
}

@media (max-width: 1023.98px) {
  /* Below the breakpoint the panel is not drawn at all, and the rules below
     do not apply, so the app has the whole window back. */
  #platform-side-panel {
    display: none;
  }
}

@media (min-width: 1024px) {
  html[data-side-panel] #app-view {
    margin-right: var(--side-panel-w);
    min-width: 480px;
  }
  html[data-side-panel] #app-frame-host {
    border-top-right-radius: 0;
  }
}

/* ── THE SIDE PANEL'S OWN DOCUMENT (`?panel=1`) ──────────────────────────
   The platform, framed beside a running app, showing one page: no header,
   no tab bar or rail, no parked strip, no banners — the top window has all
   of those — and none of the space they reserve. `in-side-panel` is put on
   <html> by head.html before the first paint, and only when the document is
   framed by a same-origin top window (a `?panel=1` address opened on its own
   is the ordinary platform). The frame is narrow, so every screen takes its
   phone layout, which is the point: the panel is a phone-width column. */
html.in-side-panel :is(#platform-header, #platform-tabs, #platform-rail-peek,
    #platform-parked, #chromeless-pill, #offline-banner, #mobile-install-banner,
    #view-as-non-admin-banner, #language-notice, #platform-side-panel) {
  display: none !important;
}
html.in-side-panel {
  /* Nothing sits above a screen here, so nothing measured from the header's
     foot (the Workshop's fitted area, a sheet's cap) reserves its 52px. */
  --platform-header-h: 0px;
}
html.in-side-panel body {
  /* The bar's band, its parked-strip addition and the rail's width, all nil —
     !important because the rules that reserve them key off the bar's own
     visibility, which the router publishes only once it has routed. */
  --platform-tabs-h: 0px !important;
  --platform-bar-h: 0px !important;
  --platform-rail-w: 0px !important;
}

/* ── The Workshop tab, cleaned up (#4457) ─────────────────────────────
   One list per section, a hairline between rows (workshop/work-row.tsx).
   A row is a neutral tile, the title (15px, two lines at most), one line
   in words (13px, muted), then small tags for what is happening on it and,
   on a change up for a vote, its votes. The title is a link stretched over
   the row; the Vote button sits above it. */
.dev-ws-wlist { display: flex; flex-direction: column; margin-top: 4px; }
.dev-ws-wrow {
  position: relative; display: flex; align-items: flex-start; gap: 12px;
  padding: 12px 2px; min-width: 0; border-radius: 12px;
}
.dev-ws-wrow + .dev-ws-wrow,
.dev-ws-week + .dev-ws-week { border-top: 1px solid var(--app-sheet-line); }
.dev-ws-wrow:hover { background: var(--state-neutral-bg); }
.dev-ws-wrow[data-on] { background: var(--lit-tint); }
.dev-ws-wrow[data-on] + .dev-ws-wrow,
.dev-ws-wrow:hover + .dev-ws-wrow { border-top-color: transparent; }
.dev-ws-wrow:hover, .dev-ws-wrow[data-on] { margin: 0 -8px; padding-left: 10px; padding-right: 10px; }
.dev-ws-wrow-tile {
  flex: none; width: 34px; height: 34px; margin-top: 1px; border-radius: 10px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-ws-wrow-tile svg { width: 18px; height: 18px; }
.dev-ws-wrow-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.dev-ws-wrow-link {
  font-size: 15px; font-weight: 650; line-height: 1.3; color: var(--text-primary); text-decoration: none;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.dev-ws-wrow-link::after { content: ''; position: absolute; inset: 0; border-radius: inherit; }
.dev-ws-wrow-link:focus-visible { outline: none; }
.dev-ws-wrow:has(> .dev-ws-wrow-main > .dev-ws-wrow-link:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-wrow-sub {
  font-size: 13px; line-height: 1.35; color: var(--text-muted);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dev-ws-wrow-status { margin-top: 6px; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.dev-ws-wrow-chev { flex: none; width: 16px; height: 16px; margin-top: 2px; color: var(--text-muted); opacity: .7; }
.dev-ws-tag {
  display: inline-flex; align-items: center; gap: 5px; height: 22px; padding: 0 8px; border-radius: 999px;
  font-size: 12px; font-weight: 600; white-space: nowrap;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-ws-tag svg { width: 12px; height: 12px; }
.dev-ws-tag .dc-status-spinner-arc { width: 11px; height: 11px; }
.dev-ws-tag[data-tone="run"] { color: var(--lit-ink); background: var(--lit-tint); }
.dev-ws-tag[data-tone="ok"] { color: var(--state-ok); background: var(--state-ok-bg); }
.dev-ws-tag[data-tone="warn"] { color: var(--state-attention); background: var(--state-attention-bg); }
.dev-ws-tag[data-tone="bad"] { color: var(--state-blocked); background: var(--state-blocked-bg); }
/* #4499: how far a check run is, as a thin bar inside the checks chip
   (the cards' status band, the Workshop's rows, All items). It is drawn in
   the chip's own ink, so it wears whichever tone the chip does; the exact
   count is the chip's tooltip and the bar's accessible name. */
.checks-chip-bar {
  position: relative; flex: none; display: inline-block; overflow: hidden;
  width: 36px; height: 4px; margin-left: 4px; border-radius: 999px; vertical-align: middle;
  background: color-mix(in srgb, currentColor 22%, transparent);
}
.checks-chip-bar-fill {
  position: absolute; inset: 0 auto 0 0; border-radius: inherit;
  background: currentColor; transition: width .3s ease;
}
@media (prefers-reduced-motion: reduce) { .checks-chip-bar-fill { transition: none; } }
/* #4628: the run's early phase — the preview build and the checks'
   preparation, before the run knows how many checks it will run — shows
   the same bar pulsing, with nothing to fill it yet. The pulse is the
   Testing card's ledger bar's own (dev-ledger-progress-pulse). */
.checks-chip-bar-busy {
  background: color-mix(in srgb, currentColor 45%, transparent);
  animation: dev-ledger-progress-pulse 1.4s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .checks-chip-bar-busy { animation: none; }
}
/* A change's vote (#4486): the card's own status bar with its own words
   ("0 of 1 approval"), as long as its words plus the 56px the thin bar it
   replaced took, then the card's Vote button. #4718: the pair takes a line
   of its own under the row's tags, at that line's far end
   (`margin-left: auto`), on every width, a phone's too — never on the tags
   line, so rows beside each other keep their pills lined up. Only Vote
   takes a click: a tap on the bar is a tap on the row. */
.dev-ws-wrow-votes { margin-top: 6px; display: flex; align-items: center; }
.dev-ws-wvote {
  position: relative; z-index: 1; margin-left: auto; display: inline-flex; align-items: center; gap: 8px;
  pointer-events: none;
}
.dev-ws-wvote > .dev-ws-wvote-btn { pointer-events: auto; }
.dev-ws-wvote-state { flex: none; padding: 0 calc(10px + 56px) 0 10px; }
.dev-ws-none { margin: 6px 0 2px; font-size: 14px; color: var(--text-muted); }

/* Week by week: one row a week, which opens the week's own page. */
.dev-ws-week {
  appearance: none; border: 0; background: transparent; font: inherit; text-align: left; cursor: pointer;
  display: flex; align-items: center; gap: 12px; width: 100%; padding: 12px 2px; border-radius: 12px; color: inherit;
}
.dev-ws-week:hover { background: var(--state-neutral-bg); }
.dev-ws-week:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-week-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.dev-ws-week-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 8px; font-size: 13px; color: var(--text-muted); }
.dev-ws-week-head b { font-size: 15px; font-weight: 650; color: var(--text-primary); }
.dev-ws-week-fresh {
  align-self: center; display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border-radius: 999px;
  font-size: 12px; font-weight: 600; background: var(--accent-tint); color: var(--accent-wash-ink);
}
.dev-ws-week-n { flex: none; font-size: 13px; color: var(--text-muted); white-space: nowrap; }
.dev-ws-week-n b, .dev-ws-week-live { font-size: 15px; font-weight: 650; color: var(--state-ok); font-variant-numeric: tabular-nums; }
.dev-ws-week .dev-ws-wrow-chev { margin-top: 0; }
.dev-ws-weekpage { display: flex; flex-direction: column; gap: 10px; }
.dev-ws-week-meta { margin: 0; font-size: 13px; color: var(--text-muted); }
.dev-ws-week-lead { margin: 8px 0 0; font-size: 15px; line-height: 1.5; color: var(--text-primary); }

/* ── The Workshop tab in three titled parts (After-Workshop-B) ──────────
   OVERVIEW, WHAT'S HAPPENING and WHAT HAPPENED, each the shell's small-caps
   SectionHeader over its card or cards. What happened's label carries Mark
   all seen at its far end, outside the card. */
.dev-ws-parthead { display: flex; align-items: baseline; gap: 8px; }
.dev-ws-parthead > .dev-ws-since-clear { margin-right: 2px; }

/* The Overview card: the four figures, where most of the open work is, and
   the approval rule in one line. The card is its own container, so the rule
   says itself shorter where the CARD is narrow ("Goes live when …"), on a
   phone or beside the side panel, not only where the window is. */
.dev-ws-strip[data-ws-dashboard] { container-type: inline-size; }
.dev-ws-dash-sub {
  display: flex; align-items: center; gap: 3px; margin-top: 1px;
  font-size: 11.5px; line-height: 1.3; color: var(--text-muted);
}
@media (min-width: 420px) {
  .dev-ws-dash-sub { font-size: 12px; }
}
/* "4 on you" asks something of the viewer, so it is the accent; "from 382"
   is green going up, muted otherwise, its glyph turned over going down. */
.dev-ws-dash-sub[data-ws-dash-sub="owed"] { color: var(--accent-wash-ink); }
.dev-ws-dash-sub[data-dir="up"] { color: var(--state-ok); }
.dev-ws-dash-trend { flex: none; width: 10px; height: 10px; }
.dev-ws-dash-sub[data-dir="down"] .dev-ws-dash-trend { transform: rotate(180deg); }
.dev-ws-ov-rule {
  margin: 0; padding-top: 10px; border-top: 1px solid var(--app-sheet-line);
  display: flex; align-items: flex-start; gap: 8px;
  font-size: 13px; line-height: 1.4; color: var(--text-secondary);
}
.dev-ws-ov-rule-icon { flex: none; width: 16px; height: 16px; margin-top: 1px; color: var(--text-muted); }
.dev-ws-ov-rule-text { flex: 1 1 auto; min-width: 0; }
.dev-ws-ov-rule-text b { font-weight: 650; color: var(--text-primary); }
.dev-ws-ov-rule-wait { margin-left: 4px; color: var(--text-muted); }
.dev-ws-ov-rule-edit { flex: none; margin-left: 4px; }
.dev-ws-ov-faces { display: inline-flex; margin-right: 4px; vertical-align: -3px; }
.dev-ws-ov-face {
  width: 16px; height: 16px; border-radius: 999px;
  display: inline-flex; align-items: center; justify-content: center;
  color: #fff; font-size: 9px; font-weight: 700;
  box-shadow: 0 0 0 1.5px var(--dc-sheet-solid, var(--bg-primary));
}
.dev-ws-ov-face + .dev-ws-ov-face { margin-left: -4px; }
.dev-ws-ov-narrow { display: none; }
@container (max-width: 520px) {
  .dev-ws-ov-wide { display: none; }
  .dev-ws-ov-narrow { display: inline; }
}

/* Your work and New for you (workshop/work-row.tsx ActRow): the title and
   one line in words, then the row's one next step at its end, above the
   stretched link; under the words on a phone. A vote that waits on the
   viewer wears the attention tint on its tile. */
.dev-ws-arow { align-items: center; }
.dev-ws-arow-act { position: relative; z-index: 1; flex: none; display: inline-flex; align-items: center; }
.dev-ws-wrow-tile[data-tone="ask"] { background: var(--state-attention-bg); color: var(--state-attention); }
@media (max-width: 640px) {
  .dev-ws-arow { flex-wrap: wrap; align-items: flex-start; }
  .dev-ws-arow > .dev-ws-wrow-main { flex: 1 1 0; }
  .dev-ws-arow-act { flex-basis: 100%; padding-left: 40px; }
}

/* What happened (workshop/week-pages.tsx): this week's head and its rows,
   one line each, then a row for every week before it. A row's title is a
   link stretched over it; on a narrow card the title wraps and the rest
   goes under it. */
.dev-ws-strip[data-ws-happened] { container-type: inline-size; gap: 4px; }
.dev-ws-hweek { display: flex; flex-direction: column; }
.dev-ws-hweek-head { display: flex; align-items: center; gap: 12px; padding: 4px 2px 8px; }
.dev-ws-hweek-head > .dev-ws-week-head { flex: 1 1 auto; min-width: 0; }
.dev-ws-hlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.dev-ws-hrow {
  position: relative; display: flex; align-items: center; gap: 10px; min-width: 0;
  padding: 8px 2px; border-top: 1px solid var(--app-sheet-line); border-radius: 10px;
}
.dev-ws-hrow:hover { background: var(--state-neutral-bg); }
.dev-ws-hrow[data-on] { background: var(--lit-tint); }
.dev-ws-hrow-dot { flex: none; width: 7px; height: 7px; border-radius: 999px; }
.dev-ws-hrow[data-new] .dev-ws-hrow-dot { background: var(--accent); }
.dev-ws-hrow-glyph { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; color: var(--text-muted); }
.dev-ws-hrow-glyph svg { width: 16px; height: 16px; }
.dev-ws-hrow-glyph[data-kind="live"] { color: var(--state-ok); }
.dev-ws-hrow-glyph .dc-status-spinner-arc { width: 13px; height: 13px; color: var(--lit-ink); }
.dev-ws-hrow-main { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 10px; }
.dev-ws-hrow-link {
  flex: 1 1 auto; min-width: 0; font-size: 15px; line-height: 1.35; color: var(--text-primary); text-decoration: none;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dev-ws-hrow-link::after { content: ''; position: absolute; inset: 0; border-radius: inherit; }
.dev-ws-hrow-link:focus-visible { outline: none; }
.dev-ws-hrow:has(.dev-ws-hrow-link:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-hrow-meta {
  flex: none; display: inline-flex; align-items: center; gap: 8px;
  font-size: 13px; color: var(--text-muted); white-space: nowrap;
  /* Never wider than the row (polish board #24): a long step, "Waiting for
     a slot, 1 request ahead of it", ran past the card's edge and was cut
     with no ellipsis. The words tags give way with an ellipsis instead; the
     pill, the glyph tags and the time keep their size. */
  max-width: 100%; min-width: 0;
}
.dev-ws-hrow-meta > * { flex: none; }
.dev-ws-hrow-meta > .dev-ws-hrow-tag:not(:has(> svg)) {
  flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
}
.dev-ws-hrow-you {
  display: inline-flex; align-items: center; height: 18px; padding: 0 6px; border-radius: 999px;
  background: var(--accent-tint); color: var(--accent-wash-ink); font-size: 11px; font-weight: 700;
}
.dev-ws-hrow-tag[data-tone="run"] { color: var(--lit-ink); }
.dev-ws-hrow-tag[data-tone="bad"] { color: var(--state-blocked); }
/* What's happening's own-work rows end with where the work stands
   (week-pages.tsx MetaTag): the tag's words in its tone, no pill, with its
   glyph ("Only you", Homeroom bot) where it has one. */
.dev-ws-hrow-tag[data-tone="warn"] { color: var(--state-attention); }
.dev-ws-hrow-tag[data-tone="ok"] { color: var(--state-ok); }
.dev-ws-hrow-tag:has(> svg) { display: inline-flex; align-items: center; gap: 4px; }
.dev-ws-hrow-tag > svg { flex: none; width: 12px; height: 12px; }
.dev-ws-hrow-ago { font-variant-numeric: tabular-nums; }
.dev-ws-hweek > .dev-ws-reveal { margin-top: 2px; }
.dev-ws-hweek + .dev-ws-wlist { margin-top: 4px; border-top: 1px solid var(--app-sheet-line); }
@container (max-width: 520px) {
  .dev-ws-hrow { align-items: flex-start; }
  .dev-ws-hrow-dot { margin-top: 7px; }
  .dev-ws-hrow-glyph { margin-top: 2px; }
  .dev-ws-hrow-main { flex-direction: column; align-items: flex-start; gap: 2px; }
  .dev-ws-hrow-link { white-space: normal; }
}

/* Your work (#4716, workshop/work-row.tsx variant `line`): This week's
   one-line row with a second line. The first line is the small glyph, the
   title on one line in This week's weight, and at its end the card's status
   pill and Vote (#4486's pair, unchanged), then ☰; the line is as tall as
   Vote, so the glyph and ☰ centre on it whether or not the row has one. The
   second line is the item's number (and the requests it is for), its tags
   most important first, then Homeroom bot's tag on a change it built for
   you (#4716; your own work names nobody), on one line that scrolls
   sideways and fades at its end, so nothing wraps; it sits above the row's
   stretched link to take the swipe, and a click on it opens the item
   (work-row.tsx). On a narrow card the title wraps (two lines at most)
   and the pill and Vote end the row, at its
   far end, as they do on a phone today (#4718): the first line lets go of
   its box (`display: contents`) so the title and the pair are the column's
   own items, and the pair is ordered after the tags. */
/* WHAT'S HAPPENING IS ONE CARD (After-Workshop-C, Oct 2026), drawn as What
   happened's is: its own container (so a narrow card wraps its rows, not
   only a narrow window), each part headed with This week's head
   (`.dev-ws-hweek-head`: the name at 15px/650, its count beside it, 4px
   above and 8px below), a hairline over every row, and a hairline between
   parts, as between This week and the weeks under it. Waiting on you, Your
   work and Going live are This week's one-line row (`.dev-ws-hrow`, the
   `standing` HappenedRow); Yours, up for a vote keeps the two-line row
   below (`.dev-ws-lrow`), whose glyph starts where the one-line rows'
   does, after their 7px dot and 10px gap. A row lights in place on hover,
   as This week's does, rather than bleeding past the card's edge. */
.dev-ws-strip[data-ws-happening] { container-type: inline-size; gap: 4px; }
.dev-ws-hpart { display: flex; flex-direction: column; min-width: 0; }
.dev-ws-hpart + .dev-ws-hpart { margin-top: 4px; border-top: 1px solid var(--app-sheet-line); }
.dev-ws-hpart + .dev-ws-hpart > .dev-ws-hweek-head { padding-top: 14px; }
.dev-ws-hpart .dev-ws-wlist { margin-top: 0; }
.dev-ws-hpart > .dev-ws-wlist > .dev-ws-wrow:first-child { border-top: 1px solid var(--app-sheet-line); }
.dev-ws-hpart .dev-ws-reveal { margin-top: 2px; }
.dev-ws-hpart [data-ws-mine-empty] { margin: 0; padding: 8px 2px; border-top: 1px solid var(--app-sheet-line); }
.dev-ws-lrow { gap: 10px; padding: 8px 2px 8px 19px; border-top: 1px solid var(--app-sheet-line); border-radius: 10px; }
.dev-ws-lrow:hover, .dev-ws-lrow[data-on] { margin: 0; padding-left: 19px; padding-right: 2px; }
.dev-ws-lrow:hover + .dev-ws-lrow, .dev-ws-lrow[data-on] + .dev-ws-lrow { border-top-color: var(--app-sheet-line); }
.dev-ws-lrow > .dev-ws-hrow-glyph { height: 30px; margin-top: 0; }
.dev-ws-lrow > .dev-ws-wrow-main { gap: 2px; }
.dev-ws-lrow-line { display: flex; align-items: center; gap: 10px; min-width: 0; min-height: 30px; }
.dev-ws-lrow .dev-ws-wrow-link {
  flex: 1 1 auto; min-width: 0; display: block; font-weight: 400; line-height: 1.35;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dev-ws-lrow:has(.dev-ws-wrow-link:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-lrow-line > .dev-ws-wvote { flex: none; margin-left: 0; }
.dev-ws-lrow .dev-ws-wrow-status {
  position: relative; z-index: 1; margin-top: 0; column-gap: 8px; flex-wrap: nowrap; min-width: 0;
  overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none; cursor: pointer;
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent);
}
.dev-ws-lrow .dev-ws-wrow-status::-webkit-scrollbar { display: none; }
.dev-ws-lrow .dev-ws-wrow-status > * { flex: none; }
.dev-ws-lrow .dev-ws-wrow-sub { overflow: visible; }
.dev-ws-lrow > .dev-ws-wrow-menu { margin-top: 1px; }
@container (max-width: 520px) {
  .dev-ws-lrow > .dev-ws-hrow-glyph { height: 20px; }
  .dev-ws-lrow-line { display: contents; }
  .dev-ws-lrow .dev-ws-wrow-link {
    white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  }
  .dev-ws-lrow-line > :not(.dev-ws-wrow-link) { order: 1; align-self: flex-end; margin-top: 4px; }
  .dev-ws-lrow .dev-ws-wrow-status { margin-top: 2px; }
  .dev-ws-lrow > .dev-ws-wrow-menu { margin-top: -4px; }
}

/* The item's page beside the list, on a wide window (workshop/side-panel.tsx).
   The list moves left to make room and keeps its place. */
#dev-workshop:has(> .dev-ws[data-ws-side-open]) {
  margin-left: 24px; margin-right: 0; max-width: min(760px, calc(100% - 616px));
}
.dev-ws-side {
  position: fixed; z-index: 40; right: 24px; width: 560px;
  top: calc(var(--browser-banner-h, 0px) + var(--platform-header-h, 3.25rem) + var(--platform-safe-top, 0px) + 8px);
  bottom: 12px;
  display: flex; flex-direction: column; overflow: hidden; border-radius: 20px;
  background: var(--dc-sheet-solid, var(--bg-primary));
  box-shadow: 0 16px 48px rgba(0, 0, 0, .28), inset 0 0 0 1px var(--app-sheet-line);
}
.dev-ws-side-bar {
  flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 10px;
  border-bottom: 1px solid var(--app-sheet-line);
}
.dev-ws-side-btn {
  appearance: none; border: 0; cursor: pointer; font: inherit; font-size: 13px; font-weight: 600;
  color: var(--text-secondary); background: var(--state-neutral-bg);
  height: 30px; padding: 0 12px; border-radius: 999px; display: inline-flex; align-items: center; gap: 4px;
}
.dev-ws-side-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.dev-ws-side-btn svg { width: 15px; height: 15px; }
.dev-ws-side-close { width: 30px; padding: 0; justify-content: center; }
.dev-ws-side-full { margin-left: auto; }
.dev-ws-side-page { flex: 1 1 auto; min-height: 0; overflow: auto; }
/* #4875: the page and, over it, the plan reader's slot. The panel is too
   narrow to dock the reader beside the page, so the reader takes its
   narrow-window layout here at every width: it fills the panel below the bar. */
.dev-ws-side-body { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
.dev-ws-side .gc-spec-side-panel.gc-spec-side-panel-open {
  position: absolute; inset: 0; z-index: 2;
  width: auto !important; min-width: 0; max-width: none; border-left: none;
}

/* ── The row's card parts, on every row (#4486) ──────────────────────
   The Workshop's row carries the card's own pieces now: on live work the
   bar's "✓ Live" at its words' width first on the tags line, then the
   coloured category chip and the 💬 count (dev-card.tsx, unchanged), and ☰,
   the card's own menu trigger, at the row's top right in place of the
   chevron. The chips and ☰ sit above the row's stretched link, so each does
   its own job; the 💬 count and the bar are words, and a tap on them is a
   tap on the row. */
.dev-ws-wrow-status > .dev-badge { height: 22px; font-size: 12px; margin: 0; }
.dev-ws-wrow-status > button.dev-badge { position: relative; z-index: 1; }
.dev-ws-wrow-status > .dev-ws-wrow-live { flex: none; }
.dev-ws-wrow-menu { position: relative; z-index: 1; flex: none; display: inline-flex; margin: -3px -6px 0 -2px; }
.dev-ws-wrow-menu > .gc-vote-btn.dev-card-menu-btn {
  width: 28px; height: 28px; padding: 0; border-color: transparent;
  background: transparent; box-shadow: none; color: var(--text-muted);
}
.dev-ws-wrow-menu > .gc-vote-btn.dev-card-menu-btn:hover,
.dev-ws-wrow-menu > .gc-vote-btn.dev-card-menu-btn[aria-expanded="true"] {
  background: var(--state-neutral-bg); border-color: transparent; color: var(--text-primary);
}
.dev-ws-wrow-menu > .dev-card-menu-btn svg { width: 17px; height: 17px; }

/* ── The board's row (#4486, work-row.tsx variant `board`) ────────────
   All items' columns and By category's lanes: no tile (the column or the
   stage label says what it is), the title up to three lines, the line in
   words free to wrap in a narrow column, and the card's status bar with
   Vote under it all, the row's full width (fold.tsx RowBand, without the
   chips its tags say). The bar is words: a tap on it opens the row; only
   Vote takes its own click. */
.dev-ws-brow { flex-wrap: wrap; column-gap: 8px; row-gap: 0; }
.dev-ws-brow > .dev-ws-wrow-main { flex: 1 1 0; gap: 1px; }
.dev-ws-brow .dev-ws-wrow-link { -webkit-line-clamp: 3; }
.dev-ws-brow .dev-ws-wrow-sub { white-space: normal; overflow: visible; }
.dev-ws-brow .dev-ws-wrow-status { margin-top: 6px; gap: 5px; }
.dev-ws-brow > .dev-ws-row-band {
  position: relative; z-index: 1; flex: 1 0 100%; min-width: 0; margin-top: 8px; pointer-events: none;
}
.dev-ws-brow > .dev-ws-row-band > .dev-ws-row-trailing { pointer-events: auto; }

/* ── All items' one-row head (#4486) ─────────────────────────────────
   The way back and the page's name, the search, By category / By stage, then
   the filters, in one row that wraps on a narrow window and on a phone. The
   search and the filter chips are the strip's own (kanban-filters.tsx), laid
   out here as the row's items so the switch sits between them; the chips
   wrap as one group. */
.dev-ws-allbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; min-width: 0; }
.dev-ws-allbar > .dev-ws-pagehead { order: 0; flex: none; margin-right: 6px; }
.dev-ws-allbar .dev-ws-pagehead-title { font-size: 22px; line-height: 28px; }
.dev-ws-allbar :is(#dev-actions, #dev-kanban-filterbar, #dev-filter-row) { display: contents; }
.dev-ws-allbar #dev-kanban-search {
  order: 1; flex: 1 1 320px; min-width: 200px; max-width: 440px; height: 36px; padding: 0 14px;
  border-color: transparent; border-radius: 999px; font-size: 14px;
  background: var(--state-neutral-bg); color: var(--text-primary);
}
.dev-ws-allbar > .dev-ws-group { order: 2; flex: none; }
.dev-ws-allbar > .dev-ws-group > .dev-ws-group-tab { flex: none; }
.dev-ws-allbar #dev-filter-chips { order: 3; display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; min-width: 0; }
.dev-ws-allbar #dev-filter-chips button { height: 28px; padding: 0 11px; font-size: 12.5px; }

/* ── The pipeline (#4486, card/dev-kanban.tsx StageStrip) ────────────
   The column heads as the steps an idea goes through, joined by arrows,
   each with its icon and its count, and Done's the one green step. On the
   board's own grid (`#dev-kanban`), 8px in from the head's padding to meet
   the board's; it scrolls sideways only with the board. */
.dev-kanban-stages {
  display: grid; grid-template-columns: repeat(4, minmax(220px, 1fr)); column-gap: 20px;
  margin-top: 4px; padding: 0 8px; overflow: hidden;
}
.dev-kanban-step {
  position: relative; display: flex; align-items: center; gap: 9px; min-width: 0;
  padding: 4px 2px; border: 0; background: transparent; font: inherit; color: inherit;
  text-align: left; cursor: default; -webkit-tap-highlight-color: transparent;
}
.dev-kanban-step:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 10px; }
.dev-kanban-step-tile {
  flex: none; width: 30px; height: 30px; border-radius: 9px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--state-neutral-bg); color: var(--text-secondary);
}
.dev-kanban-step-tile svg { width: 16px; height: 16px; }
.dev-kanban-step-tile[data-kind="done"] { background: var(--state-ok-bg); color: var(--state-ok); }
.dev-kanban-step-name {
  min-width: 0; font-size: 15px; font-weight: 650; color: var(--text-primary);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.dev-kanban-step-n { flex: none; font-size: 15px; font-weight: 600; color: var(--text-muted); font-variant-numeric: tabular-nums; }
.dev-kanban-step-arrow {
  position: absolute; right: -19px; top: 50%; width: 18px; height: 18px; margin-top: -9px;
  color: var(--text-muted); opacity: .6;
}

/* ── A column is one card of rows (#4486) ────────────────────────────
   Drawn like the shell's other lists: the plane colour, 20px corners, one
   hairline. Waiting for approval leads with its order (Newest or Vote
   priority), Done with what is live; "Show 2,748 more" closes Done. */
.dev-kanban-card {
  border-radius: 20px; padding: 2px 14px 8px;
  background: var(--dc-sheet-solid, var(--bg-primary));
  box-shadow: inset 0 0 0 1px var(--app-sheet-line);
}
.dev-kanban-card > .dev-ws-wlist { margin-top: 0; }
.dev-kanban-lead { margin: 10px 2px 2px; font-size: 12.5px; line-height: 1.4; }
.dev-kanban-sort { display: inline-flex; align-items: center; gap: 5px; }
.dev-kanban-sort-icon { width: 13px; height: 13px; transform: rotate(180deg); }
.dev-kanban-empty { padding: 12px 2px; font-size: 13px; color: var(--text-muted); }
.dev-kanban-foot { margin: 2px 0 2px; }
.dev-kanban-foot > .dev-kanban-more { justify-content: flex-start; padding: 8px 2px; font-size: 13px; }
.dev-kanban-card > .dev-col-divider { margin: 10px 2px 2px; }

/* ── By stage, the board sits on the page ────────────────────────────
   The columns are the cards, so the pane around them draws no sheet of its
   own, and its head is on the page until it pins: then the row and the
   steps take the sheet's colour, square on top under the tabs and rounded
   below, with a hairline at their foot (`data-ws-head-pinned`). */
#dev-workshop.dev-ws-has-board .dev-ws-pane { box-shadow: none; }
#dev-workshop.dev-ws-has-board .dev-ws-pane-head,
#dev-workshop.dev-ws-has-board .dev-ws-pane-body { background-color: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; }
#dev-workshop.dev-ws-has-board .dev-ws-pane-head { gap: 12px; padding: 8px 10px 10px; }
#dev-workshop.dev-ws-has-board .dev-ws[data-ws-head-pinned] .dev-ws-pane-head {
  background-color: var(--dc-sheet-solid);
  border-radius: 0 0 18px 18px;
  box-shadow: inset 0 -1px 0 var(--app-sheet-line);
}

/* ── The board beside the panel (#4486) ──────────────────────────────
   A row opens its page in the Workshop's panel; the board keeps its place
   to its left, its columns 316px, and scrolls sideways (the Workshop brings
   the tapped column beside the panel). */
#dev-workshop.dev-ws-has-board:has(> .dev-ws[data-ws-side-open]) { max-width: calc(100% - 616px); }
.dev-ws[data-ws-side-open] :is(#dev-kanban, .dev-kanban-stages) { grid-template-columns: repeat(4, 316px); }

@media (max-width: 639px) {
  /* #4486: on a phone the steps are the tabs, one column at a time, and
     they scroll sideways on their own. */
  .dev-kanban-stages {
    display: flex; gap: 6px; padding: 0 0 2px; margin-top: 2px;
    overflow-x: auto; scrollbar-width: none;
  }
  .dev-kanban-stages::-webkit-scrollbar { display: none; }
  .dev-kanban-step {
    flex: none; min-height: 40px; padding: 6px 13px 6px 7px; gap: 7px; cursor: pointer;
    border-radius: 999px; background: var(--state-neutral-bg);
  }
  .dev-kanban-step[aria-selected="true"] { background: var(--lit-tint); box-shadow: inset 0 0 0 1px var(--lit-line); }
  .dev-kanban-step[aria-selected="true"] :is(.dev-kanban-step-name, .dev-kanban-step-tile) { color: var(--lit-ink); }
  .dev-kanban-step-tile, .dev-kanban-step-tile[data-kind="done"] { width: 22px; height: 22px; border-radius: 999px; background: transparent; }
  .dev-kanban-step-name, .dev-kanban-step-n { font-size: 14px; }
  .dev-kanban-step-arrow { display: none; }
  .dev-kanban-card { padding: 2px 12px 6px; }
  /* The head: the way back and the name, then the search on its own line,
     and the switch and the filters sharing the line under it (#5029). */
  .dev-ws-allbar #dev-kanban-search { flex-basis: 100%; max-width: none; min-width: 0; }
  .dev-ws-allbar #dev-filter-chips { flex: 1 1 0; min-width: 0; flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
  .dev-ws-allbar .dev-ws-pagehead-title { font-size: 20px; line-height: 26px; }
}
@media (max-width: 640px) {
  .dev-ws-wrow { gap: 10px; }
  .dev-ws-wrow-tile { width: 30px; height: 30px; border-radius: 9px; }
}

/* ═══════════════════════════════════════════════════════════════════
   EXPERIMENTAL: the new Needs you review flow (.rf-*)
   ═══════════════════════════════════════════════════════════════════

   frontend/src/features/review-flow/, behind Settings › Experimental
   ("New Needs you", kept on the device). The feed of title shots, the
   change page that expands out of a card, its before/after slider and its
   vote sheet. Drawn for the phone first; a wide window shows the same
   column centred. Colours are the shell's own tokens, light and dark. */
.rf-feed, .rf-page-wrap {
  --rf-page: var(--bg-primary);
  --rf-sheet: var(--bg-secondary);
  --rf-fill: var(--bg-tertiary);
  --rf-ink: var(--text-primary);
  --rf-muted: var(--text-muted);
  --rf-line: var(--app-sheet-line);
  --rf-black: #1b1b1d;
  --rf-cream: #fffeea;
}
.dark .rf-feed, .dark .rf-page-wrap { --rf-page: #0b0d1b; --rf-sheet: #1c1c1e; --rf-fill: #2c2c2e; }

/* The feed: a title shot and a title, nothing else. One column, never wider
   than the change page (520px, gutters included), centred in the pane: a
   wide window keeps the phone's cards instead of stretching one picture
   across the screen. review-flow.tsx composes the cards at the same cap
   (COLUMN), so the pictures and this column agree. A phone is narrower than
   the cap, so nothing changes there. */
.rf-feed, .rf-feed-note { box-sizing: border-box; width: 100%; max-width: 520px; margin-left: auto; margin-right: auto; }
.rf-feed { display: flex; flex-direction: column; gap: 28px; padding: 4px 16px 32px; transform-origin: 50% 30%; }
.rf-loading { min-height: 50vh; }
.rf-card { display: flex; flex-direction: column; gap: 12px; padding: 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.rf-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 20px; }
.rf-cover { position: relative; display: grid; place-items: center; overflow: hidden; border-radius: 20px; isolation: isolate; }
.rf-plate { position: relative; overflow: hidden; border-radius: 12px; background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.10); }
.rf-plate img { position: absolute; left: 0; top: 0; max-width: none; transform-origin: 0 0; }
.rf-plate-whole img { position: static; width: 100%; height: 100%; object-fit: cover; object-position: top; }
.rf-quiet-card, .rf-hero-quiet { display: flex; flex-direction: column; justify-content: center; gap: 10px; box-sizing: border-box; padding: 24px 28px; color: #1b1b1d; text-align: left; }
.rf-quiet-card { width: 100%; height: 100%; }
.rf-quiet-over { font: 600 11px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .08em; text-transform: uppercase; color: rgba(27,27,29,.62); }
.rf-quiet-text { font-size: 17px; font-weight: 600; line-height: 23px; display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden; }
.rf-card-title { padding: 0 4px; font-size: 17px; font-weight: 600; line-height: 22px; color: var(--rf-ink); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.rf-end { display: flex; flex-direction: column; align-items: center; padding: 28px 8px 8px; text-align: center; }
.rf-end-mark { width: 48px; height: 48px; border-radius: 999px; background: var(--lit-tint); color: var(--lit-ink); display: grid; place-items: center; }
.rf-end-line { margin: 14px 0 20px; font-size: 15px; line-height: 21px; color: var(--rf-muted); }
.rf-end-btn { align-self: stretch; height: 48px; border: 0; border-radius: 999px; background: var(--rf-fill); color: var(--rf-ink); font: inherit; font-size: 15px; font-weight: 600; cursor: pointer; }
.rf-capped { margin: -12px 0 0; font-size: 12px; color: var(--rf-muted); text-align: center; }

/* The change page: fixed above the tab bar, which stays. From 768px it
   starts after the sidebar (--platform-rail-w, 0 on a phone and while the
   rail is folded), so it centres over the feed's column and a card grows
   straight up into it. */
.rf-page-wrap { position: fixed; left: var(--platform-rail-w, 0px); right: 0; top: 0; bottom: var(--platform-tabs-h, 0px); z-index: 35; pointer-events: none; }
.rf-page { position: absolute; top: 0; bottom: 0; left: max(0px, calc(50% - 260px)); width: min(100%, 520px); display: flex; flex-direction: column; overflow: hidden; background: var(--rf-page); color: var(--rf-ink); pointer-events: auto; -webkit-font-smoothing: antialiased; }
.rf-band { position: absolute; left: 0; right: 0; top: 0; height: 0; z-index: 3; pointer-events: none; }
.rf-head { position: relative; z-index: 4; flex: none; display: flex; align-items: center; gap: 4px; padding: env(safe-area-inset-top, 0px) 4px 4px; min-height: 52px; background: var(--rf-page); }
.rf-head-btn { width: 44px; height: 44px; flex: none; display: grid; place-items: center; border: 0; background: none; color: inherit; cursor: pointer; }
.rf-head-title { flex: 1; min-width: 0; margin: 0; text-align: center; font-size: 16px; font-weight: 600; line-height: 22px; }
.rf-head-title span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf-fly { position: fixed; z-index: 36; margin: 0; overflow: hidden; -webkit-box-orient: vertical; transform-origin: 0 0; pointer-events: none; will-change: transform, opacity; }
.rf-ghost-cover { position: fixed; z-index: 35; margin: 0; transform-origin: 0 0; pointer-events: none; visibility: visible; will-change: transform, opacity; }
.rf-stage { position: relative; flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; }
.rf-stage-layer { background: var(--rf-page); }
.rf-stage-leaving { position: absolute; left: 0; right: 0; top: 0; z-index: 0; }
.rf-stage-arriving { position: relative; z-index: 1; }
.rf-page-body { padding-bottom: 16px; }
/* The shot and its thumbnails read as one object, set apart from the page. */
.rf-preview { margin: 12px 12px 0; border-radius: 16px; overflow: hidden; background: var(--rf-sheet); box-shadow: 0 0 0 1px var(--rf-line); }
.rf-claim { margin: 0; padding: 20px 16px 0 20px; font-size: 17px; font-weight: 600; line-height: 23px; text-wrap: pretty; }
.rf-thumbs { display: flex; gap: 8px; padding: 10px 14px 4px; overflow-x: auto; scrollbar-width: none; }
.rf-thumbs::-webkit-scrollbar { display: none; }
.rf-thumb { position: relative; flex: none; width: 52px; height: 52px; margin: 4px; padding: 0; border: 0; border-radius: 12px; overflow: hidden; display: grid; place-items: center; box-shadow: inset 0 0 0 1px var(--rf-line); cursor: pointer; }
.rf-thumb[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--rf-page), 0 0 0 4px var(--lit-ink); }
.rf-preview .rf-thumb[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--rf-sheet), 0 0 0 4px var(--lit-ink); }
.rf-thumb-in { position: relative; overflow: hidden; border-radius: 4px; background: #fff; }
.rf-thumb-in img { position: absolute; left: 0; top: 0; max-width: none; transform-origin: 0 0; }
.rf-hero-quiet { width: 100%; }
.rf-scrim { position: absolute; inset: 0; z-index: 5; background: rgba(0,0,0,.62); opacity: 0; pointer-events: none; transition: opacity .22s cubic-bezier(0.22,1,0.36,1); }
.rf-scrim-on { opacity: 1; pointer-events: auto; }

/* The slider. The pill sits at the foot of the picture, where a thumb
   pressing it covers the least of the shot. */
.rf-compare { position: relative; overflow: hidden; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; outline: none; }
.rf-compare:focus-visible { box-shadow: inset 0 0 0 2px var(--accent); }
.rf-layer, .rf-layer-clip { position: absolute; left: 0; top: 0; }
.rf-layer-clip { inset: 0; }
.rf-shot-img { display: block; max-width: none; }
.rf-shot-whole { width: 100%; height: auto; }
.rf-seam { position: absolute; top: 0; bottom: 0; left: 0; width: 2px; margin-left: -1px; background: var(--rf-black); z-index: 3; pointer-events: none; transition: opacity .18s cubic-bezier(0.22,1,0.36,1), width .15s ease-out; }
.rf-dragging .rf-seam { width: 1px; opacity: .5 !important; }
.rf-tab { position: absolute; left: 0; top: 0; width: 30px; height: 60px; z-index: 4; display: grid; place-items: center; border-radius: 0 14px 14px 0; background: var(--rf-black); color: var(--rf-cream); pointer-events: none; transition: opacity .18s cubic-bezier(0.22,1,0.36,1); }
.rf-tab-right { border-radius: 14px 0 0 14px; }
.rf-tab-mid { border-radius: 14px; }
.rf-dragging .rf-tab { opacity: 0 !important; transition: opacity .12s cubic-bezier(0.22,1,0.36,1); }
.rf-pill { position: absolute; left: 50%; bottom: 12px; z-index: 5; width: 104px; height: 36px; margin-left: -52px; padding: 0; border: 0; border-radius: 999px; overflow: hidden; background: var(--rf-cream); color: var(--rf-black); box-shadow: inset 0 0 0 1px var(--rf-black); font: 560 14px/36px var(--font-sans, ui-sans-serif, system-ui, sans-serif); cursor: pointer; touch-action: manipulation; -webkit-touch-callout: none; transition: opacity .3s cubic-bezier(0.22,1,0.36,1), scale .12s; }
.rf-pill::before { content: ''; position: absolute; left: 50%; top: 50%; width: 120px; height: 44px; transform: translate(-50%, -50%); }
.rf-pill:active { scale: .96; }
.rf-pill-word { position: relative; display: block; text-align: center; }
.rf-pill-dark { position: absolute; inset: 0; background: var(--rf-black); color: var(--rf-cream); clip-path: inset(0 calc(100% - var(--rf-g, 0%)) 0 0); }
.rf-pill[data-view="before"] { background: var(--rf-black); color: var(--rf-cream); }

/* Held still, the shot opens a round magnifier: the pill's cream and
   near-black ring, the plates' own soft shadow. */
.rf-lens { position: absolute; left: 0; top: 0; z-index: 6; width: 132px; height: 132px; border-radius: 999px; overflow: hidden; pointer-events: none; box-shadow: 0 0 0 2px var(--rf-cream), 0 0 0 3px var(--rf-black), 0 8px 24px rgba(0,0,0,.18); animation: rf-lens-in .12s cubic-bezier(0.22,1,0.36,1); }
@keyframes rf-lens-in { from { opacity: 0; scale: .85; } }

/* The vote sheet, pinned at the page's foot. */
.rf-sheet { position: relative; z-index: 6; flex: none; overflow: hidden; border-radius: 20px 20px 0 0; background: var(--rf-sheet); box-shadow: 0 -8px 32px rgba(0,0,0,.18), inset 0 1px 0 rgba(255,255,255,.06); transition: height .26s cubic-bezier(0.22,1,0.36,1); }
.rf-sheet-inner { display: flex; flex-direction: column; max-height: inherit; padding: 0 16px 10px; }
.rf-sheet button, .rf-sheet input, .rf-sheet textarea { font-family: inherit; }
.rf-grab { flex: none; display: block; width: 100%; height: 27px; margin: 0; padding: 0; border: 0; background: none; cursor: pointer; }
.rf-grab span { display: block; width: 36px; height: 5px; margin: 8px auto 14px; border-radius: 999px; background: var(--rf-line); }
.rf-yn { flex: none; display: flex; gap: 10px; }
.rf-pill-btn { flex: 1; height: 52px; border: 0; border-radius: 999px; background: var(--rf-fill); color: var(--rf-ink); display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 17px; font-weight: 600; cursor: pointer; }
.rf-pill-btn:active { scale: .98; }
.rf-pill-btn:disabled { opacity: .6; }
.rf-pill-no { background: rgba(239,68,68,.16); color: #dc2626; }
.dark .rf-pill-no { background: rgba(239,68,68,.18); color: #f87171; }
.rf-pill-primary { background: var(--accent); color: #fff; }
.dark .rf-pill-primary { background: #0a6ee0; }
.rf-quiet { flex: none; width: 100%; min-height: 44px; margin-top: 4px; border: 0; background: none; display: flex; align-items: center; justify-content: center; gap: 6px; color: var(--rf-muted); font-size: 15px; font-weight: 500; cursor: pointer; }
.rf-line { margin: 12px 4px 6px; font-size: 13px; line-height: 18px; color: var(--rf-muted); }
.rf-line-tight { margin: 6px 4px 0; }
.rf-row { position: relative; width: 100%; display: flex; align-items: center; gap: 14px; min-height: 64px; padding: 10px 0; border: 0; background: none; color: var(--rf-ink); text-align: left; text-decoration: none; cursor: pointer; }
.rf-row::after { content: ''; position: absolute; left: 50px; right: 0; bottom: 0; height: 1px; background: var(--rf-line); }
.rf-row:last-child::after { display: none; }
.rf-row > svg:last-child { flex: none; color: var(--rf-muted); }
.rf-row-icon { width: 36px; height: 36px; flex: none; border-radius: 12px; background: var(--rf-fill); display: grid; place-items: center; }
.rf-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.rf-row-text b { font-size: 15px; font-weight: 650; line-height: 20px; }
.rf-row-text i { font-style: normal; font-size: 13px; line-height: 18px; color: var(--rf-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rf-menu .rf-menu-row { min-height: 52px; padding: 0 4px; font-size: 17px; font-weight: 500; }
.rf-menu .rf-menu-row::after { left: 4px; }
.rf-menu-cancel { color: var(--rf-muted); }
.rf-sub { flex: none; display: flex; align-items: center; gap: 2px; margin: 10px 0 2px -12px; }
.rf-sub button { width: 44px; height: 44px; flex: none; display: grid; place-items: center; border: 0; background: none; color: var(--rf-ink); cursor: pointer; }
.rf-sub h2 { margin: 0; font-size: 17px; font-weight: 600; line-height: 22px; }
.rf-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; padding: 2px 0 12px; }
.rf-note { margin: 0; font-size: 13px; line-height: 18px; color: var(--rf-muted); }
.rf-para { margin: 0; font-size: 15px; line-height: 21px; }
.rf-sect { margin: 6px 0 -4px; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--rf-muted); }
.rf-facts { border-radius: 16px; background: var(--rf-fill); padding: 4px 14px; }
.rf-facts > div { display: flex; flex-direction: column; justify-content: center; min-height: 52px; border-bottom: 1px solid var(--rf-line); }
.rf-facts > div:last-child { border-bottom: 0; }
.rf-facts b { font-size: 15px; font-weight: 600; line-height: 20px; }
.rf-facts small { font-size: 13px; color: var(--rf-muted); }
.rf-link { align-self: flex-start; min-height: 44px; display: inline-flex; align-items: center; color: var(--accent); font-size: 15px; font-weight: 500; text-decoration: none; }
.rf-me { align-self: flex-end; max-width: 80%; padding: 10px 14px; border-radius: 18px 18px 4px 18px; background: #0a6ee0; color: #fff; font-size: 15px; line-height: 20px; white-space: pre-wrap; }
.rf-ai { align-self: flex-start; max-width: 88%; display: flex; flex-direction: column; gap: 6px; }
.rf-ai small { display: flex; align-items: center; gap: 6px; font-size: 12px; line-height: 16px; color: var(--rf-muted); }
.rf-ai small svg { width: 14px; height: 14px; }
.rf-ai p { margin: 0; padding: 10px 14px; border-radius: 18px 18px 18px 4px; background: var(--rf-fill); font-size: 15px; line-height: 20px; white-space: pre-wrap; }
.rf-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.rf-chip { height: 44px; padding: 0 13px; border: 0; border-radius: 999px; background: var(--rf-fill); color: var(--rf-ink); font-size: 15px; font-weight: 500; white-space: nowrap; cursor: pointer; }
.rf-ask-form { flex: none; display: flex; align-items: center; gap: 8px; margin: 0; padding: 4px 0 8px; }
.rf-ask-form input, .rf-textarea { flex: 1; min-width: 0; box-sizing: border-box; border: 0; background: var(--rf-fill); box-shadow: inset 0 0 0 1px var(--rf-line); color: var(--rf-ink); font-size: 16px; line-height: 22px; outline: 0; }
.rf-ask-form input { height: 44px; padding: 0 16px; border-radius: 999px; }
.rf-textarea { display: block; width: 100%; height: 76px; padding: 10px 12px; border-radius: 12px; resize: none; }
.rf-ask-label { display: block; margin: 18px 4px 8px; font-size: 15px; font-weight: 600; line-height: 20px; }
.rf-ends { display: flex; justify-content: flex-end; align-items: center; gap: 8px; padding-top: 12px; }
.rf-cancel { height: 44px; padding: 0 16px; border: 0; background: none; color: var(--rf-muted); font-size: 15px; font-weight: 600; cursor: pointer; }
.rf-go { flex: none; height: 44px; padding: 0 20px; border: 0; border-radius: 999px; background: #0a6ee0; color: #fff; font-size: 15px; font-weight: 600; cursor: pointer; }
.rf-go:disabled { opacity: .5; cursor: default; }
.rf-go-wide { align-self: stretch; height: 48px; }
.rf-thread { min-height: 200px; }
.rf-said { display: flex; flex-direction: column; align-items: center; padding: 14px 0 24px; }
.rf-said-mark { width: 72px; height: 72px; border-radius: 999px; background: var(--lit-tint); color: var(--lit-ink); display: grid; place-items: center; }
.rf-said h2 { margin: 14px 0 0; font-size: 20px; font-weight: 700; line-height: 26px; letter-spacing: -.01em; }
.rf-said + .rf-yn { margin-bottom: 6px; }
@keyframes rf-mark-in { 0% { transform: scale(.6); opacity: 0; } 60% { transform: scale(1.06); opacity: 1; } 100% { transform: none; } }
@keyframes rf-draw { from { stroke-dashoffset: 40; } }
@keyframes rf-rise { from { opacity: 0; transform: translateY(4px); } }
.rf-fresh .rf-said-mark { animation: rf-mark-in .42s cubic-bezier(0.22,1,0.36,1); }
.rf-fresh .rf-said-mark path { stroke-dasharray: 40; animation: rf-draw .36s .12s cubic-bezier(0.22,1,0.36,1) both; }
.rf-fresh h2 { animation: rf-rise .3s .08s cubic-bezier(0.22,1,0.36,1) both; }
@media (prefers-reduced-motion: reduce) {
  .rf-sheet, .rf-scrim, .rf-seam, .rf-tab, .rf-pill { transition: none; }
  .rf-lens { animation: none; }
  .rf-fresh .rf-said-mark, .rf-fresh .rf-said-mark path, .rf-fresh h2 { animation: none; }
}

/* New Needs you (an experiment), pictures for every card: a drawn picture
   fills its card or hero; the shots run's own crop sits on a plate. */
.rf-cover-drawn, .rf-hero-drawn { display: block; position: relative; overflow: hidden; }
.rf-cover-drawn { border-radius: 20px; }
.rf-plate-fill { position: static !important; display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; transform: none !important; }
.rf-plate-strip { object-fit: contain; }
.rf-claim { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }

/* ── Review flow: drawn pictures (features/review-flow/illustrations.tsx) ──
   One outlined object per benefit or group decision, on the ground the caller
   paints. Sizes are set inline from the box; these rules are the fixed parts.
   The hero replays the sage part once (rf-ill-play), never with reduced motion. */
.rf-ill { position: relative; display: block; overflow: hidden; }
.rf-ill-stage { position: absolute; display: block; overflow: visible; }
.rf-ill-label { position: absolute; display: block; margin: 0; line-height: 1.25; font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: rgba(27,27,29,.86); }
.rf-ill-area { color: rgba(27,27,29,.62); }
.rf-ill-token { position: absolute; left: 50%; transform: translateX(-50%); display: flex; align-items: stretch; border-radius: 999px; overflow: hidden; white-space: nowrap; line-height: 1; font-variant-numeric: tabular-nums; letter-spacing: -.005em; }
.rf-ill-token > span { display: flex; align-items: center; }
.rf-ill-before { background: #1b1b1d; color: #FFFEEA; font-weight: 500; }
.rf-ill-after { background: #A9CF97; color: #1b1b1d; font-weight: 700; }
@keyframes rf-ill-fill { 0%, 30% { fill: #FFFEEA; } 100% { fill: #A9CF97; } }
@keyframes rf-ill-pop { 0%, 30% { transform: scale(.92); } 70% { transform: scale(1.05); } 100% { transform: none; } }
.rf-ill-play .rf-ill-sage { animation: rf-ill-fill .84s cubic-bezier(0.22,1,0.36,1) both; }
.rf-ill-play .rf-ill-gain { transform-box: fill-box; transform-origin: center; animation: rf-ill-pop .84s cubic-bezier(0.22,1,0.36,1) both; }
@media (prefers-reduced-motion: reduce) {
  .rf-ill-play .rf-ill-sage, .rf-ill-play .rf-ill-gain { animation: none; }
}

/* New Needs you (an experiment), the title shot's plates: real pixels of the
   changed area and 16px of its surroundings, flat (no shadow, no card of
   their own), the house radius; a deck's back plates are plain tones. */
.rf-tplate { position: absolute; overflow: hidden; display: block; }
.rf-tplate img { position: absolute; display: block; max-width: none; }
.rf-tplate-pair img { position: static; width: 100%; height: 100%; object-fit: cover; object-position: center; }
