/* missing-ebooks stylesheet. Hand-rolled, daisyUI-flavored.
   Layers: 1) tokens 2) themes 3) base 4) components */

/* tokens, assigned per theme */
:root,
[data-theme="light"] {
  --color-base-100: #ffffff;
  --color-base-200: #f2f3f5;
  --color-base-300: #e5e6e9;
  --color-base-content: #1f2733;
  --color-muted: #6b7480;
  --color-surface-raised: #ffffff;
  --color-primary: #605dff;
  --color-primary-content: #ffffff;
  /* The gap / needs-attention role, amber.
     Carries the gaps-to-fill count, the "needs ebook" badge, the flagged folder
     icon, and per-root gap chips. Amber sits warm-but-yellower-than-error-red in
     the palette, so a gap badge and an error badge do not read as the same alarm.
     Token value is #f5a524 on both themes. The token keeps the name
     --color-warning.
     Ink policy: the default ink is the same #f5a524 on both themes, which puts
     sub-AA contrast on the pale amber pill by choice, so the badge label and hero
     count stay plain amber rather than compromising the color. The accent picker
     derives a readable per-theme ink for a custom base to clear AA. */
  --color-warning: #f5a524;
  --color-success: #00b074;
  --color-error: #ef4565;
  /* Status roles: the base tokens above fill and tint. The success and error -text
     variants carry status color as text or a meaning-bearing icon, where the
     saturated base fails contrast on a light surface. Warning is the exception
     above: its ink is the base amber. */
  --color-success-text: #046b4d;
  --color-warning-text: #f5a524;
  --color-error-text: #be123c;
  --guide: #dfe2e7;
  --color-border: #e5e6e9;
  --radius-box: 1rem;
  --radius-field: 0.5rem;
  --border: 1px;
  /* the caret column and the row's inline padding. the tree guide is positioned
     off both, so they are tokens rather than literals: see .menu ul */
  --chev-size: 0.7rem;
  --row-pad-x: 0.5rem;
  /* the kebab column on phones. one token so the trigger and the empty slot a
     covered row reserves cannot drift apart */
  --kebab-col: 1.6rem;
  --scrim: rgb(0 0 0 / 45%);
  --shadow-popover: 0 8px 24px rgb(0 0 0 / 18%);
  --radius-pill: 999px;
  --motion-fast: 0.15s;
  --fs-xs: 0.7rem;
  --fs-sm: 0.8rem;
  --fs-base: 0.85rem;
}

[data-theme="dark"] {
  --color-base-100: #1d232a;
  --color-base-200: #191e24;
  --color-base-300: #14181d;
  --color-base-content: #c7ccd4;
  --color-muted: #a8b1be;
  --color-surface-raised: #2b3139;
  --color-primary: #7c79ff;
  --color-primary-content: #14181d;
  --color-warning: #f5a524;
  --color-success: #00b074;
  --color-error: #ff6b85;
  --color-success-text: #34d399;
  --color-warning-text: #f5a524;
  --color-error-text: #ff8095;
  --guide: #2b323b;
  --color-border: #333b46;
}

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

body {
  margin: 0;
  padding: 1.5rem;
  font-family: system-ui, -apple-system, sans-serif;
  background: var(--color-base-200);
  color: var(--color-base-content);
  -webkit-font-smoothing: antialiased;
}

details > summary {
  list-style: none;
}
details > summary::-webkit-details-marker {
  display: none;
}

/* components */

/* Shared content-column cap: navbar, cards, scan-bar, gap-summary, search-empty,
   intro-card, and site-footer all track the same 52rem column. */
.navbar,
.card,
.scan-bar,
.gap-summary,
.search-empty,
.intro-card,
.site-footer {
  max-width: 52rem;
}

/* navbar */
.navbar {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 0 auto 1.25rem;
  padding: 0.75rem 1rem;
  background: var(--color-base-100);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-box);
}
.navbar h1 {
  margin: 0;
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}
/* the title is a home link: inherits the wordmark's color and weight so it looks
   unchanged. the flex spacing the brand mark from the text lives here. */
.navbar h1 a {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  color: inherit;
  text-decoration: none;
}
.navbar h1 a:hover {
  text-decoration: underline;
}
.navbar h1 a:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
  border-radius: var(--radius-field);
}
/* the inline brand mark leading the title, indigo via the theme's primary */
.brand-mark {
  flex: none;
  width: 1.6rem;
  height: 1.6rem;
  color: var(--color-primary);
}
.navbar .spacer {
  flex: 1;
}
.navbar form {
  margin: 0;
}
/* The help and settings buttons are both chrome, not functional toolbar controls,
   so pull the cog toward the help button to read as one grouped pair rather than
   inheriting the navbar's full inter-control gap. */
.navbar .settings-cog {
  margin-left: -0.5rem;
}

/* buttons */
.btn {
  font: inherit;
  font-weight: 600;
  font-size: var(--fs-sm);
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.4rem 0.8rem;
  border: var(--border) solid transparent;
  border-radius: var(--radius-field);
  cursor: pointer;
  text-decoration: none;
  white-space: nowrap;
  transition: background var(--motion-fast), border-color var(--motion-fast), transform 0.05s;
}
.btn:active {
  transform: translateY(1px);
}
.btn-primary {
  background: var(--color-primary);
  color: var(--color-primary-content);
}
.btn-ghost {
  background: transparent;
  color: var(--color-base-content);
}
.btn-ghost:hover {
  background: var(--color-base-200);
}
.btn-outline {
  background: transparent;
  border-color: var(--color-border);
  color: var(--color-base-content);
}
.btn-outline:hover {
  background: var(--color-base-200);
}
.btn-xs {
  font-size: var(--fs-xs);
  padding: 0.2rem 0.5rem;
}
.btn-square {
  padding: 0.4rem;
}

/* segmented view control */
.segmented {
  display: inline-flex;
  gap: 0.15rem;
  padding: 0.15rem;
  background: var(--color-base-200);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-field);
}
.segment {
  font: inherit;
  font-weight: 600;
  font-size: var(--fs-sm);
  padding: 0.3rem 0.7rem;
  border-radius: calc(var(--radius-field) - 0.15rem);
  color: var(--color-muted);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  /* the theme control uses <button> segments, the view toggle <span>/<a>. drop
     the native control chrome so a button renders flat like the others. the
     active fill comes from .segment-active below. inert for <span>/<a>, which
     already have no background or border. */
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  border: none;
}
.segment:hover {
  color: var(--color-base-content);
}
.segment-active,
.segment-active:hover {
  background: var(--color-primary);
  color: var(--color-primary-content);
  cursor: default;
}

/* card / root section */
.card {
  margin: 0 auto 1rem;
  padding: 1rem 1.1rem;
  background: var(--color-base-100);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-box);
}
.root-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0 0 0.6rem;
}
.root-head h2 {
  margin: 0;
  font-size: 0.92rem;
  font-weight: 600;
  color: var(--color-muted);
  word-break: break-all;
}
.root-fold > summary.root-head {
  cursor: pointer;
}
.root-badge {
  flex: none;
  font-size: var(--fs-xs);
  font-weight: 700;
  line-height: 1.4;
  padding: 0.12rem 0.5rem;
  border-radius: var(--radius-pill);
  white-space: nowrap;
}
.root-badge-gaps {
  background: color-mix(in srgb, var(--color-warning) 16%, var(--color-base-100));
  color: var(--color-warning-text);
}
.root-badge-clean {
  background: color-mix(in srgb, var(--color-success) 18%, var(--color-base-100));
  color: var(--color-success-text);
}
.root-badge-error {
  background: color-mix(in srgb, var(--color-error) 16%, var(--color-base-100));
  color: var(--color-error-text);
}

