/* Roadmap aggregator — theme-aware, no dependencies. */
/* Parent identity — warm "map-paper" light, warm "ink" dark; trail-orange accent
   (#cf551b/#e2622a), forest-green as the go/ready semantic. Chrome uses --mono. */

/* Self-hosted typefaces (woff2, latin subset, font-display: swap):
   Geist + Geist Mono (OFL-1.1) — one designed pair for the sans and the mono, and the
   first choice on every platform including Apple. The board used to let Apple's San
   Francisco win and fall back to Switzer elsewhere, which meant the same board wore two
   faces depending on who opened it — and neither of them was related to the mono beside
   it. Chrome here is heavily mono (counts, keys, eyebrows, dates), so a sans and a mono
   drawn to the same skeleton is worth more than a native-feeling sans that never matches.
   Single variable files covering 100–900, so no weight is ever synthesised, and a real
   italic for the sans — markdown emphasis in a puck body is drawn, not slanted.
   No italic *mono*: the only way to reach one is inline code that happens to contain a
   pair of asterisks, since `mdInline` applies emphasis after it has wrapped `code`. That
   is a renderer quirk rather than something anyone writes on purpose, and an oblique is
   a fine answer to it — 23 kB is not. */
@font-face { font-family: "Geist"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("fonts/geist.woff2") format("woff2"); }
@font-face { font-family: "Geist"; font-style: italic; font-weight: 100 900; font-display: swap; src: url("fonts/geist-italic.woff2") format("woff2"); }
@font-face { font-family: "Geist Mono"; font-style: normal; font-weight: 100 900; font-display: swap; src: url("fonts/geist-mono.woff2") format("woff2"); }

/* Every colour that carries *words* clears 4.5:1 against the darkest surface it can
   land on — in light that is --sidebar-bg, in dark it is --panel-2. The rule is not
   a preference: --ink-3 alone sets the column counts, the card dates, the tag pills,
   the property keys, every empty-value state, the breadcrumbs, the eyebrows and the
   resting tabs, and it sat at 2.4-3.1:1 in both themes. That is most of the
   interface's second voice, at 10-13px, below the readable floor — which is why the
   light board looked washed beside a screenshot of Linear and the fix was never
   layout. The semantic six carry words too (a status pill, a drift flag, `.disc-*`),
   so they move by the same rule rather than getting text-only twins: the swatch is
   the same token at 10px, and a dot that clears 4.5 clears the 3:1 a mark needs with
   room to spare. It cost the light --later its gold — #b8944a read at 2.6:1, and
   bronze is what an amber that can be read looks like on warm paper. --done and
   --cancelled keep their old order (done the more present of the two, in both
   themes) because the line-through can't be the only thing separating them. */
:root {
  --bg: #f5f3ee;
  --panel: #fffefb;
  --panel-2: #ebe8e1;
  --sidebar-bg: #e9e6df;
  --ink: #262320;
  --ink-2: #504a44;
  --ink-3: #6e665d;
  --line: #e6e2d9;
  --accent: #87414b;
  --shadow: 0 1px 2px rgba(45, 38, 30, 0.07), 0 2px 8px rgba(45, 38, 30, 0.05);
  --radius: 12px; /* legacy alias for --r-lg; kept so nothing outside breaks */
  /* Two scales, because sixteen radii and fourteen type sizes were not scales —
     they were sixteen and fourteen decisions. A step exists here only if two
     different things genuinely need to differ; everything else rounds to the
     nearest one. The halves (10.5 / 11.5 / 12.5) were nudges, not steps. */
  --r-xs: 4px;    /* marks on a 10-13px shape: swatches, the urgent chip, code */
  --r-sm: 6px;    /* badges and menu rows */
  --r-md: 8px;    /* controls: buttons, fields, chips, popovers */
  --r-lg: 12px;   /* panels and cards */
  --r-pill: 999px;
  /* The page's content line. Five different numbers lived here — 20 in the topbar
     and view-header, 22 in the puck page, 24 in the chip row, the board and the
     footer — so "line the sheet up with the page" had no answer to give. One token,
     and a surface can inherit it instead of guessing which of the five to match. */
  --pad: 22px;
  /* What a sheet's row-highlight leaves untouched at the screen edge. The row's text
     stays on `--pad`; only the lit background reaches past it. */
  --sheet-gap: 8px;
  --fs-xs: 10px;  /* badges, eyebrows, counters */
  --fs-sm: 11px;  /* the meta line under a card, and every count beside a label */
  --fs-base: 12px; /* dense chrome: small buttons, chips, field labels */
  --fs-md: 13px;  /* the working size: rows, controls, secondary body */
  --fs-lg: 14px;   /* body and headings inside a card or panel */
  --fs-xl: 15px;   /* the page's base size, and a sheet's — a thumb reads this */
  --fs-2xl: 16px;  /* also the floor iOS needs to not zoom a focused field */
  --fs-3xl: 18px;  /* the largest thing on the board: a puck's title */
  /* Weight — one emphasis step, not two. Switzer maps 500-800 onto a single
     Medium face, so a 700 sitting next to a 600 was a step that existed only on
     Apple's SF and collapsed to nothing everywhere else: the same emphasis with
     two answers, and which one you got depended on the machine. Three weights,
     three jobs, and `600` is the only way to say "this matters more". */
  --fw-normal: 400;    /* body, and every control that isn't in a chosen state */
  --fw-medium: 500;    /* optical weight on a large lone glyph (the + button) */
  --fw-semibold: 600;  /* the one emphasis: titles, headings, active states */
  /* Tracking — by job, not by eye. Thirteen ad-hoc values lived here, four of
     them (.1 / .11 / .12 / .13em) on the same mono eyebrow in four places, and
     two notations for the same number (.06 and 0.06). Three tokens, by job.
     Mono at normal case tracks at 0 — JetBrains Mono is wide before you touch
     it, and every mono crumb, count and id on the board already sets that way. */
  --tr-eyebrow: 0.12em; /* mono, uppercase, 10-13px: column heads, field labels */
  --tr-caps: 0.05em;    /* sans, uppercase, 10-11px: status and state pills */
  --tr-tight: -0.01em;  /* headings, 15px and up — they set close, not open */
  /* Leading — tied to what the text *is*, not to how big it is. `line-height: 1`
     stays a literal where it appears: that is a box being centred, not prose
     being set. */
  --lh-tight: 1.3;   /* titles and headings — one to three lines, set close */
  --lh-snug: 1.45;   /* dense notes, help text, code blocks */
  --lh-body: 1.5;    /* the base: UI text, rows, labels */
  --lh-prose: 1.6;   /* long markdown and the body editor */
  --topbar-h: 52px; /* fixed app-header height, shared by the topbar and sidebar header */
  /* Geist first on every platform — the point of choosing a pair is that the board reads
     the same wherever it is opened. The system stack stays behind it for the moment
     before the file lands, and for anyone who blocks web fonts. */
  --sans: "Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --now: #87414b;
  --next: #4b6f59;
  --later: #7d6431;
  --inbox: #5b6089;
  --done: #5f5a51;
  --cancelled: #6f6659;
  /* The amber, once more, for the two places it sits on a wash of *itself* — the
     drift flag and the pressed attention chip. A 12-18% tint pulls the ground toward
     the text and eats the margin the token was chosen for (4.7 down to 4.0), so those
     two need a step darker. Defined once and only here: `--later` and `--ink` are
     both redefined per theme, and the mix re-resolves with them, so this darkens in
     light and lightens in dark without a second declaration. */
  --later-ink: color-mix(in srgb, var(--later) 85%, var(--ink));
  /* Three reds with three jobs, named so they stop being literals: a destructive
     *word* (Delete), the urgent priority *mark* (now a stroke, not a fill), and the one
     solid surface that carries white text. The link red and the menu-item red were the
     same job in two shades, forty lines apart. */
  --danger: #ae453e;
  --urgent: #c42b2b;
  --danger-solid: #b91c1c;
  /* The ink that sits ON a filled brand or semantic surface — a chosen status pill,
     the primary button, a pressed label chip. (The urgent mark was on this list until it
     stopped being a character reversed out of a solid square.) It is the mirror of the
     rule above: those tokens are tuned to be *read against* the page, and in dark that
     makes them light, so white on them fails everywhere — `--later` at 2.37:1, `--next`
     at 2.78, and that was true before the palette moved. The page's own ground reads on
     all of them (5.35-7.49), and in light white still does (5.62-7.27). One token, not
     nine background twins. `.demo-ribbon` had already found this and hard-coded a dark
     ink for itself; this is that fix, generalised — and it covers the `auto` theme,
     which the bespoke one missed. `--danger-solid` is the exception and keeps white:
     it is dark in both themes, which is the whole reason it exists. */
  --on-solid: #fff;
  color-scheme: light;
}
@media (prefers-color-scheme: dark) {
  :root[data-theme="auto"] {
    --bg: #1a1815;
    --panel: #211f1b;
    --panel-2: #2a2722;
    --sidebar-bg: #141210;
    --ink: #eae6de;
    --ink-2: #b7b1a9;
    --ink-3: #958d80;
    --line: #322e28;
    --accent: #c17985;
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 12px rgba(0, 0, 0, 0.35);
    --now: #c17985;
    --next: #79a58a;
    --later: #c9a35b;
    --inbox: #8b93c0;
    --done: #a09889;
    --cancelled: #948d82;
    --danger: #d98d84;
    --urgent: #e07070;
    --danger-solid: #a83232;
    --on-solid: var(--bg);
    color-scheme: dark;
  }
}
:root[data-theme="dark"] {
  --bg: #1a1815;
  --panel: #211f1b;
  --panel-2: #2a2722;
  --sidebar-bg: #141210;
  --ink: #eae6de;
  --ink-2: #b7b1a9;
  --ink-3: #958d80;
  --line: #322e28;
  --accent: #c17985;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.45), 0 2px 12px rgba(0, 0, 0, 0.35);
  --now: #c17985;
  --next: #79a58a;
  --later: #c9a35b;
  --inbox: #8b93c0;
  --done: #a09889;
  --cancelled: #948d82;
  --danger: #d98d84;
  --urgent: #e07070;
  --danger-solid: #a83232;
  --on-solid: var(--bg);
  color-scheme: dark;
}

* { box-sizing: border-box; }
/* The browser puts `bold` (700) on h1-h6, so every heading the stylesheet didn't
   give a weight to took one by default — and took it differently per platform.
   Switzer folds 500-800 onto a single Medium face, so a UA-bold sans heading
   (the puck title, the token dialog, a markdown h2) matched its 600 neighbours
   here and outweighed them on Apple's SF; the mono column heads, on a genuinely
   variable face, simply set heavier than the card titles they rank beside. Half
   the headings were 600 on purpose and the other half 700 by accident. One
   emphasis weight, chosen — anything that wants to sit lighter says so. */
h1, h2, h3, h4, h5, h6 { font-weight: var(--fw-semibold); }
/* Form controls do not inherit type. Chromium's UA sheet *declares* `font:
   13.3333px Arial` (plus tracking and casing) on them, and a declaration on the
   element beats an inherited value — so any control that set its own size but
   never its own family rendered in Arial, beside Switzer, at the same size. That
   was not a corner: it was the brand wordmark, every repo chip in the sidebar,
   and both topbar menus — two typefaces on one screen, which is most of what
   reads as "unsettled" before you can name it. The stylesheet already knew the
   trap and had patched it twice by hand (`.linklike`, `.lh-toggle`); this is the
   same fix, once, for all of them. Family and not `font:` — the shorthand would
   also drag every control's chosen size back to the inherited one. */
button, input, select, textarea, optgroup {
  font-family: inherit; line-height: inherit; letter-spacing: inherit; text-transform: inherit;
}
/* `hidden` means hidden. The browser's own rule is `[hidden] { display: none }`,
   which any class that sets `display` outranks — so every component that lays
   itself out had to carry its own `[hidden]` exception, and seventeen of them did.
   The eighteenth (`.side-section`) never got one, and the Agents heading showed
   above an empty list. One rule, and nothing can forget it. */
[hidden] { display: none !important; }
html, body { margin: 0; }
/* Never let the browser resize text on its own.
   A mobile engine inflates *some* blocks when it decides a page is laid out wider than
   the screen — and it picks them per block, so the result is not "everything is bigger"
   but one element out of scale with the rest. Measured on a phone: a puck whose body
   held a wide code block pushed the page to 540px in a 390px screen, and its three
   section headings came back 38px tall against 22px on the puck beside it, while every
   row label on the same page was identical to the pixel.
   The width bug is fixed where it belongs (`.detail-pane`), and this is not a substitute
   for that: a page that outgrows the screen is still wrong, and the sideways scroll still
   says so. This only takes away the disfigurement, so the next such bug reads as one
   symptom instead of two. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
/* The fixed height lives here, not on `.app`, because `.app` is not always the only
   thing on the page: the config-driven ribbon is inserted as its *sibling*. With
   `height: 100dvh` on `.app` the document came out ribbon-tall plus a full viewport —
   an outer page scroll back again, the bottom of `.work` below the fold, and a page
   that could still move while a sheet locked only `.work`. As a column, the ribbon
   takes what it needs and the shell takes the rest, whatever the ribbon's height is
   and whether or not there is one. */
body {
  background: var(--bg);
  color: var(--ink);
  font: var(--fw-normal) var(--fs-xl)/var(--lh-body) var(--sans);
  -webkit-font-smoothing: antialiased;
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: 100dvh;
}

.topbar {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 16px;
  padding: 20px 24px 12px;
  flex-wrap: wrap;
}
.brand-lockup { display: flex; align-items: center; gap: 8px; }
.brand-mark { display: inline-flex; color: var(--accent); flex: none; }
.brand-glyph { width: 20px; height: 20px; }
.controls { display: flex; gap: 8px; align-items: center; }
.search-wrap { position: relative; min-width: 220px; }
#search {
  width: 100%;
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--ink);
  border-radius: var(--r-md);
  padding: 8px 34px 8px 12px;
  font-size: var(--fs-lg);
}
#search:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
/* Hide the native search clear so it doesn't double up with our own button. */
#search::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.search-clear {
  position: absolute;
  top: 50%;
  right: 7px;
  transform: translateY(-50%);
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  background: var(--panel-2);
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--ink-3);
  font-size: var(--fs-sm);
  line-height: 1;
  cursor: pointer;
}
@media (hover: hover) { .search-clear:hover { color: var(--ink); border-color: var(--accent); } }

/* ── search suggestions dropdown ── */
.suggest {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  z-index: 40;
  margin: 0;
  padding: 4px;
  list-style: none;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow);
  max-height: 320px;
  overflow-y: auto;
}
/* ── row: the one selectable line ──────────────────────────────────────────────
   A picker option, a menu item and a search suggestion were three classes with
   the same job and three sets of numbers (7/9 · r6, 7/9 · r6, 8/10 · r7). They
   are one component; where a row *sits* only decides its size, which is what the
   sheet's thumb-sized override is. */
.row {
  display: flex; align-items: center; gap: 8px; width: 100%;
  font: inherit; font-size: var(--fs-md); text-align: left; color: var(--ink);
  background: none; border: 0; border-radius: var(--r-sm); padding: 7px 9px;
  cursor: pointer; text-decoration: none; /* a row can be an <a> — see the ⋯ menu */
}
/* The lit row was a full `--panel-2` slab — the same token a filled badge uses for
   its own surface, so a selected label row swallowed its pill whole and the band
   read heavier than anything it contained. Two fixes, one idea: the highlight is a
   *wash* under the content rather than a step level with it, and the row declares
   what a chip standing on it should be. The field above already worked this way —
   `.token` sits on `--panel` inside a `--panel-2` box — so this is that move, made
   general instead of local. `--chip-bg` inherits, so a badge anywhere inside a lit
   row picks it up without knowing it is in one. */
.row:hover, .row[aria-selected="true"], .row.on {
  background: color-mix(in srgb, var(--panel-2) 60%, transparent);
  --chip-bg: var(--panel);
}
.row.danger { color: var(--danger); }
.suggest-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.suggest-title {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-lg);
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.ghost {
  background: var(--panel);
  border: 1px solid var(--line);
  color: var(--ink);
  border-radius: var(--r-md);
  padding: 8px 10px;
  cursor: pointer;
  font-size: var(--fs-xl);
}
/* Hover only on real-pointer devices: on iOS :hover sticks after a tap until you
   tap elsewhere, which read as a stuck focus/highlight on the toggle. */
@media (hover: hover) { .ghost:hover { border-color: var(--accent); } }
/* Mouse/touch taps shouldn't leave a focus ring; keyboard focus still shows one. */
.ghost:focus:not(:focus-visible) { outline: none; }

.filters {
  display: flex;
  gap: 16px;
  align-items: center;
  flex-wrap: wrap;
  padding: 4px var(--pad) 14px;
  border-bottom: 1px solid var(--line);
}
.filter-sort { display: flex; align-items: center; gap: 8px; }
.filter-sort label { color: var(--ink-3); font-size: var(--fs-base); text-transform: uppercase; letter-spacing: var(--tr-caps); }
.filter-sort select {
  background: var(--panel);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--r-md);
  padding: 6px 10px;
  font-size: var(--fs-md);
  cursor: pointer;
}
.filter-sort select:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.filter-group { display: flex; gap: 6px; flex-wrap: wrap; }
.filter-chips { flex-basis: 100%; min-width: 0; display: flex; flex-direction: column; gap: 10px; }
.ghost.active { border-color: var(--accent); color: var(--accent); }
.chip {
  border: 1px solid var(--line);
  background: var(--panel);
  color: var(--ink-2);
  border-radius: var(--r-pill);
  padding: 4px 11px;
  font-size: var(--fs-md);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  white-space: nowrap;
  flex: none; /* keep natural width — never shrink and wrap the label */
  user-select: none;
}
.chip .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.chip[aria-pressed="true"] { color: var(--ink); border-color: currentColor; }
.chip.repo[aria-pressed="true"] { background: var(--panel-2); }
.chip.tag[aria-pressed="true"] { background: var(--accent); color: var(--on-solid); border-color: var(--accent); }
.chip .n { color: var(--ink-3); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-2); font-size: var(--fs-md); cursor: pointer; }

.board {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: minmax(280px, 1fr);
  gap: 16px;
  /* No padding at the top, and that is about the pinned head rather than about spacing.
     A sticky box pins against the scrollport's *content* box, so the board's own 18px
     gutter was a band above the head that belonged to neither: the cards scrolled up
     through it and a strip of the one behind stood above `PIA 6` — measured at 390px,
     `elementFromPoint` in that band answering `card` with the head pinned 18px lower
     than the port's edge. It is the list's leak (two frozen cells with a 14px gap
     between them) one layout over, and the same repair: give the space to the box that
     travels. On the column it scrolls away with the column's own first screen. */
  padding: 0 var(--pad) 40px;
  overflow-x: auto;
  /* Lika höga banor, inte innehållshöga kolumner. En sticky-box kan bara färdas inuti
     sitt containing block, så en kort kolumn tar sin egen rubrik med sig ut ur rutan när
     den tar slut: rapporterat från en telefon som "Now försvinner upp", och mätt — vid
     scroll 380 stod `Now` på 106 med portens kant på 113, alltså avklippt mitt i ordet,
     medan grannarna stod kvar. `start` gav varje kolumn dess egen höjd, vilket är rätt
     för en lista av kort och fel för en bana man läser tvärs över. Stretchade är alla
     banor den längstas höjd, ingen rubrik kan tryckas ut, och en tom kolumns släppyta
     blir hela banan i stället för fyrtio pixlar. */
  align-items: stretch;
}
.column { min-width: 0; border-radius: var(--r-lg); padding-top: 18px; }
/* drag-to-restatus */
.card[draggable="true"] { cursor: grab; }
.card.dragging { opacity: .5; }
.column.drop-target { background: color-mix(in srgb, var(--accent) 8%, transparent); outline: 1.5px dashed color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: -2px; }
/* ── btn: the pressable rectangle ──────────────────────────────────────────────
   Seven classes carried seven sets of numbers for one family. One base with what
   they share; each name keeps only its difference, and `.btn` is what new code
   reaches for. Size is a modifier, never a parent selector — except where the
   parent *is* the reason (a phone wants thumb-sized controls), which is the same
   rule `.sheet .row` follows. */
.btn, .tbtn, .newbtn, .iconbtn, .date-act, .focusbtn, .edit-btn, .col-add {
  display: inline-flex; align-items: center; gap: 6px;
  font: inherit; font-size: var(--fs-md); line-height: var(--lh-tight);
  padding: 6px 12px; border-radius: var(--r-md);
  border: 1px solid var(--line); background: var(--panel); color: var(--ink);
  cursor: pointer; white-space: nowrap;
}
.btn--sm, .date-act, .edit-btn { font-size: var(--fs-base); padding: 5px 10px; }
.btn--lg, .tbtn { padding: 7px 14px; }
.btn--ghost, .date-act, .col-add { background: none; color: var(--ink-2); }
.btn--quiet, .focusbtn { background: none; border-color: transparent; color: var(--ink-2); }
.btn--pill, .edit-btn { border-radius: var(--r-pill); }
.btn--dashed, .col-add { border-style: dashed; color: var(--ink-3); }
/* square: an icon carries the meaning, so there is no label to pad around */
.btn--icon { padding: 5px; }

/* The hidden-columns tray — a column-shaped place at the end of the board holding the
   ones the filter took away. It is not a column: nothing drops into it, and it carries
   rows rather than cards, so it sets itself apart by weight — a quieter head, and rows that
   look nothing like cards — instead of by size. The count on each row is the reason it exists:
   the chip row can say *what* is hidden, never how much is behind it. */
/* The tray lines up with the columns beside it, and the alignment is now a matter of
   having nothing to align: the dashed edge is gone at the user's request, and with it the
   1px border and 10px side padding that inset everything inside it. Measured before:
   the tray's title 11px from its own left edge against a column's 18px, and its rows 11px
   in where the cards beside them start at 0 — a seven-pixel and an eleven-pixel drift,
   both of them paid for by a box that no longer exists. What is left is `align-self:
   start` (a list of four rows should not be column-tall) — `.column`'s own `padding-top`
   and `border-radius` say the rest, which is the point: the tray is a column.
   It sets itself apart by its head and its rows now, not by a line around it. */
.hidden-cols { align-self: start; }
/* The head's height is set by the tallest thing it may hold — the `⋯` button at 27px — so a
   head carrying none is 7px shorter, and its centred title rides up with it. Stated so every
   head is the same height whatever controls it happens to have, which is what puts the
   titles on one line. 41 = the button's 27 plus this rule's own `padding-block: 4px 10px`;
   `box-sizing` is border-box here, so the padding is inside the number. */
