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

  /* THE SCROLL THUMB IS A THREE-STATE RAMP, DERIVED FROM A CONTRAST TARGET
     RATHER THAN PICKED. Measured against --bg #0f0f0f: 1.43, 2.20 and 3.50.
     Rest reads as a hairline, which is what subtle means here. Hover is a
     step nobody has to hunt for. Held, the thumb is the loudest neutral the
     page draws outside its text.

     Rest IS --line, so the thumb at rest matches the edge it runs beside.
     The other two have no token on the neutral ramp: it steps from #363636
     to #8f8f8f, and both ends are wrong for a hover. */
  --scroll-rest: #303030;
  --scroll-hover: #4c4c4c;
  --scroll-held: #6a6a6a;

  /* 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: #ff0044;
  --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
     the accent everywhere, so nothing that is not carrying words moved.

     THE ACCENT BECAME #ff0044 ON 25 SEPTEMBER 2026, their choice, and the
     pair is made the same way: 88% for the rest state and 78% for the hover.
     White reads 4.96 on #e0003c and 6.03 on #c70035, 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: #e0003c;
  --accent-fill-hover: #c70035;
  --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 ------------------------------------------ */
  /* EVERY STEP IS A MULTIPLE OF 4, AND ONE HALF-STEP SITS UNDER THE SCALE.
     Their instruction, 17 September 2026, then their correction the same day:

       you can switch to multiples of 2 instead of multiples of 4 for areas
       that seem too tight or too spacious compared to what we had before

     Moving the floor to 4 doubled six distances, and every one of them sits
     INSIDE a single object: a chip's label to its cross, that cross's own
     inset, a count to its runtime line, a toast title to its detail, a code
     chip's block padding, and the dead marker's mark to its word.

     THE HALF-STEP IS FOR A DISTANCE INSIDE ONE OBJECT, never between two.
     Nothing above it moved, so the rhythm between things is still 4 and up.
     The grid guard takes 2 only where a comment says `half-step`, and it
     refuses anything that is not a multiple of 2 whatever the marker says. */
  --space-2xs: 2px; /* half-step */
  --space-xs: 4px;
  /* THE SPACING SHRINKS WITH A NARROW SCREEN. Their report, 3 October 2026:
     on a 370-wide phone the fixed gaps took a far larger share of the screen
     than on their own 487-wide one. So every step from 8 up keeps its share
     of 487, shrinks in proportion below it, and snaps to the 4px grid. At
     370 they read 6, 8, 12, 20, 24 and 36. At 487 and wider nothing moves.

     8 SNAPS TO 2, BECAUSE 4 WOULD BREAK A PAIR. Shrinking only the outer
     steps put a 12 between groups against an 8 inside them, under the 2:1
     bar. 16 over 8 must stay 2:1, so at 370 it is 12 over 6. 4 and 2 stay
     fixed, since nothing on the grid is smaller. */
  --space-sm: round(min(8px, 100vw * 8 / 487), 2px); /* half-step */
  --space-md: round(min(12px, 100vw * 12 / 487), 4px);

  /* The rating run's own gap. On the component rather than on the table: a
     token declared on one container is absent everywhere else, and the run
     read 70px instead of 78 the first time it rendered outside one. */
  --star-gap: var(--space-2xs); /* half-step: inside one control */
  --space-lg: round(min(16px, 100vw * 16 / 487), 4px);
  --space-xl: round(min(24px, 100vw * 24 / 487), 4px);
  --space-2xl: round(min(32px, 100vw * 32 / 487), 4px);
  --space-3xl: round(min(48px, 100vw * 48 / 487), 4px);

  /* ---- 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 SAME RATIO, SNAPPED TO THE HALF-STEP. 1.5 over the six type steps
     gives 15, 18, 21, 24, 30 and 36, and 21 is the one they named. At 2px
     that reads 16, 18, 22, 24, 30 and 36.

     THEY CHOSE IT FROM A DRAWING, 17 September 2026, after the ladders were
     rendered side by side on the shipped components: "let's go with 2px".

     A 4px SNAP WAS THE FIRST ANSWER AND IT MOVED THREE OF THE SIX. It gave
     16, 20, 20, 24, 32, 36, which loosened 12px from 18 to 20 and tightened
     14px from 21 to 20. The ratios then ran 1.43 to 1.67 against 1.50 to 1.60
     here, so the half-step is nearer the ratio everywhere.

     WHAT IT COSTS, MEASURED ON THE SHIPPED LIST. A five-line run at 14px goes
     100px to 110. The same run at 12px goes 100 to 90. A card goes 166 to 164,
     because its grid rows ARE the line boxes: 20 20 20 36 becomes 22 18 18 36.
     A table row does not move at all, at 53 either way, because a 28px chip
     sets that height rather than the line.

     THE em RESOLVES WHERE THE TOKEN IS USED, because a custom property
     substitutes as tokens and the declaration is parsed on that element. So
     one value serves every size, exactly as the ratio did. */
  --lead-tight: round(calc(var(--leading-tight) * 1em), 2px);
  --lead-body: round(calc(var(--leading-body) * 1em), 2px);

  /* AND ONE FIXED LENGTH, FOR A BOX THAT STANDS IN FOR A SMALL RUN. The two
     above read 1em, so they answer whichever element uses them. Three boxes
     need the line a 14px run occupies whatever their own size is: the title
     cell's floor, a skeleton bar, and the card's own remove and edit
     controls.

     All three wrote calc(--text-sm * --leading-body) and got the UNSNAPPED
     21, which is the exact number they named. The grid guard could not see
     it, because a length built only from tokens holds no px literal.

     IT SNAPS THE SAME WAY THE OTHER TWO DO, so it reads 22 and cannot drift
     from the line it stands in for. */
  --lead-sm: round(calc(var(--text-sm) * var(--leading-body)), 2px);
  --lead-xs: round(calc(var(--text-xs) * var(--leading-body)), 2px);

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

  /* Three steps, all on the grid. The middle one was 6px and its four
     consumers are the brand mark and three floating panels.

     THE LIST AND THE CARDS TOOK THE SMALL STEP, 17 September 2026: "give the
     list corners and card corners 4px radii." So a card and the control
     standing on it now share one corner, and 8 is left to the things that
     float above the page: the brand mark, the alert, the import panel, the
     toast, the three dropdown panels and the dialog. */
  --radius-sm: 4px;
  --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. At 8
     that clearance halved, which is a distance nobody chose. */
  --select-bar-hover: 6px; /* half-step */
  --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;
  /* A MENU OPENED FROM INSIDE A MODAL, such as the note editor's share menu. */
  --layer-popover: 700;
  --layer-toast: 800;

  /* ---- motion ---------------------------------------------------------- */
  /* ONE DURATION, WHICH IS THEIRS. 250ms for every fade in the interface.
     Their instruction, 17 September 2026: "please raise all the 125ms
     animations to 250ms, let's give them some eye candy."

     IT WAS 125 AND THE OLD NUMBER IS NOT KEPT HERE. This comment used to say
     they tried 250 and came back, which is the reading their own instruction
     reverses. A rule stating the value it no longer holds is worse than one
     stating nothing.

     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: 250ms;
  /* 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 SECOND PAIR, FOR THE TWO THINGS THAT TRAVEL FAR. Their instruction,
     17 September 2026: 500ms for the filter fold and for the list switcher,
     eased out, "no ease-in ... only ease-out".

     THE FIRST CONTROL POINT IS THE ORIGIN, which is what makes it pure. Any
     x1 above 0 holds the start back, and --ease sets 0.4 on purpose. Here the
     run leaves at full speed and only the landing is soft: 33.6% of the
     distance in the first tenth, 68.9% by a quarter, 99.0% at nine tenths.

     250ms STAYS FOR EVERYTHING ELSE. A hover, a chevron and a chip have no
     distance to cover, and 500ms on those reads as lag rather than motion. */
  --duration-fold: 500ms;
  --ease-out: cubic-bezier(0, 0, .2, 1);

  /* A THIRD CURVE, FOR THE PANEL THAT TRAVELS A WHOLE SCREEN WIDTH. Their
     ask, 22 September 2026: the slide needs a smoother easing out curve.

     Same rule, softer landing. The first control point is still the origin,
     so nothing holds the start back. Moving the second from .2 to .1 pulls
     the tail longer: 55.6% of the distance in the first tenth against 33.6%,
     and 0.6% left at nine tenths against 1.0%. So it arrives sooner and
     settles for longer, which is what reads as smooth.

     ONE WRITER. The drag reads this token out of the stylesheet rather than
     holding a second copy of the curve. */
  --ease-slide: cubic-bezier(0, 0, .1, 1);
}

/* ==========================================================================
   The loading screen

   THE PAGE COVERS ITSELF UNTIL IT IS TRUE. Before the script runs, this
   document shows open filters, an empty sort menu and zero counts, because
   every one of those is written by script. Their report, with a screenshot:
   it "looks seriously broken" until the list loads.

   `data-booting` LIVES ON THE BODY AND THE SCRIPT REMOVES IT. In the markup
   rather than added by script, so it is on screen for the moment it exists
   for. A browser that runs no script never gets the attribute removed, so
   the rule is written to fail toward the app: the cover is what hides, never
   the page.
   ========================================================================== */

.boot {
  position: fixed;
  inset: 0;
  z-index: var(--layer-modal);
  display: none;
  place-items: center;
  background: var(--bg);
  transition: opacity var(--duration-fold) var(--ease-out), display var(--duration-fold) allow-discrete;
}

body[data-booting] .boot { display: grid }

/* IT FADES OUT AND NEVER IN. The attribute is in the markup, so the cover is
   there on the first paint with nothing to animate from. Only its removal
   runs, which is the one moment worth softening. */
.boot { opacity: 0 }
body[data-booting] .boot { opacity: 1 }

/* THE PAGE UNDER IT IS NOT PAINTED EITHER. The cover is opaque, so this
   costs nothing to look at. It stops a screen reader reading a list that is
   still zero, and it stops the browser scrolling to a field the cover hides. */
body[data-booting] > main { visibility: hidden }

.boot-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-lg);
  width: min(320px, calc(100% - var(--space-xl) * 2));
}

/* THE MARK STANDS CLEAR OF THE BAR UNDER IT. Their report, 22 September 2026:
   "you need to put some padding under the icon in the loading overlay too."
   The column states 16 and this adds 8, painting 24, which is the same
   distance the empty state's own mark now takes at its own scale. */
.boot-mark { width: 48px; height: 48px; margin-block-end: var(--space-sm) }

/* A DETERMINATE BAR, BECAUSE THE STEPS ARE COUNTABLE. A spinner says only
   that something is happening. This says how much of it is left. */
.boot-track {
  width: 100%;
  height: 4px;
  overflow: hidden;
  border-radius: var(--radius-pill);
  background: var(--surface-raised);
}

.boot-fill {
  width: 0%;
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  transition: width var(--duration) var(--ease-out);
}

.boot-step {
  color: var(--text-muted);
  font-size: var(--text-sm);
  text-align: center;
}

/* ==========================================================================
   The note editor

   FULL SCREEN, THEIR CHOICE. A note is long-form and a phone has one column,
   so the editor takes the whole surface rather than growing a card inside a
   list.

   IT SITS OVER THE APP RATHER THAN REPLACING IT. The list keeps its scroll
   position, its filters and its selection, so closing a note puts the reader
   back exactly where they were.
   ========================================================================== */

.note-editor {
  position: fixed;
  inset: 0;
  z-index: var(--layer-modal);
  display: flex;
  flex-direction: column;
  background: var(--bg);
}

/* THE PAGE UNDER IT DOES NOT SCROLL. A reader flicking through a long note
   would otherwise drag the list behind it once the note ran out. */
body[data-note-open] { overflow: hidden }

.note-bar {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: var(--space-sm) var(--space-lg);
  border-bottom: 1px solid var(--line);
}

/* THE NAME TAKES THE SLACK, so the two actions sit at the far end however
   long the title is. Their instruction, 21 September 2026. */
/* THE SQUARE STANDS 16px FROM THE TITLE'S FIRST LETTER, the distance
   between the delete and download buttons. Their instruction, 5 October
   2026. The field carries a 4px inset and a 1px edge before its text, so
   the gap is what is left of the step. */
.note-name {
  display: flex;
  align-items: center;
  gap: calc(var(--space-lg) - var(--space-xs) - 1px);
  flex: 1;
  min-width: 0;
}

/* FLAT UNTIL IT IS FOCUSED. A bordered field in a title bar reads as a form,
   and the note's name is not a form. The border arrives on focus, which is
   the moment it is a field rather than a heading. */
.note-title {
  /* THE FIELD TAKES ITS TEXT, SO THE PEN SITS BESIDE THE WORDS. At `flex: 1`
     it spanned the whole bar and put the pen 1500px from the title it
     renames, which is the affordance pointing at nothing.

     `field-sizing` IS THE ONLY WAY AN INPUT SIZES TO ITS CONTENT. Chrome has
     it. Where a browser does not, the field falls back to its own default
     width and still works, so nothing breaks and the pen still sits beside
     it. */
  field-sizing: content;
  min-width: 12ch;
  max-width: 100%;
  height: max(var(--control-md), var(--control-floor));
  padding: 0 var(--space-xs);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--text);
  font-family: inherit;
  font-size: var(--text-md);
  font-weight: 650;
  outline: 0;
  text-overflow: ellipsis;
  transition: border-color var(--duration) var(--ease), background-color var(--duration) var(--ease);
}