/* menu / tree */
.menu {
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.9rem;
}
/* the nesting guide. margin-left places the border on the center of the parent
   row's caret, so it is derived rather than picked: the row's inline padding to
   reach the caret box, plus half a caret to reach its middle. padding-left is
   independent, it only sets the gap between the border and the child row, which
   is what the phone block trims. */
.menu ul {
  list-style: none;
  margin: 0 0 0 calc(var(--row-pad-x) + var(--chev-size) / 2);
  padding-left: 0.8rem;
  border-left: var(--border) solid var(--guide);
}
.menu li {
  margin: 1px 0;
}

.row {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  padding: 0.3rem var(--row-pad-x);
  border-radius: var(--radius-field);
  /* reserve the action-button row height so a covered row, which drops the buttons,
     stays as tall as a flagged one and marking never shifts the rows below */
  min-height: 2rem;
}
/* row is flex-start so a wrapping label grows downward from the first line.
   each non-label child gets a box the height of that reserved row (2rem) and
   centres its content, so every glyph sits at the vertical middle of that box.
   the label then pads its top by the same offset that centres a single line
   inside 2rem: first-line centre and glyph centre land on the same y, and any
   extra lines flow below. */
.row > *:not(.row-label):not(.spring) {
  min-height: 2rem;
  display: inline-flex;
  align-items: center;
}
.row-label {
  padding-block-start: calc((2rem - 1lh) / 2);
}
.row:hover {
  background: var(--color-base-200);
}
/* Folder rows render as <summary> and toggle <details> on click, so they get a
   pointer. Leaf rows are a plain <div> and keep the default cursor. The buttons
   inside any row carry their own pointer. */
details > summary.row {
  cursor: pointer;
}

.chev {
  width: var(--chev-size);
  flex: none;
  color: var(--color-muted);
  transition: transform var(--motion-fast);
}
details[open] > summary .chev {
  transform: rotate(90deg);
}
.icon {
  width: 1.05rem;
  height: 1.05rem;
  flex: none;
  color: var(--color-muted);
}
/* stands in for the caret on a leaf row, so leaf and folder icons share a column */
.leaf-pad {
  width: var(--chev-size);
  flex: none;
}
.name {
  font-weight: 500;
}
/* the row's text run (the name plus the muted notes that read as part of it) as
   one box at both widths. only the basis differs: auto here so the box hugs its
   text and the mark slot sits right after it, 0 in the phone block so the run
   fills the row and wraps. */
.row-label {
  display: block;
  flex: 0 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
}
.spring {
  flex: 1;
}
/* the row's mark slot: whichever of the "needs ebook" pill or the covered check
   the row carries. its own flex item rather than trailing text, so the mark holds
   one column at the row's right edge however long the name beside it runs. the
   slot sizes to its content: every flagged row carries the same pill, so they
   line up without a declared width. */
.row-mark {
  display: inline-flex;
  flex: none;
}
/* a covered folder is finished, so its check leaves the working mark column and
   right-aligns in the row's trailing slot, in line with the last icon button. the
   right edge then reads as one column of glyphs: pill or nothing in the mark slot,
   buttons or a check at the edge. order does it without a second element in the
   markup; the phone block puts the check back, where there is no action slot to
   retire into. */
.row-mark-done {
  order: 1;
}

/* the row's trailing slot. the cluster inside it is display:contents on desktop,
   so this is what carries the gap the row used to provide between the marker form
   and the search links. */
.row-actions {
  display: inline-flex;
  align-items: center;
  justify-content: flex-end;
  gap: 0.5rem;
  flex: none;
}

.actions {
  display: inline-flex;
  align-items: center;
  gap: 0.25rem;
}
.mark {
  margin: 0;
}

/* per-row action trigger: a kebab that reveals the cluster on mobile only. on
   desktop the trigger is hidden and the group is display:contents, so its
   children flow inline in the row exactly as before. */
.actions-trigger {
  display: none;
  font: inherit;
  align-items: center;
  padding: 0.2rem;
  background: transparent;
  border: var(--border) solid transparent;
  border-radius: var(--radius-field);
  cursor: pointer;
}
.actions-trigger:hover {
  background: var(--color-base-200);
}
.actions-trigger .icon {
  width: 1.05rem;
  height: 1.05rem;
  color: var(--color-muted);
}
/* the kebab stays grey even in flagged rows, where .flagged .icon would
   otherwise tint it amber along with the folder icon */
.flagged .actions-trigger .icon {
  color: var(--color-muted);
}
.actions-group {
  display: contents;
}
/* sheet-only chrome: grab handle, title, verbose marker labels, the "Search"
   divider. hidden by default so the desktop cluster stays compact. the mobile
   sheet reveals each. */
.sheet-header,
.sheet-grip,
.sheet-title,
.sheet-divider,
.label-long {
  display: none;
}
/* the marker buttons carry their glyph at every width: that glyph is the whole
   button once the label is clipped. the search-links rows keep theirs for the
   sheet only, where the menu is a list of full-width rows. */
.mark .sheet-icon {
  display: inline-flex;
  align-items: center;
}
.mark .sheet-icon .icon {
  width: 1.05rem;
  height: 1.05rem;
  color: var(--color-muted);
}
/* the marker glyphs stay grey inside a flagged row, where .flagged .icon
   would otherwise tint them amber along with the folder icon */
.flagged .mark .sheet-icon .icon {
  color: var(--color-muted);
}
.links-menu .sheet-icon {
  display: none;
}
/* the marker labels leave the row visually but stay in the DOM and the a11y tree,
   so the buttons keep an accessible name and a screen reader still reads them.
   the same clip pattern the phone block uses on the row's muted notes. an
   absolutely positioned child is not a flex item, so the button's gap does not
   reserve a space beside the icon for it. */
.label-short {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
/* hover tooltips for the icon-only row buttons. drawn here rather than left to
   native `title`, whose delay is browser and OS controlled somewhere around half
   a second with no hook to shorten it. hover-capable pointers only: a touch device
   has no hover state to read one from, and the action sheet names both markers in
   full there. focus-visible mirrors it for the keyboard. */
@media (hover: hover) {
  [data-tip] {
    position: relative;
  }
  [data-tip]::after {
    content: attr(data-tip);
    position: absolute;
    bottom: calc(100% + 0.35rem);
    left: 50%;
    transform: translateX(-50%);
    z-index: 20;
    padding: 0.25rem 0.5rem;
    border-radius: var(--radius-field);
    background: var(--color-base-content);
    color: var(--color-base-100);
    font-size: var(--fs-xs);
    font-weight: 500;
    line-height: 1.4;
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.1s;
  }
  [data-tip]:hover::after,
  [data-tip]:focus-visible::after {
    opacity: 1;
    transition-delay: 150ms;
  }
}
.links {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.72rem;
}
.links a {
  color: var(--color-primary);
  text-decoration: none;
}
.links a:hover {
  text-decoration: underline;
}

/* search-links dropdown: a magnifying-glass button opens a popover menu */
.links-toggle .icon {
  width: 0.85rem;
  height: 0.85rem;
  color: var(--color-muted);
}
/* the search toggle stays grey even in flagged rows, where .flagged .icon
   would otherwise tint it amber along with the folder icon */
.flagged .links-toggle .icon {
  color: var(--color-muted);
}
.links-menu {
  margin: 0;
  min-width: 9rem;
  padding: 0.25rem;
  background: var(--color-base-100);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-field);
  box-shadow: var(--shadow-popover);
}
/* where CSS anchor positioning is available, re-anchor the popover under its
   button. Elsewhere the popover keeps the usable centered top-layer default. */
