/* The application shell: header, navigation, main, footer. */

.app {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

/* ---------- boot ---------- */

.boot {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-4);
  min-height: 60vh;
  color: var(--text-muted);
}

.boot__spinner,
.state__spinner {
  width: 2rem;
  height: 2rem;
  border: 2px solid var(--border-strong);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.boot__text { margin: 0; }

/* ---------- header ---------- */

.app__header {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  flex-wrap: wrap;
  min-height: var(--header-height);
  padding: var(--space-2) var(--space-4);
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  position: sticky;
  top: 0;
  z-index: 20;
}

.brand {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-weight: var(--weight-bold);
  white-space: nowrap;
}

.brand__mark {
  width: 1.25rem;
  height: 1.25rem;
  border-radius: var(--radius-sm);
  background: linear-gradient(135deg, var(--accent), var(--accent-hover));
}

.header__spacer { flex: 1 1 auto; }

.picker {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-width: 0;
}

.picker__label {
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--text-subtle);
  white-space: nowrap;
}

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

/* ---------- announcement (RF-14) ---------- */
/* Contributes zero layout when empty — the container holds no padding, no border and no height
   of its own, and `margin: 0 auto` has no vertical component, so a quiet deployment with
   nothing to say still reserves no strip. Only the `.notice` inside it is styled once one
   actually exists (see states.js's `announcementBanner`).

   RF-20260826-06 — IT LIVES IN THE CONTENT COLUMN, exactly like `.app__main`, and that is a
   correctness rule rather than a taste one. `.app__side` is a FIXED rail in the left gutter
   that `.app__main`'s `max-width` + `margin: 0 auto` creates. A full-width banner ran straight
   underneath it and the rail painted over its left end. Matching main's column is what keeps
   the banner beside the rail instead of behind it. Mount order does the vertical half of the
   same fix — see shell.js. */
.app__announcement {
  width: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
}
.app__announcement .notice { margin: var(--space-2) var(--space-4); }

/* ---------- navigation ---------- */

.app__nav {
  background: var(--surface);
  border-bottom: 1px solid var(--border);
  padding: 0 var(--space-4);
  position: sticky;
  /* THE MEASURED HEADER, NOT THE DECLARED MINIMUM — see --chrome-header in tokens.css.
     The header is padded and wraps, so it renders taller than --header-height and this bar
     used to slide underneath it. */
  top: var(--chrome-header);
  /* DECLARED, so the Settings tab bar can stick underneath it. See --nav-height. */
  min-height: var(--nav-height);
  z-index: 15;
  /* A row, so the view links and the global New task action share it and the action can be
     pushed to the end. */
  display: flex;
  align-items: center;
  gap: var(--space-3);
}

.app__nav[hidden] { display: none; }

.nav__list {
  display: flex;
  gap: var(--space-1);
  list-style: none;
  margin: 0;
  padding: 0;
  /* The links scroll if they must; the New task action never does, so it stays put. */
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: thin;
}

.nav__link {
  display: inline-block;
  padding: var(--space-3) var(--space-4);
  color: var(--text-muted);
  text-decoration: none;
  white-space: nowrap;
  /* Rounded, like .side__link, so the active indicator below can curl into the corner
     instead of ending in a hard square edge. */
  border-radius: var(--radius-sm);
  transition: color var(--transition), background var(--transition), box-shadow var(--transition);
}

.nav__link:hover {
  color: var(--text);
  background: var(--surface-sunken);
}

/* THE SAME TECHNIQUE AS .side__link--active: an INSET box-shadow on a rounded box, not a
   border. A border draws a flat line that stops dead at the corner; an inset shadow is
   clipped by the element's own border-radius, so it bends up into the curve instead of
   ending square — the same little arc the left rail's active mark makes around its icon. */
.nav__link--active {
  color: var(--accent);
  box-shadow: inset 0 -2px 0 0 var(--accent);
  font-weight: var(--weight-medium);
}

/* ---------- main ---------- */

