/* ==========================================================================
   A tea garden: near-black moss ink on warm white, with a single gold accent
   held back for focus, the active nav item and the primary action. Status hues
   are the one exception — a board is unreadable without them, so each is a
   muted earth tone, distinguishable from the others and legible on its own
   soft fill. This is the adopted "苔と石 / Moss & Stone" palette; the document,
   the alternatives it was chosen over and the contrast rules live in
   docs/design-system.md.
   ========================================================================== */

/* ==========================================================================
   Component conventions

   Seven components were invented in place while the board was being built, each
   with its reasoning written at its own rules and nowhere else. That is fine
   until the eighth: the next person adding a control on a fifty-row table has
   no way to know these decisions were made, and re-litigates them. Collected
   here as the rules, with the arguments left where they are.

   THE PILL-WRAPPED SELECT (.status > .status-select, ~"Inline row editing")
   A control that edits a value in a scannable column is dressed as the value.
   The <select> is stripped of its native chevron and box and wrapped in the
   very pill the cell used to print, so the colour, the dot and the alignment
   of fifty rows survive the cell becoming editable. It admits to being a
   control on hover and on focus, never at rest. Its width is reserved for the
   longest label so the column still lines up.

   THE ASSIGNEE CHIP AND ITS REMOVE (.assignee-chip, .chip-remove)
   Names are printed, each with its own way off, and the picker beside them
   ADDS rather than replaces — a cell that overwrites drops whoever else was on
   the issue without saying so. The × is muted, never hidden: a control that
   appears on hover cannot be found by touch and cannot be tabbed to.

   THE STICKY BULK BAR (.bulk-bar)
   Appears only once something is selected, and overlays rather than reflows:
   revealing an in-flow bar shifts the table under the cursor mid-click. Its
   scope is deliberately the rows on screen, never "everything matching the
   filter" — one click must not be able to reassign the whole board.

   THE FILTER DISCLOSURE (details.filter-more)
   The list is the subject of the page, so the controls for narrowing it are
   collapsed — but open whenever a filter inside them is active, and their
   summary names what is applied. A list narrowed by something the reader
   cannot see reads as a bug. Hidden form controls still post, so the fields
   submit either way and this stays a presentation choice.

   THE BOARD COLUMN AND CARD (.board, .board-column, .board-card)
   Eight columns are wider than a laptop, so the BOARD scrolls sideways and the
   document never does — a page that scrolls horizontally takes the header, the
   nav and the flash region off screen with it. Columns are never squeezed to
   fit; each scrolls its own cards. A column heading is the same status pill its
   cards carry. An empty column keeps a body, because it is somewhere a card
   still has to be able to land.

   THE BADGES (.badge and .issue-sub)
   A badge states a fact about a row that its own fields cannot: not synced,
   removed from the sheet, still open in support. Colour is never the only
   signal — every badge carries its words — and only a badge that changes what
   somebody may do gets a hue. Anything about the OTHER system's state also
   carries how old the evidence is, because both feeds can stop without a pixel
   here changing.

   THE DROP ZONE AND THE ATTACHMENT LIST (.drop-zone, .attachment-list)
   The dashed box is drawn AROUND the <input type="file">, never instead of it:
   a drop fills that same input, so the input is the feature and the box is a
   shortcut to it. The list of files renders identically on the issue page and
   in the edit form, differing only at the end of the row — the reference to
   paste into a description, or the checkbox that takes the file off.

   THE COMMENT THREAD (.comment-thread, .issue-comment, .comment-form)
   A reply is indented by a 2px border and a fixed rem, never a percentage:
   percentages compound, and six levels of them leave a word-wide column on a
   phone inside a .detail that is only 52rem to begin with. The border doubles
   as the thread line, so the nesting costs no gutter. Reply and Edit are
   <details> disclosures wrapping real forms — closed they cost nothing, open
   they need no script, and the page keeps working with JavaScript off. A
   removed comment keeps its row and prints who removed it: a node that
   vanished would take its replies' context with it.

   IN FLIGHT, EVERYWHERE
   One vocabulary for "the server has not answered yet": dimmed to ~0.55,
   cursor: progress, aria-busy on the control, and never hidden — the pending
   value stays readable while the row, the card or the page waits.
   ========================================================================== */

/* Dark mode is deliberately not implemented, and this is the note that says so
   rather than leaving it looking forgotten. The palette below is one committed
   look: a muted ink-on-warm-white sheet whose eight status hues were each
   picked against #ffffff AND against their own soft fill for 4.5:1, and
   against one another for distinguishability, which is the harder half. A
   second set that holds all three properties against a dark ground is a real
   piece of colour work, not a token swap, and this board is read on office
   laptops in daylight by a team of five. If it is ever wanted: every colour in
   the app already resolves through the tokens in this block — that is what
   makes it possible — so the work is a prefers-color-scheme override here plus
   the four on-ink shades (--on-ink, --ink-hover, --ink-border, --red-hover). */
:root {
  /* Neutrals. Near-black rather than #000 — pure black on white vibrates. */
  --ink: #26302b;
  --ink-soft: #5e6b5f;
  --ink-faint: #849085;
  --surface: #ffffff;
  --raised: #fafaf4;
  --sunken: #eeeee3;
  --line: #dfe0d5;
  --line-strong: #c2c4b4;

  /* Gold, held back for focus, the active nav item and the primary action. */
  --accent: #b8860b;
  --accent-ink: #26302b;
  --accent-soft: #edf2cf;

  /* Status hues. Each is paired with a soft background of the same hue so a
     badge reads as one object rather than a border with unrelated fill. The
     small text printed on those fills is what sets each value, not the swatch:
     every hue is darkened until it clears 4.5:1 on BOTH white and its own soft
     ground. */
  --grey: #606b5f;      --grey-soft: #eff2ec;
  --blue: #2e4e7e;      --blue-soft: #e8eef6;
  --purple: #6f5c99;    --purple-soft: #f0edf8;
  --teal: #3a6b66;      --teal-soft: #e7f2f1;
  --amber: #876024;     --amber-soft: #f5ecdd;
  --green: #42702f;     --green-soft: #e9f1e3;
  --red: #8c3b33;       --red-soft: #f6e7e4;
  /* The cold end of the priority ramp. Muted on purpose: glacial work is real
     work, so it stays legible, but it must never compete with a due date. */
  --ice: #547484;

  /* Shades that sit ON the dark ink — the header, its links and the neutral
     button — kept apart from the text tokens above, which sit on white. */
  --on-ink: #d7dbd3;
  --ink-hover: #37423a;
  --ink-border: #4a554c;
  --red-hover: #6f2e29;

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.5rem;
  --space-6: 2rem;
  --space-7: 3rem;

  --text-xs: 0.75rem;
  --text-sm: 0.8125rem;
  --text-base: 0.9375rem;
  --text-lg: 1.125rem;
  --text-xl: 1.5rem;
  --text-2xl: 1.875rem;

  --radius: 6px;
  --radius-pill: 999px;
  --shadow: 0 1px 2px rgb(0 0 0 / 6%), 0 1px 3px rgb(0 0 0 / 4%);

  --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  --container: 82rem;
  --header-height: 3.5rem;
}

/* ==========================================================================
   Base
   ========================================================================== */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background-color: var(--surface);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: var(--text-base);
  line-height: 1.55;
}

h1, h2, h3 { line-height: 1.25; font-weight: 600; }
h1 { font-size: var(--text-2xl); margin: 0 0 var(--space-2); letter-spacing: -0.015em; }
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
h3 { font-size: var(--text-base); margin: 0 0 var(--space-2); }

a { color: inherit; text-decoration-color: var(--line-strong); text-underline-offset: 2px; }
a:hover { text-decoration-color: currentColor; }

/* code/pre had no rule, so inline code rendered as ordinary prose even though
   --font-mono exists for exactly this. A base rule rather than a class: these
   are elements a view emits naturally, and a class would push the burden onto
   every caller. */
code, pre {
  font-family: var(--font-mono);
  font-size: 0.9em;
}

/* One visible focus treatment for every interactive element. The old stylesheet
   had none, which made the whole app unusable from the keyboard. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 2px;
}

.container {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--space-5);
}

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  left: var(--space-3);
  top: -3rem;
  z-index: 20;
  padding: var(--space-2) var(--space-3);
  background-color: var(--accent);
  color: var(--accent-ink);
  border-radius: var(--radius);
  font-weight: 600;
  transition: top 120ms ease;
}
.skip-link:focus { top: var(--space-3); }

/* ==========================================================================
   Header / navigation
   ========================================================================== */

.app-header {
  position: sticky;
  top: 0;
  z-index: 10;
  background-color: var(--ink);
  color: var(--surface);
}

.app-header .container {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  min-height: var(--header-height);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-weight: 700;
  letter-spacing: -0.01em;
  text-decoration: none;
  white-space: nowrap;
}

.brand-mark {
  width: 0.5rem;
  height: 1.15rem;
  background-color: var(--accent);
  border-radius: 1px;
}

/* The old nav was a <ul> whose bullets showed on every item but the first. */
.nav {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}

.nav-link {
  display: inline-block;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius);
  color: var(--on-ink);
  font-size: var(--text-sm);
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
}
.nav-link:hover { background-color: var(--ink-hover); color: var(--surface); }

.nav-link[aria-current="page"] {
  color: var(--surface);
  box-shadow: inset 0 -2px 0 var(--accent);
}

.header-meta {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-left: auto;
  font-size: var(--text-sm);
}

.header-user {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--on-ink);
  white-space: nowrap;
}

.header-role {
  padding: 0.05rem var(--space-2);
  border: 1px solid var(--ink-border);
  border-radius: var(--radius-pill);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.header-form { margin: 0; }

.link-button {
  padding: var(--space-2) var(--space-3);
  border: 0;
  border-radius: var(--radius-pill);
  background: none;
  color: var(--on-ink);
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
}
.link-button:hover { background-color: var(--ink-hover); color: var(--surface); }

/* ==========================================================================
   Page scaffolding
   ========================================================================== */

main { display: block; padding-block: var(--space-6) var(--space-7); }

.page-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-4);
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
}

.page-header h1 { margin-bottom: 0; }

.page-subtitle {
  margin: var(--space-1) 0 0;
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.page-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}

/* ==========================================================================
   Flash messages
   ========================================================================== */

.flash {
  display: flex;
  gap: var(--space-2);
  margin-bottom: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: var(--raised);
  font-size: var(--text-sm);
}

.flash-notice { border-left-color: var(--green); background-color: var(--green-soft); }
.flash-alert { border-left-color: var(--red); background-color: var(--red-soft); }

/* ==========================================================================
   Buttons
   ========================================================================== */

.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  padding: 0.4rem var(--space-3);
  border: 1px solid var(--ink);
  border-radius: var(--radius-pill);
  background-color: var(--ink);
  color: var(--surface);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 500;
  line-height: 1.4;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
}

.button:hover { background-color: var(--ink-hover); border-color: var(--ink-hover); }
.button:active { transform: translateY(1px); }

.button-secondary {
  background-color: var(--surface);
  border-color: var(--line-strong);
  color: var(--ink);
}
.button-secondary:hover { background-color: var(--sunken); border-color: var(--ink-faint); }

.button-danger { background-color: var(--red); border-color: var(--red); }
.button-danger:hover { background-color: var(--red-hover); border-color: var(--red-hover); }

.button-small { padding: 0.2rem var(--space-2); font-size: var(--text-xs); }

.button[disabled], .button[aria-disabled="true"] { opacity: 0.55; cursor: not-allowed; }

/* button_to wraps its button in a form; keep it inline with sibling buttons. */
.button-form { display: inline; margin: 0; }

/* ==========================================================================
   Filter toolbar
   ========================================================================== */

/* Scope switches. Wraps rather than scrolls, so all three stay reachable on a
   narrow screen where the table header is hidden. */
.quick-filters {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}

.quick-filter {
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  color: var(--ink-soft);
  text-decoration: none;
  font-size: 0.875rem;
}

.quick-filter:hover { color: var(--ink); }

/* The active scope is marked by aria-current, the same hook the nav uses, so
   the state has one source rather than a class that can drift from it. */
.quick-filter[aria-current="page"] {
  color: var(--ink);
  font-weight: 600;
  border-color: var(--ink);
}

/* The pull request status chips are a row of their own above the toolbar. They
   carry .filter-chip-group for its alignment, but that class zeroes the bottom
   margin for groups stacked inside .filter-chip-groups — which pulled the
   toolbar up flush against the chips. This row is not inside one, so it keeps
   the space the demo's chips have. */
.quick-filters.pr-chips { margin-bottom: var(--space-4); }

/* The two everyday filters, promoted out of a select and a collapsed
   disclosure into rows of chips. Their own container rather than more
   .quick-filters siblings, because the scope row above answers "which board"
   and these answer "which slice of it" - stacking all three unlabelled would
   read as one row that had wrapped. */
.filter-chip-groups {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}

.filter-chip-group {
  align-items: center;
  margin-bottom: 0;
}

/* Fixed width so the two rows' chips start on the same left edge; a ragged
   start reads as two unrelated rows rather than one control surface. */
/* One rule, two callers, because the second was written by copying the first
   and a copy is where a fix lands in one place and not the other.
   --ink-soft, not --ink-faint: at 12px the faint token is 3.38:1 on white,
   under the 4.5:1 AA floor, and these replaced labels that were 7.73:1. */
.filter-chip-label,
.project-field-label {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* Fixed width so both chip rows start on the same left edge. */
.filter-chip-label { flex: 0 0 4.5rem; }

@media (max-width: 48rem) {
  .filter-chip-label { flex-basis: 100%; }
}

.toolbar {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
}

.filter-row {
  display: flex;
  align-items: flex-end;
  gap: var(--space-3);
  flex-wrap: wrap;
}

.filter-group {
  min-width: 0;
  margin: 0;
  padding: 0;
  border: 0;
}

.filter-group > legend {
  padding: 0;
  margin-bottom: var(--space-2);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.filter-options {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
}

.filter-check {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-sm);
  white-space: nowrap;
  cursor: pointer;
}

.filter-check input[type="checkbox"] {
  width: auto;
  accent-color: var(--ink);
}

.toolbar-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  min-width: 9rem;
}

