/* ============================================================
   JIGGER, COMPONENTS

   ORGANISATION
   One contiguous block per component. A component's base rules, its
   variants, its keyframes and its breakpoint overrides all live together,
   so a rule and the thing that overrides it are always within a screen of
   each other.

   There are no global media query blocks. Adding a desktop tweak means
   editing the component, not appending to the bottom of the file. That is
   the change that stops this file growing by accretion: previously there
   were four separate min-width:900px blocks at the end, three of which set
   .sheet height with !important against each other.

   BREAKPOINT
   900px, once only, written inline in each component that needs it.

   Every value comes from tokens.css.
   ============================================================ */


/* ══ 1. FOUNDATION ═════════════════════════════════════════ */

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

/* Set from JS at runtime. Declared here so the first paint is correct
   before any scroll event has fired. */
:root {
  --header-h: 0px;       /* live height of the collapsing header */
  --strip-bottom: 14rem; /* viewport y of the sticky strip's bottom edge */
}

/* [hidden] is only display:none at UA-stylesheet strength, so ANY explicit
   display beats it. This bit three times, the badge, the list and the
   empty state all stayed visible while the attribute was set. */
[hidden] { display: none !important; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--surface-page);
  color: var(--text-primary);
  font-family: var(--font-family);
  font-size: var(--text-size-16);
  line-height: var(--text-line-24);
  font-variation-settings: var(--axis-blunt);
  padding-bottom: 6rem;               /* clears the fixed bottom nav */
}
@media (min-width: 900px) {
  body { padding-bottom: 0; }         /* no bottom nav to clear */
}

button, input { font: inherit; color: inherit; }
button { background: none; border: 0; cursor: pointer; }

:focus-visible {
  outline: var(--stroke-focus) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}

.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}

/* Both skip links sit at the same coordinates. Only one can hold focus, and
   only the focused one is on screen, so they can never collide. */
.skip-link {
  position: absolute; left: var(--space-16); top: -4rem;
  z-index: 100;
  background: var(--surface-card); color: var(--action-primary);
  padding: var(--space-12) var(--space-16);
  border-radius: var(--radius-lg);
  text-decoration: none;
  font: var(--font-weight-640) var(--text-size-14)/var(--text-line-20) var(--font-family);
  transition: top var(--motion-fast) var(--ease);
}
/* focus-visible, not focus: a click near the top left should not summon it.
   It exists for someone walking the page with a keyboard. */
.skip-link:focus-visible { top: var(--space-16); }

/* Strokes inherit, so an icon takes its colour from wherever it sits. */
.icon {
  width: 24px; height: 24px; flex: none;
  fill: none; stroke: currentColor; stroke-width: var(--stroke-thick);
  stroke-linecap: round; stroke-linejoin: round;
}


/* ══ 2. HEADER ═════════════════════════════════════════════
   CONDENSES CONTINUOUSLY, driven by --header-progress (0 at the top, 1 once
   scrolled past the range). No threshold, so no half-open state and nothing
   to snap back to, every scroll position is one you can rest at.

   It condenses rather than hides: a shy header means the brand is sometimes
   absent, and "static header nav" asks for the opposite. */

.header {
  --header-progress: 0;
  --pad-top: max(var(--safe-top), env(safe-area-inset-top));
  position: sticky; top: 0; z-index: 25;
  display: flex; align-items: center; gap: var(--space-16);
  background: var(--surface-header);
  padding-inline: var(--space-20);
  /* interpolate between the open and condensed paddings */
  padding-top: calc(var(--pad-top) - (var(--pad-top) - var(--space-10)) * var(--header-progress));
  padding-bottom: calc(var(--space-20) - (var(--space-20) - var(--space-10)) * var(--header-progress));
}

/* The shadow is the one part that stays binary, fading it continuously
   makes the edge look dirty rather than lifted. */
.header.is-condensed { box-shadow: var(--elevation-card); }

/* Only the return to the top is animated. Collapsing is scroll-linked, so a
   transition there would lag the finger; expanding is a single jump from
   wherever it had condensed to, so it needs one. */
.header.is-easing { transition: padding var(--motion-base) var(--ease); }
.header.is-easing .wordmark { transition: transform var(--motion-base) var(--ease); }

/* The ring inverts on the brand surface. Indigo on forest-900 is 1.16:1, so
   tabbing to the desktop nav would land on a ring nobody can see. Porcelain
   there is 11.73:1. */
.header :focus-visible { outline-color: var(--focus-ring-on-brand); }

.wordmark {
  margin: 0;
  color: var(--text-on-brand);
  font: var(--font-weight-655) var(--display-h1-size)/var(--display-h1-line) var(--font-family);
  letter-spacing: var(--tracking-tight);
  font-variation-settings: var(--axis-sharp);   /* the only sharp thing in the interface */
  transform-origin: left center;
  /* Scale rather than restyle: transitioning font-size relayouts every frame
     and looks rubbery. A transform is composited and does not reflow. */
  transform: scale(calc(1 - 0.28 * var(--header-progress)));
}

/* The bottom bar is a phone pattern. At this width it wastes a whole row and
   sits nowhere near the pointer, so the destinations move up here. */
.header-nav { display: none; }

/* Top right, every width. Pushed there by margin rather than by order, so the
   markup still reads wordmark, destinations, help. */
.btn-help {
  margin-left: auto; flex: none;
  display: grid; place-items: center;
  width: var(--touch-min); height: var(--touch-min);
  border-radius: var(--radius-lg);
  color: var(--text-on-brand);
  opacity: 0.72;
  transition: opacity var(--motion-fast) var(--ease);
}
.btn-help:hover { opacity: 1; }

/* Two ways to earn the header nav. Width is the usual one. The other is a
   short viewport: at high zoom the bottom bar was spending 90 of about 300
   usable pixels on two destinations the header can hold for free. Below 30rem
   wide there is no room for both, so an actual phone keeps its bottom bar. */
@media (min-width: 900px), (max-height: 36rem) and (min-width: 30rem) {
  .header { gap: var(--space-32); }
  .header-nav {
    display: flex; align-items: center; gap: var(--space-24);
    margin-left: auto;
  }
  /* the nav has taken the auto margin, so help just follows it */
  .btn-help { margin-left: 0; }
  .nav-link {
    color: var(--text-on-brand); text-decoration: none;
    padding: var(--space-6) 0;
    border-bottom: var(--stroke-focus) solid transparent;
    font: var(--font-weight-524) var(--text-size-16)/var(--text-line-24) var(--font-family);
    opacity: 0.72;
  }
  .nav-link.is-selected { opacity: 1; border-bottom-color: var(--text-on-brand); }
  .nav-link:hover { opacity: 1; }

  .btn-header {
    min-height: 40px; padding: var(--space-6) var(--space-16);
    background: var(--text-on-brand); color: var(--action-primary);
    border-radius: var(--radius-lg);
    font: var(--font-weight-640) var(--text-size-14)/var(--text-line-20) var(--font-family);
  }
  .btn-header:hover { background: var(--surface-selected); }
}

/* Flat padding, no interpolation. The open value was set to 8px while the
   condensed target is 10px, so calc(8 - (8 - 10) * progress) resolved to
   8 + 2 * progress and the header GREW by 2px as you scrolled, while the
   wordmark scaled down 28% underneath it. Below this height there is nothing
   left to condense, so the whole mechanism is switched off. */
@media (max-height: 36rem) {
  .header {
    padding-top: max(var(--space-8), env(safe-area-inset-top));
    padding-bottom: var(--space-8);
  }
  .wordmark {
    font-size: var(--display-h3-size);
    line-height: var(--display-h3-line);
    transform: none;
  }
}

/* Below 20rem of height there is no arrangement of a sticky header, a sticky
   control strip and a bottom bar that leaves room for a single row of shelf.
   On a 1536px laptop at 400% zoom the viewport is about 384 by 171, and those
   three were taking 162 of it. Nine pixels of content is not content.

   The header is the honest thing to unstick. It carries the brand and the
   shortcut list, not the navigation, the destinations are in the bar at the
   bottom at this width, and they stay put. So it scrolls away like any other
   content and comes back when you return to the top, which is what a masthead
   does on a page too small to hold one. */
@media (max-height: 20rem) {
  .header { position: static; }
  /* The rail has the destinations at this size. Two navigations showing the
     same two links is worse than either one alone. */
  .header-nav { display: none; }
  .btn-help { margin-left: auto; }
}
/* .control-strip's matching top:0 lives in CONTROL STRIP below, where its own
   sticky offset is declared. */

@media (max-height: 36rem) and (min-width: 30rem) {
  /* the wordmark is the one thing here that can afford to give up size */
  .wordmark {
    font-size: var(--display-h5-size);
    line-height: var(--display-h5-line);
  }
  .header-nav { gap: var(--space-16); }
  .btn-header { min-height: var(--touch-min); }
}
/* The bottom bar and the back-to-top button also change at this size, but
   those rules live in their own blocks below. Putting them here looked tidier
   and lost: .bottom-nav's own display:flex is declared 700 lines later, so a
   display:none written up here never had a chance. */


/* ══ 3. CONTROL STRIP ══════════════════════════════════════
   Sticky under the header. On a phone the markup order is tabs then search,
   because that is the desktop reading order; flex order puts search back on
   top where a narrow screen wants it, without a second copy in the DOM. */

