:root {
  --bg: var(--theme-bg); --panel: var(--theme-panel); --panel2: var(--theme-panel); --line: var(--theme-line);
  --fg: var(--theme-fg); --muted: var(--theme-muted); --accent: var(--theme-primary); --accent2: var(--theme-primary);
  --ok: var(--theme-good); --warn: var(--theme-warn); --bad: var(--theme-bad); --purple: var(--theme-purple);
  --scroll-thumb: var(--theme-edge); --scroll-thumb-hover: var(--theme-muted);
  /* Search, and nothing else. Amber rather than the accent: blue already means "this is an action"
     and "this is a ticket key" here, and a found ticket is neither — a blue ring round a card
     whose key is blue reads as selection. Kept out of --warn, which the medium-priority edge and
     the stale chip already own, so a highlight can never be mistaken for a state of the work.
     map.js paints the same value on canvas as FOUND. The wash sits at 35%: enough to catch the eye
     scanning a column, without dropping the text behind it under 4.5:1 against --fg. */
  --find: var(--theme-warn); --find-wash: var(--theme-warnTint); --find-ring: var(--theme-warn);
  --radius: 8px;
  /* The built-in easings are too weak to read as intentional. Entering and exiting use
     --ease-out; anything moving on screen uses --ease-in-out. Nothing uses ease-in: it holds
     still exactly when the eye is watching hardest, which reads as lag. */
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  /* --ease-out is so front-loaded that a 220ms modal is visually done in 80: right for a 140ms
     button press, wrong for anything that travels. Things that move a distance use this. */
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
}
* { box-sizing: border-box; }
body {
  margin: 0; background: var(--bg); color: var(--fg);
  font: 14px/1.5 ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.hidden { display: none !important; }

/* One native scrollbar treatment, shared by every intentional app viewport. The track and thumb
   area stay native, so wheel, trackpad, keyboard and direct thumb dragging keep browser behavior.
   Only the paint changes, and high-contrast mode receives no override at all. */
@media not (forced-colors: active) {
  /* Chromium's standard scrollbar-color overrides its richer thumb hover pseudo-elements. Keep
     that fallback for engines without the WebKit scrollbar surface, so Edge/Chrome can reveal a
     transparent thumb precisely when the thumb itself is hovered or dragged. */
  @supports not selector(.appScroll::-webkit-scrollbar-thumb) {
    .appScroll {
      scrollbar-width: thin;
      scrollbar-color: transparent transparent;
    }
    .appScroll.scrollActive { scrollbar-color: var(--scroll-thumb) transparent; }
  }
  .appScroll::-webkit-scrollbar {
    width: 10px;
    height: 10px;
    background: transparent;
  }
  .appScroll::-webkit-scrollbar-track,
  .appScroll::-webkit-scrollbar-track-piece {
    background: transparent;
    border: 0;
    box-shadow: none;
  }
  .appScroll::-webkit-scrollbar-thumb {
    min-width: 32px;
    min-height: 32px;
    border: 3px solid transparent;
    border-radius: 999px;
    background: transparent;
    background-clip: content-box;
  }
  .appScroll.scrollActive::-webkit-scrollbar-thumb {
    background-color: var(--scroll-thumb);
  }
  .appScroll::-webkit-scrollbar-thumb:hover,
  .appScroll::-webkit-scrollbar-thumb:active {
    background-color: var(--scroll-thumb-hover);
  }
  .appScroll::-webkit-scrollbar-button {
    display: none;
    width: 0;
    height: 0;
  }
  .appScroll::-webkit-scrollbar-corner,
  .appScroll::-webkit-resizer { background: transparent; }
}
.muted { color: var(--muted); }
.small { font-size: 12px; }
.spacer { flex: 1; }
.err { color: var(--bad); min-height: 1.2em; }
a { color: var(--accent); }

input, select, textarea, button {
  font: inherit; color: var(--fg); background: var(--panel2);
  border: 1px solid var(--line); border-radius: 6px; padding: 6px 9px;
}
textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; resize: vertical; width: 100%; }
button { cursor: pointer; transition: background-color 140ms ease, border-color 140ms ease, transform 140ms var(--ease-out); }
button:hover { border-color: var(--muted); }
button:active { transform: scale(0.97); }        /* the press has to be felt, not just handled */
button.primary { background: var(--accent2); border-color: var(--accent2); color: var(--theme-fg); }
button.primary:hover { background: var(--accent); }
input, select, textarea { transition: border-color 140ms ease, background-color 140ms ease; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
label input, label select, label textarea { color: var(--fg); }
.chk { flex-direction: row; align-items: center; gap: 6px; }

/* ---------- auth ---------- */
.gate { display: grid; place-items: center; min-height: 100vh; padding: 20px; }
.auth { width: 340px; display: flex; flex-direction: column; gap: 12px; animation: rise 320ms var(--ease-out) both; }
@keyframes rise { from { opacity: 0; transform: translateY(10px); } }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 24px; }
.auth h1 { margin: 0; font-size: 20px; }
/* The separator belongs to the seat note rather than to the line it sits on: an ordinary sign-in
   says nothing about seats, and a bullet with nothing after it reads as a line that failed to
   render. */
#seats:not(:empty)::before { content: ' · '; }

/* ---------- top bar ---------- */
header#top {
  /* flex-wrap because #top is already a non-wrapping row holding a brand, a 260px search, three
     selects, a checkbox and four buttons — the project switcher is what pushes it over. */
  display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 14px;
  border-bottom: 1px solid var(--line); background: var(--panel); position: sticky; top: 0; z-index: 5;
}
/* `flex-basis: 100%` is what puts the filters on a line of their own inside #top: nine controls
   sharing the header's flow interleaved with the view switcher and the identity cluster, so the
   row rearranged itself into a different order at every window width. A full-basis flex item
   always wraps, at every width, which makes the two rows a layout rather than a coincidence. */
.filters {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  flex: 1 0 100%; padding-top: 2px;
}
/* Grows twice as fast as anything beside it: the selects say what they hold at any width, and the
   search box is the only control here whose own text gets clipped when it loses the argument. */
#q { flex: 2 1 220px; min-width: 150px; max-width: 320px; }
/* Capped, so one epic titled with a whole sentence cannot set the width of a control in a row of
   nine. Native selects clip rather than ellipsise, which is why the label is truncated in JS too. */
.filters select { max-width: 180px; }
.filters select:disabled { opacity: .45; cursor: not-allowed; }
#boardSort { max-width: 215px; }
#boardSort.active { border-color: var(--border-selected); }
/* Present only while it has something to undo, and coloured like the state it is reporting: this
   is the one control in the row that says the board you are looking at is not the whole board. */
.clearF { border-color: var(--accent2); color: var(--accent); background: transparent; white-space: nowrap; }
.clearF::before { content: "✕ "; }
.clearF:hover { background: var(--accent2); color: var(--theme-fg); border-color: var(--accent2); }
/* A button that reads as a sentence, for the one in the meter that offers the same escape. */
.linkish {
  background: none; border: none; padding: 0; font-size: 12px; color: var(--accent);
  text-decoration: underline; text-underline-offset: 2px;
}
.linkish:hover { color: var(--fg); }
#fProject { font-weight: 600; }
.spacer { flex: 1 1 40px; }
/* margin-left:auto so that when the row does wrap, the identity cluster still sits on the right
   rather than starting a new row hard against the left edge. */
.me { display: flex; align-items: center; gap: 8px; margin-left: auto; }
.iconBtn { min-width: 34px; font-size: 17px; line-height: 1; }
.iconBtn.busy { color: var(--accent); animation: refreshSpin 700ms linear infinite; }
.iconBtn.busy:not(:disabled) { cursor: progress; }
.iconBtn:disabled { opacity: .65; cursor: progress; }
@keyframes refreshSpin { to { transform: rotate(360deg); } }