.toolbar-field > span {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.toolbar-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.toolbar-count {
  margin-left: auto;
  color: var(--ink-soft);
  font-size: var(--text-sm);
  white-space: nowrap;
}

/* ==========================================================================
   Form controls
   ========================================================================== */

input[type="text"], input[type="url"], input[type="email"], input[type="password"],
input[type="date"], input[type="search"], select, textarea {
  width: 100%;
  padding: 0.4rem var(--space-2);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background-color: var(--surface);
  color: var(--ink);
  font: inherit;
  font-size: var(--text-sm);
}

textarea { line-height: 1.5; resize: vertical; }

input:focus-visible, select:focus-visible, textarea:focus-visible {
  border-color: var(--ink);
  outline-offset: 0;
}

::placeholder { color: var(--ink-faint); }

.form { max-width: 46rem; }

.form-section {
  margin: 0 0 var(--space-5);
  padding: 0;
  border: 0;
}

.form-section > legend {
  padding: 0;
  margin-bottom: var(--space-3);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.field { margin-bottom: var(--space-4); }

/* The second selector is not defensive. Rails wraps a label and its input in
   <div class="field_with_errors"> the moment the record carries an error on
   that attribute, which drops both out of `.field > label` — so on a rejected
   form the only fields rendered unstyled were the ones the reader had just
   been sent back to fix. Every form in the app had it: issue, profile,
   allow-list, sign-up. display: contents below takes the wrapper out of the
   layout, but `>` is a question about structure and display cannot answer it,
   so the rule is named twice instead. */
.field > label,
.field > .field_with_errors > label {
  display: block;
  margin-bottom: var(--space-1);
  font-size: var(--text-sm);
  font-weight: 600;
}

.field_with_errors { display: contents; }

/* The other half of the same gap: the summary card counts the errors, and
   until now nothing on the form itself said which fields they were about. */
.field_with_errors input,
.field_with_errors select,
.field_with_errors textarea { border-color: var(--red); }

.field-hint {
  margin: var(--space-1) 0 0;
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.required-mark { color: var(--red); }

/* Short fields sit side by side rather than each spanning the full measure. */
.field-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: var(--space-4);
}
.field-row .field { margin-bottom: var(--space-4); }

/* A multi-select needs ctrl-click to deselect and silently truncates its list;
   checkboxes show every assignable person and their state at a glance. */
.checkbox-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
  gap: var(--space-2);
  padding: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
}

.checkbox-grid label {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-sm);
  cursor: pointer;
}

.checkbox-grid input[type="checkbox"] { width: auto; accent-color: var(--ink); }

.form-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-top: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line);
}

/* Destructive actions do not sit shoulder to shoulder with ordinary ones. */
.form-actions .action-destructive { margin-left: auto; }

.form-errors {
  margin-bottom: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--red);
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: var(--red-soft);
}
.form-errors h2 { margin-bottom: var(--space-2); font-size: var(--text-sm); }
.form-errors ul { margin: 0; padding-left: var(--space-4); font-size: var(--text-sm); }

.form-hint {
  max-width: 46rem;
  margin: 0 0 var(--space-5);
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

/* --- Sign-in and account screens -----------------------------------------
   The block that used to sit here reached after Devise's generated markup
   instead — `form:has(> .actions)`, `.field p`, a `:has(> p input)` selector
   to undo the two stacked <p>s Devise wraps a checkbox in. It worked, and it
   was a trap: the next `rails generate devise:views` would have put the
   scaffold back and nothing would have said so. The views are the app's own
   now (spec/requests/devise_screens_spec.rb holds them there) and what is left
   here is only what those screens add.
   `.field p { margin: 0 }` went with it, and took a bug with it: at 0-1-1 it
   outranked `.field-hint`, so every hint under a field in this app — the issue
   form's, the profile's — had been rendering flush against its input. */

/* A sign-in form is four short controls. Given the 46rem measure `.form` sets
   for a page of prose fields, the password box ran a third of the screen wide
   and read as a search bar. */
.form-narrow { max-width: 24rem; }

/* Checkbox beside its label rather than above it, and not bold: here the label
   is the sentence, not a heading for a control underneath. */
.field-check {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.field-check > label { margin-bottom: 0; font-weight: 400; }
.field-check input[type="checkbox"] { width: auto; accent-color: var(--ink); }

/* A password field with its Show/Hide button beside it
   (app/views/shared/_password_input.html.erb). The input is matched untyped
   and as a descendant rather than a child: its type flips between password
   and text as it is revealed, and on a rejected form Rails wraps it in
   .field_with_errors, which display: contents takes out of the layout but not
   out of the tree. */
.password-input {
  display: flex;
  align-items: stretch;
  gap: var(--space-2);
}
.password-input input { flex: 1 1 auto; min-width: 0; }

/* One width for both words, so the input does not move when Show becomes Hide. */
.password-toggle { min-width: 4.5em; }

/* A rule of its own, because .button sets display: inline-flex and an author
   declaration beats the UA rule the hidden attribute leans on — the trap
   written up at .sync-banner[hidden]. Without it the button is on screen, and
   does nothing, on any page whose JavaScript did not load. */
.password-toggle[hidden] { display: none; }

/* Edge draws its own reveal control inside a password field. Hidden only once
   ours is showing, so a page whose JavaScript did not load keeps Edge's. */
.password-input:has(> .password-toggle:not([hidden])) input::-ms-reveal { display: none; }

/* Everything else a person might have come to this screen to do, on one line.
   As a paragraph each, four of them took more vertical space than the form
   they belong to and read as the page's main content. No rule of its own:
   .form-actions already draws one directly above, and two hairlines a button's
   height apart read as a mistake rather than as two groups. */
.auth-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-4);
  max-width: 24rem;
  margin-top: var(--space-4);
  font-size: var(--text-sm);
}

/* A short form or action standing on its own below a page's main content: the
   account screen's Telegram link and account closure, the allow-list's "add one
   more address". Left at the container's 82rem these read as belonging to a
   different page, and a lone red button ends up a foot from the sentence that
   explains it. .detail-section is the neighbouring component and is deliberately
   NOT this one: it carries bottom spacing for sections that stack, where this
   one needs the gap above it, away from whatever it follows. */
.form-block {
  max-width: 24rem;
  margin-top: var(--space-6);
}

/* Bare submit fallback. Without it a form whose inputs are styled but whose
   button is not reads as half-finished rather than unstyled.
   :not(.button) is not belt and braces. A type selector plus an attribute is
   0-1-1 and `.button-secondary` is 0-1-0, so this rule was silently winning
   over it: every `submit_tag "…", class: "button button-secondary"` in the app
   — Rename and Merge on the projects screen, Merge into… on the users screen —
   has been painting itself as the primary action. Excluding the classed ones
   is the whole fix; the same trap is written up at .row-edit-save. */
input[type="submit"]:not(.button) {
  display: inline-flex;
  padding: 0.4rem var(--space-3);
  border: 1px solid var(--ink);
  border-radius: var(--radius-pill);
  background-color: var(--ink);
  color: var(--surface);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 500;
  cursor: pointer;
}
input[type="submit"]:not(.button):hover { background-color: var(--ink-hover); border-color: var(--ink-hover); }

/* ==========================================================================
   Sync banner
   ========================================================================== */

.sync-banner {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: var(--raised);
  font-size: var(--text-sm);
}

.sync-banner strong { font-weight: 600; }

.sync-banner-succeeded { border-left-color: var(--green); }
.sync-banner-running { border-left-color: var(--blue); background-color: var(--blue-soft); }
.sync-banner-failed { border-left-color: var(--red); background-color: var(--red-soft); }
.sync-banner-never { border-left-color: var(--amber); background-color: var(--amber-soft); }
/* Red rather than amber: a sync that stopped is a board showing wrong data, not
   a board waiting to be set up. */
.sync-banner-stale { border-left-color: var(--red); background-color: var(--red-soft); }

/* The support sync's two actionable states, wearing the sheet sync's colours on
   purpose: one bar vocabulary, in which amber means "nothing has run yet" and
   red means "it was running and stopped", whichever integration is speaking. */
.sync-banner-no_credential { border-left-color: var(--amber); background-color: var(--amber-soft); }
.sync-banner-stale_credential { border-left-color: var(--red); background-color: var(--red-soft); }

/* A GitHub run that read most repos and not all: working, with a corner of the
   page older than the rest. Amber, like anything that wants a look. */
.sync-banner-partial { border-left-color: var(--amber); background-color: var(--amber-soft); }

.sync-detail { color: var(--ink-soft); }

/* Sits inside a banner that says "Last synced", which is a reassuring thing to
   read — so the one sentence contradicting it has to carry its own weight
   rather than inherit the muted colour around it. Amber, not red: the guard
   worked, and nothing is broken; what is being reported is that the sheet read
   short and the app declined to act on it. */
.sync-warn {
  display: block;
  margin-top: var(--space-1);
  color: var(--amber);
  font-weight: 600;
}

.sync-error {
  flex-basis: 100%;
  margin-top: var(--space-1);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  overflow-wrap: anywhere;
}

/* A rule of its own, because .sync-banner sets display: flex and an author
   declaration beats the UA rule the hidden attribute leans on — the same trap
   written up at .row-edit-save and answered the same way at .bulk-bar. */
.sync-banner[hidden] { display: none; }

/* Indeterminate on purpose. Nothing on this page knows how many rows the sheet
   holds or how the Sheets API will feel today, so a bar that filled would be
   inventing a number. A sweep says only "still working", which is the whole of
   what is actually known. */
.sync-progress {
  flex-basis: 100%;
  height: 2px;
  margin-top: var(--space-2);
  border-radius: 1px;
  background-color: var(--blue-soft);
  background-image: linear-gradient(90deg, transparent, var(--blue), transparent);
  background-size: 40% 100%;
  background-repeat: no-repeat;
  animation: sync-sweep 1.1s linear infinite;
}

@keyframes sync-sweep {
  from { background-position: -40% 0; }
  to { background-position: 140% 0; }
}

/* The blanket reduced-motion rule clamps every animation to 0.01ms, which for
   a looping sweep is a flicker — worse than stillness. Stopped outright here;
   the sentence above the bar is what carries the state anyway. */
@media (prefers-reduced-motion: reduce) {
  .sync-progress {
    animation: none;
    background-image: none;
    background-color: var(--blue);
    opacity: 0.4;
  }
}

/* ==========================================================================
   Issue table
   ========================================================================== */

.table-wrap {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  overflow-x: auto;
}

/* Choosing which months the stats cover. A panel, for the same reason the
   add-project form is one: it sits above a wall of numbers and without an edge
   it reads as a caption belonging to the first tile. */
.period-filter {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.75rem 1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  padding: 0.9rem 1rem;
  margin-bottom: 1.25rem;
}

.period-filter fieldset {
  border: 0;
  margin: 0;
  padding: 0;
  flex: 1 1 20rem;
}

.period-filter-legend {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--ink-soft);
  padding: 0;
  margin-bottom: 0.4rem;
}

/* Wraps rather than scrolls: a year of tabs is twelve of these, and a
   horizontal scrollbar inside a filter is a control people miss. */
.period-filter-months {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}

/* The whole chip is the hit target, not just the box — a month name is a
   comfortable thing to aim at and a 13px checkbox is not. */
.period-chip {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: 0.25rem 0.7rem;
  font-size: 0.85rem;
  cursor: pointer;
  white-space: nowrap;
}

.period-chip:hover { border-color: var(--ink-faint); }

/* :has, so the chosen months read at a glance without any JavaScript to keep a
   class in sync with the checkbox. */
.period-chip:has(input:checked) {
  border-color: var(--accent, var(--blue));
  background-color: var(--blue-soft);
  font-weight: 600;
}

.period-chip:has(input:focus-visible) { outline: 2px solid var(--blue); outline-offset: 2px; }

.period-filter-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.period-filter-note {
  margin: -0.5rem 0 1.25rem;
  font-size: 0.85rem;
  color: var(--ink-soft);
}

/* Adding a project, above the list it adds to. A panel rather than a bare row
   of inputs: it sits between two warning banners and the table, and without an
   edge of its own it reads as a stray field belonging to whichever of them the
   eye reached last. */
/* Reference copy that used to be three stacked banners. Closed by default: it
   is read once and then is scenery, and every warning in it is repeated in the
   confirm dialog of the button it applies to. */
.page-note {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  margin-bottom: var(--space-4);
}

.page-note > summary {
  padding: var(--space-3) var(--space-4);
  color: var(--ink-soft);
  font-size: var(--text-sm);
  font-weight: 600;
  cursor: pointer;
}

.page-note-body {
  padding: 0 var(--space-4) var(--space-4);
  max-width: 60rem;
}

.page-note-body h2 {
  margin: var(--space-4) 0 var(--space-1);
  font-size: var(--text-sm);
}

.page-note-body p {
  margin: 0;
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

/* One card per project. Sized by content rather than by the widest cell in a
   column, so a project with no members is short and one with six is not. */
/* align-items: start, because grid stretches by default and that put the
   table's worst habit straight back: two cards side by side both grew to the
   height of whichever had more members, and .project-card-actions' margin-top
   pushed the shorter one's buttons to the bottom of a card that was mostly
   empty. Each card is now its own height. */
.project-cards {
  display: grid;
  align-items: start;
  gap: var(--space-4);
  margin-bottom: var(--space-5);
}

@media (min-width: 72rem) {
  .project-cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

.project-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  padding: var(--space-4);
}

.project-card-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-2);
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--line);
}

.project-card-name {
  margin: 0;
  font-size: var(--text-base);
}

/* Pushed to the far end so the badges sit with the name they qualify rather
   than drifting to wherever the count happens to end. */
.project-card-count {
  order: 3;
  margin-left: auto;
  color: var(--ink-faint);
  font-size: var(--text-sm);
  white-space: nowrap;
}

.project-card-fields {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
}

/* Label above its control, not beside it: beside it, three fields of different
   control widths give three different ragged left edges for the controls. */
.project-field {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  min-width: 0;
}

.project-field-value {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
}

.project-field-value .form-hint { margin: 0; }

/* Text field first and allowed to take the room, so a long project name is
   readable in the box that renames it. */
.project-field .source-form { flex-wrap: wrap; }
.project-field input[type="text"] { flex: 1 1 12rem; min-width: 0; }

.project-card-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin-top: auto;
  padding-top: var(--space-3);
  border-top: 1px solid var(--line);
}

/* The one control here that destroys a project, kept away from the two that
   merely toggle a state — the same reason Delete sits apart from Back on an
   issue. */