.col-head { min-height: 41px; }
.hidden-cols .col-head { position: static; background: none; color: var(--ink-3); }
/* The tray keeps the column head's *shape* without its mark: a swatch is a colour, and a
   tray of four columns has no one colour to show. Blank, it is the slot rather than the
   mark — and the slot is what puts `HIDDEN` on the same line as `NOW` and `NEXT`. Stated
   as an absent background rather than as a left padding of 18px, so the width stays owned
   by `.col-head .swatch` and the gap by the head: a padding would be those two numbers
   written down a second time, in the one place nothing would keep them in step. */
.hidden-cols .col-head .swatch { background: none; }
.hidden-cols .col-head h2 { color: var(--ink-3); }
.hidden-col { gap: 8px; font-size: var(--fs-md); }
.hidden-col .swatch { width: 10px; height: 10px; border-radius: var(--r-xs); flex: none; background: var(--ink-3); }
.hidden-col .hidden-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The count sits with the label; the eye takes the right edge, where the action is. */
.hidden-col .count { color: var(--ink-3); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.hidden-col .icn { margin-left: auto; width: 15px; height: 15px; color: var(--ink-3); }
@media (hover: hover) { .hidden-col:hover .icn { color: var(--accent); } }
/* The status swatches already have their colours; a hidden column keeps its identity. */
.hidden-col.col-status-now .swatch { background: var(--now); }
.hidden-col.col-status-next .swatch { background: var(--next); }
.hidden-col.col-status-later .swatch { background: var(--later); }
.hidden-col.col-status-inbox .swatch { background: var(--inbox); }
.hidden-col.col-status-done .swatch { background: var(--done); }
.hidden-col.col-status-cancelled .swatch { background: var(--cancelled); }

/* column ⋯ — the two things you can say about a column rather than about its pucks.
   Pushed to the right end of the head, and revealed on reach like `.col-add`: a head
   that carried a visible button per column read as six controls before it read as six
   columns. `.col-add` already sets the precedent, and `(hover: none)` keeps both
   reachable where there is no hover to reveal them. */
/* Beside the count, not out at the column's right edge. Linear can hang it on the
   edge because its columns are boxed; ours are borderless and packed close, so an
   edge-anchored ⋯ sat 60px from the *next* column's swatch and read as that one's. */
.col-more { opacity: 0; transition: opacity .12s; }
.col-more .btn { color: var(--ink-3); border-color: transparent; background: none; }
.col-more .btn svg { width: 15px; height: 15px; }
.column:hover .col-more, .col-more:focus-within, .col-more:has([aria-expanded="true"]) { opacity: 1; }
@media (hover: hover) { .col-more .btn:hover { color: var(--ink); border-color: var(--line); } }
@media (hover: none) { .col-more { opacity: 1; } }
/* in-column "+" — add a puck already in this column */
.col-add {
  /* No top margin: it sits inside `.cards`, whose own `gap` already spaces it from the last
     card. The margin dates from when it was the column's sibling and doubled the gap once
     it moved in. */
  width: 100%; display: flex; align-items: center; justify-content: center;
  padding: 7px; opacity: 0; transition: opacity .12s;
}
.column:hover .col-add, .col-add:focus-visible { opacity: 1; }
.col-add:hover { color: var(--accent); border-color: var(--accent); }
.col-add svg { width: 15px; height: 15px; }
@media (hover: none) { .col-add { opacity: 1; } }
/* Not pinned, and no longer needing to be. The head sits *outside* the box that scrolls —
   `.cards` is the scroller, the head is its sibling — so the whole apparatus a pin
   requires is gone rather than tuned: no `position: sticky`, no `top: 0` resolving against
   a port's content box, no `z-index` ranking it over the cards, and no opaque background
   bleeding 8px past the column to cover the `0 2px 8px` shadow of a card passing beneath
   it. That shadow was a real report from a phone — "a small edge/shadow behind the other
   titles" — and the repair is that nothing passes beneath any more.
   (The list's heading is `.list-head` and is still pinned: `.work` is its scrollport and
   the heading travels inside it.) */
.col-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding-block: 4px 10px;
}
.col-head .swatch { width: 10px; height: 10px; border-radius: var(--r-xs); }
/* Two kinds of head, and until parent grouping arrived there was only one. A category
   label (`NOW`, `ETAPP SITE`) is short by construction and wears the eyebrow. A head
   named after a puck carries an arbitrary title, so it keeps the puck's own casing —
   uppercase plus letter-spacing costs 15–20% of the width and legibility both — and it
   truncates, which `min-width: 0` is what allows: a flex item defaults to never being
   narrower than its content, so the title wrapped to three lines and left the count and
   the rollup badge centred against it. */
.col-head h2, .list-head h2 { min-width: 0; }
.col-head h2 { margin: 0; font-size: var(--fs-md); text-transform: uppercase; letter-spacing: var(--tr-eyebrow); }
.col-head h2:not(.named) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-head h2.named, .list-head h2.named { text-transform: none; letter-spacing: normal; }
/* The button *is* the title: no chrome of its own, and it truncates rather than the
   `h2` around it, because the text it has to cut is inside it. */
