/* site/app/tokens.css — the design system, as values.
 *
 * DESIGN.md is the reasoning; this file is the implementation. If the two ever disagree,
 * DESIGN.md is right and this file has drifted.
 *
 * Load order: this file first, then a screen's own CSS. Nothing else defines a colour, a
 * type size, an easing curve or a duration — a hex code written into a screen is a bug,
 * because it will not follow the palette into dark mode.
 */

/* ═══ 1 · COLOUR ═══════════════════════════════════════════════════════════
 * The brand palette. Navy and cream do the talking; orange and chartreuse are used the way
 * a coach uses a whistle — sparingly, and only to call attention.
 *
 * ⚠ THE FULL LIGHT PALETTE IS DEFINED ON BARE :root. Dark mode REDEFINES tokens; it never
 *   introduces a colour that has no light-mode value. A token whose only definition sits
 *   inside a media query is invisible in the other theme.
 */
:root {
  /* Brand constants — these five do not change between themes. They are the brand. */
  --navy:       #001F3E;
  --cream:      #F6F8ED;
  --orange:     #F58E03;
  --chartreuse: #DBE64C;
  --steel:      #7C8078;

  /* Tints, derived. Kept few on purpose: a palette with nine greys is a palette nobody
     can hold in their head, and every extra one is a decision at 200 call sites. */
  --navy-90:    #0A2A4A;
  --navy-70:    #2B4665;
  --cream-dim:  #E8EBDD;
  --steel-20:   rgba(124, 128, 120, 0.20);
  --steel-40:   rgba(124, 128, 120, 0.40);
  /* ⚠ A DARKENED STEEL, not the brand steel. Brand steel #7C8078 is a *material* colour —
     it is correct for a divider and for a disabled control, and it is wrong for a sentence.
     As text it measured 3.75 / 4.03 / 3.33 against --bg / --bg-raised / --bg-sunken: below
     the 4.5 floor on every reading surface in the app, which is why three separate reviewers
     independently reported "the secondary text is unreadable in daylight" on three different
     screens. Same hue family, ten points darker. */
  --steel-ink:  #626861;

  /* ── Semantic roles. Screens use THESE, never the brand constants above. ── */
  --bg:            var(--cream);
  --bg-raised:     #FFFFFF;
  --bg-sunken:     var(--cream-dim);
  --ink:           var(--navy);
  --ink-secondary: #40566E;
  /* Measured against --bg / --bg-raised / --bg-sunken: 5.33 / 5.72 / 4.73. Still clearly
     third in the hierarchy — --ink is 15.46 and --ink-secondary is 7.05 on --bg — so the
     three levels are still three levels; they are just all readable now. */
  --ink-tertiary:  var(--steel-ink);
  --rule:          var(--steel-20);

  /* ⚠ INK FOR A FIXED-NAVY SURFACE, which does NOT flip with the theme. A navy identity card
     (the crest, the court board, the scorekeeper) is navy in both themes on purpose, so ink
     on it cannot come from --ink — that would turn cream in light mode and vanish. */
  --on-navy:       var(--cream);
  --on-navy-dim:   #9FB3C6;   /* 7.24 on --navy */

  /* ⚠ ONE ACTION COLOUR. Orange is the thing you are meant to press, and nothing else.
     Two orange things on a screen means one of them is wrong. */
  --action:        var(--orange);
  --action-ink:    #2A1800;
  --action-press:  #D97C02;

  /* ⚠ ATTENTION IS NOT ACTION, AND THIS IS WHY IT IS NOT ORANGE.
     Two separate failures were being papered over by using --action for status:
       1. Semantic. Orange means "press this." When it also means "look at this", a player
          cannot tell a button from a label without stopping to read it. DESIGN.md §2: "if
          two things on a screen are orange, one of them is wrong."
       2. Measurable. --action is a FILL that carries --action-ink (7.12); it is not ink.
          #F58E03 as text measures 2.24 / 2.40 / 1.98 on --bg / --bg-raised / --bg-sunken.
          Every orange word on a pale ground in this app is below half the floor — which is
          exactly why "2 LEFT" and "CART · NOT PAID" came out as the *least* readable text on
          the checkout screen, the opposite of what a payment-first flow needs.
     --attn is the ink to reach for when a status has to pull the eye and is not a button.
     Light 6.34 / 6.80 / 5.62 · dark 10.56 / 9.26 / 11.39, on those same three grounds. */
  --attn:          #8A4B00;
  --attn-bg:       #FBEAD1;   /* the chip form of the same idea */
  --attn-ink:      #7A4300;   /* ⚠ pairs with --attn-bg ONLY (6.76). Not a page ink. */

  /* The quieter option, and the right default: a status that is merely true rather than
     urgent gets a neutral tag, not a colour. Most of what is orange today belongs here. */
  --tag-bg:        #DFE5EC;
  --tag-ink:       var(--ink);   /* 13.08 on --tag-bg */

  /* ⚠ CHARTREUSE IS ENERGY, NOT STATUS. It marks a live rally, a ball, a thing happening
     now. It is never "success" — it reads as a highlighter, not as a tick. */
  --live:          var(--chartreuse);
  --live-ink:      var(--navy);

  /* ⚠ A SELECTED THING NEEDS A SURFACE THAT FLIPS. Screens were painting a picked court slot
     with raw --navy, which is correct on cream and invisible on a navy page — in dark mode
     the slot you chose read as a hole in the grid and the ones you did not choose looked
     picked. A raw brand colour is never a surface: the brand does not know what the page
     behind it is. Use these instead; they invert with the theme.
     ⚠ And keep the WORD. A tick that *replaces* "Picked" leaves the state with no word at
     all, which fails DESIGN.md §2 on its own, before any contrast question. */
  --selected-bg:       var(--navy);    /* the strong, filled form */
  --selected-ink:      var(--cream);   /* 15.46 on --selected-bg, both themes */
  --selected-soft-bg:  #D3DEEA;        /* the chip form — a day pill, a slot */
  --selected-soft-ink: var(--navy);    /* 12.17 light · 8.85 dark on --selected-soft-bg */
  --selected-edge:     var(--navy);    /* ⚠ the soft fill alone is 1.27 against the page;
                                          it needs this border to survive sunlight */

  --ok:            #166B3E;   /* was #1B7F4B — 4.15 on --bg-sunken. Now 6.10 / 6.55 / 5.41 */
  /* ⚠ The two sentences the spec is most insistent about are both --warn: "NOT RESERVED" on
     the payment sheet and "Partner required — you cannot join this one alone". The old
     #9A6300 measured 4.71 / 5.05 / 4.17 — under the floor on --bg-sunken and inside the
     margin of error on --bg, at caption size, in daylight. Now 6.02 / 6.46 / 5.34. */
  --warn:          #845400;
  --danger:        #A32020;

  /* ⚠ THE FOCUS RING IS THEME-AWARE, and it is not the action colour. Orange at 2.24 on
     cream is not a visible ring, and a keyboard user has nothing else to go on. Navy in
     light, orange in dark — each is the one that reads against its own ground. */
  --focus:         var(--navy);

  /* Translucent chrome. See DESIGN.md §5. */
  --chrome:        rgba(246, 248, 237, 0.72);
  --chrome-blur:   blur(20px) saturate(180%);
  --chrome-edge:   rgba(255, 255, 255, 0.55);

  --shadow-1: 0 1px 2px rgba(0, 31, 62, 0.06), 0 1px 3px rgba(0, 31, 62, 0.08);
  --shadow-2: 0 4px 12px rgba(0, 31, 62, 0.10), 0 2px 4px rgba(0, 31, 62, 0.06);
  --shadow-3: 0 12px 32px rgba(0, 31, 62, 0.16), 0 4px 8px rgba(0, 31, 62, 0.08);

  color-scheme: light dark;
}