.project-merge {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-left: auto;
}

.project-merge .project-field-label { white-space: nowrap; }

@media (max-width: 48rem) {
  .project-merge { margin-left: 0; width: 100%; }
}

.new-project {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  padding: 1rem 1.1rem;
  margin-bottom: 1.25rem;
}

.section-heading {
  font-size: 1rem;
  margin: 0 0 0.15rem;
}

.section-note {
  margin: 0 0 0.85rem;
  color: var(--ink-soft);
  font-size: 0.875rem;
}

/* Wraps rather than scrolls: the label sits above its control, so on a phone
   the two fields and the button stack instead of running off the side. */
.new-project-form {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.75rem;
}

.new-project-field {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}

/* Enough for a real project name, and it gives up the space first when the row
   has to wrap. */
.new-project-field:first-child { flex: 1 1 16rem; }

.new-project-field label {
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--ink-soft);
}

.table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--text-sm);
}

/* Half the padding this table used to carry, and cells centred against their
   row rather than hung from its top. A row is as tall as its tallest control
   either way, so the space above and below the editors was the only thing
   between the reader and twice as many issues per screen. */
.table th, .table td {
  padding: var(--space-2) var(--space-3);
  text-align: left;
  vertical-align: middle;
  border-bottom: 1px solid var(--line);
}

.table thead th {
  position: sticky;
  /* Not var(--header-height), which is what a sticky row would need to clear
     the sticky .app-header. .table-wrap sets overflow-x, which makes IT the
     scroll container for this sticky cell, so the offset is measured from the
     wrap's top edge rather than the viewport: it pushed the header 3.5rem down
     inside its own container, leaving an empty band above it and overlapping
     the first rows. The horizontal scroll is worth more here than sticking. */
  top: 0;
  z-index: 1;
  background-color: var(--sunken);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}

.table tbody tr:last-child td { border-bottom: 0; }
.table tbody tr:hover { background-color: var(--raised); }

.col-title { width: 42%; }
.col-compact { width: 1%; white-space: nowrap; }

/* Its own class rather than .col-compact: the overdue flag prints beside the
   date, so this column is wider than the ones holding a date alone and would
   otherwise wrap the word onto its own line on every late row. */
.col-due { width: 1%; white-space: nowrap; }

/* A pull request's title and sub-line are shaped like an issue's on purpose:
   one list look across the app, and one rule to change it in. */
.issue-link,
.pr-title {
  font-weight: 600;
  text-decoration: none;
}
.issue-link:hover,
.pr-title:hover { text-decoration: underline; }

.issue-sub,
.pr-sub {
  display: block;
  margin-top: var(--space-1);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

/* The same sub-line, in the amber the rest of the app already uses for "this
   needs a human". Not red: a task closed ahead of its ticket is a loose end,
   not a failure, and red here would outrank genuinely blocked work. */
/* A badge, not a sentence: the full wording is in its title. Kept on one line
   so a support row is the height of an ordinary one. */
.issue-sub-diverged {
  display: inline-block;
  margin-top: var(--space-1);
  padding: 0.05rem var(--space-2);
  border: 1px solid currentcolor;
  border-radius: var(--radius-pill, 999px);
  color: var(--amber);
  white-space: nowrap;
  cursor: help;
}

/* Two lines of title, then an ellipsis, with the whole of it in the cell's
   own title attribute. The clamp is on this element rather than the cell,
   because the cell also carries the pending and divergence marks and a
   -webkit-box display would swallow them. */
.issue-link {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}

.cell-muted { color: var(--ink-soft); }

/* External task/PR links: small, obviously clickable, never bare words. */
.ref-links { display: flex; gap: var(--space-1); flex-wrap: wrap; }

.ref-link {
  display: inline-flex;
  align-items: center;
  gap: 0.15rem;
  padding: 0.1rem var(--space-2);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  text-decoration: none;
  /* Without this the trailing arrow wraps to its own line and doubles the row
     height in the narrow links column. */
  white-space: nowrap;
}
.ref-link:hover { border-color: var(--ink); color: var(--ink); background-color: var(--sunken); }

/* ==========================================================================
   Status, priority, badges
   Colour is never the only signal — every one of these carries its text label,
   and the status dot differs in fill as well as hue.
   ========================================================================== */

.status {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  white-space: nowrap;
}

.status-dot {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  border: 1.5px solid currentColor;
  border-radius: 50%;
}

.status-not_yet_started { color: var(--grey); }
.status-in_progress { color: var(--blue); }
.status-in_review { color: var(--purple); }
.status-waiting_to_deploy { color: var(--teal); }
.status-waiting_for_answer { color: var(--amber); }
.status-on_hold { color: var(--amber); }
.status-completed { color: var(--green); }
.status-blocked { color: var(--red); }

/* Filled dot = terminal or halted; hollow = in flight. */
.status-completed .status-dot,
.status-blocked .status-dot,
.status-on_hold .status-dot { background-color: currentColor; }

.status-label { color: var(--ink); }

/* Pull requests (app/views/pull_requests). Prefixed so a pull request status can
   never share a class, and so a colour, with an issue status of the same word.
   Green only for "press merge now"; red only for something broken. */
.status-pr-ready { color: var(--green); }
.status-pr-needs_review { color: var(--purple); }
.status-pr-failed_checks { color: var(--red); }
.status-pr-waiting { color: var(--amber); }
.status-pr-wip { color: var(--grey); }
.status-pr-merged { color: var(--teal); }
.status-pr-merged .status-dot { background-color: currentColor; }

/* Under the status, not run on after it: it says what to do next, and read
   inline the two made one sentence ("Needs review waiting for Wilbur"). */
.pr-reason {
  display: block;
  margin-top: var(--space-1);
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

/* A row the last run could not refresh. Few, and worth a look. */
.pr-as-of { color: var(--amber); }

/* One issue a line: board titles are long, and inline they ran together. */
.pr-board-links { display: flex; flex-direction: column; gap: var(--space-1); }

.pr-legend dl {
  display: grid;
  grid-template-columns: 9rem 1fr;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  font-size: var(--text-sm);
}
.pr-legend dt { font-weight: 600; }
.pr-legend dd { margin: 0; color: var(--ink-soft); }

/* The Checks and Reviews columns carry a colour as well as their word: an
   approved review and a failing scanner are the two things a reader scans the
   list for, and both labels still say what they are. Green only for a genuine
   pass or approval; faint, never green, when nothing ran. */
.pr-checks { font-size: var(--text-xs); font-weight: 600; }
.pr-reviews { font-size: var(--text-xs); color: var(--ink-soft); }
.pr-checks-ok { color: var(--green); }
.pr-checks-fail { color: var(--red); }
.pr-checks-run { color: var(--amber); }
.pr-checks-none { color: var(--ink-faint); font-weight: 400; }
.pr-reviews-approved,
.pr-named-review-approved { color: var(--green); font-weight: 600; }

/* The board column is a short list of issue links; the same small type as the
   sub-line it sits under, so a row reads as one block. */
.pr-board-links { font-size: var(--text-xs); }

/* A clean read says so in a single line — a green dot and the sentence — the
   strip the page demos use. Only something a reader must act on keeps the
   banner (see pull_requests/_sync_status.html.erb). */
.sync {
  display: flex;
  gap: var(--space-2);
  align-items: flex-start;
  flex-wrap: wrap;
  margin-bottom: var(--space-4);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
  font-size: var(--text-sm);
  color: var(--ink-soft);
}
.sync strong { color: var(--ink); font-weight: 600; }
.sync .dot-ok {
  flex: none;
  width: 0.5rem;
  height: 0.5rem;
  margin-top: 0.4rem;
  border-radius: 50%;
  background-color: var(--green);
}

/* The note under the list: what a row's "as of" means, said once rather than
   on every row. Quieter than the rows it explains. */
.pr-footnote {
  max-width: 62rem;
  margin: var(--space-5) 0 0;
  color: var(--ink-faint);
  font-size: var(--text-sm);
}

/* Priority is on every row, so only "high" is allowed to raise its voice. */
.priority { font-variant-numeric: tabular-nums; }
/* Seven levels read as a temperature, coldest to hottest, so the ladder is
   legible without a legend. The restraint that mattered at three levels still
   applies: the column repeats on every row, so the bottom four recede and only
   the top three are allowed to draw the eye. Weight and letterform carry the
   last two steps because three shades of the same red do not separate at 13px,
   and neither does colour alone for a reader who cannot see it. */
.priority-glacial { color: var(--ice); font-style: italic; }
.priority-low { color: var(--ink-faint); }
.priority-medium { color: var(--ink-soft); }
.priority-high { color: var(--amber); font-weight: 600; }
.priority-urgent { color: var(--red); font-weight: 600; }
.priority-critical { color: var(--red); font-weight: 700; }
.priority-inferno {
  color: var(--red);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: 0.92em;
}

/* A project's colour, and deliberately not a second status dot.
   Status owns the saturated hues on this page and a reader has already learnt
   that code, so handing them a blue circle meaning "in progress" and a blue
   circle meaning "Portals" would teach them to trust neither. This differs in
   both of the ways it can: a rounded RECTANGLE rather than a circle, and a
   tint mixed back towards the page rather than the full-strength hue. The two
   marks sit within a couple of centimetres of each other on a board card, so
   the shape is doing most of the work.
   The name is always beside it — the same rule as everything above — and here
   that is load-bearing rather than polite: there are more projects possible
   than colours in this list, so two can share one, and the word settles it. */
.project { display: inline-flex; align-items: center; gap: var(--space-2); }

.project-swatch {
  flex: none;
  width: 0.75rem;
  height: 0.5rem;
  border-radius: 2px;
  border: 1px solid color-mix(in srgb, currentColor 55%, var(--surface));
  background-color: color-mix(in srgb, currentColor 30%, var(--surface));
}

.project-colour-0 { color: var(--grey); }
.project-colour-1 { color: var(--blue); }
.project-colour-2 { color: var(--purple); }
.project-colour-3 { color: var(--teal); }
.project-colour-4 { color: var(--amber); }
.project-colour-5 { color: var(--green); }
.project-colour-6 { color: var(--red); }
.project-colour-7 { color: var(--ice); }

/* The swatch carries the hue; the name stays in ordinary ink so a row does not
   read as eight coloured words. */
.project-name { color: inherit; }

/* In a row, the project name is what sets the row's height the moment it
   wraps: "Digital Internship" in this column is two lines, and on a board
   where most issues belong to two-word projects every row pays for it — 57px
   against 41px, three rows a screen. One line here, clipped, with the whole
   name in the cell's title; the stacked phone layout below is unaffected. */
.table .project-name {
  display: inline-block;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  vertical-align: bottom;
}

.badge {
  display: inline-block;
  padding: 0.05rem var(--space-2);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-pill);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  white-space: nowrap;
}

.badge-pending { border-color: var(--amber); background-color: var(--amber-soft); color: var(--amber); }
.badge-removed { border-color: var(--line-strong); background-color: var(--sunken); }

/* Account badges. Only the two that change what an admin may do with a row get
   a colour: an admin can act everywhere, and a deactivated account is closed.
   Placeholder and Telegram are facts, not warnings, so they stay quiet. */
.badge-admin { border-color: var(--blue); background-color: var(--blue-soft); color: var(--blue); }
.badge-deactivated { border-color: var(--red); background-color: var(--red-soft); color: var(--red); }
.badge-placeholder { border-color: var(--amber); background-color: var(--amber-soft); color: var(--amber); }
.badge-telegram { border-color: var(--line-strong); background-color: var(--sunken); }

/* Several badges sit beside one name, so they need to wrap rather than push
   the column wide. */
.badge-row {
  display: inline-flex;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-left: var(--space-2);
  vertical-align: middle;
}

.dash { color: var(--ink-faint); }

/* ==========================================================================
   Empty state
   ========================================================================== */

.empty-state {
  padding: var(--space-7) var(--space-5);
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius);
  background-color: var(--raised);
  text-align: center;
}

.empty-state h2 { margin-bottom: var(--space-2); }

.empty-state p {
  max-width: 34rem;
  margin: 0 auto var(--space-4);
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

/* ==========================================================================
   Dashboard
   Three panels of short lists. Fixed-height rows and a capped list keep the
   page a predictable size no matter how much work exists.
   ========================================================================== */

.digest-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
  gap: var(--space-4);
  align-items: start;
  margin-top: var(--space-5);
}

.digest-panel {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
}

.digest-panel-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--line);
  background-color: var(--raised);
}

.digest-panel-header h2 {
  margin: 0;
  font-size: var(--text-base);
}

.digest-panel-link {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  white-space: nowrap;
}

.digest-list {
  margin: 0;
  padding: 0;
  list-style: none;
}

.digest-row {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-3) var(--space-4);
  border-bottom: 1px solid var(--line);
}
.digest-row:last-child { border-bottom: 0; }
.digest-row:hover { background-color: var(--raised); }

.digest-title {
  font-size: var(--text-sm);
  font-weight: 600;
  text-decoration: none;
}
.digest-title:hover { text-decoration: underline; }

.digest-meta {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  font-size: var(--text-xs);
}

.digest-source { color: var(--ink-faint); }

/* The same amber the row badge uses, so a divergence looks the same wherever
   the reader meets it. */
.digest-ticket { color: var(--amber); }

.digest-empty {
  margin: 0;
  padding: var(--space-5) var(--space-4);
  color: var(--ink-soft);
  font-size: var(--text-sm);
  text-align: center;
}

.digest-more {
  display: block;
  padding: var(--space-2) var(--space-4);
  border-top: 1px solid var(--line);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  text-decoration: none;
}
.digest-more:hover { background-color: var(--raised); color: var(--ink); }

/* The dashboard's digest wears a second skin: four raised cards in a two-by-two
   block, each row going white under the cursor, so the summaries read as one
   set against the white page. The stats sections share this markup and stay
   white — the two looks are deliberate, see docs/demos/home.html against
   docs/demos/stats.html. */
.digest-grid--cards { grid-template-columns: 1fr 1fr; }
.digest-grid--cards .digest-panel { background-color: var(--raised); display: flex; flex-direction: column; }
.digest-grid--cards .digest-panel-header { background-color: transparent; }
.digest-grid--cards .digest-row:hover { background-color: var(--surface); }
.digest-grid--cards .digest-more { border-top: 0; margin-top: auto; }
@media (max-width: 56rem) {
  .digest-grid--cards { grid-template-columns: 1fr; }
}

