/* ==========================================================================
   YeeTlist
   Every number below comes from a published scale. Nothing is typed at the
   point of use, so a step change moves every control that reads it.

   SPACE sits on a 4px grid, with multiples of 2 below 8px and 1 for a hairline.
   TYPE  sits on multiples of 2 below 24px and multiples of 4 from 24 up.
   ========================================================================== */

:root {
  /* ---- ground and surfaces -------------------------------------------- */
  --bg: #0f0f0f;
  --surface: #181818;
  --surface-raised: #202020;
  --surface-sunken: #111;

  /* ROW PLANES. The order is hover < selection, and the distance between them
     is what a reader uses to tell a pointer from a choice.

     MEASURED AGAINST THE ROW AT REST, NEVER AGAINST THE PAGE. A row paints
     --surface itself, so --surface is what a hover REPLACES. The old comment
     compared both planes to --bg, which is the ground behind the table and
     not the colour that changes. Against --bg the hover read 1.10 and looked
     solved. Against the row it read 1.02, and a reader saw nothing at all.

     Both planes moved, because nothing between the old rest and the old
     selection clears the bar. Hover 1.09 against the row, selection 1.237,
     and 1.135 between the two. The accent bar still carries the selection,
     and it reads 3.91 on the new fill against a 3.0 bar for a graphic.

     THE DIVIDER IS ABSENT ON BOTH OF THESE PLANES, AND THAT IS NOT A COST. A
     td draws --line-subtle #282828, so the hairline reads 1.105 on the hover
     and 1.027 on the selection. What separates two rows is whichever of the
     hairline and the fill step a reader can see, and the fill step takes over
     exactly where the hairline stops: 1.090 under a hovered row and 1.237
     under a selected one.

     Two adjacent SELECTED rows are the one pair with neither, at 1.027 and
     1.000. They meet with no seam on purpose, because they are one selection.

     Measured over all seven adjacent pairs before this was left alone. */
  --row-hover: #202020;
  --row-selected: #2a2a2a;

  /* A SELECTED ROW STILL ANSWERS THE POINTER. Without this it went dead under
     the cursor: both rules weighed (0,1,2), and the selection rule came later
     in the file, so it won every time.

     Their choice, from four rendered on real rows. One more neutral step,
     1.120 against the selected plane, plus the wider bar from a louder
     candidate. The step alone is the same MECHANISM as the plain hover, which
     is what keeps the four planes one ladder a reader can order.

     THIS COSTS NOTHING, AND AN EARLIER VERSION OF THIS COMMENT SAID IT DID.
     It named --line #303030 at 1.029. Nothing draws a row divider with that.
     A td draws --line-subtle #282828, which reads 1.150 here against 1.027 on
     the plane below it. So the new plane GIVES BACK some of a hairline that
     was already absent, rather than spending one. */
  --row-selected-hover: #323232;

  /* A DROPDOWN OPTION SITS ON --surface-raised, WHICH IS A DIFFERENT GROUND.
     One pair cannot serve both: the old --row-hover is DARKER than a panel,
     so it painted a dark band where the table paints a light one. One role,
     two grounds, two directions. Measured on the panel: hover 1.135 and
     selected 1.309, with 1.153 between them. */
  --option-hover: #2a2a2a;
  --option-selected: #343434;

  /* ---- lines ----------------------------------------------------------- */
  /* --line divides structure. --line-subtle divides content of one kind.
     Neither may approach its ground: below about 1.2:1 a hairline is absent. */
  --line: #303030;
  --line-subtle: #282828;

  /* A LINE IS CHOSEN AGAINST THE GROUND IT SITS ON, and a dropdown panel is
     one step up from a card. --line reads 1.345 on --surface and only 1.235
     on --surface-raised, which is the floor. #363636 reads 1.348 there, so a
     rule on a panel carries the same weight as a rule on a card. */
  --line-raised: #363636;

  /* ---- text ------------------------------------------------------------ */
  --text: #f1f1f1;
  --text-muted: #a8a8a8;
  --text-quiet: #8f8f8f;

  /* ---- accent and status ------------------------------------------------ */
  --accent: #ff3030;
  --accent-ink: #ffffff;

  /* A FILL THAT CARRIES WORDS ANSWERS A DIFFERENT BAR FROM A MARK. --accent
     paints a brand square, a tick, a selection bar and a progress fill, and
     every one of those is a graphic at 3:1. Two rules paint it under WORDS,
     and those answer 4.5. One role cannot satisfy both, so it splits.

     Measured on screen before the split: --accent-ink on --accent is 3.67 on
     three buttons, and the old #ff4444 hover was 3.41. The mark keeps
     #ff3030 everywhere, so nothing that is not carrying words moved.

     #ff3030 darkened to 88%. White reads 4.63 on the rest state and 5.65 on
     the hover, and the two sit 1.22 apart, which clears the 1.2 a shape
     needs. The hover DARKENS: every lighter red fails the bar, because white
     is already the lightest ink there is. */
  --accent-fill: #e02a2a;
  --accent-fill-hover: #c72525;
  --danger: #ff3030;
  --danger-quiet: #ff7474;

  /* SAVE IS ITS OWN COLOUR, not the accent. It sits where the delete mark
     sits and must not read as either that or the brand. A MARK answers the
     3:1 graphic bar rather than the 4.5 for words. Measured on the row's own
     surface: 6.05, and 4.89 on the selected plane. */
  --save: #ff6600;

  /* The same split, for the same reason, kept separate so a change to the
     accent cannot silently repaint a delete button. */
  --danger-fill: #e02a2a;
  --danger-fill-hover: #c72525;
  --danger-line: #784040;

  /* --danger at 32% over --bg, written as the mixed value so a reader sees
     one colour rather than an alpha to resolve. A wash carries its own
     darkness toward whatever words stand on it, so every pair was measured
     ON IT rather than on the page: --text 11.49, --text-muted 5.46 and the
     --danger mark 3.53, against bars of 4.5 and 3. The wash itself reads
     1.48 against --bg and 1.37 against the --surface card above it. */
  --danger-wash: #5c1a1a;
  --ok: #35be70;
  --warn: #e8a33d;

  --tag-bg: #303030;
  --tag-ink: #dddddd;
  --tag-active-bg: #eeeeee;
  --tag-active-ink: #111111;

  /* ---- space, on the 4px grid ------------------------------------------ */
  --space-2xs: 2px;
  --space-xs: 4px;
  --space-sm: 8px;
  --space-md: 12px;
  --space-lg: 16px;
  --space-xl: 24px;
  --space-2xl: 32px;
  --space-3xl: 48px;

  /* ---- type ------------------------------------------------------------ */
  /* Onest covers Latin and Cyrillic only. Every other script falls through to
     the system stack behind it, so a Bengali or Japanese title still paints. */
  --font-body: Onest, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: "Geist Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  --text-2xs: 10px;
  --text-xs: 12px;
  --text-sm: 14px;
  --text-md: 16px;
  --text-lg: 20px;
  --text-xl: 24px;
  --leading-tight: 1.2;
  --leading-body: 1.5;

  /* The body face's cap height, as a share of its size. Measured from painted
     ink at 400px, where whole-pixel rounding cannot swamp it. Onest is
     0.71875 and Inter was 0.73438, so this moves when the face does. */
  --cap-ratio: 0.71875;
  --track-tight: -0.4px;
  --track-label: 1px;

  /* ---- controls -------------------------------------------------------- */
  --control-sm: 28px;
  --control-md: 36px;
  --control-lg: 44px;

  /* One mark size at every control size. The alignment rule centres it at
     whatever size it is, so nothing is left for a size step to track. */
  --mark: 14px;
  --icon-gap: 8px;
  --icon-stroke: 1.75;

  --radius-sm: 4px;
  --radius-md: 6px;
  --radius-lg: 8px;
  --radius-pill: 999px;

  /* Two target minimums, because a finger and a mouse are different sizes.
     24px is WCAG 2.5.8 at AA. 44px is the touch floor. */
  --target-mouse: 24px;
  --target-touch: 44px;
  --control-floor: var(--target-mouse);

  /* How far a hit area reaches past the small step. Zero on a mouse, because
     28px already clears 24. A row that sits inside such a target subtracts
     this from its own gap, so the visible rhythm does not change with the
     pointer. */
  --tag-overhang: max(0px, calc(max(var(--target-mouse), var(--control-floor)) - var(--control-sm)));

  /* Weight of the bar that marks a selected row, and the inset it costs.
     Both ship, because a build that flushes the first column adds the width
     to its own number rather than taking the sum. */
  --select-bar: 4px;

  /* The bar widens under the pointer rather than changing colour. A brighter
     red was shown beside it and not taken. 6px is inside --select-inset at
     16px, so the label does not move and the gap to it goes 12 to 10. */
  --select-bar-hover: 6px;
  --select-inset: calc(var(--space-md) + var(--select-bar));

  /* ---- stacking -------------------------------------------------------- */
  /* A card lifts, a header sticks, a menu opens, a scrim covers. A dialog
     sits on the scrim and a toast clears everything. The step leaves room to
     slot one between two without renumbering. */
  --layer-raised: 100;
  --layer-sticky: 200;
  --layer-dropdown: 300;
  --layer-overlay: 500;
  --layer-modal: 600;
  --layer-toast: 800;

  /* ---- motion ---------------------------------------------------------- */
  /* ONE DURATION, WHICH IS THEIRS. 125ms for every fade in the interface. They
     tried 250 and came back: 125 is the number.
     Three tokens held 120, 200 and 250, so a hover, a panel and a mark each
     moved at their own speed and nothing in the app said why. A reader
     cannot name the axis that separates them.

     The three names are gone rather than aliased to one number. Three names
     for one value is how they drift apart again. */
  --duration: 125ms;
  /* IT EASES IN AS WELL AS OUT. The old curve started at almost full speed:
     9.6% of the distance in the first 10% of the run, and 44.8% by a quarter
     of it. Only the landing was soft. Measured on the replacement: 2.6% and
     23.7%, with the same 99.4% at nine tenths. So the start is soft, the
     finish is unchanged, and nothing runs longer. */
  --ease: cubic-bezier(.4, 0, .2, 1);
}

/* A coarse pointer raises the floor. Every control height reads
   max(its own step, --control-floor), so nothing has to remember this. */
@media (pointer: coarse) {
  :root { --control-floor: var(--target-touch) }
}

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

/* ==========================================================================
   Reset. :where() carries no weight, so nothing here outranks a component.
   ========================================================================== */

* { box-sizing: border-box }

/* ==========================================================================
   Everything the script shows and hides, fading

   `hidden` IS display:none, AND display CANNOT TWEEN. Every panel, menu and
   card in this app appears and disappears in one frame, however long the
   fade token says. The export menu is the one they saw. It was thirteen.

   `transition-behavior: allow-discrete` flips display at the far end of the
   run rather than at the start, so the box stays laid out while the opacity
   falls. `@starting-style` gives the entering element a value to come FROM,
   because an element that was display:none has no previous style.

   Measured against this file's own `[hidden] { display: none !important }`:
   entering read 0.22 at 50ms with one animation running, leaving read 0.80
   with display still block, and display reached none only at the end. An
   important declaration does not stop it.

   THIS IS A LIST, AND A LIST APPROVES WHATEVER NOBODY THOUGHT OF. It is the
   set of elements `app.js` writes `hidden` on, which is a question about the
   script rather than about the DOM, so no selector can ask it. Grep
   `.hidden =` in app.js before adding a panel: there are thirteen here and
   the grep must return the same set.

   NOT A GLOBAL RULE. Putting display in the transition every element gets
   would fade every label a media query drops and every cell the card view
   rearranges, on each breakpoint a drag crosses. That is work nobody asked
   for at a width nobody stops at. */
#listRuntime, #searchClear, #state, #tagSuggest, #tagBulkPanel,
#addStatusDismiss, #typedConfirm, #importPanel, #driveConnect,
#driveGroup, #driveAlert, #tagFilterList, #exportMenu {
  transition:
    opacity var(--duration) var(--ease),
    display var(--duration) allow-discrete;
}

#listRuntime[hidden], #searchClear[hidden], #state[hidden], #tagSuggest[hidden],
#tagBulkPanel[hidden], #addStatusDismiss[hidden], #typedConfirm[hidden],
#importPanel[hidden], #driveConnect[hidden], #driveGroup[hidden],
#driveAlert[hidden], #tagFilterList[hidden], #exportMenu[hidden] {
  opacity: 0;
}

@starting-style {
  #listRuntime:not([hidden]), #searchClear:not([hidden]), #state:not([hidden]),
  #tagSuggest:not([hidden]), #tagBulkPanel:not([hidden]),
  #addStatusDismiss:not([hidden]), #typedConfirm:not([hidden]),
  #importPanel:not([hidden]), #driveConnect:not([hidden]),
  #driveGroup:not([hidden]), #driveAlert:not([hidden]),
  #tagFilterList:not([hidden]), #exportMenu:not([hidden]) {
    opacity: 0;
  }
}

/* TWO MORE APPEAR AND DISAPPEAR WITHOUT `hidden`, and grepping for the
   attribute would have missed both. The dialog opens with showModal() and
   the toast is a node the script appends and removes.

   A DIALOG NEEDS `overlay` AS WELL. It sits in the top layer, and the
   browser takes it out of that layer the moment it closes. Without this the
   opacity has nothing to fall over and the exit is instant, however the
   display behaves. The backdrop is its own box and fades with it. */