/* Dark is the natural expression here, not a chore: navy is already the foundation, and the
 * app is used courtside at night. Guarded with :not([data-theme="light"]) so an explicit
 * user choice still wins in both directions. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg:            var(--navy);
    --bg-raised:     var(--navy-90);
    --bg-sunken:     #001830;
    --ink:           var(--cream);
    --ink-secondary: #A9B8C6;
    /* Was #7C8B99 — 4.17 on --bg-raised, i.e. it failed on the card surface most of the
       app's secondary text actually sits on. Now 6.42 / 5.63 / 6.92. */
    --ink-tertiary:  #93A3B2;
    --rule:          rgba(246, 248, 237, 0.14);

    --action-ink:    #2A1800;
    --chrome:        rgba(0, 31, 62, 0.72);
    --chrome-edge:   rgba(255, 255, 255, 0.10);

    --attn:          #FFC46B;
    --attn-bg:       #3A2A0E;
    --attn-ink:      #FFC46B;   /* 8.80 on --attn-bg */
    --tag-bg:        #143253;   /* --tag-ink is --ink (cream) — 12.15 */

    /* ⚠ THE INVERSION. In dark the page is already navy, so the filled selected surface has
       to become the cream one or the picked state disappears into the page. */
    --selected-bg:       var(--cream);
    --selected-ink:      var(--navy);
    --selected-soft-bg:  #14477A;
    --selected-soft-ink: var(--cream);
    --selected-edge:     var(--cream);

    --focus:         var(--orange);   /* 6.92 on --bg; navy would be invisible here */

    --ok:            #4ADE80;
    --warn:          #FBBF24;   /* 9.94 / 8.72 / 10.72 — already correct, left alone */
    --danger:        #F87171;

    --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.30);
    --shadow-2: 0 4px 12px rgba(0, 0, 0, 0.38);
    --shadow-3: 0 12px 32px rgba(0, 0, 0, 0.48);
  }
}

