Ctrl K
ComunidadGuías…

src/dashboard/index.html

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="pageTitle.main">WindsurfAPI bydwgx1337 控制台</title>
<script>
  // Set theme before first paint to avoid a light-cream FOUC (default is dark).
  try {
    var t = localStorage.getItem('dashboard_theme') || 'dark';
    if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
  } catch (e) { document.documentElement.setAttribute('data-theme', 'dark'); }
</script>
<style>
:root {
  color-scheme: light;     /* native controls (date pickers, scrollbars) = light */
  /* ── Cool-neutral light theme (pre-workbuddy KiroStudio palette: zinc
     greys, clay accent — no warm cast). Ratios measured 2026-10-25 with
     WCAG 2.2 sRGB math; see evidence/DashboardColor.md for the palette
     table and every new pair. ── */
  --paper:      #f4f4f5;   /* neutral grey-white main background */
  --paper-2:    #ececee;   /* secondary surface */
  --paper-3:    #e2e2e5;   /* faint zone base */
  --ink:        #18181b;   /* neutral near-black (zinc-900) */
  --ink-soft:   #3f3f46;
  --mid-gray:   #a1a1aa;

  /* ── Surface / elevation ramp ────────────────────────────────────────────
     One ladder, no parallel palette:
       0 page      --bg / --paper
       1 card      --surface
       2 control   --surface-2   (fields, segmented wells)
       3 raised    --surface-3   (chips, highest fill)
     --bg-elev is the field step. Shadows pair with the same ramp:
     --shadow-sm (card) → --shadow (hover/popover) → --shadow-lg (modal).
     Adjacent steps keep ≥1.08 separation, asserted by
     scripts/check-dashboard-contrast.mjs. */
  --bg: var(--paper);
  --bg-elev: var(--surface-2);
  --surface: #ffffff;
  --surface-2: var(--paper-2);
  --surface-3: var(--paper-3);
  --border: rgba(24,24,27,.10);
  --border-strong: rgba(24,24,27,.18);
  --border-control: rgba(24,24,27,.58); /* interactive boundary — ≥3:1 on every surface */
  --text: var(--ink);
  --text-muted: #52525b;
  --text-dim: #626269;     /* 6.05 surface / 5.13 surface-2 / 4.68 surface-3 — 10px labels sit on --surface-2 */
  --text-faint: #a1a1aa;   /* disabled + decorative only, never body copy */

  /* Anthropic accent — signature clay orange. --accent is the fill/dot hue;
     --accent-text is the AA label/link hue; --accent-hover/active/focus are
     the interaction states (fills and rings). */
  --accent: #d06b49;       /* 3.57 surface / 3.04 accent-soft — fill only */
  --accent-hover: #c86543;
  --accent-active: #b85738;
  --accent-focus: #a84a2e; /* 5.70 surface — 3:1 focus-ring floor */
  --accent-text: #a3462b;  /* 6.05 surface / 5.16 accent-soft / 4.68 surface-3 */
  --accent-soft: rgba(208,107,73,.14);
  --on-accent: #18181b;    /* 4.96 on accent — white would be 3.57 */
  --gradient-accent: linear-gradient(135deg, var(--accent-soft) 0%, transparent 72%);
  --blue: #6a9bcc;
  --green: #788c5d;
  /* Status fills (--success/--warn/--error/--info) carry --on-accent labels
     and 3:1 dots. The -text twins are the AA label hue on the matching
     -soft wash (and on --surface). */
  --success: #788c5d;      /* 4.82 with --on-accent */
  --success-hover: #7f9462; /* explicit hover fill — 5.33 with --on-accent */
  --success-soft: rgba(120,140,93,.16);
  --success-text: #4f6b36; /* 6.02 surface / 5.08 success-soft / 4.66 surface-3 */
  --on-success: var(--on-accent);
  --warn: #b07d1c;         /* 4.89 with --on-accent */
  --warn-hover: #b8892a;   /* 5.61 with --on-accent */
  --warn-soft: rgba(217,162,87,.16);
  --warn-text: #8a5a00;    /* 5.93 surface / 5.25 warn-soft / 4.58 surface-3 */
  --on-warn: var(--on-accent);
  --error: #cc6658;        /* 4.73 with --on-accent */
  --error-hover: #d47466;  /* 5.47 with --on-accent */
  --error-soft: rgba(198,93,78,.14);
  --error-text: #ad392c;   /* 6.18 surface / 5.19 error-soft / 4.78 surface-3 */
  --on-error: var(--on-accent);
  --info: #5b8cc0;         /* 5.03 with --on-accent */
  --info-hover: #6a9bcc;   /* 5.23 with --on-accent */
  --info-soft: rgba(106,155,204,.16);
  --info-text: #2a6599;    /* 6.14 surface / 5.29 info-soft / 4.75 surface-3 */
  --on-info: var(--on-accent);
  --think: #6b5b95;
  --think-soft: rgba(107,91,149,.12);
  --tip-bg: rgba(24,24,27,.98);
  --tip-fg: #fafafa;
  --tip-border: rgba(250,250,250,.14);
  /* Overlay scrims — cool ink, matching the zinc ladder. */
  --scrim: rgba(24,24,27,.45);
  --scrim-strong: rgba(24,24,27,.70);

  /* data-viz palette: req = blue line+area, succ = green rate, err = red. */
  --chart-req:  #4c9aff;
  --chart-succ: #4a9d6a;
  --chart-err:  #e0524a;
  --chart-blue: #4c9aff;
  --grid: rgba(24,24,27,.09);

  /* geometry — 12 card / 8 control / 14 section */
  --radius: 12px;
  --radius-sm: 8px;
  --radius-lg: 14px;
  --radius-xs: 4px;
  --page-pad: clamp(16px, 2vw, 36px);

  /* ── Spacing scale (UISpec P2) ───────────────────────────────────────────
     Tokens are named by their px value so a rule reads without counting steps:
     --space-12 IS 12px. Even values only (2px grid); 1px stays a literal
     hairline. The account + usage surfaces below use these instead of raw px,
     enforced by scripts/check-dashboard-scale.mjs — a new off-scale padding
     fails the gate instead of quietly widening the drift. */
  --space-2: 2px;
  --space-4: 4px;
  --space-6: 6px;
  --space-8: 8px;
  --space-10: 10px;
  --space-12: 12px;
  --space-14: 14px;
  --space-16: 16px;
  --space-20: 20px;
  --space-24: 24px;
  --space-32: 32px;
  /* 44 px row budget. The comfortable account row lands on 44 with 5 px of
     vertical cell padding (see the .acct-tier-stack note); 5 is deliberately
     NOT on the 2 px grid so a scale sweep cannot silently move the row height. */
  --acct-row-pad-y: 5px;

  /* ── Type scale (UISpec P2) ──────────────────────────────────────────────
     One dense ladder for the console. 3xs/2xs are table micro-labels, xs/sm
     are meta, md/lg body, xl+ headings and KPI values. */
  --fs-3xs: 9px;
  --fs-2xs: 10px;
  --fs-xs: 11px;
  --fs-sm: 12px;
  --fs-md: 13px;
  --fs-lg: 14px;
  --fs-xl: 15px;
  --fs-2xl: 18px;
  --fs-3xl: 22px;
  --fs-4xl: 24px;

  /* ── Provider swatches (UISpec P2-12) ────────────────────────────────────
     Brand-adjacent hues tuned to ≥3:1 against --surface in THIS theme
     (graphical-object rule, WCAG 1.4.11) — the old hardcoded set was 2.4–2.9
     on one theme or the other. The dark block carries the lighter ramp because
     the dark surface is #141414. Recomputed by
     scripts/check-dashboard-contrast.mjs; the swatch never carries information
     alone (the provider name sits next to it). */
  --prov-anthropic: #b45309;
  --prov-openai: #047857;
  --prov-google: #1a56db;
  --prov-deepseek: #6d28d9;
  --prov-xai: #be123c;
  --prov-alibaba: #be185d;
  --prov-moonshot: #0e7490;
  --prov-zhipu: #7e22ce;
  --prov-minimax: #b91c1c;
  --prov-windsurf: #4338ca;
  --prov-other: #52525b;
  /* OAuth brand blocks. White on Google's #4285F4 is 3.56 (AA fail for text);
     the deeper blue clears 4.5 while staying recognisably Google. */
  --brand-google-bg: #1967d2;
  --brand-google-fg: #ffffff;
  --brand-google-bg-hover: #1558d6;
  --brand-github-bg: #24292e;
  --brand-github-fg: #ffffff;
  --brand-github-bg-hover: #1b1f23;
  --shadow-sm: 0 1px 2px rgba(24,24,27,.05), 0 1px 3px rgba(24,24,27,.08);
  --shadow: 0 2px 4px rgba(24,24,27,.04), 0 4px 12px rgba(24,24,27,.06);
  --shadow-lg: 0 8px 24px rgba(24,24,27,.10), 0 2px 8px rgba(24,24,27,.06);
  --ink-shadow-soft: var(--shadow-sm);

  /* font stacks — local system sans (no Google Fonts dependency) */
  --font: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', system-ui, sans-serif;
  --font-display: ui-serif, Georgia, 'Source Han Serif SC', 'SimSun', serif;
  --serif: var(--font-display);
  --hand: var(--font);
  --mono: 'Cascadia Code', 'Consolas', 'SFMono-Regular', monospace;
}

/* ── Dark theme (toggle via [data-theme="dark"] on <html>) ── */
[data-theme="dark"] {
  color-scheme: dark;      /* native controls follow the dark theme */
  /* ── Cool-neutral dark theme (Vercel-style near-black, zero warm cast;
     default theme). Each surface step keeps ≥1.08 separation from the one
     below it so a card still reads as a card on near-black. ── */
  --paper: #080808;
  --paper-2: #141414;
  --paper-3: #1f1f1f;
  --ink: #ededed;
  --ink-soft: #c4c4c6;
  --mid-gray: #8a8a8f;
  --bg: var(--paper);
  --bg-elev: var(--surface-2);
  --surface: #141414;
  --surface-2: #1d1d1d;
  --surface-3: #262626;
  --border: rgba(255,255,255,.10);
  --border-strong: rgba(255,255,255,.18);
  --border-control: rgba(255,255,255,.42); /* interactive boundary — ≥3:1 on every surface */
  --text: var(--ink);
  --text-muted: #a1a1aa;
  --text-dim: #90909a;     /* 5.83 on surface — was #71717a (3.81 FAIL) */
  --text-faint: #71717a;
  --accent: #d97757;       /* 5.90 — fill only */
  --accent-hover: #c86543;
  --accent-active: #b85f42;
  --accent-focus: #e89a7d; /* 8.20 surface */
  --accent-text: #df8a6b;  /* 7.01 surface / 5.28 accent-soft */
  --accent-soft: rgba(217,119,87,.20);
  --on-accent: #141414;    /* 5.86 — white would be 3.12 */
  --gradient-accent: linear-gradient(135deg, var(--accent-soft) 0%, transparent 72%);
  --success: #788c5d;      /* 5.01 */
  --success-hover: #86996a; /* 5.95 with --on-accent */
  --success-soft: rgba(120,140,93,.22);
  --success-text: #96b277; /* 7.84 surface / 5.90 success-soft */
  --on-success: var(--on-accent);
  --warn: #d9a257;         /* 8.12 */
  --warn-hover: #e3b06b;   /* 9.38 with --on-accent */
  --warn-soft: rgba(217,162,87,.22);
  --warn-text: #d9a95f;    /* 8.59 surface / 5.73 warn-soft */
  --on-warn: var(--on-accent);
  --error: #cc6658;        /* 4.92 */
  --error-hover: #d97a6c;  /* 6.10 with --on-accent */
  --error-soft: rgba(198,93,78,.22);
  --error-text: #da8578;   /* 6.67 surface / 5.21 error-soft */
  --on-error: var(--on-accent);
  --info: #6a9bcc;         /* 6.29 */
  --info-hover: #8abcf0;   /* 9.24 with --on-accent */
  --info-soft: rgba(106,155,204,.22);
  --info-text: #7cb2ee;    /* 8.30 surface / 5.88 info-soft */
  --on-info: var(--on-accent);
  --think: #b3a4d0;
  --think-soft: rgba(179,164,208,.16);
  --blue: #6a9bcc;
  --green: #788c5d;
  --grid: rgba(255,255,255,.09);
  --tip-bg: rgba(28,28,30,.98);
  --tip-fg: var(--text);
  --tip-border: rgba(255,255,255,.14);
  --scrim: rgba(0,0,0,.55);
  --scrim-strong: rgba(0,0,0,.70);
  /* Provider swatches: lighter ramp for the dark surface (≥3:1 on #141414). */
  --prov-anthropic: #f0a86e;
  --prov-openai: #6ee7b7;
  --prov-google: #93c5fd;
  --prov-deepseek: #c4b5fd;
  --prov-xai: #fda4af;
  --prov-alibaba: #f9a8d4;
  --prov-moonshot: #67e8f9;
  --prov-zhipu: #d8b4fe;
  --prov-minimax: #fca5a5;
  --prov-windsurf: #a5b4fc;
  --prov-other: #a1a1aa;
  --shadow-sm: 0 1px 2px rgba(0,0,0,.30), 0 1px 3px rgba(0,0,0,.40);
  --shadow: 0 2px 4px rgba(0,0,0,.30), 0 4px 14px rgba(0,0,0,.40);
  --shadow-lg: 0 8px 24px rgba(0,0,0,.50), 0 2px 8px rgba(0,0,0,.40);
}
/* dark: top inset highlight gives cards/sections a subtle glass depth */
[data-theme="dark"] .card,
[data-theme="dark"] .section {
  box-shadow: inset 0 1px 0 rgba(255,255,255,.04), var(--shadow-sm);
}
[data-theme="dark"] .card:hover { box-shadow: inset 0 1px 0 rgba(255,255,255,.05), var(--shadow); }

* { margin: 0; padding: 0; box-sizing: border-box; }
*::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }

/* Accessibility: honor reduced-motion. Decorative/idle animation (chart header
   shimmer, gacha pulses, etc.) is suppressed; transitions collapse to instant.
   Operational spinners keep a minimal animation so "loading" stays legible. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
    scroll-behavior: auto !important;
  }
  /* A loading spinner is informative motion, not decorative — keep it spinning
     (slower) so "loading" is still conveyed under reduced-motion. */
  .spinner { animation-duration: .8s !important; animation-iteration-count: infinite !important; }
}
body {
  font-family: var(--font);
  background-color: var(--paper);
  color: var(--text);
  font-size: 14px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  display: flex;
  min-height: 100vh;
}

/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--surface-3); border-radius: 5px; border: 2px solid var(--bg); }
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }

a { color: var(--accent-text); text-decoration: none; }
code {
  font-family: var(--mono);
  font-size: 12px;
  background: var(--surface-2);
  color: var(--text);
  padding: 2px 6px;
  border-radius: 4px;
  border: 1px solid var(--border);
}

/* ─── Sidebar ──────────────────────────────────── */
.sidebar {
  width: 232px;
  background: var(--surface);
  border-right: 1px solid var(--border);
  position: fixed;
  top: 0; left: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  z-index: 10;
}
.sidebar .brand {
  padding: 20px 22px 18px;
  display: flex;
  align-items: center;
  gap: 10px;
  border-bottom: 1px solid var(--border);
}
.sidebar .brand-logo {
  width: 32px; height: 32px;
  border-radius: 8px;
  background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
  display: flex; align-items: center; justify-content: center;
  font-weight: 800; color: var(--on-accent); font-size: 15px;
  box-shadow: 0 4px 12px var(--accent-soft);
}
.sidebar .brand-name { font-size: 15px; font-weight: 600; letter-spacing: -.01em; }
.sidebar .brand-sub { font-size: 12px; color: var(--text-dim); margin-top: 1px; }

.sidebar nav { flex: 1; padding: 12px 10px; overflow-y: auto; }
.sidebar nav .nav-group { margin-bottom: 18px; }
.sidebar nav .nav-group-label {
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: .08em;
  color: var(--text-dim);
  padding: 0 12px 6px;
  font-weight: 600;
}
.sidebar nav a {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  color: var(--text-muted);
  font-size: 13px;
  font-weight: 500;
  border-radius: var(--radius-sm);
  transition: all .15s;
  margin-bottom: 2px;
}
.sidebar nav a:hover { color: var(--text); background: var(--surface-2); }
.sidebar nav a.active {
  color: var(--text);
  background: var(--surface-3);
  box-shadow: inset 2px 0 0 var(--accent);
}
.sidebar nav a svg { width: 16px; height: 16px; stroke-width: 2; flex-shrink: 0; }

.sidebar .footer {
  padding: 12px 18px;
  font-size: 12px;
  color: var(--text-dim);
  border-top: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.sidebar .footer-actions { display: flex; align-items: center; gap: 8px; }
.sidebar .footer-meta { display: flex; align-items: center; gap: 6px; white-space: nowrap; overflow: hidden; }
.sidebar .footer-meta .ver { overflow: hidden; text-overflow: ellipsis; }
.footer-icon-btn { display: inline-flex; align-items: center; justify-content: center; padding: 4px 6px; }
.footer-icon-btn svg { width: 15px; height: 15px; display: block; }
.sidebar .footer .ver { font-family: var(--mono); }

/* ─── Main Layout ─────────────────────────────── */
.main {
  margin-left: 232px;
  flex: 1; min-width: 0;
  padding: 20px var(--page-pad) 60px;
  min-height: 100vh;
  max-width: 2000px;
  /* min-width:0 lets this flex column shrink; .table-wrap owns horizontal scroll (accounts was 1628px @1440). */
}
.page-header {
  position: relative;
  isolation: isolate;
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 24px;
  gap: 16px;
  flex-wrap: wrap;
}
/* Subtle accent wash behind the title/actions. Absolute, so it adds no box
   to the layout and cannot move the header's 24px rhythm. */
.page-header::before {
  content: "";
  position: absolute;
  inset: -10px -14px;
  z-index: -1;
  border-radius: var(--radius);
  background: var(--gradient-accent);
  pointer-events: none;
}
.page-title {
  font-family: var(--font);
  font-size: 24px;
  font-weight: 650;
  letter-spacing: -.02em;
}
.page-subtitle {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 4px;
  text-transform: uppercase;
  letter-spacing: .22em;
}
.panel { display: none; animation: fadeIn .2s ease; }
.panel.active { display: block; }

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ─── Cards ───────────────────────────────────── */
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 16px 18px;
  box-shadow: var(--shadow-sm);
  transition: transform .18s cubic-bezier(.16,1,.3,1), box-shadow .18s cubic-bezier(.16,1,.3,1), border-color .18s;
  /* Consistent shape so a row of KPI cards lines up (no "gnawed" ragged edges):
     fixed min-height, header pinned top, value block pushed to a common baseline. */
  display: flex;
  flex-direction: column;
  min-height: 104px;
}
.card:hover { transform: translateY(1px); box-shadow: var(--shadow-sm); border-color: var(--border-strong); }

/* ─── Self-Healing cells (C1) ─── */
.heal-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
.heal-cell { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; background: var(--surface); box-shadow: var(--shadow-sm); border-left: 3px solid var(--border); transition: border-color .18s, box-shadow .18s; }
.heal-cell.good { border-left-color: var(--green); }
.heal-cell.bad { border-left-color: var(--error); }
.heal-cell.neutral { border-left-color: var(--blue); }
.heal-cell:hover { box-shadow: var(--shadow-sm); transform: translateY(1px); }
.heal-cell .heal-num { font-size: 26px; font-weight: 700; font-variant-numeric: tabular-nums; line-height: 1.1; letter-spacing: -.02em; }
.heal-cell.good .heal-num { color: var(--success-text); }
.heal-cell.bad.hot .heal-num { color: var(--error-text); }
.heal-cell.neutral .heal-num { color: var(--info-text); }
.heal-cell.zero .heal-num { color: var(--text-dim); }
.heal-cell .heal-lbl { font-size: 12px; color: var(--text-muted); letter-spacing: .06em; text-transform: uppercase; margin-top: 2px; }
.heal-cell .heal-ok { font-size: 12px; color: var(--success-text); letter-spacing: .04em; }
@keyframes healflash { 0% { transform: scale(.94); opacity: .6; } 14% { transform: scale(1.06); opacity: 1; } 100% { transform: scale(1); opacity: 1; } }
.heal-num.bump { animation: healflash .45s cubic-bezier(.16,1,.3,1); }

/* ─── GlowGrid — account-pool "compute core" wall (KiroStudio-style) ───
   Each account = a small breathing core, tinted by health, lit with a low-freq
   glow. Hover lifts + intensifies the halo; a live hit flashes the core. */
.gg-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(24px, 1fr)); gap: 6px; }
.gg-core {
  position: relative; aspect-ratio: 1; border-radius: 5px; cursor: pointer;
  transition: transform .2s ease-out;
}
.gg-core:hover { z-index: 10; transform: translateY(-2px) scale(1.18); }
.gg-core-breathe {
  position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
  animation: gg-breathe var(--gg-dur, 4.4s) ease-in-out infinite;
  animation-delay: var(--gg-delay, 0s);
}
@keyframes gg-breathe { 0%,100% { opacity: .4; } 50% { opacity: .62; } }
.gg-core-spec { /* top lens highlight */
  position: absolute; left: 2px; right: 2px; top: 2px; height: 33%;
  border-radius: 999px; background: rgba(255,255,255,.12); filter: blur(1.5px);
  pointer-events: none; transition: background .2s;
}
.gg-core:hover .gg-core-spec { background: rgba(255,255,255,.28); }
.gg-core-halo { /* hover outer glow, transparent until hover */
  position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
  opacity: 0; transition: opacity .2s;
}
.gg-core:hover .gg-core-halo { opacity: 1; }
.gg-core-flash { /* live-hit: white core + tint burst, single replay */
  position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
  animation: gg-flash .9s ease-out forwards;
}
@keyframes gg-flash { 0% { opacity: 1; transform: scale(.9); } 100% { opacity: 0; transform: scale(1.35); } }
@media (prefers-reduced-motion: reduce) {
  .gg-core-breathe, .gg-core-flash { animation: none; }
  .gg-core-breathe { opacity: .7; }
}
.gg-legend { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 6px 16px; font-size: 12px; color: var(--text-muted); }
.gg-legend span.lg { display: inline-flex; align-items: center; gap: 6px; }
.gg-legend .lg-sw { width: 10px; height: 10px; border-radius: 3px; display: inline-block; }
/* Saturated core "on fire": a flickering ember glow layer (canvas-free, cheap —
   only the handful of RPM-saturated cores get it). Two offset flame flickers +
   a rising hue give a live-fire feel without a WebGL context. */
.gg-core.hot { z-index: 2; }
.gg-core .gg-flame {
  position: absolute; inset: -2px; border-radius: 6px; pointer-events: none;
  background:
    radial-gradient(60% 80% at 30% 100%, rgba(255,90,20,.95), rgba(255,60,10,.25) 55%, transparent 72%),
    radial-gradient(55% 75% at 68% 100%, rgba(255,170,40,.9), rgba(255,110,20,.2) 55%, transparent 72%),
    radial-gradient(50% 60% at 50% 100%, rgba(255,230,120,.95), transparent 65%);
  mix-blend-mode: screen; filter: blur(1px);
  animation: gg-flame 2.2s ease-in-out infinite alternate;
}
.gg-core .gg-flame::after {
  content: ''; position: absolute; inset: 0; border-radius: 6px;
  box-shadow: 0 0 10px 2px rgba(255,120,30,.7), 0 -3px 8px 1px rgba(255,80,15,.55);
}
@keyframes gg-flame {
  0%   { transform: translateY(0) scaleY(1);    opacity: .9; }
  100% { transform: translateY(-1px) scaleY(1.14); opacity: 1; }
}
/* Live-hit flash: a request just flowed through this core — white core + tint
   burst that scales out and fades once (re-triggered by remounting via key). */
.gg-core .gg-hit {
  position: absolute; inset: 0; border-radius: 5px; pointer-events: none;
  background: radial-gradient(circle at 50% 45%, rgba(255,255,255,.95), var(--gg-hit-tint, rgba(120,220,255,.6)) 55%, transparent 72%);
  animation: gg-hit .7s ease-out forwards;
}
@keyframes gg-hit { 0% { opacity: 1; transform: scale(.85); } 100% { opacity: 0; transform: scale(1.4); } }
@media (prefers-reduced-motion: reduce) {
  .gg-core .gg-flame, .gg-core .gg-hit { animation: none; }
}

/* ─── StatusBars view (dense ops-panel rows) ─── */
.sbar-list { display: flex; flex-direction: column; gap: 6px; }
.sbar {
  position: relative; display: flex; align-items: center; gap: 10px;
  height: 36px; padding: 0 12px 0 15px; border: 1px solid var(--border);
  border-radius: 8px; overflow: hidden; cursor: pointer;
  transition: border-color .15s, background .15s;
}
.sbar:hover { border-color: var(--border-strong); }
.sbar-band { position: absolute; inset: 0 auto 0 0; width: 3px; }
.sbar-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.sbar-id { font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; color: var(--text); flex-shrink: 0; }
.sbar-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--text-muted); }
.sbar-tier { flex-shrink: 0; font-family: var(--mono); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); background: var(--surface-2); border-radius: 999px; padding: 2px 8px; }
.sbar-fire { flex-shrink: 0; font-size: 12px; }
.sbar-metric { flex-shrink: 0; font-family: var(--mono); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; min-width: 34px; }
.sbar-metric .u { font-size: 12px; color: var(--text-muted); opacity: 1; }
.sbar-rpm { color: var(--info-text); }
.sbar-inflight { color: var(--accent-text); min-width: 52px; }
.sbar-ok { color: var(--success-text); min-width: 30px; }
.sbar-err { color: var(--error-text); min-width: 26px; }
.sbar-muted { color: var(--text-dim); min-width: 26px; }
.sbar.hot { border-color: rgba(255,120,30,.5); box-shadow: 0 0 8px rgba(255,110,20,.25) inset; }
/* WebGL fire (KiroStudio FireCanvas port): a full-width burning band behind the
   row content on saturated accounts. Absolutely positioned so it fills the row,
   clipped to the border-radius by .sbar's overflow:hidden. `screen` blend makes
   the flame add light over the dark row without a hard rectangle. Sits under the
   text/metrics (z-index 0; children default above). Only mounted on the hottest
   .hot rows (see _mountBarFires), capped by _FIRE_MAX. */
.sbar-fire-canvas {
  position: absolute; inset: 0; width: 100%; height: 100%;
  pointer-events: none; mix-blend-mode: screen; z-index: 0; opacity: .92;
}
.sbar.hot > *:not(.sbar-fire-canvas) { position: relative; z-index: 1; }
/* When the emoji fallback is used (no WebGL2) the row keeps its CSS glow. */
@media (prefers-reduced-motion: reduce) { .sbar-fire-canvas { display: none; } }

/* ─── Mouse-following account hover card (both pool views) ─── */
.pool-hover-card {
  display: none; position: fixed; z-index: 300; pointer-events: none;
  min-width: 180px; max-width: 260px; padding: 10px 12px;
  background: var(--tip-bg); color: var(--tip-fg);
  border: 1px solid var(--tip-border); border-radius: 10px;
  box-shadow: var(--shadow-lg); font-size: 12px; line-height: 1.55;
  animation: phc-rise .13s ease-out;
}
@keyframes phc-rise { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
.phc-head { display: flex; align-items: center; gap: 6px; font-weight: 600; font-family: var(--mono); }
.phc-dot { width: 8px; height: 8px; border-radius: 50%; }
.phc-fire { font-size: var(--fs-sm); font-weight: 500; color: var(--warn-text); }
.phc-email { color: var(--text-muted); font-size: 12px; margin: 2px 0 6px; word-break: break-all; }
.phc-row { display: flex; justify-content: space-between; gap: 14px; }
.phc-k { color: var(--text-muted); }
.phc-v { font-variant-numeric: tabular-nums; }

/* ─── Rankings (C2) ─── */
.rank-row { margin-bottom: 10px; }
.rank-top { display: flex; align-items: center; gap: 8px; margin-bottom: 3px; }
.rank-no { width: 20px; height: 20px; flex: none; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-family: var(--hand); font-weight: 700; font-size: 12px; background: var(--paper-3); color: var(--text-muted); }
.rank-row.first .rank-no { background: var(--accent-soft); color: var(--accent-text); border: 1.5px solid var(--accent); }
.rank-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rank-val { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.rank-track { height: 7px; border-radius: 9999px; background: var(--paper-3); overflow: hidden; }
.rank-row.first .rank-track { height: 9px; }
.rank-fill { height: 100%; border-radius: 9999px; width: 0; background: linear-gradient(90deg, var(--accent), var(--accent-hover)); transition: width 700ms cubic-bezier(.16,1,.3,1); }
.rank-sub { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; padding-left: 28px; }
.rank-chip { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; background: var(--paper-3); border-radius: 6px; padding: 1px 7px; line-height: 1.6; }
.rank-chip-dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
@media (prefers-reduced-motion: reduce) { .rank-fill { transition: none; } }

.card-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 8px;
  min-height: 16px;
  margin-bottom: 0;
}
/* Value block sits at the same vertical position across all cards regardless of
   whether a card has an icon or a longer title. */
.card-title {
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: .12em;
}
.card-icon {
  width: 18px; height: 18px;
  color: var(--text-dim);
}
.card-value {
  font-size: 27px;
  font-weight: 700;
  letter-spacing: -.02em;
  line-height: 1.2;
  font-variant-numeric: tabular-nums;
  margin-top: auto; /* pin the value block to a common baseline across the row */
}
.card-sub { font-size: 12px; color: var(--text-muted); margin-top: 4px; min-height: 15px; }
/* Loading skeleton: pure-CSS shimmer placeholder shown before data arrives.
   The universal reduced-motion rule above freezes the shimmer automatically. */
.skeleton {
  position: relative;
  overflow: hidden;
  background: var(--surface, rgba(255,255,255,.04));
  border-radius: 6px;
  color: transparent !important;
}
.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg, transparent, var(--border, rgba(255,255,255,.08)), transparent);
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
}
@keyframes skeleton-shimmer { 100% { transform: translateX(100%); } }
.skeleton-line { height: 1em; margin: 6px 0; }
.skeleton-card { height: 96px; }
.batch-bar { display: flex; align-items: center; gap: 12px; padding: 8px 12px; margin-bottom: 10px; background: var(--surface, rgba(255,255,255,.04)); border: 1px solid var(--border, rgba(255,255,255,.08)); border-radius: 8px; }
.batch-bar .batch-count { font-size: var(--fs-md); font-weight: 600; color: var(--accent-text); }
.card.accent .card-value { color: var(--accent-text); }
.card.success .card-value { color: var(--success-text); }
.card.warn .card-value { color: var(--warn-text); }
.card.error .card-value { color: var(--error-text); }
.card.info .card-value { color: var(--info-text); }
/* semantic left rail + tinted icon so status reads at a glance in dark */
.card.success, .card.info, .card.warn, .card.error, .card.accent { border-left: 3px solid transparent; }
.card.success { border-left-color: var(--success); }
.card.info    { border-left-color: var(--info); }
.card.warn    { border-left-color: var(--warn); }
.card.error   { border-left-color: var(--error); }
.card.accent  { border-left-color: var(--accent); }
.card.success .card-icon { color: var(--success-text); }
.card.info    .card-icon { color: var(--info-text); }
.card.warn    .card-icon { color: var(--warn-text); }
.card.error   .card-icon { color: var(--error-text); }
.card.accent  .card-icon { color: var(--accent-text); }

.metrics-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 14px;
  margin-bottom: 24px;
}
/* KPI/summary cards carry the same accent wash as the page header. The image
   paints over --surface; no geometry, border or content changes. */
.metrics-grid .card { background-image: var(--gradient-accent); }
/* Overview has 6 KPI cards — use column counts that divide evenly (3×2 / 2×3)
   so there is never a lone orphan card dangling on its own row. */
