/* ============================================================================
 * myvpn console — v2 (redesigned) stylesheet.
 *
 * Loaded ONLY by templates/v2/*.html; P6-2 deleted the legacy console and its v1
 * stylesheet, so this is the only shared stylesheet (the per-page files under pages/
 * layer on top of it). Every token is nevertheless scoped to .v2-root rather than
 * :root — belt and braces, and it keeps a stray future page from inheriting v2
 * colors by accident.
 *
 * Theme structure (the four-block shape the deleted v1 stylesheet used, kept):
 *   .v2-root                                   → light baseline
 *   @media (prefers-color-scheme: dark)        → auto-dark (NO data-theme attr)
 *   html[data-theme="dark"|"vscode"] .v2-root  → explicit dark
 *   html[data-theme="oled"] .v2-root           → explicit dark, pure black
 *
 * The auto-dark media query is not optional: SystemTheme defaults to "auto",
 * and the header only emits data-theme when the value is non-empty and not
 * "auto". Skipping that block would leave every default user stuck in light
 * mode no matter what their OS says.
 * ========================================================================== */

/* ── Self-hosted Geist (SIL OFL 1.1; see fonts/OFL.txt, served alongside). ──
 * Self-hosted rather than Google Fonts on purpose: coord is often reached from
 * networks where fonts.googleapis.com is slow or unreachable, and a blocking
 * webfont on the critical path is worse than no webfont at all. font-display:
 * swap so text paints immediately regardless.
 *
 * Geist has NO CJK glyphs. Chinese falls through to the system stack below —
 * that is intended, not a gap. Latin text and (crucially) all the tabular
 * numbers, IPs, endpoints and versions are what Geist is here for. */
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 400; font-display: swap; src: url('fonts/geist-sans-400.woff2') format('woff2'); }
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 500; font-display: swap; src: url('fonts/geist-sans-500.woff2') format('woff2'); }
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 600; font-display: swap; src: url('fonts/geist-sans-600.woff2') format('woff2'); }
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 700; font-display: swap; src: url('fonts/geist-sans-700.woff2') format('woff2'); }
@font-face { font-family: 'Geist Mono'; font-style: normal; font-weight: 400; font-display: swap; src: url('fonts/geist-mono-400.woff2') format('woff2'); }
@font-face { font-family: 'Geist Mono'; font-style: normal; font-weight: 500; font-display: swap; src: url('fonts/geist-mono-500.woff2') format('woff2'); }

/* ─────────────────────────── Tokens: light ─────────────────────────── */
.v2-root {
  color-scheme: light;

  --canvas: #fafafa;
  --bg: #ffffff;
  --surface: #ffffff;
  --surface-2: #f6f6f7;
  --surface-3: #f0f0f2;
  --border: #ececee;
  --border-2: #dfdfe2;
  --fg: #18181b;
  --fg-2: #52525b;
  --muted: #8a8a94;
  --subtle: #b6b6be;

  --accent: #5a63e6;
  --accent-fg: #ffffff;
  /* Solid fallback FIRST, color-mix second. When color-mix is unsupported the
   * whole declaration is dropped, and an undefined --accent-soft would leave the
   * active nav item with no background at all — i.e. you could not tell which
   * page you were on. The rgba line keeps that from ever happening. */
  --accent-soft: rgba(90, 99, 230, .11);
  --accent-soft: color-mix(in srgb, var(--accent) 11%, transparent);
  --accent-line: rgba(90, 99, 230, .26);
  --accent-line: color-mix(in srgb, var(--accent) 26%, transparent);

  --online: #16a34a;
  --online-soft: #e9f7ee;
  --offline: #a1a1aa;
  --danger: #dc2626;
  --danger-soft: #fdecec;
  --warn: #c2740a;
  --warn-soft: #fbf1e3;
  /* --attn is the "needs attention" step between warn and danger (relay health).
   * Not in the handoff token table — lifted from the Operations prototype. */
  --attn: #bc4c00;
  --attn-soft: #fdeee2;

  /* Topology connectivity matrix. Kept here (not in a topology-only file) so the
   * whole palette lives in one place.
   *
   * NOTE: the design handoff's token table lists only 7 matrix states, but
   * deriveCellStatus() emits NINE. The missing one is --m-unverified
   * (cell-direct-unverified, "直连·待确认"): the endpoint is still direct and
   * carrying traffic, but this end cannot actively verify the direct probe — it
   * may be a public→NAT reverse-path illusion (link is fine), or the direct path
   * may genuinely be degrading. Folding it into --m-fake (red) would cry wolf;
   * folding it into --m-direct (green) would hide a real degradation. It needs its
   * own colour. Teal, carried over from the legacy --cell-unverified-*. */
  --m-direct-bg: #e9f7ee;     --m-direct-fg: #15803d;
  --m-degrade-bg: #fbf1e3;    --m-degrade-fg: #b45309;
  --m-unverified-bg: #e0f2f1; --m-unverified-fg: #00695c;
  --m-fake-bg: #fdecec;       --m-fake-fg: #dc2626;
  --m-relay-bg: #fdefe0;      --m-relay-fg: #c2560a;
  --m-warm-bg: #eef2f8;       --m-warm-fg: #5b6b86;
  --m-none-bg: #f4f4f6;       --m-none-fg: #b0b0b8;
  --m-self-bg: #fafafa;

  --shadow-sm: 0 1px 2px rgba(24, 24, 27, .05);
  --shadow-lg: 0 1px 3px rgba(24, 24, 27, .06), 0 24px 48px -20px rgba(24, 24, 27, .22);

  --font: 'Geist', system-ui, -apple-system, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif;
  --font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}

/* ── Tokens: dark. Three selectors share one block so auto-dark and manual-dark
 * are guaranteed identical (the same rule app.css states in a comment). ── */
@media (prefers-color-scheme: dark) {
  html:not([data-theme]) .v2-root {
    color-scheme: dark;
    --canvas: #08080a;
    --bg: #0c0c0f;
    --surface: #141417;
    --surface-2: #18181c;
    --surface-3: #1e1e23;
    --border: #242429;
    --border-2: #2e2e35;
    --fg: #ededf0;
    --fg-2: #a9a9b3;
    --muted: #71717a;
    --subtle: #4b4b54;
    --accent-soft: rgba(90, 99, 230, .20);
    --accent-soft: color-mix(in srgb, var(--accent) 20%, transparent);
    --accent-line: rgba(90, 99, 230, .42);
    --accent-line: color-mix(in srgb, var(--accent) 42%, transparent);
    --online: #3ecf8e;
    --online-soft: #0f2a20;
    --offline: #5b5b64;
    --danger: #f2555a;
    --danger-soft: #2a1416;
    --warn: #e0a340;
    --warn-soft: #2a2012;
    --attn: #f0883c;
    --attn-soft: #2a1a0e;
    --m-direct-bg: #0f2a20;     --m-direct-fg: #3ecf8e;
    --m-degrade-bg: #2a2012;    --m-degrade-fg: #e0a340;
    --m-unverified-bg: #0c2626; --m-unverified-fg: #39c5cf;
    --m-fake-bg: #2a1416;       --m-fake-fg: #f2555a;
    --m-relay-bg: #2a1a0e;      --m-relay-fg: #f0883c;
    --m-warm-bg: #14202e;       --m-warm-fg: #8aa4c8;
    --m-none-bg: #161619;       --m-none-fg: #52525b;
    --m-self-bg: #0e0e11;
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
    --shadow-lg: 0 1px 3px rgba(0, 0, 0, .5), 0 28px 56px -22px rgba(0, 0, 0, .75);
  }
}

html[data-theme="dark"] .v2-root,
html[data-theme="oled"] .v2-root,
html[data-theme="vscode"] .v2-root {
  color-scheme: dark;
  --canvas: #08080a;
  --bg: #0c0c0f;
  --surface: #141417;
  --surface-2: #18181c;
  --surface-3: #1e1e23;
  --border: #242429;
  --border-2: #2e2e35;
  --fg: #ededf0;
  --fg-2: #a9a9b3;
  --muted: #71717a;
  --subtle: #4b4b54;
  --accent-soft: rgba(90, 99, 230, .20);
  --accent-soft: color-mix(in srgb, var(--accent) 20%, transparent);
  --accent-line: rgba(90, 99, 230, .42);
  --accent-line: color-mix(in srgb, var(--accent) 42%, transparent);
  --online: #3ecf8e;
  --online-soft: #0f2a20;
  --offline: #5b5b64;
  --danger: #f2555a;
  --danger-soft: #2a1416;
  --warn: #e0a340;
  --warn-soft: #2a2012;
  --attn: #f0883c;
  --attn-soft: #2a1a0e;
  --m-direct-bg: #0f2a20;     --m-direct-fg: #3ecf8e;
  --m-degrade-bg: #2a2012;    --m-degrade-fg: #e0a340;
  --m-unverified-bg: #0c2626; --m-unverified-fg: #39c5cf;
  --m-fake-bg: #2a1416;       --m-fake-fg: #f2555a;
  --m-relay-bg: #2a1a0e;      --m-relay-fg: #f0883c;
  --m-warm-bg: #14202e;       --m-warm-fg: #8aa4c8;
  --m-none-bg: #161619;       --m-none-fg: #52525b;
  --m-self-bg: #0e0e11;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, .4);
  --shadow-lg: 0 1px 3px rgba(0, 0, 0, .5), 0 28px 56px -22px rgba(0, 0, 0, .75);
}

/* OLED keeps the dark palette but drops the backdrop to true black — that is the
 * entire point of the theme (unlit pixels). Only the surfaces move. */
html[data-theme="oled"] .v2-root {
  --canvas: #000000;
  --bg: #000000;
  --surface: #0a0a0c;
  --surface-2: #101013;
  --surface-3: #17171b;
  --border: #1c1c21;
  --border-2: #26262c;
  --m-self-bg: #050506;
}

/* ────────────────────────────── Reset / base ────────────────────────────── */
.v2-root *, .v2-root *::before, .v2-root *::after { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }

.v2-root {
  font-family: var(--font);
  font-size: 14px;
  line-height: 1.5;
  color: var(--fg);
  background: var(--canvas);
  -webkit-font-smoothing: antialiased;
}
/* The anchor + button defaults. :where() is load-bearing, not decoration — it drops these
 * to (0,0,1) / (0,1,1) so they are a FALLBACK any component can override, not a command.
 *
 * They used to be `.v2-root a` (0,1,1) / `.v2-root a:hover` (0,2,1) and `.v2-root button`
 * (0,1,1). Since .v2-root is on <body>, that beat every "single class sets color on an
 * <a> or <button>" rule (0,1,0). On <a> the color in .v2-navitem (--fg-2), .v2-rowact,
 * .v2-btn (--fg-2) and .v2-back (--muted) was DEAD CODE and all rendered accent-blue
 * (Issue #431, fixed #435). On <button> EVERY classed button (.v2-btn, .v2-rowact, .v2-chip,
 * .v2-link, .v2-iconbtn, .v2-devlayout-opt …) rendered the inherited --fg
 * instead of its own color, and .v2-btn:hover (which only darkens text --fg-2→--fg)
 * became a pure no-op — buttons had NO hover feedback at all (Issue #436). The anchor
 * :hover half also killed `text-decoration: none` in .v2-navitem:hover / .v2-back:hover,
 * so nav links underlined on hover. Patched locally three times (the .v2-acct-menu
 * .v2-menu-item block below, pages/disconnects.css:39, pages/help.css:139) — never centrally.
 *
 * :where() rather than `:not([class])`: a classed <a>/<button> that deliberately declares
 * NO color (.v2-exit-exp, .v2-dc-detail; .v2-menu-item wanting --fg) must still get the
 * fallback. :not([class]) would stop matching classed elements entirely and drop them to
 * the inherited color — a regression. Here they keep the fallback, and anything with its
 * own color wins on (0,1,0) alone. NB .v2-rowact now deliberately sets --fg-2 (a user
 * design choice, not its original --muted) — see its rule below. */
:where(.v2-root) a { color: var(--accent); text-decoration: none; }
:where(.v2-root) a:hover { text-decoration: underline; }
:where(.v2-root) button { font-family: inherit; color: inherit; cursor: pointer; }
.v2-root input, .v2-root select, .v2-root textarea { font-family: inherit; color: inherit; }
.v2-root input::placeholder { color: var(--muted); }
.v2-root select { appearance: none; -webkit-appearance: none; }
.v2-root h1, .v2-root h2, .v2-root h3 { margin: 0; }
/* The UA stylesheet's [hidden] { display: none } is a LOW-specificity rule, so any
 * author rule that sets display — .v2-drawer{display:flex}, .v2-bulkbar{display:flex}
 * — silently beats it and the element shows up anyway. Every overlay here is
 * toggled by the hidden attribute, so without this the drawer and the bulk bar are
 * permanently on screen. (Caught by actually rendering the page; no unit test would
 * have seen it.) */