.head-open {
  font: inherit; text-transform: inherit; letter-spacing: inherit; color: inherit;
  background: none; border: 0; padding: 0; margin: 0; cursor: pointer; text-align: left;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block;
}
@media (hover: hover) { .head-open:hover { color: var(--accent); } }
.head-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--r-xs); }
.col-head .count { color: var(--ink-3); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
/* "N archived" — what the archive is holding back from *this* column, in the head of
   the column that is short of it. Shaped like the tray's row rather than like a chip:
   a quiet count and the same eye, because it is the same repair in a different place.
   Word included, not a bare number: the head already carries one count, and two
   numbers side by side read as two counts of the same kind. */
.col-archived {
  display: inline-flex; align-items: center; gap: 5px;
  background: none; border: 0; padding: 0 2px; cursor: pointer;
  color: var(--ink-3); font: inherit; font-size: var(--fs-sm);
  font-family: var(--mono); white-space: nowrap;
}
.col-archived .count { font-size: inherit; }
.col-archived .icn { width: 13px; height: 13px; }
@media (hover: hover) { .col-archived:hover { color: var(--accent); } }
.col-status-now .swatch { background: var(--now); }
.col-status-next .swatch { background: var(--next); }
.col-status-later .swatch { background: var(--later); }
.col-status-inbox .swatch { background: var(--inbox); }
.col-status-done .swatch { background: var(--done); }
.col-status-cancelled .swatch { background: var(--cancelled); }

.cards { display: flex; flex-direction: column; gap: 10px; }

/* ── list view ── a full-width table grouped by status. #board gets .as-list. */
.board.as-list {
  /* One column, sized to the widest group. Every group is stretched to it, which is what
     lets an archive stub — a heading with no rows, and therefore no width of its own —
     keep its heading pinned when the list is scrolled right. As `display: block` each
     group was only as wide as its own content, so the short ones slid off the left edge
     entirely: a sticky box cannot be held outside its own containing block. */
  display: grid;
  /* At least what the rows need, and all of the box when there is more. `min-content`,
     not `max-content`: the latter reads the longest *title*, which is how a single long
     puck name came to set the scroll width for every row in the list — and, worse, to
     widen the frozen name cell past the screen. Each row's own `min-width` is the number
     that belongs here, and that is exactly its min-content.
     `1fr` and not bare `min-content`: without it the column stopped at the content and
     left 214px of nothing to the right of the date on a 1400px screen. */
  grid-template-columns: minmax(min-content, 1fr);
  /* `.board` sets `grid-auto-flow: column` for the kanban columns; without this the
     groups laid themselves out side by side. */
  grid-auto-flow: row;
  overflow-x: visible;
  max-width: none;
  /* The board's own gutter, and a token because three other rules have to agree with it:
     the frozen cells pin *at* it (so they do not travel), and their background bleeds
     across it (so the scrolled metadata does not appear inside it). */
  --list-pad: 24px;
  padding: 12px var(--list-pad) 40px;
  /* Wide content scrolls in its own box; the page stays put. The same rule the code
     blocks and the tables already follow, and the reason the document no longer comes
     out 652px wide on a 390px phone with the topbar sized to the viewport sitting in
     60% of it. `min-width: 0` is kept as belt and braces: a scroll container's
     automatic minimum is already 0, but the declaration is what makes that independent
     of whichever overflow value this rule ends up carrying. */
  /* No scroller of its own: `.work` is the one scrollport, and that is what buys the
     heading its vertical stickiness back — a box that scrolls sideways is a scroll
     container in both axes, and the heading was sticking to a port that never moved. */
  overflow-x: visible;
  min-width: 0;
  /* Not for queries any more — the tiers are gone — but `cqw` in `.lh-inner` needs a
     container to measure against, and "the board's width" is exactly the number it
     wants. */
  container-type: inline-size;
}
.list-group { margin-bottom: 20px; }
/* The heading is two boxes. The outer one spans the group so its background reaches
   across the scrolled width; the inner one is only as wide as its content, which is what
   lets `left: 0` pin it — a sticky box cannot be offset inside a containing block it
   fills completely, and a full-width heading in a scrolled row simply slid away to the
   left (measured: −376px at scrollLeft 400).
   `100cqw` caps the inner box at the *board's* width, so a long parent name still
   ellipsises instead of widening every row's scroll range. That is the one thing
   `container-type` is still here for; the tiers it used to serve are gone. */
/* One sticky box per axis, nested, because the two axes want opposite shapes: a sticky
   box can only travel *inside* its containing block, so vertical stickiness needs an
   element shorter than its container (the heading inside the tall group) and horizontal
   stickiness one narrower than its container (the inner box inside the full-width
   heading). Measured with only the inner one sticky: it pinned sideways and slid straight
   off the top, −469px after scrolling 600. */
.list-head {
  position: sticky;
  top: 0;
  /* A token for the same reason the row's are: `.lh-inner` pins at the gutter *plus*
     this, so the heading does not travel either. */
  --head-pad: 2px;
  padding: 6px var(--head-pad) 8px;
  background: var(--bg);
  /* Above the rows' own frozen cells (z-index 2). Same level and later in the document
     meant the scrolled row's title painted over the pinned heading — two things claiming
     the same 30 pixels at the top of the port. */
  z-index: 4;
}
.lh-inner {
  display: flex;
  align-items: center;
  gap: 8px;
  position: sticky;
  /* Sideways only — the outer `.list-head` carries the vertical axis. The old
     `top: var(--topbar-h)` arithmetic went with the page scroll: the topbar sits above
     the scrollport now rather than over it. Sideways the offset is the heading's own
     resting position — the board's gutter plus this box's padding — so it freezes
     without travelling; `left: 0` slid it 26px first. */
  left: calc(var(--list-pad) + var(--head-pad));
  width: max-content;
  max-width: 100cqw;
  background: var(--bg);
  z-index: 3;
}
.list-head .swatch { width: 10px; height: 10px; border-radius: var(--r-xs); }
.list-head h2 { margin: 0; font-size: var(--fs-md); text-transform: uppercase; letter-spacing: var(--tr-eyebrow); }
.lh-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* A heading with two controls is a flex row of its own. The `!important`-free way to
   keep the split invisible: the toggle drops its right bleed so the gap between caret
   and name is the head's own 8px, not 8 plus the toggle's padding. */
.list-head h2 { display: flex; align-items: center; gap: 8px; min-width: 0; }
/* **Only the name gives.** Everything else in a group heading is a fixed mark — the
   swatch, the count, the archive control, the rollup badge — and a flex item with a width
   is still shrinkable, so a long parent name pressed against the heading's `100cqw` cap
   squeezed them instead of ellipsising itself: measured at 393px, the swatch went from
   10px to **0** (the colour dot simply gone) and the count from 16px to 7, clipping the
   digit. The shrinkable boxes are named — and they are every box *on the way down to the
   name*, which is the part the first version got wrong. It named the `h2` and the name
   itself and stopped; between them sit `.lh-stub` and `.lh-toggle`, children of the `h2`
   and therefore caught by the blanket rule. Rigid, they kept their content width inside a
   shrunk parent and simply overflowed it: measured on live data at 390px, a stub 359px
   wide inside a 246px `h2`, its name running **89px** under the archive mark. The
   stylesheet already carried a comment saying this exact overlap had been fixed once — it
   had, one box further in, and this rule re-broke it from one box further out. Each
   exception is written more specifically than the blanket rule, since source order is the
   only thing deciding them. */
.list-head .lh-inner > *, .list-head h2 > *, .list-head .lh-toggle > *, .list-head .lh-stub > * { flex: none; }
.list-head .lh-inner > h2 { flex: 0 1 auto; min-width: 0; }
/* A box may give only if the name is *inside* it. `.lh-toggle--split` is the toggle of a
   heading whose name is its own sibling button, so it holds nothing but the caret and the
   swatch — letting it shrink squeezed it to 28px around 39px of content and pushed the
   swatch out of its own box onto the first letter of the title (measured: swatch at
   47–57, name starting at 46). Which is the blanket rule's own point, one level in. */
.list-head h2 > .lh-stub, .list-head h2 > .lh-toggle:not(.lh-toggle--split) { flex: 0 1 auto; min-width: 0; }
.list-head .lh-label { flex: 0 1 auto; min-width: 0; }
.lh-toggle--split { margin-right: 0; padding-right: 0; }
.list-head .head-open { font: inherit; text-transform: inherit; letter-spacing: inherit; }
.list-head .count { color: var(--ink-3); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
/* The whole heading is the fold control, so it carries the row's own spacing and
   reaches a little past the content line — a header you can click should look like
   one target, not like a label with a hit area drawn around the words. */
/* The shape both list headings wear. The stub — what a fully archived group leaves
   behind — used to *reuse* `.lh-toggle` and then override it from further up the file,
   which is a fight it loses: `cursor: default` and its padding both sat above the rule
   they were correcting, so neither ever applied. The stub kept the pointer, took the
   full hover background, and sat 24px to the left of every real heading, while a
   comment above it claimed the opposite. Splitting the shape from the behaviour is what
   makes the claim checkable: a span cannot pick up an interaction it is not given. */
.lh-toggle, .lh-stub {
  display: inline-flex; align-items: center; gap: 8px;
  /* Both are flex items of the heading, and both hold the label that is supposed to
     ellipsize. Without this the shrink stops at the item and the name runs under the
     archive mark — which is what a fully archived parent with a long title did on a
     phone, the two texts overlapping in the same 30 pixels. */
  min-width: 0;
  font: inherit; color: inherit; background: none; border: 0;
  margin: -4px -8px; padding: 4px 8px; border-radius: var(--r-sm);
  /* `font: inherit` does not cover these two, and the UA sheet *declares* them on
     `button` — a declaration on the element beats an inherited value, so the heading
     lost its caps and its tracking the moment the label moved inside a control. Same
     trap `.linklike` fell into with the browser's own 13.33px button type. */
  text-transform: inherit; letter-spacing: inherit;
}
/* Only the control behaves like one. */
.lh-toggle { cursor: pointer; }
/* Bakom guarden, som elva andra regler i den här filen — och de här fyra hade missat den.
   På en telefon finns ingen pekare som lämnar: `:hover` fastnar efter ett tapp och står
   kvar tills man rör något annat. För de flesta hover-reglerna spelar det ingen roll (de
   sitter i ytor som stängs av tappet, eller flyttar bara en textfärg), men listans egna
   fäll- och arkivkontroller *står kvar under fingret* och målar en bakgrund — så det syns
   som en mörk ruta runt precis det man nyss tryckte på. Rapporterat från en telefon, en
   bild per kontroll. Mätt med `(hover: hover)` falskt och framtvingad `:hover`:
   `.lh-toggle` och `.list-fold` målade `--panel-2`, medan `.list-row` — som redan hade
   guarden — inte gjorde det. */
@media (hover: hover) { .lh-toggle:hover { background: var(--panel-2); } }
/* The stub has no caret, so it pays the caret's width in padding instead and its label
   lands where every other label in the list does. There is nothing under it to expand
   until the archive toggle lifts, so it offers no cursor and no hover.
   The 13px below is the caret's *rendered* width, which is the only number that can be
   right here — a sum copied from a rule that turns out to be overridden is how the first
   version of this came to be 3px out. `tests/board.test.mjs` measures the two labels
   against each other rather than trusting the arithmetic. */
.lh-stub { cursor: default; padding-left: calc(8px + 13px + 8px); }
/* The caret is the one part that is chrome rather than content: it stays quiet until
   the header is under the pointer, the way a disclosure does everywhere else.
   Descendant selector, not `.lh-caret` alone: `.icn` sets every icon to 16px and sits
   later in this file, so a bare class ties on specificity and loses on order — these
   13px were dead for as long as the rule existed, and the caret drew at 16px while the
   stylesheet said otherwise. The caret only ever appears inside a toggle, so nothing is
   narrowed by qualifying it. */
.lh-toggle .lh-caret { width: 13px; height: 13px; color: var(--ink-3); }
@media (hover: hover) { .lh-toggle:hover .lh-caret { color: var(--ink-2); } }
/* A folded group keeps its heading and loses its bottom margin — the gap belonged to
   the rows that are no longer there. */
.list-group.shut { margin-bottom: 4px; }
/* The row keeps every column and scrolls when they do not fit. What stood here before
   was the opposite — tiers that dropped agent, then repo, as the *board* narrowed — and
   the numbers were the problem: 560/720 for a flat row, then 652/812 once the tree's
   indent turned out to shrink the row the container query could not see. Two rounds of
   arithmetic, one of them wrong, for a rule whose whole purpose was deciding what to
   throw away.
   A row that may scroll needs nobody to know what fits. The title never scrolls out from
   under you (see the frozen cells below), so the thing that was being protected is
   protected by construction instead of by a breakpoint. */
.list-row {
  display: grid;
  /* The name gets a floor and not only a ceiling: `1fr` alone collapses to nothing once
     the fixed tracks fill the box, and the title is the one thing a row is for. Wider
     than the box, the row scrolls; narrower, `min-width` keeps it filling the box so the
     borders still reach the edge. */
  /* Tokens because `--list-fixed` below sums them, and a track widened in one place and
     not the other is a row whose declared minimum is not its actual one. */
  --glyph-w: 18px;
  --row-gap: 14px;
  --row-pad: 12px;
  /* Only the two the chooser never asks about are written here. Everything after the name
     is `--list-tracks`, set on the list by `applyListTracks()` from the same walk over
     FIELDS that builds the cells — so a property that is switched off takes its column
     with it, and a track can never outlive the cell that filled it. The fallbacks are the
     shipped set, so a row rendered before the variable lands is still the old row rather
     than a two-column one. */
  grid-template-columns: var(--glyph-w) minmax(220px, 1fr) var(--list-tracks, 44px 108px 148px 92px);
  /* The minimum comes from the *columns*, never from the content. `min-width: max-content`
     read the longest title instead, and on a phone that is a disaster rather than a
     nicety: measured with one 120-character title, the name cell came out 851px wide on a
     390px screen, which set the scroll width of every row in the list from one puck's
     name. (It was worse while the cell was frozen — opaque and wider than the screen, it
     covered the metadata rather than pushing it along — and that is gone with the freeze;
     the floor stays because the row's minimum should come from its columns either way.)
     The sum is the tracks it must not squeeze: five gaps and the row's own padding, plus
     the name's floor. The title ellipsises inside that floor, which is what it has always
     been specified to do. */
  --list-fixed: calc(var(--glyph-w) + var(--list-track-px, 392px) + var(--list-gaps, 5) * var(--row-gap) + var(--row-pad) + 6px);
  min-width: calc(var(--list-fixed) + 220px);
  align-items: center;
  gap: var(--row-gap);
  padding: 8px 6px 8px var(--row-pad);
  border-bottom: 1px solid var(--line);
  cursor: pointer;
}
/* The tree's indent. Capped, because the format caps nothing: `parent:` goes as deep as
   anyone writes it, and on a 390px screen the fifth level is a staircase out of the
   viewport. Past the cap the rows stop stepping and the caret keeps saying what nests
   inside what.
   On the *name*, not on the row: it puts every row's metadata on the same x whatever its
   depth, which padding on the row could not. (It was also what kept the structure on
   screen while the name was frozen — and the indent inside a frozen cell is exactly what
   took that block to 368 of 390 pixels, which is why the freeze is gone.) */
/* The indent lives inside the name cell, so a nested row needs that much more before its
   title reaches the same floor a flat row has. The cap is what makes it a number: 16px of
   gutter plus the 64px the indent stops at. */
.is-tree .list-row { min-width: calc(var(--list-fixed) + 220px + 80px); }
.is-tree .list-name {
  position: relative; /* the caret sits in this cell's own indent, see .list-fold */
  padding-left: calc(16px + min(var(--depth, 0) * 16px, 64px));
}
.is-tree .list-empty {
  padding-left: calc(28px + min(var(--depth, 0) * 16px, 64px));
}
/* Reserved on every row in a tree, taken by only some: a caret in a grid track of its
   own would have to exist on the leaves too, to keep the five columns in register, and
   a leaf that reserves a control it never gets is a target that answers nothing. In the
   gutter, both rows put their glyph in the same place and only the parents have a
   caret. */
.list-fold {
  position: absolute;
  /* Inside the name cell now, which is what keeps it on screen when the row is scrolled.
     The indent is that cell's padding, so the caret only has to sit inside it. */
  left: calc(min(var(--depth, 0) * 16px, 64px) - 4px);
  top: 50%;
  transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 22px; height: 22px; padding: 0;
  background: none; border: 0; border-radius: var(--r-sm);
  color: var(--ink-3); cursor: pointer;
}
@media (hover: hover) { .list-fold:hover { background: var(--panel-2); color: var(--ink-2); } }
.list-fold .lh-caret { width: 13px; height: 13px; }
/* A parent whose parts the filter took. Not a row: there is nothing to open, and the
   line exists to stop the parent above it from drawing exactly like a leaf. */
.list-empty {
  display: flex; align-items: center; gap: 6px;
  width: 100%;
  padding: 6px 6px 6px 12px;
  background: none; border: 0; border-bottom: 1px solid var(--line);
  text-align: left;
  color: var(--ink-3);
  font-family: inherit;
  font-size: var(--fs-md);
  font-style: italic;
}
.list-archived { cursor: pointer; font-style: normal; }
@media (hover: hover) { .list-archived:hover { background: var(--panel-2); color: var(--ink-2); } }
.list-archived .icn { width: 13px; height: 13px; }
@media (hover: hover) { .list-row:hover { background: var(--panel-2); } }
.list-name { min-width: 0; display: flex; align-items: center; gap: 8px; }
.list-title {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-size: var(--fs-lg); font-weight: var(--fw-semibold); color: var(--ink);
}
.list-cell { display: flex; align-items: center; gap: 6px; min-width: 0; }
.list-pri { justify-content: center; }
.list-repo { color: var(--ink-2); font-size: var(--fs-md); }
.list-repo .repo-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.list-repo .repo-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
.list-dt { justify-content: flex-end; gap: 8px; }
.list-status { justify-content: flex-start; }
.list-owner { justify-content: center; }
.list-owner .owner-avatar { width: 20px; height: 20px; }
.list-tags { gap: 4px; overflow: hidden; }
/* The field's name, drawn only when a view shows more than one date — see dateEl(). Small
   and quiet: it is there so two numbers are not ambiguous, not to compete with them. */
.date-tag {
  margin-right: 4px;
  color: var(--ink-3); opacity: .8;
  font-size: var(--fs-xs); font-weight: var(--fw-semibold);
  text-transform: uppercase; letter-spacing: .04em;
}
.list-date { color: var(--ink-3); font-size: var(--fs-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
/* The columns the tiers used to drop. They are all here, always — the row is as wide as
   its columns need and the box scrolls, so nothing has to be dropped and nothing has to
   be computed. What went with them: `@container list (min-width: 560px|720px)` for flat
   rows and 652|812 for nested ones, four rules whose numbers were hand-derived twice and
   wrong once. A row that may scroll needs no one to know what fits.

   **The whole row scrolls, the title included.** Freezing the glyph and the name was the
   first answer, and the measurement retired it: on a 390px phone the frozen block was
   288px flat and 368px once the tree's indent was in it, leaving 102px and *22px* of
   window onto the metadata the scroll exists to reach. A frozen column that leaves 6% of
   the screen to scroll in is not keeping your place, it is taking the screen.

   It leaked as well, and the leak was structural rather than a missing background: two
   frozen cells with a grid gap between them are two opaque boxes and one 14px slot that
   belongs to neither, so the priority bars and agent chips slid through it and sat beside
   the glyph. A single frozen box would have fixed that and not the 22px.

   What the group heading keeps: it stays pinned in both axes. It names the group you are
   inside, one line per group rather than one per row, and it costs the rows nothing.
   A pinned box only covers its own bounds, so the heading's background bleeds across the
   gutter to its left — `background: inherit`, not a colour, so there is one place to
   change it. It is one box, so it has no gap of its own to leak through. */
.lh-inner::before {
  content: "";
  position: absolute;
  left: calc(-1 * (var(--list-pad) + var(--head-pad)));
  top: 0; bottom: 0;
  width: calc(var(--list-pad) + var(--head-pad));
  background: inherit;
}
/* The heading is frozen sideways, or it slides out to the left and the group loses its
   name at exactly the moment you are furthest from it. */

.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 12px 13px;
  box-shadow: var(--shadow);
  cursor: pointer;
  transition: border-color 0.12s ease, transform 0.06s ease;
}
@media (hover: hover) { .card:hover { border-color: var(--accent); } }
.card:active { transform: translatey(1px); }
/* The glyph's rail. The mark leads the title, so the title starts 22px in — and the
   two rows under it started at the card's padding, which made the title the one
   indented line on the card and left the mark hanging off nothing. Linear's card has
   the same mark and puts the second row's icon on the same x; this is that column,
   derived from the two numbers that draw it so they cannot drift apart. */
.card { --glyph: 15px; --rail: 7px; }
/* Direct children only: `.card-tags` is also the LABELS row on the puck page, which
   has its own rail and must not inherit this one. */
.card > .card-meta, .card > .card-tags { padding-left: calc(var(--glyph) + var(--rail)); }
.card-top { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.card h3 { margin: 0; font-size: var(--fs-xl); line-height: var(--lh-tight); font-weight: var(--fw-semibold); }
.repo-name { font-size: var(--fs-sm); color: var(--repo, var(--ink-3)); font-weight: var(--fw-semibold); white-space: nowrap; }
/* Row 2 — repo (colored dot + name) left, ⚠/date right. */
/* The repo cell is what gives, and it is the only child here that can. Every other one is a
   fixed mark — a date, a badge, a warning — and each is `nowrap` by design, so with nothing
   able to shrink the row simply grew: measured at 280px with a long organisation name and
   every property ticked, 149px of overflow in the row and the date block pushed 135px past
   the card's right edge. That used to be reachable by scrolling sideways, which was the
   two-axis bug; with `overflow-x: clip` on the column it is merely hidden, which is worse.
   Codex found the pair (#54).
   **Sabotage had to be run three times to say what bears it, and the answer is neither
   declaration alone.** A flex item's automatic minimum is its content *only while its
   `overflow` is `visible`* — so `min-width: 0` and `overflow: hidden` each lower it to zero
   by themselves, and removing either one changes nothing. Only removing both brings the
   overflow back. That is belt and braces rather than a mechanism, and it is kept for the
   reason `.board.as-list` keeps the same pair: the declaration is what makes the behaviour
   independent of whichever `overflow` this rule ends up carrying, and `overflow: hidden` is
   here for the ellipsis in any case.
   **What was removed is the one that never applied**: `min-width: 0` on `.card-meta`, where
   `.card` is a block, so the row is not a flex item and has no automatic minimum to lower.
   A declaration that cannot apply is not a guard, it is a claim. */
.card-meta { display: flex; align-items: center; gap: 8px; margin-top: 8px; font-size: var(--fs-sm); color: var(--ink-3); }
/* `inline-block`, not `inline-flex`: the card's repo cell holds one text node (the dot
   belongs to the list's `.repo-name` variant), so `align-items` and `gap` decided nothing —
   and `text-overflow` needs a block container. As a flex box it clipped mid-letter instead
   of ellipsising. */
.card-repo { display: inline-block; color: var(--ink-2); font-weight: var(--fw-semibold); white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.repo-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.card-date { margin-left: auto; font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Two or three dates, kept inside the card. `min-width: 0` is what lets the box be
   narrower than the dates it holds — a flex item defaults to its content width, which is
   how a nowrap row came to paint outside the card in the first place — and the wrap then
   has somewhere to go. The auto margin moves here from `.card-date`, so the single-date
   card is laid out by exactly the rule it always was; the inner one is cancelled, or each
   date would push the next one to a line of its own. */
.card-dates { margin-left: auto; min-width: 0; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 2px 8px; }
.card-dates .card-date { margin-left: 0; }
/* Row 3 — tags. Badges are STATIC (flat, rounded-rect, no border, default cursor);
   the round bordered pills in the filter bar (.chip) are the CLICKABLE ones. */
.card-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
/* ── badge: a small labelled mark ──────────────────────────────────────────────
   Six classes carried six sets of numbers for one family — three radii, four font
   sizes, four paddings — and two of them (`.rollup`, `.agent-badge`) were byte
   identical. One base, and each name keeps only what genuinely differs. `.badge`
   is the name new code should reach for; the six stay as aliases so the markup
   they are in does not have to move to get the alignment. */
.badge, .status-pill, .issue-state, .rollup, .agent-badge, .tagpill, .adapted-badge, .token {
  display: inline-flex; align-items: center; gap: 3px; flex: none;
  font-size: var(--fs-xs); color: var(--ink-2);
  /* `--chip-bg` is the ground a lit row hands down; --panel-2 is the resting one. */
  background: var(--chip-bg, var(--panel-2)); border: 1px solid var(--line);
  border-radius: var(--r-sm); padding: 1px 7px;
}
.badge--pill, .status-pill, .rollup, .agent-badge { border-radius: var(--r-pill); }
/* The agent handle gives, in the one place a card has to fit it. Measured at 1000px with a
   long handle and four properties ticked: the badge stood 325px wide at `flex: none` while
   `.card-repo` had already shrunk to nothing, and the row overflowed its card by 159px —
   which `overflow-x: clip` on the column then hides rather than lets you reach. Codex found
   it (#54), as the sequel to the repo name.
   Scoped to the card's row on purpose: the list gives the agent its own 108px track and
   answers this question there, and widening the rule into it would be a sweep nobody
   measured. And `flex: 0 1 auto`, not `min-width` alone: the shared badge rule says
   `flex: none`, which is `flex-shrink: 0` — a box that may not shrink does not care what
   its minimum is. Measured with only the minimum lowered: 325px, unchanged. */
.card-meta > .agent-badge { flex: 0 1 auto; min-width: 0; }
.agent-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
/* Takes its colour from the value it stands for, so the border follows the text. */
.badge--outline, .status-pill, .issue-state { background: none; border-color: currentColor; }
.badge--mono, .rollup, .agent-badge { font-family: var(--mono); }
.badge--quiet, .tagpill { border-color: transparent; }
.badge--dashed, .adapted-badge { background: none; border-style: dashed; }

.tagpill { font-size: var(--fs-sm); color: var(--ink-3); cursor: default; }
.updated { font-size: var(--fs-sm); color: var(--ink-3); font-variant-numeric: tabular-nums; margin-left: auto; }
.adapted-badge { color: var(--ink-3); padding: 0 5px; }
/* The drift flag and the blocked flag sit next to each other in the meta row and are
   now the same species: one set, one stroke, one 14px box. */
.warn-badge { color: var(--later); display: inline-flex; line-height: 0; cursor: help; flex: none; }
.warn-badge .icn { width: 14px; height: 14px; }
.block-badge { display: inline-flex; line-height: 0; cursor: help; flex: none; }
.owner { display: inline-flex; align-items: center; gap: 5px; flex: none; text-decoration: none; color: var(--ink-2); }
.owner-avatar { width: 16px; height: 16px; border-radius: 50%; flex: none; background: var(--panel-2); object-fit: cover; }
.owner-name { font-weight: var(--fw-semibold); }
@media (hover: hover) { a.owner:hover .owner-name { text-decoration: underline; } }
.card-flags { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; }
.flag {
  display: flex; align-items: flex-start; gap: 6px;
  font-size: var(--fs-sm);
  color: var(--later-ink);
  background: color-mix(in srgb, var(--later) 12%, transparent);
  border: 1px solid color-mix(in srgb, var(--later) 30%, transparent);
  border-radius: var(--r-sm);
  padding: 3px 7px;
}
/* Nudged down to the first line's x-height: the mark leads a wrapping sentence, so it
   aligns to the text's top rather than to the box's. */
.flag .icn { width: 13px; height: 13px; margin-top: 1px; }
.chip.attention { color: var(--later); border-color: color-mix(in srgb, var(--later) 40%, var(--line)); }
.chip.attention[aria-pressed="true"] {
  background: color-mix(in srgb, var(--later) 18%, var(--panel));
  color: var(--later-ink);
  border-color: var(--later);
}
.card-body { margin-top: 8px; font-size: var(--fs-md); line-height: var(--lh-snug); color: var(--ink-2); display: none; overflow-wrap: anywhere; }
.card.open .card-body { display: block; }
.card-body :is(h2, h3, h4) { font-size: var(--fs-md); margin: 12px 0 4px; color: var(--ink); }
.card-body :is(h2, h3, h4):first-child { margin-top: 0; }
.card-body p { margin: 0 0 8px; }
.card-body ul, .card-body ol { margin: 4px 0 8px; padding-left: 20px; }
.card-body ol { list-style: decimal; }
.card-body li { margin: 2px 0; }
.card-body a { color: var(--accent); }
.card-links { margin-top: 10px; display: flex; gap: 12px; font-size: var(--fs-base); }
.card-links a { color: var(--accent); text-decoration: none; }
/* `.linklike` only ever existed in three scoped variants, so one outside them —
   "Edit body" — fell back to the browser's own button type (13.33px), the single
   size on the page that came from nowhere. The base is the base. */
.linklike { font: inherit; font-size: var(--fs-md); background: none; border: 0; padding: 0; margin: 0; color: var(--accent); cursor: pointer; }
.card-links .linklike { background: none; border: 0; padding: 0; margin: 0; font: inherit; font-size: var(--fs-md); color: var(--accent); cursor: pointer; }
.card-links .linklike.danger { color: var(--danger); margin-left: auto; }
@media (hover: hover) { .card-links a:hover, .card-links .linklike:hover { text-decoration: underline; } }

/* ── detail modal ── */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(8, 11, 16, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 50;
}
.modal {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow);
  width: 100%;
  max-width: 640px;
  max-height: 85vh;
  overflow: hidden;
}
/* Absolute inside .modal (not .modal-content) so it stays put while the body
   scrolls. Round and generously sized so it reads as a real button, not a
   cramped glyph. */
.modal-close {
  position: absolute;
  top: 14px;
  right: 16px;
  width: 34px;
  height: 34px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--panel-2);
  border: 1px solid var(--line);
  color: var(--ink-2);
  border-radius: 50%;
  font-size: var(--fs-2xl);
  line-height: 1;
  cursor: pointer;
  z-index: 1;
}
.modal-close:hover { color: var(--ink); border-color: var(--accent); }
.modal-content { overflow-y: auto; padding: 22px; -webkit-overflow-scrolling: touch; }
/* Title reserves room on the right so long titles don't run under the ✕. */
.modal-title { margin: 0; padding-right: 40px; font-size: var(--fs-3xl); line-height: var(--lh-tight); letter-spacing: var(--tr-tight); }
/* Meta row mirrors the card: repo + status on the left, date pushed right. */
.modal-meta { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.modal-idrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: var(--fs-sm); color: var(--ink-3); }
.modal-dates { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: var(--fs-base); color: var(--ink-3); font-variant-numeric: tabular-nums; }
.modal-dates-sep { color: var(--ink-3); opacity: 0.6; }
.modal-blocked { margin-top: 12px; font-size: var(--fs-md); color: var(--ink-2); }
.modal-blocked .blocker-link { color: var(--accent); text-decoration: none; }
@media (hover: hover) { .modal-blocked .blocker-link:hover { text-decoration: underline; } }
.status-pill { font-size: var(--fs-sm); padding: 1px 9px; text-transform: uppercase; letter-spacing: var(--tr-caps); }
.status-pill.status-now { color: var(--now); }
.status-pill.status-next { color: var(--next); }
.status-pill.status-later { color: var(--later); }
.status-pill.status-inbox { color: var(--inbox); }
.status-pill.status-done { color: var(--done); }
.status-pill.status-cancelled { color: var(--cancelled); text-decoration: line-through; }
/* ── prose in a puck: one size, and the phone sets it ──────────────────────── */
/* 16px on a phone, 14px above it — and it is not a taste call. iOS demands 16px of
   anything focused and editable (measured: a 14px editor zoomed the page by 1.147,
   and 16/14 is 1.143), so the editor cannot go below it there. That left two ways to
   close the jump between reading a puck and editing it, and lifting the reading is
   the one that does not fight the platform: 16px is right for body text on a phone
   anyway. It is only on a desktop that 14px is.
   One pair of variables rather than a media override per element, so everything whose
   size was expressed against the block's own follows it — including `.body-editor`,
   which sits *inside* `.modal-body` and therefore cannot drift from the text it
   replaces. `--prose-sm` is the step below, for code blocks and table cells. */
.modal-body, .disc-body { --prose: var(--fs-lg); --prose-sm: var(--fs-md); }
/* Touch *or* narrow, and the touch half is the one that matters: an iPad, or a phone
   in landscape, is wider than 640px and still zooms a focused field under 16px — which
   would put the editor back in the zoom this whole rule exists to avoid, and reopen the
   jump on the one device class where reading and editing were finally the same text.
   Width alone answered for a phone in portrait and nothing else. */
@media (max-width: 640px), (pointer: coarse) {
  .modal-body, .disc-body { --prose: var(--fs-2xl); --prose-sm: var(--fs-lg); }
}
.modal-body { margin-top: 14px; font-size: var(--prose); line-height: var(--lh-prose); color: var(--ink-2); overflow-wrap: anywhere; }
/* Headings carry the body's size and are told apart by weight — so one variable does
   both, and a heading can never end up smaller than the text under it. */
.modal-body :is(h2, h3, h4) { font-size: var(--prose); margin: 16px 0 6px; color: var(--ink); }
.modal-body :is(h2, h3, h4):first-child { margin-top: 0; }
.modal-body p { margin: 0 0 10px; }
.modal-body ul, .modal-body ol { margin: 6px 0 10px; padding-left: 22px; }
.modal-body ol { list-style: decimal; }
.modal-body ol li, .modal-body ul li { margin: 2px 0; }
.modal-body li { margin: 3px 0; }
.modal-body code { background: var(--panel-2); padding: 1px 5px; border-radius: var(--r-xs); font-size: 0.92em; }
.modal-body pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 12px; margin: 0 0 10px; overflow-x: auto; }
.modal-body pre code { background: none; padding: 0; font-size: var(--prose-sm); line-height: var(--lh-snug); }
.modal-body a { color: var(--accent); }
/* Tables and blockquotes — the two block types a puck body gained. Written once for
   both markdown surfaces: Overview renders into `.modal-body`, Discussion into
   `.disc-body`, and both come out of the same `renderMd`.
   `.md-table` is a scroll container for the same reason `pre` is one: a wide table
   must scroll in its own box and never make the page wider than the phone. The
   permission that makes that work is `min-width: 0` on `.detail-pane` — already there,
   and the whole finding of `en-bred-kodrad-drog-ut-hela-puckvyn`. */
.modal-body .md-table, .disc-body .md-table { overflow-x: auto; margin: 0 0 10px; }
.modal-body table, .disc-body table { border-collapse: collapse; font-size: var(--prose-sm); }
.modal-body :is(th, td), .disc-body :is(th, td) {
  border: 1px solid var(--line); padding: 5px 9px; text-align: left; vertical-align: top;
  /* `.modal-body` breaks anywhere, which is right for prose and wrong here: a cell
     that shatters mid-word turns a table into a column of syllables rather than
     letting the table be too wide and scroll. Same call as the code block's. */
  overflow-wrap: normal;
}
.modal-body th, .disc-body th { background: var(--panel-2); color: var(--ink); font-weight: var(--fw-semibold); }
.modal-body blockquote, .disc-body blockquote {
  margin: 0 0 10px; padding: 2px 0 2px 12px;
  border-left: 2px solid var(--line); color: var(--ink-3);
}
.modal .card-links { padding-top: 4px; }
body.modal-open { overflow: hidden; }
/* Pinned rather than merely `overflow: hidden`, which iOS ignores: the body keeps
   its place at a negative offset and is put back when the last layer closes. */
/* The body-pinning lock is gone with the page scroll it was written for: the shell is a
   fixed-height column, so the body never scrolls and `.work` is locked directly (see
   `lockScroll`). The rule is kept as a no-op guard for anything that still adds the
   class — a fixed body with nothing to scroll is harmless, and its absence would be a
   silent behaviour change rather than a loud one. */
body.scroll-locked { overflow: hidden; }
/* The board's vertical scroll lives in the columns, so that is what a surface has to hold
   still. Written as a selector and not as a set of elements on purpose: the surface that
   locks is also the one that redraws the board, so any node the lock had remembered would
   be detached by the time it was unlocked. See `lockScroll`.

   The two `:not(.x)` are specificity, not logic, and they are load-bearing: the rule that
   makes a column scroll is `body:not(.viewing-puck) .port > .board:not(.as-list) > .column
   > .cards`, and a `:not()` counts as what is inside it — four classes to this rule's two.
   Without them the lock computed `overflow-y: auto` and did nothing, while the sheet's own
   scrim happened to swallow the wheel and made it look like it worked. */
body.board-locked:not(.x) .port > .board:not(.x) > .column > .cards { overflow: hidden; }
@media (max-width: 600px) {
  .modal-backdrop { padding: 0; }
  .modal { max-width: none; max-height: 100vh; height: 100%; border-radius: 0; border: none; border-left: none; }
  /* Fullscreen modal butts against the top edge — give the title real breathing
     room. Fixed value (not env safe-area, which is 0 without viewport-fit=cover)
     so the margin is deterministic; Safari already insets below the status bar. */
  .modal-content { padding-top: 52px; }
  .modal-close { top: 20px; }
  /* Chip groups become single swipeable rows so the expanded filters stay compact.
     min-width:0 is load-bearing: without it the nowrap row grows past the viewport
     and scrolls the whole page instead of scrolling inside the row. The negative
     margins let the row scroll edge-to-edge (full-bleed) instead of being clipped
     short of the screen edge by the filter bar's padding. */
  .filter-group { flex-wrap: nowrap; overflow-x: auto; min-width: 0; margin: 0 -24px; padding: 0 24px 4px; scrollbar-width: none; }
  .filter-group::-webkit-scrollbar { display: none; }
  /* Four controls (search + Filter + view + theme) overflow one mobile row, so
     let the search take a full-width row and the buttons wrap below it — nothing
     gets clipped past the edge. 16px min also kills iOS Safari's zoom-on-focus
     (it zooms any input under 16px); desktop keeps the tighter 14px. */
  .controls { flex: 1 1 100%; flex-wrap: wrap; justify-content: flex-end; }
  .search-wrap { flex: 1 1 100%; min-width: 0; }
  /* 16px kills iOS Safari's zoom-on-focus (it zooms inputs under 16px). */
  #search { font-size: var(--fs-2xl); }
}

.empty { color: var(--ink-3); font-size: var(--fs-md); padding: 8px 2px; }
/* The sidebar's foot: where the board's footer went, and what it stopped being. It was a
   horizontal row in the one place vertical space is scarce — 114px of an 844px phone,
   three wrapped lines with an orphaned "·" on the last — and it had to be pinned sideways
   (`position: sticky; left: 0`) because the port scrolls horizontally and the row is only
   port-wide: read to the right, then to the bottom, and the harvest time, `sync now` and
   every link stood parked off screen. Measured at 390px: `left: -800`.

   None of that survives the move, because the box it was true of is gone. The list's own
   footer had the same sideways fault (`left: -488`, since the day the list was written)
   and it goes with it — one repair for a defect that had two homes.

   Sticky against the bottom is `.side-brand`'s rule mirrored, and the reasoning beside
   that band applies unchanged: this sits *inside* the sidebar's own scroller, so "last"
   holds only at the end of the scroll. `margin-top: auto` puts it at the bottom while the
   sections above are short enough not to fill the column. */
.side-foot {
  margin-top: auto;
  position: sticky;
  bottom: 0;
  z-index: 2;
  background: var(--sidebar-bg);
  border-top: 1px solid var(--line);
  padding: 10px 8px 14px;
  color: var(--ink-3);
  font-size: var(--fs-sm);
  display: flex;
  flex-direction: column;
  gap: 4px;
}
/* The date is one token however many hyphens are in it — and *only* the date. The words
   around it wrap, or the whole 29-character stamp becomes unbreakable and a sidebar dragged
   to its 190px minimum gets 41px of horizontal overflow in a box that only scrolls
   vertically. See `footmeta` for the measurement. */
.fm-stamp { white-space: nowrap; }
/* The links wrap among themselves rather than stretching the band: at 240px "flat digest ·
   roadmap.json · source" is two lines, and that is the shape it should take. */
.side-foot-links { display: flex; flex-wrap: wrap; gap: 6px; }
.side-foot a { color: var(--ink-2); }
/* The band dates the harvest, so the control that refreshes it belongs in the same
   sentence — not in a menu two clicks away from the number it answers for. It is a
   button and not a link (it starts a workflow run; it does not go anywhere), but it
   wears the band's type: this row is prose, and one control shaped like a control
   would read as chrome that wandered in. The name is the board footer's, kept because
   the thing it names did not change when its home did. */
.foot-sync {
  font: inherit;
  color: var(--ink-2);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.foot-sync:hover:not(:disabled) { color: var(--ink); }
/* Disabled is the running state, and it lasts a minute or two. Dropping the underline
   as well as the colour is what stops it reading as a link you may press again. */
.foot-sync:disabled { cursor: default; color: var(--ink-3); text-decoration: none; }
/* The wrapper holds the button *and* the separator after it, so hiding one hides both
   — the alternative leaves a stray "·" on a logged-out board. It needs the link row's own
   gap repeated inside it: `.side-foot-links`' gap reaches between flex items and not into
   one, so a plain space here set this separator tighter than the three beside it.
   Safe against `hidden` only because `[hidden]` above is `!important` — a bare
   `display: flex` would otherwise beat the browser's `[hidden] { display: none }` and
   put the button back on a board with no token. */
#syncWrap { display: flex; gap: 6px; }
.banner {
  margin: 24px;
  padding: 16px 18px;
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  background: var(--panel);
  color: var(--ink-2);
}
.banner code { background: var(--panel-2); padding: 1px 5px; border-radius: var(--r-xs); }

/* ── GUI editing: token control, status editor, toast ─────────────────────
   All of this is inert until a token is set; the read-only board is unchanged. */
.iconbtn {
  font-size: var(--fs-xl); line-height: 1; padding: 6px 8px;
}
.iconbtn:hover { border-color: var(--ink-3); }
.tokenbtn.on { border-color: var(--now); box-shadow: 0 0 0 1px var(--now) inset; }

/* status editor inside the modal */
.edit-status {
  display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
  margin: 10px 0 2px; padding: 10px; border: 1px dashed var(--line);
  border-radius: var(--r-lg); background: var(--panel-2);
}
.edit-label { font-size: var(--fs-base); color: var(--ink-2); margin-right: 2px; }
.edit-btn {
  padding: 4px 10px; background: var(--panel);
}
.edit-btn:hover:not(:disabled) { color: var(--ink); border-color: currentColor; }
.edit-btn.active {
  color: var(--on-solid); cursor: default; font-weight: var(--fw-semibold);
  border-color: transparent;
}
.edit-btn.active.status-now { background: var(--now); }
.edit-btn.active.status-next { background: var(--next); }
.edit-btn.active.status-later { background: var(--later); }
.edit-btn.active.status-inbox { background: var(--inbox); }
.edit-btn.active.status-done { background: var(--done); }
.edit-btn.active.status-cancelled { background: var(--cancelled); }
/* cancelled pucks read as struck-through + faded on the board */
.col-status-cancelled .card h3, .col-status-cancelled .card-title,
.col-status-cancelled .list-title { text-decoration: line-through; }
.col-status-cancelled .card, .col-status-cancelled .list-row { opacity: .72; }

/* toast */
.toast {
  position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 20px);
  /* `width: max-content` is load-bearing, not a tidy-up. A fixed box with `left` and no
     `right` gets its shrink-to-fit width from `left` to the containing block's right
     edge — half the viewport — so `max-width` was unreachable and every long message
     wrapped inside a 50vw column that `translateX(-50%)` then centred, hiding the cause.
     On a 390px phone the sync and token errors were 195px wide and five lines tall.
     Laying out on the content instead lets max-width be the thing that decides. */
  width: max-content;
  max-width: min(34rem, calc(100vw - 32px));
  background: var(--ink); color: var(--bg); padding: 10px 16px;
  /* Not --r-pill. A pill is a shape for one line; at four it draws a lozenge whose
     curve eats into the first and last rows. These are sentences and sometimes wrap,
     so they are small panels — which is what --r-lg is for. */
  border-radius: var(--r-lg);
  font-size: var(--fs-md); box-shadow: var(--shadow); opacity: 0; pointer-events: none;
  transition: opacity .18s ease, transform .18s ease; z-index: 10000;
  /* Two even lines rather than a long one and an orphan. Ignored where unsupported. */
  text-wrap: balance;
}
.toast.show { opacity: 1; transform: translate(-50%, 0); }

/* The sync run's progress. Indeterminate on purpose: the run clones seven repos,
   harvests and deploys, and GitHub reports no fraction along the way — a bar that
   claimed a percentage would be inventing one.
   `position: fixed` and a z-index above the panels, because a 2px non-interactive
   line is exactly the kind of thing that should still be legible over a sheet; the
   footer's `syncing…` cannot, since a sheet covers it and a puck page removes it. */
.syncbar {
  position: fixed; top: 0; left: 0; right: 0; height: 2px;
  background: var(--line); overflow: hidden;
  z-index: 10002; pointer-events: none;
}
.syncbar::after {
  content: ""; display: block; height: 100%; width: 40%;
  background: var(--accent);
  animation: syncslide 1.15s ease-in-out infinite;
}
@keyframes syncslide {
  from { transform: translateX(-100%); }
  to { transform: translateX(350%); }
}
/* A minute and a half of looping motion is a lot to put in front of someone who asked
   for less of it. The bar still says the run is live — by being there at all. */
@media (prefers-reduced-motion: reduce) {
  .syncbar::after { animation: none; width: 100%; opacity: .55; }
}
/* The one surface that stays white: --danger-solid is dark in both themes. */
.toast.err { background: var(--danger-solid); color: #fff; }

/* token panel */
.token-backdrop {
  position: fixed; inset: 0; background: rgba(10, 13, 18, .5);
  display: flex; padding: 16px; z-index: 10001;
  /* Scroll the panel (not the board behind) — centered when it fits, scrollable
     from the top when it's taller than the space above the keyboard. */
  overflow-y: auto; -webkit-overflow-scrolling: touch;
}
.token-panel {
  background: var(--panel); color: var(--ink); border: 1px solid var(--line);
  border-radius: var(--r-lg); box-shadow: var(--shadow); padding: 18px;
  width: 100%; max-width: 400px; margin: auto;
}
.token-title { margin: 0 0 6px; font-size: var(--fs-2xl); }
.token-note { margin: 0 0 8px; font-size: var(--fs-md); color: var(--ink-2); line-height: var(--lh-snug); }
.token-help { font-size: var(--fs-md); color: var(--accent); text-decoration: none; }
.token-help:hover { text-decoration: underline; }
.token-input {
  width: 100%; box-sizing: border-box; margin: 12px 0; padding: 9px 11px;
  font-size: var(--fs-2xl); border: 1px solid var(--line); border-radius: var(--r-md);
  background: var(--bg); color: var(--ink);
}
.token-input:focus { outline: none; border-color: var(--accent); }
.token-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }

.tbtn:hover { border-color: var(--ink-3); }
.tbtn.primary { background: var(--accent); border-color: var(--accent); color: var(--on-solid); }
.tbtn.primary:hover { filter: brightness(1.05); }
.tbtn:disabled { opacity: .5; cursor: not-allowed; }
.tbtn:disabled:hover { border-color: var(--accent); filter: none; }

/* ── settings panel ──────────────────────────────────────────────────────── */
.set-panel { max-width: 440px; }
.set-panel .token-input { margin: 0; }
.set-eyebrow { font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: var(--tr-eyebrow); text-transform: uppercase; color: var(--ink-3); margin: 18px 0 4px; }
.set-eyebrow:first-of-type { margin-top: 10px; }
.set-note { margin: 0 0 4px; font-size: var(--fs-md); color: var(--ink-2); line-height: var(--lh-snug); }
.set-note.warn { color: var(--later); }
/* ── .segmented — one value out of a small closed set ───────────────────────── */
/* One frame around the group, never one around each option: a border per option
   reads as several buttons that happen to be adjacent, a single frame as one control
   with N positions. The layout switch drew the first and the theme switch a filled
   track; they are the same control and wear the same clothes now.
   Not for tabs — `.tab-btn` switches what the page *shows* and stays underlined. */
.segmented {
  display: inline-flex; align-self: flex-start; padding: 2px; gap: 2px;
  border: 1px solid var(--line); border-radius: var(--r-md); background: var(--panel-2);
}
.segmented-btn {
  flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  font: inherit; font-size: var(--fs-base); padding: 5px 12px; white-space: nowrap;
  border: 0; border-radius: var(--r-sm); background: none; color: var(--ink-2); cursor: pointer;
}
.segmented-btn .icn { width: 14px; height: 14px; }
.segmented-btn:hover { color: var(--ink); }
/* The chosen position is a raised surface inside the track, so the frame stays the
   control's outline and the fill stays the answer to "which one". */
.segmented-btn.on { background: var(--panel); color: var(--ink); box-shadow: var(--shadow); font-weight: var(--fw-semibold); }
/* The only delta a reset in the Settings panel needs: room above it — the rest comes
   from `.resetbtn`, which is what it is now. (main tokenised the old accent-link rule
   that used to live here; this branch is what deletes it.) */
.set-linkbtn { margin-top: 12px; }

/* ── inline SVG icons (Feather-style, inherit currentColor) ──────────────── */
/* Icons are decorative — never let the SVG become the tap target (which can eat
   taps on touch); the click always lands on the button. */
.icn { width: 16px; height: 16px; display: inline-block; vertical-align: middle; flex: none; pointer-events: none; }
/* The remove/close mark. The path draws across half its box (7.5 of a 15 grid),
   so a 14px box puts ~7px of ink on screen — the height the 11px "✕" character it
   replaced actually had. Overridden only where the button is bigger than the
   chrome default. */
.x-icn { width: 14px; height: 14px; }
.modal-close .x-icn { width: 18px; height: 18px; }
.shortcut-help .sc-close .x-icn { width: 16px; height: 16px; }
.icn.inline { width: 13px; height: 13px; vertical-align: -2px; margin-right: 4px; }
/* icon-only ghost buttons: center the glyph */
#viewToggle { display: inline-flex; align-items: center; justify-content: center; }
/* blocked badge: amber caution tint, slightly smaller in the dense meta row */
.block-badge { color: var(--later); }
.block-badge .icn { width: 14px; height: 14px; }

/* ── new-puck form ─────────────────────────────────────────────────────── */
.iconbtn.newpuckbtn { font-size: var(--fs-3xl); line-height: 1; font-weight: var(--fw-medium); }
.np-field { margin: 10px 0; }
.np-field label { display: block; font-size: var(--fs-base); color: var(--ink-2); margin-bottom: 4px; }
.np-preview { font-family: var(--mono); font-size: var(--fs-base); color: var(--ink-3); min-height: 14px; margin: -4px 0 2px; }
.np-context { resize: vertical; min-height: 46px; line-height: var(--lh-body); }
.np-tagsug { display: flex; flex-wrap: wrap; gap: 5px; align-items: center; margin: 6px 0 2px; }
.np-sug-label { font-size: var(--fs-sm); color: var(--ink-3); font-family: var(--mono); }
.np-sug-chip { font: inherit; font-size: var(--fs-base); padding: 2px 8px; border: 1px solid var(--line); border-radius: var(--r-pill); background: var(--panel-2); color: var(--ink-2); cursor: pointer; }
.np-sug-chip:hover { border-color: var(--accent); color: var(--ink); }
.np-sug-n { color: var(--ink-3); font-family: var(--mono); font-size: var(--fs-xs); margin-left: 4px; }

/* fix: the `hidden` attribute must win over .iconbtn's display */

/* body editing */
.body-edit { margin-top: 8px; }
/* The editor wears the text it replaces: same family, same size, same line-height, so
   pressing Edit changes the box and nothing else. `--prose` rather than a number is
   what makes that true by construction — the textarea sits inside `.modal-body` and
   inherits it, so the two cannot be changed apart. On a phone that resolves to 16px,
   which is also the floor iOS demands of anything editable (see the note above
   `.modal-body`); above 640px it is 14px, and the fallback here is the floor for the
   day this box is used somewhere that does not set `--prose`.
   Mono was the other half of the jump. Markdown source is code-ish, but mono measures
   wider per character than sans at the same size, so the same text arrived both bigger
   *and* wider — and at 16px on a 390px screen that is what read as clumsy. Indentation
   is what mono actually bought here, and `--lh-prose` with a 2-space list indent keeps
   that legible without it. */
.body-editor {
  width: 100%; box-sizing: border-box; min-height: 200px; resize: none; overflow: hidden;
  font-family: var(--sans); font-size: var(--prose, var(--fs-2xl)); line-height: var(--lh-prose);
  padding: 12px 14px; border: 1px solid var(--line); border-radius: var(--r-lg);
  background: var(--bg); color: var(--ink);
}
.body-editor:focus { outline: none; border-color: var(--accent); }
.body-edit-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }

/* Parent identity — mono for chrome/data (counts, column labels, dates) */
.col-head h2, .col-head .count, .list-head h2, .list-head .count,
.card-date, .chip .n, #footmeta { font-family: var(--mono); }

/* ── cockpit focus switcher (Ready · All · ⚠) ─────────────────────────────── */
.focusseg {
  display: inline-flex; background: var(--panel-2); border: 1px solid var(--line);
  border-radius: var(--r-md); padding: 2px; gap: 2px; flex: none;
}
@media (hover: hover) { .focusbtn:hover { color: var(--ink); } }
.focusbtn.on { background: var(--panel); color: var(--ink); box-shadow: var(--shadow); }
.focusbtn.focus-ready.on { color: var(--next); }
.focusbtn.focus-attention.on { color: var(--later); }
.focusbtn:focus:not(:focus-visible) { outline: none; }

/* ── Pass 3: work area = board + optional detail pane (desktop) ──────────── */
/* The scrollport. `min-height: 0` is what lets a flex item be *shorter* than its content
   — without it the box grows and the shell's height means nothing. */
.work {
  display: grid; grid-template-columns: 1fr; align-items: start;
  /* The rows are sized by what is in them, said out loud — because with a scroll container
     in one, `auto` does not mean that. `#board` carries `overflow-x: auto`, which makes it
     a scroll container in *both* axes, and a scroll container contributes almost nothing to
     its row's height: it can scroll, so it need not be tall. Left `auto`, row one took the
     leftover space (573px) while the board itself, `align-self: start` and therefore its
     own content height, stood 3019px tall — and the 2446px difference was painted straight
     over the footer beneath it. Reported from a phone as the footer's text sitting among
     the cards, mid-board.
     It only became reachable when the shell gained a definite height: with none, the rows
     had no free space to distribute and sized to their content by default. So this is the
     one thing that change cost, and it costs it only in the kanban layout — the list's
     board is short enough that its row and its content agreed. */
  grid-auto-rows: max-content;
  flex: 1; min-height: 0; overflow: auto;
}
/* …except in the kanban layout, where `.port` is the scrollport and therefore has to be
   exactly as tall as this box. `max-content` is what the paragraph above needed while
   `.work` was the only scrollport: a content-tall board in a row sized to the leftover
   space painted over the footer. Stretched, the row *is* the port's height and there is
   no leftover to get wrong — the port scrolls its own overflow instead of handing it
   upwards, which is what `.col-head`'s `position: sticky` has claimed all along and what
   puts the sideways scrollbar at the bottom of the window rather than at the bottom of
   4700px of cards.
   **The footer was inside the port, not beside it**, which is why `.port` came to exist
   rather than the board being the scroller itself: the board lays its children out
   `grid-auto-flow: column`, so a footer in there would be one more column — and a footer
   *outside* the scroller is a footer that never scrolls away. (The footer has since moved
   to the sidebar and the port holds only the board; the box stays for the other reason
   below — a sticky column head must not resolve against the board.) It was one, for exactly one
   commit: 114px of permanent furniture on a 390px phone, reported from the device.
   `body:not(.viewing-puck)` for the reason the three rules below already give: a hidden
   `#board` keeps its layout class, so without it a puck page would size its only row to
   the port and clip its own content — the detail pane is `.work`'s other row, and it is
   the one that has to stay content-tall and scroll. And `min-height: 0`, because a grid
   item's automatic minimum is its content: without it the stretch is undone by the
   columns' own height and the port grows straight back. */
body:not(.viewing-puck) .work:has(> .port > .board:not(.as-list)) { grid-auto-rows: auto; }
body:not(.viewing-puck) .work > .port:has(> .board:not(.as-list)) {
  align-self: stretch; min-height: 0;
  /* One axis, and that is the whole of this change. The port used to be `overflow: auto`
     — both axes in one box — and a box that can pan two ways is one the browser will pan
     two ways: measured with a 42° drag on a phone, `left 299` *and* `top 337` from a
     gesture meant as a read downwards. Before the board had a port at all the axes sat on
     two boxes (`.work` scrolled y, `#board` scrolled x) and the browser picked one; the
     port merged them and there was nothing left to pick between.
     Sideways is the port's; downwards belongs to a column, one box further in.

     **And this declaration is not what fixes it — the column being a scroller is.**
     Sabotage says so: put `overflow: auto` back here and the drift check still passes,
     because the gesture now targets the innermost scroller and that is the column. Take
     `overflow-y` off `.cards` instead and the sideways drift returns at once (measured:
     `portX: 514`), with or without this line. It stays as a statement of intent and as
     the guard for the day something gives the board a vertical overflow again — the same
     standing as the wheel forwarder's containment guard, which first refusal holds. */
  overflow-x: auto;
  overflow-y: hidden;
}
/* And the board stops being a scroll container, or it — not the port — is what a sticky
   column head would resolve against, and it has no height of its own to travel in. This
   is the list's arrangement exactly: `.board.as-list` has turned its own `overflow-x` off
   since the day the list was written, for the same reason. `min-width: 0` comes with it:
   a grid item's automatic minimum is its min-content, and it was only 0 here because
   `overflow-x: auto` made the box a scroll container. Without it the widest unbreakable
   thing on the page decides the document's width again — measured at 652px on a 390px
   phone the last time that happened. */
body:not(.viewing-puck) .port > .board:not(.as-list) {
  overflow: visible; min-width: 0;
  /* The board fills the port rather than standing at its content height, because that is
     what gives a column a definite height to scroll inside. Without it the board is as
     tall as its tallest column (measured: 1833px in a 731px port), every column is
     content-tall, and `.cards` has nothing to overflow — the rule below then does nothing
     at all, silently. It is the least obvious line here and the one everything rests on. */
  flex: 1; min-height: 0;
  /* The bottom gutter was air after the last card, in a box that scrolled past it. The
     port does not scroll downwards any more, so it would be dead space under every
     column instead. Each column keeps its own. */
  padding-bottom: 0;
}
/* A column is a head that stays and a list that moves. The head needs no `position:
   sticky` — it is outside the box that scrolls, which is what dissolves the question
   rather than answering it: no z-index against the cards, no pin resolving against a
   port's content box, and no background bleeding to cover a card's shadow passing
   underneath, because nothing passes underneath. */
body:not(.viewing-puck) .port > .board:not(.as-list) > .column {
  display: flex; flex-direction: column; min-height: 0;
}
/* The tray hugs its content — `align-self: start`, because a dashed box around four rows
   should not be column-tall — and that left it the one box on the board with no ceiling.
   With the port no longer scrolling downwards there is no ancestor to reach a clipped row
   through, so a tray taller than the port simply loses its lower eyes: measured at 240px
   with a single hidden column, already 89px of tray in an 80px port. Codex found it (#54).
   A ceiling rather than a stretch: short stays short, tall stops at the port and its own
   list takes over the scrolling — which it can, because `.cards` above is already a
   scroller and only ever lacked a definite height to scroll within. */
body:not(.viewing-puck) .port > .board:not(.as-list) > .column.hidden-cols { max-height: 100%; }
body:not(.viewing-puck) .port > .board:not(.as-list) > .column > .cards {
  overflow-y: auto; min-height: 0; flex: 1;
  /* The scrollbar gets a lane of its own, beside the cards rather than on them. A scroller
     exactly as wide as its content leaves an overlay bar nowhere to go but on top of it —
     reported from a phone, the bar drawn across the right edge of every card in the column
     being read. Linear's scroller is simply wider than its cards; this is that, borrowed
     from the 16px the board already keeps between columns.
     The bleed and the inset are the same number, so the cards' own box is unchanged: they
     stay exactly column-wide and in line with `.col-head`, which is a sibling and does not
     move. What lands in the borrowed strip is the bar and nothing else. Half the gap, so
     the neighbouring column keeps the other half; the last column bleeds into the board's
     own `--pad`, which is wider still. */
  --bar-lane: 8px;
  margin-right: calc(-1 * var(--bar-lane));
  padding-right: var(--bar-lane);
  /* And the bar drawn in that lane is the sidebar's, which has been the board's one
     self-effacing scrollbar since it was written: a thin thumb in `--line`, no track, and
     `--ink-3` while the pointer is over the box. Both themes are already answered, because
     both tokens are — `--line` is a hairline on the page's own ground in each. The standard
     properties only, deliberately no `::-webkit-scrollbar`: a width there is what turns an
     overlay bar into a classic one, and a classic bar takes its width out of `clientWidth`,
     which would shrink the cards by 8px and walk them out of line with `.col-head` — the
     exact register the lane exists to keep. Measured in the test browser: `clientWidth` 288
     before and after, the card's right edge 302 against the head's 302, both ways. */
  scrollbar-width: thin;
  scrollbar-color: var(--line) transparent;
  /* The card's shadow needs somewhere to fall on the left, and a scrollport clips at its
     padding box. With no left padding the card's left edge *was* the clip edge — measured,
     scroller and card both at 262 — so `0 2px 8px` fell softly into the lane on the right
     and was cut flat on the left. One card, two different edges. Four pixels is the blur's
     own reach (half of 8), bled and re-inset exactly like the lane above it, so the card's
     box does not move: it stays column-wide and in register with the head. */
  --card-bleed: 4px;
  margin-left: calc(-1 * var(--card-bleed));
  padding-left: var(--card-bleed);
  /* One axis, said out loud, because CSS will not leave it alone otherwise: an `overflow`
     of `visible` on one axis computes to `auto` when the other is not `visible`. This is
     the rule the board already knows from the other direction — `overflow-x: auto` forcing
     `overflow-y: auto` too, which is why `#board` must not be a scroll container — and it
     bites here the moment a card holds something unbreakable. Measured with one URL in a
     title: 414px of horizontal scroll beside 1337 of vertical, in the very box this whole
     arrangement exists to keep to a single axis. Codex found it (#54).

     `clip` states the intent — this axis is not a scroll axis — but be clear that it buys
     nothing over `hidden` *here*: when the other axis scrolls, `clip` is specified to
     compute to `hidden`, and it does (measured: `overflowX` reads `hidden`, and
     `scrollLeft = 50` sticks at 50 under both). What either one buys is the half that
     matters — neither can be *dragged* sideways, so the gesture stays on one axis. The
     word is kept for the day the other axis stops scrolling and `clip` becomes literal.

     **And this is the guarantee, not the fix.** With overflow still present it hides the
     text instead of scrolling to it — measured, 414px of a title out of reach — which is
     worse for a reader than the bug. The rule below is what removes the overflow; this one
     is what stops anything *else* that overflows from re-opening a second axis. */
  overflow-x: clip;
}
/* Findable when you reach for it, invisible when you are not. Guarded, because `:hover` is
   a state a finger cannot leave — the convention the list's own controls already follow. */
@media (hover: hover) {
  body:not(.viewing-puck) .port > .board:not(.as-list) > .column > .cards:hover { scrollbar-color: var(--ink-3) transparent; }
}
/* The content shrinks instead of overflowing, and that is the actual repair. A title is
   the one field holding arbitrary text — a pasted URL is the ordinary case — and
   `anywhere` is what lets an unbreakable token break at all. Measured against the clip
   above: with the wrap the column's horizontal overflow is 0; without it, 414px, clipped.
   The metadata row is `nowrap` by design and already has `min-width: 0` to shrink inside
   the card; the title was the one with no way out. */
.card h3 { overflow-wrap: anywhere; }
/* In the list the port is `.work`, so this box is structure and nothing else. */
.port { min-width: 0; display: flex; flex-direction: column; }
/* The scrollports are tab stops (see `markPort` and `renderColumns`), so a click on the
   empty space beside the columns — or on a column's own background — lands on one. The
   ring belongs to the keyboard route only, the same rule the focus buttons follow.
   All three, because "the scrollport" stopped being one box: `.work` in the list and on a
   puck page, `.port` in kanban, and every `.cards` since the columns took the vertical
   axis. Written for `.work` alone it covered the layout that was, and in kanban a mouse
   click landed a ring on the port or on the column it was aimed at — measured, `.work`
   holding no `tabindex` at all there while `.port` and the columns hold theirs. */
.work:focus:not(:focus-visible),
.port:focus:not(:focus-visible),
.cards:focus:not(:focus-visible) { outline: none; }
/* One axis per drag, decided before the gesture rather than corrected after it: the
   browser is told it may not pan this box sideways, and `armAxisLock` drives `scrollLeft`
   itself when a drag turns out to be a sideways one. `pinch-zoom` stays, or the board
   could not be zoomed. Scoped to the list by the class the renderer already sets — the
   kanban board is its own sideways scroller, and `pan-y` on an ancestor forbids it. */
/* `body:not(.viewing-puck)`, because a hidden `#board` keeps its `as-list` class: a puck
   opened from the list would otherwise carry the list's lock through a page that is not a
   list at all, and forbid sideways panning inside its own code blocks and tables. The same
   puck reached from the board layout had none — an asymmetry with no reason behind it. */
body:not(.viewing-puck) .work:has(> .port > .board.as-list) { touch-action: pan-y pinch-zoom; }
/* And no rubber band sideways. `touch-action` stops the browser *starting* a horizontal
   pan; it does not re-decide a scroll already under way, which is why chaining a swipe
   down straight into a swipe right moves both axes on iOS (measured on the device: 1 of 9
   touchmoves cancelable, we refused none and wrote nothing, and x still travelled 138 with
   y 318). The half we cannot take back is the browser's. The half we can is what it does
   at the *edge*: with a bounce, that gesture drags the whole list past its own left edge —
   headings and rows shifted ~180px right of where they belong, the sticky boxes riding
   along with the compositor while `scrollLeft` sits at 0 and cannot say so.
   `-x` only, and `none` rather than `contain`: the vertical rubber band is the one a long
   list is actually read with, and it stays. The property is not new here — the sheet has
   used it since `overscroll-behavior: none` was what removed *its* bounce, so it is known
   to land on the device this is for. */
body:not(.viewing-puck) .work:has(> .port > .board.as-list) { overscroll-behavior-x: none; }
/* No native scroll indicators on a phone, and the reason is that a two-axis port draws
   two of them badly. Reported from a real device, three symptoms of one cause: the
   vertical bar is painted *under* the sticky group headings, it travels with the sideways
   scroll instead of standing at the port's edge, and a plain flick down flashes the
   horizontal one as well — an indicator for an axis the browser is not even scrolling,
   since `armAxisLock` drives that one. Touch only: on a desktop the bar is how you learn
   the list scrolls sideways at all, and it is not transient there. */
@media (pointer: coarse) {
  body:not(.viewing-puck) .work:has(> .port > .board.as-list) { scrollbar-width: none; }
  body:not(.viewing-puck) .work:has(> .port > .board.as-list)::-webkit-scrollbar { display: none; }
}
/* A puck opens as a full-width page (same on desktop + mobile): the board and the
   view-header hide, and the detail fills the content area as a centered,
   document-width column. The breadcrumb "← All pucks" is the back action. */
/* The footer belongs to the board: it counts and dates the harvest behind it. On a
   puck page there is no board under it, so it hid nothing and only reported on a
   view the reader had left. */
/* And so does the chip row — it sits *outside* `.vhead` in the markup, which is the
   whole reason it was missing from this list and rode along into the puck page. There
   it says what the board behind it was narrowed to, which is not what you are reading,
   and its `Save view` offered to save a view from a page that is not a board.
   Hidden here rather than in `openDetail`, because `chipRow.hidden` answers a different
   question — "is there anything to say" — and `renderChips` rewrites it on every render.
   One flag for content, one for which page you are on; folding them together would let
   a render while a puck is open put the row back. */
body.viewing-puck #board,
body.viewing-puck .chiprow,
body.viewing-puck .vhead { display: none; }
.detail-pane {
  background: var(--bg);
  padding: 16px var(--pad) 56px; display: flex; flex-direction: column;
  /* A grid item's `min-width` defaults to `auto`, which means "never narrower than my
     content's minimum" — and a `<pre>` has no minimum worth speaking of: it is one long
     line. So a puck whose body holds a terminal transcript pushed this pane wider than
     the phone, the page gained a horizontal scroll, and the browser's text autosizing
     read the too-wide layout as a reason to inflate every font on the page. One puck
     rendered large and draggable while the one beside it was fine.
     `overflow-x: auto` on the `pre` was already there and was never the missing piece:
     a scroll container still contributes its content's minimum width upward until an
     ancestor is allowed to shrink. This is that permission. */
  min-width: 0;
}
.detail-close { display: none; }
.detail-content { max-width: 760px; width: 100%; margin: 0 auto; }
.detail-content .card-links { padding-top: 4px; }

/* selected card/row highlight */
.card.sel, .list-row.sel { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }

/* ── Pass 4: app shell — left sidebar + thin topbar ───────────────────────── */
/* A fixed-height shell with one scrollport inside it (`.work`), rather than a page that
   grows and scrolls as a whole. Two things needed it:
   the list scrolls sideways, and a box that scrolls horizontally is a scroll container in
   *both* axes — so a group heading's `top: sticky` resolved against a box with no vertical
   travel and stopped sticking entirely. Inside a real scrollport it sticks again, in both
   axes at once.
   And `100vh` was already wrong here: on iOS it is the *large* viewport (toolbar
   retracted) while the visible one at load is the small one, so every page was taller
   than its own window by exactly the toolbar's height — a ghost scroll on a board with
   one card. `100dvh` is the same number the sheets already use, with `100vh` left in
   front of it for anything that does not know the unit. */
.app {
  display: grid; grid-template-columns: var(--sidebar-w, 240px) minmax(0, 1fr);
  /* The rest of the column after any ribbon — see `body`. `min-height: 0` is what lets
     a flex item be shorter than its content; without it the shell grows and its height
     means nothing. */
  flex: 1;
  min-height: 0;
  overflow: hidden;
}
.maincol { min-width: 0; display: flex; flex-direction: column; min-height: 0; position: relative; }

/* drag handle straddling the sidebar/main seam — desktop only (mobile is a drawer) */
.col-resizer { position: absolute; left: -3px; top: 0; width: 6px; height: 100%; cursor: col-resize; z-index: 30; }
.col-resizer::after { content: ""; position: absolute; left: 2px; top: 0; width: 1px; height: 100%; background: transparent; transition: background .15s; }
.col-resizer:hover::after, .col-resizer.dragging::after { background: var(--accent); }
body.col-resizing { cursor: col-resize; user-select: none; }
@media (max-width: 899px) { .col-resizer { display: none; } }

.sidebar {
  background: var(--sidebar-bg); border-right: 1px solid var(--line);
  /* No bottom padding: `.side-foot` is the last thing in here and owns that air. The
     16px that used to keep the last repo row off the edge put a stripe *below* the
     band, and a `bottom: 0` sticky box that stops 16px short looks stuck rather than
     pinned. */
  padding: 0 12px; display: flex; flex-direction: column; gap: 18px;
  /* minimal, self-effacing scrollbar */
  scrollbar-width: thin; scrollbar-color: var(--line) transparent;
  position: sticky; top: 0; height: 100%; overflow-y: auto;
}
.sidebar::-webkit-scrollbar { width: 8px; }
.sidebar::-webkit-scrollbar-track { background: transparent; }
.sidebar::-webkit-scrollbar-thumb { background: var(--line); border-radius: var(--r-md); border: 2px solid transparent; background-clip: padding-box; }
.sidebar:hover::-webkit-scrollbar-thumb { background: var(--ink-3); background-clip: padding-box; }
/* The workspace-header band: same fixed height as the topbar and top-aligned to the
   viewport (the sidebar has no top padding), so "Roadmap" + search line up with the
   topbar's title/breadcrumb across the seam. */
/* Sticky, not merely first: the band sits *inside* the sidebar's own scroller
   (`overflow-y: auto`), so "top-aligned to the viewport" only held at scroll offset
   0. On a phone the drawer's list overflows — the sections plus repos are taller
   than a short viewport — and the smallest scroll carried the workspace row up out
   of the topbar's band, which is exactly where a header must not go. It keeps its
   own background for the rows that now pass under it. */
.side-brand { height: var(--topbar-h); box-sizing: border-box; flex: none; padding: 0 4px; position: sticky; top: 0; z-index: 2; background: var(--sidebar-bg); display: flex; align-items: center; gap: 2px; }
.side-brand h1 { margin: 0; font-size: var(--fs-2xl); letter-spacing: var(--tr-tight); }
.side-brand .brand-glyph { width: 17px; height: 17px; }
.side-eyebrow {
  font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase; color: var(--ink-3); padding: 0 8px; margin-bottom: 6px;
}
.side-section { display: flex; flex-direction: column; }
/* The eyebrow became the fold control. It keeps `.side-eyebrow`, so it keeps the
   heading's exact metrics — a heading that shifted when it turned into a switch would
   read as a different element — and only loses the button chrome. */
.eyebrow-fold {
  font: inherit; background: none; border: 0; cursor: pointer;
  display: flex; align-items: center; text-align: left; width: 100%;
  font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: var(--tr-eyebrow);
  text-transform: uppercase; color: var(--ink-3);
}
@media (hover: hover) { .eyebrow-fold:hover { color: var(--ink-2); } }
/* Quiet while open, plain while shut. A chevron that always showed would put five
   permanent marks in the sidebar to advertise something most people set once; one that
   *never* showed would leave a folded section looking like an empty one. So: on hover,
   on focus, and whenever it is actually folded — where it is no longer decoration but
   the only thing saying rows are hidden. Touch has no hover, so there it always shows. */
.eyebrow-fold .eyebrow-chev {
  width: 12px; height: 12px; margin-left: auto; flex: none;
  opacity: 0; transition: opacity .12s, transform .12s;
}
.eyebrow-fold:hover .eyebrow-chev,
.eyebrow-fold:focus-visible .eyebrow-chev { opacity: 1; }
.eyebrow-fold.folded .eyebrow-chev { opacity: 1; transform: rotate(-90deg); }
@media (hover: none) { .eyebrow-fold .eyebrow-chev { opacity: .55; } }

/* focus switcher → vertical nav in the sidebar */
.side-views .focusseg { display: flex; flex-direction: column; background: none; border: 0; padding: 0; gap: 1px; }
/* The nav now holds several groups, so a heading that follows rows needs the same
   air the sidebar puts between its sections (gap: 18px) — the first one doesn't. */
.side-views .focusseg + .side-eyebrow { margin-top: 18px; }
/* The sidebar is the switcher's only home, so this is not a variant — it is the
   control. Folded in rather than kept as a second size under a parent selector. */
.focusbtn {
  text-align: left; justify-content: flex-start; padding: 7px 10px;
}
.side-views .focusbtn.on { box-shadow: none; background: var(--panel-2); }

/* repos → sidebar rows */
.side-repos { display: flex; flex-direction: column; gap: 1px; }
.side-repos .chip {
  width: 100%; justify-content: flex-start; border: 0; background: none;
  border-radius: var(--r-sm); padding: 6px 8px; font-size: var(--fs-md); color: var(--ink-2);
}
@media (hover: hover) { .side-repos .chip:hover { background: var(--panel-2); color: var(--ink); } }
.side-repos .chip[aria-pressed="true"] { background: var(--panel-2); color: var(--ink); border: 0; }
.side-repos .chip .n { margin-left: auto; } /* size comes from `.chip .n` — one rule */

/* topbar — thin, contextual (brand moved to the sidebar). Fixed height so it
   doesn't jump between board (title) and puck (breadcrumb + taller share button),
   and so its content lines up with the sidebar's workspace-header band. */
.topbar {
  justify-content: flex-start; align-items: center; gap: 12px;
  height: var(--topbar-h); box-sizing: border-box; padding: 0 var(--pad);
  position: sticky; top: 0; z-index: 20;
  background: var(--bg); border-bottom: 1px solid var(--line);
}
.topbar .search-wrap { flex: 1 1 auto; max-width: 440px; }
.topbar .controls { margin-left: auto; }
.menu-toggle { display: none; }
.filters { padding: 10px 20px; }

/* Suppress every transition during first paint, then app.js drops .preload.
   Without this, iOS Safari animates the mobile drawer from its stylesheet
   transform on load — the sidebar visibly slides out on every reload. */
body.preload * { transition: none !important; }

/* mobile: the sidebar becomes a slide-in drawer over a scrim */
@media (max-width: 899px) {
  .app { grid-template-columns: 1fr; }
  .sidebar {
    position: fixed; left: 0; top: 0; bottom: 0; width: 264px; height: 100%;
    transform: translateX(-100%); transition: transform .2s ease; z-index: 60;
  }
  /* Shadow only while the drawer is open — otherwise the closed, off-screen drawer's
     24px blur bleeds past its right edge into the content. */
  .app.menu-open .sidebar { transform: none; box-shadow: 2px 0 24px rgba(0, 0, 0, .34); }
  .menu-toggle { display: inline-flex; align-items: center; justify-content: center; }
  .scrim { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 55; }
}
@media (min-width: 900px) { .scrim { display: none; } }
@media (prefers-reduced-motion: reduce) { .sidebar { transition: none; } }

/* ── v2 detail: breadcrumb header + properties rail (growable) ─────────────── */
.detail-crumb { font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-3); display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.detail-crumb .repo-dot { width: 7px; height: 7px; }
.props { display: flex; flex-direction: column; margin: 12px 0 4px; }
.prop { display: grid; grid-template-columns: 76px 1fr; align-items: center; gap: 10px; min-height: 34px; padding: 6px 0; border-top: 1px solid var(--line); }
.prop:first-child { border-top: 0; }
.prop-k { font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-3); }
.prop-v { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; font-size: var(--fs-md); color: var(--ink); }
.prop-muted { color: var(--ink-3); }
.issue-cell { display: inline-flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.issue-link { color: var(--accent); text-decoration: none; font-family: var(--mono); font-size: var(--fs-md); }
.issue-link:hover { text-decoration: underline; }
/* The repo row's value: the mark first, then the name it belongs to. `--ink` rather
   than the accent — it is a fact about the puck like the ones under it, not a link out
   of the page; the press scopes the board, which is navigation *within* the app. */
.repo-cell { display: inline-flex; align-items: center; gap: 7px; color: var(--ink); }
.repo-cell .repo-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
@media (hover: hover) { .repo-cell:hover { color: var(--accent); } }
.issue-cell .linklike { background: none; border: 0; padding: 0; font: inherit; font-size: var(--fs-md); color: var(--accent); cursor: pointer; }
.issue-cell .linklike:hover { text-decoration: underline; }
.issue-editbtn { font-size: var(--fs-base); color: var(--ink-3); }
.issue-newbtn::before { content: "·"; color: var(--ink-3); margin: 0 6px 0 -2px; }
.issue-state { text-transform: uppercase; letter-spacing: var(--tr-caps); padding: 1px 6px; }
.issue-state.issue-open { color: var(--next); }
.issue-state.issue-closed { color: var(--done); }
.prop-date { font-family: var(--mono); font-size: var(--fs-base); color: var(--ink-2); }
/* The horizon reads as a horizon: quiet by default, warm once it has passed —
   the ⚠ badge already carries the alarm, so this only needs to be noticeable. */
.target-date { color: var(--ink-2); }
.target-date.past { color: var(--now); }
.prop-v .card-tags { margin: 0; gap: 5px; }
.prop-status-edit { display: flex; gap: 5px; flex-wrap: wrap; }
/* The keys in the rail are mono, 11px, muted — a heading in the same voice does not
   rank above what it groups. More air above, less below (it belongs to what
   follows), and enough weight to read as a heading. */
.sect-label { font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: var(--tr-eyebrow); text-transform: uppercase; color: var(--ink-2); font-weight: var(--fw-semibold); margin: 26px 0 4px; }
/* A section heading names the column, so the row inside it does not repeat it. */
.prop-keyless { grid-template-columns: 1fr; }
.prop-keyless .prop-k { display: none; }
/* Derived, never edited, and Activity has the same fact in full. */
.prop-foot { margin: 26px 0 0; font-family: var(--mono); font-size: var(--fs-xs); color: var(--ink-3); opacity: .8; }

/* ── workspace header (leads with the board) + account menu (identity + sign out) ─ */
.ws-btn {
  display: flex; align-items: center; gap: 8px; flex: 0 1 auto; min-width: 0;
  background: none; border: 1px solid transparent; border-radius: var(--r-md);
  padding: 3px 6px; color: var(--ink); cursor: pointer; text-align: left;
  font: inherit; /* the only control whose size was the UA's 13.3333px, not a step */
}
.ws-btn:hover, .ws-btn[aria-expanded="true"] { background: var(--panel-2); }
.ws-btn h1 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ws-caret { margin-left: 1px; color: var(--ink-3); flex: none; }
/* search + New as icon buttons on the workspace row (Linear-style) */
.side-head-actions { margin-left: auto; display: flex; gap: 2px; flex: none; }
.head-act {
  width: 32px; height: 32px; display: inline-flex; align-items: center; justify-content: center;
  background: none; border: 1px solid transparent; border-radius: var(--r-md); color: var(--ink-2); cursor: pointer;
}
.head-act:hover { background: var(--panel-2); color: var(--ink); }
.head-act svg { width: 17px; height: 17px; }
.user-av { width: 22px; height: 22px; border-radius: 50%; background: var(--panel-2); overflow: hidden; flex: none; }
.user-av-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.user-menu {
  position: absolute; top: calc(100% + 4px); left: 4px; right: 4px; z-index: 50;
  background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-md); box-shadow: var(--shadow); padding: 4px; display: flex; flex-direction: column;
}
.ws-menu-head { display: flex; align-items: center; gap: 8px; padding: 7px 9px 8px; margin-bottom: 4px; border-bottom: 1px solid var(--line); }
.ws-menu-who { font-size: var(--fs-md); font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-2); }
.ws-menu-head.bad .ws-menu-who { color: var(--danger); }