/* ⚠ THIS BLOCK MUST MIRROR THE ONE ABOVE, VALUE FOR VALUE. It exists so an explicit user
   choice beats the OS preference, which means it is the same palette written twice — and a
   token added to only one of them is a token that works until someone touches the theme
   toggle, then silently reverts to its light value on a navy page. Add to both, always. */
:root[data-theme="dark"] {
  --bg: var(--navy);            --bg-raised: var(--navy-90);   --bg-sunken: #001830;
  --ink: var(--cream);          --ink-secondary: #A9B8C6;      --ink-tertiary: #93A3B2;
  --rule: rgba(246, 248, 237, 0.14);
  --chrome: rgba(0, 31, 62, 0.72);  --chrome-edge: rgba(255, 255, 255, 0.10);
  --attn: #FFC46B;  --attn-bg: #3A2A0E;  --attn-ink: #FFC46B;  --tag-bg: #143253;
  --selected-bg: var(--cream);      --selected-ink: var(--navy);
  --selected-soft-bg: #14477A;      --selected-soft-ink: var(--cream);
  --selected-edge: var(--cream);
  --focus: var(--orange);
  --ok: #4ADE80;  --warn: #FBBF24;  --danger: #F87171;
  --shadow-1: 0 1px 2px rgba(0,0,0,.30);
  --shadow-2: 0 4px 12px rgba(0,0,0,.38);
  --shadow-3: 0 12px 32px rgba(0,0,0,.48);
}

/* ═══ 2 · TYPE ═════════════════════════════════════════════════════════════
 * ⚠ SELF-HOSTED, NOT A FONT CDN. site/_headers has no font-src for fonts.googleapis.com,
 *   deliberately: a third-party font host watches every player load every page. Drop the
 *   WOFF2 files under /assets/ with content-hashed names so they ride the immutable cache
 *   rule, and add the @font-face blocks here.
 */