.v2-root [hidden] { display: none !important; }
.v2-root code { font-family: var(--font-mono); font-size: .92em; }
.v2-root ::-webkit-scrollbar { width: 10px; height: 10px; }
.v2-root ::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
.v2-root ::-webkit-scrollbar-track { background: transparent; }
.v2-root :focus-visible { outline: 2px solid var(--accent-line); outline-offset: 1px; }

.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.v2-dash { color: var(--subtle); }
.v2-muted { color: var(--muted); }
.v2-warn { color: var(--warn); }
.v2-ok { color: var(--online); }

/* ── Fragments the SERVER writes, in the legacy class names.
 *
 * Four handlers bypass the templates entirely and emit HTML from Go:
 *
 *   note_change.go     <p class="err">…   devices note dialog
 *   vip_change.go      <p class="err">…   devices IP dialog
 *   help_messages.go   <p class="err">…   message board (/ui/board)
 *   terminal_theme.go  <p class="ok">…    terminal theme save
 *
 * They are swapped into a v2 page with LEGACY class names that only the deleted v1
 * stylesheet styled. Without these two rules a rejected message or a bad IP comes back
 * as unstyled black body text — the user is told "no" in the same colour as "yes".
 * The template cannot fix this; only the stylesheet can. */
.v2-root .err { color: var(--danger); font-size: 12px; margin: 8px 0 0; }
.v2-root .ok  { color: var(--online); font-size: 12px; margin: 8px 0 0; }

/* ───────────────────────────────── Shell ───────────────────────────────── */
/* height:100dvh (100vh first as the fallback for browsers without dvh) so the
 * shell tracks the VISIBLE viewport on iOS/iPadOS Safari, whose 100vh is the LARGE
 * viewport that excludes the toolbar — under plain 100vh the shell is taller than
 * the screen and the sidebar footer (account menu, the collapse toggle)
 * is pushed below the fold. MUST be unscoped: iPad landscape is a desktop-width
 * (>900px) touch browser WITH a dynamic toolbar, so the old mobile-only (≤900px)
 * dvh switch missed it. On desktop dvh === vh, so this is a no-op there. */
.v2-shell { display: flex; flex-direction: column; height: 100vh; height: 100dvh; overflow: hidden; }
/* min-height:0 is load-bearing. Without it the read-only banner pushes the flex
 * row past 100vh instead of letting the scrolling table shrink, and the whole
 * page (sidebar included) grows an outer scrollbar. */
.v2-body { flex: 1; display: flex; min-height: 0; }
.v2-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }

/* The read-only standby banner is deliberately THEME-INDEPENDENT, reusing the
 * v1 .ro-banner #4d3800/#ffd666 exactly (that stylesheet went in P6-2). Recoloring it
 * to var(--warn) would fade it into near-invisibility under the OLED palette —
 * precisely defeating the one job it has. */
.v2-ro-banner {
  flex: 0 0 auto;
  background: #4d3800; color: #ffd666;
  text-align: center; padding: .5rem; font-size: 12.5px;
}

/* ──────────────────────────────── Sidebar ──────────────────────────────── */
.v2-sidebar {
  width: 240px; flex-shrink: 0;
  display: flex; flex-direction: column;
  background: var(--surface-2);
  border-right: 1px solid var(--border);
  padding: 20px 12px;
  /* Smooth the desktop collapse (240px ↔ 64px icon rail). The narrow drawer rule
   * re-declares transition (transform/visibility), so this width tween is inert
   * there and cannot fight the slide-in animation. */
  transition: width .16s ease;
}
.v2-brand { display: flex; align-items: center; gap: 9px; padding: 4px 8px 14px; }
.v2-logo {
  width: 28px; height: 28px; border-radius: 8px; background: var(--accent);
  display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.v2-brand-name { font-size: 15.5px; font-weight: 600; letter-spacing: -.01em; }
.v2-ver-pill {
  margin-left: auto; font-family: var(--font-mono); font-size: 10px;
  background: var(--surface-3); color: var(--subtle);
  padding: 2px 6px; border-radius: 5px; white-space: nowrap;
}

/* All 11 nav items must FIT, not scroll: an overflow-scrolled nav silently hides the
 * last item (留言板) below the fold — you don't get a scrollbar hint, the item is just
 * gone. At the paddings below the owner's 4 group headers + 11 items fit with the
 * footer in place in a viewport ≥ ~693px tall (a 1440×900 laptop gives ~780); the
 * max-height block after these rules squeezes them for shorter windows. Both are
 * pinned by TestBrowserBoardKeepsLineBreaksAndIsReachableFromNav (780 and 650).
 * overflow-y stays as a safety net for windows shorter still. */
.v2-nav { flex: 1 1 auto; overflow-y: auto; min-height: 0; }
.v2-navgroup {
  font-size: 10.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase;
  color: var(--subtle); padding: 11px 8px 4px;
}
.v2-navgroup:first-child { padding-top: 6px; }
.v2-navitem {
  position: relative; display: flex; align-items: center; gap: 11px;
  padding: 7px 10px; border-radius: 9px;
  font-size: 13.5px; color: var(--fg-2);
}
.v2-navitem:hover { background: var(--surface-3); text-decoration: none; color: var(--fg); }
.v2-navitem svg { flex-shrink: 0; }
.v2-navitem.active { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
/* Short windows (a 1366×768 laptop leaves ~625–660px): tighter vertical padding, so
 * the owner's full nav still fits down to a ~611px viewport. Taller windows keep the
 * paddings above unchanged. The collapsed rail's .v2-navgroup override still wins. */
@media (max-height: 720px) {
  .v2-navgroup { padding-top: 7px; padding-bottom: 3px; }
  .v2-navitem { padding-top: 4px; padding-bottom: 4px; }
}
/* left:0, NOT a negative offset: .v2-nav{overflow-y:auto} forces computed
 * overflow-x:auto, making it a scroll container that CLIPS left-overflowing
 * content (and leftward overflow is unreachable — no scrollbar ever appears).
 * The original left:-12px aimed at the sidebar's padding gutter and was silently
 * clipped 100% of the time; at left:0 the bar sits on the item's own left edge,
 * inside the scrollport, and actually paints — in both expanded and collapsed
 * (rail) states, which is why the rail needs no override. */
.v2-navitem.active::before {
  content: ""; position: absolute; left: 0; top: 50%; transform: translateY(-50%);
  width: 3px; height: 18px; border-radius: 2px; background: var(--accent);
}
.v2-count-pill {
  margin-left: auto; font-family: var(--font-mono); font-size: 11px;
  background: var(--accent); color: var(--accent-fg);
  padding: 1px 6px; border-radius: 20px; line-height: 1.45;
}

/* position:relative makes this the containing block for the account menu below.
 * z-index stays auto, so it creates NO stacking context and the foot's background
 * stays transparent — provably inert at both breakpoints. */
.v2-sidebar-foot { position: relative; margin-top: auto; display: flex; flex-direction: column; gap: 4px; padding-top: 12px; }
.v2-sidebar-foot form { margin: 0; }
.v2-theme-btn {
  width: 100%; display: flex; align-items: center; gap: 9px;
  padding: 8px 10px; border-radius: 9px; font-size: 13px;
  background: transparent; color: var(--fg-2); border: 1px solid transparent;
}
.v2-theme-btn:hover:not([disabled]) { background: var(--surface-3); color: var(--fg); }
.v2-theme-btn[disabled] { opacity: .45; cursor: not-allowed; }

.v2-user {
  display: flex; align-items: center; gap: 9px;
  margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);
}
.v2-avatar {
  width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
  background: var(--surface-3); color: var(--fg-2);
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 600; text-transform: uppercase;
}
.v2-user-meta { min-width: 0; flex: 1; }
.v2-user-email {
  display: block; font-size: 12.5px; font-weight: 500;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.v2-user-role { display: block; font-size: 11px; color: var(--muted); }
/* ───────────────── Account menu (sidebar footer avatar) ───────────────── */
/* Deliberately NOT .v2-popover (see partials.html). Two more things make this a
 * different animal from the row menu below: it is CSS-anchored rather than
 * JS-measured (no inline styles, so nothing needs !important at narrow), and it is
 * server-rendered inside the shell rather than cloned into #v2-overlays (so no
 * busy() poll gate).
 *
 * NO z-index, on purpose. .v2-sidebar is not a stacking context at desktop (its
 * whole rule above sets no position, no z-index, no transform, no isolation), so
 * this menu paints in the ROOT stacking context at painting step 8 — above the
 * unpositioned .v2-nav, and above .v2-navitem{position:relative} (same step, and
 * the nav is earlier in tree order). Giving it a z-index would PROMOTE it to step
 * 9, where .v2-table thead th{z-index:1}, .v2-matrix's sticky z-1/2/3 and
 * .v2-fold-set{z-index:1} live — all inside .v2-main, all LATER in tree order, all
 * of which would then paint OVER it. The only reason none of that bites is
 * GEOMETRY: left:0/right:0 keeps the menu inside the sidebar's 216px content column
 * (x ∈ [12,228]) and --shadow-lg reaches x ≤ 232, while .v2-main starts at x = 240.
 * Give this menu min-width > 216px, a negative left, left:100%, or "let it float
 * past the sidebar edge", and it slides SILENTLY under a sticky <thead>. Don't.
 *
 * AMENDMENT (collapsed rail): the ≥680px .v2-shell.nav-collapsed override is the
 * ONE sanctioned exception — there the 64px rail cannot hold the menu, so it
 * becomes a position:fixed flyout past the sidebar edge with an explicit
 * z-index:10 (> the z-1/2/3 theads it now crosses, < every fixed overlay). All
 * of the above still binds the EXPANDED menu; see the collapse section for the
 * flyout's own argument.
 *
 * At narrow none of this matters: .v2-sidebar is fixed + z-86 + transform, hence a
 * stacking context, so the menu paints inside it, above the z-85 scrim, for free. */
.v2-acct-menu {
  /* bottom:100% resolves against .v2-sidebar-foot, so the menu opens above the WHOLE
   * footer (the collapse toggle included, where it shows), not just the avatar row.
   * (The 切回旧版 button that used to sit in that footer was deleted at P6-3 K3.) */
  position: absolute; left: 0; right: 0; bottom: calc(100% + 6px);
  /* .v2-shell{overflow:hidden} clips at y=0 and this menu cannot escape it (absolute,
   * not fixed). The cap + internal scroll is the only rescue on a very short window.
   * 150 ≈ footer 110 + .v2-sidebar padding-bottom 20 + 20 breathing. At the real worst
   * cases the menu top is 34–89px, so this almost never engages.
   * ⚠ This 150 is only valid where the footer is 110px tall — i.e. at NARROW,
   * where the collapse toggle is display:none. At ≥680px the toggle adds ~40px to
   * the footer and the reserve is re-derived to 190 in the collapse section;
   * change the footer's height and BOTH numbers move.
   * ⚠ Both were derived WITH the 切回旧版 row in the footer. P6-3 K3 deleted that row
   * (one footer button plus its 4px gap — the same kind of row as the toggle's ~40px)
   * and did NOT re-derive them (TestV2ShellHeightUsesDvhUnscoped pins both): they now
   * over-reserve by that row — the safe direction, the menu only starts scrolling sooner. */
  /* dvh with the vh line first as fallback, matching .v2-shell — the menu's ceiling
   * is the shell, which now tracks the visible viewport at every width. */
  max-height: calc(100vh - 150px); max-height: calc(100dvh - 150px); overflow-y: auto;
  padding: 5px;
  background: var(--bg);
  border: 1px solid var(--border-2); border-radius: 11px;
  box-shadow: var(--shadow-lg);
  /* v2modal (translateY(8px)→0), NOT v2pop (translateY(-6px)→0): v2pop is a drop-DOWN
   * and this menu opens UP. Same 120ms, existing keyframe, right direction. */
  animation: v2modal .12s ease-out;
}

/* Every row here is icon + label, like a nav item — override the row menu's base
 * .v2-menu-item{display:block} below.
 * The `color` here is now REDUNDANT and kept only so this block reads as one unit:
 * it used to be load-bearing, because the old `.v2-root a` (0,1,1) beat
 * .v2-menu-item{color:var(--fg)} at (0,1,0) and these <a> rendered accent-blue. The
 * anchor default is :where()-scoped now (see :211), so .v2-menu-item wins on its own
 * and this declaration merely restates it. Still ties with .v2-menu-item.danger —
 * which is LATER in this file and therefore still wins for 登出. Don't move this down. */
.v2-acct-menu .v2-menu-item {
  display: flex; align-items: center; gap: 9px;
  padding: 8px 10px; border-radius: 8px; color: var(--fg);
}
/* Still REQUIRED: .v2-menu-item:hover declares no text-decoration, so without this the
 * (0,1,1) anchor-hover fallback underlines these. (0,3,0) beats it comfortably. */
.v2-acct-menu .v2-menu-item:hover { text-decoration: none; }
.v2-acct-menu .v2-menu-item svg { flex-shrink: 0; }
.v2-acct-menu .v2-menu-item.active {
  background: var(--accent-soft); color: var(--accent); font-weight: 500;
}
/* .v2-theme-btn keeps its class attribute byte-exact (a test pins
 * `class="v2-theme-btn" disabled`), so it is harmonised by descendant selector rather
 * than by joining the class list. It already has width:100% / display:flex /
 * align-items:center / gap:9px / padding:8px 10px / font-size:13px from its own rule
 * above; only colour and radius need aligning. Its [disabled]{opacity:.45} still
 * applies, so the standby state reads correctly inside the menu. */
.v2-acct-menu .v2-theme-btn { border-radius: 8px; color: var(--fg); }
.v2-acct-menu .v2-theme-btn:hover:not([disabled]) { background: var(--surface-2); }

/* Trigger. The negative margin cancels the button's own padding on BOTH axes so the
 * closed footer is pixel-identical to before (avatar still at x=0) while hover/expanded
 * still gets a 228px background — it bleeds 6px into the sidebar's 12px gutter, which is
 * half of it, and stays far from .v2-main at x=240. .v2-user itself is unchanged. */
.v2-user-btn {
  flex: 1; min-width: 0;
  display: flex; align-items: center; gap: 9px;
  padding: 5px 6px; margin: -5px -6px;
  border: 0; border-radius: 9px;
  background: transparent; color: inherit; text-align: left; font: inherit;
}
.v2-user-btn:hover,
.v2-user-btn[aria-expanded="true"] { background: var(--surface-3); }
/* icon_chevron points DOWN; the menu opens UP, so the resting state is flipped and it
 * rotates back to down (= "collapse") when open. The define's inline style is
 * pointer-events/opacity only, so a class rule owns transform uncontested. */
.v2-user-btn > svg {
  flex-shrink: 0; margin-left: 2px;
  transform: rotate(180deg); transition: transform .15s ease;
}
.v2-user-btn[aria-expanded="true"] > svg { transform: rotate(0deg); }

/* "You are here" for the three pages that left the nav and no longer get
 * .v2-navitem.active. Same accent language as the nav, at avatar scale. */
.v2-user.here .v2-avatar { background: var(--accent); color: var(--accent-fg); }
.v2-user.here .v2-user-email { color: var(--accent); }

/* ──────────────────────────────── Toolbar ──────────────────────────────── */
.v2-toolbar {
  display: flex; align-items: center; gap: 14px;
  padding: 22px 32px 18px; border-bottom: 1px solid var(--border);
  background: var(--bg);
}
.v2-toolbar h1 { font-size: 21px; font-weight: 600; letter-spacing: -.015em; }
.v2-subline { display: flex; align-items: center; gap: 6px; margin-top: 3px; font-size: 12.5px; color: var(--muted); }
/* Title row: h1 + admin account-filter dropdown on the right (v1 had the dropdown in
 * the toolbar; v2 surfaces it next to the title so an admin can scope the table to one
 * account without scrolling). Callers without device.view@all see only the h1. The
 * dropdown uses the standard .v2-select skin (container + icon_user + icon_chevron),
 * identical to the devices/topology account filter; .ops-acct-pick is retained as a
 * semantic + test anchor only (v2_topology_ops_test asserts the string). */
.ops-title-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.ops-acct-pick { white-space: nowrap; }
.v2-spacer { flex: 1; }
.v2-controls { display: flex; align-items: center; gap: 10px; margin: 0; }

.v2-select {
  display: flex; align-items: center; gap: 7px;
  height: 38px; padding: 0 11px 0 13px;
  border: 1px solid var(--border-2); border-radius: 10px;
  background: var(--surface); color: var(--fg-2);
}
.v2-select select { border: 0; background: transparent; font-size: 13px; outline: none; max-width: 170px; }
.v2-select:focus-within { border-color: var(--accent-line); }

.v2-search {
  display: flex; align-items: center; gap: 8px;
  width: 230px; height: 38px; padding: 0 13px;
  border: 1px solid var(--border-2); border-radius: 10px;
  background: var(--surface-2); color: var(--muted);
}
.v2-search input { flex: 1; min-width: 0; border: 0; background: transparent; font-size: 13px; outline: none; }
.v2-search:focus-within { border-color: var(--accent-line); }

.v2-iconbtn {
  width: 38px; height: 38px; flex-shrink: 0;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--border-2); border-radius: 10px;
  background: var(--surface); color: var(--fg-2);
}
.v2-iconbtn:hover { border-color: var(--accent-line); color: var(--accent); }
.v2-iconbtn.sm { width: 30px; height: 30px; border-radius: 8px; }
.v2-iconbtn.htmx-request svg { animation: v2spin .6s linear infinite; }

.v2-banner-err {
  margin: 12px 32px 0; padding: 9px 13px; border-radius: 9px;
  background: var(--danger-soft); color: var(--danger); font-size: 12.5px;
}

/* ───────────────────────────── Filter chips ───────────────────────────── */
.v2-chips { display: flex; gap: 7px; padding: 14px 32px 6px; flex-wrap: wrap; }
.v2-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 13px; border-radius: 20px; font-size: 12.5px;
  background: var(--surface-2); color: var(--fg-2); border: 1px solid var(--border);
}
.v2-chip:hover { border-color: var(--border-2); color: var(--fg); }
.v2-chip span { font-family: var(--font-mono); font-size: 11px; opacity: .6; }
.v2-chip.on { background: var(--fg); color: var(--bg); border-color: var(--fg); font-weight: 500; }