@supports (position-area: bottom span-left) {
  .links-menu {
    position: absolute;
    inset: auto;
    position-area: bottom span-left;
    position-try-fallbacks: top span-left, bottom span-right;
  }
}
.links-menu a {
  display: block;
  padding: 0.3rem 0.5rem;
  border-radius: calc(var(--radius-field) - 0.2rem);
  color: var(--color-primary);
  font-size: 0.78rem;
  text-decoration: none;
  white-space: nowrap;
}
.links-menu a:hover {
  background: var(--color-base-200);
}

/* badges */
.badge {
  font-size: 0.66rem;
  font-weight: 700;
  line-height: 1.4;
  padding: 0.1rem 0.45rem;
  border-radius: var(--radius-pill);
  flex: none;
  white-space: nowrap;
}
.badge-warning {
  background: color-mix(in srgb, var(--color-warning) 16%, var(--color-base-100));
  color: var(--color-warning-text);
}
.badge-neutral {
  background: var(--color-base-200);
  color: var(--color-muted);
}

/* flagged folders: amber icon and bolder name, the badge carries the label.
   The icon is foreground, so it takes the -text variant: the saturated base
   fails contrast as a glyph on a light surface. Matches .gap-hero-num. */
.flagged .icon {
  color: var(--color-warning-text);
}
.flagged .name {
  font-weight: 600;
}

/* structural-smell microlabels: a muted organizational note on a flagged row,
   set off by a leading middot. Not a filled badge: organization is a different
   axis from coverage, so it stays quieter than the amber "needs ebook" badge.
   the audio-file count is the same muted, middot-led note. */
.smell,
.file-count {
  flex: none;
  margin-inline-start: 0.35rem;
  font-size: 0.74rem;
  color: var(--color-muted);
}
.smell::before,
.file-count::before {
  content: "·";
  margin-inline-end: 0.35rem;
}

/* a revealed audio-file row: muted, indented on the tree guide like a child folder,
   with a music glyph so it reads as a file rather than an actionable folder. The
   reserved row min-height does not apply, since these carry no action buttons. */
.file-row {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.14rem 0.5rem;
  color: var(--color-muted);
  font-size: var(--fs-sm);
}
.file-row .file-glyph {
  width: 0.85rem;
  height: 0.85rem;
  flex: none;
  color: var(--color-muted);
}
.file-name {
  word-break: break-all;
}

/* covered rows + status check (show-all view only): a dimmed row that carries a
   success check and no actions */
.covered .name {
  color: var(--color-muted);
  font-weight: 500;
}
.covered .icon {
  color: var(--color-muted);
}
.status {
  display: inline-flex;
  align-items: center;
}
.covered .status .icon {
  color: var(--color-success-text);
}

/* depth typography: bold the top container of each tree, italicize the
   containers nested below it. Flagged and leaf rows never get these classes,
   so their weight is left alone. These sit after the covered rules so the top
   weight wins on a covered top container (equal specificity, source order
   decides). Each rule carries its own html:not([data-…="off"]) guard so the
   two settings switches toggle independently. An absent attribute matches the
   default and no-JS path. */
html:not([data-bold-top="off"]) .container-top .name {
  font-weight: 700;
}
html:not([data-italic-nested="off"]) .container-nested .name {
  font-style: italic;
}

/* covering ebook/marker filenames on a covered row (show-all only): muted, set off
   from the name and wrapping whole. inline-block keeps the list atomic, so it moves
   to the next line entire rather than breaking mid-filename under the label's
   overflow-wrap. no ellipsis: truncating them hides exactly what a covered row
   exists to show. */
.cover-files {
  display: inline-block;
  max-width: 100%;
  margin-inline-start: 0.5rem;
  color: var(--color-muted);
  font-size: 0.72rem;
}

/* root states */
.alert {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.55rem 0.8rem;
  border-radius: var(--radius-field);
  font-size: var(--fs-base);
}
.alert-error {
  background: color-mix(in srgb, var(--color-error) 14%, var(--color-base-100));
  border: var(--border) solid color-mix(in srgb, var(--color-error) 35%, transparent);
  color: var(--color-base-content);
}
.alert .icon {
  color: var(--color-error-text);
}
.alert-warning {
  background: color-mix(in srgb, var(--color-warning) 14%, var(--color-base-100));
  border: var(--border) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
  color: var(--color-base-content);
}
.alert-warning .icon {
  color: var(--color-warning-text);
}

.empty {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.3rem 0.2rem;
  color: var(--color-muted);
  font-size: var(--fs-base);
}
.empty .icon {
  color: var(--color-success-text);
}

/* settings menu: a cog-triggered popover panel (desktop) / bottom sheet (mobile) */
/* a centered overlay: the open-popover defaults (position: fixed, inset: 0) plus
   margin auto sit it dead center, so the cog and the ? hotkey open it in the same
   place on every browser, with no anchor to lose. a faint scrim dims the page
   behind it. the mobile rule re-pins it to the bottom as a sheet. */
.settings-panel {
  margin: auto;
  min-width: 15rem;
  padding: 0.4rem;
  background: var(--color-surface-raised);
  /* a popover's UA color is CanvasText (black), not the inherited theme color.
     restore it so panel text reads in dark, as .confirm-dialog does. */
  color: var(--color-base-content);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-field);
  box-shadow: var(--shadow-popover);
}
/* Modal separation runs on surface tone, not scrim opacity or backdrop blur.
   The panel sits on --color-surface-raised, #ffffff in light (flush with
   existing panels) and #2b3139 in dark (a step lighter than the #191e24 body),
   so elevation reads by tone on both themes. The token is orthogonal to the
   --color-base-100/200/300 scale, which stays scoped to the page and its cards.
   Backdrops keep a soft 45% black scrim across the settings panel, confirm
   dialog, and mobile bottom sheets. No backdrop-filter: the accent picker's
   live preview needs the page to stay legible behind an open panel. The 1px
   border and drop shadow read as redundant in dark but stay because they carry
   the elevation in light. A future pill or badge that wants the raised look
   targets --color-surface-raised and adds a matching entry to the
   accent-derive test's SURFACE map. */
.settings-panel::backdrop {
  background: var(--scrim);
}
.settings-head {
  padding: 0.3rem 0.5rem 0.4rem;
  font-size: var(--fs-xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--color-muted);
}
/* sections after the first open with a wider gap above the header than below it,
   so whitespace alone marks the grouping. The panel carries no divider rules. */
.settings-head:not(:first-child) {
  margin-top: 0.8rem;
}
.settings-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  padding: 0.5rem;
}
.settings-row-theme {
  flex-direction: column;
  align-items: stretch;
  gap: 0.4rem;
}
.settings-row-theme .segmented {
  display: flex;
}
.settings-row-theme .segment {
  flex: 1;
  text-align: center;
}
.settings-label {
  font-size: var(--fs-base);
  font-weight: 500;
  color: var(--color-base-content);
}
.settings-sub {
  display: block;
  font-size: var(--fs-xs);
  font-weight: 400;
  color: var(--color-muted);
  margin-top: 0.1rem;
}

/* settings shortcuts: the read-only hotkey reference folded into the panel,
   below the live controls and set off by the same whitespace gap that separates
   the other sections. hidden on mobile, where there is no keyboard (see the
   navbar media query). */
.settings-shortcuts {
  margin-top: 0.8rem;
}
.settings-shortcuts-list {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.4rem 0.9rem;
  margin: 0;
  padding: 0.2rem 0.5rem 0.4rem;
}
.settings-shortcuts-list dt,
.settings-shortcuts-list dd {
  margin: 0;
}
.settings-shortcuts-list dd {
  color: var(--color-muted);
  align-self: center;
}
.settings-shortcuts kbd {
  padding: 0.1rem 0.4rem;
  font: inherit;
  font-size: 0.75rem;
  font-weight: 700;
  background: var(--color-base-200);
  border: var(--border) solid var(--color-border);
  border-radius: 0.3rem;
}