.note-title::placeholder { color: var(--text-quiet) }
@media (hover: hover) {
  .note-title:hover { border-color: var(--line) }
}
.note-title:focus { border-color: #777; background: var(--surface-sunken) }

/* A READOUT, NOT A CONTROL, so it takes its own content and no slack. It
   used to take the middle and every pixel between the two buttons. */
/* "SAVED" STANDS 16px CLEAR OF THE DELETE BUTTON, the distance between
   delete and download. Their instruction, 5 October 2026: the bar's 8px gap
   and 8px of its own. */
.note-state {
  flex: 0 0 auto;
  padding-inline-end: var(--space-sm);
  color: var(--text-muted);
  font-size: var(--text-xs);
  white-space: nowrap;
}

.note-tools {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  /* THE GAP BETWEEN GROUPS BEATS THE GAP INSIDE ONE, at 16 against 4, which
     is four to one and clear of the two the rule asks for. Without it the
     eight controls read as one run and the pairs mean nothing. */
  /* ONE STEP ON BOTH AXES, BECAUSE THE CHILDREN ARE GROUPS. A wrapped run of
     like buttons shares one gap in both directions, and this is the other
     case: a line break here IS a boundary between the style control, the
     marks and the lists. It read 8 down against 16 across. */
  gap: var(--space-lg);
  padding: var(--space-sm) var(--space-lg);
  border-bottom: 1px solid var(--line);
}

.note-tool-group {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
}

/* EVERYTHING IN A CONTROL ROW IS ONE HEIGHT, and this row held three. The
   style control was a 36px field, the eight tool buttons were the 28px small
   step, and a "Style" label sat at 16. Measured 21 September 2026: the box
   was evenly padded at 8 above and below, and the reader still saw uneven
   padding, because three heights in one row is what that looks like.

   36 IS THE APP'S CONTROL HEIGHT. The search field, the tag filter and the
   New note button all measure it. The label is gone with them: the trigger
   states the level it is on, so a word beside it repeated nothing. */
/* `.multi.note-level`, NEVER `.note-level` ALONE. The tag filter's `.multi`
   states 160px and sits 1400 lines further down, so a rule of equal weight
   up here lost on order and the control rendered at 160. A dead rule reads
   as done.

   168 IS MEASURED, NOT PICKED. "Level 3 (Subheading)" inks 117.72px, plus
   the 8px mark gap, the 14px chevron, 12px of padding each side and the two
   borders: 165.72. The next step on the 4px grid is 168. Recompute it if any
   option's wording changes. */
.multi.note-level { width: 168px; flex: 0 0 auto }

/* THE HAMBURGER ONLY EXISTS WHERE THE BAR RAN SHORT, so it is absent by
   default and the two actions sit in the bar as themselves. */
#noteMenu { display: none }
/* Twice the bar gap after delete, so it reads as apart. */
#noteDelete { margin-inline-end: var(--space-sm) }

/* ══════════════════════════════════════════════════════════════════════════
   THE EDITOR FOLDS TO TWO ROWS. Their instruction, 22 September 2026: the
   download and the delete go into a hamburger, the four marks take one full
   row, and the style control shares the next row with the three lists.

   THE THRESHOLD IS THE TOOLBAR'S OWN SUM, AND THERE ARE TWO OF THEM, because
   a mark is 36px under a mouse and 44 under a finger. Measured:

     mouse   168 + 16 + (36*4 + 4*3) + 16 + (36*3 + 4*2) + 32 = 504
     finger  168 + 16 + (44*4 + 4*3) + 16 + (44*3 + 4*2) + 32 = 560

   ONE NUMBER WOULD BE WRONG ON ONE OF THEM. At 504 for both, a finger at
   520px kept the flat row, the lists wrapped on their own and sat at the
   left with 348px of empty bar beside them. Measured on that exact state:
   the group at 16 to 156 inside a 504px bar.

   GRID, NOT A WRAPPER PER ROW. The markup order is the wide order, and two
   line wrappers would either reorder it or need dissolving at every other
   width. Placement says which line each group is on and changes no markup.
   ══════════════════════════════════════════════════════════════════════════ */
@media (max-width: 503px), (pointer: coarse) and (max-width: 559px) {
  #noteGet,
  #noteDelete { display: none }
  #noteMenu { display: inline-flex }

  .note-tools {
    display: grid;
    grid-template-columns: auto 1fr;
    /* ONE STEP BETWEEN THE GROUPS, against the 4px inside each one. That is
       two to one, which is the bar. */
    gap: var(--space-sm);
  }

  .note-marks { grid-column: 1 / -1; grid-row: 1 }
  .note-lists { grid-column: 2; grid-row: 2 }

  /* IT KEEPS ITS MEASURED 168. Left at `auto` the grid gave it its
     min-content and it came out 77.4px, which clips "Level 3 (Subheading)"
     at 165.72. A control that cannot show its own value is broken, and that
     is the fault this same session fixed on the sort trigger. */
  .multi.note-level { grid-column: 1; grid-row: 2 }

  /* AND BELOW THE WIDTH WHERE THE PAIR FITS, EACH TAKES ITS OWN LINE. Two
     sums again, for the same reason:

       mouse   168 + 8 + (36*3 + 4*2) + 32 = 324
       finger  168 + 8 + (44*3 + 4*2) + 32 = 348

     Under that the three list marks fall to 26.7px each at 296. */
  @media (max-width: 323px), (pointer: coarse) and (max-width: 347px) {
    .multi.note-level { grid-column: 1 / -1; grid-row: 2; width: 100% }
    .note-lists { grid-column: 1 / -1; grid-row: 3 }
  }

  /* A STRETCHED MARK IS NO LONGER A SQUARE, so the ratio has to go. Left in
     place it reads the grown WIDTH and sets the height to match: at four
     buttons across 343px that is an 82px tall toolbar row. The height token
     stays the single writer. */
  .note-tool-group > .note-tool {
    flex: 1 1 0;
    aspect-ratio: auto;
    min-width: 0;
  }
}

/* A PRESSED TOOL IS A STATE, AND IT READS OFF aria-pressed RATHER THAN A
   CLASS. The attribute is what a screen reader hears, so a second marker
   would be a second thing that can disagree with it.

   THE SELECTED ROW'S OWN FILL, because that is what this system already
   uses to say "this one is chosen" and a second tint would be a second
   meaning for one idea. */
.note-tool[aria-pressed="true"] {
  background: var(--row-selected);
  color: var(--text);
}

/* ONE COLUMN, ONE MEASURE. Prose past about 70 characters costs the reader
   the line they are on, so the text is capped and centred while the box
   itself keeps the whole width for its scroll. */
.note-body {
  flex: 1;
  width: min(72ch, 100%);
  margin-inline: auto;
  padding: var(--space-xl) var(--space-lg);
  overflow-y: auto;
  outline: none;
}

/* NO RING ON THE DOCUMENT ITSELF. Every other focus ring here marks a
   control among other controls. This box is the whole screen, and a 2px
   accent outline around all of it reads as an error rather than as focus.

   THE CARET IS THE INDICATOR, which is what it is for a text box anywhere.
   It is on screen whenever this has focus, and there is nothing beside it
   to be confused with. */
.note-body:focus-visible { outline: none }

/* THE DOCUMENT'S OWN TYPE, because a note is a document. It carries `.dmd`,
   so every heading, list and link inside it is drawn by the rules the
   preview already states rather than by a second set written here. */
.note-body > :first-child { margin-block-start: 0 }

.note-body :where(h1, h2, h3, h4, h5, h6) { margin-block: var(--space-lg) var(--space-sm) }
.note-body :where(p, ul, ol) { margin-block: 0 var(--space-md) }
.note-body :where(ul, ol) { padding-inline-start: var(--space-xl) }
.note-body :where(li) { margin-block-end: var(--space-2xs) }
.note-body :where(a) { color: var(--accent) }
.note-body :where(del) { color: var(--text-muted) }

/* A TASK DRAWS A BOX WHERE ITS BULLET WAS. Their request, 5 October 2026.
   The box is the app's own checkbox, 16px with the same edge and the same
   accent fill, so a ticked task reads as the ticked boxes everywhere else.
   It sits in the list's own inset and centres on the first line, so a task
   that wraps keeps its box beside the line it starts. A ticked task's words
   go quiet and take a line through them. */
.note-body li[data-task] {
  position: relative;
  list-style: none;
}
.note-body li[data-task]::before,
.note-body li[data-task]::after {
  content: '';
  position: absolute;
  inset-inline-start: calc(var(--space-xl) * -1);
  top: calc((1lh - 16px) / 2);
  width: 16px;
  height: 16px;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.note-body li[data-task]::before {
  border: 1px solid #666;
  background: var(--surface-sunken);
}
.note-body li[data-task="done"]::before {
  border-color: var(--accent);
  background: var(--accent);
}
.note-body li[data-task="done"]::after {
  background: var(--accent-ink);
  mask: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M4 8.5l2.5 2.5L12 5.5" fill="none" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>') center / 16px 16px no-repeat;
}
.note-body li[data-task="done"] {
  color: var(--text-muted);
  text-decoration: line-through;
}

/* CODE TEXT IS THE MONO FACE AND A QUIET FILL. The face alone is not enough
   inside a sentence: both faces paint the same colour at the same size, so a
   run of three words changed shape and nothing said where it started. The
   fill draws the edge of the run instead.

   0.9em, NEVER A STEP. A mono face runs larger than the body face at one
   size, and this sits inside a line of body text whose leading may not grow
   for it. The block below takes the small step, because there it IS the
   line. */
.note-body :where(code) {
  padding: var(--space-2xs) var(--space-xs); /* half-step: inside one run */
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.9em;
}

/* A BLOCK IS A SURFACE, so it carries the padding and the edge a card does
   and its own element draws the fill. The code inside it takes none of the
   span treatment above, or the run would be boxed twice. */
.note-body :where(pre) {
  margin-block: 0 var(--space-md);
  padding: var(--space-md);
  overflow-x: auto;
  border: 1px solid var(--line-subtle);
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  line-height: var(--leading-body);
  white-space: pre;
  tab-size: 2;
}

.note-body :where(pre code) {
  padding: 0;
  border-radius: 0;
  background: none;
  font-size: inherit;
}

/* AN EMPTY NOTE SAYS WHAT TO DO. A blank screen with a caret is the one
   state a reader cannot tell from a broken one. */
.note-body:empty::before,
.note-body > p:only-child:empty::before {
  content: 'Write anything. Markdown works.';
  color: var(--text-quiet);
  pointer-events: none;
}

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

/* MOTION STOPS. A CROSS-FADE IS NOT MOTION, AND THE BLUNT FORM KILLED BOTH.
   This block read `transition-duration: 1ms !important` on every element.

   macOS carries Reduce Motion as a SYSTEM setting, so one switch turned every
   animation off in every browser on that machine. They reported exactly that.

   Counted in this file: 3 things travel and 31 rules fade. So the blunt form
   spent the whole interface to stop three. The travels are the chevron's
   rotation, the pill's glide between tabs, and the toast's rise. The last one
   and the skeleton's pulse are already gated on `no-preference`.

   Replacing a slide with a cross-fade is what the platform itself does under
   this setting, so the fades stay and nothing moves. */
@media (prefers-reduced-motion: reduce) {
  .multi-chevron { transition-property: none }

  /* A CARD'S FOLD IS TRAVEL, so the height jumps and the details fade in
     place. The fade stays, because a fade is not motion. */
  tbody tr { transition-property: none }
  .row-fold .icon { transition-property: none }

  /* THE NAME IS ON THE INDICATOR, NOT ON THE TAB, and this rule named the
     tab. It moved to the `::after` when the bar became the only thing that
     travels, and this line stayed where it was. So the pill glided for a
     reader who had asked for less motion, and nothing said so: the rule was
     live, it matched an element, and that element carries no name to remove.

     Measured 22 September 2026 by lifting this block out of the served file
     and injecting it: a swipe still ran a `::view-transition-group(tab-pill)`
     animation. With the `::after` named, the browser has one rectangle and
     nothing to glide between, so the pill cross-fades with everything else. */
  .tab[aria-selected="true"]::after { view-transition-name: none }

  /* THE ASK CARD'S TWO TRAVELS GO THE SAME WAY. The paragraph crossing the
     page and the list rising behind it are both travel, so the names come off
     and the page cross-fades from one state to the other instead. The card's
     own fade stays, because a fade is not motion. */
  :root[data-ask] #syncNote,
  :root[data-ask] .tabs,
  :root[data-ask] .list-panel { view-transition-name: none }

  /* A SLIDE IS TRAVEL, SO IT BECOMES A CROSS-FADE. That is the substitution
     the platform itself makes under this setting, and it is what this file
     already does for the fold. The two pages fade rather than move, and the
     `plus-lighter` blend comes back with them so the overlap reads right. */
  [data-vt="next"]::view-transition-old(list-panel),
  [data-vt="prev"]::view-transition-old(list-panel) { animation-name: -ua-view-transition-fade-out }
  [data-vt="next"]::view-transition-new(list-panel),
  [data-vt="prev"]::view-transition-new(list-panel) { animation-name: -ua-view-transition-fade-in }
  [data-vt="next"]::view-transition-old(list-panel),
  [data-vt="next"]::view-transition-new(list-panel),
  [data-vt="prev"]::view-transition-old(list-panel),
  [data-vt="prev"]::view-transition-new(list-panel) { mix-blend-mode: plus-lighter }

  /* THE FOLD IS A TRAVEL, so it goes. The opacity and the display flip stay,
     which is the cross-fade the platform keeps under this setting. app.js
     asks the same query and skips the measuring, because with no height in
     the run there is no transitionend to clear the pinned pixels. */
  #filterPanel { transition-property: opacity, display }
}

/* ==========================================================================
   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 the script writes `hidden` on, which is a question about
   the script rather than about the DOM, so no selector can ask it.

   THE GREP THIS COMMENT PRESCRIBED WAS NOT ENOUGH, AND TWO PANELS PROVED IT.
   It asked for `$('#x').hidden =`, and half the writes here go through a
   local: `menu.hidden`, `box.hidden`, `list.hidden`. So a rating menu and a
   notification's own cross shipped with no fade, and the reader reported both.

   `tools/fade-guard.mjs` asks the markup instead. Every element carrying a
   `hidden` attribute in index.html is something the script shows, because a
   permanently hidden one would not be in the page. Two shapes are exempt by
   declaration rather than by name: an `<svg>` sprite and a file picker.

   A CHILD OF A FADING BOX IS NOT IN THIS SET. The notification's own cross
   sits inside the clipper that fades with the fold, so a second fade on the
   cross is a second writer for one visual. Measured with it in the list: the
   fold finished at 273ms and the cross was still fading at 379, which reads
   as a straggler. It carries `data-fade="parent"` in the markup instead, and
   the guard counts that rather than approving a name.

   AND THE SET IS WRITTEN THREE TIMES, WHICH THE FIRST GUARD COULD NOT SEE.
   The transition, the resting `opacity: 0` and the `@starting-style` entry
   are three lists that must hold the same ids. Adding a panel to the first
   alone flips `display` at the far end and never moves the opacity: measured
   on the rating menu, display block through 236ms and none at 283, with
   opacity 1 the whole way. The guard compares all three.

   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,
#typedConfirm, #importPanel, #driveConnect, #rateList, #noteLevelList,
#driveGroup, #driveAlert, #tagFilterPanel, #sortList, #exportMenu, #noteGetMenu, #noteHuePop, #filterPanel,
#noteEditor, #rateGroup, #pager, #syncAsk, #adStrip {
  transition:
    opacity var(--duration) var(--ease),
    display var(--duration) allow-discrete;
}

#listRuntime[hidden], #searchClear[hidden], #state[hidden], #tagSuggest[hidden],
#tagBulkPanel[hidden], #typedConfirm[hidden],
#importPanel[hidden], #driveConnect[hidden], #driveGroup[hidden],
#driveAlert[hidden], #tagFilterPanel[hidden], #sortList[hidden], #exportMenu[hidden], #noteGetMenu[hidden], #noteHuePop[hidden],
#rateList[hidden], #noteLevelList[hidden],
#noteEditor[hidden], #rateGroup[hidden], #pager[hidden],
#filterPanel[hidden], #syncAsk[hidden], #adStrip[hidden] {
  opacity: 0;
}

@starting-style {
  #listRuntime:not([hidden]), #searchClear:not([hidden]), #state:not([hidden]),
  #tagSuggest:not([hidden]), #tagBulkPanel:not([hidden]),
  #typedConfirm:not([hidden]),
  #importPanel:not([hidden]), #driveConnect:not([hidden]),
  #driveGroup:not([hidden]), #driveAlert:not([hidden]),
  #tagFilterPanel:not([hidden]), #sortList:not([hidden]), #exportMenu:not([hidden]), #noteGetMenu:not([hidden]), #noteHuePop:not([hidden]),
  #rateList:not([hidden]), #noteLevelList:not([hidden]),
  #noteEditor:not([hidden]), #rateGroup:not([hidden]),
  #pager:not([hidden]),
  #filterPanel:not([hidden]), #syncAsk:not([hidden]), #adStrip:not([hidden]) {
    opacity: 0;
  }
}

/* ==========================================================================
   The filter panel folds by its own height

   Their instruction, 17 September 2026: a 500ms slide that pushes everything
   below it down, and back up on the way in.

   A HEIGHT, NOT A TRANSFORM. A transform moves the panel and nothing else, so
   the list would sit still and the panel would slide over it. The push is the
   whole ask, and only a length in flow can do it.

   `display` IS ALREADY IN THE RUN, with allow-discrete, so `hidden` flips at
   the far end rather than the near one and the box stays laid out while the
   height falls. That mechanism is the block above, and this reads the same
   attribute rather than adding a second state.

   THE PIXELS COME FROM app.js, because a height has nothing to interpolate
   against `auto`. The script measures the natural height, pins it for one
   frame, then sets the target.

   overflow: hidden ONLY WHILE IT RUNS. The tag menu inside this panel is
   absolutely placed and opens past the panel's own foot, so a permanent clip
   would cut it off. The script marks the panel for the length of the run.

   AND THE PADDING TRAVELS WITH THE HEIGHT, or the run ends in a jerk. Their
   report: "there's a weird jerking motion at the end of the animation."

   A HEIGHT OF ZERO IS NOT A BOX OF ZERO. `box-sizing: border-box` floors the
   used height at the padding, so this panel bottomed out at 37px, which is
   its own 12 and 25. The last 37 then went in one frame, when `display`
   flipped to none. Both ends move together now, and the box reaches 0.
   ========================================================================== */
@media (max-width: 1120px) {
  /* THE FADE IS THE CONTENT'S, NOT THE BOX'S. Their report: the band's rule
     "is now disappearing when the transition happens, and popping into
     existence at the end of it."

     OPACITY APPLIES TO AN ELEMENT AND EVERYTHING IT PAINTS, its pseudo-
     elements with it. So the panel fading took its own rule with it, and a
     pure ease-out spends the fade early: under 0.31 at 125ms of 500. The line
     was gone for three quarters of a slide it is supposed to ride.

     The clipper fades and the panel's box does not. The rule then travels at
     full strength and lands on the bar's own line, which is what it hands over
     to. Everything inside still cross-fades. */
  #filterPanel {
    transition:
      grid-template-rows var(--duration-fold) var(--ease-out),
      display var(--duration-fold) allow-discrete;
  }

  #filterPanel[hidden] { grid-template-rows: 0fr; opacity: 1 }

  .filters-fold { transition: opacity var(--duration-fold) var(--ease-out) }
  #filterPanel[hidden] .filters-fold { opacity: 0 }

  /* An element that was display: none has no previous style, so the entering
     fold needs a value to come FROM. */
  @starting-style {
    #filterPanel:not([hidden]) { grid-template-rows: 0fr }
    #filterPanel:not([hidden]) .filters-fold { 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. */
/* A RELOAD CROSS-FADES FROM THE OLD SCREEN. Their report, 3 October 2026:
   a refresh that loaded a newer build flashed dark. The browser keeps the
   last screen until the new page's first frame, which is the finished list,
   and fades between them. */
@view-transition { navigation: auto; }

::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. */
/* THE NAME IS ON THE INDICATOR, NOT ON THE TAB. Every tab takes the same
   share of the band now, so morphing the tab element moves nothing a reader
   can see. The bar is the only thing that travels. */
.tab[aria-selected="true"]::after { view-transition-name: tab-pill }

/* THE SWITCHER TAKES THE LONG PAIR. Their instruction: the same 500ms and the
   same eased-out curve as the filter fold. The pill only moves on a switch,
   so it needs no state to ask about. */
::view-transition-group(tab-pill) {
  animation-duration: var(--duration-fold);
  animation-timing-function: var(--ease-out);
}

/* AND THE ROWS UNDER IT, BUT ONLY ON A SWITCH. dissolve() runs on a filter,
   a sort and a deletion too, and 500ms on those is lag rather than motion.
   showTab() marks the root for the length of its own transition, so one
   attribute separates the switch from every other render. */
/* ══════════════════════════════════════════════════════════════════════════
   THE LISTS SLIDE PAST EACH OTHER. Their ask, 22 September 2026: "any chance
   we can have a left/right sliding animation between the tabs instead of
   crossfading?"

   THE STRIP IS THE FILMSTRIP. It reads left to right, so moving to a later
   tab moves the window right along it: the old list leaves to the LEFT and
   the new one arrives from the RIGHT. Going back is the mirror. app.js marks
   the root with the direction, because CSS cannot know which way the reader
   went.

   A SLIDE DOES NOT FADE. Two half-transparent pages laid over each other is
   the cross-fade this replaces, and it is why a slide has to name its own
   animation: the browser's default for these two is opacity. `both` holds
   the end frame so nothing snaps back before the transition ends.

   AND THE PAIR STOPS LIGHTENING. The default blend for a cross-fade is
   `plus-lighter`, which makes two solid pages glow where they overlap. A
   slide wants them opaque.
   ══════════════════════════════════════════════════════════════════════════ */
@keyframes tab-leave-left  { to   { transform: translateX(-100%) } }
@keyframes tab-enter-right { from { transform: translateX(100%) } }
@keyframes tab-leave-right { to   { transform: translateX(100%) } }
@keyframes tab-enter-left  { from { transform: translateX(-100%) } }

/* ONLY THE PANEL MOVES. Their report, 22 September 2026: "why is it sliding
   the entire page? i only want the section BELOW THE TABS to slide, not
   everything!"

   The name was on the ROOT, which is the whole document, so the title bar,
   the tab strip and the footer travelled with the list. Naming this box
   lifts it out of the root's own picture, so the two are drawn separately
   and only this one is told to move.

   ONLY WHILE AN ANIMATION RUNS. A name makes the box one flat layer, so at
   rest everything inside it, the sticky paste card included, sat under the
   page's top strip whatever its own layer number said. Their report,
   26 September 2026: the card's top edge was still cut off. The strip is
   meant to sit under the card and over the rows, and with the name on at
   rest it sat over both. */
:root:is([data-vt-run], [data-ask]) .list-panel { view-transition-name: list-panel }

/* THE ADD CARD HOLDS STILL WHILE THE TABS SLIDE. Their instruction,
   3 October 2026: the field stays put across all three tabs, and only the
   heading above it and the list below it travel. A name of its own lifts it
   out of the panel's picture, and a group with no animation stays where it
   is. Its contents are the same on every tab, so nothing inside it changes. */
:root[data-vt-run] .add-card { view-transition-name: add-card }
::view-transition-group(add-card),
::view-transition-old(add-card),
::view-transition-new(add-card) { animation: none }
::view-transition-old(add-card) { display: none }

[data-vt="next"]::view-transition-old(list-panel),
[data-vt="next"]::view-transition-new(list-panel),
[data-vt="prev"]::view-transition-old(list-panel),
[data-vt="prev"]::view-transition-new(list-panel) {
  animation-duration: var(--duration);
  animation-timing-function: var(--ease-slide);
  animation-fill-mode: both;
  mix-blend-mode: normal;
}

/* A PICTURE OF A BOX THAT CHANGES SIZE IS STRETCHED TO FIT IT, and that is
   what reads as anything but a smooth move.

   THE PANEL IS A DIFFERENT HEIGHT ON EVERY TAB, and a different height again
   once the ask card goes. The group animates between the two, so the old
   list squashes toward the new height while it travels. Their report,
   22 September 2026, about the dismissal: "still not rising smoothly."
   Measured on that run: the panel grew 441px to 675 while its top rose 234,
   so every row inside the old picture was stretched over the whole 500ms.

   EACH PICTURE KEEPS ITS OWN SIZE AND HANGS FROM THE TOP. The box grows
   underneath them and more of the new one shows, which is a rise rather than
   a stretch.

   ONE RULE, BOTH TRANSITIONS. The swipe and the dismissal state their own
   durations and share this, because it is one answer to one question. */
[data-vt="next"]::view-transition-old(list-panel),
[data-vt="next"]::view-transition-new(list-panel),
[data-vt="prev"]::view-transition-old(list-panel),
[data-vt="prev"]::view-transition-new(list-panel),
[data-ask]::view-transition-old(list-panel),
[data-ask]::view-transition-new(list-panel),
[data-ask]::view-transition-old(sync-note),
[data-ask]::view-transition-new(sync-note) {
  height: 100%;
  width: 100%;
  object-fit: none;
  object-position: top left;
  overflow: clip;
}

/* THE CONTENT FOLLOWS THE FINGER. Their ask, 22 September 2026: reverse the
   slide direction.

   The first reading was the filmstrip: the strip runs left to right, so a
   later tab moved the window right and the pages travelled left. This is the
   other convention, where the page goes the way the hand went. A swipe right
   still steps to a later tab, which is their earlier instruction and is
   unchanged. Only the direction the pictures travel is reversed. */
[data-vt="next"]::view-transition-old(list-panel) { animation-name: tab-leave-right }
[data-vt="next"]::view-transition-new(list-panel) { animation-name: tab-enter-left }
[data-vt="prev"]::view-transition-old(list-panel) { animation-name: tab-leave-left }
[data-vt="prev"]::view-transition-new(list-panel) { animation-name: tab-enter-right }

/* AND THE PAGE AROUND IT HOLDS STILL. With the panel lifted out, the root's
   own picture is everything else: the bar, the tabs and the footer. Those do
   not change between tabs, so they are told not to animate at all rather
   than cross-fading against themselves. */
[data-vt="next"]::view-transition-old(root),
[data-vt="next"]::view-transition-new(root),
[data-vt="prev"]::view-transition-old(root),
[data-vt="prev"]::view-transition-new(root) { animation: none }

/* THE PILL KEEPS PACE WITH THE PANEL. At the fold's 500ms it was still
   gliding under a list that had finished arriving. */
[data-vt="next"]::view-transition-group(tab-pill),
[data-vt="prev"]::view-transition-group(tab-pill),
[data-vt="next"]::view-transition-group(list-panel),
[data-vt="prev"]::view-transition-group(list-panel) {
  animation-duration: var(--duration);
  animation-timing-function: var(--ease-slide);
}

/* ══════════════════════════════════════════════════════════════════════════
   THE PANEL FOLLOWS THE FINGER
   Their ask, 22 September 2026: "make the slide actually stay responsive
   under the finger ... if i release it past a certain threshold toward the
   left or the right, it would slide to that tab, otherwise snap back."

   A VIEW TRANSITION CANNOT DO THIS. It snapshots the page and plays a fixed
   animation, so nothing in it can read a finger. A drag needs both pages
   mounted at once, which is the one shape a view transition exists to avoid.

   So the drag mounts its own second page: a copy of the panel as it was,
   fixed over the real one, while the live panel renders the tab being
   dragged in. The script moves the pair. It removes the copy the moment the
   gesture ends, so only one list is mounted at rest.

   IT PAINTS NOTHING A READER CAN REACH. The copy is out of the
   accessibility tree and takes no pointer events, so a tap during the drag
   lands on the real page underneath.
   ══════════════════════════════════════════════════════════════════════════ */
.panel-ghost {
  position: fixed;
  z-index: var(--layer-raised);
  margin: 0;
  overflow: clip;
  pointer-events: none;
  background: var(--bg);
}

/* THE WHOLE GESTURE IS ONE LAYER EACH, so the browser rasters the two pages
   once and then only moves them. */
:root[data-drag] .list-panel,
.panel-ghost { will-change: transform }

/* THE STILL COPY OF THE ADD CARD, over both pages while a finger drags. */
.add-card.card-shot {
  position: fixed;
  z-index: calc(var(--layer-raised) + 1);
  margin: 0;
  pointer-events: none;
}

/* AND ON A TAP. Their instruction, 3 October 2026: the pager ducks out and
   back on a tab switch by tap too. It takes its own picture for the length
   of the switch, so it moves only up and down: the old one is gone by the
   midpoint and the new one rises from there. A tab without a pager has no
   new picture, so nothing rises. */
:root[data-vt-run]:is([data-vt="next"], [data-vt="prev"]) #pager:not([hidden]) { view-transition-name: pager }
::view-transition-group(pager) { animation: none }
:is([data-vt="next"], [data-vt="prev"])::view-transition-old(pager) {
  animation: pager-duck var(--duration) var(--ease-slide) both;
}
:is([data-vt="next"], [data-vt="prev"])::view-transition-new(pager) {
  animation: pager-rise var(--duration) var(--ease-slide) both;
}
@keyframes pager-duck {
  0% { transform: translateY(0) }
  50%, 100% { transform: translateY(calc(100% + var(--space-2xl))); opacity: 0 }
}
@keyframes pager-rise {
  0%, 50% { transform: translateY(calc(100% + var(--space-2xl))); opacity: 0 }
  100% { transform: translateY(0) }
}

/* PULL TO REFRESH. Their request, 3 October 2026. The strip the page slides
   down to reveal: a ring that fills in the accent, and under it the words,
   which arrive only once the strip can hold both with 32px above and below. */
.ptr {
  --ptr-gap: var(--space-sm);
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  height: 0;
  display: flex;
  /* CENTRED IN THE GAP, so they slide down with the page. Their choice,
     3 October 2026, after trying them pinned to the top. */
  align-items: center;
  justify-content: center;
  overflow: clip;
  pointer-events: none;
  opacity: 0;
}
.ptr-stack { display: flex; flex-direction: column; align-items: center }
.ptr-ring { width: var(--space-xl); height: var(--space-xl); transform: rotate(-90deg) }
.ptr-track, .ptr-fill { fill: none; stroke-width: 2.5; vector-effect: non-scaling-stroke }
.ptr-track { stroke: var(--line) }
.ptr-fill { stroke: var(--accent); stroke-linecap: round; stroke-dasharray: 100; stroke-dashoffset: 100 }
.ptr-label {
  max-height: 0;
  margin-block-start: 0;
  opacity: 0;
  overflow: clip;
  color: var(--text-muted);
  font-size: var(--text-sm);
  line-height: var(--lead-sm);
  white-space: nowrap;
  transition:
    max-height var(--duration) var(--ease-out),
    margin-block-start var(--duration) var(--ease-out),
    opacity var(--duration) var(--ease-out);
}
.ptr[data-roomy] .ptr-label { max-height: var(--lead-sm); margin-block-start: var(--ptr-gap); opacity: 1 }

/* THE SWIPE'S COPY OF THE LIST DRAWS EVERY ROW AT ONCE. A row skips its own
   layout until it is on screen and stands at a guessed 130px meanwhile, and
   a fresh copy has no remembered size, so its first frame laid every row
   out at the guess and the list jumped, then settled. Measured at 375: the
   copy's second row at 493 against 415 on the page. Their report of a
   flicker at the start of every swipe, 3 October 2026. */
.panel-ghost tbody tr { content-visibility: visible }

/* THE PAGER'S STILL COPIES, which sink and rise with the drag. */
/* TWO CLASSES, because `.pager` is stated later in this file and its sticky
   position won at one class each. The copy then sat in the page's flow and
   the pager read as vanishing. Their report, 3 October 2026. */
.pager.pager-shot {
  position: fixed;
  inset: auto;
  z-index: calc(var(--layer-raised) + 1);
  margin: 0;
  pointer-events: none;
}

/* AND NOTHING UNDER THE FINGER CROSSES THE PANEL'S EDGE while it travels. */
:root[data-drag] .list-panel { overflow: clip }

/* ONE AXIS AT A TIME. The page keeps the vertical axis at rest, and the drag
   takes both the moment it starts. Without this a finger travelling
   diagonally scrolls the list it is dragging away. */
:root[data-drag] body,
:root[data-drag] .table-wrap { touch-action: none }

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

   IT NEEDS NO REDUCED-MOTION GUARD, and the reason is the property list
   above rather than a second rule. Nothing here changes a position or a
   size, so there is no motion to reduce. */
: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);
}

/* THE HORIZONTAL AXIS IS THE APP'S, AND THE VERTICAL STAYS THE BROWSER'S.
   Their report, 22 September 2026: the tab swipe did nothing on the phone.

   A BROWSER OWNS A TOUCH GESTURE UNTIL SOMETHING SAYS OTHERWISE. Once it
   starts scrolling it fires `pointercancel` and sends nothing further, so a
   handler waiting for `pointerup` is never called. Every synthetic test
   passed, because dispatching the events bypasses that decision entirely.

   `pan-y` KEEPS VERTICAL SCROLLING, which is what this app is mostly used
   with, and hands the horizontal over. `pinch-zoom` is named too: `pan-y`
   alone takes the zoom away, and nothing here asked for that.

   NOT `none`. That would take vertical scrolling with it, and this rule is on
   the body. */
:where(body) { touch-action: pan-y pinch-zoom }

/* AND THE LIST IS ITS OWN SCROLLER, so the declaration has to reach it too.
   A rule on the body does not govern a gesture that begins inside a box with
   its own overflow. */
:where(.table-wrap) { touch-action: pan-y pinch-zoom }

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

/* NO TAP FLASH. Android paints a blue box over whatever a finger presses, and
   a card flashed blue on every tap. Their report, 1 October 2026. Every
   control already shows its own pressed and focused states. */
* { -webkit-tap-highlight-color: transparent }

/* EVERY LINE BOX LANDS ON THE GRID, AND IT TAKES A RULE PER ELEMENT. A ratio
   inherits as a ratio, so each element recomputes it from its own size. A
   LENGTH inherits as that length, so setting it once on the body would give a
   24px heading the body's own 20px line.

   The ratio stays as the first declaration. A browser without round() then
   keeps today's leading rather than falling back to the font's own normal.

   It carries no weight, so every component stating its own line-height still
   wins. Two do, and both are snapped the same way. */
:where(*) {
  line-height: var(--leading-body);
  line-height: var(--lead-body);
}

: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);
  /* THE SIDE PADDING IS NOT THE TOP PADDING, and one name for both is how a
     full-bleed child misses the page edge. `--page-pad` is 24 here and the
     inline padding is 32, so a band subtracting the first stopped 8px short
     on each side. Measured: the band at x 169 against the shell's own 161.
     Published so a child can ask the box that made the inset. */
  --page-inline: var(--space-2xl);
  max-width: 1380px;
  margin: auto;
  /* THE SAME DISTANCE ABOVE AND BELOW. Their instruction, 30 September
     2026: the page sat 24px from the window's top and 48px from its foot,
     "a ton of wasted pixels". One step for both. */
  padding: var(--page-pad) var(--space-2xl);

  /* THE FOOTER HUGS THE BOTTOM WHEN NOTHING SCROLLS. Their idea, 22
     September 2026: "what if you set the footer to just hug the bottom of
     the viewport if there is no scrolling required?"

     Measured at 375x1200 with no notes: the page came to 979 and left 221px
     of empty screen under the footer. The column fills the viewport instead,
     and the footer takes whatever is spare.

     `min-height`, NEVER `height`. A long list has to grow past the viewport,
     and the locked layout below states its own fixed height for the one case
     that must not. */
  display: flex;
  flex-direction: column;
  min-height: calc(100dvh - var(--ad-strip, 0px));
}

