/* Chemlingo — design tokens, reset, typography, layout primitives */

/* Grandstander (SIL OFL, self-hosted from assets/fonts/) — the app's bold, bouncy, cartoony
   display and body font. Its slightly irregular, marker-like baseline gives it a hand-drawn
   cartoon character distinct from more generic uniform-bubble rounded sans faces. Static
   per-weight files, not the variable font, since only these four weights are used anywhere in
   the app (see the typography scale below). */
@font-face {
  font-family: "Grandstander";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../assets/fonts/Grandstander-400.woff2") format("woff2");
}
@font-face {
  font-family: "Grandstander";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../assets/fonts/Grandstander-500.woff2") format("woff2");
}
@font-face {
  font-family: "Grandstander";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../assets/fonts/Grandstander-600.woff2") format("woff2");
}
@font-face {
  font-family: "Grandstander";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../assets/fonts/Grandstander-700.woff2") format("woff2");
}

:root {
  /*
   * Surfaces. Light, but never plain white: the page carries a faint violet cast that ties it to
   * the brand purple, and cards sit *above* it in true white so the elevation reads without
   * needing heavy borders.
   */
  --bg: #f6f5fb;
  --surface: #ffffff;
  --raised: #fbfaff;
  --border: #e5e2f0;
  --border-strong: #cdc8de;

  /* Text */
  --text: #191428;
  --muted: #5c5674;
  /* 4.75:1 on --bg. The old value looked right but measured 3.3:1, which fails AA for the
     small uppercase labels this is mostly used on. */
  --faint: #6f6a86;

  /*
   * Brand — the app's own colour, distinct from the per-field accents below. Chrome only: the
   * mark, links, buttons, badges, focus rings, the home page. It says "Chemlingo"; the accent says
   * which field you are standing in, and the two must not be confused.
   *
   * --brand is a pale lavender, so white text on it measures 2.3:1 and fails AA. Anything filled
   * with --brand takes --brand-ink (near-black, 8.5:1) rather than white, and --brand-text is the
   * darker sibling used for brand-coloured *text* on the page background (5.9:1 on --bg).
   * --brand-ridge is a literal, not a mix toward black: mixing a pale lavender down produces a
   * muddy grey-violet, and the chunky-button shadow needs a saturated purple to read as depth.
   */
  --brand: #a78bfa;
  /* Deliberately the same literal as --brand-ridge, not an alias of it: one is brand-coloured
     *text* on the page background, the other is the saturated ridge under a chunky button. They
     happen to want the same purple today; a change to either must not silently move the other. */
  --brand-text: #7c3aed;
  --brand-ink: var(--text);
  --brand-ridge: #7c3aed;
  --brand-dim: color-mix(in srgb, var(--brand) 24%, transparent);
  --brand-faint: color-mix(in srgb, var(--brand) 9%, transparent);
  /* The sidebar's liquid pool: --brand flattened over --bg at 30% and 55%. Pre-computed opaque
     literals rather than a color-mix() toward transparent, for two reasons — the wave SVG that
     continues this gradient has to carry the identical value inline (a custom property doesn't
     resolve inside a data-URI SVG document), and a translucent pool would let the frosted rail's
     own backdrop-blur show through it unevenly. --brand-ink is what reads on top of them: white
     fails on the brand family (see above), and the deep end measures 10.4:1 against ink. */
  --liquid-top: #ded5fb;
  --liquid-deep: #cbbbfa;
  /* The sidebar's graduation ticks. A step lighter than the wall they hang off (--brand at 3px),
     so the scale reads as markings *on* the glass rather than as a second set of borders
     competing with it. */
  --gauge-line: color-mix(in srgb, var(--brand) 55%, transparent);

  /* Accent — overridden per field on <body data-field>, .field-card and .profile-field. This
     value is a fallback only: it is what an accent-tinted component resolves to when it is not
     inside any field scope at all, so it deliberately matches the brand rather than any one
     field's colour (it used to be the organic green, which made stray components look "organic"). */
  --accent: #a78bfa;

  /* Semantic */
  --danger: #c1341a;
  --flame: #e8590c;
  /* Mock-exam medal pill (js/pages/exams.js). Matches real medal metals rather than the accent
     system — a medal result is about the actual olympiad, not which field you're browsing. */
  --gold: #a87c11;
  --silver: #6b7280;
  --bronze: #a15c2e;
  /* RGB triplet, not a colour: call sites wrap it in rgba(var(--shadow-ink), X) and keep their
     own blur/spread/offset — this only consolidates the ink hue four near-identical shadows share. */
  --shadow-ink: 35, 24, 64;
  /* The "tactile tier" a raised card sits on: a hard ridge plus a soft ambient drop. Written out
     identically by .pricing-card, .widget-card and .exam-toolbar, which is what this consolidates.
     Cards wanting a different depth still write their own shadow rather than overriding this. */
  --shadow-tier: 0 6px 0 -2px rgba(var(--shadow-ink), 0.1), 0 10px 20px -8px rgba(var(--shadow-ink), 0.14);

  /* The five field accents. This is the *only* place these hexes live: --accent below selects one
     of them per page, and .avatar[data-hue] reuses the same five for friend avatars. */
  --field-organic: #1e8e5a;
  --field-physical: #1f6fbf;
  --field-analytical: #9a6b12;
  --field-inorganic: #a0329b;
  --field-biochemistry: #c2185b;

  /* Spacing scale (8px base) */
  --s2: 8px;
  --s3: 12px;
  --s4: 16px;
  --s5: 24px;
  --s6: 32px;
  --s7: 48px;
  --s8: 64px;

  --radius-sm: 8px;
  --radius: 10px;
  --radius-lg: 16px;
  /* The "tactile tier": prominent cards (widget-card, pricing-card, the exam viewer, the auth
     card, modals) round further than plain .card — part of the app-wide tactile identity, not
     a one-off value repeated by hand at each call site. */
  --radius-xl: 20px;
  --radius-pill: 999px;

  --font: "Grandstander", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  --dur: 160ms;
  /* A genuine overshoot curve, distinct from --ease's gentle ease-out. Scoped to the skill tree
     (its badges and hub) rather than applied app-wide — the tree is the app's most distinctive
     surface and gets its own, more playful register; retrofitting every existing button/chip
     hover onto real spring physics would risk the "everything jiggles" over-application the
     mascot's own rarity rule already warns against for a different primitive. */
  --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);

  /* Vertical room fixed top chrome occupies. 0 on desktop/tablet, where navigation lives in the
     left --sidebar-w rail instead of a top bar; only the ≤640px layout (sidebar collapsed to a
     top bar) sets this back to a real height. Read only by CSS — `0px` is a real value that a JS
     reader would have to guard against treating as missing, which is exactly the bug the deleted
     atom tree shipped (see CLAUDE.md's traps). */
  --header-h: 0px;
  /* Vertical room the footer gauge occupies, including the space under it, so page padding can
     clear it. */
  --footer-h: 104px;
  /* Horizontal room the left sidebar occupies. 0 on the ≤640px layout, where it collapses into
     a top bar and --header-h takes over instead. */
  --sidebar-w: 232px;
  --content-max: 1080px;
}