/* switch: a styled checkbox for on/off settings */
.switch {
  position: relative;
  display: inline-flex;
  flex: none;
  cursor: pointer;
}
.switch input {
  position: absolute;
  width: 0;
  height: 0;
  opacity: 0;
}
.switch-track {
  width: 2.1rem;
  height: 1.2rem;
  border-radius: var(--radius-pill);
  background: var(--color-base-300);
  transition: background var(--motion-fast);
}
.switch-track::after {
  content: "";
  position: absolute;
  top: 0.15rem;
  left: 0.15rem;
  width: 0.9rem;
  height: 0.9rem;
  border-radius: 50%;
  /* Intentionally white on both themes: physical toggle metaphor. */
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 25%);
  transition: left var(--motion-fast);
}
.switch input:checked + .switch-track {
  background: var(--color-primary);
}
.switch input:checked + .switch-track::after {
  left: 1.05rem;
}
/* off-track in dark: base-300 is darker than the panel and effectively vanishes,
   so an off toggle reads as empty space. Lift the fill above the surface and
   outline it so the control's shape is defined whether it is on or off. The
   :checked rule above wins by specificity, so the on-state primary fill is kept. */
[data-theme="dark"] .switch-track {
  background: #3a4250;
  border: 1px solid var(--color-border);
}
/* On, the primary fill defines the track, so drop the off-state outline for
   parity with light. Transparent rather than removed so the box keeps its size
   across states. */
[data-theme="dark"] .switch input:checked + .switch-track {
  border-color: transparent;
}

/* accent control: a quick-pick dot row plus the native color picker, in the
   Theme section. The dots are data-accent quick picks. The swatch is the OS
   picker. The default (#f5a524) writes no override, so the tokens above apply.
   A custom pick is set inline on <html> by app.js. */
.accent-ctl {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  flex: none;
}
.accent-dots {
  display: inline-flex;
  gap: 0.5rem;
}
.accent-dot {
  width: 1.2rem;
  height: 1.2rem;
  padding: 0;
  border-radius: 50%;
  border: var(--border) solid var(--color-border);
  cursor: pointer;
}
/* the active quick-pick: a ring offset by a base-100 gap so it reads against the
   dot's own fill. The 0.5rem dot gap keeps the ring clear of its neighbors. */
.accent-dot-active {
  box-shadow:
    0 0 0 2px var(--color-base-100),
    0 0 0 4px var(--color-primary);
}
/* the native color input, sized to match the panel's controls. The vendor
   pseudo-elements strip the default inset border so the swatch reads as a clean
   rounded chip. */
.accent-swatch {
  width: 2.1rem;
  height: 1.7rem;
  padding: 0;
  background: none;
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-field);
  cursor: pointer;
}
.accent-swatch::-webkit-color-swatch-wrapper {
  padding: 0;
}
.accent-swatch::-webkit-color-swatch {
  border: none;
  border-radius: calc(var(--radius-field) - 2px);
}
.accent-swatch::-moz-color-swatch {
  border: none;
  border-radius: calc(var(--radius-field) - 2px);
}

/* marker-write confirmation dialog: a themed centered modal over a dimmed page */
.confirm-dialog {
  width: min(22rem, calc(100vw - 2rem));
  padding: 1.1rem 1.15rem 1rem;
  background: var(--color-surface-raised);
  color: var(--color-base-content);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-box);
  box-shadow: 0 16px 48px rgb(0 0 0 / 28%);
}
.confirm-dialog::backdrop {
  background: var(--scrim);
}
.confirm-title {
  margin: 0 0 0.5rem;
  font-size: 1rem;
  font-weight: 700;
}
.confirm-body {
  margin: 0 0 0.9rem;
  font-size: 0.88rem;
  line-height: 1.5;
}
.confirm-chip {
  font-family: ui-monospace, monospace;
  font-size: 0.82rem;
  background: var(--color-base-200);
  border: var(--border) solid var(--color-border);
  border-radius: 0.35rem;
  padding: 0.05rem 0.3rem;
}
.confirm-again-label {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  margin-bottom: 0.95rem;
  font-size: var(--fs-sm);
  color: var(--color-muted);
}
.confirm-again-label input {
  accent-color: var(--color-primary);
}
.confirm-actions {
  display: flex;
  justify-content: flex-end;
  gap: 0.5rem;
}
.confirm-icon {
  display: inline-flex;
}
/* the inline-flex above outranks the user-agent `[hidden] { display: none }`, so
   the non-matching marker glyph would otherwise show. re-hide it explicitly so
   only the fired button's marker renders. */
.confirm-icon[hidden] {
  display: none;
}
.confirm-icon .icon {
  width: 1.05rem;
  height: 1.05rem;
  color: currentColor;
}

/* tablet-width concessions, ahead of the phone treatment at 650px: the "needs
   ebook" label stops earning its horizontal room, and the navbar's single row
   stops fitting without squeezing the wordmark. the kebab sheet, the control
   reflow and the cover-file drop still wait for 650px. keep these rules here
   rather than folding them back. */
@media (max-width: 800px) {
  /* the "needs ebook" pill collapses to an amber dot. text-indent and overflow
     hide the label visually while keeping it in the DOM and a11y tree, so screen
     readers still announce it and the HTML tests still match. the solid dot fill
     is reasserted just below. inline-block is load-bearing: width and height do
     not apply to an inline box, and .row-mark is inline-flex, so without it the
     dot re-expands to the width of the label it is hiding. */
  .badge {
    display: inline-block;
    width: 0.5rem;
    height: 0.5rem;
    min-width: 0.5rem;
    padding: 0;
    border-radius: var(--radius-pill);
    overflow: hidden;
    white-space: nowrap;
    text-indent: 100%;
  }
  /* the collapsed dot needs a solid fill to read at 0.5rem; the desktop tint would
     wash out, so .badge-warning reasserts the solid amber here. */
  .badge-warning {
    background: var(--color-warning-text);
  }
  /* the single row needs 708px and the wordmark stops fitting on one line at
     about 825px, so the filter box, the widest control at 194px, drops to a
     full-width row of its own first. the rest of the navbar keeps its desktop
     layout down to 650px, where the remaining row stops fitting and the phone
     treatment takes over. order 5 sorts it last against the default order 0 on
     every other control, so it works here without the 650px block's order rules. */
  .navbar {
    flex-wrap: wrap;
  }
  .navbar .search {
    order: 5;
    flex-basis: 100%;
  }
}