/* THE PANEL IS THE COLUMN THAT FILLS IT. Wrapping the list broke the locked
   layout, because `.table-wrap` takes `flex: 1` from its parent and its
   parent had been the shell. Measured at 1536x900 before this: the shell held
   its 900, the scroller sat at its content height of 783, and the document
   scrolled to 1250 when a locked app scrolls nothing.

   `min-height: 0` is what lets it shrink under its own content. Without it
   the column pushes past the viewport and gives the document its scrollbar
   back, which is the same fault one box along. */
.list-panel {
  display: flex;
  flex-direction: column;
  flex: 1;
  min-height: 0;
}

/* ==========================================================================
   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. */
/* `.page-head`, NEVER A BARE `header`. This is the app's own top bar, and a
   tag selector reaches every header anywhere. The note editor's bar is one,
   so it took this rule's 24px bottom margin, its `space-between` and its
   wrap. Measured 21 September 2026: 24px of empty band sat between the title
   bar and the toolbar, so the reader saw 32px above the toolbar's controls
   against 8 below and reported the padding as uneven. It was even. The gap
   above it was not.

   A CLASS CANNOT REACH SOMETHING NOBODY GAVE IT TO. */
.page-head {
  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-lg);
  background: var(--accent);
  color: var(--accent-ink);
}

/* THE MARK FILLS 64% OF ITS SQUARE, ring edge to ring edge, the share the
   launcher icon uses. Its drawing spans 83.5% of its own box, so the box
   is sized up by that. */
.brand-mark {
  --brand-icon: calc(var(--control-sm) * 0.64 * 512 / 427.4);
  /* THE SQUARE RESTS ON ITS MARK'S BOTTOM EDGE, because the mark is the only
     thing in it the row can take a baseline from. So a bigger mark lowered
     the square's resting point. Their report, 25 September 2026: "alignment
     is fucked". Measured: the square's centre at 30.00 against the name's
     cap band at 33.22, 3.22px high.

     Half the difference between the mark and the cap height moves the
     square's centre onto the cap band, the same lift every mark beside a
     label takes here. `em` is the name's own size, which the square
     inherits. */
  transform: translateY(calc((var(--brand-icon) - var(--cap-ratio) * 1em) / 2));
}

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

/* 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. */
/* THE STATE STANDS 16px CLEAR OF THE BUTTON AFTER IT, the step the note bar
   keeps between "Saved" and its first button. Their instruction, 5 October
   2026. The group's 8px gap, the box's 1px edge and 7px of its own. */
.sync-state {
  display: inline-flex;
  align-items: center;
  /* 8px from the dot to its words, doubled from 4 on their word, 5 October
     2026. Against the 16 after the words it is still two to one. */
  gap: var(--space-sm);
  padding-inline-end: calc(var(--space-sm) - 1px);
  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);
}

/* THREE COLOURS, AND EACH ANSWERS ONE QUESTION. Green: the app owes the
   network nothing. Amber: a request is in flight. Red: something is waiting,
   either a row with no details or a Drive push that failed.

   A FOURTH RULE FOR `off` WAS DEAD. The code wrote 'local' and the sheet
   styled 'off', so "Stored locally" painted the default green by accident.
   The colour was right and nothing chose it. */
.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);
}

@media (hover: hover) {
  .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);
}
@media (hover: hover) {
  .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);
}
@media (hover: hover) {
  .btn-danger-outline:hover { background: #2a1414 }
}

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

.btn:disabled { opacity: .4; cursor: not-allowed }
@media (hover: hover) {
  .btn:disabled:hover { background: var(--surface-raised) }
}
@media (hover: hover) {
  .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);
}

@media (hover: hover) {
  .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 }

/* ==========================================================================
   The select field is gone, and the rule set with it

   A native select's option list is drawn by the operating system. It takes
   none of this file's colours, none of its type and none of its transitions,
   so no rule here could give it the fade every other menu has. Their report:
   the menu animation is "nice and smooth in desktop view, absent everywhere
   else", then "in fact the sort by menu also looks very different".

   The sort control is the tag filter's own dropdown now, so nothing carries
   .select-field and every rule that named it reached no element.
   ========================================================================== */


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

.add-card {
  /* THE SECTION STEP, BECAUSE THE TITLE NO LONGER CARRIES IT. This card used
     to be followed by the list head, whose own top margin made the distance.
     With the head above it the card met the filter row at 0.00px.

     IT COSTS THE STUCK LAYOUT NOTHING. `--stick-1` is the page padding plus
     this card's height, so the filter row lands flush under it while both are
     stuck, and the margin is only ever seen in flow. */
  margin-block-end: var(--space-xl);
  padding: var(--space-xl);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface);
}

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

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

/* THE FIELD'S SECOND PART TAKES TAGS. Their instruction, 3 October 2026.
   It wears the field's own box, so the two read as one field split in two.
   On a wide row it is one line and the pills scroll sideways under the
   arrow keys; on a narrow one it takes its own line and wraps. */