/* ───────────────────────────────── Table ───────────────────────────────── */
/* The scroll container is a SEPARATE element from the table because the table is
 * what the 10s poll replaces. Putting overflow:auto on the table's parent means
 * scrollTop survives every swap. (The v1 page scrolled the window and jumped
 * back to the top every 10 seconds.) */
.v2-scroll { flex: 1; overflow: auto; padding: 2px 16px 44px; }

.v2-table { width: 100%; border-collapse: collapse; font-size: 13px; min-width: 960px; }
.v2-table thead th {
  position: sticky; top: 0; z-index: 1;
  text-align: left; padding: 10px; background: var(--canvas);
  font-size: 11px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
  color: var(--muted); white-space: nowrap;
}
.v2-table td { padding: 12px 10px; border-top: 1px solid var(--border); vertical-align: middle; }
.v2-table .col-check { width: 34px; }
.v2-table .col-menu { width: 40px; }
/* 富空态:把「一行小字浮在大片空白里」升级成有图标、有呼吸感、读作「有意的状态」而非
 * 「加载中 / 坏了」。作用于共享 .v2-empty,全站空页(断连 / IP回收 / 出口 / 终端 /
 * 账户 auth-key …)统一受益。图标用中性圆环(不预设「好/坏」);逐页需要时可另加语义色
 * (如断连页空=健康,可在该页 CSS 覆盖成绿勾)。 */
.v2-empty { padding: 46px 20px 50px; text-align: center; color: var(--muted); font-size: 13px; line-height: 1.7; }
.v2-empty::before {
  content: ""; display: block; width: 46px; height: 46px; margin: 0 auto 15px;
  border-radius: 50%; background: var(--surface-2);
  border: 1.5px solid var(--border-2);
  box-shadow: inset 0 0 0 6px var(--surface); /* 中性「空」环:外描边 + 内圈,不含字形 */
}
.v2-empty code { background: var(--surface-3); padding: 1px 5px; border-radius: 4px; }

.v2-row { cursor: pointer; }
.v2-row:hover { background: var(--surface-2); }
.v2-row.is-selected { background: var(--accent-soft); }
.v2-row.is-suspended { opacity: .6; }
.v2-row:hover .v2-rowact, .v2-rowact:focus-visible { opacity: 1; }

/* color:--fg-2 (mid grey) is a USER CHOICE (#436): the author wrote --muted but it read too
 * faint, so it's pinned to --fg-2 to match .v2-btn. Uniform across its <button> instances
 * (备注/改IP/⋯) and its one <a> (devices.html「历史」, which rendered --muted before #436). */
.v2-rowact {
  opacity: 0; transition: opacity .1s;
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 22px; height: 22px; padding: 0 5px;
  border: 1px solid var(--border-2); border-radius: 6px;
  background: var(--surface); color: var(--fg-2); font-size: 11px;
}
/* text-decoration:none is needed because .v2-rowact is used on an <a> too (devices.html
 * 「历史」): the :where(.v2-root) a:hover (0,1,1) fallback would underline it; (0,2,0) beats
 * it. The <button> instances don't take that anchor underline, but they DO take this rule's
 * color:--accent on hover — so this block is no longer inert on them. */
.v2-rowact:hover { color: var(--accent); border-color: var(--accent-line); text-decoration: none; }

/* Checkbox. A <span role=checkbox> rather than <input> so the whole 17px box is
 * one paintable surface; JS keeps aria-checked in sync. */