/*
 * Per-field accents.
 *
 * Chosen for a light page: each must hold up as a line, a ring and a filled disc on #f6f5fb, so
 * they are saturated mid-darks rather than the pale tints the dark build used. Inorganic is
 * deliberately magenta rather than the violet it once was — against a purple-branded app a violet
 * field accent stops reading as the field's own colour and starts reading as chrome.
 */
/* Three selectors, because a field's colour is needed in three situations: on <body> when you are
   inside that field, on a single card when the home page shows all five at once, and on a single
   per-field progress row (profile page, friend progress panel) where five fields are listed at
   once. This rule is the *only* place these values live — the field JSON used to carry an `accent`
   too, and the page modules pushed it onto <body> as an inline style, which silently beat
   everything here. One copy, in the stylesheet, where a colour belongs. */
body[data-field="organic"],      .field-card[data-field="organic"],      .profile-field[data-field="organic"],      .timeline-row[data-field="organic"]      { --accent: var(--field-organic); }
body[data-field="physical"],     .field-card[data-field="physical"],     .profile-field[data-field="physical"],     .timeline-row[data-field="physical"]     { --accent: var(--field-physical); }
body[data-field="analytical"],   .field-card[data-field="analytical"],   .profile-field[data-field="analytical"],   .timeline-row[data-field="analytical"]   { --accent: var(--field-analytical); }
body[data-field="inorganic"],    .field-card[data-field="inorganic"],    .profile-field[data-field="inorganic"],    .timeline-row[data-field="inorganic"]    { --accent: var(--field-inorganic); }
body[data-field="biochemistry"], .field-card[data-field="biochemistry"], .profile-field[data-field="biochemistry"], .timeline-row[data-field="biochemistry"] { --accent: var(--field-biochemistry); }