.control-strip {
  position: sticky; top: var(--header-h, 0px); z-index: 20;
  /* the collapsed search field expands over this, so it needs a containing
     block to expand within */
  background: var(--surface-page);
  padding-bottom: var(--space-6);
  display: flex; flex-direction: column;
}
.control-strip > .search-field {
  order: -1;
  margin: var(--space-12) var(--gutter) var(--space-8);
}
.control-strip > .strip        { order: 0; }
.control-strip > .result-count { order: 1; }

/* min-width:0 is load-bearing. As a grid item this defaults to min-width:auto,
   so the column refuses to shrink below the tabs' full intrinsic width and the
   whole page grows a horizontal scrollbar, a reflow failure, not just an
   untidy one. The tabs scroll inside themselves; they must be allowed to. */
.strip {
  display: flex; align-items: center; gap: var(--space-4);
  min-width: 0;
  padding: 0 var(--space-12) 0 var(--gutter);
}

/* Two conditions, one layout. Width is the obvious trigger, but height is the
   one that actually bites: at 200% zoom a laptop reports about 470px of height,
   and two stacked full-width bars were eating a whole row of it. Whenever there
   is horizontal room to spare and vertical room to save, they share a line. */
@media (min-width: 900px), (min-width: 30rem) and (max-height: 36rem) {
  /* Search and the controls share one line. Two full-width bars stacked is a
     phone compromise; here they fit side by side and give the list back a whole
     row of vertical space. Tabs first, search last: the tabs are the primary way
     through the shelf, search is the fallback. */
  .control-strip {
    max-width: var(--measure-list); margin-inline: auto;
    display: grid;
    /* Tabs first. minmax(14rem, …) let the search field claim 14rem before the
       tabs got anything, which crushed six categories into one and a half
       letters. The tabs are the primary way through the shelf; search is the
       fallback, so it takes a share of what is left rather than a floor. */
    grid-template-columns: 1fr min(30%, 20rem);
    align-items: center;
    column-gap: var(--space-16);
    row-gap: var(--space-6);
    padding: var(--space-8) var(--gutter) var(--space-6);
  }
  .control-strip > .search-field { grid-column: 2; grid-row: 1; margin: 0; }
  .control-strip > .strip        { grid-column: 1; grid-row: 1; padding-inline: 0; }
  .control-strip > .result-count { grid-column: 1 / -1; grid-row: 2; padding-inline: 0; }
}

/* The header has stopped being sticky at this height, so there is no header
   left to sit beneath. */
@media (max-height: 20rem) {
  .control-strip { top: 0; }
  /* Tabs, filter and view toggle scroll together as one row instead of fighting
     over a column too narrow for all three. One axis of scrolling inside a
     component is what 1.4.10 allows; controls sitting on top of each other is
     not. */
  .control-strip > .strip { overflow-x: auto; scrollbar-width: none; }
  .control-strip > .strip::-webkit-scrollbar { display: none; }
}

@media (max-height: 36rem) {
  .control-strip { padding-bottom: 0; }
  .control-strip > .search-field { margin-block: var(--space-8) var(--space-6); }
  /* the count still announces, it just stops paying for a full line of air */
  .control-strip > .result-count { padding-top: var(--space-4); }
}

/* NARROW AND SHORT: three things on one line instead of three lines.

   Keyed on BOTH axes. On a 440 by 956 phone there is a whole screen of vertical
   room and no reason to hide the search field behind a button, collapsing it
   there solved a problem that only exists when height is scarce.

   Below 30rem the strip was spending a row on a search field holding nothing
   but a placeholder, and another on four words. The field collapses to a 44pt
   button and the count moves up beside it, which gives the shelf two rows back.

   No JavaScript: the magnifier is a <label for="search">, so pressing it
   focuses the input, and :focus-within expands the field over the tabs. It
   stays open while it holds a query, :has(:not(:placeholder-shown)), so
   blurring to read your results does not throw the search away. */
@media (max-width: 30rem) and (max-height: 36rem) {
  .control-strip {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto auto;
    align-items: center;
    column-gap: var(--space-8);
    padding-inline: var(--gutter);
  }
  /* grid-row on all three, and order reset. The stacked layout gives the search
     field order:-1 to lift it above the tabs; left in place here it gets PLACED
     first, so the grid cursor passes column 1 and the strip is pushed onto a
     second row. Explicit rows make the placement independent of order. */
  .control-strip > .strip        { grid-column: 1; grid-row: 1; order: 0; padding-inline: 0; min-width: 0; }
  .control-strip > .search-field { grid-column: 2; grid-row: 1; order: 0; margin: 0; }
  .control-strip > .result-count { grid-column: 3; grid-row: 1; order: 0; padding: 0; white-space: nowrap; }
  /* room for the field's row to appear under the controls when it opens */
  .control-strip { grid-template-rows: auto auto; row-gap: var(--space-8); }
}
/* The field's own collapse rules live in SEARCH FIELD below, where its base
   padding is declared. Written here they lost to it. */


/* SHORT VIEWPORTS
   At 200% zoom a laptop reports roughly 470px of height, and the header plus
   the sticky strip were taking more than half of it before a single row of
   shelf appeared. 1.4.10 assesses reflow at 320 by 256, so height is the axis
   that runs out first and the one these rules are keyed on.

   Nothing is hidden. The type steps down and the padding tightens, so the
   result count keeps announcing and every control stays reachable.

   The rules themselves live in the component blocks below, NOT here. Written
   as one block at this point in the file, seven of eleven declarations were
   dead on arrival: .search-field, .tab, .items, .bottom-nav, .nav-item,
   .nav-pill and .add-btn all declare their base styles further down, and a
   later rule of equal specificity wins. */


/* ══ 4. SEARCH FIELD ═══════════════════════════════════════ */

.search-field {
  display: flex; align-items: center; gap: var(--space-8);
  background: var(--surface-card);
  border: var(--stroke-thick) solid var(--border-interactive);  /* 1.4.11 needs 3:1 */
  border-radius: var(--radius-lg);
  padding: 0 var(--space-12) 0 var(--space-14);
  min-height: var(--touch-min);
  /* the field never spills past its own box */
  overflow: hidden;
}

/* The magnifier is a <label for="search">, so pressing it focuses the input.
   It needs a base rule and not just one inside the collapse breakpoint,
   otherwise it is an inline element sitting on the text baseline rather than a
   flex item, and it rides out over the right border. */
.search-icon {
  display: grid; place-items: center;
  flex: none;
  cursor: pointer;
  color: var(--action-primary);
}
.search-field:focus-within {
  outline: var(--stroke-focus) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
.search-field .icon { flex: none; color: var(--action-primary); }
.search-field input {
  /* min-width:0 is the load-bearing part. A flex item defaults to
     min-width:auto, so the input refuses to shrink below the width of its own
     placeholder. In a grid column narrower than that it overflows and pushes
     the magnifier out through the padding. Same trap as .strip. */
  flex: 1; min-width: 0;
  border: 0; outline: 0; background: none;
  padding: var(--space-10) 0;
  font-size: var(--text-size-16);   /* never below 16 or iOS zooms on focus */
  text-overflow: ellipsis;
}
.search-field input::placeholder { color: var(--text-muted); }
.search-field input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }

.search-clear {
  display: grid; place-items: center;
  flex: none;
  width: 32px; height: 32px;
  border-radius: var(--radius-md);
  color: var(--text-muted);
}
.search-clear:hover { color: var(--text-primary); }


@media (max-height: 36rem) {
  .search-field { min-height: var(--touch-min); }
  .search-field input { padding-block: var(--space-6); }
}

/* Collapsed to a 44pt button below 30rem, expanding over the tabs when it has
   focus or a query. No JavaScript: the magnifier is a <label for="search">, so
   pressing it focuses the input and :focus-within does the rest. It stays open
   while it holds something, :has(input:not(:placeholder-shown)), so blurring
   to read your results does not throw the search away. */
@media (max-width: 30rem) and (max-height: 36rem) {
  /* Collapsed it IS a button, and its peer is the filter button beside it,
     which has no box at all. Keeping the field's border made it read as an
     empty input someone had forgotten to fill in.

     The border is made transparent rather than removed so the box does not
     change size between the two states, the only thing that animates is width. */
  .search-field {
    width: var(--touch-min); height: var(--touch-min); flex: none;
    padding: 0;
    /* gap:0, and the input takes no space. The field is a flex row and its base
       8px gap still applies between the zero-width input and the icon, so the
       content came to 52px inside a 44px box and pushed the magnifier out the
       right-hand side. */
    gap: 0;
    justify-content: center;
    border-color: transparent;
    border-radius: var(--radius-lg);
    background: none;
    transition: width var(--motion-base) var(--ease);
  }
  .search-field:hover { background: var(--surface-selected); }
  .search-field input { flex: 0 0 0; }
  .search-field input { width: 0; min-width: 0; padding: 0; }
  .search-icon {
    display: grid; place-items: center;
    width: var(--touch-min); height: var(--touch-min);
    flex: none; cursor: pointer;
  }
  /* Open, it takes a row of its own and is a field again.

     It was an absolutely positioned overlay across the strip, which looked
     tidier and did not survive contact: an abspos grid item is laid out against
     the grid container rather than the track it came from, and the field stayed
     44px wide with its icon hanging out the side. A full-width grid row needs no
     positioning at all and cannot fail the same way. It costs a row of height,
     but only while you are actually typing. */
  .search-field:focus-within,
  .search-field:has(input:not(:placeholder-shown)) {
    grid-column: 1 / -1;
    grid-row: 2;
    width: auto; height: auto; min-width: 0;
    gap: var(--space-8);
    justify-content: flex-start;
    padding: 0 var(--space-8) 0 var(--space-14);
    border-color: var(--border-interactive);
    background: var(--surface-card);
  }
  .search-field:focus-within input,
  .search-field:has(input:not(:placeholder-shown)) input {
    width: auto; flex: 1 1 auto; padding: var(--space-10) 0;
  }
}