/* mobile: first-class phone layout */
/* See docs/superpowers/specs/2026-06-07-mobile-ui-improvements-design.md */
@media (max-width: 650px) {
  body {
    padding: 1rem 0.75rem;
  }
  .navbar {
    gap: 0.6rem;
  }
  /* phone navbar lays out in three rows: the title with the help, settings cog
     and rescan on top, the view toggle as a full-width segmented control below,
     and the filter box last, from the 800px block above.
     flex `order` moves the controls without reordering the markup. each control
     is matched by class (.spacer, .intro-help, .settings-cog, form, > .segmented)
     so a later navbar addition can't silently inherit a row's order. .intro-help
     and .settings-cog deliberately share order 2, the two chrome icon buttons
     pair into one cluster (the negative margin above ties them visually), so a
     future third icon button sitting between them must opt in explicitly with
     its own .intro-help/.settings-cog-class rule. the view toggle uses a
     child combinator so the settings panel's nested theme segmented control,
     which lives in the same navbar, is left out of this row layout. the leading
     spacer keeps growing on the top row, so the cog and rescan stay pinned to the
     right edge instead of stranding a gap on wrap. */
  .navbar .spacer {
    order: 1;
  }
  .navbar .intro-help {
    order: 2;
  }
  .navbar .settings-cog {
    order: 2;
  }
  .navbar form {
    order: 3;
  }
  .navbar > .segmented {
    order: 4;
    flex-basis: 100%;
  }
  .navbar > .segmented .segment {
    flex: 1;
    text-align: center;
  }
  /* no keyboard on a touch device, so the shortcuts reference is hidden; the
     theme and confirm controls still reach the small screen. */
  .settings-shortcuts {
    display: none;
  }
  .gap-summary-head {
    flex-direction: column;
    align-items: flex-start;
    gap: 0.3rem;
  }
  .card {
    padding: 1rem 0.85rem;
  }

  /* the tree's per-level indent costs a large slice of a phone viewport, so trim
     it to the gutter between the guide and the child row. margin-left is not the
     knob: 0.85rem is the row's own 0.5rem padding plus half a chevron, which is
     what lands the guide on the center of the parent's caret. moving it floats
     the line off the caret. padding-left is free, it only sets breathing room
     right of the line. */
  .menu ul {
    padding-left: 0.15rem;
  }

  /* rows may wrap so the action menu and cover files can drop to a new line.
     the caret, the folder icon and the kebab align to the first text line rather
     than to the middle of a label that may run to several lines, so they stay in
     a straight column down the tree however tall the rows get. */
  .row {
    flex-wrap: wrap;
    row-gap: 0.4rem;
    align-items: flex-start;
  }
  /* the caret, folder icon, mark and kebab all inherit the desktop rule that
     gives each row child a 2rem box centred on its content, so they land on the
     same y as the label's first line without per-glyph nudges. */
  /* a phone row carries the name, its status mark and the covering filenames.
     the smell label and the file count are detail: at 390px they crowd out the
     name, and both are still on the desktop row and in the expanded file list.
     hidden visually rather than with display:none, so a screen reader still
     announces them here and the row reads the same on a phone as on a desktop. */
  .row-label .smell,
  .row-label .file-count {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  /* the name and its notes fill the row and wrap. a flex item's box is sized
     before its text reflows and nothing re-measures it after, so a wrapped name
     used to strand the leftover width inside its own box: a gap before the mark
     on a flagged row, a whole claimed flex line on a covered one. flex-basis 0 is
     what keeps the run off a line of its own. */
  .row-label {
    flex: 1 1 0;
  }
  /* the run already fills the row, so the spring has nothing left to push */
  .spring {
    display: none;
  }
  /* the mark and the covered check both sit inside the shared 2rem child box, so
     the desktop centring puts them on the label's first-line optical middle. */
  .row-mark-done {
    order: 0;
  }

  /* show the kebab trigger; it opens the per-row action sheet via the popover API.
     the trigger and the empty slot on a row without one are both one --kebab-col
     wide, so the mark column to their left holds whether a row has actions or not. */
  .actions-trigger {
    display: inline-flex;
    justify-content: center;
    width: var(--kebab-col);
  }
  .row-actions {
    min-width: var(--kebab-col);
  }

  /* the closed sheet is hidden. the author rule `.actions-group { display: contents }`
     outranks the user-agent rule that hides a closed popover, so the closed
     state must be re-hidden explicitly here. */
  .actions-group:not(:popover-open) {
    display: none;
  }

  /* the sheet box, shared by the open and the animating-closed states: a
     full-width panel pinned to the bottom of the viewport. the default transform
     sits one full height below the fold, so the open state slides it up and the
     closing state slides it back down. Shared chrome for both bottom-sheet
     popovers (actions and settings) lives in the grouped rule below; per-panel
     content styling stays here. */
  .actions-group[popover],
  .settings-panel[popover] {
    position: fixed;
    inset: auto 0 0 0;
    margin: 0;
    width: 100%;
    max-height: 80vh;
    overflow-y: auto;
    border: none;
    border-radius: var(--radius-box) var(--radius-box) 0 0;
    box-shadow: 0 -8px 24px rgb(0 0 0 / 18%);
    transform: translateY(100%);
    transition:
      transform 0.25s ease,
      overlay 0.25s ease allow-discrete,
      display 0.25s ease allow-discrete;
  }
  .actions-group[popover] {
    flex-direction: column;
    gap: 0.1rem;
    padding: 0.5rem 0.75rem calc(0.85rem + env(safe-area-inset-bottom));
    background: var(--color-surface-raised);
  }

  /* the open sheet: shown and slid into place */
  .actions-group:popover-open {
    display: flex;
    transform: translateY(0);
  }

  /* entry animation start: begin a full height below the fold. progressive
     enhancement; without @starting-style and allow-discrete the sheet simply
     appears and disappears without sliding, and still works. */
  @starting-style {
    .actions-group:popover-open,
    .settings-panel:popover-open {
      transform: translateY(100%);
    }
  }

  /* dim the page behind the open sheet */
  .actions-group::backdrop,
  .settings-panel::backdrop {
    background: var(--scrim);
  }

  /* reveal the sheet chrome the desktop cluster hides */
  .actions-group .sheet-header {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    padding: 0.25rem 0.25rem 0.4rem;
  }
  .actions-group .sheet-grip {
    display: block;
    align-self: center;
    width: 2.25rem;
    height: 0.25rem;
    border-radius: var(--radius-pill);
    background: var(--color-base-300);
  }
  .actions-group .sheet-title {
    display: block;
    font-size: 0.95rem;
    font-weight: 700;
    color: var(--color-base-content);
    word-break: break-word;
  }
  .actions-group .sheet-icon {
    display: inline-flex;
    align-items: center;
  }
  .actions-group .sheet-icon .icon {
    width: 1.2rem;
    height: 1.2rem;
    color: var(--color-muted);
  }
  .actions-group .label-long {
    display: inline;
  }
  .actions-group .label-short {
    display: none;
  }

  /* marker buttons: two equal-width filled tiles side by side, icon over label */
  .actions-group .mark {
    display: flex;
    flex-direction: row;
    gap: 0.5rem;
  }
  .actions-group .mark .btn {
    flex: 1 1 0;
    min-width: 0;
    flex-direction: column;
    justify-content: center;
    gap: 0.35rem;
    padding: 0.85rem 0.5rem;
    font-size: 0.95rem;
    border: none;
    border-radius: var(--radius-field);
    background: var(--color-base-200);
  }
  .actions-group .mark .btn:hover {
    background: var(--color-base-300);
  }
  .actions-group .mark .btn .icon {
    width: 1.4rem;
    height: 1.4rem;
  }

  /* a "Search" section label between the marker rows and the links */
  .actions-group .sheet-divider {
    display: block;
    padding: 0.5rem 0.6rem 0.2rem;
    font-size: var(--fs-xs);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--color-muted);
  }

  /* search links render inline inside the sheet, not as a floating popover, and
     match the marker rows: large, left-aligned, full-width, with a leading icon */
  .actions-group .links {
    display: flex;
    flex-direction: column;
    align-items: stretch;
    gap: 0.15rem;
  }
  .actions-group .links-toggle {
    display: none;
  }
  .actions-group .links-menu {
    display: block !important;
    position: static !important;
    inset: auto !important;
    width: 100%;
    min-width: 0;
    padding: 0;
    border: none;
    box-shadow: none;
    background: transparent;
  }
  .actions-group .links-menu a {
    display: flex;
    align-items: center;
    gap: 0.65rem;
    width: 100%;
    padding: 0.7rem 0.6rem;
    border-radius: var(--radius-field);
    font-size: 0.95rem;
  }
  .actions-group .links-menu a:hover {
    background: var(--color-base-200);
  }

  /* the confirm buttons grow to fill the dialog footer on narrow screens */
  .confirm-actions .btn {
    flex: 1;
    justify-content: center;
    padding: 0.6rem;
    font-size: 0.9rem;
  }

  /* the settings panel drops its centered overlay and becomes a bottom sheet,
     matching the action sheet: pinned full-width to the bottom and slid up.
     Shared chrome lives with .actions-group above; only the padding differs. */
  .settings-panel[popover] {
    padding: 0.6rem 0.75rem calc(0.85rem + env(safe-area-inset-bottom));
  }
  .settings-panel:popover-open {
    transform: translateY(0);
  }
}

