/* GENERATED by marketing/build.mjs from app/src/board.css — do not edit.
   Scoped to .app-figure so the app's bare header/body/* rules cannot
   reach the marketing chrome. :root is hoisted out so custom properties
   still resolve inside the scope. */
:root {
  --bg: #14171c; --panel: #1c2027; --panel-2: #232833; --line: #2e3540;
  --text: #e6e9ef; --dim: #9aa4b2; --faint: #6b7480;
  --done: #46c98a; --planned: #7c5cff; --idea: #e8eaf0; --bug: #e0705f; --in-progress: #f0c040;
  --accent: #f59e0b;
  --gutter: 28px;
  /* Corner radii for the big surfaces — the SDV card and the BV/SDV lanes.
     Tokens rather than the magic numbers these used to be (14/12, 10/9, 8/7),
     because each surface's *inner* element has to track its outer one: an inner
     radius that doesn't sit inside its border reads as a crack at the corner.
     One token per pair keeps them in step.

     Turned up to sit closer to an iPhone's screen corner (roughly a 44-48px
     device radius). The old values read as square against it on mobile — the
     turn was too tight and the mismatch was obvious with the phone's own curve
     right beside it. These are not matched to the device exactly: a lane is a
     much smaller box than a screen, and a radius that large would eat the
     corners of its content. They are the largest that still leave the head and
     body rows their full width. */
  --radius-card: 22px;      /* .detail-card, and .detail-inner via the -inner pair */
  --radius-lane: 16px;      /* .lane (BV) */
  --radius-task-lane: 13px; /* .task-lane (SDV) — a smaller box, so a step down */
  /* The -inner halves are DERIVED, not chosen. An element sitting inside a
     rounded border has to curve at the outer radius *minus the border width*,
     or its corner and its parent's are two different arcs and the gap between
     them shows: with 16px outer, 1.5px border and a hand-picked 15px inner, the
     lane-bottom fade overlay's corner sat half a pixel proud of the lane's own
     and left a dark wedge in the corner. Eyeballed round numbers get this wrong
     by construction — the correct value is rarely an integer. */
  --radius-card-inner: calc(var(--radius-card) - 1.5px);
  /* How far the SDV task board stops short of .detail-inner's edges. Derived
     from the corner it has to clear, not picked: see the .task-board rules. */
  --board-inset: calc(var(--radius-card-inner) * 0.667);
  --radius-lane-inner: calc(var(--radius-lane) - 1.5px);
  --radius-task-lane-inner: calc(var(--radius-task-lane) - 1.5px);
  /* The hairline that separates adjacent *colored* fills — the seams between
     status segments, between type segments, and the one between the status band
     and the type band below it. Not `--line` (that's for borders on dark
     panels): this has to read against every fill it divides, including the near
     white `--idea`. So it is translucent *black* rather than a tinted dark
     color — it darkens whatever sits under it instead of assuming a dark
     backdrop, which is what made a `#14171c`-based seam vanish under the pale
     Ideas segment. One token so all three seams stay identical. */
  --seam: rgba(0,0,0,.55);
  /* The heavier variant, for a seam dividing two different *kinds* of data
     rather than two peers — currently just the status band / type band edge in
     the progress bar. Opaque, because at 1px a translucent line between two
     saturated fills reads as a smudge rather than a division. */
  --seam-strong: #14171c;
}

@scope (.app-figure) {
/*
 * Board View styles — ported verbatim from the root index.html <style> block
 * (the slice-1 BV subset: tokens, header, progress bar, lanes, cards, and the
 * mobile blocks). Class names and values are kept identical so the
 * @rewrite-port specs ported from tests/board.spec.js assert against the same
 * selectors and computed styles. docs/design.md is the design-system source.
 *
 * SDV / edit-UI rules (detail card, dropdowns, add flows, drag feedback) are
 * NOT here — they land with their slices (tada-199.2 / .3). The `.in-detail`
 * body-class selectors are kept in the BV rules they scope so those slices
 * slot in without rewriting these.
 */

* { box-sizing: border-box; }

/* Shared ring/shimmer/flash system (ported from index.html, tada-199.4): .card,
   .lane, .task-lane, .checklist-item, and .detail-inner all get a thin 1.5px
   --ring-color border, plus pseudo-elements that paint an animated
   conic-gradient band on top — ::before sweeps continuously (.is-shimmering, a
   mutation in flight, wired via useBoardEffects.runFx), ::after pulses twice
   then fades (.is-flashing, a background update just landed via SSE resync,
   wired via flashChanges). Splitting shimmer/flash across two pseudo-elements
   keeps them from fighting over one `animation` shorthand, so a flash can play
   over an active shimmer without interrupting it. .lane and .task-lane already
   use ::after for their own scroll-fade overlay (body:not(.in-detail)
   .lane::after / body.in-detail .task-lane::after below), so they only get the
   shimmer half (::before) here — flash on those two would fight the fade for
   the same pseudo slot. */
@property --ring-angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
.card::before, .card::after,
.lane::before,
.task-lane::before,
.checklist-item::before, .checklist-item::after,
.detail-inner::before, .detail-inner::after {
  content: ""; position: absolute; inset: -3px; border-radius: inherit;
  padding: 2px; opacity: 0; pointer-events: none;
  background: conic-gradient(from var(--ring-angle), transparent 0deg, color-mix(in oklch, var(--ring-color, var(--accent)) 55%, white) 8deg, var(--ring-color, var(--accent)) 26deg, transparent 55deg, transparent 360deg);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  transition: opacity .2s ease;
}
.card.is-shimmering::before, .lane.is-shimmering::before, .task-lane.is-shimmering::before, .checklist-item.is-shimmering::before, .detail-inner.is-shimmering::before { opacity: 1; animation: ring-spin 1.4s linear infinite; }
.card.is-flashing::after, .checklist-item.is-flashing::after, .detail-inner.is-flashing::after { opacity: 1; animation: ring-spin .5s linear 2, ring-fade-out .3s ease .7s 1 forwards; }
/* A card landed in this lane while it was collapsed (tada-316). The card itself
   is `display: none`, so it can carry no signal — the lane wears it instead, in
   the same two-pulse-then-fade the card flash uses, so both read as one event.

   `.lane` normally can't have a flash: its ::after is the scroll-fade overlay
   (see body:not(.in-detail) .lane::after below). A *collapsed* lane doesn't
   scroll and explicitly drops that overlay, so the slot is free exactly when
   this needs it — hence `.lane.collapsed`, never bare `.lane`. */
.lane.collapsed.lane-landed::after {
  display: block; content: ""; position: absolute; inset: -3px; border-radius: inherit;
  padding: 2px; pointer-events: none; z-index: 5; background: conic-gradient(from var(--ring-angle), transparent 0deg, color-mix(in oklch, var(--ring-color, var(--accent)) 55%, white) 8deg, var(--ring-color, var(--accent)) 26deg, transparent 55deg, transparent 360deg);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
  opacity: 1; animation: ring-spin .5s linear 2, ring-fade-out .3s ease .7s 1 forwards;
}
@media (prefers-reduced-motion: reduce) {
  /* No spin, but not silent either: the lane still marks itself, it just
     states the change instead of animating it. */
  .lane.collapsed.lane-landed::after { animation: ring-fade-out .3s ease .7s 1 forwards; }
}
@keyframes ring-spin { to { --ring-angle: 360deg; } }
@keyframes ring-fade-out { to { opacity: 0; } }

/* Stop horizontal overscroll from chaining into the browser's back/forward
   navigation: a left-edge swipe was triggering "back" and breaking the app.
   Keeps vertical pull-to-refresh / scrolling untouched (only the x axis). */
html, body { overscroll-behavior-x: none; }
body {
  margin: 0; background: var(--bg); color: var(--text);
  font: 17px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  /* Light text on a dark UI looks heavy/fuzzy under macOS sub-pixel
     antialiasing; grayscale smoothing renders it noticeably crisper. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Kill the double-tap-to-zoom gesture on mobile (pinch-zoom still works). */
  touch-action: manipulation;
}
/* No top padding: the sticky header's first child is the progress bar, which
   must sit flush at y=0 (tada-65.1). The old 28px of breathing room now lives
   under the header instead (.app-header's own padding-bottom on the title row). */
.wrap { margin: 0; padding: 0 0 80px; overflow-x: clip; }
button { font: inherit; }

/* Sticky app header: progress bar flush against the very top of the page
   (tada-65.1 — no top padding, so the bar's own edge is y=0), with the
   title / board picker / back affordance directly beneath. Stays fixed while
   the board scrolls under it. */
.app-header {
  position: sticky; top: 0; z-index: 6; background: var(--bg);
  margin-bottom: 14px;
}
header { display: flex; flex-direction: column; gap: 4px; padding-left: var(--gutter); padding-right: var(--gutter); }
header h1 { margin: 0; font-size: 30px; letter-spacing: -0.01em; }
.header-back:not(:empty) { padding: 10px var(--gutter) 0; }
/* Version / Updated chips live in the top-right of the sticky header, under
   the progress bar and aligned (right) with the title + board picker. */
.header-meta { margin-left: auto; display: flex; flex-direction: column; gap: 4px; align-items: flex-end; }
.header-meta-chips { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: flex-end; }
.header-changelog-link { font-size: 12px; color: var(--accent); text-decoration: none; opacity: .8; cursor: pointer; }
.header-changelog-link:hover { text-decoration: underline; opacity: 1; }

/* BV: center the board title like SDV does. Lay the header out as a 3-col grid
   (hamburger left, title centered, meta right) so the title sits dead-center
   regardless of the hamburger/chip widths flanking it. All widths. */
body:not(.in-detail) #appHeader header {
  display: grid !important;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
}
body:not(.in-detail) #appHeader header h1 { grid-column: 2; justify-self: center; text-align: center; }
body:not(.in-detail) #appHeader header .hamburger { grid-column: 1; justify-self: start; }
body:not(.in-detail) #appHeader header .header-right { grid-column: 3; justify-self: end; }

/* Board-picker hamburger + tray drawer (tada-215.6), ported from index.html. The
   button's grid placement and rest-fade live above (with .header-meta); this is
   its look and the drawer it opens. */
.hamburger {
  background: none; border: none; color: var(--faint); font-size: 20px;
  cursor: pointer; padding: 4px 8px; border-radius: 6px; line-height: 1;
  flex-shrink: 0;
}
.hamburger:hover { color: var(--text); background: var(--panel-2); }
.tray-overlay {
  position: fixed; inset: 0; background: rgba(0,0,0,.45); z-index: 100;
  opacity: 0; pointer-events: none; transition: opacity .2s ease;
}
.tray-overlay.open { opacity: 1; pointer-events: all; }
.tray {
  position: fixed; top: 0; left: 0; bottom: 0; width: 280px; max-width: 85vw;
  background: var(--panel); border-right: 1px solid var(--line);
  display: flex; flex-direction: column; z-index: 101;
  transform: translateX(-100%); transition: transform .22s ease;
}
.tray-overlay.open .tray { transform: translateX(0); }
.tray-header {
  display: flex; align-items: center; justify-content: space-between;
  padding: 18px 18px 14px; border-bottom: 1px solid var(--line);
}
.tray-title { font-size: 15px; font-weight: 700; color: var(--dim); text-transform: uppercase; letter-spacing: .06em; }
.tray-close {
  background: none; border: none; color: var(--faint); font-size: 20px;
  cursor: pointer; padding: 2px 6px; border-radius: 5px; line-height: 1;
}
.tray-close:hover { color: var(--text); background: var(--panel-2); }
.tray-list { flex: 1; overflow-y: auto; padding: 10px; }
.tray-empty { color: var(--faint); padding: 8px 12px; font-size: 14px; }
.tray-item {
  display: flex; align-items: center; justify-content: space-between;
  padding: 11px 12px; border-radius: 8px; cursor: pointer;
  text-decoration: none; color: var(--text); font-size: 15px; font-weight: 500;
  transition: background .1s;
}
.tray-item:hover { background: var(--panel-2); }
.tray-item.active { background: var(--panel-2); color: var(--accent); }
.tray-item-arrow { color: var(--faint); font-size: 14px; }

/* SDV: condense the app header (tada-199.6, port of index.html's
   `body.in-detail #appHeader` collapse). Opening a Story drops the header from
   the tall BV layout (progress bar + big centered title + a hover-reveal meta
   row) to a single tighter row — smaller title, the Version/Updated chips hidden
   — so the detail card gets the reclaimed vertical space.

   Back lives in #headerBack, inline with (left of) the centered title, matching
   the legacy SDV header. #appHeader itself becomes the grid so #headerBack (a
   sibling of <header>) and <header> land in adjacent columns: progress bar spans
   all, Back col 1, the <header> (title) col 2 centered. The always-visible auth
   control (.header-auth) is kept — only the faded meta chips go. */
body.in-detail #appHeader {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  grid-template-rows: auto auto;
  align-items: center;
  position: relative; /* positioning context for the pinned .header-right */
}
body.in-detail #appHeader #progress { grid-column: 1 / -1; grid-row: 1; }
body.in-detail #appHeader #headerBack {
  grid-column: 1; grid-row: 2; justify-self: start;
  padding: 4px var(--gutter) 0;
}
body.in-detail #appHeader > header {
  grid-column: 2; grid-row: 2; display: block; padding-top: 4px;
}
body.in-detail #appHeader header h1 {
  justify-self: center; text-align: center; font-size: 22px;
}
/* The header-right cluster (auth + hidden meta) rides along in <header>; pin it
   to the far right, on the title row (row 2) — bottom-aligned so it clears the
   full-width progress bar on row 1 rather than overlapping it. */
body.in-detail #appHeader header .header-right {
  position: absolute; right: var(--gutter); bottom: 4px;
}
/* The Version/Updated chips are BV-only context — drop them in the SDV, matching
   legacy (#headerMeta display:none in-detail). Auth stays (its own slot). */
body.in-detail #appHeader #headerMeta { display: none; }
/* When no SDV is open the slot is empty — never let it add a stray header row. */
body.in-detail #appHeader #headerBack:empty { display: none; }

/* Right cluster: the always-visible auth control anchored at the far right,
   with the hover-revealed meta chips to its left. Grouping them in one flex row
   keeps the auth control from adding a second header row and lets the chips
   fade independently. */
.header-right { display: flex; align-items: center; gap: 12px; }
/* The auth control (sign-in button / user button) is always visible and
   clickable — it must NOT inherit .header-meta's hover-reveal fade. Kept as its
   own slot so a signed-out user can always reach sign-in (tada-200 follow-up). */
.header-auth { display: flex; align-items: center; flex: none; order: 2; }
/* The signed-out Sign in button is a .btn.ghost; its size override has to come
   *after* .btn in source order to win (equal specificity), so it lives with the
   .btn rules further down rather than here. */
.header-right .header-meta { order: 1; }

.chip {
  background: var(--panel-2); border: 1px solid var(--line); color: var(--dim);
  padding: 3px 8px; border-radius: 999px; font-size: 12px;
}
.chip b { color: var(--text); font-weight: 600; }

/* Full-bleed: the bar spans the whole viewport width, edge to edge, with no
   gutter inset (tada-65.1). The header content below keeps its gutters. */
#progress { padding: 0; }

/* progress bar. The gap below it carries the vertical breathing room that used
   to come from .wrap's 28px top padding (dropped so the bar can reach y=0).

   The wrap reserves the bar's *collapsed* height in normal flow and the bar
   itself is absolutely positioned inside it, so expanding (10px → 48px) draws
   the open bar *over* the page instead of growing the sticky header and shoving
   the title row + board down 38px. The bar is a transient overlay affordance,
   not a layout participant — a pull-to-peek that reflows the whole page reads
   as a jump, and the puck's arc is unwatchable while the content under it
   slides. Height stays the animated property (not a transform) so the segment
   labels and the type strip lay out at their true size.

   The bottom margin is sized so the *expanded* 48px bar still clears the
   hamburger and meta chips below it: those are exactly what expanding reveals
   (`:has(#progressBar.expanded)` further down), so an overlay that covered them
   would hide the thing it just uncovered. 10px reserved + 38px of margin puts
   the open bar's bottom edge at the old pushed-down spacing. */
.progress-wrap { position: relative; height: 10px; margin: 0 0 38px; }
.progress {
  position: absolute; top: 0; left: 0; right: 0;
  /* Above the header's own stacking so the open bar covers the title row it
     used to displace (.app-header is z-index 6). */
  z-index: 7;
  height: 10px; background: var(--panel-2); overflow: hidden;
  /* Square along the top edge (it *is* the top edge of the page); only the
     bottom corners round, and only once expanded. */
  border-radius: 0;
  border: 0; border-bottom: 1px solid var(--line); display: flex; cursor: default;
  box-shadow: 0 3px 8px rgba(0,0,0,.45), inset 0 1px 0 rgba(255,255,255,.03);
  transition: height .2s ease, border-radius .2s ease;
}
.progress:hover, .progress.expanded { height: 48px; border-radius: 0 0 10px 10px; overflow: visible; }
.progress > .p-seg {
  height: 100%; transition: width .4s ease; position: relative;
  display: flex; align-items: center; justify-content: center;
  /* Seam on the *leading* edge, drawn inside this segment's own box via an inset
     shadow. A trailing `box-shadow: 1px 0 0` paints *outside* the element, so
     the next sibling (later in paint order, no stacking context to lift it)
     covered it with its own background and the seam never appeared between two
     segments — only past the last one. Inset, nothing can overdraw it. */
  box-shadow: inset 1px 0 0 var(--seam);
}
/* While a puck is in the air the widths hold still, then resize once it lands:
   the puck carries the change across and the bar answers it, rather than both
   happening at once (which read as a resize with a circle over it). The delay is
   the puck's launch delay plus its flight, set as --arc-land by the component so
   it tracks ARC_DELAY_MS/FLIGHT_MS instead of restating them here; the fallback
   is those two defaults summed.

   Only the delay is overridden: the .4s ease travel above still applies, so the
   widths ease as they always did — just later. */
.progress.arc-flying > .p-seg { transition-delay: var(--arc-land, 1100ms); }
/* The first segment's leading edge is the page's left edge, not a boundary
   between two fills — no seam to draw there. */
.progress > .p-seg.p-first { box-shadow: none; }
/* The full-bleed bar is square at rest (its top edge is the page's top edge);
   only the expanded state rounds its bottom outer corners, so the fill matches. */
.p-seg.p-first, .p-seg.p-last { border-radius: 0; }
.progress:hover .p-seg.p-first,
.progress.expanded .p-seg.p-first { border-radius: 0 0 0 10px; }
.progress:hover .p-seg.p-last,
.progress.expanded .p-seg.p-last { border-radius: 0 0 10px 0; }
.progress > .p-done { background: var(--done); }
.progress > .p-in-progress  { background: var(--in-progress); }
.progress > .p-planned { background: var(--planned); }
.progress > .p-idea { background: var(--idea); }
.p-label {
  display: none; flex-direction: column; align-items: center; line-height: 1.1;
  color: rgba(0,0,0,0.7); font-weight: 700; white-space: nowrap; pointer-events: none;
  padding-bottom: 6px;
}
.p-label .p-num { font-size: 15px; }
.p-label .p-lbl { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; opacity: .8; }
.progress:hover .p-label, .progress.expanded .p-label { display: flex; }
.progress:hover .narrow .p-label, .progress.expanded .narrow .p-label { display: none; }
/* per-segment tooltip for narrow segments */
.progress > div.narrow::after {
  content: attr(data-tip); display: none;
  position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
  background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
  padding: 3px 8px; font-size: 12px; color: var(--text); white-space: nowrap;
  box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 20; pointer-events: none;
}
/* Adjacent narrow segments centre their tooltips over tiny widths, so they
   collide (worst on mobile). Stagger every other narrow tooltip to a higher
   row, and connect it to its segment with a thin stem, so neighbours clear. */
.progress > div.narrow.tip-hi::after { bottom: calc(100% + 26px); }
.progress > div.narrow.tip-hi::before {
  content: ""; display: none; position: absolute; bottom: 100%; left: 50%;
  width: 1px; height: 26px; background: var(--line); z-index: 19;
}
.progress:hover > div.narrow::after, .progress.expanded > div.narrow::after { display: block; }
.progress:hover > div.narrow.tip-hi::before, .progress.expanded > div.narrow.tip-hi::before { display: block; }
/* Status-change arc (tada-65.4): a circle in the old status color flies from the
   old segment to the new one, cross-fading into the new color as it lands, while
   the segments below ease to their new widths (.p-seg's width transition).

   The horizontal travel is linear (ease-out would make the landing lag behind
   the widths settling); the vertical rise is a parabola sampled at fixed
   percentages of the flight, which is why x and y are separate keyframe tracks
   composed in one transform. The circle is centered on its endpoints via the
   -50% offsets, so --arc-from-x/--arc-to-x are true segment centers. */