.app__main {
  flex: 1 1 auto;
  width: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: var(--space-5) var(--space-4);
}

/* RF-20260827-21 — A ROUTE MAY OPT INTO MORE WIDTH, WITHOUT MOVING THE RAIL'S GUTTER.
   Kanban above all: a fixed `--content-max` clipped its columns on a wide monitor while the
   whole rest of the screen sat empty on both sides. The obvious fix — raise `--content-max`
   itself — was rejected: the left rail's THREE breakpoint states (see the block above `.app__side`)
   are hand-derived algebra that assumes `--content-max` is one fixed number, and every one of
   them would need re-deriving, would need to know which view is showing to pick the right
   number, and the rail's own presentation would then change depending on which TAB was open at
   an UNCHANGED viewport width — the exact "layout jump on route change" the acceptance criteria
   rules out.

   SO THIS GROWS ONLY TO THE RIGHT. `.app__main`'s normal LEFT EDGE — `(100% - --content-max) / 2`,
   the same arithmetic `margin: 0 auto` produces — is recomputed here explicitly and used as
   `margin-left`, so a `--wide` route sits at EXACTLY the position an ordinary route would have
   put its left edge. The rail's gutter is built from that same, now-untouched, edge and never has
   to know this class exists. Nothing else occupies the right gutter (confirmed — the only other
   `position: fixed` element in this file is the rail, on the left), so growing rightward carries
   no collision risk symmetric growth would have had.

   DELIBERATELY `%`, NEVER `vw`, FOR BOTH HALVES OF THIS CALCULATION. The rail-gutter comment
   above already documents the exact failure mode of mixing them: `vw` measures the full
   viewport, a percentage here is carved from the (possibly scrollbar-narrowed) containing
   block, and treating them as interchangeable is "the part that is easy to get wrong" by that
   comment's own account. Width is expressed as "the whole container, minus the same invariant
   left margin" — both terms drawn from the identical `100%` base — rather than as an
   independently-specified viewport fraction, so no discrepancy can creep in between them.

   The width still lands in "roughly 80-90% of usable width" in practice (measured: 82% of a
   2000px viewport, verified in Edge) and is hard-capped at `--content-wide-max` so an ultra-wide
   monitor is not handed one unreadable sprawl of columns — which is also what "without forcing
   every panel to fill that maximum" asks for: past the cap, the box stops growing rather than
   chasing the viewport indefinitely.

   Below the width where the left-margin formula would go negative (a narrow viewport, where the
   rail is already off-canvas), `max(0px, ...)` clamps it to zero and both the margin and the
   width converge to the same edge-to-edge behaviour `.app__main` itself already has there —
   narrow-screen behaviour is inherited, not re-implemented. */
.app__main--wide {
  max-width: none;
  margin-left: max(0px, calc((100% - var(--content-max)) / 2));
  width: min(calc(100% - max(0px, calc((100% - var(--content-max)) / 2))), var(--content-wide-max));
}

.view__header { margin-bottom: var(--space-5); }
.view__title { margin-bottom: var(--space-1); }

.view__subtitle {
  color: var(--text-muted);
  max-width: 60ch;
  margin: 0;
}

/* ---------- footer ---------- */

.app__footer {
  padding: var(--space-4);
  border-top: 1px solid var(--border);
  background: var(--surface);
}

.footer__text {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  justify-content: center;
  margin: 0;
  font-size: var(--text-sm);
  color: var(--text-subtle);
}

@media (max-width: 40rem) {
  .app__header { gap: var(--space-2); }
  .picker__label { display: none; }
  .app__nav { top: auto; position: static; }
}

/* The global New task action. Pushed to the end of the navigation row so its position is
   the same on every view — that consistency is the whole point of making it global. */
.nav__actions { margin-inline-start: auto; display: flex; align-items: center; gap: var(--space-2); }
.nav__new-task { white-space: nowrap; }