/* ── v2 consistent view-header ─────────────────────────────────────────────── */
.vhead { display: flex; align-items: center; gap: 10px; padding: 11px var(--pad); border-bottom: 1px solid var(--line); }
/* The title *is* the view switcher — one control on both widths, opening the same
   surface the overlay primitive gives every other menu. It replaces nothing: the
   sidebar list stays, and both are built from viewsShown(), so they cannot drift. */
.view-switch { position: relative; display: inline-flex; min-width: 0; margin: 0; }
.view-switch-btn {
  display: inline-flex; align-items: baseline; gap: 8px; min-width: 0;
  font: inherit; background: none; border: 0; border-radius: var(--r-sm);
  padding: 3px 6px; margin: -3px -6px; color: inherit; cursor: pointer; text-align: left;
}
.view-switch-btn:hover { background: var(--panel-2); }
.view-switch-btn:hover .vs-caret { color: var(--ink-2); }
.vs-caret { color: var(--ink-3); flex: none; }
/* A disclosure caret is an *affordance*, not punctuation: it says "this opens" and
   should say it the same way beside an 11px workspace name and a 17px view title.
   So it keeps a fixed size, unlike the separators — which are punctuation and scale
   with the type they sit in. Two rules, because they are two different jobs.
   Which way it points is the button's own `aria-expanded`; nothing to keep in sync. */