.p-arc {
  /* Anchored to the bar's *bottom* edge, not its middle: the bar is mid
     height-transition (10px → 48px) while the arc flies, so a top/50% anchor
     would drift downward under the puck as it travels. The bottom edge holds
     still. 11px lifts it clear of the type strip pinned there. */
  position: absolute; bottom: 11px; left: 0;
  width: 14px; height: 14px; border-radius: 50%;
  margin: 0 0 -7px -7px; /* center on the (x, y) the transform places */
  pointer-events: none; z-index: 25;
  background: var(--arc-from-color);
  box-shadow: 0 0 0 2px rgba(20,23,28,.45), 0 2px 10px rgba(0,0,0,.5);
  /* Delayed by the bar's own .2s height transition: the puck arcs *inside* the
     expanded bar, so launching it while the bar is still 10px tall would clip
     its crown against the page edge (there is no headroom above y=0). */
  animation: p-arc-fly var(--arc-ms, 900ms) cubic-bezier(.4,0,.6,1) var(--arc-delay, 200ms) forwards,
             p-arc-tint var(--arc-ms, 900ms) ease-in-out var(--arc-delay, 200ms) forwards;
  opacity: 0; /* until the delayed animation's first keyframe takes over */
}
@keyframes p-arc-fly {
  0%   { transform: translate(var(--arc-from-x), 0) scale(.6); opacity: 0; }
  12%  { opacity: 1; }
  /* Parabolic rise: peak at the midpoint, symmetric on both sides. The x values
     interpolate linearly between the endpoints at the same percentages. */
  25%  { transform: translate(calc(var(--arc-from-x) + (var(--arc-to-x) - var(--arc-from-x)) * .25),
                              calc(var(--arc-rise) * -.75)) scale(1.05); }
  50%  { transform: translate(calc(var(--arc-from-x) + (var(--arc-to-x) - var(--arc-from-x)) * .5),
                              calc(var(--arc-rise) * -1)) scale(1.1); }
  75%  { transform: translate(calc(var(--arc-from-x) + (var(--arc-to-x) - var(--arc-from-x)) * .75),
                              calc(var(--arc-rise) * -.75)) scale(1.05); }
  88%  { opacity: 1; }
  100% { transform: translate(var(--arc-to-x), 0) scale(.75); opacity: 0; }
}
/* The color morph spans most of the flight rather than snapping near the end:
   the puck holds the old status color only long enough to be read as *coming
   from* it (through the launch fade-in), then cross-fades across the whole arc
   and is fully the new color by the time it starts descending into the target
   segment — so it lands the same color as what it lands on. */
@keyframes p-arc-tint {
  0%, 15% { background: var(--arc-from-color); }
  85%     { background: var(--arc-to-color); }
  100%    { background: var(--arc-to-color); }
}
/* The arc is decorative: with reduced motion the segment widths still update
   (that's information), but nothing flies. The component also skips spawning
   arcs at all — this is the belt to that braces. */
@media (prefers-reduced-motion: reduce) {
  .p-arc { display: none; }
  /* No puck to wait for, so nothing holds the widths back. The component never
     sets `arc-flying` under reduced motion either; this is the same belt/braces
     as the rule above. */
  .progress.arc-flying > .p-seg { transition-delay: 0s; }
}

/* type breakdown bar — one full-width strip pinned to the bottom of the whole bar */
.p-type-bar {
  display: none; position: absolute; bottom: 0; left: 0; right: 0;
  height: 8px; flex-direction: row; overflow: hidden;
  border-radius: 0 0 9px 9px;
}
/* The seam between the status band above and this type band, as an overlay line
   rather than a shadow on either band.

   Neither shadow direction works here. Cast *upward* (`0 -1px 0`) it lands
   outside this element, where the status segments paint over it. Cast *inset*
   (`inset 0 1px 0`) it lands on this band's own top edge — but the type segments
   are children filling the band's full 8px height, so their backgrounds cover it
   just the same. A ::before pinned to the top edge sits above both (it is a
   later-painted child of the same box), so nothing can overdraw it.

   It is also the one seam drawn at full strength rather than `--seam`'s alpha:
   the segment seams divide like from like (one status from the next), but this
   line divides two different *kinds* of data — counts above, type breakdown
   below — and at 1px/55% it was effectively invisible between two saturated
   fills. */
.p-type-bar::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 1px;
  background: var(--seam-strong); z-index: 1; pointer-events: none;
}
/* Leading-edge inset seam, for the same reason as .p-seg above — an outset
   shadow here was covered by the next type segment's background. */
.p-type-bar > div { height: 100%; transition: width .3s ease; box-shadow: inset 1px 0 0 var(--seam); }
.p-type-bar > div:first-child { box-shadow: none; }
.progress:hover .p-type-bar, .progress.expanded .p-type-bar { display: flex; }

@media (max-width: 599px) {
  :root { --gutter: 14px; }

  /* Controls row stays inline on narrow screens: the Filters trigger, shared
     clear, and +Add hold their size (flex:0 0 auto) while the search box yields
     (min-width:0). Legacy shrinks the trigger/clear a touch here so all four fit
     without the row overflowing. */
  .controls-row1 { min-width: 0; }
  .bv-type-trigger { flex: 0 0 auto; padding: 8px 10px; font-size: 14px; }
  .filter-clear { width: 32px; height: 32px; }
  #search { flex: 1 1 0; min-width: 0; font-size: 15px; }
  #addBtn { flex: 0 0 auto; white-space: nowrap; }

  /* Keep header chips compact and let them stack on the right so they don't
     crowd the large title on narrow screens. */
  .header-meta { gap: 3px; }
  .header-meta-chips { gap: 5px; }
  .header-meta .chip { font-size: 11px; padding: 2px 6px; }

  /* board: vertical stack instead of horizontal scroll */
  .board { flex-direction: column; overflow-x: visible; gap: 8px; }

  /* progress bar: suppress sticky touch-hover so only tap-toggle controls expansion */
  .progress:hover:not(.expanded) { height: 8px; border-radius: 0; }
  .progress:hover:not(.expanded) .p-label { display: none; }
  .progress:hover:not(.expanded) > div.narrow::after { display: none; }
  .progress:hover:not(.expanded) .p-type-bar { display: none; }
  .p-label .p-num { font-size: 12px; }
  .p-label .p-lbl { font-size: 9px; }
  .lane { flex: none; width: 100%; }

  /* Expanded + collapsed lane heads share a pixel-identical 40px box on mobile
     (fixed height, 0 vertical padding, 14px sides, NO bottom border in either
     state) so toggling a lane doesn't nudge the emoji / count label in any
     direction. The expanded head's divider line is drawn with box-shadow
     instead of border-bottom — box-shadow doesn't consume layout space, so it
     can't shift the centered content the way a 1px border did. */
  .board .lane-head {
    height: 40px; box-sizing: border-box; padding: 0 14px;
    border-bottom: none; box-shadow: inset 0 -1px 0 var(--line);
  }
  .board .lane.collapsed .lane-head { box-shadow: none; }
  /* collapsed lane on mobile: compact horizontal bar with label. Let the lane
     size to its 40px head (height:auto) instead of pinning the lane to 40px —
     a fixed lane height fought the lane's 1px borders and left the head 38px
     tall, centering the emoji/count 1px higher than in the expanded state. */
  .lane.collapsed { flex: none; width: 100%; height: auto; }
  .lane.collapsed .lane-head {
    flex-direction: row; align-items: center; justify-content: flex-start;
    height: 40px; padding: 0 14px; gap: 8px;
    /* The lane's inner radius: the head covers the lane's whole inner box and
       paints the hover fill, so anything smaller leaves the lane's background
       in the corners (tada-336). */
    border-radius: var(--radius-lane-inner);
  }
  .lane.collapsed .lane-head:hover { border-radius: var(--radius-lane-inner); }
  .lane.collapsed .lane-head .badge { writing-mode: horizontal-tb; text-orientation: mixed; letter-spacing: .06em; padding: 3px 8px; }
  .lane.collapsed .lane-head .count { margin-left: auto; font-size: 14px; font-weight: normal; color: var(--faint); }
}

/* Hide the board picker (hamburger) and the meta block (Version/Updated chips
   + the Changelog link nested under them) by default on the Board View, and
   reveal them only when the progress bar is expanded (hover on desktop, tap on
   mobile). Applies at all widths — the header stays minimal until the user
   opens the progress bar. The .header-changelog-link is inside .header-meta, so
   fading .header-meta carries it along. */