.add-row #videoUrl { flex: 3 1 0 }
.add-tags {
  flex: 2 1 0;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2xs);
  min-height: max(var(--control-md), var(--control-floor));
  padding: 0 var(--space-2xs);
  border: 1px solid #444;
  border-radius: var(--radius-sm);
  background: var(--surface-sunken);
  cursor: text;
  overflow: clip;
  transition: border-color var(--duration) var(--ease);
}
.add-tags:focus-within { border-color: #777 }
.add-tags-pills {
  display: flex;
  flex: 0 1 auto;
  gap: var(--space-2xs);
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
.add-tags-pills:empty { display: none }
/* PILLS SCROLLED OFF THE START FADE OUT, so the edge says there is more. */
.add-tags-pills[data-cut] { mask-image: linear-gradient(to right, transparent, #000 var(--space-lg)) }
.add-tags-pills::-webkit-scrollbar { display: none }
.add-pill { flex: none; cursor: default }
.add-pill:focus-visible { outline: 2px solid var(--text); outline-offset: -2px }
.add-pill-x {
  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;
}
@media (hover: hover) {
  .add-pill-x:hover { color: var(--text); background: #ffffff14 }
}
.add-tags .add-tag-input {
  flex: 1 1 96px;
  min-width: 96px;
  padding-inline: var(--space-sm);
  color: var(--text);
}
.add-tags .add-tag-input:focus { background: transparent }
@media (hover: hover) {
  .add-tags .add-tag-input:hover { background: transparent }
}

/* THE PASTE BUTTON WEARS THE ACCENT, outline and mark, so it stands out
   from the field beside it. Their instruction, 29 September 2026. */
/* WHITE, WITH NO OUTLINE. Their instruction, 28 September 2026, replacing
   the accent outline of the day before. The mark takes the page colour. */
#pasteBtn { border-color: transparent; color: var(--accent); background: #fff }
@media (hover: hover) {
  #pasteBtn:hover { background: color-mix(in oklch, #fff 86%, var(--bg)) }
}

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


/* THE MESSAGE ARRIVES AND LEAVES ON A GRID TRACK, so the card grows and
   shrinks around it. Their instruction, 19 September 2026: the notification
   fades out over 250ms while the card's height shrinks back, and the card
   grows as it fades in.

   A HEIGHT WOULD HAVE TO BE MEASURED AND A TRACK DOES NOT. 1fr IS the
   message's natural height, whatever it wraps to, so a two-line message needs
   no second number. */
.status-line {
  display: grid;
  grid-template-rows: 1fr;
  margin-block-start: var(--space-md);
  transition:
    grid-template-rows var(--duration) var(--ease),
    margin-block-start var(--duration) var(--ease);
}

/* THE DISTANCE TRAVELS WITH THE HEIGHT, AND IT IS A MARGIN. Written as
   padding inside this box the two siblings would still meet at 0.00px, which
   is the fault a bare block between two others always is. A margin sits
   BETWEEN them, so the add row stands 12px clear while the message is up and
   nothing at all when it is away. */
.status-line:not([data-shown]) { grid-template-rows: 0fr; margin-block-start: 0 }

/* THE FADE GOES ON THE CLIPPER, NEVER ON THE GRID BOX. Opacity applies to
   everything an element paints, so fading the outer box would fade the row's
   own distance with it and leave nothing to travel.

   `overflow: clip` rather than hidden: a hidden box is still a scroll
   container, and the dismiss control inside it can be focused.
   `min-height: 0` or a grid item refuses to go under its own content. */
.status-clip {
  min-height: 0;
  overflow: clip;
  transition: opacity var(--duration) var(--ease);
}

.status-line:not([data-shown]) .status-clip { opacity: 0 }

/* THE ROW CARRIES NO PADDING, because the distance above it is the outer
   box's margin. A padded clipper could not reach zero either:
   `box-sizing: border-box` floors a box at its own padding, so the last 12px
   of the travel would be a jump.

   THE ROW CARRIES THE MESSAGE'S TYPE, or the mark's cap band is computed from
   the wrong size. The lift is stated in em, and em resolves against the
   element carrying the transform. */
.status-row {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  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. */
/* AND THE WORDS ARE WHITE. Their instruction, 22 September 2026: "the text
   itself should be white, so that it's readable at smaller sizes. the grey
   looks fancy but offers little in the way of visibility."

   Measured on the card at 12px: the muted grey reads 6.85:1 and the page's
   own text reads 14.43. */
.status-row .hint { flex: 1; min-width: 0; margin: 0; color: var(--text) }

/* THE MARK SAYS WHICH KIND OF MESSAGE THIS IS, and its SHAPE says it as well
   as its colour. Three tones: a fault, a limit, and a fact.

   IT SITS ON THE MESSAGE'S FIRST LINE. A mark beside a label lives between
   the cap line and the baseline, which is the same lift the dismiss control
   beside it takes. */
.status-mark {
  flex: 0 0 auto;
  align-self: baseline;
  width: var(--mark);
  height: var(--mark);
  transform: translateY(calc((100% - var(--cap-ratio) * 1em) / 2));
}

.status-row[data-tone="error"] .status-mark { color: var(--danger) }
.status-row[data-tone="warn"] .status-mark { color: var(--warn) }
.status-row[data-tone="info"] .status-mark { color: var(--text) }

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

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

/* TWO SMALL CONTROLS SIDE BY SIDE MUST NOT SHARE A TAP. Each one's reach is
   wider than its drawn mark, so the marks sit at least one reach apart,
   centre to centre. The row's own gap already gives part of that, so the
   margin is the difference and never adds to it: 10px apart on a mouse,
   30px on a finger. */
.status-x + .status-x {
  margin-inline-start: max(0px, calc(
    max(var(--target-mouse), var(--control-floor)) - var(--mark) - var(--space-sm)));
}

.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(--lead-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(--lead-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 }
  /* AN OPTICAL CORRECTION, NOT A DISTANCE. It drops the mark onto the
     title's cap band, so it answers to the ink rather than to the grid. */
  .alert-mark { align-self: flex-start; margin-top: 2px /* optical */ }
  .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) }

.progress-track {
  height: var(--space-sm);
  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: calc(var(--space-lg) + var(--ad-strip, 0px));
  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;
}

/* ONE LINE CENTRES ON THE CLOSE BUTTON. Their report, 30 September 2026: a
   one-line notice hung its tick and words at the top of a box the 44px close
   button made tall, with empty space below them. With a second line the
   tick stays on the first line and the cross centres on the block. */
.toast {
  display: flex;
  align-items: center;
  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. */
/* The same optical correction as the alert's mark, for the same reason. */
.toast-mark { flex: 0 0 auto; margin-top: 2px /* optical */; 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:has(.toast-detail) .toast-mark { align-self: flex-start }
/* Centred, the tick keeps the same 2px optical drop its top-aligned margin gave it. */
.toast:not(:has(.toast-detail)) .toast-mark { margin-top: 0; translate: 0 2px /* optical */ }
.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 MATERIAL TAB BAND ──────────────────────────────────────────────
 *
 * Their instruction, 21 September 2026: tabs at every size, edge to edge,
 * in the material style. So the segmented pill group is gone, and with it
 * the dropdown the old strip turned into below 460.
 *
 * IT IS THE WIDTH OF THE PAGE'S OWN COLUMN. Their correction, 22 September
 * 2026, with a screenshot: "tab bar width needs to be the same as that of the
 * other elements, instead of extending past them."
 *
 * IT USED TO SPAN THE VIEWPORT, on the reading that a band under an app bar
 * is chrome rather than a control in the page. Measured at 1536: the band ran
 * 1536px against 1316 for the card, the paste field and the table, so it was
 * the one thing on screen that reached past the margins. Their eye is the
 * instrument for that, and the two edges now agree.
 *
 * THE RULE UNDER IT SPANS THE WHOLE ROW, and the chosen tab's indicator
 * sits on top of it. That is what says the band and the list are one thing.
 */
.tabs {
  display: flex;
  flex-wrap: nowrap;
  /* THE BAND OWNS THE DISTANCE TO THE HEADING UNDER IT. Their instruction,
     22 September 2026, with a screenshot of "11 Bookmarks": 8px more above
     the title. 24 to 32, which is the next step rather than a number off the
     scale. One writer, because the band is the thing above it at every
     width. */
  margin-block-end: var(--space-2xl);
  border-block-end: 1px solid var(--line);
}

/* EVERY TAB TAKES THE SAME SHARE, which is what makes the band a band. At
   `flex: 0 0 auto` three labels of different lengths leave a ragged end and
   the row stops reading as one control.

   48 IS THE MATERIAL HEIGHT, and it clears both pointer floors, so the
   ::after that used to carry the touch reach is gone with the pill. */
.tab {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
  flex: 1 1 0;
  min-width: 0;
  height: 48px;
  padding: 0 var(--space-sm);
  border: 0;
  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);
}

@media (hover: hover) {
  .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.
   THE INDICATOR IS A BAR ON THE RULE, not a fill. It paints over the band's
   own hairline rather than beside it, so the two never read as two lines. */
.tab[aria-selected="true"] { color: var(--text) }

.tab[aria-selected="true"]::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: -1px;
  height: 2px; /* half-step: a rule's weight, like a hairline, not a step */
  background: var(--accent);
}

/* A TAB THAT CANNOT HOLD ITS MARK, ITS NAME AND ITS COUNT ON ONE LINE DROPS
   THE COUNT FIRST, THEN THE MARK. Their call, 25 September 2026, shown three
   ways: on phones the counts go and the marks stay. The count is still the
   list's heading, so nothing is lost.

   Measured that day: Bookmarks needs 134.5px with everything, and a 375px
   phone gives each tab 114.3. At 296 a tab is 88px, which holds the name
   alone but not the name with its mark. 144 leaves room for a three-digit
   count, and 108 for the widest name with its mark.

   A QUERY MEASURES INSIDE THE PADDING, so each figure is the tab's width
   minus its 16px of padding. Written as the whole width, the marks hid at
   375 and 480 as well.

   AND AT 296 THE NAME ALONE WAS 2PX TOO WIDE, before any mark existed. The
   row there borrows 4px of padding on each side rather than spilling.

   The tab is its own container, so the answer follows the tab's width rather
   than the window's. Its width comes from the row, so the containment costs
   nothing. */
.tab { container-type: inline-size }

/* THE MARK AND THE COUNT FADE AS A WIDTH TAKES THEM AWAY OR GIVES THEM
   BACK. Their choice, 26 September 2026: the same three states, animated.
   The display flip waits for the fade out and leads the fade in. */
/* AND THE WORD GLIDES INTO THE ROOM THEY LEAVE. Their rule, 26 September
   2026: always the smooth option, never the jumpy one. A hidden part keeps
   its box while it fades, so its width and the gap beside it shrink to 0
   with it rather than vanishing when the fade ends. */
.tab .icon, .tab .tab-count {
  max-width: 4em;
  overflow: hidden;
  transition:
    opacity var(--duration) linear,
    max-width var(--duration) cubic-bezier(0, 0, .2, 1),
    margin var(--duration) cubic-bezier(0, 0, .2, 1),
    display var(--duration) linear allow-discrete;
}
@starting-style {
  .tab .icon { opacity: 0; max-width: 0; margin-inline-end: calc(var(--space-sm) * -1) }
  .tab .tab-count { opacity: 0; max-width: 0; margin-inline-start: calc(var(--space-sm) * -1) }
}
@container (max-width: 128px) {
  .tab .tab-count { display: none; opacity: 0; max-width: 0; margin-inline-start: calc(var(--space-sm) * -1) }
}
@container (max-width: 92px) {
  .tab .icon { display: none; opacity: 0; max-width: 0; margin-inline-end: calc(var(--space-sm) * -1) }
}
@container (max-width: 76px) {
  .tab .tab-inner { margin-inline: calc(var(--space-xs) * -1) }
}

/* THE MARK, THE NAME AND THE COUNT ARE ONE ROW ON ONE BASELINE, so the mark
   can sit between the name’s cap line and baseline. The row is its own box,
   centred in the tab, so it never wraps onto a second line. */
.tab-inner {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  min-width: 0;
}

.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) {
  /* A BAR PAINTED WITH `background` IS DISREGARDED IN FORCED COLORS, so the
     chosen tab loses its only marker there. An outline survives that mode. */
  .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 IS GONE AND SO IS THE ROW. This block held the count beside a
   pill group, and the comment here carried six measurements about keeping
   the two on one line: the group's 34px height, the wrap-reverse that put
   the picker above the title, the cross-axis flip that came with it, and
   the 568 switch. Every one of them described a second child that no longer
   exists.

   Their instruction, 21 September 2026: the tabs are a band of their own
   above this. So the block holds the words and nothing else. */
.list-head {
  /* THE TITLE HEADS THE PAGE NOW, so both numbers moved. Their instruction,
     21 September 2026: the title and the tab selector sit above the link
     field, in every view.

     NO TOP MARGIN. The page header already publishes 24 below itself, and
     two writers on one distance give a gap nobody chose. It measured 48.

     24 BELOW, WHICH IS THE SECTION STEP. The old 8 had its reason written
     beside it: the runtime line sat nearer the filter row than the strip
     above it. Nothing is below this but the add field now, so that reason
     is spent. */
  margin: 0 0 var(--space-xl);
}

/* THE COUNT AND THE RUNTIME SIT ON ONE LINE, AND THE RUNTIME IS BESIDE IT.
   Their instruction, 22 September 2026: put the time counter next to the
   count. The day before, it was the two edges of the row. It went to the far
   edge first, then beside the count once "Saved" left the noun and the
   runtime took its short form.

   TEXT BESIDE TEXT TAKES A BASELINE, whatever the two sizes are. The count is
   an h2 and the runtime is a small mono line, so box centring would leave
   them on two different lines of ink.

   `flex-start`, NOT `space-between`. Spread across the row, the runtime sat
   at the far edge, which is what they asked to change. Nothing states an auto
   margin, so there is no slack to place and the declared gap is the whole
   distance.

   IT STILL WRAPS, and the wrapped line still packs at the start. A margin
   cannot say "first on this line", which cost 85px of false indent on the
   pager. Measured with their own figures at 375: the pair needs 226.6px
   against 343 available, so it does not wrap there. A longer library will. */
.list-words {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2xs) var(--space-md);
  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 row gap is 2px, which only paints at the width where the runtime drops
   onto its own line. On one line the distance is the slack. */
.list-runtime {
  margin: 0;
  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);
}

/* THE FUNNEL ONLY EXISTS WHERE THE ROW STACKS. Above 1120 the filter row is
   61px on one line and folding it saves a reader nothing. Below it the row
   is 236, which is more than the add card and the header together.

   IT MARKS AN ACTIVE FILTER, because a folded panel hides the one thing a
   reader needs to know about it. Without this the list can be showing 4 of
   34 rows with nothing on screen saying why. The fill is the one a chosen
   tab already uses, so the two read as one state and not as two. */
.filter-toggle { display: none }

/* IT ANSWERS TO THE SORT BAR NOW, NOT TO THE TITLE. It used to sit on the
   heading's row, where 36 was the title's own line box and a finger promoting
   it to 44 overhung a 17.00px cap band by 9.00 above and 18.00 below. They
   called it huge. Moved in beside the select and the select-all box, the row
   it belongs to is a row of controls, and everything in one is one height.

   SO THE HEIGHT IS A max AGAIN, like every other control. Its neighbours read
   max(36, --control-floor), which is 36 on a mouse and 44 on a finger. A bare
   36 here would leave two heights in one row the moment a finger arrives.

   The ::after stays. It resolves to no overhang once the box is already past
   the floor, so it costs nothing and covers the mouse case. */
.filter-toggle {
  position: relative;
  height: max(var(--control-md), var(--control-floor));
}

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

.filter-toggle[data-on] {
  border-color: var(--line-raised);
  background: var(--row-selected);
  color: var(--text);
}

@media (max-width: 1120px) {
  .filter-toggle { display: inline-flex }
}

/* ==========================================================================
   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. */
/* THE SECTION IS THE FOLD AND .filters-row IS WHAT FOLDS. A one-row grid
   going 1fr to 0fr needs nothing measured, nothing pinned and nothing
   released, so the end state IS the natural height rather than a number
   somebody wrote down. Two faults came out of measuring it: a box floored at
   its own padding, and a second slide when the pin was released.

   THE PADDING MOVED TO THE ROW. On the grid itself it cannot collapse.

   NO BOTTOM BORDER, BECAUSE THE LIST OWNS ITS OWN TOP EDGE NOW. That line
   used to live here, and it made the box's top corners belong to nothing:
   "i don't see a 4px radius here", pointing at the top right. A rule spanning
   the content width cannot be rounded. Moved onto .table-wrap, it is the same
   line in the same place and the corner is now the list's to draw.

   The card view draws no list box at all and states its own rule for this,
   a few hundred lines below. */
.filters {
  display: grid;
  grid-template-rows: 1fr;
}

/* THE CLIPPER, AND IT CARRIES NO PADDING. box-sizing floors a box at its own
   padding, so a padded grid item never reaches zero however small the track
   gets. Measured with the padding here: 37px at 0fr. Both declarations are
   needed, or a grid item refuses to go under its own content. */
/* `clip`, NEVER `hidden`, BECAUSE THE PANEL FOCUSES ITS SEARCH FIELD. A box
   at `overflow: hidden` is still a scroll container, so the browser scrolls it
   to bring the focused field into view. Nobody can reach that scroller and
   nothing in this file asks for it.

   Measured at 760, mid-fold with the track forced to 100px: scrollTop 22 on a
   189px content box, so the row sat 22px above its own clipper and exactly
   half the 44px field was cut. The browser re-scrolls it on every frame of the
   500ms run, then hands it back at full height, which is the second movement.

   Their three reports were all this one fault: "half of the search bar is
   obscured", "it freezes when it's pulling out", and the slide arriving in two
   steps. It outlived the height rewrite because it was never the height.

   `clip` is not a scroll container, so there is nothing left to scroll.
   min-height: 0 stays, because a grid item still refuses to go under its own
   content without it. */
.filters-fold {
  min-height: 0;
}

/* AND IT CLIPS ONLY WHILE THE FOLD IS IN FLIGHT. At rest the track IS the
   content height, so there is nothing to clip and the clip only costs.

   Their report: "filter menu is opening behind the list". The tag menu is
   absolutely placed against its own trigger, which sits inside this box, so a
   clipper here cuts it at the panel's foot. Measured at 1035 with the menu
   open: the menu ran 482 to 641 and the list started at 555, and a hit test
   answered the menu at its top and a table row 86px lower.

   A DROPDOWN CANNOT ESCAPE A CLIPPED ANCESTOR, whichever value clips it. So
   the clip is the thing that goes, rather than the menu that moves. `hidden`
   covers the collapse from the press and the marker covers both runs, so a
   missing marker costs one spill rather than a cut menu at rest. */
#filterPanel[hidden] .filters-fold,
#filterPanel[data-folding] .filters-fold { overflow: clip }

.filters-row {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  padding: var(--space-md) 0;
}

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

@media (hover: hover) {
  .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));
  margin: 0;
  border: 1px solid var(--line);
  /* Their instruction, 17 September 2026: "give the menus 4px corner
     radius." It read the large step, which is the card's. A menu is a
     control's own surface rather than a page section, so it takes the
     control corner the trigger under it already uses. */
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  box-shadow: 0 8px 24px #000000a8;
}

/* THE BOX AND THE SCROLLER ARE TWO THINGS NOW. Two of the three menus are a
   list and nothing else, so they carry both classes. The tag filter holds a
   search field above its list, and the field must not scroll away with the
   options. */
.multi-list {
  max-height: 280px;
  margin: 0;
  padding: var(--space-xs);
  overflow-y: auto;
  list-style: none;
}

/* The panel states the ceiling and the list gives way inside it, so the field
   keeps its height whatever the list does. `min-height: 0` or the list
   refuses to go under its own content. */
#tagFilterPanel {
  display: flex;
  flex-direction: column;
  max-height: 280px;
  overflow: clip;
}

#tagFilterPanel .multi-list { max-height: none; min-height: 0 }

/* THE FIELD IS THE PANEL'S OWN HEAD, so it takes the panel's fill and a rule
   under it rather than a box of its own. A second bordered box inside a
   bordered panel reads as two controls. */
.multi-search {
  display: flex;
  align-items: center;
  gap: var(--icon-gap);
  flex: 0 0 auto;
  padding: 0 var(--space-sm);
  border-bottom: 1px solid var(--line-raised);
  color: var(--text-muted);
}

.multi-search input {
  flex: 1;
  min-width: 0;
  height: max(var(--control-sm), var(--control-floor));
  padding: 0;
  border: 0;
  background: none;
  color: var(--text);
  font: inherit;
  font-size: var(--text-xs);
}

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

/* An underline from a shadow adds no height and joins no box, so the row
   below it does not move when the field takes focus. */
.multi-search input:focus { outline: none }
.multi-search:focus-within { box-shadow: inset 0 -2px 0 var(--accent) }

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

@media (hover: hover) {
  .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);
}

/* ONE RULE FOR BOTH SHAPES. The tag list is a listbox and states
   aria-selected. The sort list is a menu of radios and states
   aria-checked. The mark they paint is the same mark. */
.multi-option[aria-selected="true"] .multi-box,
.multi-option[aria-checked="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,
.multi-option[aria-checked="true"] .multi-box .icon { opacity: 1 }

/* A READOUT, NOT AN OPTION. Their request, 3 October 2026: smaller, and a
   grey that stands apart from the options. 10px in --text-muted, against the
   options' 12px in --tag-ink. */
.multi-version {
  padding: var(--space-xs) var(--space-sm) 0;
  color: var(--text-muted);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  cursor: default;
}

/* 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,
  .multi-option[aria-checked="true"] .multi-box { outline: 2px solid Highlight }

  /* Forced colors ignores a box-shadow outright, so the field's focus mark
     needs an outline. A negative offset keeps it inside the box, which costs
     no layout. */
  .multi-search:focus-within { outline: 2px solid Highlight; outline-offset: -2px }

  /* 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-row { flex-wrap: nowrap }

.filters-break { display: none }

/* THE RATING MENU COST THE ROW ITS SECOND LINE BACK. Measured with it in:
   the row needs 1265.8px, which is a 1373px window once the shell's padding
   and the section's own inset are paid. The card stack does not take over
   until 1120, so between those two the row had nowhere to give: the search
   field was already at its 280px floor and everything else states a width.
   It ran 11px past the section at 1265 and further below that.

   So the actions drop to a line of their own, and the break element says
   where rather than leaving it to whichever item happens not to fit. */
@media (min-width: 1121px) and (max-width: 1372px) {
  .filters-row { flex-wrap: wrap }
  .filters-break { display: block; flex: 0 0 100%; height: 0 }
}

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

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

.search input {
  width: 100%;
  min-width: 0;
  height: 100%;
  /* The user agent's own 1px by 2px, which the wrapper's inset already
     answers. Left alone it put the value 2px off the mark beside it. */
  padding: 0;
  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;
}

/* THE SORT CONTROL IS THE SAME DROPDOWN THE TAG FILTER USES. Their
   instruction: "it needs to look like the filter by tag menu."

   IT IS A DIV NOW, SO IT RESTATES THE ROW `.sort-bar label` GAVE IT. A label
   holding a button is invalid and would forward its own press.

   THE TAG CONTROL STATES 160px AND THIS ONE DOES NOT. That one is sized to
   its own widest label, because the search field beside it takes the slack.
   This one IS its row's flexible thing. */
.sort-field {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
  flex: 1;
}

.sort-field .multi { flex: 1; width: auto }

/* A CONTROL MUST BE ABLE TO SHOW ITS OWN VALUE, AND THIS ONE COULD NOT.
   Measured at 296: the trigger stood 44.4px wide and its value span 0, so the
   sort control painted a chevron and no words at all. It is the flexible
   thing in its row, so every pixel the row ran short came out of it.

   THE THRESHOLD IS THE ROW'S OWN SUM, measured rather than picked. The
   trigger needs its widest value: 12 + 78 + 8 + 14 + 12 + 2 = 126. The field
   needs the label and its gap: 50.6 + 8 + 126 = 184.6. The row needs the
   field, the Select All pair and the filter button with two gaps:
   184.6 + 16 + 85 + 16 + 44 = 345.6. The bar is the viewport less 32px of
   shell padding, so the flat row fits at 377.6 and no narrower.

   IT COLLAPSES BY RULE, NEVER BY flex-wrap ALONE. The field takes a full
   line, which is the rule for anything left on its own, and the pair below it
   sits flush at both edges. */
/* THE LABEL GOES BEFORE THE ROW BREAKS. Their rule, restated 3 October
   2026: on a narrow screen the words "Sort by" vanish and the dropdown stays.
   Without the label and its gap the row needs 345.6 - 58.6 = 287, so it
   holds one line down to a 319px window. */
@media (max-width: 378px) {
  .sort-field .filters-label { display: none }
}
@media (max-width: 318px) {
  .sort-bar { flex-wrap: wrap }
  .sort-field { flex: 1 1 100% }
  .sort-bar #filterToggle { margin-inline-start: auto }
}

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

.sort-bar {
  position: sticky;
}

/* THE FILTER PANEL PAINTS OVER THE SORT BAR, WHATEVER THE 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.

   Measured when the panel sat ABOVE the bar: 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.

   The panel sits BELOW the bar now, so DOM order happens to agree. These
   numbers stay anyway, because the menu can open upward when the room below
   runs out, and then the order is backwards again.

   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) }
.sort-bar { z-index: var(--layer-sticky) }

/* AND THE BAR LIFTS WHILE ITS OWN MENU IS OPEN. The filter panel sits one
   layer above this bar, on purpose, so the tag menu can reach past it. A
   sticky element carrying a z-index makes a stacking context, so the sort
   menu's own --layer-dropdown is trapped inside the bar and the panel below
   swallowed it. Measured at 768: 215px of menu with only its first and last
   rows visible, the middle behind the search field and the filter rows.

   It lifts ONLY while the menu is open, so the two keep their usual order
   the rest of the time. +2 is free: the add card holds +3. */
.sort-bar:has(#sortList:not([hidden])) { z-index: calc(var(--layer-sticky) + 2) }

/* 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(--stick-card, 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);
  /* UNDER THE STICKY BAND, OVER THE ROWS. Their report, 26 September 2026:
     the top of the paste card looked cut off. The strip grows by the card's
     corner radius once the band sticks, so the corners hide no row, and
     drawn above the band it also hid the card's own top edge. One layer
     lower it still covers the rows and the card paints over it. */
  z-index: calc(var(--layer-sticky) - 1);
  background: var(--bg);
}

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

.sort-bar { top: var(--stick-1, 0px) }
.filters { 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
   ========================================================================== */

/* THE LIST IS A SURFACE, SO IT TAKES THE SURFACE RADIUS. Their question,
   17 September 2026: "how come the button corners have a radius but the table
   corners don't?" Measured: every card reads 8, every control reads 4, and
   this box read 0. It was the only surface in the app with square corners.

   THE TWO IT OWNS ARE THE BOTTOM PAIR. The filter row's own bottom border is
   this box's top edge, and a rule spanning the content width has no corners
   to round. So the sides start square against that rule and close at 8. */
.table-wrap {
  /* The mask reads this too, so the guarded hairline and the drawn one cannot
     disagree. The card view draws no box and sets it to zero. */
  --list-edge: 1px;

  border: var(--list-edge) solid var(--line);
  border-radius: var(--radius-sm);
  min-height: 260px;

  /* A COLUMN, SO THE EMPTY BOX CAN TAKE THE LEFTOVER. It holds a table and a
     state box, and only the state box grows. The table keeps its own height,
     or a long list would be squeezed to share. */
  display: flex;
  flex-direction: column;

  /* THE LIST IS WHAT MAKES A FOLD STUTTER, AND THE COST IS PAINT. Their
     report: the folding and growing animations are stuttery on a phone, "even
     faster devices like a Galaxy S23".

     A fold changes the page's height on every frame, so without containment
     the browser repaints the whole list each time. `contain: paint` lets it
     skip everything outside this box.

     MEASURED AT 375 WITH 400 ROWS, three runs each, driving the real funnel:
     60.5 fps without it and 97.9 with. Frames over 32ms fell from 17 to 9.
     `contain: layout` alone bought nothing, at 57.2, so the layout was never
     the expensive half.

     IT CLIPS, AND NOTHING HERE WAS RELYING ON THAT. The card view sets
     `overflow: visible` on purpose, so it was measured: 0 descendants cross
     this box, and a row control's touch reach still hit-tests 44px with the
     containment on. No descendant is fixed, so nothing changes containing
     block. */
  contain: paint;
}

/* ==========================================================================
   The list scrolls, the app does not

   The page used to scroll as one document, and three bars answered that with
   `position: sticky`. Locked to the viewport there is nothing to stick to:
   the chrome cannot move, so it does not need pinning.

   THE FLOOR IS ROOM FOR THREE ROWS, and a row is two sizes. The table view
   draws a 52px row and the card view draws a 163px card, so one height cannot
   serve both. 1121 is where the table view starts, which is a threshold this
   file already declares.

   TABLE VIEW, at least 700 tall. 339 above the table, 36 of footer and 48 of
   shell padding is 423, plus the wrapper's own 260px floor. 683 rounds to 700.

   CARD VIEW, at least 700 wide AND 950 tall. Measured at 768x1024: 390 above
   the table, 54 of footer and 16 of padding, leaving 564 for 3.46 cards. The
   sum for three is 390 + 54 + 16 + 489, which is 949.

   THE WIDTH IS THERE BECAUSE THE CHROME GROWS AS THE PAGE NARROWS. Measured
   with the filters folded: 390 above the table at 753 wide, 428 at 693, and
   470 at 600 and below. The footer runs 54, 72, 90 and 108 over the same
   band. So one height floor is right at one end of the card view and wrong at
   the other. 700 is where the chrome stops growing.

   IT COVERS EVERY REAL SIZE. A tablet in portrait is 1024 tall or more and
   locks. The tallest phone is 956 and 430 wide, so the width stops it. A
   tablet in LANDSCAPE is 768 tall, which leaves 308px for 1.9 cards, and the
   height stops that.

   Below either floor the page scrolls exactly as it did. A locked app with no
   room for its list is worse than a long page.

   THE FOOTER STAYS OUT OF THE SCROLLER, so the scrollbar is the list's and
   nothing else's. It costs 36px at 1265 and 54 at 768. */
@media (min-width: 1121px) and (min-height: 700px),
       (max-width: 1120px) and (min-width: 700px) and (min-height: 950px) {
  .shell {
    display: flex;
    flex-direction: column;
    height: calc(100dvh - var(--ad-strip, 0px));
  }

  .add-card, .sort-bar { position: static }
  .shell .tabs { position: static }
  .shell .tabs::before { content: none }

  /* THE FILTERS BAND TAKES `relative` RATHER THAN `static`, BECAUSE ITS OWN
     RULE IS PLACED AGAINST THIS BOX. That rule is an absolutely placed
     pseudo-element at 100% minus a step, so a static ancestor sends the
     percentage to the initial containing block instead.

     Measured at 768x1024: inset-block-start computed 1012px against a 165px
     panel, and the width read the viewport's 768, so the line was drawn at
     the foot of the window. At 375x812 the page scrolls, the band IS sticky,
     and the same rule read 237px against a 249px panel.

     Their report: the line "vanishes in width-limited responsive mode", then
     "also in tablet mode", then "works fine only in mobile view". Mobile is
     the one size short enough to leave the band sticky.

     `relative` and `static` are the same layout here, because nothing in a
     locked app scrolls past this box. Only the two boxes with no such rule
     stay static. */
  .filters {
    position: relative;

    /* AND ITS STICKY OFFSET IS CLEARED WITH IT. `top` does nothing under
       `static` and offsets the box under `relative`, so the band read
       --stick-2 as an instruction. Measured at 1462x1248 before this line:
       the panel's top sat at 388 against a table starting at 338, so it had
       dropped 247px onto the list. */
    top: auto;
  }

  /* It painted the gap the stuck card scrolled through. Nothing scrolls. */
  .shell::before { content: none }

  /* `min-height: 0` is what lets a flex item shrink under its own content.
     The wrapper's own 260px floor would otherwise push the column past the
     viewport and give the document its scrollbar back. */
  .table-wrap {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: scroll;
  }

  /* THE HEADINGS ARE NOW INSIDE A SCROLLER, so they pin to its top. They
     paint nothing of their own, and the rows would read straight through. */
  thead th {
    position: sticky;
    inset-block-start: 0;
    z-index: var(--layer-raised);
    background: var(--bg);
  }

}

/* ==========================================================================
   Every cut edge of the list fades

   A HARD CUT READS AS A BROKEN ROW. Their instruction, 17 September 2026:

     can you add a little fading-to-black gradient near the top so that it
     doesn't cut off so harshly? 16-32px height ought to do it

   Then twice more, the same day: both ends, and the table as well as the
   cards, "within its bounds".

   I SHIPPED IT TWICE AND THEY SAW NEITHER, BECAUSE I ONLY MEASURED THE LOCKED
   APP. Their words: "still a sharp cut, both at the top and bottom edges.
   what are you doing wrong?"

   The lock needs 950px of height. Below that the app scrolls as one document
   and .table-wrap is not a scroller at all, so a rule written for its edges
   paints nothing. Measured at 900 by 800: the shell is 6456 tall, the page
   scrolls, and the sort bar is sticky at 146 with an opaque fill. The rows
   cut at its foot, which is a place neither earlier fix could reach.

   SO THERE ARE FOUR CUTS, AND TWO MECHANISMS ANSWER THEM.

   | where a row is cut                    | what answers it       |
   |---------------------------------------|-----------------------|
   | under a sticky bar, the page scrolling | a tail below the bar |
   | under the sticky headings, table locked | a tail below them   |
   | the scroller's own top, cards locked   | a mask               |
   | the scroller's own foot, either view locked | a mask          |

   A TAIL IS THE OPAQUE THING CARRYING ON PAST ITS OWN EDGE. It starts at the
   fill the bar already paints and reaches nothing, so a row emerges from it
   rather than appearing. A MASK is for an edge with nothing over it: the rows
   themselves go to transparent and the page shows through.

   A SCRIM IS NEITHER, AND IT WAS MY FIRST ANSWER. Painting the page colour
   over a card at partial alpha moves it 9 values out of 255, so the cut
   survives underneath whatever height the band is given.

   EACH ONE IS ZERO WHERE NOTHING SITS PAST IT. Their rule: "make sure it
   stops existing at the very bottom of the list ... just like it shouldn't
   exist at the very top of the list either."

   AND app.js PUBLISHES THE THREE LENGTHS, rather than a scroll timeline.
   `animation-timeline` is the one thing here a browser may not have, and
   without it the fade is simply absent, which is the state they reported
   twice. A scroll listener runs everywhere. This file decides where each
   number paints, so the script knows nothing about which view is on screen.
   ========================================================================== */

/* ONE NUMBER FOR EVERY EDGE, AND app.js READS IT FROM HERE. It was 32, the
   top of the range they first gave, and they halved it once they could see
   it: "make it half of what it is". 16 is the bottom of that same range. */
:root { --edge-fade: var(--space-lg) }

@property --scrolled-top { syntax: "<length>"; inherits: true; initial-value: 0px }
@property --scrolled-end { syntax: "<length>"; inherits: true; initial-value: 0px }
@property --bar-tail { syntax: "<length>"; inherits: true; initial-value: 0px }
@property --page-fade-end { syntax: "<length>"; inherits: true; initial-value: 0px }
/* THE STRIP READS ITS OWN VALUE, because it stands down where the pinned
   pager covers the foot and the pager's ramp does not. */
@property --page-strip { syntax: "<length>"; inherits: true; initial-value: 0px }

/* AND THE PAGER RAMP IS ITS COMPLEMENT: what the pager itself has left to
   travel while it is pinned, so the two are never both painting one edge. */
@property --pager-fade { syntax: "<length>"; inherits: true; initial-value: 0px }

/* The mask's top term is the scroller's own travel, except where something
   opaque already sits at that edge and a tail does the work instead. */
/* THE TABLE KEEPS ITS OWN HEIGHT INSIDE THAT COLUMN. Left to `flex: 0 1 auto`
   a long list would shrink to fit the box rather than scrolling it. */
.table-wrap > table { flex: 0 0 auto }

.table-wrap { --mask-top: var(--scrolled-top) }

/* THE RAMPS STAND DOWN WHILE A FOLD TRAVELS. Measured on the device's own
   profiler, 400 rows at 375px, three runs a row: 90.8 fps with 12 hitches as
   shipped, and 102.7 with 4 once this mask is off. Containment removed reads
   68.7 with 30, so both halves count.

   A MASK MAKES THE BROWSER RASTER THE WHOLE BOX EVERY FRAME ITS GEOMETRY
   MOVES, and that cost scales with the screen. Nine gradient stops over a
   viewport-tall scroller is cheap at 1x and is not at 3x.

   The ramps soften a CUT EDGE, and a reader watching the list travel is not
   reading that edge. app.js marks this element for the length of the fold. */
.table-wrap[data-travelling] {
  -webkit-mask-image: none;
  mask-image: none;
}

.table-wrap {
  /* TWO LAYERS, AND THE DEFAULT COMPOSITE IS `add`, SO THIS IS THEIR UNION.

     A MASK ERASES THE BOX'S OWN EDGE, WHICH IS WHAT THEY SAW: "you've cut off
     the bottom edge of the table here". A mask applies to everything the
     element paints, its border included, so a fade at the foot took the frame
     with it. The first layer is the frame: opaque on the hairline at each
     side, transparent between them. The second is the fade, held opaque for
     that same hairline at the top and the foot.

     So the box keeps a whole ring and the rows fade inside it. --list-edge is
     the width the border is drawn at, read by both, so the guarded hairline
     and the painted one cannot disagree. The card view draws no box and sets
     it to zero, which leaves no opaque sliver where there is no frame.

     Proven at 1:1 on three probes: the fade alone loses the border at the top
     and the foot AND thins the sides, the two layers keep the whole frame,
     and the unmasked twin is the reference. */
  -webkit-mask-image:
    linear-gradient(to right, #000 0 var(--list-edge), #0000 var(--list-edge) calc(100% - var(--list-edge)), #000 calc(100% - var(--list-edge))),
    linear-gradient(
      to bottom,
      #000 0 var(--list-edge),
      #0000 var(--list-edge),
      #0006 calc(var(--list-edge) + var(--mask-top) * 0.38),
      #000d calc(var(--list-edge) + var(--mask-top) * 0.72),
      #000 calc(var(--list-edge) + var(--mask-top)),
      #000 calc(100% - var(--list-edge) - var(--scrolled-end)),
      #000d calc(100% - var(--list-edge) - var(--scrolled-end) * 0.72),
      #0006 calc(100% - var(--list-edge) - var(--scrolled-end) * 0.38),
      #0000 calc(100% - var(--list-edge)),
      #000 calc(100% - var(--list-edge))
    );
  mask-image:
    linear-gradient(to right, #000 0 var(--list-edge), #0000 var(--list-edge) calc(100% - var(--list-edge)), #000 calc(100% - var(--list-edge))),
    linear-gradient(
      to bottom,
      #000 0 var(--list-edge),
      #0000 var(--list-edge),
      #0006 calc(var(--list-edge) + var(--mask-top) * 0.38),
      #000d calc(var(--list-edge) + var(--mask-top) * 0.72),
      #000 calc(var(--list-edge) + var(--mask-top)),
      #000 calc(100% - var(--list-edge) - var(--scrolled-end)),
      #000d calc(100% - var(--list-edge) - var(--scrolled-end) * 0.72),
      #0006 calc(100% - var(--list-edge) - var(--scrolled-end) * 0.38),
      #0000 calc(100% - var(--list-edge)),
      #000 calc(100% - var(--list-edge))
    );
}

/* THE TAIL MIRRORS THE MASK'S RAMP, reversed. The mask reaches nothing at the
   cut and full at 38% and 72% of the way in. The tail is full at the bar's
   own edge, so its stops sit at 28% and 62%, which are the same two points
   measured from the other end. */
.filters::after,
.sort-bar::after,
thead th::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-start: 100%;
  background: linear-gradient(
    to bottom,
    var(--bg) 0,
    color-mix(in srgb, var(--bg) 87%, transparent) 28%,
    color-mix(in srgb, var(--bg) 40%, transparent) 62%,
    transparent 100%
  );
  pointer-events: none;
}

/* Which bar carries it is the script's answer, because it depends on what is
   rendered: the sort bar exists in the card view and the filters row can be
   folded away. Height 0 is the rest state, so a bar nobody scrolls under
   paints nothing. */
.filters::after,
.sort-bar::after { height: var(--bar-tail) }

thead th::after { height: 0 }

/* THE PAGE HAS A FOOT TOO, AND ONLY WHEN THE PAGE IS THE SCROLLER. Their
   instruction: "apply it to the bottom too." A locked app never scrolls the
   document, so the script reads zero there and this paints nothing without
   asking a media query.

   FIXED TO THE VIEWPORT, because the cut is the window's own lower edge
   rather than any element's. It takes no press: 16px across the foot of the
   screen is a band a reader may want to click through. */
.shell::after {
  content: "";
  position: fixed;
  inset-block-end: var(--ad-strip, 0px);
  inset-inline: 0;
  height: var(--page-strip);
  z-index: calc(var(--layer-sticky) + 2);
  background: linear-gradient(
    to top,
    var(--bg) 0,
    color-mix(in srgb, var(--bg) 87%, transparent) 28%,
    color-mix(in srgb, var(--bg) 40%, transparent) 62%,
    transparent 100%
  );
  pointer-events: none;
}

/* THE TABLE'S HEADINGS ARE THE OPAQUE THING IN THE LOCKED TABLE VIEW, so the
   top of that scroller takes a tail and the mask's top term goes to zero. A
   mask there would fade the headings themselves, which nothing is cutting. */
@media (min-width: 1121px) and (min-height: 700px) {
  .table-wrap { --mask-top: 0px }
  thead th::after { height: var(--scrolled-top) }
}

   ALWAYS DRAWN, NEVER OVERLAID. `overflow-y: scroll` keeps the track on
   screen with a short list, and styling the WebKit pseudo-elements is what
   turns an overlay scrollbar into a classic one on macOS. `scrollbar-gutter`
   holds the width either way, so an empty list does not move the columns.

   AN 8px HANDLE IN A 24px TRACK. Their number for the handle. The 8px
   transparent border plus `background-clip: padding-box` insets it without a
   second element: 24 - 16 = 8.

   THE TRACK IS WIDE SO THE GAPS MATCH. A 12px track with a 12px padding
   beside it put 14px between the list and the handle and 2px between the
   handle and the table's own edge. They saw it and called it uneven. Centred
   in a 24px track the handle has 8 on both sides, and the gutter costs the
   same 24px it cost as 12 plus 12.

   FIREFOX TAKES ONE COLOUR AND ONE WIDTH. `scrollbar-width: thin` is not a
   length, and `scrollbar-color` has no state. So the hover and the held
   states are WebKit and Blink only, and the rest colour is what Firefox
   draws throughout.

   AND THOSE TWO PROPERTIES MUST NOT REACH BLINK AT ALL. Declared globally,
   Chrome takes them and DISREGARDS every `::-webkit-scrollbar` rule below.
   Measured that way: a 10px bar with no hover and no held state, against the
   12 asked for. `@supports selector()` is the split, because the pseudo-
   element's own support is exactly the question.

   THE PAGE'S OWN BAR TAKES THE SAME RULES. Below the lock's floors the PAGE
   scrolls and the list does not, so the reader met the browser's default bar
   at one width and this one at the next. Two scrollbars in one app, and the
   width decided which. `html` is the viewport's scroller, and it carries no
   gutter: a reserved 24px with nothing in it would inset the whole page. */
.table-wrap { scrollbar-gutter: stable }

@supports not selector(::-webkit-scrollbar) {
  html, .table-wrap, .multi-list {
    scrollbar-width: thin;
    scrollbar-color: var(--scroll-rest) transparent;
  }
}

html::-webkit-scrollbar,
.table-wrap::-webkit-scrollbar { width: 24px; height: 24px }

html::-webkit-scrollbar-track,
.table-wrap::-webkit-scrollbar-track { background: transparent }

html::-webkit-scrollbar-thumb,
.table-wrap::-webkit-scrollbar-thumb {
  border: var(--space-sm) solid transparent;
  border-radius: var(--radius-pill);
  background-color: var(--scroll-rest);
  background-clip: padding-box;
}

/* A PSEUDO-ELEMENT SCROLLBAR TAKES NO TRANSITION, so these two snap. The
   duration token reaches every other control and cannot reach this one. */
@media (hover: hover) {
  html::-webkit-scrollbar-thumb:hover,
  .table-wrap::-webkit-scrollbar-thumb:hover { background-color: var(--scroll-hover) }
}

html::-webkit-scrollbar-thumb:active,
.table-wrap::-webkit-scrollbar-thumb:active { background-color: var(--scroll-held) }

/* A 4px HANDLE IN A 12px TRACK, WHICH IS THEIR NUMBER FOR THE MENUS. The same
   arithmetic the list's bar uses: a transparent border plus
   `background-clip: padding-box` insets the handle with no second element.
   12 - 4 - 4 = 4.

   THE TRACK IS 12 SO THE GAPS MATCH THE PANEL'S OWN. It pads its options by
   4px on every other side, and a 4px border each side of the handle gives the
   same 4px between the handle and the panel's inner edge.

   The two standard properties are in the block above, never here. Declared
   where Blink can see them, they disable every rule in this section. */
.multi-list::-webkit-scrollbar { width: var(--space-md) }

.multi-list::-webkit-scrollbar-track { background: transparent }

.multi-list::-webkit-scrollbar-thumb {
  border: var(--space-xs) solid transparent;
  border-radius: var(--radius-pill);
  background-color: var(--scroll-rest);
  background-clip: padding-box;
}

@media (hover: hover) {
  .multi-list::-webkit-scrollbar-thumb:hover { background-color: var(--scroll-hover) }
}
.multi-list::-webkit-scrollbar-thumb:active { background-color: var(--scroll-held) }

/* 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. The floor is that
     heading plus its two paddings, measured at 30.23 + 24 = 54.23.

     THE TOKEN ROUNDS THAT UP RATHER THAN STATING IT. A floor can only be
     wrong by being too small, so 60 is safe where 54 is exact, and it sits
     on the grid with no marker. The comment used to say 58, which matched
     neither the token nor the measurement. */
  --col-tags: 60px;

  /* THE RUN AND ITS COLUMN ARE ONE SUM, so a change to the mark size cannot
     leave the column behind. Five marks, four gaps between them, and the
     cell's own two paddings. The gap itself is declared at the root, because
     the control renders outside this table too. */
  --col-rate: calc(var(--mark) * 5 + var(--star-gap) * 4 + var(--col-pad));

  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-rate { width: var(--col-rate) }
/* AN ORNAMENT COLUMN IS ITS CONTROL PLUS THE TWO PADDINGS, and the control
   reads the touch floor. The download column holds the same size of mark as
   the other two, so it takes the same width rather than a number of its own. */
.col-remove, .col-edit, .col-get, .col-share { 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) }

/* THE NOTES LIST HAS TWO COLUMNS OF PROSE, so the name states a width and
   the excerpt takes the remainder. Left auto, the name would take every
   pixel the other columns gave up and the excerpt would truncate to a word.
   The excerpt is the one that can lose its tail without costing anything:
   it is an excerpt already. */
table { --col-note-name: 260px }

table[data-list="notes"] .col-title { width: var(--col-note-name) }
table[data-list="notes"] .col-edited { width: var(--col-date) }

.cell-excerpt .truncate { display: block; color: var(--text-muted) }

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

@media (hover: hover) {
  .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 }
/* GEIST MONO BESIDE 14px ONEST TAKES 13px in the table. Their rule, 1 October
   2026: 12px first, then 13. */
@media (min-width: 1121px) {
  td.cell-dur { font-size: 13px /* optical: their choice, 1 October 2026 */; line-height: var(--lead-sm) }
  /* AND IT TAKES THE ROW'S BASELINE FROM AN INVISIBLE 14px ONEST CHARACTER.
     Each cell centres its own line, and smaller type sits 1px higher in the
     same line box, so the duration's baseline missed the channel's. The
     character sets the line's baseline exactly where the text beside it has
     its own, and the digits sit on it. */
  td.cell-dur::before {
    content: "\200B";
    font-family: var(--font-body);
    font-size: var(--text-sm);
  }

  /* EVERY TEXT IN A ROW ON ONE BASELINE. Their instruction, 1 October 2026.
     Measured at 1336 against the row's 14px text at 754.72: the duration's
     digits still sat 0.5px high after the strut, and each tag chip's words,
     centred in the chip's own box, 1px high. Both drop by what they
     measured. */
  td.cell-dur { position: relative; top: 0.5px /* optical */ }
  td.cell-tags .tags { translate: 0 1px /* optical */ }
}

/* 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: 120px }
  .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);
}

@media (hover: hover) {
  .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) }
/* THE WHOLE ROW OPENS IT, so the whole row says so. The cells that do
   something else carry their own cursor, and a control inside the row states
   its own. */
tbody tr { cursor: pointer }
@media (hover: hover) {
  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. */
@media (hover: hover) {
  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);
}

@media (hover: hover) {
  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. */
/* THE TARGET IS A REACH AT EVERY WIDTH, NOT ONLY THE NARROW ONE. The link's
   box is its child's line box, 20px, against a 24px floor. The overhang used
   to live in the card view's block alone, so 34 titles on the table view were
   4px short of the mouse minimum. It costs no layout, so it belongs here. */
.video-link {
  position: relative;
  display: block;
  min-width: 0;
  /* THE FLOOR IS THE ::after, NEVER THE LEADING. Written as a line-height it
     became 44px of layout around a 20px line at a coarse pointer, and the
     24px of slack read as padding under the words. */
  line-height: var(--lead-body);
  color: var(--text);
  font-weight: 650;
  text-decoration: none;
  transition: color var(--duration) var(--ease);
}

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

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

/* A NOTE'S NAME IS A BUTTON, because it opens the editor rather than going
   anywhere, and a button carries a UA box an anchor does not. Measured at
   375 without this: 180px past the card, because a button's intrinsic width
   is its content and `min-width: 0` alone does not make it shrink inside a
   grid cell. It needs the whole width stated and the text clipped in the
   span, which is what the anchor already does. */
button.video-link {
  width: 100%;
  max-width: 100%;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  text-align: start;
  cursor: pointer;
}

button.video-link .truncate { display: block }

/* 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, a 4px gap between
   them, 12px before the add control and 24px for it. A chip is as wide as
   its own word, measured over 39 of them at 60.84 to 93.67. The field only
   exists while someone is typing, and it may take a second line. */
/* THE GAP BETWEEN CHIPS CLEARS THE GAP INSIDE ONE, at exactly 2:1. Both
   halves doubled when the scale lost its 2px step, and the row was the most
   spacious thing the pass produced: each chip grew 4px and every gap grew 4.
   The chip's inner distances took the half-step back, so this one follows. */
.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 }

/* THE BOX `fitTags()` MEASURES A CHIP IN, and it holds one chip per distinct
   word rather than one per row. A row states `content-visibility: auto`, so
   reading a rectangle inside it renders that row, and 800 rows cost 800
   layouts. This box is one layout.

   IT SITS OUT OF FLOW AND COMES STRAIGHT BACK OUT, inside one task, so it
   moves nothing and no sweep can find it. The width is the viewport so no
   chip is ever asked to shrink, which would report the width the box gave it
   rather than the chip's own. */
.tag-probe {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  width: 100vw;
  visibility: hidden;
  pointer-events: none;
}

.tag-probe .tag-chip { flex: 0 0 auto }

/* ==========================================================================
   Pager

   FIFTY ROWS AT A TIME, AND THE REST BEHIND THIS. It sits outside the
   scroller, so it stays put while the rows move under it.
   ========================================================================== */

/* TWO GROUPS, AND THE SLACK BETWEEN THEM. The readout is one thing and the
   controls are another, so every spare pixel lands in one place. The declared
   gap is the floor at the width where they wrap. 24 against the 4 inside the
   run is 6:1.

   `space-between`, NEVER AN AUTO MARGIN ON THE NAV. A margin cannot say
   "first on this line". Written that way the nav read correctly on one line
   and as an 85px indent on two, at 375px. `space-between` distributes along
   each LINE, so one item alone on a line packs at its start.

   TWO CHILDREN, SO THERE IS ONE GAP TO SPREAD THE SLACK ACROSS. The warning
   about this mechanism is about a row of three, where the slack opens a hole
   inside a group. */
/* THE PAGER SITS ON THE FOOT OF THE SCREEN UNTIL THE LIST RUNS OUT. Their
   ask, 22 September 2026: "can i have the paginator locked at the bottom of
   the pane? and when it scrolls to the absolute bottom of the list, it slides
   up, revealing the footer?"

   `position: sticky` WITH A BOTTOM INSET IS BOTH HALVES. A sticky box is
   pinned while its own place in the flow is off the bottom of the screen, and
   it lets go the moment that place arrives. Its place is directly above the
   footer, so reaching the end of the list is exactly when it rises and the
   footer comes up under it. No script, and it moves with the finger rather
   than to a duration nobody chose.

   AN OPAQUE FILL IS NOT OPTIONAL. A sticky box stays in flow, so the rows
   scroll UNDER it. The two bands at the top of this list take the same fill
   for the same reason.

   THE PADDING IS THE PAGE'S OWN. Pinned, the box's bottom edge IS the
   viewport edge, so without it the buttons sit on the glass. */
.pager {
  position: sticky;
  inset-block-end: 0;
  z-index: var(--layer-sticky);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  /* THE COLUMN GAP IS A MINIMUM, NOT THE PAINTED DISTANCE. `space-between`
     hands every spare pixel to this one gap, so it only binds at the width
     where the row is about to break. Raising the nav's own gaps took 320
     onto two rows: 293.3px of content in 288 of room. At 16 it is 285.3 and
     holds one row, and the painted gap is unchanged at every wider size. */
  gap: var(--space-sm) var(--space-lg);
  margin-block-start: var(--space-xl);
  padding-block: var(--space-md) var(--page-pad);
  background: var(--bg);
}

/* AND THE ROWS FADE INTO IT RATHER THAN BEING CUT BY IT. Their instruction,
   22 September 2026: "that nice little fading gradient at the top of the
   scrolling pane also needs to be at the bottom of the pane, above the
   paginator, until it scrolls to the bottom."

   THE CUT MOVED WHEN THE PAGER BECAME STICKY. The page's own foot already
   carries this ramp, and the pager's opaque fill now sits over it, so the
   rows disappear under the pager's top edge instead.

   IT MEASURES FROM THE FOOTER, NOT FROM THE PAGE. The page travel left is
   the wrong distance: the pager lets go a whole footer before the page ends,
   so a ramp keyed on it would still read full at that moment and vanish in
   one frame. `--pager-fade` is the travel the pager itself has left, and it
   reaches zero as the pager lets go. Nothing in CSS can ask whether a box is
   stuck, so render() measures it.

   THE SAME RAMP, REVERSED. Full at the pager's own edge and nothing at the
   far end, with the stops the tails already use. */
.pager::before {
  content: "";
  position: absolute;
  inset-block-end: 100%;
  inset-inline: 0;
  height: var(--pager-fade);
  background: linear-gradient(
    to top,
    var(--bg) 0,
    color-mix(in srgb, var(--bg) 87%, transparent) 28%,
    color-mix(in srgb, var(--bg) 40%, transparent) 62%,
    transparent 100%
  );
  pointer-events: none;
}

/* AND IT GOES BACK INTO FLOW WHERE THE APP LOCKS. There the list is its own
   scroller and the pager sits under it in a column that never scrolls, so
   there is nothing for it to stick to.

   THE CONDITION IS REPEATED HERE RATHER THAN WRITTEN WITH THE OTHER TWO
   STICKY BOXES, because a media query adds no specificity and this file
   states `.pager` further down. Written up there, the base rule came later
   and won: measured at 1536x900, the pager read `sticky` with the page's own
   padding on its foot while the app was locked. */
@media (min-width: 1121px) and (min-height: 700px),
       (max-width: 1120px) and (min-width: 700px) and (min-height: 950px) {
  .pager {
    position: static;
    padding-block: 0;
    background: none;
  }

  .pager::before { content: none }
}

.pager-count {
  margin: 0;
  color: var(--text-muted);
  font-size: var(--text-sm);
}

/* The readout takes focus after a page change and is not a control, so it
   shows no ring of its own. The reset already names [tabindex]. */
.pager-count:focus-visible { outline: none }

.pager-nav {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
}

/* THE FIELD AND ITS TOTAL ARE ONE GROUP, so they sit closer to each other
   than either sits to an arrow.

   BOTH NUMBERS WENT UP A STEP. Their report, 22 September 2026: the field
   was "crammed next to the 'of'". It measured 4px, which is the smallest
   step on the scale, so the word sat against the box.

   RAISING THE INNER ONE ALONE WOULD HAVE DELETED THE GROUP. At 8 against 8
   the four controls read as one flat run, and nothing says the field and
   its total belong together. 16 between and 8 inside keeps two to one. */
.pager-jump {
  display: flex;
  margin-inline-end: calc(var(--space-lg) * -1 + var(--space-sm));
}

/* THE BOX IS SIZED BY ITS `size`, WHICH THE RENDER SETS FROM THE PAGE COUNT.
   A stated width would hold a four-digit list to the same box as a two-digit
   one. Tabular figures, or the box changes width as the reader pages. */
.pager-field {
  inline-size: auto;
  padding-inline: var(--space-2xs); /* half-step: inside one control */
  text-align: center;
  font-variant-numeric: tabular-nums;
}

.pager-of {
  color: var(--text-muted);
  font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}

/* AN END KEEPS ITS PLACE IN THE TAB ORDER AND STOPS LOOKING LIVE.
   `disabled` would take Previous out of reach on page one. */
.pager-step[aria-disabled="true"] {
  opacity: 0.4;
  cursor: default;
}

@media (hover: hover) {
  .pager-step[aria-disabled="true"]:hover {
    background: none;
    color: var(--text-muted);
  }
}

/* ONE GLYPH, TWO ROTATIONS. The sprite holds a chevron pointing down, and a
   typed guillemet comes from the text font with none of the stroke, size or
   join the icon set states. */
.pager-back { transform: rotate(90deg) }
.pager-on { transform: rotate(-90deg) }

/* ==========================================================================
   Rating

   TWO RUNS OVER ONE PATH. The lower run draws the outlines and the upper run
   is the same five marks filled, clipped to the value. So a half star costs a
   clip rather than a second drawing, and 2.5 needs no case of its own.
   ========================================================================== */

.stars {
  position: relative;
  display: inline-flex;
  gap: var(--star-gap);
  color: var(--text-quiet);
  cursor: pointer;
  border-radius: var(--radius-sm);
}

.stars-off, .stars-on {
  display: flex;
  gap: var(--star-gap);
}

/* THE FILLED RUN SITS OVER THE OUTLINE ONE AND CLIPS, so it is out of flow
   and the outline run alone states the width. `overflow: clip` rather than
   hidden: a scroll container here would scroll itself the moment the control
   takes focus. */
.stars-on {
  position: absolute;
  inset-block: 0;
  inset-inline-start: 0;
  width: var(--rate, 0%);
  overflow: clip;
  /* WHITE, WHICH IS THEIR CHOICE. A row's rating is not a status, and the
     accent means one thing here: the control a reader is about to act on.
     Measured on the four row planes: 15.72, 14.43, 12.71 and 11.35. */
  color: var(--text);
}

/* THE FILL TRAVELS WHEN THE VALUE CHANGES, AND NOT WHILE PREVIEWING. A press
   moved the width in one frame, because the global transition list carries no
   length. A preview follows the pointer, so the same 250ms there would drag
   the fill behind it.

   BOTH PROPERTIES ARE NAMED, BECAUSE A SHORTHAND REPLACES THE WHOLE LIST. The
   colour comes from the `:where(*)` rule, so declaring the width alone here
   would take the preview's own fade away. The peek state drops the width and
   keeps the colour. */
.stars-on {
  transition:
    color var(--duration) var(--ease),
    width var(--duration) var(--ease);
}

.stars[data-peek] .stars-on { transition: color var(--duration) var(--ease) }

.stars-on .icon { fill: currentColor }

/* A TARGET IS A REACH, NEVER A SIZE. The run is 14px tall, so a finger has
   nothing to press without this. Out of flow, so it moves no row, and `min`
   against zero keeps a control already past the floor where it is. */
.stars::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}

/* The focus ring comes from the reset, which already names [tabindex]. A
   second one here would be a second writer for one mark. */

/* EACH STAR SHOWS WHAT PRESSING IT WOULD GIVE, AND ONLY A MOUSE CAN.
   A finger has no hover, so the preview would paint on the press and read as
   a value that did not take. The script writes the width and this decides
   whether it paints. */
@media (pointer: fine) {
  /* THE PREVIEW IS MUTED GREY, WHICH THEY PICKED FROM SEVEN CANDIDATES
     RENDERED AT ACTUAL SIZE. It sits between the outline and the committed
     white, so the three states are a ladder rather than two colours.

     Measured on all four row planes. Outline 5.49 down to 3.96, preview 7.47
     down to 5.39, committed 15.72 down to 11.35. The order holds at every
     plane, and the preview clears 4.5 wherever the row is.

     ONE WRITER FOR THE HOVER. The outline used to lift to this same grey,
     which left the preview and the outline one colour apart by their fill
     alone. The outline stays at rest and the fill is the only change. */
  .stars[data-peek] .stars-on {
    width: var(--peek, 0%);
    color: var(--text-muted);
  }
}

/* ==========================================================================
   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-lg);
  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 }
@media (hover: hover) {
  .row-dead .video-link:hover { color: var(--danger-quiet) }
}

/* THE #removed TAG IS THE WHOLE TREATMENT. A chip saying Unavailable used to
   sit beside it, so a dead row carried the same fact twice, in two shapes. It
   also wrapped into the tag run and read as a tag that cannot be removed.
   Their instruction: "if a video is unavailable, it doesn't need the
   'unavailable' badge, just add the #removed tag and be done with it."

   IT KEEPS THE RETIRED BADGE'S MARK AND ITS COLOUR. Their instruction:
   "add the warning icon from the retired 'unavailable' badge to the left of
   the #removed text and make it red, while fixing the padding."

   AND THE PADDING IS THE HALF THEY SAW FIRST. A tag chip states 8px at the
   start and 2px at the end, because a remove button follows and carries its
   own box. This chip has no such button, so that 2px left its word against
   the pill's own edge. It takes the same 8px at both ends.

   THE MARK IS A SHAPE, WHICH IS WHAT SEPARATES IT. A reader who cannot tell
   two hues apart still sees one chip carrying a triangle. */
.tag-chip[data-dead] {
  padding-inline: var(--space-sm);
  color: var(--danger-quiet);
}

.tag-chip[data-dead] .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));
}

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