#overview-cards { grid-template-columns: repeat(3, 1fr); }
@media (max-width: 1100px) { #overview-cards { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { #overview-cards { grid-template-columns: 1fr; } }

/* ─── Section Block ───────────────────────────── */
.section {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  margin-bottom: 20px;
  overflow: hidden;
  box-shadow: var(--shadow-sm);
}
.section-header {
  padding: 16px 20px;
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
/* Compact account-table density (App.toggleAccountsDensity): denser rows so a
   large pool scans faster. Toggled via the accounts-list density button and
   persisted in localStorage. */
#accounts-table.accounts-compact th,
#accounts-table.accounts-compact td {
  padding-top: var(--acct-row-pad-y);
  padding-bottom: var(--acct-row-pad-y);
  font-size: var(--fs-sm);
}
#accounts-density-btn.active { background: var(--accent-soft); color: var(--accent-text); }
.section-title {
  font-family: var(--font);
  font-size: 16px;
  font-weight: 650;
  letter-spacing: -.02em;
}
.section-desc {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 2px;
  font-weight: 400;
  letter-spacing: .02em;
}
.section-body { padding: 20px; }
.section-body.tight { padding: 0; }

/* ─── Buttons ─────────────────────────────────── */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 16px;
  font-size: 13px;
  font-weight: 500;
  font-family: var(--font);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: all .15s;
  background: var(--surface-2);
  color: var(--text);
  white-space: nowrap;
  line-height: 1.2;
}
.btn:hover:not(:disabled) { background: var(--surface-3); border-color: var(--border-strong); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn svg { width: 14px; height: 14px; }

.btn-primary {
  background: var(--accent);
  color: var(--on-accent);
  border-color: var(--accent);
  box-shadow: 0 1px 3px var(--accent-soft);
}
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-primary:active:not(:disabled) {
  background: var(--accent-hover);
  border-color: var(--accent-active);
  box-shadow: inset 0 1px 2px rgba(0,0,0,.18);
}

.btn-outline {
  background: transparent;
  border-color: var(--border-control);
  color: var(--text);
}
.btn-outline:hover:not(:disabled) { background: var(--surface-2); border-color: var(--accent); }

.btn-ghost { background: transparent; border-color: transparent; color: var(--text-muted); }
.btn-ghost:hover:not(:disabled) { background: var(--surface-2); color: var(--text); }

.btn-danger {
  background: var(--error);
  color: var(--on-error);
  border-color: var(--error);
}
.btn-danger:hover:not(:disabled) {
  background: var(--error-hover);
  border-color: var(--error-hover);
  color: var(--on-error);
}

.btn-success {
  background: var(--success);
  color: var(--on-success);
  border-color: var(--success);
}
.btn-success:hover:not(:disabled) {
  background: var(--success-hover);
  border-color: var(--success-hover);
  color: var(--on-success);
}

.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-xs { padding: 3px 8px; font-size: 12px; }
.btn-icon { padding: 7px; }

.btn-group { display: inline-flex; gap: 6px; flex-wrap: wrap; }

.btn.active, .btn.active:hover {
  background: var(--accent); color: var(--on-accent); border-color: var(--accent);
}

/* ─── Switch ──────────────────────────────────── */
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.switch-slider {
  position: absolute; cursor: pointer; inset: 0;
  background: var(--surface-2); border: 1px solid var(--border-control);
  border-radius: 22px; transition: .18s;
}
.switch-slider::before {
  content: ''; position: absolute; height: 16px; width: 16px;
  left: 2px; bottom: 2px; background: var(--text);
  border-radius: 50%; transition: .18s;
}
.switch input:checked + .switch-slider {
  background: var(--accent); border-color: var(--accent);
}
.switch input:checked + .switch-slider::before { transform: translateX(18px); background: var(--on-accent); }

/* ─── Form Controls ───────────────────────────── */
.input, .select, .textarea {
  display: block;
  width: 100%;
  padding: 9px 12px;
  font-size: 13px;
  font-family: var(--font);
  background: var(--bg-elev);
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  color: var(--text);
  outline: none;
  transition: all .15s;
  line-height: 1.35;
}
.input:hover, .select:hover, .textarea:hover { border-color: var(--accent); }
.input:focus, .select:focus, .textarea:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::placeholder, .textarea::placeholder { color: var(--text-dim); }
/* type=search clear-X: themed, visible cancel affordance (default is near-invisible
   on a dark field). Appears only when the field has a value. */
.input[type=search]::-webkit-search-cancel-button {
  -webkit-appearance: none;
  height: 13px; width: 13px; margin-left: 6px; cursor: pointer;
  background: var(--text-dim);
  -webkit-mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M18 6L6 18M6 6l12 12' stroke='black' stroke-width='3' stroke-linecap='round'/></svg>") center/contain no-repeat;
          mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M18 6L6 18M6 6l12 12' stroke='black' stroke-width='3' stroke-linecap='round'/></svg>") center/contain no-repeat;
}
.input[type=search]::-webkit-search-cancel-button:hover { background: var(--text); }

.select {
  appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236a675e' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
  background-repeat: no-repeat;
  background-position: right 10px center;
  padding-right: 32px;
  cursor: pointer;
}
/* Chevron follows --text-dim per theme (a data URI cannot read a custom prop,
   so the two ramps are spelled out; the old #a1a1aa was 2.56 on white). */
[data-theme="dark"] .select { background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239a978e' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>"); }
.select option { background: var(--surface); color: var(--text); }

/* ── Custom themed select (enhances a hidden native <select>) ── */
.cs { position: relative; display: block; width: 100%; }
.cs-native {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  opacity: 0; pointer-events: none;
}
.cs-trigger {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; padding: 9px 12px; font-size: 13px; font-family: var(--font);
  background: var(--bg-elev); border: 1px solid var(--border-control);
  border-radius: var(--radius-sm); color: var(--text); cursor: pointer;
  text-align: left; line-height: 1.35; transition: border-color .15s, box-shadow .15s;
}
.cs-trigger:hover { border-color: var(--accent); }
.cs-trigger:focus-visible,
.cs-trigger[aria-expanded="true"] {
  outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft);
}
.cs-trigger[disabled] { opacity: .5; cursor: not-allowed; }
.cs-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cs-chevron { flex-shrink: 0; width: 12px; height: 12px; color: var(--text-dim); transition: transform .15s; }
.cs-trigger[aria-expanded="true"] .cs-chevron { transform: rotate(180deg); }
.cs-pop {
  position: fixed; z-index: 150; min-width: 120px; max-height: 280px; overflow-y: auto;
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
  box-shadow: var(--shadow-lg); padding: 5px; transform-origin: top;
  animation: modalIn .16s cubic-bezier(.16,1,.3,1);
}
.cs-pop::-webkit-scrollbar { width: 8px; }
.cs-pop::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; border: 2px solid var(--surface); }
.cs-pop::-webkit-scrollbar-track { background: transparent; }
.cs-opt {
  display: flex; align-items: center; justify-content: space-between; gap: 10px;
  padding: 8px 10px; font-size: 13px; color: var(--text-muted); border-radius: var(--radius-sm);
  cursor: pointer; white-space: nowrap; transition: background .1s, color .1s;
}
.cs-opt + .cs-opt { margin-top: 1px; }
.cs-opt[aria-selected="true"] { color: var(--text); font-weight: 500; }
.cs-opt.cs-hi { background: var(--accent-soft); color: var(--text); }
.cs-opt[aria-disabled="true"] { opacity: .4; cursor: not-allowed; }
.cs-opt .cs-check { color: var(--accent-text); flex-shrink: 0; opacity: 0; font-size: 12px; }
.cs-opt[aria-selected="true"] .cs-check { opacity: 1; }

/* ── Custom number stepper (enhances native input[type=number]) ── */
/* inline-flex + max-content so the wrapper shrink-wraps its input — otherwise a
   max-width-constrained input leaves the wrapper full-width and the absolutely
   positioned .ns-btns float far to the right of the (narrow) field. */
.ns { position: relative; display: inline-flex; width: max-content; max-width: 100%; vertical-align: middle; }
.ns-input { flex: 1; padding-right: 30px; font-variant-numeric: tabular-nums; }
.ns-input::-webkit-outer-spin-button,
.ns-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.ns-input[type=number] { -moz-appearance: textfield; }
.ns:focus-within .ns-input { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.ns-btns {
  position: absolute; right: 1px; top: 1px; bottom: 1px; width: 26px;
  display: flex; flex-direction: column; border-left: 1px solid var(--border);
}
.ns-btn {
  flex: 1; display: flex; align-items: center; justify-content: center;
  background: transparent; border: none; color: var(--text-dim); cursor: pointer;
  font-size: 12px; line-height: 1; padding: 0; user-select: none;
}
.ns-btn:hover { color: var(--text); background: var(--surface-2); }
.ns-btn:first-child { border-radius: 0 var(--radius-sm) 0 0; }
.ns-btn:last-child { border-radius: 0 0 var(--radius-sm) 0; border-top: 1px solid var(--border); }
.ns-input[disabled] ~ .ns-btns .ns-btn { opacity: .5; cursor: not-allowed; }

/* Keyboard focus visibility for controls whose real input is opacity:0 (switch /
   checkbox / segmented) plus nav + segmented buttons — otherwise keyboard users
   have no visible focus. Mouse users are unaffected (:focus-visible only). */
.switch input:focus-visible + .switch-slider,
.checkbox input:focus-visible + .box,
.segmented label:has(input:focus-visible) {
  outline: 2px solid var(--accent-focus); outline-offset: 2px;
  box-shadow: none; border-color: var(--accent);
}
/* P1-C6: a wash-only ring (1.17 light / 1.33 dark against --surface) is not a
   sufficient sole focus indicator. Sidebar links, segmented buttons and
   role=button controls get a solid 2px accent outline with offset. */
.sidebar nav a:focus-visible,
.seg-btn:focus-visible,
[role="button"]:focus-visible {
  outline: 2px solid var(--accent-focus);
  outline-offset: 2px;
  box-shadow: none;
  border-radius: 3px;
}
.btn:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
/* Solid-fill buttons: a ring floated off the fill (gap + accent) so it reads on
   any background. */
.btn-primary:focus-visible, .btn-danger:focus-visible, .btn-success:focus-visible {
  box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-focus);
}
a:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; border-radius: 2px; }

.field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 0; }
.field-label {
  font-size: 12px;
  color: var(--text-muted);
  font-weight: 500;
}
.field-hint {
  font-size: 12px;
  color: var(--text-dim);
  line-height: 1.5;
}
.field-row {
  display: grid;
  gap: 12px;
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
  align-items: end;
}
.field-row-actions {
  display: flex;
  gap: 8px;
  margin-top: 14px;
  flex-wrap: wrap;
}

/* Input group (prefix + input) */
.input-group { display: flex; gap: 6px; align-items: stretch; }
.input-group .input { flex: 1; }

/* Switch / Checkbox */
.checkbox {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  font-size: 13px;
  color: var(--text-muted);
  user-select: none;
}
.checkbox input { position: absolute; opacity: 0; pointer-events: none; }
.checkbox .box {
  width: 16px; height: 16px;
  border: 1.5px solid var(--border-control);
  border-radius: 4px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--bg-elev);
  transition: all .15s;
  flex-shrink: 0;
}
.checkbox input:checked + .box {
  background: var(--accent);
  border-color: var(--accent);
}
.checkbox input:checked + .box::after {
  content: '';
  width: 4px; height: 8px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) translateY(-1px);
}
.checkbox:hover .box { border-color: var(--accent); }

/* Themed bare checkbox / radio. Paints EVERY raw <input type=checkbox|radio>
   that is NOT already wrapped by a styled component (.switch toggle, .checkbox
   with .box, .segmented radiogroup — those hide the native input via opacity:0
   and draw their own control). One rule themes the model picker, batch-select,
   blocked-models editor, and every settings checkbox at once — existing and
   future — with no per-element markup. Uses theme vars so light/dark both work. */
input[type=checkbox]:not(.switch input):not(.checkbox input),
input[type=radio]:not(.segmented input) {
  appearance: none;
  -webkit-appearance: none;
  width: 16px;
  height: 16px;
  margin: 0;
  flex-shrink: 0;
  vertical-align: -3px;
  border: 1.5px solid var(--border-control);
  background: var(--bg-elev);
  cursor: pointer;
  transition: background .15s, border-color .15s, box-shadow .15s;
  position: relative;
}
input[type=checkbox]:not(.switch input):not(.checkbox input) { border-radius: 4px; }
input[type=radio]:not(.segmented input) { border-radius: 50%; }
input[type=checkbox]:not(.switch input):not(.checkbox input):hover,
input[type=radio]:not(.segmented input):hover { border-color: var(--accent); }
input[type=checkbox]:not(.switch input):not(.checkbox input):checked,
input[type=radio]:not(.segmented input):checked {
  background: var(--accent);
  border-color: var(--accent);
}
/* checkbox tick (matches .checkbox .box::after) */
input[type=checkbox]:not(.switch input):not(.checkbox input):checked::after {
  content: '';
  position: absolute;
  left: 4px; top: 1px;
  width: 4px; height: 8px;
  border: solid #fff;
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
/* radio inner dot */
input[type=radio]:not(.segmented input):checked::after {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 6px; height: 6px;
  border-radius: 50%;
  background: #fff;
  transform: translate(-50%, -50%);
}
input[type=checkbox]:not(.switch input):not(.checkbox input):focus-visible,
input[type=radio]:not(.segmented input):focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 35%, transparent);
}
input[type=checkbox]:not(.switch input):not(.checkbox input):disabled,
input[type=radio]:not(.segmented input):disabled {
  opacity: .45;
  cursor: not-allowed;
}

/* Tab-style radio group */
.segmented {
  display: inline-flex;
  background: var(--bg-elev);
  border: 1px solid var(--border-control);
  border-radius: var(--radius-sm);
  padding: 3px;
  gap: 2px;
}
.segmented label {
  padding: 6px 14px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
  cursor: pointer;
  border-radius: 5px;
  transition: all .15s;
  position: relative;
}
.segmented label input { position: absolute; opacity: 0; pointer-events: none; }
.segmented label:hover { color: var(--text); }
.segmented label:has(input:checked) {
  background: var(--surface-3);
  color: var(--text);
  box-shadow: 0 1px 2px rgba(0,0,0,.3);
}

/* ─── Tables ──────────────────────────────────── */
.table-wrap {
  overflow-x: auto;
  border-radius: var(--radius);
}
table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}
thead th {
  text-align: left;
  padding: 11px 16px;
  font-size: 12px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: .04em;
  color: var(--text-muted);
  background: var(--surface-2);
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
  position: sticky;
  top: 0;
  z-index: 1;
}
thead th.th-help {
  cursor: help;
}
thead th.th-help::after {
  content: " ⓘ";
  opacity: .5;
  font-size: 12px;
  margin-left: 3px;
  letter-spacing: normal;
}
tbody td {
  padding: 11px 16px;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}
tbody tr:last-child td { border-bottom: none; }
tbody tr { transition: background .1s; }
tbody tr:hover td { background: var(--surface-2); }
.empty-row td {
  text-align: center;
  color: var(--text-dim);
  padding: 32px 16px;
  font-style: italic;
}
/* Centered empty-state placeholder for chart/health panels (icon + title + sub). */
.panel-empty {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 8px; width: 100%; height: 100%; min-height: 120px; padding: 28px 16px; text-align: center; color: var(--text-dim);
}
.panel-empty .pe-icon { width: 34px; height: 34px; opacity: .45; stroke: currentColor; }
.panel-empty .pe-title { font-size: 13px; font-weight: 600; color: var(--text-muted); }
.panel-empty .pe-sub { font-size: 12px; color: var(--text-dim); max-width: 300px; line-height: 1.5; }
.table-pagination {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 8px;
  padding: 10px 12px;
  border-top: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-muted);
  font-size: 12px;
  flex-wrap: wrap;
}
.table-pagination .select {
  width: auto;
  min-width: 84px;
  padding-top: 5px;
  padding-bottom: 5px;
}
.table-pagination .cs { width: auto; min-width: 84px; }
.table-pagination .page-label {
  min-width: 160px;
  text-align: center;
  color: var(--text-muted);
}

/* ─── Account detail (drawer body) ──────────────────── */
.account-detail-wrap {
  padding: 20px 28px;
  background: linear-gradient(180deg, var(--accent-soft), transparent 80%);
}
.detail-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
  margin-bottom: 14px;
}
@media (max-width: 1100px) { .detail-grid { grid-template-columns: 1fr; } }
.detail-card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 14px 16px;
}
.detail-card-head {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: .06em;
  margin-bottom: 10px;
}
.detail-card-head .dot {
  width: 6px; height: 6px;
  border-radius: 2px;
  background: var(--accent);
}
.detail-kv {
  display: grid;
  grid-template-columns: 90px 1fr;
  gap: 4px 14px;
  font-size: 12px;
  line-height: 1.6;
}
.detail-kv > .k { color: var(--text-dim); }
.detail-kv > .v { color: var(--text); font-variant-numeric: tabular-nums; }
.detail-kv code { font-size: 12px; padding: 1px 5px; }

/* ─── DevinMap cards (account drawer) ─────────────────── */
/* Every list is a bounded vertical scroller with overflow-x hidden: the card
   width is whatever the drawer gives it (full width at 390, one grid column at
   1440), so a long model id or a wide delta string must wrap or ellipsize, never
   widen the page. */
.dm-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 10px 18px;
}
.dm-stat {
  display: flex;
  align-items: baseline;
  gap: 6px;
  min-width: 0;
  padding: 6px 10px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.dm-stat-value {
  font-family: var(--mono);
  font-size: 15px;
  font-weight: 600;
  color: var(--text);
  white-space: nowrap;
}
.dm-stat-label {
  font-size: 12px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: .05em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
}
.dm-subhead {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 12px 0 6px;
  padding-top: 10px;
  border-top: 1px dashed var(--border);
  font-size: 12px;
  font-weight: 600;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: .06em;
}
.dm-subhead .dm-subhead-count {
  margin-left: auto;
  font-family: var(--mono);
  color: var(--text-dim);
  letter-spacing: 0;
}
.dm-rows,
.dm-attention {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 190px;
  overflow-y: auto;
  overflow-x: hidden;
}
.dm-row {
  display: grid;
  grid-template-columns: auto auto minmax(0, 1fr);
  align-items: center;
  gap: 8px;
  min-width: 0;
  padding: 4px 8px;
  background: var(--surface-2);
  border-radius: 6px;
  font-size: 12px;
}
.dm-row-time {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-dim);
  white-space: nowrap;
}
.dm-row-delta {
  font-family: var(--mono);
  font-size: 12px;
  min-width: 0;
  overflow-wrap: anywhere;
}
/* Request-history rows: time · model · tokens · credit. The model column is the
   only flexible one, so a 60-char selector ellipsizes instead of widening the
   drawer; the three numeric columns are tabular and never wrap. */
.dm-req-row {
  display: grid;
  grid-template-columns: 38px minmax(0, 1fr) 58px 46px;
  align-items: center;
  gap: 8px;
  min-width: 0;
  padding: 4px 8px;
  background: var(--surface-2);
  border-radius: 6px;
  font-size: 12px;
}
.dm-req-model {
  font-family: var(--mono);
  font-size: 12px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.dm-req-num {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-dim);
  text-align: right;
  white-space: nowrap;
}
.dm-req-num.is-credit { color: var(--text); }
.dm-model-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: 8px;
  min-width: 0;
  padding: 4px 8px;
  background: var(--surface-2);
  border-radius: 6px;
  font-size: 12px;
}
.dm-model-name {
  font-family: var(--mono);
  font-size: 12px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.dm-inline {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 10px;
  min-width: 0;
}
.dm-note {
  margin-top: 8px;
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-dim);
}
.dm-controls {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
  margin-bottom: 10px;
}
.dm-controls .select { flex: 1 1 140px; min-width: 0; max-width: 260px; }
.dm-empty {
  padding: 8px 2px;
  font-size: 12px;
  color: var(--text-dim);
}

.detail-bar {
  display: grid;
  grid-template-columns: 60px 1fr 50px;
  align-items: center;
  gap: 10px;
  margin-bottom: 6px;
  font-size: 12px;
}
.detail-bar .bar-label { color: var(--text-dim); font-size: 12px; }
.detail-bar .bar-track {
  height: 7px;
  border-radius: 4px;
  background: var(--surface-2);
  overflow: hidden;
  border: 1px solid var(--border);
}
.detail-bar .bar-fill {
  height: 100%;
  border-radius: 3px;
  transition: width .6s cubic-bezier(.2,.8,.2,1);
}
.detail-bar .bar-pct {
  text-align: right;
  color: var(--text);
  font-family: var(--mono);
  font-size: 12px;
  font-weight: 600;
}
.detail-bar .bar-reset {
  grid-column: 2 / 4;
  font-size: 12px;
  color: var(--text-dim);
  font-family: var(--mono);
  margin-top: 2px;
}

/* Model list grouped by provider */
.model-list-card { grid-column: 1 / -1; }
.model-group {
  margin-bottom: 14px;
}
.model-group:last-child { margin-bottom: 0; }
.model-group-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 6px;
  padding-bottom: 5px;
  border-bottom: 1px dashed var(--border);
}
.model-group-name {
  font-size: 12px;
  font-weight: 600;
  color: var(--text);
  letter-spacing: .02em;
}
.model-group-count {
  font-size: 12px;
  color: var(--text-dim);
  font-family: var(--mono);
}
.model-chips {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 6px;
}
.detail-model-chip {
  display: grid;
  grid-template-columns: 10px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 6px;
  font-size: 12px;
  transition: all .15s ease;
}
.detail-model-chip:hover {
  border-color: var(--border-strong);
  background: var(--surface-3);
}
.detail-model-chip.blocked {
  opacity: .45;
  text-decoration: line-through;
  text-decoration-color: var(--text-dim);
  text-decoration-thickness: 1px;
}
.detail-model-chip-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--success);
}
.detail-model-chip.blocked .detail-model-chip-dot { background: var(--error); }
.detail-model-chip-name {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.detail-model-chip-cost {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--warn-text);
  padding: 1px 6px;
  background: rgba(245,158,11,.08);
  border: 1px solid rgba(245,158,11,.2);
  border-radius: 10px;
  white-space: nowrap;
  font-weight: 600;
}
/* Provider swatch: colour comes from the theme-aware --prov-* tokens, so the
   light and dark ramps can each clear 3:1 on their own surface (P2-12). The
   provider name always sits beside it — the colour is never the only signal. */
.provider-swatch {
  width: 14px; height: 14px;
  border-radius: var(--radius-xs);
  display: inline-block;
  margin-right: var(--space-6);
  vertical-align: middle;
  background: var(--prov-other);
}
.provider-swatch[data-provider="anthropic"] { background: var(--prov-anthropic); }
.provider-swatch[data-provider="openai"] { background: var(--prov-openai); }
.provider-swatch[data-provider="google"] { background: var(--prov-google); }
.provider-swatch[data-provider="deepseek"] { background: var(--prov-deepseek); }
.provider-swatch[data-provider="xai"] { background: var(--prov-xai); }
.provider-swatch[data-provider="alibaba"] { background: var(--prov-alibaba); }
.provider-swatch[data-provider="moonshot"] { background: var(--prov-moonshot); }
.provider-swatch[data-provider="zhipu"] { background: var(--prov-zhipu); }
.provider-swatch[data-provider="minimax"] { background: var(--prov-minimax); }
.provider-swatch[data-provider="windsurf"] { background: var(--prov-windsurf); }
.provider-swatch[data-provider="other"] { background: var(--prov-other); }

/* ─── Badges ──────────────────────────────────── */
/* One badge base + one variant matrix (P1-S1). The legacy `badge-success`
   class in static markup is an alias of `.badge.success`; the same matrix
   covers error/warn/info/muted. Shape, size and label colour are shared so a
   status cannot drift by component. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  line-height: 1.5;
  border: 1px solid transparent;
  /* AA rule (UIResearch §2.4): the LABEL is --text on a soft wash (14/16% in
     light, 20/22% in dark; 10.49–15.70:1 both themes); the HUE lives in the
     6 px dot, where 3:1 is the graphical-object floor. workbuddy's hue-on-soft
     labels measured 3.69–5.58 and failed AA in four pairs — do not restore that. */
  color: var(--text);
  background: var(--surface-3);
  --badge-dot: var(--text-dim);
}
.badge::before {
  content: '';
  width: 6px; height: 6px;
  border-radius: 50%;
  background: var(--badge-dot);
}
.badge.active, .badge.running, .badge.success, .badge-success {
  background: var(--success-soft);
  --badge-dot: var(--success);
}
.badge.error, .badge.stopped, .badge-danger, .badge-error {
  background: var(--error-soft);
  --badge-dot: var(--error);
}
.badge.banned {
  background: var(--error-soft);
  --badge-dot: var(--error);
  box-shadow: inset 0 0 0 1px var(--error);
}
.badge.disabled, .badge.muted, .badge-muted {
  background: var(--surface-3);
  --badge-dot: var(--text-dim);
}
.badge.warn, .badge-warn {
  background: var(--warn-soft);
  --badge-dot: var(--warn);
}
.badge.info, .badge.pro, .badge-info {
  background: var(--info-soft);
  --badge-dot: var(--info);
}
.badge.no-dot::before { display: none; }

/* SessionReauth: the "needs a fresh token" notice (drawer body + bound login
   modal). Terminal state, so it borrows the banned chip's error treatment. */
.reauth-notice {
  margin: 0 0 12px;
  padding: 10px 12px;
  border-radius: var(--radius);
  border-left: 3px solid var(--error);
  background: var(--surface-2);
}
.reauth-notice-title { font-weight: 600; color: var(--error-text); margin-bottom: 4px; }
.reauth-notice .btn { margin-top: 8px; }

/* Tier pill — same base geometry as .badge (P1-S1). */
.tier {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  line-height: 1.5;
  text-transform: uppercase;
  letter-spacing: .04em;
}
.tier.pro { background: var(--accent); color: var(--on-accent); }
.tier.free { background: var(--info-soft); color: var(--info-text); }
.tier.expired { background: var(--error-soft); color: var(--error-text); }
.tier.unknown { background: var(--surface-3); color: var(--text-muted); }

/* ─── LS Pool Cards ───────────────────────────── */
.ls-pool {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 12px;
}
.ls-inst {
  padding: 14px 16px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-size: 12px;
}
.ls-inst .ls-key {
  font-weight: 600;
  color: var(--text);
  font-size: 13px;
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 6px;
}
.ls-inst .ls-dot {
  width: 7px; height: 7px;
  border-radius: 2px;
  background: var(--success);
}
.ls-inst .ls-dot.pending { background: var(--warn); }
.ls-inst .ls-meta { color: var(--text-dim); font-family: var(--mono); font-size: 12px; margin-top: 2px; }
.ls-telemetry {
  margin-top: 12px;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  align-items: center;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-dim);
}
.ls-telemetry .badge {
  font-family: var(--mono);
  font-size: 12px;
  text-transform: none;
  letter-spacing: 0;
}
.ls-telemetry .ls-telemetry-detail {
  min-width: 180px;
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.native-bridge-panel {
  margin-top: 12px;
  padding: 14px 16px;
  background: var(--bg-elev);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.native-bridge-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 10px;
}
.native-bridge-title {
  font-size: 13px;
  font-weight: 600;
}
.native-bridge-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: 8px;
  margin-bottom: 10px;
}
.native-bridge-kv {
  padding: 8px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.native-bridge-kv .k {
  display: block;
  font-size: 12px;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: .04em;
}
.native-bridge-kv .v {
  display: block;
  margin-top: 3px;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.native-bridge-decisions {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.native-bridge-decision {
  display: grid;
  grid-template-columns: minmax(160px, 1fr) minmax(120px, 1fr) minmax(180px, 2fr);
  gap: 8px;
  align-items: center;
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-muted);
}
.native-bridge-tools {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
@media (max-width: 720px) {
  .native-bridge-decision { grid-template-columns: 1fr; }
  /* Cockpit two-up sections stack on narrow screens so the trend chart and
     connect metrics don't get crushed. */
  .grid-2 { grid-template-columns: 1fr !important; }
}

/* ─── Model Chips ─────────────────────────────── */
.model-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 10px 0;
}
.model-chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 11px;
  border-radius: 6px;
  font-size: 12px;
  border: 1px solid var(--border);
  background: var(--bg-elev);
  color: var(--text-muted);
  cursor: pointer;
  transition: all .15s;
  font-weight: 500;
}
.model-chip:hover {
  border-color: var(--accent);
  color: var(--text);
}
.model-chip.selected {
  background: var(--accent-soft);
  border-color: var(--accent);
  color: var(--accent-text);
}
/* Listed but not serveable by this deployment right now (no entitled account, or the name
   maps to nothing on this transport). Dimmed rather than hidden: the allow/deny list is
   rendered from this same set, so a hidden chip would be an entry the operator can no
   longer click to remove. Still clickable for exactly that reason. */
.model-chip.unreachable {
  color: var(--text-dim);
  border-style: dashed;
}
.model-chip.unreachable:hover { color: var(--text-muted); }
/* #235 — does this model cost quota. Only rendered when the answer is KNOWN; absence of a
   badge means "no rate table yet", which must not look like either answer. */
.chip-badge {
  margin-left: 5px;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  line-height: 1.5;
  letter-spacing: .02em;
}
.chip-badge.free { background: var(--success); color: var(--on-accent); }
.chip-badge.paid { background: var(--warn); color: var(--on-accent); }
.model-chip .remove {
  color: var(--error-text);
  font-weight: 700;
  cursor: pointer;
  padding: 0 2px;
}
.provider-group { margin-bottom: 16px; }
.provider-label {
  font-size: 12px;
  color: var(--text-dim);
  text-transform: uppercase;
  letter-spacing: .08em;
  margin-bottom: 6px;
  font-weight: 600;
}

/* ─── Log Viewer ──────────────────────────────── */
.log-container {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  height: 520px;
  overflow-y: auto;
  font-family: var(--mono);
  font-size: 12px;
  line-height: 1.6;
}
.log-entry {
  padding: 3px 16px;
  border-bottom: 1px solid rgba(255,255,255,.02);
  display: flex;
  gap: 10px;
  align-items: baseline;
  word-break: break-all;
}
.log-entry:hover { background: var(--surface-2); }
.log-entry .ts { color: var(--text-dim); flex-shrink: 0; }
.log-entry .lvl {
  font-weight: 700;
  flex-shrink: 0;
  width: 50px;
}
.log-entry.debug .lvl { color: var(--text-dim); }
.log-entry.info .lvl { color: var(--info-text); }
.log-entry.warn .lvl { color: var(--warn-text); }
.log-entry.error .lvl { color: var(--error-text); }
.log-entry.error { background: rgba(239,68,68,.03); }

.log-controls {
  display: flex;
  gap: 10px;
  align-items: center;
  flex-wrap: wrap;
  margin-bottom: 14px;
}
.log-controls .input, .log-controls .select { width: auto; }
.log-controls .search { flex: 1; min-width: 200px; }

/* ─── Chart ───────────────────────────────────── */
.chart {
  padding: 22px 24px 20px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  margin-bottom: 20px;
  position: relative;
  overflow: hidden;
}
.chart::before {
  content: "";
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 2px;
  background: linear-gradient(90deg, #3b82f6, #a78bfa, #3b82f6);
  background-size: 200% 100%;
  animation: chart-shimmer 3s linear infinite;
}
@keyframes chart-shimmer { 0%{background-position:200% 0} 100%{background-position:0 0} }
.chart h3 { font-size: 14px; font-weight: 600; margin: 0; letter-spacing: -.01em; }
.chart-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 18px;
}
.chart-title-group { min-width: 0; flex: 1; }
.chart-subtitle {
  font-size: 12px;
  color: var(--text-dim);
  margin-top: 4px;
  letter-spacing: .01em;
}
.chart-controls {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  align-items: center;
}

/* Segmented button group — for chart-type / range switches */
.seg-group {
  display: inline-flex;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 3px;
  gap: 2px;
}
.seg-btn {
  padding: 5px 11px;
  font-size: 12px;
  font-weight: 500;
  color: var(--text-muted);
  background: transparent;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: all .18s ease;
  font-family: inherit;
  letter-spacing: -.005em;
  white-space: nowrap;
}
.seg-btn:hover {
  color: var(--text);
  background: rgba(255,255,255,.03);
}
.seg-btn.active {
  color: var(--on-accent);
  background: var(--accent);
  box-shadow: var(--ink-shadow-soft);
}

/* Main chart canvas */
.chart-canvas-wrap {
  position: relative;
  width: 100%;
  height: 280px;
  padding-top: 6px;
}
.chart-canvas-wrap canvas {
  width: 100%;
  height: 100%;
  display: block;
}
.chart-range-indicator {
  position: absolute;
  top: 8px;
  right: 8px;
  font-size: 12px;
  color: var(--text-dim);
  font-family: var(--mono);
  padding: 3px 8px;
  background: rgba(255,255,255,.02);
  border: 1px solid var(--border);
  border-radius: 6px;
  pointer-events: none;
  letter-spacing: .01em;
}

/* Custom date range popup */
.custom-range-popup {
  position: relative;
  margin-top: 14px;
  padding: 18px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  max-width: 420px;
  opacity: 0;
  transform: translateY(-6px) scale(.98);
  transition: opacity .16s ease, transform .16s ease;
  pointer-events: none;
}
.custom-range-popup.open { opacity: 1; transform: none; pointer-events: auto; }
.custom-range-title {
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 14px;
  color: var(--text);
}
.custom-range-fields {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 14px;
  margin-bottom: 10px;
}
.custom-range-fields label {
  display: flex;
  flex-direction: column;
  gap: 5px;
  font-size: 12px;
  color: var(--text-dim);
  font-weight: 500;
  letter-spacing: .01em;
}
.custom-range-fields input[type="date"] {
  font-family: var(--mono);
  font-size: 12px;
  color-scheme: light dark;  /* follow the active theme, not always dark */
}
.custom-range-hint {
  font-size: 12px;
  color: var(--text-dim);
  margin-bottom: 14px;
}
.custom-range-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}
.chart-tooltip {
  position: absolute;
  pointer-events: none;
  background: var(--tip-bg);
  color: var(--tip-fg);
  padding: 8px 12px;
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.55;
  border: 1px solid var(--border-strong);
  box-shadow: 0 8px 28px rgba(0,0,0,.5);
  backdrop-filter: blur(10px);
  opacity: 0;
  /* Cursor-following tooltip: JS sets left/top to the box origin (no centering
     translate) and tracks the pointer, so only opacity is transitioned —
     animating left/top would lag the cursor. */
  transition: opacity .12s ease;
  z-index: 5;
  white-space: nowrap;
}
.chart-tooltip.show { opacity: 1; }
.chart-tooltip .tt-time { color: var(--text-dim); font-size: 12px; margin-bottom: 4px; }
.chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; }
.chart-tooltip .tt-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.chart-tooltip .tt-label { color: var(--text-dim); margin-right: 4px; }
.chart-tooltip .tt-value { font-weight: 600; font-variant-numeric: tabular-nums; margin-left: auto; }
.chart-tooltip .tt-sep { height: 1px; background: var(--border, rgba(128,128,128,.2)); margin: 6px 0 4px; }
.chart-tooltip .tt-subhead { color: var(--text-dim); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; margin-bottom: 3px; }

/* Inline help "?" with an i18n hover/focus bubble — reusable info tooltip.
   Text is injected via App._helpTip(key) or a static [data-i18n-tip] span.
   Themed bg/fg so text is legible in BOTH themes; a small arrow points at the
   icon. Placement variants dodge .section overflow:hidden clipping. */