.ws-caret, .vs-caret { display: inline-flex; align-items: center; }
.ws-caret .icn, .vs-caret .icn { width: 13px; height: 13px; }
.caret-up { display: none; }
[aria-expanded="true"] > .ws-caret > .caret-down,
[aria-expanded="true"] > .vs-caret > .caret-down { display: none; }
[aria-expanded="true"] > .ws-caret > .caret-up,
[aria-expanded="true"] > .vs-caret > .caret-up { display: inline-block; }
.vs-section { padding: 8px 9px 4px; }
.vs-section:first-child { padding-top: 4px; }
.view-title { font-size: var(--fs-lg); font-weight: var(--fw-semibold); margin: 0; letter-spacing: var(--tr-tight); }
/* keep a long composed "view · scope" title from pushing the view-options off */
.vhead .view-title { max-width: 55ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* One count mark, one size, no exception: `--fs-sm`, which the scale now names for it.
   "How many cards are behind this label" is the same sentence beside the view title,
   on a column head, on a list head, on a sidebar view row, on a repo or agent row and
   on a tray row — six marks, and they used to be four sizes. No rule explained them:
   the sidebar view row and the column head are *both* `--fs-md` and their counts still
   disagreed, and the largest heading on the board carried the smallest count.
   Why 11 and not 12, which the scale used to name: the repo rows are the longest list
   of counts on the board and were already 11, so unifying upward moved the majority to
   match the minority. Reported by eye, from a phone, once the other five had moved.
   `.filter-count` stays out of this and should: it counts *filters*, not cards, and it
   is a solid pill rather than a mark beside a label. */
.view-count { font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-3); font-variant-numeric: tabular-nums; }
/* `min-width: 0` is the load-bearing half: a flex item's automatic minimum is its
   *content*, so without it `.vacts`' own shrinking has nothing to shrink inside — the
   ellipsis rule below did nothing and the Update button ran 250px past a 390px window,
   clipped by `.app` and genuinely unreachable. Capping the text is two rules, one per
   level, and only the pair works. */