/* ==========================================================================
   Issue detail
   ========================================================================== */

.detail { max-width: 52rem; }

.detail-meta {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-5);
  padding-bottom: var(--space-4);
  border-bottom: 1px solid var(--line);
}

/* Prose, above a grid of one-line facts. Held to a readable measure rather
   than the detail column's full width, because this is the only thing on the
   page somebody wrote in sentences. */
.detail-comments {
  margin-bottom: var(--space-5);
}

.detail-comments p {
  margin: 0;
  max-width: 42rem;
  white-space: pre-line;
  overflow-wrap: anywhere;
}

.detail-grid {
  display: grid;
  grid-template-columns: 9rem 1fr;
  gap: var(--space-3) var(--space-4);
  margin: 0 0 var(--space-5);
  font-size: var(--text-sm);
}

.detail-grid dt {
  color: var(--ink-soft);
  font-weight: 600;
}

.detail-grid dd {
  margin: 0;
  overflow-wrap: anywhere;
}

.detail-grid dd a { color: var(--blue); }

/* Two pairs per row once there is room. Nine fields down a single column made
   the page a long thin ribbon with two thirds of a wide screen empty, and half
   of those rows are an em dash on an imported task — absence given exactly as
   much height as content. Falls back to one column below the breakpoint, where
   the ribbon is the right shape. */
@media (min-width: 60rem) {
  .detail-grid { grid-template-columns: 9rem minmax(12rem, 1fr) 9rem minmax(12rem, 1fr); }
}

/* Moving the task along, from the page you were already reading. Sits with the
   facts rather than down in the action bar: it is a change to one of them, not
   a navigation. */
.detail-status {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin: 0 0 var(--space-5);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--sunken);
}

.detail-status label { font-size: var(--text-sm); font-weight: 600; color: var(--ink-soft); }

/* Sized to its longest option, not to the panel. The app's form styling makes a
   select fill its container, which for "Waiting for answer" in an 800px row is
   a control eight times wider than anything it can hold. */
.detail-status select { width: auto; min-width: 12rem; }

/* Quiet, and last. Deleting a row that mirrors a shared spreadsheet is the most
   consequential thing on this page and used to be drawn as the most prominent —
   a solid red button with the same weight as Edit. The colour arrives on hover,
   where it belongs: at the moment somebody is actually aiming at it. */
.action-delete { color: var(--red); }
.action-delete:hover { background-color: var(--red); border-color: var(--red); color: #fff; }

.detail-section { margin-bottom: var(--space-6); }

/* detail-section headings. These are subsections under the page <h1>, so they
    are stepped down from h1 but still h2 by semantics — the same treatment as
    the prose headings, because the prose itself uses h1–h6 at the same levels.
    The reduced margin matches the prose spacing so a section header and the
    first element inside it sit at the same interval as a heading and its
    paragraph in a description. */
.detail-section > h2 {
  font-size: var(--text-base);
  margin: 0 0 var(--space-2);
}

/* detail-section accordion <details>. The summary is the only visible part when
    closed; the body is hidden until opened. Uses the same spacing convention
    as prose headings and paragraphs so the internal layout stays consistent
    whether the section is collapsed or expanded. */
.detail-accordion > details {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
}

.detail-accordion > details > summary {
  cursor: pointer;
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-base);
  font-weight: 600;
  list-style: none;
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.detail-accordion > details > summary::-webkit-details-marker { display: none; }

.detail-accordion > details > summary::before {
  content: "";
  display: inline-block;
  width: 0.5rem;
  height: 0.5rem;
  border-right: 2px solid var(--ink-faint);
  border-bottom: 2px solid var(--ink-faint);
  transform: rotate(-45deg);
  transition: transform 0.12s ease;
  flex: none;
}

.detail-accordion > details[open] > summary::before {
  transform: rotate(45deg);
}

.detail-accordion > details > summary:hover {
  background-color: var(--sunken);
}

.detail-accordion > details .detail-section {
  margin-bottom: 0;
  padding: 0 var(--space-4) var(--space-4);
}

/* Rendered Markdown. The tags here are exactly the ones
   Markup::SafeMarkdown::ALLOWED_TAGS lets through — anything outside that list
   never reaches the page, so styling it would be styling nothing. */
.prose { font-size: var(--text-sm); }
.prose p { margin: 0 0 var(--space-3); }
.prose p:last-child { margin-bottom: 0; }

/* Stepped down hard from the document scale: these headings sit under the
   page's own <h2>, so a description's "# Steps" must not outshout it. */
.prose h1 { font-size: var(--text-base); margin: var(--space-4) 0 var(--space-2); }
.prose h2 { font-size: var(--text-base); margin: var(--space-4) 0 var(--space-2); }
.prose h3,
.prose h4,
.prose h5,
.prose h6 { font-size: var(--text-sm); margin: var(--space-3) 0 var(--space-2); }
.prose > :first-child { margin-top: 0; }

.prose ul,
.prose ol { margin: 0 0 var(--space-3); padding-left: var(--space-5); }
.prose li { margin-bottom: var(--space-1); }

/* A checklist item. The box stands in for the bullet, as it does on GitHub and
   in Plane, rather than sitting beside one.
   The server renders every box disabled, and a page enables them only for
   someone who may edit. So the disabled look is the reader's look, and it has
   to read as a box that cannot be ticked. */
.prose li:has(> input[type="checkbox"]) { list-style: none; }
.prose li > input[type="checkbox"] { margin: 0 var(--space-2) 0 calc(-1 * var(--space-4)); vertical-align: middle; }
.prose li > input[type="checkbox"]:enabled { cursor: pointer; }
.prose li > input[type="checkbox"]:disabled { cursor: default; }
/* An item in a list with blank lines between its items holds its text in a
   <p>. As a block it would push the text onto the line under its box. */
.prose li > input[type="checkbox"] + p { display: inline; }

/* What a tick that did not save says about itself, under the checklist. Empty,
   and so taking no room, until then. */
.task-list-status { margin: 0; font-size: var(--text-sm); color: var(--red); }
.task-list-status:not(:empty) { margin-top: var(--space-2); }

.prose blockquote {
  margin: 0 0 var(--space-3);
  padding-left: var(--space-3);
  border-left: 3px solid var(--line);
  color: var(--ink-soft);
}

/* A code block holds a stack trace or a command, and both are ruined by
   wrapping — so it scrolls itself rather than letting the page scroll
   sideways and carry the header and nav off screen with it. */
.prose pre {
  margin: 0 0 var(--space-3);
  padding: var(--space-3);
  overflow-x: auto;
  background: var(--sunken);
  border-radius: var(--radius);
}

.prose pre code { background: none; padding: 0; }

/* Capped rather than full width: a screenshot is evidence beside the text, not
   the subject of the page, and an untouched phone photo is 4000px wide. */
.prose img {
  display: block;
  max-width: 100%;
  height: auto;
  margin: 0 0 var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

/* Same reasoning as the code block: the table scrolls, the document does not. */
.prose table {
  display: block;
  overflow-x: auto;
  border-collapse: collapse;
  margin: 0 0 var(--space-3);
}

.prose th,
.prose td {
  border: 1px solid var(--line);
  padding: var(--space-2) var(--space-3);
  text-align: left;
}

.prose a { color: var(--blue); }
.prose hr { border: 0; border-top: 1px solid var(--line); margin: var(--space-4) 0; }

/* ==========================================================================
   Attachments — one list, two pages

   The same rows render on the issue and inside the edit form, so a file looks
   the same wherever it is read. The two differ only in what sits at the end of
   the row: a reference to copy, or a way to take the file off.
   ========================================================================== */

.attachment-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
  font-size: var(--text-sm);
}

.attachment-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.attachment-name { color: var(--blue); overflow-wrap: anywhere; }
.attachment-size { color: var(--ink-soft); }

/* The how-to list's search (app/views/how_to/index.html.erb). The excerpt
   takes a line of its own under the title, rather than squeezing in beside the
   date. */
.how-to-search .toolbar-search { flex: 1 1 20rem; }

/* min-width and overflow-wrap together, because a flex item's min-content width
   is its longest unbreakable run: a checksum or a token quoted out of a runbook
   would otherwise widen the row, the list and the page, and scroll a phone
   sideways. */
.how-to-excerpt {
  flex-basis: 100%;
  min-width: 0;
  margin: 0;
  overflow-wrap: anywhere;
  color: var(--ink-soft);
}

/* The accent's tint rather than the browser's own yellow, and the text keeps
   its colour, so a marked word inside a title still reads as part of the link. */
.how-to-results mark {
  padding: 0 0.1em;
  border-radius: 2px;
  background-color: var(--accent-soft);
  color: inherit;
}

/* ==========================================================================
   The how-to panel (.howto-panel and its rows)
   The list is the page's subject, so it reads as one panel of rows divided by
   hairlines rather than a field of separate cards. The pinned page leads it in
   the accent tint; a page with children is a group that folds to one line; a
   page with nothing under it is a line of its own. The whole row is the
   target, and the arrow says so.
   ========================================================================== */

.howto-panel {
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
  background: var(--surface);
}

.howto-panel > * {
  border-bottom: 1px solid var(--line);
}

.howto-panel > *:last-child {
  border-bottom: 0;
}

.howto-start,
.howto-single,
.howto-page {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  text-decoration: none;
  color: inherit;
}

.howto-start {
  padding: var(--space-4);
  background: var(--accent-soft);
}

.howto-single {
  padding: var(--space-4);
}

.howto-single:hover,
.howto-page:hover {
  background: var(--raised);
}

.howto-start__body,
.howto-single__body,
.howto-page__body {
  flex: 1;
  min-width: 0;
}

.howto-title {
  font-weight: 600;
  overflow-wrap: anywhere;
}

.howto-start__title,
.howto-single__title,
.howto-page__title {
  display: block;
  color: var(--ink);
}

.how-to-result a.howto-title {
  color: var(--blue);
}

.howto-start__label {
  display: block;
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--accent-ink);
}

.howto-start__when,
.howto-single__when,
.howto-page__when {
  display: block;
  font-size: var(--text-sm);
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}

.howto-start__go,
.howto-single__go,
.howto-page__go {
  flex: none;
  color: var(--ink-faint);
  transition: transform 0.15s ease-out;
}

.howto-start:hover .howto-start__go,
.howto-single:hover .howto-single__go,
.howto-page:hover .howto-page__go {
  transform: translateX(3px);
  color: var(--ink);
}

.howto-group > summary {
  list-style: none;
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3) var(--space-4);
  cursor: pointer;
}

.howto-group > summary::-webkit-details-marker {
  display: none;
}

.howto-group > summary:hover,
.howto-group[open] > summary {
  background: var(--raised);
}

.howto-group[open] > summary {
  border-bottom: 1px solid var(--line);
}

.howto-group__label {
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.howto-group__meta {
  margin-left: auto;
  font-size: var(--text-xs);
  color: var(--ink-faint);
  white-space: nowrap;
}

.howto-group__chevron {
  flex: none;
  color: var(--ink-faint);
  transition: transform 0.15s ease-out;
}

.howto-group:not([open]) .howto-group__chevron {
  transform: rotate(-90deg);
}

.howto-group__pages {
  list-style: none;
  margin: 0;
  padding: 0;
}

.howto-page {
  padding: var(--space-3) var(--space-4) var(--space-3) var(--space-6);
  border-top: 1px solid var(--line);
}

.howto-group__pages li:first-child .howto-page {
  border-top: 0;
}

.howto-controls {
  display: flex;
  gap: var(--space-4);
  margin-top: var(--space-4);
}

.howto-controls button {
  border: 0;
  background: none;
  padding: 0;
  font: inherit;
  font-size: var(--text-sm);
  color: var(--ink-soft);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}

.howto-controls button:hover {
  color: var(--ink);
}

/* A search result: the title, then the matching line beneath it. */
.how-to-results {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-3);
}

.how-to-result {
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--line);
}

.how-to-result:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}


/* The text to paste into a description. Monospaced because every character of
   it matters, and it is the one string on the page a reader has to reproduce
   exactly. */
.attachment-ref {
  margin-left: auto;
  color: var(--ink-soft);
  font-size: var(--text-xs);
  overflow-wrap: anywhere;
}

/* Small enough that a list of eight is still a list, big enough to tell two
   screenshots apart — which is the entire reason it is here rather than a
   filename on its own. */
.attachment-thumb img {
  display: block;
  width: 3.5rem;
  height: 3.5rem;
  object-fit: cover;
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.attachment-remove {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-weight: 400;
}

/* THE DROP ZONE (.drop-zone)
   A box drawn around the file input, never instead of it. Dropping fills that
   same input, so the dashed border is an invitation to a shortcut and the
   control inside it is the feature — the board's card drag and the timeline's
   bar drag make the same bargain. It is styled at rest rather than only while
   something is over it, because an affordance nobody can see is one nobody
   uses. */
.drop-zone {
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius);
  padding: var(--space-3);
  transition: background-color 0.12s ease, border-color 0.12s ease;
}

.drop-zone.is-drop-target {
  border-color: var(--accent);
  background-color: var(--accent-soft);
}

.attachment-pending {
  list-style: none;
  margin: var(--space-3) 0 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
  font-size: var(--text-sm);
}

.attachment-pending .attachment-row { border-style: dashed; }
.attachment-pending .chip-remove { margin-left: auto; }

/* ==========================================================================
   Stats — retokenised versions of the concurrent session's stat components.
   Same class names, so this is a drop-in replacement for its hardcoded hexes.
   ========================================================================== */

.stat-section { margin: var(--space-6) 0; }

.stat-section h2 {
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--line);
}

.stat-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-3);
  margin: var(--space-4) 0;
  padding: 0;
  list-style: none;
}

.stat-tile {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  padding: var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
}