.help-tip {
  position: relative; display: inline-flex; align-items: center; justify-content: center;
  width: 15px; height: 15px; margin-left: 5px; border-radius: 50%;
  border: 1px solid transparent; background: var(--accent-soft); color: var(--accent-text);
  font-size: 12px; font-weight: 700; line-height: 1; cursor: help;
  vertical-align: middle; outline: none; user-select: none;
  transition: color .15s ease, border-color .15s ease, background .15s ease, box-shadow .15s ease, transform .15s ease;
}
.help-tip:hover, .help-tip:focus-visible {
  color: var(--on-accent); border-color: var(--accent);
  background: var(--accent); transform: translateY(-.5px);
}
.help-tip:focus-visible { box-shadow: 0 0 0 3px var(--accent-soft); }
.help-tip__pop {
  position: absolute; top: calc(100% + 9px); left: -2px; width: max-content;
  max-width: min(260px, 78vw);
  padding: 9px 11px; background: var(--tip-bg); color: var(--tip-fg);
  border: 1px solid var(--tip-border); border-radius: 9px;
  box-shadow: 0 10px 30px rgba(0,0,0,.28), 0 2px 6px rgba(0,0,0,.16);
  backdrop-filter: blur(10px);
  font-size: 12px; font-weight: 400; line-height: 1.55; white-space: normal; text-align: left;
  opacity: 0; pointer-events: none; transform: translateY(-4px) scale(.97);
  transform-origin: top left;
  transition: opacity .16s ease, transform .16s cubic-bezier(.16,1,.3,1);
  z-index: 30;
}
/* Arrow pointing back at the ? icon (border trick: outer=border color, inner=bg). */
.help-tip__pop::before { content: ""; position: absolute; bottom: 100%; left: 9px; border: 6px solid transparent; border-bottom-color: var(--tip-border); }
.help-tip__pop::after  { content: ""; position: absolute; bottom: 100%; left: 10px; border: 5px solid transparent; border-bottom-color: var(--tip-bg); }
.help-tip:hover .help-tip__pop,
.help-tip:focus-visible .help-tip__pop,
.help-tip:focus-within .help-tip__pop { opacity: 1; transform: translateY(0) scale(1); pointer-events: auto; }
/* Placement variants dodge .section overflow:hidden clipping + right edge spill. */
.help-tip--top .help-tip__pop { top: auto; bottom: calc(100% + 9px); transform-origin: bottom left; transform: translateY(4px) scale(.97); }
.help-tip--top .help-tip__pop::before { bottom: auto; top: 100%; border-bottom-color: transparent; border-top-color: var(--tip-border); }
.help-tip--top .help-tip__pop::after  { bottom: auto; top: 100%; border-bottom-color: transparent; border-top-color: var(--tip-bg); }
.help-tip--top:hover .help-tip__pop,
.help-tip--top:focus-visible .help-tip__pop,
.help-tip--top:focus-within .help-tip__pop { transform: translateY(0) scale(1); }
.help-tip--right .help-tip__pop { left: auto; right: -2px; transform-origin: top right; }
.help-tip--right .help-tip__pop::before { left: auto; right: 9px; }
.help-tip--right .help-tip__pop::after  { left: auto; right: 10px; }
.help-tip--top.help-tip--right .help-tip__pop { transform-origin: bottom right; }
@media (pointer: coarse) { .help-tip { width: 18px; height: 18px; font-size: 12px; } }
@media (prefers-reduced-motion: reduce) { .help-tip, .help-tip__pop { transition: none; } }

/* Chart legend */
.chart-legend {
  display: flex;
  gap: 18px;
  justify-content: center;
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
  flex-wrap: wrap;
}
.chart-legend-item {
  display: flex;
  align-items: center;
  gap: 7px;
  font-size: 12px;
  color: var(--text-muted);
  cursor: pointer;
  user-select: none;
  transition: opacity .15s ease;
}
.chart-legend-item.muted { color: var(--text-dim); opacity: 1; }
.chart-legend-item:hover { color: var(--text); }
.chart-legend-swatch {
  width: 14px; height: 4px;
  border-radius: 2px;
}
.chart-legend-swatch.dot {
  width: 10px; height: 10px;
  border-radius: 50%;
}
.chart-legend-value {
  font-family: var(--mono);
  font-size: 12px;
  color: var(--text-dim);
  font-variant-numeric: tabular-nums;
}

/* Expandable model-stats rows: clickable summary row + on-demand detail row. */
.model-row { cursor: pointer; transition: background .12s ease; }
.model-row:hover { background: var(--surface-2); }
.model-row:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: -2px; }
.row-chevron { display: inline-block; width: 1em; color: var(--text-muted); font-size: 12px; transition: transform .12s ease; }
.model-detail-row > td { padding: 0 !important; background: var(--surface-2); }
.model-detail { display: flex; flex-wrap: wrap; gap: 20px; padding: 12px 16px; }
.model-detail-item { display: flex; flex-direction: column; gap: 2px; }
.mdi-label { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--text-muted); }
.mdi-val { font-variant-numeric: tabular-nums; font-size: 13px; color: var(--text); }

/* Stats request log: filter bar, numeric columns, bounded cells. */
.stats-log-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.stats-filter-bar {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 14px;
  padding: 10px 16px;
  border-bottom: 1px solid var(--border);
  background: var(--surface-2);
}
.stats-filter-bar label { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted); white-space: nowrap; }
.stats-filter-bar .select { width: auto; min-width: 140px; max-width: 260px; }
#stats-log-table .numeric, .stats-agg-table .numeric { text-align: right; font-variant-numeric: tabular-nums; }
.cell-ellipsis { display: inline-block; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; }

.bars {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 160px;
  padding-bottom: 4px;
  border-bottom: 1px solid rgba(255,255,255,.05);
}
.bar-wrap {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  height: 100%;
  justify-content: flex-end;
  position: relative;
}
.bar {
  width: 100%;
  min-width: 6px;
  max-width: 28px;
  background: linear-gradient(to top, var(--accent), var(--accent-hover));
  border-radius: 4px 4px 1px 1px;
  transition: all .3s cubic-bezier(.4,0,.2,1);
  cursor: pointer;
  position: relative;
}
.bar::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(to top, transparent 60%, rgba(255,255,255,.12));
  pointer-events: none;
}
.bar.has-errors {
  background: linear-gradient(to top, rgba(239,68,68,.6), rgba(251,146,60,.9));
}
.bar-wrap:hover .bar {
  box-shadow: 0 0 16px var(--accent-soft), inset 0 0 8px rgba(255,255,255,.08);
  transform: scaleY(1.02) scaleX(1.08);
  filter: brightness(1.15);
}
.bar-wrap:hover .bar.has-errors {
  box-shadow: 0 0 16px rgba(239,68,68,.4), inset 0 0 8px rgba(255,255,255,.08);
}
.bar-label {
  font-size: 12px;
  color: var(--text-muted);
  margin-top: 6px;
  font-family: var(--mono);
  transition: color .2s;
}
.bar-wrap:hover .bar-label { color: var(--text); }
.bar-tooltip {
  display: none;
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  background: rgba(15,15,25,.95);
  color: #e0e0e0;
  padding: 8px 12px;
  border-radius: 8px;
  font-size: 12px;
  line-height: 1.5;
  white-space: nowrap;
  pointer-events: none;
  z-index: 10;
  box-shadow: 0 8px 24px rgba(0,0,0,.5);
  border: 1px solid rgba(255,255,255,.08);
  backdrop-filter: blur(8px);
}
.bar-tooltip b { color: var(--accent-text); }
.bar-tooltip::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 6px solid transparent;
  border-top-color: rgba(15,15,25,.95);
}
.bar-wrap:hover .bar-tooltip { display: block; animation: tt-in .15s ease; }
@keyframes tt-in { from{opacity:0;transform:translateX(-50%) translateY(4px)} to{opacity:1;transform:translateX(-50%) translateY(0)} }

/* ─── Toast ───────────────────────────────────── */
.diff-stat { font-size: 12px; color: var(--text-muted); margin-bottom: 8px; }
.diff-box { max-height: 50vh; overflow: auto; font-family: var(--mono, monospace); font-size: 12px; line-height: 1.5; background: var(--bg-elev, rgba(0,0,0,.2)); border-radius: 8px; padding: 8px; margin: 0; }
.diff-line { white-space: pre-wrap; word-break: break-all; padding: 0 6px; }
.diff-add { background: rgba(74,222,128,.14); color: var(--success-text); }
.diff-del { background: rgba(248,113,113,.14); color: var(--error-text); }
.diff-ctx { color: var(--text-muted); }
.toast-stack {
  position: fixed;
  bottom: 24px;
  right: 24px;
  display: flex;
  flex-direction: column-reverse;
  gap: 10px;
  z-index: 9999;
  pointer-events: none;
}
.toast {
  padding: 12px 18px;
  border-radius: var(--radius-sm);
  font-size: 13px;
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  animation: slideIn .25s ease;
  pointer-events: auto;
  max-width: 400px;
  display: flex;
  align-items: center;
  gap: 10px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--error); }
.toast.info { border-left: 3px solid var(--info); }
@keyframes slideIn {
  from { opacity: 0; transform: translateX(20px); }
  to { opacity: 1; transform: translateX(0); }
}

/* ─── Modal ───────────────────────────────────── */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: var(--scrim-strong);
  backdrop-filter: blur(4px);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  animation: fadeIn .15s ease;
}
.modal {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  width: 90%;
  max-width: 440px;
  box-shadow: var(--shadow-lg);
  overflow: hidden;
  animation: modalIn .2s cubic-bezier(.16,1,.3,1);
}
@keyframes modalIn {
  from { opacity: 0; transform: translateY(-10px) scale(.98); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}
.modal-header {
  padding: 20px 24px 12px;
}
.modal-title { font-size: 16px; font-weight: 600; }
.modal-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
.modal-body { padding: 12px 24px 20px; }
.modal-footer {
  padding: 14px 24px;
  background: var(--surface-2);
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  border-top: 1px solid var(--border);
}

/* ─── Login Overlay ───────────────────────────── */
.login-overlay {
  position: fixed;
  inset: 0;
  background: radial-gradient(ellipse at center, var(--accent-soft) 0%, var(--bg) 70%);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 200;
}
.login-box {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 36px 32px;
  width: min(380px, calc(100% - 32px));
  box-shadow: var(--shadow-lg);
}
.login-box .login-logo {
  width: 56px; height: 56px;
  margin: 0 auto 18px;
  border-radius: 14px;
  background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
  display: flex; align-items: center; justify-content: center;
  font-weight: 800; font-size: 22px; color: var(--on-accent);
  box-shadow: 0 10px 30px var(--accent-soft);
}
.login-box h3 { text-align: center; font-size: 18px; margin-bottom: 8px; }
.login-box p { text-align: center; font-size: 13px; color: var(--text-muted); margin-bottom: 22px; }
.login-box .input { margin-bottom: 14px; }
.login-box .btn { width: 100%; padding: 11px; font-weight: 600; }

/* ─── Spinner ─────────────────────────────────── */
.spinner {
  display: inline-block;
  width: 14px; height: 14px;
  border: 2px solid rgba(255,255,255,.2);
  border-top-color: currentColor;
  border-radius: 50%;
  animation: spin .7s linear infinite;
  vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ─── Capability Markers ──────────────────────── */
.cap-list { display: inline-flex; flex-wrap: wrap; gap: 4px; }
.cap-item {
  font-size: 12px;
  padding: 2px 8px;
  border-radius: 999px;
  font-family: var(--mono);
  font-weight: 600;
  line-height: 1.5;
}
.cap-item.ok { background: var(--success-soft); color: var(--success-text); }
.cap-item.fail { background: var(--error-soft); color: var(--error-text); text-decoration: line-through; }

/* P1 #2/#3/#8: inline lifecycle states (never toast-only, never silent). */
#overview-stale-banner {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin: 0 0 16px; padding: 12px 16px;
  background: var(--warn-soft); border: 1px solid var(--warn);
  border-radius: var(--radius); color: var(--text); font-size: 13px;
}
.overview-error { border-left: 3px solid var(--error); }
.modal-confirm-require { padding-top: 0; }
.modal-confirm-require .field-label { display: block; margin-bottom: 6px; }
.inline-action-error {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin-bottom: 12px; padding: 10px 12px;
  background: var(--error-soft); border: 1px solid var(--error);
  border-radius: var(--radius-sm); color: var(--text); font-size: 13px;
}
.inline-save-state { display: inline-flex; align-items: center; gap: 6px; margin-left: 8px; font-size: 12px; color: var(--text-muted); }
.inline-save-state.is-error { color: var(--error-text); }
.inline-save-state.is-ok { color: var(--success-text); }

/* ─── Utilities ───────────────────────────────── */
.flex { display: flex; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 8px; } .gap-3 { gap: 12px; } .gap-4 { gap: 16px; }
.mt-2 { margin-top: 8px; } .mt-3 { margin-top: 12px; } .mt-4 { margin-top: 16px; }
.text-muted { color: var(--text-muted); }
.text-dim { color: var(--text-dim); }
.text-sm { font-size: 12px; }
.text-xs { font-size: 12px; }
.nowrap { white-space: nowrap; }
.hidden { display: none !important; }
.break-all { word-break: break-all; }

/* ─── Responsive ──────────────────────────────── */
@media (max-width: 900px) {
  .sidebar { width: 60px; }
  .sidebar .brand-name, .sidebar .brand-sub, .sidebar nav a span, .sidebar .footer .ver, .sidebar .nav-group-label { display: none; }
  .sidebar .brand { justify-content: center; padding: 20px 0; }
  .sidebar nav a { justify-content: center; padding: 10px; }
  .main { margin-left: 60px; padding: 20px 16px; }
}
/* P1 #5: mobile app bar + labelled off-canvas drawer (below 720px). */
.mobile-appbar, .mobile-nav-mask { display: none; }
@media (max-width: 720px) {
  .mobile-appbar {
    display: flex; align-items: center; gap: 8px;
    position: fixed; top: 0; left: 0; right: 0; z-index: 130;
    height: 56px; padding: 0 10px;
    background: var(--surface); border-bottom: 1px solid var(--border);
  }
  .mobile-menu-btn { min-width: 44px; min-height: 44px; padding: 8px; }
  .mobile-menu-btn svg { width: 20px; height: 20px; }
  .mobile-appbar-title { font-weight: 600; font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  .mobile-appbar-spacer { flex: 1; }
  .mobile-appbar-btn { min-width: 44px; min-height: 44px; padding: 6px 8px; }
  .mobile-appbar-icon svg { width: 16px; height: 16px; }
  .mobile-nav-mask:not(.hidden) { display: block; position: fixed; inset: 0; z-index: 135; background: var(--scrim); }
  body.mobile-nav-open { overflow: hidden; }
  .sidebar {
    width: min(280px, 86vw); z-index: 140;
    transform: translateX(-100%); transition: transform .2s ease;
  }
  .sidebar.open { transform: none; }
  /* Restore labels hidden by the 900px icon-rail rule: the drawer is labelled. */
  .sidebar .brand-name, .sidebar .brand-sub, .sidebar nav a span,
  .sidebar .footer .ver, .sidebar .nav-group-label { display: block; }
  .sidebar .brand { justify-content: flex-start; padding: 20px 22px 18px; }
  .sidebar nav a { justify-content: flex-start; padding: 12px; min-height: 44px; }
  .sidebar nav a svg { width: 18px; height: 18px; }
  .main { margin-left: 0; padding: 72px 16px 80px; }
  #settings-search { display: none; }
  .sidebar.settings-open #settings-search { display: block; }
  /* P1 #5 touch targets: row menus, pagination and filters stay >=44px. */
  .row-menu-btn, .table-pagination .btn, .btn-xs { min-height: 44px; min-width: 44px; }
  #accounts-table .acct-check-cell { min-height: 44px; }
  #accounts-table input[type=checkbox] { width: 22px; height: 22px; }
  .acct-filters .input, .acct-filters .select { min-height: 44px; }
}
.contributor-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(360px, 1fr)); gap: 12px; }
.contributor-card { display: flex; gap: 14px; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); transition: border-color .15s; min-width: 0; }
.contributor-card:hover { border-color: var(--accent); }
.contributor-card .avatar { width: 48px; height: 48px; border-radius: 50%; flex: none; border: 2px solid var(--border); background: var(--surface-2); object-fit: cover; }
.contributor-card .meta { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.contributor-card .meta-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: start; }
.contributor-card .identity { display: flex; align-items: center; gap: 8px; min-width: 0; flex-wrap: wrap; }
.contributor-card .links { display: flex; align-items: center; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
.contributor-card .meta-top .name { color: var(--accent-text); font-weight: 600; font-size: 15px; text-decoration: none; }
.contributor-card .meta-top .name:hover { text-decoration: underline; }
.contributor-card .meta-top .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; }
.contributor-card .meta-top .pr-link:hover { color: var(--accent-text); border-color: var(--accent); }
.contributor-card .meta-top .merged-at { font-size: 12px; color: var(--text-dim); }
.contributor-card .pr-title { font-weight: 500; margin-top: 2px; font-size: 13px; line-height: 1.45; }
.contributor-card .summary-toggle { margin-top: 6px; font-size: 12px; color: var(--text-dim); }
.contributor-card .summary-toggle summary { cursor: pointer; width: fit-content; user-select: none; }
.contributor-card .summary-toggle summary:hover { color: var(--accent-text); }
.contributor-card .summary { font-size: 13px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; }
.contributor-card .rarity-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 38px; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 800; letter-spacing: 0.7px; font-family: var(--mono); line-height: 1.5; }
/* v2.0.60 — extended rarity ladder. Rarer = more dramatic visual:
   GOD glows with animated rainbow (project creator only); MR pulses cyan;
   LR has a cool prism gradient; UR keeps the existing fire palette. */
.contributor-card .rarity-GOD {
  background: linear-gradient(110deg, #ffd700 0%, #ff6b9d 25%, #c084fc 50%, #60a5fa 75%, #34d399 100%);
  background-size: 220% 220%;
  color: #1a1208;
  text-shadow: 0 0 6px rgba(255,255,255,0.55);
  font-weight: 900;
  box-shadow: 0 0 0 1px rgba(255,215,0,0.55), 0 0 14px rgba(192,132,252,0.4);
  animation: rarity-god-pulse 4s ease-in-out infinite;
}
@keyframes rarity-god-pulse {
  0%, 100% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
}
.contributor-card .rarity-MR {
  background: linear-gradient(135deg, #06b6d4 0%, #8b5cf6 50%, #ec4899 100%);
  color: #fff;
  font-weight: 800;
  box-shadow: 0 0 0 1px rgba(139,92,246,0.45), 0 0 10px rgba(6,182,212,0.25);
}
.contributor-card .rarity-LR {
  background: linear-gradient(135deg, #a855f7 0%, #6366f1 50%, #06b6d4 100%);
  color: #fff;
  font-weight: 800;
  box-shadow: 0 0 0 1px rgba(99,102,241,0.42);
}
.contributor-card .rarity-UR { background: linear-gradient(135deg, #f5d06f, #e11d48 52%, #7c3aed); color: #fff; box-shadow: 0 0 0 1px rgba(225,29,72,0.28); }
.contributor-card .rarity-SSR { background: linear-gradient(135deg, #f59e0b, #b45309); color: #fff7ed; box-shadow: 0 0 0 1px rgba(180,83,9,0.25); }
.contributor-card .rarity-SR { background: var(--info-soft); color: var(--info-text); border: 1px solid rgba(56,189,248,0.28); }
.contributor-card .rarity-R { background: var(--surface-2); color: var(--text-muted); border: 1px solid var(--border); }
.contributor-card .contribution-count { font-size: 12px; color: var(--text-dim); padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; line-height: 1.5; }
.contributor-card .history-list { list-style: none; padding: 0; margin: 6px 0 0; display: flex; flex-direction: column; gap: 8px; }
.contributor-card .history-item { padding: 10px 12px; border: 1px solid var(--border); border-radius: 4px; background: var(--surface-2); }
.contributor-card .history-head { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.contributor-card .history-head .pr-link { font-size: 12px; color: var(--text-dim); text-decoration: none; padding: 2px 8px; border: 1px solid var(--border); border-radius: 4px; }
.contributor-card .history-head .pr-link:hover { color: var(--accent-text); border-color: var(--accent); }
.contributor-card .history-head .merged-at { font-size: 12px; color: var(--text-dim); }
.contributor-card .history-rarity-label { font-size: 12px; color: var(--text-dim); font-style: italic; flex: 1; min-width: 0; }
.contributor-card .history-title { font-size: 13px; font-weight: 500; margin-top: 6px; line-height: 1.45; }
.contributor-card .history-summary { font-size: 12px; color: var(--text-muted); line-height: 1.65; margin-top: 6px; }
@media (max-width: 640px) {
  .contributor-list { grid-template-columns: 1fr; }
  .contributor-card { padding: 12px; gap: 12px; }
  .contributor-card .avatar { width: 40px; height: 40px; }
  .contributor-card .meta-top { grid-template-columns: 1fr; }
  .contributor-card .links { justify-content: flex-start; }
}
/* Screen-reader-only: visually hidden but present in the a11y tree (chart data
   tables, etc.). Standard clip-rect technique — no display:none (that hides it
   from screen readers too). */
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0);
  white-space: nowrap; border: 0;
}
/* ─── Merged account management page (2026-10-22 account-UI merge) ─────
   Geometry follows workbuddy2api-hub via UIResearch §2.2/§3; colours come from
   the token blocks above. Row target 44 px comfortable / 36 px compact. */
.acct-kpis {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  margin-bottom: var(--space-16);
  overflow: hidden;
}
.acct-kpi { padding: var(--space-12) var(--space-16); border-left: 1px solid var(--border); min-width: 0; }
.acct-kpi:first-child { border-left: 0; }
.acct-kpi-label {
  font-size: var(--fs-sm); font-weight: 600; text-transform: uppercase; letter-spacing: .06em;
  color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct-kpi-value { font-size: var(--fs-3xl); font-weight: 700; line-height: 1.2; margin-top: var(--space-2); font-variant-numeric: tabular-nums; }
.acct-kpi-sub { font-size: var(--fs-sm); color: var(--text-dim); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.acct-toolbar { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; margin-bottom: var(--space-12); }
/* The three credential verbs are one group, sized like the toolbar clusters so
   the header stays one line at 1440 and wraps as a unit at 390. */
.acct-add-group { display: inline-flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }
.acct-load-status { font-size: var(--fs-sm); color: var(--text-muted); margin: calc(-1 * var(--space-4)) 0 var(--space-10); }
.acct-load-status[hidden] { display: none; }
.acct-skeleton-row td { padding: var(--space-10) var(--space-16) !important; }
.acct-skeleton-row .skeleton { height: 12px; border-radius: var(--radius-xs); }
.tb-group {
  display: inline-flex; align-items: center; gap: var(--space-4); padding: var(--space-4);
  background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius);
}
.tb-group .btn { border-color: transparent; background: transparent; }
.tb-group .btn:hover:not(:disabled) { background: var(--surface); border-color: var(--border); }
/* `.btn` sets display:inline-flex, which beats the UA `[hidden]` rule. Every
   hidden-by-state button (fleet cancel, fleet details, filter clear) needs this
   or it stays visible while `hidden` is set. */
.btn[hidden] { display: none; }
.acct-op-status { font-size: var(--fs-sm); color: var(--text-muted); min-height: 18px; }

/* Last fleet run. Lives outside the selection bar so a probe-all (no selected
   rows) still has a readable failure list after the run. */
.fleet-panel {
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
  padding: var(--space-8) var(--space-12); margin-bottom: var(--space-12); font-size: var(--fs-sm);
}
.fleet-panel-head { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }
.fleet-panel-head #accounts-fleet-summary { color: var(--text-muted); }
.fleet-list { margin: var(--space-6) 0 0; padding-left: var(--space-20); max-height: 200px; overflow: auto; }
.fleet-list li { margin: var(--space-2) 0; color: var(--text-muted); word-break: break-all; }
.fleet-item { display: flex; align-items: baseline; gap: var(--space-6); flex-wrap: wrap; }
.fleet-item-email { color: var(--text); font-weight: 600; }
.fleet-item-result {
  flex: none; padding: 0 var(--space-6); border-radius: var(--radius-xs); font-size: var(--fs-sm); font-weight: 700;
}
.fleet-item-result.ok { background: var(--success-soft); color: var(--text); }
.fleet-item-result.fail { background: var(--error-soft); color: var(--text); }
.fleet-item-result.skip { background: var(--warn-soft); color: var(--text); }

.acct-filters { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; margin-bottom: var(--space-12); }
.acct-filters .input, .acct-filters .select { width: auto; height: 32px; padding-top: var(--space-4); padding-bottom: var(--space-4); }
.acct-filters .cs { width: auto; flex: 0 0 auto; min-width: 150px; }
.acct-filters .cs-trigger { height: 32px; padding-top: var(--space-4); padding-bottom: var(--space-4); }
.acct-filters .input { flex: 1 1 240px; min-width: 160px; max-width: 360px; }
.acct-filter-count { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
.acct-filter-clear[hidden] { display: none; }

#accounts-table { min-width: 1070px; table-layout: fixed; }
#accounts-table th, #accounts-table td { padding: var(--acct-row-pad-y) var(--space-16); }
/* Sortable header: the whole label is the control, and aria-sort on the th is
   the state (a screen reader announces it; the caret is decoration). */
#accounts-table th.th-help { cursor: default; }
.th-sort {
  display: inline-flex; align-items: center; gap: var(--space-4);
  padding: 0; border: 0; background: transparent; color: inherit;
  font: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer;
}
.th-sort:hover { color: var(--text); }
.th-sort:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; border-radius: var(--radius-xs); }
.th-sort-mark { font-size: var(--fs-sm); color: var(--text-dim); letter-spacing: normal; }
#accounts-table th[aria-sort="ascending"] .th-sort-mark::after { content: "▲"; }
#accounts-table th[aria-sort="descending"] .th-sort-mark::after { content: "▼"; }
#accounts-table th[aria-sort="ascending"] .th-sort,
#accounts-table th[aria-sort="descending"] .th-sort { color: var(--text); }
.acct-sort-field { display: inline-flex; align-items: center; }
.acct-sort-select { width: auto; height: 32px; padding-top: var(--space-4); padding-bottom: var(--space-4); }
#accounts-table .acct-check-col { width: 40px; padding-left: var(--space-12); padding-right: 0; }
#accounts-table .acct-account-col { width: auto; min-width: 260px; }
#accounts-table .acct-tier-col { width: 88px; }
#accounts-table .acct-state-col { width: 150px; }
#accounts-table .acct-quota-col { width: 168px; }
#accounts-table .acct-models-col { width: 92px; }
#accounts-table .acct-rpm-col { width: 96px; }
#accounts-table .acct-last-col { width: 132px; }
#accounts-table .acct-actions-col { width: 44px; padding-left: var(--space-4); padding-right: var(--space-8); }
#accounts-table tbody td { overflow: hidden; }
#accounts-table.accounts-compact th, #accounts-table.accounts-compact td { padding: var(--space-4) var(--space-12); font-size: var(--fs-sm); }
#accounts-table.accounts-compact .acct-name { font-size: var(--fs-sm); }
#accounts-table.accounts-compact .acct-line2 { font-size: var(--fs-sm); }

.acct-line1 { display: flex; align-items: center; gap: var(--space-6); min-width: 0; }
/* Tier cell is the one cell allowed two lines. It is budgeted so the row still
   lands on the 44 px comfortable target: pill (17.2) + 1 px gap + 11 px subline
   + 10 px cell padding ≈ 40 px. Before this rule the subline inherited 1.5
   line-height and pushed rows with a reported plan to 49 px. */
.acct-tier-stack { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.acct-tier-sub {
  font-size: var(--fs-sm); line-height: 1.1; color: var(--text-dim);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.acct-name { font-size: var(--fs-lg); font-weight: 600; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct-open { border: 0; background: none; padding: 0; color: var(--text); font-family: inherit; text-align: left; cursor: pointer; }
.acct-open:hover { color: var(--accent-text); text-decoration: underline; }
.acct-open:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 1px; border-radius: 2px; }
.acct-line2 { display: flex; align-items: center; gap: var(--space-6); font-size: var(--fs-sm); line-height: 1.3; color: var(--text-dim); min-width: 0; }
.acct-line2 code { background: transparent; border: 0; padding: 0; font-size: var(--fs-sm); color: var(--text-dim); }
.acct-line2 .acct-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Last check lives on the existing second line, so the 44px row budget holds. */
.acct-last-check {
  margin-left: auto; flex: none; display: inline-flex; align-items: center; gap: var(--space-4);
  max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  color: var(--text-dim); font-size: var(--fs-sm);
}
.acct-last-check.is-skip { color: var(--warn-text); }
.acct-last-check-text { overflow: hidden; text-overflow: ellipsis; }
.acct-last-check-retry {
  border: 0; background: transparent; padding: 0; color: var(--accent-text);
  font: inherit; font-size: var(--fs-sm); cursor: pointer; text-decoration: underline; flex: none;
}
.acct-last-check-retry:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 1px; }
.note-chip {
  display: inline-flex; align-items: center; max-width: 120px; height: 18px; padding: 0 var(--space-6);
  border-radius: 999px; border: 1px dashed var(--border-strong);
  font-size: var(--fs-sm); line-height: 16px; color: var(--text-muted);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: none;
}
.method-chip { font-size: var(--fs-sm); color: var(--text-dim); flex: none; }
.acct-band { box-shadow: inset 3px 0 0 var(--text-dim); }
.acct-state { display: inline-flex; align-items: center; gap: var(--space-6); flex-wrap: wrap; }
.acct-state-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
.acct-countdown { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
.acct-error-count { font-size: var(--fs-sm); color: var(--error-text); font-weight: 600; }
.acct-batch-chip { font-size: var(--fs-sm); font-weight: 600; padding: 0 var(--space-6); border-radius: var(--radius-xs); }
.acct-batch-chip.ok { background: var(--success-soft); color: var(--text); }
.acct-batch-chip.fail { background: var(--error-soft); color: var(--text); }
.acct-batch-chip.skip { background: var(--warn-soft); color: var(--text); }

.row-menu-btn {
  display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px;
  border: 1px solid transparent; border-radius: var(--radius-sm); background: transparent;
  color: var(--text-muted); font-size: var(--fs-xl); line-height: 1; cursor: pointer;
}
.row-menu-btn:hover, .row-menu-btn[aria-expanded="true"] { background: var(--surface-2); border-color: var(--border); color: var(--text); }
.row-menu-btn:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 1px; }
.row-menu {
  position: fixed; z-index: 160; min-width: 190px; padding: var(--space-4);
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
  box-shadow: var(--shadow-lg);
}
.row-menu [role="menuitem"] {
  display: flex; align-items: center; width: 100%; padding: var(--space-8) var(--space-10); border: 0; border-radius: var(--radius-xs);
  background: transparent; color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: left; cursor: pointer;
}
.row-menu [role="menuitem"]:hover, .row-menu [role="menuitem"]:focus-visible { background: var(--surface-2); outline: none; }
.row-menu [role="menuitem"].danger { color: var(--error-text); }
.row-menu .menu-sep { height: 1px; margin: var(--space-4) var(--space-6); background: var(--border); }

#accounts-batch-bar {
  position: sticky; bottom: 0; z-index: 4;
  margin: 0; padding: var(--space-10) var(--space-12); border-radius: 0;
  border: 0; border-top: 1px solid var(--border);
  background: var(--surface-2); box-shadow: 0 -8px 18px -14px rgba(24,24,27,.55);
  flex-wrap: wrap;
}
#accounts-batch-bar .batch-scope { font-size: var(--fs-sm); color: var(--text-muted); }
#accounts-batch-bar .batch-scope button {
  border: 0; background: transparent; padding: 0 var(--space-2); color: var(--accent-text);
  font: inherit; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; text-decoration: underline;
}
#accounts-batch-bar .batch-progress { font-size: var(--fs-sm); color: var(--text-muted); font-variant-numeric: tabular-nums; }
#accounts-batch-bar .batch-fail-link { border: 0; background: transparent; color: var(--error-text); font: inherit; font-size: var(--fs-sm); cursor: pointer; text-decoration: underline; }
#accounts-batch-bar .batch-fail-pop {
  position: absolute; bottom: calc(100% + 6px); left: 12px; max-width: 520px; max-height: 220px; overflow: auto;
  padding: var(--space-10) var(--space-12); background: var(--surface); border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm); box-shadow: var(--shadow-lg); font-size: var(--fs-sm);
}
#accounts-batch-bar .batch-fail-pop ul { margin: var(--space-6) 0 0; padding-left: var(--space-20); }
#accounts-batch-bar .batch-fail-pop li { margin: var(--space-2) 0; color: var(--text-muted); word-break: break-all; }

.acct-empty { padding: var(--space-32) var(--space-16); text-align: center; }
.acct-empty-title { font-family: var(--font-display); font-size: var(--fs-xl); font-weight: 600; }
.acct-empty-sub { font-size: var(--fs-md); color: var(--text-muted); margin-top: var(--space-4); }
.acct-empty-actions { margin-top: var(--space-14); display: flex; gap: var(--space-8); justify-content: center; flex-wrap: wrap; }
.acct-empty.is-error .acct-empty-title { color: var(--error-text); }

/* Detail drawer (replaces the colspan=12 detail row) */
.acct-drawer-mask {
  position: fixed; inset: 0; z-index: 140;
  background: var(--scrim); backdrop-filter: blur(2px);
}
.acct-drawer {
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 141;
  width: min(480px, 100vw); display: flex; flex-direction: column;
  background: var(--surface); border-left: 1px solid var(--border); box-shadow: var(--shadow-lg);
}
.acct-drawer.hidden, .acct-drawer-mask.hidden { display: none; }
.acct-drawer-head { display: flex; align-items: flex-start; gap: var(--space-12); padding: var(--space-16) var(--space-20); border-bottom: 1px solid var(--border); }
.acct-drawer-title { font-size: var(--fs-xl); font-weight: 700; line-height: 1.3; word-break: break-all; }
.acct-drawer-sub { font-size: var(--fs-sm); color: var(--text-dim); margin-top: var(--space-4); display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }
.acct-drawer-body { flex: 1; overflow-y: auto; padding: var(--space-16) var(--space-20); }
.acct-drawer-foot {
  position: sticky; bottom: 0; display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap;
  padding: var(--space-12) var(--space-20); border-top: 1px solid var(--border); background: var(--surface-2);
}
.acct-drawer-foot .spacer { flex: 1; }
.acct-drawer .account-detail-wrap { padding: 0; background: none; }
.acct-drawer .detail-grid { grid-template-columns: 1fr; gap: var(--space-12); margin-bottom: 0; }
.acct-drawer .detail-card { border-radius: var(--radius); }
/* Primary cards answer the four questions; technical cards live behind this
   disclosure so the drawer's first screen is not a wall of ledgers. */
.detail-advanced { margin-top: var(--space-6); border-top: 1px dashed var(--border); padding-top: var(--space-8); }
.detail-advanced > summary {
  cursor: pointer; list-style: none; display: flex; align-items: baseline; gap: var(--space-8);
  padding: var(--space-6) var(--space-2); font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted);
}
.detail-advanced > summary::-webkit-details-marker { display: none; }
.detail-advanced > summary::before { content: '▸'; color: var(--text-dim); }
.detail-advanced[open] > summary::before { content: '▾'; }
.detail-advanced > summary:hover { color: var(--text); }
.detail-advanced > summary:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
.detail-advanced-hint { font-weight: 400; font-size: var(--fs-sm); color: var(--text-dim); }
.detail-advanced .detail-grid { margin-top: var(--space-10); }

/* Add-account modal: 4 tabs (login / paste / import / local) */
.add-modal { width: min(720px, 100%); max-width: 720px; max-height: 88vh; display: flex; flex-direction: column; }
.add-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-12); padding: var(--space-16) var(--space-20) var(--space-10); }
.add-tabs { display: flex; gap: var(--space-4); padding: 0 var(--space-20); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.add-tab {
  padding: var(--space-8) var(--space-12); border: 0; border-bottom: 2px solid transparent; background: transparent;
  color: var(--text-muted); font: inherit; font-size: var(--fs-md); font-weight: 600; cursor: pointer;
}
.add-tab:hover { color: var(--text); }
.add-tab[aria-selected="true"] { color: var(--text); border-bottom-color: var(--accent); }
.add-tab:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: -2px; }
.add-modal-body { padding: var(--space-16) var(--space-20) var(--space-20); overflow-y: auto; }
.add-panel { display: none; }
.add-panel.active { display: block; }
.add-result { margin-top: var(--space-14); }
.add-result .section { margin-bottom: 0; }
.add-panel .section { border: 0; box-shadow: none; margin-bottom: var(--space-12); }
.add-panel .section-header { padding: 0 0 var(--space-8); border-bottom: 0; }
.add-panel .section-body { padding: 0; }
.add-panel .section-body.tight { padding: 0; }
.add-panel .field-row { margin-bottom: var(--space-10); }
/* Login step 1: two method cards + a secondary token link. */
.login-methods { display: grid; gap: var(--space-10); margin-bottom: var(--space-4); }
.login-methods.hidden { display: none; }
.login-method {
  display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-4); width: 100%;
  padding: var(--space-14) var(--space-16); text-align: left; cursor: pointer;
  background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--radius);
  color: var(--text); font: inherit;
}
.login-method:hover { border-color: var(--accent); background: var(--surface-2); }
.login-method:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
.login-method-title { font-size: var(--fs-lg); font-weight: 700; }
.login-method-desc { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.5; }
.login-method-token { justify-self: start; }
/* OAuth brand blocks (P2 palette). These were inline `background:#4285F4` with
   white text — 3.56, an AA text failure — plus a hardcoded hex outside the
   theme system. Tokens keep the brand hue while clearing 4.5. */