/* mark: collapse the leaving row */
/* A folder marked in gaps-only leaves the list. app.js pins the row's height then
   drops it to zero next frame, and this rule transitions the collapse and fade.
   The marker form delays its section swap (hx-swap "swap:") so this finishes
   before the fresh section lands. Motion-sensitive users get instant removal, no
   slide. */
li.leaving {
  overflow: hidden;
  opacity: 0;
  margin-block: 0;
  transition: max-height 250ms ease, opacity 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
  li.leaving {
    transition: none;
  }
}

/* undo: expand the restored row */
/* The inverse of li.leaving. Undo brings a marked folder back into the gaps list;
   app.js measures the restored row's height, drops it to zero, then releases it
   next frame so the spine slides open over the same curve it left on. The inline
   max-height and opacity are cleared once the slide ends. Motion-sensitive users
   get the row instantly, no slide. */
li.entering {
  overflow: hidden;
  transition: max-height 250ms ease, opacity 180ms ease;
}
@media (prefers-reduced-motion: reduce) {
  li.entering {
    transition: none;
  }
}

/* rescan button */
/* The button keeps its "Rescan" label throughout. hx-disabled-elt disables it for
   the request so a second click cannot double-scan. The JS retry hold keeps it
   disabled across backoff gaps, so :disabled is the single hook for the busy
   look. */
#rescan-btn {
  /* trimmer than the default button padding so the navbar wordmark has room. */
  padding-inline: 0.6rem;
}
#rescan-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* rescan progress bar */
/* The bar is the hx-indicator: htmx adds htmx-request to it for the request's
   duration. Rather than replace the tree, we keep #roots on screen, dim it, and slide
   a slim bar above it, so a rescan reads as a refresh, not a collapse. The wrapper is
   the positioning context for the bar. */
.roots-wrap {
  position: relative;
}
/* The 0.2s transition-delay on the active rule is the show-delay: htmx removes
   htmx-request the instant the scan ends, so a fast scan reverts before the delayed
   fade-in starts and never flashes. The base rule has no delay, so the bar clears
   immediately on completion. */
.scan-bar {
  position: absolute;
  top: -0.55rem;
  /* Centered and capped at 52rem so the bar tracks the content column (the navbar
     and cards share this max-width) rather than stretching the full wrapper. */
  left: 50%;
  transform: translateX(-50%);
  width: 100%;
  height: 3px;
  border-radius: 2px;
  overflow: hidden;
  background: color-mix(in srgb, var(--color-primary) 14%, transparent);
  opacity: 0;
  transition: opacity var(--motion-fast) ease;
  pointer-events: none;
}
/* The sliding segment. The track stays put while this loops across it. */
.scan-bar::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -40%;
  width: 40%;
  border-radius: 2px;
  background: var(--color-primary);
}
.scan-bar.htmx-request {
  opacity: 1;
  transition: opacity var(--motion-fast) ease 0.2s;
}
.scan-bar:is(.htmx-request, .is-retrying)::before {
  animation: scan-indeterminate 1.05s ease-in-out infinite;
}
/* The tree dims in place behind the same show-delay, so dim and bar arrive together
   and a fast scan leaves the tree untouched. The base #roots rule restores instantly. */
#roots {
  transition: opacity var(--motion-fast) ease;
}
.roots-wrap:has(.scan-bar.htmx-request) #roots {
  opacity: 0.4;
  pointer-events: none;
  transition: opacity var(--motion-fast) ease 0.2s;
}
@keyframes scan-indeterminate {
  from {
    left: -40%;
  }
  to {
    left: 100%;
  }
}
@media (prefers-reduced-motion: reduce) {
  .scan-bar::before {
    animation: none !important;
    left: 0;
    width: 100%;
    opacity: 0.55;
  }
}

/* connection-status banner */
/* Pinned to the top, full-bleed past the body's 1.5rem padding, in normal flow so
   it pushes the app down while visible. Hidden via the `hidden` attribute until the
   connection JS reveals it and sets a state class. */
.conn-banner[hidden] {
  display: none;
}
.conn-banner {
  position: sticky;
  top: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  gap: 0.55rem;
  margin: -1.5rem -1.5rem 1.25rem;
  padding: 0.6rem 1rem;
  font-size: var(--fs-base);
  font-weight: 500;
  border-bottom: var(--border) solid transparent;
}
.conn-banner.is-offline {
  background: color-mix(in srgb, var(--color-muted) 14%, var(--color-base-100));
  color: var(--color-base-content);
  border-bottom-color: color-mix(in srgb, var(--color-muted) 30%, transparent);
}
.conn-banner.is-retrying {
  background: color-mix(in srgb, var(--color-warning) 16%, var(--color-base-100));
  color: var(--color-base-content);
  border-bottom-color: color-mix(in srgb, var(--color-warning) 35%, transparent);
}
.conn-banner.is-failed {
  background: color-mix(in srgb, var(--color-error) 14%, var(--color-base-100));
  color: var(--color-error-text);
  border-bottom-color: color-mix(in srgb, var(--color-error) 35%, transparent);
}
.conn-banner.is-reconnected {
  background: color-mix(in srgb, var(--color-success) 15%, var(--color-base-100));
  color: var(--color-base-content);
  border-bottom-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
/* The spinner shows only while retrying. */
.conn-banner-spinner {
  display: none;
}
.conn-banner.is-retrying .conn-banner-spinner {
  display: inline-block;
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--color-warning) 35%, transparent);
  border-top-color: var(--color-warning);
  animation: conn-spin 0.9s linear infinite;
}
@keyframes conn-spin {
  to {
    transform: rotate(360deg);
  }
}
@media (prefers-reduced-motion: reduce) {
  .conn-banner.is-retrying .conn-banner-spinner {
    animation: none;
  }
}

/* no-JS notice */
/* The UI needs JavaScript. <noscript> renders this strip only when scripting is
   off, so it never shows on a normal visit. Edge-to-edge like the connection
   banner, in the warning palette. */
.noscript-notice {
  margin: -1.5rem -1.5rem 1.25rem;
  padding: 0.75rem 1rem;
  text-align: center;
  font-size: 0.9rem;
  font-weight: 500;
  background: color-mix(in srgb, var(--color-warning) 16%, var(--color-base-100));
  color: var(--color-base-content);
  border-bottom: var(--border) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
}

/* retry-held states */
/* While a rescan auto-retry runs across backoff gaps, the connection JS holds the
   bar with .is-retrying, independent of htmx's per-attempt htmx-request
   toggling, so it does not flicker between tries. The button stays disabled across
   the gaps (JS sets btn.disabled), so its dim is carried by the :disabled rule above. */
.scan-bar.is-retrying {
  opacity: 1;
  transition: none;
}
.roots-wrap:has(.scan-bar.is-retrying) #roots {
  opacity: 0.4;
  pointer-events: none;
}
/* After a rescan fails for good, the Rescan button itself is the retry: a focus
   ring draws the eye to it. */
#rescan-btn.conn-retry-hl {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 28%, transparent);
}

/* mark failure: the row springs back with an inline error and a retry */
/* The strip attaches to the folder's own row (it is inserted as the row's next
   sibling), so it reads as part of that folder. The row above it gets a faint error
   wash and the strip a matching left rule, tying the two together as one unit. */