body:not(.in-detail) .hamburger,
body:not(.in-detail) .header-meta { opacity: 0; pointer-events: none; transition: opacity .15s; }
body:not(.in-detail) .app-header:has(#progressBar.expanded) .hamburger,
body:not(.in-detail) .app-header:has(#progressBar.expanded) .header-meta {
  opacity: 1; pointer-events: auto;
}

/* kanban board */
.board { display: flex; gap: 14px; align-items: stretch; overflow-x: auto; overscroll-behavior-x: contain; padding-bottom: 24px; padding-left: var(--gutter); padding-right: var(--gutter); }
/* `flex` is set per-breakpoint, NOT here: the mobile override (`flex: none;
   width: 100%`) lives in the @media (max-width: 599px) block *above* this
   rule, so a `flex` declaration here — later in source order, equal
   specificity — would clobber it and the lanes would inherit the desktop
   `1 1 270px`, capping each stacked lane's height and spilling cards out the
   bottom. Desktop sets `flex: 1 1 270px` in its own @media block below. */
/* min-width: 0 is load-bearing, not tidiness. A flex item defaults to
   min-width: auto, which refuses to shrink below its content's min-content
   width — so one unwrappable child (a <pre>, a long chip) sets a floor for the
   whole column, the lane blows past its `flex: 1 1 270px` share, and .board's
   overflow-x: auto scrolls the trailing lanes off-screen. The per-element
   `overflow-wrap: anywhere` guard on .card-title fixes the tokens it covers;
   this fixes the class, for any child that can't wrap.
   .task-lane and .card-main already carry it. */
.lane { background: var(--panel); border: 1.5px solid var(--ring-color, var(--line)); border-radius: var(--radius-lane); display: flex; flex-direction: column; min-height: 0; min-width: 0; position: relative; }
.lane-head { padding: 10px 14px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.lane-head:hover { background: var(--panel-2); border-radius: var(--radius-lane-inner) var(--radius-lane-inner) 0 0; }
.lane-head .count { color: var(--faint); font-size: 14px; margin-left: auto; }
/* The status emoji sits in its own cell left of the text label when expanded. */
.lane-emoji { font-size: 17px; line-height: 1; }
/* On mobile the page scrolls (lanes are stacked), so the lane body grows
   naturally — no overflow here. On desktop (≥600px) each lane scrolls
   independently inside a viewport-locked board (see the body:not(.in-detail)
   rules below), mirroring the SDV's per-column scroll so a long column doesn't
   push the others' "+ Add story" off-screen. */
/* Bottom padding is derived from the corner it has to clear, the sides and top
   are not. 8px all round left the last card's progress bar and the "+ Add
   Story" button running straight into the lane's bottom edge, crossing the
   14.5px inner curve and reading as squared off — the same fixed-inset-behind-a-
   moved-radius bug as the SDV board's. Only the bottom needs it: the top edge is
   a straight border under the lane head, and the sides are straight for the
   lane's whole height. Two-thirds of the radius, matching --board-inset's
   reasoning — enough that the corner reads as uninterrupted without spending
   more lane height than the curve actually occupies. */
.lane-body {
  padding: 8px 8px calc(var(--radius-lane-inner) * 0.667);
  min-height: 80px; flex: 1;
  border-radius: 0 0 var(--radius-lane-inner) var(--radius-lane-inner);
  transition: background .12s;
}
.lane.collapsed .lane-body { display: none; }
@media (min-width: 600px) {
  /* padding-top (not margin) clears the lane glow ring's 20px upward reach:
     .board's overflow-x:auto (desktop only — mobile sets overflow-x:visible)
     computes its overflow-y to auto too (the CSS overflow spec forces a
     "visible" axis to auto when the other axis is a scroll value), so a
     child's box-shadow bleeding above .board's own box gets clipped there
     regardless of spacing outside the box. Padding (inside the box) gives
     the glow room; margin (outside it) wouldn't. This also clears the glow
     from the sticky #appHeader's opaque background (z-index 6, above the
     board), which would otherwise paint over it. Scoped to desktop: mobile
     doesn't have the overflow-y:auto clipping, and adding this padding on
     mobile pushed the page's scroll math enough to let a scrolled lane-head
     land under the sticky header at click time (tada-98 regression). */
  .board { padding-top: 20px; }
  /* Desktop: lanes share the board width as equal flex columns. (Mobile
     stacks them full-width — see the @media (max-width: 599px) .lane rule.) */
  .lane { flex: 1 1 270px; }
  .lane.collapsed { flex: 0 0 38px; }
  .lane.collapsed .lane-head { border-bottom: none; border-radius: var(--radius-lane-inner); flex-direction: column; align-items: center; justify-content: flex-start; padding: 14px 4px 10px; gap: 10px; height: 100%; }
  .lane.collapsed .lane-head:hover { border-radius: var(--radius-lane-inner); }
  .lane.collapsed .lane-head .count { margin-left: 0; font-size: 15px; font-weight: 600; color: var(--dim); }
  .lane.collapsed .lane-head .badge { writing-mode: vertical-lr; text-orientation: upright; letter-spacing: .08em; padding: 6px 3px; }

  /* ---- Footer mode: every Story lane collapsed (tada-336) ----------------
     The BV half of the SDV task board's footer. Collapsing all four lanes used
     to leave four vertical strips holding the full height of the viewport;
     now the board folds to a flat row of bars along the bottom and the space
     above it is freed for the page's other content.

     The motion itself is shared — `.lane-board` / `.lane-col` and the
     `--lane-fold-*` timings, up in the fold block — so only the geometry that
     differs from the SDV's lives here. And it does differ: the SDV board sits
     in a locked frame beside a description, while this one *is* the page
     column, so the footer is pinned with `margin-top: auto` against `.wrap`'s
     flex column rather than inside a card. */
  body:not(.in-detail) .board.footer {
    /* Out of the flex-grow split so the space above is genuinely freed, and
       pinned to the bottom. */
    flex: 0 0 auto;
    min-height: 0;
    /* No `margin-bottom` + `translateY` reservation here, unlike the SDV's
       footer: that pairing exists to hold the resting `margin-top: 24px` the
       task board carries, and this board's spacing is padding on `.board`
       instead. Reserving a margin it never had made it settle 24px short and
       jump when the transition class dropped. */
    margin-top: auto;
    align-items: stretch;
    gap: 8px;
  }
  /* Each lane: a flat bar sharing the row, overriding the 38px strip above. */
  body:not(.in-detail) .board.footer .lane.collapsed { flex: 1; min-width: 0; }
  body:not(.in-detail) .board.footer .lane.collapsed .lane-head {
    flex-direction: row; align-items: center; justify-content: flex-start;
    padding: 0 12px; gap: 8px; height: 40px;
    /* The head is the only thing painted inside the lane here — body and border
       are the lane's, and the head covers its whole inner box. So its radius is
       the lane's *inner* radius, not a smaller number chosen for the bar's own
       proportions: the head paints the hover fill, and any shortfall leaves the
       lane's darker background showing through the four corners. That read as
       the corners dimming on hover (tada-336), which is a radius mismatch, not
       a color one. Clamped by the browser to half the 40px height anyway, so
       the "rounds into a pill" worry the old 12px guarded against is bounded by
       the box regardless. */
    border-radius: var(--radius-lane-inner);
  }
  /* Badge back to reading left-to-right; count to the far end of the bar. */
  body:not(.in-detail) .board.footer .lane.collapsed .lane-head .badge {
    writing-mode: horizontal-tb; text-orientation: mixed;
    letter-spacing: .06em; padding: 3px 8px;
  }
  body:not(.in-detail) .board.footer .lane.collapsed .lane-head .count {
    margin-left: auto; font-size: 14px; font-weight: normal;
  }
  /* The board's own height legs, the halves the shared `.lane-col` rule cannot
     carry. `flex-grow` belongs here as much as the heights do: the board's
     expanded height comes from growing into the page column, not from
     min-height, so grow snapping 0 → 1 would put it at full height in one frame
     while the height animation trailed something already arrived. */
  body:not(.in-detail) .board.lane-folding {
    transition:
      min-height var(--lane-fold-ms) var(--lane-fold-ease),
      flex-grow var(--lane-fold-ms) var(--lane-fold-ease);
  }
  body:not(.in-detail) .board.lane-unfolding {
    transition:
      min-height var(--lane-fold-ms) var(--lane-fold-ease),
      max-height var(--lane-fold-ms) var(--lane-fold-ease),
      flex-grow var(--lane-fold-ms) var(--lane-fold-ease);
    /* Stay bottom-anchored while the height grows, so the board opens upward
       out of the footer line rather than downward from the top of its final
       slot — otherwise the lanes' bottoms travel below the viewport until the
       height catches up. */
    margin-top: auto;
  }
  @media (prefers-reduced-motion: reduce) {
    body:not(.in-detail) .board.lane-folding,
    body:not(.in-detail) .board.lane-unfolding { transition: none; }
  }

  /* Desktop BV scroll containment (tada-78): lock the page to the viewport and
     lay .wrap out as a flex column — the sticky app-header takes its natural
     height at the top and the board fills the rest. Each .lane-body then scrolls
     within its lane, so a long column doesn't push the other columns' "+ Add
     story" off-screen. The lane head stays pinned per-column. Mirrors the SDV's
     per-column scroll. Off on mobile, where lanes stack and the page scrolls. */
  body:not(.in-detail) .wrap { height: 100dvh; overflow: hidden; display: flex; flex-direction: column; padding-bottom: 0; }
  body:not(.in-detail) #app { flex: 1; min-height: 0; display: flex; flex-direction: column; }
  body:not(.in-detail) .board { flex: 1; min-height: 0; padding-bottom: 12px; }
  body:not(.in-detail) .lane-body {
    overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
    /* The custom thumb positions against this box (useFadingScrollbar). */
    position: relative;
    /* Native bar suppressed in favour of that thumb — a lane is a permanent
       scroller, so the native gutter sat in every column whether or not it had
       anything to say. Overflow stays `auto`, so the wheel and
       useSdvCollapse's scroller arbitration are untouched. */
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  body:not(.in-detail) .lane-body::-webkit-scrollbar { width: 0; height: 0; }
  /* Fade at the TOP of a scrolled lane (tada-340.1) — the mirror of the bottom
     ::after. A card scrolling up out of the lane was cut off flat against the
     lane head; the bottom edge already softened its exit, the top did not.

     This is a real element (`.lane-top-fade`, rendered by BoardView), not a
     pseudo-element or a background, because both of the alternatives are
     structurally unavailable here:

       • `.lane`'s two pseudo slots are taken — ::before is the shimmer ring,
         ::after the bottom fade.
       • `.lane-head`'s slots are free, but it is `overflow: hidden` (see
         .lane-head below, where that clip fixes a collapsed-lane text spill),
         so an overlay at `top: 100%` is clipped away entirely. Computed styles
         report it present with the right geometry and opacity while nothing
         paints — a confusing dead end, hence this note.
       • A `background-attachment: local` scroll shadow on `.lane-body` can't
         work either: the Story cards are its children and paint ABOVE its
         background, so the fade would only tint the gaps between them.

     `position: sticky; top: 0` inside the scroller pins it to the lane's top
     edge while remaining a sibling of the cards, so it paints over them without
     needing a z-index war. It is `height: 0` in flow (the fade hangs off it via
     ::after) so it takes no layout space and can't push the first card down. */
  body:not(.in-detail) .lane-top-fade {
    /* `top: -8px` cancels .lane-body's 8px top padding. A sticky element pins to
       the padding box, so at `top: 0` the fade began 8px BELOW the lane head and
       left a band where a card scrolled past fully opaque — visibly "too low",
       and worse than no fade, because content appeared out of nowhere just under
       the head. Pinning at the padding edge puts the fade's start on the head's
       inner edge, where the content actually enters the lane. */
    position: sticky; top: -8px; height: 0; z-index: 3;
    pointer-events: none;
  }
  body:not(.in-detail) .lane-top-fade::after {
    content: ""; position: absolute; display: block;
    /* The lane body is padded 8px each side; stretch back over that so the fade
       spans the lane's full inner width rather than just the card column. */
    left: -8px; right: -8px; top: 0; height: 36px;
    /* Ends in the lane's own fill, and starts BELOW full opacity.
       At a solid `--panel` first stop the overlay's top edge is an opaque bar:
       it erases whatever sits under it in one pixel row, so a card title
       crossing that line is sliced cleanly in half rather than faded — measured
       text at rgb(140,150,163) on one row and flat panel on the next. A scroll
       fade has to *attenuate* content, never cut it. The bottom fade can end at
       full panel because it terminates at the lane's edge, where nothing
       follows; this one has live content on both sides of it. */
    background: linear-gradient(
      to bottom,
      color-mix(in srgb, var(--panel) 92%, transparent),
      color-mix(in srgb, var(--panel) 55%, transparent) 45%,
      transparent
    );
    opacity: 0; transition: opacity .15s ease;
  }
  body:not(.in-detail) .lane.scrolled-top .lane-top-fade::after { opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    body:not(.in-detail) .lane-top-fade::after { transition: none; }
  }
  /* Fade-to-panel overlay at the bottom of each BV lane — mirrors the SDV
     scroll fade. Only applied on desktop where lanes actually scroll. */
  body:not(.in-detail) .lane::after {
    content: ""; position: absolute; height: 36px;
    pointer-events: none; z-index: 4;
    /* Stretched over the *border* box, not the padding box. Absolute insets are
       measured from the padding box, so `left/right/bottom: 0` stopped the fade
       1.5px short of the lane's outer edge on three sides. At the straight edges
       that is invisible — the border paints over it — but at the corner the
       lane's curve pulls its own fill inward faster than the overlay's does, and
       a card scrolled to sit under the corner showed its square stroke through
       the sliver between the two. Hence the negative insets and the matching
       outer radius: the overlay now covers every pixel the lane rounds off. */
    left: -1.5px; right: -1.5px; bottom: -1.5px;
    border-bottom-left-radius: var(--radius-lane);
    border-bottom-right-radius: var(--radius-lane);
    background: linear-gradient(to bottom, transparent, var(--panel));
    /* …but the border box is exactly what the 1.5px status border occupies, and
       this overlay sits ABOVE it at z-index 4 — so at full opacity the gradient
       painted --panel straight over the border's bottom 48px, fading the ring
       out along the lane's own edge (tada-340). The commit that widened these
       insets reasoned the straight edges were safe because "the border paints
       over it"; the z-order is the other way round.

       So: keep the wide box for the corner, and mask the gradient off the two
       vertical border strips it must not paint over — except across the last
       2px, where the corner is. The mask is the UNION of two gradients
       (mask-composite: add): a horizontal one that clips each side, and a
       vertical one that adds full-width coverage back over that bottom 2px.
       Union, not intersection — the second layer has to ADD the corner strip
       back, including the side pixels the first layer removed.

       Two numbers here were each got wrong once, so both are load-bearing:

       • The corner band is 2px, NOT the full --radius-lane. The sliver 1503329
         closed is the gap between the lane's curve and a card's square edge, and
         it is only ever ~1.5px — the width of the border the corner rounds away.
         Exempting the whole radius (16px) instead re-covers the border across
         the bottom 2% of the lane, which is exactly where the fade was measured,
         so the bug survives its own fix.
       • The side clip runs to 2.5px and ramps to opaque by 3.5px, rather than
         stopping dead at the border's own 1.5px. The border straddles device
         pixels at a DPR above 1, so a hard cut on its edge still let the gradient
         tint the column the border was antialiased into — measured at ~55% of
         full colour. Clearing it by a pixel, with a 1px ramp instead of a step,
         leaves the border alone and keeps the mask edge invisible against the
         fade.

       TLBC1 in tests/lane-border-continuity.spec.ts samples painted pixels down
       the border and fails if either number drifts back. */
    -webkit-mask-image:
      linear-gradient(to right, transparent 2.5px, #000 3.5px, #000 calc(100% - 3.5px), transparent calc(100% - 2.5px)),
      linear-gradient(to bottom, transparent calc(100% - 2px), #000 calc(100% - 2px));
    mask-image:
      linear-gradient(to right, transparent 2.5px, #000 3.5px, #000 calc(100% - 3.5px), transparent calc(100% - 2.5px)),
      linear-gradient(to bottom, transparent calc(100% - 2px), #000 calc(100% - 2px));
    -webkit-mask-composite: source-over;
    mask-composite: add;
  }
  /* Only while there is actually something below to scroll to (tada-340.1).
     Unconditionally painted, this sat as a permanent haze over the bottom 36px
     of every lane — including a short lane with three cards and nothing
     underneath them — which is not a fade but a stripe, the same defect the SDV
     task-lane fade names in its own comment. `.scrollable-bottom` is set from
     the same BoardView scroll handler that drives `.scrolled-top`, and cleared
     once the lane is scrolled to its end, so the last card lands crisp. */
  body:not(.in-detail) .lane::after { opacity: 0; transition: opacity .15s ease; }
  body:not(.in-detail) .lane.scrollable-bottom::after { opacity: 1; }
  @media (prefers-reduced-motion: reduce) {
    body:not(.in-detail) .lane::after { transition: none; }
  }
  /* …except while it's ringing a landed card (tada-316): that reuses this very
     ::after slot, which is free precisely because a collapsed lane doesn't
     scroll. Without the :not() this rule would win on specificity and order,
     and the ring would never paint. */
  body:not(.in-detail) .lane.collapsed:not(.lane-landed)::after { display: none; }
}
/* Status-color ring around each BV column (tada-79): thin 1.5px border
   colored via --ring-color, set per status. Matches STATUS_COLOR in
   lib/board-meta.ts. */
.lane[data-status="idea"]        { --ring-color: var(--idea); }
.lane[data-status="planned"]     { --ring-color: var(--planned); }
.lane[data-status="in-progress"] { --ring-color: var(--in-progress); }
.lane[data-status="done"]        { --ring-color: var(--done); }
.lane-empty { color: var(--faint); font-size: 15px; text-align: center; padding: 20px 8px; font-style: italic; pointer-events: none; }

/* cards */
.card {
  position: relative;
  /* Rounded to match the softer lanes, but only as far as the padding allows.
     A corner eats into the text box exactly when its radius exceeds the padding
     — at that point the curve crosses the content edge and the first/last line
     of a wrapping title has to sit clear of it. With 10px of padding, 12px is
     one step past square-looking and still leaves the text its full width.
     This is why the cards do NOT take --radius-lane: a lane's 16px would cost
     real rendering space on a box this small. */
  background: var(--panel-2); border: 1.5px solid var(--ring-color, var(--line)); border-radius: 12px;
  padding: 10px; margin: 6px 2px 14px; cursor: grab; user-select: none;
  transition: opacity .15s, box-shadow .15s;
}
/* Cards are position:relative siblings, so a hovered card's type-icon tooltip
   (which floats up over the previous card) would otherwise be painted under
   that earlier-in-DOM neighbour. Lift the hovered card above its siblings so
   its tooltip / action menu float on top of the stack. */
.card:hover { z-index: 2; }
.card:focus-within { z-index: 10; }
/* The actions strip (the kebab menu) floats top-right at right:6px, width
   22px, spanning only the title's row (y ~7–29px). The reserve for it lives on
   .card-title alone (its padding-right), not on the whole card — so the note
   excerpt and task count below the strip reflow full-width to the card edge
   instead of stopping short. (tada-199 BV polish.) */
.card:active { cursor: grabbing; }
/* A card is a left icon column (type emoji / id number / status icon) beside
   a main column holding the title, note, and task count. */
.card-body { display: flex; align-items: flex-start; gap: 9px; }
/* The card's bottom edge is occupied by .card-progress — absolutely positioned
   at bottom:4px, 7px tall, so it is out of flow and reserves no space of its
   own. The card's uniform 10px padding is not enough to clear it.

   The reserve lives on .card-body (the flex row), not on either column inside
   it: whichever column is taller sets the card's height, and that is not always
   the same one. tada-9 first hit this via the due chip and reserved on
   .card-footnotes, which sits in .card-main — that fixed it only while
   .card-main was the taller side. On a short card (one-line title, no note
   excerpt) the icon rail is taller, and its tags/type emoji ran into the bar
   with no reserve at all (tada-328). Reserving on the row covers both.

   Only when the bar is actually there: `.card-progress` renders solely for a
   story with tasks (CardProgress returns null otherwise), so an unconditional
   reserve would leave a dead gap on task-less cards. A CSS sibling selector
   can't express this — the bar is a *later* sibling of .card-body — hence the
   modifier class from the component. */
.card-body.has-progress { padding-bottom: 5px; }
.card-main { min-width: 0; flex: 1; }
.card-idicon {
  flex-shrink: 0; display: flex; flex-direction: column; align-items: center;
  gap: 8px; min-width: 26px;
}
/* Thin proportional-segment bar along the card's bottom edge, sitting inside
   the card's glow ring (.card is position:relative, so bottom:0 lands on the
   padding edge, inside the border). Its radius does NOT track .card's: the bar
   is 7px tall, and a border-radius over half the height is clamped by the
   browser to that half — anything above 3.5px here renders identically. So the
   card getting rounder cannot be matched by the bar, and asking for it just
   writes a number that never takes effect. It reads fine: at this size the bar
   is a hairline and its ends sit 4px inside the card's own curve. Not clipped
   with overflow:hidden on
   .card itself, since the card-type tooltip (.card-type-icon::after) floats
   outside the card's box and overflow:hidden would clip it. */
.card-progress {
  position: absolute; left: 4px; right: 4px; bottom: 4px; height: 7px;
  display: flex; overflow: hidden;
  border-radius: 3.5px;
  box-shadow: 0 2px 5px rgba(0,0,0,.5), inset 0 1px 0 rgba(0,0,0,.35);
}
.card-progress > div { box-shadow: 1px 0 0 rgba(20,23,28,.55); }
.card-progress > div:last-child { box-shadow: none; }
.card-type-icon {
  font-size: 19px; line-height: 1; flex-shrink: 0;
  position: relative; cursor: default;
}
.card-type-icon::after {
  content: attr(data-type); display: none;
  position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%);
  background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
  padding: 3px 8px; font-size: 12px; color: var(--text); white-space: nowrap;
  box-shadow: 0 2px 8px rgba(0,0,0,.4); z-index: 20; pointer-events: none;
  text-transform: capitalize;
}
.card-type-icon:hover::after { display: block; }
/* Tag chips: emoji-only, kebab-case label revealed in a tooltip on hover
   (desktop) or tap (mobile). Used on both BV cards and the SDV header so the
   two views stay visual equals. */
.tags { display: inline-flex; gap: 4px; flex-wrap: wrap; align-items: center; }
.tag {
  font-size: 14px; line-height: 1; flex-shrink: 0;
  position: relative; cursor: default;
}
/* Card-tags: a 3-D CAROUSEL in the narrow id/icon column. The current tag is
   centered and full-size; the previous and next tags peek behind it, dimmed
   and scaled down. A JS ticker advances the roles on a ~1.8s loop, stepping
   backwards through the tag array so the incoming chip rises out of the left
   (.tag-prev) slot — the stack reads left-to-right (tada-221). The box is
   capped to the column width (26px) and chips are positioned within it, so the
   peeking neighbours never widen the column or push the card body right. All
   chips stack at center; role classes (.tag-cur / .tag-prev / .tag-next)
   place them. */
.card-tags {
  position: relative; width: 26px; height: 20px; align-self: center;
  flex: 0 0 auto; cursor: pointer;
}
.card-tags .tag {
  position: absolute; left: 50%; top: 50%; display: flex;
  align-items: center; justify-content: center;
  transition: transform .7s ease, opacity .7s ease, filter .7s ease;
  pointer-events: none; opacity: 0; transform: translate(-50%, -50%) scale(.5);
  z-index: 0;
  /* Faintest possible light glow so the emoji reads against the lane bg. */
  text-shadow: 0 0 5px rgba(255, 255, 255, .35);
}
/* Current: centered, full size, on top. */
.card-tags .tag.tag-cur {
  opacity: 1; transform: translate(-50%, -50%) scale(1); z-index: 3;
}
/* Neighbours peek behind to the sides, shrunk and darkened. ~9px offset keeps
   them inside the 26px box (13px half-width − a hair). */
.card-tags .tag.tag-prev {
  opacity: .55; z-index: 1; filter: brightness(.5);
  transform: translate(calc(-50% - 9px), -50%) scale(.62);
}
.card-tags .tag.tag-next {
  opacity: .55; z-index: 1; filter: brightness(.5);
  transform: translate(calc(-50% + 9px), -50%) scale(.62);
}
/* A lone tag never animates — just shown, centered, no neighbours. */
.card-tags.single .tag { opacity: 1; transform: translate(-50%, -50%); transition: none; }
.card-tags:hover .tag.tag-prev, .card-tags:hover .tag.tag-next { filter: brightness(.7); }
/* A Story card is a preview tile, so the title is line-clamped (like the note
   below) to keep a very long title from ballooning the card past its lane.
   The full title is always visible in the SDV. line-clamp self-hides when the
   title is short, so the common one/two-line case is untouched. */
.card-title {
  font-weight: 600; font-size: 15px; line-height: 1.4; padding-top: 1px;
  /* Reserve room for the actions strip, which floats over the title's row only
     (strip left edge ~28px from the card's right border — one 22px kebab
     button at right:6px). The note/count below the strip carry no such
     reserve, so they reflow full-width. */
  padding-right: 28px;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3;
  overflow: hidden;
  /* Break long unbreakable tokens (URLs, inline `code`, path:line spans) so a
     single card can't set the lane's min-content width wider than its flex
     share, which would refuse to shrink and push lanes off the right edge. */
  overflow-wrap: anywhere;
}
.card-title.placeholder { color: var(--faint); font-style: italic; font-weight: 400; }
.card-id {
  /* Title's UI font, isolated in a rounded-rect chip. A small top margin
     drops the chip so it centers on the title's first line (line-box ~21px),
     keeping it visually inline with the title. */
  font-size: 14px; font-weight: 700; line-height: 1; color: var(--dim);
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 3px 7px; margin-top: 3px;
}
.badge { font-size: 12px; text-transform: uppercase; letter-spacing: .06em; padding: 3px 8px; border-radius: 5px; font-weight: 700; white-space: nowrap; }
.badge.done { background: rgba(70,201,138,.15);  color: var(--done); }
.badge.planned { background: rgba(124,92,255,.15);  color: var(--planned); }
.badge.idea { background: rgba(232,234,240,.12); color: var(--idea); }
.badge.bug  { background: rgba(224,112,95,.15);  color: var(--bug); }
.badge.in-progress  { background: rgba(240,192,64,.15);  color: var(--in-progress); }
.card-subtask-count { font-size: 13px; color: var(--faint); }

/* Due date (tada-9) ─────────────────────────────────────────────────────────
   The card's footnote row: task count and due chip share a line, wrapping
   rather than clipping when a long "Overdue by 12d" meets a big task count. */
.card-footnotes {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px;
}

/* ── Alerts (tada-318) ───────────────────────────────────────────────────────
   `.alert` is the shared shape for a **static marker stating a fact about a
   Story**: a small pill, rendered identically on a Story card and in the SDV
   header, that the reader looks at and does not click.

   That definition is the whole boundary, and it is deliberately narrower than
   "things that look like pills". `.detail-links-chip` (relationships) is a
   *control* — it opens a popover, has a hover state, and carries its own
   blocked styling — so it is NOT an alert, however similar it looks. Folding a
   button in here would mean either alerts acquire affordances they shouldn't
   have, or that chip loses ones it needs.

   Variants set three tokens and nothing else:
     --alert-fg  text + dot colour
     --alert-bg  fill
     --alert-bd  border

   That token seam is what tada-9.4 built when the due chip was the only member.
   The loose-ends marker was its third consumer, at which point the shared shape
   earned its own name: before this, "borrow `.due-chip`" meant a structural
   marker wore the *schedule* marker's class, and `.due-chip` stopped being a
   reliable selector for "a due date" (which broke TDD18). Now `.due-chip` means
   a due date again, and `.alert` means the shape. */
.alert {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 12px; line-height: 1; white-space: nowrap;
  padding: 3px 7px; border-radius: 999px;
  color: var(--alert-fg, var(--faint));
  background: var(--alert-bg, transparent);
  border: 1px solid var(--alert-bd, transparent);
}
/* The leading glyph slot. A dot for the due states, a flag for loose ends —
   same box either way, so alerts line up whatever marks them. */
.alert-icon { flex: none; }
/* The alert's text. Named for the role, not the variant, so a new alert doesn't
   have to reach for a `due-` class to get its label styled. */
.alert-text { min-width: 0; }

/* The due alert (tada-9). Colors come from the existing status palette rather
   than new tokens: overdue borrows --bug (the same red that already means
   "something is wrong here") and due-soon borrows --accent, so the board keeps
   one vocabulary of alarm rather than inventing a second. `upcoming` stays
   deliberately quiet — a date weeks out is information, not a warning, and
   coloring it would spend the reader's attention on the stories that need it
   least. */
.due-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: currentColor;
}

/* tada-9.6 — a chip DERIVED from a task's date rather than the item's own.
   A second axis on the same marker (source), orthogonal to state: the state
   tokens still set the colour, this only softens the fill so a derived chip
   reads as one step removed without inventing a second colour vocabulary. The
   label already says "Task due …" — this is the redundant visual channel, not
   the only one. */
.due-chip.due-derived { border-style: dashed; opacity: .85; }
/* The overrun hint: a task due after its parent story. Deliberately quiet — it
   points at the SDV, it doesn't report. Its meaning lives in the chip's
   aria-label, so it's aria-hidden and needs no hover affordance (which mobile
   wouldn't get anyway). */
.due-overrun { font-weight: 700; margin-left: 1px; opacity: .8; }
.due-upcoming { --alert-fg: var(--faint); --alert-bd: var(--line); }
.due-soon {
  --alert-fg: var(--accent);
  --alert-bg: color-mix(in oklch, var(--accent) 14%, transparent);
  --alert-bd: color-mix(in oklch, var(--accent) 35%, transparent);
}
.due-overdue {
  --alert-fg: var(--bug);
  --alert-bg: color-mix(in oklch, var(--bug) 16%, transparent);
  --alert-bd: color-mix(in oklch, var(--bug) 45%, transparent);
}
/* Only overdue pulses. A slow, low-amplitude breath on the dot alone — enough to
   catch the eye on a scan of the board, far short of the flashing that makes a
   board unpleasant to sit in front of all day. Honors reduced-motion below. */
.due-overdue .due-dot { animation: due-pulse 2.4s ease-in-out infinite; }
@keyframes due-pulse {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: .45; transform: scale(1.35); }
}
@media (prefers-reduced-motion: reduce) {
  .due-overdue .due-dot { animation: none; }
}
.due-exact { color: var(--faint); font-size: 11px; }

/* The tasks-incomplete alert (tada-318) — a done Story still holding `planned`
   / `in-progress` tasks.

   Deliberately NOT alarming. Two readings are both legitimate — closed early,
   or stale bookkeeping — so the marker points, it doesn't judge. That rules out
   `--bug` (which already means "something is wrong here") and rules out the
   overdue dot's pulse: a Done lane full of breathing red chips on a mature
   board is how a marker gets ignored. `--dim` on a faint ground reads as an
   annotation, which is what it is.

   Its glyph is a flag rather than a dot, so it is distinguishable from a due
   alert at a glance and without relying on colour alone. */
.alert-tasks-incomplete {
  --alert-fg: var(--dim);
  --alert-bg: color-mix(in oklch, var(--dim) 10%, transparent);
  --alert-bd: color-mix(in oklch, var(--dim) 30%, transparent);
}
.alert-tasks-incomplete .alert-icon { font-size: 11px; line-height: 1; }

/* ── The SDV header slot ─────────────────────────────────────────────────────
   An alert riding the inner header bar wears a **knockout pill**: `--panel`
   punched into the colored band with an inset shadow, the same treatment as the
   id chip and the type/status dropdowns.

   Not only for consistency — the bar is *type*-colored, so a Bug's red band
   would swallow an overdue alert's own `--bug` red entirely (the alarm state
   rendering as the least visible thing in the header), and a `--dim`
   tasks-incomplete alert on a Chore's band would be close to invisible. The
   opaque ground gives every alert the same dark backdrop it has on a card, so
   the variant colors mean the same thing in both places.

   One rule for every alert slot rather than one per marker: the due slot and
   the loose-ends slot had identical bodies, and a third would have copied them
   again. */
.detail-ring-bar--inner .alert-slot {
  display: inline-flex; align-items: center;
  background: var(--panel); border-radius: 999px;
  box-shadow: inset 0 1px 4px rgba(0,0,0,.4);
  padding: 2px 8px;
}
/* The alert sits *in* the pill, so it drops its own outline and lets the
   punch-out be the container — otherwise it reads as a chip inside a chip. */
.detail-ring-bar--inner .alert-slot .alert {
  background: transparent; border-color: transparent; padding: 0;
}

/* The due slot is an `.alert-slot` (above) plus one thing of its own: unlike
   the other alerts it is *editable in place* (tada-9), so it sits inline with
   the type/status/id chips rather than being pushed to the far edge — hence no
   `margin-left: auto`.

   The empty state is a bare button with no alert inside, so it needs the pill's
   affordance itself — `--faint` on the colored bar was unreadable. */
.detail-ring-bar--inner .detail-due-btn--empty { color: var(--dim); }
.detail-ring-bar--inner .detail-due-btn--empty:hover { color: var(--accent); }
.detail-due { display: inline-flex; align-items: center; gap: 6px; }
.detail-due-btn {
  background: none; border: none; padding: 0; cursor: pointer; font: inherit;
  display: inline-flex; align-items: center;
}
.detail-due-btn--empty { font-size: 12px; color: var(--faint); }
.detail-due-btn--empty:hover { color: var(--accent); }
/* The date input. Note the *popup calendar itself is not styleable* — it is
   browser chrome, drawn outside the page, and no CSS reaches it. What we can
   style is the field and its calendar icon, so those are matched to the panel
   rather than left as the OS default light-on-white control. `color-scheme:
   dark` is the one lever that does affect the popup: it asks the browser to
   render its own chrome dark, which is why the popup follows the app instead of
   flashing white. */
.detail-due-input {
  background: var(--panel-2); color: var(--text);
  border: 1px solid var(--line); border-radius: 6px;
  padding: 3px 6px; font: inherit; font-size: 12px;
  color-scheme: dark;
}
.detail-due-input:focus { outline: none; border-color: var(--accent); }
/* WebKit's calendar button is black-on-dark by default, effectively invisible
   here; invert makes it read, and it brightens to the accent on hover so it
   looks like the affordance it is. */
.detail-due-input::-webkit-calendar-picker-indicator {
  filter: invert(1); opacity: .55; cursor: pointer;
}
.detail-due-input::-webkit-calendar-picker-indicator:hover { opacity: 1; }
.detail-due-clear {
  background: none; border: none; color: var(--faint);
  cursor: pointer; font-size: 12px; padding: 2px 4px; line-height: 1;
}
.detail-due-clear:hover { color: var(--bug); }

/* Global fixed tooltip — positioned by JS, escapes all stacking contexts. */
#tooltip {
  position: fixed; z-index: 9999; pointer-events: none;
  background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
  padding: 4px 9px; font-size: 12px; color: var(--text); white-space: nowrap;
  box-shadow: 0 2px 10px rgba(0,0,0,.5);
  opacity: 0; transition: opacity .1s;
}
#tooltip.visible { opacity: 1; }
/* Tag popover — the card-tags hover grid: every applied tag as emoji + label.
   Fixed-position (JS-placed) so it escapes lane stacking contexts, like the
   tooltip. pointer-events:none keeps it from stealing the hover it depends on. */
#tagpop {
  position: fixed; z-index: 9999; pointer-events: none;
  background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 9px; box-shadow: 0 4px 14px rgba(0,0,0,.5);
  opacity: 0; transition: opacity .1s;
  display: grid; grid-template-columns: auto auto; gap: 4px 8px;
  align-items: center; font-size: 12px; color: var(--text);
}
#tagpop.visible { opacity: 1; }
#tagpop .tp-emoji { font-size: 14px; line-height: 1; }
#tagpop .tp-label { white-space: nowrap; }

/* ── BV edit slice (tada-199.2) — ported verbatim from index.html ─────────── */

/* Controls row: Filters dropdown | shared clear | search (flex) | + Add. */
#controls { padding: 0 var(--gutter); position: relative; }
.controls { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; }
/* Fills the row so the search-wrap can grow; legacy's row1 is flex:1 too. */
.controls-row1 { display: flex; gap: 8px; align-items: center; min-width: 0; flex: 1 1 0; }

/* Search bar — ported from index.html. The wrap grows to fill the row; the
   clear (❌) button is absolutely pinned inside the input's right pad and only
   renders when there's a query (Controls). */
.search-wrap { flex: 1 1 0; min-width: 0; position: relative; display: flex; }
#search {
  flex: 1 1 0; min-width: 0; height: 36px; box-sizing: border-box; background: var(--panel-2);
  border: 1px solid var(--line); color: var(--text); padding: 0 36px 0 12px;
  border-radius: 8px; font-size: 16px;
}
#search:focus { outline: none; border-color: var(--accent); }
.search-clear {
  position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
  background: none; border: none; cursor: pointer; padding: 0;
  width: 24px; height: 24px; border-radius: 6px; font-size: 14px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  transition: background .12s, box-shadow .12s;
}
.search-clear:hover { background: var(--panel); box-shadow: 0 0 0 1px var(--line); }

/* ── BV Filters dropdown (tada-205) — ported from index.html ────────────────
   The custom dropdown (.detail-dd base) is otherwise an SDV slice; the subset
   the Filters control needs is ported here so BV gets it now. Keep in lockstep
   with index.html's .detail-dd* / .bv-* rules. */
.detail-dd { position: relative; flex: 0 0 auto; }
.detail-dd-trigger {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: inherit; font-size: 13px; font-weight: 600;
  background: var(--panel-2); border: 1.5px solid var(--line); border-radius: 999px;
  padding: 4px 11px; cursor: pointer; color: var(--text);
  transition: background-color .12s, box-shadow .12s;
}
.detail-dd-trigger .dd-caret { font-size: 9px; opacity: .7; margin-left: 1px; }
.detail-dd-trigger:hover { background-color: var(--panel); }
.detail-dd-trigger:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(255,255,255,.10); }
.detail-dd-menu {
  display: none; position: absolute; z-index: 30; top: calc(100% + 5px); left: 0;
  min-width: 100%; max-width: min(280px, calc(100vw - 2 * var(--gutter)));
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px;
  padding: 4px; box-shadow: 0 8px 28px rgba(0,0,0,.5);
}
.detail-dd-menu.open { display: block; }
.detail-dd-opt {
  display: flex; align-items: center; gap: 8px; width: 100%;
  font-family: inherit; font-size: 13px; font-weight: 600; text-align: left;
  background: none; border: none; border-radius: 6px; padding: 7px 12px 7px 9px;
  cursor: pointer; color: var(--text);
}
.detail-dd-opt .dd-emoji { font-size: 14px; width: 18px; text-align: center; }
.detail-dd-opt:hover { background: rgba(255,255,255,.07); }
.detail-dd-opt.selected { background: rgba(255,255,255,.05); }