/* ---------- global ticket finder ---------- */
#findBtn.locating { color: var(--find); border-color: var(--find-ring); background: var(--theme-warnTint); }
.finder {
  inset: calc(var(--topH, 104px) + 10px) auto auto 50%;
  width: min(620px, calc(100vw - 24px)); max-height: min(68vh, 620px);
  margin: 0; padding: 0; overflow: hidden; color: var(--fg); background: var(--panel);
  border: 1px solid var(--find-ring); border-radius: 10px; box-shadow: 0 18px 55px var(--theme-scrim);
  transform: translateX(-50%);
}
.finder::backdrop { background: var(--theme-shadow); }
.finderInput { display: flex; align-items: center; gap: 9px; padding: 10px; border-bottom: 1px solid var(--line); }
.finderInput > span { color: var(--find); font-size: 22px; line-height: 1; }
.finderInput input { flex: 1; min-width: 0; font-size: 16px; border-color: transparent; background: transparent; }
.finderClose { display: none; }
.finderInput input:focus { border-color: var(--find-ring); }
kbd {
  color: var(--muted); background: var(--panel2); border: 1px solid var(--line);
  border-bottom-width: 2px; border-radius: 4px; padding: 1px 6px; font: 11px ui-monospace, monospace;
}
.finderStatus { padding: 8px 12px; border-bottom: 1px solid var(--line); }
.findResults { max-height: min(50vh, 470px); overflow-y: auto; padding: 6px; }
.findResult {
  width: 100%; display: grid; grid-template-columns: auto auto minmax(0, 1fr) auto;
  grid-template-areas: "type key title state" ". why why why";
  align-items: center; gap: 2px 8px; padding: 9px 10px; text-align: left;
  border-color: transparent; background: transparent;
}
.findResult:hover, .findResult.active { background: var(--panel2); border-color: var(--line); }
.findResult.active { outline: 2px solid var(--find-ring); outline-offset: -2px; }
.findResult .type { grid-area: type; }
.findResult .k { grid-area: key; }
.findResult .t { grid-area: title; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.findResult .st { grid-area: state; }
.findResult .why {
  grid-area: why; color: var(--muted); font-size: 11px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.findPinned {
  display: flex; align-items: center; gap: 10px; padding: 8px 10px;
  border-top: 1px solid var(--line); background: var(--theme-warnTint);
}
.findPinned span { flex: 1; }
.findPinned b { color: var(--find); font-family: ui-monospace, monospace; }
.findPinned button { padding-block: 4px; white-space: nowrap; }

/* ---------- board ---------- */
.board { display: flex; gap: 10px; padding: 14px; overflow-x: auto; align-items: flex-start; min-height: calc(100vh - 60px); }
.col {
  flex: 1 0 var(--board-column-width, 220px); min-width: 0; background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--radius); display: flex; flex-direction: column; max-height: calc(100vh - 90px);
  outline: 2px dashed transparent; outline-offset: -2px;
  transition: background-color 160ms ease, outline-color 160ms ease;
  contain: layout paint;
}
.col > h2 { margin: 0; padding: 10px 12px; font-size: 12px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; }
.col.over { outline-color: var(--accent); background: var(--theme-hover); }
.col .stack { padding: 8px; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }

.cardItem {
  background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: 6px; padding: 9px 10px; cursor: grab;
  transition: transform 140ms var(--ease-out), border-color 140ms ease, background-color 140ms ease, opacity 140ms ease;
  contain: layout paint style;
}
/* Touch devices fire hover on tap, so a lift there would stick after every open.

   Three sides, not four: the left edge is carrying the priority, and `border-color` is shorthand
   for all four — so hovering a `highest` card used to repaint its red edge grey, losing the one
   signal the edge exists to give at exactly the moment the reader is pointing at it. Same fix on
   .mini, .treeRow and .node, which all had it. */
@media (hover: hover) and (pointer: fine) {
  .cardItem:hover { border-block-color: var(--muted); border-inline-end-color: var(--muted); transform: translateY(-1px); }
}
.cardItem.dragging { opacity: .4; transform: scale(.98); cursor: grabbing; }
.board.sorted .cardItem { cursor: default; }
/* Where did it land? The board re-sorts under you on drop; this says "there". */
.cardItem.landed { animation: landed 800ms var(--ease-out); }
@keyframes landed {
  from { box-shadow: inset 0 0 0 2px var(--accent); }
  to { box-shadow: inset 0 0 0 2px transparent; }
}
.cardItem .t { overflow-wrap: anywhere; display: block; margin: 4px 0 6px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.cardItem .meta { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; font-size: 11px; color: var(--muted); }
.cardItem[data-p="highest"], .cardItem[data-p="high"] { border-left-color: var(--bad); }
.cardItem[data-p="medium"] { border-left-color: var(--warn); }
.cardItem[data-p="low"], .cardItem[data-p="lowest"] { border-left-color: var(--muted); }

.k { font-family: ui-monospace, monospace; font-size: 11px; color: var(--accent); text-decoration: none; white-space: nowrap; flex: none; }
.chip { border: 1px solid var(--line); border-radius: 99px; padding: 1px 7px; font-size: 11px; background: var(--panel); }
.chip.epic { color: var(--purple); border-color: var(--theme-purpleTint); }
.chip.blocked { color: var(--bad); border-color: var(--theme-badTint); }
.chip.pr { color: var(--ok); border-color: var(--theme-goodTint); }
.chip.agent { color: var(--purple); border-color: var(--theme-purpleTint); text-transform: uppercase; letter-spacing: .06em; font-size: 10px; }
/* A live lease reads as "someone is on this"; a stale one as "you may take it". */
.chip.claimed { color: var(--accent); border-color: var(--theme-line); background: var(--theme-panel); }
.chip.claimed.stale { color: var(--warn); border-color: var(--theme-warnTint); background: var(--theme-warnTint); }
/* Nobody has moved this card in a week. Same amber as an expired lease, because they are the
   same news — something claims to be under way and nothing is happening. */
.chip.stale { color: var(--warn); border-color: var(--theme-warnTint); }
.avatar { width: 20px; height: 20px; border-radius: 99px; background: var(--accent2); color: var(--theme-fg); display: grid; place-items: center; font-size: 10px; }
.type { font-size: 13px; }

/* ---------- views + readiness meter ---------- */
/* One segmented control rather than a button that cycles: with three views, "press it twice to
   get back" is something you work out, not something you know. */
.views { display: flex; }
.views button { border-radius: 0; border-right-width: 0; white-space: nowrap; }
.views button:first-child { border-radius: 6px 0 0 6px; }
.views button:last-child { border-radius: 0 6px 6px 0; border-right-width: 1px; }
.views button.on { background: var(--accent2); border-color: var(--accent2); color: var(--theme-fg); }
.views button.on:hover { background: var(--accent); }

.meter { display: flex; flex-direction: column; gap: 6px; padding: 10px 14px 0; }
.meter .bar {
  display: flex; height: 22px; border-radius: 99px; overflow: hidden;
  border: 1px solid var(--line); background: var(--panel2);
}
.meter .seg {
  border: none; border-radius: 0; padding: 0; min-width: 0; flex-basis: 0;
  font-size: 11px; font-weight: 600; color: var(--theme-bg); cursor: pointer;
  /* The width IS the number, so it animates: a slice that jumps has to be re-read, a slice that
     grows tells you which way the board moved without you reading anything. */
  transition: flex-grow 420ms var(--ease-drawer), filter 140ms ease, box-shadow 140ms ease;
}
.meter .seg.empty { flex-grow: 0 !important; }
.meter .seg.ready { background: var(--ok); }
.meter .seg.active { background: var(--warn); }
.meter .seg.held { background: var(--muted); }
.meter .seg.blocked { background: var(--bad); }
/* Every slice is a filter now, so every slice reacts to the pointer — the two that were not used
   to sit under a `:not([data-avail=""])` guard, which is the sort of selector that outlives the
   reason for it. */
.meter .seg:hover { filter: brightness(1.18); }
/* Which slice is driving the board right now. Dimming the others rather than outlining the
   chosen one: an outline on a 22px bar is a hairline you have to go looking for. */
.meter .bar.filtered .seg:not(.picked) { opacity: .3; }
.meter .seg.picked { box-shadow: inset 0 0 0 2px var(--bg); }
.meter .keys { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; font-size: 11px; color: var(--muted); }
.meter .keys > span:first-child { display: flex; flex-wrap: wrap; gap: 4px 14px; }
.meter .keys select { font-size: 12px; padding: 3px 7px; }
.meter .mk { display: flex; align-items: center; gap: 5px; }
.meter .mk i { width: 8px; height: 8px; border-radius: 99px; background: var(--muted); }
.meter .mk.ready i { background: var(--ok); }
.meter .mk.active i { background: var(--warn); }
.meter .mk.blocked i { background: var(--bad); }
.chip.unblocks { color: var(--ok); border-color: var(--theme-goodTint); }

/* ---------- dependency canvas ---------- */
/* An infinite board, not a table. Auto-arrange opens it in waves — wave 1 is the work nothing is
   in front of — and then it is yours: pan, zoom, drag a box anywhere. --topH and --meterH are
   measured rather than assumed, because both rows wrap on a narrow window. */
.depsView {
  position: fixed; inset: calc(var(--topH, 53px) + var(--meterH, 0px)) 0 0 0;
  display: flex; flex-direction: column;
}
.depsBar {
  display: flex; align-items: center; gap: 10px; padding: 8px 14px;
  border-bottom: 1px solid var(--line); background: var(--panel);
}
.depsBar button { padding: 4px 10px; font-size: 12px; }
.depsCanvas {
  position: relative; flex: 1; overflow: hidden; cursor: grab;
  /* A faint grid is what tells you the surface moves at all — without it a pan of an empty area
     looks like nothing happened. */
  background-image: radial-gradient(circle at 1px 1px, var(--theme-hover) 1px, transparent 0);
  background-size: 26px 26px;
  touch-action: none;
  contain: layout paint style;
}
.depsCanvas.panning { cursor: grabbing; }
.depsWorld { position: absolute; top: 0; left: 0; transform-origin: 0 0; will-change: transform; }

.node {
  position: absolute; top: 0; left: 0; width: 224px; padding: 8px 10px 9px;
  background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: 8px; cursor: grab; user-select: none;
  box-shadow: 0 2px 10px var(--theme-scrim);
  transition: border-color 140ms ease, box-shadow 140ms ease, opacity 140ms ease;
}
.node.dragging { cursor: grabbing; box-shadow: 0 12px 30px var(--theme-scrim); border-color: var(--accent); z-index: 3; }
.node[data-p="highest"], .node[data-p="high"] { border-left-color: var(--bad); }
.node[data-p="medium"] { border-left-color: var(--warn); }
.node[data-p="low"], .node[data-p="lowest"] { border-left-color: var(--muted); }
@media (hover: hover) and (pointer: fine) {
  .node:hover { border-block-color: var(--muted); border-inline-end-color: var(--muted); }
}
.node .nHead { display: flex; align-items: center; gap: 6px; font-size: 11px; }
.node .k { font-family: ui-monospace, monospace; font-size: 11px; color: var(--accent); }
.node .dot { width: 8px; height: 8px; border-radius: 99px; background: var(--muted); flex: none; }
.node .dot[data-s="todo"] { background: var(--accent); }
.node .dot[data-s="in_progress"] { background: var(--warn); }
.node .dot[data-s="blocked"] { background: var(--bad); }
.node .dot[data-s="under_review"] { background: var(--purple); }
.node .dot[data-s="done"] { background: var(--ok); }
.node .nTitle {
  margin-top: 3px; font-size: 13px; line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
/* The three facts a box used to make you open the ticket for. All single-line and clipped: a node
   that grows to fit a long epic title re-flows the layout you were in the middle of reading. */
.node .nPts {
  font-size: 10px; font-weight: 700; line-height: 1; padding: 2px 5px; border-radius: 99px;
  background: var(--line); color: var(--fg); flex: none;
}
.node .nWho {
  font-size: 9px; font-weight: 700; letter-spacing: .02em; line-height: 1;
  padding: 3px 4px; border-radius: 99px; background: var(--accent2); color: var(--theme-fg); flex: none;
}
.node .nHeld { font-size: 10px; flex: none; }
.node .nEpic {
  margin-top: 4px; font-size: 11px; color: var(--purple);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node .nFoot { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 11px; }
.node .nFoot b { font-weight: 600; }
.node .nFoot .b { color: var(--bad); }
.node .nFoot .u { color: var(--ok); }
.node .nFoot .r { color: var(--purple); }
.node .nFoot .s { color: var(--warn); }
.node .nFoot i { color: var(--muted); font-style: normal; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Only stranded boxes get this, so it is allowed to be the loudest thing on a node: the whole
   point of that column is that these are the ones a person has to settle by hand. */
.node .nFix { margin-top: 8px; }
.node .fix {
  width: 100%; padding: 4px 6px; font-size: 11px;
  color: var(--ok); border-color: var(--theme-goodTint); background: transparent;
}
.node .fix:hover { background: var(--theme-goodTint); border-color: var(--ok); }
.node.hot { border-color: var(--accent); }

.colLabel {
  position: absolute; top: 0; left: 0; width: 224px; display: flex; gap: 8px; align-items: baseline;
  font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
  padding-bottom: 6px; pointer-events: none;
}
.colLabel span { color: var(--fg); opacity: .5; }
.colLabel.ready { color: var(--ok); }
.colLabel.tangle { color: var(--bad); }
.colLabel.stranded { color: var(--warn); }

.wires { position: absolute; top: 0; left: 0; overflow: visible; pointer-events: none; }
.wires path { fill: none; transition: opacity 140ms ease; }
.wires .wire { stroke: var(--theme-edge); stroke-width: 1.5; }
.wires marker path { fill: var(--theme-muted); stroke: none; }
.wires .wire.bad { stroke: var(--theme-bad); }

/* The particles. A solid pipe with lit dashes running along it, blocker → ticket that is waiting:
   a static line between two boxes cannot say which of the two is stuck, and that is the only
   question this view exists to answer. Slow and dim at rest so sixty of them are texture rather
   than a fairground; fast and bright on the chain you are pointing at. */
.wires .flow {
  stroke: var(--theme-muted); stroke-width: 2.2; stroke-linecap: round;
  stroke-dasharray: 3 25; opacity: .75;
  animation: wireFlow 2.6s linear infinite;
}
.wires .flow.bad { stroke: var(--bad); }
@keyframes wireFlow { to { stroke-dashoffset: -28; } }

.wires .wire.hot { stroke: var(--accent); stroke-width: 2.5; }
.wires .flow.hot { stroke: var(--theme-fg); opacity: 1; animation-duration: 1.1s; }
/* Hovering a box dims everything not on its chain. On a crowded board this is the difference
   between "there are arrows" and "these six tickets are the chain". */
.depsCanvas.focused .node:not(.hot) { opacity: .18; }
.depsCanvas.focused .wires path:not(.hot) { opacity: .06; }

.depsKey {
  position: absolute; left: 12px; bottom: 12px; display: flex; align-items: center; gap: 14px;
  font-size: 11px; color: var(--muted); pointer-events: none;
  background: color-mix(in srgb,var(--theme-panel) 80%,transparent); border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px;
  backdrop-filter: blur(6px);
}
.depsKey span { display: flex; align-items: center; gap: 6px; }
.depsKey b { color: var(--fg); font-weight: 500; }
/* The legend line animates with the same particles as the graph, so the key is a live sample
   rather than a description of one. */
.depsKey .wireKey {
  width: 34px; height: 2px; background: var(--theme-edge); border-radius: 2px; position: relative;
  overflow: hidden;
}
.depsKey .wireKey::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent 0 40%, var(--theme-fg) 45% 55%, transparent 60% 100%);
  animation: keyFlow 1.6s linear infinite;
}
@keyframes keyFlow { from { transform: translateX(-100%); } to { transform: translateX(100%); } }

.zoomKit {
  position: absolute; right: 12px; bottom: 12px; display: flex; align-items: center; gap: 2px;
  background: color-mix(in srgb,var(--theme-panel) 90%,transparent); border: 1px solid var(--line); border-radius: 8px; padding: 3px;
  backdrop-filter: blur(6px); font-size: 11px; color: var(--muted);
}
.zoomKit button { border: none; background: none; padding: 2px 8px; font-size: 14px; line-height: 1; }
.zoomKit span { min-width: 38px; text-align: center; }
.depsEmpty { position: absolute; inset: 0; display: grid; align-content: center; justify-items: start; max-width: 620px; padding: 0 32px; }
.depsEmpty h3 { text-transform: none; letter-spacing: 0; font-size: 15px; color: var(--fg); }

/* ---------- dependency editing ---------- */
.verdict {
  display: flex; gap: 8px; align-items: center; flex-wrap: wrap; align-self: flex-start;
  padding: 8px 12px; border-radius: 8px; border: 1px solid var(--line);
  background: var(--panel2); font-size: 13px;
}
.verdict.good { color: var(--ok); border-color: var(--theme-goodTint); background: var(--theme-goodTint); }
.verdict.bad { color: var(--bad); border-color: var(--theme-badTint); background: var(--theme-badTint); }
.linkBlock { display: flex; flex-direction: column; gap: 14px; }
.linkGroup h4 {
  margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .05em; display: flex; gap: 10px; align-items: baseline;
}
.linkGroup h4 span { text-transform: none; letter-spacing: 0; font-weight: 400; }
.linkAdd { display: flex; gap: 6px; margin-top: 6px; }
.linkAdd input { font-family: ui-monospace, monospace; }
/* Same rule as the copy-link button: revealed on hover, never hidden from a keyboard or a tap.
   Sized as a real target — it sits inside a row that is itself clickable, so a 12px glyph means
   every near miss opens the ticket you were trying to unlink from. */
.unlink {
  background: none; border: none; padding: 2px 6px; border-radius: 5px; line-height: 1;
  color: var(--muted); opacity: 0; transition: opacity 140ms ease, background-color 140ms ease;
}
.mini:hover .unlink, .unlink:focus-visible { opacity: 1; }
.unlink:hover { color: var(--bad); background: var(--theme-badTint); }
@media (hover: none) { .unlink { opacity: .7; } }

/* ---------- map ---------- */
/* --topH is measured, not assumed: #top wraps to a second row on a narrow window or with the
   project switcher shown, and a hardcoded inset would slice the top off the graph. */
.mapView { position: fixed; inset: var(--topH, 53px) 0 0 0; overflow: hidden; contain: layout paint style; }
#mapCanvas, #mapFlowCanvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
#mapCanvas { z-index: 1; cursor: grab; touch-action: none; }
#mapFlowCanvas { z-index: 0; pointer-events: none; }
#mapCanvas:active { cursor: grabbing; }
.mapPanel {
  position: absolute; top: 12px; right: 12px; width: 250px; z-index: 3;
  display: flex; flex-direction: column; gap: 9px; padding: 12px;
  background: color-mix(in srgb,var(--theme-panel) 90%,transparent); border: 1px solid var(--line); border-radius: 10px;
  backdrop-filter: blur(6px);
}
.mapPanel .row { gap: 8px; }
.mapPanel label { flex: 1; }
.mapPanel select { width: 100%; }
.mapTip {
  position: absolute; z-index: 4; pointer-events: auto; max-width: 320px;
  background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 10px; box-shadow: 0 6px 20px var(--theme-scrim); font-size: 12px;
  transition: opacity 120ms var(--ease-out);
}
.mapTip.hidden { display: block !important; opacity: 0; visibility: hidden; pointer-events: none; }
/* The verdict line: "can I start this", before the four facts you could already read off the card. */
.mapTip .tipRow { display: flex; flex-wrap: wrap; gap: 10px; margin: 5px 0 3px; font-size: 12px; }
.mapTip .ok { color: var(--ok); }
.mapTip .bad { color: var(--bad); }
.mapTip .warn { color: var(--warn); }
.legend { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: 11px; color: var(--muted); }
.legend .lg { display: flex; align-items: center; gap: 5px; }
.legend i { width: 9px; height: 9px; border-radius: 99px; }
#mSpread { width: 100%; accent-color: var(--accent); }

/* What the LINES mean. The nodes had a legend and the edges did not, which is how a map of four
   different relationships read as one undifferentiated web. The `blocks` sample carries the same
   particles as the graph, so the key is a live sample rather than a description of one. */
.edgeKey {
  display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: var(--muted);
  border-top: 1px solid var(--line); padding-top: 8px;
}
.edgeKey span { display: flex; align-items: center; gap: 7px; }
.edgeKey b { color: var(--theme-bad); }
.edgeKey .ek { width: 26px; height: 2px; flex: none; border-radius: 2px; position: relative; overflow: hidden; }
.edgeKey .ek.blocks { background: var(--theme-bad); }
.edgeKey .ek.blocks::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent 0 40%, var(--theme-bad) 45% 55%, transparent 60% 100%);
  animation: keyFlow 1.6s linear infinite;
}
.edgeKey .ek.relates { background: repeating-linear-gradient(90deg, var(--theme-good) 0 5px, transparent 5px 11px); }
.edgeKey .ek.dup { background: repeating-linear-gradient(90deg, var(--theme-purple) 0 2px, transparent 2px 6px); }
.edgeKey .ek.tree { background: var(--theme-hover); }

/* ---------- sheets ---------- */
.sheet { width: min(860px, 94vw); max-height: 92vh; padding: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); color: var(--fg); }