:root {
  /* "Fraunces Fallback" is Georgia at 92% — see the block at the foot of fonts.css. It sits
     ahead of the plain names so the swap does not relayout the page. */
  --font-display: "Fraunces", "Fraunces Fallback", Georgia, "Times New Roman", serif;
  --font-ui: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, "SF Mono", Menlo, monospace;

  /* ⚠ TRACKING IS SIZE-SPECIFIC. One letter-spacing for every size is wrong somewhere:
     large text reads too loose as it grows, small text too tight. Leading moves the other
     way — tight on display, generous on body. */
  --display-size: clamp(2.5rem, 8vw, 4rem);
  --display-lh: 1.05;   --display-track: -0.022em;
  --h1-size: 2.5rem;    --h1-lh: 1.1;   --h1-track: -0.018em;
  --h2-size: 1.75rem;   --h2-lh: 1.2;   --h2-track: -0.012em;
  --h3-size: 1.25rem;   --h3-lh: 1.3;   --h3-track: -0.006em;
  --body-size: 1.0625rem; /* 17px — the iOS reading size; the brand guide asks for 16–17 */
  --body-lh: 1.5;       --body-track: 0;
  --caption-size: 0.8125rem; --caption-lh: 1.4; --caption-track: 0.01em;
}

/* ═══ 3 · SPACE ════════════════════════════════════════════════════════════
 * In rem, never px, so the user's text-size setting expands the layout WITH the text
 * instead of breaking it.
 */
:root {
  --s-1: 0.25rem;  --s-2: 0.5rem;   --s-3: 0.75rem;  --s-4: 1rem;
  --s-5: 1.5rem;   --s-6: 2rem;     --s-7: 3rem;     --s-8: 4rem;

  --r-sm: 8px;  --r-md: 12px;  --r-lg: 16px;  --r-xl: 22px;  --r-full: 999px;

  /* ⚠ 44px is the floor, not a target. A control smaller than this is missed by a thumb
     on a phone held one-handed at a venue — which is every use of this app. */
  --tap-min: 44px;

  --safe-top: env(safe-area-inset-top, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
  --safe-right: env(safe-area-inset-right, 0px);

  /* ⚠⚠ THE BAND THE BROWSER'S OWN FLOATING BAR SITS IN, AND env() DOES NOT REPORT IT.
     Safari 26 on a modern iPhone hangs its address bar over the page as a floating glass
     capsule. `viewport-fit=cover` is set, and I checked what the browser actually says on an
     iPhone 17 running iOS 26.5: safe-area-inset-bottom is **0px**. The band is real and
     nothing in env() knows about it — measured on the same device, 100lvh is 754 and 100dvh
     is 714, so the bar is the 40px between them.
     That difference IS the number, live: it is 40px while the bar is showing, 0 when it is
     collapsed away, and 0 in a standalone home-screen app where there is no bar at all — at
     which point --safe-bottom's home-indicator inset takes over, which is why this is a max()
     of the two rather than a sum. Adding them would push the nav 74px up a home screen.
     ⚠ BEHIND @supports, BECAUSE A BROWSER WITHOUT lvh MUST NOT LOSE THE VALUE ENTIRELY. A
       custom property accepts any token stream at parse time and only fails where it is
       SUBSTITUTED — so on Safari 15 `bottom: calc(14px + var(--bottom-inset))` would become
       invalid at computed-value time and reset `bottom` to auto, dropping the tab bar into
       the middle of the page. The plain declaration below is the floor; the query raises it. */
  --bottom-inset: var(--safe-bottom);
}
/* ⚠ EVERY "LEAVE ROOM AT THE BOTTOM OF THE SCREEN" NUMBER IN THE APP READS --bottom-inset
   RATHER THAN --safe-bottom NOW: the tab bar, the live pill, the scrollports, the sheet
   bodies, the sign-in page. --safe-bottom survives and still means exactly what it says — the
   hardware's own inset — and is the floor this is built on. The two were the same value until
   Safari started floating a bar over the page, and the places that meant "clear the bottom
   edge" were all silently 40px short. */
@supports (height: 100lvh) {
  :root { --bottom-inset: max(var(--safe-bottom), calc(100lvh - 100dvh)); }
}

/* ═══ 4 · MOTION ═══════════════════════════════════════════════════════════
 * ⚠ NEVER `ease-in` ON A UI ELEMENT. It delays the initial movement — the exact moment the
 *   user is watching most closely — so a 300ms ease-in dropdown FEELS slower than a 300ms
 *   ease-out one at the identical duration.
 *
 * ⚠ The built-in CSS easings are too weak; these are the stronger variants. One family for
 *   the whole app: two visual languages are paid for by the reader on every screen.
 */
:root {
  --ease-out:    cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);

  --t-press:    120ms;   /* button feedback  */
  --t-tooltip:  160ms;   /* tooltips, chips  */
  --t-menu:     200ms;   /* dropdowns        */
  --t-sheet:    320ms;   /* sheets, modals   */
  --t-exit:     160ms;   /* ⚠ exit is always faster than enter */

  /* Spring parameters for JS-driven, gesture-owned motion. Apple's damping/response, not
     mass/stiffness/damping — two numbers a designer can reason about.
     ⚠ Default to damping 1.0 (no overshoot). Bounce ONLY when the gesture itself carried
       momentum: overshoot on a menu that merely faded in feels wrong; overshoot on a card
       you flicked feels right. */
  --spring-damping: 1.0;    --spring-response: 0.4;
  --spring-sheet-damping: 0.8;  --spring-sheet-response: 0.3;
}