/* The Filters trigger overrides the round pill: a fixed-height square-ish button
   flush with #search / +Add in the row. */
.bv-type-trigger {
  height: 36px; box-sizing: border-box; padding: 0 14px;
  font-size: 15px; border-radius: 8px; white-space: nowrap;
}
.bv-type-trigger.active { border-color: var(--accent); color: var(--accent); }
.bv-type-opt.bv-type-opt { padding-left: 9px; } /* override .detail-dd-opt's padding-left */
.bv-type-check {
  display: inline-flex; align-items: center; justify-content: center;
  width: 14px; font-size: 12px; color: var(--accent); font-weight: 700;
}
.bv-type-dots { display: inline-flex; align-items: center; gap: 5px; }
.bv-type-dot { width: 10px; height: 10px; border-radius: 50%; flex: 0 0 auto; }
.bv-tag-dot { font-size: 14px; line-height: 1; flex: 0 0 auto; }
/* An inset ring around the trigger's tag-emoji group signals "All" (AND) mode —
   the group reads as one bundled condition rather than loose picks. */
.bv-tag-dots { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; }
.bv-tag-dots.and {
  padding: 4px 6px; box-shadow: inset 0 0 0 1.5px rgba(255, 200, 87, .55);
  background: rgba(255, 200, 87, .08);
}
/* Filters menu: Type + Tags sections, each with a small header; the Tags header
   carries the Any/All match-mode toggle. */
.bv-filter-menu { max-width: min(240px, calc(100vw - 2 * var(--gutter))); }
.bv-filter-section-head {
  display: flex; align-items: center; justify-content: space-between;
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
  color: var(--faint); padding: 8px 9px 4px;
}
.bv-filter-section-head:first-child { padding-top: 4px; }
.bv-tag-mode { display: inline-flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.bv-tag-mode-opt {
  font-family: inherit; font-size: 10px; font-weight: 700; text-transform: uppercase;
  background: none; border: none; padding: 3px 7px; cursor: pointer; color: var(--faint);
}
.bv-tag-mode-opt.selected { background: var(--accent); color: #1a1200; }
.bv-tag-mode-opt:not(.selected):hover { background: rgba(255,255,255,.07); }
/* Shared clear: an icon-only red X (clears query + type + tag). Its label shows
   in a small CSS tooltip on hover/focus (data-tooltip). */
.filter-clear {
  position: relative; background: none; border: 1px solid transparent; color: var(--bug);
  width: 36px; height: 36px; border-radius: 8px; font-size: 16px; cursor: pointer;
  display: flex; align-items: center; justify-content: center; flex: 0 0 auto;
}
.filter-clear:hover { border-color: var(--bug); background: rgba(220,80,80,.1); }
.filter-clear::after {
  content: attr(data-tooltip);
  position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%);
  background: var(--panel-2); border: 1px solid var(--line); color: var(--text);
  font-size: 12px; font-weight: 600; white-space: nowrap;
  padding: 4px 8px; border-radius: 6px; box-shadow: 0 4px 14px rgba(0,0,0,.4);
  opacity: 0; pointer-events: none; transition: opacity .1s ease; z-index: 40;
}
.filter-clear:hover::after, .filter-clear:focus-visible::after { opacity: 1; }

.btn {
  height: 36px; box-sizing: border-box;
  background: var(--accent); color: #1a1200; border: none; padding: 0 16px;
  border-radius: 8px; cursor: pointer; font-size: 15px; font-weight: 600;
}
.btn:hover { filter: brightness(1.08); }
.btn.ghost { background: var(--panel-2); color: var(--text); border: 1px solid var(--line); }
.btn.danger { background: var(--bug); color: #fff; }
/* Signed-out auth control (.header-auth). A ghost pill shrunk from .btn's
   36px/15px default to sit level with the signed-in Clerk avatar (28px) it
   swaps with, so the header row doesn't jump on sign in/out. Must follow .btn
   here: same specificity, so source order decides. */
.sign-in-btn { height: 28px; padding: 0 12px; font-size: 13px; border-radius: 7px; }

/* Board load-failure state (tada-234.2), replacing the bare <pre> of the raw
   API message. A board URL is a deep link that a signed-out visitor can legibly
   land on, so this is a real landing rather than a debug dump: a heading, a
   sentence, and the one action that can help (sign in). Narrow measure and top
   margin keep it from sitting flush under the header like a stack trace.
   Copy — and which half renders — comes from lib/board-error.ts. */
.board-error { max-width: 46ch; margin: 48px auto 0; text-align: center; }
.board-error-heading { font-size: 19px; font-weight: 700; color: var(--text); margin: 0 0 8px; }
.board-error-body { color: var(--dim); font-size: 15px; line-height: 1.55; margin: 0 0 20px; }
/* Signed-in only: the raw message, dimmed to read as a diagnostic footnote
   rather than the main event, and wrapped so a long slug can't force the page
   to scroll sideways. */
.board-error-detail {
  margin: 20px 0 0; padding: 10px 12px;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  color: var(--faint); font-size: 13px; text-align: left;
  white-space: pre-wrap; overflow-wrap: anywhere;
}
/* Out of flow so its text popping in/out ("Saving…" → "") never reflows the
   toolbar.

   `fixed`, not `absolute` (tada-261): this moved out of #controls — which was
   the `position: relative` ancestor it used to hang off — so it can report
   writes from the SDV and Changelog too, not just the Board View. Its only
   positioned ancestor now would be the viewport, and `absolute` would let it
   scroll away from the corner mid-save. Fixed keeps it put on every view.
   z-index so it stays above the sticky detail header it can now overlap. */
#saveStatus {
  position: fixed; top: 6px; right: var(--gutter); z-index: 60;
  font-size: 14px; color: var(--faint); pointer-events: none;
  padding-left: 8px;
}
#saveStatus.saving, #saveStatus.saved, #saveStatus.error { background: var(--bg); }
#saveStatus.saving { color: var(--idea); }
#saveStatus.saved  { color: var(--done); }
#saveStatus.error  { color: var(--bug); }

/* Live-sync disconnected banner (tada-220.3). Sits below the save strip so the
   two never overlap — #saveStatus owns the top-right corner and this is a
   persistent state, not a transient one. */
#syncStatus {
  position: absolute; top: 26px; right: var(--gutter);
  font-size: 13px; color: var(--bug); background: var(--bg);
  pointer-events: none; padding-left: 8px; white-space: nowrap;
}

/* Lane add row */
.lane-add-row { padding: 6px 2px 2px; margin-top: 4px; }
.lane-add-btn {
  background: none; border: 1.5px dashed var(--line); color: var(--faint); font-size: 14px;
  cursor: pointer; font-family: inherit; padding: 8px;
  width: 100%; text-align: center; border-radius: 6px; transition: border-color .15s, color .15s, background .15s;
  touch-action: manipulation;
}
/* No z-index needed on `.lane-add-btn` in either view. Both add rows used to sit
   under an ungated scroll-fade overlay and carried `position: relative;
   z-index: 5` to climb out from under it; both fades are now cleared once their
   lane reaches its end, which is exactly where the add row lives (tada-340,
   tada-362). */
.lane-add-btn:hover { color: var(--text); background: var(--panel-2); border-color: var(--accent); }

/* Add Story dropdown — desktop flyout */
.add-dd {
  position: fixed; z-index: 200;
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
  padding: 4px; min-width: 160px;
  box-shadow: 0 8px 32px rgba(0,0,0,.5);
  animation: add-dd-in .12s ease;
}
@keyframes add-dd-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
.add-dd-type {
  position: relative;
  display: flex; align-items: center; justify-content: space-between; gap: 6px;
  padding: 8px 10px; border-radius: 7px; cursor: pointer;
  font-size: 14px; color: var(--text); user-select: none;
  transition: background .1s;
}
.add-dd-type:hover, .add-dd-type.open { background: var(--panel-2); }
/* Dot + title group, so justify-content: space-between keeps the › arrow pushed
   right while the leading type-color dot sits beside the title (mirrors the
   status rows' leading dot). */
.add-dd-type-label { display: flex; align-items: center; gap: 8px; min-width: 0; }
.add-dd-type-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.add-dd-type-arrow { color: var(--faint); font-size: 11px; flex-shrink: 0; }
.add-dd-flyout {
  display: none;
  position: absolute; left: 100%; top: -4px;
  /* Left padding bridges the gap so the mouse stays inside .add-dd-type
     while traveling from the row text to the flyout panel. */
  padding-left: 8px;
}
.add-dd-flyout-inner {
  background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
  padding: 4px; min-width: 140px;
  box-shadow: 0 8px 32px rgba(0,0,0,.5);
  animation: add-dd-in .1s ease;
}
.add-dd-type:hover .add-dd-flyout,
.add-dd-type.open .add-dd-flyout { display: block; }
.add-dd-status {
  display: flex; align-items: center; gap: 8px;
  padding: 8px 10px; border-radius: 7px; cursor: pointer;
  font-size: 14px; color: var(--text); user-select: none;
  transition: background .1s;
  white-space: nowrap;
}
.add-dd-status:hover { background: var(--panel-2); }
.add-dd-status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }

/* Add Story bottom sheet — mobile */
.add-sheet-backdrop {
  display: none; position: fixed; inset: 0; z-index: 190;
  background: rgba(0,0,0,.55);
  animation: add-sheet-bg-in .18s ease;
}
@keyframes add-sheet-bg-in { from { opacity: 0; } to { opacity: 1; } }
.add-sheet-backdrop.open { display: block; }
.add-sheet {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 200;
  background: var(--panel); border-radius: 18px 18px 0 0;
  padding: 0 0 env(safe-area-inset-bottom, 0);
  box-shadow: 0 -8px 40px rgba(0,0,0,.5);
  animation: add-sheet-in .2s cubic-bezier(.22,1,.36,1);
  overflow: hidden;
}
@keyframes add-sheet-in { from { transform: translateY(100%); } to { transform: none; } }
.add-sheet-handle {
  width: 36px; height: 4px; border-radius: 2px;
  background: var(--line); margin: 10px auto 0;
}
.add-sheet-title {
  font-size: 13px; font-weight: 600; color: var(--faint);
  text-transform: uppercase; letter-spacing: .07em;
  padding: 12px 18px 8px;
}
.add-sheet-back {
  display: none; background: none; border: none; color: var(--accent);
  font: inherit; font-size: 14px; cursor: pointer;
  padding: 12px 18px 8px; text-align: left;
}
.add-sheet-back.visible { display: block; }
.add-sheet-row {
  display: flex; align-items: center; justify-content: space-between;
  padding: 14px 18px; border-top: 1px solid var(--line);
  font-size: 16px; color: var(--text); cursor: pointer;
  transition: background .1s; user-select: none;
}
.add-sheet-row:active { background: var(--panel-2); }
/* Leading dot + title on the type panel's rows — the type-color counterpart to
   the status panel's trailing status dot. */
.add-sheet-row-label { display: flex; align-items: center; gap: 10px; min-width: 0; }
.add-sheet-row-right { display: flex; align-items: center; gap: 8px; color: var(--faint); font-size: 14px; }
.add-sheet-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }

/* Drag feedback */
.lane.drop-target .lane-body { background: var(--panel-2); }
.lane.collapsed.drop-target { border-color: var(--accent); background: var(--panel-2); }
.card.dragging { opacity: .35; box-shadow: none; }
/* Drop marker: a line highlighted in the gap *between* cards, not bound to a
   card's own edge. A pseudo-element floats in the inter-card gap, so the
   indicator reads as "insert here between these two cards" rather than as a
   border on one card.
   The offset centers the 2px marker line on the gap's midpoint, and is the same
   for both triggers so the identical physical line paints whether the gap is
   approached from the card above (drop-after) or below (drop-before) it.
   .card's vertical margins are asymmetric (6px top / 14px bottom) and collapse
   in the block-flow lane body, so the real gap is max(6,14) = 14px → midpoint
   at 7px. The line is 2px tall, and top/bottom offset positions its *outer*
   edge, so to center its midpoint at 7px the offset is 7 + 1 = 8px. A stale
   -5px (tuned for an old symmetric ~8px gap) put the two triggers at 5px vs 9px
   — the marker jumped to one card's edge depending which card was hovered
   (tada-203). */
/* These reuse .card's ::before/::after, which the ring system (top of file)
   also owns — so they must undo the ring's geometry and, critically, its
   `opacity: 0`. Without the reset the marker paints fully transparent and the
   drop zone is invisible; `inset: -3px` likewise stretches the 2px line over
   the whole card. Also clear the ring's conic-gradient mask, which would carve
   a hole out of the solid line. */
.card.drop-before::before,
.card.drop-after::after {
  content: ""; position: absolute; left: 0; right: 0; inset-inline: 0;
  top: auto; bottom: auto; opacity: 1;
  height: 2px; background: var(--accent); border-radius: 2px;
  padding: 0; -webkit-mask: none; mask: none; animation: none;
}
.card.drop-before::before { top: -8px; }
.card.drop-after::after  { bottom: -8px; }

/* Card actions strip (move buttons + kebab) */
.card .actions {
  /* top:9px centers the strip on the title's first line (line-box centre ~21px:
     card pad-top 10 + title pad-top 1 + half of the 21px line box) so the
     buttons read as inline with the title rather than riding above it. */
  position: absolute; top: 9px; right: 6px;
  display: flex; gap: 4px;
  opacity: 0; transition: opacity .12s;
}
.card:hover .actions, .card:focus-within .actions, .card .actions.open { opacity: 1; }
/* Touch has no hover, so .actions (the kebab menu) would otherwise never
   reveal. Keep it always-on there so it's reachable on mobile. */
@media (hover: none) {
  .card .actions { opacity: 1; }
}
/* When the menu is open, snap the container fully opaque with no transition.
   The opacity fade (above) otherwise leaves .card-menu-item mid-animation —
   not "stable/visible" to Playwright — so clicking Delete races the fade and
   flakes on CI (no real hover to hold it open). */
.card .actions.open { transition: none; }
.card-menu-btn {
  width: 22px; height: 22px; border-radius: 5px;
  border: 1px solid var(--line); background: var(--panel); color: var(--dim);
  font-size: 15px; line-height: 1; cursor: pointer; padding: 0;
  display: flex; align-items: center; justify-content: center;
}
.card-menu-btn:hover { color: var(--text); border-color: var(--faint); background: var(--panel-2); }
.card-menu-btn:disabled { opacity: .35; cursor: default; pointer-events: none; }
.card-menu {
  position: absolute; right: 0; top: 26px; z-index: 30;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  min-width: 110px; box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 4px;
  display: none;
}
.card-menu.open { display: block; }
.card-menu-item {
  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 7px 11px;
  background: none; border: none; border-radius: 5px; font-size: 14px;
  color: var(--text); cursor: pointer; font-family: inherit;
}
.card-menu-item:hover { background: rgba(255,255,255,.07); }
.card-menu-item.danger { color: var(--bug); }

/* "Add tag(s)" submenu — shared by the BV card menu and (later) the SDV kebab
   so the two views stay equals. The parent row toggles .open on its wrapper to
   reveal the option list; each option is a toggle (✓ when the tag is applied). */
.tag-menu-wrap .tag-submenu { display: none; padding: 2px 0 2px 6px; }
.tag-menu-wrap.open .tag-submenu { display: block; }
.tag-menu-parent .tag-caret { margin-left: auto; font-size: 11px; color: var(--faint); transition: transform .12s; }
.tag-menu-wrap.open .tag-menu-parent .tag-caret { transform: rotate(90deg); }
/* Desktop-only: the BV card menu's "Add tag(s)" flies out sideways on hover
   (no tap needed) instead of expanding inline below the Tags row. Clicking
   the row still works too (toggles .open, same as touch) — it's just a second
   way in, so keyboard/focus access isn't lost. Flies to the *left* since the
   card menu itself hugs the card's right edge, so a right-flyout would run
   off-screen. Position relative to the wrap (not the row) so it doesn't shift
   if the row's own height changes. */
@media (hover: hover) {
  .card-menu .tag-menu-wrap { position: relative; }
  .card-menu .tag-menu-wrap .tag-submenu {
    display: none; position: absolute; right: 100%; top: -4px; margin-right: 4px;
    background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
    box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 4px; min-width: 110px;
  }
  .card-menu .tag-menu-wrap:hover .tag-submenu,
  .card-menu .tag-menu-wrap:focus-within .tag-submenu,
  .card-menu .tag-menu-wrap.open .tag-submenu { display: block; }
}
.tag-opt { justify-content: flex-start; }
.tag-opt .tag-emoji { width: 18px; text-align: center; }
.tag-opt .tag-check { margin-left: auto; color: var(--done); opacity: 0; font-size: 13px; }
.tag-opt.on .tag-check { opacity: 1; }

/* Styled confirmation dialog (replaces the OS-native confirm()). */
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: flex-start; justify-content: center; padding: 40px 16px; z-index: 50; overflow: auto; }
.modal { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; width: 100%; max-width: 560px; padding: 22px; }
.modal h3 { margin: 0 0 14px; }
.modal .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.confirm-modal { max-width: 420px; }
.confirm-modal h3 { margin: 0 0 10px; font-size: 18px; }
.confirm-modal .confirm-body { color: var(--dim); font-size: 15px; line-height: 1.5; margin: 0; }
.confirm-modal .confirm-body .confirm-subject { color: var(--text); font-weight: 600; word-break: break-word; }


/*
 * Story Detail View (SDV) — ported from index.html (tada-199.3, slice 3).
 * Read-only shell first; editing affordances (.edit-btn, .detail-edit,
 * drag/drop drop markers) are styled here too so the later edit chunks
 * light up without a second CSS pass. The animated ring shimmer/flash
 * (.detail-inner::before/::after) landed in Slice 4a (tada-199.4) — see the
 * shared ring layer near the top of this file and useBoardEffects. The
 * in-detail header-collapse layout landed in Slice 4b (tada-199.6) up in the
 * header block (search `body.in-detail #appHeader header`).
 * .detail-dd* and the tag submenu are already in this file (BV edit slice).
 */

/* ---- SDV: detail card + ring bars + sticky/scroll frame (index.html:866-1000) ---- */
/* The 32px top padding used to be clearance for the status glow — the halo
   reaches ~48px past the card edge, and without room it was clipped by the
   header above. That bought the space at the cost of the SDV sitting a long way
   below the title/Back/auth row, which is the more expensive of the two.

   The glow is allowed to pass *under* the header instead. `.app-header` is
   sticky at z-index 6 with an opaque `var(--bg)` background, so it simply
   occludes the top of the halo — the light still emanates from the card's own
   edges to the sides and below, where it is actually visible. Nothing is
   clipped; part of it is covered by a higher element, which reads as depth. */
.detail-wrap { margin: 8px 0 0; padding: 10px var(--gutter) 0; }
.detail-back-row { margin-bottom: 12px; }  /* changelog view back button */
/* SDV Back button: a compact ghost pill (matches the legacy .btn.ghost Back),
   left-aligned — not the full-width dashed lane-add button. */
.detail-back-btn { height: auto; padding: 5px 12px; font-size: 14px; }
/* Status reads as a static glow hugging the card's own edge — a crisp rim
   ring plus a soft halo just outside the boundary — not a second bar and
   not a diffuse blob floating behind it. --ring-color is set inline per
   story status. This glow is deliberately static: no shimmer/flash/pulse
   animation on it (tried and explicitly rejected — keep it plain). */