dialog,
dialog::backdrop {
  transition:
    opacity var(--duration) var(--ease),
    display var(--duration) allow-discrete,
    overlay var(--duration) allow-discrete;
}

dialog:not([open]), dialog:not([open])::backdrop { opacity: 0 }

@starting-style {
  dialog[open], dialog[open]::backdrop { opacity: 0 }
}

/* THE VIEW TRANSITION READS THE SAME TOKEN. Its default is 250ms, which is
   a second duration nobody chose, in the one place the stylesheet does not
   obviously own. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: var(--duration);
  animation-timing-function: var(--ease);
}

/* THE CHOSEN PILL GLIDES ACROSS RATHER THAN SWAPPING FILLS. Their ask.

   A view transition pairs the old picture with the new one BY NAME, and the
   name sits on whichever tab is chosen. Before the switch that is one tab
   and after it the other, so the browser has two rectangles for one name and
   moves between them. Nothing measures a position and nothing writes one.

   THE NAME MUST BE UNIQUE WHILE THE TRANSITION RUNS, and exactly one tab
   carries aria-selected="true" at a time. A second element answering to this
   selector would abort the whole transition rather than glide.

   IT COSTS NO JAVASCRIPT AND NO EXTRA ELEMENT. The other answer is an
   absolutely placed bar under the tabs, whose left and width are measured
   and written on every switch, every resize and every font swap. That is
   three writers for one position. */
.tab[aria-selected="true"] { view-transition-name: tab-pill }

::view-transition-group(tab-pill) {
  animation-duration: var(--duration);
  animation-timing-function: var(--ease);
}

/* A TOAST IS A NEW NODE, so it has no previous style to come from and
   @starting-style is the whole entrance. Its exit is a `remove()`, and a
   removed node cannot transition, so the script marks it and waits. */
.toast { transition: opacity var(--duration) var(--ease) }
.toast[data-leaving] { opacity: 0 }

@starting-style {
  .toast { opacity: 0 }
}

/* EVERYTHING FADES, AT THAT ONE DURATION. Their instruction: one fade across
   the whole interface. Before this, 29 declarations carried a transition and
   everything else changed in one frame, so a hover was smooth and a state
   change beside it snapped.

   COLOUR AND OPACITY ONLY. A transition on a LENGTH animates the layout,
   which moves boxes under the reader's pointer and under every measurement
   this project takes. Nothing here changes where anything is.

   IT CARRIES NO WEIGHT, so a component stating its own transition still
   wins. The shorthand in those 29 rules replaces this outright rather than
   fighting it.

   The reduced-motion block below already answers `!important` on every
   element, so this needs no second guard. */
:where(*, *::before, *::after) {
  transition-property: color, background-color, border-color, outline-color,
    box-shadow, opacity, fill, stroke;
  transition-duration: var(--duration);
  transition-timing-function: var(--ease);
}

/* hidden means hidden. The UA rule for it is an ordinary (0,1,0), so any
   component class beats it: an inline sprite kept 300x150px of layout and
   pushed the page down 155px, and a [hidden] group carrying .sync computed
   display:flex and painted beside the control it replaces. No component may
   outvote this, so it is the one !important in the file. */
[hidden] { display: none !important }

:where(body) {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: var(--text-sm) / var(--leading-body) var(--font-body);
}

:where(h1, h2, h3, p, menu) { margin: 0 }

:where(button) { font: inherit }

/* One focus ring for everything. An outline costs no layout and survives
   forced colors, which is why it is the mark a keyboard reader relies on. */
:where(a, button, input, select, textarea, label, [tabindex]):focus-visible {
  outline: 2px solid var(--text);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

/* ==========================================================================
   Icons
   vector-effect is declared on the SHAPES as well as the svg. The property
   applies to drawn geometry and does not inherit, so a rule on the root
   element alone changes nothing.
   ========================================================================== */

.icon, .icon * { vector-effect: non-scaling-stroke }

.icon {
  width: var(--mark);
  height: var(--mark);
  flex: 0 0 auto;
  fill: none;
  stroke: currentColor;
  stroke-width: var(--icon-stroke);
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* A screen-reader label is a whole word inside a clipped 1x1 box on purpose. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* An icon beside a label lives between the cap height and the baseline.
   The percentage resolves against the icon's own height, so one rule holds
   at every mark size. The cap height comes from a token, because it is a
   measurement OF THE FACE: a typed 0.75 left the brand mark 1.5px off its
   wordmark the moment the body face changed. */
.icon-inline {
  vertical-align: baseline;
  align-self: baseline;
  transform: translateY(calc((100% - var(--cap-ratio) * 1em) / 2));
}

/* ==========================================================================
   Shell
   ========================================================================== */

/* --page-pad is the shell's own top padding, published so the sticky stack
   can hold the same distance from the viewport edge that the page holds at
   rest. A custom property inherits, and no selector can ask an ancestor for
   its padding. */
.shell {
  --page-pad: var(--space-xl);
  max-width: 1380px;
  margin: auto;
  padding: var(--page-pad) var(--space-2xl) var(--space-3xl);
}

/* ==========================================================================
   Header
   A mostly-text row, so it sits on one baseline. The status dot carries no
   text, so it takes the same cap-band transform every mark takes.
   ========================================================================== */

/* Two GROUPS, not two runs of text: a brand lockup and a cluster of controls.
   Centring the groups is what stops one group claiming the other's baseline.
   Each group is then internally correct on its own terms. */
/* A RULE BETWEEN SECTIONS SITS INSIDE THE GAP, HALF EACH SIDE. The shell is a
   block, so nothing spaced the header from the card below it: 24px above the
   rule and 0 below, with the card's own top border on the next pixel.

   The 24 below is also what clears the card's band. That band paints the 16px
   the stuck card holds above itself, and at rest it sat over this rule and
   erased it. Measured: elementFromPoint at the rule's own y returned the
   card. It now stands 8px clear of the band's top edge. */
/* BASELINE, NOT CENTRE. The row holds a 20px wordmark and a 12px control
   cluster. Centred, two type sizes give two baselines: measured 2.00px apart,
   with the wordmark low. On the line they share one. */
/* IT WRAPS RATHER THAN OVERFLOWING. At `nowrap` the row of controls ran 47px
   past the page once Drive was connected at 375, with no scroller and no
   error. Wrap costs nothing where it fits and is the only thing that gives
   where it does not. The row carries its own auto margin, so a wrapped line
   keeps the end it had beside the brand. */
header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-lg);
  border-bottom: 1px solid var(--line);
  padding-bottom: var(--space-xl);
  margin-bottom: var(--space-xl);
}

.brand {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  font-size: var(--text-lg);
  font-weight: 700;
  letter-spacing: var(--track-tight);
}

/* The build string. It rides the wordmark's baseline like every other run on
   this row, so it takes no alignment of its own. */
.brand-build {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: 400;
  letter-spacing: 0.02em;
  color: var(--text-quiet);
  white-space: nowrap;
  cursor: default;
}

/* THE SQUARE SITS BETWEEN THE WORDMARK'S CAP LINE AND ITS BASELINE, and it
   needs no transform to get there. Measured: 0.19px off the band's centre,
   which is under the whole pixel a repair could move.

   It used to take the row's box CENTRE, correct while the body face was Inter
   at a 0.5px residual. Onest has a shorter cap, so the same centring drifted
   to 1.19px and the square read low.

   The mechanism is the icon inside it. A flex container takes its baseline
   from its first item, so this square donates the 14px mark's bottom edge
   rather than its own. Put that on the text baseline and the 28px box lands
   on the cap band, because the mark is centred in it.

   THE CAP-BAND TRANSFORM IS WRONG HERE, and it was measured: it assumes the
   box's own bottom edge is the baseline, so it pushed the square a further
   7.00px down. `overflow: hidden` does not rescue it either, because that
   rule governs an inline-block's synthesised baseline and not a flex
   container's. */
.brand-mark {
  display: inline-flex;
  align-self: baseline;
  align-items: center;
  justify-content: center;
  width: var(--control-sm);
  height: var(--control-sm);
  border-radius: var(--radius-md);
  background: var(--accent);
  color: var(--accent-ink);
}

.brand-mark .icon { width: var(--mark); height: var(--mark) }

/* A cluster of controls plus one readout, so it centres. Every item in it is
   one stated height and one label size: two font sizes cannot share a
   baseline, and two heights cannot share a centre. */
.sync {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  color: var(--text-muted);
  font-size: var(--text-xs);
}

/* THE ROW OF GROUPS, WHICH IS NOT THE GROUP. `.sync` is the run-of-buttons
   step and this is the distance between three separate things: the readout,
   the Drive control and the two file controls. Given the same 8 they read as
   one run of five, measured at 1.0:1 against a 2:1 bar. 16 is exactly two.

   IT WRAPS, BECAUSE IT DOES NOT ALWAYS FIT. Connected, at 375 on a finger,
   the brand and this row came to 382px of content in 343 and ran 47px past
   the page with no scroller. The header held `nowrap`, so nothing gave. On
   its own line the row keeps the end it had beside the brand. */
.sync-row {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  margin-inline-start: auto;
  color: var(--text-muted);
  font-size: var(--text-xs);
}

/* The dot belongs to the words it qualifies, never to the buttons beside it.
   Grouped, it answers to its own label and the row holds one fewer item. */
/* The transparent border is not decoration. Every button in this row carries
   a 1px one, so its line box centres in a content box 2px shorter than the
   control. Without a matching border the readout's ink sat 1px higher than
   every label beside it: line-height answers to the CONTENT box. */
.sync-state {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  min-height: max(var(--control-sm), var(--control-floor));
  border: 1px solid transparent;
  flex: 0 0 auto;
}

.sync-dot {
  display: inline-block;
  width: var(--space-sm);
  height: var(--space-sm);
  border-radius: 50%;
  background: var(--ok);
}

.sync-dot[data-state="off"] { background: var(--text-quiet) }
.sync-dot[data-state="busy"] { background: var(--warn) }
.sync-dot[data-state="error"] { background: var(--danger) }

/* ==========================================================================
   Buttons
   One size fixes three things: the box, the label and the mark. The mark
   stands still at 14px, because the alignment rule centres it at any size.
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--icon-gap);
  height: max(var(--control-md), var(--control-floor));
  padding: 0 var(--space-lg);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  transition:
    background-color var(--duration) var(--ease),
    border-color var(--duration) var(--ease),
    color var(--duration) var(--ease);
}

.btn:hover { background: #2a2a2a }

/* One size fixes the box AND the label. The pairing is published, so two
   sizes can never share a label size and drift apart:
   sm 28/12, md 36/14, lg 44/16. The mark stands still at 14 in all three. */
.btn-sm {
  height: max(var(--control-sm), var(--control-floor));
  padding: 0 var(--space-md);
  font-size: var(--text-xs);
}

.btn-lg {
  height: max(var(--control-lg), var(--control-floor));
  padding: 0 var(--space-xl);
  font-size: var(--text-md);
}

.btn-primary {
  background: var(--accent-fill);
  color: var(--accent-ink);
  border-color: var(--accent-fill);
}
.btn-primary:hover { background: var(--accent-fill-hover); border-color: var(--accent-fill-hover) }

.btn-danger-outline {
  background: transparent;
  border-color: var(--danger-line);
  color: var(--danger-quiet);
}
.btn-danger-outline:hover { background: #2a1414 }

.btn-danger {
  background: var(--danger-fill);
  color: var(--accent-ink);
  border-color: var(--danger-fill);
}
.btn-danger:hover { background: var(--danger-fill-hover); border-color: var(--danger-fill-hover) }

.btn:disabled { opacity: .4; cursor: not-allowed }
.btn:disabled:hover { background: var(--surface-raised) }
.btn-danger-outline:disabled:hover { background: transparent }

/* An icon-only control is SQUARE at every size step. State no width at all:
   a stated width defeats the ratio, and a touch promotion then moves one
   axis and leaves the other. The height is the single writer. */
.btn-icon {
  aspect-ratio: 1;
  padding: 0;
}

/* A text button is still a pressable thing, so it clears the floor. It takes
   the small step, so a row holding it beside a .btn-sm is one height and one
   label size: two boxes of different heights cannot share a baseline. */
.btn-text {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  min-height: max(var(--control-sm), var(--control-floor));
  padding: 0 var(--space-xs);
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--tag-ink);
  font-size: var(--text-xs);
  text-decoration: underline;
  cursor: pointer;
  transition: color var(--duration) var(--ease);
}

.btn-text:hover { color: var(--text) }
.btn-text:disabled { opacity: .4; cursor: not-allowed }

/* ==========================================================================
   Fields
   ========================================================================== */

.input {
  height: max(var(--control-md), var(--control-floor));
  padding: 0 var(--space-md);
  border: 1px solid #444;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text);
  font: inherit;
  outline: 0;
  transition: border-color var(--duration) var(--ease);
}