.vopts { margin-left: auto; display: flex; align-items: center; gap: 8px; min-width: 0; }
/* Pushed to the far end of the row it shares with Filter and Display. On a wide board
   `.vopts` is already shrink-to-fit at the right, so this decides nothing there; in the
   vhead, where `.vopts` spans (`margin-left: 0` below), it is what puts the actions
   opposite the two openers instead of crowding them. */
.vacts { margin-left: auto; display: flex; align-items: center; gap: 8px; min-width: 0; }
.vacts:empty { display: none; }
/* A saved view's name is unbounded — neither the save nor the rename path caps it — and
   `Update “<name>”` carries it. Left to wrap, a long one turned a toolbar button into a
   six-line balloon 130px tall that pushed the row out of shape (measured at 390px with a
   52-character name). It ellipsises instead: the *visible* text is cut, while the button's
   own text node keeps the whole name, so a screen reader still announces which view it
   updates. `min-width: 0` on both the container and the buttons is what lets a flex item be
   narrower than its content at all. */
/* **Only the button carrying the unbounded name may shrink**, and `min-width: 0` is the
   whole mechanism: a flex item's automatic minimum is its content, so every sibling without
   it already refuses to lose a character. The first version also wrote `flex: none` on the
   parts — the shape the list heading landed on — and sabotage showed it changed nothing
   here, so it is gone. `Reset` keeps its word because nothing told it not to. */
.vacts .fchip-save { flex: 0 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Filter and Display keep their size; the squeeze goes to the actions, which are the part
   that can lose characters without losing meaning. Direct children only — Save view's own
   `.filter-wrap` (the popover's anchor) lives inside `.vacts`. */
.vopts > .filter-wrap { flex: none; }
.newbtn { gap: 5px; background: var(--panel-2); }
.newbtn:hover { border-color: var(--ink-3); }
/* filter popover: no empty band when closed (padding/border live on the chips) */
.filters { padding: 0; border: 0; }
.filter-chips { padding: 12px 20px; border-bottom: 1px solid var(--line); }

/* ── disciplines (tags) + show-done in the sidebar ─────────────────────────── */
.side-more { background: none; border: 0; color: var(--ink-3); font-family: var(--mono); font-size: var(--fs-sm); text-align: left; padding: 5px 8px; cursor: pointer; border-radius: var(--r-sm); }
.side-more:hover { color: var(--ink-2); background: var(--panel-2); }
.side-repos .chip.tag { color: var(--ink-2); }
.side-repos .chip.tag[aria-pressed="true"] { background: var(--panel-2); color: var(--ink); border: 0; }
.side-toggle { display: flex; align-items: center; gap: 8px; padding: 6px 10px; color: var(--ink-2); font-size: var(--fs-md); cursor: pointer; }
.side-toggle input { cursor: pointer; accent-color: var(--accent); }

/* ── ⌘K command palette ────────────────────────────────────────────────────── */
.cmdk-trigger {
  display: inline-flex; align-items: center; gap: 8px; flex: 0 1 280px; min-width: 0;
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-md);
  padding: 7px 11px; color: var(--ink-3); cursor: text; font-size: var(--fs-md);
}
.cmdk-trigger:hover { border-color: var(--ink-3); }
.cmdk-trigger svg { width: 14px; height: 14px; flex: none; }
.cmdk-trigger span { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cmdk-trigger kbd { font-family: var(--mono); font-size: var(--fs-xs); color: var(--ink-3); border: 1px solid var(--line); border-radius: var(--r-xs); padding: 1px 5px; flex: none; }

.cmdk-overlay {
  position: fixed; inset: 0; z-index: 80; background: rgba(0, 0, 0, .4);
  display: flex; align-items: flex-start; justify-content: center; padding: 12vh 16px 16px;
}
.cmdk-box {
  width: 100%; max-width: 560px; background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-lg); box-shadow: var(--shadow); overflow: hidden;
}
.cmdk-box .search-wrap { min-width: 0; }
.cmdk-box #search { border: 0; border-bottom: 1px solid var(--line); border-radius: 0; font-size: var(--fs-2xl); padding: 14px 40px 14px 16px; background: var(--panel); }
.cmdk-box #search:focus { outline: none; }
.cmdk-box .search-clear { top: 25px; right: 12px; }
.cmdk-box .suggest { position: static; border: 0; border-radius: 0; box-shadow: none; max-height: 52vh; margin: 0; padding: 6px; background: var(--panel); }

/* ── keyboard shortcut help overlay ("?") ── */
.shortcut-help {
  position: fixed; inset: 0; z-index: 90; background: rgba(0, 0, 0, .4);
  display: flex; align-items: flex-start; justify-content: center; padding: 10vh 16px 16px;
}
/* It takes focus on open so the keyboard follows what is on screen, but it is a
   container, not a control — no ring on it. */
.shortcut-help:focus { outline: none; }
.shortcut-help .sc-card {
  width: 100%; max-width: 440px; background: var(--panel); border: 1px solid var(--line);
  border-radius: var(--r-lg); box-shadow: var(--shadow); overflow: hidden;
}
.shortcut-help .sc-head {
  display: flex; align-items: center; justify-content: space-between;
  padding: 14px 16px; border-bottom: 1px solid var(--line);
}
.shortcut-help .sc-title { margin: 0; font-size: var(--fs-lg); font-weight: var(--fw-semibold); color: var(--ink); }
.shortcut-help .sc-close {
  border: 0; background: none; color: var(--ink-3); font-size: var(--fs-lg); cursor: pointer;
  padding: 2px 6px; border-radius: var(--r-sm); line-height: 1;
}
.shortcut-help .sc-close:hover { color: var(--ink); background: var(--panel-2); }
.shortcut-help .sc-list { padding: 8px; max-height: 64vh; overflow: auto; }
.shortcut-help .sc-row {
  display: flex; align-items: center; gap: 12px; padding: 7px 8px; border-radius: var(--r-sm);
}
.shortcut-help .sc-row:hover { background: var(--panel-2); }
.shortcut-help .sc-keys { display: flex; align-items: center; gap: 5px; flex: none; min-width: 116px; }
.shortcut-help .sc-key {
  font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-2);
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: var(--r-sm);
  padding: 2px 6px; min-width: 18px; text-align: center; background: var(--panel);
}
.shortcut-help .sc-then { font-size: var(--fs-sm); color: var(--ink-3); }
.shortcut-help .sc-desc { font-size: var(--fs-md); color: var(--ink); }

/* ── priority: an optional ordered field (urgent/high/medium/low) ── */
/* Card badge: ascending signal bars, urgent = a red "!" chip. */
.pri { display: inline-flex; align-items: flex-end; gap: 2px; height: 11px; flex: none; line-height: 1; }
.pri .pri-bar { width: 3px; background: var(--ink-3); border-radius: var(--r-pill); opacity: 0.3; }
.pri .pri-bar:nth-child(1) { height: 5px; }
.pri .pri-bar:nth-child(2) { height: 8px; }
.pri .pri-bar:nth-child(3) { height: 11px; }
.pri .pri-bar.on { opacity: 1; }
.pri.pri-low .pri-bar.on { background: var(--ink-2); }
.pri.pri-medium .pri-bar.on { background: var(--later); }
.pri.pri-high .pri-bar.on { background: var(--now); }
/* Urgent is the one level that is not bars, and it used to be a "!" reversed out of a
   solid red square. The mark is a path now, and an *outline* path — inside a 13px filled
   chip that is a circle inside a rounded square, two containers at a size that has room
   for one, with the stroke of the exclamation nearly lost against the red. Measured side
   by side, the glyph alone is the legible one.
   What that costs: urgent no longer shouts with a solid surface, it shouts with a shape
   the other three levels do not have. The colour is unchanged. */
.pri.pri-urgent { color: var(--urgent); align-items: center; }
.pri.pri-urgent .icn.pri-glyph { width: 13px; height: 13px; }
/* detail rail: static badge + label */
.pri-inline { display: inline-flex; align-items: center; gap: 6px; color: var(--ink-2); }
/* detail rail: editable priority chips share .edit-btn; active gets a filled color */
.edit-btn.active.pri-urgent { background: var(--urgent); }
.edit-btn.active.pri-high { background: var(--now); }
.edit-btn.active.pri-medium { background: var(--later); }
.edit-btn.active.pri-low { background: var(--ink-3); }
.edit-btn.active.pri-none { background: var(--ink-3); }

/* ── v3: sidebar top-actions · Views list · filter popover · breadcrumb ─────── */

/* ⌘K hint inside the search modal (shown when the field is empty) */
.cmdk-hint {
  position: absolute; top: 18px; right: 14px;
  font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-3);
  border: 1px solid var(--line); border-radius: var(--r-sm); padding: 1px 6px; background: var(--panel-2);
}

/* topbar: desktop hides it (actions live in the sidebar); mobile shows ☰ + 🔍 */
/* Desktop shares the same contextual header (title / breadcrumb + share). Search
   and New live permanently in the sidebar there, so drop them from the topbar. */
@media (min-width: 900px) {
  .topbar .top-search.ghost, .topbar .top-new.ghost { display: none; }
  /* the sidebar is always present on desktop — no drawer to toggle */
  .topbar .menu-toggle.ghost { display: none; }
}
.top-search { margin-left: auto; }
.top-search svg { width: 18px; height: 18px; }

/* Views list — nav rows with a live count badge */
.side-views .focusbtn { display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 8px; }
.focus-label { flex: 1; text-align: left; }
/* Sized in `em` like the other marks that sit in a line of text, so the same row
   works in the mono sidebar and in the switcher's larger sheet rows. */
.focus-icn { width: 1.15em; height: 1.15em; color: var(--ink-3); }
.focusbtn.on .focus-icn, .row.on .focus-icn { color: inherit; }
/* Fixed like the rest, and `em` would have been wrong here even though this row has
   two sizes (`--fs-md` in the popover, `--fs-xl` in the sheet). The neighbouring icon
   looks like a precedent for scaling and is not one: `.focus-icn` asks for `1.15em`,
   but `.pick-menu .row > .icn` outranks it and pins the icon to 15px in the menu, so
   `em` there would have grown the number past a mark that stayed put. */
.focus-n { font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-3); font-variant-numeric: tabular-nums; flex: none; }
.side-views .focusbtn.on .focus-n { color: var(--ink-2); }

/* filter popover in the view-header */
.filter-wrap { position: relative; display: inline-flex; }
.filter-btn { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-md); }
.filter-btn svg { width: 15px; height: 15px; }
.filter-btn.on { border-color: var(--accent); color: var(--accent); }
.filter-count {
  font-family: var(--mono); font-size: var(--fs-xs); background: var(--accent); color: var(--on-solid);
  border-radius: var(--r-pill); min-width: 16px; text-align: center; padding: 0 4px;
}
/* Anchored under its own button (Filter and Display both sit at the left of the
   view header), so a popover never opens leftward across the sidebar. */
/* Shell (position, background, border, shadow) comes from .pop / .sheet; this is
   only what the panel itself needs, so the same markup serves both. */
/* Width and padding are the *popover's* geometry: a sheet is full-width and carries
   the page's own inset. Scoped to .pop rather than undone per class afterwards —
   three of these five already had a `.sheet.<x>` rule cancelling the widths, and the
   padding was forgotten in all of them, so every picker sheet sat at 6-10px while
   the page (and the sheets without an override) sat at 20. */
.pop.filter-pop { padding: 10px; min-width: 232px; max-width: 300px; }
.filter-pop .surface-body { gap: 8px; }
/* Display menu — same shell as the filter popover (one popover language), with
   rows for the choices that shape the presentation. */
.pop.display-pop { min-width: 260px; }
.display-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); display: inline-block; }
/* The layout switch is a `.segmented`; the only thing it adds is that it spans the
   menu's width instead of hugging its labels. `dp-seg`/`dp-segbtn` remain as hooks. */
.dp-seg { display: flex; align-self: stretch; }
/* The board-only rows are rebuilt as a group (see paintWholesale), so the rule that
   introduces them has to live *inside* the group — outside it, it would linger when
   the group empties. That made it the one rule on the surface with nothing under it:
   the surface spaces its children with `gap`, and a plain <div> passes none of that
   on, so it sat flush on the first checkbox while its twin below kept the rhythm.
   `gap: inherit` takes the surface's own spacing rather than restating the number,
   so the group cannot drift from the surface it sits in. */
.dp-wholesale { display: flex; flex-direction: column; gap: inherit; }
/* `.dp-row`/`.dp-label` are hooks now — the row is an `.fp-row`, laid out by the
   same rule the filter panel's rows use, because it is the same kind of row. */
.dp-label { color: var(--ink-2); }
/* The value a setting currently holds, sitting where iOS puts it: pushed to the far
   end, with the chevron behind it. The chevron's own `margin-left: auto` has to give
   way, or the two autos would split the free space and park the value mid-row. */
.fp-cur { margin-left: auto; color: var(--ink-3); }
.dp-rule { height: 1px; background: var(--line); margin: 2px 0; }
/* ── the ordering chain ─────────────────────────────────────────────────────── */
/* One row per key — `[↑] Field  direction  ✕` — at the same rhythm as a `.row`, but a
   <div>: the row is not the target, its three controls are. There is no meaning to
   invent for "press the key you are already sorting by".
   The row's *position* is the setting — it says this key is asked third — which is
   why there are no ordinals any more: with one list instead of two, the order is
   readable from the stack itself, and a number was a second way of saying it. */
.dp-sort { display: flex; align-items: center; gap: 6px; padding: 5px 9px; font-size: var(--fs-md); }
/* The field name gives first and the direction second: a name is what the row is
   *about*, a direction is what it does. Both may ellipsise — `soonest → latest` is
   the longest thing in here — but neither may squeeze the two icon controls, which
   are fixed marks. That is the list headings' rule, one surface over. */
.dp-sort-field, .dp-sort-dir {
  display: inline-flex; align-items: center; gap: 4px; min-width: 0;
  padding: 3px 6px; border: 0; border-radius: var(--r-sm);
  background: none; font: inherit; text-align: left; cursor: pointer;
}
/* Pulled back by its own padding, so the *label* starts on the content line and the hover
   wash still has room around it. Without this the field name sat 15px right of every other
   value label in the surface — the picker's rows one level down begin exactly there. */
.dp-sort-field { flex: 1 1 auto; color: var(--ink); margin-left: -6px; }
.dp-sort-dir { flex: 0 1 auto; color: var(--ink-3); }
.dp-sort-field > span, .dp-sort-dir > span {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.dp-sort-chev { flex: none; width: 11px; height: 11px; color: var(--ink-3); }
.dp-sort-act {
  flex: none; display: inline-flex; align-items: center; justify-content: center;
  width: 24px; height: 24px; padding: 0; border: 0; border-radius: var(--r-sm);
  background: none; color: var(--ink-3); cursor: pointer;
}
/* The first row has no `↑`, so it gets this instead — the same width, no target.
   Without it the field names stepped 24px left on row one and the column read as a
   mistake rather than as a control that is missing on purpose. Its own class, not the
   control's: sharing one put a <span> into every set that asks a row what controls it
   has. */
.dp-sort-gap { flex: none; width: 24px; }
/* `(hover: hover)` because on a phone a tap leaves the state stuck under your finger
   — the rule the list's own controls were swept into. */
@media (hover: hover) {
  .dp-sort-act:hover, .dp-sort-field:hover, .dp-sort-dir:hover {
    background: var(--panel-2); color: var(--ink);
  }
}
.dp-sort-act .icn { width: 13px; height: 13px; }
/* `＋ Add a key` and `🗑 Reset ordering` are ordinary rows — they belong to the list
   rather than to any key in it, and drawing them as anything else would have made
   them read as a third kind of thing in a surface that just got down to one. */
.dp-sort-add { color: var(--ink-3); }
.dp-sort-add .icn { width: 13px; height: 13px; flex: none; }
@media (max-width: 639px) {
  /* A sheet is a thumb surface: the controls grow with its rows, like every other
     target in here. */
  /* Bled to the scroller's clip edge and re-inset, exactly like `.sheet .row` — the same
     two edges for the same reason: the text is content and belongs on the content line,
     while the box may reach further out. Without it the chain rows kept a 9px inset the
     rows above and below them do not take, and the surface had three text columns. */
  .sheet .dp-sort {
    font-size: var(--fs-xl); min-height: 40px;
    margin-inline: calc(var(--sheet-gap) - var(--pad));
    padding-inline: calc(var(--pad) - var(--sheet-gap));
  }
  .sheet .dp-sort-act { width: 34px; height: 34px; }
  .sheet .dp-sort-gap { width: 34px; }
  .sheet .dp-sort-field, .sheet .dp-sort-dir { padding: 6px; }
}
/* ── .resetbtn — put this back the way it was ───────────────────────────────── */
/* There were two of these and they wore different colours: the Display menu's in
   `--ink-3`, Settings' sidebar-width one in the accent red. Neither is destructive —
   they restore, they don't remove — so red was wrong on the one that had it, and two
   colours for one act was wrong on both. Quiet at rest, since a reset is never the
   thing you came to the menu to do. `dp-reset`/`set-linkbtn` stay as hooks. */
.resetbtn {
  align-self: flex-start; background: none; border: 0; padding: 2px 0;
  font-size: var(--fs-base); color: var(--ink-3); cursor: pointer;
  display: inline-flex; align-items: center; gap: 6px;
}
.resetbtn .icn { width: 13px; height: 13px; }
.resetbtn:hover { color: var(--ink); text-decoration: underline; }
/* A column grouped by something without its own palette (agent, repo, priority)
   still gets a swatch — tinted where the field has a colour, neutral otherwise. */
.col-plain .swatch { background: var(--tint, var(--ink-3)); }
/* Where a dragged card would land, in Manual ordering. Only drawn while dragging,
   so it reads as a position and not as a divider. */
.drop-line {
  height: 2px; margin: 3px 2px; border-radius: var(--r-pill);
  background: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent);
}
/* A saved view's actions — only on hover/focus, so the sidebar stays calm and a saved
   view reads as navigation first. Always shown on touch, which has no hover to reveal
   it: the same rule `.col-more` follows, for the same reason. */
.saved-more-wrap { margin-left: auto; display: inline-flex; }
.saved-more {
  display: inline-flex; align-items: center; background: none; border: 0; padding: 0 2px;
  cursor: pointer; color: var(--ink-3); line-height: 1; opacity: 0; transition: opacity .12s;
}
.saved-more .icn { width: 14px; height: 14px; }
.focusbtn:hover .saved-more, .saved-more:focus { opacity: 1; }
.saved-more:hover { color: var(--ink); }
@media (hover: none) { .saved-more { opacity: 1; } }
/* The row is a <button> and so is this; the menu it opens is anchored in the wrapper,
   which must not inherit the row's own text alignment. Narrower than the shared
   minimum, because it has to clear the sidebar's own draggable minimum width (190px)
   and its three labels are short. */
.saved-more-wrap .pop { text-align: left; }
.saved-more-wrap .pop.pick-menu { min-width: 164px; }
.dp-save { color: var(--accent); }

/* The chip row: one chip per active predicate, under the view header. It is the
   filter's display — which is why the Filter button needs no count of its own. */
/* Symmetric padding, because this is a band of its own. It had none on top: the
   air above the chips was the *view header's* bottom padding, borrowed across a
   1px rule — which is not air this row owns. Every layout that hides or shrinks
   the header (mobile puts the title in the topbar) left the chips hard against
   the rule with 10px hanging below them. */
/* Capped, because the chrome above the scrollport is the one thing that can push the
   scrollport out of the shell. A long enough query wraps this row over several lines, and
   with a fixed-height shell there is no page left to scroll: measured at 390×360 with 20
   predicates the row was 295px tall, `.work` collapsed to *zero*, and the last chips —
   including the ones that remove the terms — sat 24px below the screen with nothing to
   scroll them into view. It scrolls itself instead, so the board always keeps its share
   and no control is ever unreachable. */
.chiprow {
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
  padding: 10px var(--pad); border-bottom: 1px solid var(--line);
  /* Two declarations, and the first is the load-bearing one: a browser without dynamic
     viewport units throws the `dvh` line away entirely, and an uncapped chip row is the
     collapse this cap exists to prevent — back in a browser that has no page scroll to
     rescue it either. The shell's height is written the same way, one line above the
     other, for the same reason. */
  max-height: 30vh;
  max-height: 30dvh;
  overflow-y: auto;
}
.fchip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 3px 4px 3px 9px; border: 1px solid var(--line); border-radius: var(--r-pill);
  background: var(--panel); font-size: var(--fs-base); color: var(--ink-2);
}
.fchip-label { white-space: nowrap; }
.fchip-x {
  background: none; border: 0; padding: 0 4px; cursor: pointer;
  color: var(--ink-3); font-size: var(--fs-sm); line-height: 1;
}
.fchip-x:hover { color: var(--ink); }
/* The row's actions, pinned right. `margin-left: auto` inside the wrapping flex
   means they sit at the end of the last chip line when there is room and drop to a
   line of their own — still right — when the chips fill the width. That is the
   phone case, and it is the shape Linear's filter bar has. */
.fchip-acts { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.fchip-clear { background: none; border: 0; font-size: var(--fs-base); color: var(--ink-3); cursor: pointer; padding: 0 4px; }
.fchip-clear:hover { color: var(--ink); text-decoration: underline; }
/* Bordered against the plain-text Clear beside it: one of these two changes what
   you are looking at, the other writes a file. They should not read alike. */
.fchip-save {
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-pill);
  padding: 4px 12px; font-size: var(--fs-base); color: var(--ink); cursor: pointer;
}
@media (hover: hover) { .fchip-save:hover { border-color: var(--accent); color: var(--accent); } }