/* ═══ 5 · BASE ═════════════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }

html {
  -webkit-text-size-adjust: 100%;
  /* Momentum scrolling, and no rubber-band on the document itself — the app supplies its
     own boundaries so the page does not bounce behind a sheet. */
  overscroll-behavior-y: none;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: var(--body-size);
  line-height: var(--body-lh);
  letter-spacing: var(--body-track);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

h1, h2, h3 { font-family: var(--font-display); font-weight: 600; margin: 0; }
h1 { font-size: var(--h1-size); line-height: var(--h1-lh); letter-spacing: var(--h1-track); }
h2 { font-size: var(--h2-size); line-height: var(--h2-lh); letter-spacing: var(--h2-track); }
h3 { font-family: var(--font-ui); font-size: var(--h3-size); line-height: var(--h3-lh);
     letter-spacing: var(--h3-track); }

.display {
  font-family: var(--font-display); font-weight: 600;
  font-size: var(--display-size); line-height: var(--display-lh);
  letter-spacing: var(--display-track); font-optical-sizing: auto;
}

/* Kickers and quotes. Serif italic at SMALL sizes only — never a serif italic paragraph. */
.kicker {
  font-family: var(--font-display); font-style: italic; font-size: var(--caption-size);
  letter-spacing: var(--caption-track); color: var(--ink-secondary);
}

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

/* ⚠ The focus ring is never removed, only restyled. It is the only affordance a keyboard
   or switch-control user has, and this app has a public sign-up — assume every input
   method arrives. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
  border-radius: var(--r-sm);
}

/* ═══ 6 · PRESSABLES ═══════════════════════════════════════════════════════
 * ⚠ FEEDBACK ON POINTER-DOWN, NOT ON RELEASE. Waiting for click to show a response feels
 *   dead; the moment lag appears, the sense of directness falls off a cliff.
 */
.pressable {
  touch-action: manipulation;              /* removes the legacy ~300ms tap delay */
  -webkit-tap-highlight-color: transparent; /* we draw our own, instantly */
  min-height: var(--tap-min);
  min-width: var(--tap-min);
  transition: transform var(--t-press) var(--ease-out),
              background-color var(--t-press) var(--ease-out);
}
.pressable:active { transform: scale(0.97); }