.stat-tile-value {
  font-size: var(--text-2xl);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

.stat-tile-label { color: var(--ink-soft); font-size: var(--text-xs); }

/* A dashboard tile whose number is a thing to act on takes the colour its
   status carries, so a stuck pile and a set free to pick up are told apart
   before the label is read. Colour is never the only signal — both labels
   name what they count. */
.stat-tile.is-attention { border-color: var(--line-strong); }
.stat-tile.is-attention .stat-tile-value { color: var(--red); }
.stat-tile.is-free .stat-tile-value { color: var(--green); }

/* Quieter than the label it qualifies: it is the caveat on the number, not a
   second thing to read. */
.stat-tile-note { color: var(--ink-faint); font-size: var(--text-xs); }

.stat-bars { margin: var(--space-4) 0; padding: 0; list-style: none; }

/* Fixed side columns, so every track on the page is the same length.

   max-content sized the label column to the longest label IN THAT LIST, which
   made the track a different width in every section: "Waiting for answer" is 18
   characters and "Critical" is 8, so By status drew short bars and By priority
   drew long ones on the same screen. The percentages were right and the
   drawings were not comparable — a 50% bar in one section was visibly longer
   than a 50% bar in the next, which is the one job a bar chart has.

   Wide enough for the longest status on one line; anything longer (a project or
   a person's name) wraps inside the column rather than stealing from the track,
   and align-items keeps a wrapped label centred against its bar. */
.stat-bar {
  display: grid;
  grid-template-columns: 11rem 1fr 5rem;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-1) 0;
  font-size: var(--text-sm);
}

.stat-bar-label { overflow-wrap: anywhere; }

.stat-bar-track {
  height: 0.75rem;
  border-radius: 2px;
  background-color: var(--sunken);
  overflow: hidden;
}

.stat-bar-fill {
  display: block;
  height: 100%;
  background-color: var(--blue);
  /* A zero-width bar is invisible, which reads as a missing row rather than an
     empty one; the minimum keeps the track legible at 0%. */
  min-width: 2px;
}

.stat-bar-fill-completed { background-color: var(--green); }
.stat-bar-fill-in_progress { background-color: var(--blue); }
.stat-bar-fill-in_review { background-color: var(--purple); }
.stat-bar-fill-waiting_to_deploy { background-color: var(--teal); }
.stat-bar-fill-waiting_for_answer { background-color: var(--amber); }
.stat-bar-fill-not_yet_started { background-color: var(--grey); }
.stat-bar-fill-blocked { background-color: var(--red); }
/* Amber, matching .status-on_hold and the timeline key. It was red here and
   amber everywhere else, so the same status was two different colours on the
   same screen and the chart's own key contradicted this page. */
.stat-bar-fill-on_hold { background-color: var(--amber); }

/* The priority ramp, cold to hot, mirroring the text colours the list and the
   cards use. Only three of these existed when the scale had three levels, so
   glacial, urgent, critical and inferno were falling through to the default
   blue — which drew "Critical" in the same colour as an ordinary bar and told
   the reader nothing.

   The top three are mixed rather than reused: three identical reds cannot be
   told apart in a bar, where colour is the only channel there is. */
.stat-bar-fill-glacial { background-color: var(--ice); }
.stat-bar-fill-low { background-color: var(--grey); }
.stat-bar-fill-medium { background-color: var(--ink-faint); }
.stat-bar-fill-high { background-color: var(--amber); }
.stat-bar-fill-urgent { background-color: color-mix(in srgb, var(--red) 65%, var(--amber)); }
.stat-bar-fill-critical { background-color: var(--red); }
.stat-bar-fill-inferno { background-color: color-mix(in srgb, var(--red) 72%, #000); }

.stat-bar-value { font-variant-numeric: tabular-nums; white-space: nowrap; }
.stat-bar-percent { color: var(--ink-soft); font-size: var(--text-xs); }

.stat-warning {
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--red);
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: var(--red-soft);
  font-size: var(--text-sm);
}

.stat-note { color: var(--ink-soft); font-size: var(--text-sm); }

/* The range a section covers, when it is not the range the month filter names.
   Outside the heading rather than inside it: in the heading, every section's
   accessible name would end in the disclaimer. */
.section-range {
  margin: calc(-1 * var(--space-2)) 0 var(--space-4);
  color: var(--ink-faint);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}

/* One sentence saying what happened, before the numbers that qualify it. */
.section-lead { margin: var(--space-4) 0; max-width: 68ch; }

/* Two breakdowns side by side. The minimum is wide enough for the fixed bar
   columns (11rem label + 5rem value + two gaps) plus a track worth drawing;
   below that they stack rather than squeezing the track to nothing. */
.stat-split {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(28rem, 1fr));
  gap: var(--space-6);
  margin-top: var(--space-5);
}

.stat-subsection { margin-top: var(--space-5); }

.stat-table { width: 100%; margin-top: var(--space-4); border-collapse: collapse; font-size: var(--text-sm); }

.stat-table th, .stat-table td {
  padding: var(--space-2) var(--space-3);
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid var(--line);
}

.stat-table thead th {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.badge-sync-succeeded { border-color: var(--green); background-color: var(--green-soft); color: var(--green); }
.badge-sync-failed { border-color: var(--red); background-color: var(--red-soft); color: var(--red); }
.badge-sync-running { border-color: var(--blue); background-color: var(--blue-soft); color: var(--blue); }

/* ==========================================================================
   Responsive
   The previous stylesheet had no media queries at all: the nav wrapped mid-word
   and the filter bar broke onto ragged rows on a phone.
   ========================================================================== */

@media (max-width: 60rem) {
  .container { padding-inline: var(--space-4); }

  .app-header .container {
    flex-wrap: wrap;
    gap: var(--space-2);
    padding-block: var(--space-2);
  }

  .header-meta { width: 100%; margin-left: 0; justify-content: space-between; }

  /* The flex item here is the <nav> wrapper, not the .nav list inside it: left
     at min-width:auto it refuses to shrink below its row of nowrap links, so it
     overflows the header and takes the whole document with it — the page scrolls
     sideways and the last items sit off-screen. Shrinking it lets the list below
     scroll inside its own bar instead. */
  .app-header nav { min-width: 0; }

  .nav { order: 3; width: 100%; overflow-x: auto; }

  /* Table header can no longer stick once rows become blocks. */
  .table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

  .table, .table tbody, .table tr, .table td { display: block; width: auto; }

  .table tbody tr {
    padding: var(--space-3) var(--space-4);
    border-bottom: 1px solid var(--line);
  }

  .table td {
    display: flex;
    justify-content: space-between;
    gap: var(--space-4);
    padding: var(--space-1) 0;
    border: 0;
  }

  /* Row label comes from the cell's data-label, so the stacked view stays
     readable without duplicating every heading in the markup. */
  .table td::before {
    content: attr(data-label);
    flex: none;
    color: var(--ink-faint);
    font-size: var(--text-xs);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
  }

  .table td.cell-title {
    display: block;
    margin-bottom: var(--space-2);
    font-size: var(--text-base);
  }
  .table td.cell-title::before { content: none; }

  .table td:empty { display: none; }

  .toolbar-field { flex: 1 1 8rem; min-width: 0; }
  .toolbar-count { width: 100%; margin-left: 0; }

  .detail-grid { grid-template-columns: 1fr; gap: var(--space-1) 0; }
  .detail-grid dd { margin-bottom: var(--space-3); }

  .form-actions .action-destructive { margin-left: 0; }

  /* Narrower columns, still fixed: the point is that every track matches, and
     on a phone the track is the part that can least afford to be eaten. Status
     labels wrap to two lines here, which is the right trade. */
  .stat-bar { grid-template-columns: 7rem 1fr 4.25rem; gap: var(--space-2); }

  /* .cell-edit's nowrap was written for the desktop table, where .col-status
     and .col-assignee reserve the width it needs. Here the table has stacked
     and the cell is a flex row of [label | value], so a row carrying three
     assignee chips cannot break and pushes the whole DOCUMENT sideways —
     which takes the header, the nav and the flash region off screen with it,
     the same failure the board scrolls inside itself to avoid.
     Left-aligned rather than space-between as well: once the values wrap onto
     a second line, spreading every line to both edges strands the last chip
     alone against the right margin. */
  .table td.cell-edit {
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-start;
    gap: var(--space-2);
    white-space: normal;
  }

  /* The assignee cell is the one holding a variable number of values, so it is
     the one whose label goes on its own line: left inline, the first name sits
     alone beside it and every other one starts a ragged second row.
     data-label is already this layout's hook — the stacked view draws all its
     row labels from that attribute. */
  .table td.cell-edit[data-label="Assignee"]::before { flex: 0 0 100%; }

  /* The chip's right margin exists to hold it off the next chip in a desktop
     row. Here the flex gap does that, and the two together double it. */
  .table td.cell-edit .assignee-chip { margin-right: 0; }

  /* A status with its reason, and a cell of board links, are each one value
     here, against the right edge like every other cell's. */
  .pr-status, .pr-board-links { text-align: right; }
  .pr-board-links { align-items: flex-end; }

  .pr-legend dl { grid-template-columns: 1fr; gap: var(--space-1); }
  .pr-legend dd { margin-bottom: var(--space-2); }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}

/* The header identity doubles as the way into the profile. */
a.header-user { text-decoration: none; border-radius: var(--radius); padding: var(--space-1) var(--space-2); }
a.header-user:hover { background-color: #27272b; color: var(--surface); }
a.header-user[aria-current="page"] { color: var(--surface); box-shadow: inset 0 -2px 0 var(--accent); }

/* --- One-time secret reveal (API token, join link) ------------------------ */

.token-reveal { flex-direction: column; }

.token-value {
  display: block;
  margin-top: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background-color: var(--surface);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  /* The token has no spaces, so it must be allowed to break or it will push the
     page sideways on a phone. */
  overflow-wrap: anywhere;

  /* One click selects the whole value. Both things shown here are shown once
     and never again, and a join link is pasted into a chat window — a
     half-selected URL is a link that does not work, and the person it was for
     has to ask for another one. */
  user-select: all;
}

/* --- Support tracker state ------------------------------------------------ */

.support-ticket {
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
}
.support-ticket + .support-ticket { margin-top: var(--space-2); }

.support-ticket-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}

.support-ticket-meta {
  margin: var(--space-2) 0 0;
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.support-ticket-warn { color: var(--amber); }

/* --- Keyboard shortcuts --------------------------------------------------- */

.shortcut-hint {
  margin: 0;
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

/* The two bands that used to sit between the toolbar and the first row, now
   one line: saving a view on the left, the shortcut hint on the right. It
   wraps rather than squeezing, because the save field needs a real width. */
.toolbar-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  margin: var(--space-2) 0 var(--space-3);
}

/* A finished, clean sync says so in a line. Everything a reader has to act on
   keeps the banner — see shared/_sync_banner. */
.sync-quiet {
  margin: 0 0 var(--space-3);
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.sync-quiet strong { color: var(--ink); font-weight: 600; }

kbd {
  display: inline-block;
  min-width: 1.4em;
  padding: 0.05rem var(--space-1);
  border: 1px solid var(--line-strong);
  border-bottom-width: 2px;
  border-radius: 4px;
  background-color: var(--surface);
  color: var(--ink-soft);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  text-align: center;
}

.shortcut-help {
  max-width: 26rem;
  margin-bottom: var(--space-4);
  padding: var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
}

.shortcut-help h2 { font-size: var(--text-base); }

.shortcut-list {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  font-size: var(--text-sm);
}

.shortcut-list dt { text-align: right; }
.shortcut-list dd { margin: 0; color: var(--ink-soft); }

/* The table header is sticky, so a row focused by j/k would otherwise slide
   underneath it. Reserve its height plus a little breathing room. */
.table tbody tr { scroll-margin-top: calc(var(--header-height) + 3rem); }

/* --- The comment thread --------------------------------------------------- */

.comment-thread {
  margin: 0;
  padding: 0;
  list-style: none;
}

.issue-comment {
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--line);
  scroll-margin-top: calc(var(--header-height) + 1rem);
}
.issue-comment:last-child { border-bottom: 0; }

/* A reply is indented by a border rather than by padding on the list, and the
   indent is a fixed rem rather than a percentage. Six levels of percentage
   indent leaves a word-wide column on a phone; six levels of this leaves
   4.5rem of it, and .detail is only 52rem wide to begin with. The border is
   the thread line — it says "this is under that" without spending width on a
   gutter. */
.comment-thread .comment-thread {
  margin-top: var(--space-3);
  padding-left: var(--space-3);
  border-left: 2px solid var(--line);
}

.comment-meta {
  margin: 0 0 var(--space-1);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

/* The words are gone but the node is not, so it reads as a gap somebody left
   rather than as a comment that failed to render. */
.comment-removed {
  margin: 0;
  color: var(--ink-faint);
  font-style: italic;
  font-size: var(--text-sm);
}

.comment-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2);
  margin: var(--space-2) 0 0;
  font-size: var(--text-sm);
}
.comment-actions details { display: inline-block; }
.comment-actions summary { cursor: pointer; color: var(--ink-faint); }

/* Remove is a button_to, so it would otherwise render as a native button — a
   grey box in the middle of a row of quiet text actions, off the baseline of
   the Reply and Edit disclosures beside it. Dressed as one more text action:
   borderless, inheriting the font, sitting on the same line, and only turning
   red — the destructive colour — on hover. */
.comment-actions .button-form { display: inline; margin: 0; }
.comment-action {
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--text-sm);
  line-height: inherit;
  color: var(--ink-faint);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 2px;
}
.comment-action:hover { color: var(--ink); }
.comment-action.action-delete { color: var(--ink-faint); }
.comment-action.action-delete:hover { color: var(--red); background: none; border: 0; }

/* Long enough to write a paragraph in without being the whole page, and it
   grows rather than scrolling inside itself. overflow-wrap because a pasted
   URL with no spaces in it is the one string that can push a phone sideways. */
.comment-form textarea {
  width: 100%;
  margin-bottom: var(--space-2);
  overflow-wrap: anywhere;
}
.comment-form { margin-bottom: var(--space-4); }
.comment-error { color: var(--red); }

/* A name somebody was actually told about, marked so it does not read as
   ordinary text that happens to start with an @. Tinted rather than boxed: a
   mention sits inside a sentence, and a pill would break the line it is in.
   The span is the only markup the renderer emits that an author cannot — see
   Markup::SafeMarkdown::MENTION_CLASS. */
.prose .mention {
  color: var(--blue);
  font-weight: 600;
}

/* --- Activity trail ------------------------------------------------------- */

.activity {
  margin: 0;
  padding: 0;
  list-style: none;
}

.activity-entry {
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--line);
}
.activity-entry:last-child { border-bottom: 0; }

.activity-line { margin: 0; font-size: var(--text-sm); }