.oauth-brand-btn { padding: var(--space-10) var(--space-24); font-weight: 600; gap: var(--space-8); }
.oauth-brand-google { background: var(--brand-google-bg); color: var(--brand-google-fg); }
.oauth-brand-google:hover:not(:disabled) { background: var(--brand-google-bg-hover); border-color: transparent; }
.oauth-brand-github { background: var(--brand-github-bg); color: var(--brand-github-fg); }
.oauth-brand-github:hover:not(:disabled) { background: var(--brand-github-bg-hover); border-color: transparent; }
/* Token step panel: neutral border. Its accent stripe was a leftover of the
   pre-workbuddy palette; the panel is a step, not a status. */
.login-token-step {
  margin-top: var(--space-14); padding: var(--space-14);
  background: var(--surface-2); border-radius: var(--radius);
  border-left: 3px solid var(--border-strong);
}
/* "Try another way" / OAuth-only hint panels: same neutral treatment. */
.login-alt-panel {
  margin-top: var(--space-14); padding: var(--space-12);
  background: var(--surface-2); border-radius: var(--radius);
  border-left: 3px solid var(--border-strong);
}
/* ── Usage page (P2 scale) ──────────────────────────────────────────────
   The ACU panel reuses .card/.metrics-grid; only its geometry lives here so
   the inline px that used to sit in the renderer has one home. */
.usage-org-bar { padding: var(--space-12) var(--space-16); }
.usage-metrics { margin-bottom: var(--space-20); }
.usage-card-value { font-size: var(--fs-4xl); }
.usage-retry { margin-top: var(--space-12); }
.usage-org-inline { display: flex; align-items: center; gap: var(--space-8); flex-wrap: wrap; }
.login-method-panel.hidden { display: none; }
.login-panel-head { display: flex; align-items: center; gap: var(--space-10); margin-bottom: var(--space-10); }
.login-panel-title { font-size: var(--fs-md); font-weight: 700; }
.login-steps { margin: 0; padding-left: var(--space-20); display: grid; gap: var(--space-10); font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.6; }
.login-steps li { display: grid; gap: var(--space-6); justify-items: start; }
.login-steps li > .btn { justify-self: start; }
.login-advanced { margin-top: var(--space-14); border-top: 1px dashed var(--border); padding-top: var(--space-8); }
.login-advanced > summary { cursor: pointer; font-size: var(--fs-sm); font-weight: 600; color: var(--text-muted); padding: var(--space-4) 0; }
.login-advanced > summary:hover { color: var(--text); }
.login-advanced > summary:focus-visible { outline: 2px solid var(--accent-focus); outline-offset: 2px; }
.login-advanced-body { padding-top: var(--space-8); }
.login-advanced-body .section { margin-bottom: var(--space-8); }