@media (forced-colors: active) {
  @media (hover: hover) {
    .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);
}

@media (hover: hover) {
  .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.

   AND THE SWAP CROSS-FADES, BECAUSE IT WAS THE LAST STATE CHANGE IN THIS APP
   THAT DID NOT. Measured by asking the document what was animating one frame
   after the press: zero. Every panel, menu and dialog here fades, so a plus
   that becomes a field in one frame is the one control that pops.

   The mechanism is the panels' own: opacity with `display` at the far end,
   and `@starting-style` to give the entering half a value to come from. */
.tag-add-btn,
.tag-input-hit {
  transition:
    opacity var(--duration) var(--ease),
    display var(--duration) allow-discrete;
}

.tag-add[data-expanded] .tag-add-btn,
.tag-add:not([data-expanded]) .tag-input-hit {
  display: none;
  opacity: 0;
}

@starting-style {
  .tag-add[data-expanded] .tag-input-hit,
  .tag-add:not([data-expanded]) .tag-add-btn {
    opacity: 0;
  }
}

/* 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) }
/* TWO RULES, BECAUSE ONLY ONE OF THEM ANSWERS TO A POINTER. The hover half
   is gated below on a device that has one. Focus has to keep working on a
   finger, and a shared selector list would have taken it away. */
.tag-input:focus { background: var(--surface-sunken) }
@media (hover: hover) {
  .tag-input:hover { background: var(--surface-sunken) }
}

.row-remove,
.row-edit,
.row-get,
.row-share,
.row-fold {
  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);
}

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

