/* The one token layer. Every colour in the application is declared here, once,
   through light-dark() — the light value first, the dark one second. Nothing
   further down needs a media query, and there is no second copy of the palette
   that could come to disagree with this one.
 *
 * Which half applies is decided by `color-scheme`, and that is decided in three
 * scopes: the declaration below follows the OS, and the two [data-theme] rules
 * pin it once the user has clicked the moon in the top bar. js/main.js writes
 * that attribute; nothing else about the theme lives in JavaScript. */

:root {
  color-scheme: light dark;

  /* The three planes: the page the cards lie on, the cards themselves, and
     the recess inside a card (an inline panel, the indicator strip). */
  --page: light-dark(#f2f2ef, #0d0f12);
  --surface: light-dark(#ffffff, #16181c);
  --surface-sunken: light-dark(#f8f8f6, #1b1e23);

  /* Ink, three levels: body text, secondary prose, and the faint level the
     axis ticks, captions and section labels sit at. */
  --fg: light-dark(#14161a, #f0efec);
  --fg-muted: light-dark(#53575e, #b4b8bf);
  --fg-faint: light-dark(#8b8e94, #82868d);

  --line: light-dark(#e4e4df, #262a31);
  --line-strong: light-dark(#cfd0ca, #383d45);

  /* Identity, read off the logo. Used by the mark, the wordmark and the active
     tab's underline, and by nothing else — an identity colour that also means
     "clickable" is neither. */
  --brand-navy: light-dark(#1a3c86, #4f7ae0);
  --brand-teal: light-dark(#0aa39c, #3fc9c4);
  --brand-violet: light-dark(#5b46c8, #9085e9);

  /* Interaction, and the sequential hue the density heatmap ramps on: blue is
     the validated default for a single-hue ramp. Deliberately not the brand
     teal — see above. */
  --accent: light-dark(#2a78d6, #5598e7);
  --accent-bg: light-dark(#eaf2fd, #16233a);

  /* Status. Reserved for verdicts and errors, never spent as a series colour.
     The trio cannot be separated by hue alone under protanopia (measured:
     green↔amber ΔE 3.6 light, 1.9 dark, and no red/amber/green set does
     better), which is why every verdict carries a glyph and a word as well —
     that pairing is the mitigation, not decoration. */
  --verdict-ok: light-dark(#0f8a3d, #4fc46a);
  --verdict-ok-bg: light-dark(#eaf5ed, #14291b);
  --verdict-borderline: light-dark(#a8720d, #e0b559);
  --verdict-borderline-bg: light-dark(#fbf2df, #2c2411);
  --verdict-violation: light-dark(#c33a3a, #ef7a76);
  --verdict-violation-bg: light-dark(#fbeceb, #2f1a19);
  /* A parse error is the same red at the same steps, under the name the error
     rules already use. One alias, not a second pair of values. */
  --error: var(--verdict-violation);
  --error-bg: var(--verdict-violation-bg);

  --wheel-point: light-dark(#26314a, #c9d3e6);

  /* The twelve rim wedges, hue (n-1)*30 by the position formula. Declared here
     rather than built in wheel.js so the light and dark steps can be chosen
     separately: the ring has to recede behind the verdict-coloured path in
     front of it, and what recedes on white does not recede on #16181c. */
  --rim-1: light-dark(hsl(0 38% 66%), hsl(0 30% 42%));
  --rim-2: light-dark(hsl(30 38% 66%), hsl(30 30% 42%));
  --rim-3: light-dark(hsl(60 38% 66%), hsl(60 30% 42%));
  --rim-4: light-dark(hsl(90 38% 66%), hsl(90 30% 42%));
  --rim-5: light-dark(hsl(120 38% 66%), hsl(120 30% 42%));
  --rim-6: light-dark(hsl(150 38% 66%), hsl(150 30% 42%));
  --rim-7: light-dark(hsl(180 38% 66%), hsl(180 30% 42%));
  --rim-8: light-dark(hsl(210 38% 66%), hsl(210 30% 42%));
  --rim-9: light-dark(hsl(240 38% 66%), hsl(240 30% 42%));
  --rim-10: light-dark(hsl(270 38% 66%), hsl(270 30% 42%));
  --rim-11: light-dark(hsl(300 38% 66%), hsl(300 30% 42%));
  --rim-12: light-dark(hsl(330 38% 66%), hsl(330 30% 42%));

  /* Eight fixed hues, assigned to genres by track-count rank (wheel.js's
     assignGenreHues), plus one shared grey for every genre beyond the
     eighth. Never tied to Camelot position — that channel already belongs
     to the rim. Slots 1–4 are measured to separate under CVD; slots 5–8
     cannot be, which is why the sidebar's filter rows are the legend. */
  --genre-hue-0: light-dark(#2a78d6, #3987e5);
  --genre-hue-1: light-dark(#eb6834, #d95926);
  --genre-hue-2: light-dark(#1baf7a, #199e70);
  --genre-hue-3: light-dark(#4a3aa7, #c07be0);
  --genre-hue-4: light-dark(#e87ba4, #d55181);
  --genre-hue-5: light-dark(#eda100, #c98500);
  --genre-hue-6: light-dark(#008300, #008300);
  --genre-hue-7: light-dark(#e34948, #e66767);
  --genre-other: light-dark(#9a958c, #85807a);

  --radius: 8px;
  --radius-card: 14px;
  /* Two layers: a contact shadow that says the card has an edge, and a soft
     lift under it. light-dark() is a <color> function and cannot wrap a
     shadow list, so the two switch by their colours instead — and the lift
     goes fully transparent in dark mode, where a large soft shadow reads as a
     grey halo rather than as elevation. */
  --shadow-contact: light-dark(rgba(20, 22, 26, 0.05), rgba(0, 0, 0, 0.4));
  --shadow-lift: light-dark(rgba(20, 22, 26, 0.18), transparent);
  --shadow: 0 1px 2px var(--shadow-contact), 0 8px 24px -12px var(--shadow-lift);
  --gap: 1.5rem;
  /* The distance between two cards, wherever two cards meet: tile to tile,
     the tile row to the drawings, drawing to drawing, and the drawings to the
     table. One value, because the eye reads an inconsistent one as a grouping
     that was meant. Tighter than --gap, which is the page's own margin — the
     cards are one instrument, not four things on a desk. */
  --card-gap: 0.75rem;
  /* The height every box-shaped control aligns on: the numeric fields, the
     select, the settings buttons and the three in the top bar. */
  --control-h: 1.85rem;
  /* The settings column's width, and the strip it collapses to. --sidebar-w is
     overwritten on <html> by js/sidebar.js from the persisted setting; the
     declaration here is what applies when nothing is stored. */
  --sidebar-w: 18rem;
  --sidebar-rail: 2.5rem;
  /* The fixed top bar's height. Everything below it — the sidebar, the
     scrolling content column, the toast stack — is offset by exactly this,
     so the bar's size is stated once. */
  --bar-h: 4.5rem;

  /* A percentage, never a pixel count: this tightens the whole rem system at
     once while a reader who has raised their browser's default font still
     scales with it. */
  font-size: 87.5%;
}

/* The explicit choice, stamped onto <html> by main.js's applyTheme(). It flips
   `color-scheme` and nothing else — every value above is already stated for
   both halves, so there is no palette to repeat here. */
:root[data-theme='light'] { color-scheme: light; }
:root[data-theme='dark'] { color-scheme: dark; }

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  /* The bar is fixed and so takes no space in the flow; this is the space
     it would have taken. */
  padding-top: var(--bar-h);
  background: var(--page);
  color: var(--fg);
  font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
  /* In rem, so body text scales with the root's 87.5% along with everything
     else. A pixel value here would have left one size behind. */
  font-size: 1rem;
  line-height: 1.5;
}

.app__title {
  margin: 0 0 2rem;
  font-size: 1.5rem;
  font-weight: 600;
  letter-spacing: 0.01em;
}

/* Visually hidden but present for assistive tech, standard pattern. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The top bar: app-level navigation across the full viewport width, above
   the sidebar rather than inside it. Carries the brand, the document tabs and
   the three controls on the right. A flat panel, not a card — it is the frame
   the cards sit inside, and a frame with a shadow of its own would compete
   with them. z-index sits above the sticky .viz-row's 10 and below the
   tooltip's 20 — the tooltip only ever appears under the pointer, which is in
   the content area. */

.topbar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 15;
  height: var(--bar-h);
  display: flex;
  align-items: stretch;
  gap: var(--gap);
  padding: 0 var(--gap);
  border-bottom: 1px solid var(--line);
  background: var(--surface);
}

/* Mark and wordmark, held apart from the tabs by a hairline: the identity is
   not one of the documents. */

.topbar__brand {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex: none;
  padding-right: var(--gap);
  border-right: 1px solid var(--line);
}

.topbar__logo {
  flex: none;
  height: 2.25rem;
  width: auto;
}

/* The one place the brand gradient is spent, beside the active tab's
   underline. Falls back to plain ink wherever background-clip: text does not
   apply — the colour is set first and only overwritten on the way through. */
.topbar__wordmark {
  color: var(--fg);
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  background: linear-gradient(100deg, var(--brand-navy), var(--brand-teal) 55%, var(--brand-violet));
  -webkit-background-clip: text;
  background-clip: text;
}

@supports (background-clip: text) or (-webkit-background-clip: text) {
  .topbar__wordmark {
    color: transparent;
  }
}

/* What state the tool is in, beside what it is called. In the bar rather than
   only in the footer: the footer is in view when nothing is loaded, and the
   moment a file lands it scrolls to the bottom of a long table. The full
   version and the terms are its tooltip. */
.topbar__version {
  flex: none;
  align-self: center;
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  color: var(--fg-faint);
  font-size: 0.625rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  cursor: help;
}

.topbar__tabs {
  display: flex;
  align-items: stretch;
  gap: 0.5rem;
  flex: 1 1 auto;
  /* Without this the flex item refuses to shrink below its content, and the
     ellipsis on the file name never engages. */
  min-width: 0;
  /* Four tabs against the min-width floor below outgrow a narrow viewport.
     Scrolling the row keeps every one of them readable; shrinking them past
     the floor would leave four ellipses that say nothing. */
  overflow-x: auto;
}

/* One tab per document — the collection, then one per loaded set version,
   then the empty one that would become the next. The whole tab is clickable
   — the label is a button for the keyboard, the click handler sits on the
   wrapper — except for the ✕, which stops the event so unloading never
   doubles as selecting. */

.topbar__tab {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.1rem;
  min-width: 7rem;
  max-width: 18rem;
  flex: 0 1 auto;
  padding: 0.3rem 0.7rem;
  border: 1px solid transparent;
  border-radius: var(--radius) var(--radius) 0 0;
  color: var(--fg-muted);
  cursor: pointer;
}

.topbar__tab:hover {
  background: color-mix(in srgb, var(--fg) 4%, transparent);
}

/* A gradient underline rather than a filled box: the tab row sits on the bar's
   own surface, and a second filled plane inside it read as a button. The rule
   is drawn over the bar's bottom border, which is what makes the active tab
   look attached to the content below it. */
.topbar__tab.is-active {
  color: var(--fg);
}

.topbar__tab.is-active::after {
  content: '';
  position: absolute;
  left: 0.35rem;
  right: 0.35rem;
  bottom: -1px;
  height: 2px;
  border-radius: 2px 2px 0 0;
  background: linear-gradient(90deg, var(--brand-teal), var(--brand-violet));
}

.topbar__tab-label {
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  font-size: 0.75rem;
  font-weight: 600;
  text-align: left;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.topbar__file {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  min-width: 0;
  color: var(--fg-faint);
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

/* A file name is arbitrarily long and the bar is not. Truncated here, in
   full in the title attribute. */
.topbar__file-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.topbar__clear {
  flex: none;
  padding: 0.1rem 0.3rem;
  border: none;
  border-radius: 4px;
  background: none;
  color: var(--fg-faint);
  font-size: 0.8125rem;
  line-height: 1;
  cursor: pointer;
}

.topbar__clear:hover {
  background: color-mix(in srgb, var(--fg) 10%, transparent);
  color: var(--fg);
}

.topbar__load {
  padding: 0;
  border: none;
  background: none;
  color: var(--accent);
  font: inherit;
  font-size: 0.8125rem;
  text-align: left;
  cursor: pointer;
}

.topbar__load:hover {
  text-decoration: underline;
}

/* The guide, the language toggle and the theme toggle sit together at the
   right, not in the tab row: none of them is a document that was loaded. The
   guide carries the auto margin that pushes the group over, so the two beside
   it only need a gap — three auto margins would share the free space and drift
   apart. All three are centred vertically inside the stretched bar. */

.topbar__help,
.topbar__language,
.topbar__theme {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  align-self: center;
  height: var(--control-h);
  padding: 0 0.7rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--fg-muted);
  font: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
}

.topbar__help {
  margin-left: auto;
}

.topbar__language,
.topbar__theme {
  margin-left: 0.5rem;
}

/* Square, because its content is one glyph rather than a word. */
.topbar__theme {
  width: var(--control-h);
  padding: 0;
  font-size: 0.9375rem;
  line-height: 1;
}

.topbar__help:hover,
.topbar__language:hover,
.topbar__theme:hover {
  border-color: var(--accent);
  background: var(--accent-bg);
  color: var(--accent);
}

.topbar__help.is-active {
  border-color: var(--accent);
  background: var(--accent-bg);
  color: var(--accent);
}

/* Sidebar: fixed to the viewport's left edge below the bar, with its own
   scroll, so it stays in place regardless of how tall main content or the
   sidebar's own contents grow. Falls back to static/stacked below the
   breakpoint. */

.sidebar {
  position: fixed;
  top: var(--bar-h);
  left: 0;
  width: var(--sidebar-w);
  height: calc(100vh - var(--bar-h));
  overflow-y: auto;
  padding: 1.25rem var(--gap) 2rem;
  border-right: 1px solid var(--line);
  /* The second flat panel, beside the top bar: the settings are the frame the
     content is read through, not a card on the page. */
  background: var(--surface);
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

/* Collapsed: a rail carrying nothing but the button that reopens it. The
   sections are not hidden here, they are not rendered at all (see
   renderSidebar) — a hidden control that still takes focus would be a worse
   bug than the width this fixes. */

[data-sidebar='collapsed'] .sidebar {
  width: var(--sidebar-rail);
  padding: 0.5rem 0;
  align-items: center;
}

[data-sidebar='collapsed'] .main-content {
  margin-left: var(--sidebar-rail);
}

/* The drag handle: a fixed sibling of the sidebar rather than a child, because
   .sidebar scrolls its own content and would carry the handle away with it.
   Sits on the sidebar's right edge, half its width to either side of it, below
   the top bar's z-index of 15 — the bar is navigation and stays on top. */

.sidebar-handle {
  position: fixed;
  top: var(--bar-h);
  bottom: 0;
  left: var(--sidebar-w);
  z-index: 12;
  width: 6px;
  margin-left: -3px;
  cursor: col-resize;
  /* The visible hairline is the sidebar's own border; this only appears on
     hover, so the handle is discoverable without drawing a second line. */
  background: transparent;
  touch-action: none;
}

.sidebar-handle:hover,
.sidebar-handle:focus-visible {
  background: var(--accent);
  outline: none;
}

/* Nothing to resize while collapsed: the rail is a fixed width, and a handle
   there would drag the sidebar back open sideways. */
[data-sidebar='collapsed'] .sidebar-handle {
  display: none;
}

/* The collapse toggle: present in both states, since it is what reopens the
   sidebar. Aligned to the right edge when open — the edge it acts on — and
   centred in the rail when collapsed, where it is the only thing there. */

.sidebar-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: flex-end;
  flex: none;
  width: var(--control-h);
  height: var(--control-h);
  padding: 0;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--fg-faint);
  font: inherit;
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
}

.sidebar-toggle:hover {
  border-color: var(--accent);
  background: var(--accent-bg);
  color: var(--accent);
}

[data-sidebar='collapsed'] .sidebar-toggle {
  align-self: center;
}

/* Main content: offset by the sidebar's width and the bar's height, its own
   independent scroll container so the sidebar never moves as the table
   grows — and so .viz-row's sticky top: 0 still means "just below the
   bar". */

.main-content {
  margin-left: var(--sidebar-w);
  height: calc(100vh - var(--bar-h));
  overflow-y: auto;
  /* The top padding is exactly the height .viz-row::before paints above the
     sticky row: the two are one measurement, and changing this one without the
     other leaves a strip of table showing through. */
  padding: 2.5rem var(--gap) 4rem;
}

/* Below the breakpoint the two columns stack and both scroll with the page;
   the bar stays fixed, since navigation is the one thing that must not
   scroll away. */

@media (max-width: 60rem) {
  .sidebar {
    position: static;
    width: auto;
    height: auto;
    overflow-y: visible;
    border-right: none;
    border-bottom: 1px solid var(--line);
  }

  .main-content {
    margin-left: 0;
    height: auto;
    overflow-y: visible;
  }

  /* Stacked, so there is no vertical edge to drag and no left offset to
     collapse: the rail becomes a one-line strip above the content, carrying the
     same button, and the handle goes away entirely. */

  [data-sidebar='collapsed'] .sidebar {
    width: auto;
    padding: 0.5rem var(--gap);
    align-items: flex-end;
  }

  [data-sidebar='collapsed'] .main-content {
    margin-left: 0;
  }

  .sidebar-handle {
    display: none;
  }
}

/* The content area's one child: the active view, or the empty state and its
   error. No margin-top of its own — main-content's own padding-top already
   provides that spacing, so an extra margin here would be a second,
   uncovered gap above whatever .status renders (notably a problem for
   .viz-row below, which needs to own every pixel of space above it once it
   goes sticky). */

.status:empty {
  display: none;
}

/* The drop overlay: raised over the whole viewport on any drag, split into
   the two slots. Above the tooltip's 20 and the toast container's 30 —
   while a file is in the air, nothing else matters. The halves swallow the
   drag (nothing beneath them can receive it); their own children do not, so
   the element that gets the event is always the half, whichever line of
   text the pointer happens to be over. */

.drop-overlay {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
}

.drop-overlay[hidden] {
  display: none;
}

.drop-overlay__half {
  flex: 1 1 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: var(--gap);
  border: 2px dashed var(--line-strong);
  background: color-mix(in srgb, var(--page) 94%, transparent);
  text-align: center;
}

.drop-overlay__half > * {
  pointer-events: none;
}

.drop-overlay__half.is-target {
  border-color: var(--accent);
  border-style: solid;
  background: var(--accent-bg);
}

/* Three versions are loaded and this half takes nothing: muted, never
   accented on hover, and the cursor says so before the drop is attempted. */
.drop-overlay__half.is-disabled {
  color: var(--fg-muted);
  border-color: var(--line);
  cursor: not-allowed;
}

.drop-overlay__title {
  margin: 0;
  font-size: 1.5rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.drop-overlay__hint {
  margin: 0;
  font-size: 1.0625rem;
}

.drop-overlay__format {
  margin: 0;
  color: var(--fg-faint);
  font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
}

/* Named, not implied: an overwrite is visible before the pointer is
   released, so replacing the wrong file takes a deliberate mistake. */
.drop-overlay__replaces {
  margin: 0.5rem 0 0;
  color: var(--error);
  font-size: 0.9375rem;
}

/* Row-level parse issues, and the plain empty-playlist message: quieter
   than an alert, still not the console. */

/* A notice is content, so it sits on a card like the rest of it — with the
   left rule kept, because it is what says this is an aside rather than the
   view that was expected. */
.notice {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.75rem;
  margin-top: 0.75rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--line);
  border-left: 3px solid var(--line-strong);
  background: var(--surface);
  border-radius: 0 var(--radius) var(--radius) 0;
  color: var(--fg-muted);
}

/* Empty-state info block, shown in main content before a file is loaded. */

/* The empty state is the only thing on the page, so it is the card. */
.intro {
  max-width: 32rem;
  margin: 4rem auto 0;
  padding: 2rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  background: var(--surface);
  box-shadow: var(--shadow);
  text-align: center;
}

.intro__text {
  margin: 0 0 0.75rem;
  font-size: 1.0625rem;
}

.intro__actions {
  display: flex;
  justify-content: center;
  margin-bottom: 0.75rem;
}

/* The one primary action in the application: with nothing loaded there is
   literally nothing else to do on the page. Bigger than the settings buttons
   it otherwise shares a class with, and outlined in the interaction colour —
   filled would need an on-accent ink token for the sake of one button, and the
   dark accent does not carry white text at 4.5:1 anyway. */
.intro__actions .controls__button {
  height: 2.4rem;
  padding: 0 1.25rem;
  border-color: var(--accent);
  color: var(--accent);
  font-size: 0.9375rem;
}

.intro__actions .controls__button:hover {
  background: var(--accent-bg);
}

.intro__hint {
  margin: 0;
  color: var(--fg-muted);
  font-size: 0.9375rem;
}

/* The error sits above the intro text, inside the same block: a failed
   load leaves the slot empty, so the message shows up where the content
   would have been. Left-aligned against the centred intro on purpose —
   it is prose, not a caption. */
.intro .error {
  margin-bottom: 1.5rem;
  text-align: left;
}

/* The guide: three chapters, opened from the top bar over the content area.
 *
 * Two columns — chapters left, the chapter itself right — the way a mail client
 * puts folders beside messages. The settings column stays where it is, so the
 * screen reads left to right as settings, chapters, prose.
 *
 * The prose keeps a reading measure rather than spreading across the freed
 * width: a line of text 140 characters wide is read twice. The width the guide
 * does not need is simply not taken. */

.help {
  display: grid;
  grid-template-columns: minmax(10rem, 13rem) minmax(0, 1fr);
  gap: var(--card-gap);
  align-items: start;
}

/* Its own card, and sticky: the chapters stay reachable however far down a
   chapter is read. .main-content is the scroll container, so top: 0 means the
   top of the content area. */
.help__sidebar {
  position: sticky;
  top: 0;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  background: var(--surface);
  box-shadow: var(--shadow);
}

.help__sidebar-title {
  margin: 0;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.help__nav {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
}

/* Full-width rows rather than pills: in a column, a pill is a button that
   happens to be under another button, and a row is a list. */
.help__nav-button {
  display: flex;
  align-items: center;
  padding: 0.35rem 0.6rem;
  border: none;
  border-radius: var(--radius);
  background: none;
  color: var(--fg-muted);
  font: inherit;
  font-size: 0.9375rem;
  text-align: left;
  cursor: pointer;
}

.help__nav-button:hover {
  color: var(--fg);
  background: color-mix(in srgb, var(--fg) 5%, transparent);
}

.help__nav-button.is-active {
  background: var(--accent-bg);
  color: var(--accent);
  font-weight: 600;
}

/* Behind a rule at the foot of the column: the way out, not a fourth chapter. */
.help__close {
  margin-top: 0.35rem;
  padding: 0.55rem 0.6rem 0;
  border: none;
  border-top: 1px solid var(--line);
  background: none;
  color: var(--fg-muted);
  font: inherit;
  font-size: 0.875rem;
  text-align: left;
  cursor: pointer;
}

.help__close:hover {
  color: var(--accent);
}

/* The chapter itself, on a card like every other piece of content. */
.help__body {
  max-width: 44rem;
  padding: 1.25rem 1.5rem 1.75rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  background: var(--surface);
  box-shadow: var(--shadow);
}

@media (max-width: 60rem) {
  /* Stacked, and the chapter list stops following the scroll: on a narrow
     screen it would take a third of the height it is meant to save. */
  .help {
    grid-template-columns: minmax(0, 1fr);
  }

  .help__sidebar {
    position: static;
  }
}

.help__title {
  margin: 0 0 0.75rem;
  font-size: 1.375rem;
}

.help__heading {
  margin: 2rem 0 0.5rem;
  font-size: 1.0625rem;
}

.help__lead {
  margin: 0 0 1rem;
  font-size: 1.0625rem;
  line-height: 1.6;
}

.help__para {
  margin: 0 0 0.85rem;
  line-height: 1.6;
}

/* An aside to the page, not a step in it: the same muted weight the caption
   under a diagram carries. */
.help__note {
  margin: 1.25rem 0 0;
  padding-left: 0.75rem;
  border-left: 2px solid var(--line-strong);
  color: var(--fg-muted);
  line-height: 1.55;
}

.help__steps {
  margin: 0 0 1rem;
  padding-left: 1.25rem;
  line-height: 1.6;
}

.help__step {
  margin-bottom: 0.5rem;
}

/* Term and explanation. The <dd> loses the browser's 40px indent: the bold
   term above it already marks where an entry starts, and a second signal for
   the same thing only narrows the measure. */

.help__terms {
  margin: 0 0 1rem;
}

.help__term {
  margin-top: 0.85rem;
  font-weight: 600;
}

.help__term:first-child {
  margin-top: 0;
}

.help__def {
  margin: 0.15rem 0 0;
  line-height: 1.55;
}

.help-figure {
  margin: 1.25rem 0;
}

.help-figure__caption {
  margin-top: 0.4rem;
  color: var(--fg-faint);
  font-size: 0.875rem;
}

/* The diagrams carry no colour of their own: every fill and stroke is one of
   the variables the views already use, so a diagram cannot come to disagree
   with the thing it explains — including in dark mode, which it inherits for
   free. */

.help-diagram {
  width: 100%;
  max-width: 30rem;
  height: auto;
  font-family: inherit;
}

.help-diagram__label {
  fill: var(--fg);
  font-size: 13px;
  font-weight: 600;
}

.help-diagram__sub {
  fill: var(--fg-faint);
  font-size: 11px;
}

.help-diagram__half {
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1;
  stroke-dasharray: 5 4;
}

.help-diagram__band {
  stroke-width: 1;
}

.help-diagram__band--ok {
  fill: var(--verdict-ok-bg);
  stroke: var(--verdict-ok);
}

.help-diagram__band--borderline {
  fill: var(--verdict-borderline-bg);
  stroke: var(--verdict-borderline);
}

.help-diagram__band--violation {
  fill: var(--verdict-violation-bg);
  stroke: var(--verdict-violation);
}

.help-diagram__box {
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1;
}

.help-diagram__box--bridge {
  fill: var(--accent-bg);
  stroke: var(--accent);
}

.help-diagram__leg {
  stroke: var(--fg-muted);
  stroke-width: 1.5;
}

.help-diagram__arrow {
  fill: var(--fg-muted);
}

/* Toasts: one-shot, auto-dismissing announcements for the same two
   conditions the indicator strip below tracks persistently. Fixed,
   centered just below the top bar, above the tooltip's z-index so a toast
   is never hidden behind it. */

.toast-container {
  position: fixed;
  top: calc(var(--bar-h) + 1rem);
  left: 50%;
  transform: translateX(-50%);
  z-index: 30;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  pointer-events: none;
}

/* A card, floating over the content: the same edge and shadow every other card
   has, so a toast reads as this application speaking rather than as the
   browser. */
.toast {
  display: flex;
  align-items: flex-start;
  gap: 0.75rem;
  max-width: 28rem;
  padding: 0.75rem 1rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow);
  pointer-events: auto;
}

.toast--warning {
  border-color: var(--error);
  background: var(--error-bg);
  color: var(--error);
}

.toast--notice {
  background: var(--surface);
  color: var(--fg-muted);
}

.toast__text {
  margin: 0;
}

.toast__dismiss {
  flex: none;
  border: none;
  background: none;
  color: inherit;
  font-size: 1rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.25rem 0.4rem;
  border-radius: 4px;
}

.toast__dismiss:hover {
  background: color-mix(in srgb, currentColor 15%, transparent);
}

/* Indicator strip: a lasting, quieter echo of the toast above — present for
   as long as the condition holds for the loaded file, regardless of whether
   the toast was seen. Sits at the top of the content area, above the
   visualisations it qualifies. */

.indicator-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 0.75rem;
  margin-bottom: 1rem;
}

/* The sunken plane, not a card: the strip qualifies what is below it rather
   than being another thing to read alongside it. */
.indicator-strip__item {
  margin: 0;
  padding: 0.35rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--fg-muted);
  font-size: 0.8125rem;
}

.indicator-strip__item::before {
  content: '\26A0';
  margin-right: 0.4rem;
}

/* Playing-time summary */

.playing-time {
  margin: 1.5rem 0 0;
  color: var(--fg-muted);
  font-size: 0.9375rem;
  font-variant-numeric: tabular-nums;
}

/* Interleaved track / transition table
 *
 * A track and the transition that follows it are one visual "pair": the
 * verdict colour wraps both. Pairs are separated by spacing alone, no
 * border — the gap is enough to keep same-verdict neighbours distinct.
 * div/grid rather than <table>/<tr>, because a <tr> cannot carry the margin
 * a pair needs. */

/* No margin-top of its own: .viz-row's padding-bottom is the gap above this
   card, and it has to be there anyway — it is the strip the sticky row paints
   over the table scrolling under it. A second margin here would double it. */
.track-list-wrap {
  padding: 0.9rem 1rem 1rem;
  overflow-x: auto;
}

/* The line above the table: what there is, and the filter over it. Carries
   the same min-width as the table below so the two scroll as one when the
   column set outgrows the viewport. */

.track-list-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  min-width: 46.5rem;
  margin-bottom: 0.5rem;
  font-size: 0.875rem;
}

.track-list-header__count {
  margin: 0;
  color: var(--fg-faint);
  font-variant-numeric: tabular-nums;
}

.track-list-header__toggle {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  white-space: nowrap;
  cursor: pointer;
}

/* A place-keeper, not content: a dashed rule with the count sitting on it,
   muted, so a collapsed run reads as a gap that was closed rather than as
   another row. */

.hidden-run {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 0.9rem;
  padding: 0 0.6rem;
  color: var(--fg-faint);
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
}

.hidden-run::before,
.hidden-run::after {
  content: '';
  flex: 1;
  border-top: 1px dashed var(--line-strong);
}

.transition-table {
  /* --pair-columns is set inline by table.js, computed from the active
     optional columns. 46.5rem, not 44rem: it covers the 2.25rem the rating and
     added-date columns gained, and the header above carries the same floor so
     the two scroll as one. */
  width: 100%;
  min-width: 46.5rem;
  font-variant-numeric: tabular-nums;
}

.transition-table__head {
  display: grid;
  grid-template-columns: var(--pair-columns);
  gap: 0.75rem;
  padding: 0.4rem 0.6rem;
  border-bottom: 1px solid var(--line);
  color: var(--fg-faint);
  font-weight: 600;
  font-size: 0.8125rem;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

/* The collection list's head cells: only that table sorts, so this affordance
   only ever appears there. A bare button that inherits the head row's own type
   — the label is the same node the set table's header shows, so the two cannot
   come to disagree about a column's name. */

.sort-cell {
  display: flex;
  min-width: 0;
}

.sort-head {
  display: flex;
  align-items: center;
  gap: 0.25rem;
  flex: 1 1 auto;
  min-width: 0;
  padding: 0;
  border: none;
  background: none;
  color: inherit;
  font: inherit;
  text-transform: inherit;
  letter-spacing: inherit;
  cursor: pointer;
}

.sort-head:hover {
  color: var(--fg);
}

.sort-head:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* The right-aligned columns — the same list the cells below keep — lay their
   two children out from the right edge instead, so the arrow grows inward and
   the label stays in line with the numbers under it rather than being pushed
   off the column's edge by it. */
.sort-head:has(> .track-row__index),
.sort-head:has(> .track-row__bpm),
.sort-head:has(> .track-row__duration),
.sort-head:has(> .track-row__rating),
.sort-head:has(> .track-row__added),
.sort-head:has(> .track-row__degree),
.sort-head:has(> .track-row__component) {
  flex-direction: row-reverse;
}

.sort-head__arrow {
  flex: none;
  color: var(--accent);
  font-size: 0.6875rem;
  line-height: 1;
}

/* The title carries the row's weight and nothing else does: a row set entirely
   in 600 has no first thing to read. */
.track-row {
  display: grid;
  grid-template-columns: var(--pair-columns);
  gap: 0.75rem;
  padding: 0.5rem 0.6rem 0.15rem;
  font-size: 0.9375rem;
}

.track-row__title {
  font-weight: 600;
}

/* Most rows carry nothing here but an em dash, and the column exists to be
   scanned rather than read: the faint level is what keeps the flagged rows
   findable among the ones that are not. */
.track-row__flags {
  color: var(--fg-faint);
}

.track-row__artist {
  color: var(--fg-faint);
}

.track-row__index,
.track-row__bpm,
.track-row__duration,
.track-row__start,
.track-row__rating,
.track-row__added,
.track-row__degree,
.track-row__component,
.track-row__flags {
  text-align: right;
  white-space: nowrap;
}

/* A key in both notations at once: the Camelot code the graph works in, and
   the classical name a shop is searched by. Neither is the "real" one, so the
   cell carries both rather than making the reader translate twenty-four codes
   in their head.

   Left-aligned, unlike the numeric columns around it — the pair is two tokens
   of text, and right-aligning them would ragged the codes down the column. */

.track-row__key,
.bridge-row__key {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  min-width: 0;
  text-align: left;
  white-space: nowrap;
}

/* The head cell is a bare label, not a key: it keeps the flex box so the
   column heading sits over the code rather than over the gap. */
.transition-table__head .track-row__key {
  display: block;
}

/* The code itself, boxed. Monospace-adjacent by way of tabular figures: '10A'
   and '9A' should occupy the same width down the column. */
.key-code {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 2.5rem;
  padding: 0 0.35rem;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--surface-sunken);
  color: var(--fg-muted);
  font-size: 0.8125rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* A key that could not be read is one em dash, not a boxed one: there is no
   code to put in a box. */
.key-code--empty {
  border-color: transparent;
  background: none;
  font-weight: 400;
}

/* The name beside it: present, and quieter than the code. Truncated rather
   than wrapped — a second line here would make every row in a few hundred
   taller for a label that is already redundant with the box next to it. */
.key-classical {
  overflow: hidden;
  color: var(--fg-faint);
  font-size: 0.8125rem;
  text-overflow: ellipsis;
}

/* The seam under a track row: what the transition out of it is worth. Set on
   the sunken plane so the pair reads as a row and its annotation rather than as
   two rows. */
.transition-row__cell {
  padding: 0.25rem 0.6rem 0.35rem;
  color: var(--fg-muted);
  font-size: 0.875rem;
}

.transition-row__line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.75rem;
}

.transition-row__delta,
.transition-row__percent {
  color: var(--fg-faint);
  white-space: nowrap;
}

.transition-row__move {
  white-space: nowrap;
}

/* A question put to a row, and the panel it opens: the bridge lookup on a
 * transition that is not ok, and the connection lookup on an isolated track.
 * Both panels sit inside their row's own box — an annexe to that row, not
 * another row — and carry a background of their own rather than the pair's
 * verdict tint, so neither ever reads as a second verdict. Indented to say
 * the same thing again in the layout.
 *
 * .inline-panel is the box the two share. What is genuinely a bridge — the
 * candidate rows and their "+N more" line — keeps the .bridge-* names; what
 * both use lost them, because a connection panel is not a bridge and copying
 * the rules under a second name would let the two drift apart. */

/* A pill rather than the square controls above: this one sits inside a line of
   running text, where a box would read as a field to type in. */
.row-action {
  display: inline-flex;
  align-items: center;
  margin-left: 0.75rem;
  padding: 0.1rem 0.6rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: var(--surface);
  color: var(--fg-muted);
  font: inherit;
  font-size: 0.8125rem;
  white-space: nowrap;
  cursor: pointer;
}

.row-action:hover {
  border-color: var(--accent);
  background: var(--accent-bg);
  color: var(--accent);
}

.inline-panel {
  margin: 0 0.6rem 0.6rem 1.6rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  font-size: 0.875rem;
}

/* What to look for, above what was found: the one part of either panel that
   is worth reading when the list below it is empty, so it carries a little
   more weight than the headings under it. */

.inline-panel__require {
  margin-bottom: 0.75rem;
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--line);
}

.inline-panel__require-line {
  margin: 0;
  font-weight: 600;
}

.inline-panel__require-note {
  margin: 0.35rem 0 0;
  color: var(--fg-muted);
  font-weight: 400;
}

.inline-panel__heading {
  margin: 0.5rem 0 0.35rem;
  color: var(--fg-muted);
  font-weight: 600;
}

.inline-panel__heading:first-child {
  margin-top: 0;
}

.inline-panel__note,
.bridge-panel__more {
  margin: 0.35rem 0 0;
  color: var(--fg-muted);
}

.inline-panel__note:first-child {
  margin-top: 0;
}

.inline-panel .controls__button {
  margin-top: 0.5rem;
}

/* One reachable component: what it is worth, over the windows that reach it.
   The chips are their own line and wrap, because the number of keys depends
   on which rules are switched on and a fixed grid would either clip them or
   stand mostly empty. */

.connect-component {
  margin-top: 0.5rem;
}

.connect-component__label {
  margin: 0;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.connect-ranges {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 0.75rem;
  margin-top: 0.2rem;
  padding-left: 1.2rem;
}

.connect-range {
  color: var(--fg-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* Every track is one row and every row is its own grid, exactly as the table
 * above works — so the template must not contain a content-sized track. An
 * `auto` last column made each row resolve its own widths from whichever
 * markers that row happened to carry, and every column drifted with it. All
 * eight tracks are fixed or fractional, and both marker cells are always
 * emitted, so one template resolves the same way for every row. */

.bridge-row {
  display: grid;
  grid-template-columns:
    /* The last column is fixed by decision, so this is the one place it
       changes: the already-in-set marker now names a position and needs the
       room for it rather than wrapping onto a second line. The key track is
       KEY_COLUMN_WIDTH's lower bound — the cell carries both notations here
       exactly as it does in the tables above. */
    minmax(7rem, 2fr) minmax(5rem, 1.4fr) 7rem 3.5rem 3.5rem 6.5rem 9.5rem 11.5rem;
  gap: 0.6rem;
  padding: 0.2rem 0;
  font-variant-numeric: tabular-nums;
}

.bridge-row__bpm,
.bridge-row__duration,
.bridge-row__distance {
  text-align: right;
  white-space: nowrap;
}

.bridge-row__artist {
  color: var(--fg-faint);
}

/* The weak leg of a near miss, in the borderline colour the verdict dots
   already use — same channel, same meaning. A clean candidate has no weak leg
   and the cell stays empty, so the dot has to go with the text: :empty is what
   distinguishes "no borderline leg" from one whose label failed to resolve. */

.bridge-row__leg {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  color: var(--verdict-borderline);
  font-size: 0.8125rem;
}

.bridge-row__leg::before {
  content: '';
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: currentColor;
}

.bridge-row__leg:empty::before {
  content: none;
}

.bridge-row__flag {
  color: var(--fg-faint);
  font-size: 0.8125rem;
}

/* Colour marks the exception, not the majority. An ok seam is the ordinary
 * case and carries no tint and no rail at all — on a clean set, twelve tinted
 * rows are exactly what hides the three that need work. The three that are not
 * ok keep the tint and gain a heavier inset rail.
 *
 * Inset box-shadow rather than border-left: a border is part of the box and
 * would push every ok pair 3px out of line with its tinted neighbours. */

.transition-pair {
  margin-bottom: 0.5rem;
  border-radius: var(--radius);
}

/* Deliberately empty of colour. Kept as a rule so the class is documented
   where its three siblings are, rather than only in table.js. */
.transition-pair--ok {
  background: none;
}

/* Only where there is no verdict tint to fight with: a tinted pair already
   answers the pointer with the cross-view outline hover.js puts on it. */
.transition-pair--ok:hover {
  background: var(--accent-bg);
}

.transition-pair--borderline {
  background: var(--verdict-borderline-bg);
  box-shadow: inset 3px 0 0 var(--verdict-borderline);
}

.transition-pair--violation {
  background: var(--verdict-violation-bg);
  box-shadow: inset 3px 0 0 var(--verdict-violation);
}

.transition-pair--unknown {
  background: var(--surface-sunken);
  box-shadow: inset 3px 0 0 var(--line-strong);
}

/* The verdict's own mark: a glyph and a word, never one without the other.
 *
 * The two exceptions are filled pills; ok carries its ink and no fill. That is
 * the same rule the rows above follow one scale down — a green pill on every
 * ordinary seam would put the majority back in colour, which is the thing this
 * phase removed. */

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  flex: none;
  padding: 0.05rem 0.5rem 0.05rem 0.4rem;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  white-space: nowrap;
}

/* The glyphs differ in shape, not only in colour: the status trio cannot be
   separated by hue under protanopia, and this pairing is the mitigation rather
   than a flourish. Decoration, so no language and no place in i18n.js. */
.chip::before {
  font-size: 0.8125rem;
  line-height: 1;
}

.chip--ok {
  color: var(--verdict-ok);
}

.chip--ok::before {
  content: '\2713';
}

.chip--borderline {
  background: var(--verdict-borderline-bg);
  color: var(--verdict-borderline);
}

.chip--borderline::before {
  content: '\25CF';
}

.chip--violation {
  background: var(--verdict-violation-bg);
  color: var(--verdict-violation);
}

.chip--violation::before {
  content: '\25B2';
}

.chip--unknown {
  background: var(--surface-sunken);
  color: var(--fg-muted);
}

.chip--unknown::before {
  content: '?';
}

/* Timeline and wheel side by side above the table; wraps to stacked once
   the main content column narrows, no extra breakpoint needed. Sticky so
   both stay visible while the table scrolls beneath them; z-index 10 stays
   below the tooltip's 20 and the toast container's 30. A negative
   margin-top was tried here to let the row's own box absorb
   .main-content's top padding, but negative margins on a `position:
   sticky` element are unreliable — browsers can miscompute both the
   sticky offset and the following sibling's flow position, which is
   exactly what let a real table row render above this one. The ::before
   below solves the same problem without touching this element's box
   model: an absolutely positioned layer, anchored to whatever position
   this row currently has (in flow or stuck), that paints the same solid
   colour into the space directly above it — nothing is ever left
   uncovered for the table to show through, and layout is untouched. */

/* padding-bottom, not a margin on the table below: this strip is inside the
   sticky box and so is what paints over the table as it scrolls under. It is
   therefore also the gap between the two, which is why the table carries no
   margin-top of its own. */
.viz-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--card-gap);
  position: sticky;
  top: 0;
  z-index: 10;
  background: var(--page);
  padding-bottom: var(--card-gap);
}

.viz-row::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 100%;
  height: 2.5rem;
  background: var(--page);
}

@media (max-width: 60rem) {
  .viz-row {
    position: static;
  }

  .viz-row::before {
    display: none;
  }
}

/* Grow ratio 45:14 matches the SVGs' own aspect ratios — 900:280 for the
   timeline (TIMELINE_BOX in main.js), 480:480 for the wheel (WHEEL_BOX) —
   so with flex-basis 0 the allocated widths keep both renders at the same
   height at any viewport size, not just the one the ratio was tuned for.
   min-width is the wrap threshold only; it does not need to match the
   ratio. */
.viz-row .timeline-wrap {
  flex: 45 1 0;
  min-width: 26rem;
}

.viz-row .wheel-wrap {
  flex: 14 1 0;
  min-width: 14rem;
}

/* One card recipe, applied to everything that is content sitting on the page
   plane: the stat tiles, the two drawings in the sticky row, and the table. The
   top bar and the sidebar are deliberately not in this list — they are flat
   panels, the frame the cards sit inside.
 *
 * Declared once rather than per element, so a card can never come to have a
 * different radius or a different edge from its neighbour. */

.stat-tile,
.viz-row .timeline-wrap,
.viz-row .wheel-wrap,
.track-list-wrap {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow);
}

/* Section headings: the header line of the card their drawing fills, so no top
   margin — the card's own padding is the space above them. */

.section-title {
  margin: 0 0 0.5rem;
  font-size: 1.0625rem;
  font-weight: 600;
}

/* BPM curve */

.timeline-wrap {
  padding: 0.9rem 1rem 1rem;
}

.timeline {
  display: block;
  width: 100%;
  height: auto;
}

.timeline__axis {
  stroke: var(--line-strong);
  stroke-width: 1;
}

.timeline__gridline {
  stroke: var(--line);
  stroke-width: 1;
}

.timeline__tick {
  fill: var(--fg-faint);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
}

.timeline__tick--y {
  text-anchor: end;
}

.timeline__tick--x {
  text-anchor: middle;
}

.timeline__axis-label {
  fill: var(--fg-faint);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

.timeline__slot-line {
  stroke: var(--accent);
  stroke-width: 1.5;
  stroke-dasharray: 4 3;
}

.timeline__slot-label {
  fill: var(--accent);
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}

.timeline__bar {
  fill: var(--fg);
  rx: 1.5;
}

.timeline__riser {
  stroke-width: 3;
}

.timeline__riser--ok {
  stroke: var(--verdict-ok);
}

.timeline__riser--borderline {
  stroke: var(--verdict-borderline);
}

.timeline__riser--violation {
  stroke: var(--verdict-violation);
}

.timeline__riser--unknown {
  stroke: var(--fg-muted);
}

.timeline__glyph--ok {
  fill: var(--verdict-ok);
}

.timeline__glyph--borderline {
  fill: var(--verdict-borderline);
}

.timeline__glyph--violation {
  fill: var(--verdict-violation);
}

.timeline__glyph--unknown {
  fill: var(--fg-muted);
}

.timeline__caption {
  margin: 0.5rem 0 0;
  color: var(--fg-faint);
  font-size: 0.9375rem;
  font-variant-numeric: tabular-nums;
}

/* The donut */

.wheel-wrap {
  padding: 0.9rem 1rem 1rem;
}

.wheel {
  display: block;
  width: 100%;
  height: auto;
}

.wheel__hole {
  fill: color-mix(in srgb, var(--fg) 3%, transparent);
}

/* Low saturation so the rim orients without competing with the verdict
   colours on the path — the only colour channel that matters here. */
.wheel__rim-segment {
  opacity: 0.55;
}

/* The Camelot number inside its own wedge. Hue alone cannot say which wedge is
   5 and which is 9, and the rim carried no labels at all before this. Muted,
   not full ink: it orients, it is not the drawing. */
.wheel__rim-label {
  fill: var(--fg-muted);
  font-size: 9px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  pointer-events: none;
}

.wheel__point {
  stroke: none;
}

.wheel__point--minor {
  fill: var(--wheel-point);
}

.wheel__point--major {
  fill: none;
  stroke: var(--wheel-point);
  stroke-width: 2;
}

.wheel__path {
  fill: none;
  stroke-width: 2;
}

.wheel__path--ok {
  stroke: var(--verdict-ok);
}

.wheel__path--borderline {
  stroke: var(--verdict-borderline);
}

.wheel__path--violation {
  stroke: var(--verdict-violation);
}

.wheel__path--unknown {
  stroke: var(--fg-muted);
}

.wheel__arrow--ok {
  fill: var(--verdict-ok);
}

.wheel__arrow--borderline {
  fill: var(--verdict-borderline);
}

.wheel__arrow--violation {
  fill: var(--verdict-violation);
}

.wheel__arrow--unknown {
  fill: var(--fg-muted);
}

.wheel__start {
  fill: var(--accent);
}

.wheel__centre-time {
  fill: var(--fg);
  font-size: 15px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.wheel__centre-count {
  fill: var(--fg-faint);
  font-size: 10px;
}

.wheel__legend,
.wheel__caption {
  margin: 0.5rem 0 0;
  color: var(--fg-faint);
  font-size: 0.875rem;
  text-align: center;
}

.wheel__legend-start::before {
  content: '\25CF';
  margin-right: 0.3rem;
  color: var(--accent);
}

/* The collection donut: hue = genre, opacity = age (presentation attribute,
   decision 10), filled = minor, ring = major, halo = articulation point,
   graph edges as plain lines. No rim colour on the data points themselves —
   that channel already belongs to the twelve Camelot segments. */

.collection-wheel__edge {
  stroke: var(--line-strong);
  stroke-width: 1;
  opacity: 0.5;
}

.collection-wheel__halo {
  fill: var(--accent);
  stroke: none;
  opacity: 0.22;
}

.collection-wheel__point {
  stroke: none;
}

.collection-wheel__point--major {
  stroke-width: 2;
}

/* A CSS class rule already outranks the fill/stroke presentation attributes
   genreFill() sets in wheel.js — same cascade fact decision 10 relies on for
   opacity — so no !important is needed to win over the genre colour. */

.collection-wheel__point.is-hovered {
  stroke: var(--fg);
  stroke-width: 3;
}

.collection-wheel__point.is-neighbour {
  stroke: var(--accent);
  stroke-width: 2.5;
}

/* Hovering or locking a track dims every other point and edge, so the
   neighbourhood reachable under the active rules reads at a glance
   (PROJECT.md:232). The class lands on the SVG itself; a plain CSS rule
   already outranks the opacity presentation attribute on each point, no
   extra specificity trick needed. */

.wheel--collection.is-focusing .collection-wheel__point:not(.is-hovered):not(.is-neighbour) {
  opacity: 0.12;
}

.wheel--collection.is-focusing .collection-wheel__edge {
  opacity: 0.08;
}

/* The genre filter's own swatch, in the sidebar: the same hue the dot on
   the donut has, from the same genreFill(). There is no separate legend
   panel — the filter is the legend, and it is the interactive copy. */

.genre-swatch {
  width: 0.65rem;
  height: 0.65rem;
  flex: none;
  border-radius: 50%;
}

/* Density mode: a single-hue heatmap replaces the dots above
   densityThreshold filtered tracks (~60), where the area encoding stops
   meaning anything with points stacking in the same cell. Opacity per cell
   is set as a presentation attribute in wheel.js (0.15 floor, 1 at the
   densest cell); --accent already has a legible dark-mode value, so the
   ramp needs no separate dark-mode override. */

.density-cell {
  fill: var(--accent);
  stroke: var(--surface);
  stroke-width: 0.5;
}

/* The ramp sits under the donut it explains, in the caption area, at the
   donut's own width rather than a column's. */

.density-ramp {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  max-width: 20rem;
  margin: 0.5rem auto 0;
}

.density-ramp__title {
  margin: 0;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-weight: 600;
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.density-ramp__bar {
  height: 0.75rem;
  border-radius: 4px;
  background: linear-gradient(to right, color-mix(in srgb, var(--accent) 15%, transparent), var(--accent));
}

.density-ramp__labels {
  display: flex;
  justify-content: space-between;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
}

/* Sidebar settings sections: tolerance/overlap/slot, the five Camelot
   rules, the table-column toggles — each its own headed group. A hairline
   above each one rather than spacing alone: the sections are now the panel's
   only structure, and at this density the gap between two of them was hard to
   tell from the gap between two rows inside one. */

.sidebar-section {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.85rem 1.25rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--line);
  font-size: 0.875rem;
}

/* The first section has the panel's own top edge above it and needs no second
   line; the toggle is not a section, so :first-of-type is the right test. */
.sidebar-section:first-of-type {
  padding-top: 0;
  border-top: none;
}

.sidebar-section__title {
  flex-basis: 100%;
  margin: 0;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* A settings row: what it is on the left, what it is set to on the right.
 *
 * The value is not in a box. A column of outlined fields reads as a form to be
 * filled in, and these are not — they are a setting's current state, which is
 * read far more often than it is changed. The box appears on hover and on
 * focus, when it is about to be one. Same arrangement a phone's settings list
 * uses, and the reason it works there is the same: one thing per line, the
 * answer always in the same column.
 *
 * Full width, so the answers line up under each other rather than wherever the
 * label before them happened to end. */

.controls__field {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-basis: 100%;
  min-height: var(--control-h);
}

.controls__label {
  margin-right: auto;
  color: var(--fg-muted);
}

.controls__input {
  width: 3.75rem;
  height: var(--control-h);
  padding: 0 0.4rem;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: none;
  color: var(--fg);
  font: inherit;
  font-size: 0.875rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* The platform's spinner arrows: two tiny hit targets for a value that can be
   typed, dragged and arrow-keyed already, and they crowd the number out of its
   own column. */
.controls__input::-webkit-inner-spin-button,
.controls__input::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.controls__input[type='number'] {
  -moz-appearance: textfield;
  appearance: textfield;
}

/* The box, revealed at the moment it becomes one. */
.controls__input:hover {
  border-color: var(--line-strong);
  background: var(--surface-sunken);
}

.controls__button {
  height: var(--control-h);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--fg);
  font: inherit;
  font-size: 0.875rem;
}

/* The select keeps a visible box at all times, unlike the fields above it: it
   is a menu to be opened rather than a value to be typed over, and a chevron
   floating without an edge under it has nothing to belong to. Wide enough for
   its longest option — "Automatisch" does not fit in the width a BPM value
   needs. */
select.controls__input {
  width: auto;
  min-width: 7.5rem;
  padding: 0 1.6rem 0 0.5rem;
  border-color: var(--line-strong);
  background: var(--surface);
  font-weight: 400;
  text-align: left;
  /* The native arrow is drawn by the platform and cannot be themed; the
     ::after below replaces it with one that follows the token layer. */
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}

.controls__field:has(> select.controls__input) {
  position: relative;
}

/* A chevron built from two borders rather than a background image: no data
   URI, no second file, and it takes currentColor's token like everything
   else. */
.controls__field:has(> select.controls__input)::after {
  content: '';
  position: absolute;
  right: 0.6rem;
  width: 0.4rem;
  height: 0.4rem;
  border-right: 1.5px solid var(--fg-faint);
  border-bottom: 1.5px solid var(--fg-faint);
  transform: translateY(-0.15em) rotate(45deg);
  pointer-events: none;
}

select.controls__input:hover,
.controls__button:hover {
  border-color: var(--accent);
}

/* Every focusable control in the application answers the keyboard the same
   way. Stated once here and inherited by name below rather than repeated. */
.controls__input:focus-visible,
.controls__button:focus-visible,
.topbar__help:focus-visible,
.topbar__language:focus-visible,
.topbar__theme:focus-visible,
.topbar__tab-label:focus-visible,
.sidebar-toggle:focus-visible,
.help__nav-button:focus-visible,
.row-action:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: var(--accent);
}

/* A switch, for the settings that are simply on or off.
 *
 * Built on the native checkbox rather than beside it: `appearance: none` takes
 * away the platform's drawing and leaves everything else exactly where it was —
 * the semantics, the label association, Space to toggle, :checked, :disabled,
 * the change event. No JavaScript knows this happened, and neither does the
 * markup.
 *
 * The knob is a background gradient rather than a ::before, deliberately.
 * Pseudo-elements on form controls are the sort of thing that works in three
 * browsers and not the fourth, and this tool has to still be right in five
 * years; a background-position is as old as CSS and animates for free. */

.controls__rule:not(.controls__rule--genre) > input[type='checkbox'],
.track-list-header__toggle > input[type='checkbox'] {
  -webkit-appearance: none;
  appearance: none;
  flex: none;
  /* Pushed to the right edge of its row, past a label of any length, without
     the row having to know whether there is a note between the two. */
  order: 1;
  margin: 0 0 0 auto;
  width: 2rem;
  height: 1.15rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background-color: var(--surface-sunken);
  background-image: radial-gradient(circle closest-side, var(--line-strong) 100%, transparent 100%);
  background-repeat: no-repeat;
  background-size: 0.85rem 0.85rem;
  background-position: 0.1rem 50%;
  cursor: pointer;
  transition: background-color 0.12s, background-position 0.12s, border-color 0.12s;
}

.controls__rule:not(.controls__rule--genre) > input[type='checkbox']:checked,
.track-list-header__toggle > input[type='checkbox']:checked {
  border-color: var(--accent);
  background-color: var(--accent);
  /* The knob against the accent, not against a surface: this one is a shape on
     a filled track, so it is white in both themes rather than a token. */
  background-image: radial-gradient(circle closest-side, #ffffff 100%, transparent 100%);
  background-position: calc(100% - 0.1rem) 50%;
}

.controls__rule:not(.controls__rule--genre) > input[type='checkbox']:hover:not(:disabled) {
  border-color: var(--accent);
}

/* A rule the view has switched off, not the user: +7 in collection mode, the
   edges under the heatmap. Faded rather than hidden — a control that vanishes
   reads as a bug, and the row still has something to say. Why it is off is the
   row's tooltip, which is what the help cursor promises. */
.controls__rule > input[type='checkbox']:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

.controls__rule.is-disabled {
  color: var(--fg-faint);
  cursor: help;
}

/* A genre row keeps a real checkbox: it is one line of a multi-select list,
   where a column of switches would read as ten independent settings rather
   than as one filter with ten boxes ticked. Only the colour changes. */
.controls__rule--genre > input[type='checkbox'] {
  accent-color: var(--accent);
  width: 0.95rem;
  height: 0.95rem;
  cursor: pointer;
}

/* A field that changes on a horizontal drag. The cursor is the whole
   affordance — there is no second control to point at — and touch-action: none
   keeps a touch drag from being taken by the page's own scrolling before the
   field ever sees it. js/scrub.js adds the class. */
.controls__input.is-scrubbable {
  cursor: ew-resize;
  touch-action: none;
}

/* While a drag is running. On <body> rather than on the field, because the
   pointer leaves the field almost immediately and the text it passes over is
   the sidebar's, not the input's. */
body.is-scrubbing {
  user-select: none;
  cursor: ew-resize;
}

.controls__suffix {
  color: var(--fg-faint);
}

.controls__rules {
  display: flex;
  flex-wrap: wrap;
  gap: 0.15rem 1rem;
  flex-basis: 100%;
}

/* One setting per line, with its switch in the same column as every other
   switch. A row of them flowing two and three to a line put the answers
   wherever the labels happened to end. */
.controls__rule {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-basis: 100%;
  min-height: 1.5rem;
  white-space: normal;
}

/* A genre comes out of the export and can be any length; a rule label cannot.
   So this is the one row family that wraps, with the checkbox and swatch held
   on the first line rather than centred against a two-line label. Whole rows,
   too: a genre list reads as a column, not as chips flowing two to a line. */
.controls__rule--genre {
  flex-basis: 100%;
  align-items: flex-start;
  white-space: normal;
}

/* The checkbox and the swatch keep the label's first line rather than the
   middle of a wrapped block. The attribute selector is not decoration: the
   shared checkbox rule above zeroes this margin at the same specificity, and
   without it the box would drop to the middle of a two-line genre name. */
.controls__rule--genre > input[type='checkbox'],
.controls__rule--genre > .genre-swatch {
  margin-top: 0.3rem;
}

.controls__rule-note {
  color: var(--fg-faint);
  font-size: 0.75rem;
  white-space: normal;
}

/* The worked example beside a rule's name. Monospace, because it is notation
   and the codes have to line up down the column to be comparable; muted,
   because the name is what the row is called and this is what it means. */
.controls__rule-example {
  color: var(--fg-faint);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.75rem;
  white-space: nowrap;
}

/* The plain button of the settings family: the genre all/none pair, and the
   empty state's load button in the content area. */

.controls__button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 0.75rem;
  font-weight: 600;
  cursor: pointer;
}

.controls__button:hover {
  background: var(--accent-bg);
  color: var(--accent);
}

/* The tile row both modes open with: three numbers, then one caption line.
   Sits above .viz-row and outside it, so it scrolls away under the sticky
   visualisations — a summary is read once, the drawings stay. Kept short
   for exactly that reason: whatever height this row takes, .viz-row starts
   that much further down. */

/* The caption line lives inside this block, under the tiles, so the space it
   takes reads as part of the gap below the row. That is why this is the one
   card gap that has to be stated rather than inherited from a flex gap — set
   to --gap it measured the same as the others and looked half again as wide. */
.stat-tiles-wrap {
  margin-bottom: var(--card-gap);
}

.stat-tiles {
  display: flex;
  flex-wrap: wrap;
  gap: var(--card-gap);
}

/* The card's edge, radius and shadow come from the shared recipe above; what
   is left here is only what is a tile's own. */
.stat-tile {
  flex: 1 1 0;
  min-width: 9rem;
  padding: 0.7rem 1rem 0.8rem;
  font-variant-numeric: tabular-nums;
}

/* One tile per mode may lead. In set mode it is the playing time — the number
   the whole view exists to move — and the collection marks none, because its
   headline number is the one in the donut's centre and two leads would fight.
   The wash is the accent's, not a verdict's: a hero is not a good result. */
.stat-tile--hero {
  background: linear-gradient(120deg, var(--accent-bg), transparent 62%), var(--surface);
}

.stat-tile--hero .stat-tile__value {
  font-size: 2rem;
}

.stat-tile__label {
  margin: 0;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

.stat-tile__value {
  margin: 0.15rem 0 0;
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1.2;
}

/* A before → after pair inside a tile. Both halves and the arrow set their own
   size, which is what keeps the hero tile's 2rem from making a pair of clock
   times wider than the tile holding them. */
.stat-tile__range {
  font-size: 1.375rem;
}

.stat-tile__range-arrow {
  color: var(--fg-faint);
  font-size: 1.125rem;
  font-weight: 400;
}

.stat-tile__note {
  margin: 0.1rem 0 0;
  color: var(--fg-faint);
  font-size: 0.75rem;
}

/* The comparison against the version one tab to the left. One step below the
   note, and with no colour of its own: "−2 Verstoß" in the violation red
   would paint good news as bad, and the sign already carries the direction. */
.stat-tile__delta {
  margin: 0.25rem 0 0;
  color: var(--fg-faint);
  font-size: 0.6875rem;
}

/* The verdict tile carries three numbers in one value line. Colour repeats
   what the note below spells out in words — never the only channel, same
   rule as the table's verdict dots. */

.stat-tile__verdict--ok {
  color: var(--verdict-ok);
}

.stat-tile__verdict--borderline {
  color: var(--verdict-borderline);
}

.stat-tile__verdict--violation {
  color: var(--verdict-violation);
}

.stat-tile__separator {
  color: var(--line-strong);
  font-weight: 400;
}

.stat-caption {
  margin: 0.4rem 0 0;
  color: var(--fg-faint);
  font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
}

/* The collection row is the donut alone now — the stats moved up into the
   tile row and the genre legend into the sidebar's filter. It takes the
   whole width; overrides the set-mode .viz-row .wheel-wrap ratio (45:14)
   with a selector one class more specific, so source order doesn't
   matter. */

.viz-row .wheel-wrap.wheel-wrap--collection {
  flex: 1 1 100%;
  min-width: 16rem;
}

/* The SVG is 1:1 and otherwise scales to fill its column (.wheel's own
   width: 100%) — the column is now the full content width, so an uncapped
   donut would grow tall enough to push the whole table below the fold. The
   cap is what keeps the sticky .viz-row short; it went up with the freed
   width, not away. */
.wheel-wrap--collection .wheel {
  max-width: 440px;
  margin: 0 auto;
}

/* Hover: cross-view highlighting. Visible by an outline/stroke appearing,
   not by colour alone. */

.transition-pair.is-hovered {
  outline: 2px solid var(--accent);
  outline-offset: -1px;
}

.timeline__bar.is-hovered {
  stroke: var(--accent);
  stroke-width: 2;
}

.wheel__point.is-hovered {
  stroke: var(--accent);
  stroke-width: 2.5;
}

/* The tooltip: one persistent element, positioned near the pointer. */

/* A card, not an inverted block. It sits over the cards now, and a block of
   inverted ink among them reads as a different application having opened. */
.tooltip {
  position: fixed;
  z-index: 20;
  max-width: 16rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--fg);
  font-size: 0.8125rem;
  line-height: 1.4;
  pointer-events: none;
  box-shadow: var(--shadow);
}

.tooltip[hidden] {
  display: none;
}

.tooltip p {
  margin: 0;
}

.tooltip p + p {
  margin-top: 0.15rem;
}

.tooltip__title {
  font-weight: 600;
}

/* The purchase-planning tab: the paste area, the candidate list and the edit
 * panel under a row.
 *
 * Almost nothing here is new. The list is .transition-table with its own
 * column template, a row is a .transition-pair, the panel is an .inline-panel
 * and the button that opens it is a .row-action — the same three parts a
 * collection row with an open connection panel has. What is left below is only
 * what a candidate has and a track does not: a box to paste into, a way to
 * throw a row away, and fields that can be typed in. */

/* What every number below is relative to, above everything that is relative to
   it. Not a card: it qualifies the view the way the indicator strip qualifies
   a loaded file, and a card would make it a piece of content of its own. */
.plan-scope {
  margin-bottom: var(--card-gap);
  padding: 0.5rem 0.75rem;
  border-left: 3px solid var(--line-strong);
  color: var(--fg-muted);
  font-size: 0.875rem;
}

.plan-scope__line {
  margin: 0;
  font-variant-numeric: tabular-nums;
}

/* A genre name comes out of an export and can be any length, so it gets its
   own line rather than being folded into the one above. */
.plan-scope__genres {
  margin: 0.2rem 0 0;
  color: var(--fg-faint);
  text-wrap: pretty;
}

.plan-paste {
  margin: 0.75rem 0 1rem;
}

.plan-paste__label {
  display: block;
}

.plan-paste__label-text {
  display: block;
  margin-bottom: 0.35rem;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* Monospace, and wrapping off: what is pasted in here is a page's line
   structure, and that structure is exactly what the parser reads. Seeing it
   wrap would misrepresent which line is which. */
.plan-paste__input {
  display: block;
  width: 100%;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--surface-sunken);
  color: var(--fg);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8125rem;
  line-height: 1.45;
  resize: vertical;
  white-space: pre;
  overflow-x: auto;
}

.plan-paste__input:hover {
  border-color: var(--line-strong);
}

.plan-paste__input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.plan-paste__hint {
  margin: 0.35rem 0 0;
  color: var(--fg-faint);
  font-size: 0.8125rem;
}

/* A refusal is about the box above it, so it sits directly under it rather
   than at the top of the view where a load error goes. */
.plan-paste__problem {
  margin-top: 0.5rem;
}

/* The second click of the two-step clear. Armed, it borrows the error colour
   the rest of the application already spends on things that cannot be taken
   back — the word changes too, so the colour is never the only channel. */
.controls__button.is-armed {
  border-color: var(--error);
  color: var(--error);
}

/* No file to unload, so no ✕ in the tab row — this is where a candidate goes
   away instead. Quiet until the row is under the pointer: it is destructive
   and sits at the end of every row. */
.plan-row__remove {
  display: flex;
  justify-content: flex-end;
}

.plan-remove {
  padding: 0 0.3rem;
  border: none;
  border-radius: 4px;
  background: none;
  color: var(--fg-faint);
  font: inherit;
  line-height: 1.2;
  cursor: pointer;
  opacity: 0;
}

.plan-row:hover .plan-remove,
.plan-remove:focus-visible {
  opacity: 1;
}

.plan-remove:hover {
  background: var(--error-bg);
  color: var(--error);
}

/* A candidate pasted from the bare metadata block has no title at all. That is
   a property of what was copied, not a failure, so it is said in words — an
   empty cell in the widest column reads as something that failed to render. */
.plan-row__untitled {
  color: var(--fg-faint);
  font-style: italic;
  font-weight: 400;
}

/* The edit panel's fields. Two per line where they are short, one per line
   where the value is prose out of a shop and can be any length. */

.plan-fields {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 0.4rem 1rem;
}

.plan-field {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
}

.plan-field--text {
  grid-column: 1 / -1;
}

.plan-field__label {
  flex: none;
  width: 5.5rem;
  color: var(--fg-faint);
  font-size: 0.8125rem;
}

.plan-field__input {
  flex: 1 1 auto;
  min-width: 0;
}

/* A field the parser filled from line order alone, with no label to go on. The
   dotted underline marks it in the row, the word beside the label says what
   the marking means — the same two-channel rule the verdict chips follow. */
.plan-field__input.is-guessed {
  text-decoration: underline dotted var(--fg-faint);
  text-underline-offset: 0.25em;
}

.plan-field__guessed {
  margin-left: 0.4rem;
  color: var(--fg-faint);
  font-size: 0.75rem;
  font-style: italic;
}

/* The joined components and the signed material change, in the row. Right
   aligned with the numeric columns beside it. */
.plan-row__impact {
  text-align: right;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* The export has caught up with a candidate. Quieter than a verdict, because
   it is the absence of one: this row is no longer a decision. */
.plan-row__owned {
  color: var(--fg-faint);
  font-style: italic;
}

/* What owning this candidate would do, under the fields it would do it with.
   Behind a rule, because the block above is editable and this one is a
   consequence of it. */
.plan-verdict {
  margin-top: 0.75rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--line);
}

.plan-verdict__lead {
  margin: 0;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

.plan-verdict__line {
  margin: 0.2rem 0 0;
  padding-left: 1.2rem;
  color: var(--fg-muted);
  font-variant-numeric: tabular-nums;
}

/* What the key field resolved to. The paste says 'E Major' and the graph works
   in 12B; both are on screen rather than the reader having to trust that the
   translation happened. */
.plan-field__echo {
  flex: none;
  color: var(--fg-faint);
  font-size: 0.8125rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.plan-field__echo--bad {
  color: var(--error);
}

/* The foot of the settings column: which version this is, and on what terms.
 *
 * margin-top: auto is the whole trick — the sidebar is a flex column of a fixed
 * height, so the footer takes whatever room is left over and sits on the bottom
 * edge. That edge does not move, which is the point: in the content column the
 * notice followed whatever had been loaded and landed somewhere different every
 * time.
 *
 * Not sticky. When the settings themselves outgrow the column this scrolls away
 * with them, and that is the right trade — a permanent four-line disclaimer
 * would be taking that room from the genre filter, which is what the column is
 * for. */

.sidebar-footer {
  margin-top: auto;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
  color: var(--fg-faint);
  font-size: 0.8125rem;
  line-height: 1.45;
}

/* One run of text, wrapping wherever the column ends. text-wrap: pretty only
   improves the last line where it is supported and changes nothing where it is
   not. */
.sidebar-footer__text {
  margin: 0;
  text-wrap: pretty;
}

/* The way to make the sentence above it untrue. Quiet by default — it is not
   an action anybody comes here to take — and it borrows the error colour once
   armed, the way the plan tab's clear does. The word changes too, so the
   colour is never the only channel. */
.sidebar-footer__clear {
  margin-top: 0.6rem;
  padding: 0.2rem 0.6rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: none;
  color: var(--fg-faint);
  font: inherit;
  font-size: 0.75rem;
  cursor: pointer;
}

.sidebar-footer__clear:hover {
  border-color: var(--error);
  color: var(--error);
}

.sidebar-footer__clear.is-armed {
  border-color: var(--error);
  background: var(--error-bg);
  color: var(--error);
}

.sidebar-footer__clear:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Errors are visible in the page, never only in the console. */

.error {
  margin: 0;
  padding: 0.75rem 1rem;
  border-left: 3px solid var(--error);
  background: var(--error-bg);
  border-radius: 0 var(--radius) var(--radius) 0;
  color: var(--error);
}

/* A failed load with content still on screen: the error leads the view,
   above the indicator strip, and needs the same gap under it the strip has. */
.status > .error {
  margin-bottom: 1rem;
}

/* The warning sign is decoration, not text — it carries no language and so
   belongs here rather than in i18n.js. */
.error::before {
  content: '\26A0';
  margin-right: 0.5rem;
}