.detail-card {
  position: relative; background: var(--panel); border-radius: var(--radius-card); padding: 0;
  display: flex; flex-direction: column; --ring-color: var(--in-progress);
}
.detail-card::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit; pointer-events: none;
  /* Three stacked shadows, not one: the 1.5px rim keeps the card's edge crisp,
     and the two blurs behind it are the halo. Turned up a step — each layer a
     little more opaque and reaching a little further, with the overall opacity
     lifted from .7 to .82. Enough to read as lit rather than tinted; still a
     glow hugging the edge rather than the diffuse blob the design doc rejects. */
  box-shadow:
    0 0 0 1.5px color-mix(in oklch, var(--ring-color) 82%, transparent),
    0 0 24px 3px color-mix(in oklch, var(--ring-color) 62%, transparent),
    0 0 52px 12px color-mix(in oklch, var(--ring-color) 36%, transparent);
  opacity: .82;
}
/* Type ring stays a thin animated border on the inner card — same
   ring/shimmer/flash system as .card/.lane/.task-lane, --ring-color set
   inline per story type. */
.detail-inner {
  position: relative; border-radius: var(--radius-card-inner); padding: 0; display: flex; flex-direction: column;
  border: 1.5px solid var(--ring-color, #4f8ef7);
}
/* Ring top bar: the type ring's top edge is a solid color strip instead of
   a thin border, sized to exactly the height of the dropdown pills it
   carries. Each pill's own background shows through as a "knockout" hole in
   the solid bar; everything else riding the bar (ID, timestamps, kebab)
   keeps its normal chip background so it reads as sitting in relief on top
   of the color instead of cut into it. */
.detail-ring-bar {
  display: flex; align-items: center; flex: 0 0 auto;
  padding: 0 14px; gap: 8px;
}
.detail-ring-bar--inner {
  border-radius: var(--radius-card-inner) var(--radius-card-inner) 0 0;
  padding: 6px 16px;
  justify-content: space-between;
}
.detail-ring-bar--inner .detail-type-corner { display: flex; align-items: center; gap: 8px; }
/* Knockout pills: the status/type dropdown, id chip, and tag chip riding
   the bar drop the fake border-matches-background trick and instead punch
   into the colored bar via an inset shadow, so they read as genuinely cut
   into the bar rather than floating loose on it. .detail-dd-trigger is
   scoped to .detail-ring-bar (not bare) since the same class is reused
   outside the bar (e.g. .bv-type-trigger), which keeps its normal chip
   look — and the extra class also outweighs the later bare .detail-dd-trigger
   base rule regardless of source order. */
.detail-ring-bar .detail-dd-trigger, .detail-id, .detail-tags {
  background: var(--panel); border: none; box-shadow: inset 0 1px 4px rgba(0,0,0,.4);
}
.detail-ring-bar .detail-dd-trigger:hover { background: var(--panel-2); }
.detail-ring-bar .detail-tags {
  border-radius: 999px; padding: 3px 9px;
}
/* Kebab rides the inner bar too — give it the same relief chip background as
   the id/timestamp pills instead of its normal transparent look, so its thin
   border reads against the solid color instead of nearly disappearing. */
.detail-ring-bar--inner .detail-kebab-btn { background: var(--panel-2); }
.detail-ring-bar--inner .detail-kebab-btn:hover { background: var(--panel); }
/* Sticky/scroll split: padding lives on the two sections, not .detail-inner
   (which stays edge-to-edge so the ring bars above can run full width). */
.detail-sticky { padding: 14px 30px 8px; }
.detail-scroll { padding: 0 30px 26px; }

/* Shown when the open story is removed by another writer (live SDV update). */
.detail-removed {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 14px; text-align: center; padding: 60px 24px; color: var(--faint);
}
.detail-removed-icon { font-size: 40px; }
.detail-removed-msg { font-size: 16px; }

/* SDV scroll containment: lock the page to the viewport and lay .wrap out as a
   flex column — the app-header takes its natural height at the top, and the
   detail card fills the rest. Inside the colored ring, .detail-sticky (type +
   status pills, action menu, title) stays pinned and .detail-scroll holds the
   description / fields / tasks and scrolls — so the ring never scrolls off and
   the header stays put while content moves under it. */
body.in-detail .wrap { height: 100dvh; overflow: hidden; display: flex; flex-direction: column; padding: 12px 0 0; }
body.in-detail #app { flex: 1; min-height: 0; display: flex; flex-direction: column; }
body.in-detail .detail-wrap {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  /* Room for the glow inside `body.in-detail .wrap`'s `overflow: hidden` (it
     locks the frame to the viewport): without padding here the halo's ~48px
     reach is cut off square wherever that box ends. Sides and bottom get enough
     to fade out on their own; the top is tight so the card sits close under the
     header, and the skirt below handles what the header would otherwise cut. */
  margin: 8px 0 0; padding: 6px var(--gutter) 22px;
}
/* The header's skirt: a short `--bg`-to-transparent gradient hanging below its
   opaque background, in the SDV only.

   `.app-header` is opaque and sits at z-index 6, above the card — so the top of
   the status glow is painted over, and the boundary is the header's own
   straight bottom edge. That reads as the halo being sliced off in a hard line,
   not as one element passing behind another; a flat rectangle meeting a soft
   glow has no depth cue at all. Occlusion only reads as depth when the occluder
   has some falloff.

   So the header's background does not stop abruptly: it continues for 24px as a
   fade to transparent, and the glow dissolves into it. Purely additive — a
   gradient below the header's own box, pointer-events off, so nothing about the
   header's layout, stacking, or hit area changes. BV-exempt because there is no
   card glow to meet up there. */
body.in-detail .app-header::after {
  content: ""; position: absolute; left: 0; right: 0;
  pointer-events: none;
  /* Hangs ABOVE the header's bottom edge, not below it, and fades *upward*.

     The first version sat below (`top: 100%`) fading downward, which put it in
     the strip between the header and the card — a region the glow already lit —
     while the header's own opaque background went on covering the 36px of halo
     that reaches above it. So the hard line stayed exactly where it was, and the
     skirt merely added a second seam at its own bottom edge.

     Pulling it up over the header's last 40px means the opaque background stops
     being opaque where the glow arrives: `--bg` at the top (fully hiding the
     halo behind the title row, which is the point) easing to transparent at the
     header's edge, so the glow emerges gradually instead of at a step. */
  bottom: 0; height: 40px;
  background: linear-gradient(to top, transparent, var(--bg));
  /* Behind the header's own content. Both pseudos are backdrop, not chrome:
     without this they paint over the title, Back and auth control, which are
     non-positioned children and so lose to a pseudo-element by default. */
  z-index: -1;
}
/* The header's background is what the skirt has to soften, so the header itself
   cannot also paint it flat — it would sit on top of the gradient and restore
   the hard edge. The color moves to the skirt entirely; the header keeps only
   the part of it that is genuinely opaque, above the skirt's reach. */
body.in-detail .app-header { background: none; }
body.in-detail .app-header::before {
  content: ""; position: absolute; left: 0; right: 0; top: 0; bottom: 40px;
  background: var(--bg); pointer-events: none; z-index: -1;
}
/* `z-index: -1` on the pseudos is relative to the header's own stacking
   context, so the header must establish one — otherwise they drop behind the
   page background instead of just behind the header's content. It already has
   `position: sticky`, which does exactly that once a z-index is set (it is:
   6). */
body.in-detail .detail-card {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
}
body.in-detail .detail-inner {
  flex: 1; min-height: 0; display: flex; flex-direction: column;
  position: relative;
}
/* No SDV-wide bottom fade: the scroll-off darken/blur lives per task lane
   (.task-lane::after) so it overlaps tasks scrolling out of each column —
   a card-wide fade here cast a gradient over the task lane borders and the
   gap between the status/type rings (tada-113). */
body.in-detail .detail-sticky {
  flex: 0 0 auto;
  /* subtle divider + soft shadow so the pinned header reads as separate from
     the content scrolling beneath it */
  border-bottom: 1px solid var(--line);
  box-shadow: 0 6px 12px -8px rgba(0,0,0,.55);
  background: var(--panel); position: relative; z-index: 6;
  /* Round the top corners to nest inside .detail-inner's radius (14px less
     its 1.5px border) so the sticky's solid background doesn't square off
     the inner colored ring's top corners. */
  border-top-left-radius: 12px; border-top-right-radius: 12px;
}
body.in-detail .detail-scroll {
  flex: 1; min-height: 0; overflow-y: auto;
  overscroll-behavior: contain; -webkit-overflow-scrolling: touch;
}
/* Section headers within the scrolling body stick to the top of .detail-scroll
   while their section is in view, then scroll away as the next section's row
   pushes them out (each label's containing block is its own .detail-field-row /
   section, which bounds how far it can stick). The opaque background keeps the
   scrolling content from showing through the pinned label. */
body.in-detail .detail-scroll .detail-desc-head,
body.in-detail .detail-scroll .task-checklist-label {
  position: sticky; top: 0; z-index: 5;
  display: flex; margin-left: 0; padding: 8px 4px 6px;
  background: var(--panel);
  border-bottom: 1px solid var(--line);
  box-shadow: 0 4px 6px -5px rgba(0,0,0,.5);
}
/* The Description section header: the label on the left, the Status changed /
   Edited timestamp pills pushed to the right edge, in line with the label
   (moved here from the top ring bar). */
.detail-desc-head { display: flex; align-items: center; gap: 12px; }
.detail-desc-head .detail-typefield-label { margin-bottom: 0; }
.detail-desc-head .detail-timestamps { margin-left: auto; }
@media (max-width: 599px) {
  /* Inner bar's contents (type pill, id, timestamps, kebab) wrap onto a
     second line on narrow screens instead of squeezing into one row. */
  .detail-ring-bar--inner { flex-wrap: wrap; height: auto; padding: 8px 16px; }
  /* The knockout pills wrap as whole units rather than being compressed: an
     alert's text can't shrink, so without this it overflowed the bar and
     clipped mid-word ("Overdue by 3d" -> "Overdue by 3"), while the id chip
     broke across two lines on its hyphen. Applies to every `.alert-slot`, not
     just the due one — "2 tasks left open" clips exactly the same way. */
  .detail-ring-bar--inner .alert-slot,
  .detail-ring-bar--inner .detail-id { flex: 0 0 auto; white-space: nowrap; }
  .detail-ring-bar--inner .detail-type-corner { flex-wrap: wrap; row-gap: 8px; }
  .detail-timestamps { flex-wrap: wrap; justify-content: flex-end; }
}
.detail-type { font-size: 13px; color: var(--faint); text-transform: capitalize; }

/* ---- SDV: detail id / timestamps / kebab (index.html:1034-1072) ---- */
.detail-id {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12px; color: var(--dim); border-radius: 4px; padding: 1px 6px;
}
/* Timestamp pills (Status changed / Edited / Added) — same chip weight as
   .detail-id so they read as the same family of relief elements riding the
   inner ring bar, without sharing its class (avoids collisions with
   .detail-id's own DOM lookups/selectors). Value color bumped to --dim for
   legibility; .ts-label prefix stays --faint so the value still reads as
   the more prominent part. */
/* The pills lay out in a row (side by side), not stacked. */
.detail-timestamps { display: flex; gap: 8px; flex-wrap: wrap; }
.detail-ts {
  font-size: 12px; color: var(--dim); background: var(--panel-2);
  border: 1px solid var(--line); border-radius: 4px; padding: 1px 6px;
  display: flex; align-items: center; gap: 6px;
}
.detail-ts .ts-label { color: var(--faint); }
.detail-kebab {
  position: relative; margin-left: auto;
}
.detail-kebab-btn {
  width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--line);
  background: none; color: var(--dim); font-size: 18px; cursor: pointer;
  display: flex; align-items: center; justify-content: center; line-height: 1;
}
.detail-kebab-btn:hover { background: var(--panel-2); color: var(--text); }
.detail-kebab-menu {
  position: absolute; right: 0; top: 34px; z-index: 50;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  min-width: 130px; box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 4px;
  display: none;
}
.detail-kebab-menu.open { display: block; }
.detail-kebab-item {
  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; padding: 8px 12px;
  background: none; border: none; border-radius: 5px; font-size: 14px;
  color: var(--text); cursor: pointer;
}
.detail-kebab-item:hover { background: rgba(255,255,255,.07); }
.detail-kebab-item.danger { color: var(--bug); }

/* ---- SDV: title / note / task board / checklist (index.html:1104-1451) ---- */
.detail-field-row { display: block; margin: 0 0 16px; position: relative; }
.detail-field-wrap { display: inline-flex; align-items: flex-start; gap: 4px; max-width: 100%; }
.detail-field-row .edit-btn {
  flex-shrink: 0; opacity: 0; background: none; border: none; cursor: pointer;
  color: var(--faint); font-size: 13px; padding: 3px 5px; border-radius: 5px;
  transition: opacity .12s, background .12s; line-height: 1; align-self: flex-start; margin-top: 4px;
}
/* Every ✏️ edit pencil sits to the right of the text it edits; the default
   glyph points up-right (away from that text). Mirror it horizontally so the
   tip points inward toward the text it modifies — SDV title/description and
   changelog stories. (Task cards have no pencil: clicking the card edits it.) */
.detail-field-row .edit-btn,
.changelog-edit { transform: scaleX(-1); }

/* Changelog view (tada-206) — ported class-for-class from index.html so the
   markup in ChangelogView.tsx styles unchanged. Archived stories listed
   most-recent-first, with inline title/note edit and restore/delete icons.
   tada-231: each row also keeps its id chip, type-color ring border, and tag
   carousel visible/editable — the same identity a Story card shows on the
   active board, so archiving doesn't strip a story's identity. */
/* Full-bleed, not the old 860px measure: the rows are short and a centered
   column left most of a desktop screen empty once the 30-day sweep started
   filing stories in bulk. Width comes from the gutter alone. */
.changelog-wrap { padding: 0 var(--gutter); }
/* The list is its own scroll container on desktop.
   `body:not(.in-detail) .wrap` (see the BV scroll-containment block above) pins
   the page to 100dvh and hides its overflow so each BV lane can scroll
   independently. `in-detail` is set by the SDV only, so the Changelog — a
   sibling view sharing the same #app wrapper — inherited the viewport lock
   *without* having anything inside it that scrolls, and simply clipped at the
   bottom of the screen. Scrolling here rather than dropping the lock keeps BV's
   behavior untouched. Mobile is unaffected: the lock is desktop-only and the
   page scrolls normally. */
@media (min-width: 600px) {
  /* #app is already the flex column with min-height:0 (the rule above), so the
     wrap only has to claim the free space and scroll its own overflow. */
  .changelog-wrap {
    /* min-height:0 lets this flex child shrink below its content height —
       without it the item would size to its content and overflow the lock
       instead of scrolling inside it. */
    flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
    -webkit-overflow-scrolling: touch;
    /* The heading scrolls with the list; the gutter padding stays horizontal,
       so add the bottom breathing room the pinned .wrap dropped
       (padding-bottom: 0). */
    padding-bottom: 24px;
  }
}
/* Two columns of rows on a wide screen. Rows vary in height (a long note, a
   wrapped tag carousel), so this is a masonry-ish column layout rather than a
   grid: `columns` keeps the reading order top-to-bottom within each column,
   which matches a most-recent-first list better than a grid's left-right
   zig-zag. `break-inside: avoid` stops a row being split across the column
   boundary. Single column below 1100px, where two would squeeze each row's
   head (id + emoji + tags + title + date + 3 icons) past legibility. */
@media (min-width: 1100px) {
  .changelog-list { columns: 2; column-gap: 14px; }
  .changelog-list > .changelog-item { break-inside: avoid; }
}
/* TAR view (AuditReportsView.tsx). Full-width, same as .changelog-wrap: a
   finding's prose and loc read better wide than in a narrow column. */
.audit-wrap { padding: 0 var(--gutter); }
@media (min-width: 600px) {
  /* Same viewport-lock inheritance the Changelog has (see above): this view
     shares the #app wrapper, which `body:not(.in-detail)` pins to 100dvh with
     hidden overflow, so a long report list was clipped at the fold. Scroll the
     wrap. */
  .audit-wrap { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding-bottom: 24px; }
}
.changelog-heading { font-size: 22px; margin: 6px 0 18px; }
.changelog-item {
  padding: 12px 14px; margin-bottom: 8px; border-radius: 10px;
  background: var(--panel); border: 1.5px solid var(--ring-color, var(--line));
}
.changelog-item-head { display: flex; align-items: baseline; gap: 8px; }
/* Same chip language as .card-id (BV/SDV parity) — the story's numeric id,
   visible on a Changelog row exactly as it is on an active Story card
   (tada-231: archived stories keep their id/type-color/tags legible). */
.changelog-id {
  font-size: 13px; font-weight: 700; line-height: 1; color: var(--dim);
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 2px 6px; flex-shrink: 0;
}
.changelog-emoji { font-size: 15px; }
.changelog-title { font-weight: 600; flex: 1; cursor: text; }
.changelog-when { color: var(--faint); font-size: 12.5px; white-space: nowrap; }
.changelog-note { color: var(--dim); font-size: 14px; margin-top: 6px; cursor: text; border-radius: 4px; }
.changelog-note.placeholder { color: var(--faint); font-style: italic; }
.changelog-empty { color: var(--faint); padding: 20px 0; }
/* Reuses the Task action-icon style (.task-icon); the icons fade in on
   changelog-item hover rather than on the item being the drag target. */
.changelog-item:hover .task-icon { opacity: 1; }
.changelog-del:hover { color: var(--bug); }
@media (hover: none) {
  .changelog-edit, .changelog-restore, .changelog-del { opacity: 1; }
}

/* TAR view (AuditReportsView.tsx, tada-250.4) — Observations rendered live from
   /api/audit/observations, replacing the uploaded-HTML iframe. Findings are
   cards in the .changelog-item language (same panel, radius, and border) rather
   than a new one: a TAR finding and a Changelog row are both "a record with an
   id, a body, and some meta", and the view sits in the same wrap. */
.tar-meta {
  display: flex; gap: 12px; flex-wrap: wrap;
  color: var(--faint); font-size: 12.5px; margin: -10px 0 18px;
}
.tar-section { margin-bottom: 22px; }
.tar-section-head {
  font-size: 13px; font-weight: 700; text-transform: uppercase;
  letter-spacing: .04em; color: var(--dim);
  margin: 0 0 8px; padding-bottom: 5px; border-bottom: 1px solid var(--line);
}
.tar-finding {
  padding: 11px 13px; margin-bottom: 8px; border-radius: 10px;
  background: var(--panel); border: 1.5px solid var(--line);
  /* Severity reads off the left edge, so scanning for "high" doesn't mean
     reading every badge. Colour is the accent, not the whole border: a red
     outline on every high finding would make the list look like an error
     state. */
  border-left-width: 3px;
}
.tar-sev-high { border-left-color: var(--bug); }
.tar-sev-med { border-left-color: var(--in-progress); }
.tar-sev-low { border-left-color: var(--line); }
.tar-sev-none { border-left-color: var(--line); }
/* An ignored finding stays in place rather than disappearing — it must remain
   findable to be un-ignored — but recedes so it doesn't compete with open work. */
.tar-ignored { opacity: .5; }
/* One row: id, area, severity, loc, then the Ignore button pushed right. The
   loc grows to take the slack and truncates rather than wrapping — a long path
   must not push the button onto its own line, and `min-width: 0` is what lets a
   flex item shrink below its content width at all. */
/* `flex-direction: row` is load-bearing, not redundant: this is a <header>, and
   the bare `header` rule above sets `column` app-wide. Without it the chips
   stack one per line. */
.tar-finding-head { display: flex; flex-direction: row; align-items: baseline; gap: 8px; }
/* Same chip language as .card-id / .changelog-id (BV/SDV parity). */
.tar-id {
  font-size: 13px; font-weight: 700; line-height: 1; color: var(--dim);
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 2px 6px; flex-shrink: 0;
}
.tar-area { font-size: 12px; font-weight: 700; color: var(--faint); flex-shrink: 0; }
.tar-sev { font-size: 11.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--faint); flex-shrink: 0; }
.tar-sev-high .tar-sev { color: var(--bug); }
.tar-sev-med .tar-sev { color: var(--in-progress); }
.tar-loc {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 12px; color: var(--faint);
  flex: 1; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.tar-ignore {
  background: none; border: 1px solid var(--line); border-radius: 6px;
  color: var(--dim); font-size: 12px; padding: 2px 8px; cursor: pointer;
  flex-shrink: 0; transition: background .1s, color .1s, border-color .1s;
}
.tar-ignore:hover:not(:disabled) { background: var(--panel-2); color: var(--text); border-color: var(--accent); }
.tar-ignore:disabled { opacity: .5; cursor: default; }
/* File-to-board control (tada-292): a type select plus the File button, paired
   as one unit so they read as a single action rather than two chips.
   `margin-left: auto` right-aligns the pair even on a finding with no `loc` —
   .tar-loc's `flex: 1` is what normally eats the slack, and it isn't rendered
   when the finding has no location. */
.tar-file { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; }
.tar-file-type {
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px;
  color: var(--dim); font-size: 12px; padding: 2px 4px; cursor: pointer;
  max-width: 9.5em;
}
.tar-file-type:disabled { opacity: .5; cursor: default; }
/* The primary action of the row, so it carries the accent border at rest —
   Ignore stays a quiet outline. */
.tar-file-btn {
  background: none; border: 1px solid var(--accent); border-radius: 6px;
  color: var(--text); font-size: 12px; font-weight: 600; padding: 2px 10px;
  cursor: pointer; flex-shrink: 0;
  transition: background .1s, color .1s, border-color .1s;
}
.tar-file-btn:hover:not(:disabled) { background: var(--accent); color: var(--bg); }
.tar-file-btn:disabled { opacity: .5; cursor: default; }
.tar-what { color: var(--text); font-size: 14px; margin: 7px 0 0; }
.tar-fix { color: var(--dim); font-size: 13.5px; margin: 5px 0 0; }
.tar-what code, .tar-fix code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .92em; background: var(--panel-2);
  border-radius: 4px; padding: 1px 4px;
}
.tar-finding-foot {
  display: flex; gap: 12px; flex-wrap: wrap;
  color: var(--faint); font-size: 12px; margin-top: 8px;
}
.tar-seen { margin-left: auto; }
/* Resolved history is collapsed by default: it's the bulk of a mature ledger
   and none of it is actionable. */