/* ══ 5. TABS ═══════════════════════════════════════════════ */

.tabs {
  position: relative;
  display: flex; gap: var(--space-8);
  flex: 1; min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  scroll-snap-type: x proximity;
  /* overflow-x:auto forces overflow-y to clip, so a focus ring on a tab is
     cut off top and bottom. The padding is the ring's room; the negative
     margin gives the space back to the layout. */
  padding: var(--space-6) var(--space-8);
  margin: calc(var(--space-6) * -1) calc(var(--space-8) * -1);
  scroll-padding-inline: var(--space-16);
}
.tabs::-webkit-scrollbar { display: none; }

.tab {
  flex: none; scroll-snap-align: start;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-6);
  padding: var(--space-12) var(--space-14) 0;
  min-height: var(--touch-min);
  color: var(--text-muted);
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-20) var(--font-family);
  border-radius: var(--radius-md);
  white-space: nowrap;
  transition: color var(--motion-fast) var(--ease),
              background var(--motion-fast) var(--ease);
}
.tab::after {
  content: ""; display: block; align-self: stretch;
  height: 1px; background: var(--border-hairline);
  transition: height var(--motion-fast) var(--ease),
              background var(--motion-fast) var(--ease);
}
.tab:hover { background: var(--surface-selected); }
.tab[aria-checked="true"] {
  color: var(--text-primary);
  font-weight: var(--font-weight-524);          /* weight AND rule, not colour alone */
}
.tab[aria-checked="true"]::after {
  height: var(--stroke-focus); background: var(--action-primary);
}

/* Only when the strip actually has more to the right, a fade with nothing
   behind it is a promise the interface does not keep. */
.scroll-fade {
  position: sticky; right: calc(var(--space-8) * -1); flex: none;
  width: var(--space-32); margin-left: calc(var(--space-32) * -1);
  align-self: stretch; pointer-events: none;
  background: linear-gradient(to right, transparent, var(--surface-page));
  opacity: 0;
  transition: opacity var(--motion-fast) var(--ease);
}
.tabs.can-scroll-right .scroll-fade { opacity: 1; }

/* Shown only where the strip cannot be. See the block at the foot of this
   section. */
.cat-select { display: none; }

@media (max-height: 36rem) {
  .tab { padding-top: var(--space-8); }
}

/* Keyed on WIDTH, because width is what makes a tab strip unusable. Six
   categories in 130px is one and a half words with no room to scroll to the
   rest, and that is true whether the viewport is short or not. The height
   condition only keeps it off a tall narrow phone, where the strip has room to
   scroll and is the better control.

   One step narrower than the search collapse, so the two form a ladder: at
   480px the search becomes a button, at 416px the tabs become a picker.

   The strip is display:none rather than hidden, so only one of the two is in
   the accessibility tree at a time and the category is never announced twice. */
@media (max-width: 26rem) and (max-height: 36rem) {
  .tabs { display: none; }
  .cat-select {
    display: block;
    flex: 1; min-width: 0;
    min-height: var(--touch-min);
    padding: var(--space-6) var(--space-12);
    border: var(--stroke-thick) solid var(--border-interactive);
    border-radius: var(--radius-lg);
    background: var(--surface-card); color: var(--text-primary);
    font: var(--font-weight-524) var(--text-size-14)/var(--text-line-20) var(--font-family);
  }
}


/* ══ 6. PINNED CONTROLS (filter button, view toggle) ═══════ */

.pinned {
  display: flex; align-items: center; flex: none;
  /* 12px, not 4: the focus ring extends 5px beyond each control, so a
     smaller gap makes two rings touch when tabbing between them */
  gap: var(--space-12);
  padding-left: var(--space-8);
}

@media (max-height: 20rem) {
  /* it scrolls along with the tabs now rather than holding its own ground */
  .pinned { flex: none; }
}

.icon-btn {
  position: relative;
  display: grid; place-items: center;
  width: var(--touch-min); height: var(--touch-min);
  border-radius: var(--radius-lg);
  color: var(--text-primary);
}
.icon-btn:hover { background: var(--surface-selected); }

.badge {
  position: absolute; top: 0; right: 0;
  min-width: 20px; height: 20px;
  display: grid; place-items: center;
  padding: 0 var(--space-6);
  border-radius: var(--radius-pill);
  background: var(--marker-count); color: var(--text-on-brand);
  font: var(--font-weight-524) var(--text-size-12)/1 var(--font-family);
}

.view-toggle {
  display: flex; gap: 0; padding: var(--space-2);
  background: var(--surface-page);
  border: var(--stroke-hairline) solid var(--border-hairline);
  border-radius: var(--radius-lg);
}
.view-toggle .seg {
  display: grid; place-items: center;
  width: var(--touch-min); height: var(--touch-min);
  border-radius: var(--radius-md);
  color: var(--text-muted);
  transition: background var(--motion-fast) var(--ease),
              color var(--motion-fast) var(--ease);
}
.view-toggle .seg.is-active {
  background: var(--surface-card);
  border: var(--stroke-hairline) solid var(--border-interactive);  /* fill AND border */
  color: var(--text-primary);
}

/* Stands down at 320px. A two-column card grid there is 150px per card, which
   is not a browsing view, it is a worse list, so the toggle is offering a
   choice with only one sensible answer while charging 88px for the privilege.
   V still switches it for anyone who wants the other one. */
@media (max-height: 20rem) {
  .view-toggle { display: none; }
}

/* The tab underline is a ::after on the selected tab, so it ends flush with the
   bottom of the strip and the count was landing on it. */
.result-count {
  margin: 0; padding: var(--space-8) var(--gutter) var(--space-2);
  color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.result-count.is-filtered { color: var(--text-secondary); }


/* ══ 7. LIST CONTAINER ═════════════════════════════════════ */

.shelf { padding: 0 var(--gutter); overflow: visible; }
.shelf:focus { outline: none; }

.items {
  list-style: none; margin: 0;
  /* leaves room for the focus ring on the first row */
  padding: var(--space-8) 0 var(--space-16);
}
.items.is-list { display: flex; flex-direction: column; gap: var(--space-8); }
.items.is-cards {
  display: grid; gap: var(--space-20);
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
}

@media (min-width: 900px) {
  .shelf { max-width: var(--measure-list); margin-inline: auto; }
  .items.is-cards { grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); }
}

@media (max-height: 36rem) {
  .items { padding-top: var(--space-6); }
}


/* ══ 8. ITEM ═══════════════════════════════════════════════
   One component, three presentations: row, card, and the recipe book's
   borrowed version of both. The card is the full expression; the row hides
   what will not fit.

   The recipe book borrows the row and card wholesale. Only two things
   differ: there are no per-item actions to swipe to, and the thumbnail is a
   glass rather than a bottle. */

.item {
  position: relative;
  background: var(--surface-card);
  border: var(--stroke-hairline) solid var(--border-hairline);
  border-radius: var(--radius-lg);
  box-shadow: var(--elevation-card);
  /* the ring extends 5px beyond the card, so scrolling to a focused item
     must leave room for it AND clear the sticky strip above */
  scroll-margin-top: 9rem;
  scroll-margin-bottom: 6rem;
  transition: box-shadow var(--motion-fast) var(--ease),
              transform var(--motion-fast) var(--ease),
              background var(--motion-fast) var(--ease);
}
.item:hover { background: var(--surface-page); box-shadow: var(--elevation-raised); }
.item:focus-visible {
  transform: translateY(-2px) scale(1.005);
  box-shadow: var(--elevation-overlay);
}
.item:active { transform: translateY(0) scale(0.995); transition-duration: 80ms; }
.item.is-out .item-name,
.item.is-out .item-thumb { opacity: var(--opacity-faded); }   /* 0.6 failed AA */