/* ---------- the left navigation rail ----------

   IT GOES IN SPACE THE SHELL ALREADY HAD. `.app__main` is `max-width: var(--content-max)`
   centred with `margin: 0 auto`, so every screen wider than the content carries an empty
   column down each side. The rail occupies the left one.

   FIXED, NOT IN FLOW, AND THAT IS THE WHOLE POINT. Placing it in the layout would take its
   width from `.app__main` and shift every view right — the task table, the widest thing in
   the product, would lose a column of real estate to pay for navigation. Positioned instead,
   the main column stays exactly where it was.

   THREE STATES, DECIDED BY WHETHER IT FITS. CSS is the only thing that knows how much room
   there actually is, so the breakpoints decide and the component does not:

     wide    the gutter holds the expanded rail       (--content-max + 2 x --side-width)
     medium  the gutter holds the icon rail only      (--content-max + 2 x --side-rail)
     narrow  the gutter cannot hold anything          off-canvas, opened by a button

   Overlapping the content it is meant to sit beside would be worse than not being there.

   RF-20260826-07 — THE BREAKPOINTS NOW ACTUALLY COMPUTE THAT RULE. THEY DID NOT.
   They read 101rem and 64rem, which are not derivable from any token here, and the gap was not
   cosmetic: measured in Edge, `.app__main`'s own content sat UNDER the fixed rail across
   1030-1360px and 1616-1664px — the two bands where a state had been entered before its gutter
   existed. 1024-1360px covers ordinary laptop widths, so the most common screens were the
   broken ones, and the announcement banner meeting the rail was one symptom of it.

   THE ARITHMETIC, so the next person can re-derive rather than trust:

     the rail sits in the left gutter          gutter = (document width - --content-max) / 2
     it must not reach the content             gutter >= its own width
     so                                        document width >= --content-max + 2 x width
     and a media query measures the VIEWPORT,
     which still includes the classic scrollbar
     while the gutter is computed without it   viewport >= --content-max + 2 x width + scrollbar

     expanded  80rem + 2 x 13rem  + 1rem  = 107rem
     icon      80rem + 2 x 3.5rem + 1rem  =  88rem

   THE 1rem IS THE SCROLLBAR, AND IT IS THE PART THAT IS EASY TO GET WRONG. Windows draws a
   15-17px classic scrollbar; `@media (max-width)` matches `window.innerWidth`, which includes
   it, while the centred gutter is carved out of the narrower document box. Without the
   allowance the rail re-entered each state ~15px too early and clipped the content again — the
   original defect, one scrollbar wide. Overlay scrollbars simply make it 1rem conservative.

   THE THREE QUERIES BELOW MOVE TOGETHER OR THE RAIL BECOMES UNREACHABLE: the off-canvas rule
   and the drawer-toggle rule must use the SAME number, or there is a band where the rail has
   stepped off screen and the button that brings it back is still hidden.
   `app/test/railbreakpoints.test.js` recomputes all of this from the tokens and fails if any
   of them drifts again. */

.app__side {
  --side-width: 13rem;
  --side-rail: 3.5rem;
  position: fixed;
  top: calc(var(--chrome-header) + var(--chrome-nav));
  bottom: 0;
  left: 0;
  width: var(--side-width);
  z-index: 14;
  display: flex;
  flex-direction: column;
  padding: var(--space-3) var(--space-2);
  background: var(--surface);
  border-right: 1px solid var(--border);
  overflow-y: auto;
  overscroll-behavior: contain;
}

.app__side[hidden] { display: none; }
.app__side[data-collapsed="true"] { width: var(--side-rail); }