.input::placeholder { color: var(--text-quiet) }
.input:focus { border-color: #777 }

/* ==========================================================================
   Select field

   A native select keeps the UA's own chrome, so .input alone gave it an
   authored background under a browser-drawn face. Beside the tag dropdown,
   which is a button styled by this file, the two read as different kinds of
   control. appearance:none hands the whole face over.

   A native select also ignores line-height on its value, so the text is
   centred by the BOX and never by the leading.

   NOT called .select, and the name cost a real fault. `.select` already
   means "this row is selected" on the row checkbox, and app.js reads that
   class to know which box changed. Taking the name put a 32px chevron inset
   on every checkbox: they measured 46x16 instead of 16x16. Grep the
   stylesheet for a class name before taking it.
   ========================================================================== */

.select-field {
  appearance: none;
  -webkit-appearance: none;
  height: max(var(--control-md), var(--control-floor));
  padding-block: 0;
  padding-inline: var(--space-md) var(--space-2xl);
  border: 1px solid #444;
  border-radius: var(--radius-sm);
  background-color: var(--surface-sunken);
  color: var(--text);
  font: inherit;
  font-size: var(--text-xs);
  cursor: pointer;
  outline: 0;
  transition: border-color var(--duration) var(--ease);

  /* The chevron matches #i-chevron-down. Its stroke is the one literal in
     this file that cannot read a token: a data URI has no access to a custom
     property. It is --text-muted, and the two move together by hand. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23a8a8a8' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right var(--space-md) center;
  background-size: var(--mark) var(--mark);
}

.select-field:hover, .select-field:focus { border-color: #777 }

/* The option list is drawn by the operating system, so it takes the page
   colours rather than this file's. Naming them is all CSS can do. */
.select-field option { background: var(--surface-raised); color: var(--text) }

/* Forced colors drops a background-image, which would take the chevron with
   it. Hand the whole control back to the UA there. */
@media (forced-colors: active) {
  .select-field {
    appearance: auto;
    -webkit-appearance: auto;
    background-image: none;
    padding-inline-end: var(--space-md);
  }
}

/* ==========================================================================
   Add card
   ========================================================================== */

.add-card {
  padding: var(--space-xl);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
}

.add-row {
  display: flex;
  gap: var(--space-sm);
}

.add-row .input { flex: 1; min-width: 0 }

.hint {
  margin-top: var(--space-md);
  color: var(--text-muted);
  font-size: var(--text-xs);
}

/* AN EMPTY BLOCK STILL CHARGES ITS MARGIN. The add card's status line holds
   nothing until something happens, and the 12px would sit under the button
   as a gap nobody asked for. */
.hint:empty { margin: 0 }

/* THE ROW CARRIES THE MESSAGE'S TYPE, or the cap band below is computed from
   the wrong size. The mark's lift is stated in em, and em resolves against
   the element carrying the transform. */
.status-line {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  margin-top: var(--space-md);
  font-size: var(--text-xs);
}

/* THE ROW OWNS THE DISTANCE, NOT ITS CHILD. Left on the message, the wrapper
   met the add row at 0.00px and the 12px was written one level too deep. */
.status-line .hint { flex: 1; min-width: 0; margin: 0 }

/* A row with nothing in it owns no distance. :has() takes the weight of its
   argument, so this beats the rule above whatever the order. */
.status-line:has(.hint:empty) { margin-top: 0 }

/* AN ICON-ONLY CONTROL IS SQUARE, AND IT IS DRAWN AT THE MARK SIZE. The touch
   floor is a REACH, not a size: written as a height it would put a 44px box
   on an 18px line. The ::after is out of flow, so the target costs no layout,
   and 100% resolves against the element, so one rule serves every size.

   IT BELONGS TO THE MESSAGE'S FIRST LINE. A flex box with no text takes its
   baseline from its bottom margin edge, so the box lands ON the baseline. The
   lift is what centres it between the cap line and that baseline, which is
   the same treatment every mark beside a label takes here. */
.status-x {
  flex: 0 0 auto;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: baseline;
  aspect-ratio: 1;
  height: var(--mark);
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transform: translateY(calc((100% - var(--cap-ratio) * 1em) / 2));
  transition: color var(--duration) var(--ease);
}

.status-x::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

.status-x:hover { color: var(--text) }

.hint a { color: var(--tag-ink) }

/* ==========================================================================
   Import progress

   A long job gets the SHAPE of what is coming, a count, and a way out. The
   panel sits where the adding happens, directly under the add card.
   ========================================================================== */

/* ==========================================================================
   Drive sync is broken

   THE LOUDEST THING ON THE PAGE, because it is the only state a reader must
   act on. It takes the same box as the panel above it, so the column keeps
   one edge, and it carries a red wash and a red edge instead of the neutral
   surface.

   THE EDGE IS --danger, NOT --danger-line. A line role is for dividing. This
   one is the alert's whole claim on the reader's attention.
   ========================================================================== */

.alert {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  margin-top: var(--space-md);
  padding: var(--space-lg) var(--space-xl);
  border: 1px solid var(--danger);
  border-radius: var(--radius-lg);
  background: var(--danger-wash);
}

/* THE MARK IS THE STATUS, so it takes the full role rather than the quiet
   one. It is also larger than a label's mark: it stands beside a 20px title
   rather than beside a 14px button label. */
.alert-mark {
  flex: 0 0 auto;
  width: var(--text-xl);
  height: var(--text-xl);
  color: var(--danger);
}

/* The words take the slack, so the button keeps its own width and the title
   wraps rather than pushing it off the line. */
.alert-words {
  flex: 1 1 auto;
  min-width: 0;
}

.alert-title {
  margin: 0;
  color: var(--text);
  font-size: var(--text-lg);
  font-weight: 700;
  letter-spacing: var(--track-tight);
  line-height: var(--leading-tight);
}

/* 8px under the title, against 16px to the row's own neighbours. The title
   and this sentence are one group. */
.alert-body {
  margin: var(--space-sm) 0 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: var(--leading-body);
}

.alert .btn { flex: 0 0 auto }

/* THE WORDS AND THE BUTTON STACK WHERE THE TITLE LOSES ITS LINE. Below that
   the row is two lines tall anyway, so stacking costs no height and hands the
   sentence the whole width.

   MEASURED BY SHRINKING THE REAL ROW, one pixel at a time, with the wrap
   rule off: the title holds one line at 694 and takes two at 693. Arithmetic
   gave 694 too, from the WRONG shell padding, and was 32px out. A sum works
   on a row with two things in it. This one holds four.

   The figure is true of these labels. It is 24 (mark) + 16 + 324.75 (title)
   + 16 + 198.39 (button) + 50 (the alert's padding and edges) + 32 (the
   shell's padding below 952). Recompute it if any label changes.

   The mark stays with the title, because a status mark floating above a
   paragraph reads as attached to the paragraph. The button then takes the
   whole line, which is what an orphan gets. */
@media (max-width: 693px) {
  .alert { flex-wrap: wrap }
  .alert-mark { align-self: flex-start; margin-top: var(--space-2xs) }
  .alert .btn { flex: 1 1 100% }
}

.import-panel {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
  margin-top: var(--space-md);
  padding: var(--space-lg);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
}

.import-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-lg);
}

.import-panel h3 { font-size: var(--text-sm) }

/* 6px sits on the space grid: multiples of 2 are legal below 8. */
.progress-track {
  height: 6px;
  overflow: hidden;
  border-radius: var(--radius-pill);
  background: var(--surface-sunken);
}

/* A value change reads at the hover step. The reduced-motion block at the top
   of this file cuts it to 1ms, so the bar jumps rather than crawls. */
.progress-fill {
  width: 0%;
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  transition: width var(--duration) var(--ease);
}

.import-panel .hint { margin-top: 0 }

/* The bar is the only mark in forced colors, where a background is dropped. */
@media (forced-colors: active) {
  .progress-track { border: 1px solid CanvasText }
  .progress-fill { background: Highlight; forced-color-adjust: none }
}

/* ==========================================================================
   Toasts

   They persist until dismissed. A count is the reason the toast exists, and
   an auto-dismissing one takes the number away before it has been read.
   ========================================================================== */

.toasts {
  position: fixed;
  z-index: var(--layer-toast);
  inset-block-end: var(--space-lg);
  inset-inline-end: var(--space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  width: min(380px, calc(100vw - var(--space-2xl)));
  pointer-events: none;
}

.toast {
  display: flex;
  align-items: flex-start;
  gap: var(--space-md);
  padding: var(--space-md) var(--space-md) var(--space-md) var(--space-lg);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface-raised);
  box-shadow: 0 8px 24px #000000a8;
  pointer-events: auto;
}

/* The mark carries the outcome, so it takes the meaning colour. A reader who
   cannot separate two hues still gets the wording beside it. */
.toast-mark { flex: 0 0 auto; margin-top: var(--space-2xs); color: var(--ok) }
.toast[data-kind="warn"] .toast-mark { color: var(--warn) }
.toast[data-kind="error"] .toast-mark { color: var(--danger) }

.toast-body { flex: 1; min-width: 0 }
.toast-title { font-weight: 650 }
.toast-detail { margin-top: var(--space-2xs); color: var(--text-muted); font-size: var(--text-xs) }

@media (prefers-reduced-motion: no-preference) {
  .toast { animation: toast-in var(--duration) var(--ease) }
}

@keyframes toast-in {
  from { opacity: 0; transform: translateY(var(--space-sm)) }
}

@media (max-width: 952px) {
  .toasts {
    inset-inline: var(--space-lg);
    width: auto;
  }
}

@media (forced-colors: active) {
  .toast { border: 1px solid CanvasText }
}

/* ==========================================================================
   List head
   A heading beside its actions. The row sits on one baseline, so the button
   labels land on the heading's line.
   ========================================================================== */

/* The heading and its action sit on one baseline. The overline that used to
   stack above the heading is gone, so nothing offsets the two sides. */
/* A COLUMN NOW, BECAUSE THE ACTION LEFT. Delete Selected sat here as the
   second item of a spread row. It moved into the filter row, so what remains
   is a title and its own readout. Two lines, one group. */
/* ==========================================================================
   The two lists

   A TAB STRIP NEVER WRAPS AND NEVER SCROLLS. Folded to two rows it reads as
   two controls rather than one line of destinations. Two tabs fit the
   narrowest width this ships, so the dropdown the rule prescribes for a
   strip that does not fit has no case here yet.

   THE RULE UNDER IT SPANS THE WHOLE ROW, and the chosen tab sits on top of
   it. That is what says the strip and the list below are one thing.
   ========================================================================== */

/* A SEGMENTED PICKER, NOT A RULED STRIP. It sits beside the count now, so an
   underline has no rule of its own to sit on and would draw a 2px mark against
   nothing. The group carries the edge and the chosen pill carries the fill. */
.tabs {
  display: flex;
  flex-wrap: nowrap;
  flex: 0 0 auto;
  gap: var(--space-2xs);
  padding: var(--space-2xs);
  border: 1px solid var(--line-subtle);
  border-radius: var(--radius-pill);
  background: var(--surface);
}

/* A TAB NEVER SHRINKS BELOW ITS OWN CONTENT. It wraps its children for the
   baseline packing below, so a squeezed one puts its count on a second line
   inside a box whose height is stated. Measured at 360 with the long label:
   each tab 22.8px under its content, and the count on a line of its own.
   Nothing clipped and nothing reported, because the strip itself still fit. */
/* THE FLOOR IS A REACH, NOT A HEIGHT. Written as max(step, floor) the pill
   grew to 44 on a finger, the group to 50, and the group then sat 8px below
   the title it is supposed to line up with. The box keeps its own step and
   an ::after carries the target, which costs no layout, so the group is 34
   at both pointers. */
.tab {
  position: relative;
  display: inline-flex;
  align-items: baseline;
  gap: var(--icon-gap);
  flex: 0 0 auto;
  min-width: 0;
  height: var(--control-sm);
  padding-inline: var(--space-md);
  border: 0;
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 650;
  cursor: pointer;
  transition:
    color var(--duration) var(--ease),
    background-color var(--duration) var(--ease);
}

/* A fixed-height box carrying words packs its single flex line to the top
   under baseline alignment, so the line is placed and the items within it
   are aligned. The two compose; one alone leaves the label high. */
.tabs { align-items: stretch }
.tab { flex-wrap: wrap; align-content: center }

/* 100% is the pill's own box, so one rule serves both labels. The inline
   inset resolves to 0 on a pill already wider than the floor, and only the
   block axis reaches: 8px above and below at a coarse pointer, inside the
   24px the group stands clear of the card above it. */
.tab::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

.tab .icon { align-self: baseline; transform: translateY(calc((100% - var(--cap-ratio) * 1em) / 2)) }

.tab:hover { color: var(--text); background: var(--row-hover) }

/* PAINT IS NOT A STATE, so the mark is aria-selected and the rule reads it.
   A FILL rather than a border, which adds no height and joins no box, so the
   two pills stay the same size whichever one is chosen. */
.tab[aria-selected="true"] {
  color: var(--text);
  background: var(--row-selected);
}

.tab[aria-selected="true"]:hover { background: var(--row-selected-hover) }

.tab-count {
  color: var(--text-quiet);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 400;
  font-size: var(--text-xs);
}

/* A FILL IS DISREGARDED IN FORCED COLORS, so the chosen pill loses its only
   marker there. An outline survives that mode and costs no layout. */
@media (forced-colors: active) {
  .tabs { border: 1px solid CanvasText }
  .tab[aria-selected="true"] { outline: 2px solid Highlight; outline-offset: -2px }
}

