/* Titan Vaults — brand theme, dark and light.

   Palette from the Titan media kit: lime #D0F500 on the brand ink #121223.
   Two token layers make the themes swap cleanly:

     --accent       text/border accent. Lime reads well on the dark surface but
                    is illegible on white, so light mode moves it to brand violet.
     --accent-fill  the vivid lime itself, only ever used as a *background*
                    (buttons, badges, chips) with --on-accent for the text on it.

   Surfaces stay translucent over a layered gradient backdrop; the backdrop is
   drawn by two fixed pseudo-elements on <body> so it never scrolls with content
   and never sits on top of anything.

   No inline styles anywhere: the CSP allows stylesheets from 'self' only. */

/* Syne ships in the media kit as a variable TTF; woff2 is the same file, ~60% smaller. */
@font-face {
  font-family: "Syne";
  src: url("/static/fonts/Syne.woff2") format("woff2"),
       url("/static/fonts/Syne.ttf") format("truetype");
  font-weight: 400 800;
  font-display: swap;
}

/* Bricolage Grotesque — the display face for the languages Syne cannot set.
   Self-hosted because the CSP allows no external origin, and split into the
   same three subsets Google serves so a page in English never downloads the
   Vietnamese glyphs it will not draw. SIL Open Font License 1.1, © 2022 The
   Bricolage Grotesque Project Authors. */
@font-face {
  font-family: "Bricolage Grotesque";
  src: url("/static/fonts/bricolage-vietnamese.woff2") format("woff2");
  font-weight: 200 800;
  font-display: swap;
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: "Bricolage Grotesque";
  src: url("/static/fonts/bricolage-latin-ext.woff2") format("woff2");
  font-weight: 200 800;
  font-display: swap;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: "Bricolage Grotesque";
  src: url("/static/fonts/bricolage-latin.woff2") format("woff2");
  font-weight: 200 800;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* Tells the browser which way its own furniture should go: scrollbars, form
     controls, the caret, the spellcheck underline. Without it a dark page keeps
     a bright white scrollbar down its right-hand edge. */
  color-scheme: dark;

  --lime: #d0f500;
  --ink: #121223;
  --violet: #7c4dff;

  --accent: #d0f500;
  --accent-soft: rgba(208, 245, 0, 0.14);
  --accent-line: rgba(208, 245, 0, 0.35);
  --accent-fill: #d0f500;
  --on-accent: #121223;

  --bg: #0b0819;
  --panel: rgba(22, 16, 48, 0.88);
  /* Opaque twin of --panel, for surfaces that float over the page and must not
     let it read through — the phone's language sheet. */
  --panel-solid: #170f31;
  --panel-2: rgba(38, 27, 82, 0.96);
  --head-bg: rgba(17, 12, 42, 0.96);
  --bar-bg: rgba(13, 10, 30, 0.78);
  --line: rgba(167, 139, 250, 0.18);
  --line-soft: rgba(167, 139, 250, 0.11);
  --field: rgba(18, 18, 35, 0.6);

  --text: #f3effe;
  --muted: #a99ed6;
  --muted-2: #7b6fae;

  --up: #d0f500;
  --down: #ff5c7a;
  --warn: #ffb457;
  /* Tinted backings for the state tags — the same three colours at the weight a
     pill needs behind text rather than as text. */
  --ok-soft: rgba(208, 245, 0, 0.14);
  --warn-soft: rgba(255, 180, 87, 0.16);
  --bad-soft: rgba(255, 92, 122, 0.14);
  /* The hairline that goes with --bad-soft. The sign-in page's error banner
     used to carry this colour written out, which meant it kept the dark
     theme's pink on the light page. */
  --bad-line: rgba(255, 92, 122, 0.30);
  /* Tag text sits on its own tint, which eats a little contrast. On the dark
     surface the state colours still clear AA, so they are used as they are. */
  --ok-ink: var(--up);
  --warn-ink: var(--warn);
  --bad-ink: var(--down);
  /* The resting badge is deliberately colourless, which leaves its legibility
     resting entirely on this one value. `--muted` measured 7.52:1 on the dark
     theme and 6.35:1 on the light — so the dark one was never the weaker of the
     two on paper, and yet it read that way: violet-grey text on a violet-tinted
     chip goes soft in a way the ratio does not capture, and the chip itself all
     but vanished into the panel. A dedicated step per theme (10.73:1 dark,
     8.03:1 light, measured over the real composited backing) plus a hairline
     border fixes both halves of that. */
  --tag-ink: #c9c0e8;

  /* Exchange colours, one per theme. The logo is the brand's own mark and is
     used exactly as supplied; a bar segment is a *chart* colour, which has a
     second job the logo does not — being legible on this surface and telling
     itself apart from the segment beside it. Bitget's cyan measures 1.39:1 on
     the light panel, i.e. invisible, so each hue is kept and only its lightness
     moves (at most 0.22, or it stops looking like the exchange). Measured: at
     least 2.6:1 against the panel and 2.3:1 between any two segments, so they
     separate even in greyscale — and the legend names each one in words
     regardless, because colour should never be the only channel. */
  --ex-bitget: #70f7ff;
  --ex-gate: #1948d7;
  --ex-kucoin: #28a986;

  --glow-a: rgba(124, 77, 255, 0.55);
  --glow-b: rgba(208, 245, 0, 0.18);
  --glow-c: rgba(124, 77, 255, 0.34);
  --grid: rgba(167, 139, 250, 0.06);
  --ornament: 0.05;
  --shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
  /* The halo under the sign-in button. On the dark page the lime can bloom; on
     the light one the same colour over near-white reads as a smudge rather than
     a shadow, so that theme drops the lightness and keeps the hue. */
  --accent-glow: rgba(208, 245, 0, 0.45);
  /* The sign-in page's horizon: the accent, held back. It is a two-pixel line
     across the whole window, and at full strength that much of it competes with
     the button — which is the one thing on the page that has to be found. The
     alpha is what turns the lime into the dim gold of a lit edge, because what
     it is drawn over is the dark ground rather than the page. */
  --horizon-line: rgba(208, 245, 0, 0.62);

  --radius: 14px;
  /* Every control that can sit next to another one is this tall. The search
     box and the exchange filter were 38px and 32px, which read as a mistake
     because it was one. */
  --control-h: 38px;
  /* Syne (the brand face) carries no CJK glyphs, so Japanese falls through to
     the next family per character — that is normal, but the CJK families have
     to be named or some systems render tofu. Order: macOS, Windows, Linux. */
  --cjk: "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Apple SD Gothic Neo",
         "Yu Gothic", "Yu Gothic UI", Meiryo, "Malgun Gothic", NanumGothic,
         "PingFang SC", "Microsoft YaHei", "Noto Sans JP", "Noto Sans KR",
         "Noto Sans SC", "Noto Sans CJK JP", "Droid Sans Fallback";
  --display: "Syne", -apple-system, BlinkMacSystemFont, "Segoe UI", var(--cjk), sans-serif;
  --body: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
          var(--cjk), sans-serif;
}

:root[data-theme="light"] {
  color-scheme: light;
  /* Lime on white fails every contrast check, so the *text* accent becomes the
     brand violet; lime survives as fills, where ink sits on top of it. */
  --accent: #5b2ee8;
  --accent-soft: rgba(91, 46, 232, 0.10);
  --accent-line: rgba(91, 46, 232, 0.30);
  --accent-fill: #d0f500;
  --on-accent: #121223;

  --bg: #f4f2fb;
  --panel: rgba(255, 255, 255, 0.88);
  --panel-solid: #fbfaff;
  --panel-2: #ffffff;
  --head-bg: rgba(248, 247, 253, 0.97);
  --bar-bg: rgba(248, 247, 253, 0.82);
  --line: rgba(18, 18, 35, 0.14);
  --line-soft: rgba(18, 18, 35, 0.08);
  --field: rgba(18, 18, 35, 0.04);

  --text: #121223;
  --muted: #5a5673;
  --muted-2: #8b86a6;

  --up: #4f7a00;          /* same hue family as the brand lime, readable on white */
  --down: #d8305a;
  --warn: #b06f05;
  --ok-soft: rgba(79, 122, 0, 0.11);
  --warn-soft: rgba(176, 111, 5, 0.13);
  --bad-soft: rgba(216, 48, 90, 0.10);
  --bad-line: rgba(216, 48, 90, 0.28);
  /* On white the same three fall to 4.36 / 3.47 / 4.00:1 against their own
     tint — under AA for 12px text — so the tag uses a darker step. Measured
     over the real composited backing: 5.42 / 5.38 / 6.03:1. */
  --ok-ink: #456a00;
  --warn-ink: #875403;
  --bad-ink: #ad1740;
  --tag-ink: #4b4763;

  --ex-bitget: #00adb8;
  --ex-gate: #102f8e;
  --ex-kucoin: #1b735c;

  --glow-a: rgba(124, 77, 255, 0.30);
  --glow-b: rgba(208, 245, 0, 0.45);
  --glow-c: rgba(124, 77, 255, 0.20);
  --grid: rgba(18, 18, 35, 0.05);
  --ornament: 0.07;
  --shadow: 0 10px 30px rgba(24, 18, 56, 0.14);
  --accent-glow: rgba(150, 178, 0, 0.42);
  /* Violet rather than lime, for the reason --accent is: over a pale ground the
     lime hairline is not a dim gold, it is nothing at all. */
  --horizon-line: rgba(91, 46, 232, 0.55);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  position: relative;
  color: var(--text);
  background: var(--bg);
  font-family: var(--body);
  font-size: 14px;
  -webkit-font-smoothing: antialiased;
}

/* ---------- layered backdrop ----------
   Glows drift slowly on one layer; a static grid and vignette sit on the other.
   Both are fixed and behind everything, so scrolling never drags them and long
   tables never end up sitting on the brightest part of the gradient. */
body::before, body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
}
body::before {
  background:
    radial-gradient(46vmax 34vmax at 18% 8%, var(--glow-a), transparent 62%),
    radial-gradient(34vmax 26vmax at 88% 2%, var(--glow-b), transparent 64%),
    radial-gradient(40vmax 30vmax at 70% 62%, var(--glow-c), transparent 64%),
    radial-gradient(32vmax 26vmax at 4% 72%, var(--glow-b), transparent 66%),
    radial-gradient(36vmax 28vmax at 50% 108%, var(--glow-a), transparent 62%);
  /* The drift scales up 6% and shifts at most 1.5%, so an edge can never be
     exposed however far it wanders. */
  animation: drift 34s ease-in-out infinite alternate;
  will-change: transform;
}
body::after {
  background:
    linear-gradient(var(--grid) 1px, transparent 1px) 0 0 / 100% 68px,
    linear-gradient(90deg, var(--grid) 1px, transparent 1px) 0 0 / 68px 100%;
  /* The grid fades out toward the edges so it reads as texture, never as a
     border the eye tries to follow. */
  mask-image: radial-gradient(90vmax 80vmax at 50% 34%, #000 25%, transparent 82%);
  -webkit-mask-image: radial-gradient(90vmax 80vmax at 50% 34%, #000 25%, transparent 82%);
}
@keyframes drift {
  from { transform: translate3d(-1.5%, 0.8%, 0) scale(1.06); }
  to   { transform: translate3d(1.5%, -1.2%, 0) scale(1.1); }
}
@media (prefers-reduced-motion: reduce) {
  body::before { animation: none; }
}

a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; }

/* Syne is for names and titles only. The big figures stay in the interface font:
   they are read as data, and Syne's display shapes make long digit strings
   harder to scan at a glance. */
h1, h2, .brand, .hero-title { font-family: var(--display); }
/* Numbers use the same face as the text around them; `tabular-nums` keeps
   every digit the same width so columns still line up. Both Syne and the
   system UI fonts ship the `tnum` feature. */
.num { font-variant-numeric: tabular-nums; }
.up { color: var(--up); }
.down { color: var(--down); }
.muted { color: var(--muted); }
.dim { color: var(--muted-2); }

/* ---------- top bar ---------- */
.topbar {
  display: flex; align-items: center; gap: 26px;
  padding: 0 22px; height: 62px;
  border-bottom: 1px solid var(--line-soft);
  background: var(--bar-bg);
  backdrop-filter: blur(12px);
  position: sticky; top: 0; z-index: 20;
}
.brand { display: flex; align-items: center; gap: 10px; }
/* The lockup stacks the Titan wordmark over VAULTS, so height buys two lines
   rather than one: at the 26px the single-line mark used to take, the second
   line lands at 5.7px and stops being readable. 38px puts VAULTS at an 8px cap
   inside a 62px bar, and the whole mark is only 83px wide — narrower than the
   94px the old wordmark needed. */
.brand img { height: 38px; display: block; }
/* One lockup per theme: the white one on dark, the ink one on light.
   Both selectors carry the :root qualifier so they outrank the more specific
   `.login-card img.logo` display rule. */
:root:not([data-theme="light"]) .logo-on-light { display: none; }
:root[data-theme="light"] .logo-on-dark { display: none; }
.brand-tag {
  font-size: 11px; letter-spacing: 1.4px; text-transform: uppercase;
  color: var(--accent); border: 1px solid var(--accent-line);
  border-radius: 999px; padding: 3px 9px;
}
.nav { display: flex; gap: 22px; color: var(--muted); font-size: 14px; }
.nav a:hover, .nav a.on { color: var(--accent); }
/* The clock leads the right-hand cluster and carries the auto margin, so on a
   phone it can drop onto the nav row instead of forcing a third row. */
#clock { margin-left: auto; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.pill {
  border: 1px solid var(--line); border-radius: 999px;
  padding: 6px 13px; font-size: 12px; color: var(--muted);
  background: var(--field); font-variant-numeric: tabular-nums;
}
.pill.live { color: var(--accent); border-color: var(--accent-line); }
/* The clock keeps ticking either way; the colour is about the *data* behind it
   going quiet, which is a different failure from the page freezing. */
.pill.live.stale { color: var(--warn); border-color: rgba(255, 180, 87, 0.4); }
.btn-ghost {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  background: transparent; border: 1px solid var(--line); border-radius: 999px;
  padding: 0 15px; min-height: var(--control-h); font-size: 13px;
  color: var(--muted); cursor: pointer; white-space: nowrap;
  transition: color .15s, border-color .15s, background .15s;
}
.btn-ghost svg { flex: none; }
.btn-ghost:hover {
  color: var(--on-accent); background: var(--accent-fill); border-color: var(--accent-fill);
}
.btn-icon {
  width: var(--control-h); height: var(--control-h); padding: 0; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center; gap: 0;
}

/* ---------- layout ---------- */
.wrap { max-width: 1520px; margin: 0 auto; padding: 24px 22px 72px; }
.crumbs { color: var(--muted); font-size: 13px; margin-bottom: 10px; }
.crumbs a:hover { color: var(--accent); }
.page-head {
  position: relative; display: flex; align-items: flex-end;
  gap: 16px; flex-wrap: wrap; margin-bottom: 22px;
}
h1 { font-size: 38px; margin: 0; font-weight: 700; letter-spacing: -0.6px; }
.subtitle { color: var(--muted); font-size: 13px; margin-top: 8px; }
/* The brand mark, oversized and nearly invisible, fills the empty right half
   of the header band. */
.watermark {
  position: absolute; right: -8px; top: 50%; transform: translateY(-50%);
  width: 230px; height: 96px; opacity: var(--ornament); pointer-events: none;
  background: url("/static/img/symbol.svg") right center / contain no-repeat;
}
:root[data-theme="light"] .watermark { background-image: url("/static/img/symbol-dark.svg"); }
@media (max-width: 760px) { .watermark { display: none; } }

/* ---------- cards ---------- */
.stat-grid {
  display: grid; gap: 14px; margin-bottom: 18px;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
}
.card {
  background: var(--panel); border: 1px solid var(--line-soft);
  border-radius: var(--radius); padding: 18px 20px;
  backdrop-filter: blur(6px);
}
.card-label { color: var(--muted); font-size: 13px; margin-bottom: 10px; }
.card-value { font-size: 29px; font-weight: 700; letter-spacing: -0.5px; }
.card-sub { margin-top: 9px; font-size: 12px; color: var(--muted); }
.badge {
  display: inline-block; margin-left: 8px; padding: 3px 9px; border-radius: 999px;
  font-size: 11px; font-weight: 700; vertical-align: middle;
  background: var(--accent-soft); color: var(--accent);
  /* Same face and tabular figures as every other number on the page. */
  font-variant-numeric: tabular-nums;
}

/* ---------- two-column panel row ---------- */
.split { display: grid; grid-template-columns: minmax(320px, 5fr) minmax(430px, 7fr); gap: 14px; }
@media (max-width: 1050px) { .split { grid-template-columns: 1fr; } }

.panel {
  background: var(--panel); border: 1px solid var(--line-soft);
  border-radius: var(--radius); overflow: hidden; backdrop-filter: blur(6px);
}
.panel-tabs {
  display: flex; gap: 20px; padding: 0 18px;
  border-bottom: 1px solid var(--line-soft);
}
.panel-tabs button {
  background: none; border: 0; padding: 14px 0; cursor: pointer;
  color: var(--muted); font-size: 13px; border-bottom: 2px solid transparent;
}
.panel-tabs button.on { color: var(--accent); border-bottom-color: var(--accent); }
.panel-tabs .spacer { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.panel-body { padding: 18px 20px; }

.kv { display: grid; grid-template-columns: 150px 1fr; gap: 10px 14px; font-size: 13px; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.chip {
  border: 1px solid var(--line); border-radius: 999px; padding: 6px 12px;
  font-size: 12px; color: var(--muted); background: var(--field);
}
.chip:hover { color: var(--accent); border-color: var(--accent-line); }
.chip .num { color: var(--text); margin-left: 7px; }

/* ---------- chart ---------- */
/* ---------- picker ----------
   The one dropdown the whole app uses (see picker.js): a pill button plus a
   listbox — a popup under the button on a desktop, a sheet on the bottom edge
   of a phone. `--flags` is the language switch, which carries a circular flag
   per entry and shrinks to just that flag on a phone. `--block` fills the row
   it sits in, for the ones that replaced full-width selects. */
.picker { position: relative; }
.picker--block { display: block; min-width: 0; }
.picker--block .picker-btn {
  width: 100%; justify-content: space-between; padding: 6px 13px;
}
/* At least as wide as the control it belongs to, the way a native select
   behaves — but free to grow, because a translated option is often longer than
   the button and wrapping every line was worse than a wider list. */
.picker--block .picker-pop {
  left: 0; right: auto; min-width: 100%; width: max-content; max-width: min(320px, 88vw);
}
.picker-btn {
  display: flex; align-items: center; gap: 8px;
  background: var(--field); color: var(--text);
  border: 1px solid var(--line); border-radius: 999px;
  padding: 0 12px; min-height: var(--control-h); font-size: 13px; cursor: pointer;
}
.picker-btn:hover { border-color: var(--accent-line); }
.picker-btn:focus-visible, .picker-menu li:focus-visible {
  outline: 2px solid var(--accent-line); outline-offset: 1px;
}
/* A leading mark, for the pickers whose subject is not obvious from the value
   alone: the exchange filter reads "All exchanges", which could be anything. */
.picker-lead { display: flex; color: var(--muted-2); flex: none; }
.picker-lead:empty { display: none; }
/* The chosen label can be longer than the control (a translated column name);
   it truncates rather than stretching the row it sits in. */
.picker-current {
  display: flex; align-items: center; gap: 8px; min-width: 0;
}
.picker-current span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.picker .caret { color: var(--muted-2); display: flex; transform: rotate(90deg); }
.flag {
  width: 20px; height: 20px; border-radius: 50%; display: block; flex: none;
  box-shadow: 0 0 0 1px var(--line);
}
/* The popup is the container; the list inside it is only a list. They used to
   be the same element, which is why the title had to be smuggled in as an
   <li> — and then read as one of the choices. */
.picker-pop {
  position: absolute; right: 0; top: calc(100% + 8px); z-index: 30;
  min-width: 190px;
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 14px; box-shadow: var(--shadow);
  overflow: hidden;
}
.picker-menu {
  margin: 0; padding: 6px; list-style: none; max-height: 60vh; overflow-y: auto;
}
.picker-menu li {
  display: flex; align-items: center; gap: 12px;
  padding: 9px 12px; border-radius: 10px; cursor: pointer;
  font-size: 14px; color: var(--text);
}
.picker-menu li:hover { background: var(--accent-soft); }
.picker-menu li.on { color: var(--accent); }
/* The tick tells you which one is current without relying on colour alone. */
.picker-tick { margin-left: auto; display: flex; opacity: 0; flex: none; }
.picker-menu li.on .picker-tick { opacity: 1; }
/* Title and dimmer belong to the phone sheet only; on a desktop the popup sits
   under the button and needs neither. */
.picker-head, .picker-scrim { display: none; }
@keyframes sheet-up { from { transform: translateY(100%); } to { transform: none; } }
@media (prefers-reduced-motion: reduce) { .picker-pop { animation: none; } }
/* Names are written in their own script, so the picker needs the CJK stack even
   while the interface is in English. */
.picker, .picker-menu { font-family: var(--body); }
.chart-host { position: relative; height: 306px; padding: 8px 4px 0; }
.chart-host svg { display: block; width: 100%; height: 100%; }
.chart-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 10px; height: 100%; color: var(--muted-2); font-size: 13px;
}
.chart-empty img { width: 74px; opacity: 0.75; }
.tip {
  position: absolute; pointer-events: none; z-index: 5;
  background: var(--panel-2); border: 1px solid var(--accent-line);
  border-radius: 10px; padding: 9px 11px; font-size: 12px; min-width: 132px;
  transform: translate(-50%, -120%); white-space: nowrap;
  box-shadow: var(--shadow);
}
.tip.hidden { display: none; }
.tip .tip-day { color: var(--muted); margin-bottom: 4px; }
.tip .tip-val { font-size: 14px; font-variant-numeric: tabular-nums; }

/* ---------- tab bar + tables ---------- */
.tabbar {
  display: flex; gap: 24px; overflow-x: auto; margin: 20px 0 0;
  border-bottom: 1px solid var(--line-soft); padding: 0 4px;
}
.tabbar button {
  background: none; border: 0; padding: 13px 2px; cursor: pointer; white-space: nowrap;
  color: var(--muted); font-size: 13px; border-bottom: 2px solid transparent;
}
.tabbar button.on { color: var(--accent); border-bottom-color: var(--accent); }
.tabbar .count { color: var(--muted-2); margin-left: 5px; font-size: 12px; }

/* `overflow-x: auto` makes overflow-y compute to auto as well, which turns this
   into a scroll container — and a sticky header inside a container that never
   scrolls is simply not sticky. It was pinned to a box the page scrolled past.
   Giving the box a height is what makes the header do its job: the table scrolls
   inside it and the column names stay put, which is the whole point when the
   list runs to three hundred resting orders.

   The four background layers are the scroll shadows: two `local` covers that
   travel with the content, two `scroll` shadows that stay at the edges, so a
   fade appears only on the side that still has columns hidden behind it. */
.table-host {
  overflow: auto;
  /* Sized against the viewport rather than against what happens to sit above
     it, so the box behaves the same on both pages. Scroll chaining is left on:
     reaching the bottom of the table carries on down the page, which is what a
     wheel is expected to do. */
  max-height: 70vh;
  max-height: 70dvh;
  background:
    linear-gradient(to right, var(--panel-solid), transparent) 0 0 / 26px 100% no-repeat local,
    linear-gradient(to left, var(--panel-solid), transparent) 100% 0 / 26px 100% no-repeat local,
    linear-gradient(to right, rgba(0, 0, 0, 0.26), transparent) 0 0 / 14px 100% no-repeat scroll,
    linear-gradient(to left, rgba(0, 0, 0, 0.26), transparent) 100% 0 / 14px 100% no-repeat scroll;
}
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead th {
  text-align: right; padding: 13px 14px; color: var(--muted); font-weight: 500;
  font-size: 12px; border-bottom: 1px solid var(--line-soft); white-space: nowrap;
  position: sticky; top: 0; z-index: 2; background: var(--head-bg); cursor: pointer;
}
thead th:not([data-sort]):not([data-col]) { cursor: default; }
/* A text column has to be left-aligned in *both* halves of the table. Only the
   header was, which went unnoticed while every text column happened to be the
   first one — `td:first-child` was quietly doing the job. The moment a text
   column appeared in the middle (Account, and Market on the funding table) the
   header sat left and its values sat right, on opposite sides of the column. */
thead th:first-child, tbody td:first-child,
thead th.text, tbody td.text { text-align: left; }
thead th.sorted { color: var(--accent); }
tbody td {
  text-align: right; padding: 12px 14px; border-bottom: 1px solid var(--line-soft);
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
tbody tr:hover { background: var(--accent-soft); }
/* A fill that arrived on this poll: one pass of brand green, then it settles. */
@keyframes rowflash {
  from { background: rgba(208, 245, 0, 0.20); }
  to   { background: transparent; }
}
tbody tr.fresh { animation: rowflash 1.8s ease-out; }
.count.live { color: var(--up); }
/* Two animations that had been missed: a page asked to stop moving should
   stop moving. Both still say what they said — one in colour, one in a dot. */
@media (prefers-reduced-motion: reduce) {
  tbody tr.fresh { animation: none; background: var(--accent-soft); }
  .count.live::before { animation: none; }
}
.count.live::before {
  content: ""; display: inline-block; width: 6px; height: 6px; border-radius: 50%;
  background: var(--up); margin-right: 5px; vertical-align: middle;
  animation: pulse 1.6s ease-in-out infinite;
}
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }
.market { display: flex; align-items: center; gap: 8px; }
.lev {
  font-size: 11px; padding: 2px 7px; border-radius: 999px;
  background: var(--accent-soft); color: var(--accent);
}
.lev.short { background: rgba(255, 92, 122, 0.14); color: var(--down); }
.empty {
  padding: 34px; text-align: center; color: var(--muted-2); font-size: 13px;
  display: flex; flex-direction: column; align-items: center; gap: 12px;
}
.empty img { width: 92px; opacity: 0.8; }
.note { padding: 10px 16px; color: var(--muted-2); font-size: 12px; }
.err { color: var(--down); font-size: 12px; padding: 8px 16px; }

/* ---------- phone-only structures ----------
   A 390px screen cannot hold an eleven-column table or a six-tab strip, so on a
   phone the same data is rendered as cards, as a select, and as rows that open
   to show the columns that did not fit. All three are inert above 760px; the
   markup exists on every screen and CSS alone decides which one is live. */
.vcards, .tabpick, .sortbar, td.chev, th.chev { display: none; }
.vcard {
  display: block; background: var(--panel); border: 1px solid var(--line-soft);
  border-radius: 14px; padding: 13px 14px;
}
.vcard:active { border-color: var(--accent-line); }
.vcard-top { display: flex; align-items: center; gap: 8px; margin-bottom: 11px; }
.vcard-top strong { font-size: 15px; }
.vcard-top .badge { margin-left: auto; margin-right: 0; flex: none; }
.vcard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 11px 12px; }
.vcard-grid .card-label { font-size: 11px; margin-bottom: 3px; }
/* Ten cards of bold 17px figures, half of them coloured, was a wall of
   shouting. Smaller and at medium weight the colour still reads as the signal
   while the page stops fighting the eye. */