/*
 * Accent-derived colours must be re-declared on every element that overrides --accent.
 * A custom property is substituted where it is *declared*, so these would otherwise all
 * resolve against :root's accent and ignore the per-field override entirely.
 *
 * `--accent-text` mixes toward *black* here. On the dark build it mixed toward white to lift the
 * accent off the background; doing that on a light page washes it out to invisibility.
 */
:root,
body[data-field],
.field-card,
.profile-field[data-field],
.timeline-row[data-field] {
  --accent-dim: color-mix(in srgb, var(--accent) 26%, transparent);
  --accent-faint: color-mix(in srgb, var(--accent) 9%, transparent);
  --accent-text: color-mix(in srgb, var(--accent) 86%, black);
}

/* ── Reset ─────────────────────────────────────────────────────────── */

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

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

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font);
  font-size: 15px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* Clears the fixed .sidebar, for every page including field.html — the route (`.path-scroll`)
     is in normal flow, so nothing opts out of this inset. */
  padding-left: var(--sidebar-w);
}

h1, h2, h3, h4, p, figure, ul, ol, dl { margin: 0; }
ul, ol { padding: 0; list-style: none; }

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

/* Links are app chrome, so they take the brand purple rather than whichever field you're in. */
a { color: var(--brand-text); text-decoration: none; }
a:hover { text-decoration: underline; }

img, svg { display: block; max-width: 100%; }

:focus-visible {
  /* --brand-text, not --brand: the pale lavender is nearly invisible as a 2px ring on --bg. */
  outline: 2px solid var(--brand-text);
  outline-offset: 2px;
  border-radius: 4px;
}
:focus:not(:focus-visible) { outline: none; }

::selection { background: var(--brand-dim); }