.side__nav { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.side__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.side__list--foot { margin-top: auto; padding-top: var(--space-3); border-top: 1px solid var(--border); }

.side__link {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  text-decoration: none;
  font-size: var(--text-sm);
  white-space: nowrap;
  /* The label is what overflows when the rail narrows, never the mark. */
  overflow: hidden;
}

.side__link:hover { background: var(--surface-raised, var(--surface)); color: var(--text); }

/* THE CURRENT VIEW, MARKED TWICE. The bar is what a sighted person sees; `aria-current` on the
   link is what everybody else gets. Colour alone would say nothing to either a screen reader
   or somebody who cannot distinguish it. */
.side__link--active {
  background: var(--surface-raised, var(--surface));
  color: var(--text);
  box-shadow: inset 2px 0 0 var(--accent);
  font-weight: var(--weight-bold);
}

/* B14 — A VALID DROP TARGET WHILE DRAGGING A TASK. A dashed outline, not a colour alone: it
   must read clearly beside the solid inset shadow `--active` already uses, and it must not be
   mistaken for "this is the current view" when it may be neither. */
.side__link--dropzone {
  outline: 2px dashed var(--accent);
  outline-offset: -2px;
  background: var(--surface-raised, var(--surface));
}

.side__mark { flex: 0 0 auto; width: 1.25rem; text-align: center; opacity: 0.85; }
.side__label { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.side__count {
  margin-left: auto;
  font-size: var(--text-xs);
  padding: 0 var(--space-2);
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--border);
  color: var(--text-muted);
}

/* QUICK ACCESS. Set apart from the views by a rule rather than by spacing alone, because the
   two lists answer different questions — "which view" and "which project" — and reading them
   as one list is how somebody clicks a project expecting a view. */
.side__pinned {
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--border);
}