.activity-meta {
  margin: var(--space-1) 0 0;
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

/* The values are issue text from a shared sheet, so they are quoted visually
   rather than being allowed to read as part of the sentence. */
.activity-value {
  padding: 0.05rem var(--space-1);
  border-radius: 3px;
  background-color: var(--sunken);
  font-family: var(--font-mono);
  font-size: 0.9em;
  overflow-wrap: anywhere;
}

/* --- Bulk actions --------------------------------------------------------- */

/* Sticky at the foot of the viewport rather than in the document flow. The bar
   only appears once something is selected, and revealing an in-flow element
   would shift the table under the cursor mid-click - the hazard the previous
   always-visible version was avoiding. Overlaying answers it without keeping
   four dropdowns on screen permanently. */
.bulk-bar {
  position: sticky;
  bottom: var(--space-3);
  z-index: 5;
  display: flex;
  align-items: flex-end;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-3);
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--raised);
  box-shadow: 0 6px 24px rgb(0 0 0 / 18%);
}

.bulk-bar[hidden] { display: none; }

/* --- Inline row editing ------------------------------------------------- */

/* The controls should read as part of the row, not as a form dropped into it:
   borderless until hovered or focused, so twenty rows of them stay calm. */
.row-edit { display: inline; }

/* The no-JS fallback, kept out of everyone else's way.
   Hidden here rather than from Stimulus: .button sets its own display, which
   beats the UA rule for the hidden attribute, so setting el.hidden left every
   Save button on screen - three per row, fifty-three rows. CSS wins that fight,
   and the class comes off <html> before first paint so nothing flashes. */
/* The type selector is not decoration: input[type="submit"] carries its own
   display: inline-flex at 0-1-1, which outranks a bare class no matter how late
   it appears - which is exactly why every Save button stayed on screen through
   two attempts to hide it. Matching the same weight settles it. */
.row-edit-save,
input.row-edit-save,
button.row-edit-save { display: none; }

.no-js .row-edit-save,
.no-js input.row-edit-save,
.no-js button.row-edit-save { display: inline-flex; }

/* appearance: none is the point of this block. A native select forces a caret,
   a box and OS metrics into every cell; at fifty-three rows that is a hundred
   arrows pointing at nothing. Stripped back, the control renders as the value
   it holds - and only admits to being a control on hover or focus. */
.row-select {
  appearance: none;
  -webkit-appearance: none;
  padding: 1px var(--space-2) 1px 2px;
  border: 1px solid transparent;
  border-radius: var(--radius-sm, 4px);
  background-color: transparent;
  font: inherit;
  color: inherit;
  cursor: pointer;
  max-width: none;
}

/* The chevron appears next to the label, not pinned to a far edge, and only
   when the reader is actually pointing at the cell.
   It painted --muted, which this stylesheet has never defined. An unresolvable
   var() is invalid at computed-value time, which for background-image means
   `none` — so the chevron described above and in the three comments below it
   had never once appeared, and nobody noticed because the rules all read as if
   it had. The four other uses of the same phantom token were quieter: `color`
   inherits instead, so those controls were simply not muted. */
.row-select:hover,
.row-select:focus-visible {
  border-color: var(--line);
  background-color: var(--surface);
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-faint) 50%),
                    linear-gradient(135deg, var(--ink-faint) 50%, transparent 50%);
  background-position: right 0.62rem center, right 0.35rem center;
  background-size: 0.28rem 0.28rem, 0.28rem 0.28rem;
  background-repeat: no-repeat;
  padding-right: 1.15rem;
}

/* Never remove the focus ring - the whole feature is keyboard-reachable and a
   select you cannot see the focus on is unusable without a mouse. */
.row-select:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }

/* In flight. The controller disables the control so a second change cannot
   race the first; aria-busy carries that state to assistive tech, and this is
   its one visual echo - dimmed, not hidden, so the pending value stays
   readable while the row waits for its swap. */
.row-select[aria-busy="true"] {
  opacity: 0.55;
  cursor: progress;
}

/* The label inside the pill: the wrapper carries the state colour and the dot,
   so the text matches .status-label exactly as before. */
/* Sized by its own label rather than padded out to the longest one: a fixed
   min-width left the chevron stranded an inch from the text, reading as a
   second control. The COLUMN holds the width instead, so rows still align. */
/* min-width is back, but it is no longer visible as a gap: the chevron only
   appears on hover now, so the reserved room reads as ordinary cell padding
   rather than as a second control stranded to the right. Sized to the longest
   label ("Waiting for answer") plus the room that chevron takes. */
.status-select { color: var(--ink); min-width: 9.6rem; }

.col-status { width: 12rem; white-space: nowrap; }
.col-assignee { width: 13rem; white-space: nowrap; }

/* Reads as a quiet "add" affordance rather than a squashed dropdown: no caret
   of its own, round on hover. The native menu is laid out by the browser, so
   every name is still legible when it opens. */
/* A resting outline, faint but there: without one this control is a bare glyph
   that says nothing about being a control until the pointer is already on it,
   and a reader looking for where to add somebody has nothing to look for. */
.row-select-add {
  width: 1.65rem;
  padding: 1px 0;
  border: 1px dashed var(--line);
  border-radius: 999px;
  color: var(--ink-faint);
  text-align: center;
  font-size: 1rem;
  line-height: 1.1;
}

.row-select-add:hover,
.row-select-add:focus-visible {
  background-image: none;
  padding-right: 0;
  border-style: solid;
  border-radius: 999px;
  color: var(--ink);
}

/* Quieter than the name it sits beside, but never hidden. Revealing it only on
   hover looked tidier and was worse: there is no hover on a touch screen, you
   cannot tell a name is removable until you happen to point at it, and tabbing
   to an invisible control is not a fallback. Muted, not absent. */
.chip-remove { opacity: 0.5; transition: opacity 0.12s ease; }
.assignee-chip:hover .chip-remove,
.chip-remove:hover,
.chip-remove:focus-visible { opacity: 1; }

/* Says the change landed. The row is swapped by Turbo, so the animation runs
   on the new element and needs no cleanup. */
@keyframes row-saved {
  from { background-color: var(--accent-soft); }
  to { background-color: transparent; }
}

/* Only on a row the server just re-rendered after a change - not on every row
   of every page load, which would flash the whole table yellow on arrival. */
.row-saved { animation: row-saved 0.9s ease-out; }

@media (prefers-reduced-motion: reduce) {
  .row-saved { animation: none; }
}

.cell-edit { white-space: nowrap; }

.assignee-chip {
  display: inline-flex;
  align-items: center;
  gap: 1px;
  margin-right: var(--space-2);
  white-space: nowrap;
}

.chip-remove {
  border: 0;
  background: none;
  padding: 0 2px;
  color: var(--ink-faint);
  cursor: pointer;
  font-size: 1rem;
  line-height: 1;
}

.chip-remove:hover { color: var(--ink); }

/* A pointer that cannot hover cannot reveal anything: on a touch screen the
   0.5 opacity was not a resting state, it was the ONLY state, on a control the
   reader has no way to discover — and 2px of padding around a × is a target no
   thumb can hit. Grown toward the 44px a finger needs; the chip gets taller
   with it, which on a phone is the right way round.
   `hover: none` also matches a hybrid (a laptop with both a touch screen and a
   trackpad). Giving one of those a bigger, fully visible × is the cheaper half
   of the trade — the alternative leaves the person with no hover at all unable
   to take a name off an issue. */
@media (hover: none) {
  .chip-remove {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 2.5rem;
    min-height: 2.5rem;
    padding: 0;
    opacity: 1;
    font-size: 1.1rem;
  }
}

/* --- Collapsed filters --------------------------------------------------- */

.filter-more > summary {
  cursor: pointer;
  padding: var(--space-2) 0;
  font-weight: 600;
  color: var(--ink-faint);
}

.filter-more[open] > summary { margin-bottom: var(--space-2); }

/* In the toolbar's one row while closed, so the way to the other filters costs
   a summary and nothing else; the full width of it once opened, below the
   controls it shares the row with. */
.filter-more { flex: 0 0 auto; }

.filter-more[open] {
  flex: 1 0 100%;
  order: 10;
}

/* What is hidden, named on the summary line, so a narrowed list always says
   what narrowed it. */
.filter-summary {
  margin-left: var(--space-2);
  font-weight: 400;
  color: var(--ink);
}

.filter-row-actions { align-items: center; }

/* The count sits at the far end of the row it shares with the controls. */
.filter-row-actions .toolbar-count { margin-left: auto; }

.bulk-count {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.col-select, .cell-select { width: 1%; }

.cell-select input[type="checkbox"],
.bulk-bar input[type="checkbox"] { width: auto; accent-color: var(--ink); }

/* The stacked layout hides the header, so the bare checkbox would have no
   label at all; its aria-label carries the issue title. */
@media (max-width: 60rem) {
  .table td.cell-select::before { content: none; }
}

/* --- Saved views ---------------------------------------------------------- */

.saved-views {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-3);
}

.saved-view {
  display: inline-flex;
  align-items: center;
}

/* Sits against its link rather than floating: the × belongs to the name beside
   it, and a gap here reads as a separate control. */
.saved-view-remove {
  padding: 0 var(--space-2);
  border: 0;
  background: none;
  color: var(--ink-faint);
  font: inherit;
  line-height: 1;
  cursor: pointer;
}
.saved-view-remove:hover { color: var(--red); }

.saved-view-form {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.saved-view-form input[type="text"] { width: 12rem; }

/* --- Project management --------------------------------------------------- */

.source-form {
  display: flex;
  align-items: center;
  gap: var(--space-2);
}

.source-form input[type="text"] { width: 11rem; }

/* The access column can hold three controls at once — a merge picker and two
   join-link buttons. Laid in one wrapping row they align on the cross axis;
   left to flow, each block form broke the line and the buttons stacked ragged. */
.access-cell {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}

/* --- Due dates ------------------------------------------------------------ */

/* Tabular figures so a column of dates lines up digit for digit; a due column
   is read by scanning down it, not by reading any one row. */
.due-date {
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* The one date allowed to draw the eye, and only while the work is still open.
   Red is the app's alarm colour and it is spent sparingly - the word "overdue"
   is printed beside it, so this is reinforcement rather than the signal. */
.due-overdue {
  color: var(--red);
  font-weight: 600;
}

/* --- Status board --------------------------------------------------------- */

/* The list and the board are two ways of reading one set of issues, so their
   switch sits on the same line as the scope filters rather than starting a
   second row of controls above the page. */
.view-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-3);
}

.view-switch {
  display: flex;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}

.board-summary {
  margin-bottom: var(--space-4);
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.board-clear { margin-left: var(--space-2); }

/* Eight columns is wider than a laptop, so the BOARD is the scroll container
   rather than the document: a page that scrolls sideways takes the header, the
   nav and the flash region off screen with it, and the reader loses the way out
   of the view they are in. */
.board {
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
  overflow-x: auto;
  padding-bottom: var(--space-3);
}

.board-column {
  display: flex;
  flex-direction: column;
  /* Never squeezed to fit: eight columns sharing a laptop's width would be
     eight columns of wrapped single words. The board scrolls instead. */
  flex: none;
  width: 15rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--sunken);
}

.board-column-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding: var(--space-3);
  border-bottom: 1px solid var(--line);
}

/* The heading is the .status pill, so a column and the cards in it carry the
   same hue and the same dot. */
.board-column-title {
  margin: 0;
  font-size: var(--text-sm);
}

.board-column-link {
  color: var(--ink);
  font-weight: 700;
  text-decoration: none;
}
.board-column-link:hover { text-decoration: underline; }

.board-count {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}

/* Each column scrolls inside its own container, so one very long column cannot
   set the height of the whole board. */
.board-cards {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  /* An empty column is still somewhere a card has to be able to land, so it
     keeps a body rather than collapsing to nothing. */
  min-height: 4.5rem;
  max-height: 60vh;
  padding: var(--space-2);
  overflow-y: auto;
}

.board-card {
  /* Not decoration: .visually-hidden is position: absolute, and with no
     positioned ancestor its containing block is the page rather than the card.
     That puts its 1px box at the card's static position, OUTSIDE the board's
     overflow clip - and a label per card across eight columns gave the document
     the horizontal scrollbar this board exists to keep off it. */
  position: relative;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background-color: var(--surface);
  box-shadow: var(--shadow);
  font-size: var(--text-sm);
}

.board-card-title { display: block; }

/* An editor's card is draggable, so it says so before anything is picked up.
   The selector keys off the attribute the card partial sets only for an editor,
   so a client's read-only card does not advertise a gesture it cannot make. */
.board-card[draggable="true"] { cursor: grab; }
.board-card[draggable="true"]:active { cursor: grabbing; }

.board-card-status { margin-top: var(--space-2); }

/* The row's status select reserves room for the longest label plus its
   chevron, which is most of a 15rem column. Here the pill is on its own line
   and the column heading already names the status, so it sizes to its value. */
.board-card .status-select { min-width: 0; }

/* Lifted off the board rather than hidden: the reader needs to keep track of
   which card they are carrying. */
.board-card.is-dragging {
  opacity: 0.5;
  cursor: grabbing;
}

/* Where it would land. Borrows the accent the rest of the app reserves for
   focus and the primary action, which is exactly what this is. */
.board-column.is-drop-target {
  border-color: var(--accent);
  background-color: var(--accent-soft);
}

/* In flight, in the same language the row controls use: dimmed rather than
   hidden, so the card stays readable while the server decides. A refused move
   clears this and puts the select back - the card never moved, and it must not
   be left claiming it did. */
.board-card.is-moving {
  opacity: 0.55;
  cursor: progress;
}

/* Rendered only when the card has a date, so it needs no empty-state of its
   own - see the card partial. */