/* Filter panel, two levels: fields, then that field's values. */
.fp-row, .fp-back {
  display: flex; align-items: center; gap: 8px; width: 100%;
  background: none; border: 0; padding: 6px 4px; border-radius: var(--r-sm);
  font: inherit; font-size: var(--fs-md); color: var(--ink); cursor: pointer; text-align: left;
}
.fp-row:hover, .fp-back:hover { background: var(--line); }
.fp-chev { width: 1.15em; height: 1.15em; color: var(--ink-3); }
.fp-row .fp-chev { margin-left: auto; }
/* When the row carries its current value, that span takes the free space and the
   chevron follows it. Two `auto` margins would split the space between them and park
   the value in the middle of the row instead of at its end. */
.fp-row .fp-cur + .fp-chev { margin-left: 0; }
/* The back chevron leads its label rather than standing a full row-gap from it. */
.fp-back .fp-chev { margin-right: -4px; }
.fp-back { color: var(--ink-2); }
.fp-values { display: flex; flex-direction: column; gap: 2px; max-height: 46vh; overflow-y: auto; }
/* On a phone the values are not a box inside the sheet — they *are* the sheet's
   content, and the one scrolling region runs from under the pinned field to the
   screen edge. `.pick-list` already had this rule; `.fp-values` is the same kind of
   list and was simply missed. A scroller inside a scroller gives the finger two
   places to land and neither of them the whole distance. */
@media (max-width: 640px) {
  .sheet .fp-values { max-height: none; overflow: visible; }
}
.fp-val {
  display: flex; align-items: center; gap: 8px; padding: 5px 4px; border-radius: var(--r-sm);
  font-size: var(--fs-md); color: var(--ink); cursor: pointer;
}
.fp-val:hover { background: var(--line); }
.fp-val input { accent-color: var(--accent); cursor: pointer; }
.fp-val .fp-n { margin-left: auto; color: var(--ink-3); font-family: var(--mono); font-size: var(--fs-xs); }
.fp-vlabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-row .fp-n { color: var(--accent); font-family: var(--mono); font-size: var(--fs-xs); }
.fp-toggle { display: flex; align-items: center; gap: 10px; font-size: var(--fs-md); color: var(--ink); cursor: pointer; }
/* `flex-start` only where there are two lines — a one-line row centres against its box the
   way every other control in this menu does, and switching all of them to top-alignment to
   serve the two-line case would move a row nobody asked about. */
.fp-toggle:has(.fp-toggle-hint) { align-items: flex-start; }
.fp-toggle:has(.fp-toggle-hint) input { margin-top: 1px; }
.fp-toggle-txt { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* The definition, not a second name: one step down the type scale and one step out of the
   ink, so the eye reads the row as a control with a footnote rather than two controls. */
.fp-toggle-hint { font-size: var(--fs-sm); color: var(--ink-3); }
/* A checkbox and not a switch, on purpose. A switch says "turn this feature on"; a
   checkbox says "include this in the set" — and that is exactly what these two do,
   in a menu whose other controls also *choose* (a segmented control, two selects).
   A switch would be a fourth affordance for the same menu.
   Drawn rather than native: at 13px the browser's box was the one control on the
   page that wasn't on the site's scale, next to 15px text. */
.fp-toggle input {
  appearance: none; -webkit-appearance: none; margin: 0; flex: none;
  width: 18px; height: 18px; border: 1px solid var(--ink-3); border-radius: var(--r-xs);
  background: var(--panel); cursor: pointer; position: relative;
}
.fp-toggle input:checked { background: var(--accent); border-color: var(--accent); }
/* The tick is drawn, not a glyph: a ✓ character rides the font's baseline and sits
   low in an 18px box whatever the line-height says. */
.fp-toggle input:checked::after {
  content: ""; position: absolute; left: 5px; top: 1px; width: 5px; height: 10px;
  border: solid var(--on-solid); border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.fp-toggle input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.fp-label { font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: var(--tr-eyebrow); text-transform: uppercase; color: var(--ink-3); margin-top: 2px; }
/* An eyebrow carries the same inset as the rows it labels, so the two start on one
   line. `.vs-section` has always matched `.row`'s 9px; the filter panel's bare label
   sat at 0 against rows inset by 4, which is where its four-pixel step came from.
   Scoped to `.pop` and away from `.vs-section`: the sheet pulls both onto the content
   line instead (see the `.sheet` rules), and the views eyebrow keeps its own 9px. */
.pop .fp-label:not(.vs-section) { padding-inline: 4px; }
.fp-chips { display: flex; flex-wrap: wrap; gap: 5px; }
.fp-chip {
  font: inherit; font-size: var(--fs-base); padding: 3px 9px; border-radius: var(--r-pill);
  border: 1px solid var(--line); background: var(--panel); color: var(--ink-2); cursor: pointer;
}
.fp-chip:hover { color: var(--ink); border-color: var(--ink-3); }
.fp-chip.on { color: var(--on-solid); border-color: transparent; background: var(--accent); font-weight: var(--fw-semibold); }
.fp-chip.on.pri-urgent { background: var(--urgent); }
.fp-chip.on.pri-high { background: var(--now); }
.fp-chip.on.pri-medium { background: var(--later); }
/* disciplines filter: search + scrollable chip list */
.fp-search {
  width: 100%; box-sizing: border-box; padding: 6px 9px; font: inherit; font-size: var(--fs-md);
  border: 1px solid var(--line); border-radius: var(--r-md); background: var(--bg); color: var(--ink);
}
/* iOS Safari zooms the page in whenever a focused field's font-size is under 16px,
   and there is no way back out except pinching. Every text control gets 16px on a
   phone — the touch targets want the extra height anyway.
   The alternatives are worse than the floor: `user-scalable=no` takes pinch-zoom from
   everyone to spare one field, and a `transform: scale()` on a 16px field leaves the
   element's layout box lying about its own size — the exact kind of lie that has cost
   this file two bugs. Where the floor is genuinely unwanted, the answer is to not have
   a native field: the two <select>s that used to force it here are `openSurface()`
   pickers now, so `select` is no longer in this list. */
@media (max-width: 640px) {
  input[type="text"], input[type="search"], input:not([type]), textarea { font-size: var(--fs-2xl); }
}
.fp-search:focus { outline: none; border-color: var(--accent); }
.fp-tags { display: flex; flex-wrap: wrap; gap: 5px; max-height: 168px; overflow-y: auto; }
.fp-chip.tag .fp-n { color: var(--ink-3); font-family: var(--mono); font-size: var(--fs-xs); margin-left: 4px; }
.fp-chip.tag.on .fp-n { color: color-mix(in srgb, var(--on-solid) 80%, transparent); }
.fp-empty { color: var(--ink-3); font-size: var(--fs-base); padding: 4px 0; }
.fp-more { align-self: flex-start; background: none; border: 0; padding: 2px 0; margin-top: 2px; font-size: var(--fs-base); color: var(--accent); cursor: pointer; }
.fp-more:hover { text-decoration: underline; }

/* read-only note on the puck detail (signed in, but no write access to this repo) */
.detail-note {
  margin: 4px 0 10px; padding: 7px 11px; font-size: var(--fs-md); color: var(--ink-2);
  background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md);
}

/* puck glyph on the detail page — git-commit tinted with the repo (project) colour */
.detail-icon { margin: 2px 0 8px; line-height: 0; }
.detail-icon svg { width: 26px; height: 26px; }

/* puck glyph on cards — a repo-colored leading mark before the title */
.card-head { display: flex; align-items: flex-start; gap: var(--rail, 7px); }
.card-head .card-title { flex: 1; min-width: 0; margin: 0; }
.puck-glyph { display: inline-flex; flex: none; line-height: 0; }
.card-head .puck-glyph { margin-top: 2px; }
.puck-glyph svg { width: 15px; height: 15px; }
.card-head .puck-glyph svg { width: var(--glyph, 15px); height: var(--glyph, 15px); }

/* discipline shortcuts in the ⌘K palette */
.suggest-tagmark { width: 16px; text-align: center; flex: none; color: var(--accent); font-family: var(--mono); font-weight: var(--fw-semibold); }
.suggest-hint { flex: none; color: var(--ink-3); font-size: var(--fs-sm); font-family: var(--mono); }
.suggest-createmark, .suggest-cmdmark { width: 16px; display: inline-flex; align-items: center; justify-content: center; flex: none; color: var(--accent); }
.suggest-createmark svg, .suggest-cmdmark svg { width: 14px; height: 14px; }
.suggest-cmdmark { color: var(--ink-3); }
.suggest-create { border-top: 1px solid var(--line); }
.suggest-create .suggest-title { color: var(--accent); }
.fp-chip.on.pri-low { background: var(--ink-3); }

/* breadcrumb: clickable Board segment → current puck */
.crumb-home { font: inherit; font-family: var(--mono); font-size: var(--fs-sm); color: var(--ink-2); background: none; border: 0; padding: 0; cursor: pointer; display: inline-flex; align-items: center; gap: 1px; }
.crumb-home:hover { color: var(--accent); }
.crumb-sep, .crumb-cur { color: var(--ink-3); }
/* The direction marks are punctuation, so they are sized in `em` and not in px:
   the same separator sits in an 11px mono crumb and in a 14px sans one, and a fixed
   16px icon would tower over the first and shrink against the second — which is
   exactly what the typographic › did right and the icon had to be taught. */
.crumb-sep, .crumb-home .icn { width: 1.15em; height: 1.15em; }

/* ── properties rail: value-chip + picker popover (Linear-style) ────────────── */
.prop-pick { position: relative; display: inline-flex; }
.pick-chip {
  display: inline-flex; align-items: center; gap: 6px; font: inherit;
  background: none; border: 1px solid transparent; border-radius: var(--r-md);
  padding: 3px 7px; margin: -3px -7px; color: var(--ink); cursor: pointer; text-align: left;
}
.pick-chip.editable:hover { background: var(--panel-2); border-color: var(--line); }
.pick-chip.static { cursor: default; padding: 0; margin: 0; }
/* Bare-text values need the box at rest — see propPicker's `boxed`. */
.pick-chip.boxed { border-color: var(--line); background: var(--panel-2); margin: 0; }
/* No caret: the chip *is* the control. A caret only helps where hover can reveal
   an affordance, which is nowhere on a phone — so the shape has to carry it. A set
   value already looks like something (a status pill, a badge); an empty one is
   plain muted text, so that is the one that gets drawn as a chip. */
/* An empty value is a *state* ("no priority"), not a call to action — and drawing
   every one of them as an outlined button turned a puck with nothing set into a
   form full of buttons. So: quiet by default, and the border arrives on reach.
   The padding stays either way, because it is the touch target — but it is paid
   back with a negative margin, or the text would sit 9px right of the values that
   carry no control, and the column's left edge would move from row to row
   depending on whether that field happens to be set. The chip already worked this
   way; the empty state has to as well, which is why the padding lives on the chip
   and not on the muted span inside it. */
.prop .linklike {
  border: 1px solid transparent; border-radius: var(--r-md); padding: 3px 9px; margin: -3px -9px;
  background: none; font: inherit; font-size: var(--fs-md); color: var(--ink-3); cursor: pointer;
}
.prop .linklike:hover, .prop .linklike:focus-visible,
.pick-chip.editable:hover, .pick-chip.editable:focus-visible {
  border-color: var(--line); background: var(--panel-2); color: var(--ink); text-decoration: none;
}
/* A finger has no hover, so the press is the feedback there. */
.prop .linklike:active, .pick-chip.editable:active {
  border-color: var(--line); background: var(--panel-2); color: var(--ink);
}
/* A trigger that carries the row's value looks like the value, not like a button —
   until you reach for it. */
.prop .prop-trigger.has-value { border-color: transparent; color: var(--ink); }
.prop .prop-trigger.has-value:hover { border-color: var(--line); }
/* The secondary form: a value already carries the row and this only changes it. */
.prop .prop-trigger.quiet { border: 0; padding: 2px 6px; margin: 0; font-size: var(--fs-xl); line-height: 1; color: var(--ink-3); }
/* The surface's secondary action: a row, the same shape as any other row in a
   picker, under the field it is an alternative to. */
.inputpop .alt-act { width: 100%; justify-content: flex-start; color: var(--accent); margin-top: 2px; }
/* The shell (position, background, border, shadow) comes from .pop / .sheet — this
   carries only what a menu of options needs, so the same markup works in both. */
.pop.pick-menu { min-width: 190px; padding: 4px; }
.pick-menu .surface-body { gap: 1px; }

/* Sized in `em` like the other marks that sit in a line of text: the same check
   appears in a 13px desktop menu row and a 15px sheet row. */
.pick-check { margin-left: auto; color: var(--accent); flex: none; width: 1.15em; height: 1.15em; }

/* ── mobile: view title moves into the topbar (fills the ☰ … 🔍 dead band) ──── */
.top-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; flex: 1; }
.top-title .view-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The title lives in the topbar on every width now, so the vhead carries only the
   view-options (filter / sort / layout) — the "body" toolbar. */
.vhead .view-switch { display: none; }
/* Spans, so `.vacts`' own auto-margin has somewhere to push to. Shrink-to-fit, the
   actions sat hard against Display and read as a third opener rather than as the thing
   you do *to* what the two of them changed. The h2 beside it is `display: none` here, so
   growing costs nothing else on the row. */
.vhead .vopts { margin-left: 0; flex: 1; }

/* ── uniform control height across the topbar + view-header ─────────────────── */
.topbar .ghost,
.vhead .ghost,
.vhead .filter-btn,
.vhead .filter-sort select {
  height: 38px;
  display: inline-flex;
  align-items: center;
  padding-top: 0;
  padding-bottom: 0;
  line-height: 1;
}
.topbar .ghost { justify-content: center; }
.top-new svg { width: 18px; height: 18px; }

/* ── mobile: keep the app header above the puck overlay ─────────────────────── */
/* The overlay backdrop is z-index 50; the topbar sits just above it (but below the
   menu drawer at 55/60) so the header stays visible and ☰ / search stay tappable
   while a puck is open. openOverlay() offsets the backdrop below the topbar. */
@media (max-width: 899px) {
  .topbar { z-index: 51; }
}

/* ── puck page: Overview / Activity tabs + git-history list ─────────────────── */
/* The two gaps that set the page's rhythm: title → tabs, and the rule under the
   tabs → the first property. Both were tight enough that the title read as part
   of the tab strip and the rule as part of the table under it. */
/* The strip is one row: tabs left, the puck's ⋯ menu right. `align-items: flex-end`
   is what lets the active tab's 2px underline sit on the rule while a taller
   control on the right keeps its own height. */
.detail-tabs {
  display: flex; align-items: flex-end; justify-content: space-between; gap: 8px;
  margin: 20px 0 24px; border-bottom: 1px solid var(--line);
}
.tab-list { display: flex; gap: 2px; min-width: 0; }
/* Same shape as the topbar's share button — panel fill, hairline border, the same
   radius — because it is the same kind of thing: the one control on this page that
   acts on the file rather than on a field. Drawn as a quiet borderless glyph first,
   it was invisible: nothing said it was pressable, and two readers in a row failed
   to find Delete inside it. A shade smaller than share, since it sits beside the
   tabs rather than in the chrome. */
.puck-more { margin-bottom: 4px; }
.puck-more .btn { color: var(--ink-2); }
.puck-more .btn svg { width: 16px; height: 16px; }
@media (hover: hover) {
  .puck-more .btn:hover { border-color: var(--accent); color: var(--ink); }
}
.tab-btn {
  font: inherit; font-size: var(--fs-md); background: none; border: 0;
  border-bottom: 2px solid transparent; margin-bottom: -1px; padding: 8px 10px;
  color: var(--ink-3); cursor: pointer;
}
.tab-btn:hover { color: var(--ink-2); }
.tab-btn.on { color: var(--ink); border-bottom-color: var(--accent); font-weight: var(--fw-semibold); }

.activity-loading, .activity-empty { color: var(--ink-3); font-size: var(--fs-md); padding: 12px 0; }
.activity-list { list-style: none; margin: 4px 0 0; padding: 0; }
.activity-item { display: flex; gap: 11px; padding: 11px 0; border-top: 1px solid var(--line); }
.activity-item:first-child { border-top: 0; }
.activity-gutter { flex: none; display: flex; align-items: center; justify-content: center; width: 22px; margin-top: 1px; }
.activity-avatar { width: 22px; height: 22px; border-radius: 50%; display: block; background: var(--line); }
.activity-gutter .activity-glyph { width: 16px; height: 16px; color: var(--ink-3); }
.activity-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-3); flex: none; }
.activity-body { min-width: 0; }
.activity-line { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.activity-line .activity-glyph { width: 14px; height: 14px; color: var(--ink-3); flex: none; }
.activity-line .status-pill { flex: none; }
.activity-line .pri { flex: none; }
.activity-msg { color: var(--ink); text-decoration: none; font-size: var(--fs-md); overflow-wrap: anywhere; }
.activity-msg:hover { color: var(--accent); }
.activity-meta { color: var(--ink-3); font-size: var(--fs-base); font-family: var(--mono); margin-top: 2px; }

/* ── Discussion tab: the linked issue + its comments ────────────────────────── */
.disc-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; flex-wrap: wrap; }
.disc-state { font-size: var(--fs-sm); border-radius: var(--r-pill); padding: 2px 9px; text-transform: uppercase; letter-spacing: var(--tr-caps); border: 1px solid currentColor; }
.disc-open { color: var(--next); }
.disc-closed { color: var(--done); }
.disc-title { color: var(--ink); text-decoration: none; font-weight: var(--fw-semibold); font-size: var(--fs-lg); }
.disc-title:hover { color: var(--accent); }
.disc-comment { padding: 12px 0; border-top: 1px solid var(--line); }
.disc-comment:first-of-type { border-top: 0; }
.disc-cmeta { font-family: var(--mono); font-size: var(--fs-base); color: var(--ink-3); margin-bottom: 4px; }
.disc-body { font-size: var(--prose); line-height: var(--lh-prose); color: var(--ink-2); overflow-wrap: anywhere; }
.disc-body :is(h1,h2,h3,h4) { font-size: var(--prose); margin: 12px 0 6px; color: var(--ink); }
.disc-body p { margin: 0 0 8px; }
.disc-body code { background: var(--panel-2); padding: 1px 5px; border-radius: var(--r-xs); font-size: 0.92em; }
.disc-body pre { background: var(--panel-2); border: 1px solid var(--line); border-radius: var(--r-md); padding: 10px 12px; overflow-x: auto; }
.disc-body a { color: var(--accent); }

/* ── overlay primitive: one surface, two presentations ──────────────────────── */
/* Anchored popover on a wide screen, bottom sheet on a phone. The content is
   written once; only the shell differs. `.surface-body` is what the builder fills,
   so a list, a calendar or a form all scroll the same way. */
.pop {
  position: absolute; top: calc(100% + 5px); left: -1px; z-index: 45;
  background: var(--panel); border: 1px solid var(--line); border-radius: var(--r-lg);
  box-shadow: var(--shadow);
}
.pop-center { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); }
/* A popover hangs from the left edge of its trigger by default. A trigger sitting
   at the right edge of its own row needs the mirror, or the menu runs off-page. */
/* `.pop.` and not `.menu-right` alone: on a phone the same surface is a `.sheet`,
   which owns left/right itself — a bare class here would edge it off-screen. */
.pop.menu-right { left: auto; right: -1px; }
/* Which *side* a menu hangs from is authored (`menu-right`); which *end* is measured.
   A rail control near the bottom of a puck page has no room below it at any width, so
   `fitPop` flips the surface above its trigger rather than capping it to nothing. The
   5px is the same gap the unflipped rule uses, and `fitPop` reads it rather than
   restating it. */
.pop.pop-flip { top: auto; bottom: calc(100% + 5px); }
.menu-rule { height: 1px; background: var(--line); margin: 4px 6px; }
/* A menu row's icon is the quiet half of the pair — except on a danger row, where
   the whole row is one colour and a grey glyph would read as disabled. */
.pick-menu .row > .icn { width: 15px; height: 15px; color: var(--ink-3); }
.pick-menu .row.danger > .icn { color: currentColor; }
.surface-body { display: flex; flex-direction: column; }