.side__heading {
  margin: 0 0 var(--space-2);
  padding: 0 var(--space-3);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

.side__link--project { font-size: var(--text-sm); }

/* UNPIN APPEARS ON HOVER OR FOCUS, NEVER `display: none`. Removed from the box entirely it
   would be unreachable by keyboard and invisible to a screen reader; faded it is always in
   the tab order and always announced. Same rule as the Projects row actions. */
.side__unpin {
  flex: 0 0 auto;
  margin-left: auto;
  background: none;
  border: 0;
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  cursor: pointer;
  padding: 0 var(--space-2);
  opacity: 0;
  transition: opacity 120ms ease;
}
.side__item:hover .side__unpin,
.side__item:focus-within .side__unpin,
.side__unpin:focus-visible { opacity: 1; }
.side__unpin:hover { color: var(--text); }

.side__toggle {
  margin-top: var(--space-3);
  align-self: flex-start;
  background: none;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  padding: var(--space-1) var(--space-2);
  cursor: pointer;
}
.side__toggle:hover { color: var(--text); }

/* MEDIUM: the gutter holds marks but not labels, so the rail is forced narrow whatever the
   person's saved preference says. The preference is not overwritten — it applies again as
   soon as there is room for it. */
@media (max-width: 107rem) {
  .app__side { width: var(--side-rail); }
  .app__side .side__label { display: none; }
  .app__side .side__count { margin-left: 0; }

  /* RF-20260827-01 — AND THE OVERLAY STILL OPENS HERE, which is what makes the expand control
     in this band do something real instead of nothing.

     There is no gutter left to grow into between 88rem and 107rem, so "expand" cannot mean a
     wider rail in flow. It means the rail lifts over the content at full width — the same
     overlay the narrow screens already use, reached from the same toggle and the same
     double-click. Before this the band had a forced icon rail, a toggle that offered to collapse
     something already collapsed, a dead double-click and no ☰: navigation with no way out. */
  .app__side[data-drawer="open"] { width: var(--side-width); }
  .app__side[data-drawer="open"] .side__label { display: block; }
  .app__side[data-drawer="open"] .side__count { margin-left: auto; }
}

/* THE OVERLAY'S OWN TREATMENT, AT EVERY WIDTH THAT HAS ONE. Hoisted out of the narrow query
   because the icon-rail band above now opens it too, and a scrim that appeared on a phone but
   not on a laptop would be the same rail behaving as two different components. */
.app__side[data-drawer="open"] {
  /* The scrim, without a second element to keep in sync with the open state. It is not
     hit-testable, so a tap on the page behind it reaches the page — which is exactly what the
     rail's own outside-click handler listens for to close. */
  box-shadow: 0 0 0 100vmax rgb(0 0 0 / 0.45);
}

/* NARROW: no gutter at all. The rail steps off-canvas and comes back as a drawer over the
   content, opened by a control in the header.
   
   OFF-CANVAS, NOT `display: none`. Removing it would make the pinned projects and the Backlog
   count unreachable from the rail on a phone, and the drawer is the whole reason they are not.
   Translated out it keeps its DOM, its focus order when open, and its collapse preference. */
@media (max-width: 88rem) {
  .app__side {
    width: var(--side-width);
    transform: translateX(-100%);
    transition: transform 140ms ease;
  }

  /* The collapse preference is about how much gutter to spend, and in a drawer there is no
     gutter being spent — so the drawer is always the full width regardless of it. */
  .app__side[data-collapsed="true"] { width: var(--side-width); }

  /* Only the travel is narrow-specific now; the scrim and the labels are declared once, above. */
  .app__side[data-drawer="open"] { transform: translateX(0); }
}

@media (prefers-reduced-motion: reduce) {
  .app__side { transition: none; }
}

/* THE DRAWER CONTROL. Present only where the drawer is, because a button that opens a panel
   already sitting open beside it is noise.

   RF-20260826-07 — IT WAS PRESENT EVERYWHERE, AND HAD BEEN ALL ALONG. The rule below used to
   read `.app__drawer-toggle { display: none; }`, one class. The button is built as
   `class="btn btn--ghost app__drawer-toggle"`, and `.btn { display: inline-flex }` lives in
   components.css, which index.html loads AFTER this file — equal specificity, later wins, so the
   hiding rule never applied and the ☰ sat in the header next to a rail that was already on
   screen. Measured in Edge before the fix: computed `display: flex` at 1920, 1500 and 1409px.

   `.btn` IS PART OF THE SELECTOR SO THE INTENT OUT-SPECIFIES THE THING IT MUST BEAT (0,2,0
   against 0,1,0), rather than depending on which stylesheet happens to load last. That is the
   same cascade trap recorded at beta.44 and in the settings-nav override note in components.css,
   and `railbreakpoints.test.js` now fails if this selector loses its compound form. */
.btn.app__drawer-toggle { display: none; }
/* THE SAME NUMBER AS THE OFF-CANVAS QUERY, DELIBERATELY. A band where the rail has stepped off
   screen and its only opener is still hidden would strand the pinned projects and the Backlog
   count with no way back. Asserted in railbreakpoints.test.js. */
@media (max-width: 88rem) {
  .btn.app__drawer-toggle { display: inline-flex; }
}

/* RF-20260827-01 — AND THE PUBLISHED MODE HAS THE FINAL WORD, because a viewport width is not
   the question. The question is whether navigation can be reached from what is on screen.

   `resolveNavMode` (app/js/core/navmode.js) answers it once, and `shell.js` publishes the answer
   as `data-nav-mode` on the mount point. The two queries above remain as the pre-measurement
   failsafe — correct for the instant before the first `publishNavMode`, and anywhere `matchMedia`
   or a `defaultView` does not exist — and `railbreakpoints.test.js` fails the build if they and
   NAV_BREAKPOINTS ever stop agreeing. That agreement is the property that actually broke, so it
   is the property under test.

   THE COMPOUND `.btn` IS STILL LOAD-BEARING. `.btn { display: inline-flex }` lives in
   components.css, which index.html loads after this file; a single-class rule here loses to it on
   source order at equal specificity, which is exactly how the original hide rule was dead for its
   entire life. Every rule below out-specifies it. */
[data-nav-mode="drawer"] .btn.app__drawer-toggle { display: inline-flex; }
[data-nav-mode="rail"] .btn.app__drawer-toggle { display: none; }
[data-nav-mode="full"] .btn.app__drawer-toggle { display: none; }