@media (max-width: 1100px) {
  .acct-kpis { grid-template-columns: repeat(3, 1fr); }
  .acct-kpi:nth-child(4) { border-left: 0; }
  .acct-kpi:nth-child(n+4) { border-top: 1px solid var(--border); }
  #accounts-table { min-width: 1070px; }
}
@media (max-width: 720px) {
  .acct-kpis { grid-template-columns: repeat(2, 1fr); }
  .acct-kpi { border-left: 0; }
  .acct-kpi:nth-child(even) { border-left: 1px solid var(--border); }
  .acct-kpi:nth-child(n+3) { border-top: 1px solid var(--border); }
  .acct-filters .input { max-width: none; flex-basis: 100%; }
  #accounts-table { min-width: 0; table-layout: auto; }
  #accounts-table thead { display: none; }
  #accounts-table, #accounts-table tbody { display: block; width: 100%; }
  #accounts-table tr {
    display: grid; grid-template-columns: 1fr 1fr; gap: 2px 10px;
    padding: 12px; margin: 0 0 10px; border: 1px solid var(--border); border-radius: var(--radius);
    background: var(--surface);
  }
  #accounts-table td { display: block; border: 0; padding: 2px 0; overflow: visible; }
  #accounts-table td[data-label]::before {
    content: attr(data-label); display: block; font-size: 12px; font-weight: 600;
    letter-spacing: .04em; text-transform: uppercase; color: var(--text-dim);
  }
  #accounts-table td.acct-account-cell, #accounts-table td.acct-check-cell { grid-column: 1 / -1; }
  #accounts-table td.acct-check-cell { display: flex; align-items: center; gap: 8px; }
  #accounts-table td.acct-actions-cell { grid-column: 1 / -1; display: flex; justify-content: flex-end; padding-top: 6px; }
  #accounts-table .acct-band { box-shadow: none; }
  /* P1 #9: the card transformation belongs to the accounts table only. Other
     tables (requests, stats, proxy, future tables) keep a real scroll port. */
  .accounts-table-wrap { overflow-x: visible; }
  .table-wrap:not(.accounts-table-wrap) { overflow-x: auto; overscroll-behavior-inline: contain; }
  .acct-drawer { width: 100%; top: auto; height: 88vh; border-left: 0; border-top: 1px solid var(--border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
  #accounts-batch-bar { position: fixed; left: 12px; right: 12px; bottom: 12px; border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); }
}
.acct-blocked-count { margin-left: var(--space-4); padding: 0 var(--space-4); border-radius: var(--radius-xs); background:var(--warn-soft); color:var(--text); font-size: var(--fs-sm); font-weight:600; }
.acct-quota { font-size: var(--fs-sm); line-height: 1.25; min-width: 0; }
.acct-quota-head { display: flex; align-items: center; gap: var(--space-4); font-size: var(--fs-sm); white-space: nowrap; overflow: hidden; }
.acct-quota-head b { font-weight: 600; overflow: hidden; text-overflow: ellipsis; }
.acct-quota-head > span:last-child { margin-left: auto; }
.acct-quota-bar { display: flex; align-items: center; gap: var(--space-4); margin-top: 1px; line-height: 1; }
.acct-quota-label { width: 10px; flex: none; font-size: var(--fs-sm); font-weight: 600; color: var(--text-dim); }
.acct-quota-track { flex: 1; height: 3px; background: var(--surface-3); border-radius: 2px; overflow: hidden; min-width: 30px; }
.acct-quota-track.na { outline: 1px dashed var(--text-dim); outline-offset: -1px; }
.acct-quota-pct { width: 26px; text-align: right; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.acct-state { gap: var(--space-4); flex-wrap: nowrap; min-width: 0; }
.acct-state .badge { padding: 1px var(--space-6); font-size: var(--fs-sm); flex: none; }
.acct-state-dot { flex: none; }
.acct-countdown { flex: none; padding: 0 var(--space-4); border-radius: var(--radius-xs); background: var(--surface-2); font-variant-numeric: tabular-nums; }
.acct-open { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
</style>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
<!-- Firebase signInWithPopup was removed: on a self-hosted public origin it always
     fails with auth/unauthorized-domain (Firebase authorized-domain lock). Quick
     Login now drives the Windsurf official sign-in + paste-token flow instead
     (App.oauthLogin), which runs on windsurf.com and is not bound to our origin. -->
</head>
<body>

<!-- P1 #5: below 720px the fixed rail becomes a top app bar + labelled drawer. -->
<header class="mobile-appbar" id="mobile-appbar">
  <button class="btn btn-ghost mobile-menu-btn" id="mobile-menu-btn" type="button"
          aria-label="打开导航" data-i18n-aria-label="aria.openNav"
          aria-expanded="false" aria-controls="app-sidebar" onclick="App.toggleMobileNav()">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
  </button>
  <span class="mobile-appbar-title" id="mobile-appbar-title" data-i18n="nav.overview">仪表盘</span>
  <span class="mobile-appbar-spacer"></span>
  <button class="btn btn-ghost mobile-appbar-btn" type="button" onclick="App.toggleLang()" data-i18n-title="action.switchLang" title="Switch language">
    <span id="mobile-lang-indicator">中文</span>
  </button>
  <button class="btn btn-ghost mobile-appbar-btn mobile-appbar-icon" type="button" onclick="App.toggleTheme()" data-i18n-title="theme.toggle" title="Toggle theme">
    <span id="mobile-theme-indicator"></span>
  </button>
</header>
<div class="mobile-nav-mask hidden" id="mobile-nav-mask" onclick="App.closeMobileNav()"></div>

<!-- ════════ Sidebar ════════ -->
<aside class="sidebar" id="app-sidebar">
  <div class="brand">
    <div class="brand-logo">W</div>
    <div>
      <div class="brand-name">WindsurfAPI <span style="font-size: 12px;opacity:.6;vertical-align:top;margin-left:2px">bydwgx1337</span></div>
      <div class="brand-sub" data-i18n="brand.sub">管理控制台</div>
    </div>
  </div>
  <nav>
    <!-- IA reorganized into 4 work areas: Overview / Accounts / Usage / Settings.
         Every data-panel + href + onclick target is preserved unchanged — only
         the grouping and group labels changed. Panel section IDs are untouched. -->
    <div class="nav-group">
      <div class="nav-group-label" data-i18n="nav.group.overview">概览</div>
      <a class="active" href="#overview" data-panel="overview">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
        <span data-i18n="nav.overview">仪表盘</span>
      </a>
    </div>
    <div class="nav-group">
      <div class="nav-group-label" data-i18n="nav.group.account">账号</div>
      <a href="#accounts" data-panel="accounts">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
        <span data-i18n="nav.accounts">账号管理</span>
      </a>
    </div>
    <div class="nav-group">
      <div class="nav-group-label" data-i18n="nav.group.usage">用量</div>
      <a href="#devin" data-panel="devin">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 9l3 3-3 3"/><path d="M13 15h4"/></svg>
        <span data-i18n="nav.devin">Devin 面板</span>
      </a>
      <a href="#usage" data-panel="usage">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="M7 14l4-4 3 3 5-6"/></svg>
        <span data-i18n="nav.usage">用量</span>
      </a>
      <a href="#stats" data-panel="stats">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
        <span data-i18n="nav.stats">统计分析</span>
      </a>
    </div>
    <div class="nav-group" id="settings-nav-group">
      <div class="nav-group-label" data-i18n="nav.group.settings">设置 · 高级</div>
      <input id="settings-search" class="input" type="text" data-i18n-placeholder="settings.search.placeholder" placeholder="筛选设置…" oninput="App.filterSettingsNav(this.value)" style="margin:2px 0 6px;font-size:12px;padding:5px 8px;width:100%;box-sizing:border-box;" />
      <a href="#settings" data-panel="settings">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
        <span data-i18n="nav.settings">全局设置</span>
      </a>
      <a href="#models" data-panel="models">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
        <span data-i18n="nav.models">模型控制</span>
      </a>
      <a href="#proxy" data-panel="proxy">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M17 1l4 4-4 4"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><path d="M7 23l-4-4 4-4"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
        <span data-i18n="nav.proxy">代理配置</span>
      </a>
      <a href="#logs" data-panel="logs">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
        <span data-i18n="nav.logs">运行日志</span>
      </a>
      <a href="#experimental" data-panel="experimental">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M10 2v7.31"/><path d="M14 9.3V2"/><path d="M8.5 2h7"/><path d="M14 9.3a6.5 6.5 0 1 1-4 0"/></svg>
        <span data-i18n="nav.experimental">实验性功能</span>
      </a>
      <a href="#credits" data-panel="credits">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
        <span data-i18n="nav.credits">致谢</span>
      </a>
    </div>
  </nav>
  <div class="footer">
    <div class="footer-actions">
      <button id="lang-toggle-btn" class="btn btn-ghost btn-xs" onclick="App.toggleLang()" data-i18n-title="action.switchLang" title="Switch language">
        <span id="lang-indicator">中文</span>
      </button>
      <button id="theme-toggle-btn" class="btn btn-ghost btn-xs footer-icon-btn" onclick="App.toggleTheme()" data-i18n-title="theme.toggle">
        <span id="theme-indicator"></span>
      </button>
    </div>
    <div class="footer-meta">
      <span>© Windsurf</span>
      <span class="ver" id="sidebar-ver" data-i18n-title="footer.version" title="版本">v1.2.0</span>
    </div>
  </div>
</aside>

<!-- ════════ Main ════════ -->
<main class="main">
  <!-- Overview -->
  <section class="panel active" id="p-overview">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.overview">仪表盘</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.overview">系统运行状态与关键指标概览</div>
      </div>
      <div class="btn-group">
        <button class="btn btn-outline btn-sm" id="btn-check-update" onclick="App.checkUpdate()">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
          <span data-i18n="action.checkUpdate">检查更新</span>
        </button>
        <button class="btn btn-primary btn-sm hidden" id="btn-apply-update" onclick="App.applyUpdate()">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg>
          <span id="btn-apply-update-text" data-i18n="action.applyUpdate">一键更新并重启</span>
        </button>
        <button class="btn btn-outline warn btn-sm hidden" id="btn-rollback-update" onclick="App.rollbackUpdate()">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>
          <span id="btn-rollback-update-text" data-i18n="action.rollbackUpdate">回滚到上个版本</span>
        </button>
      </div>
    </div>
    <div id="update-status" class="section hidden" style="padding:12px;margin-bottom:12px"></div>
    <div id="overview-stale-banner" class="banner banner-warn hidden" role="status" aria-live="polite"></div>
    <div class="metrics-grid" id="overview-cards"></div>
    <!-- Cockpit: pool health → connect self-healing → trend → rankings.
         Each is rendered idempotently by loadOverview from real telemetry;
         hidden/empty when its data source is unavailable. -->
    <div class="section" id="pool-health-overview">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.poolHealth.title">账号池健康</div>
          <div class="section-desc" data-i18n="section.poolHealth.desc">按最坏优先汇总各账号健康信号</div>
        </div>
        <div class="seg-group" role="radiogroup" data-i18n-aria-label="aria.poolViewGroup" aria-label="账号池视图" id="pool-view-switch">
          <button class="seg-btn pool-view-btn" role="radio" aria-checked="false" data-view="grid" onclick="App.setPoolView('grid')" data-i18n="poolView.grid">发光网格</button>
          <button class="seg-btn pool-view-btn active" role="radio" aria-checked="true" data-view="bars" onclick="App.setPoolView('bars')" data-i18n="poolView.bars">状态条</button>
        </div>
      </div>
      <div class="section-body"><div id="pool-health-summary"></div></div>
    </div>
    <div class="grid-2" style="display:grid;grid-template-columns:1fr;gap:16px;">
      <div class="section" id="connect-metrics-overview">
        <div class="section-header">
          <div>
            <div class="section-title" data-i18n="section.connectMetrics.title">连接自愈</div>
            <div class="section-desc" data-i18n="section.connectMetrics.desc">实时恢复计数 — 重登、故障转移、限流、失效 token</div>
          </div>
        </div>
        <div class="section-body"><div id="connect-metrics-status" style="width:100%;"></div></div>
      </div>
    </div>
    <div class="section" id="catalog-diff-overview">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.catalogDiff.title">模型目录差异</div>
          <div class="section-desc" data-i18n="section.catalogDiff.desc">实时目录与内置冷启动快照的集合差异 — 只读,不代表套餐权益</div>
        </div>
      </div>
      <div class="section-body"><div id="catalog-diff-body"></div></div>
    </div>
    <div class="section" id="overview-rankings">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.rankings.title">排行</div>
          <div class="section-desc" data-i18n="section.rankings.desc">按请求量(仅本地计数器)</div>
        </div>
      </div>
      <div class="section-body"><div id="overview-rankings-body"></div></div>
    </div>
    <div class="section" id="recent-requests-section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.recentRequests.title">最近请求</div>
          <div class="section-desc" data-i18n="section.recentRequests.desc">最新 20 条已完成请求 — 仅元数据,不含请求头与提示词</div>
        </div>
        <div style="display:flex;align-items:center;gap:10px">
          <span id="recent-requests-meta" class="text-sm text-muted"></span>
          <button class="btn btn-outline btn-sm" id="btn-refresh-requests" onclick="App.loadRecentRequests()" data-i18n-title="section.recentRequests.refreshTitle" title="重新加载最近请求">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
            <span data-i18n="section.recentRequests.refresh">刷新</span>
          </button>
        </div>
      </div>
      <div class="section-body tight">
        <div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
          <table aria-label="最近请求表格" data-i18n-aria-label="aria.recentRequests">
            <thead><tr>
              <th data-i18n="table.header.time">时间</th>
              <th data-i18n="table.header.model">模型</th>
              <th data-i18n="table.header.status">状态</th>
              <th data-i18n="table.header.duration">耗时</th>
              <th data-i18n="table.header.class">分类</th>
              <th data-i18n="table.header.caller">调用方</th>
            </tr></thead>
            <tbody id="recent-requests-body">
              <tr class="empty-row"><td colspan="6" data-i18n="table.empty.noRequestData">暂无请求数据</td></tr>
            </tbody>
          </table>
        </div>
      </div>
    </div>
    <div class="section" id="ls-section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.lsStatus.title">Language Server</div>
          <div class="section-desc" data-i18n="section.lsStatus.desc">语言服务器实例状态</div>
        </div>
        <div style="display:flex;gap:8px">
          <button class="btn btn-outline btn-sm" onclick="App.updateLsBinary()" data-i18n-title="action.updateLsBinaryTitle" title="Download latest language_server binary and restart pool">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
            <span data-i18n="action.updateLsBinary">更新 LS</span>
          </button>
          <button class="btn btn-outline btn-sm" onclick="App.restartLs()">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
            <span data-i18n="action.restart">重启</span>
          </button>
        </div>
      </div>
      <div class="section-body">
        <div id="ls-status"></div>
        <div id="ls-binary-info" style="margin-top:12px;font-size:12px;color:var(--text-muted)"></div>
      </div>
    </div>
    <div class="section" id="native-bridge-overview-section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.nativeBridge.title">原生桥接</div>
          <div class="section-desc" data-i18n="section.nativeBridge.desc">路由决策与灰度门可见性</div>
        </div>
      </div>
      <div class="section-body">
        <div id="native-bridge-status"></div>
      </div>
    </div>
  </section>

  <!-- Accounts -->
  <section class="panel" id="p-accounts">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.accounts">账号管理</h1>
        <div class="page-subtitle" id="accounts-subtitle" data-i18n="pageSubtitle.accounts">维护账号池,检查各账号的套餐层级与可用模型</div>
      </div>
      <!-- One credential-entry surface (the add modal), three inline entry verbs.
           The old standalone add-account box and the separate login page are gone,
           so this group is the only place on the page that opens a credential form. -->
      <div class="acct-add-group" role="group" aria-label="登录、导入或粘贴凭证" data-i18n-aria-label="accounts.add.groupLabel">
        <button class="btn btn-primary" id="accounts-login-btn" onclick="App.openAddAccountModal('login')" data-i18n="accounts.add.login">登录</button>
        <button class="btn btn-outline" id="accounts-import-btn" onclick="App.openAddAccountModal('import')" data-i18n="accounts.add.import">导入</button>
        <button class="btn btn-outline" id="accounts-add-btn" onclick="App.openAddAccountModal('paste')" data-i18n="accounts.add.paste">粘贴凭证</button>
      </div>
    </div>
    <!-- v2.0.57 Fix 5 — drought mode banner: shown only when every active
         account has weekly% < 5. Hidden by default; loadDrought() flips it. -->
    <div id="drought-banner" class="banner banner-warn" style="display:none;margin:0 0 16px;padding:14px 18px;border-radius:var(--radius);background:var(--warn-soft);border:1px solid var(--border-strong);color:var(--text)">
      <strong data-i18n="drought.title">配额预警:</strong>
      <span id="drought-message" data-i18n="drought.body">所有账号本周配额都低于阈值,建议补账号或等待重置</span><span data-i18n-tip="drought"></span>
      <span id="drought-detail" class="text-sm text-muted" style="margin-left:8px"></span>
    </div>

    <!-- A failed GET is its own state: the last valid table stays painted (never
         replace a real pool with an empty one) and this banner explains the
         staleness with a retry. Hidden unless a load actually fails. -->
    <div id="accounts-error" class="banner banner-error" style="display:none;margin:0 0 16px;padding:12px 16px;border-radius:var(--radius);background:var(--error-soft);border:1px solid var(--error);color:var(--text)" role="alert">
      <span id="accounts-error-text"></span>
      <button class="btn btn-outline btn-xs" style="margin-left:10px" onclick="App.loadAccounts()" data-i18n="accounts.retry">重试</button>
    </div>
    <div class="acct-load-status" id="accounts-load-status" role="status" aria-live="polite" hidden></div>

    <!-- KPI strip — six cells, one panel, zero extra requests (stats rides the list response). -->
    <div class="acct-kpis" id="accounts-kpis" aria-live="polite"></div>

    <!-- Fleet toolbar: long-running pool operations live here, not in the page header. -->
    <div class="acct-toolbar">
      <div class="tb-group" role="group" data-i18n-aria-label="accounts.toolbar.fleet">
        <button class="btn btn-outline btn-sm" onclick="App.startFleetRun('probe')" data-i18n="accounts.toolbar.probeAll">全部检查</button>
        <button class="btn btn-outline btn-sm" onclick="App.startFleetRun('refresh')" data-i18n="accounts.toolbar.refreshAll">刷新全部余额</button>
        <button class="btn btn-outline btn-sm" id="accounts-fleet-cancel" onclick="App.cancelFleetRun()" hidden data-i18n="accounts.fleet.cancel">取消</button>
        <button class="btn btn-outline btn-sm" id="accounts-fleet-reopen" onclick="App.reopenFleetPanel()" hidden data-i18n="accounts.fleet.reopen">本次结果</button>
        <button class="btn btn-outline btn-sm" onclick="App.exportAccounts()" data-i18n="accounts.export.label">导出</button>
      </div>
      <div class="tb-group" role="group" data-i18n-aria-label="accounts.toolbar.view">
        <button class="btn btn-ghost btn-sm" id="accounts-density-btn" onclick="App.toggleAccountsDensity()" aria-pressed="false" data-i18n-title="accounts.density.title" title="切换紧凑/宽松密度"><span data-i18n="accounts.density.label">紧凑</span></button>
      </div>
      <!-- Sort: server-side (sort/order query params), persisted with the page
           size. The quota and last-used columns are also clickable headers. -->
      <label class="acct-sort-field">
        <span class="sr-only" data-i18n="accounts.sort.label">排序</span>
        <select id="accounts-sort" class="select acct-sort-select" onchange="App.setAccountsSort(this.value)" data-i18n-title="accounts.sort.hint" title="点一次排序,再点一次反向">
          <option value="" data-i18n="accounts.sort.default">默认顺序</option>
          <option value="quota_weekly" data-i18n="table.header.quota">配额</option>
          <option value="balance" data-i18n="table.header.credits">余额</option>
          <option value="last_used" data-i18n="table.header.lastUsed">最后使用</option>
          <option value="errors" data-i18n="table.header.errorCount">错误数</option>
        </select>
      </label>
      <span class="acct-op-status" id="accounts-op-status" aria-live="polite"></span>
    </div>

    <!-- Result panel for the last fleet run. Independent of the batch bar on
         purpose: a probe-all has no selected rows, and the failures must stay
         readable after the run (and after the bar closes). -->
    <div id="accounts-fleet-panel" class="fleet-panel" hidden>
      <div class="fleet-panel-head">
        <span id="accounts-fleet-summary"></span>
        <button class="btn btn-ghost btn-xs" id="accounts-fleet-excluded" hidden onclick="App.toggleFleetExcluded()" aria-expanded="false" data-i18n="accounts.fleet.excludedToggle">已排除非活跃账号</button>
        <button class="btn btn-ghost btn-xs" id="accounts-fleet-toggle" aria-expanded="false" hidden onclick="App.toggleFleetDetails()" data-i18n="accounts.fleet.details">查看明细</button>
        <button class="btn btn-ghost btn-xs" id="accounts-fleet-dismiss" onclick="App.dismissFleetPanel()" data-i18n="accounts.fleet.dismiss">关闭</button>
      </div>
      <ul id="accounts-fleet-list" class="fleet-list" hidden></ul>
      <ul id="accounts-fleet-excluded-list" class="fleet-list" hidden></ul>
    </div>

    <!-- Filter bar: search / tier / status groups / count / clear. Every control
         maps to a server query param, so a filter never silently means "this page". -->
    <div class="acct-filters" role="search">
      <input id="accounts-search" type="search" class="input" autocomplete="off" oninput="App.onAccountsSearchInput(this.value)" data-i18n-placeholder="accounts.filter.searchPlaceholder" placeholder="搜索邮箱 / ID / 标签">
      <select id="accounts-tier-filter" class="select" onchange="App.setAccountsTier(this.value)" data-i18n-title="accounts.tierFilter.title" title="按层级筛选">
        <option value="" data-i18n="accounts.tierFilter.all">全部层级</option>
        <option value="pro" data-i18n="accounts.tierFilter.pro">Pro</option>
        <option value="free" data-i18n="accounts.tierFilter.free">Free</option>
        <option value="unknown" data-i18n="accounts.tierFilter.unknown">未知</option>
        <option value="expired" data-i18n="accounts.tierFilter.expired">已过期</option>
      </select>
      <!-- Status vocabulary: three human groups by default. The ten raw states
           stay one click away behind the "more statuses" button, each with a
           one-line explanation; "needs attention" is the SAME `filter=flagged`
           predicate as the KPI cell. -->
      <select id="accounts-status-filter" class="select" onchange="App.setAccountsStatus(this.value)" data-i18n-title="accounts.filter.statusTitle" title="按状态筛选">
        <option value="" data-i18n="accounts.filter.allStatus" data-i18n-title="accounts.filter.allStatusTip" title="不按状态筛选">全部状态</option>
        <option value="active" data-i18n="accounts.status.active" data-i18n-title="accounts.statusTip.active" title="能接请求:未停用、未限流、配额未耗尽">正常</option>
        <option value="disabled" data-i18n="accounts.status.disabled" data-i18n-title="accounts.statusTip.disabled" title="你手动停用的账号">已停用</option>
        <option value="attention" data-i18n="accounts.filter.attention" data-i18n-title="accounts.filter.attentionTip" title="需处理">需处理</option>
      </select>
      <button class="btn btn-ghost btn-xs" id="accounts-status-more" aria-expanded="false" aria-controls="accounts-status-filter" onclick="App.toggleStatusDetails()" data-i18n="accounts.filter.moreStatus">更多状态</button>
      <template id="accounts-status-details">
        <option value="active" data-status-detail data-i18n="accounts.status.active" data-i18n-title="accounts.statusTip.active" title="能接请求:未停用、未限流、配额未耗尽">正常</option>
        <option value="disabled" data-status-detail data-i18n="accounts.status.disabled" data-i18n-title="accounts.statusTip.disabled" title="你手动停用的账号">已停用</option>
        <option value="banned" data-status-detail data-i18n="accounts.status.banned" data-i18n-title="accounts.statusTip.banned" title="Windsurf 封禁了这个账号">已封禁</option>
        <option value="reauth" data-status-detail data-i18n="accounts.status.reauth" data-i18n-title="accounts.statusTip.reauth" title="登录凭证已失效,需要重新登录">需要重新授权</option>
        <option value="error" data-status-detail data-i18n="accounts.status.error" data-i18n-title="accounts.statusTip.error" title="最近一次请求或检查失败">异常</option>
        <option value="quota_cooled" data-status-detail data-i18n="accounts.status.quotaCooled" data-i18n-title="accounts.statusTip.quota_cooled" title="配额用尽,等待重置">配额耗尽</option>
        <option value="rate_limited" data-status-detail data-i18n="accounts.status.rateLimited" data-i18n-title="accounts.statusTip.rate_limited" title="被服务限流,稍后自动恢复">限流中</option>
        <option value="capacity" data-status-detail data-i18n="accounts.status.capacity" data-i18n-title="accounts.statusTip.capacity" title="当前模型容量不足,换模型或稍后重试">模型繁忙</option>
        <option value="near_limit" data-status-detail data-i18n="accounts.status.nearLimit" data-i18n-title="accounts.statusTip.near_limit" title="每分钟请求用量已接近上限">接近上限</option>
        <option value="watch" data-status-detail data-i18n="accounts.status.watch" data-i18n-title="accounts.statusTip.watch" title="有历史错误计数,但仍可接请求">有错误</option>
      </template>
      <span class="acct-filter-count" id="accounts-filter-count"></span>
      <button class="btn btn-ghost btn-xs acct-filter-clear" id="accounts-filter-clear" onclick="App.clearAccountsFilters()" hidden data-i18n="accounts.filter.clear">清除筛选</button>
    </div>

    <div class="section">
      <div class="section-body tight">
        <div id="accounts-batch-bar" class="batch-bar" style="display:none">
          <span class="batch-count" id="accounts-batch-count"></span>
          <span class="batch-scope" id="accounts-batch-scope"></span>
          <div class="btn-group">
            <button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('enable')" data-i18n="batchOps.enable">启用</button>
            <button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('disable')" data-i18n="batchOps.disable">禁用</button>
            <button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('probe')" data-i18n="batchOps.probe">检查</button>
            <button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('refresh')" data-i18n="batchOps.refresh">刷新余额</button>
            <button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('reset')" data-i18n="accounts.batch.reset">重置错误</button>
            <button class="btn btn-ghost btn-xs" onclick="App.batchAccountOp('export')" data-i18n="accounts.batch.export">导出所选</button>
            <button class="btn btn-ghost btn-xs" style="color: var(--error-text)" onclick="App.batchAccountOp('delete')" data-i18n="batchOps.delete">删除</button>
            <button class="btn btn-ghost btn-xs" onclick="App.clearAccountSelection()" data-i18n="batchOps.clear">清除选择</button>
          </div>
          <span class="batch-progress" id="accounts-batch-progress" aria-live="polite"></span>
          <button class="batch-fail-link" id="accounts-batch-fail" hidden onclick="App.toggleBatchFailures()" data-i18n="accounts.batch.viewFailures">查看失败</button>
          <div class="batch-fail-pop" id="accounts-batch-fail-pop" hidden></div>
        </div>
        <div class="table-wrap accounts-table-wrap">
          <table id="accounts-table">
            <thead><tr><th class="acct-check-col" style="text-align:center"><input type="checkbox" id="accounts-select-all" aria-label="选择本页" data-i18n-aria-label="accounts.selectAllPage" data-i18n-title="accounts.selectAllPage" title="选择本页" onchange="App.toggleSelectAllAccounts(this.checked)"></th><th class="acct-account-col" data-i18n="table.header.account">账号</th><th class="acct-tier-col" data-i18n="table.header.tier">层级</th><th class="acct-state-col" data-i18n="table.header.status">状态</th><th class="acct-quota-col th-help" data-i18n-title="creditsBar.columnTooltip" title="配额使用情况" aria-sort="none"><button type="button" class="th-sort" onclick="App.setAccountsSort('quota_weekly')" data-i18n-title="accounts.sort.hint" title="点一次排序,再点一次反向"><span data-i18n="table.header.quota">配额</span><span class="th-sort-mark" aria-hidden="true"></span></button></th><th class="acct-models-col" data-i18n="table.header.models">模型</th><th class="acct-rpm-col" data-i18n="table.header.rpm">RPM</th><th class="acct-last-col" aria-sort="none"><button type="button" class="th-sort" onclick="App.setAccountsSort('last_used')" data-i18n-title="accounts.sort.hint" title="点一次排序,再点一次反向"><span data-i18n="table.header.lastUsed">最后使用</span><span class="th-sort-mark" aria-hidden="true"></span></button></th><th class="acct-actions-col" data-i18n="table.header.action">操作</th></tr></thead>
            <tbody></tbody>
          </table>
        </div>
        <div class="table-pagination" id="accounts-pagination">
          <button class="btn btn-ghost btn-xs" id="accounts-prev" onclick="App.setAccountsPage(App.accountsPage - 1)" data-i18n="action.prev">上一页</button>
          <span class="page-label" id="accounts-page-label">-</span>
          <select class="select" id="accounts-page-size" onchange="App.setAccountsPageSize(this.value)">
            <option value="25">25</option>
            <option value="50">50</option>
            <option value="100">100</option>
            <option value="200">200</option>
          </select>
          <button class="btn btn-ghost btn-xs" id="accounts-next" onclick="App.setAccountsPage(App.accountsPage + 1)" data-i18n="action.next">下一页</button>
        </div>
      </div>
    </div>

    <!-- Detail drawer: replaces the old inline detail row (a colspan cell). -->
    <div class="acct-drawer-mask hidden" id="acct-drawer-mask" onclick="App.closeAccountDrawer()"></div>
    <aside class="acct-drawer hidden" id="acct-drawer" role="dialog" aria-modal="true" aria-labelledby="acct-drawer-title" tabindex="-1">
      <div class="acct-drawer-head">
        <div style="min-width:0;flex:1">
          <div class="acct-drawer-title" id="acct-drawer-title"></div>
          <div class="acct-drawer-sub" id="acct-drawer-sub"></div>
        </div>
        <button class="btn btn-ghost btn-sm" onclick="App.closeAccountDrawer()" aria-label="关闭" data-i18n-aria-label="action.close">×</button>
      </div>
      <div class="acct-drawer-body" id="acct-drawer-body"></div>
      <div class="acct-drawer-foot" id="acct-drawer-foot"></div>
    </aside>
  </section>

  <!-- Add / login / import modal — the ONLY entry point for new credentials. -->
  <div class="modal-overlay hidden" id="add-account-overlay" role="presentation" onclick="if(event.target===this)App.closeAddAccountModal()">
    <div class="modal add-modal" role="dialog" aria-modal="true" aria-labelledby="add-modal-title">
      <div class="modal-header add-modal-head">
        <div>
          <!-- Title follows the active tab (set by App.setAddModalTitle), so the
               entry verb the user clicked is the one they land on. -->
          <div class="modal-title" id="add-modal-title"></div>
          <div class="modal-desc" data-i18n="accounts.add.desc">选择一种方式添加账号:登录、粘贴凭证、批量导入或从本机扫描</div>
        </div>
        <button class="btn btn-ghost btn-sm" onclick="App.closeAddAccountModal()" aria-label="关闭" data-i18n-aria-label="action.close">×</button>
      </div>
      <!-- SessionReauth: shown only when the modal was opened from an account
           row. Every submit then carries accountId, so the login REPLACES that
           account's token instead of adding a duplicate row. -->
      <div id="add-reauth-banner" class="reauth-notice hidden" role="status">
        <div class="reauth-notice-title" data-i18n="accounts.reauth.bannerTitle">需要重新授权</div>
        <div class="text-sm" id="add-reauth-banner-text"></div>
        <button class="btn btn-outline btn-sm" onclick="App.clearReauthBinding()" data-i18n="accounts.reauth.cancelBinding">取消绑定</button>
      </div>
      <div class="add-tabs" role="tablist" data-i18n-aria-label="accounts.add.tabsLabel" onkeydown="App.onAddTabKeydown(event)">
        <button class="add-tab active" role="tab" id="add-tab-login" aria-selected="true" aria-controls="add-panel-login" onclick="App.switchAddTab('login')" data-i18n="accounts.add.tab.login">登录</button>
        <button class="add-tab" role="tab" id="add-tab-paste" aria-selected="false" aria-controls="add-panel-paste" tabindex="-1" onclick="App.switchAddTab('paste')" data-i18n="accounts.add.tab.paste">粘贴凭证</button>
        <button class="add-tab" role="tab" id="add-tab-import" aria-selected="false" aria-controls="add-panel-import" tabindex="-1" onclick="App.switchAddTab('import')" data-i18n="accounts.add.tab.import">批量导入</button>
        <button class="add-tab" role="tab" id="add-tab-local" aria-selected="false" aria-controls="add-panel-local" tabindex="-1" onclick="App.switchAddTab('local')" data-i18n="accounts.add.tab.local">本机扫描</button>
      </div>
      <div class="add-modal-body">
        <div class="add-panel active" id="add-panel-login" role="tabpanel" aria-labelledby="add-tab-login">
          <!-- Step 1: choose ONE method. The proxy fields, the saved-password
               checkbox and the login history are under the advanced disclosure,
               so the first screen is two choices plus a secondary token link —
               not four stacked sections competing for attention. -->
          <div class="login-methods" id="wl-method-step" role="group" data-i18n-aria-label="accounts.login.methodsLabel">
            <button type="button" class="login-method" id="wl-method-oauth-btn" onclick="App.selectLoginMethod('oauth')">
              <span class="login-method-title" data-i18n="section.oauthLogin.title">Google / GitHub 登录</span>
              <span class="login-method-desc" data-i18n="section.oauthLogin.desc">在 Windsurf 官方页面完成登录,回到这里粘贴 Token。</span>
            </button>
            <button type="button" class="login-method" id="wl-method-email-btn" onclick="App.selectLoginMethod('email')">
              <span class="login-method-title" data-i18n="section.emailLogin.title">邮箱登录</span>
              <span class="login-method-desc" data-i18n="section.emailLogin.desc">有密码的账号用密码登录;没有密码的账号改用邮箱验证码。</span>
            </button>
            <button type="button" class="btn btn-ghost btn-sm login-method-token" id="wl-method-token-btn" onclick="App.selectLoginMethod('token')" data-i18n="accounts.login.pasteToken">粘贴 Token</button>
          </div>

          <div class="login-method-panel hidden" id="wl-panel-oauth">
            <div class="login-panel-head">
              <button type="button" class="btn btn-ghost btn-xs" onclick="App.selectLoginMethod(null)" data-i18n="accounts.login.back">换一种方式</button>
              <span class="login-panel-title" data-i18n="section.oauthLogin.title">Google / GitHub 登录</span>
            </div>
            <div class="section-body">
              <div style="display:flex;gap:12px;flex-wrap:wrap">
                <button class="btn oauth-brand-btn oauth-brand-google" onclick="App.oauthLogin('google')" id="oauth-google-btn">
                  <svg width="18" height="18" viewBox="0 0 48 48"><path fill="#FFC107" d="M43.6 20.1H42V20H24v8h11.3C33.9 33.1 29.3 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 13 4 4 13 4 24s9 20 20 20 20-9 20-20c0-1.3-.2-2.7-.4-3.9z"/><path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.5 15.5 18.8 12 24 12c3.1 0 5.8 1.2 8 3l5.7-5.7C34 6 29.3 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"/><path fill="#4CAF50" d="M24 44c5.2 0 9.9-2 13.4-5.2l-6.2-5.2C29.2 35.1 26.7 36 24 36c-5.2 0-9.6-3.6-11.2-8.5l-6.5 5C9.5 39.6 16.2 44 24 44z"/><path fill="#1976D2" d="M43.6 20.1H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.6l6.2 5.2C37 39.2 44 34 44 24c0-1.3-.2-2.7-.4-3.9z"/></svg>
                  <span data-i18n="oauth.google">Google 登录</span>
                </button>
                <button class="btn oauth-brand-btn oauth-brand-github" onclick="App.oauthLogin('github')" id="oauth-github-btn">
                  <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.3 3.44 9.8 8.2 11.38.6.11.82-.26.82-.58v-2.03c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.08-.74.08-.73.08-.73 1.2.08 1.84 1.23 1.84 1.23 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.3.76-1.6-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.12-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 016.02 0c2.28-1.55 3.29-1.23 3.29-1.23.66 1.66.25 2.88.12 3.18.77.84 1.24 1.91 1.24 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.82 1.1.82 2.22v3.29c0 .32.21.7.82.58C20.56 21.8 24 17.3 24 12 24 5.37 18.63 0 12 0z"/></svg>
                  <span data-i18n="oauth.github">GitHub 登录</span>
                </button>
              </div>
              <div id="oauth-status" aria-live="polite" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
              <!-- Three steps, each with its own action button. Step 2 fills the
                   box and imports; step 3 imports whatever is in the box. -->
              <div id="oauth-token-step" class="login-token-step hidden">
                <div style="font-weight:600;margin-bottom:6px" data-i18n="oauth.step2Title">粘贴 Windsurf 返回的 Token</div>
                <div style="font-size:12px;color:var(--text-muted);margin-bottom:10px" data-i18n="oauth.step2Desc">登录完成后,Windsurf 会显示一串 Token。复制它,或直接复制整条回调地址。</div>
                <ol class="login-steps">
                  <li>
                    <span data-i18n="oauth.step1">在新标签页完成登录(Google、GitHub 或邮箱验证码均可)。</span>
                    <button class="btn btn-outline btn-sm" onclick="App.fetchWindsurfToken()" id="oauth-fetch-token-btn" data-i18n="oauth.openSignin">打开 Windsurf 登录页</button>
                  </li>
                  <li>
                    <span data-i18n="oauth.step2">登录完成后页面会显示 Token;点“复制”或直接全选地址栏。</span>
                    <button class="btn btn-outline btn-sm" onclick="App.pasteOAuthTokenFromClipboard()" id="oauth-paste-btn" data-i18n="oauth.pasteFromClipboard">从剪贴板粘贴</button>
                  </li>
                  <li>
                    <span data-i18n="oauth.step3">回到这里,粘贴并添加;Token 会立即写入账号池。</span>
                    <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:6px">
                      <input id="oauth-token-input" type="text" class="input" style="flex:1;min-width:220px" data-i18n-placeholder="oauth.urlOrTokenPlaceholder" placeholder="粘贴回调 URL 或 Auth Token...">
                      <button class="btn btn-primary" onclick="App.submitOAuthToken()" id="oauth-import-btn" data-i18n="oauth.addWithToken">用 Token 添加</button>
                    </div>
                  </li>
                </ol>
                <a id="oauth-manual-link" href="#" class="hidden" onclick="return App.openWindsurfSignin(event)" style="display:inline-block;margin-top:8px;font-size:12px;color: var(--accent-text)" data-i18n="oauth.openManually">登录页没弹出来?点这里手动打开</a>
              </div>
            </div>
          </div>

          <div class="login-method-panel hidden" id="wl-panel-email">
            <div class="login-panel-head">
              <button type="button" class="btn btn-ghost btn-xs" onclick="App.selectLoginMethod(null)" data-i18n="accounts.login.back">换一种方式</button>
              <span class="login-panel-title" data-i18n="section.emailLogin.title">邮箱登录</span>
            </div>
            <div class="section-body">
              <div class="field-row">
                <div class="field">
                  <label class="field-label" data-i18n="field.email.label">邮箱</label>
                  <input id="wl-email" class="input" data-i18n-placeholder="field.email.placeholder" placeholder="your-email@example.com" autocomplete="off" onblur="App.windsurfProbeFromInput()" oninput="App.onWindsurfEmailInput()">
                  <div class="field-hint" id="wl-probe-status" aria-live="polite"></div>
                </div>
                <div class="field" id="wl-password-field">
                  <label class="field-label" data-i18n="field.password.label">密码</label>
                  <input id="wl-password" class="input" type="password" data-i18n-placeholder="field.password.placeholder" placeholder="••••••••" autocomplete="off" onkeydown="if(event.key==='Enter')App.windsurfLogin()">
                </div>
              </div>
              <div class="field-row-actions">
                <button class="btn btn-primary" onclick="App.windsurfLogin()" id="wl-btn">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" y1="12" x2="3" y2="12"/></svg>
                  <span data-i18n="action.login">登录</span>
                </button>
                <label class="checkbox" style="margin-left:auto">
                  <input type="checkbox" id="wl-auto-add" checked>
                  <span class="box"></span>
                  <span data-i18n="field.autoAdd">登录成功自动加入账号池</span>
                </label>
              </div>
              <!-- Email-code path: shown the moment the probe reports
                   has_password:false. The code is requested by the operator
                   (the Login / Send-code buttons), never auto-sent on blur. -->
              <div id="wl-email-code-field" class="hidden" style="margin-top:14px;padding-top:14px;border-top:1px dashed var(--border)">
                <div class="field-row">
                  <div class="field">
                    <label class="field-label" data-i18n="field.otpCode.label">验证码 / OTP Code</label>
                    <input id="wl-email-code" class="input" inputmode="numeric" autocomplete="one-time-code" maxlength="10" data-i18n-placeholder="field.otpCode.placeholder" placeholder="123456" onkeydown="if(event.key==='Enter')App.windsurfCompleteEmailCode()">
                    <div class="field-hint" data-i18n="field.otpCode.hint">从邮箱获取 6 位验证码</div>
                  </div>
                  <div class="field" style="align-self:flex-end">
                    <button class="btn btn-outline" onclick="App.windsurfSendEmailCode()" id="wl-send-code-btn">
                      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/></svg>
                      <span id="wl-send-code-label" data-i18n="action.sendCode">发送验证码</span>
                    </button>
                  </div>
                </div>
                <div class="field-row-actions">
                  <button class="btn btn-primary" onclick="App.windsurfCompleteEmailCode()" id="wl-code-login-btn" disabled>
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M20 6L9 17l-5-5"/></svg>
                    <span data-i18n="action.verifyAndLogin">验证并登录</span>
                  </button>
                  <button class="btn btn-ghost btn-sm" onclick="App.windsurfSendEmailCode({host:'devin'})" id="wl-devin-code-btn" data-i18n="emailLogin.tryDevinHost">没收到邮件?改用 app.devin.ai 发送</button>
                </div>
                <div id="wl-code-status" aria-live="polite" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>
              </div>
              <!-- OAuth-only path: has_password:false and no email connection. -->
              <div id="wl-oauth-hint" class="login-alt-panel hidden">
                <div class="text-sm" data-i18n="emailLogin.oauthOnlyHint">该账号没有密码,也不支持邮箱验证码。请用上面的 Google / GitHub 按钮登录,或粘贴 show-auth-token 页面的 Token。</div>
                <button class="btn btn-outline" style="margin-top:8px" onclick="App.showOAuthTokenStep()" data-i18n="emailLogin.useToken">改用 Token 登录</button>
              </div>
            </div>
          </div>

          <!-- Advanced: proxy, saved-password opt-in, login history. Closed by
               default so the first screen is the two methods above. -->
          <details class="login-advanced" id="wl-advanced">
            <summary data-i18n="accounts.login.advanced">高级选项(代理 / 登录历史 / 存储密码)</summary>
            <div class="login-advanced-body">
              <label class="checkbox" style="margin:8px 0 12px" data-i18n-title="field.storeCredentialHint" title="加密保存密码用于自动续期">
                <input type="checkbox" id="wl-store-cred">
                <span class="box"></span>
                <span data-i18n="field.storeCredential">加密保存密码以在会话失效时自动续期</span>
              </label>

          <div class="section" id="wl-proxy-section">
            <div class="section-header">
              <div>
                <div class="section-title" data-i18n="section.loginProxy.title">登录代理(可选)</div>
                <div class="section-desc" data-i18n="section.loginProxy.desc">为本次登录指定代理;留空则使用全局代理设置。代理生效后账号的后续聊天请求也会经此代理出站</div>
              </div>
            </div>
            <div class="section-body">
              <div class="field-row">
                <div class="field" style="max-width:120px">
                  <label class="field-label" data-i18n="field.type.label">类型</label>
                  <select id="wl-proxy-type" class="select">
                    <option value="http">HTTP</option>
                    <option value="https">HTTPS</option>
                    <option value="socks5">SOCKS5</option>
                  </select>
                </div>
                <div class="field" style="flex:2">
                  <label class="field-label" data-i18n="field.proxyHost.label">主机</label>
                  <input id="wl-proxy-host" class="input" data-i18n-placeholder="field.proxyHost.placeholder" placeholder="留空=使用全局">
                </div>
                <div class="field" style="max-width:110px">
                  <label class="field-label" data-i18n="field.port.label">端口</label>
                  <input id="wl-proxy-port" class="input" data-i18n-placeholder="field.port.placeholder" placeholder="8080" type="number">
                </div>
                <div class="field">
                  <label class="field-label" data-i18n="field.username.label">用户名</label>
                  <input id="wl-proxy-user" class="input" data-i18n-placeholder="field.username.placeholder" placeholder="可选">
                </div>
                <div class="field">
                  <label class="field-label" data-i18n="field.passwordProxy.label">密码</label>
                  <input id="wl-proxy-pass" class="input" type="password" data-i18n-placeholder="field.passwordProxy.placeholder" placeholder="可选">
                </div>
              </div>
              <div style="display:flex;gap:8px;align-items:center;margin-top:10px">
                <button class="btn btn-outline btn-sm" onclick="App.testLoginProxy()" id="wl-proxy-test-btn">
                  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M9 12l2 2 4-4"/><circle cx="12" cy="12" r="10"/></svg>
                  <span data-i18n="proxy.test.button">测试代理</span>
                </button>
                <span id="wl-proxy-test-result" class="text-sm text-muted"></span>
              </div>
            </div>
          </div>

          <div class="section">
            <div class="section-header">
              <div>
                <div class="section-title" data-i18n="section.loginHistory.title">登录历史</div>
                <div class="section-desc" data-i18n="section.loginHistory.desc">本地记录最近 50 条登录操作</div>
              </div>
            </div>
            <div class="section-body tight">
              <div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
                <table id="wl-history-table">
                  <thead><tr><th data-i18n="table.header.time">时间</th><th data-i18n="table.header.email">邮箱</th><th data-i18n="table.header.status">状态</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead>
                  <tbody></tbody>
                </table>
              </div>
            </div>
          </div>
            </div>
          </details>
        </div>

        <div class="add-panel" id="add-panel-paste" role="tabpanel" aria-labelledby="add-tab-paste">
          <div class="section">
            <div class="section-header">
              <div>
                <div class="section-title" data-i18n="section.addAccount.title">添加账号</div>
                <div class="section-desc" data-i18n="section.addAccount.desc">支持 API Key 或 Auth Token 两种方式</div>
              </div>
            </div>
            <div class="section-body">
              <div style="padding:8px 12px;margin-bottom:12px;background:var(--surface-2);border-radius:var(--radius);font-size:12px;color:var(--text-muted);line-height:1.6">
                <span data-i18n="help.tokenGuide">推荐用 Token 方式添加账号 点</span>
                <a href="https://windsurf.com/show-auth-token" target="_blank" style="color: var(--accent-text);font-weight:600">windsurf.com/show-auth-token</a>
                <span data-i18n="help.tokenGuideEnd">登录后复制 Token 粘贴到下面就行 所有登录方式(邮箱/Google/GitHub)都能用</span>
              </div>
              <div class="field-row">
                <div class="field" style="max-width:180px">
                  <label class="field-label" data-i18n="field.type.label">类型</label>
                  <select id="acc-type" class="select">
                    <option value="token" selected>Auth Token</option>
                    <option value="api_key">API Key</option>
                  </select>
                </div>
                <div class="field" style="flex:2">
                  <label class="field-label" data-i18n="field.key.label">Key / Token</label>
                  <input id="acc-key" class="input" data-i18n-placeholder="field.key.placeholder" placeholder="粘贴 Auth Token">
                </div>
                <div class="field" style="max-width:200px">
                  <label class="field-label" data-i18n="field.label.label">标签</label>
                  <input id="acc-label" class="input" data-i18n-placeholder="field.label.placeholder" placeholder="可选">
                </div>
              </div>
              <div class="field-row" style="margin-top:12px">
                <div class="field" style="flex:1">
                  <label class="field-label" data-i18n="field.proxy.label">代理</label>
                  <input id="acc-proxy" class="input" data-i18n-placeholder="field.proxy.placeholder" placeholder="http://proxy:8080 或 socks5://user:pass@host:port(可选)">
                </div>
              </div>
              <div class="field-hint" style="margin-top:4px" data-i18n="field.proxy.hint">留空则使用全局代理设置</div>
              <div class="field-row-actions">
                <button class="btn btn-primary" onclick="App.addAccount()" data-i18n="action.add">添加账号</button>
              </div>
            </div>
          </div>
        </div>

        <div class="add-panel" id="add-panel-import" role="tabpanel" aria-labelledby="add-tab-import">
          <div class="section">
            <div class="section-header">
              <div>
                <div class="section-title" data-i18n="section.smartImport.title">智能导入</div>
                <div class="section-desc" data-i18n="section.smartImport.desc">粘贴即识别,支持批量。自动识别 Session Token / show-auth-token 链接 / Firebase Refresh / 邮箱密码 / 标签配对,可混合。auth1_ 是中间票,不能当池密钥。</div>
              </div>
            </div>
            <div class="section-body">
              <textarea id="import-text-area" class="input" rows="5" style="width:100%;font-family:var(--mono);font-size:12px;resize:vertical" data-i18n-placeholder="section.smartImport.placeholder" placeholder="devin-session-token$...  或  show-auth-token 链接  或  email----password  每行一个,可混合"></textarea>
              <div style="margin-top:10px"><button class="btn btn-primary" onclick="App.importAccountsFromText()" data-i18n="section.smartImport.button">智能导入</button></div>
              <div id="import-text-result" style="margin-top:10px"></div>
            </div>
          </div>
          <div class="section">
            <div class="section-header">
              <div>
                <div class="section-title" data-i18n="field.batchInput.label">批量导入</div>
                <div class="section-desc" data-i18n="field.batchInput.hint">每行一个账号。支持:邮箱 密码、邮箱----密码、邮箱|密码、邮箱,密码、邮箱:密码。也支持上传 JSON 文件。代理可选,格式:[代理] 邮箱 密码。</div>
              </div>
            </div>
            <div class="section-body">
              <textarea id="wl-batch-input" class="textarea" rows="6" placeholder="user1@mail.com  pass123&#10;http://proxy:8080 user2@mail.com  pass456&#10;socks5://user:pass@1.2.3.4:1080 user3@mail.com  pass789"></textarea>
              <input id="wl-batch-file" type="file" accept=".json,application/json" class="hidden" onchange="App.handleWindsurfBatchFile(event)">
              <div class="field-row-actions">
                <button class="btn btn-outline" onclick="App.pasteWindsurfBatch()">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><rect x="8" y="2" width="8" height="4" rx="1" ry="1"/></svg>
                  <span data-i18n="action.readClipboard">读取剪贴板</span>
                </button>
                <button class="btn btn-outline" onclick="App.triggerWindsurfBatchFile()">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
                  <span data-i18n="action.uploadJson">上传 JSON</span>
                </button>
                <button class="btn btn-ghost hidden" id="wl-batch-file-clear" onclick="App.clearWindsurfBatchFile()">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
                  <span data-i18n="action.clearFile">清除文件</span>
                </button>
                <button class="btn btn-primary" onclick="App.windsurfLoginBatch()" id="wl-batch-btn">
                  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3 5h18"/><path d="M3 12h18"/><path d="M3 19h18"/></svg>
                  <span data-i18n="action.batchImport">批量导入并登录</span>
                </button>
              </div>
              <div class="field-hint" id="wl-batch-file-status" data-i18n="batch.fileNone">未选择 JSON 文件</div>
            </div>
          </div>
        </div>

        <div class="add-panel" id="add-panel-local" role="tabpanel" aria-labelledby="add-tab-local">
          <div class="section">
            <div class="section-header">
              <div>
                <div class="section-title" data-i18n="accounts.add.localTitle">从本机 Windsurf 导入</div>
                <div class="section-desc" data-i18n="accounts.add.localDesc">读取本机 Windsurf 客户端已登录的凭证(仅 loopback 访问)</div>
              </div>
            </div>
            <div class="section-body">
              <div class="field-row-actions">
                <button id="local-import-btn" class="btn btn-outline" onclick="App.discoverLocalWindsurf()" data-i18n="action.importLocal" data-i18n-title="action.importLocalTitle" title="从本地 Windsurf 客户端读取已登录的凭证(仅本机访问)">从本地 Windsurf 导入</button>
              </div>
              <div id="local-import-hint" style="margin-top:8px;color:var(--text-muted);font-size:12px;line-height:1.6"></div>
              <div id="local-windsurf-result" style="margin-top:12px"></div>
            </div>
          </div>
        </div>

        <div class="add-result hidden" id="wl-result"></div>
      </div>
    </div>
  </div>


  <!-- Models -->
  <section class="panel" id="p-settings">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.settings">全局设置</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.settings">跨浏览器共享的偏好与安全阈值,可自定义</div>
      </div>
      <div class="btn-group">
        <button class="btn btn-ghost btn-sm" onclick="App.loadSettings()" data-i18n-title="action.refresh" title="刷新">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
          <span data-i18n="action.refresh">刷新</span>
        </button>
      </div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.settings.ux.title">界面偏好</div>
          <div class="section-desc" data-i18n="section.settings.ux.desc">跨浏览器/设备共享(存于服务端)</div>
        </div>
      </div>
      <div class="section-body">
        <label class="checkbox">
          <input type="checkbox" id="set-oauth-skip-chooser" onchange="App.savePref('oauthSkipChooser', this.checked)">
          <span class="box"></span>
          <span data-i18n="settings.oauthSkipChooser">Google/GitHub 登录时跳过"打开/复制"选择弹窗,直接打开登录页</span>
        </label>
      </div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.settings.security.title">安全与锁定</div>
          <div class="section-desc" data-i18n="section.settings.security.desc">登录失败锁定阈值与时长,可调整或关闭</div>
        </div>
      </div>
      <div class="section-body" id="set-security-body">
        <div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div>
      </div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.settings.runtimeEnv.title">运行时后端</div>
          <div class="section-desc" data-i18n="section.settings.runtimeEnv.desc">这些开关可在本页直接切换并写入 runtime-config.json,立即生效、无需重启;未手动切换过的项跟随部署时的环境变量</div>
        </div>
      </div>
      <div class="section-body" id="set-runtime-env-body">
        <div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div>
      </div>
    </div>

    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.settings.serviceControl.title">服务控制</div>
          <div class="section-desc" data-i18n="section.settings.serviceControl.desc">重启网关进程,让需要重启才生效的配置生效或从卡死中恢复。重启期间会先排空在途请求(最多约 10 秒),再由进程守护(systemd / PM2 / docker)自动拉起。</div>
        </div>
      </div>
      <div class="section-body">
        <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
          <button class="btn btn-outline warn" id="btn-restart-service" onclick="App.restartService()" data-i18n="action.restartService">重启服务</button>
          <span class="text-sm text-muted" id="restart-status"></span>
        </div>
      </div>
    </div>

    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.systemPrompts.title">系统提示词</div>
          <div class="section-desc" data-i18n="section.systemPrompts.desc">工具注入、对话模式等内置提示词模板。修改后立即生效,点"重置"恢复默认。</div>
        </div>
      </div>
      <div class="section-body" id="system-prompts-editor" style="display:grid;gap:14px"></div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.credentials.title">凭证管理</div>
          <div class="section-desc" data-i18n="section.credentials.desc">运行时改 API_KEY 和 Dashboard 密码,写入 runtime-config.json 立即生效,不用重启容器或重读 .env。改完之后下一次请求需要用新值;当前会话会要求重新登录。</div>
        </div>
      </div>
      <div class="section-body" style="display:grid;gap:14px">
        <div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px">
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            <strong data-i18n="credentials.apiKey">API_KEY</strong>
            <span class="text-sm text-muted" id="credentials-apikey-source"></span>
            <span class="text-sm" style="font-family:monospace" id="credentials-apikey-masked"></span>
          </div>
          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <input type="password" class="input" id="credentials-apikey-input" data-i18n-placeholder="credentials.apiKeyPlaceholder" placeholder="新 API_KEY(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
            <button class="btn btn-outline btn-sm" onclick="App.toggleApiKeyVisibility()" data-i18n="credentials.show">显示/隐藏</button>
            <button class="btn btn-primary btn-sm" onclick="App.saveCredential('apiKey')" data-i18n="credentials.save">保存</button>
          </div>
          <div class="text-sm text-muted" data-i18n="credentials.apiKeyHint">改完后所有 chat 客户端要换成新 KEY;旧 KEY 立即失效。</div>
        </div>
        <div style="padding:12px;background:var(--surface-2);border-radius:var(--radius);display:grid;gap:8px">
          <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
            <strong data-i18n="credentials.dashboardPassword">Dashboard 密码</strong>
            <span class="text-sm text-muted" id="credentials-dashboardpw-source"></span>
            <span class="text-sm" id="credentials-dashboardpw-status"></span>
          </div>
          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
            <input type="password" class="input" id="credentials-dashboardpw-input" data-i18n-placeholder="credentials.dashboardPwPlaceholder" placeholder="新 Dashboard 密码(≥ 8 字符;留空清除运行时覆盖)" style="flex:1;min-width:240px">
            <button class="btn btn-outline btn-sm" onclick="App.toggleDashboardPwVisibility()" data-i18n="credentials.show">显示/隐藏</button>
            <button class="btn btn-primary btn-sm" onclick="App.saveCredential('dashboardPassword')" data-i18n="credentials.save">保存</button>
          </div>
          <div class="text-sm text-muted" data-i18n="credentials.dashboardPwHint">用 scrypt 派生哈希存盘;改完之后会自动登出当前会话,需要用新密码重登。</div>
        </div>
        <div class="text-sm text-muted" data-i18n="credentials.bruteForceHint">为防暴破:同一 IP 连续 5 次 dashboard 登录失败会被锁 30 分钟(2.0.56 起)。</div>
      </div>
    </div>

    <div class="section" style="margin-top:24px">
      <div class="section-header"><div>
        <div class="section-title"><span data-i18n="section.settings.breaker.title">熔断与限流</span><span data-i18n-tip="circuitBreaker"></span></div>
        <div class="section-desc" data-i18n="section.settings.breaker.desc">账号错误熔断与自愈退避阈值,可热调,空则回退环境变量/默认</div>
      </div></div>
      <div class="section-body" id="set-breaker-body">
        <div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div>
      </div>
    </div>
  </section>

  <section class="panel" id="p-models">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.models">模型控制</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.models">配置模型访问策略,限制可用模型范围</div>
      </div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.modelPolicy.title">访问策略</div>
          <div class="section-desc" data-i18n="section.modelPolicy.desc">选择"全部允许"不限制;"白名单"只允许列出的模型;"黑名单"屏蔽列出的模型</div>
        </div>
      </div>
      <div class="section-body">
        <div class="segmented" id="model-mode-group">
          <label><input type="radio" name="model-mode" value="all" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.all">全部允许</span></label>
          <label><input type="radio" name="model-mode" value="allowlist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.allowlist">白名单</span></label>
          <label><input type="radio" name="model-mode" value="blocklist" onchange="App.setModelMode(this.value)"><span data-i18n="model.mode.blocklist">黑名单</span></label>
        </div>
        <div class="field hidden mt-4" id="model-default-field">
          <label class="field-label" data-i18n="field.defaultModel.label">默认模型</label>
          <input id="model-default" class="input" data-i18n-placeholder="field.defaultModel.placeholder" placeholder="留空则拒绝清单外请求" onchange="App.setDefaultModel(this.value)">
          <div class="field-hint" data-i18n="field.defaultModel.hint">清单外的模型请求会改用此模型,而不是被拒绝。留空表示拒绝(默认)。</div>
        </div>
      </div>
    </div>

    <div class="section hidden" id="model-list-section">
      <div class="section-header">
        <div>
          <div class="section-title" id="model-list-title" data-i18n="section.modelList.title">模型清单</div>
          <div class="section-desc" id="model-list-hint"></div>
        </div>
      </div>
      <div class="section-body">
        <div class="field-row">
          <div class="field">
            <label class="field-label" data-i18n="field.searchModel.label">搜索模型</label>
            <input id="model-search" class="input" type="search" data-i18n-placeholder="field.searchModel.placeholder" placeholder="输入模型名称筛选..." oninput="App.filterModels()">
          </div>
          <div class="field" style="max-width:220px">
            <label class="field-label" data-i18n="field.provider.label">供应商</label>
            <select id="model-provider-filter" class="select" onchange="App.filterModels()">
              <option value="" data-i18n="field.provider.all">全部供应商</option>
              <option value="anthropic">Anthropic</option>
              <option value="openai">OpenAI</option>
              <option value="google">Google</option>
              <option value="deepseek">DeepSeek</option>
              <option value="xai">xAI</option>
              <option value="alibaba">Alibaba</option>
              <option value="moonshot">Moonshot</option>
              <option value="windsurf">Windsurf</option>
            </select>
          </div>
        </div>
        <div id="model-list-current" class="mt-4"></div>
        <div id="model-chips-container" class="mt-4"></div>
      </div>
    </div>
  </section>

  <!-- Proxy -->
  <section class="panel" id="p-proxy">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.proxy">代理配置</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.proxy">全局或按账号配置出口代理,独立代理将启动独立的语言服务器实例</div>
      </div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.globalProxy.title">全局代理</div>
          <div class="section-desc" data-i18n="section.globalProxy.desc">未配置独立代理的账号将使用此配置</div>
        </div>
      </div>
      <div class="section-body">
        <div class="field-row">
          <div class="field" style="max-width:140px">
            <label class="field-label" data-i18n="field.type.label">类型</label>
            <select id="proxy-type" class="select">
              <option value="http">HTTP</option>
              <option value="https">HTTPS</option>
              <option value="socks5">SOCKS5</option>
            </select>
          </div>
          <div class="field">
            <label class="field-label" data-i18n="field.host.label">主机</label>
            <input id="proxy-host" class="input" data-i18n-placeholder="field.host.placeholder" placeholder="proxy.example.com">
          </div>
          <div class="field" style="max-width:140px">
            <label class="field-label" data-i18n="field.port.label">端口</label>
            <input id="proxy-port" class="input" type="number" data-i18n-placeholder="field.port.placeholder" placeholder="8080">
          </div>
        </div>
        <div class="field-row mt-3">
          <div class="field">
            <label class="field-label" data-i18n="field.username.label">用户名</label>
            <input id="proxy-user" class="input" data-i18n-placeholder="field.proxyUser.placeholder" placeholder="代理认证用户名">
          </div>
          <div class="field">
            <label class="field-label" data-i18n="field.passwordProxy.label">密码</label>
            <div class="input-group">
              <input id="proxy-pass" class="input" type="password" data-i18n-placeholder="field.proxyPass.placeholder" placeholder="代理认证密码">
              <button class="btn btn-outline btn-icon" onclick="const el=document.getElementById('proxy-pass');el.type=el.type==='password'?'text':'password'" data-i18n-title="action.showPassword" title="显示/隐藏密码">
                <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
              </button>
            </div>
          </div>
        </div>
        <div class="field-row-actions">
          <button class="btn btn-primary" onclick="App.saveGlobalProxy()" data-i18n="action.save">保存</button>
          <button class="btn btn-outline" onclick="App.clearGlobalProxy()" data-i18n="action.clear">清除</button>
          <span id="proxy-current" class="text-sm text-muted" style="margin-left:auto;align-self:center"></span>
        </div>
      </div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.accountProxy.title">账号独立代理</div>
          <div class="section-desc" data-i18n="section.accountProxy.desc">为特定账号设置专属代理;每个独立代理会启动独立 LS 实例</div>
        </div>
      </div>
      <div class="section-body tight">
        <div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
          <table id="proxy-accounts-table">
            <thead><tr><th data-i18n="table.header.account">账号</th><th data-i18n="table.header.proxy">代理</th><th data-i18n="table.header.action">操作</th></tr></thead>
            <tbody></tbody>
          </table>
        </div>
        <div class="table-pagination" id="proxy-accounts-pagination">
          <button class="btn btn-ghost btn-xs" id="proxy-accounts-prev" onclick="App.setProxyPage(App.proxyPage - 1)" data-i18n="action.prev">上一页</button>
          <span class="page-label" id="proxy-accounts-page-label">-</span>
          <select class="select" id="proxy-accounts-page-size" onchange="App.setProxyPageSize(this.value)">
            <option value="25">25</option>
            <option value="50">50</option>
            <option value="100">100</option>
            <option value="200">200</option>
          </select>
          <button class="btn btn-ghost btn-xs" id="proxy-accounts-next" onclick="App.setProxyPage(App.proxyPage + 1)" data-i18n="action.next">下一页</button>
        </div>
      </div>
    </div>
  </section>

  <!-- Logs -->
  <section class="panel" id="p-logs">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.logs">运行日志</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.logs">通过 SSE 实时流式接收服务端日志</div>
      </div>
    </div>
    <div class="log-controls">
      <select id="log-level" class="select" style="width:130px" onchange="App.filterLogs()">
        <option value="" data-i18n="log.level.all">所有级别</option>
        <option value="debug">Debug</option>
        <option value="info">Info</option>
        <option value="warn">Warn</option>
        <option value="error">Error</option>
      </select>
      <input id="log-search" class="input search" type="search" data-i18n-placeholder="log.searchPlaceholder" placeholder="搜索日志内容..." oninput="App.debouncedFilterLogs()">
      <label class="checkbox">
        <input type="checkbox" id="log-autoscroll" checked>
        <span class="box"></span>
        <span data-i18n="field.autoScroll">自动滚动</span>
      </label>
      <button class="btn btn-outline btn-sm" onclick="App.clearLogView()" data-i18n="log.clearView">清空视图</button>
      <!-- v2.0.60 — log export. Type select drives which slice to dump
           (api requests vs system housekeeping vs both); format selects
           machine-readable jsonl vs human-readable txt. -->
      <select id="log-export-type" class="select" style="width:130px">
        <option value="all" data-i18n="log.export.all">全部日志</option>
        <option value="api" data-i18n="log.export.api">API 请求日志</option>
        <option value="system" data-i18n="log.export.system">系统运行日志</option>
      </select>
      <select id="log-export-format" class="select" style="width:120px">
        <option value="jsonl">JSONL</option>
        <option value="txt" data-i18n="log.export.txt">纯文本</option>
      </select>
      <button class="btn btn-primary btn-sm" onclick="App.exportLogs()" data-i18n="log.export.btn">下载日志</button>
    </div>
    <div class="log-container" id="log-container"></div>
  </section>

  <!-- ACU & usage (Q3) -->
  <section class="panel" id="p-usage">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.usage">用量</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.usage">ACU 是 Devin 的用量单位;这里先回答本周期还剩多少,再看已用、每日与按产品的消耗。同一工作区的多个账号只显示一次。</div>
      </div>
      <div class="btn-group">
        <button class="btn btn-ghost btn-sm" onclick="App.loadUsage(true)" data-i18n-title="action.refresh" title="刷新">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
          <span data-i18n="action.refresh">刷新</span>
        </button>
      </div>
    </div>
    <div id="usage-org-bar" class="section usage-org-bar hidden"></div>
    <div id="usage-content"></div>
  </section>

  <!-- DevIn panel: one screen for the Devin surface the proxy can actually read.
       The session/catalog/quota/settings split mirrors the four questions an
       operator asks during an incident. Anything the proxy cannot observe is
       labelled with a reason (never rendered as a zero or an empty list). -->
  <section class="panel" id="p-devin">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.devin">Devin 面板</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.devin">会话与轨迹、模型目录、用量配额、运行时设置。只显示代理真实读到的数据;读不到的项标明原因,不用 0 冒充。</div>
      </div>
      <div class="btn-group">
        <button class="btn btn-ghost btn-sm" onclick="App.loadDevin(true)" data-i18n-title="action.refresh" title="刷新">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
          <span data-i18n="action.refresh">刷新</span>
        </button>
      </div>
    </div>

    <div class="metrics-grid" id="devin-kpis"></div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.devin.runtime.title">运行时</div>
          <div class="section-desc" data-i18n="section.devin.runtime.desc">语言服务器进程、准入计数与断连续传缓存。全部来自本进程状态,打开页面不产生上游调用。</div>
        </div>
      </div>
      <div class="section-body" id="devin-runtime"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.devin.sessions.title">会话与轨迹</div>
          <div class="section-desc" data-i18n="section.devin.sessions.desc">代理侧已计费轮次(按账号去重合并)。Cascade 轨迹与 ACP 会话不在代理可见范围内,原因写在来源表里。</div>
        </div>
      </div>
      <div class="section-body" id="devin-sessions"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.devin.catalog.title">模型目录与状态</div>
          <div class="section-desc" data-i18n="section.devin.catalog.desc">本地可达性、免费/计费三态与实时目录差异。上游权限(entitlement)需要点“上游对账”,那一步会产生上游调用。</div>
        </div>
        <div class="btn-group">
          <button class="btn btn-outline btn-sm" id="devin-reconcile-btn" onclick="App.loadDevinReconcile()" data-i18n="devin.catalog.reconcile.run">上游对账</button>
        </div>
      </div>
      <div class="section-body" id="devin-catalog"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.devin.quota.title">用量与配额</div>
          <div class="section-desc" data-i18n="section.devin.quota.desc">干旱判定与阈值来自本地缓存;工作区 ACU 需要点“读取 ACU”,冷缓存时会读一次上游。</div>
        </div>
        <div class="btn-group">
          <button class="btn btn-ghost btn-sm" onclick="App.navigate('usage')" data-i18n="devin.quota.acu.full">完整用量</button>
          <button class="btn btn-outline btn-sm" id="devin-acu-btn" onclick="App.loadDevinAcu()" data-i18n="devin.quota.acu.load">读取 ACU</button>
        </div>
      </div>
      <div class="section-body" id="devin-quota"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
    </div>

    <div class="section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.devin.settings.title">设置</div>
          <div class="section-desc" data-i18n="section.devin.settings.desc">Devin 相关开关的实际生效值与来源;写入入口在全局设置页。配额/按量策略可直接在此保存。</div>
        </div>
        <div class="btn-group">
          <button class="btn btn-ghost btn-sm" onclick="App.navigate('settings')" data-i18n="devin.settings.open">打开全局设置</button>
        </div>
      </div>
      <div class="section-body" id="devin-settings"><div class="text-sm text-muted" data-i18n="settings.loading">加载中…</div></div>
    </div>
  </section>

  <!-- Stats -->
  <section class="panel" id="p-stats">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.stats">统计分析</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.stats">请求量、成功率、延迟分位数与账号/模型维度统计</div>
      </div>
      <div class="btn-group">
        <button class="btn btn-ghost btn-sm" onclick="App.loadStats()" data-i18n-title="action.refresh" title="刷新">
          <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
          <span data-i18n="action.refresh">刷新</span>
        </button>
        <button class="btn btn-danger btn-sm" onclick="App.resetStats()" data-i18n="action.reset">重置统计</button>
      </div>
    </div>
    <div class="metrics-grid" id="stats-cards"></div>
    <div class="chart chart-main">
      <div class="chart-header">
        <div class="chart-title-group">
          <h3 data-i18n="section.requestChart.title">请求量时间序列</h3>
          <div class="chart-subtitle" data-i18n="section.requestChart.subtitle">切换图表类型和时间范围 · 悬停查看具体数值</div>
        </div>
        <div class="chart-controls">
          <div class="seg-group" role="radiogroup" data-i18n-aria-label="aria.chartTypeGroup" aria-label="图表类型">
            <button class="seg-btn stats-type-btn" role="radio" aria-checked="false" data-type="area" onclick="App.setChartType('area')" data-i18n="section.requestChart.typeArea">面积图</button>
            <button class="seg-btn stats-type-btn" role="radio" aria-checked="false" data-type="line" onclick="App.setChartType('line')" data-i18n="section.requestChart.typeLine">折线图</button>
            <button class="seg-btn stats-type-btn active" role="radio" aria-checked="true" data-type="bar" onclick="App.setChartType('bar')" data-i18n="section.requestChart.typeBar">柱状图</button>
            <button class="seg-btn stats-type-btn" role="radio" aria-checked="false" data-type="stacked" onclick="App.setChartType('stacked')" data-i18n="section.requestChart.typeStacked">堆叠图</button>
          </div>
          <div class="seg-group" role="radiogroup" data-i18n-aria-label="aria.statsRangeGroup" aria-label="统计时间范围">
            <button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="6" onclick="App.setStatsRange(6)" data-i18n="range.6h">6小时</button>
            <button class="seg-btn stats-range-btn active" role="radio" aria-checked="true" data-range="24" onclick="App.setStatsRange(24)" data-i18n="range.24h">24小时</button>
            <button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="168" onclick="App.setStatsRange(168)" data-i18n="range.7d">7天</button>
            <button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="720" onclick="App.setStatsRange(720)" data-i18n="range.30d">30天</button>
            <button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="all" onclick="App.setStatsRange('all')" data-i18n="range.all">全部</button>
            <button class="seg-btn stats-range-btn" role="radio" aria-checked="false" data-range="custom" onclick="App.openCustomRange()" data-i18n="range.custom">自定义</button>
          </div>
        </div>
      </div>
      <div class="chart-canvas-wrap">
        <canvas id="stats-canvas" role="img" data-i18n-aria-label="aria.statsChart" aria-label="请求量时间序列图"></canvas>
        <div class="chart-tooltip" id="stats-tooltip"></div>
        <div class="chart-range-indicator" id="stats-range-indicator"></div>
      </div>
      <div class="custom-range-popup" id="custom-range-popup" style="display:none">
        <div class="custom-range-title" data-i18n="range.customTitle">自定义日期范围</div>
        <div class="custom-range-fields">
          <label>
            <span data-i18n="range.customStart">起始日期</span>
            <input type="date" id="range-start" class="input">
          </label>
          <label>
            <span data-i18n="range.customEnd">结束日期</span>
            <input type="date" id="range-end" class="input">
          </label>
        </div>
        <div class="custom-range-hint" data-i18n="range.customHint">最多显示最近 30 天数据</div>
        <div class="custom-range-actions">
          <button class="btn btn-ghost btn-sm" onclick="App.closeCustomRange()" data-i18n="range.customCancel">取消</button>
          <button class="btn btn-primary btn-sm" onclick="App.applyCustomRange()" data-i18n="range.customApply">应用</button>
        </div>
      </div>
      <div class="chart-legend" id="stats-legend"></div>
    </div>
    <div class="section" id="stats-log-section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.statsLog.title">请求日志</div>
          <div class="section-desc" data-i18n="section.statsLog.desc">逐请求明细:时间、模型、账号、状态、延迟、Token 与费用。每次上游尝试一行,费用单位为 Devin credits。</div>
        </div>
        <div class="stats-log-meta">
          <span id="stats-log-summary" class="text-sm text-muted"></span>
          <button class="btn btn-ghost btn-xs" onclick="App.loadStatsLog()" data-i18n-title="action.refresh" title="刷新">
            <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M21 12a9 9 0 0 1-9 9 9 9 0 0 1-6.24-2.52L3 21"/><path d="M3 12a9 9 0 0 1 9-9 9 9 0 0 1 6.24 2.52L21 3"/><path d="M21 3v6h-6"/><path d="M3 21v-6h6"/></svg>
            <span data-i18n="action.refresh">刷新</span>
          </button>
        </div>
      </div>
      <div class="stats-filter-bar">
        <label><span data-i18n="section.statsLog.filters.model">模型</span>
          <select class="select" id="stats-log-model" onchange="App.setStatsLogFilter('model', this.value)"></select>
        </label>
        <label><span data-i18n="section.statsLog.filters.account">账号</span>
          <select class="select" id="stats-log-account" onchange="App.setStatsLogFilter('account', this.value)"></select>
        </label>
        <label><span data-i18n="section.statsLog.filters.status">状态</span>
          <select class="select" id="stats-log-status" onchange="App.setStatsLogFilter('status', this.value)"></select>
        </label>
        <button class="btn btn-ghost btn-xs" onclick="App.resetStatsLogFilters()" data-i18n="section.statsLog.filters.reset">清除筛选</button>
        <span class="text-sm text-muted" id="stats-log-retention"></span>
      </div>
      <div class="section-body tight">
        <div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
          <table id="stats-log-table" aria-label="请求日志表格" data-i18n-aria-label="aria.statsLog">
            <thead><tr>
              <th data-i18n="table.header.time">时间</th>
              <th data-i18n="table.header.model">模型</th>
              <th data-i18n="table.header.account">账号</th>
              <th data-i18n="table.header.status">状态</th>
              <th class="numeric" data-i18n="table.header.duration">耗时</th>
              <th class="numeric" data-i18n="table.header.tokensIn">输入 Token</th>
              <th class="numeric" data-i18n="table.header.tokensOut">输出 Token</th>
              <th class="numeric" data-i18n="table.header.cost">费用 (credits)</th>
            </tr></thead>
            <tbody id="stats-log-body">
              <tr class="empty-row"><td colspan="8" data-i18n="table.empty.noRequestData">暂无请求数据</td></tr>
            </tbody>
          </table>
        </div>
        <div class="table-pagination" id="stats-log-pagination">
          <button class="btn btn-ghost btn-xs" id="stats-log-prev" onclick="App.setStatsLogPage(App.statsLogPage - 1)" data-i18n="action.prev">上一页</button>
          <span class="page-label" id="stats-log-page-label">-</span>
          <select class="select" id="stats-log-page-size" onchange="App.setStatsLogPageSize(this.value)">
            <option value="25">25</option>
            <option value="50">50</option>
            <option value="100">100</option>
            <option value="200">200</option>
          </select>
          <button class="btn btn-ghost btn-xs" id="stats-log-next" onclick="App.setStatsLogPage(App.statsLogPage + 1)" data-i18n="action.next">下一页</button>
        </div>
      </div>
    </div>
    <div class="section" id="stats-agg-section">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.statsAgg.title">聚合统计</div>
          <div class="section-desc" data-i18n="section.statsAgg.desc">与请求日志同一筛选窗口;表内合计与日志共用同一分母,不做估算。</div>
        </div>
        <div class="seg-group" role="radiogroup" aria-label="聚合维度" data-i18n-aria-label="aria.statsAggGroup">
          <button class="seg-btn stats-agg-tab active" role="radio" aria-checked="true" data-agg="model" onclick="App.setStatsAggTab('model')" data-i18n="section.statsAgg.byModel">按模型</button>
          <button class="seg-btn stats-agg-tab" role="radio" aria-checked="false" data-agg="account" onclick="App.setStatsAggTab('account')" data-i18n="section.statsAgg.byAccount">按账号</button>
          <button class="seg-btn stats-agg-tab" role="radio" aria-checked="false" data-agg="day" onclick="App.setStatsAggTab('day')" data-i18n="section.statsAgg.byDay">按天</button>
        </div>
      </div>
      <div class="section-body tight">
        <div class="table-wrap" tabindex="0" role="region" aria-label="可滚动表格" data-i18n-aria-label="aria.scrollableTable">
          <table id="stats-agg-table" class="stats-agg-table">
            <thead id="stats-agg-head"></thead>
            <tbody id="stats-agg-body"></tbody>
          </table>
        </div>
        <div id="stats-agg-note" class="text-sm text-muted" style="margin-top:8px" hidden></div>
      </div>
    </div>
  </section>

  <section class="panel" id="p-credits">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.credits">致谢</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.credits">感谢以下朋友为项目提交 PR / 审计代码 / 修复问题</div>
      </div>
    </div>
    <div class="section">
      <div class="section-header">
        <div class="section-title" data-i18n="credits.contributors">核心贡献者</div>
      </div>
      <div class="section-body" id="credits-body">
        <div class="text-sm text-dim" data-i18n="status.loading">加载中…</div>
      </div>
    </div>
    <div class="section">
      <div class="section-header">
        <div class="section-title" data-i18n="credits.cta.title">想加入这份名单?</div>
      </div>
      <div class="section-body">
        <div class="section-desc" data-i18n="credits.cta.desc">欢迎到 GitHub 提 issue 或 pull request — 不管是修 bug、加模型、改 UI 还是发现安全问题,都会被记录在这里。</div>
        <div style="margin-top:12px">
          <a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/issues">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
            <span data-i18n="credits.cta.issue">提 issue</span>
          </a>
          <a class="btn btn-outline btn-sm" target="_blank" rel="noopener" href="https://github.com/dwgx/WindsurfAPI/pulls" style="margin-left:8px">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" style="width:14px;height:14px;margin-right:6px"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M13 6h3a2 2 0 0 1 2 2v7"/><line x1="6" y1="9" x2="6" y2="21"/></svg>
            <span data-i18n="credits.cta.pr">提 PR</span>
          </a>
        </div>
      </div>
    </div>
  </section>

  <section class="panel" id="p-experimental">
    <div class="page-header">
      <div>
        <h1 class="page-title" data-i18n="page.experimental">实验性功能</h1>
        <div class="page-subtitle" data-i18n="pageSubtitle.experimental">尚未稳定的优化项;如果发现异常可以随时关闭</div>
      </div>
    </div>
    <div class="section">
      <div class="section-header">
        <div class="section-title" data-i18n="section.cascadeReuse.title">Cascade 对话复用</div>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.cascadeReuse.desc">多轮对话时复用同一个 cascade_id,只把最新一条 user 消息发给 Windsurf,让服务端维持上下文缓存。命中时可显著降低 TTFB 与上传体积;未命中或账号/LS 变更会自动回退到新会话。需要客户端保留完整历史并按顺序追加(如 new-api、OpenWebUI)。</div>
        <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-cascade-reuse" onchange="App.toggleExperimental('cascadeConversationReuse', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.cascadeReuse.enable">启用 Cascade 对话复用</div>
            <div class="text-sm text-muted" data-i18n="section.cascadeReuse.hint">默认开启。关闭后当前对话池立即停止复用。</div>
          </div>
        </div>
        <div class="metrics-grid" id="exp-pool-cards"></div>
        <div style="margin-top:12px">
          <button class="btn btn-outline btn-sm" onclick="App.clearConversationPool()" data-i18n="action.clearPool">清空对话池</button>
          <button class="btn btn-ghost btn-sm" onclick="App.loadExperimental()" data-i18n="action.refresh">刷新</button>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.droughtRestrict.title">配额低水位时屏蔽 premium 模型</div>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.droughtRestrict.desc">所有账号本周配额都低于 5% 时,对 premium 模型(claude-opus / claude-sonnet 等)请求直接返 503,避免上游 rate-limit 烧最后一点配额。免费层模型(gemini-2.5-flash 等)保持可用。关闭后即使在 drought mode 也照常下发,accept 上游可能立即 429。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-drought-restrict" onchange="App.toggleExperimental('droughtRestrictPremium', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.droughtRestrict.enable">启用 drought-mode premium 屏蔽</div>
            <div class="text-sm text-muted" data-i18n="section.droughtRestrict.hint">默认开启。drought 状态由账号池自动判定(GET /dashboard/api/drought 看实时值)。</div>
          </div>
        </div>
        <div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
          <label class="field-label" style="margin:0" for="exp-drought-threshold" data-i18n="section.droughtRestrict.thresholdLabel">Drought 阈值(本周配额 %)</label>
          <input type="number" id="exp-drought-threshold" class="input" min="0" max="100" step="1" style="max-width:110px" onchange="App.saveDroughtThreshold(this.value)">
          <span class="text-sm text-muted" data-i18n="section.droughtRestrict.thresholdHint">账号本周配额低于此值即计入 drought</span>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.stickyByUser.title">粘性会话按用户绑定</div>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.stickyByUser.desc">开启后,同一 user 无论请求什么模型都会固定绑定到同一个上游账号。关闭时按 (user, model) 二元组分别绑定(不同模型可能分配不同账号,适合账号有模型限制的场景)。需启用 STICKY_SESSION_ENABLED=1 才生效。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-sticky-by-user" onchange="App.toggleExperimental('stickyBindByUserOnly', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.stickyByUser.enable">忽略模型维度</div>
            <div class="text-sm text-muted" data-i18n="section.stickyByUser.hint">默认关闭。开启后 user_a 下的 opus/haiku/gemini 全部共享同一个绑定。</div>
          </div>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.stickyNoFallback.title">粘性会话禁止回退</div>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.stickyNoFallback.desc">开启后,粘性会话绑定的账号如果遇到限流/错误/模型不可用,<strong>不会</strong>自动切换到账号池中的其他账号,而是直接将错误返回给客户端。这样可以确保每个 user 严格只消耗自己绑定账号的额度。需启用 STICKY_SESSION_ENABLED=1 才生效。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-sticky-no-fallback" onchange="App.toggleExperimental('stickyNoFallback', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.stickyNoFallback.enable">禁止账号回退</div>
            <div class="text-sm text-muted" data-i18n="section.stickyNoFallback.hint">默认关闭。开启后绑定账号失败时不会切换到其他账号消耗额度。</div>
          </div>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.nativeToolCall.title">原生 tool_call</div>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.nativeToolCall.desc">开启后工具定义走原生 protobuf 线协议(已标定的 #10 ToolDef + #6 ChatToolCall),而非提示词模拟(<tool_call> 文本标记)。原生路对弱模型(fable)多工具更稳、不受提示词模拟的空回复限制。关闭则回退到提示词模拟(当前默认稳定路径)。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-native-toolcall" onchange="App.toggleExperimental('nativeToolCall', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.nativeToolCall.enable">启用原生 tool_call</div>
            <div class="text-sm text-muted" data-i18n="section.nativeToolCall.hint">默认开启(runtime-config.js 的 experimental.nativeToolCall)。关闭则回退到提示词模拟。</div>
          </div>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.clineCompat.title">Cline 兼容模式</div>
        <span class="badge badge-success" id="cline-compat-badge" style="display:none" data-i18n="section.clineCompat.badgeOn">Cline 兼容 ON</span>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.clineCompat.desc">为 Cline 插件做原生级兼容。Cline 的 OpenAI Compatible provider 底层是 @ai-sdk/openai-compatible,对 tool_call 的 arguments 有 isParsableJson 门槛——参数串不是合法 JSON 就静默丢弃这次工具调用(Claude 对无参数工具会发空串)。开启后,识别为 Cline 的请求(按 User-Agent)在标准 /v1 端点会自动套用兼容垫片(tool_call 参数归一为合法 JSON)。专用端点 /v1/cline/*(如 /v1/cline/chat/completions)无论此开关是否开启都始终生效——命名空间本身就是显式选择。默认关闭,标准 /v1 对其他客户端保持字节等价。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-cline-compat" onchange="App.toggleExperimental('clineCompat', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.clineCompat.enable">启用 Cline 特征识别兼容</div>
            <div class="text-sm text-muted" data-i18n="section.clineCompat.hint">默认关闭。开启后按 User-Agent 识别 Cline 请求自动套兼容层;/v1/cline/* 专用端点不受此开关约束。</div>
          </div>
        </div>
        <div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
          <span class="text-sm text-muted" data-i18n="section.clineCompat.repairLabel">已修复 tool_call 参数次数</span>
          <span class="badge" id="cline-compat-repairs" data-kpi>0</span>
          <span class="text-sm text-muted" data-i18n="section.clineCompat.repairHint">(把空/坏 JSON 参数归一为 {} 的累计次数,防 @ai-sdk 静默丢工具)</span>
          <button class="btn btn-ghost btn-xs" onclick="App.loadClineCompat()" data-i18n="action.refresh">刷新</button>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.ccCompat.title">Claude Code 兼容</div>
        <span class="badge badge-success" id="cc-compat-badge" style="display:none" data-i18n="section.ccCompat.badgeOn">已启用</span>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.ccCompat.desc">为 Claude Code 做原生级兼容。检测激活后,识别为 Claude Code 的请求会自动套用兼容层:剥离顶层 schema combinator(anyOf/oneOf/allOf)并把竞品身份行中和,避开上游 content-policy 拦截。专用端点 /v1/cc/*(如 /v1/cc/messages)无论此开关是否开启都始终生效——命名空间本身就是显式选择。默认关闭,标准端点对其他客户端保持字节等价。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch">
            <input type="checkbox" id="exp-cc-compat" onchange="App.toggleExperimental('ccCompat', this.checked)">
            <span class="switch-slider"></span>
          </label>
          <div>
            <div style="font-weight:500" data-i18n="section.ccCompat.enable">启用 Claude Code 特征识别兼容</div>
            <div class="text-sm text-muted" data-i18n="section.ccCompat.hint">默认关闭。开启后按检测特征识别 Claude Code 请求自动套兼容层;/v1/cc/* 专用端点不受此开关约束。</div>
          </div>
        </div>
        <div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
          <span class="text-sm text-muted" data-i18n="section.ccCompat.schemaLabel">Schema 规范化</span>
          <span class="badge" id="cc-compat-schema" data-kpi>0</span>
          <span class="text-sm text-muted" data-i18n="section.ccCompat.schemaHint">(剥离顶层 combinator 的累计次数)</span>
        </div>
        <div style="display:flex;align-items:center;gap:10px;margin-top:12px;flex-wrap:wrap">
          <span class="text-sm text-muted" data-i18n="section.ccCompat.identityLabel">身份中和</span>
          <span class="badge" id="cc-compat-identity" data-kpi>0</span>
          <span class="text-sm text-muted" data-i18n="section.ccCompat.identityHint">(中和竞品身份行的累计次数)</span>
          <button class="btn btn-ghost btn-xs" onclick="App.loadCcCompat()" data-i18n="action.refresh">刷新</button>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div class="section-title" data-i18n="section.preflight.title">发送前预检配额</div>
      </div>
      <div class="section-body">
        <div class="section-desc" style="margin-bottom:12px" data-i18n="section.preflight.desc">每次 chat 前先探一次 server.codeium.com 剩余配额,无额度账号直接快速失败,避免烧上游 429。代价是每请求多一次网络往返。</div>
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <label class="switch"><input type="checkbox" id="exp-preflight" onchange="App.toggleExperimental('preflightRateLimit', this.checked)"><span class="switch-slider"></span></label>
          <div>
            <div style="font-weight:500" data-i18n="section.preflight.enable">启用发送前预检</div>
            <div class="text-sm text-muted" data-i18n="section.preflight.hint">默认关闭。会增加延迟;账号频繁耗尽时再开。</div>
          </div>
        </div>
      </div>
    </div>
    <div class="section" style="margin-top:24px">
      <div class="section-header">
        <div>
          <div class="section-title" data-i18n="section.skin.title">界面风格</div>
          <div class="section-desc" data-i18n="section.skin.desc">控制台外观。默认现代风;手绘草稿风是实验性 UI,部分功能简化或采用不同布局。切换后页面会刷新。</div>
        </div>
      </div>
      <div class="section-body">
        <div style="display:flex;align-items:center;gap:16px;padding:12px;background:var(--surface-2);border-radius:var(--radius)">
          <select id="skin-select" class="input" style="max-width:240px" onchange="App.switchSkin(this.value)">
            <option value="modern" data-i18n="skin.modern">默认现代风</option>
            <option value="sketch" data-i18n="skin.sketch">手绘草稿风(实验)</option>
          </select>
          <div class="text-sm text-muted" data-i18n="skin.hint">选择后立即写入 cookie 并刷新</div>
        </div>
      </div>
    </div>
  </section>
</main>

<!-- Login Overlay -->
<div class="login-overlay hidden" id="login-overlay">
  <div class="login-box">
    <div class="login-logo">W</div>
    <h3 data-i18n="login.title">控制台登录</h3>
    <p data-i18n="login.subtitle">请输入管理密码以继续</p>
    <input type="password" id="login-password" class="input" data-i18n-placeholder="login.passwordPlaceholder" placeholder="Dashboard 密码" onkeydown="if(event.key==='Enter')App.login()">
    <button class="btn btn-primary" id="login-btn" onclick="App.login()" data-i18n="login.button">登 录</button>
    <div id="login-error" style="margin-top:12px;color: var(--error-text);font-size:13px;min-height:18px"></div>
  </div>
</div>

<!-- Modal Container -->
<div id="modal-container"></div>

<!-- Toast Container -->
<div class="toast-stack" id="toast-stack" aria-live="polite" aria-atomic="true"></div>

<script>
// Comprehensive i18n system with locale bundles
const I18n = {
  locale: 'zh-CN',
  bundles: {},
  loaded: false,

  // Load locale bundle from server
  async loadLocale(lang) {
    const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang);
    try {
      const res = await fetch(`/dashboard/i18n/${code}.json`);
      if (!res.ok) throw new Error(`Failed to load ${code}`);
      this.bundles[code] = await res.json();
      this.loaded = true;
      return true;
    } catch (e) {
      console.error('i18n load failed:', e);
      return false;
    }
  },

  // Get translation with placeholder interpolation
  t(key, vars = {}) {
    const code = this.locale === 'zh' ? 'zh-CN' : this.locale;
    const bundle = this.bundles[code] || {};
    const keys = key.split('.');
    let val = bundle;
    for (const k of keys) {
      val = val?.[k];
      if (val === undefined) break;
    }
    // Flat-key fallback: some keys are stored with a literal dot in the property
    // name (e.g. settings."emailLockThreshold.hint") rather than fully nested, so
    // the segment walk above misses them. Try progressively-shorter object paths
    // with the remainder as one flat property before giving up.
    if (typeof val !== 'string') {
      for (let i = keys.length - 1; i >= 1; i--) {
        let node = bundle;
        for (let j = 0; j < i && node; j++) node = node?.[keys[j]];
        const flat = node?.[keys.slice(i).join('.')];
        if (typeof flat === 'string') { val = flat; break; }
      }
    }
    // Fallback to key if not found
    if (typeof val !== 'string') {
      // Try embedded default (for zh-CN, use text from HTML).
      // The DOM lookup is only meaningful when the key is a real dotted
      // i18n identifier (alnum + dot + underscore). A backend error
      // message accidentally passed as a key — e.g. `error.docker API
      // POST /containers/create -> 404: {"message":"..."}` — would blow
      // up `querySelector` because `"` and `{` are CSS-selector syntax.
      // Guard with CSS.escape AND a sanity charset check so we never
      // throw out of the i18n resolver.
      if (code === 'zh-CN' && /^[A-Za-z0-9_.-]+$/.test(key)) {
        try {
          const el = document.querySelector(`[data-i18n="${CSS.escape(key)}"]`);
          if (el?.dataset.i18nOrig) return el.dataset.i18nOrig;
        } catch { /* selector still rejected — give up and return the key */ }
      }
      return key;
    }
    // Interpolate {{var}} placeholders
    return val.replace(/\{\{(\w+)\}\}/g, (_, k) => vars[k] ?? '');
  },

  // Apply translations to all elements with data-i18n attributes
  apply(root = document) {
    // Text content
    root.querySelectorAll('[data-i18n]').forEach(el => {
      const key = el.dataset.i18n;
      const vars = {};
      // Parse data-i18n-vars if present: "count:5,total:10"
      if (el.dataset.i18nVars) {
        el.dataset.i18nVars.split(',').forEach(pair => {
          const [k, v] = pair.split(':');
          if (k && v !== undefined) vars[k.trim()] = v.trim();
        });
      }
      const text = this.t(key, vars);
      // Store original for zh mode
      if (!el.dataset.i18nOrig) el.dataset.i18nOrig = el.textContent;
      if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
        el.textContent = text;
      } else {
        el.textContent = el.dataset.i18nOrig;
      }
    });

    // Placeholders
    root.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
      const key = el.dataset.i18nPlaceholder;
      const text = this.t(key);
      if (!el.dataset.i18nPlaceholderOrig) el.dataset.i18nPlaceholderOrig = el.placeholder;
      if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
        el.placeholder = text;
      } else {
        el.placeholder = el.dataset.i18nPlaceholderOrig;
      }
    });

    // Title attributes
    root.querySelectorAll('[data-i18n-title]').forEach(el => {
      const key = el.dataset.i18nTitle;
      const text = this.t(key);
      if (!el.dataset.i18nTitleOrig) el.dataset.i18nTitleOrig = el.title;
      if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
        el.title = text;
      } else {
        el.title = el.dataset.i18nTitleOrig;
      }
    });

    // aria-label attributes (a11y: e.g. radiogroup labels on the segmented
    // controls). Same store-original / restore-on-zh dance as title above.
    root.querySelectorAll('[data-i18n-aria-label]').forEach(el => {
      const key = el.dataset.i18nAriaLabel;
      const text = this.t(key);
      if (!el.dataset.i18nAriaLabelOrig) el.dataset.i18nAriaLabelOrig = el.getAttribute('aria-label') || '';
      if (this.locale !== 'zh-CN' && this.locale !== 'zh') {
        el.setAttribute('aria-label', text);
      } else {
        el.setAttribute('aria-label', el.dataset.i18nAriaLabelOrig);
      }
    });

    // Help-tip hydration for static markup: <span data-i18n-tip="key"
    // data-tip-placement="top"></span>. Rebuilt from the key each apply (so a
    // language switch re-localizes); className assigned (not appended) = idempotent.
    root.querySelectorAll('[data-i18n-tip]').forEach(el => {
      const txt = this.t('tooltip.help.' + el.dataset.i18nTip);
      const p = el.dataset.tipPlacement;
      el.className = 'help-tip' + (p ? ' help-tip--' + p : '');
      el.setAttribute('tabindex', '0'); el.setAttribute('role', 'note');
      el.setAttribute('aria-label', txt);
      el.innerHTML = '?<span class="help-tip__pop" role="tooltip"></span>';
      el.querySelector('.help-tip__pop').textContent = txt; // textContent avoids escaping
    });

    // Update HTML lang attribute
    document.documentElement.lang = this.locale === 'zh' || this.locale === 'zh-CN' ? 'zh-CN' : 'en';

    // Update language indicator
    const zhMode = this.locale === 'zh' || this.locale === 'zh-CN';
    const indicator = document.getElementById('lang-indicator');
    if (indicator) indicator.textContent = zhMode ? '中文' : 'English';
    {
      const indicator = document.getElementById('mobile-lang-indicator');
      if (indicator) indicator.textContent = zhMode ? '中文' : 'English';
    }
    const toggleBtn = document.getElementById('lang-toggle-btn');
    if (toggleBtn) toggleBtn.title = I18n.t(zhMode ? 'footer.langToggleToEn' : 'footer.langToggleToZh');
  },

  async setLocale(lang) {
    const code = lang === 'zh' ? 'zh-CN' : (lang === 'en' ? 'en' : lang);
    if (!this.bundles[code]) {
      await this.loadLocale(lang);
    }
    this.locale = code;
    localStorage.setItem('lang', lang);
    this.apply();
  },

  async init() {
    const saved = localStorage.getItem('lang');
    const browser = navigator.language;
    const defaultLang = saved || (browser.startsWith('zh') ? 'zh' : 'en');
    await this.setLocale(defaultLang);
  }
};