.vcard-grid .card-value { font-size: 15px; font-weight: 500; letter-spacing: 0; }
.chev-mark { display: inline-block; transition: transform 0.15s; }
tr.open .chev-mark { transform: rotate(90deg); }
.rowmore td { padding: 2px 14px 13px; white-space: normal; }
.kv-mini { display: grid; grid-template-columns: auto 1fr; gap: 7px 14px; margin: 0; font-size: 12px; }
.kv-mini dt { color: var(--muted); }
.kv-mini dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }

/* ---------- vault list ---------- */
.toolbar { display: flex; gap: 10px; align-items: center; margin: 18px 0 12px; flex-wrap: wrap; }
/* `appearance: none` is the whole fix for a search box that changed shape when
   it was focused: `type="search"` gets the platform's own searchfield look, and
   on focus the engine re-applies it — square corners over the pill. */
.input {
  appearance: none; -webkit-appearance: none;
  background: var(--field); border: 1px solid var(--line); border-radius: 999px;
  padding: 0 15px; min-height: var(--control-h);
  color: var(--text); font-size: 13px; min-width: 210px;
  font-family: inherit;
}
.input::placeholder { color: var(--muted-2); }
.input:focus { outline: none; border-color: var(--accent-line); border-radius: 999px; }
.input::-webkit-search-decoration,
.input::-webkit-search-cancel-button,
.input::-webkit-search-results-button { -webkit-appearance: none; display: none; }

/* A search field says what it is with a mark, not only with placeholder text
   that disappears the moment you use it. */
.searchbox { position: relative; display: flex; min-width: 0; }
.searchbox .input { width: 100%; padding-left: 40px; }
.searchbox-i {
  position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
  display: flex; color: var(--muted-2); pointer-events: none;
}
.searchbox:focus-within .searchbox-i { color: var(--accent); }

.hero {
  /* Not `overflow: hidden`: the mascot is meant to stand *in* the card with its
     head above the rim, which clipping made impossible. Backgrounds clip to the
     rounded border box on their own, so nothing else needed it. */
  display: block; position: relative;
  background:
    radial-gradient(560px 300px at 92% 20%, var(--glow-b), transparent 65%),
    linear-gradient(115deg, var(--glow-a), transparent 58%),
    var(--panel);
  border: 1px solid var(--line); border-radius: 20px;
  padding: 24px 26px; margin-bottom: 18px;
}
.hero:hover { border-color: var(--accent-line); }
/* `translate` and `rotate` rather than `transform`: they are separate
   properties, so the drift keeps animating while `transform` stays free for the
   pointer parallax. One property cannot do both jobs at once. */
@keyframes float {
  from { translate: 0 0; rotate: -3deg; }
  to   { translate: 0 -12px; rotate: 3deg; }
}
/* Ornaments are backgrounds declared inside a min-width query, not <img> tags.
   A `display: none` image is still fetched — the mascot alone is 230 KB — so
   the phone that never shows these was paying for all of them. A background
   whose rule does not match is never requested at all. */
@media (min-width: 901px) {
  /* The artwork is 520x730 — a standing figure, not a square. Fitting it into
     a 168px square left it letterboxed and, with the frame pushed 18px below
     the card, cropped exactly at the feet. Now the box carries the real 0.693
     ratio, sits *on* the bottom edge so the feet stay inside, and is tall
     enough that the head clears the top rim. */
  .hero { margin-top: 16px; }
  .hero-mascot {
    position: absolute; right: 26px; bottom: 0;
    width: 139px; height: 200px;
    pointer-events: none; filter: drop-shadow(0 14px 34px rgba(0, 0, 0, 0.42));
    background: url("/static/img/mascot.png") bottom center / contain no-repeat;
  }
  .hero-cube {
    position: absolute; right: 210px; top: 18px; width: 62px; height: 62px;
    opacity: 0.85; pointer-events: none;
    background: url("/static/img/cube.png") center / contain no-repeat;
    animation: float 7s ease-in-out infinite alternate;
  }
  @media (prefers-reduced-motion: reduce) { .hero-cube { animation: none; } }
}
.hero-top { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.hero-title { font-size: 22px; font-weight: 700; }
.hero-grid {
  display: grid; gap: 20px; position: relative;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); max-width: calc(100% - 190px);
}
@media (max-width: 900px) { .hero-grid { max-width: 100%; } }
.hero-grid .card-value { font-size: 24px; }
.dot {
  width: 8px; height: 8px; border-radius: 50%; background: var(--up);
  display: inline-block; position: relative;
  box-shadow: 0 0 0 3px var(--accent-soft);
}
/* A ring that leaves the dot and fades, once every two seconds. "Live" is a
   claim about right now, so it should not be painted as a static graphic — and
   only the healthy dot makes that claim: a stale or failing account keeps the
   colour without the pulse. */
.dot::after {
  content: ""; position: absolute; inset: 0; border-radius: 50%;
  background: var(--up); animation: dot-pulse 2s ease-out infinite;
  pointer-events: none;
}
@keyframes dot-pulse {
  0%        { transform: scale(1);   opacity: 0.55; }
  70%, 100% { transform: scale(2.8); opacity: 0; }
}
.dot.bad { background: var(--down); box-shadow: 0 0 0 3px rgba(255, 92, 122, 0.15); }
.dot.stale { background: var(--warn); box-shadow: 0 0 0 3px rgba(255, 180, 87, 0.15); }
.dot.bad::after, .dot.stale::after { display: none; }
@media (prefers-reduced-motion: reduce) { .dot::after { animation: none; } }
.exch {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px;
  color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 2px 9px;
}

/* ---------- state tag ----------
   A state is a label, not prose: "đang chạy" reads as a value in a sentence, so
   it gets the shape of a value — a tinted pill with the state's own colour and
   a small icon. Same three colours as the status dot, so the two never disagree. */
.tag {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 4px 11px 4px 9px; border-radius: 999px;
  font-size: 11px; font-weight: 700; line-height: 1.35;
  text-transform: uppercase; letter-spacing: 0.6px;
  /* An error carries its own message, which can be long — it wraps inside the
     pill instead of pushing the layout sideways. */
  text-align: left; max-width: 100%;
}
.tag svg { flex: none; }
.tag .dot { flex: none; }
/* "Running" is the state everything is in almost all of the time, so it does
   not get the brand colour — the page spends that on things worth looking at.
   The signal survives in the one part that is genuinely live: the pulsing dot,
   which keeps its green. */
.tag-ok {
  background: var(--field); color: var(--tag-ink);
  /* Without a border the chip dissolves into the panel on the dark theme,
     where the field tint and the surface are almost the same colour. */
  box-shadow: inset 0 0 0 1px var(--line);
}
.tag-warn { background: var(--warn-soft); color: var(--warn-ink); }
.tag-bad { background: var(--bad-soft); color: var(--bad-ink); }
/* The dot draws its own ring from --up; inside the tag that is the same colour
   the text already uses, so it stays legible in both themes. */
.tag-ok .dot { box-shadow: 0 0 0 3px var(--ok-soft); }
.tag-ok svg { color: var(--muted-2); }
.tag-row { margin-top: 7px; }

/* ---------- sign in ----------
   Laid out like the marketing hero: the ask on the left, the mascot and its
   brand ornaments orbiting on the right. This page is pre-auth, so it states
   nothing at all about the accounts behind it. */
.auth-body {
  /* Unlike the app pages, nothing here has to stay readable over a bright
     patch, so the violet is allowed to bloom the way the site's hero does.

     Four layers rather than one, listed front to back the way background-image
     stacks: a lime breath in the bottom corner where the horizon's crest picks
     the same colour up again, a magenta lift on the right, an ink ceiling so the
     headline has something dark to sit on, and under all of it the violet floor
     the page always had, taken deeper. */
  background:
    radial-gradient(58vmax 40vmax at 6% 102%, rgba(208, 245, 0, 0.10), transparent 60%),
    radial-gradient(72vmax 54vmax at 86% 40%, rgba(206, 74, 255, 0.17), transparent 64%),
    linear-gradient(180deg, rgba(8, 6, 20, 0.72) 0%, transparent 44%),
    linear-gradient(180deg, transparent 24%, rgba(124, 77, 255, 0.32) 76%,
                    rgba(74, 32, 168, 0.46) 100%),
    var(--bg);
}
/* On white the same four read as grime rather than as depth: the ink ceiling
   has nothing to darken and the magenta goes muddy. The light theme keeps the
   lime corner, which is the one layer that works over a pale surface. */
:root[data-theme="light"] .auth-body {
  background:
    radial-gradient(58vmax 40vmax at 6% 102%, rgba(208, 245, 0, 0.22), transparent 60%),
    radial-gradient(72vmax 54vmax at 86% 40%, rgba(206, 74, 255, 0.09), transparent 64%),
    linear-gradient(180deg, transparent 30%, rgba(124, 77, 255, 0.20) 100%),
    var(--bg);
}
.auth-topbar {
  position: absolute; top: 0; right: 0; z-index: 5;
  display: flex; align-items: center; justify-content: flex-end; gap: 10px;
  padding: 20px 24px;
}
/* The same lift the chips take, on the two controls in the corner — the theme
   toggle and the language pill. Everywhere else in the app these two sit in a
   bar full of numbers and hold still; here they are two of the four things on
   the page that answer a cursor at all. */
.auth-topbar .btn-ghost, .auth-topbar .picker-btn {
  transition: transform .22s cubic-bezier(0.22, 1, 0.36, 1), box-shadow .22s,
              color .15s, border-color .15s, background .15s;
}
.auth-topbar .btn-ghost:hover, .auth-topbar .picker-btn:hover {
  transform: translateY(-2px);
  box-shadow: 0 12px 20px -14px rgba(124, 77, 255, 0.95);
}
.auth-topbar .picker-btn:hover { border-color: var(--accent-line); }
@media (prefers-reduced-motion: reduce) {
  .auth-topbar .btn-ghost:hover, .auth-topbar .picker-btn:hover { transform: none; }
}
/* ---------- the sky ----------
   One fixed layer of pure ornament behind the sign-in page: stars, sparkles,
   the two shooting stars, the light shaft, and the horizon along the bottom. It
   is aria-hidden in the markup and takes no pointer events.

   All of it is CSS and none of it is script. The CSP allows no style attribute,
   so a star cannot be placed from JavaScript without one — and a page that
   draws itself with the scripts blocked is one fewer thing that can fail. */
.sky {
  position: fixed; inset: 0; z-index: 0; overflow: hidden; pointer-events: none;
}

/* ---- the nebula ----
   Three clouds of gas behind the stars, drifting on cycles long enough that no
   two ever repeat the same arrangement in a sitting.

   Gradients rather than a blurred shape: a `filter: blur()` big enough to
   soften a 900px blob is expensive and, worse, it needs a shape to blur —
   which means an edge, which means a chance of seeing one. A radial gradient
   fading to transparent has no edge to begin with. Two of them per cloud, off
   centre from each other, because one alone is an ellipse and an ellipse in the
   sky reads as a lens flare.

   The hues are the page's own — magenta, indigo, a breath of the brand lime —
   held between 0.10 and 0.16, which is where they register as depth rather than
   as weather. `translate` and `scale` and not `transform`, for the reason
   everything on this page has that comment: the parallax owns `transform`. */
.nebula { position: absolute; inset: 0; }
.neb {
  position: absolute;
  animation: neb-drift var(--t, 60s) ease-in-out var(--d, 0s) infinite alternate;
}
@keyframes neb-drift {
  from { translate: 0 0; scale: 1; }
  to   { translate: var(--dx, 40px) var(--dy, -28px); scale: var(--ds, 1.1); }
}
.neb-1 {
  left: -16%; top: -10%; width: 64vw; height: 48vw;
  background:
    radial-gradient(50% 50% at 44% 42%, rgba(206, 74, 255, 0.15), transparent 70%),
    radial-gradient(44% 52% at 68% 64%, rgba(140, 90, 255, 0.12), transparent 72%);
  --t: 64s; --dx: 48px; --dy: 32px; --ds: 1.08;
}
/* The cool one. The page is violet from top to bottom and a second violet cloud
   would be invisible; this one leans blue, which is the only way three clouds
   read as three. */
.neb-2 {
  left: 54%; top: 8%; width: 58vw; height: 44vw;
  background:
    radial-gradient(48% 50% at 56% 46%, rgba(84, 108, 255, 0.13), transparent 70%),
    radial-gradient(42% 46% at 34% 66%, rgba(180, 96, 255, 0.10), transparent 72%);
  --t: 82s; --d: -14s; --dx: -54px; --dy: 26px; --ds: 1.11;
}
/* Lime, and barely. It sits low, where the page's own accent glow already is,
   so the ground has something to have been lit by. */
.neb-3 {
  left: 6%; top: 46%; width: 70vw; height: 46vw;
  background:
    radial-gradient(46% 48% at 40% 54%, rgba(208, 245, 0, 0.08), transparent 68%),
    radial-gradient(50% 54% at 70% 40%, rgba(124, 77, 255, 0.14), transparent 72%);
  --t: 96s; --d: -38s; --dx: 40px; --dy: -30px; --ds: 1.06;
}

/* A shaft of light across the whole page, at the threshold of visible. It does
   nothing on its own; what it does is stop the gradient reading as flat. */
.streak {
  position: absolute; left: -25%; top: -40%; width: 96%; height: 200%;
  transform: rotate(-9deg);
  background: linear-gradient(100deg,
    transparent 40%, rgba(255, 255, 255, 0.05) 50%, transparent 60%);
}

.stars { position: absolute; inset: 0; }
.star {
  position: absolute; width: var(--s, 2px); height: var(--s, 2px);
  border-radius: 50%; background: #fff; opacity: 0.12;
  animation: twinkle var(--t, 4s) ease-in-out var(--d, 0s) infinite;
}
/* The two ends are variables because the light theme needs a different pair.
   A white speck at 0.92 over ink is a star; a dark speck at 0.92 over near-white
   is a full stop somebody dropped on the page. Same animation, half the swing. */
@keyframes twinkle {
  0%, 100% { opacity: var(--twinkle-lo, 0.10); transform: scale(0.75); }
  50%      { opacity: var(--twinkle-hi, 0.92); transform: scale(1); }
}
/* Two lime and two violet among the twenty-six, so the sky carries the brand's
   own two colours instead of being grey dust. */
.star:nth-child(9), .star:nth-child(19) {
  background: var(--accent-fill); box-shadow: 0 0 7px rgba(208, 245, 0, 0.75);
}
.star:nth-child(4), .star:nth-child(23) {
  background: #b48cff; box-shadow: 0 0 7px rgba(180, 140, 255, 0.7);
}
/* Placed on a 6x5 grid with one star per cell and a jitter inside it, so the
   scatter has neither a bald patch nor a clump. Fixed, not random per load:
   a sky that rearranges itself on every visit reads as a rendering bug. */
.star:nth-child(1)  { left: 9.1%;  top: 12.2%; --s: 2px; --t: 3.2s; --d: 6.2s; }
.star:nth-child(2)  { left: 19.2%; top: 14.2%; --s: 2px; --t: 6.2s; --d: 3.5s; }
.star:nth-child(3)  { left: 37.0%; top: 13.6%; --s: 2px; --t: 5.6s; --d: 5.4s; }
.star:nth-child(4)  { left: 53.1%; top: 4.7%;  --s: 2px; --t: 5.0s; --d: 3.7s; }
.star:nth-child(5)  { left: 76.1%; top: 15.2%; --s: 1px; --t: 3.1s; --d: 3.0s; }
.star:nth-child(6)  { left: 89.8%; top: 13.8%; --s: 1px; --t: 5.9s; --d: 3.5s; }
.star:nth-child(7)  { left: 27.7%; top: 25.0%; --s: 2px; --t: 5.9s; --d: 4.9s; }
.star:nth-child(8)  { left: 60.6%; top: 34.9%; --s: 2px; --t: 5.5s; --d: 3.1s; }
.star:nth-child(9)  { left: 70.1%; top: 29.7%; --s: 3px; --t: 6.4s; --d: 0.3s; }
.star:nth-child(10) { left: 92.6%; top: 31.5%; --s: 3px; --t: 3.4s; --d: 4.9s; }
.star:nth-child(11) { left: 7.8%;  top: 42.6%; --s: 2px; --t: 5.3s; --d: 3.1s; }
.star:nth-child(12) { left: 27.2%; top: 49.2%; --s: 2px; --t: 3.9s; --d: 2.5s; }
.star:nth-child(13) { left: 35.3%; top: 43.3%; --s: 1px; --t: 3.5s; --d: 3.3s; }
.star:nth-child(14) { left: 62.0%; top: 45.2%; --s: 2px; --t: 4.9s; --d: 0.1s; }
.star:nth-child(15) { left: 68.2%; top: 43.6%; --s: 2px; --t: 3.4s; --d: 1.7s; }
.star:nth-child(16) { left: 89.9%; top: 53.6%; --s: 2px; --t: 3.5s; --d: 1.4s; }
.star:nth-child(17) { left: 14.2%; top: 68.3%; --s: 2px; --t: 3.7s; --d: 3.5s; }
.star:nth-child(18) { left: 23.6%; top: 64.1%; --s: 3px; --t: 4.5s; --d: 2.5s; }
.star:nth-child(19) { left: 40.9%; top: 62.2%; --s: 2px; --t: 4.3s; --d: 2.7s; }
.star:nth-child(20) { left: 60.2%; top: 63.5%; --s: 3px; --t: 3.5s; --d: 6.5s; }
.star:nth-child(21) { left: 78.7%; top: 68.9%; --s: 2px; --t: 4.8s; --d: 6.4s; }
.star:nth-child(22) { left: 7.2%;  top: 88.8%; --s: 2px; --t: 3.4s; --d: 2.8s; }
.star:nth-child(23) { left: 28.8%; top: 88.5%; --s: 3px; --t: 2.9s; --d: 5.8s; }
.star:nth-child(24) { left: 60.5%; top: 83.1%; --s: 2px; --t: 3.7s; --d: 5.9s; }
.star:nth-child(25) { left: 72.8%; top: 90.3%; --s: 3px; --t: 4.8s; --d: 1.6s; }
.star:nth-child(26) { left: 88.0%; top: 85.2%; --s: 2px; --t: 3.1s; --d: 5.5s; }

