/* Accent colours live in variables so the two consoles can be told apart at
   a glance. They share this stylesheet, this layout and one domain; before
   this, the only difference on screen was one word in the sidebar.

   Agent keeps the blue it has always had. Hub takes indigo -- far enough
   from blue to read instantly, and deliberately NOT amber: this interface is
   full of red and yellow alerts, and a warm accent would compete with them. */
:root {
    --wg-accent: #38bdf8;
    --wg-accent-solid: #0284c7;
    --wg-accent-solid-hover: #0369a1;
    --wg-sidebar: #111c33;
}
[data-console="hub"] {
    --wg-accent: #a78bfa;
    --wg-accent-solid: #7c3aed;
    --wg-accent-solid-hover: #6d28d9;
    --wg-sidebar: #1a1533;
}
/* WebGuard UI styles — dark, compact management console. */
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
    font-family: -apple-system, "Segoe UI", "Microsoft JhengHei", sans-serif;
    background: #0f172a;
    color: #e2e8f0;
    line-height: 1.5;
}
a { color: var(--wg-accent); text-decoration: none; }
a:hover { text-decoration: underline; }
h1 { font-size: 1.6rem; }
h2 { font-size: 1.1rem; margin: 1.5rem 0 .6rem; color: #cbd5e1; }

/* Shell: fixed-width nav rail on the left, content fills the rest. */
.wg-shell { display: flex; align-items: stretch; min-height: 100vh; }
.wg-sidebar {
    flex: 0 0 190px;
    display: flex; flex-direction: column; gap: 1.2rem;
    padding: 1rem .75rem;
    background: var(--wg-sidebar); border-right: 1px solid #334155;
    position: sticky; top: 0; height: 100vh; overflow-y: auto;
}
.wg-content { flex: 1; min-width: 0; }
.wg-brand { display: flex; align-items: center; gap: .5rem; font-weight: 700; letter-spacing: .08em; color: var(--wg-accent); text-decoration: none; padding: .2rem .5rem; }
.wg-brand:hover { text-decoration: none; }
.wg-logo { width: 22px; height: 26px; display: block; }
.wg-user a { margin-left: .8rem; }

/* Dashboard head actions + run button */
.wg-head-right { display: flex; align-items: center; gap: 1rem; }
.wg-btn-run { background: var(--wg-accent-solid); color: #fff; border: none; border-radius: 5px; padding: .4rem .9rem; font-weight: 600; }
.wg-btn-run:hover { background: var(--wg-accent-solid-hover); }

/* Generic form (change password) */
.wg-form { max-width: 360px; background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 1.5rem; }
.wg-form label { display: block; margin: .8rem 0; color: #cbd5e1; font-size: .85rem; }
.wg-form input { width: 100%; margin-top: .3rem; padding: .5rem; background: #0f172a; border: 1px solid #334155; border-radius: 5px; color: #e2e8f0; }
.wg-form button { margin-top: 1rem; padding: .55rem 1.2rem; background: var(--wg-accent-solid); color: #fff; border: none; border-radius: 5px; font-weight: 600; }
.wg-form button:hover { background: var(--wg-accent-solid-hover); }

/* Login logo */
.wg-login-logo { width: 56px; height: 66px; display: block; margin: 0 auto .5rem; }
/* Vertical nav rail */
.wg-nav { display: flex; flex-direction: column; gap: .15rem; }
.wg-nav a {
    display: block; color: #cbd5e1; padding: .45rem .6rem; border-radius: 6px;
    border-left: 3px solid transparent; font-size: .95rem;
}
.wg-nav a:hover { background: #334155; color: #f1f5f9; text-decoration: none; border-left-color: var(--wg-accent); }

/* Account block pinned to the bottom of the rail */
.wg-user {
    margin-top: auto; display: flex; flex-direction: column; gap: .2rem;
    color: #94a3b8; font-size: .85rem;
    padding: .6rem; border-top: 1px solid #334155;
}
.wg-user-name { color: #cbd5e1; font-weight: 600; }
.wg-user a { margin-left: 0; }
.wg-logout { margin-left: 0; }

.wg-main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }

/* Narrow screens: the rail folds back into a horizontal bar on top. */
@media (max-width: 860px) {
    .wg-shell { flex-direction: column; }
    .wg-sidebar {
        flex: none; width: 100%; height: auto; position: static;
        flex-direction: row; align-items: center; flex-wrap: wrap; gap: .6rem 1rem;
        border-right: none; border-bottom: 1px solid #334155;
    }
    .wg-nav { flex-direction: row; flex-wrap: wrap; gap: .2rem .4rem; }
    .wg-nav a { border-left: none; border-bottom: 3px solid transparent; }
    .wg-nav a:hover { border-left-color: transparent; border-bottom-color: var(--wg-accent); }
    .wg-user {
        margin-top: 0; margin-left: auto; flex-direction: row; align-items: center;
        gap: .8rem; border-top: none; padding: 0;
    }
}
.wg-page-head { display: flex; align-items: baseline; justify-content: space-between; }
.wg-date { color: #64748b; font-size: .9rem; }
.wg-hint { color: #94a3b8; font-size: .9rem; margin: .5rem 0 1rem; }

/* Cards */
.wg-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-top: 1rem; }
.wg-card { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; text-align: center; }
.wg-card-num { display: block; font-size: 1.8rem; font-weight: 700; color: #f1f5f9; }
.wg-card-label { color: #94a3b8; font-size: .85rem; }
.wg-card-warn { border-color: #7f1d1d; }
.wg-card-warn .wg-card-num { color: #f87171; }

/* Alert box */
.wg-alert-box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem; margin-top: 1.5rem; }
.wg-alert-list { list-style: none; }
.wg-alert-list li { display: flex; gap: .8rem; align-items: center; padding: .3rem 0; border-bottom: 1px solid #26334a; font-size: .9rem; }
.wg-ip { font-family: monospace; color: #f1f5f9; }
.wg-rule { color: #94a3b8; }
.wg-time { margin-left: auto; color: #64748b; font-size: .82rem; }

/* Severity badges */
.wg-sev { display: inline-block; padding: .1rem .5rem; border-radius: 4px; font-size: .75rem; font-weight: 700; text-transform: uppercase; }
.wg-sev-high { background: #7f1d1d; color: #fecaca; }
.wg-sev-medium { background: #78350f; color: #fed7aa; }
.wg-sev-low { background: #334155; color: #cbd5e1; }

/* Rule category / event source tags: quieter than severity badges, since
   they classify rather than warn. */
.wg-cat-tag { display: inline-block; margin-left: .4rem; padding: .05rem .4rem; border: 1px solid #334155;
              border-radius: 4px; font-size: .68rem; color: #94a3b8; white-space: nowrap; }
/* Historical-scan transition screen. The scan can run for minutes, so
   something must visibly move the whole time or it reads as a hang. */
.wg-seed-progress { background: #1e293b; border: 1px solid #334155; border-radius: 8px;
                    padding: 2rem; margin-top: 1.5rem; text-align: center; }
.wg-seed-progress h2 { margin: .8rem 0 .2rem; font-size: 1.1rem; }
/* ⛔ The update cover's rules are NOT here any more -- they are inline in
   internal/webui/templates/layout.html. This file is served BY the process
   the cover exists to hide while it restarts, so the cover could not be
   styled at the one moment it matters (production, 2026-09-09: an unstyled
   page and no cover at all). One definition, in the place that always
   loads. ⚠️ .wg-seed-* stay here: the seed page shares them. */
.wg-seed-spinner { width: 42px; height: 42px; margin: 0 auto; border-radius: 50%;
                   border: 3px solid #334155; border-top-color: var(--wg-accent);
                   animation: wg-spin 0.9s linear infinite; }
@keyframes wg-spin { to { transform: rotate(360deg); } }
.wg-seed-detail { color: #64748b; font-size: .8rem; font-family: monospace;
                  overflow-wrap: anywhere; min-height: 1.2em; margin: 0 0 .8rem; }
.wg-seed-bar { height: 6px; background: #0f172a; border-radius: 3px; overflow: hidden;
               max-width: 460px; margin: 0 auto 1.2rem; }
.wg-seed-bar-fill { height: 100%; background: var(--wg-accent); width: 0; transition: width .3s ease; }
/* No total to divide by while reading files: sweep instead of faking a
   percentage the scan does not actually know. */
.wg-seed-bar-indet .wg-seed-bar-fill { animation: wg-sweep 1.4s ease-in-out infinite; }
@keyframes wg-sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.wg-seed-stats { max-width: 460px; margin: 0 auto 1rem; }
.wg-seed-stats th { text-align: left; color: #94a3b8; font-weight: 500; }
.wg-seed-stats td { text-align: right; font-variant-numeric: tabular-nums; }
.wg-alert-warn { background: #78350f; color: #fed7aa; }

/* One tag per event source on a block row. A blocked IP can carry more
   than one, so they must wrap rather than stretch the column. */
.wg-src-tag { display: inline-block; margin: 0 .25rem .2rem 0; padding: .05rem .4rem;
              background: #0f2942; border: 1px solid #1e4976; border-radius: 4px;
              font-size: .7rem; color: #93c5fd; white-space: nowrap; }
.wg-rule-cat { margin: 1.4rem 0 .2rem; font-size: 1rem; color: #cbd5e1;
               border-left: 3px solid #3b82f6; padding-left: .5rem; }
/* Over-capacity notice. Deliberately loud: a blocklist that does not fit
   into the backend means some addresses shown as "blocked" are not being
   dropped by the kernel at all, and that gap must not be discoverable only
   by reading a log file. */
.wg-capacity-warning { background: #3f1d1d; border: 1px solid #b91c1c; border-radius: 6px;
                       padding: .8rem 1rem; margin: 1rem 0; color: #fecaca; line-height: 1.6; }
.wg-capacity-warning strong { display: block; margin-bottom: .3rem; color: #fca5a5; }
/* Per-row marker, so the answer to "is THIS address blocked?" is visible
   next to the address rather than only as a total at the top. */
/* Secondary sort link tucked under the severity header: "severity, then
   newest within each level". A view only -- it has no bearing on which
   addresses the firewall actually enforces. */
.wg-sort-combo { font-size: .68rem; opacity: .75; margin-left: .3rem; }
.wg-unenforced { display: inline-block; margin-left: .4rem; padding: .05rem .4rem;
                 background: #450a0a; border: 1px solid #b91c1c; border-radius: 4px;
                 font-size: .68rem; color: #fca5a5; white-space: nowrap; cursor: help; }

/* Block state on the IP list. Kept on one line: the column is narrow and a
   four-character label breaking across two lines is exactly the "hard to
   read at a glance" this column was added to fix. */
.wg-blockstate { white-space: nowrap; }

/* Tables */
.wg-table { width: 100%; border-collapse: collapse; margin-top: .5rem; font-size: .9rem; }
.wg-table th, .wg-table td { text-align: left; padding: .5rem .6rem; border-bottom: 1px solid #26334a; vertical-align: top; }
.wg-table th { color: #94a3b8; font-weight: 600; }
.wg-empty { color: #64748b; text-align: center; padding: 1.5rem; }
.wg-evidence { white-space: pre-wrap; word-break: break-all; font-size: .72rem; color: #94a3b8; max-width: 340px; max-height: 4.5rem; overflow: auto; }
/* The evidence is raw log lines with no spaces to break on, so the cell it
   sits in grows until the rest of the row has nothing left. max-width on the
   <pre> alone does not stop that: the table sizes the CELL from its content.
   Capping the cell is what makes the other columns readable.

   The rule column is the one that suffered: squeezed to a single character
   wide, "R10 · 機密檔掃描" came out as one letter per line. */
.wg-table td.wg-evidence-cell { max-width: 360px; width: 360px; }
.wg-table td.wg-rulecell { min-width: 8.5rem; }
/* A failed delivery has to stand out at a glance: the notification log is
   read to find the rows that did not go out, not the ones that did. */
.wg-row-bad td { background: #3f1d1d; }
.wg-pre { white-space: pre-wrap; word-break: break-word; font-size: .78rem; color: #cbd5e1; margin: .4rem 0 0; max-width: 46em; max-height: 16rem; overflow: auto; }

/* Buttons */
button { cursor: pointer; font: inherit; }
.wg-btn-unblock { background: #334155; color: #e2e8f0; border: 1px solid #475569; border-radius: 5px; padding: .3rem .7rem; }
.wg-btn-unblock:hover { background: #475569; }
/* A control that cannot be pressed has to LOOK like one. Without this the
   update page's gated buttons were identical to working ones -- the
   disabled attribute was correct, every test passed, and the screenshot
   showed three buttons a customer would press and press again. */
.wg-btn-unblock:disabled,
.wg-btn-unblock[disabled] { background: #1e293b; color: #64748b; border-color: #334155; cursor: not-allowed; }
.wg-btn-unblock:disabled:hover,
.wg-btn-unblock[disabled]:hover { background: #1e293b; }

/* Login */
.wg-login { max-width: 340px; margin: 4vh auto; background: #1e293b; border: 1px solid #334155; border-radius: 10px; padding: 1.4rem 1.5rem; text-align: center; }
.wg-login h1 { color: var(--wg-accent); letter-spacing: .1em; }
.wg-sub { color: #94a3b8; margin-bottom: 1.2rem; font-size: .9rem; }
.wg-login label { display: block; text-align: left; margin: .5rem 0; color: #cbd5e1; font-size: .85rem; }
.wg-login input { width: 100%; margin-top: .15rem; padding: .4rem .5rem; background: #0f172a; border: 1px solid #334155; border-radius: 5px; color: #e2e8f0; }
.wg-login button { width: 100%; margin-top: .7rem; padding: .5rem; background: var(--wg-accent-solid); color: #fff; border: none; border-radius: 5px; font-weight: 600; }

/* Vertical rhythm inside the sign-in cards, tightened as one group.
   The body's 1.5 line-height suits paragraphs of prose; a card that is a
   stack of one-line labels and hints spends it on empty space instead, and
   the join card wore roughly a third of its height that way. Everything here
   is scoped to .wg-login so the console's own pages -- which really are
   prose and tables -- keep the looser rhythm they were designed with. */
.wg-login { line-height: 1.35; }
.wg-login-logo { width: 44px; height: 52px; margin-bottom: .3rem; }
.wg-login h1 { font-size: 1.35rem; }
.wg-login .wg-sub { margin-bottom: .8rem; }
.wg-login .wg-hint { margin: .15rem 0 .5rem; line-height: 1.3; }
.wg-login .wg-facts { margin: .5rem 0; }
.wg-login .wg-facts dt { margin-top: .45rem; }
.wg-login .wg-details { margin: .4rem 0; }
.wg-login .wg-details > summary { padding: .2rem 0; }
/* The links under the button are separate lines of one sentence each; the
   paragraph gap between them read as a gap between sections. */
.wg-login form + .wg-hint { margin-top: .5rem; }
.wg-login p.wg-sub { margin: .35rem 0 0; }
.wg-login p.wg-sub:last-of-type { margin-bottom: .2rem; }
.wg-login .wg-langbar { margin-top: .8rem; padding-top: .5rem; }
.wg-login button:hover { background: var(--wg-accent-solid-hover); }

/* Two columns for the cards that got tall: sign in, register, and the join
   confirmation. The join card was the one that provoked this -- name, host
   facts, details, email, password and the confirm button stacked into a
   screenful, which put the button people have to think about below the fold.

   The card only widens where the columns are actually used (.wg-login-wide);
   everything else keeps the 340px it was designed for. Below 700px the grid
   collapses back to one column, because two columns of half-width inputs on
   a phone is worse than the height it saves. */
.wg-login-wide { max-width: 640px; }
.wg-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; text-align: left; }
/* Full-width rows inside the grid: headings, hints, the submit button and
   anything that reads as a sentence rather than a field. */
.wg-cols > .wg-col-full { grid-column: 1 / -1; }
/* The first field in each column should line up. Labels carry a top margin
   for spacing between stacked fields; at the top of a column that margin is
   what makes the two sides sit at different heights. */
.wg-cols > label:first-child, .wg-cols > *:first-child > label:first-child { margin-top: 0; }
@media (max-width: 700px) {
    .wg-login-wide { max-width: 340px; }
    .wg-cols { grid-template-columns: 1fr; gap: 0; }
}
/* The agent's sign-in page is buttons only now: no password is typed on the
   machine. .wg-btn had no rule at all, so those anchors rendered as bare
   links -- fine as a footnote, wrong as the only control on the page. */
/* Inline by default. The full-width form below is for the sign-in card, where
   the anchor IS the page's one control; applied to every a.wg-btn it turned
   the "歷程" link in a table cell into a full-width purple slab. A rule
   written for one page has to say which page it means. */
a.wg-btn { display: inline-block; box-sizing: border-box; padding: .3rem .7rem; background: var(--wg-accent-solid); color: #fff; border-radius: 5px; font-weight: 600; text-decoration: none; }
.wg-login a.wg-btn, .wg-card a.wg-btn { display: block; width: 100%; margin-top: 1rem; padding: .6rem; }
a.wg-btn:hover { background: var(--wg-accent-solid-hover); }
a.wg-btn-secondary { background: #334155; color: #e2e8f0; border: 1px solid #475569; }
a.wg-btn-secondary:hover { background: #475569; }
/* The per-row controls on "my subscriptions". Each is its own form (they post
   different things), and without this the forms stack as blocks and the row
   grows three lines tall. */
.wg-row-actions { display: flex; gap: .4rem; flex-wrap: wrap; }
.wg-row-actions form { margin: 0; }
/* What a POST on that page has to say back: the refund was sent, the
   cancellation is on its way, the site cannot do this. It is the answer to
   something the customer just pressed, so it must not read as a footnote --
   .wg-hint alone is grey text nobody looks at twice. */
.wg-notice { border-left: 3px solid var(--wg-accent-solid); background: #1e293b; padding: .6rem .8rem; color: #e2e8f0; border-radius: 4px; }
/* The refund estimate, which is a step rather than a summary card. */
.wg-quote { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem 1.2rem; margin: 1rem 0; }
.wg-quote h2 { margin-top: 0; }
/* Host facts on the join-confirm page. .wg-table is built for the wide
   pages: two columns, one line per row. Inside the 340px card that is not
   enough room for a machine-id, so the rows ran past the card edge and the
   label wrapped mid-word. Stack label over value instead, and let the long
   identifiers break anywhere -- they have no spaces to break at. */
.wg-login .wg-table { text-align: left; table-layout: fixed; width: 100%; }
.wg-login .wg-table tr { display: block; padding: .45rem 0; border-bottom: 1px solid #26334a; }
.wg-login .wg-table th,
.wg-login .wg-table td { display: block; width: 100%; padding: .1rem 0; border: none; }
.wg-login .wg-table th { font-size: .78rem; text-transform: uppercase; letter-spacing: .04em; }
.wg-login .wg-table td { overflow-wrap: anywhere; word-break: break-all; }
.wg-langbar { margin-top: 1.5rem; border-top: 1px solid #334155; padding-top: .8rem; }
.wg-langbar a { color: var(--wg-accent); margin: 0 .4rem; }
.wg-langbar strong { color: #e2e8f0; margin: 0 .4rem; }

/* IP category filter bar + badges */
.wg-catbar { display: flex; flex-wrap: wrap; gap: .5rem; margin: 1rem 0; }
.wg-catbar .wg-cat { padding: .3rem .7rem; border-radius: 5px; background: #1e293b; border: 1px solid #334155; color: #cbd5e1; font-size: .85rem; }
.wg-catbar .wg-cat.on { border-color: var(--wg-accent); color: var(--wg-accent); }
.wg-cat { display: inline-block; padding: .1rem .5rem; border-radius: 4px; font-size: .78rem; }
.wg-cat-attacker { background: #7f1d1d; color: #fecaca; }
.wg-cat-robot_good { background: #14532d; color: #bbf7d0; }
.wg-cat-robot_bad { background: #78350f; color: #fed7aa; }
.wg-cat-ad_crawler { background: #3730a3; color: #c7d2fe; }
.wg-cat-fixed_user { background: #0c4a6e; color: #bae6fd; }
.wg-cat-app_client { background: #064e3b; color: #a7f3d0; }
.wg-cat-unknown { background: #334155; color: #cbd5e1; }
.wg-vhosts { font-size: .8rem; color: #cbd5e1; max-width: 240px; word-break: break-word; }
.wg-country { text-align: center; }
.wg-flagimg { width: 24px; height: 18px; border-radius: 2px; vertical-align: middle; object-fit: cover; cursor: help; box-shadow: 0 0 0 1px #33415580; }
.wg-sort { color: #94a3b8; }
.wg-sort:hover { color: var(--wg-accent); }
.wg-catbar .wg-cat-attacker.on, .wg-catbar .wg-cat-robot_good.on,
.wg-catbar .wg-cat-robot_bad.on, .wg-catbar .wg-cat-ad_crawler.on,
.wg-catbar .wg-cat-fixed_user.on, .wg-catbar .wg-cat-unknown.on { outline: 1px solid currentColor; }
.wg-rdns { font-family: monospace; font-size: .78rem; color: #94a3b8; word-break: break-all; max-width: 220px; }
.wg-search { margin: .5rem 0 1rem; display: flex; gap: .5rem; }
.wg-search input { flex: 0 1 320px; padding: .4rem .6rem; background: #0f172a; border: 1px solid #334155; border-radius: 5px; color: #e2e8f0; }
.wg-search button, .wg-catbar + form button { background: var(--wg-accent-solid); color: #fff; border: none; border-radius: 5px; padding: .4rem .9rem; }
.wg-btn-block { background: #7f1d1d; color: #fecaca; border: 1px solid #991b1b; border-radius: 5px; padding: .3rem .7rem; }
.wg-btn-block:hover { background: #991b1b; }

/* A select that is not inside a form or a named block. Same look as the
   vhost selector below -- a dropdown that keeps the browser's default
   chrome on a dark page reads as a rendering fault, not as a control. */
.wg-select { padding: .4rem .6rem; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 5px; }

/* Analysis: vhost selector + heatmap + paths */
.wg-vhost-sel select { padding: .4rem .6rem; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 5px; }
.wg-heatmap-wrap { overflow-x: auto; }
.wg-heatmap { border-collapse: collapse; }
.wg-heatmap th { color: #64748b; font-size: .7rem; font-weight: 400; padding: 2px 4px; text-align: center; }
.wg-heatmap tbody th { text-align: right; padding-right: .5rem; color: #94a3b8; }
.wg-heatmap td { width: 22px; height: 22px; border: 1px solid #0f172a; background: rgba(56,189,248,0); }
.wg-path { font-family: monospace; font-size: .8rem; word-break: break-all; max-width: 480px; }

/* Server error (5xx) box */
.wg-srverr-box { background: #1e293b; border: 1px solid #78350f; border-radius: 8px; padding: 1rem; margin-top: 1.5rem; }
.wg-srverr-box h2 { color: #fdba74; margin-top: 0; }
.wg-srverr-total { font-weight: 700; color: #fdba74; }

/* Dashboard per-source blocks (apache / nginx / ssh / ...). One block is
   generated per source that has data, so the styling must not assume how
   many there are. */
.wg-source-box { background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 1rem 1rem .4rem; margin-top: 1.5rem; }
.wg-source-box h2 { margin-top: 0; display: flex; align-items: baseline; gap: .6rem; font-size: 1.05rem; }
.wg-source-box h2 .wg-date { margin-left: auto; font-weight: 400; color: #64748b; font-size: .82rem; }
.wg-source-box h3 { margin: 1rem 0 .4rem; font-size: .9rem; color: #94a3b8; font-weight: 600; }
.wg-source-box .wg-cards { margin: 0; }

/* Alerts */
.wg-alert { padding: .6rem .9rem; border-radius: 6px; margin: .8rem 0; font-size: .9rem; }
.wg-alert-error { background: #7f1d1d; color: #fecaca; }
.wg-alert-ok { background: #14532d; color: #bbf7d0; }

/* Message box. Additive: no existing selector changes, so no page that does
   not use .wg-modal can be affected by it.
   ⚠️ Styled for BOTH states of <dialog>: `open` in normal flow (what a
   browser with no script shows) and ::backdrop (what showModal gives). The
   first is not a fallback nobody sees -- it is what the page renders before
   the script runs. */
/* ⚠️ `margin: auto` is what centres an open modal, and it has to be said
   here: this stylesheet's reset zeroes margins, which beats the browser's
   own dialog rule -- the box then sits in the top-left corner. Every
   assertion still passed while it did (it was rendered, visible, dismissible
   and really a modal); only the screenshot showed it. */
.wg-modal { background: #1e293b; color: #e2e8f0; border: 1px solid #475569;
            border-radius: 10px; padding: 1.1rem 1.3rem; max-width: 30rem;
            margin: auto; text-align: center;
            box-shadow: 0 10px 30px rgba(0,0,0,.45); }
.wg-modal .wg-btn { display: inline-block; min-width: 8rem; }
.wg-modal::backdrop { background: rgba(2, 6, 23, .65); }
.wg-modal-title { margin: 0 0 .5rem; font-size: 1.05rem; color: #fecaca; }
.wg-modal-body { margin: 0 0 1rem; font-size: .92rem; line-height: 1.5; text-align: left; }

/* ---- notification settings page ---- */
.wg-settings-form h2 { margin: 1.2rem 0 2px; font-size: 1.05rem; }
.wg-settings-table td { vertical-align: middle; }
.wg-set-label { width: 12em; color: #cbd5e1; white-space: nowrap; }
.wg-settings-table .wg-hint { margin: 0; font-size: .82rem; }
.wg-settings-form input[type="text"], .wg-settings-form input[type="password"],
.wg-settings-form input[type="number"], .wg-settings-form select {
    background: #0f172a; color: #e2e8f0; border: 1px solid #334155;
    border-radius: 6px; padding: 4px 8px;
}

/* ---- dashboard attack map (OSM) ---- */
.wg-attackmap-box { margin-top: 1.2rem; }
/* Map spans the full width; the Top-10 list drops below it. */
.wg-attackmap-layout { display: flex; flex-direction: column; gap: 16px; }
.wg-attackmap-side { width: 100%; }
.wg-attackmap-side h3 { margin: 0 0 6px; font-size: 0.95rem; color: #cbd5e1; }
/* A heading with its explanation folded behind a "?". Native <details>, so
   it works with JavaScript off and Playwright can open it like a user. */
.wg-head-help { display: flex; align-items: baseline; gap: 8px; }
.wg-head-help h2 { margin-right: 0; }
.wg-help { display: inline-block; position: relative; }
.wg-help summary {
    list-style: none; cursor: help;
    display: inline-flex; align-items: center; justify-content: center;
    width: 18px; height: 18px; border-radius: 50%;
    background: rgba(148, 163, 184, .15); color: #94a3b8;
    font-size: .75rem; line-height: 1; user-select: none;
}
.wg-help summary::-webkit-details-marker { display: none; }
.wg-help summary:hover { background: rgba(56, 189, 248, .18); color: var(--wg-accent); }
.wg-help[open] summary { background: rgba(56, 189, 248, .18); color: var(--wg-accent); }
.wg-help-body {
    position: absolute; z-index: 20; top: 24px; left: 0; width: 30rem; max-width: 70vw;
    padding: 8px 10px; border-radius: 6px;
    background: #0f172a; border: 1px solid rgba(148, 163, 184, .25);
    color: #cbd5e1; font-size: .8rem; line-height: 1.5; font-weight: 400;
}
.wg-help-body p { margin: 0; }
.wg-help-body p + p { margin-top: 6px; }

/* Fleet daily trend: one polyline per host on a shared linear axis. Drawn
   by hand rather than with a chart library -- the only vendored front-end
   dependency is Leaflet, and this chart needs no interaction. */
/* position: relative anchors the hover readout, which is an HTML div rather
   than SVG text so it can wrap and list one row per host. */
.wg-dailytrend { margin-top: 8px; position: relative; }
.wg-trend-svg { width: 100%; height: auto; display: block; }
.wg-trend-grid { stroke: rgba(148, 163, 184, .18); stroke-width: 1; }
.wg-trend-tick { fill: #94a3b8; font-size: 11px; }
.wg-trend-tick-y { text-anchor: end; }
.wg-trend-tick-x { text-anchor: middle; }
/* The host name printed at the end of its own line. */
.wg-trend-endlabel { font-size: 11px; font-weight: 600; }
.wg-trend-crossline { stroke: rgba(226, 232, 240, .45); stroke-width: 1; stroke-dasharray: 3 3; }
.wg-trend-hit { cursor: crosshair; }
.wg-trend-tip {
    position: absolute; z-index: 15; pointer-events: none;
    min-width: 8rem; padding: 6px 9px; border-radius: 6px;
    background: rgba(15, 23, 42, .96); border: 1px solid rgba(148, 163, 184, .3);
    font-size: .78rem; color: #e2e8f0; white-space: nowrap;
}
.wg-trend-tip-day { color: #94a3b8; margin-bottom: 3px; }
.wg-trend-tip ul { list-style: none; margin: 0; padding: 0; }
.wg-trend-tip li { display: flex; align-items: center; gap: 5px; }
.wg-trend-tip li i { display: inline-block; width: 8px; height: 8px; border-radius: 2px; }
.wg-trend-tip li b { margin-left: auto; padding-left: 10px; }
.wg-trend-legend {
    list-style: none; margin: 6px 0 0; padding: 0;
    display: flex; flex-wrap: wrap; gap: 10px;
    font-size: .8rem; color: #cbd5e1;
}
.wg-trend-legend i {
    display: inline-block; width: 10px; height: 10px;
    border-radius: 2px; margin-right: 5px; vertical-align: middle;
}

.wg-map-toplist { max-width: 560px; }
.wg-map-toplist td, .wg-map-toplist th { padding: 4px 8px; font-size: 0.85rem; }

/* ---- dashboard card sparklines ---- */
.wg-card { position: relative; overflow: hidden; }
/* A card carrying a "?" must not clip it. The popover is absolutely
   positioned and taller than the card, so the rule above would cut it off
   with no sign on screen that anything is missing -- it stays in the DOM,
   so a text assertion still finds it. Only for cards with no sparkline. */
.wg-card-help { overflow: visible; }
.wg-card-help .wg-help-body { left: auto; right: 0; text-align: left; }
.wg-spark { position: absolute; left: 8px; right: 8px; bottom: 4px; width: calc(100% - 16px); height: 20px; opacity: .8; }
.wg-spark polyline { fill: none; stroke: #60a5fa; stroke-width: 2; vector-effect: non-scaling-stroke; }
.wg-spark-warn polyline { stroke: #f87171; }

/* ---- OSM attack map ---- */
#wg-osmmap { width: 100%; height: 460px; border-radius: 10px; border: 1px solid #1e293b; background: #0b1220; z-index: 0; }

/* attack markers: filled circle with the count inside */
.wg-attack-dot span {
    display: flex; align-items: center; justify-content: center;
    border-radius: 50%; color: #fff; font-weight: 700; font-size: 11px;
    border: 2px solid rgba(255,255,255,.85); box-shadow: 0 0 4px rgba(0,0,0,.5);
    box-sizing: border-box;
}

/* map "back to origin" control */
.wg-map-homebtn { font-size: 18px; line-height: 26px; text-align: center; display: block; width: 30px; height: 30px; color: #333; }

/* pager (blocklist etc.) */
.wg-pager { display: flex; align-items: center; gap: 1rem; margin-top: 1rem; justify-content: center; }
.wg-pager-info { color: #94a3b8; font-size: .9rem; }
.wg-pager-off { color: #475569; }
.wg-table thead a { color: #cbd5e1; }
.wg-table thead a:hover { color: var(--wg-accent); text-decoration: none; }

/* ---- fleet console (multi-host) ---- */
.wg-state {
    display: inline-block; padding: 1px 9px; border-radius: 999px;
    font-size: .8rem; font-weight: 600; white-space: nowrap;
}
/* Line charts (monitoring trends). Server-rendered SVG; the only script is
   a hover layer, so the picture is complete with JS off. */
.wg-chart { position: relative; margin: .4rem 0 .6rem; }
.wg-chart-svg {
    width: 100%; height: 220px; display: block;
    background: rgba(15,23,42,.35); border: 1px solid #1e293b; border-radius: 8px;
}
.wg-chart-grid   { stroke: rgba(148,163,184,.16); stroke-width: 1; }
.wg-chart-mark   { stroke-width: 1; stroke-dasharray: 5 4; opacity: .75; }
.wg-chart-axis   { fill: #94a3b8; font-size: 10px; }
.wg-chart-line   { fill: none; stroke-width: 1.8; stroke-linejoin: round; stroke-linecap: round;
                   vector-effect: non-scaling-stroke; }
.wg-chart-area   { opacity: .10; }
.wg-chart-cursor { stroke: #cbd5e1; stroke-width: 1; stroke-dasharray: 3 3; opacity: .6; }

.wg-chart-legend { display: flex; flex-wrap: wrap; gap: .9rem; margin-bottom: .35rem; font-size: .82rem; }
.wg-legend-item  { display: inline-flex; align-items: center; gap: .35rem; color: #cbd5e1; }
.wg-legend-item i { width: 12px; height: 3px; border-radius: 2px; display: inline-block; }
.wg-legend-item b { color: #f1f5f9; font-weight: 600; }
.wg-legend-off   { opacity: .45; }

.wg-chart-tip {
    position: absolute; top: 6px; pointer-events: none; z-index: 5;
    background: rgba(15,23,42,.95); border: 1px solid #334155; border-radius: 6px;
    padding: 6px 9px; font-size: .78rem; line-height: 1.45; color: #e2e8f0; white-space: nowrap;
}
.wg-chart-range { display: inline-flex; gap: .4rem; font-size: .8rem; }
.wg-chart-range a {
    padding: 2px 9px; border-radius: 999px; text-decoration: none;
    background: rgba(148,163,184,.10); color: #94a3b8; border: 1px solid transparent;
}
.wg-chart-range a.wg-range-on { background: rgba(56,189,248,.15); color: var(--wg-accent); border-color: rgba(56,189,248,.4); }

/* Resource readings on the fleet host list: compact chips, red when the
   value crossed its alert threshold. */
.wg-res {
    display: inline-block; margin-right: 6px; padding: 1px 7px; border-radius: 4px;
    font-size: .76rem; white-space: nowrap;
    background: rgba(148,163,184,.12); color: #94a3b8;
}
.wg-res-over { background: rgba(220,38,38,.18); color: #fca5a5; }

.wg-state-ok   { background: rgba(34,197,94,.15);  color: #4ade80; border: 1px solid rgba(74,222,128,.35); }
.wg-state-warn { background: rgba(234,179,8,.15);  color: #facc15; border: 1px solid rgba(250,204,21,.35); }
/* Not a warning: something this machine depends on cannot be reached, and
   the reader has to act. Kept distinct from -warn so "the feed arrives
   tonight" and "this host stopped receiving the feed" cannot be skimmed as
   the same amber. */
.wg-state-err  { background: rgba(239,68,68,.15);  color: #f87171; border: 1px solid rgba(248,113,113,.35); }
.wg-state-off  { background: rgba(148,163,184,.12); color: #94a3b8; border: 1px solid rgba(148,163,184,.3); }
/* One-line evidence in a table cell: clip it rather than let a log line push
   the layout sideways. Deliberately NOT pre: a <pre class="wg-evidence"> is a
   snippet meant to be read and copied, and nowrap+ellipsis silently ate the
   ini example on the rulepack page down to "…". */
:not(pre).wg-evidence { max-width: 34em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #94a3b8; font-size: .85rem; }
pre.wg-evidence { white-space: pre; overflow-x: auto; color: #94a3b8; font-size: .85rem; max-width: 100%; max-height: none; }

/* Hub console: filter selects + hourly activity bars (plan F2.1) */
.wg-search select { padding: .4rem .6rem; background: #0f172a; color: #e2e8f0; border: 1px solid #334155; border-radius: 5px; }
.wg-bars { display: flex; align-items: flex-end; gap: 2px; height: 64px; margin: .4rem 0; }
.wg-bar { flex: 1; display: flex; align-items: flex-end; height: 100%; background: rgba(148,163,184,.08); border-radius: 2px; }
.wg-bar i { display: block; width: 100%; background: var(--wg-accent); border-radius: 2px 2px 0 0; min-height: 0; }
/* Hover readout for the hourly bars. The line keeps its height whether or
   not anything is hovered -- swapping between empty and filled would shift
   the chart under the pointer, which moves the bar out from under it. */
/* Stacked strip: each bar holds one <i> per host, stacked bottom-up.
   column-reverse so the first series sits at the BOTTOM, matching the
   legend's reading order -- colour is the only thing tying a segment to a
   name, so a flipped stack would quietly mislabel every bar. */
.wg-bars-stacked .wg-bar { flex-direction: column-reverse; justify-content: flex-start; }
.wg-bars-stacked .wg-bar i { border-radius: 0; }
.wg-bars-stacked .wg-bar i:last-child { border-radius: 2px 2px 0 0; }
.wg-bars-legend { margin: .3rem 0 0; font-size: .78rem; color: #94a3b8; display: flex; flex-wrap: wrap; gap: .1rem 1rem; }
.wg-legend-item { display: inline-flex; align-items: center; gap: .35rem; }
.wg-legend-item i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
.wg-bars-readout { margin: 0; min-height: 1.2em; color: #64748b; font-size: .8rem; }
.wg-bars-readout-on { color: var(--wg-accent); font-weight: 600; }
.wg-bar:hover, .wg-bar-on { background: rgba(148,163,184,.18); }
.wg-bar-on i { filter: brightness(1.25); }


/* Which console am I on. The sidebar says it in words; the accent colour
   says it before anyone reads. */
.wg-role { color: #94a3b8; font-size: .75rem; letter-spacing: .08em;
           text-transform: uppercase; padding: 0 .5rem; margin-top: -.9rem; }
/* The running build, under the console name. Dimmer and tighter to the line
   above: it is a reference, not something to read on the way past. */
.wg-sidebar-version { color: #64748b; letter-spacing: .04em; text-transform: none;
                      margin-top: -.6rem; }
/* The build revision sits under the version, quieter than it: the version is
   what people read at a glance, the revision is what they come looking for.
   It can be long ("r6414:6420M" on a mixed working copy), so it wraps inside
   the rail rather than widening it. */
.wg-sidebar-rev { font-size: .78em; opacity: .72; word-break: break-all; }
/* Button classes used on links as well as buttons: an anchor keeps its
   underline and its inline box otherwise, which reads as a link wearing a
   button's colours. */
a.wg-btn-run, a.wg-btn-unblock { display: inline-block; text-decoration: none; }
/* Subscription tier, next to the console name. Bold and a shade brighter
   than the label it sits on: it answers "what am I paying for" at a glance,
   and a customer who cannot see the difference cannot ask about it.
   text-transform is reset because the source strings are already cased. */
.wg-tier { font-weight: 700; letter-spacing: .04em; text-transform: none; }
.wg-tier-pro { color: #34d399; }
.wg-tier-free { color: #cbd5e1; }

.wg-langbar-side { margin: 0 0 .3rem; border: none; padding: 0; font-size: .82rem; }
.wg-langbar-side a, .wg-langbar-side strong { margin: 0 .35rem 0 0; }

/* Join-confirmation facts. Machine-generated identifiers have no spaces to
   break at, so they are told they may break anywhere. */
.wg-facts { margin: 1rem 0; text-align: left; }
.wg-facts dt { color: #94a3b8; font-size: .78rem; text-transform: uppercase;
               letter-spacing: .04em; margin-top: .7rem; }
.wg-facts dd { color: #e2e8f0; overflow-wrap: anywhere; word-break: break-all; }
.wg-facts-fingerprint dd { font-family: monospace; font-size: .85rem; color: #94a3b8; }

/* The identifiers nobody checks by eye, folded away so the confirm button
   stays on the first screen. Closed it costs one line; open it says the
   same as before. */
.wg-details { text-align: left; margin: .8rem 0; }
.wg-details > summary { cursor: pointer; color: #94a3b8; font-size: .85rem;
                        padding: .35rem 0; list-style-position: inside; }
.wg-details > summary:hover { color: #cbd5e1; }
.wg-details[open] > summary { color: #cbd5e1; }
.wg-details .wg-facts { margin: .4rem 0 0; }


/* A button that has been pressed and is waiting for the page to move.
   Without it a click on a slow action looks like nothing happened, and the
   reader presses again -- which is how one unblock becomes two requests.

   The spinner is drawn from the button's own colour, so it works on every
   button variant without a per-variant rule. Reduced-motion readers get the
   same state without the animation: the label change and the dimming are
   what carry the meaning, the spin is decoration. */
button.wg-busy, input.wg-busy { opacity: .72; cursor: progress; }
button.wg-busy::before {
    content: ""; display: inline-block; width: .78em; height: .78em;
    margin-right: .45em; vertical-align: -.06em;
    border: 2px solid currentColor; border-right-color: transparent;
    border-radius: 50%; animation: wg-spin .6s linear infinite;
}
@keyframes wg-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
    button.wg-busy::before { animation: none; opacity: .5; }
}

/* Update cover. Raised while this machine updates itself: the reader must not
   be able to operate a console that is about to swap its own binary and
   restart, so this covers the sidebar too rather than only disabling the
   buttons on one page.

   ⛔ position:fixed with a full viewport inset is the whole point -- an
   overlay nested in the shell would leave the navigation clickable, which is
   exactly the failure this exists to prevent. */
/* ⛔ Without this the cover is ALWAYS up. The `hidden` attribute is honoured by
   a UA rule of the lowest possible specificity, so the `display:flex` above
   silently wins and a hidden overlay still swallows every click on the page --
   including the sign-in button, which is how this was found. Anything given a
   display value must restate hidden. */
/* The cover gave up waiting rather than the update having finished. It stays
   on screen and changes colour: hiding it would make "done" and "we stopped
   watching" look the same. */
/* Three endings, three colours. They are not decoration: the next step
   differs for each -- sign in again, look at why it did not take, or go and
   check which version is actually running -- so a reader who only glances at
   the shape must not be able to mistake one for another.
   The spinner stops in all three: a turning spinner over a finished job is
   the page still claiming to be working. */
/* ⛔ .wg-update-versions and .wg-update-relogin are NOT here either -- they
   are inline in layout.html with the rest of the cover, for the same reason:
   this file is served by the process the cover exists to hide. They were left
   behind when the cover moved, so the ending screen showed a bare unstyled
   link where its button should be. */
/* Paged lists (admin console). The count sits on the left and the page
   numbers on the right, so the total is read before the controls -- it is
   the figure that says whether the list on screen is the whole answer. */
.wg-pager { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem; margin: .6rem 0 1.2rem; font-size: .9rem; }
.wg-pager-count { color: #94a3b8; }
.wg-pager-pages { display: flex; align-items: center; gap: .25rem; }
.wg-pager-pages a, .wg-pager-here, .wg-pager-off, .wg-pager-gap { padding: .25rem .55rem; border-radius: 5px; }
.wg-pager-pages a { color: #e2e8f0; border: 1px solid #334155; text-decoration: none; }
.wg-pager-pages a:hover { background: #1e293b; }
.wg-pager-here { background: var(--wg-accent-solid); color: #fff; border: 1px solid transparent; }
/* Not a link, and it must not look like one: an arrow that cannot go
   anywhere is worse than no arrow, because it reads as a broken page. */
.wg-pager-off { color: #475569; border: 1px solid #1e293b; }
.wg-pager-gap { color: #475569; }
.wg-tabs { display: flex; gap: .5rem; margin: .8rem 0 .2rem; }
.wg-tabs a { padding: .35rem .8rem; border-radius: 5px 5px 0 0; border: 1px solid #334155; border-bottom: none; color: #cbd5e1; text-decoration: none; }
.wg-tabs a.wg-tab-on { background: #1e293b; color: #fff; }
/* The sortable column headers. The arrow is part of the link so the whole
   header is one target. */
.wg-table th a { color: inherit; text-decoration: none; }
.wg-table th a:hover { text-decoration: underline; }
.wg-sort-on { color: var(--wg-accent-solid); }