/* WHILE A NAME IS OPEN, THE CHECK IS PURE RED AND THE UNDO AND CANCEL ARE
   WHITE. Their instruction, 2 October 2026, correcting a build that had the
   red on the other two. */
.row-undo,
.row-remove[data-mode="revert"] { color: var(--text) }
@media (hover: hover) {
  .row-undo:hover,
  .row-remove[data-mode="revert"]:hover { color: var(--text); background: var(--row-hover) }
}

/* A SAVED NAME'S UNDO SITS AGAINST THE NAME ITSELF, not the cell's edge,
   one standard step clear of it, in the accent. It fades in, and fades out
   after 30 seconds. Their instruction, 2 October 2026. */
.title-row {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
}
.title-row .video-link { flex: 0 1 auto }
.name-undo {
  position: relative;
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: var(--control-sm);
  height: var(--control-sm);
  /* IT TAKES NO MORE HEIGHT THAN THE NAME'S LINE. At its full 28px it made
     the line 6px taller and pushed the name 3px below the row's other marks.
     Measured 2 October 2026; the overhang costs no layout. */
  margin-block: calc((var(--lead-body) - var(--control-sm)) / 2);
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--accent);
  cursor: pointer;
  opacity: 1;
  transition:
    opacity var(--duration-fold) var(--ease-out),
    background-color var(--duration) var(--ease);
}
.name-undo::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}
.name-undo[data-leaving] { opacity: 0 }
@starting-style { .name-undo { opacity: 0 } }
@media (hover: hover) {
  .name-undo:hover { background: var(--row-hover) }
}

/* A TAG CHIP FILTERS ON A CLICK, so it says it can be pressed, and the tag
   that is filtering is red wherever it appears. */
.cell-tags .tag-chip { cursor: pointer; user-select: none }
.tag-chip[data-solo] {
  background: var(--accent-fill);
  color: var(--accent-ink);
}
.tag-chip[data-solo] .tag-remove { color: var(--accent-ink) }

/* AN ARMED CROSS IS RED, AND A WHITE UNDO OPENS BESIDE IT. Their
   instruction, 2 October 2026. The undo's track runs from 0fr to 1fr, so the
   chip widens rather than jumping, and closes the same way. */
.tag-chip[data-armed] .tag-remove,
.tag-chip[data-armed][data-solo] .tag-remove { color: #ff0000 }
@media (hover: hover) {
  .tag-chip[data-armed] .tag-remove:hover { color: #ff0000 }
}
.tag-undo-wrap {
  display: grid;
  grid-template-columns: 0fr;
  opacity: 0;
  transition:
    grid-template-columns var(--duration) var(--ease-out),
    opacity var(--duration) var(--ease-out);
}
.tag-undo-wrap[data-open] { grid-template-columns: 1fr; opacity: 1 }
/* THE PLUS STEPS ASIDE WHILE A TAG ASKS, and gives the undo its width. The
   wider chip otherwise pushed the plus onto a second line and the row grew. */
.tags:has(.tag-chip[data-armed]) .tag-add:not([data-expanded]) { display: none }
.tag-undo-in { display: flex; min-width: 0; overflow: clip }
/* THE CLIP IS FOR THE TRAVEL ONLY. At rest it would cut the undo's reach
   down to its drawn 24px, under the touch floor. */
.tag-undo-wrap[data-settled] .tag-undo-in { overflow: visible }
.tag-undo::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((100% - max(var(--target-mouse), var(--control-floor))) / 2));
}
.tag-undo {
  position: relative;
  display: inline-flex;
  flex: none;
  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);
  cursor: pointer;
  transition: background-color var(--duration) var(--ease);
}
@media (hover: hover) {
  .tag-undo:hover { background: #ffffff14 }
}

/* THE FOLD BELONGS TO THE CARD VIEW. A table row shows every column, so it
   has nothing to fold, and the master toggle has nothing to say there. */
.row-fold, .cards-open { display: none }

/* AT THE NARROWEST WIDTHS THE RATING MENU GOES SHORT, so Expand Cards keeps
   its line. Their choice, 26 September 2026, from a drawing at 296px. The
   long form needs a 346px row: the label and its gap 51, the trigger 128,
   the gap 16 and the switch 143. So under a 378px window the label hides
   and the value reads Any, 1+ to 4+, or 5. */
.rate-short { display: none }
@media (max-width: 377px) {
  #rateGroup .filters-label { display: none }
  .filters-row #rateGroup { flex-basis: 0 }
  .rate-long { display: none }
  .rate-short { display: inline }
}

/* A SWITCH: a track with a knob that slides to the end when on. */
.switch {
  appearance: none;
  position: relative;
  flex: 0 0 auto;
  width: 36px;
  height: 20px;
  margin: 0;
  border-radius: 999px;
  background: color-mix(in oklch, var(--text-muted) 55%, var(--bg));
  cursor: pointer;
  transition: background var(--duration) linear;
}
.switch::before {
  content: "";
  position: absolute;
  inset-block-start: 2px; /* half-step: the thumb inside its track */
  inset-inline-start: 2px; /* half-step */
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: #fff;
  transition: translate var(--duration) cubic-bezier(0, 0, .2, 1);
}
.switch:checked { background: var(--accent) }
.switch:checked::before { translate: 16px 0 }
.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px }
@media (forced-colors: active) {
  .switch { outline: 1px solid CanvasText; outline-offset: -1px }
  .switch::before { background: CanvasText }
  .switch:checked::before { background: Highlight }
}

/* THE CHEVRON POINTS WHERE THE CARD WILL GO. Down while folded, up while
   open, turned rather than redrawn. */
.row-fold .icon { transition: transform var(--duration-fold) var(--ease-slide) }
.row-fold[aria-expanded="true"] .icon { transform: rotate(180deg) }

/* A DOWNLOAD IS NEITHER DESTRUCTIVE NOR A SAVE, so it takes the neutral
   hover the rename does. Held open it stays lit, because the menu belonging
   to it is on screen and nothing else says which button opened it. */
/* A DESTRUCTIVE ITEM IN A MENU SAYS SO IN ITS COLOUR. It sits under a rule,
   so the separation is stated twice: by the gap above it and by the hue. */
.multi-option.multi-danger { color: var(--danger-quiet) }
@media (hover: hover) {
  .multi-option.multi-danger:hover { background: #2a1414; color: var(--danger-quiet) }
}

@media (hover: hover) {
  .row-get:hover { color: var(--text); background: var(--row-hover) }
}
.row-get[aria-expanded="true"],
#noteGet[aria-expanded="true"],
#noteMenu[aria-expanded="true"] { color: var(--text); background: var(--row-selected) }

/* THE MENU IS FIXED TO THE WINDOW, NOT TO THE ROW. A table cell clips, and
   the list is its own scroller, so a menu placed inside either one is cut
   off at the edge. Script writes the two insets. */
.row-get-menu {
  position: fixed;
  /* ABOVE THE NOTE EDITOR, which is a modal layer. At the dropdown layer
     the editor's share menu opened behind it and read as a dead button.
     Their report, 3 October 2026. */
  z-index: var(--layer-popover);
  inset-inline-start: 0;
  min-width: 96px;
  top: 0;
}

/* 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: #ff0000 }
@media (hover: hover) {
  .row-edit[data-mode="save"]:hover { color: #ff0000; background: #2a1414 }
}

/* 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: var(--lead-sm);
}

@media (forced-colors: active) {
  .row-edit[data-mode="save"] { outline: 1px solid Highlight; outline-offset: -3px }
  /* THE HELD BUTTON LOSES ITS ONLY MARKER HERE, because this mode disregards
     an authored background outright. An outline survives it and costs no
     layout, so it cannot move the pair the row measured. */
  .row-get[aria-expanded="true"],
  #noteGet[aria-expanded="true"],
  #noteMenu[aria-expanded="true"] { 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-lg);
  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;
}