/* ⚠ THE TAP TARGET IS NOT THE PAINTED BOX, and conflating the two is how a control ends up
   the right size on screen and the wrong size under a thumb. The ones measured short were a
   30px segmented switcher, a 34px close button, a 42px Undo the scorekeeper leans on all
   night — and worst, a 39px Cancel in the payment sheet, which is the only way out of a flow
   with a five-minute clock running.
   Growing the control to 44px would wreck its proportions, so grow the *hit area* instead:
   an invisible centred overlay that carries the floor. This is the pattern the Cafe +/−
   steppers already use; it is here so nothing has to reinvent it.
   Needs `position: relative` (set below) and an ancestor that does not clip it. */
.tap-min { position: relative; }
.tap-min::after {
  content: ""; position: absolute;
  top: 50%; left: 50%; transform: translate(-50%, -50%);
  width: 100%; height: 100%;
  min-width: var(--tap-min); min-height: var(--tap-min);
}

/* ⚠ Hover is gated. A touch device fires hover on tap, so an ungated :hover leaves a
   button looking stuck in a hover state after the finger lifts. */
@media (hover: hover) and (pointer: fine) {
  .pressable:hover { background-color: var(--bg-sunken); }
}

.btn-primary {
  background: var(--action); color: var(--action-ink);
  font-family: var(--font-ui); font-weight: 600; font-size: var(--body-size);
  border: 0; border-radius: var(--r-full);
  padding: var(--s-3) var(--s-5);
}
.btn-primary:active { background: var(--action-press); }

/* ═══ 7 · CHROME ═══════════════════════════════════════════════════════════
 * A floating translucent layer with content scrolling underneath — not an opaque strip that
 * eats a fixed band of a phone screen.
 */
.chrome {
  background: var(--chrome);
  backdrop-filter: var(--chrome-blur);
  -webkit-backdrop-filter: var(--chrome-blur);
  border-bottom: 1px solid var(--chrome-edge);
  padding-top: var(--safe-top);
}
.chrome-bottom {
  background: var(--chrome);
  backdrop-filter: var(--chrome-blur);
  -webkit-backdrop-filter: var(--chrome-blur);
  border-top: 1px solid var(--chrome-edge);
  padding-bottom: var(--bottom-inset);
}

/* ⚠ NEVER STACK A LIGHT TRANSLUCENT SURFACE ON ANOTHER — legibility collapses. A sheet on
   top of translucent chrome gets a solid ground. */
.sheet { background: var(--bg-raised); box-shadow: var(--shadow-3);
         border-radius: var(--r-xl) var(--r-xl) 0 0; }

/* ═══ 8 · ENTRANCES ════════════════════════════════════════════════════════
 * ⚠ NOTHING ENTERS FROM scale(0). Nothing in the real world appears from nothing; an
 *   element that grows from zero reads as arriving out of nowhere. 0.95 and up.
 * ⚠ Transitions, not @keyframes: a transition can be interrupted and retargeted mid-flight,
 *   a keyframe restarts from zero. Everything here can be triggered rapidly.
 */
.enter {
  opacity: 1; transform: scale(1) translateY(0);
  transition: opacity var(--t-menu) var(--ease-out),
              transform var(--t-menu) var(--ease-out);
}
@starting-style { .enter { opacity: 0; transform: scale(0.96) translateY(4px); } }

/* ── Paging one container's contents ───────────────────────────────────────
   The pair that motion.js's swapHeight() drives: `.swap-out` is the layer the OUTGOING
   content is lifted into, `.swap-in` is what the caller puts on the incoming content.

   ⚠ THE OUTGOING LAYER IS ABSOLUTE, AND THAT IS LOAD-BEARING RATHER THAN COSMETIC. It is what
     lets the new content lay out at its final internal positions on the first frame, so the
     container's height is the only thing that moves. See the long note in motion.js.
   ⚠ ENTER AND EXIT ALONG THE SAME PATH. Next month arrives from the right and the old one
     leaves to the left; back is the mirror. A panel that comes in one way and goes out another
     reads as two unrelated screens crossing, and it destroys the sense that you are moving
     along a line of months.
   ⚠ EXIT IS FASTER THAN ENTER (--t-exit against 280ms). The thing you are leaving should be
     gone before the thing you asked for has finished arriving, or the two overlap as a smear.
   ⚠ 14px, NOT A SCREEN WIDTH. This is a container changing its contents, not a screen being
     pushed — the cafe's view push travels 12px for the same reason. A full-width slide here
     would claim the whole page had navigated. */