/* THE COUNT AND THE PICKER SHARE A ROW. The picker sat on its own line above
   this, which spent a whole row on two words.

   THE PICKER SITS ON THE TITLE'S LINE, NOT ON THE BLOCK'S CENTRE. The words
   are two lines on the watchlist, a title over a runtime readout, so centring
   put the pill group 10px below the title and it read as attached to the
   readout. Started, the 34px group centres at 231.00 against the title's own
   cap band centre of 231.00, and it needs no transform: the group is 2px
   shorter than the 36px line box it stands in.

   That only holds while the group IS 34. Promoted to the touch step it stood
   at 50 and sat 8px low at 693 and 534, which is why the tab's reach is an
   overhang rather than a height.

   It wraps rather than squeezing: below the width where both fit, the picker
   takes its own line.

   AND THAT LINE GOES ABOVE THE TITLE. Their instruction. `wrap-reverse`
   stacks the lines the other way, and it tracks the real break at every title
   length. A media query cannot: the break is a sum of the title's own ink, so
   a threshold measured on "34 of 100 Saved Videos" splits the row early on
   every shorter one. Measured at 375: the picker at y 279 and the title at
   321, where they were 279 and 343. The block is 98 tall either way, so the
   filter row below it does not move. The switch is at 568: one row at 569,
   two at 567.

   THE CROSS AXIS FLIPS WITH IT, so `flex-end` is what `flex-start` used to
   mean. Left alone, the picker sat 22px low on the one-row layout: the line
   is 56 tall and the group is 34. Measured on a 900px shell, before and
   after: the group at x 572.6, y 279, and the row 56 tall in both. */
.list-head {
  display: flex;
  flex-wrap: wrap-reverse;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-sm) var(--space-lg);
  /* 24 above, which is 150% of the 16 the tab strip used to stand at, and one
     step up the scale. 8 below, so the runtime line sits nearer the filter row
     than the strip above it. */
  margin: var(--space-xl) 0 var(--space-sm);
}

.list-words {
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  min-width: 0;
}

/* THE MONO FACE, WHICH IS THE ONLY OTHER FAMILY THE SYSTEM SHIPS. A figure
   standing alone normally keeps the body face, and this line is asked for in
   a different one on purpose: it is a readout under a title rather than a
   number inside a sentence.

   The gap is 2px against the 16px above the block, so the two lines read as
   one group rather than as a heading with a stray paragraph under it. */
.list-runtime {
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
}

.eyebrow {
  margin-bottom: var(--space-sm);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: var(--track-label);
}

.list-head h2 {
  font-size: var(--text-xl);
  letter-spacing: var(--track-tight);
}

/* ==========================================================================
   Filters
   Two groups on one row. The gap between them is twice the gap inside them,
   which is the proximity bar. 16 against 8.
   ========================================================================== */

/* NO TOP BORDER. It belonged to a band with space above it. Sticky closed
   that space up, so it met the add card's own bottom border at y 176: two 1px
   rules of one weight, touching, saying one boundary twice.

   THE BOTTOM BORDER STAYS, BECAUSE IT IS THE TABLE'S TOP EDGE. .table-wrap
   declares border-top: 0 and takes this one instead. That is what keeps the
   rule at the BAND'S foot: the table's own top scrolls away, and the band
   still ends in a line. Deleting it opened the table on three sides. */
.filters {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  padding: var(--space-md) 0;
  border-bottom: 1px solid var(--line);
}

/* THE PAIR IS A CONTAINER, BECAUSE FLEX CANNOT GIVE ONE GAP ITS OWN VALUE.
   8 inside against the row's 16 between is exactly the proximity bar. */
.filters-group {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  flex: 0 0 auto;
}

.filters-label {
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: var(--track-label);
  flex: 0 0 auto;
}

/* ==========================================================================
   Tag multiselect

   A run of destinations that does not fit is a list, and a list you pick from
   is a select. Ten pills wrapped to four rows on a phone, so the filter is a
   dropdown at every width rather than two controls for one decision.

   Keys: Enter or Space opens. ArrowDown and ArrowUp move the active option.
   Enter or Space toggles it. Escape closes and returns focus to the trigger.
   One tab stop, because a composite widget is one tab stop.
   ========================================================================== */

/* The tag control takes its own width. The search field is what expands, so
   this must not compete for the slack. */
/* SIZED TO ITS OWN WIDEST LABEL, not to a round number. The trigger's chrome
   is 70px: two 12px insets, two 14px marks, two 8px gaps and its borders. The
   longest thing it shows is a tag name, and a long one truncates, so the bar
   is the longest it should show WHOLE. Measured: "All" 13.8, "Untagged" 56.1,
   "2 Filters" 45.4, "#documentary" 82.8. 70 + 83 is 153, and 160 is the step
   above it. That returns 60px to the row. */
.multi {
  position: relative;
  flex: 0 0 auto;
  width: 160px;
  min-width: 0;
}

.multi-trigger {
  display: flex;
  align-items: center;
  gap: var(--icon-gap);
  width: 100%;
  height: max(var(--control-md), var(--control-floor));
  padding: 0 var(--space-md);
  border: 1px solid #444;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text);
  font-size: var(--text-xs);
  cursor: pointer;
  transition: border-color var(--duration) var(--ease);
}