// Global translation helper for JS strings
function t(key, vars) { return I18n.t(key, vars); }

const App = {
  password: localStorage.getItem('dp') || '',
  lang: localStorage.getItem('lang') || 'zh',
  sseConn: null,
  logEntries: [],
  pollers: {},
  allModels: [],
  modelAccessConfig: { mode: 'all', list: [] },
  loginHistory: JSON.parse(localStorage.getItem('wl_history') || '[]'),
  _filterTimer: null,
  accountsPage: 1,
  accountsPageSize: Number(localStorage.getItem('accountsPageSize') || 50),
  // Server-side ordering (GET /accounts?sort=&order=). Persisted next to the
  // page size so a reload keeps the view the operator set up (UISpec P2-10).
  accountsSort: ['quota_weekly', 'balance', 'last_used', 'errors'].includes(localStorage.getItem('accountsSort'))
    ? localStorage.getItem('accountsSort') : '',
  accountsOrder: localStorage.getItem('accountsOrder') === 'asc' ? 'asc' : 'desc',
  accountsTier: '',
  accountsCompact: localStorage.getItem('accountsCompact') === '1',
  accountsTotal: 0,
  accountsHasMore: false,
  proxyPage: 1,
  proxyPageSize: Number(localStorage.getItem('proxyAccountsPageSize') || 50),
  _accountDetails: new Map(),

  applyLang() {
    // Delegate to new I18n system
    I18n.apply();
  },

  // Centralized error i18n: translate `error.<code>` from server, fall back to
  // the literal message or a localized default. Replaces 6+ inline
  // `error.${err.message}` lookups across the dashboard (PR #92, smeinecke).
  translateError(errorCode, fallbackKey = 'error.unknown') {
    const errKey = errorCode ? `error.${errorCode}` : '';
    if (errKey && I18n.t(errKey) !== errKey) return I18n.t(errKey);
    // Some codes carry a detail after a colon (e.g. ERR_OAUTH_UPSTREAM:access_denied).
    // Fall back to the base-code key so the localized message still shows.
    if (errorCode && errorCode.includes(':')) {
      const baseKey = `error.${errorCode.slice(0, errorCode.indexOf(':'))}`;
      if (I18n.t(baseKey) !== baseKey) return I18n.t(baseKey);
    }
    const raw = String(errorCode || '');
    // An unmapped identifier is not a sentence (`ERR_BATCH_OP_INVALID`,
    // `probe_skipped`). Show the actionable default; callers keep the code for
    // the tooltip / technical detail instead of printing it at the user.
    if (/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(raw) || /^[a-z][a-z0-9]*(?:_[a-z0-9]+)+$/.test(raw)) {
      return I18n.t(fallbackKey === 'error.unknown' ? 'error.genericAction' : fallbackKey);
    }
    return raw || I18n.t(fallbackKey);
  },

  renderLsPoolTelemetry(ls = {}) {
    const pool = ls.pool || {};
    const guard = pool.memoryGuard || ls.memoryGuard || {};
    const stats = ls.admissionStats || {};
    const ready = Number.isFinite(pool.ready) ? pool.ready : (ls.instances || []).filter(i => i.ready).length;
    const starting = Number.isFinite(pool.starting) ? pool.starting : (ls.instances || []).filter(i => !i.ready).length;
    const occupancy = Number.isFinite(pool.occupancy) ? pool.occupancy : ((ls.instances || []).length || 0);
    const cap = pool.maxInstances || ls.maxInstances || '-';
    const guardKnown = guard.okToSpawn !== undefined || guard.availableBytes != null || guard.minAvailableBytes != null;
    const guardBlocked = guardKnown && guard.okToSpawn === false;
    const guardClass = guardBlocked ? 'warn' : 'success';
    const guardText = guardKnown
      ? `${I18n.t(guardBlocked ? 'overview.guard.blocked' : 'overview.guard.ok')} ${this.fmtBytes(guard.availableBytes)}/${this.fmtBytes(guard.minAvailableBytes)}`
      : I18n.t('overview.guard.unknown');
    const blockReason = pool.blockReason || (guardBlocked ? 'memory_guard' : null);
    const last = stats.lastFailure || stats.lastWait || stats.lastEviction || null;
    const lastKind = last?.errorType || last?.kind || null;
    const lastKey = last?.key ? ` ${last.key}` : '';
    const lastAge = last?.at ? ` ${this.fmtMs(Date.now() - last.at)} ago` : '';
    const lastTitle = last?.message || last?.kind || '';
    const details = [
      `Pool ${occupancy}/${cap}`,
      `ready ${ready}`,
      `starting ${starting}`,
      `pending ${pool.pending || 0}`,
      `stopping ${pool.stopping || 0}`,
      `active ${pool.activeRequests || 0}`,
      `evictable ${pool.idleEvictable ? 'yes' : 'no'}`,
    ].join(' | ');
    return `
      <div class="ls-telemetry">
        <span class="badge info no-dot">${this.esc(details)}</span>
        <span class="badge ${guardClass} no-dot">${this.esc(guardText)}</span>
        ${blockReason ? `<span class="badge warn no-dot">${this.esc(blockReason)}</span>` : ''}
        ${lastKind ? `<span class="ls-telemetry-detail" title="${this.esc(lastTitle)}">Last ${this.esc(lastKind)}${this.esc(lastKey)}${this.esc(lastAge)}</span>` : ''}
      </div>`;
  },

  renderNativeBridgeTelemetry(health = {}) {
    const root = document.getElementById('native-bridge-status');
    if (!root) return;
    const stats = health.nativeBridge || {};
    const cfg = health.nativeBridgeConfig || {};
    const recent = Array.isArray(stats.recentDecisions) ? stats.recentDecisions.slice(-5).reverse() : [];
    const reasons = stats.decisionReasons || {};
    const topReasons = Object.entries(reasons)
      .sort(([, a], [, b]) => Number(b || 0) - Number(a || 0))
      .slice(0, 5)
      .map(([reason, count]) => `${reason}:${count}`)
      .join(' | ');
    const mode = cfg.off ? 'off override' : (cfg.mode || 'off');
    const modeClass = cfg.off ? 'error' : (cfg.mode ? 'success' : 'muted');
    const gateBits = [
      cfg.hasApiKeyGate ? 'api-key' : '',
      cfg.hasAccountGate ? 'account' : '',
      cfg.models?.length ? `models=${cfg.models.join(',')}` : '',
      cfg.providers?.length ? `providers=${cfg.providers.join(',')}` : '',
      cfg.routes?.length ? `routes=${cfg.routes.join(',')}` : '',
      cfg.callers?.length ? `callers=${cfg.callers.length}` : '',
    ].filter(Boolean).join(' | ');
    const NB = (k, v) => I18n.t('overview.nativeBridge.' + k, v);
    const tools = Array.isArray(cfg.tools) && cfg.tools.length ? cfg.tools.join(',') : NB('defaultTools');
    const last = stats.lastDecision || null;
    const lastReason = last?.reason || NB('none');
    const rows = recent.map(d => {
      const badgeClass = d.enabled ? 'success' : 'warn';
      const mapped = (d.mappedTools || []).join(',') || '-';
      const unmapped = (d.unmappedTools || []).join(',') || '-';
      const when = d.at ? new Date(d.at).toLocaleTimeString() : '-';
      return `
        <div class="native-bridge-decision" title="${this.esc(d.reason || '')}">
          <div><span class="badge ${badgeClass} no-dot">${this.esc(d.reason || 'unknown')}</span></div>
          <div>${this.esc(d.modelKey || '-')} · ${this.esc(d.route || '-')} · ${this.esc(when)}</div>
          <div class="native-bridge-tools">${NB('mapped')} ${this.esc(mapped)} | ${NB('unmapped')} ${this.esc(unmapped)}</div>
        </div>`;
    }).join('');
    root.innerHTML = `
      <div class="native-bridge-panel">
        <div class="native-bridge-header">
          <div class="native-bridge-title">${NB('title')}</div>
          <span class="badge ${modeClass} no-dot">${this.esc(mode)}</span>
        </div>
        <div class="native-bridge-grid">
          <div class="native-bridge-kv"><span class="k">${NB('decisions')}</span><span class="v">${NB('decisionsVal', { total: stats.decisions || 0, on: stats.enabledDecisions || 0, off: stats.disabledDecisions || 0 })}</span></div>
          <div class="native-bridge-kv"><span class="k">${NB('lastReason')}</span><span class="v" title="${this.esc(lastReason)}">${this.esc(lastReason)}</span></div>
          <div class="native-bridge-kv"><span class="k">${NB('tools')}</span><span class="v" title="${this.esc(tools)}">${this.esc(tools)}</span></div>
          <div class="native-bridge-kv"><span class="k">${NB('gates')}</span><span class="v" title="${this.esc(gateBits || NB('none'))}">${this.esc(gateBits || NB('none'))}</span></div>
          <div class="native-bridge-kv"><span class="k">${NB('topReasons')}</span><span class="v" title="${this.esc(topReasons || NB('none'))}">${this.esc(topReasons || NB('none'))}</span></div>
        </div>
        <div class="native-bridge-decisions">
          ${rows || `<div class="text-sm text-muted">${NB('empty')}</div>`}
        </div>
      </div>`;
  },

  async toggleLang() {
    const newLang = I18n.locale === 'zh' || I18n.locale === 'zh-CN' ? 'en' : 'zh';
    await I18n.setLocale(newLang);
    this.lang = newLang;
    this._labelNavLinks();
    this.refreshActivePanelI18n();
  },

  // Light/dark theme toggle. Persists to localStorage; applied to <html> so the
  // [data-theme="dark"] CSS block takes over. initTheme() runs at boot.
  toggleTheme() {
    const cur = document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
    const next = cur === 'dark' ? 'light' : 'dark';
    this.applyTheme(next);
    try { localStorage.setItem('dashboard_theme', next); } catch { /* private mode */ }
  },
  applyTheme(theme) {
    if (theme === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
    else document.documentElement.removeAttribute('data-theme');
    const ind = document.getElementById('theme-indicator');
    // Clean inline SVG (sun when dark→click-to-light, moon when light→click-to-dark).
    const icon = theme === 'dark'
      ? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
      : '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>';
    if (ind) ind.innerHTML = icon;
    const mobileInd = document.getElementById('mobile-theme-indicator');
    if (mobileInd) mobileInd.innerHTML = icon;
    // Canvas charts read theme vars at paint time only — repaint on toggle so
    // grid/axis/label colors follow the new theme instead of lagging a cycle.
    if (this._lastStatsData) {
      if (typeof this.renderMainChart === 'function') this.renderMainChart(this._lastStatsData);
    }
    if (typeof this.refreshActivePanelI18n === 'function') this.refreshActivePanelI18n();
  },
  initTheme() {
    let saved = 'dark';
    try { saved = localStorage.getItem('dashboard_theme') || 'dark'; } catch { /* private mode */ }
    this.applyTheme(saved);
  },

  refreshActivePanelI18n() {
    const activePanelId = document.querySelector('.panel.active')?.id?.replace(/^p-/, '');
    if (!activePanelId) return;
    const loaders = {
      overview: 'loadOverview',
      accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
      logs: 'loadLogs', stats: 'loadStats', usage: 'loadUsage',
      experimental: 'loadExperimental', credits: 'loadCredits', settings: 'loadSettings',
    };
    const loader = loaders[activePanelId];
    if (loader && typeof this[loader] === 'function') this[loader]();
  },

  // Register a polling loader. Guarantees exactly one timer per key even if
  // the loader is invoked repeatedly, and auto-pauses while the tab is
  // hidden to avoid hammering the server in background tabs.
  poll(key, fn, intervalMs) {
    if (this.pollers[key]) clearInterval(this.pollers[key]);
    this.pollers[key] = setInterval(() => {
      if (document.hidden) return;
      // Only poll while the OWNING panel is active — a poller key matches its
      // panel id (overview/stats/bans → p-overview/…). Skip full innerHTML
      // rewrites + canvas repaints for panels the user isn't looking at.
      const active = document.querySelector('.panel.active')?.id;
      if (active && active !== 'p-' + key) return;
      fn();
    }, intervalMs);
  },

  // Panel-independent poller: runs regardless of which panel is active (only
  // skips when the tab is hidden). For cross-cutting heartbeats like the
  // pool-event notifier that must observe the pool no matter where the user is.
  pollGlobal(key, fn, intervalMs) {
    if (this.pollers[key]) clearInterval(this.pollers[key]);
    this.pollers[key] = setInterval(() => {
      if (document.hidden) return;
      fn();
    }, intervalMs);
  },

  // Idempotently upgrade native <select> / <input type=number> into themed
  // custom controls. The native element STAYS in the DOM as the source of truth
  // (.value / data-name / onchange all keep working); the custom UI only mirrors
  // and drives it. Safe to call after any render (init, prompt modal, tables).
  enhanceControls(root) {
    const r = root || document;
    r.querySelectorAll('select:not([data-enhanced])').forEach((sel) => this._enhanceSelect(sel));
    r.querySelectorAll('input[type=number]:not([data-enhanced])').forEach((inp) => this._enhanceNumber(inp));
  },

  _enhanceSelect(sel) {
    sel.dataset.enhanced = '1';
    const root = document.createElement('span');
    root.className = 'cs';
    root.setAttribute('data-cs-root', '');
    sel.parentNode.insertBefore(root, sel);
    root.appendChild(sel);
    sel.classList.add('cs-native');
    const trigger = document.createElement('button');
    trigger.type = 'button';
    trigger.className = 'cs-trigger';
    trigger.setAttribute('aria-haspopup', 'listbox');
    trigger.setAttribute('aria-expanded', 'false');
    const lbl = sel.getAttribute('aria-label') || sel.id || 'select';
    trigger.setAttribute('aria-label', lbl);
    const labelEl = document.createElement('span');
    labelEl.className = 'cs-label';
    const chevron = document.createElement('span');
    chevron.className = 'cs-chevron';
    chevron.innerHTML = "<svg viewBox='0 0 24 24' width='12' height='12' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>";
    trigger.appendChild(labelEl);
    trigger.appendChild(chevron);
    root.appendChild(trigger);

    const refreshLabel = () => {
      const opt = sel.options[sel.selectedIndex];
      labelEl.textContent = opt ? opt.textContent : '';
    };
    // Proxy .value through the native prototype getter/setter so all existing
    // reads/writes keep working; a PROGRAMMATIC write only refreshes the visible
    // label and NEVER dispatches change (avoids skin-revert / pagination-writeback
    // loops). A USER pick below dispatches change explicitly.
    const proto = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
    Object.defineProperty(sel, 'value', {
      configurable: true,
      get() { return proto.get.call(sel); },
      set(v) { proto.set.call(sel, v); refreshLabel(); },
    });
    if (sel.disabled) trigger.disabled = true;
    refreshLabel();
    this._csWireInteraction(sel, root, trigger, labelEl, refreshLabel);
  },

  _csWireInteraction(sel, root, trigger, labelEl, refreshLabel) {
    let pop = null, hi = -1, cleanup = null;
    const opts = () => Array.from(sel.options);
    const isOpen = () => trigger.getAttribute('aria-expanded') === 'true';
    const commit = (idx) => {
      const o = sel.options[idx];
      if (!o || o.disabled) return;
      const proto = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
      proto.set.call(sel, o.value);
      refreshLabel();
      sel.dispatchEvent(new Event('change', { bubbles: true }));  // USER pick → fire onchange
      close();
    };
    const paintHi = () => {
      if (!pop) return;
      Array.from(pop.children).forEach((el, i) => el.classList.toggle('cs-hi', i === hi));
      if (pop.children[hi]) {
        pop.children[hi].scrollIntoView({ block: 'nearest' });
        trigger.setAttribute('aria-activedescendant', pop.children[hi].id);
      }
    };
    const place = () => {
      if (!pop) return;
      const r = trigger.getBoundingClientRect();
      pop.style.left = r.left + 'px';
      pop.style.minWidth = r.width + 'px';
      // Flip above the trigger when there isn't room below (trigger low in the
      // viewport), so the list is never clipped off-screen.
      const popH = pop.offsetHeight || 240;
      const belowRoom = window.innerHeight - r.bottom;
      if (belowRoom < popH + 8 && r.top > belowRoom) {
        pop.style.top = Math.max(4, r.top - popH - 4) + 'px';
        pop.style.transformOrigin = 'bottom';
      } else {
        pop.style.top = (r.bottom + 4) + 'px';
        pop.style.transformOrigin = 'top';
      }
    };
    const close = () => {
      if (!pop) return;
      pop.remove(); pop = null;
      trigger.setAttribute('aria-expanded', 'false');
      trigger.removeAttribute('aria-activedescendant');
      if (cleanup) { cleanup(); cleanup = null; }
      requestAnimationFrame(() => trigger.focus());
    };
    const open = () => {
      if (isOpen() || sel.disabled) return;
      pop = document.createElement('div');
      pop.className = 'cs-pop';
      pop.setAttribute('role', 'listbox');
      opts().forEach((o, i) => {
        const el = document.createElement('div');
        el.className = 'cs-opt';
        el.id = 'cs-opt-' + Math.random().toString(36).slice(2, 8);
        el.setAttribute('role', 'option');
        el.setAttribute('aria-selected', String(i === sel.selectedIndex));
        if (o.disabled) el.setAttribute('aria-disabled', 'true');
        const t = document.createElement('span'); t.textContent = o.textContent;
        const c = document.createElement('span'); c.className = 'cs-check'; c.textContent = '✓';
        el.appendChild(t); el.appendChild(c);
        el.addEventListener('mouseenter', () => { hi = i; paintHi(); });
        el.addEventListener('mousedown', (e) => { e.preventDefault(); commit(i); });
        pop.appendChild(el);
      });
      document.body.appendChild(pop);
      trigger.setAttribute('aria-expanded', 'true');
      place();
      hi = sel.selectedIndex >= 0 ? sel.selectedIndex : 0;
      paintHi();
      const onDocDown = (e) => { if (!root.contains(e.target) && !pop.contains(e.target)) close(); };
      // Scrolling INSIDE the popover (it is overflow-y:auto) must NOT close it —
      // only an OUTER scroll should, since the fixed popover would otherwise drift
      // away from its trigger. Reposition on outer scroll instead of closing so
      // the list tracks the trigger while an ancestor scrolls.
      const onScroll = (e) => { if (pop && pop.contains(e.target)) return; place(); };
      const onResize = () => close();
      // Capture-phase Esc: while the dropdown is open, Esc closes ONLY the
      // dropdown and is stopped before it reaches an enclosing modal's own
      // capture-phase Esc handler (which would otherwise close the whole modal).
      const onKey = (e) => { if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); close(); } };
      document.addEventListener('mousedown', onDocDown, true);
      document.addEventListener('keydown', onKey, true);
      window.addEventListener('scroll', onScroll, true);
      window.addEventListener('resize', onResize, true);
      cleanup = () => {
        document.removeEventListener('mousedown', onDocDown, true);
        document.removeEventListener('keydown', onKey, true);
        window.removeEventListener('scroll', onScroll, true);
        window.removeEventListener('resize', onResize, true);
      };
    };
    const move = (d) => {
      const n = sel.options.length; if (!n) return;
      let i = hi;
      for (let k = 0; k < n; k++) { i = (i + d + n) % n; if (!sel.options[i].disabled) break; }
      hi = i; paintHi();
    };
    trigger.addEventListener('click', () => isOpen() ? close() : open());
    trigger.addEventListener('keydown', (e) => {
      if (!isOpen()) {
        if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { e.preventDefault(); open(); }
        return;
      }
      if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
      else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
      else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); commit(hi); }
      else if (e.key === 'Escape') { e.preventDefault(); close(); }
    });
    // A native focus (e.g. prompt() autofocus targets input,select) transfers to
    // the visible trigger so the keyboard user lands on the themed control.
    sel.addEventListener('focus', () => trigger.focus());
  },

  _enhanceNumber(inp) {
    inp.dataset.enhanced = '1';
    const root = document.createElement('span');
    root.className = 'ns';
    root.setAttribute('data-ns-root', '');
    inp.parentNode.insertBefore(root, inp);
    root.appendChild(inp);
    inp.classList.add('ns-input');
    const btns = document.createElement('span');
    btns.className = 'ns-btns';
    const mk = (dir, glyph, ariaKey) => {
      const b = document.createElement('button');
      b.type = 'button'; b.className = 'ns-btn'; b.tabIndex = -1;
      b.textContent = glyph;
      b.setAttribute('aria-label', I18n.t(ariaKey));
      return b;
    };
    const up = mk(1, '▲', 'aria.increment');
    const down = mk(-1, '▼', 'aria.decrement');
    btns.appendChild(up); btns.appendChild(down);
    root.appendChild(btns);

    const step = Number(inp.step) > 0 ? Number(inp.step) : 1;
    // Respect min/max declared on the input; an empty attr => no bound that side.
    const clamp = (n) => {
      const lo = inp.min !== '' ? Number(inp.min) : -Infinity;
      const hi = inp.max !== '' ? Number(inp.max) : Infinity;
      return Math.min(hi, Math.max(lo, n));
    };
    const stepBy = (d) => {
      if (inp.disabled) return;
      const cur = parseFloat(inp.value);
      const next = clamp((Number.isFinite(cur) ? cur : 0) + d * step);
      inp.value = String(next);
      inp.dispatchEvent(new Event('input', { bubbles: true }));
    };
    // Fire `change` only at a commit point (button release / each arrow key),
    // mirroring native <input> semantics so onchange-bound persistence
    // (saveTunable / saveDroughtThreshold) runs — without flooding a PUT + toast
    // on every frame of hold-to-repeat.
    const commit = () => inp.dispatchEvent(new Event('change', { bubbles: true }));
    // Hold-to-repeat: immediate step, then accelerating repeat. Timers cleared on
    // release/leave to avoid leaks; a real step commits once on release.
    let hold = null, delay = null, stepped = false;
    const startHold = (d) => {
      stepped = true; stepBy(d);
      delay = setTimeout(() => { hold = setInterval(() => stepBy(d), 60); }, 200);
    };
    const stopHold = () => {
      if (delay) { clearTimeout(delay); delay = null; }
      if (hold) { clearInterval(hold); hold = null; }
      if (stepped) { stepped = false; commit(); }
    };
    [ [up, 1], [down, -1] ].forEach(([b, d]) => {
      b.addEventListener('mousedown', (e) => { e.preventDefault(); startHold(d); });
      b.addEventListener('mouseup', stopHold);
      b.addEventListener('mouseleave', stopHold);
      b.addEventListener('touchend', stopHold);
    });
    inp.addEventListener('keydown', (e) => {
      if (e.key === 'ArrowUp') { e.preventDefault(); stepBy(1); commit(); }
      else if (e.key === 'ArrowDown') { e.preventDefault(); stepBy(-1); commit(); }
    });
  },

  async init() {
    // Initialize i18n first
    await I18n.init();
    this.initTheme();
    this.lang = I18n.locale === 'zh-CN' ? 'zh' : 'en';
    const auth = await this.api('GET', '/auth');
    // v2.0.61 (#110): when DASHBOARD_PASSWORD isn't configured AND we're
    // bound to a public host, the backend returns `locked:true`. Show a
    // dedicated message instead of a useless password prompt the user
    // can't possibly satisfy. Without this hint operators land here from
    // a fresh `docker compose up` and think the dashboard is broken.
    if (auth.locked) {
      const overlay = document.getElementById('login-overlay');
      const pwEl = document.getElementById('login-password');
      const btnEl = document.getElementById('login-btn');
      const errEl = document.getElementById('login-error');
      overlay.classList.remove('hidden');
      if (pwEl) pwEl.style.display = 'none';
      if (btnEl) btnEl.style.display = 'none';
      if (errEl) {
        errEl.style.color = 'var(--text-muted)';
        errEl.style.fontSize = '13px';
        errEl.style.lineHeight = '1.65';
        errEl.style.textAlign = 'left';
        errEl.innerHTML = `
          <strong style="color: var(--warn-text);display:block;margin-bottom:8px">${I18n.t('login.lockedTitle') || 'Dashboard locked'}</strong>
          <div>${I18n.t('login.lockedDesc') || 'Server bound to public host without DASHBOARD_PASSWORD. v2.0.55 fail-closed: API_KEY no longer doubles as the dashboard password.'}</div>
          <div style="margin-top:10px">${I18n.t('login.lockedFix') || 'Fix:'}</div>
          <pre style="background:var(--surface-2);padding:10px;border-radius:6px;font-size:12px;margin-top:6px;white-space:pre-wrap"># .env
DASHBOARD_PASSWORD=YOUR_STRONG_PASSWORD

# restart container
docker compose up -d --force-recreate</pre>
          <div style="margin-top:8px;font-size:12px">${I18n.t('login.lockedDocs') || 'See'} <a href="https://github.com/dwgx/WindsurfAPI/blob/master/docs/releases/RELEASE_NOTES_2.0.55.md" target="_blank" style="color: var(--accent-text)">${I18n.t('login.releaseNotes', {version: '2.0.55'})}</a>.</div>
        `;
      }
      return;
    }
    if (auth.required && !auth.valid) {
      document.getElementById('login-overlay').classList.remove('hidden');
      return;
    }
    document.getElementById('login-overlay').classList.add('hidden');
    document.querySelectorAll('.sidebar nav a').forEach(a => {
      a.onclick = (e) => { e.preventDefault(); this.navigate(a.dataset.panel); };
    });
    this._labelNavLinks();
    // Load shared UI prefs (e.g. oauthSkipChooser) once after auth so the OAuth
    // chooser and Settings page reflect the backend-stored preference.
    this.api('GET', '/settings/prefs').then(r => { this._prefs = r.prefs || {}; }).catch(() => { this._prefs = {}; });
    // Upgrade all static native selects / number inputs into themed controls.
    this.enhanceControls(document);
    const hash = location.hash.slice(1);
    if (hash) this.navigate(hash);
    else this.loadOverview();
    // Panel-independent pool-event heartbeat (rides zero extra upstream cost —
    // reads the in-memory pool via /pool-events). Surfaces cooldown/429/quota
    // flapping as deduped, transition-only toasts regardless of active panel.
    this.startPoolEvents();
  },

  async login() {
    const pw = document.getElementById('login-password').value;
    const btn = document.getElementById('login-btn');
    const errEl = document.getElementById('login-error');
    if (errEl) errEl.textContent = '';
    if (!pw) {
      if (errEl) errEl.textContent = I18n.t('login.empty');
      return;
    }
    if (btn) { btn.disabled = true; btn.dataset.origLabel ||= btn.textContent; btn.textContent = I18n.t('login.checking'); }
    try {
      // Pre-flight: check the password against /auth before storing it so a
      // wrong attempt actually shows feedback instead of silently re-rendering
      // the same overlay (which looks like "no response" to users).
      const r = await fetch('/dashboard/api/auth', { headers: { 'X-Dashboard-Password': pw } });
      const data = await r.json().catch(() => ({}));
      if (data.locked) {
        if (errEl) errEl.textContent = I18n.t('login.locked');
        return;
      }
      if (data.required && !data.valid) {
        if (errEl) errEl.textContent = I18n.t('login.wrong');
        document.getElementById('login-password')?.focus();
        return;
      }
      this.password = pw;
      this.bumpAuthEpoch();
      localStorage.setItem('dp', pw);
      await this.init();
    } catch (e) {
      if (errEl) errEl.textContent = I18n.t('login.networkError') + ': ' + e.message;
    } finally {
      if (btn) { btn.disabled = false; if (btn.dataset.origLabel) btn.textContent = btn.dataset.origLabel; }
    }
  },

  // P1 #5: the desktop icon rail hides link text at <=900px, so every link
  // also carries a title + aria-label. Labels are read from the live i18n span
  // and refreshed after a language switch.
  _labelNavLinks() {
    document.querySelectorAll('.sidebar nav a[data-panel]').forEach((a) => {
      const label = a.querySelector('span')?.textContent?.trim();
      if (!label) return;
      a.title = label;
      a.setAttribute('aria-label', label);
    });
  },

  // P1 #5: mobile drawer controls. The same nav markup is reused; only its
  // presentation changes below 720px, so every panel link stays labelled.
  toggleMobileNav(force) {
    const sidebar = document.getElementById('app-sidebar');
    const mask = document.getElementById('mobile-nav-mask');
    const btn = document.getElementById('mobile-menu-btn');
    if (!sidebar) return;
    const open = force == null ? !sidebar.classList.contains('open') : !!force;
    sidebar.classList.toggle('open', open);
    if (mask) mask.classList.toggle('hidden', !open);
    if (btn) btn.setAttribute('aria-expanded', String(open));
    if (document.body && document.body.classList) document.body.classList.toggle('mobile-nav-open', open);
  },
  closeMobileNav() {
    this.toggleMobileNav(false);
  },

  navigate(panel) {
    // Removed pages stay reachable as aliases so old docs links do not 404:
    // #windsurf-login opens the add modal on its 登录 tab; #bans lands on
    // 账号管理 with 仅异常 on. Neither is a destination in the sidebar.
    if (panel === 'windsurf-login') {
      this.navigate('accounts');
      this.openAddAccountModal('login');
      return;
    }
    if (panel === 'bans') {
      this.accountsFlagged = true;
      this.navigate('accounts');
      return;
    }
    this._overviewLoadGeneration = (this._overviewLoadGeneration || 0) + 1;
    document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
    document.querySelectorAll('.sidebar nav a').forEach(a => a.classList.remove('active'));
    const el = document.getElementById('p-' + panel);
    const nav = document.querySelector(`[data-panel="${panel}"]`);
    if (el) el.classList.add('active');
    if (nav) nav.classList.add('active');
    const sidebar = document.getElementById('app-sidebar');
    if (sidebar) sidebar.classList.toggle('settings-open', panel === 'settings');
    const mobileTitle = document.getElementById('mobile-appbar-title');
    const navLabel = nav && nav.querySelector('span');
    if (mobileTitle && navLabel) mobileTitle.textContent = navLabel.textContent;
    if (typeof window !== 'undefined' && window.matchMedia && window.matchMedia('(max-width: 720px)').matches) this.closeMobileNav();
    location.hash = panel;

    Object.values(this.pollers).forEach(clearInterval);
    this.pollers = {};
    if (this.sseConn) { this.sseConn.close(); this.sseConn = null; }
    // Dispose pool fires when navigating away — display:none does NOT pause
    // requestAnimationFrame (only tab-hidden does), so without this every hot-row
    // fire keeps running its 3-pass GL forever in the background. Switching back
    // to overview re-mounts via _renderPoolHealth → _mountBarFires.
    this._teardownFires();

    const loaders = {
      overview: 'loadOverview',
      accounts: 'loadAccounts', models: 'loadModels', proxy: 'loadProxy',
      logs: 'loadLogs', stats: 'loadStats', usage: 'loadUsage',
      devin: 'loadDevin',
      experimental: 'loadExperimental',
      credits: 'loadCredits', settings: 'loadSettings',
    };
    if (loaders[panel]) this[loaders[panel]]();
  },

  // Bump on any auth-context change (login / logout / 401). A response that
  // arrives after the epoch changed is stale (belongs to the previous password)
  // and is discarded so it can't paint data into a freshly-switched session.
  bumpAuthEpoch() { this._authEpoch = (this._authEpoch || 0) + 1; },

  async api(method, path, body) {
    // In-flight GET dedupe: concurrent identical GETs (common when several panel
    // sections mount at once) share ONE fetch instead of hammering the endpoint.
    // Only GETs are deduped — writes must always execute.
    if (method === 'GET' && !body) {
      this._inflightGets ||= new Map();
      if (this._inflightGets.has(path)) return this._inflightGets.get(path);
      const promise = this._apiRaw(method, path, body).finally(() => this._inflightGets.delete(path));
      this._inflightGets.set(path, promise);
      return promise;
    }
    return this._apiRaw(method, path, body);
  },

  async _apiRaw(method, path, body) {
    const epochAtStart = this._authEpoch || 0;
    const opts = { method, headers: { 'Content-Type': 'application/json' } };
    if (this.password) opts.headers['X-Dashboard-Password'] = this.password;
    if (body) opts.body = JSON.stringify(body);
    try {
      const r = await fetch('/dashboard/api' + path, opts);
      // Stale-response guard: the auth context changed while this was in flight
      // (password switch / logout) — drop the result so it can't overwrite the
      // new session's view with data fetched under the old credential.
      if ((this._authEpoch || 0) !== epochAtStart) {
        // Same shape as 401/429/offline. An empty object let loadAccounts
        // paint an empty table when /overview 401'd first and bumped the
        // epoch while GET /accounts was still in flight (#257).
        return { success: false, error: 'stale' };
      }
      const data = await r.json().catch(() => ({}));
      // 429 = IP brute-force lockout. Blindly continuing (esp. the 15s/30s poll
      // loops + the ~dozen parallel calls each panel fires) hammers the banned
      // endpoint and stacks a wall of identical toasts. Halt ALL polling and show
      // one clear "banned, retry in Ns" overlay instead of spamming.
      // A 401/429 from an upstream provider (Devin) is not an admin-auth
      // failure. The dashboard API forwards the upstream status and includes
      // `httpStatus`; only a response without that marker is the dashboard's
      // own password/IP-lock gate. Clearing the saved admin password or
      // starting an IP-lock countdown on an upstream quota error leaves the
      // operator locked out of a dashboard whose own auth is still valid.
      const upstreamFailure = (r.status === 401 || r.status === 429)
        && (data?.httpStatus === r.status || data?.upstream === true);
      if (r.status === 429 && !upstreamFailure) {
        this.handleLockout(data.retryAfterMs || (data.error && parseInt(String(data.error).match(/(\d+)s/)?.[1], 10) * 1000) || 0);
        const lockErr = (data && data.error && (data.error.message || data.error)) || 'Too many failed attempts';
        return { success: false, error: lockErr };
      }
      if (r.status === 401 && !upstreamFailure) {
        // Stale/wrong stored password: drop it so polls stop re-sending a bad
        // credential (which would rack up failed attempts and trigger a ban).
        // The password is persisted in localStorage 'dp' (see :3180) — clearing
        // sessionStorage would miss it and the bad password would survive reload.
        this.password = null;
        this.bumpAuthEpoch();
        try { localStorage.removeItem('dp'); } catch {}
        this.stopAllPolls();
        document.getElementById('login-overlay').classList.remove('hidden');
        // A lockout (handleLockout) hides the password field + submit button
        // while its countdown runs. If a 401 lands mid-countdown we re-show the
        // overlay but must also restore those controls, otherwise the user is
        // left staring at a login box with no inputs until the timer expires.
        const pwEl = document.getElementById('login-password');
        const btnEl = document.getElementById('login-btn');
        if (pwEl) pwEl.style.display = '';
        if (btnEl) btnEl.style.display = '';
        // Do not return {}. addAccount / submitOAuthToken treat a missing
        // error as toast.addFailed ("Add failed") — the #257 report.
        const authErr = (data && data.error && (data.error.message || data.error)) || 'Unauthorized';
        return { success: false, error: authErr };
      }
      // Surface non-2xx failures instead of silently returning error-shaped data
      // as if it were a valid result. The body still comes back to the caller,
      // but `success: false` is forced onto it so every `success === false`
      // loader guard stays reachable on 404/500 — and on a non-JSON error body,
      // where there is nothing to spread (#257).
      if (!r.ok) {
        const rawError = data && data.error && (data.error.message || data.error);
        this.toast(
          rawError ? this.translateError(String(rawError), 'error.genericAction') : I18n.t('toast.requestFailed', { status: r.status }),
          'error',
          rawError ? String(rawError) : '',
        );
        const failed = (data && typeof data === 'object' && !Array.isArray(data)) ? { ...data } : {};
        failed.success = false;
        return failed;
      }
      return data;
    } catch (e) {
      const msg = e.message || 'network_error';
      this.toast(msg, 'error');
      return { success: false, error: msg };
    }
  },

  stopAllPolls() {
    Object.values(this.pollers || {}).forEach(clearInterval);
    this.pollers = {};
    // A fleet run's poll is a timeout chain, not an interval. Stop it too: the
    // lockout/401 path must not keep hammering a banned endpoint.
    if (this._fleetTimer) { clearTimeout(this._fleetTimer); this._fleetTimer = null; }
    // Also tear down the logs SSE stream. Without this, stopping polls while
    // sitting on the logs panel during a 429/401 lockout leaves the fetch
    // ReadableStream open and still hitting the banned /logs/stream endpoint.
    if (this.sseConn) { try { this.sseConn.close(); } catch {} this.sseConn = null; }
  },

  // Show a single non-spammy lockout overlay with a live countdown. Stops all
  // polling so we don't keep hitting the banned endpoint.
  handleLockout(retryAfterMs) {
    this.stopAllPolls();
    if (this._lockoutTimer) return; // already showing — don't stack
    const overlay = document.getElementById('login-overlay');
    const errEl = document.getElementById('login-error');
    const pwEl = document.getElementById('login-password');
    const btnEl = document.getElementById('login-btn');
    if (overlay) overlay.classList.remove('hidden');
    if (pwEl) pwEl.style.display = 'none';
    if (btnEl) btnEl.style.display = 'none';
    let secs = Math.max(1, Math.ceil((retryAfterMs || 0) / 1000)) || 60;
    const render = () => {
      if (errEl) {
        errEl.style.color = 'var(--warn)';
        errEl.textContent = I18n.t('login.banned', { secs });
      }
    };
    render();
    this._lockoutTimer = setInterval(() => {
      secs--;
      if (secs <= 0) {
        clearInterval(this._lockoutTimer); this._lockoutTimer = null;
        if (pwEl) pwEl.style.display = '';
        if (btnEl) btnEl.style.display = '';
        if (errEl) errEl.textContent = '';
      } else render();
    }, 1000);
  },

  // `detail` is the raw server code, shown only as the tooltip: the visible
  // sentence stays human, and support can still read the identifier.
  toast(msg, type = 'success', detail = '') {
    const t = document.createElement('div');
    t.className = 'toast ' + type;
    t.textContent = msg;
    t.style.cursor = 'pointer';
    t.title = detail ? `${detail} · ${I18n.t('toast.dismiss')}` : I18n.t('toast.dismiss');
    document.getElementById('toast-stack').appendChild(t);
    let done = false;
    const dismiss = () => {
      if (done) return; done = true;
      t.style.opacity = '0';
      t.style.transform = 'translateX(20px)';
      t.style.transition = 'all .2s';
      setTimeout(() => t.remove(), 200);
    };
    t.addEventListener('click', dismiss);   // click to dismiss early
    // Errors linger longer than successes so users can actually read them.
    setTimeout(dismiss, type === 'error' ? 8000 : 3000);
  },

  // ─── KPI count-up ─────────────────────────────
  // Smoothly interpolate a numeric KPI from its previous value to the new one
  // via rAF. Honors reduced-motion (snaps to final). Preserves the element's
  // prefix/suffix (e.g. "$", "%") so "$12.30" animates the number in place.
  _animateNumber(el, to, { prefix = '', suffix = '', decimals = 0, ms = 600 } = {}) {
    if (!el) return;
    const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const fmt = (n) => prefix + (decimals ? n.toFixed(decimals) : Math.round(n).toLocaleString()) + suffix;
    const from = Number(el.dataset.kpiVal);
    const target = Number(to);
    if (reduce || !Number.isFinite(from) || from === target) {
      el.textContent = fmt(target); el.dataset.kpiVal = String(target); return;
    }
    const t0 = performance.now();
    const step = (now) => {
      const p = Math.min(1, (now - t0) / ms);
      const eased = 1 - Math.pow(1 - p, 3);   // easeOutCubic
      el.textContent = fmt(from + (target - from) * eased);
      if (p < 1) requestAnimationFrame(step);
      else { el.textContent = fmt(target); el.dataset.kpiVal = String(target); }
    };
    requestAnimationFrame(step);
  },
  // After an innerHTML re-render, run count-up on every [data-kpi] value using
  // the value stashed on the previous element (kept in this._prevKpi by key).
  _runKpiCountUp(root) {
    this._prevKpi ||= {};
    (root || document).querySelectorAll('[data-kpi]').forEach((el) => {
      const key = el.dataset.kpi;
      const to = Number(el.dataset.kpiTo);
      if (!Number.isFinite(to)) return;
      const prev = this._prevKpi[key];
      if (Number.isFinite(prev)) el.dataset.kpiVal = String(prev);  // seed for animation
      this._animateNumber(el, to, {
        prefix: el.dataset.kpiPrefix || '', suffix: el.dataset.kpiSuffix || '',
        decimals: Number(el.dataset.kpiDecimals) || 0,
      });
      this._prevKpi[key] = to;
    });
  },

  // ─── Pool-event notifications ─────────────────
  // INLINE MIRROR of src/dashboard/pool-events.js (single-file "just open it"
  // constraint). src/dashboard/check-inline-sync.js asserts the two stay in sync —
  // edit BOTH. Turns the /pool-events heartbeat into deduped, transition-only
  // toasts (healthy ↔ cooling ↔ capacity ↔ quota ↔ disabled).
  _poolAccountState(a) {
    if (!a) return 'unknown';
    if (a.status === 'disabled') return 'disabled';
    if (a.status === 'banned') return 'banned';
    if (a.status === 'reauth') return 'reauth';
    if (a.status === 'error') return 'error';
    if (a.quotaCooled) return 'quota';
    if (a.rateLimited) return 'rate_limited';
    if (a.capacityThrottled) return 'capacity';
    return 'active';
  },
  _poolClassify(from, to) {
    if (from === to) return null;
    if (to === 'active') return { kind: 'recovered', severity: 'success' };
    const sev = (to === 'disabled' || to === 'quota' || to === 'banned' || to === 'reauth') ? 'error' : 'warn';
    return { kind: to, severity: sev };
  },
  _poolCollapse(events, collapseAt) {
    const byKind = new Map();
    for (const e of events) {
      if (!byKind.has(e.kind)) byKind.set(e.kind, []);
      byKind.get(e.kind).push(e);
    }
    const out = [];
    for (const [kind, group] of byKind) {
      if (group.length >= collapseAt) out.push({ type: 'summary', kind, severity: group[0].severity, count: group.length });
      else out.push(...group);
    }
    return out;
  },
  _poolPush(snapshot, now) {
    const st = this._poolState;
    const list = Array.isArray(snapshot) ? snapshot : [];
    if (!st.primed) {
      for (const a of list) st.lastState.set(a.id, this._poolAccountState(a));
      st.primed = true;
      return [];
    }
    const raw = [];
    const seen = new Set();
    for (const a of list) {
      if (!a || a.id == null) continue;
      seen.add(a.id);
      const to = this._poolAccountState(a);
      const from = st.lastState.get(a.id) ?? 'active';
      st.lastState.set(a.id, to);
      const t = this._poolClassify(from, to);
      if (!t) continue;
      if (st.lastEmitAt.has(a.id) && now - st.lastEmitAt.get(a.id) < st.backoffMs) continue;
      st.lastEmitAt.set(a.id, now);
      raw.push({ type: 'account', id: a.id, ref: a.ref || a.id, kind: t.kind, severity: t.severity, from, to });
    }
    for (const id of [...st.lastState.keys()]) {
      if (!seen.has(id)) { st.lastState.delete(id); st.lastEmitAt.delete(id); }
    }
    return this._poolCollapse(raw, st.collapseAt);
  },
  startPoolEvents() {
    this._poolState = { primed: false, lastState: new Map(), lastEmitAt: new Map(), backoffMs: 60000, collapseAt: 3 };
    const tick = async () => {
      if (!this.password && !this._noAuth) return;
      const r = await this.api('GET', '/pool-events').catch(() => null);
      if (!r || !Array.isArray(r.accounts)) return;
      // 20 s diff, no re-render: repaint only the rows whose resolved state
      // changed (UIResearch §3.12). The table itself refreshes on demand.
      this._patchAccountStates(r.accounts);
      const events = this._poolPush(r.accounts, Date.now());
      for (const e of events) {
        const sev = e.severity === 'success' ? 'success' : (e.severity === 'error' ? 'error' : 'info');
        const kindKey = (e.type === 'summary' ? 'summary_' : '') + e.kind;
        const msg = I18n.t('notify.pool.' + kindKey, e.type === 'summary' ? { count: e.count } : { ref: e.ref });
        this.toast(msg, sev);
      }
    };
    this.pollGlobal('pool-events', tick, 20000);
    tick();  // prime immediately (silent first tick establishes baseline)
  },

  // ─── Config save-diff gate ────────────────────
  // INLINE MIRROR of src/dashboard/diff.js (single-file constraint) —
  // src/dashboard/check-inline-sync.js keeps them in sync; edit BOTH.
  _diffToLines(value) {
    if (value == null) return [];
    const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2);
    return text.split('\n');
  },
  _lineDiff(before, after) {
    const a = this._diffToLines(before), b = this._diffToLines(after);
    const n = a.length, m = b.length;
    const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
    for (let i = n - 1; i >= 0; i--)
      for (let j = m - 1; j >= 0; j--)
        lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
    const out = []; let i = 0, j = 0;
    while (i < n && j < m) {
      if (a[i] === b[j]) { out.push({ op: 'ctx', text: a[i] }); i++; j++; }
      else if (lcs[i + 1][j] >= lcs[i][j + 1]) { out.push({ op: 'del', text: a[i] }); i++; }
      else { out.push({ op: 'add', text: b[j] }); j++; }
    }
    while (i < n) { out.push({ op: 'del', text: a[i] }); i++; }
    while (j < m) { out.push({ op: 'add', text: b[j] }); j++; }
    return out;
  },
  // Show a before→after diff modal for a dangerous write; commit only on confirm.
  // Returns the api() result on apply, or null when the user cancels / no change.
  async saveWithDiff({ before, after, method, path, body }) {
    const diff = this._lineDiff(before, after);
    const changed = diff.filter(d => d.op !== 'ctx');
    if (!changed.length) { this.toast(I18n.t('confirm.saveDiffNoChange'), 'info'); return null; }
    const add = changed.filter(d => d.op === 'add').length;
    const del = changed.filter(d => d.op === 'del').length;
    const rows = diff.map(d => {
      const cls = d.op === 'add' ? 'diff-add' : (d.op === 'del' ? 'diff-del' : 'diff-ctx');
      const sign = d.op === 'add' ? '+' : (d.op === 'del' ? '-' : ' ');
      return `<div class="diff-line ${cls}">${this.esc(sign + ' ' + d.text)}</div>`;
    }).join('');
    const html = `<div class="diff-stat">${this.esc(I18n.t('confirm.saveDiffStat', { add, del }))}</div>
      <pre class="diff-box">${rows}</pre>`;
    const ok = await this.confirm(I18n.t('confirm.saveDiffTitle'), html, { html: true, wide: true, okText: I18n.t('confirm.saveDiffApply') });
    if (!ok) return null;
    return this.api(method, path, body);
  },

  // ─── Modal helpers ───────────────────────────
  confirm(title, desc, opts = {}) {
    return new Promise(resolve => {
      const wrap = document.createElement('div');
      wrap.className = 'modal-overlay';
      const isDanger = !!opts.danger;
      // opts.html=true lets callers pass rich markup in `desc` (e.g. the
      // per-account blocked-models editor). opts.wide=true widens the modal
      // so the 2-col model grid has room to breathe.
      const descHtml = desc
        ? (opts.html
          ? `<div class="modal-body">${desc}</div>`
          : `<div class="modal-desc">${this.esc(desc)}</div>`)
        : '';
      const widthStyle = opts.wide ? ' style="max-width:640px;width:92vw"' : '';
      // Destructive confirmations require typing the exact scope (bulk delete
      // above the visible page). The OK button stays disabled until it matches.
      const requireText = opts.requireText == null ? '' : String(opts.requireText);
      const requireHtml = requireText
        ? `<div class="modal-body modal-confirm-require">
             <label class="field-label" for="modal-confirm-text">${this.esc(opts.requireTextLabel || I18n.t('confirm.typeToConfirm', { text: requireText }))}</label>
             <input id="modal-confirm-text" class="input" data-confirm-text autocomplete="off" spellcheck="false" inputmode="numeric">
           </div>`
        : '';
      // Title is trusted caller input but may contain <code>/<b>; use esc by
      // default to match prior behaviour.
      wrap.innerHTML = `
        <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-c"${widthStyle}>
          <div class="modal-header">
            <div class="modal-title" id="modal-title-c">${opts.titleHtml ? title : this.esc(title)}</div>
            ${opts.html ? '' : (desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : '')}
          </div>
          ${opts.html ? descHtml : ''}
          ${requireHtml}
          <div class="modal-footer">
            <button class="btn btn-ghost" data-act="cancel">${this.esc(opts.cancelText || I18n.t('button.cancel'))}</button>
            <button class="btn ${isDanger ? 'btn-danger' : 'btn-primary'}" data-act="ok"${requireText ? ' disabled' : ''}>${this.esc(opts.okText || I18n.t('button.confirm'))}</button>
          </div>
        </div>`;
      document.getElementById('modal-container').appendChild(wrap);
      const prevFocus = document.activeElement; // restore on close (a11y)
      const okBtn = wrap.querySelector('[data-act=ok]');
      const textInput = wrap.querySelector('[data-confirm-text]');
      const confirmMatches = () => !requireText || (textInput && textInput.value.trim() === requireText);
      if (textInput) {
        textInput.addEventListener('input', () => { okBtn.disabled = !confirmMatches(); });
      }
      const onKey = (e) => {
        // Defer Esc to an open custom-select dropdown (it closes itself first).
        if (document.querySelector('.cs-pop')) return;
        if (e.key === 'Escape') { e.preventDefault(); close(false); }
        else if (e.key === 'Tab') { this._trapFocus(e, wrap); }
        // P1 #1: a danger dialog must never approve on a bare Enter. Only a
        // deliberate click on the named destructive button may resolve true.
        else if (e.key === 'Enter' && !isDanger && e.target.tagName !== 'BUTTON') { e.preventDefault(); close(true); }
      };
      const close = (v) => {
        document.removeEventListener('keydown', onKey, true);
        wrap.remove();
        if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch {} }
        resolve(v);
      };
      wrap.querySelector('[data-act=cancel]').onclick = () => close(false);
      okBtn.onclick = () => { if (confirmMatches()) close(true); };
      wrap.onclick = (e) => { if (e.target === wrap) close(false); };
      document.addEventListener('keydown', onKey, true);
      // P1 #1: focus Cancel initially on destructive dialogs so an impatient
      // Enter/space cannot confirm an irreversible action. Ordinary dialogs keep
      // the affirmative default; a typed-confirmation dialog starts in its field.
      setTimeout(() => {
        const target = isDanger
          ? wrap.querySelector('[data-act=cancel]')
          : (textInput || wrap.querySelector('[data-act=ok]'));
        target?.focus();
      }, 50);
    });
  },

  // Keep Tab focus cycling inside an open modal (a11y focus trap). Wraps from the
  // last focusable to the first (and vice-versa with Shift+Tab), so keyboard
  // users can't tab out to the page behind the dialog.
  _trapFocus(e, wrap) {
    const f = wrap.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
    if (!f.length) return;
    const first = f[0];
    const last = f[f.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  },

  prompt(title, desc, fields) {
    return new Promise(resolve => {
      const wrap = document.createElement('div');
      wrap.className = 'modal-overlay';
      const fieldHtml = fields.map(f => `
        <div class="field mt-3">
          <label class="field-label">${this.esc(f.label)}</label>
          ${f.type === 'select' ? `
            <select class="select" data-name="${this.esc(f.name)}">
              ${f.options.map(o => `<option value="${this.esc(o.value)}" ${o.value === f.value ? 'selected' : ''}>${this.esc(o.label)}</option>`).join('')}
            </select>
          ` : `
            <input class="input" data-name="${this.esc(f.name)}" type="${f.type || 'text'}" placeholder="${this.esc(f.placeholder || '')}" value="${this.esc(f.value || '')}">
          `}
          ${f.hint ? `<div class="field-hint">${this.esc(f.hint)}</div>` : ''}
        </div>
      `).join('');
      wrap.innerHTML = `
        <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title-p">
          <div class="modal-header">
            <div class="modal-title" id="modal-title-p">${this.esc(title)}</div>
            ${desc ? `<div class="modal-desc">${this.esc(desc)}</div>` : ''}
          </div>
          <div class="modal-body">${fieldHtml}</div>
          <div class="modal-footer">
            <button class="btn btn-ghost" data-act="cancel">${I18n.t('button.cancel')}</button>
            <button class="btn btn-primary" data-act="ok">${I18n.t('button.confirm')}</button>
          </div>
        </div>`;
      document.getElementById('modal-container').appendChild(wrap);
      const prevFocus = document.activeElement; // restore on close (a11y)
      // Theme the modal's dynamic selects/number inputs. data-name stays on the
      // native element, so the [data-name].value harvest below is unaffected.
      this.enhanceControls(wrap);
      const submit = () => {
        const values = {};
        wrap.querySelectorAll('[data-name]').forEach(el => values[el.dataset.name] = el.value);
        close(values);
      };
      const onKey = (e) => {
        // Defer Esc to an open custom-select dropdown (it closes itself first).
        if (document.querySelector('.cs-pop')) return;
        if (e.key === 'Escape') { e.preventDefault(); close(null); }
        else if (e.key === 'Tab') { this._trapFocus(e, wrap); }
        // Enter submits from a text input (but not from a textarea or the custom
        // select trigger, which use Enter themselves).
        else if (e.key === 'Enter' && e.target.tagName === 'INPUT') { e.preventDefault(); submit(); }
      };
      const close = (v) => {
        document.removeEventListener('keydown', onKey, true);
        wrap.remove();
        if (prevFocus && prevFocus.focus) { try { prevFocus.focus(); } catch {} }
        resolve(v);
      };
      wrap.querySelector('[data-act=cancel]').onclick = () => close(null);
      wrap.querySelector('[data-act=ok]').onclick = submit;
      wrap.onclick = (e) => { if (e.target === wrap) close(null); };
      document.addEventListener('keydown', onKey, true);
      setTimeout(() => wrap.querySelector('input, select')?.focus(), 100);
    });
  },

  // ─── Overview ────────────────────────────────
  showSelfUpdateUnavailable(status, apply, response) {
    // The standard hint always works (run `docker compose pull && up -d`
    // on the host). When docker mode also reported a reason — e.g. the
    // user has docker.sock mounted but the container wasn't started by
    // compose — surface that detail too so they know how to opt in.
    const dockerHintParts = [];
    if (response && response.dockerReason && response.dockerReason !== 'no-docker-sock') {
      dockerHintParts.push(I18n.t('status.dockerSelfUpdateBlocked', {
        reason: this.esc(response.dockerReason),
        detail: this.esc(response.dockerDetail || ''),
      }));
    }
    status.innerHTML = `
      <div style="color: var(--warn-text);font-weight:600;margin-bottom:4px">${I18n.t('status.selfUpdateUnavailable')}</div>
      <div class="text-sm text-muted">${I18n.t('status.selfUpdateDockerHint')}</div>
      ${dockerHintParts.join('')}`;
    if (apply) {
      apply.classList.add('hidden');
      apply.disabled = true;
    }
  },

  async checkUpdate() {
    const btn = document.getElementById('btn-check-update');
    const status = document.getElementById('update-status');
    const apply = document.getElementById('btn-apply-update');
    btn.disabled = true;
    apply.disabled = true;
    status.classList.remove('hidden');
    status.innerHTML = '<span class="text-muted">' + I18n.t('status.checkingUpdate') + '</span>';
    try {
      const r = await this.api('GET', '/self-update/check');
      if (!r.ok) {
        if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') {
          this.showSelfUpdateUnavailable(status, apply, r);
          return;
        }
        if (/not a git repository/i.test(r.error || '')) {
          status.innerHTML = `
            <div style="color: var(--warn-text);font-weight:600;margin-bottom:4px">${I18n.t('status.notGitRepo')}</div>
            <div class="text-sm text-muted">${I18n.t('status.notGitRepoHint')}</div>`;
          apply.classList.add('hidden');
          return;
        }
        apply.classList.add('hidden');
        apply.disabled = true;
        throw new Error(this.translateError(r.error, 'error.apiError'));
      }
      if (r.mode === 'docker') {
        // Git mode unavailable but docker.sock is mounted — backend will
        // pull the image and spawn a deployer sidecar to recreate this
        // container in place. Show a "ready to apply" prompt instead of
        // the git diff card.
        status.innerHTML = `
          <div style="color: var(--accent-text);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerReady')}</div>
          <div class="text-sm" style="display:grid;gap:3px">
            <div>${I18n.t('status.dockerImage')} <code>${this.esc(r.image || '')}</code></div>
            <div>${I18n.t('status.dockerProject')} <code>${this.esc(r.project || '')}</code></div>
          </div>`;
        apply.classList.remove('hidden');
        apply.disabled = false;
        apply.dataset.mode = 'docker';
        return;
      }
      apply.dataset.mode = 'git';
      if (r.diverged) {
        status.innerHTML = `
          <div style="color: var(--warn-text);font-weight:600;margin-bottom:6px">${I18n.t('status.updateDiverged')}</div>
          <div class="text-sm text-muted">${I18n.t('status.updateDivergedHint', {
            current: this.esc(r.commit || ''),
            target: this.esc(r.remoteCommit || ''),
          })}</div>`;
        apply.classList.add('hidden');
        apply.disabled = true;
      } else if (r.behind) {
        const unreleasedNote = r.unreleasedCount > 0
          ? `<div class="text-sm text-muted">${I18n.t('status.unreleasedHint', { count: r.unreleasedCount, tag: this.esc(r.latestTag || '') })}</div>`
          : '';
        status.innerHTML = `
          <div style="color: var(--accent-text);font-weight:600;margin-bottom:6px">${I18n.t('status.updateAvailable')}</div>
          <div class="text-sm" style="display:grid;gap:3px">
            <div>${I18n.t('status.currentVersion')} <code>${this.esc(r.commit)}</code> <span class="text-muted">${this.esc(r.localMessage)}</span></div>
            <div>${I18n.t('status.remoteVersion')} <code>${this.esc(r.remoteCommit)}</code> <span class="text-muted">${this.esc(r.remoteMessage || '')}</span></div>
            ${unreleasedNote}
          </div>`;
        apply.classList.remove('hidden');
        apply.disabled = false;
      } else {
        const unreleasedNote = r.unreleasedCount > 0
          ? `<div class="text-sm text-muted" style="margin-top:6px">${I18n.t('status.unreleasedHint', { count: r.unreleasedCount, tag: this.esc(r.latestTag || '') })}</div>`
          : '';
        status.innerHTML = '<span style="color: var(--success-text)">✓ ' + I18n.t('status.upToDate', {commit: this.esc(r.commit), message: this.esc(r.localMessage)}) + '</span>' + unreleasedNote;
        apply.classList.add('hidden');
        apply.disabled = true;
      }
    } catch (err) {
      status.innerHTML = `<span style="color: var(--error-text)">✗ ${this.esc(err.message)}</span>`;
    } finally {
      btn.disabled = false;
    }
  },

  async applyUpdate() {
    const ok = await this.confirm(I18n.t('button.updateAndRestart'), I18n.t('status.updateAndRestartHint'), { okText: I18n.t('button.updateAndRestart'), danger: true });
    if (!ok) return;
    const status = document.getElementById('update-status');
    const apply = document.getElementById('btn-apply-update');
    apply.disabled = true;
    status.innerHTML = '<span class="text-muted">' + I18n.t('status.pullingRestarting') + '</span>';
    try {
      // Capture the old process identity before the update schedules its
      // restart.  Commit-only polling can be fooled by an unrelated listener
      // serving the new tree, and a same-PID response can be the old process
      // that never restarted at all.
      let previousHealth = null;
      try {
        const previousResponse = await fetch('/health', { cache: 'no-store' });
        if (previousResponse.ok) previousHealth = await previousResponse.json();
      } catch { /* the update endpoint remains the source of truth for start */ }
      let r = await this.api('POST', '/self-update');
      if (r.dirty) {
        const filesList = (r.dirtyFiles || []).slice(0, 10).map(f => this.esc(f)).join('<br>');
        const force = await this.confirm(
          I18n.t('status.workingDirDirty'),
          I18n.t('status.workingDirDirtyHint', { files: filesList }),
          { okText: I18n.t('button.forceOverride'), danger: true, html: true }
        );
        if (!force) {
          status.innerHTML = '<span class="text-muted">' + I18n.t('status.cancelled') + '</span>';
          apply.disabled = false;
          return;
        }
        r = await this.api('POST', '/self-update', { forceReset: true });
      }
      if (r.mode === 'docker') {
        if (!r.ok) {
          // r.reason is the short stable code ("deployer-pull-failed",
          // "deployer-create-failed", etc.) and the only thing we can
          // map to a localized i18n key. r.detail is the raw upstream
          // error string — long, unstable, often containing characters
          // that explode CSS selectors if treated as an i18n key. Show
          // reason as the localized message; show detail underneath
          // for debugging.
          const localized = this.translateError(r.reason, 'error.updateFailed');
          const msg = r.detail ? `${localized} — ${r.detail}` : localized;
          throw new Error(msg);
        }
        const delaySec = r.delaySeconds || 8;
        status.innerHTML = `
          <div style="color: var(--success-text);font-weight:600;margin-bottom:6px">${I18n.t('status.dockerUpdating', { image: this.esc(r.image || ''), delay: delaySec })}</div>
          <div class="text-sm text-muted">${I18n.t('status.dockerSidecarStarted', { id: this.esc(r.deployerId || '') })}</div>`;
        setTimeout(() => location.reload(), (delaySec + 4) * 1000);
        return;
      }
      if (!r.ok) {
        if (r.error === 'ERR_SELF_UPDATE_UNAVAILABLE') {
          this.showSelfUpdateUnavailable(status, apply, r);
          return;
        }
        const conflicts = r.error === 'ERR_IGNORED_PATH_CONFLICT' && Array.isArray(r.conflictPaths)
          ? r.conflictPaths.slice(0, 10).join(', ')
          : '';
        const message = this.translateError(r.error, 'error.updateFailed');
        throw new Error(conflicts ? `${message}: ${conflicts}` : message);
      }
      if (r.changed) {
        status.innerHTML = `
          <div style="color: var(--success-text);font-weight:600;margin-bottom:6px">${I18n.t('status.updateComplete', { before: this.esc(r.before), after: this.esc(r.after) })}</div>
          <div class="text-sm text-muted">${I18n.t('status.restarting')}</div>
          <pre style="margin-top:8px;font-size: 12px;max-height:200px;overflow:auto;padding:8px;background:var(--surface-2);border-radius:4px">${this.esc(r.pullOutput || '')}</pre>`;
        // 失败回滚:更新后轮询健康,30s 内服务未恢复 → 提示 + 一键回滚按钮
        this._pollHealthAfterUpdate(status, apply, {
          expectedCommit: r.after,
          previousPid: previousHealth?.pid,
        });
      } else {
        status.innerHTML = '<span class="text-muted">' + I18n.t('status.noUpdateNeeded') + '</span>';
        apply.classList.add('hidden');
      }
    } catch (err) {
      status.innerHTML = `<span style="color: var(--error-text)">✗ ${this.esc(err.message)}</span>`;
    } finally {
      apply.disabled = false;
    }
  },

  _pollHealthAfterUpdate(status, apply, { expectedCommit = '', previousPid = null } = {}) {
    let tries = 0;
    const maxTries = 15; // 2s × 15 = 30s
    const expected = typeof expectedCommit === 'string'
      ? expectedCommit.trim().toLowerCase()
      : '';
    const oldPid = Number.isSafeInteger(previousPid) && previousPid > 0
      ? previousPid
      : null;
    const isCommitId = value => /^[0-9a-f]{7,64}$/.test(value);
    const healthMatchesUpdate = (h) => {
      if (!h || h.status !== 'ok' || !expected) return false;
      const actual = typeof h.commit === 'string' ? h.commit.trim().toLowerCase() : '';
      const abbreviated = Math.min(actual.length, expected.length) <= 12;
      const commitsCorrespond = actual === expected
        || (abbreviated && (actual.startsWith(expected) || expected.startsWith(actual)));
      if (!isCommitId(expected) || !isCommitId(actual) || !commitsCorrespond) return false;
      const pid = h.pid;
      if (!Number.isSafeInteger(pid) || pid <= 0) return false;
      // When the old process identity was observable, require a genuinely new
      // process.  This closes the same-commit/same-listener false-success path.
      return oldPid === null || pid !== oldPid;
    };
    const check = async () => {
      try {
        // 轮询公共 /health(不是 /dashboard/api/*)—— dashboard API 没有 /health 路由
        const hr = await fetch('/health', { cache: 'no-store' });
        if (!hr.ok) throw new Error(`health ${hr.status}`);