/* Scrollbars */
* { scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb {
  background: var(--border-strong);
  border: 3px solid var(--bg);
  border-radius: var(--radius-pill);
}
*::-webkit-scrollbar-thumb:hover { background: var(--faint); }
*::-webkit-scrollbar-track { background: transparent; }

/* ── Typography ────────────────────────────────────────────────────── */

.title {
  font-size: clamp(24px, 3.4vw, 34px);
  font-weight: 600;
  letter-spacing: -0.022em;
  line-height: 1.18;
}

/* Fixed-size variants for pages whose title is chrome rather than a hero — the fluid clamp above
   stays reserved for hero-style intros (home, plans). Replaced a one-off inline font-size at each
   call site with a shared, named scale. (There was a `.title-md` at 26px between these two; the
   page that used it now sets its own size as `.exam-title`, and an unused step in a three-step
   scale invites a fourth.) */
.title-sm { font-size: 22px; }
.title-lg { font-size: 28px; }

.subtitle {
  color: var(--muted);
  font-size: 15px;
  max-width: 62ch;
}

.eyebrow {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  color: var(--faint);
}

/* home.js's three section headings reuse .eyebrow as an <h2> rather than the page-intro <p> every
   other page uses it as — .stack's `* + *` margin doesn't apply above the first child of a section,
   so each needed its own space-below. Scoped to h2 so it can't leak onto the page-intro usage. */
h2.eyebrow { margin-bottom: var(--s4); }

.mono { font-family: var(--mono); font-size: 0.92em; }

/* ── Layout primitives ─────────────────────────────────────────────── */

.wrap {
  width: 100%;
  max-width: var(--content-max);
  margin-inline: auto;
  padding-inline: var(--s5);
}

.stack > * + * { margin-top: var(--s3); }

.row {
  display: flex;
  align-items: center;
  gap: var(--s3);
}

.spacer { flex: 1; }

.page { padding-block: var(--s6) var(--s8); }

/* The bare, sidebar-free auth screens — signin.html (the landing page) and password.html: undo the
   sidebar's body inset (and the mobile top-bar's padding-top) and center the auth card in the full
   viewport instead of just the space the sidebar would have left. The attribute selector outranks
   both the plain `body { … }` rule above and the `@media` one below regardless of source order, so
   this one rule wins at every breakpoint without `!important`. */
body[data-page="signin"],
body[data-page="password"] { padding-left: 0; padding-top: 0; }
body[data-page="signin"] .page,
body[data-page="password"] .page { min-height: 100vh; display: flex; align-items: center; padding-block: 0; }

/* ── App sidebar ───────────────────────────────────────────────────── */

/* Persistent left nav, Duolingo-style: a fixed full-height rail rather than a top bar, so the
   main column gets the full viewport width to breathe. The body's padding-left keeps page content
   clear of it (see `body` above) — one inset, no page opts out. */
.sidebar {
  position: fixed;
  inset: 0 auto 0 0;
  z-index: 40;
  width: var(--sidebar-w);
  display: flex;
  flex-direction: column;
  gap: var(--s5);
  padding: var(--s5) var(--s4);
  overflow-y: auto;
  background: color-mix(in srgb, var(--bg) 92%, transparent);
  backdrop-filter: saturate(1.4) blur(12px);
  -webkit-backdrop-filter: saturate(1.4) blur(12px);
  /* The flask wall. Deliberately not --border like every other divider in the app: this one line
     is the vessel the liquid pool, the bubbles and the graduations below all sit inside, so it
     takes the brand purple and enough weight (3px) to read as glass rather than as a hairline
     rule. Inside the rail's box-sizing: border-box width, so --sidebar-w still measures true and
     the body padding downstream needs no adjusting. */
  border-right: 3px solid var(--brand);
  /* The rail animates nothing. Its width is never toggled — `--sidebar-w` changes only at the
     1024px and 640px breakpoints — so an animated width could fire in exactly one situation:
     dragging the window across a breakpoint. `body`'s `padding-left` reads the same variable and
     is not animated, so the rail would have glided while the content it clears jumped. */
}

/* Graduations, the last piece that turns the rail into a flask: ticks marching up the inside of
   the wall like a measuring cylinder's scale. Two repeating gradients rather than N elements —
   a long major tick every 64px and a short minor one offset half a period into each gap, which
   is what stops an even single-length run from reading as hatching. Each layer is its own
   no-repeat box (background-size does the tick *length*, the gradient does the spacing), anchored
   right so both grow leftward off the wall.

   z-index: -1 puts them behind the nav the same way .sidebar-bubbles does; equal z-index plus
   ::after's later paint order means the graduations sit in front of the bubbles, so a bubble
   passes *behind* the scale as it would behind real glass. */
.sidebar::after {
  content: "";
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 14px;
  z-index: -1;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(180deg, var(--gauge-line) 0 2px, transparent 2px 112px),
    repeating-linear-gradient(180deg, transparent 0 56px, var(--gauge-line) 56px 58px, transparent 58px 112px);
  background-size: 20px 100%, 11px 100%;
  background-position: right top;
  background-repeat: no-repeat;
}

/* A pool of violet "liquid" filling the foot of the rail, with the sign-out button floating in it
   as its background. Deliberately *not* tied to `.sidebar-signout`'s own box: that button only
   exists when signed in (account.js appends it), and a decoration that vanishes for signed-out
   visitors would leave the rail bare on exactly the pages people see first. So the pool is always
   there, anchored to the sidebar's bottom edge, and the button sits in it when it exists.

   --LIQUID-TOP is hardcoded (#ded5fb, = --brand at 30% over --bg) rather than a color-mix(), so the
   wave SVG and the gradient it continues into can be guaranteed the same value — a CSS custom
   property doesn't resolve inside a separately-fetched data-URI SVG document, so the two would
   drift apart and show a seam. `overflow: hidden` is load-bearing: the ::before wave is 200% wide
   (see the sway below), and .sidebar computes `overflow-x: auto` from its own `overflow-y: auto`,
   so an unclipped wave would give the whole rail a horizontal scrollbar. */
.sidebar-liquid {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 116px;
  z-index: -2;
  overflow: hidden;
  pointer-events: none;
  background-image: linear-gradient(180deg, var(--liquid-top), var(--liquid-deep));
  background-repeat: no-repeat;
  background-position: bottom left;
  /* 1px of overlap under the wave strip, so no hairline seam shows between the two layers. */
  background-size: 100% calc(100% - 13px);
}

/* The surface. Two wave periods per tile, tiled twice across a 200%-wide strip, so translating it
   exactly -50% (one whole tile) loops seamlessly and reads as a slow swell rather than a slide. */
.sidebar-liquid::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 200%;
  height: 14px;
  background: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20240%2016'%20preserveAspectRatio='none'%3E%3Cpath%20d='M0%208%20Q30%200%2060%208%20T120%208%20T180%208%20T240%208%20L240%2016%20L0%2016%20Z'%20fill='%23ded5fb'/%3E%3C/svg%3E")
    repeat-x left top / 50% 100%;
  animation: sidebar-liquid-sway 11s linear infinite;
}