/* Three solid shapes among the pinpricks, drifting on a long alternate so they
   never arrive anywhere — the page has a mascot and its PNG ornaments on the
   right, but only above 900px, which left a phone with no ornament at all.
   These are three rules and no bytes, so they are the ones that stay. */
.mote {
  position: absolute; border-radius: 50%;
  animation: mote-drift var(--t, 13s) ease-in-out var(--d, 0s) infinite alternate;
}
/* On `translate` and `rotate` rather than `transform`, for the reason the float
   and the spin are: the motes are the nearest of the three sky planes, and the
   parallax at the foot of this file needs `transform` to move them with. An
   animation holding it would take it for good. */
@keyframes mote-drift {
  from { translate: 0 0; rotate: 0deg; }
  to   { translate: -14px -22px; rotate: 24deg; }
}
/* The one square in a sky of circles, which is why it is the one that reads as
   an object rather than as a star. The rotation is only visible on this one. */
.mote-1 {
  left: 83%; top: 18%; width: 15px; height: 15px; border-radius: 5px;
  background: var(--accent-fill); box-shadow: 0 0 18px rgba(208, 245, 0, 0.55);
  --t: 11s;
}
.mote-2 {
  left: 55.5%; top: 33%; width: 11px; height: 11px;
  background: #8b5cff; box-shadow: 0 0 16px rgba(139, 92, 255, 0.7);
  --t: 15s; --d: 1.6s;
}
.mote-3 {
  left: 49%; top: 49%; width: 7px; height: 7px;
  background: var(--accent-fill); box-shadow: 0 0 14px rgba(208, 245, 0, 0.6);
  --t: 13s; --d: 3.2s;
}

/* Two, and rarely: the whole point of a shooting star is catching one out of
   the corner of your eye. Each is lit for well under a tenth of its cycle, and
   the two cycles are far enough apart never to fall into step. */
.shoot {
  position: absolute; width: 130px; height: 2px; opacity: 0; border-radius: 2px;
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.95) 82%, #fff);
  filter: drop-shadow(0 0 6px rgba(208, 245, 0, 0.55));
}
.shoot-1 { top: 11%; left: 6%;  animation: shoot-1 17s ease-in 4s infinite; }
.shoot-2 { top: 33%; left: 50%; animation: shoot-2 23s ease-in 12s infinite; }
@keyframes shoot-1 {
  0%   { opacity: 0; transform: translate3d(0, 0, 0) rotate(19deg) scaleX(0.25); }
  2%   { opacity: 1; }
  9%   { opacity: 0; transform: translate3d(470px, 162px, 0) rotate(19deg) scaleX(1); }
  100% { opacity: 0; transform: translate3d(470px, 162px, 0) rotate(19deg) scaleX(1); }
}
@keyframes shoot-2 {
  0%   { opacity: 0; transform: translate3d(0, 0, 0) rotate(13deg) scaleX(0.25); }
  2%   { opacity: 1; }
  7%   { opacity: 0; transform: translate3d(380px, 88px, 0) rotate(13deg) scaleX(1); }
  100% { opacity: 0; transform: translate3d(380px, 88px, 0) rotate(13deg) scaleX(1); }
}

/* ---- the sparkles ----
   The stars above breathe; these flash. A four-pointed glint, lit for about a
   tenth of its cycle and dark for the rest, so what the eye catches is one
   corner of the page going off at a time rather than a field of blinking dots.

   Named `sparkle` and not `spark`, which the list page already spends on its
   7-day sparkline. */