.tar-resolved-toggle {
  background: none; border: none; cursor: pointer; padding: 0 0 5px;
  color: var(--dim); font-size: 13px; font-weight: 700;
  text-transform: uppercase; letter-spacing: .04em;
  width: 100%; text-align: left; border-bottom: 1px solid var(--line);
  margin-bottom: 8px;
}
.tar-resolved-toggle:hover { color: var(--text); }
.detail-field-row:hover .edit-btn { opacity: 1; }
.detail-field-row .edit-btn:hover { background: var(--panel-2); color: var(--text); }
/* Touch devices: drop the title/description edit pencils entirely. Tapping the
   title or description text already starts an inline edit (see the click
   handlers in renderDetail), so the pencil is redundant on touch — hide it and
   let the text fields be the tap target. (Desktop keeps reveal-on-hover above.) */
@media (hover: none) {
  .detail-field-row .edit-btn { display: none; }
}
.detail-title {
  font-size: 26px; font-weight: 700; line-height: 1.35;
  border-radius: 5px; padding: 2px 4px; margin-left: -4px;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden; cursor: text;
}
/* The title is the last row in the pinned .detail-sticky; drop the generic
   .detail-field-row bottom margin so the gap to the scrollable area below is
   governed by .detail-sticky's own bottom padding alone (tighter, no double
   spacing). */
/* Mobile: shrink the title font as it gets longer so more of it fits before
   the line-clamp ellipsizes, and allow a 3rd line at the smaller sizes. The
   tier comes from data-len (set by titleLenTier). Desktop keeps the 26px/2-line
   treatment — it has the horizontal room. */
@media (max-width: 599px) {
  .detail-title[data-len="m"] { font-size: 23px; }
  .detail-title[data-len="l"] { font-size: 20px; -webkit-line-clamp: 3; }
  .detail-title[data-len="xl"] { font-size: 18px; line-height: 1.3; -webkit-line-clamp: 3; }
  /* Widen the title/description rows toward the ring edges, same reasoning
     and inset (6px from .detail-inner's edge) as .task-board/.task-checklist:
     .detail-sticky/.detail-scroll's 30px side padding otherwise leaves these
     floating much further in from the ring than the task rows below them. */
  .detail-title-row, .detail-desc-row { margin-left: -24px; margin-right: -24px; }
}
.detail-title-row { margin-bottom: 0; }
/* The title row's wrap spans the full width so the inline title editor
   (textarea, width:100%) gets a full-width basis — otherwise the wrap's
   inline-flex shrinks to the text and the editor is capped narrow. Only the
   *editor* flexes to fill; the displayed title sizes to its text (like the
   description) so its edit pencil sits right after the words, not pinned to the
   far right. min-width:0 lets the long-title line-clamp shrink instead of
   overflowing. */
.detail-title-row .detail-field-wrap { display: flex; width: 100%; }
.detail-title-row .detail-field-wrap > .detail-title { min-width: 0; }
.detail-title-row .detail-field-wrap > textarea.title-edit { flex: 1; min-width: 0; }
.detail-note {
  color: var(--dim); font-size: 16px; line-height: 1.6;
  border-radius: 5px; padding: 2px 4px; margin-left: -4px;
  cursor: text;
}
.detail-note.placeholder { color: var(--faint); font-style: italic; }
.detail-title.placeholder { color: var(--faint); font-style: italic; }
.detail-note p { margin: 0; }
.detail-note p + p { margin-top: 8px; }
.detail-note a { color: var(--accent); }
.detail-note code { background: var(--panel-2); border: 1px solid var(--line); border-radius: 3px; padding: 1px 5px; font-size: 14px; }
.detail-note pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: 6px; padding: 10px 12px; overflow-x: auto; margin: 8px 0 0; }
.detail-note pre code { background: none; border: none; padding: 0; }

/* highlight.js token colors (GitHub Dark palette). The block background and
   padding come from our own `pre`/`pre code` rules above, so the highlighted
   block blends into tada's panel instead of GitHub's near-black. */