.swap-out {
  position: absolute; left: 0; right: 0; top: 0;
  opacity: 0; pointer-events: none;
  transition: opacity var(--t-exit) var(--ease-out), translate var(--t-exit) var(--ease-out);
}
@starting-style { .swap-out { opacity: 1; translate: 0 0; } }
.swap-out.to-left  { translate: -14px 0; }
.swap-out.to-right { translate: 14px 0; }

.swap-in {
  opacity: 1; translate: 0 0;
  transition: opacity 280ms var(--ease-out), translate 320ms var(--ease-out);
}
@starting-style { .swap-in { opacity: 0; } }
@starting-style { .swap-in.from-right { translate: 14px 0; } }
@starting-style { .swap-in.from-left  { translate: -14px 0; } }

/* Popovers scale from their TRIGGER, not their centre — the spatial relationship between
   the button and what it opened is the whole point. Modals are the exception and stay
   centred, because they are anchored to nothing. */
.popover { transform-origin: var(--pop-origin, center); }

/* ═══ 9 · ACCESSIBILITY ════════════════════════════════════════════════════
 * Reduced motion is a GENTLER equivalent, not nothing. Opacity and colour changes that aid
 * comprehension stay; movement, parallax and overshoot go.
 */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 160ms !important;
    scroll-behavior: auto !important;
  }
  .pressable:active { transform: none; background-color: var(--bg-sunken); }
  .enter { transition-property: opacity; }
  /* swapHeight() already skips the height spring and the outgoing layer entirely under this
     setting; this is the incoming half, which the CALLER applies and which would otherwise
     still travel 14px sideways. */
  .swap-in { transition-property: opacity; translate: none; }
  @starting-style { .enter { opacity: 0; transform: none; } }
}

@media (prefers-reduced-transparency: reduce) {
  .chrome, .chrome-bottom {
    background: var(--bg);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }
}

@media (prefers-contrast: more) {
  :root { --rule: var(--ink); --ink-secondary: var(--ink); --ink-tertiary: var(--ink); }
  .chrome, .chrome-bottom { background: var(--bg); backdrop-filter: none; }
  .btn-primary { outline: 2px solid var(--ink); }
}

/* ═══ 9 · THE THEME CHANGE ITSELF ═══════════════════════════════════════════
 * ⚠⚠ A ONE-SHOT CLASS, ADDED FOR 340ms BY profile.js AND THEN TAKEN OFF. This is the only
 *   moment in the app where every colour on screen changes at once, and cut hard it is a
 *   flash — at night, on a phone, that is the difference between a switch and a strobe.
 *   Apple's guidance names it directly: ease dark↔light changes, do not jump the brightness.
 * ⚠ IT IS TIME-BOXED FOR A REASON, AND THE REASON IS EVERY OTHER INTERACTION. Left on
 *   permanently, this universal transition would put a 300ms lag on every hover, every press
 *   highlight and every selected state in the app — a global slow-motion filter, paid on
 *   thousands of interactions to smooth exactly one.
 * ⚠ AND IT IS KEPT UNDER prefers-reduced-motion. A colour cross-fade involves no movement of
 *   anything; removing it would leave that viewer with the abrupt brightness jump, which is
 *   the very thing the reduced-motion guidance asks to avoid.
 */
html.theme-x, html.theme-x *, html.theme-x *::before, html.theme-x *::after {
  transition-property: background-color, color, border-color, fill, stroke, box-shadow;
  transition-duration: 300ms;
  transition-timing-function: var(--ease-out);
}