/* A <dialog> flips between display:none and block, which nothing can transition — hence
   allow-discrete, and @starting-style to give the open state a "from" to come from. A modal
   keeps transform-origin: center: it is not anchored to a trigger, it owns the viewport. */
.sheet, .sheet::backdrop {
  transition: opacity 240ms var(--ease-drawer), transform 240ms var(--ease-drawer),
              overlay 240ms allow-discrete, display 240ms allow-discrete;
}
.sheet { opacity: 0; transform: scale(0.96) translateY(8px); }
.sheet[open] { opacity: 1; transform: none; }
.sheet::backdrop { background: var(--theme-scrim); opacity: 0; }
.sheet[open]::backdrop { opacity: 1; }
@starting-style {
  .sheet[open] { opacity: 0; transform: scale(0.96) translateY(8px); }
  .sheet[open]::backdrop { opacity: 0; }
}
.sheetHead { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--line); position: sticky; top: 0; background: var(--panel); }
/* These titles are essays — the header shows the first two lines, the body has all of it. */
.sheetHead h2 {
  margin: 0; font-size: 16px; flex: 1; min-width: 0;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.sheetBody { padding: 18px; overflow-y: auto; max-height: calc(92vh - 130px); display: flex; flex-direction: column; gap: 14px; }
.sheetFoot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--line); }
.x { background: none; border: none; font-size: 16px; }
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.grid2 .full { grid-column: 1 / -1; }
.row { display: flex; gap: 8px; }
.plain { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
.plain li { display: flex; gap: 8px; align-items: center; border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; }
h3 { margin: 6px 0 0; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }

/* ---------- tree ----------
   Scrolls in the normal document flow rather than being pinned like the two canvases: this is a
   long list, and a fixed pane with its own scrollbar inside a page that also scrolls is two
   scrollbars answering the same gesture. --d is the depth, so indentation is data rather than
   three near-identical rules. */
.treeView { padding: 0 14px 24px; }
.treeBar {
  display: flex; align-items: center; gap: 10px; padding: 10px 0;
  position: sticky; top: var(--topH, 53px); background: var(--bg); z-index: 2;
}
.treeBar button { padding: 4px 10px; font-size: 12px; }
.treeBody { display: flex; flex-direction: column; gap: 4px; }
.treeRow {
  display: flex; align-items: center; gap: 8px; font-size: 13px;
  padding: 7px 10px; margin-left: calc(var(--d, 0) * 26px);
  background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: 6px; cursor: pointer;
  transition: border-color 140ms ease, transform 140ms var(--ease-out);
  contain: layout paint style;
}
.treeRow[data-p="highest"], .treeRow[data-p="high"] { border-left-color: var(--bad); }
.treeRow[data-p="medium"] { border-left-color: var(--warn); }
.treeRow[data-p="low"], .treeRow[data-p="lowest"] { border-left-color: var(--muted); }
@media (hover: hover) and (pointer: fine) {
  .treeRow:hover { border-block-color: var(--muted); border-inline-end-color: var(--muted); transform: translateX(2px); }
}
/* An epic is the heading of its group, so it is heavier than what sits under it — otherwise a
   long list of rows at three indents reads as one undifferentiated ladder. */
.treeRow.epicRow { background: var(--panel); border-left-color: var(--purple); font-weight: 600; }
.treeRow.epicRow .t { font-size: 14px; }
.treeRow.empty, .treeRow.waits { background: none; border-style: dashed; cursor: default; }
.treeRow.waits { font-size: 12px; color: var(--muted); cursor: pointer; }
.treeRow.waits .waitsFor { color: var(--bad); white-space: nowrap; }
.treeRow .t { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.treeRow .k { font-family: ui-monospace, monospace; font-size: 12px; color: var(--accent); }
.treeRow .avatar.none { background: none; border: 1px dashed var(--line); color: var(--muted); }
.treeRow .fold {
  border: none; background: none; padding: 0; width: 16px; color: var(--muted); font-size: 11px;
}
.treeRow .fold.ghost { cursor: default; }
.treeRow .fold:hover { color: var(--fg); }
.treeProg { display: flex; align-items: center; gap: 8px; flex: none; }
.treeProg .pbar {
  width: 90px; height: 6px; border-radius: 99px; background: var(--line); overflow: hidden;
}
.treeProg .pbar i { display: block; height: 100%; background: var(--ok); transition: width 420ms var(--ease-drawer); }

/* ---------- flow ---------- */
.flowView { padding: 14px; }
.flowGrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }
.flowCard {
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 12px 14px; display: flex; flex-direction: column; gap: 10px; overflow-x: auto;
  contain: layout paint style;
}
.flowCard.wide { grid-column: 1 / -1; }
/* Above the charts, not under them. This is the sentence that decides whether the numbers below
   mean anything, so it cannot be a footnote somebody scrolls past to reach the big bold median. */
.flowNote {
  margin: 0 0 12px; padding: 10px 13px; border-radius: var(--radius); font-size: 12px;
  color: var(--fg); background: var(--theme-warnTint); border: 1px solid var(--theme-warnTint); border-left: 3px solid var(--warn);
}
/* Nothing below can describe this board at all — a stronger claim, and it gets a stronger colour. */
.flowNote.hard { background: var(--theme-badTint); border-color: var(--theme-badTint); border-left-color: var(--bad); }
.flowNote b { display: block; margin-bottom: 3px; }
.bigStats { display: flex; gap: 22px; flex-wrap: wrap; }
.bigStats div { display: flex; flex-direction: column; }
.bigStats b { font-size: 26px; line-height: 1.1; }
.bigStats span { font-size: 11px; color: var(--muted); }
.warnText { color: var(--warn); }
/* Added and removed. Deletion is green too, not red: removing code is not a negative achievement,
   and a chart that paints it as damage teaches people not to do it. */
.flowChart .bAdd { fill: var(--ok); }
.flowChart .bDel { fill: var(--ok); opacity: .45; }
.keys .kAdd, .keys .kDel { width: 8px; height: 8px; border-radius: 99px; background: var(--ok); }
.keys .kDel { opacity: .45; }
/* A suggested estimate is not an estimate. Outlined rather than filled, so a glance down the
   column never mistakes the model's opinion for somebody's decision. */
.chip.pts.sugg { background: transparent; border-style: dashed; color: var(--accent); }
.flowChart .axis { stroke: var(--line); stroke-width: 1; }
.flowChart .bDone { fill: var(--accent); }
.flowChart .bPts { fill: var(--purple); }
.flowChart .bLab { fill: var(--muted); font-size: 9px; }
.mk i.kDone { background: var(--accent); }
.mk i.kPts { background: var(--purple); }
.flowBar { display: flex; height: 22px; border-radius: 99px; overflow: hidden; border: 1px solid var(--line); }
.flowBar .seg {
  display: grid; place-items: center; font-size: 10px; font-weight: 600; color: var(--theme-bg);
  background: var(--muted); min-width: 0;
}
.flowBar .seg[data-s="todo"] { background: var(--accent); }
.flowBar .seg[data-s="in_progress"] { background: var(--warn); }
.flowBar .seg[data-s="blocked"] { background: var(--bad); }
.flowBar .seg[data-s="under_review"] { background: var(--purple); }
.flowView .mk i[data-s="todo"] { background: var(--accent); }
.flowView .mk i[data-s="in_progress"] { background: var(--warn); }
.flowView .mk i[data-s="blocked"] { background: var(--bad); }
.flowView .mk i[data-s="under_review"] { background: var(--purple); }
.flowView .keys { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 14px; font-size: 11px; color: var(--muted); }
.flowView .mk { display: flex; align-items: center; gap: 5px; }
.flowView .mk i { width: 8px; height: 8px; border-radius: 99px; background: var(--muted); }
.flowDur { font-family: ui-monospace, monospace; font-size: 12px; min-width: 46px; text-align: right; }
.pad { padding: 10px 2px; }

/* ---------- linked issues ---------- */
/* A linked ticket is a card, not a key: type, status, priority, board and assignee are all
   things you would otherwise have to open it to find out. */
.minis { display: flex; flex-direction: column; gap: 6px; }
.mini {
  display: flex; align-items: center; gap: 8px; padding: 7px 10px; font-size: 13px;
  background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: 6px; cursor: pointer;
  transition: transform 140ms var(--ease-out), border-color 140ms ease, opacity 140ms ease;
}
.mini[data-p="highest"], .mini[data-p="high"] { border-left-color: var(--bad); }
.mini[data-p="medium"] { border-left-color: var(--warn); }
.mini[data-p="low"], .mini[data-p="lowest"] { border-left-color: var(--muted); }
@media (hover: hover) and (pointer: fine) {
  .mini:hover { border-block-color: var(--muted); border-inline-end-color: var(--muted); transform: translateX(2px); }
}
.mini.dragging { opacity: .4; }
.mini .t { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mini .grip { color: var(--line); cursor: grab; letter-spacing: -2px; }
.mini:hover .grip { color: var(--muted); }
.mini .avatar.none { background: none; border: 1px dashed var(--line); color: var(--muted); }
.chip.pri { text-transform: capitalize; }
/* The estimate. Monospace digits so a column of them lines up, and the absent one is drawn as a
   dashed outline rather than left blank — "nobody has estimated this" is information, and a gap
   where a number should be reads as a rendering bug. */
.chip.pts {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 600;
  min-width: 24px; text-align: center; padding: 1px 6px;
}
.chip.pts.none { color: var(--muted); border-style: dashed; }
.chip.rework { color: var(--purple); border-color: var(--theme-purpleTint); }
.chip.warnChip { color: var(--warn); border-color: var(--theme-warnTint); }
.chip.st { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
.chip.st::before { content: ''; width: 7px; height: 7px; border-radius: 99px; background: var(--muted); }
.chip.st[data-s="todo"]::before { background: var(--accent); }
.chip.st[data-s="in_progress"]::before { background: var(--warn); }
.chip.st[data-s="blocked"]::before { background: var(--bad); }
.chip.st[data-s="under_review"]::before { background: var(--purple); }
.chip.st[data-s="done"]::before { background: var(--ok); }
.sheetHead .back { font-size: 17px; margin-right: -4px; }
/* Jira's copy-link, on the title. Hidden until you go looking, but never hidden from a keyboard
   or from a touch device, where there is no hover to reveal it with. */
.sheetHead .copy { font-size: 14px; opacity: 0; transition: opacity 140ms ease; }
.sheetHead:hover .copy, .sheetHead .copy:focus-visible { opacity: 1; }
@media (hover: none) { .sheetHead .copy { opacity: .7; } }
.chip.trail { color: var(--muted); white-space: nowrap; }

/* ---------- detail ---------- */
.md { line-height: 1.65; overflow-wrap: anywhere; }
.md h1, .md h2, .md h3 { margin: 1em 0 .4em; }
.md code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.md pre { background: var(--panel2); padding: 12px; border-radius: 6px; overflow-x: auto; }
.md pre code { background: none; padding: 0; }
.md img { max-width: 100%; border-radius: 6px; }
.md blockquote { margin: 0; padding-left: 12px; border-left: 3px solid var(--line); color: var(--muted); }
.md table { border-collapse: collapse; }
.md td, .md th { border: 1px solid var(--line); padding: 4px 8px; }
.facts { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.facts .chip { max-width: 46ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.comment { border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; background: var(--panel2); }
.comment .who { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
.detailDisclosure { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.contentViewport { position: relative; }
.contentDisclosure:not(.is-expanded) .contentBody {
  max-height: 240px;
  overflow: hidden;
}
.contentDisclosure.is-collapsible:not(.is-expanded) .contentViewport::after {
  content: '';
  position: absolute;
  inset: auto 0 0;
  height: 72px;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent, var(--panel));
}
.commentDisclosure.is-collapsible:not(.is-expanded) .commentViewport::after {
  background: linear-gradient(to bottom, transparent, var(--panel2));
}
.disclosureToggle {
  align-self: flex-start;
  min-height: 44px;
  max-width: 100%;
  padding: 8px 10px;
  color: var(--accent);
  background: transparent;
  border-color: transparent;
  font-size: 12px;
  white-space: normal;
  text-align: left;
}
.disclosureToggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
#detail [data-detail-extra][hidden] { display: none!important; }
.childrenDisclosure > p { margin: 0; }
.attachmentList { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; }
.ticketAttachment { min-width: 0; overflow-wrap: anywhere; }
.ticketAttachment img { display: block; width: 100%; height: 160px; object-fit: contain; }
@media(max-width:700px) { .ticketAttachment img { height: 100px; } }
.disclosureToggle:hover { color: var(--fg); background: var(--panel2); border-color: var(--line); }
.commentList { display: flex; flex-direction: column; gap: 8px; }
.commentsDisclosure:not(.is-expanded) .comment:not(.is-recent) { display: none; }
/* The sheet is a flex column with a 14px gap; a section that owns a heading plus a body needs its
   own, tighter one, or the heading floats the same distance from its content as from the section
   above it. */
.sec { display: flex; flex-direction: column; gap: 8px; }
.sec > h3 { display: flex; align-items: baseline; gap: 8px; }
/* A count or an aside inside an uppercased heading is a sentence, and sentences are not shouted. */
.sec > h3 .small { text-transform: none; letter-spacing: 0; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
/* An empty state is a sentence, not a shrug: it says what would fill the space and how to make
   that happen. Boxed so it reads as "nothing here yet" rather than as a failed render. */
.emptyNote {
  margin: 0; font-size: 12px; line-height: 1.6; color: var(--muted);
  border: 1px dashed var(--line); border-radius: 8px; padding: 10px 12px;
}
.emptyNote code { background: var(--panel2); padding: 1px 5px; border-radius: 4px; }

/* ---------- github ---------- */
.ghBlock { display: flex; flex-direction: column; gap: 12px; }
.ghGroup h4 {
  margin: 0 0 6px; font-size: 12px; font-weight: 600; color: var(--muted);
  text-transform: uppercase; letter-spacing: .05em; display: flex; gap: 8px; align-items: baseline;
}
.ghGroup h4 span { text-transform: none; letter-spacing: 0; font-weight: 400; }
/* A container, not a viewport query: what decides whether the title fits is the width of the row,
   and the sheet is `min(860px, 94vw)` — so the phone and a narrow desktop window are the same
   problem and get the same answer. */
.ghRows { display: flex; flex-direction: column; gap: 4px; container-type: inline-size; }
/* The whole row is the target. It used to be the eight-character ref alone, which on a touch
   screen is a miss more often than a hit. The 3px left edge carries the kind, so branch, commit
   and PR are told apart before a word is read — the same device the cards use for priority. */
.ghRow {
  display: flex; align-items: center; gap: 8px; min-width: 0;
  padding: 7px 10px; font-size: 13px; color: var(--fg); text-decoration: none;
  background: var(--panel2); border: 1px solid var(--line); border-left: 3px solid var(--line);
  border-radius: 6px;
  transition: transform 140ms var(--ease-out), border-color 140ms ease, background-color 140ms ease;
}
a.ghRow { cursor: pointer; }
.ghRow[data-kind="pr"] { border-left-color: var(--purple); }
.ghRow[data-kind="branch"] { border-left-color: var(--accent); }
.ghRow[data-kind="commit"] { border-left-color: var(--muted); }
/* An open PR is the one row here that is still live work, so it is the one that is coloured. */
.ghRow[data-state="open"] { border-left-color: var(--ok); }
@media (hover: hover) and (pointer: fine) {
  a.ghRow:hover { border-block-color: var(--muted); border-inline-end-color: var(--muted); background: var(--theme-panel); transform: translateX(2px); }
}
.ghRef { color: var(--accent); white-space: nowrap; flex: none; font-size: 12px; }
div.ghRow .ghRef { color: var(--fg); }
/* Truncation belongs to the layout, not to the data: slicing at 70 characters cut mid-word with
   nothing to say it had been cut, and cut the same length whether the sheet was 380px or 860px. */
.ghTitle { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ghMeta { display: inline-flex; align-items: center; gap: 8px; flex: none; margin-left: auto; }
.ghWhen { color: var(--muted); font-size: 11px; white-space: nowrap; }
.chip.repo { color: var(--muted); font-size: 10px; }
/* Below this the single line stops being a line: the title, which is the only part a reader
   cannot reconstruct from the others, was down to "Round…" while the repo and the age kept their
   full width. So the title takes the second line whole, and keeps two lines of it. */
@container (max-width: 440px) {
  .ghRow { flex-wrap: wrap; row-gap: 4px; }
  .ghMeta { order: 2; }
  .ghTitle {
    order: 3; flex-basis: 100%; white-space: normal; line-height: 1.45;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
  }
  .ghTitle:empty { display: none; }
}
/* Merged, closed and open are three different pieces of news and used to be the same grey word.
   GitHub's own colours, because this is the one place a reader arrives with an expectation. */
.chip.gh { flex: none; text-transform: capitalize; }
.chip.gh[data-state="open"] { color: var(--ok); border-color: var(--theme-goodTint); background: var(--theme-goodTint); }
.chip.gh[data-state="merged"] { color: var(--purple); border-color: var(--theme-purpleTint); background: var(--theme-purpleTint); }
.chip.gh[data-state="closed"] { color: var(--bad); border-color: var(--theme-badTint); background: var(--theme-badTint); }

/* ---------- history ---------- */
/* A rail per day, not a border per row: the old per-row 2px segments broke at every gap, which
   read as a dashed line nobody had meant to draw. Grouping by day is also what lets the rows
   drop the date entirely and say "3 days ago" instead. */
.evtList { display: flex; flex-direction: column; gap: 14px; }
.historyDisclosure:not(.is-expanded) .evtDay:not(.has-recent),
.historyDisclosure:not(.is-expanded) .evt:not(.is-recent) { display: none; }
.evtDay { display: flex; flex-direction: column; gap: 6px; }
.evtDay > h4 {
  margin: 0; font-size: 11px; font-weight: 600; text-transform: uppercase;
  letter-spacing: .05em; color: var(--muted);
}
.evtRail { display: flex; flex-direction: column; gap: 8px; padding-left: 16px; border-left: 1px solid var(--line); }
.evt { font-size: 12px; position: relative; }
.evt::before {
  content: ''; position: absolute; left: -19.5px; top: 5px;
  width: 7px; height: 7px; border-radius: 99px;
  background: var(--panel); border: 1px solid var(--muted); box-sizing: border-box;
}
/* Same palette as the status dots on the cards, so "this one is a move" needs no legend. */
.evt[data-c="made"]::before { background: var(--ok); border-color: var(--ok); }
.evt[data-c="moved"]::before { background: var(--accent); border-color: var(--accent); }
.evt[data-c="said"]::before { background: var(--purple); border-color: var(--purple); }
.evt[data-c="stuck"]::before { background: var(--bad); border-color: var(--bad); }
.evt[data-c="gh"]::before { background: var(--muted); border-color: var(--muted); }
.evtHead { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.evt .who { color: var(--fg); font-weight: 600; }
.evt .verb { color: var(--muted); }
.evt .when { color: var(--muted); margin-left: auto; white-space: nowrap; font-size: 11px; }
/* Two lines, then an ellipsis the reader can see — a body edit is a paragraph, and the old
   140-character slice ended mid-sentence with no sign that anything was missing. */
.evtDetail {
  color: var(--muted); margin-top: 3px; overflow-wrap: anywhere;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.evtDetail .now { color: var(--fg); }
.evtDetail .arrow { color: var(--muted); padding: 0 4px; }
/* A non-breaking space, so a wrap cannot strand the separator alone at the end of a line. */
.evtDetail .why::before { content: '·\00a0'; }
.evtDetail .chip.st { font-size: 11px; vertical-align: 1px; }
.drop { border: 1px dashed var(--line); border-radius: 8px; padding: 14px; text-align: center; color: var(--muted); }
.drop.over { border-color: var(--accent); color: var(--accent); }
pre#tokOut, pre#invOut {
  background: var(--panel2); padding: 10px; border-radius: 6px; overflow-x: auto; user-select: all;
}

/* ---------- bulk triage + shortcuts ---------- */
.cardItem .pick { width: 13px; height: 13px; margin: 0 2px 0 0; accent-color: var(--accent); opacity: 0; }
.cardItem:hover .pick, .cardItem.picked .pick { opacity: 1; }
.cardItem.picked { border-color: var(--accent); background: var(--theme-hover); }
/* Centred by margin, not by transform, so the transform is free to carry the motion — and so
   reduced-motion can drop it without the bar sliding off to the left. */
.bulkBar {
  position: fixed; bottom: 16px; left: 0; right: 0; margin-inline: auto; width: max-content;
  z-index: 20; display: flex; gap: 8px; align-items: center; padding: 10px 14px;
  background: var(--panel); border: 1px solid var(--accent); border-radius: 10px;
  box-shadow: 0 8px 28px var(--theme-scrim);
  opacity: 0; transform: translateY(140%); pointer-events: none;
  transition: opacity 260ms var(--ease-drawer), transform 260ms var(--ease-drawer);
}
.bulkBar.show { opacity: 1; transform: none; pointer-events: auto; }
.help {
  position: fixed; bottom: 16px; right: 16px; z-index: 20; font-size: 12px; color: var(--muted);
  background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px;
}
.help b { color: var(--fg); font-family: ui-monospace, monospace; }

/* ---------- feedback: toast + load bar ---------- */
/* `bottom: auto` and `margin-block: 0` undo the UA's `[popover] { inset: 0; margin: auto }`, which
   would otherwise centre this vertically in the viewport instead of hanging it under the header. */
.toast {
  position: fixed; top: 62px; bottom: auto; left: 0; right: 0;
  margin-inline: auto; margin-block: 0; width: max-content;
  max-width: 80vw; padding: 9px 14px; font-size: 13px; overflow: visible;
  background: var(--panel); border: 1px solid var(--line); border-radius: 999px;
  color: var(--fg); box-shadow: 0 8px 28px var(--theme-scrim);
  opacity: 0; transform: translateY(-140%); pointer-events: none;
  /* `display` has to be in the transition for the exit to be visible at all: a popover that is not
     open is display:none, which without allow-discrete cuts the fade off at frame one. */
  transition: opacity 260ms var(--ease-drawer), transform 260ms var(--ease-drawer),
              display 260ms allow-discrete;
}
.toast:popover-open { opacity: 1; transform: none; }
/* The state to animate FROM. Without it the element is simply painted at its open state the
   moment display flips, and there is nothing for the transition to interpolate. */
@starting-style { .toast:popover-open { opacity: 0; transform: translateY(-140%); } }
.toast.bad { border-color: var(--bad); color: var(--bad); }
/* Something came free rather than something went wrong — the two must not read alike at a glance. */
.toast.good { border-color: var(--ok); color: var(--ok); }
/* And something got in the way: news, not a failure, so it borrows neither of the two above. */
.toast.warn { border-color: var(--warn); color: var(--warn); }

/* ---------- notifications ---------- */
/* The count rides ON the bell rather than beside it, so the header does not reflow between
   "nothing waiting" and "three things waiting" and shove the whole identity cluster sideways. */
#bellBtn { position: relative; }
#bellBtn.has { border-color: var(--accent); }
.badge {
  position: absolute; top: -6px; right: -6px; min-width: 16px; height: 16px; padding: 0 4px;
  border-radius: 99px; background: var(--bad); color: var(--theme-fg);
  font-size: 10px; line-height: 16px; text-align: center;
}
.bell {
  position: fixed; top: 56px; right: 14px; left: auto; bottom: auto; margin: 0;
  width: min(380px, calc(100vw - 28px)); max-height: 70vh; overflow-y: auto;
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius);
  color: var(--fg); box-shadow: 0 12px 32px var(--theme-scrim); padding: 0;
  opacity: 0; transform: translateY(-6px) scale(.98);
  transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out),
              display 180ms allow-discrete;
}
.bell:popover-open { opacity: 1; transform: none; }
@starting-style { .bell:popover-open { opacity: 0; transform: translateY(-6px) scale(.98); } }
.bell > header {
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
  padding: 10px 12px; border-bottom: 1px solid var(--line);
  position: sticky; top: 0; background: var(--panel); font-size: 12px;
  text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
}
/* Not `.plain`, deliberately: `.plain li` sets display:flex at a higher specificity than any
   single class can, so a grid row here would compute correctly and lay out as one flex line. */
#bellList { list-style: none; margin: 0; padding: 0; }
.notif {
  display: grid; grid-template-columns: auto 1fr; gap: 2px 8px;
  padding: 9px 12px; border-bottom: 1px solid var(--line); cursor: pointer;
}
.notif:hover { background: var(--panel2); }
/* Both cells are placed explicitly. Auto-placement packs a `grid-column: 2` item into whatever
   row still has space, so the detail line landed BESIDE the label on any row short enough to
   leave room and underneath it on the rest — one list, two layouts. */
.notif .k { grid-area: 1 / 1; }
.notif .what { grid-area: 1 / 2; font-size: 13px; }
.notif .muted { grid-area: 2 / 2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Unread is a bar on the edge, not a background wash: read and unread sit in one list and the
   difference has to survive being two rows apart. */
.notif.unread { border-left: 3px solid var(--accent); padding-left: 9px; }
.pad { padding: 14px 12px; display: block; }

/* Creeps to 70% while the request is out, then completes. Only ever shown if the wait is long
   enough to notice, so a fast board never flickers a bar at you. */
#load {
  position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 40;
  background: var(--accent); transform-origin: 0 50%; transform: scaleX(0); opacity: 0;
  transition: transform 600ms var(--ease-out), opacity 180ms ease;
}
#load.on { opacity: 1; transform: scaleX(.7); }
#load.done { opacity: 0; transform: scaleX(1); transition: transform 160ms var(--ease-out), opacity 160ms ease; }

/* ---------- zen: the graph gets the whole window ---------- */
/* The complaint this answers is "everything is cluttered", and the honest first fix for a crowded
   graph is not smaller nodes — it is more room. The filters and the meter are board furniture;
   inside a graph you are navigating, not filtering, so they go away entirely and come back on Esc.
   `inset: 0` rather than zeroing --topH: the ResizeObserver owns that variable as an inline style
   and would win the specificity fight on the next resize. */
body.zen #top, body.zen #meter, body.zen .bulkBar { display: none !important; }
body.zen .mapView, body.zen .depsView { inset: 0; }
.zenBtn { font-size: 13px; line-height: 1; }
/* The way out is the way in, lit up. A separate floating "press Esc" pill was the obvious idea
   and it landed straight on top of the toolbar it was trying not to disturb; the toast that fires
   on entry says the same thing once, where nothing else lives. */
body.zen .zenBtn { background: var(--accent2); border-color: var(--accent2); color: var(--theme-fg); }

/* ---------- search, made visible ---------- */
/* Two treatments, because a search answers two different questions and they are not the same fact.
   The WASH marks the text that matched, wherever it turns up — a title, a key, a line of a comment
   in the sheet. The RING marks a whole ticket, and only when the query names it outright.

   The ring earns its place because `q` matches keys, titles, bodies AND comments: pasting K-42
   returns K-42 itself alongside every ticket whose comments argue about it, and in a column of
   cards the one you actually asked for looked exactly like the ones that merely mention it.

   Last in the file on purpose. `.hit` shares its specificity with `.landed`, `.hot` and
   `.dragging`, all of which decorate the same four elements, and source order is what decides a
   tie — the search is the thing the reader is looking for right now, so it wins. */

mark {
  background: var(--find-wash); color: inherit;
  /* Negative margin against the padding: the wash needs room to breathe around a two-letter match
     without nudging the rest of the line, and a title that reflows as you type is unreadable. */
  border-radius: 3px; padding: 0 2px; margin: 0 -2px;
  box-shadow: inset 0 -1.5px 0 var(--find-ring);
}

/* An outline rather than a border or a box-shadow, which is what makes this rule safe to apply to
   four elements that already use both: the left border is carrying priority on every one of them,
   `.node` has a drop shadow it would lose, and neither is a fact this may overwrite. Outline also
   costs no layout, so a card does not resize when the query starts matching it. */
.cardItem.hit, .mini.hit, .treeRow.hit, .node.hit {
  outline: 2px solid var(--find-ring);
  outline-offset: -1px;
  animation: found 700ms var(--ease-out);
}
/* The ring arrives from outside and settles onto the edge — a movement inward, which reads as
   "this one" rather than as a card changing state. It fades to the resting ring rather than to
   nothing: the animation ends, and the answer has to still be on screen after it does. */
@keyframes found {
  from { outline-color: var(--find); outline-offset: 4px; }
}

/* The box the highlight came from, in the highlight's own colour, so the two are one thought. Only
   while it holds something — `:placeholder-shown` is the search being empty, and an amber box over
   an empty search is a filter that is not applied. */
#q:not(:placeholder-shown) { border-color: var(--find-ring); }

/* ---------- motion, opted out ---------- */
/* Reduced motion means gentler, not none: the fades stay because they explain what appeared. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    transition-duration: 120ms !important;
  }
  /* Every row that slides on hover belongs here, not just the board card: the colour change is
     the whole affordance, and the 2px travel is the part that was opted out of. */
  .sheet, .sheet[open], .bulkBar, .toast, .cardItem, .cardItem:hover,
  .mini:hover, .treeRow:hover, a.ghRow:hover, button:active { transform: none !important; }
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
  /* The particles are the only thing here that never stops, so they are the one thing that has to
     stop completely. The arrowheads still carry the direction. */
  .wires .flow, .depsKey .wireKey::after { animation: none !important; }
  .wires .flow { opacity: 0; }
}

/* ---------- board ⇄ map ---------- */
::view-transition-old(root), ::view-transition-new(root) { animation-duration: 180ms; }

/* Finder fallbacks for views whose normal data intentionally omits some tickets. */
.treeLocated, .flowLocated {
  border: 1px solid var(--find-ring); border-radius: 8px; background: var(--theme-warnTint);
}
.treeLocated { margin: 10px 0 14px; padding: 8px; }
.treeLocated .treeRow { margin-top: 6px; }
.treeLocatedHint, .flowLocatedHint { display: block; color: var(--muted); font-size: 11px; }
.flowLocated {
  margin: 14px 14px 0; padding: 9px 10px; display: flex; flex-direction: column; gap: 7px;
}
.flowLocated .mini { background: var(--panel); }
.locatedFallback, .mapLocated {
  display: none; position: absolute; z-index: 4; max-width: min(460px, calc(100% - 28px));
  padding: 9px 12px; text-align: left; color: var(--fg); background: var(--theme-warnTint);
  border-color: var(--find-ring); box-shadow: 0 8px 28px var(--theme-scrim);
  outline: 3px solid var(--find); outline-offset: 2px;
}
.locatedFallback.show, .mapLocated.show { display: block; animation: located 900ms var(--ease-out); }
.locatedFallback { top: 14px; left: 14px; }
.mapLocated { top: 14px; left: 14px; }

/* A finder choice persists across filters and view changes. It is stronger than the transient
   `.hit` ring from the header search, and remains visible after that search box is cleared. */
.cardItem.located, .mini.located, .treeRow.located, .node.located {
  outline: 3px solid var(--find);
  outline-offset: 2px;
  animation: located 900ms var(--ease-out);
}
@keyframes located {
  from { outline-offset: 9px; box-shadow: 0 0 0 8px var(--find-wash); }
}
@media (prefers-reduced-motion: reduce) {
  .iconBtn.busy { animation: none !important; }
  .cardItem.located, .mini.located, .treeRow.located, .node.located { outline-offset: 2px; }
}