.board-card-dates {
  margin-top: var(--space-2);
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.board-card-meta,
.board-card-people {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  font-size: var(--text-xs);
}

/* The card's chips never grew a remove control, so the margin the list's chips
   carry for theirs is only a gap here. */
.board-card .assignee-chip { margin-right: 0; }

/* Shown only while the column holds no card. :has() rather than a class the
   server maintains: a card arriving or leaving is a Turbo Stream that knows
   nothing about this paragraph, and making its visibility a fact about the DOM
   keeps it right in both directions with no second stream to keep in step. */
.board-empty {
  margin: 0;
  padding: var(--space-2);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.board-cards:has(.board-card) .board-empty { display: none; }

/* Where the rest of a capped column is read. At the bottom, because it is what
   follows the last card rather than a control over the column. */
.board-see-all {
  display: block;
  padding: var(--space-2) var(--space-3);
  border-top: 1px solid var(--line);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  text-decoration: none;
}
.board-see-all:hover { color: var(--ink); }
.source-form select { width: 11rem; }

/* --- Timeline ------------------------------------------------------------- */

/* THE TIMELINE GRID (.timeline-scroll, .timeline-grid, .timeline-bar)
   A Gantt chart is a table of positions, so it is one CSS grid and no library:
   a lane-label column, then one column per day or per week, with every item
   placed explicitly from the server. The same rule the board lives by applies
   twice over here - the GRID scrolls sideways and the document never does - and
   nothing inside it may be position: absolute, because a child with no
   positioned ancestor escapes this overflow clip and hands the whole page the
   horizontal scrollbar this container exists to prevent. That includes
   .visually-hidden, which is why the "today" marker is named in the legend
   under the chart rather than by a hidden label inside it. */
.timeline-scroll {
  overflow-x: auto;
  padding-bottom: var(--space-3);
}

.timeline-grid {
  --timeline-label: 9rem;
  --timeline-column: 1.75rem;
  display: grid;
  /* grid-template-columns is written inline by the view: the column count is
     data, it changes with the range on every render, and no stylesheet can
     hold it. The two widths above are what that inline value refers to. */
  grid-auto-rows: minmax(1.75rem, auto);
  align-items: stretch;
  row-gap: var(--space-1);
  min-width: min-content;
  font-size: var(--text-xs);
}

/* A week column has to hold "23 Mar", a day column two digits. */
.timeline-grid.is-weekly { --timeline-column: 4.25rem; }

.timeline-month {
  padding: 0 var(--space-1) var(--space-1);
  border-bottom: 1px solid var(--line);
  color: var(--ink-soft);
  font-weight: 600;
  white-space: nowrap;
}

.timeline-column-head {
  padding-bottom: var(--space-1);
  color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
  text-align: center;
}

.timeline-column-head abbr { text-decoration: none; }
.timeline-column-head.is-weekend { color: var(--line-strong); }

/* Order matters: today wins over the weekend, because the faint grey the
   weekend uses disappears against the accent the marked column is drawn on. */
.timeline-column-head.is-today {
  color: var(--ink);
  font-weight: 700;
}

/* One background for the whole chart rather than an element per cell: 42
   columns by 20 rows would be 840 empty divs to draw a set of lines. */
.timeline-backdrop {
  background-image: repeating-linear-gradient(
    to right,
    var(--line) 0 1px,
    transparent 1px var(--timeline-column)
  );
  pointer-events: none;
}

.timeline-weekend {
  background-color: var(--sunken);
  pointer-events: none;
}

/* Where now is. Accent rather than a status hue - it is the same "you are
   here" the app spends its yellow on everywhere else. */
.timeline-today {
  background-color: var(--accent-soft);
  border-left: 2px solid var(--accent);
  pointer-events: none;
}

/* Sticky, not absolute: the names have to stay readable while the chart is
   scrolled sideways, and on a phone the grid is scrolled almost immediately.
   Sticky stays inside the scroll container's clip, which absolute would not. */
.timeline-lane {
  position: sticky;
  left: 0;
  z-index: 2;
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  padding: var(--space-1) var(--space-3) var(--space-1) 0;
  border-top: 1px solid var(--line);
  background-color: var(--surface);
}

.timeline-lane-name {
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.timeline-lane-count {
  color: var(--ink-faint);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}

/* The bar takes its hue from the same .status-* class the pill and the card
   use, so one issue is one colour wherever it is drawn. currentColor does the
   rest: the border is the status, the fill is a wash of it, and the title stays
   ink so it is readable at 12px on a 1.75rem column. */
.timeline-bar {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  min-width: 0;
  padding: 0 var(--space-1);
  border: 1px solid currentColor;
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: var(--raised);
  background-color: color-mix(in srgb, currentColor 12%, var(--surface));
}

/* The bar says it can be moved before anything is picked up, in the same
   vocabulary the board's cards use. pan-y rather than none: the page must still
   scroll vertically under a finger that happens to land on a bar, while a
   sideways drag on one belongs to this controller.

   The grab hand rides on the editable wrapper, not the bar's own class: an
   editor's chart is draggable and a client's read-only one is not, and a cursor
   is a promise of the gesture. */
.timeline-bar {
  touch-action: pan-y;
  user-select: none;
}
.timeline-scroll.is-editable .timeline-bar { cursor: grab; }
.timeline-scroll.is-editable .timeline-bar:active { cursor: grabbing; }

.timeline-bar-title {
  overflow: hidden;
  color: var(--ink);
  font-weight: 600;
  text-decoration: none;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timeline-bar-title:hover { text-decoration: underline; }

/* The two ends, as real elements so a pointer can tell them apart from the
   middle. Wide enough to hit and no wider: on a one-column bar the two handles
   would otherwise be the whole bar and moving it would be impossible. */
.timeline-handle {
  flex: none;
  align-self: stretch;
  width: 6px;
  max-width: 25%;
  cursor: ew-resize;
}

/* Lifted while it is being carried, not hidden: the reader has to keep track of
   which bar they are holding and where it has got to. Outlined in the accent -
   the app's "this is the thing you are acting on" colour - because a bar under
   the pointer is otherwise indistinguishable from the seven it is passing over.
   An outline rather than a border: it takes no layout, so a lifted bar cannot
   change the width of the grid it is being dragged across. */
.timeline-bar.is-dragging {
  z-index: 3;
  cursor: grabbing;
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  box-shadow: 0 4px 10px rgb(0 0 0 / 18%);
}

/* In flight, in the one vocabulary the whole app uses for "the server has not
   answered yet": dimmed to ~0.55, cursor: progress, never hidden. A refused
   move clears this and puts the bar back where it was - it never moved, and it
   must not be left claiming it did. */
.timeline-bar.is-moving {
  opacity: 0.55;
  cursor: progress;
}

/* A bar with no deadline, whose right edge is today and moves on its own.
   Drawn deliberately unlike every other bar: an edge closed off the usual way
   would be the chart asserting a deadline nobody set, which is the very lie
   this rule exists to stop telling. So the right edge is not drawn at all and
   the fill washes out into the page - "still running", rather than "ends here".
   The fade is 1rem rather than a percentage: a bar can be a single column, and
   a percentage would eat a short one whole while washing out the title on a
   long one. 1rem is under half of the 1.75rem day column and a quarter of the
   4.25rem week one, so both layouts - and the phone, which changes only the
   label column - keep a legible bar. The words are in the bar's own title, and
   the legend under the chart names the convention. */
.timeline-bar.is-open-ended {
  border-right-width: 0;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  /* The mask takes the fill AND the two long borders with it. A background
     gradient alone left those borders running to a hard stop, which is the
     closed edge this rule is trying not to draw. The title never reaches into
     the fade on a bar wide enough to print one, and on a bar too narrow for
     that its words are in the title attribute and the schedule below. */
  mask-image: linear-gradient(to right, #000 calc(100% - 1rem), transparent);
}

/* A bar standing on the day the issue first appeared, because nobody gave it
   dates. The service has always marked these; until now nothing drew them, so
   a guessed bar and a scheduled one were pixel-identical — on a board where
   170 of 197 bars were guesses, the chart was asserting a schedule for
   virtually everything it showed.

   The left edge is the one that is fabricated in every case, so that is the
   edge that says so: dashed, and lightened toward it rather than filled to a
   hard start. Not a hue change — the status colour is doing real work here and
   the clipped rule below has already claimed amber for "this bar is not where
   it looks". The words are in the bar's title and the note above the chart. */
.timeline-bar.is-estimated {
  border-left-style: dashed;
  mask-image: linear-gradient(to right, transparent, #000 0.75rem);
}

/* Both edges guessed. The masks would otherwise overwrite one another and the
   left edge would silently go back to being drawn as a fact. */
.timeline-bar.is-estimated.is-open-ended {
  mask-image: linear-gradient(to right, transparent, #000 0.75rem calc(100% - 1rem), transparent);
}

/* A bar whose dates now fall outside the range the page drew. Clamped to the
   edge and dashed on the side that ran off it, because a bar drawn flush
   against the last column would be claiming a date it does not have. */
.timeline-bar[data-clipped] {
  border-style: dashed;
  /* Amber, not its status hue: what this bar has to say is "I am not where I
     look", which is a warning about the chart rather than a fact about the
     work, and the refit link beside the legend is drawn in the same colour. */
  border-color: var(--amber);
  cursor: not-allowed;
}

/* The colour key. Wraps rather than scrolls — eight statuses plus two edge
   conventions is more than fits one line on a laptop, and a key you have to
   scroll sideways to finish reading is a key nobody finishes reading. */
.timeline-key {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
  list-style: none;
  margin: var(--space-3) 0 0;
  padding: 0;
  font-size: var(--text-sm);
  color: var(--ink-soft);
}

.timeline-key-item {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  white-space: nowrap;
}

/* Built from the same three declarations a bar is, so the swatch is a small
   bar rather than an approximation of one: border in the status colour, fill
   mixed from it. currentColor comes from the status- class beside it. */
.timeline-key-swatch {
  width: 1.6rem;
  height: 0.8rem;
  flex: none;
  border: 1px solid currentColor;
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: color-mix(in srgb, currentColor 12%, var(--surface));
}

/* The two non-colour conventions, drawn the way the bars draw them. Neutral
   ink: these say something about the EDGE, not about a status. */
.timeline-key-open {
  color: var(--ink-faint);
  border-right-width: 0;
  border-top-right-radius: 0;
  border-bottom-right-radius: 0;
  mask-image: linear-gradient(to right, #000 55%, transparent);
}

.timeline-key-estimated {
  color: var(--ink-faint);
  border-left-style: dashed;
  mask-image: linear-gradient(to right, transparent, #000 0.5rem);
}

.timeline-undated,
.timeline-legend {
  margin: 0 0 var(--space-3);
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.timeline-legend { margin: var(--space-3) 0 var(--space-5); color: var(--ink-faint); }

/* Shown only once a bar has actually been moved out of view, so it reads as an
   answer to something that just happened rather than as standing furniture. */
.timeline-refit {
  color: var(--amber);
  font-weight: 600;
}

.timeline-schedule { margin-top: var(--space-6); }

.timeline-schedule-hint {
  margin: 0 0 var(--space-3);
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

/* The date pair reads as one control: start, due and the Save that sends them
   together, on one line. nowrap and a shrink-to-fit cell are a pair - the cell
   sizes to min-content, so a wrapping row would make that content one box wide
   and stack all three. */
.timeline-schedule-table .row-edit {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  gap: var(--space-2);
}

/* Sized to its own content. The app's base rule gives every date input the full
   measure of its container, which here is a table cell wide enough to put the
   pair on two lines and the Save on a third. */
.timeline-schedule-table input[type="date"] {
  width: auto;
  min-width: 8.5rem;
  padding: 0.2rem var(--space-2);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background-color: var(--surface);
  color: var(--ink);
  font-family: inherit;
  font-size: var(--text-sm);
}

/* The dates column takes only what it needs, so the issue titles keep the rest
   of the row rather than being squeezed against a column of white space. */
.timeline-schedule-table td:last-child,
.timeline-schedule-table th:last-child { width: 1%; white-space: nowrap; }

/* The state this view exists or dies on. Sized and worded like a paragraph
   rather than an error: nothing has gone wrong, the work simply has no dates
   yet, and the way out is a link rather than a retry. */
.timeline-empty {
  padding: var(--space-6);
  border: 1px dashed var(--line-strong);
  border-radius: var(--radius);
  background-color: var(--sunken);
  color: var(--ink-soft);
  text-align: center;
}

.timeline-empty p { margin: 0 0 var(--space-2); }
.timeline-empty p:last-child { margin-bottom: 0; }

/* Below 60rem the table stacks and the cell is the width of the screen, where
   three controls in a row no longer fit. Left unwrapped they would push the
   DOCUMENT sideways and take the header, the nav and the flash region with it -
   the same failure the assignee chips hit above, answered the same way. */
@media (max-width: 60rem) {
  .timeline-schedule-table .row-edit { flex-wrap: wrap; }
  .timeline-schedule-table td:last-child { width: auto; white-space: normal; }
}

/* On a phone the lane column takes a third of the screen before a single bar
   is visible, so it gives most of that back and the names truncate. */
@media (max-width: 40rem) {
  .timeline-grid { --timeline-label: 5.5rem; }
}

/* --- The completion heatmap ----------------------------------------------- */

/* THE HEATMAP (.heatmap-scroll, .heatmap, .heatmap-day)
   A year of finished work as one square per day: a <table> rather than a grid
   of divs, because weeks-as-columns and weekdays-as-rows IS a table, and the
   month labels are its column headers. Same rule as the board and the timeline
   - the wide thing scrolls inside itself and the document never does - with the
   same corollary: the only absolutely positioned thing in here is the hover
   tooltip, and it is anchored to a square that is position: relative, so it
   stays inside this clip rather than handing the page a sideways scrollbar.

   NO SECOND PALETTE. The five shades are mixes of --green (the hue the
   completed status already carries everywhere else in the app) into --surface,
   so one issue's colour means the same thing on a pill, a bar and a square. */
.heatmap-scroll {
  overflow-x: auto;
  /* Room under the last row for a tooltip to open downwards without being
     clipped by the very container that keeps this off the document. */
  padding-bottom: var(--space-6);
}

.heatmap {
  --heatmap-cell: 0.8125rem;
  border-collapse: separate;
  border-spacing: 3px;
  /* min-content, so the squares keep their size and the container scrolls
     instead of the year being squeezed into whatever width is going. */
  min-width: min-content;
}

.heatmap-caption {
  margin-bottom: var(--space-2);
  color: var(--ink-faint);
  font-size: var(--text-xs);
  text-align: left;
  caption-side: top;
}

/* The label is absolute inside a relative cell, and that is load-bearing rather
   than decorative: a table column is at least as wide as its header, so a
   "Dec 2025" left in flow over a one-week band would stretch that week's
   squares to twice the width of every other week's. Out of flow it overhangs
   its neighbours instead, and the cell keeps a height of its own so the row
   does not collapse to nothing. */
.heatmap-month {
  position: relative;
  height: 1.125rem;
  padding: 0;
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 600;
  text-align: left;
}

.heatmap-month-label {
  position: absolute;
  top: 0;
  left: 0;
  white-space: nowrap;
}

.heatmap-corner { width: 2.25rem; }

.heatmap-weekday {
  width: 2.25rem;
  padding-right: var(--space-2);
  color: var(--ink-faint);
  font-size: 0.625rem;
  font-weight: 400;
  text-align: right;
  white-space: nowrap;
}

/* min-width, not just width: a table column is never narrower than its cells'
   MINIMUM width, and an empty cell's minimum is zero. Without the floor, a
   phone did not scroll the grid at all — the auto table layout squeezed 53
   weeks into 358px and drew the year as a set of hairlines with every month
   label stacked on top of the next. */
.heatmap-day,
.heatmap-key {
  width: var(--heatmap-cell);
  min-width: var(--heatmap-cell);
  height: var(--heatmap-cell);
  padding: 0;
  border-radius: 2px;
  background-color: var(--sunken);
}

/* Relative, not for looks: it is what keeps the tooltip below inside the
   scroll container's clip instead of escaping to the document. */
.heatmap-day { position: relative; }

.heatmap-key { display: inline-block; }

/* A day that has not happened yet is drawn as nothing at all, so it cannot be
   mistaken for a day on which nothing was finished. */
.heatmap-day.is-future { background-color: transparent; }

/* Four steps, and which counts each one covers is decided from the data and
   printed in the legend - see Stats::CompletionCalendar#thresholds. A board
   that never finishes more than one a day gets one shade, not four faked ones. */
.heatmap-day.is-level-1,
.heatmap-key.is-level-1 { background-color: color-mix(in srgb, var(--green) 22%, var(--surface)); }
.heatmap-day.is-level-2,
.heatmap-key.is-level-2 { background-color: color-mix(in srgb, var(--green) 45%, var(--surface)); }
.heatmap-day.is-level-3,
.heatmap-key.is-level-3 { background-color: color-mix(in srgb, var(--green) 70%, var(--surface)); }
.heatmap-day.is-level-4,
.heatmap-key.is-level-4 { background-color: var(--green); }

.heatmap-day:hover { outline: 1px solid var(--ink-soft); }

/* The tooltip is the aria-label, read back through attr() rather than kept as a
   second copy in a data attribute: two strings would be two things to keep in
   step, and the one a screen reader gets is the one that must be right. */
.heatmap-day:hover::after {
  content: attr(aria-label);
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  z-index: 3;
  padding: var(--space-1) var(--space-2);
  border-radius: var(--radius);
  background-color: var(--ink);
  color: var(--surface);
  font-size: var(--text-xs);
  white-space: nowrap;
  pointer-events: none;
}

.heatmap-legend {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  margin: 0 0 var(--space-2);
  padding: 0;
  list-style: none;
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.heatmap-legend-step {
  display: flex;
  align-items: center;
  gap: var(--space-1);
}

.heatmap-key-label { font-variant-numeric: tabular-nums; }

/* A caveat, not a failure: the chart is missing work because the audit trail
   never saw it, which is a fact about the data rather than something broken.
   Amber says "read this before you read the chart" without the red that this
   app spends on a sync that is actually down. */
.heatmap-caveat {
  padding: var(--space-3) var(--space-4);
  border: 1px solid var(--amber);
  border-left-width: 3px;
  border-radius: var(--radius);
  background-color: var(--amber-soft);
  color: var(--ink);
  font-size: var(--text-sm);
}

/* Completed by month, per assignee. Thirteen columns do not fit a phone, so the
   grid scrolls inside its own box rather than widening the page. */
.assignee-months-scroll { overflow-x: auto; }

.assignee-months {
  border-collapse: collapse;
  min-width: min-content;
  font-size: var(--text-sm);
}

.assignee-months th,
.assignee-months td {
  padding: var(--space-1) var(--space-2);
  border-bottom: 1px solid var(--line);
}

.assignee-months-month {
  color: var(--ink-soft);
  font-weight: 500;
  white-space: nowrap;
}

/* The month still under way is labelled in words ("so far"); the colour only
   repeats it, so a short current month is not read as a slow one. */
.assignee-months-month.is-current { color: var(--ink-faint); font-style: italic; }

/* Pinned while the months scroll on a phone, or a row of bars loses the name
   it belongs to; the corner too, so the month labels pass under it. */
.assignee-months-corner,
.assignee-months-name {
  position: sticky;
  left: 0;
  z-index: 1;
  background-color: var(--surface);
}

/* break-word rather than anywhere: anywhere makes one letter the cell's
   minimum width, and the table then squeezes the name to a column of single
   letters. */
.assignee-months-name {
  max-width: 11rem;
  text-align: left;
  font-weight: 500;
  overflow-wrap: break-word;
}

.assignee-months-cell { text-align: center; vertical-align: bottom; }

/* Fixed height, so every bar in the grid is drawn against the same scale the
   service computed: a taller box for a busier person would undo it. */
.assignee-months-track {
  display: flex;
  align-items: flex-end;
  width: 1.5rem;
  height: 2.5rem;
  margin: 0 auto;
  border-radius: 2px;
  background-color: var(--sunken);
}

.assignee-months-fill {
  display: block;
  width: 100%;
  border-radius: 2px 2px 0 0;
  background-color: var(--green);
}

.is-unassigned .assignee-months-fill { background-color: var(--grey); }
.is-unassigned .assignee-months-name { color: var(--ink-soft); font-style: italic; }

.assignee-months-count {
  display: block;
  margin-top: 2px;
  font-variant-numeric: tabular-nums;
}

.assignee-months-cell.is-empty .assignee-months-count { color: var(--ink-faint); }

.assignee-months-total {
  text-align: right;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* Connected consoles: the agents page and the issue page's send section.
   .badge-ok is the first green badge outside the sync banner; the sync's own
   is named for the sync, and a state a console reports is not a sync. */
.badge-ok { border-color: var(--green); background-color: var(--green-soft); color: var(--green); }
/* A console that has stopped beating is gone, not waiting on anybody: amber is
   this board's "waiting on a person" colour and must not say "asleep". */
.badge-off { border-color: var(--line-strong); background-color: var(--sunken); }
/* A chat a beating console is holding: the board's in-progress blue, kept off
   green so "in hand" never reads as "done". */
.badge-work { border-color: var(--blue); background-color: var(--blue-soft); color: var(--blue); }
.agent-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-4); }
.agent { border: 1px solid var(--line); border-radius: var(--radius); padding: var(--space-4); background: var(--surface); }
/* The reader's own console, so a list of machines says which one is theirs.
   Monochrome, not the accent: gold is held for focus, the nav and the primary
   action. */
.agent-own { border-color: var(--ink-faint); }
.agent-header { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-3); }
.agent-name { margin: 0; font-size: var(--text-lg); }
.agent-meta, .agent-chat-meta, .agent-task-more { color: var(--ink-soft); font-size: var(--text-sm); }
.agent-chats, .agent-task-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
.agent-chat, .agent-task { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-3); }
.agent-empty { margin: 0; color: var(--ink-soft); font-size: var(--text-sm); }
.agent-howto { color: var(--ink-soft); font-size: var(--text-sm); }
/* The live work, lifted out of the cards to the top of the page: a raised block
   so what is being worked on now is the first thing read, not a line buried
   inside a console card. */
.agent-working { border: 1px solid var(--line); border-radius: var(--radius); background-color: var(--raised); padding: var(--space-4); margin-bottom: var(--space-4); }
.agent-working h2 { margin: 0 0 var(--space-3); font-size: var(--text-xs); font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--ink-soft); }
.agent-working-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
.agent-working-item { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2) var(--space-3); font-size: var(--text-sm); }
.agent-working-issue { font-family: var(--font-mono); font-size: var(--text-xs); }
.agent-working-title { font-weight: 600; }
.agent-working-console { color: var(--ink-soft); }
.agent-working-age { font-family: var(--font-mono); color: var(--ink-soft); font-size: var(--text-xs); }
/* The bar has no words of its own, so on the working row's baseline it would sit
   on its bottom edge; centred it reads with the row instead. */
.agent-working-item .agent-task-bar { align-self: center; }
/* A chat holding a task nobody is working: the console that took it has gone.
   Its own class rather than .badge-off, which says the console is off; this
   says the task has no answer. Same neutral grey, because it is
   neither the board's in-progress blue nor the amber that means waiting on a
   person. */
.badge-stall { border-color: var(--line-strong); background-color: var(--sunken); color: var(--ink-soft); }
/* The working row's issue link and the row's send link are the only way in from
   a row, so each reaches the height a thumb needs. */
.agent-working-issue, .agent-chat-send { display: inline-flex; align-items: center; min-height: 1.5rem; }
/* The row's action, kept on one line so "Send an issue →" never breaks after
   the arrow. */
.agent-chat-send { white-space: nowrap; }
/* The note and the commands under the connect box: each a paragraph after the
   first, given room the component's own p rule sets to none. */
.page-note-body .agent-connect-cmd, .page-note-body .agent-connect-note,
.empty-state .agent-connect-note { margin-top: var(--space-2); }
.agent-send { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); margin-bottom: var(--space-4); }
.agent-task-error { color: var(--red); font-size: var(--text-sm); }
/* The pipeline rail: the stages a console reports, the reached ones filled and
   the current one named. It carries its words as well as its dots, and the
   current step wears the board's in-progress blue — the same blue a working
   task does everywhere else. */
.agent-stage-rail { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2) var(--space-3); flex-basis: 100%; }
.agent-stage-step { display: inline-flex; align-items: center; gap: var(--space-1); font-size: var(--text-xs); color: var(--ink-faint); }
.agent-stage-step::before { content: ""; width: 0.4rem; height: 0.4rem; border-radius: 50%; border: 1.5px solid currentcolor; }
.agent-stage-step.is-done { color: var(--ink-soft); }
.agent-stage-step.is-done::before { background-color: currentcolor; }
.agent-stage-step.is-current { color: var(--blue); font-weight: 700; }
.agent-stage-step.is-current::before { background-color: var(--blue); border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }

/* A task the console is working shows how far it has got and the console's own
   estimate of the time left. The bar is a measure, not a status, so it carries
   no hue of its own; the number beside it says the same thing in words. */
.agent-task-progress { display: inline-flex; align-items: center; gap: var(--space-2); flex-basis: 100%; }
.agent-task-bar { position: relative; width: 8rem; height: 0.35rem; border-radius: var(--radius-pill); background-color: var(--sunken); overflow: hidden; }
.agent-task-bar-fill { display: block; height: 100%; background-color: var(--ink-soft); }
.agent-task-eta, .agent-chat-task { color: var(--ink-soft); font-size: var(--text-xs); }

/* ==========================================================================
   Page refinements carried over from the design-system demos: a headline
   callout that states the page's finding, deltas on the stat tiles so a number
   carries its context, a two-bar comparison, and an inline SVG line chart.
   ========================================================================== */

.headline {
  display: flex;
  gap: var(--space-3);
  align-items: flex-start;
  background-color: var(--accent-soft);
  border: 1px solid var(--accent);
  border-radius: var(--radius);
  padding: var(--space-4);
  margin-bottom: var(--space-5);
}
.headline svg { flex: none; color: var(--accent-ink); margin-top: 0.15rem; }
.headline p { margin: 0; }
.headline b { color: var(--accent-ink); }
.headline span { color: var(--ink-soft); }

.stat-tile-delta { font-size: var(--text-xs); color: var(--ink-faint); }
.stat-tile-delta.good { color: var(--green); }
.stat-tile-delta.bad { color: var(--amber); }

.compare { display: flex; flex-direction: column; gap: var(--space-3); margin: var(--space-4) 0 0; max-width: 34rem; }
.compare-row { display: grid; grid-template-columns: 7rem 1fr 4.5rem; gap: var(--space-3); align-items: center; font-size: var(--text-sm); }
.compare-track { background-color: var(--sunken); border-radius: var(--radius-pill); height: 1.1rem; overflow: hidden; }
.compare-fill { display: block; height: 100%; border-radius: var(--radius-pill); }
.compare-fill.now { background-color: var(--green); }
.compare-fill.prev { background-color: var(--ink-faint); }
.compare-value { text-align: right; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-soft); }

.chart-wrap { margin-top: var(--space-4); }
.chart { width: 100%; height: auto; display: block; overflow: visible; }
.chart .baseline { stroke: var(--line-strong); stroke-width: 1; }
.chart .gridline { stroke: var(--line); stroke-width: 1; }
.chart .series { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.chart .xtick, .chart .ytick { fill: var(--ink-faint); font-size: 10px; font-family: var(--font-mono); }
.chart-caption { font-size: var(--text-xs); color: var(--ink-faint); margin: var(--space-2) 0 0; }

/* Ageing ramps toward the red end, because old open work is the risk the chart
   is asking about — the colour carries the same reading as the label. */
.stat-bar-fill.f-age-0-7 { background-color: var(--green); }
.stat-bar-fill.f-age-8-30 { background-color: var(--grey); }
.stat-bar-fill.f-age-31-90 { background-color: var(--amber); }
.stat-bar-fill.f-age-90 { background-color: var(--red); }

/* A small mark on the today column, beside the number the legend names anyway —
   the marked column is the one a reader looks for first. */
.today-tag { display: inline-block; margin-top: var(--space-1); font-size: 0.6rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; background-color: var(--accent); color: var(--accent-ink); border-radius: var(--radius-pill); padding: 0.05rem var(--space-2); }

/* A wide form lays The issue and its attachments in the reading column, and the
   triage and references fields beside them, so the description is not pushed
   below a screen of selects. */
.form--wide { max-width: 76rem; }
.form-grid { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: var(--space-6); align-items: start; }
@media (max-width: 64rem) { .form-grid { grid-template-columns: 1fr; } }
.form-main, .form-side { min-width: 0; }

/* The issue page reads in two columns: what people wrote on the left, the facts
   and the related sections on the right. The side keeps its place in the DOM
   (it renders first) and is placed into column 2 by the grid, so a narrow
   screen just stacks them. */
.detail-layout { display: grid; grid-template-columns: minmax(0, 1fr) 22rem; gap: var(--space-6); align-items: start; }
.detail-main { grid-column: 1; grid-row: 1; min-width: 0; }
.detail-side { grid-column: 2; grid-row: 1; min-width: 0; }
@media (max-width: 64rem) {
  .detail-layout { grid-template-columns: 1fr; }
  .detail-main, .detail-side { grid-column: auto; grid-row: auto; }
}