.v2-check {
  display: inline-block; width: 17px; height: 17px; vertical-align: middle;
  border: 1.5px solid var(--border-2); border-radius: 5px;
  background: var(--surface); cursor: pointer; position: relative;
}
.v2-check:hover { border-color: var(--accent-line); }
.v2-check.on, .v2-check.some { background: var(--accent); border-color: var(--accent); }
.v2-check.on::after {
  content: ""; position: absolute; left: 4.5px; top: 1px;
  width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
.v2-check.some::after {
  content: ""; position: absolute; left: 3px; top: 6.5px;
  width: 8px; height: 2px; background: #fff; border-radius: 1px;
}

/* Device cell */
.v2-dev { display: flex; align-items: center; gap: 11px; }
.v2-osbox {
  width: 30px; height: 30px; flex-shrink: 0; border-radius: 8px;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface-2); border: 1px solid var(--border); color: var(--fg-2);
}
.v2-osbox.lg { width: 42px; height: 42px; border-radius: 11px; }
.v2-dev-txt { min-width: 0; }
.v2-dev-name { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 500; }
.v2-dev-sub { font-size: 11.5px; color: var(--muted); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.v2-dev-acct { font-size: 10.5px; color: var(--subtle); }
.v2-self { font-size: 10.5px; font-weight: 400; color: var(--muted); }

/* Badges */
.v2-badge {
  display: inline-flex; align-items: center;
  font-size: 9.5px; font-weight: 500; line-height: 1.7;
  padding: 0 6px; border-radius: 5px; white-space: nowrap;
  background: var(--surface-3); color: var(--fg-2);
}
.v2-badge.accent { background: var(--accent-soft); color: var(--accent); }
.v2-badge.danger { background: var(--danger-soft); color: var(--danger); }
.v2-badge.warn   { background: var(--warn-soft); color: var(--warn); }
.v2-badge.online { background: var(--online-soft); color: var(--online); }

/* 终端页「全部 web 终端」远端宿主徽标(2026-08 双入口 T5):行内小胶囊,中性描边
 * (--border-2;比 .v2-badge 的实心底更轻,与会话名/备注同排不抢视觉)。 */
.v2-tm-hostbadge{display:inline-block;padding:0 .45em;border:1px solid var(--border-2,#444);border-radius:.7em;font-size:.85em;opacity:.85}

/* Status dot */
.v2-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; display: inline-block; }
.v2-dot.online { background: var(--online); box-shadow: 0 0 0 3px var(--online-soft); }
.v2-dot.offline { background: var(--offline); }

.v2-status { display: flex; align-items: center; gap: 9px; }
.v2-status-t { font-size: 12.5px; }
.v2-status-s { font-size: 11px; color: var(--muted); }

/* VIP / endpoint / LAN cells */
.v2-vip-main { display: flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 12.5px; }
.v2-vip-v6 { font-family: var(--font-mono); font-size: 10.5px; color: var(--subtle); }
.v2-vip-pending { font-size: 10.5px; color: var(--warn); }
.v2-vip-link { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); }
.v2-ep-main { display: flex; align-items: center; gap: 7px; }
.v2-ep-main .mono { font-size: 11.5px; color: var(--fg-2); }
.v2-ep-sub { margin-top: 2px; font-size: 11px; color: var(--muted); display: flex; gap: 5px; }
.v2-lan { font-family: var(--font-mono); font-size: 11px; }
.v2-lan-kind { color: var(--muted); }

/* Tags */
.v2-tags { display: flex; flex-wrap: wrap; gap: 4px; }
.v2-tag {
  font-family: var(--font-mono); font-size: 10.5px; font-weight: 500;
  padding: 2px 7px; border-radius: 5px; white-space: nowrap;
  background: var(--surface-3); color: var(--fg-2);
}
.v2-tag[data-tag^="exit"] { background: var(--warn-soft); color: var(--warn); }
.v2-tag[data-tag="relay"] { background: var(--accent-soft); color: var(--accent); }

/* ─────────────────────────── Popover (⋯ menu) ─────────────────────────── */
/* Lives in #v2-overlays, outside the table, so a poll swap can never rip it out
 * from under the cursor. */
.v2-mask { position: fixed; inset: 0; z-index: 80; }
.v2-mask.dim { background: rgba(6, 6, 10, .4); z-index: 70; animation: v2fade .15s ease-out; }

.v2-popover {
  position: fixed; z-index: 81; min-width: 190px; padding: 5px;
  background: var(--bg); border: 1px solid var(--border-2); border-radius: 11px;
  box-shadow: var(--shadow-lg); animation: v2pop .12s ease-out;
}
.v2-menu-item {
  display: block; width: 100%; text-align: left;
  padding: 8px 12px; border: 0; border-radius: 7px;
  background: transparent; font-size: 13px; color: var(--fg);
}
.v2-menu-item:hover { background: var(--surface-2); }
.v2-menu-item.danger { color: var(--danger); }
.v2-menu-item.danger:hover { background: var(--danger-soft); }
/* app.js disableInFlightTwins(): the same action is still in flight (row menu or drawer). */
.v2-menu-item:disabled,
.v2-menu-item:disabled:hover { opacity: .45; cursor: progress; background: transparent; }
.v2-menu-grp {
  padding: 8px 12px 3px; font-size: 10px; font-weight: 600;
  letter-spacing: .05em; text-transform: uppercase; color: var(--subtle);
}
.v2-menu-div { height: 1px; margin: 4px 6px; background: var(--border); }
.v2-menu-note { padding: 9px 12px; font-size: 12.5px; color: var(--muted); }

/* ──────────────────────────────── Drawer ──────────────────────────────── */
.v2-drawer {
  position: fixed; top: 0; right: 0; bottom: 0; z-index: 71;
  width: 460px; max-width: 92vw;
  display: flex; flex-direction: column;
  background: var(--bg); border-left: 1px solid var(--border);
  box-shadow: var(--shadow-lg);
  animation: v2drawer .22s cubic-bezier(.32, .72, 0, 1);
}
.v2-drawer-hd { display: flex; gap: 13px; padding: 22px 24px 18px; border-bottom: 1px solid var(--border); }
.v2-drawer-hd-txt { flex: 1; min-width: 0; }
.v2-drawer-title { display: flex; align-items: center; gap: 7px; }
.v2-drawer-title h2 { font-size: 18px; font-weight: 600; }
.v2-drawer-note { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); min-height: 18px; }
.v2-drawer-note .v2-rowact { opacity: 1; }
.v2-drawer-acct { font-size: 11px; color: var(--subtle); }
.v2-drawer-body { flex: 1; overflow: auto; padding: 20px 24px; }
.v2-drawer-ft {
  display: flex; flex-wrap: wrap; gap: 9px;
  padding: 16px 24px; border-top: 1px solid var(--border); background: var(--surface-2);
}
/* The drawer footer reuses the SAME menu markup as the ⋯ popover (one template,
 * one set of gates, one set of confirm strings — they can never drift apart);
 * these rules just re-lay it out as a row of buttons. */
.v2-drawer-ft .v2-menu-grp, .v2-drawer-ft .v2-menu-div { display: none; }
.v2-drawer-ft .v2-menu-item {
  width: auto; height: 34px; padding: 0 14px;
  border: 1px solid var(--border-2); border-radius: 9px;
  background: var(--bg); font-size: 12.5px; font-weight: 500;
}
.v2-drawer-ft .v2-menu-note { width: 100%; }
.v2-drawer-pills { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 20px; }
.v2-pill {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 5px 12px; border-radius: 20px; font-size: 12.5px; font-weight: 500;
  background: var(--surface-3); color: var(--muted);
}
.v2-pill.online { background: var(--online-soft); color: var(--online); }

.v2-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 22px; }
.v2-stat { padding: 12px 13px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface-2); }
.v2-stat .k { font-size: 10.5px; color: var(--muted); }
.v2-stat .v { font-family: var(--font-mono); font-size: 15px; font-weight: 500; margin-top: 3px; }

.v2-sec-hd {
  display: flex; align-items: center; justify-content: space-between;
  font-size: 12px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 4px;
}
.v2-link { background: none; border: 0; padding: 0; color: var(--accent); font-size: 12px; text-transform: none; letter-spacing: 0; font-weight: 500; }
.v2-link:hover { text-decoration: underline; }
.v2-info { margin: 0; }
.v2-info > div {
  display: flex; justify-content: space-between; gap: 14px;
  padding: 11px 0; border-bottom: 1px solid var(--border); font-size: 12.5px;
}
.v2-info dt { color: var(--muted); flex-shrink: 0; }
.v2-info dd { margin: 0; text-align: right; min-width: 0; word-break: break-all; }

/* ─────────────────────────────── Bulk bar ─────────────────────────────── */
/* left: calc(50% + 120px) re-centers over the MAIN column, not the viewport —
 * the 240px sidebar would otherwise push it visibly off-center. */