@media (hover: hover) {
  .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.
   ========================================================================== */

/* IT SITS IN THE MIDDLE OF THE SPACE IT HAS. Their report, 22 September 2026,
   with a screenshot: "the 'your watchlist is clear' thing should be centered
   vertically inside the big blank area, not near its top edge."

   Measured at 1536x900 before: the box stood at the top of a scroller far
   taller than itself, so the words sat under the column headings with the
   rest of the panel empty below them.

   `flex: 1` TAKES THE LEFTOVER AND `justify-content` CENTRES IN IT. The
   scroller is the flex column, the table keeps its own height, and this box
   takes whatever is left. Where the list is long there is no leftover and
   nothing moves. */
.empty {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
  padding: var(--space-3xl) var(--space-xl);
  text-align: center;
}

/* THE MARK IS AN ILLUSTRATION, NOT A MARK BESIDE A LABEL. Their report: it
   "needs to be bigger, and there needs to be more padding below it." 24 to
   48, which is a step on the space scale, because the type scale stops at 24
   and a box is not type.

   THE DISTANCE UNDER IT IS 16 PAINTED. The column states 8 and this adds 8,
   which is the same shape the action below already uses. */
.empty .icon {
  width: var(--space-3xl);
  height: var(--space-3xl);
  margin-block-end: var(--space-sm);
  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.

   TWO WRITERS PUT IT AT 24. The column publishes an 8px gap and the button
   stated a 16px margin, and the two ADD. Their call, 22 September 2026, on
   which number is right: "keep it consistent." So the margin carries the
   difference and the pair measures the published step.

   A CHILD COMBINATOR, because the subtraction is about THIS parent. Written
   as a descendant it would fire on a button nested deeper, whose own parent
   publishes a different gap or none. */
.empty > .btn, .empty > .btn-text {
  margin-top: calc(var(--space-lg) - var(--space-sm));
}

/* 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: var(--lead-sm);
  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 } }

/* ==========================================================================
   The sync mark turns while it is busy

   Their instruction, 22 September 2026: have the sync icon rotate, or swap it
   for a turning throbber.

   NO SECOND ICON. The refresh mark is already a circle of arrows, so turning
   it says what a throbber says and needs no new drawing. app.js writes the
   attribute from `driveStatus`, which every state already passes through.

   THE TURN IS STEADY, NOT EASED. An eased loop reads as a stutter, because
   the eye sees it slow at the same point on every pass. `linear` is what a
   throbber wants, and it is the one place in this file that asks for it.

   ONE SECOND A TURN. Faster reads as alarm and slower reads as stuck.
   ========================================================================== */

/* THE MARK TURNS, NEVER THE BUTTON. Turning the button would take its label
   and its border with it. The mark is centred in its own box, so a rotation
   about that centre moves no layout. */
@media (prefers-reduced-motion: no-preference) {
  #driveSync[data-busy] .icon { animation: sync-turn 1s linear infinite }
}

/* AND UNDER REDUCED MOTION IT STILL SAYS SOMETHING. A mark that cannot turn
   would leave the button looking idle through the whole wait, so it fades
   instead. The platform replaces motion rather than removing the signal. */
@media (prefers-reduced-motion: reduce) {
  /* THE TOAST'S GLOW HOLDS STILL, at its full strength. */
  .toast-glow::before { animation: none }
  #driveSync[data-busy] .icon { animation: pulse 1.4s var(--ease) infinite }
}

/* A BUSY CONTROL IS NOT A PRESSABLE ONE, and the cursor says so. It stays
   reachable and keeps its ring, because `disabled` would drop it out of the
   tab order mid-wait. */
#driveSync[data-busy] { cursor: progress }

@keyframes sync-turn { to { rotate: 360deg } }

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

/* AN AUTO MARGIN PUSHES AND PADDING SPACES, so the two are not the same
   property here. The distance above the footer was a margin, and an auto
   margin consumes the free space rather than holding a minimum. Stated as
   both, the moment the column fills up the margin collapses and the footer
   touches the list. The padding cannot be consumed. */
footer {
  margin-block-start: auto;
  padding-block-start: 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
   ========================================================================== */

/* THE BOX IS SHARED AND THE FLOATING IS NOT. Their decision, 22 September
   2026: the dialog's look, in a card above the tabs. Splitting the rule is
   what lets the card take the padding, the edge, the corner and the plane
   without also taking a width meant for a box hovering over the page. */
dialog, .ask-card {
  padding: var(--space-xl);
  border: 1px solid #4a4a4a;
  border-radius: var(--radius-lg);
  background: var(--surface-raised);
  color: var(--text);
}

dialog {
  width: min(440px, calc(100% - var(--space-xl)));
  z-index: var(--layer-modal);
}

dialog::backdrop { background: #000000bb }

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

/* THE ASK CARD TAKES THE DIALOG'S OWN RULES. Their decision, 22 September
   2026: the modal's look, in a card above the tabs. Naming it beside the
   dialog is what stops the two drifting. Copying the four values instead
   would be a second design of one thing. */
dialog h2, .ask-card h2 { font-size: var(--text-lg); letter-spacing: var(--track-tight) }
dialog p, .ask-card p { color: var(--text-muted) }
/* THE OVERLINE SITS CLOSER TO THE TITLE IT NAMES. Their instruction,
   22 September 2026: "reduce the padding between SYNC and the title by 4px."
   8 to 4, which is the next step down rather than a number off the scale. */
dialog .stack, .ask-card .stack { display: flex; flex-direction: column; gap: var(--space-xs) }

/* ONE WRITER FOR ONE GAP. Their report, 22 September 2026: "why is the padding
   after SYNC so huge?"

   `.eyebrow` STATES ITS OWN 8px MARGIN, for a bare block with nothing spacing
   it, and the stack above adds another 8. The two add, so the overline sat
   16px from its own title instead of 8. The stack is the writer here, so the
   child's own margin comes off. */
dialog .stack > .eyebrow, .ask-card .stack > .eyebrow { margin-bottom: 0 }

/* IT IS A CARD, SO THE CARD STATES THE BOX. Only the two things a page
   section owns are its own: the space to the band below it, and the width it
   is read at. The dialog's 440px is for a box floating over the page. */
/* IT SPANS THE PAGE, AND THE ACTION ROW SITS UNDER THE WORDS. Their
   instruction, 22 September 2026, over three corrections: "it needs to span
   the whole width", "the width should be the same as the address bar", and
   then, of four wide layouts drawn at 1316px, "the correct answer is A, but
   the buttons will go below the row with the text, not next to it."

   SO THERE IS NOTHING TO STATE BUT THE STEP BELOW IT. No width, because the
   page already gives it the paste field's. No measure on the sentence,
   because a cap is what left 645px of dead space between the words and the
   buttons. No grid, because the buttons belong on their own line.

   MEASURED AT 1316: 142px tall against 180, the sentence on one line of
   1268px, and the buttons at the far edge under it. */
.ask-card { margin-block-end: var(--space-xl) }

/* THE TITLE AND THE SENTENCE SAT AT 0px BOX TO BOX, so the only space between
   them was the leading the two line boxes already carry. Their report,
   22 September 2026: "uncomfortably cramped." Shown 0, 4 and 8 on the real
   card, they took 8: 20px from the title's baseline to the sentence's cap,
   against 12. */
#syncNoteSlot { margin-block-start: var(--space-sm) }

/* ══════════════════════════════════════════════════════════════════════════
   THE LINK BUTTON PULSES WHILE NOTHING IS LINKED

   Their instruction, 22 September 2026: "if an account isn't linked, have the
   link google drive button slowly pulse red, 5 seconds going red, 5 seconds
   going back to grey and so on."

   TEN SECONDS, HALF EACH WAY. The keyframes name the midpoint, so the two
   halves are one run rather than two animations meeting.

   IT ENDS ON THE PRIMARY BUTTON'S OWN COLOURS, never a red invented for
   this. At the peak it is exactly the button beside it in the card.

   MEASURED ACROSS THE WHOLE RAMP: the label reads 14.43:1 at rest and 3.67
   at the peak, which is the shipped primary button's own figure. Every frame
   between is above the one it lands on, so the pulse introduces no state the
   app does not already paint.

   THE MARK SAYS WHY, NOT WHERE. `data-linked` is written by the same render
   that writes the label, so a reader whose token is merely resuming sees no
   pulse.
   ══════════════════════════════════════════════════════════════════════════ */
/* LINEAR, BOTH WAYS. Their instruction, 26 September 2026: bring the linear
   curve back, because the inverse logarithmic one (22 September) held near
   grey too long and read too subtle. */
@keyframes link-pulse {
  50% {
    background: var(--accent-fill);
    border-color: var(--accent-fill);
    color: var(--accent-ink);
  }
}

@media (prefers-reduced-motion: no-preference) {
  #driveConnect[data-linked="no"] {
    /* FIFTEEN SECONDS A CYCLE, SO 7.5 EACH WAY. Their decision, 22 September
       2026, after seeing both: "20 is too long, 10 is too short."

       A GENTLE EASE, stated here so both halves take it. Their pick,
       26 September 2026, from four curves shown side by side: linear was
       too aggressive, the inverse logarithmic too slow. */
    animation: link-pulse 15s cubic-bezier(.37, 0, .63, 1) infinite;
  }

  /* A PRESS OR A FOCUS RING IS THE READER'S OWN ANSWER, so the pulse gets out
     of the way rather than fighting the state under the pointer. */
  #driveConnect[data-linked="no"]:active,
  #driveConnect[data-linked="no"]:focus-visible { animation: none }
}

/* AND THE HOVER IS BEHIND A DEVICE THAT HAS ONE. A tap leaves `:hover` set on
   a touch screen until something else is tapped, so this would stop the pulse
   for good on the phone the first time somebody's thumb passed over it. */
@media (prefers-reduced-motion: no-preference) and (hover: hover) {
  #driveConnect[data-linked="no"]:hover { animation: none }
}

/* ══════════════════════════════════════════════════════════════════════════
   DISMISSING IT, AND EVERYTHING BELOW SLIDING UP

   Their instruction, 22 September 2026: "make sure the card dismissal also has
   a nice animation, and it causes everything to smoothly slide up", with the
   footer's words moving back down into the footer as it goes.

   ONLY WHILE IT IS LEAVING. A `view-transition-name` lifts an element out of
   its ancestor's picture, so a name left on at rest would hold the footer
   still while the lists slide past each other. That is the exact fault this
   file already records about that element, so the mark is what names them.
   ══════════════════════════════════════════════════════════════════════════ */
:root[data-ask] #syncAsk { view-transition-name: ask-card }
:root[data-ask] #syncNote { view-transition-name: sync-note }
/* THE LIST IS ALREADY NAMED, for the swipe, so it needs nothing here. Naming
   it again would be a second writer for one property. */

/* AND THE TAB BAND TRAVELS THE SAME DISTANCE. Their report, 22 September 2026:
   "the rest of the chrome moving up is feeling oddly snappy, not smooth."

   IT WAS IN THE ROOT'S OWN PICTURE, which is told not to animate, so its new
   image covered the old one at once. Everything named glided and the tabs
   jumped the whole 234px in one frame, under a list that was still moving.

   EVERYTHING BELOW THE CARD IS NAMED NOW, and everything above it holds
   still. The bar is the only thing left in the root's picture, and the bar
   does not move. */
:root[data-ask] .tabs { view-transition-name: tab-band }

/* THE TWO THAT TRAVEL TAKE THE LONG RUN AND THE SETTLING CURVE. The paragraph
   crosses the height of the card, and the list travels the same distance. */
[data-ask]::view-transition-group(sync-note),
[data-ask]::view-transition-group(list-panel),
[data-ask]::view-transition-group(tab-band),
[data-ask]::view-transition-group(tab-pill),
[data-ask]::view-transition-group(ask-card) {
  animation-duration: var(--duration-fold);
  animation-timing-function: var(--ease-slide);
}

/* THE BAND'S TWO PICTURES ARE THE SAME PICTURE, so a cross-fade between them
   is a fade of a thing against itself. It only travels, so it holds. */
[data-ask]::view-transition-old(tab-band),
[data-ask]::view-transition-new(tab-band) { animation: none }

/* THE CARD ITSELF ONLY LEAVES, so the pair is an exit rather than a swap. It
   fades where it stands, because it has no second position to travel to. */
[data-ask]::view-transition-old(ask-card) {
  animation: ask-leave var(--duration-fold) var(--ease-slide) both;
}

[data-ask]::view-transition-new(ask-card) { animation: none; opacity: 0 }

@keyframes ask-leave { to { opacity: 0 } }

/* AND THE PAGE AROUND THEM HOLDS STILL. With three boxes lifted out, the
   root's own picture is the bar and the tabs, and neither moves. */
[data-ask]::view-transition-old(root),
[data-ask]::view-transition-new(root) { animation: none }

.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, .ask-card 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); --page-inline: var(--space-lg); padding: var(--page-pad) }

  .sync-words { display: none }

  .page-head { 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. */
  .page-head { 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. */
  /* THE FIELD AND THE PASTE BUTTON SHARE A LINE, and the add button takes
     the line below. A wrap rather than a column, so the field keeps its
     own height. */
  .add-row { flex-wrap: wrap }
  .add-row .input { flex: 1 1 0; width: auto }
  .add-row #addBtn { flex: 1 1 100% }

  /* THE TAGS TAKE THEIR OWN LINE under the field and the paste button, and
     the pills wrap, so the box grows a line at a time. */
  .add-row #pasteBtn { order: 1 }
  .add-tags { order: 2; flex: 1 1 100%; flex-wrap: wrap; padding-block: var(--space-2xs) }
  /* THE PILLS AND THE TYPING SPACE SHARE LINES, so the space sits after the
     last pill when it fits, rather than always on a line of its own. */
  .add-tags-pills { display: contents }
  /* A PILL'S HEIGHT, SO A LINE OF PILLS AND THE TYPING SPACE MATCH. The box
     itself still clears the touch floor. */
  .add-tags .add-tag-input { flex: 1 1 64px; min-width: 64px; min-height: var(--control-sm) }
  .add-row #addBtn { order: 3 }

  /* 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 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-row { flex-wrap: wrap }
  .search { order: 1; flex: 1 1 100%; min-width: 0 }
  /* EXPAND CARDS SITS BESIDE THE RATING MENU, AS A SWITCH. Their
     instructions, 26 September 2026: it hogged a row of its own as a
     checkbox; then beside the search field; then beside the rating menu,
     with the search field on its own line. It takes the menu's height, so
     the two share one centre line. */
  #rateGroup { order: 3; flex: 1 1 50%; min-width: 0 }
  .cards-open {
    order: 3;
    flex: 0 0 auto;
    align-items: center;
    gap: var(--space-sm);
    height: max(var(--control-md), var(--control-floor));
    padding-inline: var(--space-xs);
    font-size: var(--text-sm);
    color: var(--text-muted);
    cursor: pointer;
  }
  .filters-group { order: 2; flex: 1 1 100% }
  .filters-group .multi { flex: 1 1 auto; width: auto }
  #tagBulk { order: 4; 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: 5; flex: 1 1 0 }
  #clearFilter { order: 6; 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.

     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 { border-bottom: 0 }

  /* AND ITS HEAD TOO, WHICH WAS THE WRITER NOBODY TOOK AWAY. The bar owns
     both distances, so the row keeping a 12px head made the panel pay twice.
     Measured open at 760: the sort control's foot to the search field read 37
     where the folded list's first card reads 25, against 16 between every
     other pair in the row.

     IT ALSO PUT 37px OF NOTHING AT THE HEAD OF THE SLIDE. The first 90ms of a
     500ms fold revealed empty band, so the panel appeared to stall and then
     start. Their words: "it freezes when it's pulling out", and "half of the
     search bar is obscured", which is that field emerging through the gap. */
  .filters-row { padding-block: 0 }

  /* THE LAST VISIBLE ROW OF THE BAND CARRIES ITS FOOT, and which row that is
     depends on the fold. The panel moved below the sort bar, so folded the
     bar is last and open the panel is. Left alone the panel ended flush
     against the first card, measured at 0px, and the rule sat inside the band
     rather than under it.

     THE PADDING IS THE HALF THAT MATTERS, and it asks only about the fold.
     The RULE needs to know what follows, which is what :has can answer. A
     browser without it draws a second line between the two rows, 152px from
     the first, which is a divider rather than a fault. */
  /* THE RULE TRAVELS WITH THE PANEL, SO IT IS A PSEUDO-ELEMENT AND NOT A
     BACKGROUND. Their report: "why does the separator line blink ahead of the
     other stuff when collapsing the filter? shouldn't it slide back up with
     the others?"

     It blinked because the OWNER swapped at the press. The sort bar's own line
     came back the instant `hidden` was set, 189px above where the panel's line
     was still sitting, and the panel then slid up to meet a line already at its
     destination.

     A BACKGROUND IS CLIPPED TO ITS OWN BOX AND AN ABSOLUTE CHILD IS NOT. At a
     12px track the gradient's 1px sat above the box and stopped painting, so
     the line would have vanished 12px short whichever way it was keyed. This
     one paints at every height, and at a zero track it lands exactly on the
     sort bar's own line. So the handover is a coincidence of position rather
     than a swap anybody can see. */
  .filters::before {
    content: "";
    position: absolute;
    inset-inline: 0;
    /* PLACED BY ITS TOP, so it lands on the same pixel the bar's own line
       uses. The bar states 100% - 12px as a background position, which is the
       line's TOP edge. Stated as inset-block-end the same 12 puts the line's
       BOTTOM there, and the handover hopped 1px. */
    inset-block-start: calc(100% - var(--space-md));
    height: 1px;
    background: var(--line);
    pointer-events: none;
  }

  /* A DESCENDANT, because the clipper now stands between the two. */
  .filters:not([hidden]) .filters-row {
    padding-block-end: calc(var(--space-md) * 2 + 1px);
  }

  /* AND THE BAR HOLDS ITS OWN LINE BACK FOR THE WHOLE RUN, not only while the
     panel is open. `hidden` flips on the press and `display` flips 500ms later,
     so the attribute alone hands the line over at the wrong end. The script
     marks the panel for the length of the fold, which is the one moment CSS
     cannot ask about. */
  .sort-bar:has(+ .filters:not([hidden])),
  .sort-bar:has(+ .filters[data-folding]) { background: var(--bg) }

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

  /* The card view draws no box, so there are no corners to round. Left on, a
     radius on a scroller clips whatever reaches its foot. */
  .table-wrap { overflow-x: visible; --list-edge: 0px; border: 0; border-radius: 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-sm);
    background: var(--surface);

    /* A CARD NOBODY IS LOOKING AT IS NOT RENDERED. Their report: the
       notification dismissal is still not smooth on phones. The message
       changes the page's height on every frame, so every card below it is
       laid out and painted again, and a long list makes that the whole cost.

       MEASURED WITH THE IN-APP PROFILER at 375 with 413 cards, three
       interleaved passes, driving the real cross: 23.7 fps as shipped
       against 113.4 with this, and the worst frame gap 383.4ms against 50.
       Nothing else moved it. Paint containment on the list gave 29.2,
       dropping the margin travel 28.6, dropping the clipper's fade 33.5.

       130px IS THE CONTENT HEIGHT, not the card's. `contain-intrinsic-size`
       states the content box and the browser adds the padding and borders,
       which here are 34. Stated as the 164 a card measures, the page came
       out 18.7% too long and the scrollbar lied about it. At 130 it is 0.18%
       short. `auto` then remembers each card's real height once it has been
       seen, so a card with three lines of tags corrects itself.

       IT IS THE CARD VIEW'S RULE BECAUSE A TABLE ROW CANNOT BE SIZE
       CONTAINED. Measured at 1280: identical row height, identical scroll
       height, no effect at all. Here the row is a grid, so it can. */
    content-visibility: auto;
    contain-intrinsic-size: auto 130px;
  }

  /* 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. */
  @media (hover: hover) {
    tbody tr:hover { background: var(--surface) }
  }
  /* TWO RULES, BECAUSE ONLY THE SECOND ANSWERS TO A POINTER. A shared
     selector list would have put the resting card behind a hover test, so a
     selected card on a phone would have lost its fill and its edge. */
  tbody tr.row-selected { background: var(--row-selected); border-color: var(--accent) }
  @media (hover: hover) {
    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 }

  /* THE TAB BAR STAYS ON SCREEN IN THE CARD VIEW. Their instruction,
     26 September 2026. It sticks one page gap from the top, and the paste
     card sticks one gap under it. stackSticky() writes that offset.

     THE GAP UNDER IT IS PAINTED, or the rows would show through it on their
     way under the card. A strip in the page colour, one gap deep, hangs
     under the bar. It costs no layout, and at rest it lies inside the bar's
     own 32px margin, where nothing is.

     A STRIP, NEVER A SHADOW. A shadow paints and takes no press, so a tap in
     the gap reached the hidden row behind it. */
  .tabs {
    position: sticky;
    top: var(--page-pad);
    z-index: calc(var(--layer-sticky) + 4);
    background: var(--bg);
  }
  /* BELOW THE BAR'S OWN BOTTOM LINE, NEVER ON IT. An absolute child is
     placed against the padding box, so 100% is the INNER edge of the border
     and the strip painted over the grey line. Their report, 26 September
     2026: the tab bar lost its baseline. */
  .tabs::before {
    content: "";
    position: absolute;
    inset-inline: 0;
    /* THE STRIP DRAWS THE BASELINE ITSELF, over the bar's own. Their report,
       26 September 2026, from a phone: a pixel of rows leaked under the tab
       bar. Starting one pixel below the border left a seam between two
       boxes, and at a fractional device pixel ratio the two edges round
       apart. One box covering both has no seam to round. */
    inset-block-start: 100%;
    height: calc(var(--page-pad) + 1px);
    background: linear-gradient(var(--line) 0 1px, var(--bg) 1px);
  }

  /* THE SEAM UNDER THE STRIP IS FILLED FROM BEHIND. The bar's height is not a
     whole pixel, so the strip's foot and the card's sticking point round
     differently from frame to frame, and a 1px line of rows flickered
     between them. Their report, 26 September 2026. The card paints 2px of
     page colour above its own edge, under the bar, so the seam is covered
     wherever the rounding lands and the card's border is untouched. */
  .add-card { box-shadow: 0 -2px 0 0 var(--bg) }

  /* THE HEADER'S BUTTONS, WHILE THEY RIDE IN THE BAR. The tabs share what
     is left, and each tab drops its count, then its mark, as it narrows. */
  .tabs[data-tools] .sync-row {
    flex: 0 0 auto;
    align-self: center;
    margin-inline-start: var(--space-sm);
  }
  .tabs[data-tools] .sync-words { display: none }

  /* ICONS ONLY IN THE BAR. Their question, 26 September 2026, after a 44px
     button stood 2px from each edge of a 49px bar. The buttons keep their
     size, so the press area is unchanged, and draw no box: the same bare
     marks as the tabs beside them. */
  .tabs[data-tools] .sync-row .btn {
    background: transparent;
    border-color: transparent;
    box-shadow: none;
    animation: none;
  }
  /* THE UNLINKED SIGNAL MOVES TO THE MARK. In the header the button's fill
     pulses red while no account is linked. With no fill to pulse, the cloud
     itself is red until a link is made. */
  /* An id outweighs the bare-mark rule above, so the pulse is stopped here too. */
  .tabs[data-tools] #driveConnect[data-linked="no"] { color: var(--danger); animation: none }
  @media (hover: hover) {
    .tabs[data-tools] .sync-row .btn:hover { background: var(--row-hover) }
  }

  /* A TAB TOO NARROW FOR ITS NAME SHOWS ITS MARK ALONE while the buttons
     share the bar. Measured at 417: the three names need about 270px and
     the buttons 174, in 385 of room, so the names ran into each other. The
     name stays for a screen reader.

     IT SWAPS WHILE THE TABS ARE FADED OUT, never in view. Their report,
     26 September 2026: the names and marks popped. dockTools() fades the
     tab contents out over the first half of the slide, sets
     data-tools-swap, and fades them back in over the second. */
  @container (max-width: 76px) {
    /* A mark with no name beside it centres on its tab, like the bare
       buttons next to it, rather than on a cap line that is not there. */
    .tab .tab-inner[data-swap] .icon {
      display: inline-block;
      transform: none;
      opacity: 1;
      max-width: 4em;
      margin-inline-end: 0;
    }
    .tab .tab-inner[data-swap] > span:not(.tab-count) {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip-path: inset(50%);
      white-space: nowrap;
    }
  }
  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. */
  /* THE FOLD IS ROWS 3 AND 4, and each is a track that goes from nothing to
     its content. Their instruction, 25 September 2026: a chevron reveals the
     dates, the tags and the rating, folding out with an animation.

     minmax(0, 0fr) TO minmax(0, 1fr) IS WHAT CAN BE ANIMATED. A track whose
     height is auto has no number to travel between, and a flex share does.
     The zero floor is what lets a track reach nothing even while its cells
     carry a margin.

     SO THE GAP BETWEEN LINES MOVED ONTO THE CELLS. A grid's row gap sits
     between tracks whatever their size, so two folded tracks still charged
     24px. Written as each line's top margin, a folded line takes its
     distance with it. Same 12px as before, and still one writer. */
  tbody tr {
    position: relative;
    grid-template-columns: max-content minmax(0, 1fr);
    grid-template-rows: auto auto minmax(0, 0fr);
    row-gap: 0;
    /* THE LINES ABOVE THE FOLD KEEP THEIR OWN HEIGHT. Their report,
       25 September 2026: the card's contents shifted and settled back while
       it folded. Mid-fold the two flex tracks share less than all the free
       space, and a grid stretches its auto tracks over whatever is left.
       Measured before: the channel line dropped 11.5px and the date line 23
       over the first 90ms. Packed at the start, nothing is stretched. */
    align-content: start;
    transition: grid-template-rows var(--duration-fold) var(--ease-slide);
  }
  tbody tr[data-open="true"] { grid-template-rows: auto auto minmax(0, 1fr) }

  /* ONE FOLDING LINE, NOT TWO. Two flexible tracks always come out the same
     size, so the date line grew to the tags' 92px and left a hole under the
     dates. The tags and the rating share the date line's track instead, set
     below the dates by the dates' own height and one more line gap. */
  tbody tr:not(:has(.cell-excerpt)) > .cell-tags {
    grid-row: 3;
    margin-block-start: calc(var(--space-md) * 2 + var(--lead-xs));
  }

  /* A NOTE KEEPS ITS DATE LINE, because its download sits on it. Only its
     tags fold. */
  tbody tr:has(.cell-excerpt) { grid-template-rows: auto auto auto minmax(0, 0fr) }
  tbody tr:has(.cell-excerpt) > .cell-tags { grid-row: 4 }
  tbody tr:has(.cell-excerpt)[data-open="true"] { grid-template-rows: auto auto auto minmax(0, 1fr) }

  tbody tr > :is(.cell-chan, .cell-dur, .cell-url, .cell-excerpt,
                 .cell-up, .cell-added, .cell-edited, .cell-get, .cell-share, .cell-tags) {
    margin-block-start: var(--space-md);
  }

  /* WHAT FOLDS FADES AS IT GOES, and is gone from a finger and a screen
     reader once folded. Visibility waits for the fade on the way in, and
     leads it on the way out. */
  tbody tr > :is(.cell-up, .cell-added, .cell-tags, .cell-rate) {
    min-height: 0;
    opacity: 0;
    visibility: hidden;
    transition:
      opacity var(--duration-fold) var(--ease-slide),
      visibility 0s linear var(--duration-fold);
  }
  tbody tr[data-open="true"] > :is(.cell-up, .cell-added, .cell-tags, .cell-rate) {
    opacity: 1;
    visibility: visible;
    transition:
      opacity var(--duration-fold) var(--ease-slide),
      visibility 0s linear 0s;
  }

  /* THE MASTER TOGGLE SHOWS WHERE THERE ARE CARDS. */
  .cards-open { display: inline-flex }
  .row-fold { display: inline-flex }

  /* 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. */
  /* 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-get,
  .row-remove,
  .row-edit,
  .row-share,
  .row-fold {
    position: relative;
    min-width: 0;
    min-height: 0;
  }

  .check-hit::after,
  .row-get::after,
  .row-remove::after,
  .row-edit::after,
  .row-share::after,
  .row-fold::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-get,
  .row-remove,
  .row-edit,
  .row-share,
  .row-fold { 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 }

  /* TWO RUNS ON ONE LINE SHARE ONE BASELINE, whatever their fonts. Their rule,
     30 September 2026. The channel is Onest and the duration Geist Mono, and
     each placed by its own box top sat 1px apart on their phone, where the
     two faces report different ascents. Baseline alignment in the grid row
     makes the line the reference instead of the box. */
  tbody tr > :is(.cell-chan, .cell-dur, .cell-up, .cell-added) { align-self: baseline }
  /* THE RATING SHARES THE TAGS LINE, AT THE END OF IT. Their instruction,
     19 September 2026. On its own line it cost the card a fifth row for one
     14px run. Centred on the FIRST chip rather than on the block: the tags
     wrap and the rating belongs to the top of them.

     The offset is the cell's own head padding plus half the difference
     between a chip and a mark, so it follows both tokens. */
  .cell-tags  { grid-column: 2; grid-row: 4 }

  /* A FLEX BOX, BECAUSE A LINE BOX ADDS ITS OWN LEADING. The run is
     inline-flex, so the cell's line box put it 2.00px below the cell's own
     top and the mark missed the chip's centre by exactly that. */
  .cell-rate {
    display: flex;
    grid-column: 2;
    grid-row: 3;
    justify-self: end;
    align-self: start;
    margin-block-start: calc(
      var(--space-md) * 2 + var(--lead-xs)
      + var(--space-sm) + (var(--control-sm) - var(--mark)) / 2
    );
  }

  /* THE CHIPS CLEAR THE RUN, NEVER THE BOX. The stars are 78px of marks and
     the remove button on a chip carries its own reach, so the reservation is
     the run plus one step. */
  .cell-tags {
    padding-inline-end: calc(
      var(--mark) * 5 + var(--star-gap) * 4 + var(--space-md)
    );
  }

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

  /* A NOTE CARD READS TITLE, EXCERPT, THEN ITS TWO DATES. Left unplaced,
     these two auto-flowed into the max-content columns beside the title, and
     the excerpt's own nowrap set the width: measured at 375, an 829px table
     inside a 343px card, 486px past the page with no scroller.

     `min-width: 0` IS WHAT LETS IT TRUNCATE. A grid item floors at its own
     content, so a nowrap line in a track that can grow simply grows it. */
  .cell-excerpt {
    grid-column: 2;
    grid-row: 2;
    justify-self: stretch;
    min-width: 0;
    color: var(--text-muted);
    font-size: var(--text-xs);
  }

  .cell-edited {
    grid-column: 2;
    grid-row: 3;
    justify-self: start;
    font-size: var(--text-xs);
    color: var(--text-muted);
  }

  /* THE ADDED DATE MOVES OVER FOR IT, the way it does for an address. Two
     facts on one line, the edited stamp first because a notes list is
     sorted by it. */
  /* A NOTE CARD SHOWS ONE DATE, AND THE DOWNLOAD SHARES ITS LINE. Their
     instruction, 22 September 2026: show the last updated date on the left
     and put the download button at its right, across the card.

     THE TWO DATES PAINTED OVER EACH OTHER BEFORE THIS. Measured at 296:
     edited ink 61 to 188.3, added ink 135.7 to 263, on one 18px band, so
     52.59px of one date was drawn on top of the other. Dropping the added
     stamp answers that by construction rather than by a threshold.

     THE ADDED DATE IS STILL IN THE MARKUP, so the sort menu and the screen
     reader keep it. Only the card stops painting it. */
  tr:has(.cell-excerpt) .cell-added { display: none }

  /* IT LEAVES LINE 1 FOR THIS LINE, so the title clears one mark rather than
     two and the delete cross keeps the corner on its own. Being in flow it
     sizes its own track, which is what puts it at the card's end edge. */
  tr:has(.cell-excerpt) .cell-get {
    grid-column: 2;
    grid-row: 3;
    justify-self: end;
    align-self: start;
    display: flex;
    align-items: center;
    height: var(--lead-xs);
    font-size: var(--text-xs);
  }
  /* ON THE DATE LINE'S CAP BAND, like the link share. Their report,
     30 September 2026: it sat a few pixels off the date. */

  /* 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: var(--lead-sm);
    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.

     ONLY THE BOOKMARK CARD HOLDS A PAIR HERE. A note's download shares the
     date line instead, so its delete cross has the corner to itself and
     takes the plain floor. */
  /* EVERY CARD HOLDS A PAIR IN ITS CORNER NOW, the chevron and the cross, so
     the distance applies to every card. A bookmark card adds its pencil to
     their left, one more pair along. */
  /* HALF THE SPACE IT WAS. Their instruction, 25 September 2026: the marks
     sat too far apart. It was one mark plus 20px on a mouse and 30px on a
     finger between them, and each press area was as wide as the pair
     distance so two never overlapped. The space between the marks is halved
     and the press areas narrow with it, so they still meet without
     overlapping: 10px apart on a mouse, 15 on a finger. */
  tbody tr {
    --row-pair: calc(var(--mark) + (max(
      calc(var(--mark) + 20px),
      max(var(--target-mouse), var(--control-floor))
    ) - var(--mark)) / 2);
  }

  .cell-remove { gap: calc(var(--row-pair) - var(--mark)) }

  /* CHEVRON, PENCIL, CROSS, reading towards the corner. The pencil sits one
     pair from the cross, and the chevron steps out one more to make room. */
  .cell-edit {
    inset-inline-end: calc(var(--space-lg) + var(--row-pair));
  }
  tr:has(.cell-edit) .row-fold { margin-inline-end: var(--row-pair) }

  /* A LINK'S SHARE SITS UNDER THE CROSS, ON THE LINE BELOW THE TITLE. Their
     instruction, 30 September 2026: under the delete, on the channel's line
     for a video and the address's line for a bookmark. It sits in the
     grid's end edge, which is the cross's end edge too, so the two marks
     share one centre line down the card.

     IT CENTRES ON THE LINE'S CAP BAND, the span from the cap height to the
     baseline. The box is the line's own line box and the mark centres in
     it. Measured 30 September 2026 at 360: 0.5px from the band's centre on
     the channel and date lines, 0.00 on the address line, so no shift is
     needed on top. */
  .cell-share {
    grid-column: 2;
    grid-row: 2;
    justify-self: end;
    align-self: start;
    display: flex;
    align-items: center;
    height: var(--lead-xs);
    font-size: var(--text-xs);
  }

  /* THE DURATION AND THE ADDRESS STEP ASIDE FOR IT, by one pair: the same
     distance the chevron keeps from the cross above. */
  tr:has(.cell-share) .cell-dur { margin-inline-end: var(--row-pair) }
  tr:has(.cell-share) .cell-url { padding-inline-end: calc(var(--row-pair) + var(--space-sm)) }
  /* An address is set in the larger type, so its share takes that line box. */
  tr:has(.cell-url) .cell-share { height: var(--lead-sm) }
  /* ON THE SMALL LINE THE BOX CENTRE SITS 0.5px UNDER THE CAP BAND'S, and a
     phone rounds that to a whole pixel. Their report, 30 September 2026:
     "still off-axis by one pixel". The address line measures 0.00 and keeps
     no lift. */
  tr:not(:has(.cell-url)) .cell-share .row-share,
  tr:has(.cell-excerpt) .cell-get .row-get { translate: 0 -0.5px /* optical */ }

  /* THAT STEP LEAVES THE CROSS'S CELL SPANNING THE PENCIL. The cell is only a
     holder, so it takes no press, and each button in it still does. Measured
     before: a tap on the pencil's centre landed on the empty cell. */
  .cell-remove { pointer-events: none }
  .cell-remove > button { pointer-events: auto }

  tbody tr :is(.row-edit, .row-remove, .row-share, .row-fold)::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. */
  tbody tr .cell-title {
    --title-reserve: calc(
      (var(--mark) + var(--row-pair)) / 2 + var(--row-pair) + var(--space-sm)
    );
    padding-inline-end: var(--title-reserve);
  }
  tr:has(.cell-edit) .cell-title {
    --title-reserve: calc(
      (var(--mark) + var(--row-pair)) / 2 + var(--row-pair) * 2 + var(--space-sm)
    );
  }


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

  .cell-chan, .cell-dur, .cell-up, .cell-added { font-size: var(--text-xs) }
  /* GEIST MONO BESIDE 12px ONEST TAKES 11px. Their rule, 1 October 2026: at
     one size the mono capitals read larger than the words beside them. The
     two still share one baseline, and a mark on the line follows the taller
     capitals, which are the Onest ones now. */
  tbody tr > .cell-dur { font-size: 11px }
  .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. */
  /* TWO WRITERS FOR ONE DISTANCE, AND THE SUM IS STATED HERE. The card's own
     row gap is 12 and this adds 8, so the chips sit 20 below the date. A grid
     gap cannot vary by row, so the cell is the only place the extra can come
     from. Anyone changing either number has to read this one. */
  .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 }
}