.row:has(+ .mark-failed) {
  background: color-mix(in srgb, var(--color-error) 7%, transparent);
}
.mark-failed {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  margin: 0.1rem 0 0.4rem;
  padding: 0.32rem 0.6rem;
  border-radius: var(--radius-field);
  border-left: 2px solid color-mix(in srgb, var(--color-error) 50%, transparent);
  background: color-mix(in srgb, var(--color-error) 9%, var(--color-base-100));
  font-size: 0.78rem;
  color: var(--color-error-text);
}
.mark-failed-icon {
  flex: none;
  width: 0.9rem;
  height: 0.9rem;
  display: inline-flex;
}
.mark-failed-msg {
  font-weight: 500;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.mark-retry {
  flex: none;
  margin-left: auto;
  border-color: color-mix(in srgb, var(--color-error) 45%, transparent);
  color: var(--color-error-text);
}

/* saving: a gaps-only row waits in place until the write is confirmed */
/* The row stays visible while its mark is in flight, so a folder never looks handled
   before the write lands. It collapses (li.leaving) only on a confirmed save, when
   the section swap reconciles. */
.row.is-saving > :not(.row-saving) {
  opacity: 0.5;
}
.row.is-saving .actions-group,
.row.is-saving .actions-trigger {
  display: none;
}
.row-saving {
  flex: none;
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.78rem;
  font-weight: 500;
  color: var(--color-base-content);
}
.row-saving-spinner {
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 50%;
  border: 2px solid color-mix(in srgb, var(--color-muted) 35%, transparent);
  border-top-color: var(--color-base-content);
  animation: conn-spin 0.9s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
  .row-saving-spinner {
    animation: none;
  }
}

/* action toast stack: undo offers and write errors, up to three at once, fixed
   at the bottom with the newest closest to the edge and older ones stacked above */
.toast-stack {
  position: fixed;
  left: 50%;
  bottom: 1.5rem;
  transform: translateX(-50%);
  z-index: 60;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.6rem;
  /* the container spans only its widest toast, so let clicks fall through the gaps
     to the page. pointer events are re-enabled on the toasts themselves. */
  pointer-events: none;
}
.toast {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  /* a fixed width so every toast is the same size. a short message keeps the
     full box rather than shrinking and looking magnified. a faint success-tinted
     border ties the card to the confirmed mark without a heavy fill. */
  width: min(92vw, 26rem);
  padding: 0.7rem 0.8rem;
  background: var(--color-base-100);
  color: var(--color-base-content);
  border: var(--border) solid color-mix(in srgb, var(--color-success) 35%, var(--color-border));
  border-radius: var(--radius-field);
  box-shadow: 0 10px 30px rgb(0 0 0 / 24%);
  animation: toast-in 380ms ease both;
}
/* status badge: a filled success-tinted circle carrying the check glyph, so
   the read lands instantly. every toast is a confirmed mark's undo offer;
   write failures render inline by the row (src/web/page.rs), so there is no
   variant. */
.toast .toast-icon {
  display: inline-flex;
  flex: none;
  width: 1.7rem;
  height: 1.7rem;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-pill);
  color: var(--color-success-text);
  background: color-mix(in srgb, var(--color-success) 16%, var(--color-base-100));
}
/* the global `.icon` rule paints the svg muted, so reset it so the glyph takes the
   badge's variant color (the confirm dialog resets the same way) */
.toast .toast-icon .icon {
  width: 1rem;
  height: 1rem;
  color: currentColor;
}
/* the message column: a primary line plus a quieter detail line */
.toast .toast-msg {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  font-size: var(--fs-base);
  line-height: 1.45;
}
/* folder name: the primary line in the app's UI font at semibold so it reads as
   a heading, truncated so a long title never wraps the card */
.toast .toast-name {
  font-weight: 600;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* the quieter outcome line under the name */
.toast .toast-detail {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  font-size: 0.72rem;
  color: var(--color-muted);
}
/* marker label: a success-tinted pill, matching the .root-badge-clean token pattern */
.toast .toast-kind {
  font-size: 0.68rem;
  font-weight: 700;
  white-space: nowrap;
  border-radius: var(--radius-pill);
  padding: 0.05rem 0.4rem;
  background: color-mix(in srgb, var(--color-success) 18%, var(--color-base-100));
  color: var(--color-success-text);
}
/* undo: an accent text action, lighter than an outlined button */
.toast .toast-undo {
  flex: none;
  align-self: center;
  background: none;
  border: none;
  padding: 0.1rem 0.25rem;
  font-size: var(--fs-sm);
  font-weight: 700;
  color: var(--color-primary);
  cursor: pointer;
}
.toast .toast-undo:hover {
  text-decoration: underline;
}
.toast .toast-close {
  flex: none;
  align-self: center;
  background: none;
  border: none;
  cursor: pointer;
  color: var(--color-muted);
  font-size: 1.2rem;
  line-height: 1;
  padding: 0 0.2rem;
}
.toast .toast-close:hover {
  color: var(--color-base-content);
}
/* slide-up and fade on arrival, reverse on the way out (the leaving class is set
   by app.js just before it removes the node) */
@keyframes toast-in {
  from {
    opacity: 0;
    transform: translateY(1.4rem) scale(0.98);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}
@keyframes toast-out {
  to {
    opacity: 0;
    transform: translateY(0.5rem) scale(0.98);
  }
}
.toast.toast--out {
  animation: toast-out 480ms ease-in both;
}
/* once a toast has finished arriving, app.js adds this to drop the entry
   animation, whose filled end-state would otherwise pin the transform and block
   the FLIP slide it runs when another toast pushes in */
.toast--settled {
  animation: none;
}
@media (prefers-reduced-motion: reduce) {
  .toast,
  .toast.toast--out {
    animation: none;
  }
}
@media (max-width: 650px) {
  .toast-stack {
    left: 0.75rem;
    right: 0.75rem;
    bottom: 0.75rem;
    transform: none;
    align-items: stretch;
  }
  .toast {
    /* let the stretched stack set the width between its left and right insets */
    width: auto;
  }
}

/* gap summary strip */
/* Rendered between the navbar and the roots, computed server-side and kept current
   by app.js as marks land. The session bar is a client-side meter of this sitting,
   not a stored statistic. */
.gap-summary {
  margin: 0 auto 1.25rem;
  padding: 0.9rem 1.1rem;
  background: var(--color-base-100);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-box);
}
.gap-summary-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
}
.gap-hero {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
}
.gap-hero-num {
  font-size: 1.6rem;
  font-weight: 800;
  letter-spacing: -0.02em;
  color: var(--color-warning-text);
}
.gap-hero-label {
  font-size: 0.95rem;
  font-weight: 600;
}
/* library coverage: a label, the readout, and the bar grouped beside the hero */
.gap-coverage {
  flex: 1 1 16rem;
  min-width: 0;
}
/* On mobile .gap-summary-head turns flex-direction: column, so the 16rem above
   becomes a height floor, stretching the ~40px coverage block to 256px and opening
   an empty band above the chips. Reset it to its natural height. Placed after the
   base rule, not in the main mobile @media block: the section is defined past that
   block, so a same-specificity override there loses the cascade. */
@media (max-width: 650px) {
  .gap-coverage {
    flex: 0 1 auto;
  }
}
.gap-coverage-readout {
  margin: 0 0 0.4rem;
  font-size: 0.9rem;
  color: var(--color-muted);
}
.coverage-num {
  font-weight: 700;
  color: var(--color-base-content);
}
/* The trailing "· 100% covered (T of T audiobooks)" inside the all-clear line.
   Same dimmed muted color as the surrounding text; no other reshape needed. */