.v2-bulkbar {
  position: fixed; bottom: 28px; left: calc(50% + 120px); transform: translateX(-50%);
  z-index: 60; display: flex; align-items: center; gap: 12px;
  padding: 10px 12px 10px 18px; border-radius: 13px;
  background: var(--fg); color: var(--bg); box-shadow: var(--shadow-lg);
  font-size: 13px; animation: v2bulk .18s ease-out;
}
.v2-bulk-sep { width: 1px; height: 18px; background: currentColor; opacity: .25; }
.v2-bulkbtn {
  height: 32px; padding: 0 14px; border: 0; border-radius: 8px;
  font-size: 12.5px; font-weight: 500;
  background: rgba(255, 255, 255, .14); color: inherit;
}
.v2-bulkbtn:hover { background: rgba(255, 255, 255, .24); }
.v2-bulkbtn.danger { background: var(--danger); color: #fff; }
.v2-bulkbtn[disabled] { opacity: .55; cursor: progress; }
.v2-bulk-clear { background: none; border: 0; color: inherit; opacity: .7; font-size: 12.5px; }
.v2-bulk-clear:hover { opacity: 1; text-decoration: underline; }

/* ───────────────────────────── Dialogs / toast ───────────────────────── */
/* <dialog> renders in the top layer, OUTSIDE .v2-root's subtree for style
 * inheritance purposes — which is exactly why .v2-root sits on <body> and not on
 * an inner div. Move it inward and every var() below resolves to nothing and the
 * modals turn into unstyled white boxes. */
.v2-dialog {
  width: 400px; max-width: 90vw; padding: 22px;
  border: 1px solid var(--border-2); border-radius: 14px;
  background: var(--bg); color: var(--fg);
  box-shadow: var(--shadow-lg); animation: v2modal .16s ease-out;
}
.v2-dialog::backdrop { background: rgba(6, 6, 10, .45); }
.v2-dialog h3 { font-size: 15px; font-weight: 600; margin-bottom: 12px; }
.v2-dialog p { margin: 0 0 12px; font-size: 13px; color: var(--fg-2); }
#v2-confirm-body { white-space: pre-line; } /* 保留 \n 换行:指定版本推送降级提示的多行安全警告(partials.html:234)*/
/* v2.confirm 的 opts.lines(共享页设备终端的确认框):正文下面逐条一行;danger 红、warn 黄 */
#v2-confirm-body .v2-confirm-line { display: block; margin-top: 8px; }
#v2-confirm-body .v2-confirm-line.is-danger { color: var(--danger); font-weight: 600; }
#v2-confirm-body .v2-confirm-line.is-warn { color: var(--warn); }
.v2-hint { font-size: 11.5px; color: var(--muted); margin-top: 8px; }
.v2-input {
  width: 100%; height: 38px; padding: 0 12px;
  border: 1px solid var(--border-2); border-radius: 9px;
  background: var(--surface-2); font-size: 13px; outline: none;
}
.v2-input:focus { border-color: var(--accent-line); }
.v2-dialog-menu { display: flex; justify-content: flex-end; gap: 9px; margin: 18px 0 0; padding: 0; }
.v2-btn {
  height: 34px; padding: 0 15px; border-radius: 9px;
  border: 1px solid var(--border-2); background: var(--surface); color: var(--fg-2);
  font-size: 13px; font-weight: 500;
}
/* text-decoration:none is needed because .v2-btn is used on an <a> too (devices.html
 * 「在拓扑页跟踪 →」, terminals.html, disconnects.html): the :where(.v2-root) a:hover (0,1,1)
 * fallback would underline it. (0,2,0) beats it, and .primary / .danger declare no
 * text-decoration of their own so this covers them too. The text-decoration half is
 * <a>-only, but color:--fg IS the live hover-darken on <button> instances now (resting is
 * --fg-2 since #436) — the button hover is no longer a no-op. */
.v2-btn:hover { color: var(--fg); border-color: var(--border-2); text-decoration: none; }
.v2-btn.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-fg); }
.v2-btn.primary:hover { filter: brightness(1.08); color: var(--accent-fg); }
.v2-btn.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
.v2-btn.danger:hover { filter: brightness(1.08); color: #fff; }
.v2-btn[disabled] { opacity: .55; cursor: not-allowed; }
.v2-err { margin-top: 10px; font-size: 12px; color: var(--danger); }
.v2-err:empty { margin: 0; }

.v2-toast {
  position: fixed; bottom: 28px; right: 28px; z-index: 90;
  padding: 11px 16px; border-radius: 10px;
  background: var(--fg); color: var(--bg);
  font-size: 13px; font-weight: 500; box-shadow: var(--shadow-lg);
  animation: v2toast .18s ease-out; max-width: 420px;
}
.v2-toast.danger { background: var(--danger); color: #fff; }
/* "warn" = the server accepted the request but the command never left the building
   (device offline, agent too old). Not an error — but the user MUST read it, or the
   click looks like it worked. See writeControlTrigger in control_notice.go. */
.v2-toast.warn { background: var(--attn); color: #fff; }

/* ──────────────────────────────── Cards ──────────────────────────────── */
.v2-card {
  border: 1px solid var(--border); border-radius: 14px; background: var(--bg);
  box-shadow: var(--shadow-sm); overflow: hidden; margin-bottom: 22px;
}
.v2-card-h {
  display: flex; align-items: center; gap: 10px;
  padding: 13px 18px; border-bottom: 1px solid var(--border);
  font-size: 14px; font-weight: 600;
}
.v2-card-h small { font-weight: 400; font-size: 11.5px; color: var(--muted); }
.v2-card .v2-table { min-width: 0; }
.v2-card .v2-table thead th { background: var(--surface-2); position: static; }
.v2-back { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); margin-bottom: 2px; }
.v2-back:hover { color: var(--accent); text-decoration: none; }
.v2-table tr.is-current { background: var(--online-soft); }

/* ────────────────────────────── Animations ────────────────────────────── */
/* NB: the prototype shipped TWO different @keyframes both called bulkUp — the
 * devices bulk bar's (which must carry translateX(-50%) to counteract its
 * left:calc(50% + 120px)) and every other page's toast (which is right-anchored
 * and must NOT be shifted). Copying that collision verbatim makes the toast
 * slide half its own width sideways while animating. Two names, no collision. */
@keyframes v2bulk   { from { transform: translate(-50%, 20px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }
@keyframes v2toast  { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
@keyframes v2drawer { from { transform: translateX(100%); } to { transform: translateX(0); } }
@keyframes v2fade   { from { opacity: 0; } to { opacity: 1; } }
@keyframes v2pop    { from { transform: translateY(-6px) scale(.98); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes v2modal  { from { transform: translateY(8px) scale(.98); opacity: 0; } to { transform: translateY(0) scale(1); opacity: 1; } }
@keyframes v2spin   { to { transform: rotate(360deg); } }

@media (prefers-reduced-motion: reduce) {
  .v2-root *, .v2-root *::before, .v2-root *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

/* ══════════════════════════════ Topology ══════════════════════════════ */

/* Segmented tab (连通性矩阵 / 链路明细). Lives OUTSIDE #topology-matrix, because a
 * pair-policy PUT swaps that element's outerHTML — a tab state inside it would be
 * reset on every policy change. */
.v2-seg {
  display: inline-flex; gap: 3px; padding: 3px;
  background: var(--surface-2); border: 1px solid var(--border); border-radius: 11px;
}
.v2-seg button {
  padding: 6px 14px; border: 0; border-radius: 8px;
  background: transparent; color: var(--fg-2); font-size: 12.5px; font-weight: 500;
}
.v2-seg button[aria-selected="true"] { background: var(--bg); color: var(--fg); box-shadow: var(--shadow-sm); }

.v2-legend { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; font-size: 11.5px; color: var(--muted); }
.v2-legend i { display: inline-block; width: 11px; height: 11px; border-radius: 3px; margin-right: 5px; vertical-align: -1px; }
.v2-legend-sep { width: 1px; height: 13px; background: var(--border-2); }

/* Matrix. border-collapse MUST be separate: with `collapse`, sticky cells lose
 * their borders (the collapsed border belongs to the table, not the cell, and does
 * not travel with a sticky element). */
.v2-matrix-wrap {
  border: 1px solid var(--border); border-radius: 13px; overflow: auto;
  background: var(--bg); max-height: 62vh;
}
.v2-matrix { border-collapse: separate; border-spacing: 0; font-size: 12.5px; }
.v2-matrix th, .v2-matrix td { border-bottom: 1px solid var(--border); border-right: 1px solid var(--border); }
.v2-matrix thead th {
  position: sticky; top: 0; z-index: 2;
  background: var(--surface-2); padding: 8px 10px; text-align: center;
  font-size: 11.5px; font-weight: 500; white-space: nowrap;
}
.v2-matrix tbody th {
  position: sticky; left: 0; z-index: 1;
  background: var(--surface-2); padding: 8px 12px; text-align: left;
  font-size: 11.5px; font-weight: 500; white-space: nowrap;
}
.v2-matrix thead th:first-child { left: 0; z-index: 3; } /* the corner outranks both */
.v2-matrix .v2-mx-vip { display: block; font-family: var(--font-mono); font-size: 9.5px; color: var(--subtle); }
.v2-matrix .v2-mx-shared { display: block; font-size: 9.5px; color: var(--subtle); }
.v2-matrix .v2-mx-offdot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--offline); margin-right: 5px; }

.v2-matrix td {
  position: relative; text-align: center; padding: 9px 10px;
  font-family: var(--font-mono); white-space: nowrap;
}
.v2-matrix td.is-clickable { cursor: pointer; }
/* Row/column crosshair. Applied by delegated JS toggling a class — NOT by
 * re-rendering the matrix, which would flicker and would be O(n²) on every
 * mousemove for a 30-node mesh. */
.v2-matrix .lit { outline: 1px solid var(--accent-line); outline-offset: -1px; }
.v2-matrix th.lit { background: var(--accent-soft); color: var(--accent); }

/* Nine cell states — deriveCellStatus() emits every one of these. */
.v2-mx-self              { background: var(--m-self-bg); color: var(--subtle); }
.cell-direct             { background: var(--m-direct-bg); color: var(--m-direct-fg); }
.cell-direct-degrade     { background: var(--m-degrade-bg); color: var(--m-degrade-fg); }
.cell-direct-unverified  { background: var(--m-unverified-bg); color: var(--m-unverified-fg); }
.cell-fake-direct        { background: var(--m-fake-bg); color: var(--m-fake-fg); font-weight: 600; }
.cell-relay              { background: var(--m-relay-bg); color: var(--m-relay-fg); }
.cell-relay-lost         { background: var(--m-relay-bg); color: var(--m-relay-fg); }
.cell-warming            { background: var(--m-warm-bg); color: var(--m-warm-fg); }
.cell-none               { background: var(--m-none-bg); color: var(--m-none-fg); }
.cell-stale              { background: var(--m-none-bg); color: var(--m-none-fg); }
/* Stale is NOT the same as no-data: the link exists, we just can't trust the
 * numbers. The word has to stay, or the two collapse into one grey blob. */
.v2-mx-stale { display: block; font-size: 9px; opacity: .75; }

.v2-mx-badge {
  position: absolute; right: 3px; bottom: 2px;
  padding: 0 3px; border-radius: 3px;
  font-size: 8.5px; font-weight: 700; color: #fff; line-height: 1.5;
}
.v2-mx-badge.R { background: var(--warn); }
.v2-mx-badge.D { background: var(--accent); }
.v2-mx-badge.B { background: var(--danger); }

/* Pair-policy popover. Anchored with position:fixed off getBoundingClientRect and
 * hosted OUTSIDE #topology-matrix, so the PUT's outerHTML swap can't tear it out. */
.v2-policy-pop { width: 236px; }
.v2-policy-hd { padding: 4px 10px 8px; font-size: 11px; color: var(--muted); }
.v2-policy-opt {
  display: flex; align-items: center; justify-content: space-between; gap: 8px;
  width: 100%; padding: 8px 12px; border: 0; border-radius: 7px;
  background: transparent; font-size: 12.5px; text-align: left; color: var(--fg);
}
.v2-policy-opt:hover { background: var(--surface-2); }
.v2-policy-opt.on { background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.v2-policy-region { padding: 8px 12px 4px; border-top: 1px solid var(--border); margin-top: 4px; }
.v2-policy-region label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 5px; }
.v2-policy-region select {
  width: 100%; height: 32px; padding: 0 8px;
  border: 1px solid var(--border-2); border-radius: 8px;
  background: var(--surface-2); font-size: 12.5px;
}

/* Link-detail table */
.v2-links { min-width: 1080px; }
.v2-links tr.is-stale { opacity: .6; }
.v2-mode-chip {
  display: inline-flex; align-items: center;
  padding: 2px 8px; border-radius: 5px;
  font-size: 11px; font-weight: 500; white-space: nowrap;
}
.v2-mode-chip.direct     { background: var(--m-direct-bg); color: var(--m-direct-fg); }
.v2-mode-chip.relay      { background: var(--m-relay-bg); color: var(--m-relay-fg); }
.v2-mode-chip.none       { background: var(--m-none-bg); color: var(--m-none-fg); }

/* ══════════════════════════════ Operations ══════════════════════════════ */

/* The batch card is inert until something is selected — dimming it is the whole
 * affordance for "these controls act on a selection you haven't made yet". */
.v2-ops-batch { border-color: var(--accent-line); }
.v2-ops-batch.is-idle { opacity: .55; }
.v2-ops-batch.is-idle .v2-ops-body { pointer-events: none; }
/* Batch-card select-all (mirrors the thead #ops-select-all; the thead is hidden on
 * narrow fold-card view, so this label in the always-visible batch card is the only
 * select-all reachable on a phone). operations.js keeps the two checkboxes in sync. */
.ops-selectall { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; white-space: nowrap; }
.ops-selectall input { cursor: pointer; }
.v2-ops-body { display: grid; grid-template-columns: 1.6fr 1fr 1fr; gap: 20px; padding: 18px; }
.v2-ops-col > h3 { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 10px; }
.v2-ops-col > h3 small { font-weight: 400; }

.v2-ops-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; }
.v2-ops-row > label { flex: 1; font-size: 12.5px; }

/* Segmented on/off control. It writes into a hidden input whose id and name are
 * IDENTICAL to the legacy <select>'s, because the apply button finds its value by
 * id (data-val="#v-mr") and builds the request param from getAttribute('name').
 * Change either and the batch apply silently posts the wrong thing. */
.v2-toggle { display: inline-flex; padding: 2px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; }
.v2-toggle button {
  padding: 4px 11px; border: 0; border-radius: 6px;
  background: transparent; color: var(--fg-2); font-size: 12px;
}
.v2-toggle button[aria-pressed="true"] { background: var(--bg); color: var(--fg); font-weight: 500; box-shadow: var(--shadow-sm); }
.v2-toggle button[aria-pressed="true"][data-danger] { background: var(--danger); color: #fff; }

.v2-ops-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.v2-ops-action {
  padding: 9px 10px; border: 1px solid var(--border-2); border-radius: 9px;
  background: var(--surface); font-size: 12.5px; text-align: center;
}
.v2-ops-action[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); font-weight: 500; }
.v2-ops-action[aria-pressed="true"][data-danger] { border-color: var(--danger); background: var(--danger-soft); color: var(--danger); }
.v2-ops-action[disabled] { opacity: .45; cursor: not-allowed; }

/* 指定版本推送下拉(ops-version-select define):套标准 .v2-select 容器,在动作区 2 列 grid
 * 中跨整行显示。hidden 在容器 label 上(非 select)——app.css 的 [hidden]{display:none!important}
 * 压过 .v2-select 的 display:flex,operations.js 切换 label.hidden 控制显隐;select 保留
 * .ops-version-select + name=target_version,JS 读值不变。max-width:none 覆盖 .v2-select select
 * 的 170px,让长版本号(v1.100.0 等)显示全。 */
.ops-version-pick { grid-column: 1 / -1; width: 100%; }
.ops-version-pick select { max-width: none; }

/* Summary + per-device failure list: the primary triage surface. The design pack
 * omitted both; without them a batch that half-fails just… half-fails, silently. */
.v2-ops-summary { padding: 10px 18px; border-top: 1px solid var(--border); font-size: 12.5px; }
.v2-ops-summary-detail {
  margin-top: 8px; padding: 10px 12px;
  background: var(--surface-2); border-radius: 9px;
  font-family: var(--font-mono); font-size: 11.5px; white-space: pre-wrap;
}

/* Force-pause banner. warn = manual/auto, danger = the timeout/failed variants. */
.v2-ops-pause {
  display: flex; gap: 12px; padding: 14px 16px; margin-bottom: 20px;
  border: 1px solid var(--warn); border-radius: 12px; background: var(--warn-soft);
}
.v2-ops-pause.lc-timeout, .v2-ops-pause.lc-failed { border-color: var(--danger); background: var(--danger-soft); }
.v2-ops-pause h3 { font-size: 13px; font-weight: 600; margin-bottom: 4px; }
.v2-ops-pause p { margin: 0; font-size: 12px; color: var(--fg-2); }

/* Lifecycle badges. The class is Go string-concatenation: "lc-" + the stored
 * lifecycle enum (lifecycleClass()). So these names are NOT free — they are
 * queued / dispatched / running / succeeded / failed / timeout / cancelled, and a
 * "lc-success" here would simply never match anything. */
.lc-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 500; padding: 1px 7px; border-radius: 5px; background: var(--surface-3); color: var(--fg-2); }
.cell-status.lc-queued .lc-badge     { background: var(--surface-3); color: var(--fg-2); }
.cell-status.lc-dispatched .lc-badge { background: var(--accent-soft); color: var(--accent); }
.cell-status.lc-running .lc-badge    { background: var(--warn-soft); color: var(--warn); }
.cell-status.lc-succeeded .lc-badge  { background: var(--online-soft); color: var(--online); font-weight: 600; }
.cell-status.lc-failed .lc-badge     { background: var(--danger-soft); color: var(--danger); font-weight: 600; }
.cell-status.lc-timeout .lc-badge    { background: var(--attn-soft); color: var(--attn); }
.cell-status.lc-cancelled .lc-badge  { background: var(--surface-3); color: var(--muted); }
.cell-status.lc-skipped .lc-badge   { background: var(--surface-3); color: var(--muted); }
/* Fixed width so a long badge+reason doesn't make the whole table jump sideways
 * every time a status changes. */
.cell-status { min-width: 12em; }
/* #1775 A:升级结论。颜色按结论的色档(webui upgradeOutcomeTone 选 lc-*),不按原 lifecycle ——
 * 「随后已升」的 failed / skipped 行是绿的。「升级:」小行(.lc-upgrade)复用同一组色档。 */
.lc-upgrade { display: block; margin-top: 3px; font-size: 11px; color: var(--muted); }
/* 小行的徽标逐档写全 font-weight:外层 .cell-status.lc-succeeded/.lc-failed 的 600 同特异度,会漏进来。 */
.lc-upgrade.lc-queued .lc-badge     { background: var(--surface-3); color: var(--fg-2); font-weight: 500; }
.lc-upgrade.lc-running .lc-badge    { background: var(--warn-soft); color: var(--warn); font-weight: 500; }
.lc-upgrade.lc-succeeded .lc-badge  { background: var(--online-soft); color: var(--online); font-weight: 600; }
.lc-upgrade.lc-failed .lc-badge     { background: var(--danger-soft); color: var(--danger); font-weight: 600; }
.lc-upgrade.lc-timeout .lc-badge    { background: var(--attn-soft); color: var(--attn); font-weight: 500; }
.lc-upgrade.lc-cancelled .lc-badge  { background: var(--surface-3); color: var(--muted); font-weight: 500; }
.lc-inferred { font-size: 10px; padding: 0 4px; margin-left: 4px; border: 1px solid var(--surface-3); border-radius: 4px; color: var(--muted); }
.lc-at { font-size: 10.5px; color: var(--muted); margin-left: 4px; }
.v2-ops-table tbody tr.ops-row-anomaly > td:first-child { box-shadow: inset 3px 0 0 var(--attn); }
/* 状态格只描边、不铺底色:底色若用 --attn-soft,会和 lc-timeout 徽标的底色撞成一片。 */
.v2-ops-table tbody tr.ops-row-anomaly > td.cell-status { box-shadow: inset 0 0 0 1px var(--attn); border-radius: 4px; }
.v2-ops-table tr.is-offline.ops-row-anomaly { opacity: 1; }
.ops-upgrade-batch { margin: 0 0 10px; padding: 7px 12px; border-radius: 8px; background: var(--surface-3); font-size: 12.5px; color: var(--fg-2); }
.ops-upgrade-batch.has-anomaly { background: var(--attn-soft); }
.ops-upgrade-batch .ob-bad { color: var(--danger); font-weight: 600; }
.ops-upgrade-batch .ob-attn { color: var(--attn); font-weight: 600; }
.lc-reason  { font-size: 11px; color: var(--muted); margin-left: 4px; }
.lc-dots i { display: inline-block; width: 3px; height: 3px; margin-left: 2px; border-radius: 50%; background: currentColor; animation: v2dots 1.1s infinite; }
.lc-dots i:nth-child(2) { animation-delay: .15s; }
.lc-dots i:nth-child(3) { animation-delay: .3s; }
@keyframes v2dots { 0%, 100% { opacity: .25; } 50% { opacity: 1; } }

/* The ops table's boolean marks stay as the literal characters ○ and ✕.
 *
 * They are NOT decoration: readCell() in operations.js reads the cell to decide
 * whether a setting actually took effect on the device, and effectTarget() compares
 * against exactly these strings. (The v2 row also carries data-val for that, so the
 * glyphs could in principle change — but keeping them legible costs nothing and the
 * fallback path still reads text.)
 *
 * The async columns must keep showing 离线 / 需升级 / 出错 / 查询中 rather than
 * collapsing to an on/off icon. An agent too old to support link-stability shows
 * "需升级"; painting that as an "off" icon would be a plain lie about the device. */
.ops-mark { text-align: center; }
.ops-mark .mk-on  { color: var(--online); font-weight: 600; }
.ops-mark .mk-off { color: var(--subtle); }
.ops-async { font-size: 11.5px; color: var(--muted); }

/* min-width 自适应:仅当两组冷列都展开时才需要 1360px 的横向空间(此时用外层
 * overflow-x:auto 横滚);默认精简视图(两组收起)自然宽,一屏放得下、不横滚。
 * ≤1000px 折叠态另有 .v2-foldable{min-width:0!important} 覆盖(见 Fold cards 段)。 */
.v2-ops-table { min-width: 0; }
.v2-ops-table:not(.ops-hide-settings):not(.ops-hide-linkhealth) { min-width: 1360px; }
.v2-ops-table td, .v2-ops-table th { white-space: nowrap; }
.v2-ops-table tr.is-offline { opacity: .62; }

/* ── 列分组:视觉分隔 + 按组折叠 ─────────────────────────────────────────────
 * 19 列分四组(身份 / 功能开关 grp-settings / 链路·健康 grp-linkhealth / 告警 / 操作)。
 * grp-start 在每组首列打一道竖分隔线(某组被隐藏时,下一可见组的 grp-start 仍在,
 * 分隔不丢);grp-settings/grp-linkhealth 表头浅色底,把「可折叠的功能/链路冷列」与
 * 「常显的身份/告警」区分开。按组隐藏用 display:none(DOM 不删)→ operations.js 按
 * class+data-val 回读不受影响;仅桌面(>1000px)生效,≤1000px 交给折叠卡。 */
/* 组间竖分隔线:grp-start 在多数组首列;数据面(告警组首列)的 body td 走 .cell-datapath
 * 选择器(不往 class 塞 grp-start,以免破坏守护测试对 `class="cell-datapath dp-*"` 的精确
 * 匹配),表头 th 数据面 仍用 grp-start。 */
.v2-ops-table .grp-start,
.v2-ops-table td.cell-datapath { border-left: 1px solid var(--border-2); }
@media (min-width: 1001px) {
  .v2-ops-table.ops-hide-settings   .grp-settings   { display: none; }
  .v2-ops-table.ops-hide-linkhealth .grp-linkhealth { display: none; }
}

/* 分组表头浅色底。 */
.v2-ops-table thead th.grp-settings,
.v2-ops-table thead th.grp-linkhealth { background: var(--surface-2); }

/* 表格外层容器:横向滚(冷列组展开时超宽 → 横滚)。
 * 注:不做 sticky 表头 —— 唯一可行的实现要把表格套进内层 max-height 滚动盒,而该盒在
 * 10s 轮询目标 #operations-table 内、每次 outerHTML swap 被重建 → 纵向滚动位置弹回顶部
 * (真回归);且固定 max-height 在短屏会出双滚动条。sticky 属次要视觉项,先不做,
 * 如需再单独用「轮询保存/恢复 scrollTop」的方式稳妥实现。 */
.ops-tablewrap { overflow-x: auto; }

/* 表格工具栏(表格正上方一排):列显隐平铺勾选 + 状态刷新 + 停止升级。 */
.ops-tablebar { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
.ops-tablebar-cols { display: flex; align-items: center; gap: 14px; }
.ops-tablebar-lbl { font-size: 12.5px; color: var(--muted); font-weight: 600; }
.ops-colchk { display: inline-flex; align-items: center; gap: 6px; font-size: 12.5px; color: var(--fg-2); cursor: pointer; white-space: nowrap; }
.ops-colchk input { cursor: pointer; }
.ops-tablebar .ops-upgrade-stop { height: 30px; font-size: 12px; }
/* 窄屏(折叠卡)不需要列显隐(按组隐藏只在 >1000px 生效);状态刷新/停止升级仍留在工具栏。 */
@media (max-width: 1000px) { .ops-tablebar-cols { display: none; } }

/* Relay-health severity. Taken from the LEGACY code, not the design pack, which has
 * these two backwards: 需关注 means self-heal has already been tried and failed, and
 * is therefore MORE severe than 中继卡死 (which can still self-heal). Red for the
 * former, orange for the latter. */
.cell-relayhealth { text-align: center; font-size: 12px; }
.cell-relayhealth.relay-ok     { color: var(--online); }
.cell-relayhealth.relay-wedged { color: var(--attn); font-weight: 600; }
.cell-relayhealth.relay-attn   { color: var(--danger); font-weight: 600; }

/* 数据面 (B1/B3). A deleted overlay route is a fault with NO other symptom in this
 * console: heartbeat green, 连接 ○, every other column healthy — and not one packet
 * reaches the mesh. This column is the only place it is visible, which is why the v2
 * page carrying the fields but not the colour would still be a half-delivery.
 *   dp-bad  (red)    = the route is provably NOT on the tunnel interface.
 *   dp-attn (orange) = repaired N times ⇒ something is deleting it; go find out what.
 *   no class         = 未对账 / async status word: "we don't know" ≠ "it's broken", so
 *                      it is deliberately left uncoloured and raises no alarm. */
.cell-datapath { text-align: center; font-size: 12px; white-space: nowrap; }
.cell-datapath.dp-ok   { color: var(--online); }
.cell-datapath.dp-attn { color: var(--attn); font-weight: 600; }
.cell-datapath.dp-bad  { color: var(--danger); font-weight: 600; }

/* 证书/CA 列(#725 T4)—— 语义与 .cell-datapath.dp-* 同源。
   红 = 无任何 CA 材料;橙 = 有锚点但无客户端证书;绿 = 齐全。 */
.cell-trust.trust-ok   { color: var(--online); }
.cell-trust.trust-attn { color: var(--attn); font-weight: 600; }
.cell-trust.trust-bad  { color: var(--danger); font-weight: 600; }

/* 进程告警 (C2). The two alarms are semantically DIFFERENT and never share a colour:
 *   pa-wedged (red)    = the tunnel verdict is permanently stuck (#210). Self-heal
 *                        cannot fix it; the process must be restarted — and the remote
 *                        lever that does that is the row's 升级 button.
 *   pa-attn   (orange) = re-exec's execve failed, the datapath was torn down, auto-up
 *                        is already healing it. Needs eyes, not hands.
 * pa-ok is the no-alarm state; an async status word carries no class at all. */
.cell-procalarm { text-align: center; font-size: 12px; }
.cell-procalarm.pa-ok { color: var(--online); }
.pa-attn   { color: var(--attn); font-weight: 600; }
.pa-wedged { color: var(--danger); font-weight: 600; }

/* The two prose notes in the 动作 column: why 修复数据面 is the default, and why 断开
 * is dead. Without the second one the disabled button just looks like a bug. */
.v2-ops-col .ops-pause-hint  { display: block; margin-top: 8px; font-size: 11.5px; line-height: 1.6; color: var(--muted); }
.v2-ops-col .ops-danger-note { display: block; margin-top: 6px; font-size: 11.5px; line-height: 1.6; color: var(--danger); font-weight: 600; }

.btn-busy { opacity: .6; pointer-events: none; }
.ops-detail {
  margin-top: 8px; padding: 10px 12px;
  background: var(--surface-2); border-radius: 9px;
  font-size: 11.5px; line-height: 1.7; max-height: 200px; overflow: auto;
}

/* ══════════════════════════════ RESPONSIVE ══════════════════════════════
 *
 * Mobile adaptation of the v2 console. DESKTOP-FIRST: every rule ABOVE is the
 * desktop truth and is left byte-for-byte unchanged; everything below only adds
 * @media overrides, so the desktop render path is unaffected. Three forms,
 * switched purely on viewport width:
 *
 *   < 680px    narrow  — hamburger + off-canvas sidebar drawer; the detail drawer,
 *                        row menu and dialogs become bottom sheets; single column.
 *   680–900px  wide    — persistent sidebar (232px), content column capped/centred.
 *   ≥ 900px    wide+   — one more device-card column (added in the devices phase).
 *
 * Breakpoint numbers live ONLY in the @media heads here. narrow's upper bound is
 * 679.98px, NOT 680px: a viewport of exactly 680px then matches the wide
 * `min-width:680px` query and not narrow, so the two never fire at once at the
 * seam (where the cascade order would otherwise decide it unpredictably).
 *
 * ⚠ DO NOT put transform/filter/perspective on .v2-shell or .v2-body: either one
 * creates a containing block that traps every position:fixed overlay (sheets,
 * drawer, scrim) inside it and strips its viewport anchoring — the sheet would
 * then be clipped by .v2-shell{overflow:hidden}. The sidebar transforms ITSELF,
 * which is safe (it is the element being moved, not an ancestor of the overlays).
 * ────────────────────────────────────────────────────────────────────────── */

/* Mobile keyframes. Kept OUT of any @media block: a @keyframes defined inside a
 * media query only exists while that query matches. drawerRight / fade / pop
 * already exist above (v2drawer / v2fade / v2pop) and are reused as-is. */
@keyframes drawerLeft { from { transform: translateX(-100%); } to { transform: translateX(0); } }
@keyframes sheetUp    { from { transform: translateY(100%); }  to { transform: translateY(0); } }
@keyframes toastUp    { from { transform: translate(-50%, 16px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }

/* Hamburger — hidden by default (desktop); narrow shows it. Absolutely positioned
 * over the top-left of .v2-main (which narrow makes position:relative). */
.v2-hamburger {
  display: none;
  position: absolute; top: 13px; left: 16px; z-index: 40;
  width: 38px; height: 38px; align-items: center; justify-content: center;
  border: 0; border-radius: 11px;
  background: var(--surface-2); color: var(--fg); cursor: pointer;
}
.v2-hamburger:focus-visible { outline: 2px solid var(--accent-line); outline-offset: 2px; }

/* Touch devices have no hover, so the device row's hover-only inline actions
 * (备注 / 改IP / 历史 / ⋯ — all .v2-rowact) would be permanently invisible after
 * the table reflows to cards. Force them visible whenever the primary pointer
 * cannot hover. Width-independent on purpose: also covers a touch laptop at
 * desktop width. */
@media (hover: none) {
  .v2-root .v2-rowact { opacity: 1; }
}

/* ─────────────── cramped toolbar (≤ 900px) ───────────────
 * Below 900px the desktop toolbar — title + account select + a 230px search + the
 * refresh button, all in one row — no longer fits. It is WORST in the 680–900
 * landscape band, where the 232px sidebar is still in-flow and squeezes the title
 * down to one glyph per line. So the reflow spans the whole ≤900 range (both the
 * narrow phone and the landscape band), independent of the narrow-only hamburger /
 * off-canvas rules below: keep the title on one line, drop the flex spacer, and let
 * the controls take their own full-width row with the search growing to fit. The
 * devices phase replaces this with the collapse-to-icon search; this is the safe
 * baseline for EVERY page's toolbar until then. */
@media (max-width: 900px) {
  /* Address-bar-safe shell height (.v2-shell) and account-menu ceiling
   * (.v2-acct-menu) now live UNSCOPED in the base rules with a dvh-over-vh
   * fallback, so iPad landscape (>900px, dynamic toolbar) is covered too — see the
   * .v2-shell comment. Nothing dvh-related is needed here anymore. */
  .v2-toolbar { flex-wrap: wrap; gap: 10px 12px; }
  .v2-toolbar > :first-child { flex-shrink: 0; }
  .v2-toolbar h1 { white-space: nowrap; }
  .v2-toolbar .v2-subline { white-space: nowrap; }
  .v2-toolbar .v2-spacer { display: none; }
  .v2-controls { flex: 1 1 100%; }
  .v2-search { flex: 1 1 auto; width: auto; min-width: 0; }
}

/* ─────────────── narrow (< 680px): phone portrait ─────────────── */
@media (max-width: 679.98px) {
  .v2-hamburger { display: flex; }
  .v2-main { position: relative; }

  /* Sidebar → left off-canvas drawer. .v2-nav is already overflow-y:auto, so the
   * 13 items scroll inside the drawer when they don't fit.
   * visibility:hidden when closed takes the off-screen links OUT of the tab order
   * (translateX alone leaves them keyboard-focusable off-screen). The visibility
   * flip is delayed .24s on close (transition visibility 0s .24s) so the slide-out
   * stays visible; on open it flips immediately. */
  .v2-sidebar {
    position: fixed; top: 0; left: 0; bottom: 0; z-index: 86;
    width: 272px; padding-top: 52px;
    transform: translateX(-100%); visibility: hidden;
    transition: transform .24s cubic-bezier(.32, .72, 0, 1), visibility 0s linear .24s;
  }
  .v2-shell.nav-open .v2-sidebar {
    transform: translateX(0); visibility: visible;
    transition: transform .24s cubic-bezier(.32, .72, 0, 1);
    animation: drawerLeft .24s cubic-bezier(.32, .72, 0, 1);
  }
  /* Scrim keeps its [hidden] default display:none until app.js shows it. */
  .v2-sidebar-scrim {
    position: fixed; inset: 0; z-index: 85;
    background: rgba(6, 6, 10, .42); animation: v2fade .15s ease-out;
  }

  /* Symmetric phone padding; only the TITLE row clears the absolutely-positioned
   * hamburger (via its own margin), so the wrapped controls rows use the full
   * width instead of inheriting a lopsided 56px left indent. */
  .v2-toolbar { padding: 15px 18px 14px; }
  .v2-toolbar > :first-child { margin-left: 44px; }
  .v2-chips   { padding: 12px 18px 4px; }
  .v2-scroll  { padding: 2px 14px 40px; }

  /* On a phone, stack the account select and search each on their own full-width
   * row: the 230px search plus a long placeholder plus the account select can't
   * co-exist in ~300px, and the account <select> won't shrink below its content. */
  .v2-controls { flex-wrap: wrap; gap: 8px; }
  .v2-controls .v2-select, .v2-controls .v2-search { flex: 1 1 100%; width: auto; }

  /* Detail drawer → bottom sheet. #dev-drawer carries no JS inline styles, so a
   * plain override is enough. ::before is the drag handle (drawer is flex-column,
   * so it becomes the first flex child). */
  .v2-drawer {
    top: auto; left: 0; right: 0; bottom: 0;
    width: auto; max-width: none; max-height: 82dvh;
    border-left: 0; border-radius: 22px 22px 0 0;
    animation: sheetUp .26s cubic-bezier(.32, .72, 0, 1);
    padding-bottom: env(safe-area-inset-bottom);
  }
  .v2-drawer::before {
    content: ""; flex: 0 0 auto; width: 38px; height: 5px;
    margin: 8px auto 2px; border-radius: 3px; background: var(--border-2);
  }

  /* Row menu popover → bottom sheet. app.js writes inline top/left/right from
   * getBoundingClientRect(), and an inline style beats a class rule — so this is
   * the ONE place !important is warranted. The positioning JS still runs; it just
   * no longer takes effect here. */
  .v2-popover {
    position: fixed !important; top: auto !important;
    left: 0 !important; right: 0 !important; bottom: 0 !important;
    min-width: 0; border-radius: 16px 16px 0 0;
    animation: sheetUp .26s cubic-bezier(.32, .72, 0, 1);
    padding-bottom: env(safe-area-inset-bottom);
  }
  .v2-popover::before {
    content: ""; display: block; width: 38px; height: 5px;
    margin: 6px auto 8px; border-radius: 3px; background: var(--border-2);
  }

  /* <dialog> (改IP / 备注 / 二次确认) → bottom sheet. It is centred by the UA
   * default margin:auto; dock it to the bottom instead. Still a child of
   * body.v2-root, so var() and ::backdrop keep resolving. */
  .v2-dialog {
    position: fixed; inset: auto 0 0 0; margin: 0;
    width: 100%; max-width: none;
    border-radius: 16px 16px 0 0;
    animation: sheetUp .26s cubic-bezier(.32, .72, 0, 1);
    padding-bottom: max(22px, env(safe-area-inset-bottom));
  }

  /* toast: bottom-centred (design) instead of the desktop bottom-right. */
  .v2-toast {
    left: 50%; right: auto; transform: translateX(-50%);
    bottom: calc(28px + env(safe-area-inset-bottom));
    max-width: 82%; text-align: center;
    animation: toastUp .2s ease-out;
  }

  /* Bulk bar: its left:calc(50% + 120px) offsets for the 240px in-flow sidebar;
   * with no in-flow sidebar in narrow, re-centre on the viewport (its keyframe
   * still carries translate(-50%), so this stays centred while animating). */
  .v2-bulkbar { left: 50%; bottom: calc(24px + env(safe-area-inset-bottom)); }

  /* ── Operations (设备操作) ── The batch card's three columns (设置 / 动作 / 诊断)
   * stack into one on a phone; the batch header and each toggle row wrap so a label
   * + segmented toggle + 应用 button never overflow. The device table already keeps
   * its own overflow-x scroller (operations.html) and its strong operations.js
   * cell-reading contract, so it is left to scroll rather than reflowed. */
  .v2-ops-body { grid-template-columns: 1fr; gap: 16px; padding: 14px; }
  .v2-card-h { flex-wrap: wrap; }
  .v2-ops-row { flex-wrap: wrap; }

  /* ── Topology (拓扑策略) ── The connectivity matrix keeps its horizontal scroll +
   * sticky first column (already in the markup); only cap its height to the visible
   * viewport so it doesn't run under the mobile address bar. */
  .v2-matrix-wrap { max-height: 70dvh; }
}

/* ─────────────── wide (≥ 680px): landscape / unfolded ─────────────── */
@media (min-width: 680px) {
  /* Persistent sidebar again — no hamburger, no scrim. Nothing else changes: the
   * desktop keeps its existing edge-to-edge content (byte-unchanged). Capping the
   * content width on a wide desktop is a separate desktop-design decision, not part
   * of the mobile adaptation, so it is deliberately NOT done here. */
  .v2-hamburger, .v2-sidebar-scrim { display: none; }
}

/* ───────────── Sidebar collapse (desktop icon rail) ─────────────
 * The toggle (#v2-nav-collapse) flips .nav-collapsed on .v2-shell — app.js does
 * it live and writes the sidebar_collapsed cookie, and the shell also renders the
 * class server-side from that cookie so there is no first-paint flash. Collapsed,
 * the 240px column shrinks to a 64px icon rail: labels, brand text, version pill,
 * group headers and the user meta fold away, leaving centered icons; app.js copies
 * each nav item's label into its title= so hover still names them.
 *
 * EVERYTHING that shrinks the column is scoped to @media (min-width: 680px). At
 * narrow the sidebar is the off-canvas drawer (272px, position:fixed), and
 * .nav-collapsed MUST be inert there — the hamburger owns that width. */
.v2-nav-collapse {
  width: 100%; display: flex; align-items: center; gap: 9px;
  padding: 8px 10px; margin-bottom: 4px; border-radius: 9px;
  font-size: 13px; color: var(--fg-2);
  background: transparent; border: 1px solid transparent;
}
.v2-nav-collapse:hover { background: var(--surface-3); color: var(--fg); }
.v2-nav-collapse svg { flex-shrink: 0; transition: transform .18s ease; }

/* Drawer owns the width at narrow; a collapse control is meaningless there. */
@media (max-width: 679.98px) { .v2-nav-collapse { display: none; } }

@media (min-width: 680px) {
  /* The collapse toggle exists at every desktop width (expanded AND collapsed),
   * making .v2-sidebar-foot ~40px taller than the base comment's "footer 110"
   * derivation — so the EXPANDED menu's clip reserve moves 150 → 190 here
   * (footer ~150 + sidebar padding-bottom 20 + 20 breathing; footer height as derived,
   * before P6-3 K3 took the 切回旧版 row out — see the base rule). Without this, on a
   * short desktop window the capped menu tops out above y=0 and
   * .v2-shell{overflow:hidden} eats its first item. Narrow keeps 150: the toggle
   * is display:none there. (The COLLAPSED flyout below is position:fixed and caps
   * against the viewport itself — this reserve is for the expanded in-column menu.) */
  .v2-acct-menu { max-height: calc(100vh - 190px); max-height: calc(100dvh - 190px); }

  .v2-shell.nav-collapsed .v2-sidebar { width: 64px; padding-left: 8px; padding-right: 8px; }

  /* Fold away every text run; keep the icons. */
  .v2-shell.nav-collapsed .v2-brand { justify-content: center; padding-left: 0; padding-right: 0; }
  .v2-shell.nav-collapsed .v2-brand-name,
  .v2-shell.nav-collapsed .v2-ver-pill,
  .v2-shell.nav-collapsed .v2-navitem > span,
  .v2-shell.nav-collapsed .v2-user-meta,
  .v2-shell.nav-collapsed .v2-nav-collapse > span { display: none; }

  /* Group headers → a thin rule, so the three groups stay visually separated with
   * no text. First group loses the rule (it would sit right under the brand). */
  .v2-shell.nav-collapsed .v2-navgroup {
    font-size: 0; color: transparent; padding: 0; margin: 10px 8px 4px;
    height: 1px; background: var(--border); overflow: hidden;
  }
  .v2-shell.nav-collapsed .v2-navgroup:first-child { margin-top: 4px; background: transparent; }

  /* Centered icons. The active accent bar needs no override: the base ::before
   * sits at left:0 of the item box, which here is the rail's inner edge. */
  .v2-shell.nav-collapsed .v2-navitem { justify-content: center; gap: 0; padding-left: 0; padding-right: 0; }

  /* Footer controls collapse to centered icons too. */
  .v2-shell.nav-collapsed .v2-nav-collapse,
  .v2-shell.nav-collapsed .v2-user-btn { justify-content: center; gap: 0; padding-left: 0; padding-right: 0; }
  .v2-shell.nav-collapsed .v2-user-btn > svg { display: none; }
  .v2-shell.nav-collapsed .v2-nav-collapse svg { transform: rotate(180deg); }

  /* The account menu can't live inside a 64px column — it flies out to the RIGHT
   * of the rail, above the content. This is the ONE sanctioned exception to the
   * base rule's "NO z-index / never past the sidebar edge" invariant (the base
   * comment carries the matching amendment), and each escape hatch is deliberate:
   *
   * position:fixed — the containing block becomes the viewport (nothing on the
   * ≥680px path puts transform/filter on an ancestor; the sidebar's width
   * transition is not a containing-block trigger), which buys two things at once:
   * a TRUE 8px gap (left:72px = the rail's 64px outer width + 8 — the previous
   * absolute left:calc(100%+8px) resolved against the FOOT's 47px content box and
   * landed 1px INSIDE the rail's border), and escape from .v2-shell{overflow:hidden},
   * so the base rule's y=0 clipping calculus no longer applies — max-height caps
   * against the viewport directly, with the menu's own overflow-y as the rescue.
   *
   * z-index:10 — crossing the sidebar edge means beating .v2-main's sticky theads
   * (z-1) and .v2-matrix's z-1/2/3, hence > 3; but it must stay BELOW every fixed
   * overlay (.v2-bulkbar 60, .v2-drawer 71, .v2-mask 80, .v2-popover 81) so an
   * open mask/popover/drawer still covers this menu exactly as it covers the
   * expanded in-column menu (a keyboard user can Tab to the avatar button through
   * a pointer-blocking mask; the menu must not paint above it). Do NOT raise ≥60. */
  .v2-shell.nav-collapsed .v2-acct-menu {
    position: fixed; left: 72px; right: auto; top: auto; bottom: 20px;
    min-width: 208px; z-index: 10;
    max-height: calc(100vh - 40px); max-height: calc(100dvh - 40px);
  }

  /* .v2-bulkbar (in #v2-overlays, a SIBLING of .v2-shell — hence ~) centers over
   * the main column via left:calc(50% + sidebar/2); its base +120px assumes the
   * 240px sidebar. The 64px rail moves the main column's center to 50% + 32px —
   * without this override the bar floats 88px right of true center and crowds the
   * viewport edge on ~680–1000px windows. */
  .v2-shell.nav-collapsed ~ #v2-overlays .v2-bulkbar { left: calc(50% + 32px); }
}

/* ══════════════════════════════ Fold cards ══════════════════════════════
 *
 * A table marked `.v2-foldable` stays a normal table on desktop (> 1000px) but
 * becomes a collapsible card list on narrow viewports (≤ 1000px), so a wide table
 * never forces horizontal scrolling on a phone. app.js wires the behaviour: it
 * copies each column header onto its cells as `data-label` (so the expanded card
 * labels itself), injects a `.v2-fold-set` button, and toggles `.open` on the row
 * (tracked by data-id so the state survives the 10s HTMX poll). The DOM is
 * otherwise untouched, so a table's existing JS cell contracts keep working.
 *
 * Desktop-first: everything here is inside the ≤1000 media query except hiding the
 * injected button, so the desktop render path is byte-unchanged. */
.v2-root .v2-foldable .v2-fold-set { display: none; }

@media (max-width: 1000px) {
  .v2-root .v2-scroll .v2-foldable { min-width: 0 !important; }
  .v2-root .v2-foldable td, .v2-root .v2-foldable th { white-space: normal; }

  .v2-root .v2-foldable thead { display: none; }
  .v2-root .v2-foldable, .v2-root .v2-foldable tbody { display: block; }
  .v2-root .v2-foldable tbody tr {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 3px 12px;
    border: 1px solid var(--border); border-radius: 12px; background: var(--surface);
    margin-bottom: 8px; padding: 11px 16px; position: relative;
  }
  .v2-root .v2-foldable tbody tr:hover { border-color: var(--border-2); }
  .v2-root .v2-foldable tbody tr.is-offline, .v2-root .v2-foldable tbody tr.is-stale { opacity: .62; }
  .v2-root .v2-foldable tbody tr.is-offline.ops-row-anomaly { opacity: 1; }

  /* default: hide every cell, then reveal the collapsed key fields */
  .v2-root .v2-foldable tbody td { display: none; padding: 0; border: 0; white-space: normal; }
  .v2-root .v2-foldable tbody tr:not(.open) td:nth-child(1) { display: block; flex: 1 1 100%; order: 0; font-size: 14px; font-weight: 600; padding-right: 84px; }
  .v2-root .v2-foldable tbody tr:not(.open) td:nth-child(2) { display: inline-flex; order: 1; font-size: 12px; color: var(--fg-2); }
  .v2-root .v2-foldable tbody tr:not(.open) td:nth-child(3) { display: inline-flex; order: 2; font-size: 12px; color: var(--muted); }

  /* the 设置 button app.js injects, pinned to the card's top-right corner */
  .v2-root .v2-foldable .v2-fold-set {
    display: block; position: absolute; top: 9px; right: 13px; z-index: 1;
    height: 28px; padding: 0 13px; border: 1px solid var(--border-2); border-radius: 8px;
    background: var(--surface-2); color: var(--fg-2); font-size: 12px; font-weight: 500;
    cursor: pointer; white-space: nowrap;
  }
  .v2-root .v2-foldable .v2-fold-set:hover { border-color: var(--accent-line); color: var(--accent); }

  /* expanded: stack every field, labelled from its column header (data-label) */
  .v2-root .v2-foldable tbody tr.open { display: block; padding: 4px 16px 10px; }
  .v2-root .v2-foldable tbody tr.open .v2-fold-set { top: 11px; background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
  .v2-root .v2-foldable tbody tr.open td { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); font-size: 12.5px; }
  .v2-root .v2-foldable tbody tr.open td:first-child { border-top: 0; font-size: 15px; font-weight: 600; padding: 10px 84px 6px 0; }
  .v2-root .v2-foldable tbody tr.open td::before { content: attr(data-label); color: var(--muted); font-size: 11px; font-weight: 600; margin-right: 8px; flex: 0 0 auto; }
  .v2-root .v2-foldable tbody tr.open td:first-child::before { content: none; }

  /* An empty table renders one .v2-empty cell. It is ALSO td:nth-child(1), so the
     collapsed-title rule above (font-weight:600, font-size:14px, padding-right:84px) would
     leak onto it — bold, oversized and shifted ~42px left of centre (0 left pad vs 84 right).
     Re-assert the muted, centred, breathing empty state; the (0,4,3) tie with the title rule
     resolves here because this rule is later in source. Fixes every foldable table's empty
     state (operations / disconnects / gateway), not just this page. */
  .v2-root .v2-foldable tbody tr:not(.open) td.v2-empty {
    display: block !important;
    font-size: 13px; font-weight: 400; text-align: center; padding: 40px 20px;
  }
  .v2-root .v2-foldable tbody tr:has(td.v2-empty) { display: block; border: 0; background: none; padding: 0; margin: 0; }
}

/* ── 设备操作(19 列控制矩阵)折叠精调 ─────────────────────────────────────
 * 通用折叠把第 1 列当标题;但操作表第 1 列是批量选勾选框、第 2 列(.cell-name)才是
 * 设备名。这里把勾选框钉到卡片左上、用设备名当标题,收起显「名·IP·在线·状态」,展开
 * 成 2 列状态控制台。DOM 不动 → operations.js 的 cell-* / data-val 校验钩子全保。 */
@media (max-width: 1000px) {
  /* 勾选框钉左上(收/展都在,供批量选);卡片左内边距让位。padding 用 !important:
     否则通用折叠给首列的 padding-right:84px 会赢,在名字上方留一层透明可点覆盖。
     `:not(.v2-empty)`:空表时首列就是 colspan 的 .v2-empty 单元格,不能把它绝对定位到
     角上(否则「本账号还没有设备」被钉成一小块)——排除它,让通用空态去卡框规则接管居中。 */
  .v2-root .v2-ops-table tbody td:first-child:not(.v2-empty) { display: block !important; position: absolute; left: 15px; top: 13px; padding: 0 !important; }
  .v2-root .v2-ops-table tbody tr:not(:has(.v2-empty)) { padding-left: 42px; }

  /* 收起:设备名(.cell-name)=标题;IP(3)·在线(4)·版本(.cell-version)·状态(.cell-status)。
     版本紧跟身份组(IP/在线),过低时 small.v2-warn 标红(语义色保留)。用 .v2-ops-table.v2-foldable
     (两个类都在同一 <table> 上)抬高特异性,压过通用的 td:nth-child(2),不靠块间源序。 */
  .v2-root .v2-ops-table.v2-foldable tbody tr:not(.open) td.cell-name { display: block; flex: 1 1 100%; order: 0; font-size: 14px; font-weight: 600; padding-right: 84px; }
  .v2-root .v2-ops-table tbody tr:not(.open) td:nth-child(3) { display: inline-flex; order: 1; font-family: var(--font-mono); font-size: 12px; color: var(--fg-2); }
  .v2-root .v2-ops-table tbody tr:not(.open) td:nth-child(4) { display: inline-flex; order: 2; font-size: 12px; color: var(--muted); }
  .v2-root .v2-ops-table.v2-foldable tbody tr:not(.open) td.cell-version { display: inline-flex; order: 3; font-family: var(--font-mono); font-size: 12px; color: var(--fg-2); }
  .v2-root .v2-ops-table tbody tr:not(.open) td.cell-status { display: inline-flex; order: 4; font-size: 12px; flex-wrap: wrap; }

  /* 展开:2 列状态控制台;设备名与状态跨整行、名字不带标签 */
  .v2-root .v2-ops-table tbody tr.open { display: grid; grid-template-columns: 1fr 1fr; gap: 0 22px; padding: 4px 16px 10px 42px; }
  .v2-root .v2-ops-table.v2-foldable tbody tr.open td.cell-name { grid-column: 1 / -1; border-top: 0; font-size: 15px; font-weight: 600; padding: 10px 84px 6px 0; }
  .v2-root .v2-ops-table tbody tr.open td.cell-name::before { content: none; }
  .v2-root .v2-ops-table tbody tr.open td.cell-status { grid-column: 1 / -1; }
  /* 控制服务器是唯一的长内容格(host:port 列表);跨整行给它满宽,配合 host:port 短展示
     就不再横向溢出、不糊到相邻「数据面」格。overflow-wrap:anywhere 兜底任何过长格(设备名
     /控制服务器等)——半宽格里的长 token 会断行而非溢出,杜绝同类重叠。 */
  .v2-root .v2-ops-table tbody tr.open td.cell-coordservers { grid-column: 1 / -1; }
  .v2-root .v2-ops-table tbody tr.open td { overflow-wrap: anywhere; }

  /* 批量控制区:中间宽度(680–900)3 列被挤到「标签竖排」—— 塌单列 */
  .v2-root .v2-ops-body { grid-template-columns: 1fr; }
  .v2-root .v2-ops-actions { grid-template-columns: 1fr; }
}

/* ══════════════════════════ 可折叠页面说明 ══════════════════════════
 *
 * 一个 `.v2-note` 说明块(页面的解释性 banner / 契约段落)在手机(≤679.98px)折叠成
 * 一条可点开的「ⓘ 本页说明」条,好让真正的 UI 不被说明文字推下去。内容**留在 DOM /
 * fragment 里**——不搬走 —— 所以 load-bearing 契约照样跨 10s 轮询存活、守护测试照过;
 * 只是窄屏视觉折叠。平板/桌面照常全显。app.js 注入 `.v2-note-bar` 并切 `.open`。 */
.v2-root .v2-note-bar { display: none; } /* 平板/桌面:不显示折叠条 */

@media (max-width: 679.98px) {
  .v2-root .v2-note-bar {
    display: flex; align-items: center; gap: 7px; width: 100%;
    padding: 9px 13px; border: 1px solid var(--border); border-radius: 10px;
    background: var(--surface-2); color: var(--fg-2); font-size: 12.5px;
    cursor: pointer; user-select: none; text-align: left;
  }
  .v2-root .v2-note-bar::before { content: "ⓘ"; color: var(--muted); }
  .v2-root .v2-note-bar::after { content: "展开"; margin-left: auto; color: var(--subtle); font-size: 11.5px; }
  .v2-root .v2-note.open > .v2-note-bar::after { content: "收起"; }
  .v2-root .v2-note.open > .v2-note-bar { margin-bottom: 8px; }
  /* 收起态:剥掉说明块自身的盒子,只留折叠条 */
  .v2-root .v2-note:not(.open) { padding: 0 !important; border: 0 !important; background: none !important; margin: 12px 0 4px !important; }
  .v2-root .v2-note:not(.open) > *:not(.v2-note-bar) { display: none !important; }
}