@keyframes sidebar-liquid-sway {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

/* Bubbles rising out of the pool: their own full-height layer rather than children of
   .sidebar-liquid, so they can climb past the nav items and the brand mark instead of being
   clipped to the pool's 116px box. Negative z-index keeps this inside .sidebar's own stacking
   context (already position:fixed with an explicit z-index) but behind every real child — one step
   above .sidebar-liquid's -2, so a bubble reads as sitting *in* the liquid at the moment it
   spawns. pointer-events is a second, redundant guard. Everything animated here is transform and
   opacity, never a layout property, and because these are real DOM elements with real CSS
   animations, the app-wide reduced-motion rule below already covers them with no extra override. */
.sidebar-bubbles {
  position: absolute;
  inset: 0;
  z-index: -1;
  overflow: hidden;
  pointer-events: none;
}
.sidebar-bubbles .bubble {
  position: absolute;
  bottom: 96px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--brand) 20%, transparent);
  border: 1.5px solid color-mix(in srgb, var(--brand) 42%, transparent);
  opacity: 0;
  /* linear is load-bearing, not laziness. A timing function applies to *every keyframe segment
     independently*, so the `ease-in` this used to carry made the bubble accelerate to full speed
     by each waypoint and then restart from a standstill at the next one — a visible stall in the
     middle of the climb. Speed is instead encoded in the keyframe spacing below, which lets it
     vary continuously across the whole path with no discontinuity at any waypoint. */
  animation: sidebar-bubble-rise 17s linear infinite;
}

/* A specular glint, so a bubble reads as a hollow bubble rather than a flat violet disc. */
.sidebar-bubbles .bubble::after {
  content: "";
  position: absolute;
  top: 16%;
  left: 18%;
  width: 26%;
  height: 26%;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.5);
}

/* Percentage lefts, not pixels, so the same five bubbles sit correctly in the 232px rail and the
   72px icon rail with no per-breakpoint repositioning — paired with a half-width negative margin
   so the percentage places each bubble's *centre*, not its left edge. Without that the widest
   bubble at the far right hangs two-thirds outside the 72px rail and reads as a clipping bug
   rather than as a bubble brushing the wall. Delays are negative so every bubble is already
   somewhere on its climb at frame 0 — a positive delay would leave the rail empty for the first
   several seconds after every navigation. */