/* ── item: shared text ─────────────────────────────────── */
.item-name {
  margin: 0;
  font: var(--font-weight-620) var(--text-size-16)/var(--text-line-24) var(--font-family);
}
.item-category {
  margin: 0;
  color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.item-sub {
  color: var(--text-secondary);
  font: var(--font-weight-460) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.item-qty {
  color: var(--text-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  font-variant-numeric: tabular-nums;
}
.item-measure { color: var(--text-muted); font-weight: var(--font-weight-420); }

/* ── item: row presentation ────────────────────────────── */
/* An explicit grid. The old one used display:contents on the head, which
   promoted the favourite AND the count into the same cell, they stacked,
   which is why the heart appeared inside the count. */
.is-list .item { overflow: hidden; }   /* clips the action panes, not the focus ring */
.is-list .item-inner,
.is-list .item-inner * { user-select: none; }
.is-list .item-inner {
  display: grid;
  grid-template-columns: 1fr auto;
  grid-template-areas:
    "name  fav"
    "meta  count";
  align-items: center;
  column-gap: var(--space-8);
  row-gap: var(--space-4);
  padding: var(--space-12) var(--gutter);
  background: inherit;
  border-radius: inherit;
  position: relative; z-index: 1;
  /* the browser keeps vertical scrolling; horizontal is ours */
  touch-action: pan-y;
}
.is-list .item-head { display: contents; }
.is-list .item-thumb,
.is-list .item-category,
.is-list .item-measure { display: none; }   /* no room in a row; a card has it */
.is-list .item-name {
  grid-area: name; min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.is-list .fav    { grid-area: fav; justify-self: end; }
.is-list .unlock { grid-area: count; justify-self: end; margin-left: 0; }
.is-list .item-meta {
  grid-area: meta;
  display: flex; align-items: center; gap: var(--space-8);
  min-width: 0;
}

/* ── item: card presentation ───────────────────────────── */
.is-cards .item {
  display: flex; flex-direction: column; gap: var(--space-6);
  padding: var(--space-12);
}
.is-cards .item-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-8);
}
.is-cards .item-thumb {
  display: grid; place-items: center;
  width: 45px; height: 45px; flex: none;
  border-radius: var(--radius-lg);
  background: var(--surface-page); color: var(--text-secondary);
}
/* Two lines of name, always. Clamping alone leaves a one-line card shorter
   than its neighbours; a fixed two-line box keeps the grid even without
   adding empty space below a name that does wrap. */
.is-cards .item-name {
  display: -webkit-box; -webkit-box-orient: vertical;
  /* Both spellings. The prefixed one is what every browser actually implements,
     including Firefox and Safari; the unprefixed one is the standard and is
     barely supported yet. Declaring only the standard would clamp nothing. */
  -webkit-line-clamp: 2; line-clamp: 2;
  overflow: hidden;
  min-height: calc(var(--text-line-24) * 2);
}
.is-cards .item-category { min-height: var(--text-line-16); }
/* The status block sits at the bottom of every card regardless of how much
   name there is, so a row of cards reads along one line. */
.is-cards .item-meta {
  margin-top: auto;
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: var(--space-6);
}
/* Rows are explicit. Left to auto-placement the pill claimed row 1 and
   pushed the count below it, because a full-width item cannot share a row
   that is already occupied. */
.is-cards .item-qty { grid-row: 1; grid-column: 1 / -1; }   /* the count reads first */
.is-cards .pill     { grid-row: 2; grid-column: 1; justify-self: start; }
.is-cards .unlock   { grid-row: 2; grid-column: 2; margin-left: 0; }
/* the subcategory is already in the category line above, showing it beside
   the pill said the same word twice */
.is-cards .item-sub { display: none; }

/* ── item: keystone card ───────────────────────────────
   ONE card in the grid is twice as wide: the out-of-stock bottle that is the
   sole thing blocking the most recipes. Restocking it is the highest-value
   thing you could do, and in a uniform grid it looked exactly like a jar of
   olives.

   The span is earned by the data, not decoration. If nothing is out of stock,
   or nothing would be unlocked by a single purchase, no card spans and the grid
   is plain, which is the honest answer, because on a full shelf there is no
   most valuable bottle.

   Only in card view. A row has nowhere to put the reason, and a row spanning
   two columns of a one-column list is just a row. */
.is-cards .item.is-keystone {
  grid-column: span 2;
  border-color: var(--border-interactive);
}
/* At one column span 2 resolves to span 1, so narrow screens get the note
   without a broken grid. */
.is-cards .item.is-keystone .item-name { -webkit-line-clamp: 1; line-clamp: 1; min-height: 0; }

.keystone-note {
  display: flex; align-items: center; gap: var(--space-6);
  margin: var(--space-8) 0 0;
  padding-top: var(--space-8);
  border-top: var(--stroke-hairline) solid var(--border-hairline);
  color: var(--action-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.keystone-note .icon { width: 14px; height: 14px; flex: none; }
.is-list .keystone-note { display: none; }

/* ── item: recipe book ─────────────────────────────────── */
.items.is-recipes .swipe-pane,
.items.is-recipes .row-actions { display: none; }
.items.is-recipes .item-thumb { color: var(--action-primary); }

/* ── item: entrance ────────────────────────────────────── */
/* Applied by the render, not held on .item. Held on the element, ANY class
   change that re-applies the animation property restarts it, closing the
   sheet made the whole list flash.

   A short stagger makes sorting and filtering legible: the list visibly
   re-forms rather than blinking into a different order. Capped so a
   74-item list never feels slow. */
@keyframes item-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
.items.is-entering .item {
  animation: item-in var(--motion-base) var(--ease) backwards;
}
.items.is-entering .item:nth-child(1)   { animation-delay: 0ms; }
.items.is-entering .item:nth-child(2)   { animation-delay: 14ms; }
.items.is-entering .item:nth-child(3)   { animation-delay: 28ms; }
.items.is-entering .item:nth-child(4)   { animation-delay: 42ms; }
.items.is-entering .item:nth-child(5)   { animation-delay: 56ms; }
.items.is-entering .item:nth-child(6)   { animation-delay: 70ms; }
.items.is-entering .item:nth-child(7)   { animation-delay: 84ms; }
.items.is-entering .item:nth-child(8)   { animation-delay: 98ms; }
.items.is-entering .item:nth-child(n+9) { animation-delay: 112ms; }

/* ── item: desktop row ─────────────────────────────────── */
/* Name, favourite and count share the top band; the actions sit beneath the
   count, reading as a footer to the row's own numbers. Putting the actions
   on their own row left the name floating against the top edge. */
@media (min-width: 900px) {
  .is-list .item-inner {
    grid-template-columns: 1fr auto auto;
    grid-template-areas:
      "name fav   count"
      "meta actions actions";
    row-gap: var(--space-8);
    transform: none !important;   /* no swipe offset survives a resize */
  }
  .is-list .unlock      { grid-area: count; justify-self: end; align-self: center; }
  .is-list .row-actions { grid-area: actions; justify-self: end; align-self: center; }
  .is-cards .row-actions { display: none; }
}


/* ══ 9. SWIPE ACTIONS ══════════════════════════════════════
   Revealed behind a list row. Left gives Use then Out, with the destructive
   action furthest from the thumb so momentum cannot reach it first. Right
   gives Restock. Every one of them is also in the detail sheet: a gesture
   cannot be discovered, labelled, or used from a keyboard. */

.swipe-pane {
  position: absolute; top: 0; bottom: 0;
  display: flex; align-items: stretch;
  z-index: 0;
  /* Hidden until a gesture starts. The row's rounded corners let a sliver of
     colour show at each edge otherwise, which looked like a rendering fault
     rather than an affordance.

     Invisible is not the same as absent: opacity 0 still takes clicks, so a
     tap on a card was landing on the Out button behind it. */
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--motion-fast) var(--ease);
}
.swipe-start { left: 0; }
.swipe-end   { right: 0; }

.item.is-swiping .swipe-pane,
.item.is-swiped-start .swipe-start,
.item.is-swiped-end .swipe-end {
  opacity: 1;
  pointer-events: auto;
}
.items.is-hinting .item:first-child .swipe-end { opacity: 1; }

.swipe-btn {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-4);
  width: 84px;
  color: var(--text-on-brand);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  cursor: pointer;
}
.swipe-btn .icon { width: 20px; height: 20px; }
.swipe-btn.is-restock { background: var(--state-good-ink); width: 96px; }
.swipe-btn.is-use     { background: var(--action-primary); }
.swipe-btn.is-out     { background: var(--state-out-ink); }

/* no transition while a finger is down, the row should track the pointer */
.is-list .item.is-swiping .item-inner { transition: none; }

/* First-run hint: one row eases open and settles back, which teaches the
   gesture without a tooltip. Runs once, and never under reduced motion. */
@keyframes swipe-hint {
  0%, 60% { transform: none; }
  75%     { transform: translateX(-44px); }
  100%    { transform: none; }
}
.items.is-hinting .item:first-child .item-inner {
  animation: swipe-hint 1.6s var(--ease) 1;
}
@media (prefers-reduced-motion: reduce) {
  .items.is-hinting .item:first-child .item-inner { animation: none; }
}
/* and on a short viewport, where there is no room to watch it happen */
@media (max-height: 36rem) {
  .items.is-hinting .item:first-child .item-inner { animation: none; }
}

/* Cards do not slide, and neither does anything at desktop width where the
   actions are inline. */
.is-cards .swipe-pane { display: none; }
@media (min-width: 900px) {
  .swipe-pane { display: none; }
}


/* ══ 10. ROW ACTIONS ═══════════════════════════════════════
   Hidden on mobile, where a swipe buys the width back; shown on desktop,
   where there is width to spare and hiding actions behind a gesture helps
   nobody and is impossible from a keyboard. */

.row-actions { display: none; }
@media (min-width: 900px) {
  .row-actions { display: flex; gap: var(--space-6); }
}

.row-action {
  display: inline-flex; align-items: center; gap: var(--space-6);
  min-height: 36px; padding: var(--space-6) var(--space-12);
  border: var(--stroke-hairline) solid var(--border-hairline);
  border-radius: var(--radius-md);
  color: var(--action-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  transition: background var(--motion-fast) var(--ease),
              border-color var(--motion-fast) var(--ease);
}
.row-action .icon { width: 14px; height: 14px; }
.row-action:hover { background: var(--surface-selected); border-color: var(--border-interactive); }
.row-action[disabled] { opacity: var(--opacity-disabled); cursor: not-allowed; }
.row-action.is-danger { color: var(--state-out-ink); }
.row-action.is-danger:hover { background: var(--state-out-fill); border-color: var(--state-out-ink); }
/* pressed state, used by the garnish toggle in the pour view */
.row-action.is-on {
  background: var(--action-primary); color: var(--text-on-brand);
  border-color: var(--action-primary);
}


/* ══ 11. STATUS PILL ═══════════════════════════════════════
   The marker is a SHAPE change per state, so status never rests on colour. */

.pill {
  display: inline-flex; align-items: center; gap: var(--space-6);
  padding: var(--space-4) var(--space-10);
  border-radius: var(--radius-pill);
  border: var(--stroke-hairline) solid currentColor;
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  white-space: nowrap;
}
.pill::before { content: ""; display: block; background: currentColor; }

.pill.is-good   { background: var(--state-good-fill); color: var(--state-good-ink); }
.pill.is-good::before   { width: 8px; height: 8px; border-radius: var(--radius-pill); }

.pill.is-low    { background: var(--state-low-fill);  color: var(--state-low-ink); }
.pill.is-low::before    { width: 8px; height: 5px; border-radius: var(--radius-xs); }

.pill.is-out    { background: var(--state-out-fill);  color: var(--state-out-ink); }
.pill.is-out::before    { width: 8px; height: 2px; border-radius: var(--radius-xs); }

.pill.is-sealed { background: var(--surface-page);    color: var(--text-secondary); }
.pill.is-sealed::before {
  width: 8px; height: 8px; border-radius: var(--radius-pill);
  background: none; border: var(--stroke-thick) solid currentColor;
}


/* ══ 12. FAVOURITE ═════════════════════════════════════════ */

.fav {
  display: grid; place-items: center;
  width: var(--touch-min); height: var(--touch-min);
  border-radius: var(--radius-lg);
  color: var(--text-muted);
}
.fav .icon { width: 20px; height: 20px; }
.fav[aria-pressed="true"] { color: var(--marker-favourite); }
.fav[aria-pressed="true"] .icon { fill: currentColor; }   /* fill AND colour */

/* In a row the heart reports a fact and cannot be pressed, so it does not
   need a touch target, and at 44pt it was setting the row height. */
.is-list .fav { width: auto; height: auto; }
.is-list .fav .icon { width: 16px; height: 16px; }


/* ══ 13. UNLOCK COUNT ══════════════════════════════════════
   Two numbers: what you can pour now, and how many recipes use it at all.
   The second is the reason to restock something you have run out of. */

.unlock {
  display: inline-flex; align-items: center; gap: var(--space-4);
  margin-left: auto;
  color: var(--action-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  font-variant-numeric: tabular-nums;
}
.unlock .icon { width: 14px; height: 14px; }
.unlock .of { color: var(--text-muted); font-weight: var(--font-weight-420); }
.unlock.is-none { color: var(--text-muted); }


/* ══ 14. BUTTONS ═══════════════════════════════════════════ */

.btn {
  display: inline-flex; align-items: center; justify-content: center;
  gap: var(--space-8);
  min-height: 48px; padding: var(--space-12) var(--space-20);
  border-radius: var(--radius-lg);
  font: var(--font-weight-640) var(--text-size-14)/var(--text-line-16) var(--font-family);
  transition: background var(--motion-fast) var(--ease);
}
.btn[disabled] { opacity: var(--opacity-disabled); cursor: not-allowed; }
.btn-block { width: 100%; }

.btn-primary   { background: var(--action-primary); color: var(--text-on-brand); }
.btn-primary:hover { background: var(--action-primary-hover); }

.btn-secondary { border: var(--stroke-thick) solid var(--border-interactive);
                 color: var(--action-primary); }
.btn-secondary:hover { background: var(--surface-page); }

.btn-tertiary  { color: var(--action-primary); }
.btn-tertiary:hover  { background: var(--surface-page); }

/* outlined: a second filled button competes with the primary */
.btn-danger    { border: var(--stroke-thick) solid var(--state-out-ink);
                 color: var(--state-out-ink); }
.btn-danger:hover { background: var(--state-out-fill); }


/* ══ 15. EMPTY STATES ══════════════════════════════════════
   Every one names a way out. An empty screen is an invitation to act, not a
   dead end. */

.empty {
  display: flex; flex-direction: column; align-items: center; gap: var(--space-14);
  padding: var(--space-56) var(--space-32) var(--space-40);
  text-align: center;
}
.empty-glyph {
  display: grid; place-items: center;
  width: 72px; height: 72px;
  border-radius: var(--radius-pill);
  background: var(--surface-selected); color: var(--action-primary);
}
.empty-glyph .icon { width: 32px; height: 32px; }
.empty h2 {
  margin: 0;
  font: var(--font-weight-524) var(--display-h4-size)/var(--display-h4-line) var(--font-family);
}
.empty p {
  margin: 0; max-width: var(--measure-prose); color: var(--text-secondary);
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.empty .actions {
  display: flex; flex-direction: column; gap: var(--space-8);
  align-self: stretch; margin-top: var(--space-6);
}


/* ══ 16. BOTTOM NAV ════════════════════════════════════════
   A phone pattern. At desktop width the destinations move into the header
   (see HEADER) and this disappears entirely. */

.bottom-nav {
  position: fixed; inset: auto 0 0 0; z-index: 30;
  display: flex; align-items: center;
  padding: var(--space-6) var(--space-8)
           max(var(--safe-bottom), env(safe-area-inset-bottom));
  background: var(--surface-card);
  border-top: var(--stroke-hairline) solid var(--border-hairline);
}
.nav-item {
  flex: 1;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-4);
  padding: var(--space-6) 0;
  color: var(--text-muted); text-decoration: none;
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
  border-radius: var(--radius-lg);
}
.nav-pill {
  display: grid; place-items: center;
  padding: var(--space-2) var(--space-16);
  border-radius: var(--radius-pill);
}
.nav-item.is-selected { color: var(--action-primary); font-weight: var(--font-weight-524); }
.nav-item.is-selected .nav-pill { background: var(--surface-selected); }  /* shape, not just colour */

.nav-action {
  flex: none;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-4);
  color: var(--action-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.add-btn {
  flex: none;
  display: grid; place-items: center;
  width: 56px; height: 56px; margin-top: -20px;
  border-radius: var(--radius-round);
  background: var(--action-primary); color: var(--text-on-brand);
  box-shadow: var(--elevation-raised);
}
.add-btn:hover { background: var(--action-primary-hover); }

/* Gone at desktop width, where the destinations sit in the header. Gone again
   on a short viewport for the same reason: at high zoom the header takes them
   over, and this bar was spending 90 of about 300 usable pixels to repeat two
   links. Still here on an actual phone, which is what the 30rem floor protects. */
@media (min-width: 900px), (max-height: 36rem) and (min-width: 30rem) {
  .bottom-nav { display: none; }
  body { padding-bottom: 0; }
}

/* Still here below 30rem, where a rail or a header nav would not fit, but
   spending far less on it. The bar was taking 90 of 470px to say three words
   the icons already say.

   The labels are hidden VISUALLY, not removed: display:none would strip the
   accessible name off both destinations, since the icons are aria-hidden and
   the label is the only text in the anchor. */
@media (max-height: 36rem) {
  .bottom-nav { padding: 0 var(--space-8) max(var(--space-4), env(safe-area-inset-bottom)); }
  .nav-item { padding: var(--space-4) 0; }
  .nav-label, .nav-action > span:not(.visually-hidden) {
    position: absolute; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .nav-pill { padding: var(--space-2) var(--space-14); }
  .add-btn { width: 44px; height: 44px; margin-top: -8px; }
  body { padding-bottom: 4rem; }
}

/* NAVIGATION RAIL
   Below 20rem of height the bar turns on its side and pins to the right edge.

   Height is the scarce axis here. At 400% zoom on a laptop the viewport is
   about 384 by 171, so trading 56px of width for 60px of height is a good
   deal: 15 percent of the width buys back 35 percent of the height. It is also
   the better ergonomics in landscape, where thumbs rest at the left and right
   edges rather than at the bottom. Material calls this a navigation rail.

   Same markup, same components, same order. Only the axis changes. */
@media (max-height: 20rem) {
  .bottom-nav {
    display: flex;
    flex-direction: column;
    justify-content: center;
    inset: 0 0 0 auto;
    width: 4rem;
    padding: var(--space-8) var(--space-4);
    gap: var(--space-10);
    border-top: 0;
    border-left: var(--stroke-hairline) solid var(--border-hairline);
  }
  .nav-item { flex: none; }
  /* no longer poking up out of a horizontal bar */
  .add-btn { margin-top: 0; }
  /* Labels come BACK here, unlike the compact bottom bar. Someone at this zoom
     is here because they need things larger, and text is the thing that scales
     meaningfully, an icon at five times size is just a big icon, and these are
     2px strokes on a 24px grid. Icon plus label stacked is also what a rail is
     supposed to be. */
  .nav-label, .nav-action > span:not(.visually-hidden) {
    position: static; width: auto; height: auto;
    margin: 0; overflow: visible; clip-path: none;
    text-align: center;
  }

  /* Pictograms out, symbol stays. A shelf mark and a cocktail glass are things
     you have to learn; at five times size they are just large abstractions, and
     the word does the job better and in less height. A plus is not a pictogram ,
     it means the same thing at any size in any product, so Add keeps its mark
     and drops to 44 to sit in a 4rem rail. */
  .nav-item .nav-pill { display: none; }
  .add-btn { width: var(--touch-min); height: var(--touch-min); }
  .add-btn .icon { width: 20px; height: 20px; }

  /* the rail is fixed, so the page needs its width kept clear, not its height */
  body { padding-bottom: 0; padding-right: 4rem; }
}



/* ══ 17. FILTER AND SORT PANEL ═════════════════════════════
   Deliberately NOT a modal. It fills the area below the sticky control strip
   so the tabs stay usable and the count changes as you switch, which means
   no scrim, no focus trap, and an explicit close rather than tap-outside. */

.panel {
  position: fixed; inset: auto 0 0 0; z-index: 45;   /* above the nav: you do not add while filtering */
  top: var(--strip-bottom, 14rem);
  display: flex; flex-direction: column;
  touch-action: none;
  background: var(--surface-card);
  border-top-left-radius: var(--radius-2xl);
  border-top-right-radius: var(--radius-2xl);
  box-shadow: var(--elevation-overlay);
  animation: sheet-up var(--motion-slow) var(--ease);
}
.panel-grab {
  display: grid; place-items: center;
  width: 100%; min-height: var(--touch-min); padding: var(--space-8) 0 0;
}
.panel-grab::before {
  content: ""; width: 36px; height: 4px;
  border-radius: var(--radius-xs); background: var(--border-hairline);
}
.panel-head {
  display: flex; align-items: center; gap: var(--space-8);
  padding: var(--space-8) var(--space-12) var(--space-8) var(--space-20);
  border-bottom: var(--stroke-hairline) solid var(--border-hairline);
}
.panel-head h2 {
  flex: 1; margin: 0;
  font: var(--font-weight-524) var(--display-h4-size)/var(--display-h4-line) var(--font-family);
}
.panel-body {
  flex: 1; overflow-y: auto;
  padding: var(--space-12) var(--space-16) var(--space-16);
  scroll-padding-block: var(--space-8);
}
.panel-foot {
  padding: var(--space-12) var(--space-20)
           max(var(--safe-bottom), env(safe-area-inset-bottom));
  border-top: var(--stroke-hairline) solid var(--border-hairline);
}

.filter-group { margin-bottom: var(--space-24); }
.filter-group:last-child { margin-bottom: 0; }
.filter-group > h3 {
  margin: 0 0 var(--space-10);
  color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
  letter-spacing: var(--tracking-wide); text-transform: uppercase;
}

@media (min-width: 900px) {
  .panel {
    top: auto; left: auto; right: var(--space-24); bottom: var(--space-24);
    width: 26rem; max-height: 70vh; border-radius: var(--radius-2xl);
  }
}

/* Full height below 20rem, where it stops being a drawer and becomes a sheet.

   Opening from the bottom of the sticky strip is what keeps the category tabs
   live while you filter, and that is a good trade everywhere it fits. At 171px
   of viewport it does not fit: the panel gets about 113px, the heading and the
   Show results button take 92 of them, and the part you came to use is left
   with twenty. The principle costs more than it returns at this size, so it is
   dropped deliberately rather than left to fail quietly.

   Head and foot tighten too, so the body gets the room rather than the chrome. */
@media (max-height: 20rem) {
  .panel { top: 0; border-radius: 0; }
  .panel-grab { display: none; }
  .panel-head { padding-block: var(--space-6); }
  .panel-body { padding-block: var(--space-8); }
  .panel-foot { padding-block: var(--space-8); }
}


/* ══ 18. CHIPS ═════════════════════════════════════════════
   Chips are CHOSEN; status pills are REPORTED. Deliberately unalike. */

.chips { display: flex; flex-wrap: wrap; gap: var(--space-8); }

.chip {
  display: inline-flex; align-items: center; gap: var(--space-6);
  min-height: var(--touch-min);
  padding: var(--space-8) var(--space-14);
  border: var(--stroke-thick) solid var(--border-interactive);
  border-radius: var(--radius-pill);
  background: var(--surface-card); color: var(--text-primary);
  font: var(--font-weight-524) var(--text-size-14)/var(--text-line-20) var(--font-family);
  transition: background var(--motion-fast) var(--ease),
              color var(--motion-fast) var(--ease);
}
.chip[aria-pressed="true"] {
  background: var(--action-primary); color: var(--text-on-brand);
  border-color: var(--action-primary);
}
.chip[aria-pressed="true"]::before { content: "✓"; }   /* tick AND fill */


/* ══ 19. OPTION ROWS ═══════════════════════════════════════
   Sort is one-of, so it uses ticked rows. Chips here would say you can pick
   two sort orders at once. */

.option {
  display: flex; align-items: center; gap: var(--space-12);
  width: 100%; min-height: 48px;
  padding: var(--space-10) var(--space-16);
  border-radius: var(--radius-lg);
  text-align: left;
  font: var(--font-weight-460) var(--text-size-16)/var(--text-line-24) var(--font-family);
}
.option:hover { background: var(--surface-page); }
.option[aria-checked="true"] {
  background: var(--surface-page);
  font-weight: var(--font-weight-524);
}
/* The tick reuses the chevron glyph rotated, so the icon set stays small. */
.option .tick {
  margin-left: auto; opacity: 0;
  color: var(--action-primary);
  transform: rotate(-90deg);
}
.option[aria-checked="true"] .tick { opacity: 1; }


/* ══ 20. SHEET SHELL ═══════════════════════════════════════
   One structure for five views: a fixed head, a scrolling middle and an
   anchored foot. The foot is what stops the primary action moving when a
   disclosure opens underneath it.

   Mobile is a bottom sheet with a drag handle. Desktop is a full-height
   side panel, where the handle and the expand toggle both have no job. */

.scrim {
  position: fixed; inset: 0; z-index: 40;
  background: var(--text-primary);
  opacity: var(--opacity-scrim);
  animation: fade-in var(--motion-base) var(--ease);
  touch-action: none;
}
@keyframes fade-in { from { opacity: 0; } to { opacity: var(--opacity-scrim); } }
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }

.sheet {
  position: fixed; inset: auto 0 0 0; z-index: 50;
  display: flex; flex-direction: column;
  max-height: 94vh;
  background: var(--surface-card);
  /* 20px was the one radius in the file that was not a token. There is no
     20px step in the scale, and a sheet is not a card, so it takes the round
     token: the same corner as the add button, which is the only other thing
     that reads as a surface rather than a control. */
  border-top-left-radius: var(--radius-round);
  border-top-right-radius: var(--radius-round);
  box-shadow: var(--elevation-overlay);
  animation: sheet-up var(--motion-slow) var(--ease);
  overscroll-behavior: contain;
}
.sheet:focus { outline: none; }

/* A form is a task, not a glance: it takes the screen below the header, so
   fields appearing and disappearing never resize it under the finger. */
.sheet.is-form { height: calc(100vh - var(--header-h, 4rem)); }
.sheet.is-form .sheet-handle { display: none; }
.sheet.is-form .sheet-body { overflow: hidden; padding-bottom: 0; }
.sheet.is-form .sheet-head { padding-top: var(--space-24); }

.sheet-handle {
  flex: none;
  display: grid; place-items: center;
  width: 100%; min-height: var(--touch-min); padding: var(--space-8) 0 var(--space-4);
  touch-action: none; cursor: grab;
}
.sheet-handle:active { cursor: grabbing; }
.sheet-handle::before {
  content: ""; width: 36px; height: 4px;
  border-radius: var(--radius-xs); background: var(--border-hairline);
  transition: width var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
}
.sheet-handle:hover::before { width: 44px; background: var(--text-secondary); }

.sheet-body {
  flex: 1; min-height: 0;
  display: flex; flex-direction: column;
  overflow-y: auto;
  overscroll-behavior: contain;
  /* horizontal padding is the focus ring's room: a scrolling container clips
     on both axes, so a control at the edge loses a side of its ring */
  padding: 0 var(--space-16) max(var(--safe-bottom), env(safe-area-inset-bottom));
}
.sheet-scroll {
  flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
  /* overflow-y:auto clips on BOTH axes, and this element is the clipper, so a
     full-width field loses a side of its focus ring. .sheet-body's padding is
     one level too high up to help. Same padding-plus-negative-margin trick as
     .tabs: the padding is the ring's room, the negative margin gives the space
     back to the layout so nothing actually moves. Ring is 5px (3px stroke +
     2px offset). */
  padding: var(--space-6) var(--space-6) var(--space-16);
  margin-inline: calc(var(--space-6) * -1);
}

/* Head, sections and foot all align to the same left edge. Indenting the
   headings past their own content is what made the panel look stepped. */
.sheet-head {
  display: flex; align-items: flex-start; gap: var(--space-8);
  padding: var(--space-16) 0 var(--space-12);
  border-bottom: var(--stroke-hairline) solid var(--border-hairline);
  margin-bottom: var(--space-16);
}
.sheet-heading { flex: 1; min-width: 0; }
.sheet-heading h2 {
  margin: 0;
  font: var(--font-weight-524) var(--display-h4-size)/var(--display-h4-line) var(--font-family);
}
.sheet-sub {
  margin: var(--space-2) 0 0; color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.sheet-head-actions { display: flex; align-items: center; gap: var(--space-4); flex: none; }

.sheet-foot {
  flex: none;
  padding: var(--space-12) 0 max(var(--safe-bottom), env(safe-area-inset-bottom));
  border-top: var(--stroke-hairline) solid var(--border-hairline);
  background: var(--surface-card);
}

.sheet-section { margin-bottom: var(--space-24); }
.sheet-section:last-child { margin-bottom: 0; }

/* ONE label style for every section heading in the sheet. There were three. */
.sheet-label {
  margin: 0 0 var(--space-10);
  color: var(--text-muted);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}
/* Sub-headings inside the filter panel: same style, one step quieter. */
.sheet-label.is-sub {
  margin-top: var(--space-12);
  color: var(--text-secondary);
  letter-spacing: 0;
  text-transform: none;
}

.sheet-status {
  display: flex; align-items: center; gap: var(--space-12);
  margin-bottom: var(--space-16);
}
.sheet-status .unlock  { margin-left: 0; }
.sheet-status .spacer  { flex: 1; }

/* An invisible marker. The partial detent measures to this rather than to
   the list, whose top margin was leaving a strip of the first recipe on
   screen. */
.sheet-cut { display: block; height: 0; }

/* THE ONLY PLACE .sheet IS DECLARED AT THIS WIDTH. */
@media (min-width: 900px) {
  .sheet {
    inset: 0 0 0 auto;                  /* pinned right, full height */
    width: min(40rem, 48vw);            /* the pour view has dropdowns in it */
    height: 100vh; max-height: 100vh;
    border-radius: 0;
    animation: none;
  }
  .sheet.is-form { height: 100vh; }

  /* A full-height panel has nothing to drag and nothing to reveal: the
     cocktail list is on screen already. */
  .sheet .sheet-handle { display: none; }
  .sheet [data-act="expand"] { display: none; }

  .sheet-body { padding-inline: var(--space-24); }
  .sheet-head { padding-top: var(--space-24); }
}


/* ══ 21. ACTION ROW AND STEPPER ════════════════════════════ */

.action-row { display: flex; gap: var(--space-8); margin-bottom: var(--space-8); }
.action-row:last-child { margin-bottom: 0; }
.action-row .btn { flex: 1; }

.batch-row { align-items: center; gap: var(--space-16); }
.batch-note {
  flex: 1; color: var(--text-muted); text-align: right;
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}

/* 48pt tall with 44pt targets, a control this fiddly is the first thing to
   fail a touch audit. */
.stepper {
  display: flex; align-items: center; flex: none;
  height: 48px; width: 148px;
  border: var(--stroke-thick) solid var(--border-interactive);
  border-radius: var(--radius-lg);
  background: var(--surface-card);
}
.stepper button {
  width: var(--touch-min); height: 100%;
  font-size: var(--text-size-18); color: var(--action-primary);
}
.stepper button[disabled] { opacity: var(--opacity-disabled); cursor: not-allowed; }
.stepper output {
  flex: 1; text-align: center; font-variant-numeric: tabular-nums;
  font: var(--font-weight-524) var(--text-size-16)/var(--text-line-24) var(--font-family);
}


/* ══ 22. INGREDIENT ROWS ═══════════════════════════════════
   Label and amount on one line, the bottle picker full width beneath. Side
   by side, a two-word label and a bottle name were both truncating. */

.need-list { list-style: none; margin: 0; padding: 0; }
.need-list li {
  padding: var(--space-12) 0;
  border-bottom: var(--stroke-hairline) solid var(--border-hairline);
}
.need-list li:last-child { border-bottom: 0; }
.need-list li.is-skipped .need-head { opacity: var(--opacity-disabled); }

.need-head { display: flex; align-items: baseline; gap: var(--space-12); }
.need-name {
  flex: 1; min-width: 0;
  font: var(--font-weight-524) var(--text-size-16)/var(--text-line-24) var(--font-family);
}
/* right-aligned because it is calculated from the batch and the picker */
.need-amount {
  flex: none; color: var(--text-secondary); text-align: right;
  font-variant-numeric: tabular-nums;
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.need-note {
  margin: var(--space-4) 0 0; color: var(--state-low-ink);
  font: var(--font-weight-460) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.need-pick { display: flex; align-items: center; gap: var(--space-8); margin-top: var(--space-8); }
.need-pick .stepper { flex: none; }
.need-none {
  flex: 1; color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.need-list .tag {
  display: inline-block; margin-left: var(--space-6);
  padding: 0 var(--space-6);
  border-radius: var(--radius-pill);
  background: var(--surface-selected); color: var(--text-secondary);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
  text-transform: none; letter-spacing: 0;
}

/* Editable rows give the name field the full width and put the amount and
   the garnish toggle beneath it. */
/* Where there is exactly one bottle to choose from, the row states it rather
   than offering a picker. Full-weight text, not the muted .need-none: this is
   a value, not an absence. */
.need-fixed {
  flex: 1; min-width: 0;
  color: var(--text-primary);
  font: var(--font-weight-460) var(--text-size-16)/var(--text-line-24) var(--font-family);
}

.need-list.is-editable .need-head { gap: var(--space-8); align-items: center; }


/* ══ 23. METHOD STEPS ══════════════════════════════════════ */

.method-steps {
  margin: var(--space-12) 0 0; padding-left: var(--space-20);
  color: var(--text-secondary);
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-24) var(--font-family);
}
.method-steps li { margin-bottom: var(--space-10); }
.method-steps li::marker { color: var(--action-primary); font-weight: var(--font-weight-640); }


/* ══ 24. UNLOCK LIST (cocktails this makes) ════════════════ */

.unlock-list { list-style: none; margin: 0; padding: 0; }
.unlock-list li {
  display: flex; align-items: center; gap: var(--space-8);
  padding: var(--space-8) 0;
  border-bottom: var(--stroke-hairline) solid var(--border-hairline);
}
.unlock-list li:last-child { border-bottom: 0; }
.unlock-list .name {
  flex: 1;
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.unlock-list .ready,
.unlock-list .short,
.unlock-list .missing {
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.unlock-list .ready   { color: var(--state-good-ink); }
.unlock-list .short   { color: var(--state-low-ink); }
.unlock-list .missing { color: var(--text-muted); }

/* 44pt, like everything else you can press */
.unlock-list .pour {
  min-height: var(--touch-min); padding: var(--space-8) var(--space-16);
  border: var(--stroke-hairline) solid var(--border-hairline);
  font: var(--font-weight-640) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.unlock-list .pour:hover { background: var(--surface-selected); border-color: var(--border-interactive); }


/* ══ 25. DISCLOSURE ════════════════════════════════════════
   A chevron, so it reads as openable rather than as a static summary. */

.details {
  padding: var(--space-12) var(--space-14);
  border: var(--stroke-hairline) solid var(--border-hairline);
  border-radius: var(--radius-lg);
  background: var(--surface-card);
}
.details > summary {
  cursor: pointer; list-style: none;
  display: flex; align-items: center; gap: var(--space-12);
  min-height: var(--touch-min);
}
.details > summary::-webkit-details-marker { display: none; }
.details[open] > summary {
  margin-bottom: var(--space-12);
  border-bottom: var(--stroke-hairline) solid var(--border-hairline);
  padding-bottom: var(--space-10);
}
.details .chevron {
  flex: none; color: var(--text-secondary);
  transition: transform var(--motion-fast) var(--ease);
}
.details[open] .chevron { transform: rotate(180deg); }

.summary-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.summary-title {
  font: var(--font-weight-524) var(--text-size-16)/var(--text-line-24) var(--font-family);
}
.summary-edit {
  display: inline-block;
  padding: 0 var(--space-6);
  border-radius: var(--radius-pill);
  background: var(--surface-selected); color: var(--action-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.details-summary {
  color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}


/* ══ 26. FORM FIELDS ═══════════════════════════════════════ */

.field { display: block; margin-bottom: var(--space-16); }
.field-label {
  display: block; margin: 0 0 var(--space-6);
  color: var(--text-secondary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.field-input {
  display: block; width: 100%;
  min-height: 48px; padding: var(--space-10) var(--space-14);
  border: var(--stroke-thick) solid var(--border-interactive);
  border-radius: var(--radius-lg);
  background: var(--surface-card); color: var(--text-primary);
  font: var(--font-weight-460) var(--text-size-16)/var(--text-line-24) var(--font-family);
}
.field-input::placeholder { color: var(--text-muted); }
/* Quiet, not disabled. The field is still typeable. Typing is how you switch
   the answer to it, so it keeps a real border rather than fading out. Muted
   ink is 5.37:1 against the card, still clear of 1.4.11's 3:1; the hairline it
   might look like a candidate for is 1.37:1 and would fail. */
.field-input.is-passive { border-color: var(--text-muted); }
.field-input.is-passive:focus { border-color: var(--border-interactive); }
/* SELECT
   The arrow is a real .icon in the markup, not a pair of CSS gradients faking
   a triangle. That way it is the same chevron as the disclosure summary, it
   takes its colour from a token, and it scales with the icon set instead of
   being two hardcoded 6px wedges. */
select.field-input {
  appearance: none;
  padding-right: var(--space-40);
}
.select { position: relative; display: block; }
.select .chevron {
  position: absolute; right: var(--space-14); top: 50%;
  transform: translateY(-50%);
  width: 20px; height: 20px;
  color: var(--text-secondary);
  pointer-events: none;   /* the click belongs to the select underneath */
}
/* wherever a select sits in a flex row, it takes the remaining width */
.need-pick .select,
.need-list.is-editable .select { flex: 1; min-width: 0; }
.field-inline { display: flex; align-items: center; gap: var(--space-10); flex-wrap: wrap; }
.field-inline .field-label { flex: 1; margin: 0; }
.field-inline .field-input { width: 6rem; }
/* .view-toggle sized for words. The segments are square by default because
   the control strip's version holds icons; these hold "shots" and "ml".
   48px matches the input beside it and clears the 44px minimum with room to
   spare. "ml" is two characters and would otherwise be a sliver. */
.unit-toggle .seg {
  width: auto; height: auto;
  min-width: 48px; min-height: 48px;
  padding-inline: var(--space-14);
  font: var(--font-weight-524) var(--text-size-14)/var(--text-line-20) var(--font-family);
}

.noun-pair { display: flex; gap: var(--space-8); }
.noun-pair .field { flex: 1; }

.hint {
  margin: var(--space-8) 0 0; color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}


/* ══ 27. SUGGESTIONS ═══════════════════════════════════════ */

.suggest {
  list-style: none; margin: 0 0 var(--space-16); padding: 0;
  display: flex; flex-direction: column; gap: var(--space-4);
}
.suggest-row {
  display: block; width: 100%; text-align: left;
  min-height: 58px; padding: var(--space-10) var(--space-14);
  border-radius: var(--radius-lg);
  background: var(--surface-card);
  border: var(--stroke-hairline) solid var(--border-hairline);
}
.suggest-row:hover { background: var(--surface-selected); border-color: var(--border-interactive); }
.suggest-row.is-custom { border-style: dashed; }
.suggest-name {
  display: block;
  font: var(--font-weight-524) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.suggest-meta {
  display: block; color: var(--text-muted);
  font: var(--font-weight-420) var(--text-size-12)/var(--text-line-16) var(--font-family);
}


/* ══ 28. FILL LEVELS ═══════════════════════════════════════
   Presets, not a slider: nobody knows their bottle is at 63%, and dragging
   on a phone is slower and no more honest. */

.levels { display: flex; gap: var(--space-6); }
/* The exact-amount row is the OTHER answer to the same question, not a
   continuation of the levels, so it gets a gap that reads as a break. */
.levels + .field { margin-top: var(--space-20); }
.level {
  flex: 1;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-6);
  min-height: 76px; padding: var(--space-10) var(--space-4);
  border: var(--stroke-hairline) solid var(--border-interactive);
  border-radius: var(--radius-lg);
  background: var(--surface-card);
}
.level[aria-pressed="true"] { background: var(--surface-selected); border-width: var(--stroke-thick); }
.level-label {
  color: var(--text-primary); text-align: center;
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.bottle {
  position: relative; display: block;
  width: 22px; height: 30px;
  border: var(--stroke-medium) solid var(--action-primary);
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.liquid { position: absolute; inset: auto 0 0 0; background: var(--action-primary); }


/* ══ 29. TOAST ═════════════════════════════════════════════
   Undo rather than a confirmation dialog: cheaper to dismiss and it does not
   interrupt the common case.

   Top on mobile, not bottom: at the bottom it covered the very button that
   had just been pressed, so Undo sat on top of Mark out of stock. Below the
   sticky controls rather than over the header: a message that covers the
   wordmark reads as an error page. */

@keyframes toast-in {
  from { opacity: 0; transform: translateY(-12px); }
  to   { opacity: 1; transform: none; }
}

.toast {
  position: fixed; z-index: 70;
  left: var(--gutter); right: var(--gutter);
  /* Below whatever the sticky strip actually occupies. 4.5rem was a guess and
     it landed on the search box once the tabs wrapped. */
  top: calc(var(--strip-bottom, 12rem) + var(--space-12));
  display: flex; align-items: center; gap: var(--space-12);
  padding: var(--space-12) var(--space-8) var(--space-12) var(--space-16);
  border-radius: var(--radius-lg);
  background: var(--text-primary); color: var(--text-on-brand);
  box-shadow: var(--elevation-overlay);
  animation: toast-in var(--motion-base) var(--ease);
}
.toast span {
  flex: 1;
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
.toast .btn { color: var(--state-low-fill); min-height: var(--touch-min); }

@media (min-width: 900px) {
  /* Bottom LEFT. Centred, it spans the middle of the viewport while the
     sheet occupies the right 40rem, so the two overlap and the message
     covers the panel it is reporting on. */
  .toast {
    left: var(--space-24); right: auto;
    top: auto; bottom: var(--space-24);
    width: min(24rem, 40vw);
  }
}


/* ══ 30. BACK TO TOP ═══════════════════════════════════════ */

/* LEFT, not right. The right edge already belongs to the filter button and the
   view toggle in the sticky strip, and at 200% zoom the viewport gets short
   enough that a bottom-right button lands on top of them, covering a control
   is a loss of functionality, not a cosmetic overlap. Nothing else lives bottom
   left on a phone: the toast is at the top there. */
.to-top {
  position: fixed; z-index: 28;
  left: var(--gutter);
  bottom: calc(6.5rem + env(safe-area-inset-bottom));   /* clears the bottom nav */
  display: flex; align-items: center; gap: var(--space-6);
  min-height: var(--touch-min); padding: var(--space-8) var(--space-16);
  border-radius: var(--radius-pill);
  background: var(--surface-card); color: var(--action-primary);
  border: var(--stroke-hairline) solid var(--border-hairline);
  box-shadow: var(--elevation-raised);
  font: var(--font-weight-640) var(--text-size-12)/var(--text-line-16) var(--font-family);
  animation: toast-in var(--motion-base) var(--ease);
}
.to-top:hover { background: var(--surface-selected); border-color: var(--border-interactive); }
.to-top .icon { width: 16px; height: 16px; }

@media (min-width: 900px) {
  /* Back to the right at this width: the toast has taken bottom left, the
     controls are no longer stacked above it, and there is room for both. */
  .to-top { bottom: var(--space-24); left: auto; right: var(--space-24); }
}

/* With no bottom bar to clear, it drops into the corner. At 6.5rem it was
   floating in the middle of the list, covering the rows it is meant to help
   you get away from. */
@media (max-height: 36rem) and (min-width: 30rem) {
  .to-top { bottom: var(--space-16); }
}

/* At the foot of the rail, where it stops being a thing that floats over the
   content and becomes the last item in the navigation. Icon only: the rail is
   56px wide and the word does not fit, and the button already carries its own
   accessible name. */
@media (max-height: 20rem) {
  .to-top {
    left: auto; right: var(--space-10);
    bottom: var(--space-8);
    padding: 0;
    width: var(--touch-min); height: var(--touch-min);
    justify-content: center;
    border: 0; box-shadow: none;
    background: none;
  }
  .to-top span:not(.visually-hidden) {
    position: absolute; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip-path: inset(50%); white-space: nowrap;
  }
  .to-top .icon { width: 24px; height: 24px; }
}


/* ══ 31. HOTKEY LEGEND ═════════════════════════════════════ */

.hotkeys {
  position: fixed; inset: 0; z-index: 60;
  display: grid; place-items: center;
  padding: var(--space-24);
  background: color-mix(in srgb, var(--text-primary) 45%, transparent);
}
.hotkeys:focus { outline: none; }
.hotkeys-panel {
  background: var(--surface-card);
  border-radius: var(--radius-2xl);
  padding: var(--space-24);
  box-shadow: var(--elevation-overlay);
  max-width: 26rem; width: 100%;
  max-height: 84vh; overflow-y: auto;
}
.hotkeys-head {
  display: flex; align-items: center; gap: var(--space-8);
  margin-bottom: var(--space-16);
}
.hotkeys h2 {
  flex: 1; margin: 0;
  font: var(--font-weight-524) var(--display-h5-size)/var(--display-h5-line) var(--font-family);
}
/* Two groups, because the answer to "how do I do this" depends on what you are
   holding. The touch group comes first: it is the one that cannot be found any
   other way. */
.hotkeys h3 {
  margin: var(--space-20) 0 var(--space-10);
  color: var(--text-muted);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
  letter-spacing: var(--tracking-wide); text-transform: uppercase;
}
.hotkeys h3:first-of-type { margin-top: 0; }
.hotkeys dl {
  display: grid; grid-template-columns: auto 1fr;
  gap: var(--space-10) var(--space-16); margin: 0;
}
.hotkeys dt {
  display: flex; gap: var(--space-4); align-items: center;
  color: var(--text-primary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}
.hotkeys dd {
  margin: 0; color: var(--text-secondary);
  font: var(--font-weight-460) var(--text-size-14)/var(--text-line-20) var(--font-family);
}
kbd {
  display: inline-grid; place-items: center;
  min-width: 28px; padding: var(--space-4) var(--space-8);
  border: var(--stroke-hairline) solid var(--text-muted);
  border-radius: var(--radius-md);
  background: var(--surface-card); color: var(--text-secondary);
  font: var(--font-weight-524) var(--text-size-12)/var(--text-line-16) var(--font-family);
}