/* ==========================================================================
   The ad strip

   PINNED TO THE WINDOW'S FOOT, and the page gives up exactly its height:
   the script publishes --ad-strip on the root, the body pads by it, the
   locked layout shortens by it, and the page's foot fade and the toasts sit
   above it. Hidden, it publishes 0 and nothing moves.

   ONE BANNER HIGH. 50px is the standard mobile banner and the smallest
   Google fills, so the strip takes the least room an ad can.
   ========================================================================== */
body { padding-block-end: var(--ad-strip, 0px) }
.ad-strip {
  position: fixed;
  inset-inline: 0;
  inset-block-end: 0;
  z-index: calc(var(--layer-sticky) + 5);
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  padding: var(--space-xs) var(--space-md);
  padding-block-end: max(var(--space-xs), env(safe-area-inset-bottom));
  background: var(--surface);
  border-block-start: 1px solid var(--line);
}
.ad-slot {
  flex: 1 1 0;
  min-width: 0;
  height: 50px; /* optical: the ad unit is 50px by its own spec */
  overflow: clip;
}
.ad-slot .adsbygoogle { display: block; width: 100%; height: 50px /* optical: the ad unit spec */ }
/* A SLOT WITH NO AD ACCOUNT BEHIND IT SAYS SO, so the layout can be judged
   on a machine that serves no ads. */
.ad-slot[data-placeholder]::before {
  content: "Ad";
  display: grid;
  place-items: center;
  height: 100%;
  border: 1px dashed var(--line);
  border-radius: var(--radius-sm);
  color: var(--text-muted);
  font-size: var(--text-xs);
}
.ad-remove { flex: none; text-decoration: none }
   Notes sit two to a row, each with its colour down the left edge

   Their request, 5 October 2026: the notes card view goes two columns side
   by side, the way Google Keep lays notes out, and every note carries a
   colour strip on its left edge.

   THE STRIP IS A BACKGROUND LAYER, so the card's own corner clips it and it
   costs no layout. A border would widen the card by its own width and move
   the text off the margin every other card keeps.
   ========================================================================== */
@media (max-width: 1120px) {
  table[data-list="notes"] tbody {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    align-items: start;
  }
  /* THE STRIP SITS OUTSIDE THE CARD'S MARGIN, never inside it. Their
     instruction, 5 October 2026: the 16px inset is measured from the strip's
     inner edge, so the left side carries the strip's own width on top. */
  table[data-list="notes"] tbody tr {
    container: note-card / inline-size;
    padding-inline-start: calc(var(--space-lg) + var(--space-xs));
    background:
      linear-gradient(var(--note-color), var(--note-color)) 0 0 / var(--space-xs) 100% no-repeat,
      var(--surface);
  }
}

/* THE NOTE'S COLOUR SQUARE AND ITS HUE STRIP. The strip is MDexed's compact
   picker's own: 12px tall, a 6px radius, a 12px thumb ringed in white with a
   dark hairline so it reads on every hue. The square is the mark size, 16px,
   and its reach meets the touch floor without moving the title. */
.note-name { position: relative }
.note-swatch {
  position: relative;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.note-swatch::after {
  content: '';
  position: absolute;
  inset: min(0px, calc((100% - max(24px, var(--control-floor))) / 2));
}
.note-hue-pop {
  position: absolute;
  inset-block-start: calc(100% + var(--space-xs));
  inset-inline-start: 0;
  z-index: var(--layer-popover);
  padding: var(--space-md);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  box-shadow: 0 8px 24px #000000a8;
}
/* SIXTEEN SWATCHES, FOUR TO A ROW. Each is 40px, so a finger lands on one
   without a reach drawn past its edge, and the 8px between them keeps two
   neighbours from reading as one block. The chosen one carries a ring in
   the text colour, 2px clear of its fill. */
.note-hue-grid {
  display: grid;
  grid-template-columns: repeat(4, 40px);
  gap: var(--space-sm);
}
.note-hue-swatch {
  width: 40px;
  height: 40px;
  padding: 0;
  border: 0;
  border-radius: var(--radius-sm);
  cursor: pointer;
}
.note-hue-swatch[aria-checked="true"] {
  outline: 2px solid var(--text);
  outline-offset: 2px;
}
.note-hue-swatch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px }

/* A NARROW NOTE CARD DROPS THE TIME AND KEEPS THE DAY. Their report,
   7 October 2026: at 432 wide the date ran into the share mark. Measured on
   the card: "Oct 7, 2026, 4:24 PM" is 115px, the mark and its gap 26, and
   the card's own insets 66, so the full form needs 207. 216 leaves room for
   a longer time such as "12:44 PM". Asked of the card, never the window,
   because two columns halve it. */
@container note-card (max-width: 216px) {
  .cell-edited .date-full { display: none }
  .cell-edited .date-day { display: inline }
}

/* ==========================================================================
   A touch screen selects by long press

   Their design, 7 October 2026. The checkbox column goes, the chosen card's
   outline turns the accent and doubles, fading, and a strip slides up from
   the foot of the screen, pushing the pager up with it.
   ========================================================================== */
@media (max-width: 1120px) {
  /* THE BOX GOES AND ITS COLUMN WITH IT. The column keeps the row's 12px
     gap even when empty, so the card's own inset gives that back and the
     text lands where the edge of every other card's margin is. */
  :root[data-touch] .cell-check,
  :root[data-touch] .sort-all { display: none }
  :root[data-touch] tbody tr {
    grid-template-columns: 0 minmax(0, 1fr) max-content;
    padding-inline-start: calc(var(--space-lg) - var(--space-md));
    -webkit-touch-callout: none;
    user-select: none;
    transition: border-color var(--duration) var(--ease), box-shadow var(--duration) var(--ease),
      background-color var(--duration) var(--ease);
  }
  :root[data-touch] table[data-list="notes"] tbody tr {
    padding-inline-start: calc(var(--space-lg) + var(--space-xs) - var(--space-md));
  }

  /* THICKER AS WELL AS ACCENT, BY HALF A PIXEL. Drawn as a shadow inside the
     edge, so the card does not grow when it is chosen. A whole second pixel
     read as "one thicc border", their words, 7 October 2026; half of one is
     1.5 device pixels on a 3x screen. */
  :root[data-touch] tbody tr.row-selected {
    border-color: var(--accent);
    box-shadow: inset 0 0 0 .5px var(--accent);
  }
  /* A CHOSEN NOTE KEEPS ITS COLOUR STRIP under the chosen fill. */
  :root[data-touch] table[data-list="notes"] tbody tr.row-selected {
    background:
      linear-gradient(var(--note-color), var(--note-color)) 0 0 / var(--space-xs) 100% no-repeat,
      var(--row-selected);
  }
}

/* THE STRIP. A frame fixed at the foot, and inside it the strip itself,
   which slides up by its own height. The pager stands on it while it is up,
   travelling the same distance on the same curve, and the page gains the
   same room at its foot so the last card is not left under it.

   THE FRAME NEVER MOVES. A tab switch takes a picture of it, and the
   picture is what animates, so it needs one fixed place to start from. */
.pick-bar {
  --pick-h: calc(max(var(--control-md), var(--control-floor)) + var(--space-md) * 2);
  /* THE PAGE'S OWN SIDE MARGIN, and centred on the page's own column where
     the window is wider than it. It sits outside the page, where the page's
     margin is not inherited, so it states the same value: 32, or 16 under
     1120 wide. Their report, 7 October 2026: the words sat on the glass. */
  --page-inline: var(--space-2xl);
  position: fixed;
  inset-inline: 0;
  inset-block-end: 0;
  z-index: calc(var(--layer-sticky) + 4);
  height: var(--pick-h);
  overflow: clip;
  pointer-events: none;
}
.pick-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  height: 100%;
  box-sizing: border-box;
  padding-inline: max(var(--page-inline), calc((100vw - 1380px) / 2 + var(--page-inline)));
  border-block-start: 1px solid var(--line);
  /* THE PAGE'S OWN GROUND, as the pager's is. On the raised plane the
     buttons' fill matched it and they read as bare marks. */
  background: var(--bg);
  transform: translateY(100%);
  transition: transform var(--duration) var(--ease-slide);
  pointer-events: auto;
}
:root[data-picking] .pick-inner { transform: none }
/* UNDER 1120 THE PAGE'S MARGIN IS 16, so the strip's is too. Written after
   the strip's own rule, which states 32, so this one wins. */
@media (max-width: 1120px) {
  .pick-bar { --page-inline: var(--space-lg) }
}
.pick-count {
  margin: 0;
  color: var(--text);
  font-size: var(--text-sm);
  font-weight: 600;
  white-space: nowrap;
}
.pick-actions { display: flex; gap: var(--space-sm) }

/* THE PAGER RIDES THE STRIP UP AND DOWN. Written by id and with the fade's
   own two runs, because the fade rule names #pager and replaces any list a
   class gives it. Without this the pager jumped 60px in one frame while the
   strip took 250ms to arrive, which read as a flicker. Their report,
   7 October 2026. */
#pager {
  transition:
    opacity var(--duration) var(--ease),
    display var(--duration) allow-discrete,
    inset-block-end var(--duration) var(--ease-slide);
}
:root[data-picking] .pager { inset-block-end: calc(max(var(--control-md), var(--control-floor)) + var(--space-md) * 2) }
:root[data-picking] body { padding-block-end: calc(max(var(--control-md), var(--control-floor)) + var(--space-md) * 2) }

/* ==========================================================================
   The strip and the pager take turns on a tab switch

   Their design, 7 October 2026. Coming in, the strip rises first and the
   pager follows it up. Going out, the pager sinks first and the strip
   follows it down. The four runs overlap, so the eye reads one movement:

     pager out   0 - 35%      strip out  15 - 50%
     strip in   50 - 85%      pager in   65 - 100%

   Without a strip on either side the pager keeps its own halves.
   ========================================================================== */

/* WHILE A FINGER DRAGS, THE PAGER DOES NOT ALSO SLIDE ON ITS OWN. The script
   reads where the incoming pager rests, and a running slide would hand it
   the starting point instead. */
:root[data-drag] #pager { transition: none }

/* ON A TAP, THE STRIP TAKES ITS OWN PICTURE, so it moves only up and down
   rather than sliding sideways with the page. */
:root[data-vt-run]:is([data-vt="next"], [data-vt="prev"]) .pick-bar { view-transition-name: pickbar }
:root[data-vt-run] .pick-inner { transition: none }
::view-transition-group(pickbar) { animation: none }
:is([data-vt="next"], [data-vt="prev"])::view-transition-old(pickbar) {
  animation: pick-out var(--duration) linear both;
}
:is([data-vt="next"], [data-vt="prev"])::view-transition-new(pickbar) {
  animation: pick-in var(--duration) linear both;
}
@keyframes pick-out {
  0%, 15% { transform: translateY(0); animation-timing-function: var(--ease-slide) }
  50%, 100% { transform: translateY(100%) }
}
@keyframes pick-in {
  0%, 50% { transform: translateY(100%); animation-timing-function: var(--ease-slide) }
  85%, 100% { transform: translateY(0) }
}

/* AND THE PAGER LEAVES EARLIER AND ARRIVES LATER when a strip is involved. */
:root[data-vt-pick]:is([data-vt="next"], [data-vt="prev"])::view-transition-old(pager) {
  animation: pager-duck-early var(--duration) linear both;
}
:root[data-vt-pick]:is([data-vt="next"], [data-vt="prev"])::view-transition-new(pager) {
  animation: pager-rise-late var(--duration) linear both;
}
@keyframes pager-duck-early {
  0% { transform: translateY(0); animation-timing-function: var(--ease-slide) }
  35%, 100% { transform: translateY(calc(100% + var(--space-2xl))); opacity: 0 }
}
@keyframes pager-rise-late {
  0%, 65% { transform: translateY(calc(100% + var(--space-2xl))); opacity: 0; animation-timing-function: var(--ease-slide) }
  100% { transform: translateY(0) }
}

/* ==========================================================================
   Messages are toasts at the foot of the screen

   Their request, 7 October 2026: messages leave their place under the add
   card and arrive as a dismissable toast near the bottom, with a pulsing
   accent glow around it, blended as Screen.

   IT STANDS ON WHATEVER IS AT THE FOOT. `--toast-bottom` is written by the
   script: the pager's top or the selection strip's, whichever is higher,
   plus one step. With neither on screen it is one step off the glass.
   ========================================================================== */
.status-line {
  position: fixed;
  inset-inline: 0;
  inset-block-end: var(--toast-bottom, var(--space-lg));
  z-index: calc(var(--layer-sticky) + 6);
  display: block;
  width: min(480px, calc(100% - var(--space-lg) * 2));
  margin: 0 auto;
  padding: var(--space-md) var(--space-lg);
  /* 2px, their call, 7 October 2026: the 1px edge read thin beside the glow. */
  border: 2px solid color-mix(in oklch, var(--accent) 55%, var(--line));
  border-radius: var(--radius-lg);
  background: var(--surface-raised);
  box-sizing: border-box;
  transition:
    opacity var(--duration) var(--ease),
    transform var(--duration) var(--ease-slide),
    inset-block-end var(--duration) var(--ease-slide),
    visibility var(--duration) allow-discrete;
}
.status-line:not([data-shown]) {
  opacity: 0;
  visibility: hidden;
  transform: translateY(var(--space-lg));
  margin: 0 auto;
}
/* THE OLD FOLD'S CLIPPER HAS NOTHING TO FOLD NOW. The box fades as one. */
.status-line .status-clip,
.status-line:not([data-shown]) .status-clip { opacity: 1; overflow: visible }
/* TWO STEPS LARGER THAN THE OLD LINE, because a toast has to be read at a glance from
   wherever the reader is looking. */
.status-line .status-row { font-size: var(--text-md); align-items: baseline }
/* THE WORDS CARRY THE SMALL HINT SIZE OF THEIR OWN, so they inherit here. */
.status-line .status-row .hint { font-size: inherit }

/* THE GLOW. Its own fixed box under the toast, sized to it by the script,
   so Screen blends it with the page itself rather than with the toast.

   SOFT, WIDE AND UNEVEN. Their note, 7 October 2026: subtler, spread wider,
   and not uniformly glowy along the whole edge. So the glow is a ring of
   accent light that thins and thickens round the box, blurred past the
   edge and turned once every 15 seconds, so the bright patches travel round it like a
   swell rather than a shimmer. The toast covers the middle, so only the
   part outside its edge shows. */
@property --glow-turn {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}
.toast-glow {
  position: fixed;
  z-index: calc(var(--layer-sticky) + 5);
  border-radius: var(--radius-lg);
  mix-blend-mode: screen;
  pointer-events: none;
  opacity: 0;
  transform: translateY(var(--space-lg));
  transition: opacity var(--duration) var(--ease), transform var(--duration) var(--ease-slide);
}
.toast-glow::before {
  content: "";
  position: absolute;
  inset: calc(var(--space-lg) * -1);
  border-radius: calc(var(--radius-lg) + var(--space-lg));
  background: conic-gradient(from var(--glow-turn),
    var(--accent) 0deg, transparent 70deg, var(--accent) 150deg,
    transparent 200deg, var(--accent) 280deg, transparent 330deg, var(--accent) 360deg);
  filter: blur(var(--space-xl));
  opacity: .45;
  animation: toast-wave 15s linear infinite;
}
.toast-glow[data-on] { opacity: 1; transform: none }
@keyframes toast-wave { to { --glow-turn: 360deg } }

/* THE TOAST IS NEVER PART OF THE PAGE'S PICTURE. A Drive sync redraws the
   list inside a page transition, and the browser holds a picture of the
   whole page for its length. A message arriving then faded under that
   picture and was shown only when it lifted, so it popped in. Their report,
   7 October 2026. Named, the toast and its glow stay live above it and
   their own fade is the one seen. */
.status-line { view-transition-name: toast }
.toast-glow { view-transition-name: toast-glow }
::view-transition-group(toast),
::view-transition-group(toast-glow) { animation: none }
::view-transition-old(toast),
::view-transition-old(toast-glow) { display: none }
::view-transition-new(toast),
::view-transition-new(toast-glow) { animation: none }