.sidebar-bubbles .bubble:nth-child(1) { left: 14%; width: 22px; height: 22px; margin-left: -11px; animation-duration: 16s; animation-delay: 0s; }
.sidebar-bubbles .bubble:nth-child(2) { left: 32%; width: 13px; height: 13px; margin-left: -6.5px; animation-duration: 20s; animation-delay: -4s; }
.sidebar-bubbles .bubble:nth-child(3) { left: 50%; width: 26px; height: 26px; margin-left: -13px; animation-duration: 17.5s; animation-delay: -8s; }
.sidebar-bubbles .bubble:nth-child(4) { left: 68%; width: 16px; height: 16px; margin-left: -8px; animation-duration: 14.5s; animation-delay: -11s; }
.sidebar-bubbles .bubble:nth-child(5) { left: 84%; width: 19px; height: 19px; margin-left: -9.5px; animation-duration: 18.5s; animation-delay: -14s; }

/* Travel is expressed in vh so a bubble always clears the top of the rail whatever the viewport
   height, and the horizontal drift keeps them from reading as beads on a vertical wire.

   The vertical stops trace y ∝ t^1.3 rather than dividing the path evenly: with `linear` between
   keyframes, per-segment speed is the gap between stops, so this yields a steadily rising 8.8 →
   12.9 → 15.1 → 16.7 → 18.0 → 19.2 vh-per-tick — a bubble that accelerates as it climbs, the way
   one actually does as it expands, with each step small enough that the change reads as continuous
   rather than as a gear shift. Deliberately many stops for that reason; a coarser set would make
   the speed changes visible as jolts. */
@keyframes sidebar-bubble-rise {
  0%   { transform: translate(0, 0) scale(0.78); opacity: 0; }
  12%  { opacity: 0.5; }
  15%  { transform: translate(3px, -8.8vh) scale(0.83); }
  30%  { transform: translate(5px, -21.7vh) scale(0.88); }
  45%  { transform: translate(3px, -36.8vh) scale(0.92); }
  60%  { transform: translate(-1px, -53.5vh) scale(0.96); }
  75%  { transform: translate(-4px, -71.5vh) scale(1); }
  82%  { opacity: 0.5; }
  90%  { transform: translate(-3px, -90.7vh) scale(1.03); }
  100% { transform: translate(0, -104vh) scale(1.06); opacity: 0; }
}

.brand {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  gap: var(--s2);
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--brand-ridge);
  font-size: 30px;
}
.brand:hover { text-decoration: none; }

.sidebar-nav { display: flex; flex-direction: column; gap: 4px; }

/* The rail's links are split into two captioned groups — "You" and "Study" — so the account item
   leads the nav instead of trailing the destinations. Each group is its own `.sidebar-nav`, and
   `js/lib/account.js` resolves `.sidebar-nav` to the *first* one (You), appending its injected
   Profile/Sign in item after that group's caption; the Study group is static markup it never
   touches. Reordering the groups in the HTML therefore also moves where the injected item lands. */
.sidebar-groups { display: flex; flex-direction: column; gap: var(--s5); }
.sidebar-group-label {
  padding: 0 12px 6px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--faint);
}
.sidebar-item {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: 10px 12px;
  border-radius: var(--radius);
  border: 2px solid transparent;
  color: var(--muted);
  font-size: 16px;
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  transition: color var(--dur) var(--ease), background var(--dur) var(--ease),
              border-color var(--dur) var(--ease);
}
.sidebar-item:hover { color: var(--brand-text); background: var(--brand-faint); text-decoration: none; }
.sidebar-item[aria-current="page"] {
  color: var(--brand-text);
  background: var(--brand-faint);
  border-color: var(--brand-dim);
}
.sidebar-item-icon {
  flex: none;
  width: 22px;
  text-align: center;
  font-size: 16px;
  line-height: 1;
}
.sidebar-item-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* `.sidebar` is a fixed, full-height (`inset: 0 auto 0 0`) flex column, unlike `.sidebar-nav`
   which is sized to its content — so unlike the abandoned `.sidebar-spacer`, `margin-top: auto`
   here has free space to actually push into. Reset button-only defaults `.sidebar-item` (an <a>
   elsewhere) never had to care about. */
