/* ==========================================================================
   Monochrome ink on white, with yellow reserved as a genuine accent — it marks
   focus, the active nav item and the primary action, and nothing else. Status
   hues are the one exception: an issue board is unreadable without them.
   ========================================================================== */

/* ==========================================================================
   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.

   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 monochrome ink-on-white sheet whose eight status hues were each
   picked against #ffffff for contrast AND for being distinguishable from one
   another, which is the harder half. A second set that holds both 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 ten declarations that still
   hardcode a hex instead (#2f2f36, #27272b, #d4d4d8, #a01717, #52525b — all of
   them hover and header shades). */
:root {
  /* Neutrals. Near-black rather than #000 — pure black on white vibrates. */
  --ink: #16161a;
  --ink-soft: #52525b;
  --ink-faint: #8b8b94;
  --surface: #ffffff;
  --raised: #fafafa;
  --sunken: #f4f4f5;
  --line: #e4e4e7;
  --line-strong: #c9c9cf;

  --accent: #f5c518;
  --accent-ink: #16161a;
  --accent-soft: #fdf6d8;

  /* 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. */
  --grey: #6b7280;      --grey-soft: #f3f4f6;
  --blue: #2563eb;      --blue-soft: #eff4ff;
  --purple: #7c3aed;    --purple-soft: #f5f0ff;
  --teal: #0d9488;      --teal-soft: #eafaf7;
  --amber: #b45309;     --amber-soft: #fdf4e3;
  --green: #15803d;     --green-soft: #eaf7ee;
  --red: #b91c1c;       --red-soft: #fdeceb;
  /* 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: #6b7f9e;

  --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: #d4d4d8;
  font-size: var(--text-sm);
  font-weight: 500;
  text-decoration: none;
  white-space: nowrap;
}
.nav-link:hover { background-color: #27272b; 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: #d4d4d8;
  white-space: nowrap;
}

.header-role {
  padding: 0.05rem var(--space-2);
  border: 1px solid #52525b;
  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);
  background: none;
  color: #d4d4d8;
  font: inherit;
  font-size: var(--text-sm);
  cursor: pointer;
}
.link-button:hover { background-color: #27272b; 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);
  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: #2f2f36; border-color: #2f2f36; }
.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: #a01717; border-color: #a01717; }

.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 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-3);
  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);
}

.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); }

/* 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);
  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: #2f2f36; border-color: #2f2f36; }

/* ==========================================================================
   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); }

.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);
}

.table th, .table td {
  padding: var(--space-3) var(--space-3);
  text-align: left;
  vertical-align: top;
  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; }

.issue-link {
  font-weight: 600;
  text-decoration: none;
}
.issue-link:hover { text-decoration: underline; }

.issue-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. */
.issue-sub-diverged { color: var(--amber); }

.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); }

/* 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; }

.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); }

/* ==========================================================================
   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-5); }

/* Rendered Markdown. The tags here are exactly the ones
   Issues::Description::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); }

.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 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); }

/* 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; }

  .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; }
}

@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 0 var(--space-3);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

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); }

/* --- 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. */
.row-select-add {
  width: 1.65rem;
  padding: 1px 0;
  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-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); }

/* 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; }

.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; }

/* --- 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; }

/* The card is draggable, so it says so before anything is picked up. */
.board-card { cursor: grab; }
.board-card: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. */
.timeline-bar {
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
}
.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);
}