.hljs { color: var(--text); }
.hljs-doctag, .hljs-keyword, .hljs-meta .hljs-keyword, .hljs-template-tag,
.hljs-template-variable, .hljs-type, .hljs-variable.language_ { color: #ff7b72; }
.hljs-title, .hljs-title.class_, .hljs-title.class_.inherited__,
.hljs-title.function_ { color: #d2a8ff; }
.hljs-attr, .hljs-attribute, .hljs-literal, .hljs-meta, .hljs-number,
.hljs-operator, .hljs-selector-attr, .hljs-selector-class, .hljs-selector-id,
.hljs-variable { color: #79c0ff; }
.hljs-meta .hljs-string, .hljs-regexp, .hljs-string { color: #a5d6ff; }
.hljs-built_in, .hljs-symbol { color: #ffa657; }
.hljs-code, .hljs-comment, .hljs-formula { color: var(--dim); }
.hljs-name, .hljs-quote, .hljs-selector-pseudo, .hljs-selector-tag { color: #7ee787; }
.hljs-subst { color: var(--text); }
.hljs-section { color: #1f6feb; font-weight: 700; }
.hljs-bullet { color: #f2cc60; }
.hljs-emphasis { color: var(--text); font-style: italic; }
.hljs-strong { color: var(--text); font-weight: 700; }
.hljs-addition { color: #aff5b4; background-color: #033a16; }
.hljs-deletion { color: #ffdcd7; background-color: #67060c; }
.detail-note strong { color: var(--text); }
.detail-note ul, .detail-note ol { margin: 4px 0 0; padding-left: 20px; }
.detail-note li { margin: 2px 0; }
.detail-typefield-label {
  font-size: 12px; font-weight: 600; text-transform: uppercase;
  letter-spacing: .04em; color: var(--faint); margin: 0 0 2px 4px;
}
input.detail-edit, textarea.detail-edit {
  background: var(--panel-2); border: 1px solid var(--accent); color: var(--text);
  border-radius: 6px; padding: 4px 8px; font-family: inherit; outline: none;
}
input.detail-edit { font-size: 26px; font-weight: 700; width: 100%; display: block; }
textarea.detail-edit { font-size: 16px; line-height: 1.6; resize: vertical; min-height: 56px; display: block; width: 100%; }
/* Title editor: a wrapping textarea so a long title is fully visible (vs. an
   input that clips and scrolls). Keeps the title's large bold styling. */
textarea.detail-edit.title-edit {
  font-size: 26px; font-weight: 700; line-height: 1.35;
  width: 100%; min-height: 0; resize: none; overflow-y: hidden;
  /* Grows line-by-line with content (JS sets height); caps at 3 lines, after
     which JS flips overflow to auto so further text scrolls. 3 × 1.35em line
     box (≈105px) + 8px vertical padding. */
  max-height: calc(3 * 1.35em + 8px); box-sizing: border-box;
}

/* task mini-kanban */
.task-board { display: flex; gap: 12px; margin-top: 24px; align-items: stretch; }
/* Expand the task board out of .detail-scroll's side+bottom padding (30px /
   26px) so its lanes sit just inside .detail-inner's edges rather than floating
   30px/26px in from them. Sides use a negative margin (description/title above
   keep their normal reading-width padding); bottom just shrinks the shared
   padding since .task-board is .detail-scroll's last child on desktop
   (task-board is desktop-only; see the max-width:599px rule below hiding it).

   How far in they stop is set by the card's corner, not by taste. A lane run
   closer to the edge than the card's radius crosses the arc and gets squared
   off by it — the bottom bars in footer mode showed this plainly, their outer
   ends overhanging a curve they could not follow. So the inset is derived from
   --radius-card-inner (the curve the lanes actually sit against, inside
   .detail-inner's border) rather than the flat 6px that happened to clear the
   old 14px radius. Two-thirds of it: the corner only has to read as
   uninterrupted, and matching the full radius wastes lane width on every side.

   Change --radius-card and these follow. That is the point — the last three
   corner bugs here were all a fixed inset left behind by a radius that moved. */
.task-board {
  margin-left: calc(-30px + var(--board-inset));
  margin-right: calc(-30px + var(--board-inset));
}
body.in-detail .detail-scroll:has(.task-board) { padding-bottom: var(--board-inset); }
.task-lane { flex: 1; min-width: 0; background: rgba(0,0,0,.18); border: 1.5px solid var(--ring-color, var(--line)); border-radius: var(--radius-task-lane); display: flex; flex-direction: column; min-height: 0; position: relative; }
/* Status-color ring around each SDV task column (tada-79): thin border
   colored via --ring-color, matching the BV lane treatment. */
.task-lane[data-status="idea"]        { --ring-color: var(--idea); }
.task-lane[data-status="planned"]     { --ring-color: var(--planned); }
.task-lane[data-status="in-progress"] { --ring-color: var(--in-progress); }
.task-lane[data-status="done"]        { --ring-color: var(--done); }
/* Bottom padding derived from the corner, exactly as .lane-body's is — a Task
   card or the "+ Add Task" row otherwise runs into the task lane's own curve. */
.task-lane-body {
  flex: 1; min-height: 60px;
  padding: 8px 8px calc(var(--radius-task-lane-inner) * 0.667);
}
/* Each task column scrolls independently in the SDV: the task-board fills the
   remaining height of the scrolling body and each lane body scrolls within its
   lane, so a long ToDo column doesn't push the other columns' contents (or the
   + Add task button) off-screen. The lane head stays pinned per-column. */
body.in-detail .detail-scroll { display: flex; flex-direction: column; }
body.in-detail #taskBoard { align-items: stretch; margin-bottom: 0; }
body.in-detail .task-lane { min-height: 0; }
/* Desktop SDV: a LOCKED FRAME, not a page scroll. .detail-scroll fills the
   card and lays its children out as a flex column that does NOT itself scroll.
   The description gets a bounded, independently-scrolling region; the board
   fills whatever height is left and its columns scroll internally. This keeps
   a definite height for the board in every case, so:
     • short description → columns fill down to the card bottom (no dead gap);
     • long description → the description scrolls inside its capped region
       instead of pushing the board below the fold, so the board (and its
       growth room) never shrink to a sliver;
     • full columns → each lane BODY scrolls in place within the frame.
   The frame removes the viewport-relative magic numbers and the page-scroll
   that previously fought the board's height. The lane body (not the lane) is
   the scroller so the lane's fade ::after, anchored to the non-scrolling
   .task-lane, stays pinned to the visible bottom edge. */
@media (min-width: 600px) {
  body.in-detail .detail-scroll {
    display: flex; flex-direction: column; overflow: hidden;
  }
  /* Description + Relationships are ONE scrolling region. They are
     one body of story detail sitting above the lanes, and splitting them into
     two independent scrollers made reaching the links a function of where the
     cursor sat: a wheel over the description scrolled the description, bottomed
     out, then drove the collapse — never the links, which were a separate box
     with its own 20% budget and its own bar.

     So the wrapper owns the cap, the overflow, and the collapse; the rows
     inside it are plain content blocks that size to their content. Capped at
     52% of the frame — a little under the 58% the two separate budgets summed
     to (38% + 20%), since one shared region no longer has to reserve room for a
     links section that is empty on most stories. The board keeps its floor
     either way (#taskBoard's min-height below is the real guarantee). */
  body.in-detail .detail-fields {
    flex: 0 1 auto; min-height: 0; max-height: 52%;
    overflow-y: auto; overscroll-behavior: contain;
    scrollbar-width: none;          /* Firefox */
    -ms-overflow-style: none;       /* legacy Edge */
    position: relative;             /* thumb positions against the scroller */
  }
  /* Suppress the native bar: it's painted whenever the content overflows,
     including at full collapse where the region is a 34px strip and the bar
     becomes a stub of gutter beside one line of header. Overflow stays `auto`,
     so the wheel still scrolls it and useSdvCollapse's scrollHeight/clientHeight
     arbitration is untouched; only the painted bar goes. useFadingScrollbar
     draws the replacement (.scrollthumb below). */
  body.in-detail .detail-fields::-webkit-scrollbar {
    width: 0; height: 0;            /* WebKit/Blink */
  }
  /* The description no longer scrolls or caps on its own — the wrapper does
     both. (Relationships left this region entirely: it is a header popover
     now, so there is no second row to size here.) */
  body.in-detail .detail-desc-row {
    flex: 0 0 auto; min-height: 0; max-height: none; overflow: visible;
  }

  /* The replacement bar. A real element, not ::-webkit-scrollbar,
     because that pseudo can't be transitioned and Firefox exposes no opacity
     for it at all — see useFadingScrollbar's header. The hook writes `top` and
     `height`; everything visual is here.

     Sits in the right padding rather than beside the text: it overlays instead
     of taking gutter, so showing and hiding it never reflows the note. Opacity
     is the only animated property, so the fade stays on the compositor. */
  /* The sticky Description header sits at z-index 5 and is opaque; the thumb
     would otherwise slide up under the label and show through beside it. */
  body.in-detail .detail-desc-head { z-index: 6; }
  /* No footer below the board: the task columns extend to the card bottom.
     (Timestamps moved up into the sticky header, beside the Story ID badge.)
     The floor is the real guarantee (tada-277): caps above bound each field
     section, but a section that *isn't* capped yet — one added later, as
     Relationships once was — would silently eat the board again. min-height
     makes the board stop yielding instead, so overflow lands on whatever grew
     rather than on the lanes. 260px fits a lane head plus ~2 task cards. */
  body.in-detail #taskBoard { flex: 1; min-height: 260px; }

  /* ---- Scroll-driven collapse (tada-277) ----
     useSdvCollapse writes --sdv-collapse (0 → 1) onto .detail-scroll from wheel
     intent; everything below is a pure function of that one value, which is
     what keeps the two regions moving as a single coherent motion instead of
     two scrollers drifting against each other.

       0 = reading  → field sections at the budgets above, board at its floor
       1 = working  → field sections collapsed to their sticky headers, board
                      takes the freed height

     The max-heights interpolate between the section's budget and
     --sdv-head-h (the collapsed header strip). Defaulting the property to 0
     here means an SDV with the hook disabled — reduced motion, narrow
     viewport, mid-edit — renders exactly the static reading layout. */
  body.in-detail .detail-scroll { --sdv-collapse: 0; --sdv-head-h: 34px; }

  /* One region, one interpolation: 52% budget → the collapsed header strip.
     The Description header is what remains visible at full collapse (it is
     sticky and first in the scroller), so the strip still reads as the way
     back — clicking it calls expand, same as before. */
  body.in-detail .detail-fields {
    max-height: calc(52% + (var(--sdv-head-h) - 52%) * var(--sdv-collapse));
  }

  /* The collapse is triggered, not dragged (tada-290): the hook sets
     --sdv-collapse straight to 0 or 1 once a wheel past the description clears
     a small intent threshold, and the transition is what you actually see. It
     used to track the wheel 1:1, which meant several hundred px of extra spin
     after the note had already been read just to bring the lanes up.
     Duration lives in one variable because the field sections and the board
     are two halves of one motion — the height the fields give up is the height
     the lanes take. Splitting them across two rules (they were 260ms here and
     .18s in the block below, which won on order) desynced them: the lanes
     finished arriving before the sections had finished folding. ~450ms is long
     enough to read as height being carried from one region to the other rather
     than the lanes snapping up, still short enough to feel like an answer to
     the push. */
  body.in-detail .detail-scroll { --sdv-collapse-ms: 450ms; }
  body.in-detail .detail-fields {
    transition: max-height var(--sdv-collapse-ms) cubic-bezier(0.22, 0.61, 0.36, 1);
  }
  /* While the gesture is hinting, the panel tracks the wheel 1:1 and must not
     animate: at 450ms the small lift would lag the push it exists to preview,
     reading as sluggishness instead of feedback. useSdvCollapse adds this class
     for the hint and drops it the moment the push commits, so the commit keeps
     the full transition — that one is a jump the eye needs carried. */
  body.in-detail .detail-scroll.sdv-hinting .detail-fields,
  body.in-detail .detail-scroll.sdv-hinting #taskBoard { transition: none; }
  @media (prefers-reduced-motion: reduce) {
    body.in-detail .detail-fields { transition: none; }
  }
  /* The header strip is the region's floor, so it never collapses past the
     point where it can be clicked to come back. On the wrapper, not the rows:
     the rows size to content now, and a floor on each would hold the collapsed
     region at two header strips instead of one. */
  body.in-detail .detail-fields { min-height: var(--sdv-head-h); }

  /* At full collapse the header is the whole section — make it read as the
     control it becomes. Pointer only past the halfway point, so a click during
     normal reading doesn't look interactive when it has nothing to restore. */
  body.in-detail .detail-scroll .detail-desc-head { cursor: default; }
  body.in-detail .detail-scroll:hover .detail-desc-head { cursor: pointer; }

  body.in-detail .task-lane-body {
    min-height: 0; overflow-y: auto; -webkit-overflow-scrolling: touch;
    /* Same custom-thumb treatment as the BV lanes and the fields region. */
    position: relative;
    scrollbar-width: none;
    -ms-overflow-style: none;
  }
  body.in-detail .task-lane-body::-webkit-scrollbar { width: 0; height: 0; }
}

/* The collapse is an interaction, not decoration — with reduced motion the
   panel still re-proportions (that's the point of the gesture), it just does so
   without easing. The hook also declines to run at all under reduced motion;
   this is the belt to that braces. */
@media (min-width: 600px) and (prefers-reduced-motion: no-preference) {
  /* Only #taskBoard here. The field rows are transitioned above and must not be
     restated: this block is later in the file at equal specificity, so listing
     them again silently overrode their easing and duration with this one. The
     board shares --sdv-collapse-ms so the two regions land together. */
  body.in-detail #taskBoard {
    transition:
      max-height var(--sdv-collapse-ms) cubic-bezier(0.22, 0.61, 0.36, 1),
      min-height var(--sdv-collapse-ms) cubic-bezier(0.22, 0.61, 0.36, 1);
  }
}
/* Fade-to-panel overlay at the bottom of each SDV task column — mirrors the
   BV lane fade and the SDV detail-inner fade. */
/* Same border-box stretch as the BV lane fade above, for the same reason: at
   `left/right/bottom: 0` the overlay stops at the padding box and a Task card
   scrolled under the corner shows its square stroke through the 1.5px sliver
   the lane rounds away. */
body.in-detail .task-lane::after {
  content: ""; position: absolute; height: 36px;
  pointer-events: none; z-index: 4;
  left: -1.5px; right: -1.5px; bottom: -1.5px;
  border-bottom-left-radius: var(--radius-task-lane);
  border-bottom-right-radius: var(--radius-task-lane);
  /* Fades to the lane's OWN background, not `--panel`.
     A scroll fade is only invisible when it ends in the exact color it covers.
     This ended in `--panel` (#1c2027) while the lane's background is
     `rgba(0,0,0,.18)` over the card — so the gradient was a visibly lighter
     band across the bottom of every lane whether or not anything was scrolling
     under it, which is not a fade, it is a stripe. Matching the lane's own fill
     means the overlay disappears against an empty lane and only shows where a
     Task card actually passes beneath it. */
  background: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,.18));
  /* Gated on having somewhere to scroll (tada-362), matching the BV lanes.
     Painted unconditionally this sat as a permanent wash over the last Task and
     the "+ Add Task" row of every column — including a two-task column with
     nothing below the fold, which is a stripe rather than a fade. */
  opacity: 0; transition: opacity .15s ease;
  /* Same border-strip mask as the BV lane fade (tada-340), for the same reason
     and by the same construction — see the long note there. This overlay's
     gradient is semi-transparent, so instead of erasing the status border it
     darkened its bottom 48px; subtler than the BV symptom, the same defect. */
  -webkit-mask-image:
    linear-gradient(to right, transparent 2.5px, #000 3.5px, #000 calc(100% - 3.5px), transparent calc(100% - 2.5px)),
    linear-gradient(to bottom, transparent calc(100% - 2px), #000 calc(100% - 2px));
  mask-image:
    linear-gradient(to right, transparent 2.5px, #000 3.5px, #000 calc(100% - 3.5px), transparent calc(100% - 2.5px)),
    linear-gradient(to bottom, transparent calc(100% - 2px), #000 calc(100% - 2px));
  -webkit-mask-composite: source-over;
  mask-composite: add;
}
body.in-detail .task-lane.scrollable-bottom::after { opacity: 1; }
body.in-detail .task-lane.collapsed::after { display: none; }

/* Fade at the TOP of a scrolled task column (tada-362) — the SDV half of the BV
   lane treatment. A Task scrolling up under the column head was cut off flat.

   Same construction as `.lane-top-fade`, and for the same reasons: `.task-lane`
   has its ::before taken by the shimmer ring and its ::after by the bottom fade,
   and `.task-lane-head` is `overflow: hidden` (see the rule below — that clip
   stops a collapsed column's label spilling), so an overlay hung off the head is
   silently clipped away while reporting perfect computed styles. A sticky,
   zero-height element inside the scroller is what actually works.

   `top: -8px` cancels .task-lane-body's 8px top padding so the fade starts on
   the head's inner edge rather than 8px below it, and the gradient starts below
   full opacity so it attenuates the Task under it instead of slicing it. */
body.in-detail .task-lane-top-fade {
  position: sticky; top: -8px; height: 0; z-index: 3;
  pointer-events: none;
}
body.in-detail .task-lane-top-fade::after {
  content: ""; position: absolute; display: block;
  left: -8px; right: -8px; top: 0; height: 36px;
  /* Ends in the column's OWN fill, not --panel — the task lane sits on
     rgba(0,0,0,.18) over the card, and a fade is only invisible when it ends in
     the exact colour it covers. Same reasoning as the bottom fade above. */
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, .30),
    rgba(0, 0, 0, .16) 45%,
    rgba(0, 0, 0, 0)
  );
  opacity: 0; transition: opacity .15s ease;
}
body.in-detail .task-lane.scrolled-top .task-lane-top-fade::after { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
  body.in-detail .task-lane::after,
  body.in-detail .task-lane-top-fade::after { transition: none; }
}
.task-lane-head { display: flex; align-items: center; gap: 6px; padding: 8px 8px; cursor: pointer; user-select: none; border-bottom: 1px solid rgba(255,255,255,.08); }
.task-lane-head:hover { background: rgba(255,255,255,.04); border-radius: var(--radius-task-lane-inner) var(--radius-task-lane-inner) 0 0; }
.task-lane.collapsed .task-lane-head { border-bottom: none; border-radius: var(--radius-task-lane-inner); flex-direction: column; align-items: center; justify-content: flex-start; padding: 10px 2px 8px; gap: 8px; height: 100%; }
.task-lane.collapsed .task-lane-head:hover { border-radius: var(--radius-task-lane-inner); }
.task-lane-head .task-count { margin-left: auto; color: var(--faint); font-size: 11px; }
.task-lane.collapsed .task-lane-head .task-count { margin-left: 0; font-weight: 600; }
.task-lane.collapsed .task-lane-head .badge { writing-mode: vertical-lr; text-orientation: upright; letter-spacing: .08em; padding: 4px 2px; font-size: 10px; }
.task-lane.collapsed .task-lane-body { display: none; }
.task-lane.collapsed { flex: 0 0 30px; }
.task-lane.drop-target { background: var(--panel-2); }

/* ── Footer mode: every lane collapsed (tada-336) ───────────────────────────
   Collapsing all four lanes used to be a dead end. Each one narrowed to a 30px
   vertical strip, so the board spent ~120px of width on sideways text while
   `#taskBoard`'s `flex: 1; min-height: 260px` held its full height regardless —
   the user had said "I'm done with tasks" as clearly as the UI allows and the
   description got nothing back for it.

   So the last collapse re-orients the whole board: the lanes turn back to
   horizontal bars in a single row along the bottom of the card, showing just
   badge + count, and every pixel above goes to the fields region. This is the
   same treatment the *mobile* BV lanes already use when collapsed (see the
   `max-width: 599px` block up in the Board View section) — horizontal-tb badge,
   count pushed to the right — ported to the desktop SDV rather than invented.

   Getting back out is clicking any bar: that expands its lane, `footer` comes
   off the container (it is derived from all four flags), and the board returns
   to the normal split. No separate affordance to discover, and none to explain.

   Desktop only — the whole `.task-board` is `display: none` under 600px, where
   TaskChecklist renders in a normal page scroll with no frame to re-proportion. */
/* `body.in-detail #taskBoard` (the `min-width: 600px` frame block above) sets
   the `flex: 1; min-height: 260px` this has to beat, and an id selector outweighs
   any number of classes — so the override has to carry the id too. Source order
   alone would not do it. */
body.in-detail #taskBoard.footer {
  /* Out of the flex-grow split entirely: the fields region above is what takes
     the freed height. `flex: 0 0 auto` rather than a fixed height so the row
     sizes to the bars, and the 260px floor is explicitly released — it is the
     rule that made the four collapsed strips pointless in the first place. */
  flex: 0 0 auto;
  min-height: 0;
  align-items: stretch;
  gap: 8px;
  /* Pinned to the bottom of the frame, which is the half of "footer" the height
     release alone does not deliver. `.detail-fields` is capped at 52%, so once
     the board stops growing *nothing* in the column does — the freed height
     lands as a gap between the two and the bars float directly under the
     description with the rest of the card empty beneath them. `margin-top:
     auto` hands that slack to the margin instead, pushing the row down.

     The fields region keeps its 52% cap rather than expanding to fill: the
     description gets the room it needs to be read, and the space below it stays
     quiet. Growing the panel to the full frame would stretch a two-line note
     across 600px of empty panel. */
  margin-top: auto;
}
/* Each lane: a flat bar, sharing the row evenly. `flex: 1` overrides the
   `flex: 0 0 30px` strip above; the height comes from the head's own padding. */
.task-board.footer .task-lane.collapsed { flex: 1; min-width: 0; }
.task-board.footer .task-lane.collapsed .task-lane-head {
  flex-direction: row; align-items: center; justify-content: flex-start;
  padding: 0 10px; gap: 8px; height: 34px;
  /* The lane's inner radius, for the reason the BV footer bar takes its own:
     the head covers the lane's whole inner box and paints the hover fill, so a
     smaller radius leaves the lane's fill in the corners and the corners look
     dimmed on hover (tada-336). */
  border-radius: var(--radius-task-lane-inner);
}
/* Badge back to reading left-to-right — the vertical-lr/upright treatment is
   what the narrow strip needed, and it is wrong the moment the box is wide. */
.task-board.footer .task-lane.collapsed .task-lane-head .badge {
  writing-mode: horizontal-tb; text-orientation: mixed;
  letter-spacing: .06em; padding: 3px 8px; font-size: 10px;
}
/* Count to the far end of the bar, the way it sits in an expanded lane head. */
.task-board.footer .task-lane.collapsed .task-lane-head .task-count {
  margin-left: auto; font-size: 12px;
}

/* ---- The two beats ----
   Collapsing the fourth lane is one click that changes two things: that lane's
   own width, and the whole board's shape. Run together they read as a single
   muddled lurch — the board appears to drop while a lane is still narrowing
   into it, and neither move is legible.

   So they are sequenced — and on the way IN the height leads, which is the
   opposite of what this originally did. Widths-first was wrong here for a
   reason no amount of retiming fixes: the three lanes already collapsed are
   30px strips, and a footer bar is `flex: 1`, so the moment footer mode turns
   on those strips are told to *grow* to a quarter of the row each. Letting that
   run first meant a collapse gesture began by expanding three lanes to ~270px
   before anything folded — the flash. Dropping the height first turns the same
   width change into the bars settling into a row that is already a footer,
   which is what it actually is.

   `--sdv-collapse-ms` (450ms) is the house duration for the fields↔board height
   handoff and is deliberately not reused here. That gesture carries height
   between two regions and needs the eye to follow it; this is a shape change
   that has already been decided, so it lands quicker. Same easing, so the two
   collapses feel like one family. */
/* All three flex longhands, not just `flex-basis`.
   The rules above swing the whole `flex` shorthand — `1` (grow 1, basis 0%) for
   an expanded lane or a footer bar, `0 0 30px` for a vertical strip — so a lane
   changes grow, shrink AND basis at once. Transitioning basis alone meant grow
   and shrink snapped in a single frame while basis eased behind them: the row's
   free space was redistributed instantly, so a lane jumped to roughly its final
   width, then the basis animation dragged it somewhere else and back. That is
   the over-expand-then-shrink on expanding a second lane, and the too-thin-then-
   widen on collapsing one. Animating all three keeps one flex resolution
   throughout, so the row moves as a single motion. */
.lane-col {
  transition:
    flex-basis var(--lane-fold-ms) var(--lane-fold-ease),
    flex-grow var(--lane-fold-ms) var(--lane-fold-ease),
    flex-shrink var(--lane-fold-ms) var(--lane-fold-ease);
}
/* Cross-fade the head's contents through the layout flip.
   A lane loses `.collapsed` on the frame it is clicked, so its head switches
   from the collapsed presentation (column, vertical badge) to the expanded one
   (row, horizontal badge) in a single frame, while the lane itself is still
   30px wide and only starts widening now. Neither `flex-direction` nor
   `writing-mode` is animatable, so that flip cannot be smoothed — but it can be
   hidden: the contents fade out and back in across the width animation, so the
   eye never catches them mid-reflow. The lane's box keeps moving throughout;
   only what is inside it blinks.

   The body needs this as much as the head. A lane's Task cards are laid out at
   full opacity from the first frame, inside a body that is still ~18px wide —
   each card's title wraps into a tall thin column and then reflows outward as
   the lane widens, which is the vertical settling that reads as the lane
   "growing up" after it has finished growing sideways. Fading it covers the
   whole reflow. */
.lane-col.lane-swapping .task-lane-head > *,
.lane-col.lane-swapping .lane-head > *,
.lane-col.lane-swapping .task-lane-body,
.lane-col.lane-swapping .lane-body {
  animation: lane-swap-fade var(--lane-fold-ms) var(--lane-fold-ease);
}
@keyframes lane-swap-fade {
  0%   { opacity: 0; }
  45%  { opacity: 0; }
  100% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .lane-col.lane-swapping .task-lane-head > *,
  .lane-col.lane-swapping .lane-head > *,
  .lane-col.lane-swapping .task-lane-body,
  .lane-col.lane-swapping .lane-body { animation: none; }
}

/* Clip the HEAD while the lane's width is in flight — not the lane.
   A lane loses `.collapsed` on the frame it is clicked, so its head is laid out
   expanded (`flex-direction: row`, a 48x25 horizontal badge) while the lane is
   still 30px wide. The badge is wider than its own lane for the first stretch
   of the animation, so it renders squashed against the edge and then unsquashes
   as the width catches up — the "wide-first, then settle" distortion. Clipping
   reveals the head as the lane widens instead of deforming it.

   On `.task-lane-head`, because `overflow-x: hidden` on `.task-lane` forces its
   `overflow-y` to `auto` (the CSS overflow spec: a `visible` axis becomes `auto`
   when the other axis is not visible). That made every lane a second scroller
   wrapped around `.task-lane-body`'s — measured 428px of scrollHeight in a
   425px box — which is a stray scrollbar and a wheel target that should not
   exist. The head has nothing to scroll, so clipping it costs nothing. */
body.in-detail .task-board .task-lane-head { overflow: hidden; }
/* The BV's lane head needs the identical clip, for the identical reason — it
   was simply missed when the SDV half was written, and the BV is where it has a
   visible consequence. A collapsed head is laid out as a row (horizontal badge
   + count) while its lane is still a 38px strip, so those children are wider
   than the box that holds them and spill to the *right*, past the board's
   padding edge. `.board` is `overflow-x: auto`, so that spill made it
   momentarily scrollable and Chromium flashed a horizontal scrollbar along the
   bottom of the page for the ~14 frames the widths took to catch up
   (tada-336.4).

   Which lane folds last decides whether it shows: the overflow runs rightward
   from the folding lane, so with Done (the last child) it lands in the board's
   own trailing padding and is absorbed. With Idea, Planned or In Progress there
   are lanes to the right and the spill clears the padding — measured 51px past
   the padding box for Idea, 44px for In Progress, 0 for Done. That asymmetry is
   why this reproduced for the user and not in any test that collapsed Done
   last.

   Same `overflow-y` caveat as the SDV's: setting one axis forces the other to
   `auto`, so this is the shorthand — the head has nothing to scroll, so
   clipping both costs nothing. */
.lane-head { overflow: hidden; }
/* Shared by both lane boards, so it lives at the root rather than on one of
   them. */
:root {
  /* One shape for both directions of the fold, shared by every collapsible
     lane board (the SDV task board and the BV story board).

     The height uses one duration both ways. The widths use a shorter one, and
     folding additionally delays them:

     - Shorter, because the two axes start from different fractions of their
       travel. The height goes its whole span from ~0; a lane's width starts at
       a footer bar's ~290px and has only the remainder to cover. On a shared
       duration one eased curve puts them at different points at every instant
       (measured: height 55% to width 26% a third of the way in), so the height
       arrived first and the closing stretch was width alone.
     - Delayed on the way in, because the already-collapsed lanes are narrow
       strips being told to grow to a footer bar's `flex: 1` — widths-first
       would begin a collapse by visibly *expanding* them. Dropping the height
       first turns that into bars settling into a row that is already a footer.
       Unfolding needs no delay: the board opens upward out of the floor, which
       reads on its own, and holding the widths back there split one motion in
       two. */
  --lane-fold-ms: 340ms;
  --lane-fold-w-ms: 285ms;
  --lane-fold-lag: 120ms;
  --lane-fold-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* The height beat, now leading rather than trailing. `min-height` rides along
   because releasing the 260px floor is what lets the height move at all —
   transitioning one without the other snaps at the floor and then eases the
   rest. No delay: this is the half the eye should follow first. */
body.in-detail #taskBoard.footer {
  transition:
    min-height var(--lane-fold-ms) var(--lane-fold-ease),
    flex-basis var(--lane-fold-ms) var(--lane-fold-ease),
    flex-grow var(--lane-fold-ms) var(--lane-fold-ease);
}
/* The lanes' widths follow it in, overlapping. Scoped to `.lane-folding` (set
   by TaskBoard for this one transition) rather than to `.footer`, for the same
   reason the unfold is: `.footer` is the resting state too, and a delay left on
   it would sit on every later collapse of an individual bar. */
.lane-board.lane-folding .lane-col {
  transition:
    flex-basis var(--lane-fold-ms) var(--lane-fold-ease) var(--lane-fold-lag),
    flex-grow var(--lane-fold-ms) var(--lane-fold-ease) var(--lane-fold-lag),
    flex-shrink var(--lane-fold-ms) var(--lane-fold-ease) var(--lane-fold-lag);
}
/* The board's height on the way *out* of footer mode.
   Deliberately NOT written as `#taskBoard:not(.footer)`. That matches the board
   in its ordinary state too, and it is both later in the file and one class more
   specific than the scroll collapse's own height rule — so it would silently
   retime that unrelated gesture from 450ms to 260ms (the exact trap the comment
   on that rule warns about). `.lane-unfolding` is set by TaskBoard for one
   transition only, so this reaches the unfold and nothing else. */
body.in-detail #taskBoard.lane-unfolding {
  /* Stay bottom-anchored while the height grows, so the board opens UPWARD out
     of the footer line instead of downward from the top of its final slot.
     `.footer` sets this and the steady state does not, so without it here the
     anchor is dropped on the first frame of the unfold: the top edge lands at
     its final position at once and the bottom edge travels instead, which puts
     the lanes' bottoms below the card — off the viewport on a short window —
     until the height catches up. The bottom edge should never move.

     `auto` alone is wrong at the *end* of that travel, though: it resolves to 0
     once the board has grown to fill the column, which overrides the 24px
     `margin-top` the steady state carries (`.task-board` above). So the board
     settles 24px taller than its resting height and then snaps down by exactly
     that when the class is dropped — the overshoot-and-reset at the end of the
     unfold. `max()` keeps the anchor while the free space lasts and hands over
     to the real margin as it runs out, so the two states meet at the same
     height and nothing jumps.

     `auto` is the only value that actually pins the bottom while the height
     grows — `max(24px, auto)` is not valid CSS and silently falls back to the
     plain margin, which pins the top instead and puts the bottom back
     off-screen.

     But an auto margin resolves to 0 once the box has filled its column, which
     overrides the 24px `margin-top` the resting state carries (`.task-board`
     above). The board therefore settles 24px taller than it will end up and
     snaps down by exactly that when the class drops — the overshoot at the end
     of the unfold.

     The fix is to make the *anchored* box describe the same geometry the
     resting one will: `auto` above for the anchor, and the resting 24px moved
     below it so the outer height matches. Reserving it with `margin-bottom`
     alone shifts the board 24px down at handover (the bottom edge jumps 739 →
     763); paired with a `translateY` of the same amount it sits exactly where
     the resting layout will put it, and the class can drop with nothing
     visibly changing. */
  margin-top: auto;
  margin-bottom: 24px;
  transform: translateY(24px);
  transition:
    min-height var(--lane-fold-ms) var(--lane-fold-ease),
    max-height var(--lane-fold-ms) var(--lane-fold-ease),
    /* `flex-grow` belongs here as much as the heights do, and leaving it out is
       why the unfold had no leading beat at all: the board's height in the
       expanded state comes from growing into the frame (`flex: 1`), not from
       min-height, so grow snapping 0 → 1 put it at full height in a single
       frame while the "leading" min-height animation trailed behind something
       that had already arrived. The fold rule had it; this one did not. */
    flex-grow var(--lane-fold-ms) var(--lane-fold-ease);
}
/* The widths follow the height in, overlapping rather than waiting for it to
   finish — a full-duration delay made the unfold read as two separate moves
   with dead air between them: the board grew, then the lanes noticed. Mirrors
   the fold exactly, same duration and same lag; the two are one motion
   reversed. */
.lane-board.lane-unfolding .lane-col {
  transition:
    flex-basis var(--lane-fold-w-ms) var(--lane-fold-ease),
    flex-grow var(--lane-fold-w-ms) var(--lane-fold-ease),
    flex-shrink var(--lane-fold-w-ms) var(--lane-fold-ease);
  /* The lane loses `.collapsed` the instant it is clicked, so its head, body and
     cards are laid out at full size on the first frame — inside a board that is
     still 36px tall. Everything spilled out below the card's bottom edge and
     then the board caught up around it, which read as the lanes snapping to
     full height rather than travelling there. Clipping each lane to its own
     (animating) height keeps the contents inside the box the whole way up, so
     the lane reveals as it grows. Only during the unfold: a lane at rest must
     not clip, or its task cards lose their glow ring and drop shadow. */
  overflow: hidden;
}
/* And the body inside it has to be free to be short. `.lane-body` carries a
   resting `min-height: 80px` so an empty lane still reads as a drop target —
   but the BV lane on the first frames of an unfold is only ~40-80px tall, which
   makes that floor taller than the box containing it. The body therefore laid
   out at 80px inside a much shorter lane and hung past the board's bottom edge
   (measured: body bottom 807 against a board bottom of 800), which the
   `overflow: hidden` above clipped rather than prevented. Clipped, the visible
   result is the body's *content* standing at its resting size in the top of the
   strip while the lane grows down around it — the lane reading as popping to
   the top and then expanding, instead of rising out of the floor.

   This is invisible to any assertion on the board's own box: the board and lane
   rects stay correctly bottom-anchored throughout (TB35 and TD42-45 all passed
   with the bug present), because the overflow is entirely inside them. TB38
   pins the inner edge instead.

   BV only. `.task-lane-body`'s floor is 60px against a taller SDV footer lane,
   so it already fits and needs no override — measured identically with and
   without one. */
.lane-board.lane-unfolding .lane-col .lane-body { min-height: 0; }

/* The sequencing is a nicety, not the feature — with reduced motion the board
   still re-proportions, it just arrives there at once. Mirrors the
   `prefers-reduced-motion` branch the scroll collapse uses above. */
@media (prefers-reduced-motion: reduce) {
  body.in-detail .task-board .task-lane,
  body.in-detail #taskBoard.footer,
  .lane-board.lane-folding .lane-col,
  .lane-board.lane-unfolding,
  .lane-board.lane-unfolding .lane-col {
    transition: none;
  }
}
.task {
  position: relative;
  /* Same padding-bounded rule as .card above; this box is tighter (7px/8px),
     so it stops a step lower. */
  background: var(--panel); border: 1px solid var(--line); border-radius: 9px;
  padding: 7px 8px; margin-bottom: 5px; display: flex; align-items: flex-start; gap: 4px;
  cursor: grab; user-select: none; transition: opacity .15s;
}
.task:active { cursor: grabbing; }
.task.dragging { opacity: .3; }
/* Provisional Task row (tada-265): shown the frame it's added, before its POST
   returns the real `<story>.<n>` id. Dimmed and non-interactive — it can't be
   dragged, retitled, or deleted until the SSE echo swaps in the server's row.
   Deliberately subtle: on a fast write it's on screen for one frame, so a loud
   treatment would read as a flicker. The .task-seq chip is absent on its own
   (taskNum() finds no `.N` tail), which is the other half of the signal. */
.task.pending, .checklist-item.pending {
  opacity: .55; cursor: default; pointer-events: none;
}
/* Drop marker between Task cards — line in the 5px gap, not a card border.
   Mirrors the Story-card treatment above (BV/SDV parity). */
.task.drop-before::before,
.task.drop-after::after {
  content: ""; position: absolute; left: 0; right: 0;
  height: 2px; background: var(--accent); border-radius: 2px;
}
/* -3.5px centers the 2px marker on the 5px inter-task gap (only margin-bottom,
   no collapse): midpoint 2.5px + 1px for the line's half-height. Both triggers
   share the value — same centering rule as the Story cards, one scale down. */
.task.drop-before::before { top: -3.5px; }
.task.drop-after::after  { bottom: -3.5px; }
/* Stable per-task seq chip (`.N`) on the Task card — mirrors the Story card's
   .card-id chip (BV/SDV parity: same chip language), one size down to suit the
   smaller Task card. Hovering shows the full id (`tada-<story>.<seq>`) via the
   title attr. (tada-133) */
.task-seq {
  flex: none; font-size: 12px; font-weight: 700; line-height: 1; color: var(--dim);
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 5px; padding: 2px 5px; margin-top: 2px; cursor: help;
}
.task-title { flex: 1; font-size: 15px; line-height: 1.4; }
/* Inline Markdown inside Task titles (both the board cards and the checklist). */
.task-title code, .checklist-item-title code {
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 3px;
  padding: 0 4px; font-size: .9em;
}
.task-title a, .checklist-item-title a { color: var(--accent); }
.task-title strong, .checklist-item-title strong { color: var(--text); font-weight: 700; }
.task-title em, .checklist-item-title em { font-style: italic; }
.hex-chip { border-radius: 3px; padding: 0 5px; font-size: .9em; font-family: ui-monospace, monospace; }
/* Task row action icons (pencil + ❌) share one style so they sit side by side,
   same size and baseline. They fade in when the Task is hovered; a highlight
   ring appears only when an icon itself is hovered (not on Task hover). */
.task-icon {
  flex-shrink: 0; opacity: 0; transition: opacity .12s, background .12s, box-shadow .12s;
  background: none; border: none; cursor: pointer;
  width: 24px; height: 24px; padding: 0; border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
  font-size: 14px; line-height: 1;
}
.task:hover .task-icon { opacity: 1; }
.task-icon:hover { background: var(--panel-2); box-shadow: 0 0 0 1px var(--line); }

/* Action-icon tooltip (tada-254). An icon button says what it does only through
   its glyph, which is guessable at best (↩️ restore? reply? undo?). The native
   `title` attribute technically answers that, but it waits ~1s, renders in OS
   chrome that ignores the app's theme, and never fires on touch — so on mobile
   the glyph is the entire affordance. This is the app's own bubble: instant,
   themed, and readable from the same `data-tip` attribute the progress bar's
   segment tooltips use.

   Positioned *below* the button, unlike the progress bar's (which opens
   upward). The SDV's `.task-lane-body` is an `overflow-y: auto` scroll
   container, so a bubble opening upward from a task's ❌ is clipped at the lane
   head. Downward keeps it inside the scrollable area — and on a Changelog row,
   which has no scroll ancestor, downward reads the same. `::before` rather than
   `::after` because `.task-lane::after` is already the column's fade overlay.

   Hover-only by design: it explains an icon that itself only appears on hover
   (`.task-icon` is opacity:0 until then). Where the icons are always visible —
   touch, see the `(hover: none)` rule with the Changelog block — there is no
   hover to trigger a tooltip, so the glyph stands alone there as it does today.
   Making that case work needs a tap-to-reveal affordance, not a CSS bubble. */
.task-icon[data-tip] { position: relative; }
.task-icon[data-tip]::before {
  content: attr(data-tip); opacity: 0; visibility: hidden;
  position: absolute; top: calc(100% + 6px); left: 50%; transform: translateX(-50%);
  background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
  padding: 3px 8px; font-size: 12px; font-weight: normal; color: var(--text);
  white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,.4);
  /* Same band as the progress bar's segment tooltips (19–20), deliberately
     below the dropdown/kebab layer (30): an open menu must cover a tooltip,
     never tie with it. */
  z-index: 21; pointer-events: none;
  transition: opacity .12s, visibility .12s;
}
/* Delay matches the icon's own .12s fade-in, so the bubble doesn't appear
   before the button it describes has finished arriving. */
.task-icon[data-tip]:hover::before { opacity: 1; visibility: visible; transition-delay: .12s; }
/* Keyboard parity: a tooltip that only answers to the mouse leaves the same
   question unanswered for anyone tabbing through the row. */
.task-icon[data-tip]:focus-visible::before { opacity: 1; visibility: visible; }
/* .changelog-edit is mirrored (scaleX(-1)) so its pencil points at the text it
   edits — which would render its tooltip's text mirrored too. Flip the bubble
   back, and negate the centering translate along with it (the parent's flip
   reverses the X axis the translate resolves against). */
.changelog-edit[data-tip]::before { transform: translateX(50%) scaleX(-1); }

.task-add-row { margin-top: 6px; }
.task textarea.detail-edit { font-size: 15px; font-weight: normal; padding: 4px 6px; min-height: 72px; resize: vertical; }

/* mobile checklist (replaces task kanban on narrow screens) */
/* Same reasoning as .task-board's negative side margins: expand out of
   .detail-scroll's 30px side padding so rows reach to 6px from
   .detail-inner's edges (the outer status ring's own inset), instead of
   floating 30px in from it. */
.task-checklist { margin-top: 20px; margin-left: -24px; margin-right: -24px; }
.task-checklist-label { font-size: 12px; color: var(--faint); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 10px; }
.checklist-item {
  position: relative;
  display: flex; align-items: stretch; gap: 10px;
  padding: 0 10px; border-radius: 8px; margin-bottom: 14px;
  background: var(--panel-2); border: 1.5px solid var(--ring-color, var(--line));
  transition: transform .15s, border-color .15s, box-shadow .15s;
}
/* Status ring, matching .lane/.task-lane/.card (docs/design.md "Color
   ring") — thin border colored via --ring-color. overflow:hidden was
   dropped from .checklist-item to let the ring pseudo-elements show —
   .checklist-seq's corner badge (top:3px;left:3px) already sits inside the
   row's own bounds, so it doesn't need the clip, and the swipe gesture
   moves the whole row via transform rather than sliding content past its
   edges. */
.checklist-item.idea { --ring-color: var(--idea); }
.checklist-item.planned { --ring-color: var(--planned); }
.checklist-item.in-progress { --ring-color: var(--in-progress); }
.checklist-item.done { --ring-color: var(--done); }
.checklist-item.swiping { transition: none; }
.checklist-status-badge {
  flex-shrink: 0; align-self: center; width: 30px; height: 30px;
  border-radius: 50%; border: none; cursor: pointer; padding: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 16px; line-height: 1; background: var(--panel);
}
.checklist-item-title {
  flex: 1; font-size: 16px; line-height: 1.4;
  cursor: text; border-radius: 4px; padding: 11px 3px;
}
/* Corner badge: shrink the shared .task-seq chip and pin it over the card's
   top-left corner instead of sitting inline (tada-177) — subtler than a
   full-size chip in the row flow, still hoverable for the full id. */
.checklist-seq {
  position: absolute; top: 3px; left: 3px; z-index: 1;
  font-size: 10px; padding: 1px 4px; margin-top: 0;
  background: var(--panel); box-shadow: 0 1px 3px rgba(0,0,0,.35);
}
.checklist-item-title.done-text { color: var(--faint); text-decoration: line-through; }
/* Per-task action menu: the delete option lives behind a kebab so the row
   isn't a one-tap-destroy target (mobile-only — this checklist is hidden ≥600px). */
.checklist-actions { flex-shrink: 0; position: static; }
.checklist-menu-btn {
  width: 30px; height: 30px; border-radius: 50%;
  border: none; background: none; color: var(--faint); font-size: 18px;
  line-height: 1; cursor: pointer; padding: 0;
  display: flex; align-items: center; justify-content: center;
}
.checklist-menu-btn:active { opacity: .6; }
/* position:fixed (not absolute) so the menu escapes .checklist-item's own
   overflow:hidden (needed to clip .checklist-seq's corner badge) -- an
   absolutely-positioned menu was getting clipped invisible the moment it
   extended past the row's bottom edge. Coordinates are set from JS at open
   time (wireTaskChecklist), computed from the button's own rect, since
   fixed positioning has no "100% of my row" to anchor to. */
.checklist-menu {
  position: fixed; z-index: 30;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  min-width: 120px; box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 4px;
  display: none;
}
.checklist-menu.open { display: block; }
.checklist-menu-item {
  display: flex; align-items: center; gap: 8px; width: 100%; text-align: left;
  padding: 8px 11px; background: none; border: none; border-radius: 5px;
  font-size: 15px; color: var(--text); cursor: pointer; font-family: inherit;
}
.checklist-menu-item:active { background: rgba(255,255,255,.07); }
.checklist-menu-item.danger { color: var(--bug); }
/* The whole row is the tap target: click anywhere to start adding a task. */
.checklist-add-row {
  display: flex; gap: 8px; align-items: center; margin-top: 10px; cursor: text;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  padding: 10px 12px; transition: border-color 0.12s, background 0.12s;
}
.checklist-add-row:focus-within { border-color: var(--accent); }
.checklist-add-input {
  flex: 1; background: transparent; border: 0; color: var(--text);
  padding: 0; font-size: 16px; font-family: inherit;
}
.checklist-add-input:focus { outline: none; }
/* Desktop: highlight the row on hover so it reads as clickable. */
@media (hover: hover) {
  .checklist-add-row:hover { border-color: var(--accent); background: var(--panel); }
}
@media (min-width: 600px) {
  .task-checklist { display: none; }
}
@media (max-width: 599px) {
  .task-board { display: none; }
}

/* ── Dependency links (tada-39) ──────────────────────────────────────────────
   The shield badge on a blocked Story card, and the SDV's Dependencies row.
   The shield is the one glyph shared with view_board's agent-facing rendering,
   so "blocked" reads identically in the UI and in a tool result. */
/* `.card-idicon` is a vertical rail (id, type glyph, tags). A third stacked
   item would lengthen it and read as clutter, so the shield overlays the type
   glyph's corner instead — present at a glance, costing no rail height. */
.card-type-icon { position: relative; }
.card-blocked {
  position: absolute; right: -6px; bottom: -4px;
  font-size: 11px; line-height: 1; pointer-events: none;
  /* A dark halo keeps the shield legible against the type emoji it overlaps. */
  text-shadow:
    0 0 3px var(--panel), 0 0 3px var(--panel), 0 0 2px var(--panel);
}

/* A two-column grid: labels in the first column, chips in the second.
   Previously each group was its own flex row with the label inline, so the
   labels started at different x positions ("Blocks", "Blocked by", "Relates
   to" are different widths) and the chips after them never lined up — the
   section read as three unrelated rows rather than one list. `max-content` on
   the label column sizes it to the widest label, so every row aligns without a
   magic width that would break when a relationship type is added. */
/* ── Custom scrollbar thumb (useFadingScrollbar) ─────────────────────────────
   Used by every scroller in the app: the SDV fields region, the BV Story lanes,
   and the SDV Task lanes. A real element, not ::-webkit-scrollbar — that pseudo
   can't be transitioned in any engine that ships it, and Firefox exposes no
   opacity for it at all. The hook writes `top`/`height`; everything visual is
   here.

   Deliberately NOT scoped to `body.in-detail`: it started life in the SDV, and
   leaving the scope on meant a thumb mounted on a BV lane rendered with no
   styling at all — so it sat permanently visible at full opacity instead of
   fading. Overlays the scroller's right padding rather than taking gutter, so
   showing and hiding it never reflows the content. Opacity is the only animated
   property, so the fade stays on the compositor. */
.scrollthumb {
  position: absolute; right: 3px; width: 4px;
  border-radius: 4px;
  background: var(--faint);
  opacity: 0;
  /* Out is slower than in: the bar should answer a scroll immediately, then
     recede gently enough that it doesn't read as a blink. Matches the
     0.12–0.15s hover band on the way in (docs/design.md "Animation"). */
  transition: opacity .35s ease-out;
  pointer-events: none;
  z-index: 5;
}
.scrollthumb.is-visible {
  opacity: .55;
  transition: opacity .12s ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .scrollthumb { transition: none; }
}

/* ── Relationships: a header chip + popover ──────────────────────────────────
   Moved out of the card body deliberately. As a body section it shared
   `.detail-fields` with the description — a region capped at 52% that also
   doubles as the collapse gesture's scroller — so every attempt to give the
   links room either ate the note's reading height or left them clipped below
   the fold. In the header they cost no body space at all. */
.detail-links-chipwrap { position: relative; display: inline-flex; }
/* Rides the inner ring bar as a knockout chip, matching .detail-id's family. */
.detail-links-chip {
  display: inline-flex; align-items: center; gap: 4px;
  background: var(--panel); border: 0; border-radius: 4px;
  box-shadow: inset 0 0 0 1px rgb(0 0 0 / .25);
  color: var(--dim); cursor: pointer; font: inherit; font-size: 12px;
  padding: 2px 7px; line-height: 1.3;
}
.detail-links-chip:hover { color: var(--text); }
/* Blocked reads at a glance without a second marker: the standalone header
   shield was removed once this chip carried the shield itself. */
.detail-links-chip.blocked { color: var(--text); }
.detail-links-count { font-variant-numeric: tabular-nums; }

.detail-links-pop {
  position: absolute; left: 0; top: calc(100% + 6px); z-index: 50;
  min-width: 280px; max-width: 420px;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  box-shadow: 0 8px 24px rgb(0 0 0 / .45);
  padding: 10px 12px 12px;
}
.detail-links-poptitle {
  font-size: 12px; font-weight: 600; color: var(--faint);
  letter-spacing: .04em; text-transform: uppercase;
  margin-bottom: 8px;
}

.detail-links {
  display: grid; grid-template-columns: max-content 1fr;
  gap: 6px 10px; align-items: start;
}
.detail-link-group { display: contents; }
.detail-link-label {
  font-size: 12px; font-weight: 600; color: var(--faint);
  /* Nudge down so the label's cap-height sits level with the chip text beside
     it; the chips are taller than the bare label. */
  padding-top: 3px;
  white-space: nowrap;
}
/* The chips for one group wrap within their column rather than pushing the
   label out of line. */
.detail-link-values { display: flex; flex-wrap: wrap; gap: 6px; }
.detail-link {
  display: inline-flex; align-items: center; gap: 2px;
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 6px; padding: 1px 2px 1px 6px;
}
/* The id is the navigation affordance — styled as a link, not a button, so it
   reads as "go there" rather than "do something". */
.detail-link-id {
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--text); font: inherit; font-size: 13px; font-variant-numeric: tabular-nums;
}
.detail-link-id:hover { color: var(--accent); text-decoration: underline; }
.detail-link-x {
  background: none; border: 0; cursor: pointer; color: var(--faint);
  font-size: 14px; line-height: 1; padding: 0 4px; border-radius: 4px;
}
.detail-link-x:hover { color: var(--text); background: var(--panel); }
.detail-link-add {
  background: none; border: 1px dashed var(--line); border-radius: 6px;
  color: var(--faint); cursor: pointer; font: inherit; font-size: 13px;
  padding: 2px 8px;
}
.detail-link-add:hover { color: var(--text); border-color: var(--faint); }
/* The add control and its relationship-type menu. `relative` so the menu
   anchors to the button; the wrapper is inline-block so the dashed button keeps
   its content width rather than stretching across the section. */
/* Spans both grid columns and sits left-aligned below the list — it belongs to
   the section, not to any one relationship type. `justify-self: start` keeps
   the dashed button at its content width instead of stretching the full row. */
.detail-link-addwrap {
  position: relative; display: inline-block;
  grid-column: 1 / -1; justify-self: start;
}
/* Same treatment as the SDV kebab menu (.detail-kebab-menu) — one menu idiom in
   this view. Opens downward from the button; the section is near the top of the
   card, so there is always room below. Sits at the dropdown layer (30) rather
   than the kebab's 50: this is an in-body control, not header chrome, and it
   must stay under the modal overlay. */
.detail-link-typemenu {
  position: absolute; left: 0; top: calc(100% + 4px); z-index: 30;
  background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px;
  min-width: 150px; box-shadow: 0 4px 16px rgba(0,0,0,.4); padding: 4px;
}
.detail-link-typeopt {
  display: block; width: 100%; text-align: left; padding: 7px 10px;
  background: none; border: none; border-radius: 5px;
  color: var(--text); font: inherit; font-size: 13px; cursor: pointer;
}
.detail-link-typeopt:hover { background: rgba(255,255,255,.07); }
.detail-link-picker {
  background: var(--panel-2); border: 1px solid var(--accent); border-radius: 6px;
  color: var(--text); font: inherit; font-size: 13px; padding: 2px 6px; outline: none;
  max-width: 320px;
}

/* Story picker results (tada-275.6). The wrap is the positioning context: the
   result list floats over the fields below rather than pushing the SDV around
   as the query narrows. */
/* Spans both columns like the add button it replaces, so opening the picker
   doesn't shift the list sideways. */
.detail-link-picker-wrap {
  position: relative; display: inline-flex;
  grid-column: 1 / -1; justify-self: start;
}
.detail-link-results {
  position: absolute; top: calc(100% + 4px); left: 0; z-index: 20;
  display: flex; flex-direction: column; min-width: 260px; max-width: 360px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
  padding: 4px; box-shadow: 0 8px 24px rgb(0 0 0 / 0.35);
}
.detail-link-result {
  display: flex; align-items: baseline; gap: 8px; width: 100%;
  background: none; border: 0; border-radius: 6px; cursor: pointer;
  padding: 4px 6px; text-align: left; font: inherit; color: var(--text);
}
/* Hover and keyboard highlight are the same state — the pointer moves the
   active index, so there is only ever one highlighted row. */
.detail-link-result.active { background: var(--panel-2); }
.detail-link-result-id {
  font-size: 12px; color: var(--faint); font-variant-numeric: tabular-nums;
  flex: none;
}
.detail-link-result-title {
  font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.detail-link-result-empty {
  font-size: 13px; color: var(--faint); padding: 4px 6px;
}

/* ── Toasts (tada-281.2) ───────────────────────────────────────────────────
   Transient notices, stacked bottom-right. The host is in
   components/ToastHost.tsx; queue policy is in lib/toast.ts.

   The stack is always in the DOM (its aria-live regions have to pre-exist the
   messages they announce), so it must never eat pointer events while empty —
   hence `pointer-events: none` on the container with the individual toasts
   opting back in. Getting that wrong makes a permanent invisible click-blocker
   over the bottom-right corner of the board, which is exactly where the
   right-most lane's cards live.

   z-index 300 puts it above every panel layer: the mobile tray/sheet pair
   (200/190), the tray drawer (101/100), and the SDV overlay (50). It stays
   *below* the two 9999 elements (`#tooltip`, `#tagpop`), which are
   pointer-following chrome positioned by JS rather than panels — a tooltip
   tracking the cursor should ride over a toast, not duck under it. */
.toast-stack {
  position: fixed; right: var(--gutter); bottom: 16px; z-index: 300;
  display: flex; flex-direction: column; gap: 8px;
  pointer-events: none;
  width: min(360px, calc(100vw - 2 * var(--gutter)));
}
.toast-region { display: flex; flex-direction: column; gap: 8px; }
.toast {
  pointer-events: auto;
  display: flex; align-items: flex-start; gap: 10px;
  padding: 10px 12px; border-radius: 8px;
  background: var(--panel-2); color: var(--text);
  border: 1px solid var(--line);
  /* The severity color reads as a left rule rather than a fill: a full-bleed
     --bug panel behind body text fails contrast, and the status colors are
     already spoken for as *status* elsewhere on the board. A 3px edge borrows
     the vocabulary without claiming the card is a status. */
  border-left: 3px solid var(--toast-accent, var(--dim));
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.45);
  font-size: 13px; line-height: 1.4;
  /* 0.2s ease is the doc's "layout / geometry" tier (docs/design.md,
     Animation / transitions) — the same tier as the progress-bar expand and the
     modal fade. A toast entering *is* a geometry change, so it uses that
     duration rather than a bespoke one. */
  animation: toast-in .2s ease;
}
/* Severity → token. Same four colors the lanes and badges use, so a warning
   toast and the In Progress lane read as the same system. */
.toast-info { --toast-accent: var(--planned); }
.toast-success { --toast-accent: var(--done); }
.toast-warning { --toast-accent: var(--in-progress); }
.toast-error { --toast-accent: var(--bug); }
.toast-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
/* Long content (a blocker list, an API error) wraps rather than overflowing —
   the stack has a fixed width and a toast has no scroll affordance. */
.toast-message { overflow-wrap: anywhere; }
.toast-detail { font-size: 12px; color: var(--dim); overflow-wrap: anywhere; }
.toast-close {
  flex: none; background: none; border: 0; cursor: pointer;
  color: var(--faint); font-size: 16px; line-height: 1;
  padding: 2px 4px; border-radius: 4px;
  /* 0.12s — the doc's "hover opacities, colors" tier. */
  transition: color .12s ease, background .12s ease;
}
.toast-close:hover { color: var(--text); background: var(--panel); }
.toast-close:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
@keyframes toast-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: none; }
}
/* The slide is decorative; the toast itself is the information. With reduced
   motion it appears in place. */
@media (prefers-reduced-motion: reduce) {
  .toast { animation: none; }
}
/* On mobile the stack spans the gutters and sits above the bottom sheet's
   resting edge rather than in the corner. */
@media (max-width: 599px) {
  .toast-stack {
    right: 12px; left: 12px; bottom: 12px; width: auto;
  }
}

/* ---------------------------------------------- due filter axis (tada-9.3)
   A notched slider anchored at today. The fill *is* the range: a forward
   window fills rightward from the origin in --accent, overdue fills leftward
   in --bug. The direction flip plus the color change is what signals that
   overdue behaves differently (exclusive) from the cumulative forward windows
   — colour alone must not carry it, since the notch labels and fill direction
   have to work for a colour-blind reader too. */
.due-axis { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; }
.due-slider { display: flex; flex-direction: column; gap: 6px; }
.due-track {
  position: relative; height: 4px; border-radius: 999px;
  background: var(--panel-2); overflow: hidden;
}
.due-fill {
  position: absolute; top: 0; bottom: 0;
  background: var(--accent); border-radius: 999px;
  transition: width .18s ease, left .18s ease, right .18s ease;
}
.due-track.overdue .due-fill { background: var(--bug); }
/* Today's tick, the origin the fill grows from. */
/* Today's tick — the point both fills grow from. Positioned inline from the
   same formula the fill uses, so the two can't drift apart. Taller than the
   track and in --text so it reads as an anchor rather than track texture. */
.due-origin {
  position: absolute; top: -3px; bottom: -3px; width: 2px;
  background: var(--text); transform: translateX(-1px); border-radius: 1px;
}
.due-notches { display: flex; align-items: flex-start; justify-content: space-between; gap: 2px; }
.due-notch {
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  background: none; border: 0; padding: 2px 4px; cursor: pointer;
  font: inherit; font-size: 11px; color: var(--faint); border-radius: 6px;
}
.due-notch:hover { color: var(--text); }
.due-notch:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.due-notch-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--panel-2); box-shadow: inset 0 0 0 1px var(--line);
}
.due-notch.on { color: var(--text); }
.due-notch.on .due-notch-dot { background: var(--accent); box-shadow: none; }
.due-notch[data-window="overdue"].on .due-notch-dot { background: var(--bug); }
.due-notch-count { font-size: 10px; color: var(--faint); font-variant-numeric: tabular-nums; }
.due-notch.on .due-notch-count { color: var(--dim); }
/* Off the track on purpose: "no date" is not a point on a time axis, and
   putting it on the slider would break the fill metaphor. */
.due-nodate {
  display: flex; align-items: center; gap: 6px; align-self: flex-start;
  background: none; border: 1px solid var(--line); border-radius: 999px;
  padding: 3px 9px; cursor: pointer; font: inherit; font-size: 11px;
  color: var(--faint);
}
.due-nodate:hover { color: var(--text); }
.due-nodate:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.due-nodate.on { color: var(--text); border-color: var(--accent); }
/* The collapsed trigger's summary pill, mirroring the type dots and tag emoji. */
.bv-due-pill {
  font-size: 11px; padding: 1px 7px; border-radius: 999px;
  background: var(--panel-2); color: var(--dim); white-space: nowrap;
}
.bv-due-pill.due-overdue { color: var(--bug); }
@media (prefers-reduced-motion: reduce) {
  .due-fill { transition: none; }
}

}