.sidebar-signout {
  margin-top: auto;
  width: 100%;
  background: none;
  appearance: none;
  cursor: pointer;
  /* This button is the one nav item sitting on a brand-filled surface (the liquid pool), so the
     Ink-Not-White rule applies: --muted measures 3.95:1 against --liquid-deep and fails AA, while
     --brand-ink clears 10.4:1. The hover pair below is not redundant — `.sidebar-item:hover` is
     more specific than `.sidebar-signout`, so without it hover would drop the label back to
     --brand-text, which measures 3.28:1 on the pool. Feedback therefore comes from the liquid
     darkening under the pointer rather than from a colour change on the label. */
  color: var(--brand-ink);
}
.sidebar-signout:hover {
  color: var(--brand-ink);
  background: color-mix(in srgb, var(--brand) 22%, transparent);
}
.sidebar-signout:disabled { opacity: 0.5; cursor: default; }

/* ── Utility ───────────────────────────────────────────────────────── */

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

[hidden] { display: none !important; }

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

/* Icon-only rail: still a left sidebar, just narrow — labels hide, icons centre. */
@media (max-width: 900px) {
  :root { --sidebar-w: 72px; }
  .sidebar { align-items: center; padding-inline: 10px; }
  /* The pool and its bubbles survive here — they're anchored to the rail's bottom edge and
     positioned in percentages, so they reflow into 72px without any repositioning. */
  .brand { font-size: 13px; }
  .sidebar-item { justify-content: center; padding-inline: 0; width: 100%; position: relative; }
  /* Clipped, not `display: none`. The icon beside it is decorative and `aria-hidden`, so hiding
     the label the layout way would leave every item in this rail with no accessible name at all —
     a screen reader reads five unlabelled links, and the icon-only rail is exactly the breakpoint
     where the text is the only name there is. Clipping keeps it in the accessibility tree while
     taking no space. (Pre-existing: the rail hid these labels this way when the icons were "⌂ ▤ ✦"
     characters, which were `aria-hidden` too.) */
  .sidebar-item-label {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  /* Captions go with the labels: "STUDY" at 0.14em tracking does not fit a 72px rail, and with
     the labels gone the group gap alone already separates You from Study. */
  .sidebar-group-label { display: none; }
}

/* Collapse to a slim top bar: a left rail has no room to spare on a phone. --sidebar-w drops to
   0 and --header-h takes over. Both are consumed in CSS alone, so field.html's route needs no
   extra handling here — it inherits the same body insets as every other page. */
@media (max-width: 640px) {
  :root { --sidebar-w: 0px; --header-h: 56px; }
  .wrap { padding-inline: var(--s4); }

  .sidebar {
    inset: 0 0 auto 0;
    width: auto;
    height: var(--header-h);
    flex-direction: row;
    align-items: center;
    gap: var(--s4);
    padding-inline: var(--s4);
    overflow-x: auto;
    overflow-y: hidden;
    border-right: 0;
    border-bottom: 3px solid var(--brand);
  }
  /* The pool is anchored to the rail's bottom edge and is taller than this 56px bar, so here it
     would simply flood the whole top bar in violet instead of reading as liquid with a surface. */
  .sidebar-liquid, .sidebar-bubbles { display: none; }
  /* Graduations go with them: with no liquid to measure, ticks along a 56px bar read as hatching
     on a divider, not as a vessel. The wall itself stays, now as the bar's bottom edge. */
  .sidebar::after { display: none; }

  /* The bar is 56px of a single row, so the two groups lie side by side rather than stacked, and
     their captions stay hidden (inherited from the 900px rule above). The gap between groups is
     wider than the gap inside one, which is the only thing still marking the You/Study split. */
  .sidebar-groups { flex-direction: row; align-items: center; gap: var(--s4); }
  .sidebar-nav { flex-direction: row; gap: 4px; }
  .sidebar-item { padding: 8px 10px; }
  .sidebar-signout {
    margin-top: 0;
    margin-left: auto;
    width: auto;
    flex: none;
    /* No pool behind it at this breakpoint, so the label goes back to the nav's own colour. */
    color: var(--muted);
  }
  .sidebar-signout:hover { color: var(--brand-text); background: var(--brand-faint); }

  body { padding-top: var(--header-h); }
}