.sparkles { position: absolute; inset: 0; }
.sparkle {
  position: absolute; width: var(--s, 12px); height: var(--s, 12px);
  color: var(--c, #fff); background: currentColor; opacity: 0;
  /* Four points with a waist between them: a plain rotated square reads as a
     diamond, and only the concave sides make it read as light. */
  clip-path: polygon(50% 0%, 57% 43%, 100% 50%, 57% 57%,
                     50% 100%, 43% 57%, 0% 50%, 43% 43%);
  filter: drop-shadow(0 0 6px currentColor);
  animation: sparkle var(--t, 7s) ease-in-out var(--d, 0s) infinite;
}
/* Rotating as it opens is what separates a glint from a fade: the shape turns
   an eighth of a turn while it is lit, which is enough to read as a catch of
   light and not enough to read as a spinning object. */
@keyframes sparkle {
  0%, 82%, 100% { opacity: 0; scale: 0.2; rotate: -22deg; }
  88%           { opacity: 1; scale: 1;   rotate: 0deg; }
  94%           { opacity: 0.35; scale: 0.7; rotate: 14deg; }
}
.sparkle:nth-child(1) { left: 8.5%;  top: 10%; --s: 13px; --t: 7.5s;  --d: 1.2s; --c: var(--accent-fill); }
.sparkle:nth-child(2) { left: 24%;   top: 20%; --s: 9px;  --t: 9.5s;  --d: 4.0s; }
.sparkle:nth-child(3) { left: 43%;   top: 7%;  --s: 15px; --t: 8.5s;  --d: 6.5s; }
.sparkle:nth-child(4) { left: 58%;   top: 23%; --s: 11px; --t: 11s;   --d: 2.4s; --c: #b48cff; }
.sparkle:nth-child(5) { left: 71%;   top: 9%;  --s: 17px; --t: 9s;    --d: 7.8s; --c: var(--accent-fill); }
.sparkle:nth-child(6) { left: 88%;   top: 31%; --s: 12px; --t: 7s;    --d: 3.3s; }
.sparkle:nth-child(7) { left: 94.5%; top: 58%; --s: 10px; --t: 10.5s; --d: 5.6s; --c: #b48cff; }
.sparkle:nth-child(8) { left: 66%;   top: 52%; --s: 15px; --t: 8s;    --d: 9.2s; --c: var(--accent-fill); }
.sparkle:nth-child(9) { left: 38%;   top: 74%; --s: 11px; --t: 12s;   --d: 0.8s; }

/* ---- the constellation ----
   Lines drawn between stars that happen to be near each other. It is a canvas
   and not more DOM because the whole point is the *pairs*, and which stars are
   near which is only known once they have been laid out — see login.js, which
   measures them rather than keeping a second copy of the twenty-six positions
   this file owns.

   Sits above the stars and below everything else in the sky, so a line passes
   behind a shooting star and in front of the nebula. The pulse is slow and
   never reaches nothing: a constellation that blinks out entirely reads as a
   rendering fault. */
.constellation {
  position: absolute; inset: 0; width: 100%; height: 100%;
  /* The whole palette lives here rather than in login.js, so the one file that
     owns how this page looks still owns it — the canvas reads these back and
     re-reads them when the theme changes. */
  --link: rgba(226, 218, 255, 0.17);
  --link-live: rgba(208, 245, 0, 0.34);
  --star: #ffffff;
  --star-lime: #d0f500;
  --star-violet: #b48cff;
  animation: constellation-pulse 9s ease-in-out infinite alternate;
}
/* The floor is high because the field is *drawn* on this canvas now, not just
   the lines between it: at 0.4 the stars themselves dimmed to nearly nothing
   every nine seconds, which is a sky flickering rather than breathing. */
@keyframes constellation-pulse { from { opacity: 0.78; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .constellation { animation: none; opacity: 1; }
}
/* login.js adds `live` to .sky once it has a canvas context and a size. From
   that moment the drawn field is the sky and these are the same twenty-six
   stars a second time, so they go. Kept in the markup, not deleted: they are
   what draws when this file does not run. One sky at a time, always one. */
.sky.live .stars { display: none; }

/* ---- the horizon ----
   One hill across the foot of the page: the crest is a hairline in the accent,
   and under it the ground falls away into violet and then into the page's own
   ink. It replaced two drifting waves, which were water — and water is a
   different promise from ground.

   Which is also why the curve in horizon.svg has no inflection in it. The
   difference between a hill and a wave is not the height, it is whether the
   curvature changes sign on the near slope: give the crest a flat top and the
   slope below it has to bend back the other way to reach the corner, and that
   S is the shape of surf. This one only ever eases.

   The hairline is made the same way the waves made theirs, and for the same
   reason: the shape is drawn once in the crest colour, then again two pixels
   lower in the ground fill, so the only crest left showing is the two pixels
   along the top. One mask file, no second colour baked into the SVG, and the
   line follows the theme — lime on the dark page, brand violet on the light
   one, where lime over near-white fails every contrast check the palette makes.

   That trick has one condition, and it is the whole reason the ground below is
   opaque: **the fill must hide what is under it.** The waves let theirs run at
   0.34 alpha, which was fine over 120px of water and is not fine over a third
   of the screen — a translucent violet over a solid lime is a pale grey-lavender,
   and the ground came out looking bleached rather than dark. The gradient
   underneath is opaque now, so the accent stops at the hairline, which is what
   the paragraph above always claimed.

   The arc is stretched to the element rather than tiled, so it is one curve
   across the window at any width. It drifts, but it is the only layer the
   pointer parallax skips — the sky answers the cursor and the ground does not,
   which is what still makes it read as ground rather than as one more thing
   floating. */
/* Sized to clear the form rather than to look right on its own. The column is
   centred in the viewport, so the room under the chips is (100dvh − column) / 2
   — about 156px on a 900px screen — and the ground is deepest at 0.68 of its
   own height where the chips end. 20vh keeps the two apart at every viewport
   height the desktop layout is used at, down to the short-and-wide laptops
   (1440x850) that the 900px *width* breakpoint does not catch. The ceiling is
   what stops a tall monitor turning it back into a third of the screen. */
.horizon {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: clamp(150px, 20vh, 270px);
  /* The arc below overhangs this box on three sides so it can sway without
     showing an end; this is what keeps the overhang off the screen. */
  overflow: hidden;
}
/* The bloom the crest sits in, behind the arc rather than on it, so it spills
   into the sky above the line as well as the ground below. Kept faint: the page
   already has its own violet glow at the bottom edge, and two of them stacked
   is the haze this was drawn to avoid. */
.horizon::before {
  content: ""; position: absolute; inset: 0;
  background: radial-gradient(58% 78% at 58% 32%, rgba(139, 92, 255, 0.16), transparent 70%);
  animation: horizon-breathe 14s ease-in-out infinite alternate;
}
@keyframes horizon-breathe { from { opacity: 0.62; } to { opacity: 1; } }
@keyframes horizon-sway { from { translate: -22px 0; } to { translate: 22px 0; } }
/* Half the bottom overhang, so the ground never lifts off the bottom edge. */
@keyframes horizon-bob { from { transform: translateY(5px); } to { transform: translateY(-5px); } }
/* The sway, and the reason the box is bigger than the window.
   The mask paints a shape that ends exactly at this element's edges, so moving
   the element moves those ends into view: sway left and a wedge of missing
   ground opens on the right. The fix is to hand it more ground than it needs —
   40px past each side and 14px below — and let .horizon clip the surplus. The
   mask stretches over the wider box, which moves the crest by half a percent of
   the width; that is the entire cost.

   40s and 34s, cycles that do not divide into each other, so the horizontal and
   the vertical drift out of step and the path is never the same twice — a
   single animation carrying both axes would trace one line back and forth,
   which is a slider, not a drift. Splitting them takes two properties, because
   two animations on `translate` do not add up: the second replaces the first.
   So the sway is on `translate` and the bob is on `transform`, which compose.
   `transform` is free here for the one reason it is not free anywhere else on
   this page — the horizon is the layer the pointer parallax deliberately skips.

   22px on a screen 1400 wide: not a thing you watch, a thing you notice is not
   still. */
.horizon-arc {
  position: absolute; inset: 0 -40px -14px -40px;
  animation: horizon-sway 40s ease-in-out infinite alternate,
             horizon-bob 34s ease-in-out infinite alternate;
  /* Not `--accent` itself. At full strength the line was as bright as the
     button, and the button is the one thing on this page anybody has to find.
     Held back to a little over half, the same lime lands as the dim gold of a
     lit edge — which is what it is. */
  background: var(--horizon-line);
  -webkit-mask-image: url("/static/img/horizon.svg");
  mask-image: url("/static/img/horizon.svg");
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: bottom;  mask-position: bottom;
  -webkit-mask-size: 100% 100%;   mask-size: 100% 100%;
  /* Applied to the masked shape, so the glow follows the curve instead of
     sitting in a rectangle behind it. */
  filter: drop-shadow(0 0 8px rgba(208, 245, 0, 0.22));
}
.horizon-arc::before {
  content: ""; position: absolute; inset: 2px 0 0 0;
  /* Opaque, top to bottom — see the note above about the hairline. A lift under
     the crest, then down to very near the page's own ink by the bottom edge, so
     the ground reads as a surface curving away into the dark rather than as a
     purple block someone dropped in. Only barely lighter than the sky it meets:
     at this size the line is the event, and the ground is where it happens. */
  background:
    radial-gradient(70% 58% at 58% 0%, rgba(126, 84, 214, 0.5), transparent 74%),
    linear-gradient(180deg, #2c1a5f 0%, #1f1349 46%, #0e0925 100%);
  -webkit-mask-image: inherit;    mask-image: inherit;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: bottom;  mask-position: bottom;
  -webkit-mask-size: 100% 100%;   mask-size: 100% 100%;
}

/* White dots on a near-white page are not stars, they are nothing; the light
   theme keeps the ground and drops the night. */
/* ---- the sky in daylight ----
   None of this used to be drawn on the light theme at all: a white speck on a
   near-white page is nothing, so the whole sky was switched off and the light
   page got a gradient and a horizon. That was the wrong conclusion from a
   correct observation. A daylight sky is not an unlit night sky — it is the
   same sky with the values inverted, so every speck that was light becomes
   dark and every glow, which is light spilling onto something, goes away.

   That last part is the rule worth stating: a halo around a dark dot on a white
   page does not read as brightness, it reads as the dot being out of focus. So
   the shapes and the motion all survive the theme change and only the *glows*
   are dropped.

   Lime becomes --up rather than --accent. --up is the palette's own answer to
   "this colour, on white, still legible" for the lime family, so the sky keeps
   its two brand hues instead of collapsing to violet. */
:root[data-theme="light"] {
  --twinkle-lo: 0.05;
  --twinkle-hi: 0.5;
}
:root[data-theme="light"] .star { background: #4a3d7a; }
:root[data-theme="light"] .star:nth-child(9),
:root[data-theme="light"] .star:nth-child(19) {
  background: var(--up); box-shadow: none;
}
:root[data-theme="light"] .star:nth-child(4),
:root[data-theme="light"] .star:nth-child(23) {
  background: var(--accent); box-shadow: none;
}
/* currentColor drives both the fill and the glow, so the glow has to go
   separately or a violet sparkle arrives wearing a violet smudge.
   `var(--c, …)` and not a flat colour: the base rule is `color: var(--c, #fff)`
   and five of the nine sparkles set --c for themselves. Written flat, this rule
   outranks all of them and the sky loses its two brand colours to one grey. */
:root[data-theme="light"] .sparkle { color: var(--c, #5b4a94); filter: none; }
:root[data-theme="light"] .sparkle:nth-child(1),
:root[data-theme="light"] .sparkle:nth-child(5),
:root[data-theme="light"] .sparkle:nth-child(8) { --c: var(--up); }
:root[data-theme="light"] .sparkle:nth-child(4),
:root[data-theme="light"] .sparkle:nth-child(7) { --c: var(--accent); }
/* The head of the streak was white and the tail transparent; inverted, the head
   is ink. The lime bloom under it becomes nothing at all. */
:root[data-theme="light"] .shoot {
  background: linear-gradient(90deg, transparent, rgba(74, 61, 122, 0.75) 82%, #4a3d7a);
  filter: none;
}
/* A shaft of light across a lit page is a shaft of shade. */
:root[data-theme="light"] .streak {
  background: linear-gradient(100deg,
    transparent 40%, rgba(60, 44, 120, 0.045) 50%, transparent 60%);
}
:root[data-theme="light"] .mote-1 { background: var(--up); box-shadow: none; }
:root[data-theme="light"] .mote-2 { background: var(--accent); box-shadow: none; }
:root[data-theme="light"] .mote-3 { background: var(--up); box-shadow: none; }
/* The clouds keep their hues and lose about half their weight: over ink they
   are light being added, over white they are pigment being laid down, and the
   same alpha does much more of the second. */
:root[data-theme="light"] .neb-1 {
  background:
    radial-gradient(50% 50% at 44% 42%, rgba(180, 74, 235, 0.09), transparent 70%),
    radial-gradient(44% 52% at 68% 64%, rgba(124, 77, 255, 0.08), transparent 72%);
}
:root[data-theme="light"] .neb-2 {
  background:
    radial-gradient(48% 50% at 56% 46%, rgba(70, 96, 235, 0.08), transparent 70%),
    radial-gradient(42% 46% at 34% 66%, rgba(150, 80, 235, 0.06), transparent 72%);
}
:root[data-theme="light"] .neb-3 {
  background:
    radial-gradient(46% 48% at 40% 54%, rgba(120, 160, 0, 0.07), transparent 68%),
    radial-gradient(50% 54% at 70% 40%, rgba(124, 77, 255, 0.08), transparent 72%);
}
/* login.js reads these off the canvas, so the drawn sky follows the theme
   without the script having to know there is one. Same inversion as the CSS
   stars above: the specks become ink, and the halo the canvas paints around the
   big ones is left to the alpha — over white it is pigment, not light, so it
   wants a good deal less of itself. */
:root[data-theme="light"] .constellation {
  --link: rgba(74, 61, 122, 0.20);
  --link-live: rgba(91, 46, 232, 0.34);
  --star: #4a3d7a;
  --star-lime: #4f7a00;
  --star-violet: #5b2ee8;
}
:root[data-theme="light"] .horizon::before {
  background: radial-gradient(58% 78% at 58% 32%, rgba(124, 77, 255, 0.09), transparent 70%);
}
:root[data-theme="light"] .horizon-arc {
  filter: drop-shadow(0 0 8px rgba(91, 46, 232, 0.18));
}
/* Opaque here too, and for the same reason — the violet crest would otherwise
   read through its own ground. The two steps sit just under the page white, so
   the ground is a tint rather than a band. */
:root[data-theme="light"] .horizon-arc::before {
  background:
    radial-gradient(70% 58% at 58% 0%, rgba(124, 77, 255, 0.16), transparent 74%),
    linear-gradient(180deg, #e6e1f8 0%, #d9d1f2 46%, #cabff0 100%);
}

/* ---------- the sign-in page in daylight ----------
   Everything above was drawn against a near-black page, and a surprising amount
   of it says so in ways a token cannot fix on its own: a shadow that is black,
   a ring that is lime, a glow that only reads over ink. The light theme drops
   the night sky wholesale (see the display:none list above) — what is left here
   is the handful of things that stay on screen and have to be re-lit.

   The through-line: on this theme lime survives only as a *fill* with ink on
   top of it, which is what --accent already encodes. Anything lime that was a
   line, a dot or a glow becomes the brand violet. */

/* Black at 45% under a mascot on a white page is soot. Same geometry, the
   brand's violet, and much less of it. */
:root[data-theme="light"] .auth-mascot {
  filter: drop-shadow(0 22px 44px rgba(76, 44, 160, 0.22));
}
:root[data-theme="light"] .auth-float {
  filter: drop-shadow(0 10px 20px rgba(76, 44, 160, 0.18));
}
/* Both rings were violet at 0.30 and 0.18 alpha — pitched to hold up over ink,
   and over near-white they were not there at all. The lime segment on the inner
   ring is worse than faint: at 1.9:1 against this page it is invisible. */
:root[data-theme="light"] .orbit-1 {
  border-color: rgba(91, 46, 232, 0.22);
  border-top-color: var(--accent); border-right-color: transparent;
}
:root[data-theme="light"] .orbit-2 { border-color: rgba(91, 46, 232, 0.16); }
/* The runners are the same problem at 8px: a lime dot with a lime halo, on
   white. Violet, and the halo pulled in — a bloom over a pale surface reads as
   the dot being out of focus rather than as it being lit. */
:root[data-theme="light"] .orbit-run::before {
  background: var(--accent); box-shadow: 0 0 8px 1px rgba(91, 46, 232, 0.35);
}
:root[data-theme="light"] .orbit-run-2::before {
  background: #7c4dff; box-shadow: 0 0 7px 1px rgba(124, 77, 255, 0.35);
}
/* #b48cff is a light violet chosen to glow on ink; on white it measures under
   2:1 and the focused field loses the mark that says which field it is. */
:root[data-theme="light"] .auth-form .field-wrap:focus-within .field-i {
  color: #5b2ee8;
}
/* The ring stays and the bloom mostly goes. Over ink the outer glow is what
   makes the box look lit; over white the same glow is a soft purple stain
   around a rectangle. */
:root[data-theme="light"] .auth-form .field input:focus {
  border-color: rgba(91, 46, 232, 0.5);
  box-shadow: 0 0 0 3px rgba(91, 46, 232, 0.16),
              0 0 14px -6px rgba(91, 46, 232, 0.5);
}
/* The lifts carried a violet shadow tuned for a dark surface; on white the same
   alpha is a grey smear under a pill. */
:root[data-theme="light"] .auth-chips .chip:hover {
  box-shadow: 0 10px 20px -14px rgba(76, 44, 160, 0.55);
}
:root[data-theme="light"] .auth-topbar .btn-ghost:hover,
:root[data-theme="light"] .auth-topbar .picker-btn:hover {
  box-shadow: 0 10px 18px -14px rgba(76, 44, 160, 0.55);
}

/* Asked to stop moving, the page stops moving. The stars stay lit rather than
   vanishing — they are half the depth in the picture — and the shooting stars
   and the sparkles go, because a thing whose entire content is a movement has
   nothing to show standing still. */
@media (prefers-reduced-motion: reduce) {
  .star { animation: none; opacity: 0.55; }
  .shoot, .sparkle { display: none; }
  /* The clouds and the ground stay — both are things you can see standing
     still, unlike a shooting star, which is only its own movement. */
  .mote, .neb { animation: none; }
  .horizon::before, .horizon-arc { animation: none; }
}

/* ---------- arriving ----------
   The column does not appear, it assembles: badge, logo, headline, lead, the
   two boxes, the button, then the three chips, each a beat behind the one
   above. Seventy milliseconds apart — far enough to read as an order, close
   enough that the whole thing is settled in two thirds of a second, which is
   less time than it takes to reach for the first field.

   The step is written into the class rather than computed, because the CSP
   allows no style attribute and a per-element delay has to come from somewhere.

   `backwards` and not `both`: `both` would leave the finished animation pinning
   `translate: 0` forever, and every hover lift below moves the same element.
   Backwards fills only the delay, and hands the element back to the stylesheet
   the moment it lands.

   --enter is when the queue starts, and every delay on the page is written
   against it: the column, the topbar, the mascot, its ornaments, the ticker.
   It exists because the splash goes in front of all of them — the page now
   opens on a logo, and the whole entrance had to move behind it. One number to
   change if that timing is ever retuned, instead of fourteen. */
.auth-body { --enter: 1s; }
@keyframes rise-in {
  from { opacity: 0; translate: 0 18px; }
  to   { opacity: 1; translate: 0 0; }
}
.rise { animation: rise-in 0.62s cubic-bezier(0.22, 1, 0.36, 1) backwards; }
.rise-1  { animation-delay: calc(var(--enter) + 0.06s); }
.rise-2  { animation-delay: calc(var(--enter) + 0.13s); }
.rise-3  { animation-delay: calc(var(--enter) + 0.20s); }
.rise-4  { animation-delay: calc(var(--enter) + 0.27s); }
.rise-5  { animation-delay: calc(var(--enter) + 0.34s); }
.rise-6  { animation-delay: calc(var(--enter) + 0.41s); }
.rise-7  { animation-delay: calc(var(--enter) + 0.48s); }
.rise-8  { animation-delay: calc(var(--enter) + 0.56s); }
.rise-9  { animation-delay: calc(var(--enter) + 0.62s); }
.rise-10 { animation-delay: calc(var(--enter) + 0.68s); }
/* The furniture in the corner is not part of the sentence the column is making,
   so it fades in where the rest rises. */
.auth-topbar { animation: fade-in 0.5s ease calc(var(--enter) + 0.05s) backwards; }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }

/* ---------- the splash ----------
   The page opens on its own mark for a second and a half, and then is not
   holding anything up: `forwards` parks it at opacity 0 and `pointer-events:
   none` means it was never in the way even before that. Both matter more than
   they look — this element covers the entire viewport including the two fields,
   and the one failure mode worth designing out is a splash that stays.

   Which is why nothing here is scripted. A splash removed by JavaScript is a
   splash that never leaves on the visit where a script 404s. */
.splash {
  position: fixed; inset: 0; z-index: 90; pointer-events: none;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 20px;
  background: linear-gradient(118deg, #0c0517 0%, #1d0f41 55%, #160b30 100%);
  animation: splash-out 1.7s ease forwards;
}
:root[data-theme="light"] .splash {
  background: linear-gradient(118deg, #f7f5fd 0%, #ece7fa 55%, #f4f2fb 100%);
}
/* Opaque for the first two thirds, then out. The hold is what makes it a
   splash rather than a flash of colour on the way in. */
@keyframes splash-out {
  0%, 62% { opacity: 1; }
  100%    { opacity: 0; visibility: hidden; }
}
.splash-mark {
  width: 66px; height: 66px; border-radius: 17px;
  /* symbol-dark.svg in both themes, not the usual pair: the chip under it is
     lime either way, and symbol.svg is the white-on-dark cut — white on lime,
     with its own lime details vanishing into the background. */
  background: url("/static/img/symbol-dark.svg") center / 58% no-repeat, var(--accent-fill);
  box-shadow: 0 16px 44px var(--accent-glow);
  animation: splash-pop 0.7s cubic-bezier(0.2, 0.9, 0.3, 1.35) both;
}
@keyframes splash-pop {
  0%   { opacity: 0; scale: 0.72; }
  62%  { scale: 1.05; }
  100% { opacity: 1; scale: 1; }
}
.splash-word {
  font-family: var(--display); font-size: 13px; font-weight: 700;
  letter-spacing: 0.34em; text-transform: uppercase; color: var(--muted);
  animation: fade-in 0.5s ease 0.3s backwards;
}
/* The bar is not measuring anything — there is nothing to measure, the page is
   already here. It is the length of the pause, drawn, so the pause reads as
   deliberate rather than as the page having stalled. */
.splash-bar {
  width: 150px; height: 3px; border-radius: 3px;
  background: var(--line); overflow: hidden;
}
.splash-bar i {
  display: block; height: 100%; border-radius: 3px; background: var(--accent-fill);
  animation: splash-load 1.05s ease 0.15s both;
}
@keyframes splash-load { from { width: 0; } to { width: 100%; } }
/* Asked for no motion, there is no splash and no queue behind it: the page is
   simply already there, which is what a reader who set that preference asked
   for. --enter goes to zero so nothing waits on a screen that never showed. */
@media (prefers-reduced-motion: reduce) {
  .splash { display: none; }
  .auth-body { --enter: 0s; }
}

/* ---------- the ticker ----------
   A marquee of prices along the foot of the page. Two things about it are
   deliberate and neither is visible.

   The prices are **fixed text**, not live. Every /api route on this app is
   behind the session and this page is reachable without one, so a live ticker
   would mean a new unauthenticated endpoint — a thing to rate-limit, cache and
   defend, added to the one page whose whole design is that a stranger who
   reaches it learns nothing. That is a decision to take deliberately rather
   than as a side effect of wanting a nice bar, so the bar is decoration until
   somebody says otherwise.

   And it is `hidden` in the markup: login.js fills it and unhides it. With
   scripting off there is no empty strip ruled across the bottom of the page. */
.ticker {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: 6;
  display: flex; align-items: center; overflow: hidden;
  height: calc(40px + env(safe-area-inset-bottom, 0px));
  padding-bottom: env(safe-area-inset-bottom, 0px);
  background: var(--bar-bg); border-top: 1px solid var(--line-soft);
  backdrop-filter: blur(8px);
  animation: fade-in 0.6s ease calc(var(--enter) + 0.9s) backwards;
}
/* `display: flex` above would otherwise beat the attribute and draw the empty
   bar anyway. */
.ticker[hidden] { display: none; }
/* Exactly half the track's width per lap, and the two halves are identical, so
   the copy arrives at the moment the original leaves and there is no seam. */
.ticker-track { display: flex; width: max-content; animation: marquee 52s linear infinite; }
@keyframes marquee { to { transform: translateX(-50%); } }
.ticker-half { display: flex; }
.tick {
  display: flex; align-items: center; gap: 9px;
  padding: 0 13px; font-size: 12.5px; color: var(--muted); white-space: nowrap;
}
.tick b { color: var(--text); font-weight: 700; }
.tick .num { font-variant-numeric: tabular-nums; }
/* A bead between entries rather than a rule: at this height a vertical line
   reads as the bar being divided into cells. */
.tick::after {
  content: ""; width: 3px; height: 3px; border-radius: 50%;
  background: var(--line); margin-left: 13px;
}
@media (prefers-reduced-motion: reduce) {
  /* The bar stays and stops. Half of it is off-screen when it does, which is
     fine — it is a strip of prices, not a sentence with an end. */
  .ticker-track { animation: none; }
}

.auth {
  /* dvh follows the mobile URL bar; vh does not, which leaves a dead strip. */
  position: relative; z-index: 1;
  min-height: 100vh; min-height: 100dvh;
  display: grid; align-items: center;
  grid-template-columns: minmax(340px, 460px) minmax(320px, 1fr);
  gap: 40px; max-width: 1240px; margin: 0 auto;
  /* The ticker is fixed, so it is out of flow and would happily sit on top of
     the chips. The bottom padding is its height plus room to breathe, which
     also lifts the centred column clear of the horizon. */
  padding: 40px 28px calc(76px + env(safe-area-inset-bottom, 0px));
}
.auth-form-col { position: relative; z-index: 2; }
/* The badge names the platform, and the dot says the platform is up — the same
   pulsing dot the vault rows use, so "live" is drawn one way across the app. */
.auth-badge {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 5px 12px 5px 10px; margin-bottom: 22px;
}
.auth-badge .dot {
  flex: none; width: 6px; height: 6px;
  box-shadow: 0 0 0 2px var(--accent-soft);
}
.auth-logo { display: block; margin-bottom: 26px; }
/* Same two-line arithmetic as the topbar, with the room this page has: 48px
   sets VAULTS at a 10px cap under a 28px Titan. */
.auth-logo img { height: 48px; display: block; }
.auth-title {
  font-family: var(--display); font-size: clamp(34px, 4.4vw, 52px);
  line-height: 1.06; margin: 0 0 12px; font-weight: 700; letter-spacing: -1px;
}
/* The reference headline mixes a muted word with solid ones; same trick here. */
.auth-title .soft { color: var(--muted); font-weight: 500; }
/* --accent rather than --accent-fill: as a 52px glyph on the light theme the
   raw lime measures under 2:1 against white and stops being a shape at all,
   which is the whole reason the palette keeps a separate text accent. */
.title-dot { color: var(--accent); font-style: normal; }
/* ---- the heading, typed ----
   Each line is a block that holds its own height whether or not there is
   anything in it yet. Without the min-height the paragraph and the whole form
   below would ride up to the top of the page and then be pushed back down one
   character at a time, which is a page that cannot decide where it is. */
.tw-line { display: block; min-height: 1.06em; }
.tw-caret {
  display: inline-block; width: 4px; height: 0.72em; margin-left: 7px;
  border-radius: 2px; background: var(--accent-fill);
  vertical-align: baseline; translate: 0 0.02em;
  /* steps(1) and not a fade: a caret that eases in and out is a glow, and a
     glow is not what a text cursor does. */
  animation: caret-blink 0.75s steps(1) infinite;
}
.tw-caret[hidden] { display: none; }
@keyframes caret-blink { 0%, 45% { opacity: 1; } 50%, 100% { opacity: 0; } }
/* The mark lights when the last letter lands, and keeps breathing after. It is
   the one piece of punctuation on the page that means something, so it is worth
   the beat it takes to arrive. */
.title-dot.lit {
  text-shadow: 0 0 22px rgba(208, 245, 0, 0.7);
  animation: dot-glow 3s ease-in-out infinite;
}
@keyframes dot-glow { 0%, 100% { opacity: 0.72; } 50% { opacity: 1; } }
/* The caret is a 4px bar of pure fill, and on this theme the fill is lime —
   which over near-white is a text cursor nobody can see. --accent is the token
   that already means "this colour, but legible", so the caret takes it. */
:root[data-theme="light"] .tw-caret { background: var(--accent); }
:root[data-theme="light"] .title-dot.lit {
  text-shadow: 0 0 18px rgba(91, 46, 232, 0.45);
}
@media (prefers-reduced-motion: reduce) {
  .tw-caret { animation: none; }
  .title-dot.lit { animation: none; text-shadow: none; }
}
.auth-lead { color: var(--muted); font-size: 15px; margin: 0 0 26px; max-width: 34ch; }

.auth-form { max-width: 400px; }
.field { display: block; margin-bottom: 18px; }
/* The base caption. The admin form borrows this class for its own six labels,
   so it stays what it has always been and the sign-in page opts into the
   larger treatment below rather than imposing it on both. */
.field-label {
  display: block; font-size: 12px; color: var(--muted);
  margin: 0 0 7px 15px; letter-spacing: 0.2px;
}
/* Here the label used to be a 12px caption indented 15px so it lined up with
   the text inside a pill. The box is a rounded rectangle now, so the label
   lines up with the box; and at display weight it reads as part of the form
   being filled in rather than as a footnote above it. */
.auth-form .field-label {
  font-family: var(--display); font-size: 15px; font-weight: 600;
  color: var(--text); margin: 0 0 9px 2px;
}
/* Both fields carry a mark, so the wrapper is what a field *is* rather than the
   exception the password box used to be. */
.field-wrap { position: relative; display: block; }
.field-i {
  position: absolute; left: 17px; top: 50%; transform: translateY(-50%);
  display: flex; color: var(--muted-2); pointer-events: none;
  transition: color .15s;
}
/* Same cue the search box gives: the mark takes the accent while the box has
   the caret, so which of the two is being typed into is never in doubt. */
.field-wrap:focus-within .field-i { color: var(--accent); }
.field input {
  width: 100%; padding: 0 18px 0 50px; min-height: 52px;
  background: var(--field); border: 1px solid var(--line);
  border-radius: 16px; color: var(--text); font-size: 15px;
  font-family: inherit; appearance: none; -webkit-appearance: none;
  transition: border-color .15s, box-shadow .15s;
}
.field input::placeholder { color: var(--muted-2); }
/* A ring rather than a heavier border: the box does not change size, so nothing
   below it moves when the caret arrives. */
.field input:focus {
  outline: none; border-color: var(--accent-line);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.field input.bad { border-color: var(--down); }
.field input.bad:focus { box-shadow: 0 0 0 3px var(--bad-soft); }
/* The rest of the app answers focus in the accent, because on those pages the
   accent is the only colour that is not already carrying data. This page
   carries none, and the one thing on it that is genuinely lime is the button
   being aimed at — so the box being typed into lights violet instead, and the
   two never compete for the same glance. A ring and a bloom: the ring says
   which box, the bloom is what makes it look lit rather than outlined. */
.auth-form .field input:focus {
  border-color: rgba(167, 139, 250, 0.62);
  box-shadow: 0 0 0 3px rgba(124, 77, 255, 0.26),
              0 0 26px -4px rgba(139, 92, 255, 0.75);
}
.auth-form .field-wrap:focus-within .field-i { color: #b48cff; }
/* Restated at this weight or the violet above would outrank it and a rejected
   password would light the same colour as a correct one being typed. */
.auth-form .field input.bad:focus {
  border-color: var(--down); box-shadow: 0 0 0 3px var(--bad-soft);
}
/* The eye sits inside the field, so the text needs room to stop before it —
   and only that one field has an eye. */
.field-wrap--eye input { padding-right: 52px; }
.eye {
  position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
  width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
  background: none; border: 0; border-radius: 50%; cursor: pointer;
  color: var(--muted); transition: color .15s, background .15s;
}
.eye:hover { color: var(--accent); background: var(--accent-soft); }
.eye:focus-visible { outline: 2px solid var(--accent-line); outline-offset: -2px; }
/* Messages sit under their own field and take no room until there is one. They
   follow the label's inset, so label, box and message share one left edge. */
.field-err, .field-note {
  display: block; font-size: 12px; margin: 7px 0 0 2px; min-height: 0;
}
.field-err:empty { display: none; }
.field-err { color: var(--down); }
.field-note { color: var(--warn); }
.field-note[hidden] { display: none; }

.auth-form button[type="submit"] {
  width: 100%; padding: 17px; margin-top: 12px; background: var(--accent-fill);
  border: 0; border-radius: 16px; color: var(--on-accent);
  font-family: var(--display); font-weight: 700; font-size: 17px; cursor: pointer;
  display: flex; align-items: center; justify-content: center; gap: 10px;
  /* The sheen below runs off both ends of the button, and `relative` is what
     the sheen is positioned against. */
  position: relative; overflow: hidden;
  /* The lime spills a little of itself onto the page underneath. It is the only
     filled control on the page and the one thing the page is asking for, so it
     is allowed to be the brightest object on the screen. */
  box-shadow: 0 12px 34px -8px var(--accent-glow);
  transition: filter .15s, transform .15s, box-shadow .15s;
}
/* A band of light crossing the face every few seconds. It is the page's only
   unprompted movement inside the form, and it exists to say which of the things
   on this page is the one to press. Long dark stretch, short sweep: at rest it
   is parked off the left edge and there is nothing to see. */
.auth-form button[type="submit"]::after {
  content: ""; position: absolute; top: 0; bottom: 0; left: -60%;
  width: 45%; pointer-events: none;
  background: linear-gradient(100deg, transparent,
                              rgba(255, 255, 255, 0.7), transparent);
  transform: skewX(-18deg);
  /* `translate` rather than `transform`, which the skew above is holding. */
  animation: sheen 5.5s ease-in-out 1.4s infinite;
}
@keyframes sheen {
  0%        { translate: 0; }
  16%, 100% { translate: 380%; }
}
/* Hovering does not wait for the cycle: swapping the animation name restarts
   it, so the sweep leaves under the cursor. Leaving hands the button back to
   the slow loop, whose 1.4s delay keeps that from firing a second sweep on the
   way out. */
.auth-form button[type="submit"]:hover::after { animation: sheen-once 0.85s ease-out; }
@keyframes sheen-once { from { translate: 0; } to { translate: 380%; } }
.auth-form button[type="submit"]:hover {
  filter: brightness(1.06); transform: translateY(-2px);
  box-shadow: 0 20px 46px -8px var(--accent-glow);
}
.auth-form button[type="submit"]:active { transform: translateY(0); }
.auth-form button[type="submit"]:disabled {
  cursor: progress; filter: none; transform: none; opacity: 0.75;
  box-shadow: 0 12px 34px -8px var(--accent-glow);
}
/* Arrived. The button is still disabled — the page is leaving — but "disabled"
   is the wrong word for what just happened, so the dimming that says *wait* is
   taken back off and the button is at full strength for the beat before the
   dashboard replaces it. Written after `:disabled`, which it ties with on
   specificity and has to outrank. */
.auth-form button[type="submit"].done {
  cursor: default; opacity: 1;
  box-shadow: 0 20px 48px -8px var(--accent-glow);
  animation: done-pop 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.auth-form button[type="submit"].done::after { animation: none; opacity: 0; }
@keyframes done-pop {
  0% { scale: 1; } 40% { scale: 1.025; } 100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .auth-form button[type="submit"]:hover { transform: none; }
  .auth-form button[type="submit"]::after,
  .auth-form button[type="submit"]:hover::after,
  .auth-form button[type="submit"].done { animation: none; }
}
/* Only drawn while the form is in flight; the gap collapses the rest of the time. */
.spinner { display: none; width: 16px; height: 16px; flex: none; }
.busy .spinner {
  display: block; border: 2px solid rgba(18, 18, 35, 0.25);
  border-top-color: var(--on-accent); border-radius: 50%;
  animation: spin 0.7s linear infinite;
}
@media (prefers-reduced-motion: reduce) { .busy .spinner { animation-duration: 2.4s; } }
/* The tick that replaces the spinner once the server has said yes. It is drawn
   only in that state, so the gap it would otherwise hold collapses. */
.auth-tick { display: none; width: 18px; height: 18px; flex: none; }
.done .auth-tick {
  display: flex; animation: tick-in 0.34s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}
@keyframes tick-in { from { opacity: 0; scale: 0.2; } to { opacity: 1; scale: 1; } }
@media (prefers-reduced-motion: reduce) { .done .auth-tick { animation: none; } }
/* Tokens rather than the dark theme's pink written out: on the light page the
   old values were a saturated band that belonged to a different palette. */
.auth-err {
  padding: 10px 16px; margin-bottom: 14px; border-radius: 12px;
  background: var(--bad-soft); border: 1px solid var(--bad-line);
}
.auth-chips { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 28px; }
/* These three state facts; nothing here is a control, so the shared chip's
   hover — which recolours the text lime, the way a link would — is dropped.
   What is kept is the lift: it answers the cursor without promising that
   anything happens if you press, which is the honest half of the gesture. */
.auth-chips .chip {
  cursor: default; display: inline-flex; align-items: center; gap: 9px;
  padding: 9px 15px; font-size: 14px; color: var(--text);
  transition: transform .22s cubic-bezier(0.22, 1, 0.36, 1),
              border-color .2s, box-shadow .22s;
}
.auth-chips .chip:hover {
  color: var(--text); border-color: var(--accent-line);
  transform: translateY(-3px);
  box-shadow: 0 12px 22px -14px rgba(124, 77, 255, 0.95);
}
@media (prefers-reduced-motion: reduce) {
  .auth-chips .chip:hover { transform: none; }
}
.auth-chips .dot { flex: none; }
.cmark { display: inline-block; flex: none; width: 8px; height: 8px; border-radius: 50%; }
/* Read-only does nothing, so its mark is the outline of one. */
.cmark-ring { box-shadow: inset 0 0 0 1.5px var(--muted-2); }
/* Two venues, two discs, overlapping — the same idea the exchange legend uses,
   at the size a chip has room for. */
.cmark-pair { position: relative; width: 14px; border-radius: 0; }
.cmark-pair::before, .cmark-pair::after {
  content: ""; position: absolute; top: 0; width: 8px; height: 8px; border-radius: 50%;
}
.cmark-pair::before { left: 0; background: var(--accent-fill); }
.cmark-pair::after { right: 0; background: var(--violet); }

/* ---- the illustration ---- */
.auth-art {
  position: relative; height: min(560px, 74vh);
  display: flex; align-items: center; justify-content: center;
}
/* The small mascot below 900px and the large one above: only the rule that
   matches is fetched, so a phone downloads 90 KB instead of 230 KB for an
   image it draws at 180px. */
.auth-mascot {
  position: relative; z-index: 2; display: block;
  width: clamp(230px, 30vw, 380px); aspect-ratio: 1;
  background: url("/static/img/mascot-small.png") center / contain no-repeat;
  filter: drop-shadow(0 26px 60px rgba(0, 0, 0, 0.45));
  /* Two animations, handed over rather than layered: the pop runs for its
     0.8s and the float's delay is set to the moment the pop lands, so the
     one that owns `translate` is never in doubt. */
  animation: mascot-pop 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)
               calc(var(--enter) + 0.18s) backwards,
             float 8s ease-in-out calc(var(--enter) + 0.98s) infinite alternate;
}
/* Overshoot and settle. `scale` and `translate` and not `transform`, for the
   same reason the float and the spin use them: `transform` belongs to the
   pointer parallax, and an animation would take it away for good. */
@keyframes mascot-pop {
  from { opacity: 0; scale: 0.58; translate: 0 30px; }
  62%  { opacity: 1; scale: 1.06; translate: 0 -8px; }
  to   { opacity: 1; scale: 1;    translate: 0 0; }
}
@keyframes pop-small {
  from { opacity: 0; scale: 0.4; }
  to   { opacity: 1; scale: 1; }
}

/* ---- what happens on the mascot ----
   mascot.png is 520x730 inside a square box drawn `contain`, so the artwork
   fills the height and occupies the middle 71.23% of the width, inset 14.38%
   each side. Every `left` below is therefore `14.38% + fx * 71.23%` where fx is
   the position measured on the image itself; `top` is the raw fraction, because
   the fit is height-first. Get that arithmetic wrong and the fire pours out
   beside the boots rather than under them — which is why the numbers are here
   in full rather than hand-nudged until they looked right.

   All of it is inside .auth-mascot, so the pop, the float and the pointer
   parallax carry it: the fire stays under the rocket wherever the rocket is.

   There were eyelids here too, blinking every 4.6s. Removed on request. */

/* Embers off the two thrusters. Each falls, shrinks and goes out; six of them
   on cycles under a second and a half, offset enough that the stream never
   pulses in unison. */
.ember {
  position: absolute; border-radius: 50%;
  width: var(--s, 5px); height: var(--s, 5px); background: var(--c, #ffb156);
  animation: ember-fall var(--t, 1s) ease-in var(--d, 0s) infinite;
}
/* The drop is a variable because it is the one number here that is not a
   proportion: every position above is a percentage of the mascot and scales
   with it, and a fall written as 44px does not. The phone rule overrides it. */
@keyframes ember-fall {
  from { opacity: 0.9; translate: 0 0; scale: 1; }
  to   { opacity: 0; translate: 0 var(--fall, 44px); scale: 0.3; }
}
/* The left boot fires down and slightly out; the right one sits lower. */
.ember-1 { left: 23.0%; top: 88%; --s: 6px; --c: #ffb156; --t: 0.95s; --d: 0s; }
.ember-2 { left: 25.4%; top: 87%; --s: 4px; --c: #ffd23c; --t: 1.10s; --d: 0.35s; }
.ember-3 { left: 21.4%; top: 86%; --s: 5px; --c: var(--accent-fill); --t: 0.85s; --d: 0.6s; }
.ember-4 { left: 52.6%; top: 92%; --s: 6px; --c: #ffb156; --t: 1.00s; --d: 0.15s; }
.ember-5 { left: 55.0%; top: 91%; --s: 4px; --c: #ffd23c; --t: 0.90s; --d: 0.5s; }
.ember-6 { left: 51.0%; top: 90%; --s: 5px; --c: var(--accent-fill); --t: 1.15s; --d: 0.8s; }
@media (prefers-reduced-motion: reduce) {
  /* An ember is its own fall; standing still it is a dot on the artwork. */
  .ember { display: none; }
}
@media (min-width: 901px) {
  .auth-mascot { background-image: url("/static/img/mascot.png"); }
}
/* Two rings and the brand mark stand in for the hero's planet. */
.orbit {
  position: absolute; border-radius: 50%; border: 1px solid var(--line);
  pointer-events: none;
}
/* The rings only fade in. They are already turning when the page opens, and a
   ring that also grows into place reads as a loading spinner that never
   finishes — which is the one thing a sign-in page must not look like. */
.orbit-1 {
  width: min(460px, 78%); aspect-ratio: 1;
  border-color: rgba(167, 139, 250, 0.30);
  animation: spin 42s linear infinite,
             fade-in 0.7s ease calc(var(--enter) + 0.34s) backwards;
  border-top-color: var(--accent-fill); border-right-color: transparent;
}
.orbit-2 {
  width: min(600px, 100%); aspect-ratio: 1;
  border-style: dashed; border-color: rgba(167, 139, 250, 0.18);
  animation: spin 90s linear infinite reverse,
             fade-in 0.7s ease calc(var(--enter) + 0.42s) backwards;
}

/* A light running the track.
   The obvious build is a dot parented to the ring, which then needs no
   arithmetic at all — the ring turns, the dot goes round with it. It is also
   the wrong one: these rings take 42 and 90 seconds a lap, slow enough to read
   as structure rather than as movement, and a dot at that speed does not look
   like it is running anywhere. So the runner is its own element at the ring's
   exact size with its own, much faster spin, and the two are only related by
   sharing a width. What you see is a point of light lapping a ring that is
   barely turning, which is the thing that was asked for.

   Sharing a width is the whole contract, so `min(460px, 78%)` and
   `min(600px, 100%)` below are not values, they are copies: change a ring's
   size and its runner leaves the track. Both are centred the same way — no
   inset at all, so an absolutely positioned child takes the static position the
   flex centring on .auth-art gives it. */
.orbit-run { position: absolute; border-radius: 50%; pointer-events: none; }
/* Half the dot hangs outside the box, which puts its centre on the element's
   edge — where the ring's own one-pixel border sits. */
.orbit-run::before {
  content: ""; position: absolute; left: 50%; top: -4px;
  width: 8px; height: 8px; margin-left: -4px; border-radius: 50%;
  background: var(--accent-fill);
  box-shadow: 0 0 12px 2px rgba(208, 245, 0, 0.6);
}
.orbit-run-1 {
  width: min(460px, 78%); aspect-ratio: 1;
  animation: spin 16s linear infinite,
             fade-in 0.7s ease calc(var(--enter) + 0.50s) backwards;
}
/* Against the first, and violet rather than lime: two lights running the same
   way at nearly the same speed would read as one thing that had smeared. */
.orbit-run-2 {
  width: min(600px, 100%); aspect-ratio: 1;
  animation: spin 26s linear infinite reverse,
             fade-in 0.7s ease calc(var(--enter) + 0.58s) backwards;
}
.orbit-run-2::before {
  top: -3px; width: 6px; height: 6px; margin-left: -3px;
  background: #b48cff; box-shadow: 0 0 10px 2px rgba(180, 140, 255, 0.65);
}
.auth-mark {
  position: absolute; width: min(520px, 88%); height: min(520px, 88%);
  opacity: calc(var(--ornament) * 2.2); pointer-events: none;
  background: url("/static/img/symbol.svg") center / contain no-repeat;
}
:root[data-theme="light"] .auth-mark { background-image: url("/static/img/symbol-dark.svg"); }
/* All three are hidden below 900px, so all three are declared above it and
   fetched by nobody else. */
@media (min-width: 901px) {
  .auth-float {
    position: absolute; z-index: 3; pointer-events: none;
    background-position: center; background-size: contain; background-repeat: no-repeat;
    filter: drop-shadow(0 10px 22px rgba(0, 0, 0, 0.35));
  }
  /* Each pops in behind the mascot rather than with it — the three of them
     arriving at once would be a burst, and one at a time is the mascot bringing
     its own things with it. Same handover as the mascot: the float's delay is
     the moment its pop lands. */
  .auth-cube {
    width: 74px; height: 74px; top: 6%; right: 12%;
    background-image: url("/static/img/cube.png");
    animation: pop-small 0.62s cubic-bezier(0.34, 1.56, 0.64, 1)
                 calc(var(--enter) + 0.62s) backwards,
               float 6s ease-in-out calc(var(--enter) + 1.24s) infinite alternate;
  }
  /* The token art is the bare mark on transparency; a disc behind it turns it
     back into a coin instead of a stray glyph floating in space. */
  .auth-token {
    width: 66px; height: 66px; bottom: 14%; left: 6%;
    background-image: url("/static/img/token.png");
    background-origin: content-box; background-clip: content-box;
    padding: 13px; box-sizing: border-box;
    background-color: var(--panel-2); border: 1px solid var(--line); border-radius: 50%;
    animation: pop-small 0.62s cubic-bezier(0.34, 1.56, 0.64, 1)
                 calc(var(--enter) + 0.72s) backwards,
               float 7.5s ease-in-out calc(var(--enter) + 1.34s) infinite alternate;
  }
  .auth-eye {
    width: 72px; height: 72px; bottom: 4%; right: 18%;
    background-image: url("/static/img/eye.png");
    animation: pop-small 0.62s cubic-bezier(0.34, 1.56, 0.64, 1)
                 calc(var(--enter) + 0.82s) backwards,
               float 9s ease-in-out calc(var(--enter) + 1.44s) infinite alternate;
  }
}

@keyframes spin { to { rotate: 360deg; } }

/* Asked to stop moving, the page is simply already assembled: `animation: none`
   drops the rise and the pops back to the element's resting style, which is the
   state each of them was animating *towards*. Nothing is hidden by this. */
@media (prefers-reduced-motion: reduce) {
  .auth-mascot, .auth-float, .orbit, .orbit-run { animation: none; }
  .rise, .auth-topbar { animation: none; }
}
@media (max-width: 900px) {
  /* The bottom padding is not decoration: the ticker is fixed to the foot of
     the window and out of flow, so this is the only thing keeping the chips off
     it. The desktop rule reserves the same space and is overridden wholesale by
     this shorthand — which is exactly how it would go missing.
     The side insets matter here and not on a desktop: viewport-fit=cover plus a
     notched phone held sideways puts the column under the cutout. */
  .auth {
    grid-template-columns: 1fr; gap: 8px;
    padding: 84px calc(22px + env(safe-area-inset-right, 0px))
             calc(74px + env(safe-area-inset-bottom, 0px))
             calc(22px + env(safe-area-inset-left, 0px));
  }
  .auth-art { order: -1; height: 230px; }
  .auth-mascot { width: 180px; }
  /* mascot-small.png is not mascot.png scaled down — it is mascot.png
     **mirrored**. The robot waves with the other hand here and its thrusters
     are on the other side, which measures as a 0.999 silhouette overlap once
     one of them is flipped. So every position above lands on the wrong half of
     the artwork on a phone: the fire pours out beside the boots.
     Mirroring is exactly what swapping the edge does. The numbers are the same
     numbers, measured from the other side, and `left: auto` is needed because
     the desktop rule is still there underneath. */
  .ember { left: auto; }
  .ember-1 { right: 23.0%; }
  .ember-2 { right: 25.4%; }
  .ember-3 { right: 21.4%; }
  .ember-4 { right: 52.6%; }
  .ember-5 { right: 55.0%; }
  .ember-6 { right: 51.0%; }
  /* The embers fall a fixed number of pixels, and the mascot here is 180px
     against a desktop's 380 — the same 44px drop is a quarter of the robot's
     height instead of a tenth, so the fire falls out from under it. */
  .ember { --fall: 22px; }
  /* Three clouds sized in vw, on a screen where vw is 390px: they came out as
     small patches rather than as sky. Re-sized against the tall axis, which is
     the one a phone has plenty of. */
  .neb-1 { left: -30%; top: -6%;  width: 130vw; height: 46vh; }
  .neb-2 { left: 12%;  top: 24%;  width: 120vw; height: 42vh; }
  .neb-3 { left: -20%; top: 58%;  width: 140vw; height: 44vh; }
  /* Tighter beads, and a lap that is not a crawl: the same 52s spent crossing
     390px instead of 1440 reads as a stalled bar. */
  .tick { padding: 0 10px; font-size: 12px; }
  .tick::after { margin-left: 10px; }
  .ticker-track { animation-duration: 30s; }
  /* The outer ring goes on a phone, and its runner has nothing left to run on. */
  .auth-float, .orbit-2, .orbit-run-2 { display: none; }
  .auth-title { font-size: 30px; }
  /* The picker floats over the art, so the column needs headroom for it. */
  .auth-topbar {
    padding: 16px calc(16px + env(safe-area-inset-right, 0px)) 16px 16px;
  }
  .auth-form, .auth-lead { max-width: 100%; }
  .auth-form button { padding: 18px; }
  /* Only the height grows on a phone — the left inset still belongs to the
     field mark, so it is restated rather than overwritten by a shorthand. */
  .field input { padding: 0 18px 0 50px; min-height: 56px; }
  .field-wrap--eye input { padding-right: 58px; }
  /* The eye was drawn at 18px inside a 40px button — fine beside a 44px field
     on a desktop, and small enough on a 56px one to be hunted for. The button
     grows to the field and the glyph grows with it; a reveal control that is
     hard to hit is a password typed twice. */
  .eye { width: 48px; height: 48px; right: 4px; }
  .eye svg { width: 22px; height: 22px; }
  .field-i svg { width: 20px; height: 20px; }
  .field-i { left: 16px; }
  /* The art moves to the top of a phone and the form fills the rest, so the
     three motes move with it — left where they are they would drift across the
     password box. */
  .mote-1 { left: 78%; top: 6%; }
  .mote-2 { left: 15%; top: 11%; }
  .mote-3 { left: 90%; top: 20%; }
  /* A phone stacks the art above the column and scrolls, so there is no
     "under the chips" to measure — the horizon is fixed to the bottom of the
     window and whatever is being read passes over it. Kept shallow for that
     reason: it is something glimpsed behind the form, not a band the form has
     to stay out of. */
  .horizon { height: clamp(140px, 20vh, 200px); }
  /* The hill's rise is a fixed share of its box, so how steep it *looks* is
     that rise measured against the width — and a phone is narrow. The curve
     that climbs 9% of the width on a desktop climbs 25% here, which is not a
     hill in the distance, it is a hill you are standing on.
     The fix is not a second curve but less of this one. The mask is drawn 200%
     as wide as the element and centred, so what shows is the crest and the
     ground either side of it while both steep skirts fall off the edges — back
     to 9%, which is exactly where a desktop has it. */
  .horizon-arc, .horizon-arc::before {
    -webkit-mask-size: 200% 100%; mask-size: 200% 100%;
    -webkit-mask-position: bottom center; mask-position: bottom center;
  }
  .auth-badge { margin-bottom: 16px; }
  .auth-logo { margin-bottom: 18px; }
  .auth-logo img { height: 40px; }
}

/* Japanese sets tighter than Latin at the same size; a touch more line height
   keeps the dense label/value rows from crowding. */
:root[lang="ja"] body { line-height: 1.55; }
:root[lang="ja"] .kv { grid-template-columns: 190px 1fr; }
:root[lang="ja"] h1 { letter-spacing: 0; }
:root[lang="ja"] .tabbar button, :root[lang="ja"] .panel-tabs button { letter-spacing: 0; }

/* =========================================================
   Phones
   Below 760px the page is not the desktop layout made smaller — it is the same
   data in shapes a 390px screen can hold. The top bar splits into two rows and
   the language picker shrinks to its flag and opens as a sheet; the account
   table becomes a list of cards; the six-tab strip becomes a select; and every
   data table prints three columns, folding the rest into the row.
   ========================================================= */
@media (max-width: 760px) {
  /* The backdrop glows are sized in vmax, so on a phone one of them spans most
     of the screen and a bright band can land squarely on a single table row —
     which then reads as a selected row. Panels go nearly opaque here; the
     gradient still shows around and between them. */
  :root { --panel: rgba(22, 16, 48, 0.96); }
  :root[data-theme="light"] { --panel: rgba(255, 255, 255, 0.96); }

  .topbar {
    /* Two rows on a phone — brand and controls, then the nav. Sticky would then
       eat a hundred pixels of an 844px screen, so it scrolls away instead and
       the sticky table headers take over that job. */
    position: static;
    height: auto; flex-wrap: wrap; gap: 8px 10px;
    padding: 8px 14px;
    padding-left: calc(14px + env(safe-area-inset-left, 0px));
    padding-right: calc(14px + env(safe-area-inset-right, 0px));
    /* The blur exists to soften content sliding under a *sticky* bar. Static,
       it only creates a stacking context — one that trapped the language menu
       behind the page, and would pin a position:fixed sheet to this bar
       instead of to the screen. */
    backdrop-filter: none;
  }
  /* Row one is brand + controls, row two is the nav with the clock pushed to
     its right edge. */
  .brand img { height: 32px; }
  /* This used to hide the topbar's "Vaults" tag, which the lockup now states
     itself. The sign-in badge shared the rule and shared the reasoning — it
     only ever had room on a wide screen — so it keeps it, by name. */
  .auth-badge { display: none; }
  .topbar-right { gap: 8px; }
  .nav { order: 3; gap: 18px; font-size: 15px; }
  #clock { order: 4; }
  .pill { padding: 0 10px; font-size: 11px; min-height: 40px;
          display: inline-flex; align-items: center; }
  /* Flag plus caret is enough to say which language is on; the name would cost
     a third of the row. */
  .picker--flags .picker-current span { display: none; }
  .picker--flags .picker-btn { padding: 0 10px 0 7px; }
  /* The list itself becomes a sheet on the bottom edge: anchored under the
     button it either ran off the side of a 390px screen or was overlapped by
     the page, and its 38px rows were a poor thumb target either way. */
  .picker-scrim {
    display: block; position: fixed; inset: 0; z-index: 60;
    background: rgba(8, 5, 24, 0.55);
  }
  .picker-pop, .picker--block .picker-pop {
    position: fixed; z-index: 61; inset: auto 0 0 0; top: auto;
    min-width: 0; width: auto; max-width: none; max-height: 76vh; overflow-y: auto;
    padding: 0 12px calc(14px + env(safe-area-inset-bottom, 0px));
    background: var(--panel-solid);
    border: 0; border-top: 1px solid var(--line);
    border-radius: 20px 20px 0 0;
    animation: sheet-up 0.18s ease-out;
  }
  .picker-menu { max-height: none; padding: 0; }
  /* The grab handle every sheet on a phone has. */
  .picker-pop::before {
    content: ""; display: block; width: 38px; height: 4px; margin: 8px auto 6px;
    border-radius: 999px; background: var(--line);
  }
  /* Unmistakably a heading now: its own row, its own border, outside the list —
     and carrying the way out. A sheet covers the bottom of the screen, so the
     only exit used to be the dimmed strip above it, which is not a thing anyone
     can see is a button. */
  .picker-head {
    display: flex; align-items: center; gap: 10px;
    padding: 4px 0 8px 4px; margin-bottom: 4px;
    border-bottom: 1px solid var(--line-soft);
    color: var(--muted-2); font-size: 11px; letter-spacing: 0.8px;
    text-transform: uppercase; font-weight: 600;
  }
  .picker-head-t { flex: 1; min-width: 0; }
  .picker-x {
    flex: none; display: flex; align-items: center; justify-content: center;
    width: 34px; height: 34px; margin: -4px 0; border-radius: 50%;
    background: var(--field); border: 1px solid var(--line);
    color: var(--muted); cursor: pointer;
  }
  .picker-x:active { color: var(--text); }
  .picker-menu li { padding: 14px 12px; font-size: 16px; gap: 14px; }
  .picker-menu li.on { background: var(--accent-soft); }
  .picker-menu .flag { width: 24px; height: 24px; }
  body.sheet-open { overflow: hidden; }
  .btn-ghost { padding: 0 14px; font-size: 12px; }
  .btn-icon { width: 40px; padding: 0; }
  /* Anything tappable gets a 40px-tall hit area — the size a thumb actually
     needs, independent of how tall its text happens to be. */
  .nav a, .btn-ghost, .picker-btn, .tabbar button,
  .panel-tabs button {
    min-height: 40px; display: inline-flex; align-items: center;
  }
  .tabbar button, .panel-tabs button { justify-content: center; }
  .brand { min-height: 40px; }
  .crumbs a { display: inline-block; padding: 6px 0; }
  #vault-rows tr { cursor: pointer; }
  #vault-rows td:first-child a { display: inline-block; padding: 5px 0; }

  .wrap {
    padding: 16px 14px 56px;
    padding-left: calc(14px + env(safe-area-inset-left, 0px));
    padding-right: calc(14px + env(safe-area-inset-right, 0px));
  }
  h1 { font-size: 27px; letter-spacing: -0.4px; }
  .page-head { margin-bottom: 16px; }
  .crumbs { margin-bottom: 8px; }

  /* Two per row: these are short numbers, and one per row pushed the chart two
     screens down. */
  .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
  .card { padding: 13px 14px; border-radius: 12px; }
  .card-label { font-size: 11px; margin-bottom: 6px; }
  .card-value { font-size: 20px; }
  .card-sub { font-size: 11px; margin-top: 6px; }
  .badge { display: block; margin: 6px 0 0; width: fit-content; }

  .split { gap: 10px; }
  .panel-body { padding: 14px; }
  .kv { grid-template-columns: 130px 1fr; gap: 8px 10px; font-size: 12.5px; }
  :root[lang="ja"] .kv, :root[lang="ko"] .kv { grid-template-columns: 150px 1fr; }
  .chart-host { height: 230px; }
  .panel-tabs { gap: 16px; padding: 0 14px; }
  .panel-tabs button { padding: 13px 0; }
  .tip { min-width: 110px; font-size: 11px; }

  /* Six tabs never fitted; the strip scrolled, so two of them were simply
     invisible. A select shows the current table, its row count, and — when
     tapped — every other table at once. */
  .tabbar { display: none; }
  .tabpick { display: flex; align-items: center; gap: 8px; margin: 18px 0 10px; }
  .tabpick .picker--block { flex: 1; min-width: 0; }
  .sortbar { display: flex; align-items: center; gap: 8px; }
  .sortbar .picker--block { flex: 1; min-width: 0; }
  /* These are primary controls on a phone, not a corner affordance. */
  .picker--block .picker-btn {
    padding: 0 14px; min-height: 40px; border-radius: 12px; font-size: 14px;
  }

  /* Only the columns worth a glance are printed; the rest live one tap away in
     the row itself, which beats swiping nine columns sideways. */
  .table-host { -webkit-overflow-scrolling: touch; }
  thead th, tbody td { padding: 10px 9px; font-size: 12px; }
  /* Three columns only fit if the widest two give up their second value: the
     leverage and side chips drop under the ticker, the return under the PnL. */
  .market { flex-wrap: wrap; gap: 5px; }
  .lev { font-size: 10px; padding: 2px 6px; }
  tbody td .sub { display: block; font-size: 11px; opacity: 0.85; }
  td.chev, th.chev {
    display: table-cell; width: 26px; padding-left: 0; padding-right: 10px;
    color: var(--muted-2); text-align: right;
  }
  tr.open td.chev { color: var(--accent); }

  /* The account list stops being a table: eleven columns on a phone meant ten
     of them were off-screen. */
  #vault-panel { display: none; }
  .vcards { display: grid; gap: 10px; }
  /* The first column used to be pinned so a sideways swipe kept the market in
     view. Nothing swipes any more, and an opaque column over a translucent
     panel printed a seam down the table wherever the backdrop glow was bright,
     so it is gone. */
  .empty { padding: 26px 16px; }
  .empty img { width: 76px; }

  /* Search owns the first row, the two filters share the second. */
  .toolbar { gap: 8px; }
  /* Horizontal padding only. The vertical 11px predates `--control-h` and quietly
     outvoted it: with the 16px text iOS requires, it made the field 43px while
     every other control on the row was 40 — the one thing the token exists to
     prevent. An input centres its own value, so the height governs. */
  .input { flex: 1 1 100%; min-width: 0; padding: 0 14px; }
  .toolbar #exch-filter { flex: 1 1 38%; }
  .toolbar .sortbar { flex: 1 1 52%; }
  .hero { padding: 18px 16px; border-radius: 16px; }
  .hero-grid { gap: 14px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .hero-grid .card-value { font-size: 19px; }
  .hero-title { font-size: 19px; }
}

@media (max-width: 400px) {
  /* A few pixels here decide whether the control row wraps a third time. */
  .topbar { padding-left: 12px; padding-right: 12px; gap: 8px; }
  .topbar-right { gap: 6px; }
  .brand img { height: 28px; }
}

@media (max-width: 380px) {
  .stat-grid { grid-template-columns: 1fr; }
  .nav { gap: 14px; font-size: 14px; }
}

@media (max-width: 359px) {
  /* The phone shows its own clock; the data age is the part that matters and it
     moves to the vault's Status row rather than costing a whole extra row here. */
  #clock { display: none; }
}

/* iOS zooms the whole page when a focused field is under 16px. */
@media (max-width: 900px) {
  .field input, .input { font-size: 16px; }
}

/* A touch screen has no pointer to move away, so :hover sticks to whatever was
   last tapped — the sort button stayed lit lime long after the tap. Devices
   without hover get the resting style back; :active still gives feedback. */
@media (hover: none) {
  .btn-ghost:hover {
    color: var(--muted); background: transparent; border-color: var(--line);
  }
  .btn-ghost:active { color: var(--on-accent); background: var(--accent-fill); }
  tbody tr:hover { background: none; }
  .picker-menu li:hover { background: none; }
  .picker-menu li.on { background: var(--accent-soft); }
  .hero:hover, .picker-btn:hover, .chip:hover { border-color: var(--line); }
  .chip:hover { color: var(--muted); }
  /* The lifts on the sign-in page have the same problem the colours do: a
     tapped chip would stay raised until something else was tapped. */
  .auth-chips .chip:hover,
  .auth-topbar .btn-ghost:hover, .auth-topbar .picker-btn:hover {
    transform: none; box-shadow: none;
  }
  .auth-chips .chip:hover, .auth-topbar .picker-btn:hover { border-color: var(--line); }
  .nav a:hover:not(.on), .crumbs a:hover { color: var(--muted); }
}

/* =========================================================
   Additions
   New surfaces and controls: the ones that make the page usable without a
   mouse, the ones that make it survive a dropped connection, and the ones that
   answer a question the numbers alone could not.
   ========================================================= */

/* ---------- keyboard ----------
   Only two controls used to draw a focus ring, so tabbing through the page was
   a guess. `:where` carries no specificity, which leaves the hand-tuned rings
   on the picker and the eye button exactly as they were. */
:where(a, button, input, select, textarea, summary, [tabindex]):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 10px;
}

/* The first thing a keyboard reaches, and invisible to everything else. */
.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
}
.skip:focus {
  left: 10px; top: 10px; padding: 10px 18px;
  background: var(--accent-fill); color: var(--on-accent);
  border-radius: 999px; font-weight: 700;
}

/* ---------- connection banner ----------
   A refresh that fails now says so. It sits over the page rather than in it,
   because whatever is underneath is still true — just older than it looks. */
.netbar {
  position: fixed; left: 50%; bottom: 18px; transform: translate(-50%, 200%);
  z-index: 70; display: flex; align-items: center; gap: 12px;
  padding: 10px 12px 10px 16px; border-radius: 999px;
  background: var(--panel-solid); border: 1px solid rgba(255, 92, 122, 0.45);
  box-shadow: var(--shadow); font-size: 13px; color: var(--text);
  transition: transform .22s ease-out;
  max-width: min(92vw, 460px);
}
.netbar.on { transform: translate(-50%, 0); }
.netbar.ok { border-color: var(--accent-line); }
.netbar-msg { min-width: 0; }
.netbar-btn {
  background: var(--accent-fill); color: var(--on-accent); border: 0;
  border-radius: 999px; padding: 6px 14px; font-size: 12px; font-weight: 700;
  cursor: pointer; flex: none;
}
.netbar-btn:empty { display: none; }
@media (prefers-reduced-motion: reduce) { .netbar { transition: none; } }
@media (max-width: 760px) { .netbar { bottom: calc(74px + env(safe-area-inset-bottom, 0px)); } }

/* ---------- privacy mode ----------
   Blurs what the numbers *are* and keeps everything else: which market, how
   many rows, which account is red. The page stays readable as a page while
   being unreadable as a balance. */
body.private .num,
body.private .card-value,
body.private .card-sub,
body.private .kv dd,
body.private .kv-mini dd,
body.private .tip-val,
body.private .badge,
body.private .bar-key b,
body.private tbody td:not(.text):not(.chev) {
  filter: blur(6px);
  user-select: none;
}
/* The trend line is a shape, not an amount, so it survives the blur — which is
   most of what makes the mode usable rather than merely private. */
body.private .spark { filter: none; }

/* ---------- value ticks ----------
   Half a second of colour where a number just moved. */
@keyframes tick-up-flash {
  from { background: rgba(208, 245, 0, 0.26); }
  to   { background: transparent; }
}
@keyframes tick-down-flash {
  from { background: rgba(255, 92, 122, 0.24); }
  to   { background: transparent; }
}
.tick-up { animation: tick-up-flash .55s ease-out; border-radius: 6px; }
.tick-down { animation: tick-down-flash .55s ease-out; border-radius: 6px; }
@media (prefers-reduced-motion: reduce) {
  .tick-up, .tick-down { animation: none; }
}

/* ---------- the refresh ring ----------
   The age beside the clock says how old the numbers are. This says how long
   until they are replaced. */
#clock { display: inline-flex; align-items: center; gap: 8px; }
.ring {
  width: 12px; height: 12px; border-radius: 50%; flex: none;
  background: conic-gradient(var(--accent) calc(var(--p, 0) * 360deg), var(--line) 0);
  mask: radial-gradient(circle, transparent 52%, #000 55%);
  -webkit-mask: radial-gradient(circle, transparent 52%, #000 55%);
}
.pill.live.stale .ring { background: conic-gradient(var(--warn) calc(var(--p, 0) * 360deg), var(--line) 0); }

/* ---------- skeletons ----------
   Held in the shape of what is coming, so the wait does not look like an error
   and the content does not jump when it lands. */
.sk {
  display: inline-block; height: 12px; border-radius: 6px; vertical-align: middle;
  background: linear-gradient(90deg, var(--line-soft), var(--line), var(--line-soft));
  background-size: 200% 100%;
  animation: shimmer 1.4s linear infinite;
}
@keyframes shimmer {
  from { background-position: 200% 0; }
  to   { background-position: -200% 0; }
}
@media (prefers-reduced-motion: reduce) { .sk { animation: none; } }
.sk-sm { height: 8px; display: block; margin-bottom: 6px; }
.sk-dot { width: 8px; height: 8px; border-radius: 50%; }
.sk-table { width: 100%; border-collapse: collapse; }
.sk-table td { padding: 13px 14px; border-bottom: 1px solid var(--line-soft); }
.empty.sk-host, .table-host.sk-host { display: block; padding: 0; }
.sk-card { pointer-events: none; }

/* ---------- segmented control ----------
   Five one-syllable options that used to be a dropdown: two clicks to change
   one thing, and the set invisible until opened. */
.seg {
  display: inline-flex; align-items: center; gap: 2px; flex: none;
  padding: 3px; border-radius: 999px;
  background: var(--field); border: 1px solid var(--line);
}
.seg-btn {
  background: none; border: 0; cursor: pointer; border-radius: 999px;
  padding: 5px 11px; font-size: 12px; color: var(--muted);
  transition: color .15s, background .15s;
  white-space: nowrap;
}
.seg-btn:hover { color: var(--text); }
.seg-btn.on { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
@media (hover: none) { .seg-btn:hover { color: var(--muted); } .seg-btn.on { color: var(--accent); } }

/* ---------- metric explainers ---------- */
.card-label { display: flex; align-items: center; gap: 6px; }
.info-dot {
  width: 16px; height: 16px; flex: none; padding: 0; cursor: pointer;
  border-radius: 50%; border: 1px solid var(--line); background: transparent;
  color: var(--muted-2); font-size: 10px; font-weight: 700; line-height: 1;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--body); font-style: italic;
}
.info-dot:hover { color: var(--accent); border-color: var(--accent-line); }
.infopop {
  position: absolute; z-index: 65; max-width: min(300px, 84vw);
  padding: 10px 13px; border-radius: 12px; font-size: 12.5px; line-height: 1.5;
  background: var(--panel-2); border: 1px solid var(--line);
  box-shadow: var(--shadow); color: var(--text);
}
.infopop.hidden { display: none; }

/* ---------- command palette ----------
   Twenty accounts, two keystrokes. */
.cmd {
  position: fixed; inset: 0; z-index: 80;
  display: flex; align-items: flex-start; justify-content: center;
  padding: min(14vh, 110px) 16px 16px;
}
.cmd.hidden { display: none; }
.cmd-scrim { position: absolute; inset: 0; background: rgba(8, 5, 24, 0.6); }
.cmd-box {
  position: relative; width: min(560px, 100%);
  background: var(--panel-solid); border: 1px solid var(--line);
  border-radius: 18px; box-shadow: var(--shadow); overflow: hidden;
}
.cmd-input {
  width: 100%; border: 0; border-bottom: 1px solid var(--line-soft);
  background: transparent; color: var(--text);
  padding: 16px 18px; font-size: 15px; font-family: var(--body);
}
.cmd-input:focus { outline: none; }
.cmd-list { margin: 0; padding: 8px; list-style: none; max-height: 52vh; overflow-y: auto; }
.cmd-group {
  padding: 8px 12px 4px; font-size: 11px; letter-spacing: 0.7px;
  text-transform: uppercase; color: var(--muted-2);
}
.cmd-item {
  display: flex; align-items: center; gap: 10px; justify-content: space-between;
  padding: 10px 12px; border-radius: 10px; cursor: pointer; font-size: 14px;
}
.cmd-item.on { background: var(--accent-soft); color: var(--accent); }
.cmd-empty { padding: 22px 12px; text-align: center; color: var(--muted-2); font-size: 13px; }
.keyhelp { padding: 20px 22px; }
.keyhelp h2 { margin: 0 0 14px; font-size: 18px; }
.keys { display: grid; grid-template-columns: auto 1fr; gap: 10px 16px; margin: 0; font-size: 13px; }
.keys dt { margin: 0; }
.keys dd { margin: 0; color: var(--muted); }
kbd {
  display: inline-block; padding: 3px 8px; border-radius: 7px; font-size: 12px;
  background: var(--field); border: 1px solid var(--line); font-family: var(--body);
}

/* ---------- pull to refresh ---------- */
.ptr {
  position: fixed; top: 0; left: 50%; z-index: 55;
  transform: translate(-50%, -100%); opacity: 0;
  display: flex; align-items: center; gap: 8px;
  padding: 8px 16px; border-radius: 0 0 14px 14px; font-size: 12px;
  background: var(--panel-solid); border: 1px solid var(--line); border-top: 0;
  color: var(--muted); pointer-events: none;
}
.ptr.on {
  opacity: 1;
  transform: translate(-50%, calc(-100% + var(--pull, 0px)));
}
.ptr.ready { color: var(--accent); border-color: var(--accent-line); }
.ptr-spin {
  width: 12px; height: 12px; border-radius: 50%; flex: none;
  border: 2px solid var(--line); border-top-color: var(--accent);
}
.ptr.busy .ptr-spin { animation: spin .7s linear infinite; }

/* ---------- "while you were away" ---------- */
.away {
  display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
  padding: 10px 14px; margin-bottom: 14px; border-radius: 12px;
  background: var(--accent-soft); border: 1px solid var(--accent-line);
  font-size: 13px;
}
.away-t { font-weight: 700; }
.away-x {
  margin-left: auto; background: none; border: 0; cursor: pointer;
  color: var(--muted); font-size: 18px; line-height: 1; padding: 0 4px;
}
.away-x:hover { color: var(--text); }

/* ---------- sparklines ----------
   Is this account climbing or sliding — for the whole list at once. */
.spark { display: block; }
.spark-none { color: var(--muted-2); }
td.spark-cell, th.spark-cell { width: 86px; padding-left: 6px; padding-right: 14px; }
th.spark-cell { cursor: default; }
td.spark-cell > .spark { margin-left: auto; }
.vcard-spark { margin-top: 10px; }
.vcard-spark .spark { width: 100%; height: 34px; }

/* ---------- pinning ---------- */
.pin {
  background: none; border: 0; cursor: pointer; flex: none; padding: 2px 4px;
  color: var(--muted-2); font-size: 13px; line-height: 1;
  opacity: 0.45; transition: opacity .15s, color .15s;
}
/* The star is always drawn. Revealing it on hover meant the way to keep an
   account at the top of the list was a control you could only find by pointing
   at a row and noticing something appear — which is not finding it, it is
   stumbling on it. Resting at 45% it is legible without turning the first
   column into a row of stars competing with the numbers; hovering, focusing or
   pinning brings it up to full. */
tr:hover .pin, .vcard:hover .pin, .pin:hover, .pin:focus-visible { opacity: 1; }
.pin.on { opacity: 1; color: var(--accent); }
.pin:hover { color: var(--accent); }
tr.pinned td:first-child, .vcard.pinned { box-shadow: inset 2px 0 0 var(--accent); }

/* ---------- compare ---------- */
.cmp-box {
  width: 16px; height: 16px; flex: none; padding: 0; cursor: pointer;
  border-radius: 5px; border: 1.5px solid var(--line); background: transparent;
}
.cmp-box.on { background: var(--accent-fill); border-color: var(--accent-fill); }
.cmp { padding: 16px 18px; margin-bottom: 14px; }
.cmp-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.cmp-head strong { font-family: var(--display); font-size: 15px; }
.cmp-head .btn-ghost { margin-left: auto; }
.cmp-hint { color: var(--muted-2); font-size: 13px; }
.cmp-chart svg { display: block; width: 100%; height: 220px; }

/* ---------- share bars ----------
   Exposure, allocation: one row that answers a question the table could only
   answer by being added up. */
.bar {
  display: flex; height: 10px; border-radius: 999px; overflow: hidden;
  background: var(--field); gap: 2px;
}
.bar-part { background: var(--c, var(--muted-2)); min-width: 2px; }
.bar-legend {
  display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; font-size: 12px;
  color: var(--muted);
}
.bar-key { display: inline-flex; align-items: center; gap: 6px; }
.bar-dot {
  width: 8px; height: 8px; border-radius: 3px; flex: none;
  background: var(--c, var(--muted-2));
}
.bar-key b { color: var(--text); font-weight: 600; }

.ex-bitget { --c: var(--ex-bitget); }
.ex-gate { --c: var(--ex-gate); }
.ex-kucoin { --c: var(--ex-kucoin); }
.ex-other { --c: var(--muted-2); }
.al-1 { --c: var(--accent-fill); }
.al-2 { --c: #7c4dff; }
.al-3 { --c: #43d9ff; }
.al-4 { --c: #ff9f43; }
.al-5 { --c: #ff5c7a; }
.al-6 { --c: #8ee06a; }
.al-rest { --c: var(--muted-2); }
.up-fill { --c: var(--up); }
.down-fill { --c: var(--down); }

.alloc { padding: 14px 18px; margin-bottom: 14px; }
.alloc-head, .expo-head {
  display: flex; align-items: center; gap: 10px; margin-bottom: 10px;
  font-size: 12px; color: var(--muted); letter-spacing: 0.3px;
}
.expo-head .num { margin-left: auto; font-weight: 600; }
.expo { padding: 14px 18px; border-bottom: 1px solid var(--line-soft); }
.panel-body .alloc { padding: 16px 0 0; margin: 14px 0 0; border-top: 1px solid var(--line-soft); }

/* ---------- risk lens ----------
   The table carried a liquidation price and a mark price and left the
   subtraction to the reader. This is the number they were doing it for. */
.risk { display: inline-flex; align-items: center; gap: 8px; justify-content: flex-end; }
.risk-num { font-size: 12px; color: var(--c, var(--muted)); font-weight: 600; }
.risk-bar {
  width: 46px; height: 5px; border-radius: 999px; background: var(--field);
  overflow: hidden; flex: none;
}
.risk-bar i { display: block; height: 100%; background: var(--c, var(--muted-2)); }
.risk-ok { --c: var(--up); }
.risk-warn { --c: var(--warn); }
.risk-danger { --c: var(--down); }
/* A row whose liquidation price is close is the one thing on this page worth
   interrupting somebody for, so it is tinted rather than merely annotated. */
tbody tr.risk-danger > td { background: rgba(255, 92, 122, 0.09); }
tbody tr.risk-warn > td { background: rgba(255, 180, 87, 0.07); }

/* ---------- chart bar ---------- */
.chart-bar {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 10px 14px; border-bottom: 1px solid var(--line-soft);
}
.chart-stats { display: flex; gap: 16px; flex-wrap: wrap; margin-left: auto; }
.cstat { display: flex; flex-direction: column; gap: 1px; }
.cstat i { font-style: normal; font-size: 10.5px; color: var(--muted-2); }
.cstat b { font-size: 12px; font-weight: 600; }
.chart-line { stroke-dasharray: none; }
.tip.below { transform: translate(-50%, 22%); }

/* ---------- table tools ---------- */
.tabtools { display: flex; align-items: center; gap: 8px; margin: 12px 0 10px; flex-wrap: wrap; }
.tabtools .input { flex: 0 1 260px; min-width: 0; }
.tabtools .btn-ghost { flex: none; }
.chip-btn[hidden] { display: none; }
.chip-btn.on { color: var(--on-accent); background: var(--accent-fill); border-color: var(--accent-fill); }
.toolbar-end { display: flex; align-items: center; gap: 8px; margin-left: auto; }

/* ---------- bottom navigation ----------
   Desktop has a top bar that stays put; a phone deliberately does not, so this
   is where getting anywhere lives. Hidden entirely above the breakpoint. */
.botnav { display: none; }

@media (max-width: 760px) {
  /* The scroll region belongs to a desktop window with a fixed height. On a
     phone the page itself is the scroller, so the table simply grows. */
  .table-host { max-height: none; background: none; }

  /* The tab strip's stand-in stays put instead: one row that says which table
     is on screen, which is the orientation the top bar gave up when it agreed
     to scroll away. */
  .tabpick {
    position: sticky; top: 0; z-index: 15;
    margin-left: -14px; margin-right: -14px;
    padding: 8px 14px;
    background: var(--panel-solid);
    border-bottom: 1px solid var(--line-soft);
  }

  .tabtools { gap: 8px; }
  .tabtools .input { flex: 1 1 100%; }
  .tabtools .btn-ghost { flex: 1 1 40%; justify-content: center; }
  .toolbar-end { flex: 1 1 100%; margin-left: 0; }
  .toolbar-end .btn-ghost { flex: 1; justify-content: center; }

  .chart-bar { padding: 10px 14px; gap: 10px; }
  .chart-stats { margin-left: 0; gap: 12px; width: 100%; justify-content: space-between; }
  .seg { width: 100%; justify-content: space-between; }
  .seg-btn { flex: 1; text-align: center; padding: 8px 6px; min-height: 36px; }
  /* Four chart modes will not fit a 360px row, so the strip scrolls rather
     than wrapping into two rows of tabs. */
  .panel-tabs { overflow-x: auto; scrollbar-width: none; }
  .panel-tabs::-webkit-scrollbar { display: none; }

  .expo, .alloc { padding: 12px 14px; }
  .cmp { padding: 12px 14px; }
  .cmp-chart svg { height: 180px; }

  /* Room for the bar, plus whatever the phone reserves below it. */
  body { padding-bottom: calc(62px + env(safe-area-inset-bottom, 0px)); }
  .botnav {
    position: fixed; left: 0; right: 0; bottom: 0; z-index: 50;
    /* However many destinations there are. It was four because there were four;
       a fifth appeared and the row would have kept sharing a quarter each. */
    display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
    background: var(--bar-bg); backdrop-filter: blur(14px);
    border-top: 1px solid var(--line);
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
  .botnav a, .botnav button {
    display: flex; flex-direction: column; align-items: center; justify-content: center;
    gap: 3px; padding: 8px 2px; min-height: 56px; min-width: 0;
    background: none; border: 0; cursor: pointer;
    color: var(--muted); font-size: 10.5px; text-align: center;
  }
  .botnav .botnav-i { font-size: 17px; line-height: 1; }
  .botnav a.on, .botnav button.on { color: var(--accent); }
  .botnav span:not(.botnav-i) {
    max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  }

  /* The sheet and the palette must clear the bar. */
  .picker-pop, .picker--block .picker-pop {
    bottom: calc(62px + env(safe-area-inset-bottom, 0px));
    border-radius: 20px 20px 0 0;
  }
  .cmd { padding-top: 8vh; }
  .cmd-list { max-height: 46vh; }

  .risk-bar { width: 34px; }
  .away { gap: 8px 12px; font-size: 12px; }
}

/* A phone in landscape is short, not narrow: the palette would have no room to
   open under a 14vh gap. */
@media (max-height: 520px) {
  .cmd { padding-top: 5vh; }
  .cmd-list { max-height: 40vh; }
}

/* ---------- late corrections ----------
   `[hidden]` is a UA rule, and any author `display` beats it — so a component
   given `display: flex` stays on screen no matter what the markup says. Listing
   the components that need to opt back out was the wrong fix: it worked until
   the next component was added and forgotten, which is exactly what happened to
   the attention strip. The attribute is made authoritative instead, once. */
[hidden] { display: none !important; }

/* The card is a container again rather than a link, so that a pin button can
   live inside it without nesting a button in an anchor. The whole card still
   opens the account — list.js delegates that — and the name stays a real link
   so middle-click and "open in new tab" keep working. */
.vcard { position: relative; cursor: pointer; }
.vcard-top .pin { margin-left: auto; }
.vcard-top .badge { margin-left: 6px; margin-right: 0; flex: none; }
.vcard-top .cmp-box { margin-right: 2px; }

/* The chart and its tooltip share one positioned box, so a coordinate computed
   inside the chart lands where it was meant to. */
.chart-wrap { position: relative; }
/* `pan-y` hands vertical scrolling back to the page while the horizontal drag
   stays with the chart — which is what makes scrubbing a line with a finger
   possible without trapping the scroll. */
.chart-host { touch-action: pan-y; }

/* =========================================================
   Second pass
   What the first pass got wrong, and the things the audit was right about.
   ========================================================= */

/* ---------- icon buttons ----------
   Every control that used to be a font character now holds an SVG, which sits
   on the baseline differently. */
.btn-icon svg, .botnav svg, .pin svg, .cmd-x svg, .cmd-clear svg,
.more-x svg, .more-out svg, .attention-i svg, .chev-mark svg {
  display: block;
}
.btn-icon { line-height: 0; }
#privacy.on { color: var(--accent); border-color: var(--accent-line); }

/* ---------- hit areas ----------
   The audit measured the pin at 21×17 and the ⓘ at 16×16. WCAG asks for 24×24
   and a thumb wants 44×44, but neither icon should *look* bigger — so the
   target is grown with a transparent overlay instead of the glyph. */
.pin, .info-dot, .cmp-box, .est {
  position: relative;
}
.pin::after, .info-dot::after, .cmp-box::after, .est::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 44px; height: 44px; transform: translate(-50%, -50%);
}
@media (pointer: fine) {
  .pin::after, .info-dot::after, .cmp-box::after, .est::after { width: 32px; height: 32px; }
}
.pin { color: var(--muted-2); }
.pin.on svg { fill: currentColor; }

/* ---------- attention ----------
   Only present when there is something to be attentive about. */
.attention {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 12px 16px; margin-bottom: 16px; border-radius: 14px;
  background: var(--warn-soft); border: 1px solid rgba(255, 180, 87, 0.42);
  color: var(--warn-ink); font-size: 13px;
}
.attention.critical {
  background: var(--bad-soft); border-color: rgba(255, 92, 122, 0.45); color: var(--bad-ink);
}
.attention-i { flex: none; display: flex; }
.attention-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.attention-body strong { font-size: 13px; }
.attention-body span { color: inherit; opacity: 0.86; }
/* Filled with the state's own colour, with the surface colour as the text —
   the one combination that stays legible whichever state is showing and
   whichever theme is on. */
.attention-cta {
  margin-left: auto; flex: none; cursor: pointer;
  border: 0; border-radius: 999px; min-height: 36px;
  padding: 8px 16px; font-size: 12px; font-weight: 700;
  background: var(--warn-ink); color: var(--panel-solid);
}
.attention.critical .attention-cta { background: var(--bad-ink); }
.attention-cta:hover { filter: brightness(1.1); }

/* ---------- long tables ---------- */
tfoot .rowmore-bar {
  display: flex; align-items: center; gap: 12px; justify-content: space-between;
  padding: 12px 14px; font-size: 12px;
}
tfoot td { border-bottom: 0; }

/* ---------- estimated data ---------- */
.est {
  cursor: pointer; border: 1px dashed var(--warn); background: transparent;
  color: var(--warn-ink); border-radius: 999px; padding: 3px 10px;
  font-size: 10.5px; font-weight: 700; letter-spacing: 0.4px; text-transform: uppercase;
  align-self: center;
}

/* ---------- command palette, second pass ---------- */
.cmd-head {
  display: flex; align-items: center; gap: 10px;
  padding: 14px 16px 10px; color: var(--muted);
}
.cmd-i { display: flex; flex: none; }
.cmd-title { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: 0.3px; }
.cmd-x {
  margin-left: auto; flex: none; background: none; border: 0; cursor: pointer;
  color: var(--muted); border-radius: 50%;
  width: 34px; height: 34px; display: flex; align-items: center; justify-content: center;
}
.cmd-x:hover { color: var(--text); background: var(--field); }
.cmd-field { position: relative; }
.cmd-field .cmd-input { padding-right: 52px; }
.cmd-clear {
  position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
  width: 28px; height: 28px; border-radius: 50%; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  background: var(--field); border: 1px solid var(--line); color: var(--muted);
}
.cmd-clear[hidden] { display: none; }
.cmd-clear:hover { color: var(--text); border-color: var(--accent-line); }

/* ---------- the More sheet ----------
   The same bottom-sheet shape as the language picker, because a phone should
   only have to learn one. */
.more { position: fixed; inset: 0; z-index: 82; }
.more.hidden { display: none; }
.more-scrim { position: absolute; inset: 0; background: rgba(8, 5, 24, 0.6); }
.more-box {
  position: absolute; left: 0; right: 0; bottom: 0; max-height: 88vh; overflow-y: auto;
  background: var(--panel-solid); border-top: 1px solid var(--line);
  border-radius: 20px 20px 0 0;
  padding: 6px 16px calc(18px + env(safe-area-inset-bottom, 0px));
  animation: sheet-up 0.18s ease-out;
}
@media (prefers-reduced-motion: reduce) { .more-box { animation: none; } }
.more-box::before {
  content: ""; display: block; width: 38px; height: 4px; margin: 8px auto 4px;
  border-radius: 999px; background: var(--line);
}
.more-head { display: flex; align-items: center; gap: 10px; padding: 6px 0 12px; }
.more-head h2 { margin: 0; font-size: 17px; }
.more-x {
  margin-left: auto; background: none; border: 0; cursor: pointer; color: var(--muted);
  width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;
  border-radius: 50%;
}
.more-row {
  display: flex; align-items: center; gap: 14px; min-height: 52px;
  border-top: 1px solid var(--line-soft);
}
.more-label { color: var(--muted); font-size: 14px; }
.more-control { margin-left: auto; }
.more-langs { flex-direction: column; align-items: stretch; gap: 8px; padding: 12px 0; }
.more-langs .more-label { margin-bottom: 2px; }
.more-langlist { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
.more-lang {
  display: flex; align-items: center; gap: 10px; min-height: 46px;
  padding: 0 12px; border-radius: 12px; cursor: pointer; font-size: 14px;
  background: var(--field); border: 1px solid var(--line); color: var(--text);
}
.more-lang.on { border-color: var(--accent-line); color: var(--accent); background: var(--accent-soft); }
.more-out {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  width: 100%; min-height: 50px; margin-top: 16px; cursor: pointer;
  background: var(--field); border: 1px solid var(--line); border-radius: 14px;
  color: var(--down); font-size: 14px; font-weight: 600;
}
/* A real switch rather than a checkbox: this is a setting that takes effect at
   once, not one you confirm. */
.switch {
  width: 48px; height: 28px; border-radius: 999px; cursor: pointer; padding: 0;
  background: var(--field); border: 1px solid var(--line); position: relative;
  transition: background .15s, border-color .15s;
}
.switch i {
  position: absolute; left: 3px; top: 50%; transform: translateY(-50%);
  width: 20px; height: 20px; border-radius: 50%; background: var(--muted);
  transition: transform .16s, background .16s;
}
.switch.on { background: var(--accent-soft); border-color: var(--accent-line); }
.switch.on i { transform: translate(20px, -50%); background: var(--accent); }
@media (prefers-reduced-motion: reduce) { .switch, .switch i { transition: none; } }

@media (max-width: 760px) {
  /* ---------- the phone's tab strip ----------
     It was a dropdown reading "Vị thế (72)": a form field where navigation
     should be, saying nothing about the other five tables. Back to being a
     strip — but a legible one. Chips, each with its count, snapping, with the
     selected one scrolled into view and a fade at the edge so it is obvious
     the row continues. */
  .tabbar {
    display: flex; gap: 8px; overflow-x: auto; border-bottom: 0;
    position: sticky; top: 0; z-index: 15;
    margin: 6px -14px 0; padding: 10px 14px;
    background: var(--panel-solid);
    scroll-snap-type: x proximity;
    scrollbar-width: none;
    -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
    mask-image: linear-gradient(90deg, #000 calc(100% - 28px), transparent);
  }
  .tabbar::-webkit-scrollbar { display: none; }
  .tabbar button {
    flex: none; scroll-snap-align: start;
    padding: 0 14px; min-height: 40px; border-radius: 999px;
    border: 1px solid var(--line); background: var(--field);
    color: var(--muted); font-size: 13.5px; white-space: nowrap;
  }
  .tabbar button.on {
    background: var(--accent-soft); border-color: var(--accent-line);
    color: var(--accent); font-weight: 600;
  }
  .tabbar .count { margin-left: 6px; opacity: 0.85; }
  .tabpick { display: none; }

  /* ---------- one top bar, not two ----------
     The audit is right that Vaults/Tổng hợp appeared twice, once in the header
     and once at the bottom. The bottom copy is the reachable one, so the header
     keeps only identity and freshness — and drops from two rows to one. */
  .topbar .nav, .topbar-right { display: none; }
  .topbar { flex-wrap: nowrap; }
  #clock { margin-left: auto; }

  /* Full-width sheet for the palette, thumb-sized rows. */
  .cmd { padding: 0; align-items: flex-end; }
  .cmd-box {
    width: 100%; border-radius: 20px 20px 0 0; border-bottom: 0;
    padding-bottom: env(safe-area-inset-bottom, 0px);
  }
  .cmd-list { max-height: 52vh; }
  .cmd-item { padding: 14px 12px; font-size: 15px; min-height: 48px; }
  .cmd-input { font-size: 16px; padding: 14px 16px; }

  /* The detail panel a row opens is its own two-column block at full width;
     it must never be something you have to swipe sideways inside. */
  .rowmore td { padding: 4px 12px 14px; }
  .kv-mini {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: 10px 12px;
  }
  .kv-mini dt { font-size: 11px; }
  .kv-mini dd { text-align: left; word-break: break-word; }
  .attention { padding: 12px 14px; gap: 10px; }
  .attention-cta { margin-left: 0; width: 100%; }
}

/* A phone held sideways is short, not wide: 844×390 was being handed the
   desktop layout, which then needed 1200px and scrolled horizontally. */
@media (max-width: 940px) and (max-height: 500px) and (orientation: landscape) {
  .split { grid-template-columns: 1fr; }
  .chart-host { height: 200px; }
  .stat-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .table-host { max-height: none; }
  .topbar { height: auto; padding: 6px 14px; }
  h1 { font-size: 24px; }
}

/* =========================================================
   Third pass — the controls
   ========================================================= */

/* ---------- the filter ----------
   A field that is always there, with the values dropping under it. The button
   that used to stand in its place hid the typing behind a press. */
/* `display: contents` so the field and the badges become items of the tools
   row itself, rather than a box of their own that was sized for both and then
   wrapped one inside it.

   The badges sit immediately after the field they came from: what you picked
   belongs next to where you picked it. They are sized to their contents and
   allowed to shrink, so a long list wraps to a second row only when it has run
   out of room — which is different from always occupying one. */
.filterbar { display: contents; }
.filter { position: relative; flex: 0 1 300px; min-width: 190px; }
.filter-badges { flex: 0 1 auto; }
.filter-field .input { width: 100%; padding-right: 34px; }
.filter-field.on .input { border-color: var(--accent-line); background: var(--accent-soft); }
.filter-field.on .searchbox-i { color: var(--accent); }
.filter-x {
  position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
  width: 22px; height: 22px; border-radius: 50%; cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; color: var(--muted-2);
}
.filter-x:hover { color: var(--text); }
.filter-x[hidden] { display: none; }
.filter-pop {
  position: absolute; left: 0; right: 0; top: calc(100% + 6px); z-index: 40;
  background: var(--panel-2); border: 1px solid var(--line);
  border-radius: 14px; box-shadow: var(--shadow); overflow: hidden;
}
.filter-list { max-height: 46vh; overflow-y: auto; padding: 6px; }
.filter-group + .filter-group { margin-top: 6px; border-top: 1px solid var(--line-soft); padding-top: 6px; }
.filter-glabel {
  padding: 6px 10px 4px; font-size: 10.5px; letter-spacing: 0.8px;
  text-transform: uppercase; color: var(--muted-2); font-weight: 600;
}
.filter-opt {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 8px 10px; border: 0; background: none; border-radius: 9px;
  cursor: pointer; font-size: 13.5px; color: var(--text); text-align: left;
  min-height: 38px;
}
.filter-opt:hover, .filter-opt.active { background: var(--accent-soft); }
@media (hover: none) { .filter-opt:hover { background: none; } }
.filter-box {
  width: 17px; height: 17px; flex: none; border-radius: 5px;
  border: 1.5px solid var(--line); display: flex; align-items: center;
  justify-content: center; color: transparent;
}
.filter-opt.on .filter-box {
  background: var(--accent-fill); border-color: var(--accent-fill); color: var(--on-accent);
}
.filter-empty { padding: 20px 12px; text-align: center; color: var(--muted-2); font-size: 13px; }
.filter-foot { padding: 4px 8px 8px; }
.filter-foot .btn-ghost { width: 100%; }
.filter-foot .btn-ghost[hidden] { display: none; }

/* Each active filter is its own removable thing, which is the part a text box
   could never do: "BTC ETH" is not two filters, it is a string matching neither. */
.filter-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; min-width: 0; }
/* Same height, radius and text size as the field it sits beside: it is one of
   the row's controls, not a label that happens to be near one. At 30px against
   the field's 38px it read as a smaller class of thing, floating rather than
   lining up. Width stays with the content — a chip is as wide as the value it
   names. */
.fbadge {
  display: inline-flex; align-items: center; gap: 6px; cursor: pointer;
  padding: 0 10px 0 13px; min-height: var(--control-h); border-radius: 999px;
  background: var(--accent-soft); border: 1px solid var(--accent-line);
  color: var(--accent); font-size: 13px; font-weight: 600;
}
.fbadge i { font-style: normal; color: var(--muted-2); font-weight: 500; }
.fbadge-x { display: flex; opacity: 0.7; }
.fbadge:hover .fbadge-x { opacity: 1; }

/* ---------- account column ---------- */
.who { color: var(--muted); font-size: 12.5px; }

/* ---------- toolbar alignment ----------
   Filter, its chosen values, exchange, sort and the buttons are one row of
   controls, so they are one height and one radius. The filter takes the slack: it is
   the widest thing anyone types into, and the other three have fixed content.
   (`.filterbar` is `display: contents`, so the field is a grandchild of the
   toolbar in the DOM while being its flex child in layout — hence no `>`.) */
.toolbar .filter { flex: 1 1 260px; min-width: 200px; }
.toolbar .picker--block .picker-btn,
.tabtools .btn-ghost, .toolbar .btn-ghost { min-height: var(--control-h); }
.picker--block .picker-btn { padding: 0 12px; }
#sort-dir { flex: none; }

@media (max-width: 760px) {
  :root { --control-h: 40px; }
  /* Anchored under the field on a phone too. As a bottom sheet it would have
     been covered by the keyboard the field itself raises. */
  .filter { flex: 1 1 100%; }
  .filter-list { max-height: 40vh; }
  .filter-opt { min-height: 46px; font-size: 15px; }
}

/* A liquidation price far enough away to be irrelevant states that, quietly,
   and gives up its bar — the column then only draws a bar where there is
   something to measure. */
.risk-moot { --c: var(--muted-2); }
.risk-moot .risk-num { font-weight: 500; }

/* ---------- exchange marks ----------
   The language switch can carry real flags because a flag is a public standard
   that can be drawn from a specification. An exchange logo is a trademark, and
   an approximation from memory is a wrong mark wearing a real company's
   identity — so each exchange gets a lettered chip in the colour it already
   holds in the TVL-by-exchange bar. Same colour, two places, one idea. */
.exmark {
  width: 20px; height: 20px; flex: none; border-radius: 6px;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--c, var(--muted-2)); color: var(--ink);
  font-size: 11px; font-weight: 800; line-height: 1;
  font-family: var(--display);
}
.exmark-all {
  background: transparent; color: var(--muted-2);
  box-shadow: inset 0 0 0 1px var(--line); border-radius: 6px;
}
.exmark-img {
  /* Each logo is a solid square with its own background — Bitget's cyan,
     Gate's white, KuCoin's green — so the chip is the artwork, rounded. The
     hairline keeps Gate's white square from dissolving into a light panel. */
  object-fit: cover; background: none; padding: 0;
  box-shadow: inset 0 0 0 1px var(--line-soft), 0 0 0 1px var(--line-soft);
}
.picker-menu li.on .exmark-all { color: var(--accent); box-shadow: inset 0 0 0 1px var(--accent-line); }
@media (max-width: 760px) {
  .picker-menu .exmark { width: 24px; height: 24px; font-size: 12px; }
}

/* ---------- sortable column headers ----------
   Before this, the only way to find out a column sorted was to click it and
   notice the order move. The mark rests faint so twelve of them do not read as
   twelve pieces of punctuation across the header row; the sorted column shows
   a single chevron pointing the way it is actually ordered. */
/* The mark sits on the side the label does *not* end on, and this is why:
   `.th-s` is 12px of SVG plus a 6px gap, and an invisible one (`opacity: 0`)
   still takes that space. With the mark trailing the label, every right-aligned
   heading was pushed 18px left of the values underneath it — "TVL" hanging in
   from the column edge while its numbers lined up flush against it. Since the
   marks outnumber the labels they can never look aligned on both sides, so they
   go on the outside: the label's right edge meets the column's right edge, and
   the mark grows away from the numbers rather than between them.

   `row-reverse` reverses the painted order only; the DOM order (label, then
   mark) is what a screen reader still reads. */
.th-l { display: inline-flex; align-items: center; gap: 6px; flex-direction: row-reverse; }
/* A left-aligned column ends on the other side, so its mark trails as usual.
   Both halves of that rule have to match the alignment rule above, `:first-child`
   included — the account list's first heading is left-aligned by position
   rather than by a class. */
thead th:first-child .th-l, thead th.text .th-l { flex-direction: row; }
.th-s { display: inline-flex; flex: none; }
.th-s-off { opacity: 0; transition: opacity .12s; }
thead th.sortable:hover .th-s-off,
thead th.sortable:focus-visible .th-s-off { opacity: 0.55; }
.th-s-on { opacity: 1; color: var(--accent); }
/* A touch screen has no hover to reveal anything, so the hint is simply there. */
@media (hover: none) {
  .th-s-off { opacity: 0.4; }
}

/* ---------- confirmation ----------
   Drawn here rather than handed to `window.confirm`, which would arrive in the
   browser's language on a page that offers five. */
.cdlg {
  position: fixed; inset: 0; z-index: 90;
  display: flex; align-items: center; justify-content: center; padding: 20px;
}
.cdlg.hidden { display: none; }
.cdlg-scrim { position: absolute; inset: 0; background: rgba(8, 5, 24, 0.62); }
.cdlg-box {
  position: relative; width: min(420px, 100%);
  background: var(--panel-solid); border: 1px solid var(--line);
  border-radius: 18px; box-shadow: var(--shadow); padding: 24px;
}
.cdlg-t { margin: 0 0 8px; font-size: 20px; }
.cdlg-b { margin: 0 0 20px; color: var(--muted); font-size: 13.5px; line-height: 1.55; }
.cdlg-b[hidden] { display: none; }
.cdlg-row { display: flex; gap: 10px; justify-content: flex-end; }
.cdlg-row .btn-ghost { min-width: 96px; }
.cdlg-yes {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  min-height: var(--control-h); min-width: 120px; padding: 0 18px;
  border: 0; border-radius: 999px; cursor: pointer;
  background: var(--accent-fill); color: var(--on-accent);
  font-size: 13px; font-weight: 700;
}
.cdlg-yes.danger { background: var(--down); color: var(--panel-solid); }
.cdlg-yes:hover { filter: brightness(1.08); }
@media (max-width: 760px) {
  .cdlg { align-items: flex-end; padding: 0; }
  .cdlg-box {
    width: 100%; border-radius: 20px 20px 0 0; border-bottom: 0;
    padding-bottom: calc(24px + env(safe-area-inset-bottom, 0px));
    animation: sheet-up 0.18s ease-out;
  }
  .cdlg-row { flex-direction: column-reverse; }
  .cdlg-row .btn-ghost, .cdlg-yes { width: 100%; min-height: 46px; }
}
@media (prefers-reduced-motion: reduce) { .cdlg-box { animation: none; } }

/* ---------- when the brand face cannot set the words ----------
   Syne holds no Vietnamese (see fitDisplay in i18n.js). Rather than let the
   browser patch a word together from two typefaces, the whole element takes a
   face that can set it — Bricolage Grotesque, which covers all 134 accented
   letters, rather than dropping to the interface font and leaving a heading
   that no longer reads as a heading.

   Family and tracking only. The first version also forced weight 800 here,
   which leaked onto everything the class touched: the nav link "Tổng hợp" came
   out heavier and tighter than "Vaults" beside it, in a place that never used
   the display face at all. Weight is left to whatever the element already
   asked for. */
.no-display {
  font-family: "Bricolage Grotesque", var(--body);
  letter-spacing: -0.01em;
}
/* Two of the elements this class is put on set their font through a selector
   heavier than one class, so the swap above never reached them: at (0,1,0) it
   loses to `.auth-form .field-label` at (0,2,0) and to `.cmp-head strong` at
   (0,1,1). The class was on the element and doing nothing, which is the worst
   kind of not-working — "Tên đăng nhập" was still being patched together out of
   Syne and a fallback, exactly as if the fix had never been made.
   Restated at a weight that wins. Anything added to DISPLAY_ELEMENTS in i18n.js
   needs the same check: it is the *font-family* selector's specificity that has
   to be beaten, not the element's. */
.auth-form .field-label.no-display,
.cmp-head strong.no-display {
  font-family: "Bricolage Grotesque", var(--body);
}

/* =========================================================
   Ambient — the sign-in page notices the pointer
   Only there. On the dashboard the same effects were a light show over
   somebody's money: a surface that lights as the cursor crosses it competes
   with the one thing those surfaces exist to carry, which is a number that
   just changed. A page whose job is to be read should hold still.

   The sign-in page has no such job. It is a hero with a mascot, two orbits and
   three floating objects, and nothing on it that anybody has to read closely —
   so that is where the effect lives.

   Driven by four custom properties that ambient.js writes inside one animation
   frame: --px/--py (where the cursor is) and --tx/--ty (how far from centre,
   -1 to 1). Nothing here causes layout: transforms and background positions,
   which the compositor owns. All of it scoped to `body.ambient`, a class only
   added when there is a real pointer and reduced motion is not asked for.
   ========================================================= */

/* ---------- the light that follows the cursor ----------
   Painted onto the grid layer rather than a new element: one more full-screen
   fixed layer is one more thing to blend on every frame. */
body.ambient::after {
  /* Grown past the viewport before it is allowed to move: the layer shifts by
     up to 12px, and a fixed layer sized exactly to the screen would show its
     own edge doing it — the same reason the drift animation scales up 6%. */
  inset: -24px;
  background-image:
    radial-gradient(380px circle at var(--px, 50%) var(--py, 50%),
                    var(--spot), transparent 68%),
    linear-gradient(var(--grid) 1px, transparent 1px),
    linear-gradient(90deg, var(--grid) 1px, transparent 1px);
  background-size: 100% 100%, 100% 68px, 68px 100%;
  background-repeat: no-repeat, repeat, repeat;
  /* Against the pointer, so the grid reads as sitting behind the light. */
  transform: translate3d(calc(var(--tx, 0) * -12px), calc(var(--ty, 0) * -12px), 0);
}
:root { --spot: rgba(167, 139, 250, 0.16); }
:root[data-theme="light"] { --spot: rgba(124, 77, 255, 0.10); }

/* ---------- depth ----------
   Each ornament moves by a different amount, which is the whole trick: parallax
   is not motion, it is *disagreement* about how much to move. Nearer things
   move more. `transform` is free for this because the float, spin and drift
   keyframes were all moved onto the `translate` and `rotate` properties.

   One scale runs through everything on the page, from the grid at the back to
   the eye at the front, and the three sky planes below are numbers on that same
   scale rather than a second system:

     grid       -12   against the pointer — the only layer that does
     nebula       3   the sky's furthest plane, all but refusing to move
     stars        5   the sky's far plane, barely moving at all
     auth-mark    6
     orbit-1     10
     sparkles    12   the sky's middle plane
     orbit-2     15
     mascot      20
     motes       26   the sky's near plane, solid shapes close to the reader
     token       28
     cube        34
     eye         40

   The horizon is deliberately absent, and stays absent now that it drifts on
   its own. It is ground: the one thing in the picture that does not answer the
   cursor, which is what the other layers are measured against. Give it a depth
   here and the whole scene becomes a poster being tilted rather than a place
   being looked at — a slow drift of its own does not cost that, because it is
   not a reply to anything the reader did. */
body.ambient .auth-mark {
  transform: translate3d(calc(var(--tx, 0) * 6px), calc(var(--ty, 0) * 4px), 0);
}
/* A runner is on its ring's plane by definition — it *is* the ring, as far as
   depth goes. Give the two different numbers and the dot walks off the track
   the moment the cursor moves, so these are paired rather than merely similar. */
body.ambient .orbit-1, body.ambient .orbit-run-1 {
  transform: translate3d(calc(var(--tx, 0) * 10px), calc(var(--ty, 0) * 7px), 0);
}
body.ambient .orbit-2, body.ambient .orbit-run-2 {
  transform: translate3d(calc(var(--tx, 0) * 15px), calc(var(--ty, 0) * 10px), 0);
}
body.ambient .auth-mascot {
  transform: translate3d(calc(var(--tx, 0) * 20px), calc(var(--ty, 0) * 13px), 0);
}
body.ambient .auth-cube {
  transform: translate3d(calc(var(--tx, 0) * 34px), calc(var(--ty, 0) * 22px), 0);
}
body.ambient .auth-token {
  transform: translate3d(calc(var(--tx, 0) * 28px), calc(var(--ty, 0) * 18px), 0);
}
body.ambient .auth-eye {
  transform: translate3d(calc(var(--tx, 0) * 40px), calc(var(--ty, 0) * 26px), 0);
}

/* ---------- the sky, in three planes ----------
   The ornaments above are one column of the page. The sky is behind all of it
   and used to be flat, which read as a painted backdrop the moment anything in
   front of it moved.

   Four planes now, and the containers move rather than their contents: one
   composited layer carrying twenty-six stars is a great deal less work than
   twenty-six of them, and it leaves each star's own twinkle — which is a
   `transform: scale` — alone. The motes have no container and do not need one;
   there are three of them. */
/* The nebula is furthest of all, and 3px is nearly nothing on purpose: it is
   the plane everything else is far *in front of*, so what sells the depth is
   that it almost refuses to move. */
body.ambient .nebula {
  transform: translate3d(calc(var(--tx, 0) * 3px), calc(var(--ty, 0) * 2px), 0);
}
/* Only the <span> field, which is the one that draws when login.js does not
   run. The canvas is deliberately left out: it answers the pointer itself now,
   star by star, and a five-pixel translate of the whole layer on top of that is
   both redundant and a coordinate problem — every pointer position would have
   to be converted through a box that moves as the pointer moves, which is a
   layout read per mousemove to correct for something nobody can see. */
body.ambient .stars {
  transform: translate3d(calc(var(--tx, 0) * 5px), calc(var(--ty, 0) * 3px), 0);
}
/* The light shaft belongs to the far plane too, but it is a sibling rather than
   a child of the stars, so it takes the same numbers on its own — with its nine
   degrees restated, because a transform is one value and this rule replaces it. */
body.ambient .streak {
  transform: translate3d(calc(var(--tx, 0) * 5px), calc(var(--ty, 0) * 3px), 0)
             rotate(-9deg);
}
body.ambient .sparkles {
  transform: translate3d(calc(var(--tx, 0) * 12px), calc(var(--ty, 0) * 8px), 0);
}
body.ambient .mote {
  transform: translate3d(calc(var(--tx, 0) * 26px), calc(var(--ty, 0) * 17px), 0);
}

/* ---------- strategy administration ----------
   The one writing surface in a read-only dashboard, so it is deliberately not
   a page: it opens over the list it changes, and closing it puts you back
   exactly where you were. Same furniture as the command palette on a desktop
   and the settings sheet on a phone — the app already taught both shapes, and
   a third would be a third thing to learn. */
.adm {
  position: fixed; inset: 0; z-index: 84;
  display: flex; align-items: flex-start; justify-content: center;
  padding: min(8vh, 72px) 16px 16px;
}
.adm.hidden { display: none; }
.adm-scrim { position: absolute; inset: 0; background: rgba(8, 5, 24, 0.62); }
.adm-box {
  position: relative; display: flex; flex-direction: column;
  width: min(720px, 100%); max-height: 84vh;
  background: var(--panel-solid); border: 1px solid var(--line);
  border-radius: 18px; box-shadow: var(--shadow); overflow: hidden;
}
.adm-head {
  display: flex; align-items: center; gap: 10px; flex: none;
  padding: 14px 16px; border-bottom: 1px solid var(--line-soft);
}
.adm-i { display: flex; flex: none; color: var(--accent); }
.adm-title { margin: 0; font-size: 17px; }
.adm-x {
  margin-left: auto; display: flex; padding: 6px; border-radius: 50%;
  background: transparent; border: 0; color: var(--muted); cursor: pointer;
}
.adm-x:hover { color: var(--text); background: var(--field); }
.adm-body { padding: 16px; overflow-y: auto; }

.adm-lead { display: flex; align-items: center; gap: 12px; margin-bottom: 14px; }
.adm-lead p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; }
/* `--accent-fill`, not `--accent`. The two are the same lime on the dark theme,
   which is why this went unnoticed: on the light theme `--accent` becomes the
   brand violet — a *text* colour — and `--on-accent` is the near-black meant to
   sit on lime, so the label measured 2.67:1 on its own button. Filled buttons
   take the fill; that is the whole reason the two tokens are separate. */
.adm-add {
  margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: 7px;
  padding: 0 15px; min-height: var(--control-h); border-radius: 999px; border: 0;
  background: var(--accent-fill); color: var(--on-accent); font-size: 13px;
  font-weight: 600; font-family: var(--body); cursor: pointer;
}
.adm-add:hover { filter: brightness(1.08); }

/* One row per strategy: what it is, who it belongs to, and the two or three
   things you can do to it. The numbers are the ones the form sets, so a row
   doubles as a read-back of what was entered. */
.adm-list { display: flex; flex-direction: column; gap: 8px; }
.adm-row {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 12px 14px; border: 1px solid var(--line-soft); border-radius: 14px;
  background: var(--field);
}
.adm-row.off { opacity: 0.6; }
.adm-row-main { flex: 1 1 260px; min-width: 0; }
.adm-row-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.adm-row-top strong { font-size: 14px; }
.adm-row-nums {
  display: flex; gap: 14px; flex-wrap: wrap; margin-top: 6px;
  font-size: 12px; color: var(--muted-2);
}
.adm-row-nums b { color: var(--text); font-weight: 600; }
.adm-row-acts { display: flex; gap: 8px; margin-left: auto; }
.adm-danger { color: var(--down); }
.adm-danger:hover { border-color: var(--down); color: var(--down); }

/* ---------- the form ---------- */
.adm-form { display: flex; flex-direction: column; gap: 14px; }
.adm-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 14px; }
.adm-field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.adm-field.wide { grid-column: 1 / -1; }
/* A hint belongs beside the label it qualifies, not under the box where it
   reads as an error. */
.adm-field .field-label { display: flex; align-items: baseline; gap: 8px; margin: 0; }
.adm-field .field-label i {
  font-style: normal; color: var(--muted-2); font-size: 11.5px; font-weight: 400;
}
.adm-field .input { width: 100%; }
.adm-static {
  display: flex; align-items: center; min-height: var(--control-h);
  padding: 0 15px; border-radius: 999px; border: 1px dashed var(--line);
  color: var(--muted); font-size: 13px;
}

/* Three or fewer options, all short: laid out in a row they cost one press and
   are readable without being opened, which a dropdown is not. */
.adm-choice { display: flex; gap: 8px; flex-wrap: wrap; }
.adm-opt {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 0 14px; min-height: var(--control-h); border-radius: 999px;
  border: 1px solid var(--line); background: var(--field); color: var(--muted);
  font-size: 13px; font-family: var(--body); cursor: pointer;
}
.adm-opt:hover { color: var(--text); border-color: var(--accent-line); }
.adm-opt.on { border-color: var(--accent-line); background: var(--accent-soft); color: var(--accent); }
.adm-opt .exmark { width: 18px; height: 18px; border-radius: 5px; }

.adm-legs { display: flex; flex-direction: column; gap: 12px; }
.adm-leg {
  display: flex; flex-direction: column; gap: 10px;
  padding: 12px 14px; border: 1px solid var(--line-soft); border-radius: 14px;
}
.adm-leg-head {
  display: flex; align-items: center; gap: 8px;
  font-size: 12px; letter-spacing: 0.4px; text-transform: uppercase; color: var(--muted-2);
}
.adm-leg-head svg { color: var(--accent); }

.adm-note {
  padding: 10px 14px; border-radius: 12px; font-size: 12.5px; line-height: 1.5;
  border: 1px solid var(--line-soft); color: var(--muted);
}
.adm-note.info { background: var(--field); }
.adm-note.ok { background: var(--ok-soft); color: var(--ok-ink); border-color: var(--accent-line); }
.adm-note.bad { background: var(--bad-soft); color: var(--bad-ink); border-color: var(--bad-ink); }

.adm-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.adm-spacer { flex: 1; }
.adm-save {
  padding: 0 20px; min-height: var(--control-h); border-radius: 999px; border: 0;
  background: var(--accent-fill); color: var(--on-accent); font-size: 13px;
  font-weight: 600; font-family: var(--body); cursor: pointer;
}
.adm-save:hover { filter: brightness(1.08); }
.adm-save[disabled], .adm-actions .btn-ghost[disabled] { opacity: 0.55; cursor: default; }

/* The delete dialog borrows the confirmation box and adds two fields, so the
   password question looks like the "are you sure" it follows rather than like
   a second sign-in page. */
.cdlg-box .adm-field { margin-bottom: 12px; text-align: left; }
.cdlg-box .adm-note { margin-bottom: 12px; }

@media (max-width: 760px) {
  /* Bottom sheet, like every other list of choices on a phone. */
  .adm { padding: 0; align-items: flex-end; }
  .adm-box {
    width: 100%; max-height: 92vh; border-radius: 20px 20px 0 0;
    border-left: 0; border-right: 0; border-bottom: 0;
    animation: sheet-up 0.18s ease-out;
  }
  .adm-body { padding: 14px 14px calc(18px + env(safe-area-inset-bottom, 0px)); }
  .adm-grid { grid-template-columns: 1fr; }
  .adm-lead { flex-wrap: wrap; }
  .adm-add { width: 100%; justify-content: center; }
  .adm-row-acts { width: 100%; margin-left: 0; }
  .adm-row-acts .btn-ghost { flex: 1; justify-content: center; }
  .adm-actions .btn-ghost, .adm-save { flex: 1 1 40%; justify-content: center; text-align: center; }
  /* The toolbar's end used to hold two buttons and now holds three. Left as
     `flex: 1` each they would be squeezed narrower than their own labels on a
     360px screen, so they wrap onto a second line instead. */
  .toolbar-end { flex-wrap: wrap; }
  .toolbar-end .btn-ghost { flex: 1 1 30%; min-width: 108px; }
}
@media (prefers-reduced-motion: reduce) { .adm-box { animation: none; } }

/* ---------- symbol chart ----------
   Colour lives here rather than in chart.js, and that is the whole arrangement:
   the renderer sets classes, this file spends the theme's own tokens on them,
   and the switch in the top bar repaints the candles without the chart knowing
   a theme exists. An SVG presentation attribute cannot read a custom property,
   so `fill="var(--up)"` was never on the table either way. */
.k-wrap { display: flex; flex-direction: column; }
.k-bar {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding: 10px 14px; border-bottom: 1px solid var(--line-soft);
}
.k-pick[hidden] { display: none; }
.k-seg { flex: none; }
/* The OHLC line the crosshair drives. It keeps the auto margin so it sits at
   the right-hand end of the row on a desktop and wraps underneath on a phone. */
.k-readout {
  margin-left: auto; display: flex; align-items: baseline; gap: 14px;
  flex-wrap: wrap; font-size: 12px; font-variant-numeric: tabular-nums;
}
.k-ro { display: inline-flex; align-items: baseline; gap: 5px; }
.k-ro i { font-style: normal; color: var(--muted-2); font-size: 10.5px; }
.k-ro b { font-weight: 600; }
.k-ro-time { color: var(--muted); }

/* `pan-y` is what makes a pinch reach the chart: the browser keeps vertical
   page scrolling for itself and hands over everything else, so two fingers zoom
   the candles instead of the whole page. */
.k-plot { position: relative; height: 460px; touch-action: pan-y; cursor: crosshair; }
.k-plot.k-dragging { cursor: grabbing; }
.k-svg { display: block; }
.k-empty {
  position: absolute; inset: 0; display: flex; align-items: center;
  justify-content: center; color: var(--muted); font-size: 13px; padding: 0 20px;
  text-align: center;
}

/* axes and grid */
.k-grid-line { stroke: var(--line-soft); stroke-width: 1; }
.k-axis { fill: var(--muted-2); font-size: 11px; }

/* candles — the same two colours the tables use for a gain and a loss, so an
   up bar here and a positive number three tabs away are the same green. */
.k-up { fill: var(--up); stroke: var(--up); }
.k-down { fill: var(--down); stroke: var(--down); }
.k-wick { stroke-width: 1; }
.k-body { stroke: none; }

/* the book. Dashed, because an order is an intention rather than a fact — the
   solid lines on this chart are all things that have happened. */
.k-order { stroke-width: 1.2; stroke-dasharray: 5 5; opacity: .8; fill: none; }
.k-buy.k-order { stroke: var(--up); }
.k-sell.k-order { stroke: var(--down); }
.k-chip { opacity: .16; stroke: none; }
.k-chip-edge { stroke: none; }
.k-chip-text { font-size: 10.5px; font-weight: 600; }
.k-buy.k-chip, .k-buy.k-chip-edge { fill: var(--up); }
.k-sell.k-chip, .k-sell.k-chip-edge { fill: var(--down); }
.k-buy.k-chip-text { fill: var(--up); }
.k-sell.k-chip-text { fill: var(--down); }

/* Dotted, so it is never mistaken for an order (dashed) or the last price
   (solid); coloured by side, so the long leg and the short leg are two lines
   rather than one confusing pair. */
.k-entry { stroke-width: 1.4; stroke-dasharray: 2 3; fill: none; }
.k-buy.k-entry { stroke: var(--up); }
.k-sell.k-entry { stroke: var(--down); }
.k-tagtext { font-size: 10.5px; font-weight: 600; }
.k-buy.k-tagtext { fill: var(--up); }
.k-sell.k-tagtext { fill: var(--down); }

/* last traded price: the one solid horizontal line, and the only filled chip */
.k-last { stroke: var(--tag-ink); stroke-width: 1; stroke-dasharray: 2 3; fill: none; }
.k-last-chip { fill: var(--tag-ink); }
.k-last-text { fill: var(--panel-solid); font-size: 10.5px; font-weight: 700; }

.k-cross { stroke: var(--text); stroke-width: 1; stroke-dasharray: 3 4; opacity: .3; }

@media (max-width: 760px) {
  /* A phone has no wheel and no room for a gutter of chips, so the plot gets
     shorter and the readout drops to its own row rather than being squeezed. */
  .k-plot { height: 340px; }
  .k-readout { margin-left: 0; width: 100%; gap: 10px; font-size: 11.5px; }
  .k-ro i { font-size: 10px; }
  .k-bar { padding: 10px; gap: 8px; }
  /* The legend is four claims and a hint; on one column it pushed the chart off
     the screen, so it wraps tight and the hint — which describes a wheel this
     device does not have — goes entirely. */
  .k-legend { gap: 10px 14px; padding: 9px 10px; font-size: 11.5px; }
}

/* The chart is not a table and must not live in the table's scroll box: a
   `position: absolute` menu cannot escape an `overflow: auto` ancestor, which
   is what turned the symbol list into a column of half-cut words. */
.table-host.k-host { overflow: visible; max-height: none; background: none; }
/* The rounded corners are what `overflow: hidden` is for, so the panel keeps
   its radius and stops clipping only while the chart's menus need to escape. */
.panel.k-host { overflow: visible; }
/* The two rules that made the price axis unreadable are gone with the liq line;
   these keep the gutter from colliding with the plot on a narrow window. */
.k-plot { min-width: 0; }

/* Depth profile: what a book of fifty orders looks like when the individual
   lines stop being readable. Translucent, because it sits over the candles and
   is context for them rather than the subject. */
.k-depth { opacity: .28; stroke: none; }
.k-buy.k-depth { fill: var(--up); }
.k-sell.k-depth { fill: var(--down); }
/* The exact levels, kept as ticks on the axis so a price is still readable. */
.k-tick { stroke-width: 1.5; opacity: .75; }
.k-buy.k-tick { stroke: var(--up); }
.k-sell.k-tick { stroke: var(--down); }

/* The shared picker opens to 60vh, which is right for a five-item filter and
   wrong for thirty-six symbols: it covered the chart it was meant to change.
   Eight rows and a scroll, anchored under its own button. */
.k-pick .picker-pop { left: 0; right: auto; min-width: 210px; }
.k-pick .picker-menu { max-height: 292px; }

/* A row count that had to stop short says so. Amber, because a truncated list
   of somebody's resting orders is not a cosmetic problem. */
.k-trunc, .count.trunc { color: var(--warn); }

/* The legend. A dashed lime line and a dashed red one are not self-explanatory,
   and a chart whose marks need explaining should carry the explanation. */
.k-legend {
  display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
  padding: 10px 14px; border-top: 1px solid var(--line-soft);
  color: var(--muted); font-size: 12px;
}
.k-legend span { display: inline-flex; align-items: center; gap: 8px; }
.k-key {
  display: inline-block; width: 16px; height: 0; flex: none;
  border-top: 2px dashed currentColor;
}
.k-legend .k-buy { color: var(--up); }
.k-legend .k-sell { color: var(--down); }
.k-key-last { color: var(--tag-ink); border-top-style: solid; }
.k-key-entry { border-top-style: dotted; border-top-width: 2px; }
@media (max-width: 760px) { .k-legend { gap: 12px; } .k-legend-entry { display: none; } }

/* The feed, as one dot. Pulsing while polls land; amber and still once one is
   overdue; hollow when the view has been panned into the past on purpose,
   which is a stop rather than a failure. */
.k-live { fill: var(--up); }
.k-live:not(.k-cold):not(.k-paused) { animation: k-beat 2s ease-out infinite; }
.k-cold { fill: var(--warn); }
.k-paused { fill: none; stroke: var(--muted-2); stroke-width: 1.4; }
@keyframes k-beat {
  0%, 100% { opacity: .35; }
  50% { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .k-live:not(.k-cold):not(.k-paused) { animation: none; opacity: 1; }
}

/* How much of the book is beyond the window, at the end it is beyond. */
.k-offscale { fill: var(--muted-2); font-size: 10px; font-weight: 600; }