@media (max-width: 640px) {
  .sheet-scrim { position: fixed; inset: 0; z-index: 58; background: rgba(0, 0, 0, 0.35); touch-action: none; }
  .sheet {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 59;
    display: flex; flex-direction: column;
    /* The resting detent. It used to be 88dvh, six points under `.full` — so a sheet
       already stood at nearly full height and "drag up to see more" had almost
       nothing to reveal. A medium rest is what makes the gesture mean something:
       the sheet grows to full first, and only then does its content scroll. */
    max-height: 60dvh;
    background: var(--panel); border-top: 1px solid var(--line);
    border-radius: 16px 16px 0 0; box-shadow: var(--shadow);
    /* The sheet's own edge inset is `--sheet-gap`, not `--pad`: the widest thing it
       ever draws is a lit row, and that row leaves `--sheet-gap`. Every *content*
       column inside adds the rest, so text still lands on the page's `--pad` line.
       Doing it the other way — insetting the sheet to `--pad` and letting the row
       bleed out with a negative margin — cannot work, because the scroll container
       clips at its padding box: the row laid out at 8 and painted at 22.
       The bottom is deliberately larger than the sides: the last control used to end
       10px above the screen edge, where the home indicator is and a thumb isn't. */
    padding: 6px var(--sheet-gap) calc(24px + env(safe-area-inset-bottom));
  }
  /* The grip is the handle: drag down to dismiss, up to go full height. The whole
     sheet listens, but only the chrome starts a drag unconditionally — from the
     list, a downward drag only takes over once the list is already at the top. */
  .sheet-grip {
    width: 40px; height: 5px; border-radius: var(--r-pill); background: var(--line);
    margin: 4px auto 14px; flex: none; cursor: grab;
  }
  .sheet {
    transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
                height 0.22s cubic-bezier(0.2, 0.8, 0.2, 1),
                max-height 0.22s ease;
    touch-action: pan-y;
  }
  /* While the finger is down it owns the sheet: no transition, and the resting cap
     lifts to the full detent so growing isn't clipped halfway there. */
  .sheet.dragging { transition: none; max-height: 94dvh; }
  /* Maximised means *taller*, so it needs a height — a bigger max-height alone does
     nothing to a sheet that is already sized to its content. */
  .sheet.full { height: 94dvh; max-height: 94dvh; }
  .sheet-grip, .sheet-head { touch-action: none; }
  /* The sheet takes focus itself on open (that is what makes it a dialog to a
     screen reader), but it isn't a control — so no ring on the container. The
     controls inside keep theirs. */
  .sheet:focus { outline: none; }
  /* Keep overscroll from chaining out to the page behind the sheet — on the sheet
     itself too, not just its list: a short sheet (the one-field editor) has nothing
     scrollable inside, so a drag there would otherwise reach the page. */
  .sheet { overscroll-behavior: contain; }
  /* `none` och inte `contain` för listan: `contain` stoppar bara kedjan vidare till
     sidan, studsen på plats blir kvar — och den studsen är fel person i det här arket.
     Vid listans topp är nedåt *arkets* gest, så webbläsarens gummiband öppnar ett band
     ovanför den fastnålade raden där listan syns röra sig (WebKits sticky halkar
     dessutom en bildruta under studsen, så en rad blinkar fram i glappet). Med `none`
     tar listan slut vid sin topp, och resten av fingerresan är vår att lämna över till
     arket (se `handoff` i app.js). */
  .sheet .surface-body { overscroll-behavior: none; }
  /* The title needs room on both sides of it: pressed up against the grip above and
     the field below, it read as a label on the field rather than the sheet's name. */
  .sheet-head { display: flex; align-items: center; gap: 8px; padding: 0 2px 18px; flex: none; }
  .sheet-title {
    margin: 0 auto; font-size: var(--fs-xl); font-weight: var(--fw-semibold); color: var(--ink);
    /* centred like a native sheet title, with the ? pulled out of the flow.
       --fs-xl and not --fs-lg: the rows inside a sheet step up to --fs-xl, and a
       heading that sets smaller than the list it heads is not a heading. */
  }
  /* The way back sits *on the title line*, not in a row of its own below it — the shape
     every native sheet uses, and the one a phone reported the absence of: a back row inside
     the body is labelled with the level you are standing in, which reads "you are here",
     and its label can never line up with the rows beneath it because it sits one chevron in.
     Absolute like the `?`, so the title stays centred in the *head* rather than in whatever
     space a flex sibling leaves it. */
  .sheet-back {
    position: absolute; left: 2px; width: 30px; height: 30px; padding: 0;
    border: 0; background: none; color: var(--ink-2); cursor: pointer;
    display: inline-flex; align-items: center; justify-content: center;
    border-radius: var(--r-sm);
  }
  .sheet-back .icn { width: 1.15em; height: 1.15em; }
  .sheet-help {
    position: absolute; left: 12px; width: 22px; height: 22px; border-radius: 50%;
    border: 1px solid var(--line); color: var(--ink-3); font-size: var(--fs-base);
    display: inline-flex; align-items: center; justify-content: center; text-decoration: none;
  }
  /* The sheet does NOT resize for the keyboard — it keeps its height and the
     keyboard covers the bottom. The pinned field stays put and the scroll position
     survives. `kb` pads the scroll area while a field is focused so the LAST row
     can still be scrolled above the keyboard; without it, it would be unreachable. */
  /* No `-webkit-overflow-scrolling: touch` here, deliberately. It switches iOS into
     its legacy scrolling mode, where a `position: sticky` child detaches during
     momentum: the list keeps moving while the pinned field lags a frame behind, and
     rows draw *above* it. Momentum has been the default since iOS 13 and the
     property is deprecated, so the only thing it still buys is that bug. */
  /* The content column: the sheet is inset to `--sheet-gap`, this adds the rest up to
     `--pad`. Because it is padding and not a margin, the scroller's *padding box* —
     which is also its clip edge — reaches all the way out to `--sheet-gap`, so a row
     that bleeds that far is painted instead of cut off. */
  .sheet .surface-body { overflow-y: auto; gap: 8px; padding-inline: calc(var(--pad) - var(--sheet-gap)); }
  /* Below the full detent the list does not scroll: a drag there grows the sheet.
     This is not politeness, it is the only way the gesture can be unambiguous — with
     both live, the browser pans the list from the same finger the sheet is reading,
     and `preventDefault` cannot take a pan back once it has begun. So the sheet is
     the only thing that can move until there is no sheet left to reveal. */
  .sheet:not(.full) .surface-body { overflow-y: hidden; touch-action: none; }
  /* Och gesten måste *deklareras* som arkets, inte bara hoppas på. `.sheet` bär
     `touch-action: pan-y`, som bjuder in webbläsaren att ta det vertikala draget —
     och tar den det, kommer `pointercancel` innan arket hunnit röra sig. Med
     scrollningen avstängd i samma regel hade draget då sett ut att göra ingenting,
     medan draghandtaget (`touch-action: none`) fungerade.
     Grundvärdet är därför `none` även vid full höjd: panoreringen lämnas tillbaka
     till listan av `syncPan()` i app.js, som skriver `pan-y` inline först när
     listans scrollposition bevisar att den har någonstans att ta vägen. Regeln kan
     inte stå här, för den beror på ett mätvärde som ändras medan arket lever. */
  .sheet.full .surface-body { touch-action: none; }
  .sheet.kb .surface-body { padding-bottom: 42dvh; }
  /* The field is wrapped in .sheet-pin (see openSurface) and that wrapper is what
     stays put — with tokens the input sits inside .tokenbox, so pinning the input
     itself pins nothing. The padding is the gap before the list: it belongs to the
     pinned box, so rows scroll *under* it instead of through it.
     It also reaches back out to the clip edge (and pads its own content back in), so
     the rows scrolling under it are hidden across the sheet's full width — otherwise
     a lit row would show through in the 14px band beside the field. */
  /* `top: -1px` med en pixels egen padding och marginal tillbaka: nålen hänger en
     pixel ut över scrollerns klippkant, innehållet står kvar på samma rad. Sömmen
     mellan klippkanten och nålens målade överkant avrundas till olika enhetspixlar vid
     en bråkdels scrollposition — vilket en tröghetsscroll ger hela tiden — och listan
     blinkade fram i hårfinan. Går inte att återskapa i Chromium (0 genomlysande
     enhetspixelrader i 2x och 3x, vid åtta bråkdelslägen), så överhänget är
     försiktighet mot WebKits sticky-komposition, inte en mätt rättelse. */
  .sheet .surface-body > .sheet-pin {
    position: sticky; top: -1px; z-index: 1; background: var(--panel); padding-bottom: 12px;
    margin-top: -1px; padding-top: 1px;
    margin-inline: calc(var(--sheet-gap) - var(--pad));
    padding-inline: calc(var(--pad) - var(--sheet-gap));
  }
  .sheet .pick-list { max-height: none; overflow: visible; }
  /* Thumb-sized rows whose *text* sits on the page's content line, with the selected
     row's background reaching back out to `--sheet-gap`. Two different things want
     two different edges: the text is content and belongs on the line; the highlight
     is a state, and boxing it inside the line makes it read as another element rather
     than as the row being lit.
     `width: auto`, not the base's 100%: with a fixed width the negative margins shift
     the row left instead of widening it, so it bled on one side and fell short on the
     other. Auto lets the flex column stretch it, and the margins then push both edges
     out by the same amount — to the scroller's clip edge, and no further. */
  .sheet .row {
    width: auto;
    margin: 0 calc(var(--sheet-gap) - var(--pad));
    padding: 12px calc(var(--pad) - var(--sheet-gap));
    font-size: var(--fs-xl);
  }
  /* Rules span the content, like the rows they separate. */
  .sheet .menu-rule { margin: 6px 0; }
  /* A section eyebrow is content too, so it belongs on the same line as the row text
     above and below it. `.vs-section` carries its own 9px inset for the popover, where
     the rows are inset by the same 9px and the two agree. In a sheet the rows are inset
     by `--pad` instead (and pulled back out by the matching negative margin), so that
     9px stopped being a shared measure and became a 9px step no other item takes.
     Zero here, not a new number: the content line is the `.surface-body` padding box,
     which is exactly where this element already starts. */
  .sheet .vs-section { padding-inline: 0; }
  /* The filter panel's rows are the same story with a smaller number: their base 4px
     inset is a shared measure in the popover and a 4px step in a sheet, where the
     content line is `--pad`. They get the row treatment rather than a padding tweak,
     because they light on hover too — and a highlight that stops short of the edge
     reads as an element inside the row instead of as the row itself. `width: auto` for
     the reason the note above gives: with a fixed width the negative margins move the
     box instead of widening it. */
  .sheet .fp-row, .sheet .fp-back, .sheet .fp-val {
    width: auto;
    margin-inline: calc(var(--sheet-gap) - var(--pad));
    padding-inline: calc(var(--pad) - var(--sheet-gap));
  }
  /* Every control in a sheet is thumb-sized, not just the list rows. These two were
     20px and 18px tall inside a menu whose other rows are 37 — the toggle read as
     half a row, and "Reset to default" was a text-height target against the screen
     edge. */
  .sheet .fp-toggle { min-height: 40px; }
  .sheet .resetbtn { min-height: 40px; align-items: center; display: inline-flex; }
  /* A sheet is a thumb surface, and that governs its *text* as much as its targets:
     the rows step up to --fs-xl, and the labels and controls beside them follow. It
     started as a fix for a native <select> stuck at the 16px iOS floor while its
     neighbours sat at 13 — the select is a picker now, but the step is right on its
     own terms and stays. */
  .sheet .dp-label,
  .sheet .resetbtn,
  .sheet .segmented-btn,
  .sheet .fp-toggle,
  .sheet .fp-row,
  .sheet .fp-back,
  .sheet .fp-val { font-size: var(--fs-xl); }
  .sheet .dp-rule { margin: 6px 0; }
}

/* ── date picker: a field and a grid, bound to each other ───────────────────── */
.pop.datepop { min-width: 268px; padding: 8px; }
.datepop .surface-body { gap: 8px; }
.date-input { font-variant-numeric: tabular-nums; }
.cal-head { display: flex; align-items: center; gap: 4px; padding: 2px 2px 6px; }
.cal-month { font-size: var(--fs-md); color: var(--ink); font-weight: var(--fw-semibold); }
.cal-nav {
  font: inherit; font-size: var(--fs-xl); line-height: 1; background: none; border: 0;
  color: var(--ink-2); cursor: pointer; padding: 2px 7px; border-radius: var(--r-sm);
}
.cal-nav:first-of-type { margin-left: auto; }
@media (hover: hover) { .cal-nav:hover { background: var(--panel-2); color: var(--ink); } }
.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.cal-wd { font-size: var(--fs-xs); color: var(--ink-3); text-align: center; padding-bottom: 4px; }
.cal-day {
  font: inherit; font-size: var(--fs-md); font-variant-numeric: tabular-nums;
  background: none; border: 0; border-radius: var(--r-md); padding: 7px 0; color: var(--ink);
  cursor: pointer; text-align: center;
}
.cal-day.out { color: var(--ink-3); opacity: 0.55; }
.cal-day.today { box-shadow: inset 0 0 0 1px var(--line); }
.cal-day.on { background: var(--accent); color: var(--bg); }
@media (hover: hover) { .cal-day:hover:not(.on) { background: var(--panel-2); } }
.date-foot { display: flex; align-items: center; gap: 6px; padding-top: 4px; border-top: 1px solid var(--line); }

@media (hover: hover) { .date-act:hover { color: var(--ink); border-color: var(--accent); } }
.date-clear { margin-left: auto; border-color: transparent; color: var(--ink-3); }
@media (max-width: 640px) {
  .datepop .cal-day { padding: 11px 0; font-size: var(--fs-lg); }
  .datepop .date-act { padding: 9px 14px; font-size: var(--fs-md); }
}

/* ── token field: the chosen values live inside the search box ──────────────── */
/* One control shows the current value and takes the query — the shape the
   reference pickers use, and the reason a multi-select needs no second row. */
.tokenbox {
  display: flex; flex-wrap: wrap; align-items: center; gap: 4px;
  border: 1px solid var(--line); border-radius: var(--r-md); padding: 4px 5px; background: var(--panel-2);
}
.tokenbox .token-input {
  flex: 1 1 90px; min-width: 90px; border: 0; background: none; padding: 3px 2px; margin: 0;
}
.tokenbox .token-input:focus { outline: none; }
.token { font-size: var(--fs-sm); background: var(--panel); padding: 1px 4px 1px 6px; }
.token-x { font: inherit; font-size: var(--fs-xs); line-height: 1; background: none; border: 0; padding: 2px; color: var(--ink-3); cursor: pointer; }
@media (hover: hover) { .token-x:hover { color: var(--ink); } }
.token .repo-dot { flex: none; }
.token-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 15ch; }

/* ── one-field surface: what window.prompt used to do, in our own shell ─────── */
.pop.inputpop { min-width: 260px; max-width: min(340px, calc(100vw - 32px)); padding: 8px; }
.inputpop .surface-body { gap: 8px; }
.inputpop .fp-empty { padding: 0 2px; color: var(--ink-3); font-size: var(--fs-sm); line-height: var(--lh-snug); }

/* ── puck picker: the control for a field whose value is another puck ───────── */
/* Ours, not the browser's: window.prompt hands the dialog to the OS, which on iOS
   draws a system sheet in its own shape and colours. This borrows the picker
   popover's shell and the filter panel's search box, so it reads as one app. */
.pop.pick-find { min-width: 260px; max-width: min(340px, calc(100vw - 32px)); padding: 6px; }
.pick-find .surface-body { gap: 6px; }
.pick-find .fp-search { margin-bottom: 2px; }
.pick-list { display: flex; flex-direction: column; gap: 1px; max-height: 46vh; overflow-y: auto; }
.pick-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pick-repo { margin-left: auto; padding-left: 8px; color: var(--ink-3); font-size: var(--fs-sm); flex: none; }
/* Search-and-create, in the shape ⌘K already uses for the same offer: an accent
   mark, an accent title, and a rule that separates a row which *makes* something
   from the rows that merely pick. The repo rides in `.pick-repo` — same slot the
   list uses to name a foreign repo, because it answers the same question: where
   would this land. */
.pick-create { border-top: 1px solid var(--line); }
.pick-create .pick-title { color: var(--accent); }
.pick-createmark { width: 1.15em; height: 1.15em; color: var(--accent); flex: none; }
.row .repo-dot { flex: none; }
.pick-find .fp-empty { padding: 8px 9px; color: var(--ink-3); font-size: var(--fs-base); }

/* On a phone every surface is a bottom sheet (see the overlay primitive), so the
   width/height caps above apply to the popover only. */
@media (max-width: 640px) {
}

/* ── a parent's members ────────────────────────────────────────────────────────
   A comma-separated line works for two pucks and collapses at eight. These are
   rows, so the parent page is where the parent is run. */
.sect-with-badge { display: flex; align-items: center; gap: 8px; }
/* The rows pay their own padding back, so a member title lands on the page's content
   line (22px) with every heading and paragraph in the section, and the trailing pill
   ends on the right one. Without it the list sat 9px in from everything around it. */
.members { display: flex; flex-direction: column; gap: 1px; margin: 2px -9px 0; }
.member { gap: 9px; }
/* A badge is right for *one* value on a page — the rail, the picker. Five outlined
   capsules down a column become the loudest thing in the section, and the section is
   a list, not five buttons. The word and its colour do the work here; the outline is
   what made it a badge and is what has to go. (`.badge--quiet` says the same thing
   for tags: same family, same escape hatch.) */
.member .status-pill {
  border-color: transparent; background: none; padding-right: 0;
  font-size: var(--fs-xs);
}
/* Natural width, and last in the row — see memberRow. A fixed width clipped Inbox
   and would have had to be 87px for Cancelled. */
.member .status-pill { flex: none; }
.member-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.member.done .member-title { color: var(--ink-3); text-decoration: line-through; }
.member-date { font-family: var(--mono); font-size: var(--fs-xs); color: var(--ink-3); flex: none; }
/* The add control sits in the list as its own row, not as a chip beside it. */
.members .prop-pick { display: block; }
.members .prop-trigger { width: 100%; text-align: left; padding: 7px 9px; margin: 0; color: var(--ink-3); }

/* ── dependencies: the Blocked by / Blocks rows ─────────────────────────────── */
/* `depends:` is the only authored half; the row edits it in place, one chip per
   declared blocker (landed ones struck through so they can still be removed). */
.prop-blockers { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
.dep-chip { display: inline-flex; align-items: center; gap: 3px; max-width: 100%; }
.dep-x {
  font: inherit; font-size: var(--fs-xs); line-height: 1; background: none; border: 0; padding: 1px 2px;
  color: var(--ink-3); cursor: pointer; flex: none;
}
@media (hover: hover) { .dep-x:hover { color: var(--ink); } }

/* ── parent: the level above a puck (rollup badge + membership chip) ─────────── */
/* A parent is a puck with children, so its marks live on the same card meta row
   as every other badge — no second card type, no second visual language. */
.rollup {
  cursor: help;
  /* No proportion mark. The count carries it, and at 1–5 parts it carries it better
     than any shape can — which is the argument that ruled out a ring, and it rules
     out a bar just as squarely. The bar was never argued for on its own merits: it
     was a rescue after a fill behind the digits lost the contrast fight, and a rescue
     of an idea that did not hold is still not a design.
     Seen on a device it also had a fault of its own. A straight rule painted along
     the bottom of a fully rounded pill is clipped at both ends by the radius, so it
     begins and ends at nothing and reads as an underline of "2/" rather than a
     measure. The two variants that fix that both need a squarer chip — and paying
     the badge language for a mark that adds nothing is the wrong trade. */
  background-repeat: no-repeat;
}
.rollup .icn { width: 11px; height: 11px; }
/* A state inside a badge changes its *colour*, never its construction. Every badge
   carries `1px solid var(--line)`, and in light mode --line (#e6e2d9) sits a hair
   from the fill (#ebe8e1) — so the ring is invisible by design and the chip reads as
   a fill. This rule used to darken that ring for the complete state, which made the
   only thing the eye caught "this one is outlined and that one isn't" — a fact the
   reader has to be told the meaning of. `--done` is the board's quieting colour
   (2.9:1 against the fill where --ink-2 is 4.7); a finished parent recedes here the
   same way a done card does everywhere else, and the numbers carry the rest. */
/* Complete: the fill is the whole chip, so it says "done" on its own and the text
   recedes with it — the same quieting `--done` every finished thing wears here. */
.rollup.full { color: var(--done); }
.rollup-n { font-variant-numeric: tabular-nums; }
.parent-chip {
  display: inline-flex; align-items: center; gap: 3px; min-width: 0; flex: 0 1 auto;
  font: inherit; font-size: var(--fs-sm); background: none; border: 0; padding: 0;
  color: var(--ink-3); cursor: help;
}
.parent-link { cursor: pointer; }
@media (hover: hover) { .parent-link:hover { color: var(--accent); } }
/* On a card the repo name sits immediately before this, and in a repo that is itself
   called Etapp the two read as one phrase ("Etapp ⑂ GUI-hantverk"). The meta row
   separates its parts by gap alone, so the chip brings its own divider — the same one
   `.issue-newbtn` uses. Not in the list row, where the chip follows the title and the
   repo has a cell of its own. */
.card-meta .parent-chip::before { content: "·"; color: var(--ink-3); margin-right: 3px; }
.parent-chip .icn { width: 11px; height: 11px; flex: none; }
.parent-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-head .rollup, .list-head .rollup { margin-left: 4px; }
/* These carried no colour outside the old modal's scope, so in the rail they fell
   back to the browser's blue underline — the only such link on the page. */
.prop .blocker-link { color: var(--accent); text-decoration: none; }
@media (hover: hover) { .prop .blocker-link:hover { text-decoration: underline; } }
.blocker-link.done { color: var(--ink-3); text-decoration: line-through; }
.prop .blocker-link.done { color: var(--ink-3); }

/* ── PO-layer: agent routing (card badge + sidebar queues) ──────────────────── */
/* The routing mark. `.icn.agent-glyph` and not `.agent-glyph` alone — and to be exact
   about what that buys: a bare class would work *today*, because this rule already sits
   later in the file than `.icn` and wins the tie on order. It is written qualified
   because that is an accident of position, not a rule, and `.lh-caret` is what the
   accident costs when it goes the other way: it declared 13px, sat above `.icn`, and
   drew at 16 for its whole life. Sabotage confirms the honest version of this — removing
   the qualifier changes nothing measurable right now; it is insurance against a future
   move of either block, not a fix for a present bug.
   Two sizes because the mark sits in two type sizes: --fs-xs in a card badge, --fs-md in
   a sidebar row. One size for both left the badge wearing a glyph taller than its own
   text.
   12px and not 11: measured in real pixels, at 11 the two eyes start to merge into the
   dome and the antenna loses its length, which is the same failure `parent` avoids by
   being dots instead of an outline. A face that has stopped having eyes is just a blob,
   and then the glyph says less than the arrow it replaced. */
.icn.agent-glyph { width: 12px; height: 12px; color: var(--accent); }
.side-repos .chip.agent .icn.agent-glyph { width: 13px; height: 13px; }
.agent-inline { display: inline-flex; align-items: center; }
.side-repos .chip.agent { color: var(--ink-2); gap: 5px; }
.side-repos .chip.agent[aria-pressed="true"] { background: var(--panel-2); color: var(--ink); }

/* ── mobile topbar goes contextual when a puck is open (Linear-style) ───────── */
/* Puck mode keeps the hamburger; the board title/actions swap for a breadcrumb
   (Pucks › Title) + a share button. Board mode hides the puck-only chrome. */
.topbar .top-crumb, .topbar .top-share-btn { display: none; }
.top-crumb {
  flex: 1; min-width: 0; display: flex; align-items: center; gap: 5px;
  font-size: var(--fs-lg); color: var(--ink-3); /* sans-serif (inherit) → more compact than mono */
}
.crumb-back { font: inherit; background: none; border: 0; padding: 0; color: var(--ink-2); cursor: pointer; flex: 0 1 auto; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.crumb-back:hover { color: var(--accent); }
.top-crumb .crumb-sep { color: var(--ink-3); flex: none; }
.crumb-title { color: var(--ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
body.viewing-puck .topbar .top-title,
body.viewing-puck .topbar .top-new,
body.viewing-puck .topbar .top-search { display: none; }
body.viewing-puck .topbar .top-crumb { display: flex; }
body.viewing-puck .topbar .top-share-btn { display: inline-flex; }
.top-share-btn { margin-left: auto; }
.top-share-btn svg { width: 17px; height: 17px; }
.menu-toggle svg { width: 17px; height: 17px; }
/* icon-only topbar buttons: guarantee a square, comfortable tap target */
.topbar .ghost { min-width: 38px; }
/* The topbar carries the breadcrumb on every width → drop the in-content one. */
body.viewing-puck .detail-crumb { display: none; }

/* ── optional config-driven ribbon (e.g. the live-demo strip) ──────────────── */
.demo-ribbon {
  display: block; text-align: center; text-decoration: none;
  font-family: var(--mono); font-size: var(--fs-md); font-weight: var(--fw-medium);
  padding: 9px 12px; background: var(--accent); color: var(--on-solid);
}
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) .demo-ribbon { color: #140d08; } }
.demo-ribbon b, .demo-ribbon strong { font-weight: var(--fw-semibold); }