.multi-trigger:hover { border-color: #777 }
.multi-trigger[aria-expanded="true"] { border-color: #777 }

.multi-value {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-align: start;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The chevron sits at the far end by justify-content, so it carries no side
   class, and it centres on its own box rather than on a label it has none of. */
.multi-chevron {
  color: var(--text-muted);
  transition: transform var(--duration) var(--ease);
}

.multi-trigger[aria-expanded="true"] .multi-chevron { transform: rotate(180deg) }

/* THE EXPORT MENU BORROWS THE PANEL AND NOT THE TRIGGER. Its trigger is a
   .btn, because it stands in a row of buttons and answers to that row's box.
   So the two things the trigger rule carried are restated here: the chevron
   turns, and the wrapper takes its content rather than the filter's 160px.

   IT OPENS FROM THE END. The filter sits at the start of its row and this
   sits at the end of the header, so a panel anchored to the start would run
   off the page. Measured at 375: 176px of panel from an x of 331. */
.export-menu { width: auto }
.export-menu .multi-panel { inset-inline-start: auto; inset-inline-end: 0 }
.export-menu .btn[aria-expanded="true"] .multi-chevron { transform: rotate(180deg) }

/* THE PANEL MAY OUTGROW ITS TRIGGER. The trigger is sized for the label it
   SHOWS, which truncates; the panel holds whole tag names and a long one
   wrapped to two lines once the trigger came down to 160. It starts at the
   trigger's own edge, never narrower than it, and stops before it can leave
   a narrow window. */
.multi-panel {
  position: absolute;
  z-index: var(--layer-dropdown);
  inset-inline-start: 0;
  inset-inline-end: auto;
  min-width: 100%;
  width: max-content;
  max-width: min(320px, calc(100vw - var(--space-2xl) * 2));
  top: calc(100% + var(--space-xs));
  max-height: 280px;
  margin: 0;
  padding: var(--space-xs);
  overflow-y: auto;
  list-style: none;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: 0 8px 24px #000000a8;
}

.multi-option {
  display: flex;
  align-items: center;
  gap: var(--icon-gap);
  min-height: max(var(--control-sm), var(--control-floor));
  padding: 0 var(--space-sm);
  border-radius: var(--radius-sm);
  color: var(--tag-ink);
  font-size: var(--text-xs);
  cursor: pointer;
}

.multi-option:hover { background: var(--option-hover) }
.multi-option.active { background: var(--option-selected); color: var(--text) }

/* The tick is the mark that says chosen. A fill alone would leave a reader
   who cannot separate two greys with nothing to read. */
.multi-option .multi-box {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--space-lg);
  height: var(--space-lg);
  flex: 0 0 auto;
  border: 1px solid #666;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
}

.multi-option[aria-selected="true"] .multi-box {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
}

.multi-option .multi-box .icon { width: var(--space-md); height: var(--space-md); opacity: 0 }
.multi-option[aria-selected="true"] .multi-box .icon { opacity: 1 }

/* Untagged is not one of the tags above it, so a rule says where the list
   ends. It sits INSIDE the gap, 4px each side, so the run of options keeps
   its own rhythm. The panel is a raised plane, so the line is the raised
   one: --line reads 1.235 here, against 1.348 for --line-raised. */
.multi-sep {
  height: 1px;
  margin: var(--space-xs) var(--space-sm);
  background: var(--line-raised);
}

.multi-count {
  margin-inline-start: auto;
  color: var(--text-quiet);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

@media (forced-colors: active) {
  .multi-option.active { outline: 2px solid Highlight; outline-offset: -2px }
  .multi-option[aria-selected="true"] .multi-box { outline: 2px solid Highlight }

  /* Forced colors drops a background, so the separator would have no marker
     at all. A border replaces it and the box gives up its height to pay for
     it, so nothing below moves: 0 + 1 is the same 1px. */
  .multi-sep { height: 0; border-top: 1px solid CanvasText }
}

/* The search field takes the whole remaining width of the row. Everything
   else in it states its own size, so this is the only member that grows. */
/* THE PADDING BELONGS TO THE LABEL, NOT THE BOX. On the box it sat outside
   the label, so a press on the field's own inset stopped focusing the input.
   On the label it is inside the focus target, and with the control hidden the
   label spans the whole field again. */
/* ONE ROW, AND THE FIELD IS WHAT GIVES. Every other control in it states its
   own width, so the search absorbs the whole shrink down to its floor. Below
   that the card view takes over, which is a rearrangement rather than another
   squeeze. The row never wraps on the way: a wrapped line put Clear Filters
   on its own row while 1100px of window was still there.

   THE FLOOR IS THE FIELD'S OWN PLACEHOLDER, which is 203.6px of ink in the
   shipped face. Everything else in the sum is a published token: the label's
   start inset, the mark and its gap, then the control with its two margins.
   The label gives up its end inset while the control is there. */
.filters { flex-wrap: nowrap }

.search {
  display: flex;
  align-items: center;
  flex: 1;
  min-width: calc(
    204px + var(--space-md) + var(--mark) + var(--icon-gap)
    + var(--mark) + var(--space-lg) + var(--space-md)
  );
  padding: 0;
  border: 1px solid #444;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text-muted);
  height: max(var(--control-md), var(--control-floor));
  transition: border-color var(--duration) var(--ease);
}

/* The mark and the field are the part a press should focus.

   IT REACHES OVER THE FIELD'S OWN BORDER. Held to the content box it measured
   42 against a 44 floor, so the two border rows focused nothing. The negative
   margin is what gives the stretch those 2px back, and it paints nothing, so
   the border still shows. */
.search-hit {
  display: flex;
  align-items: center;
  gap: var(--icon-gap);
  flex: 1;
  min-width: 0;
  align-self: stretch;
  margin-block: -1px;
  padding-inline: var(--space-md);
}

/* The end inset is the control's job while it is there. Kept on the label as
   well, it spent 12px of the field on a distance the margin already holds. */
.search:has(.search-clear:not([hidden])) .search-hit { padding-inline-end: 0 }

.search:focus-within { border-color: #777 }

/* THE GAP CLEARS THE CONTROL'S TARGET, NOT ITS BOX. The mark is drawn at 14px
   and reaches (44 - 14) / 2 = 15px back from it on a finger. At the row's own
   8px the last 7px of the label would have pressed CLEAR instead of placing
   the caret, and clearing the search is not what that press means. 16px stops
   the reach 1px short of the label.

   EACH CHILD STATES ITS OWN DISTANCE, so the row publishes none. A container
   gap and a margin ADD, which is a distance nobody chose. The end margin is
   the field's own inset, held here so the label can take the whole width
   whenever the control is hidden. */
.search-clear {
  flex: 0 0 auto;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin-inline: var(--space-lg) var(--space-md);
  aspect-ratio: 1;
  height: var(--mark);
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition: color var(--duration) var(--ease);
}

/* Out of flow, so the reach costs no layout, and 100% resolves against the
   element, so one rule serves every control size. */
.search-clear::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

.search-clear:hover { color: var(--text) }

.search input {
  width: 100%;
  min-width: 0;
  height: 100%;
  border: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
  outline: 0;
}

.search input::placeholder { color: var(--text-quiet) }

/* ==========================================================================
   Sort bar
   Only the card stack needs it: a table states its sort in its own headers.
   ========================================================================== */

.sort-bar { display: none }

.sort-bar label {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
}

.sort-field { flex: 1 }
.sort-field select { flex: 1; min-width: 0 }
.sort-bar select { cursor: pointer }

/* ==========================================================================
   The controls stay while the list scrolls

   THREE ELEMENTS, NOT THE BLOCKS AROUND THEM. Their crops bound the add ROW,
   the filter row and the sort row. The "Add a Video" label, the hint under it
   and the list heading scroll away, because none of those is a control and a
   sticky stack is paid for in list.

   Measured at 375x812: 96 + 250 + 44 = 390, which is 48% of the viewport and
   leaves 422px of list. The whole add card and the heading would have taken
   64%.

   EACH ONE'S top IS THE RUNNING SUM OF THE ONES ABOVE IT, and no selector can
   ask a previous sibling for its height. A ResizeObserver writes the two
   properties, so a wrapped filter row or a different width re-stacks with no
   typed number anywhere. The fallback of 0 puts them all at the top, which is
   the pre-sticky arrangement rather than a broken one.

   AN OPAQUE FILL IS NOT OPTIONAL. A sticky element is still in flow, so the
   list scrolls UNDER it. Transparent, the rows would read straight through
   the controls.
   ========================================================================== */

/* A STICKY ELEMENT CANNOT LEAVE ITS PARENT, which is why the add ROW alone
   does not work. Measured with position:sticky and top:0 on it: the card is
   223px tall at 375, so the row travelled 79px and then went with the card.
   At scrollY 442 its top read -255.

   So the CARD is the sticky element. Its label and its hint ride along,
   because nothing can pin a child past its own container's bottom edge. */
.add-card,
.filters,
.sort-bar {
  position: sticky;
  z-index: var(--layer-sticky);
}

/* A PANEL OPENS DOWNWARD, SO THE STACK PAINTS IN REVERSE DOM ORDER. A sticky
   element carrying a z-index makes a stacking context, so the filter menu's
   own --layer-dropdown is trapped inside .filters and cannot reach past it.
   At one z-index for all three, DOM order decides, and DOM order is exactly
   backwards here: the sort bar came later, so it won.

   Measured: the open tag menu ran from y 302 to 487 and the sort bar's select
   answered elementFromPoint at the middle option's own centre. The reader
   could see the option and not press it.

   These are offsets inside the sticky band, not new layers. Nothing here can
   reach 300, which is where a dropdown starts.

   THE CARD TOPS THE BAND, ABOVE THE STRIP, so the strip can fill the card's
   rounded corners from behind. The strip never overlaps the card's box, so
   nothing else moved when they swapped. */
.add-card { z-index: calc(var(--layer-sticky) + 3) }
.filters { z-index: calc(var(--layer-sticky) + 1) }

/* IT STOPS SHORT OF THE VIEWPORT EDGE, by the same distance the page holds
   at rest. Stuck at 0 the card sat flush against the browser chrome. */
.add-card { top: var(--page-pad) }

/* THAT GAP HAS TO BE PAINTED, or the list scrolls through it.

   A FIXED STRIP, NOT A BAND ON THE CARD. The card used to carry an absolutely
   positioned ::before sized to the gap. Two numbers had to agree, its height
   and the card's own `top`, and its box was measured from the card's PADDING
   box, so the 1px border counted twice. Both faults shipped.

   The reader still saw rows through the gap on a phone after it measured
   exact here: 224 stuck scroll positions, 40 x positions, 16 rows each, zero
   leaks. So the mechanism goes rather than the arithmetic.

   Fixed to the viewport, it reads one number, spans the FULL width rather
   than the card's, and knows nothing about any element's box. It covers the
   shell's own top padding at rest, where nothing paints.

   IT TAKES THE TAP TOO. At pointer-events: none a press in that 16px reached
   the row hidden behind it, so a delete button nobody can see was pressable.
   The sticky band above it absorbs a press for the same reason, and nothing
   interactive ever sits in the page's own top margin. */
/* IT REACHES ONE RADIUS PAST THE CARD'S TOP EDGE, AND PAINTS BEHIND IT.

   The strip used to stop exactly where the stuck card starts. The card's top
   corners are rounded, so each 8px arc showed whatever scrolled behind it.
   Measured while stuck: 3 leaking pixels at the top-left corner and 7 at the
   top-right, and elementFromPoint named a table cell.

   Adding the radius to the height cannot work above the card, because the
   strip would then paint over the card's own top 8px. So it sits one layer
   BELOW the card instead and fills the arcs from behind, in the page colour,
   which is what the corner should show. */
/* AND THE RADIUS ONLY WHILE THE CARD IS STUCK, because at rest that extra
   8px is not the page's margin. It is the header.

   The comment above says the strip covers the shell's own top padding at
   rest, where nothing paints. That stopped being true the moment the height
   gained the radius: the padding is 24 and the strip became 32, so it sat
   8px inside the content. Measured at rest: the header's five buttons start
   at y27, so 5px of every one was painted over, and elementFromPoint at
   y28 answered `shell` rather than the button. It took the top 5px of each
   press area as well.

   The arcs it fills belong to a STUCK card, so the radius belongs to the
   stuck state. Unstuck, nothing scrolls under the strip and 24px is the
   whole job. */
.shell::before {
  content: "";
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: var(--page-pad);
  z-index: calc(var(--layer-sticky) + 2);
  background: var(--bg);
}

.shell[data-stuck]::before { height: calc(var(--page-pad) + var(--radius-lg)) }

.filters { top: var(--stick-1, 0px) }
.sort-bar { top: var(--stick-2, 0px) }

/* AN OPAQUE FILL IS NOT OPTIONAL. A sticky element stays in flow, so the list
   scrolls UNDER it. The card already paints --surface. These two paint
   nothing, so the rows would read straight through them.

   THE TABLE VIEW'S FOOT IS THE FILTERS' OWN BOTTOM BORDER, above. The card
   view has no table borders at all, so the sort bar draws the band's foot. */
.filters,
.sort-bar { background: var(--bg) }

/* IT CLEARS BOTH NEIGHBOURS BY 12px. A shadow can only paint at the border
   box edge, where the first card starts, and its 8px corners would meet a
   straight line. At the content foot the rule read as an underline on the
   select above it. A background image can be placed anywhere: the percentage
   resolves against (box height - image height), so calc(100% - 12px) over a
   25px foot leaves 12 above and 12 below. The 1px between them is the rule. */
.sort-bar {
  background:
    linear-gradient(var(--line), var(--line)) 0 calc(100% - var(--space-md)) /
      100% 1px no-repeat,
    var(--bg);
}

/* ==========================================================================
   Table
   ========================================================================== */

.table-wrap {
  border: 1px solid var(--line);
  border-top: 0;
  min-height: 260px;
}

/* FIXED LAYOUT, SO THE TABLE IS EXACTLY ITS CONTAINER AT EVERY WIDTH.

   Under the automatic algorithm the columns are sized by their content, so
   the caps and the nowrap runs added up to a CONSTANT: 1314px. The shell is
   1380 wide with 32px of padding, so the container is min(1316, viewport-64).
   Every window under 1378px was therefore too narrow by construction, and at
   1351 the table ran 27px past its own edge.

   Fixed layout asks the colgroup instead, so the table can never exceed the
   space it has. What the content needs is then a set of thresholds rather
   than a width the table demands. */
table {
  /* Five columns hold a run that must not break, so each takes its content
     and no more. The title column states nothing and absorbs the remainder,
     which is why it is the one that shrinks last.

     Every figure is the widest INK that column renders, plus the 24px the
     two paddings come to. DURATION 73.5 + 24. A full date measured 158.2 and
     a day-only one 89.1.

     THE TWO ORNAMENT COLUMNS READ THE FLOOR, THEY DO NOT STATE A NUMBER. A
     control promoted by the touch floor grows, and a column typed for a
     mouse does not. Measured with the floor at 44: the checkbox target spilt
     20px past its column and the remove button 16px, at every table width.
     Written as a max, both follow whatever their own control comes out as. */
  --col-pad: calc(var(--space-md) * 2);
  --col-check: calc(max(var(--target-mouse), var(--control-floor)) + var(--col-pad));
  --col-remove: calc(max(var(--control-sm), var(--control-floor)) + var(--col-pad));
  --col-dur: 100px;
  --col-date: 188px;
  --col-chan: 184px;

  /* THE SCRIPT IS THE WRITER HERE, AND THIS IS ONLY THE VALUE BEFORE THE
     FIRST ROW EXISTS. fitTags() measures the painted chips after every
     render and states the width in pixels, between the heading's own ink
     and two chips with the add control beside them. 58 is that heading plus
     its two paddings, which is the floor the measurement never goes under. */
  --col-tags: 58px;

  width: 100%;
  table-layout: fixed;
  border-collapse: collapse;
  text-align: left;
}

.col-check { width: var(--col-check) }
.col-chan { width: var(--col-chan) }
.col-dur { width: var(--col-dur) }
.col-up, .col-added { width: var(--col-date) }
.col-tags { width: var(--col-tags) }
.col-remove, .col-edit { width: var(--col-remove) }

/* NAME IS THE SINGLE AUTO COLUMN IN BOTH TABLES, so it takes the whole
   remainder. TAGS held that job in the bookmarks list and gave the reader a
   314px column for two chips. Now that it states a measured width, the
   slack it stops taking lands on the name, which is the column a reader
   reads first and the one that truncates.

   The address is the one width this list still states. It is fixed the day
   it is saved and truncates from the end, which is the half nobody reads. */
table { --col-url: 240px }

table[data-list="links"] .col-url { width: var(--col-url) }

.cell-url .truncate { display: block }

/* A HEADING THAT WRAPS COSTS ONE LINE. A HEADING THAT SPILLS COVERS THE
   COLUMN BESIDE IT. Under fixed layout a column can end up narrower than its
   own heading, and on a touch laptop the two ornament columns grow 36px and
   take that width from it. So no heading states `nowrap`.

   VIDEO TITLE was the case this was written for, and it is NAME now. No
   heading in either list holds a space any more, so none of them CAN wrap:
   a single word ignores the permission. The rule stays because the next
   two-word heading needs it. */
th {
  padding: var(--space-md);
  border-bottom: 1px solid var(--line);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: var(--track-label);
}

td {
  padding: var(--space-md);
  border-bottom: 1px solid var(--line-subtle);
  vertical-align: middle;
}

tbody tr:last-child td { border-bottom: 0 }

/* A sortable heading is a control, so it clears the floor and says which
   way it is sorting. aria-sort carries the state; the mark shows it. */
.th-sort {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  min-height: max(var(--target-mouse), var(--control-floor));
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: inherit;
  cursor: pointer;
  transition: color var(--duration) var(--ease);
}

.th-sort:hover { color: var(--text) }
.th-sort .icon { width: var(--text-xs); height: var(--text-xs); color: var(--text-quiet) }
[aria-sort] .th-sort { color: var(--text) }
[aria-sort] .th-sort .icon { color: var(--accent) }

/* A date is one run that must not break. Left to wrap, "Sep 11, 2026, 6:05
   PM" folded onto two lines and made every row in the table taller.

   The two TEXT columns truncate instead, because a truncated date is a wrong
   date. What a date does when the room runs out is drop its TIME, one step
   down the ladder below. */
.cell-dur, .cell-up, .cell-added { white-space: nowrap }

/* THE LADDER, WIDEST FIRST. Each step gives up one thing, in the order the
   reader can most afford to lose it.

     1  everything in full
     2  the timestamps go, the dates stay
     3  the channel is capped and truncates
     4  the title gives up the rest

   A threshold is where the TITLE column would fall under its own header,
   which is 88.8px of ink plus 24px of padding. The sums are the reserved
   width of the seven stated columns:

     full          48 + 100 + 52 + 184 + 376 + 232  =  992  ->  1107 + 66
     no time       48 + 100 + 52 + 184 + 236 + 232  =  852  ->   967 + 66
     capped        48 + 100 + 52 + 104 + 236 + 232  =  772  ->   887 + 66

   The 66 is the shell's 64px of padding plus the wrapper's two borders, so
   each figure on the right is the viewport width at which that step runs
   out. The last one, 953, is where the CARD stack takes over: below it the
   eight columns cannot hold their own headers, and the ladder has nothing
   left to give up. */
@media (max-width: 1172px) {
  table { --col-date: 118px }
  .cell-up .date-full, .cell-added .date-full { display: none }
  .cell-up .date-day, .cell-added .date-day { display: inline }
}

@media (max-width: 1032px) {
  table { --col-chan: 104px }
}

/* The day-only form is the fallback. At the widest step the column has room
   for the time, so the full form carries it. */
.date-day { display: none }

.check { text-align: center }

/* The label is the target, never the 16px box drawn inside it. */
.check-hit {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: max(var(--target-mouse), var(--control-floor));
  min-height: max(var(--target-mouse), var(--control-floor));
  cursor: pointer;
}

/* Three states. The MARK tells them apart, a tick against a dash, never the
   fill: a reader who cannot separate two hues still sees two shapes. */
.checkbox {
  appearance: none;
  -webkit-appearance: none;
  width: var(--space-lg);
  height: var(--space-lg);
  margin: 0;
  border: 1px solid #666;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken) center / var(--space-md) no-repeat;
  cursor: pointer;
  transition:
    background-color var(--duration) var(--ease),
    border-color var(--duration) var(--ease);
}

.checkbox:hover { border-color: #999 }

.checkbox:checked,
.checkbox:indeterminate {
  background-color: var(--accent);
  border-color: var(--accent);
}

.checkbox:checked {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
}

.checkbox:indeterminate {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23fff' stroke-width='3.5' stroke-linecap='round'%3E%3Cpath d='M6 12h12'/%3E%3C/svg%3E");
}

/* A row plane says what it is. Hover is a pointer, selection is a choice, and
   a selected row under the pointer is both. */
tbody tr { transition: background-color var(--duration) var(--ease) }
tbody tr:hover { background: var(--row-hover) }
tbody tr.row-selected { background: var(--row-selected) }

/* (0,2,2) against (0,1,2), so this wins wherever it sits in the file. Written
   at equal weight it would have depended on the order, which is the fault
   that made a selected row inert in the first place. */
tbody tr.row-selected:hover { background: var(--row-selected-hover) }

/* The bar is a pseudo-element, stretched one hairline past each end, because
   an inset shadow stops short of the row's own bottom rule and reads as a
   dash between the lines. Two adjacent selected rows then meet with no seam. */
tbody tr.row-selected > td:first-child { position: relative }

tbody tr.row-selected > td:first-child::before {
  content: "";
  position: absolute;
  inset-block: -1px;
  inset-inline-start: 0;
  width: var(--select-bar);
  background: var(--accent);
  transition: width var(--duration) var(--ease);
}

tbody tr.row-selected:hover > td:first-child::before { width: var(--select-bar-hover) }

/* One line, truncated, with the full title on hover through the title
   attribute. The "youtube.com" caption is gone: every video here is a
   YouTube video, so the line said nothing.

   The anchor is both the text and the target. It reaches the floor by its own
   min-height rather than by an overhang, because overflow:hidden clips an
   element's own pseudo-element and a hit test measured 21.5px of a 24px
   floor that way. */
/* A block, not a flex box. Flex centring would hide the label from the row
   and a flex container will not ellipsise its own text. A stated line-height
   equal to the target floor does both jobs: it centres the single line and
   gives the anchor its height. One writer per property. */
/* NO CAP. A stated one is what made the table demand a constant width, and
   any window narrower than that constant overflowed. The COLUMN is the cap
   now, and a block with ellipsis fills whatever column it is given. */
/* THE CLIPPING IS ON THE SPAN INSIDE, NEVER ON THE ANCHOR. overflow: hidden
   clips an element's own ::after, and the anchor needs one to reach the touch
   floor without its box becoming layout. Measured with the clipping here: the
   link's target came out 22px against a 44 floor. */
.video-link {
  display: block;
  min-width: 0;
  line-height: max(var(--target-mouse), var(--control-floor));
  color: var(--text);
  font-weight: 650;
  text-decoration: none;
  transition: color var(--duration) var(--ease);
}

.video-link:hover { color: var(--accent); text-decoration: underline }

/* Any run that must stay on one line and say so on hover. */
.truncate {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* The column headers name these at a wide width. In the card view the table
   semantics are gone, so each cell carries its own name for a screen reader
   and nothing for the eye. */
.cell-name { display: none }

.subtle { color: var(--text-muted); font-size: var(--text-xs) }

/* A figure in a COLUMN of figures takes the mono face. A duration is an
   amount, so it also takes an end edge, which stacks the digits. */
.amount {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  text-align: end;
}

th.amount { text-align: end }

/* NO MINIMUM. Under fixed layout a minimum wider than the column does not
   widen the table, it pushes the chips over the column beside it. The column
   states the width, and --col-tags is sized for the COLLAPSED state, which
   is what a row shows almost all of the time: two chips at 61 and 99, a 4px
   gap between them, 12px before the add control and 24px for it. The field
   only exists while someone is typing, and it may take a second line. */
.tags {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-xs);
  min-width: 0;
}

/* A CHIP IN THE TABLE NEVER SHRINKS, because the column is sized from these
   rectangles. A shrunk chip would report the width the column already gave
   it, and the measurement would then chase itself down. The column is never
   narrower than the widest chip, so nothing here can overflow. */
.cell-tags .tag-chip { flex: 0 0 auto }

/* ==========================================================================
   Tagging a selection

   The panel borrows .tags, .tag-chip and .tag-add from the row editor, so
   nothing here restates a chip. What it adds is the box those sit in.
   ========================================================================== */

.tag-bulk {
  position: relative;
  flex: 0 0 auto;
}

/* ANCHORED TO ITS END EDGE, so it opens inward. This button sits in the right
   half of a wide row, and a 320px panel hung off its start edge would reach
   past the window. The row stacks below 953, where the button is the whole
   line and the two edges meet anyway.

   The width is capped against the VIEWPORT rather than stated, because the
   panel is wider than the control it hangs from. */
.tag-panel {
  position: absolute;
  z-index: var(--layer-dropdown);
  inset-inline-end: 0;
  top: calc(100% + var(--space-xs));
  width: min(320px, calc(100vw - var(--space-2xl) * 2));
  padding: var(--space-md);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: 0 8px 24px #000000a8;
}

/* A readout, so it says what the two buttons below will act on. */
.tag-panel-count {
  margin-bottom: var(--space-sm);
  color: var(--text-muted);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

/* An action row that breaks, breaks into pairs, and one gap serves both axes
   because a line break is not a group boundary. 16px above the pair, which is
   the published clearance from the text an action answers to. */
.tag-panel-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  margin-top: var(--space-lg);
}

.tag-panel-actions > .btn { flex: 1 1 auto }

/* The chips wrap freely here. A row cell caps this at its column width, and
   the panel has no column to answer to.

   The chips are a group and the field is not one of them, so the line gap is
   a step above the 4px between chips. 8 against 4 is the proximity bar. */
.tag-panel .tags {
  min-width: 0;
  row-gap: var(--space-sm);
}

/* THE FIELD TAKES A WHOLE LINE. Sized by the input's own default it stopped
   87px short of the edge everything else in the panel sits on. A 100% basis
   cannot share a line with anything, so the field starts at 0 whether chips
   exist or not, and it never needs the cell's separation margin. */
.tag-panel .tag-add { flex: 1 1 100% }

/* A PERMANENT FIELD CARRIES ITS FILL PERMANENTLY. In a row this field is one
   of two states and a plus marks the other, so the fill can arrive on hover.
   Here it is the only state, and with no fill its 4px text inset reads as an
   indent against a label and two buttons that start at 0. The fill is the one
   it already takes on hover, so the box edge lands on 0 and the 4px becomes
   the field's own padding. */
.tag-panel .tag-input { background: var(--surface-sunken) }

/* ==========================================================================
   A video that is gone from YouTube

   DEACTIVATED IS THE TEXT, NEVER THE CONTROLS. opacity on a row would dim the
   checkbox and the remove button too, and those are the two things a reader
   needs most on a dead row: select it, or delete it. Opacity also multiplies
   against whatever is behind it, so one value drifts across the stripe, the
   hover and the selected plane.

   So the text steps down through the colour tokens and the controls do not
   move at all.
   ========================================================================== */

.row-dead .video-link,
.row-dead .cell-chan,
.row-dead .cell-dur,
.row-dead .cell-up,
.row-dead .cell-added { color: var(--text-quiet) }

/* The title is struck through, so the row reads as dead at a glance rather
   than only on the chip. The link still works: a reader may want to see what
   YouTube says about it. */
.row-dead .video-link { text-decoration: line-through }
.row-dead .video-link:hover { color: var(--danger-quiet) }

/* NOT A TAG, AND IT SITS WHERE TAGS SIT. The tags cell is the only flexible
   one, so the marker goes there and wraps with them. Its own outline and the
   alert mark are what tell it apart from the grey pills beside it. A reader
   who cannot separate two hues still sees a different shape. */
.dead-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  min-height: var(--control-sm);
  padding-inline: var(--space-sm);
  border: 1px solid var(--danger-line);
  border-radius: var(--radius-pill);
  color: var(--danger-quiet);
  font-size: var(--text-xs);
  white-space: nowrap;
}

.dead-chip .icon { width: var(--mark); height: var(--mark) }

/* A chip is a LABEL, so it never reads the touch floor. Given the floor it
   grew to 44px on a phone and read as a giant pill beside its own row.
   Nobody presses a chip, so there is no target to protect.

   It sits beside the tag field, which IS a control and does take the floor.
   Two different kinds of thing, so two heights, and that is correct. */
/* The trailing padding is smaller than the leading one, because the remove
   button supplies its own inset. Equal padding would stand the mark twice as
   far from the edge as the label stands from the other. */
.tag-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  min-height: var(--control-sm);
  padding-inline: var(--space-sm) var(--space-2xs);
  border-radius: var(--radius-pill);
  background: var(--tag-bg);
  color: var(--tag-ink);
  font-size: var(--text-xs);
}

/* An icon-only control, so it is square and centres on its own box. Drawn at
   24px and reached at the floor through an overhang, which costs no layout
   and leaves the chip at the size it was.

   I first wrote this off as unreachable, claiming neighbouring targets would
   collide. Measured, two of them sit 78.8px apart, because a chip carries a
   label before its button. Even a one-character tag leaves 30px, against the
   20px two 44px targets need. The horizontal case was never the problem.

   The VERTICAL case is. Chips wrap, and a 44px target on a 24px button
   reaches 8px past the 28px chip. Two wrapped lines 4px apart would overlap
   by 12px, so the row gap opens to 16px at a coarse pointer and nothing
   meets. */
.tag-remove {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1;
  height: var(--target-mouse);
  padding: 0;
  border: 0;
  border-radius: var(--radius-pill);
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color var(--duration) var(--ease),
    background-color var(--duration) var(--ease);
}

/* min() against zero stops it: at a fine pointer the button already clears
   its floor, so the inset resolves to 0 and there is no overhang at all. */
.tag-remove::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

.tag-remove:hover { color: var(--danger-quiet); background: #ffffff14 }

@media (forced-colors: active) {
  .tag-remove:hover { outline: 1px solid Highlight }
}

/* COLLAPSED, THE ADD CONTROL IS A 24px PLUS.
   It expands into a field on click, and shrinks back once a tag lands or on
   leaving it untouched. That is worth more than the interaction: as a
   permanent field it demanded 96px of every row, which is what pushed the
   tags column to 272px and took that width from the title. */
.tag-add {
  display: inline-flex;
  align-items: center;
  min-width: 0;
}

/* THE FIELD TAKES WHAT THE LINE HAS LEFT, rather than a width of its own. At
   a fixed 207 it wrapped onto a second line whenever the chips had used any
   of the row, and the card grew 44px on a press. Measured on a row of three
   tags at 534: 150px still free and a field demanding 207.

   ITS FLOOR IS ITS OWN PLACEHOLDER. "Tag name" is 64.7px of ink plus its 8px
   of padding, so 76 is the step above that. Below it the field wraps, which
   is the right answer: a legible second line beats a field too narrow to
   read what you typed. */
.tag-add[data-expanded="true"] {
  /* THE BASIS IS THE FLOOR, NEVER auto. A wrapping flex box decides where a
     line breaks from the BASIS, before any growing happens. At auto that is
     the input's own 207px, so it wrapped first and then grew to fill the new
     line: 440px wide on a row that had 150px free. */
  --tag-field-floor: 76px;
  flex: 1 1 var(--tag-field-floor);
  min-width: var(--tag-field-floor);
}

.tag-add[data-expanded="true"] .tag-input-hit { min-width: 0; flex: 1 }

/* NO MARGIN OF ITS OWN. The container's 4px gap is the whole distance, and
   the plus joins the chips' rhythm.

   This carried 12px, to stand the plus clear of the chip run. In a WRAPPING
   container that is an indent waiting to happen: the plus is not the first
   child, so the rule applies, and it is first on its own line, so there is
   nothing to stand clear of. Measured at 8px in both instances, the panel's
   field and a row whose chips fill the column.

   No selector can ask "first on this line", so a margin cannot be made
   correct here. The plus also belongs to the chip list rather than sitting
   beside it, so one gap for the whole run is the honest distance. Where the
   field IS a separate thing, which is the panel, the panel states its own
   row gap instead. */

/* THE SAME HEIGHT AS A CHIP AND AS THE FIELD IT BECOMES. It read the mouse
   target, 24, while both of those read --control-sm at 28. So the row grew
   4px the moment the field opened, and in a row that already had chips the
   plus sat 4px short of the run it joins. One token, three states. */
.tag-add-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1;
  height: var(--control-sm);
  padding: 0;
  border: 1px dashed #4a4a4a;
  border-radius: var(--radius-pill);
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color var(--duration) var(--ease),
    border-color var(--duration) var(--ease),
    background-color var(--duration) var(--ease);
}

.tag-add-btn:hover { color: var(--text); border-color: #777; background: #ffffff14 }

.tag-add-btn::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

/* One of the two shows at a time. The wrapper carries the state, so neither
   half has to know about the other. */
.tag-add[data-expanded] .tag-add-btn { display: none }
.tag-add:not([data-expanded]) .tag-input-hit { display: none }

/* The label is the target and the field is what a reader sees, the same
   shape the checkbox uses.

   The 44px reach is an OVERHANG, not a height. Put it on the box and the
   label became a 44px flex item beside 28px chips, which set the row to 44
   and left every chip top 8px adrift. An overhang costs no layout, so the
   row stays 28 and the target still clears the floor.

   A percentage inset resolves against the containing block, and for an
   absolutely positioned child that block is this label. So 100% is the
   label's own height. min() against zero stops it: a label already past the
   floor gets no overhang. */
/* NO MARGIN HERE EITHER. The container's gap is the only writer for the
   distance to the chips. A margin on this label, or on .tag-add, is a second
   one, and two writers paint a distance nobody chose. */
.tag-input-hit {
  position: relative;
  display: flex;
  align-items: center;
  flex: 1;
  min-width: 96px;
  min-height: var(--control-sm);
}

.tag-input-hit::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

.tag-input {
  width: 100%;
  min-width: 0;
  min-height: var(--control-sm);
  padding: 0 var(--space-xs);
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--tag-ink);
  font: inherit;
  outline: 0;
  transition: background-color var(--duration) var(--ease);
}

.tag-input::placeholder { color: var(--text-quiet) }
.tag-input:hover, .tag-input:focus { background: var(--surface-sunken) }

.row-remove,
.row-edit {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1;
  height: max(var(--control-sm), var(--control-floor));
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition:
    color var(--duration) var(--ease),
    background-color var(--duration) var(--ease);
}

.row-remove:hover { color: var(--danger-quiet); background: #2a1414 }
.row-edit:hover { color: var(--text); background: var(--row-hover) }

/* THE MODE IS AN ATTRIBUTE, so the colour and the mark cannot disagree with
   what the press does. Save is its own hue: beside a delete cross it must
   read as neither that nor the brand. */
.row-edit[data-mode="save"] { color: var(--save) }
.row-edit[data-mode="save"]:hover { color: var(--save); background: #2a1c0c }

/* THE FIELD COSTS NO LAYOUT, so the row holds still while a name is edited.
   In flow it is a 44px control standing where the name is a 21px line, and
   the row grew 23px the moment a pencil was pressed. Out of flow it overlays
   the name it replaces and nothing below it moves.

   IT READS THE RESERVATION RATHER THAN RESTATING IT. Absolute insets resolve
   against the PADDING box, so inset-inline: 0 would run the field under the
   two marks in the card view. No selector can ask an ancestor for its
   padding, so the cell publishes the distance and the field reads it back.
   The table view reserves nothing, and the fallback is that. */
.title-input {
  position: absolute;
  inset-inline: 0 var(--title-reserve, 0px);
  inset-block-start: 50%;
  transform: translateY(-50%);
  min-width: 0;
  font-weight: 650;
}

/* THE CELL KEEPS ITS LINE WHILE THE FIELD FLOATS OVER IT. With the name out
   of flow the cell measured 0 and the row SHRANK by 5px, which is the same
   fault pointed the other way. The floor is the line the name occupies, so
   the box is identical in both states. */
.cell-title {
  position: relative;
  min-height: calc(var(--text-sm) * var(--leading-body));
}

@media (forced-colors: active) {
  .row-edit[data-mode="save"] { outline: 1px solid Highlight; outline-offset: -3px }
}

/* ==========================================================================
   Tag autocomplete

   Fixed to the viewport, not nested in the cell. The table scrolls sideways
   at narrow widths, and anything drawn inside it would be clipped.
   ========================================================================== */

.tag-suggest {
  position: fixed;
  z-index: var(--layer-dropdown);
  max-height: 240px;
  margin: 0;
  padding: var(--space-xs);
  overflow-y: auto;
  list-style: none;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-raised);
  box-shadow: 0 8px 24px #000000a8;
}

.tag-suggest li {
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-sm);
  color: var(--tag-ink);
  font-size: var(--text-xs);
  white-space: nowrap;
  cursor: pointer;
}

.tag-suggest li:hover { background: var(--option-hover) }

/* The active option is marked by its fill AND by its start edge, so the mark
   survives a reader who cannot separate two greys. */
.tag-suggest li.active {
  background: var(--option-selected);
  box-shadow: inset var(--select-bar) 0 0 0 var(--accent);
  padding-inline-start: var(--select-inset);
  color: var(--text);
}

@media (forced-colors: active) {
  .tag-suggest li.active { outline: 2px solid Highlight; outline-offset: -2px }
}

/* ==========================================================================
   Empty states
   Four of them, never one. First run offers the feature's own action.
   No results offers a way BACK. A failure names what failed. Loading holds
   the shape of what is coming.
   ========================================================================== */

.empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  padding: var(--space-3xl) var(--space-xl);
  text-align: center;
}

.empty .icon { width: var(--text-xl); height: var(--text-xl); color: var(--text-quiet) }
.empty h3 { font-size: var(--text-md) }
.empty p { max-width: 44ch; color: var(--text-muted) }

/* An action stands 16px clear of the text that explains it. */
.empty .btn, .empty .btn-text { margin-top: var(--space-lg) }

/* Every placeholder height comes from the type tokens of the line it stands
   in for, so the skeleton and the loaded row measure the same. */
.skeleton-row td > span {
  display: block;
  height: calc(var(--text-sm) * var(--leading-body));
  border-radius: var(--radius-sm);
  background: #262626;
}

.skeleton-row td > span.short { width: 40% }

@media (prefers-reduced-motion: no-preference) {
  .skeleton-row td > span { animation: pulse 1.4s var(--ease) infinite }
}

@keyframes pulse { 50% { opacity: .45 } }

/* ==========================================================================
   Footer
   ========================================================================== */

footer {
  margin-top: var(--space-xl);
  color: var(--text-muted);
  font-size: var(--text-xs);
}

footer strong { color: var(--tag-ink) }

code {
  padding: var(--space-2xs) var(--space-xs);
  border-radius: var(--radius-sm);
  background: var(--line-subtle);
  font-family: var(--font-mono);
  font-size: .92em;
}

/* ==========================================================================
   Dialog
   ========================================================================== */

dialog {
  width: min(440px, calc(100% - var(--space-xl)));
  padding: var(--space-xl);
  border: 1px solid #4a4a4a;
  border-radius: var(--radius-lg);
  background: var(--surface-raised);
  color: var(--text);
  z-index: var(--layer-modal);
}

dialog::backdrop { background: #000000bb }

.dialog-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-lg);
}

dialog h2 { font-size: var(--text-lg); letter-spacing: var(--track-tight) }
dialog p { color: var(--text-muted) }
dialog .stack { display: flex; flex-direction: column; gap: var(--space-sm) }

.typed-confirm { margin-top: var(--space-lg) }

.typed-confirm label {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  color: var(--tag-ink);
  font-size: var(--text-xs);
}

.typed-confirm .input { width: 100% }

dialog menu {
  display: flex;
  justify-content: flex-end;
  gap: var(--space-sm);
  margin-top: var(--space-xl);
  padding: 0;
}

/* ==========================================================================
   Narrow widths

   1121px, AND THE FILTER ROW SETS IT NOW. It used to be 953, the last rung of
   the table's ladder: at 887px of container the eight columns each sit at
   their own floor. The row above the table gives out first, so that figure is
   no longer the binding one. It is still the floor the table would hit, and
   it is in the Table section.

   DERIVED BY SHRINKING THE REAL ROW, never by adding its parts up. Every
   control in it states its own width and the search absorbs the rest, so the
   question is where the search reaches its floor. Measured: 354px of field at
   a 1180 viewport, 314 at 1140, 284 at 1110 with nothing to spare, and at
   1100 the row wants 6px more than it has. 1110 is the last width that holds
   one line, and the card stack takes over at 1120.
   ========================================================================== */

@media (max-width: 1120px) {
  .shell { --page-pad: var(--space-lg); padding: var(--page-pad) }

  .sync-words { display: none }

  header { gap: var(--space-sm) }

  /* The header's actions do not fit beside the brand with their labels on:
     measured 1077px of content in a 375px viewport, 702px of it off screen
     with no scroller. So each one becomes an icon-only square.

     The shape is stated in the SAME block that hides the label, because CSS
     cannot see a text node and the markup does not change. Every one of them
     carries an aria-label, so nothing loses its name. */
  /* THE BUILD COSTS A LINE HERE, AND IT IS THE ONE THING IN THE HEADER
     NOBODY PRESSES. Their instruction, taken on a measurement. At 375 on a
     finger the brand and the controls need 362px of a 343px line, so the row
     wraps and the header stands 107px tall. Without the build the brand is
     106.4 and the sum is 304.4, which fits: 69px and one line.

     It buys the line back in the state the app is in most of the time. With
     Drive connected the row is 52px wider and wraps either way, so the build
     is not what decides that one. The wordmark carries it in a title. */
  .brand-build { display: none }

  .sync-row .btn-label { display: none }

  /* The chevron goes with the word. A square holding a download mark and a
     chevron reads as neither, and the menu still opens on a press. */
  .sync-row .multi-chevron { display: none }

  /* A ROW OF SQUARES HAS NO BASELINE, SO THE HEADER CENTRES INSTEAD. With
     the words hidden the row holds nothing but icon buttons, and a flex
     container with no text takes its baseline from its first item's bottom
     margin edge. That was the 8px status dot: measured at 375, the dot's
     bottom sat exactly on the wordmark's baseline at y42, which put the
     44px buttons 3.5px below the wordmark's own optical centre.

     Baseline is right at wide widths, where the row carries words. */
  header { align-items: center }

  .sync-row .btn {
    aspect-ratio: 1;
    justify-content: center;
    padding: 0;
    gap: 0;
  }

  /* The field takes its own line, so the action below it takes the whole
     line too. An orphan gets the width it was given.

     flex must go back to none here. In a COLUMN container flex:1 means
     flex-basis:0 on the BLOCK axis, which outranks the field's own height
     and collapsed it to 19px against a 44px floor. It is the right value in
     a row, where the basis is a width. */
  .add-row { flex-direction: column }
  .add-row .input { flex: none; width: 100% }
  .add-row .btn { width: 100% }

  /* Search goes on TOP here, with the tag filter under it. On a wide row the
     tag control sits to the LEFT of search, which reads as narrowing before
     searching. Stacked, the same order would bury the field most people
     reach for first.

     Orphaned elements go full width, so search and the tag group each take
     the whole line they landed on.

     THE LABEL IS ORDERED BY ITS GROUP NOW. It used to carry its own order
     here as a direct child of the row. It sits inside .filters-group, so
     ordering it individually would move it within that pair and nothing
     else. The pair travels as one item.

     The buttons PAIR on the line they land on, rather than taking one line
     each. An action row that breaks, breaks into pairs.

     TAG SELECTED IS ONE OF THOSE BUTTONS AND HAD NO ORDER. Left out of this
     list it kept order 0 and sat on a line of its own ABOVE the search
     field, 144px wide at 937. Its panel hangs off its end edge, so the panel
     then started 159.7px outside the window. */
  /* THE SECOND WORD DROPS WITH THE TABLE. Measured with it in: the strip
     needs 372.5px against the 328 a 360px window gives, so at the narrowest
     width this ships the tabs were 22.8px under their own content. The card
     view is where that starts to matter, and it is one threshold rather than
     a fifth one for a single word. */
  .tab-long { display: none }

  /* THE CARD VIEW IS WHERE THE ROW STOPS BEING ONE ROW. Above it the field
     absorbs every pixel down to its floor, and this width is that floor. */
  .filters { flex-wrap: wrap }
  .search { order: 1; flex: 1 1 100%; min-width: 0 }
  .filters-group { order: 2; flex: 1 1 100% }
  .filters-group .multi { flex: 1 1 auto; width: auto }
  #tagBulk { order: 3; flex: 1 1 0 }
  /* THE TRIGGER, NEVER EVERY BUTTON INSIDE THE WRAPPER. Written as
     "#tagBulk .btn" this reached the panel's own two buttons and gave each a
     full line. They fit side by side in 294px and were stacked instead. */
  #tagSelected { width: 100% }
  #deleteSelected { order: 4; flex: 1 1 0 }
  #clearFilter { order: 5; flex: 1 1 0 }

  /* ANCHORED TO ITS START EDGE HERE. Hung off the end edge the panel opens
     leftward, and on this stacked row the button is the first of three
     rather than the last thing on a wide line. The page inset is 16px, so
     opening rightward from the button's start keeps the whole panel inside
     the window at every width down to 375. */
  .tag-panel {
    inset-inline-start: 0;
    inset-inline-end: auto;
  }

  /* A MARGIN IS NEVER PAINTED, SO THE SORT BAR'S SEPARATION IS PADDING. The
     bar carried 12px of margin below it. That margin is outside every box
     the stack paints, so the list scrolled through it: measured a 12px strip
     at y 378 to 390 showing a card's tag button behind the stuck stack.

     ONE WRITER FOR EACH GAP. The distance above was two writers, the filters'
     own 12px foot plus the bar's 12px head, so it read 24 at rest and 12 when
     stuck, because a stuck element ignores its margin. The filters band gives
     up its foot and the bar owns both distances. 12 above and 12 below, at
     rest and stuck alike.

     AND THE SORT BAR IS LAST HERE, so it draws the band's foot and the
     filters give up their bottom border. There is no table border in this
     view for that border to be the top of, and inside one band it would mark
     a boundary nobody needs.

     16px, THE SAME STEP THE FILTERS WRAP AT. The band is one group of list
     controls, so every line in it sits the same distance from the next. At
     12 the sort row sat closer to the buttons than the filter rows sit to
     each other. */
  .filters {
    padding-block-end: 0;
    border-bottom: 0;
  }

  .sort-bar {
    display: flex;
    gap: var(--space-lg);
    margin-block: 0;
    padding-block: var(--space-lg) calc(var(--space-md) * 2 + 1px);
  }

  /* The table becomes a stack of cards. Eight columns cannot fold into
     375px, and a sideways scroller is the answer this file calls a last
     resort: a column scrolled out of view is a column nobody reads.

     One DOM either way. Each cell carries data-label, which is what names
     the fact once the header row is gone. */

  .table-wrap { overflow-x: visible; border: 0; min-height: 0 }
  /* THE COLUMNS GO, OR THE TABLE KEEPS A WIDTH NO ROW USES. The rows here
     are grid cards and render none of the eight columns, but the colgroup
     still declares them and fixed layout still honours the sum. Measured at
     375px: a 790px table inside a 343px wrapper, and 432px of page overflow
     with no scroller. The header is hidden, the rows are cards, so nothing
     is left for a column to size. */
  colgroup { display: none }
  /* border-spacing: 0, because the tbody is a flex column here and its own
     12px gap is the only writer. The default 2px put a stray strip above the
     first card, so the stack's 12px foot read 14. */
  table {
    min-width: 0;
    table-layout: auto;
    border-collapse: separate;
    border-spacing: 0;
  }
  thead { display: none }
  tbody { display: flex; flex-direction: column; gap: var(--space-md) }

  tbody tr {
    display: grid;
    grid-template-columns: max-content minmax(0, 1fr) max-content;
    column-gap: var(--space-md);
    row-gap: var(--space-sm);
    align-items: start;
    padding: var(--space-lg);
    border: 1px solid var(--line);
    border-radius: var(--radius-lg);
    background: var(--surface);
  }

  /* A card view is reached by a finger, so hover says nothing here and both
     planes go back to their resting colour. The selected case needs its own
     line: (0,2,2) beats the (0,1,2) above it whatever the order, so without
     this a selected card would light up and the reset would look applied. */
  tbody tr:hover { background: var(--surface) }
  tbody tr.row-selected,
  tbody tr.row-selected:hover { background: var(--row-selected); border-color: var(--accent) }

  /* The bar is the row's edge on a table. On a card the border says it, so
     the pseudo-element would draw a second mark over the radius. */
  tbody tr.row-selected > td:first-child::before { content: none }

  tbody tr > td { padding: 0; border: 0 }
  tbody tr:last-child td { border: 0 }

  /* Three lines, each a left run and a right run.

     Line 1  checkbox   title, one line, truncated   delete
     Line 2  channel, one line, truncated            duration
     Line 3  uploaded, day only                      added, in full

     Lines 2 and 3 start on the TITLE's edge rather than the checkbox's, so
     the card reads as one column of text under its own heading. The right
     column is sized by the widest of the delete button, the duration and
     the added date, and every member of it is aligned to the end. */
  /* THE DELETE BUTTON LEAVES THE GRID, AND THAT IS WHAT GIVES THE TITLE ITS
     WIDTH. A grid column is shared by every row, so the button and the
     "added" date sat in one track. max-content sized it to the date at
     126px, and the title was left 119px of a 313px card: it truncated after
     three words. Positioned out of flow, the button sizes nothing, the title
     spans both columns, and its cell reserves the button's width so the two
     never overlap. Measured 119px to 213px. */
  /* TWO COLUMNS, AND EACH LINE'S PAIR SHARES ONE CELL.

     Three tracks did not work. A grid column is shared by every row, so the
     delete button, the duration and the "added" date all sat in col 3.
     max-content sized it to the widest of them at 126px, which left the
     title 119px of a 313px card and pinned the channel to the same 119px.
     The gap from the channel to the duration came out at 101px.

     So there are two tracks now. Each pair occupies ONE cell, the left run
     at its start and the right run at its end, and the left run caps itself
     so the two never meet. The delete button is out of flow entirely, and
     sizes nothing. */
  /* 12px, AND IT IS THE ONLY WRITER BETWEEN THE FACT LINES. It read 4, and
     the title looked further off than that because its own box was 23px
     taller than its line. Killing that dead box and stating the real gap are
     one change: the reader was seeing 15.5px under the title and 4px under
     everything else, from two mechanisms neither of which said 12. */
  tbody tr {
    position: relative;
    grid-template-columns: max-content minmax(0, 1fr);
    row-gap: var(--space-md);
  }

  /* THE TITLE'S BOX IS ITS LINE. The floor is a REACH, not a size: written as
     a line-height it became 44px of layout around a 21px line, and the 23px
     of slack read as padding under the words. The ::after is out of flow, so
     the target costs nothing, and 100% resolves against the element, so one
     rule serves the link at any type size. */
  .video-link {
    position: relative;
    line-height: var(--leading-body);
  }

  .video-link::after {
    content: "";
    position: absolute;
    inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2)) 0;
  }

  /* CENTRED ON LINE 1, NOT PARKED AT ITS TOP. The row declares align-items:
     start, which is right for the text cells and wrong for a 16px control
     beside a 44px line: it sat 13.5px above the title's cap band.

     Line 1 is as tall as the title's own target, and the title's text is
     centred in it, so centring the box here lands on the band with no
     arithmetic.

     AND THE CELL STRETCHES RATHER THAN CENTRING ITSELF. A block wrapping an
     inline child is a TALLER box: this td measured 21px around a 16px label,
     because it carries the line box's descender space. Centring that 21px
     cell then left the box 2.5px above the row's own centre. Stretched to the
     row and centring its child, the two agree.

     Measured after: 0.5px off the title's cap band, against the mark's 0.5 on
     the other side of the same line. */
  .cell-check {
    grid-column: 1;
    grid-row: 1;
    display: flex;
    align-items: center;
    align-self: stretch;
  }

  /* THE TITLE CLEARS THE DELETE TARGET, NEVER THE DELETE MARK. The button is
     out of flow and reserves nothing, so this padding is the only thing
     keeping the two apart.

     Reserving the MARK was my first answer and it was 7px short. A target
     centred on a 14px mark reaches (14 + 44) / 2 = 29px in from the content
     edge, while the mark itself stops at 14. Measured before the fix: a tap
     28px from the edge opened the delete button while the title's own link
     was still under it.

     Written as the arithmetic, so a change to either the mark or the floor
     moves it. 29 plus the published clearance is 37. */
  .cell-title {
    grid-column: 2;
    grid-row: 1;
    padding-inline-end: calc(
      (var(--mark) + max(var(--target-mouse), var(--control-floor))) / 2 + var(--space-sm)
    );
  }

  /* ==========================================================================
     A CONTROL'S OWN BOX SITS ON THE MARGIN. ITS TARGET OVERHANGS IT.

     Both of these were sized AT the touch floor, so 44px of box carried a
     16px checkbox and a 14px mark, each centred with 14 or 15px of slack on
     every side. The slack is invisible and it is layout, so the drawn control
     stood 14px inside a margin the text sits on, and the card read as two
     different left edges.

     A target bigger than its box costs nothing, because the ::after is out of
     flow. inset resolves against this element, so 100% is its own size: for a
     16px box against a 44px floor that is -14px a side, which is exactly 44.
     min() against zero stops it, so a control already past the floor gets no
     overhang rather than a negative one.

     Measured after, from the card's content edge: checkbox box 0, mark 0, and
     both targets still 44.
     ========================================================================= */
  .check-hit,
  .row-remove,
  .row-edit {
    position: relative;
    min-width: 0;
    min-height: 0;
  }

  .check-hit::after,
  .row-remove::after,
  .row-edit::after {
    content: "";
    position: absolute;
    inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
  }

  /* The button becomes its mark. The height is the single writer, because a
     stated width would defeat the aspect ratio. */
  .row-remove,
  .row-edit { height: var(--mark) }

  /* The channel cell STRETCHES, and that is load-bearing. With
     justify-self:start it shrink-wrapped its own content, so the percentage
     cap below resolved against the thing it was sizing. The channel measured
     0px wide and the text disappeared. Stretched, 100% is the grid cell. */
  .cell-chan  { grid-column: 2; grid-row: 2 }
  .cell-dur   { grid-column: 2; grid-row: 2; justify-self: end }
  .cell-up    { grid-column: 2; grid-row: 3; justify-self: start }
  .cell-added { grid-column: 2; grid-row: 3; justify-self: end }
  .cell-tags  { grid-column: 2; grid-row: 4 }

  /* A BOOKMARK CARD HAS ONE FACT ON ROW 2 AND ONE ON ROW 3, so the address
     takes the whole of its line rather than the start half of a pair that
     does not exist here. It also wraps, because an address is the one thing
     in this card a reader may need every character of. */
  .cell-url {
    grid-column: 2;
    grid-row: 2;
    justify-self: stretch;
    min-width: 0;
  }
  .cell-url .truncate { display: block }
  tr:has(.cell-url) .cell-added { grid-row: 3; justify-self: start }

  /* Out of flow, so it sizes no track. The insets read the CARD'S padding
     token, so moving the padding moves the button with it.

     IT IS AS TALL AS LINE 1 AND CENTRES ITS OWN MARK. Being out of flow it
     cannot take align-self from the grid, and pinned at the content top the
     14px mark sat 12.5px above the title's cap band. The height reads the
     same token the title's line box does, so the two cannot drift. */
  /* IT IS AS TALL AS LINE 1 AND CENTRES ITS OWN MARK. The comment here has
     said that for months while the height read the touch FLOOR, 44, against
     a 21px line. Measured: the marks sat 11.14px below the title's cap-band
     centre, which is where the reader's eye puts them.

     The line box is the right slot and it needs no correction on top. The
     band's centre sits a little above the line box's, and for this face at
     this size that is 0.36px: under the whole pixel a repair needs. */
  .cell-remove,
  .cell-edit {
    position: absolute;
    display: flex;
    align-items: center;
    height: calc(var(--text-sm) * var(--leading-body));
    inset-block-start: var(--space-lg);
    inset-inline-end: var(--space-lg);
  }

  /* ONE DISTANCE DECIDES BOTH THINGS. The marks are 14px and each press area
     is centred on its own, so the distance between the two centres is also
     the widest each area can be without them overlapping. One of these two
     deletes: a press at the boundary must never land on whichever painted
     last.

     SO THE DISTANCE READS THE FLOOR, NEVER A NUMBER. At 14 plus 20px of air
     it gave 34, and the sweep reported both marks 10px under the touch floor
     on every bookmark row, 8 findings at 375. Taking the larger of the two
     spreads the pair to 44 on a finger and leaves the mouse layout at 34,
     which is already past its own 24px floor.

     The block axis keeps the full floor, because nothing is competing there. */
  tr:has(.cell-edit) {
    --row-pair: max(
      calc(var(--mark) + 20px),
      max(var(--target-mouse), var(--control-floor))
    );
  }

  .cell-edit {
    inset-inline-end: calc(var(--space-lg) + var(--row-pair));
  }

  tr:has(.cell-edit) .row-edit::after,
  tr:has(.cell-edit) .row-remove::after {
    inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2))
           min(0px, calc((100% - var(--row-pair)) / 2));
  }

  /* The title shares line 1 with BOTH marks, so it clears two of them. It
     publishes the distance because the field that replaces it has to read it
     back: an absolute inset resolves against the padding box. */
  tr:has(.cell-edit) .cell-title {
    --title-reserve: calc(
      (var(--mark) + var(--row-pair)) / 2 + var(--row-pair) + var(--space-sm)
    );
    padding-inline-end: var(--title-reserve);
  }

  .cell-dur, .cell-added { text-align: end }

  .cell-chan, .cell-dur, .cell-up, .cell-added { font-size: var(--text-xs) }
  .cell-up, .cell-added { color: var(--text-muted) }

  /* The channel takes every pixel it can and stops 48px short of the
     duration, which is their number. 4.5em covers the widest duration this
     renders, "1:04:41" in the mono face at this size. */
  .cell-chan { min-width: 0; max-width: 100% }
  .cell-chan .truncate { max-width: calc(100% - var(--space-3xl) - 4.5em) }

  /* The tags are a different kind of thing from the three fact lines, so
     they stand clear of them. Padding rather than a margin, because the row
     gap is one writer already and a padding cannot be consumed. */
  .cell-tags { padding-block-start: var(--space-sm) }

  /* The names exist for a screen reader only. `display: grid` on the row
     removes the table semantics, so the column headers no longer associate
     with these cells and each one has to carry its own. */
  .cell-name {
    display: block;
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }

  /* The line-height stays at the floor: line 1 is already 44px tall from the
     checkbox's hit area, so matching it costs no height and keeps the
     title's own target the same size as the row it sits in. */

  /* THE CARD IS NOT THE NARROWEST STEP OF THE TABLE'S LADDER. Its two dates
     sit on one line and are told apart by their SHAPE, so the upload is a
     day and the addition keeps its time. That reverses the table's rule for
     the added column, which is why this block restates both. */
  .cell-up .date-full { display: none }
  .cell-up .date-day { display: inline }
  .cell-added .date-full { display: inline }
  .cell-added .date-day { display: none }

  /* A run of like things, so one gap serves both axes horizontally. The ROW
     gap opens, because each chip's remove button reaches 8px past the chip
     to make its 44px target. Two wrapped lines 4px apart would put those
     targets 12px into each other, and a tap near the boundary would remove a
     tag from the wrong line. */
  .tags { min-width: 0; row-gap: var(--space-lg) }

  /* THE HEAD IS A COLUMN AT EVERY WIDTH NOW, so it has no row to break. The
     three rules here wrapped an action group that has moved to the filter
     row, and they matched nothing once it left. */

  /* THE FILTER ROW IS ORDERED HIGHER UP IN THIS SAME BLOCK. Two rules here
     restated flex-wrap and the button widths, which this block already sets,
     and two writers for one property is how they end up disagreeing. */
}

/* ==========================================================================
   Forced colors
   This mode overrides authored colour, ignores box-shadow and drops
   background-image, so every mark we draw that way loses its only marker.
   An outline at a negative offset costs no layout and survives.
   ========================================================================== */

@media (forced-colors: active) {
  /* Let the UA draw the box, so the tick and the dash come back. */
  .checkbox {
    appearance: auto;
    -webkit-appearance: auto;
    width: auto;
    height: auto;
  }

  .tag.active { outline: 2px solid Highlight; outline-offset: -2px }
  tbody tr.row-selected > td { outline: 1px solid Highlight; outline-offset: -1px }
  tbody tr.row-selected > td:first-child::before { background: Highlight }
  .video-title { text-decoration: underline }
}