.coverage-clear {
  color: var(--color-muted);
  font-weight: 500;
}
.gap-summary-clear {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin: 0;
  font-weight: 600;
  color: var(--color-success-text);
}
.gap-summary-clear .icon {
  width: 1.15rem;
  height: 1.15rem;
}
/* the display:flex above outranks the user-agent `[hidden] { display: none }`, so
   restore it: app.js toggles whichever end-state the live total calls for. */
.gap-summary-head[hidden],
.gap-summary-clear[hidden] {
  display: none;
}

/* per-root chips */
.gap-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.75rem;
}
.gap-chip {
  display: inline-flex;
  align-items: baseline;
  gap: 0.35rem;
  padding: 0.2rem 0.6rem;
  background: var(--color-base-200);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-pill);
  font-size: var(--fs-sm);
}
.gap-chip-name {
  font-weight: 600;
}
.gap-chip-num {
  font-weight: 700;
  color: var(--color-warning-text);
}
.gap-chip-clean .gap-chip-num {
  color: var(--color-success-text);
}
.gap-chip-error .gap-chip-num {
  color: var(--color-error-text);
  font-weight: 600;
}

/* library coverage bar */
.gap-bar {
  margin-top: 0;
  height: 0.5rem;
  background: var(--color-base-200);
  border-radius: var(--radius-pill);
  overflow: hidden;
}
.gap-bar-fill {
  display: block;
  width: 0;
  height: 100%;
  background: var(--color-success);
  border-radius: inherit;
  transition: width 320ms ease;
}
@media (prefers-reduced-motion: reduce) {
  .gap-bar-fill {
    transition: none;
  }
}

/* navbar filter */
/* The box renders visible from first paint so it holds its navbar slot and never
   reflows in. app.js renders the input disabled and clears that on
   DOMContentLoaded. until then the whole control dims so it reads as
   greyed-but-present, not a dead box to type into. It snaps to live on enable, no
   transition. */
.search:has(.search-input:disabled) {
  opacity: 0.55;
}
.search-input:disabled {
  cursor: not-allowed;
}
.search {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.25rem 0.6rem;
  background: var(--color-base-200);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-field);
}
/* The input clears its own outline, so ring the whole pill on focus-within,
   the same pattern as the navbar title link and the switches. */
.search .icon {
  width: 1rem;
  height: 1rem;
  flex: none;
  color: var(--color-muted);
}
.search-input {
  /* an explicit width, since a search input's intrinsic size (~20 chars) is wider
     than the navbar wants. WARN: min-width 0 does not make the box give way on a
     cramped row. the definite width floors the flex container's min-content size,
     so the navbar wraps the box to its own row instead (see the 800px block). */
  width: 8rem;
  min-width: 0;
  font: inherit;
  font-size: var(--fs-base);
  color: var(--color-base-content);
  background: transparent;
  border: none;
  outline: none;
}

/* hide the browser's native search clear control, we draw our own themed × so it
   is consistent across browsers (Firefox renders none at all). */
.search-input::-webkit-search-cancel-button {
  -webkit-appearance: none;
  appearance: none;
  display: none;
}
/* the themed clear button: a borderless ghost in the muted color that darkens to
   the base content on hover, sized to match the leading magnifier. */
.search-clear {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 1rem;
  height: 1rem;
  padding: 0;
  color: var(--color-muted);
  background: none;
  border: none;
  cursor: pointer;
}
.search-clear svg {
  width: 100%;
  height: 100%;
}
.search-clear:hover {
  color: var(--color-base-content);
}
/* hidden while the box is empty, but kept in the flex layout (just invisible) so
   its slot is always reserved and showing the × never widens the field. */
.search-clear[hidden] {
  visibility: hidden;
}

/* a branch hidden by the filter collapses, ancestors of a match stay shown */
.filter-hidden {
  display: none;
}

/* the polite "no matches" line, hidden until a query matches nothing */
.search-empty[hidden] {
  display: none;
}
.search-empty {
  margin: 0 auto 1rem;
  padding: 0.85rem 1.1rem;
  color: var(--color-muted);
  background: var(--color-base-100);
  border: var(--border) solid var(--color-border);
  border-radius: var(--radius-box);
}

/* keyboard highlight */
/* The j/k highlight is a real focus target (tabindex -1), so it carries a visible
   focus ring and a tinted band. */
.row.row-active {
  outline: 2px solid var(--color-primary);
  outline-offset: -2px;
  border-radius: var(--radius-field);
  background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}

/* intro card */
/* The orientation card above the coverage strip, sharing its centered 52rem
   column. A primary border over a faint primary wash, lifted by a tinted
   shadow, so it reads apart from the coverage strip's neutral surface below.
   A dismiss button is pinned top-right. The body names the row controls so a
   cold reader recognizes them. Hidden before first paint by the
   [data-intro="dismissed"] rule when the per-device flag is set. */
.intro-card {
  position: relative;
  margin: 0 auto 1rem;
  padding: 1rem 1.25rem;
  background: color-mix(in srgb, var(--color-primary) 7%, var(--color-base-100));
  border: 2px solid var(--color-primary);
  border-radius: var(--radius-box);
  box-shadow: 0 4px 16px color-mix(in srgb, var(--color-primary) 14%, transparent);
}
/* A dismissed card is hidden before first paint by the pre-paint bootstrap, which
   sets data-intro on <html>; app.js sets and clears the same attribute when the
   visitor dismisses or re-shows the card. */
[data-intro="dismissed"] .intro-card {
  display: none;
}
.intro-title {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  margin: 0 2rem 0.35rem 0;
  font-size: 1rem;
  font-weight: 700;
}
/* The help-circle glyph mirrors the navbar help button that re-shows this card.
   Recolor the muted .icon default to primary so it ties to the frame. */
.intro-title .icon {
  color: var(--color-primary);
}
.intro-body {
  margin: 0;
  color: var(--color-muted);
  font-size: 0.9rem;
  line-height: 1.45;
}
/* Inline marks for the literal row-control labels named in the body, so the
   text maps onto the buttons in the tree. nowrap keeps a two-word label whole. */
.intro-mark {
  font-weight: 600;
  padding: 0.05rem 0.35rem;
  border-radius: var(--radius-field);
  background: var(--color-base-200);
  color: var(--color-base-content);
  white-space: nowrap;
}
.intro-dismiss {
  position: absolute;
  top: 0.5rem;
  right: 0.5rem;
  width: 1.75rem;
  height: 1.75rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 1.1rem;
  line-height: 1;
  border: none;
  border-radius: var(--radius-field);
  background: transparent;
  color: var(--color-muted);
  cursor: pointer;
}
.intro-dismiss:hover {
  background: var(--color-base-200);
  color: var(--color-base-content);
}
/* Shared focus ring for controls that only need the outline pair. */
.switch input:focus-visible + .switch-track,
.search:focus-within,
.intro-dismiss:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* footer */
/* The page footer, in every deployment. Matches the centered 52rem column the
   navbar and summary use, sits muted below the content, and reads as one
   centered brand link to the source. */
.site-footer {
  margin: 2rem auto 0.5rem;
  padding: 1rem;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  flex-wrap: wrap;
  border-top: var(--border) solid var(--color-border);
  color: var(--color-muted);
  font-size: var(--fs-sm);
}
.footer-brand {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--color-muted);
  text-decoration: none;
}
.footer-brand .brand-mark {
  width: 1.1rem;
  height: 1.1rem;
}
.footer-name {
  font-weight: 600;
  color: var(--color-base-content);
}
.footer-brand:hover {
  color: var(--color-base-content);
  text-decoration: underline;
}
.footer-brand:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
  border-radius: var(--radius-field);
}
