/* OldSwedes.se — dark chrome (navbar/footer) + light content area, on top
 * of Bootstrap 5.
 *
 * The whole palette is derived from one color: the orange sampled from the
 * logo (public/assets/images, hue ~42°). The warm-gray neutrals used for
 * backgrounds/surfaces and the semantic colors (success/danger/info) are
 * all generated from that single hue so nothing clashes — neutrals share
 * the orange's hue at low saturation instead of being a generic cold gray.
 */

:root {
    /* Accent (brand orange), light -> dark. -400 is the logo's exact color;
       use it on dark backgrounds (buttons, chrome). Use -600/-800 for text
       on the light content area — -400 is too bright to read there. */
    --accent-50: #f9f5eb;
    --accent-200: #fde09b;
    --accent-300: #fcc94f;
    --accent-400: #fbb204;
    --accent-500: #d39603;
    --accent-600: #b07d03;
    /* Same gold (hue 42°, saturation untouched), walked down in lightness until
       it clears 4.5:1 on the PALEST surface it has to work on — gray-100, the
       hover fill — not merely on white. accent-500 is 2.52:1 against the page:
       fine as a border, or as a background under dark text, but it vanishes the
       moment it IS the text. Use this one wherever the accent must be read:
       list markers, active links, anything small on a light surface. */
    --accent-700: #8e6502;
    --accent-800: #644702;

    /* Warm neutrals (same hue as the accent, much less saturated than the
       accent scale — just a whisper of warmth, not a cream/yellow cast) */
    --gray-50: #f8f8f7;
    --gray-100: #eeeeec;
    --gray-200: #d1cec7;
    --gray-300: #afa99d;
    /* Fills the gap between 300 and 600: the lightest tone that still clears
       3:1 on white, which is the floor for a border or any other non-text
       element you're meant to be able to see. */
    --gray-400: #8a8477;
    --gray-600: #4f4a40;
    --gray-700: #38352e;
    --gray-800: #272520;
    --gray-900: #191815;

    /* Dark chrome: navbar + footer */
    --chrome-bg: var(--gray-900);
    --chrome-text: var(--gray-50);

    /* Light content area: <main> is near-white; cards/panels sitting on it
       get a slightly-shifted warm white so they read as distinct surfaces
       instead of blending into the page. */
    --page-bg: #fdfcfb;
    --page-text: var(--gray-900);
    --page-text-muted: var(--gray-600);

    /* Semantic color, chosen to harmonize with the accent's hue */
    --os-danger: #b5222d;
    --os-success: #2f9e44;

    /* The seam between plugged-together lego pieces in the builders: the rim
       around a connector peg, and the socket hole cut into a loose brick. It
       isn't "white" by design — it's the surface BEHIND the bricks showing
       through a slightly-larger hole, so it has to follow the page. RGB triplet
       (used inside rgba()) because each spot wants its own alpha. */
    --lego-seam: 255, 255, 255;
}

body {
    background-color: var(--chrome-bg);
    color: var(--chrome-text);
}

.navbar,
footer {
    background-color: var(--chrome-bg) !important;
}

/* Accent divider between the dark chrome (navbar/footer) and the light
   content area. */
.accent-divider {
    height: 2px;
    background-color: var(--accent-400);
}

.navbar-brand img {
    filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6));
}

.dropdown-menu {
    --bs-dropdown-bg: var(--gray-800);
    --bs-dropdown-link-color: var(--gray-50);
    --bs-dropdown-link-hover-color: var(--gray-900);
    --bs-dropdown-link-hover-bg: var(--accent-400);
    --bs-dropdown-border-color: rgba(255, 255, 255, 0.08);
    --bs-dropdown-divider-bg: rgba(255, 255, 255, 0.08);
}

/* Menu-divider section headers (a labelled divider) — brand orange so they
   read clearly as section labels instead of the muted default grey. Applies
   in dropdowns and to a top-level divider label in the navbar. */
.dropdown-menu .dropdown-header,
.navbar .nav-section-label {
    color: var(--accent-400);
    font-weight: 700;
    font-size: 0.72rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

/* An unlabelled menu divider draws a visible orange line — a dashed <hr> in a
   dropdown, a vertical rule up in the horizontal main menu. */
.dropdown-menu hr.menu-divider-line {
    border-top: 1px dashed var(--accent-400);
    opacity: 1;
    margin: 0.4rem 0.75rem;
}
/* The Log-out separator: a solid, full-width orange rule that caps off the
   menu — distinct from the dashed, inset section dividers above. */
.dropdown-menu hr.menu-divider-solid {
    border-top: 1px solid var(--accent-400);
    opacity: 0.6;
    margin: 0.4rem 0.75rem; /* inset a touch on both sides so it reads as part of the menu */
}
/* A vertical separator in the horizontal main menu stays neutral — orange is
   reserved for the dropdown section dividers, and a vertical orange bar in the
   top bar reads too loud. */
.navbar .vr.menu-divider-line {
    background-color: rgba(255, 255, 255, 0.4);
    opacity: 1;
}

footer a:hover {
    text-decoration: underline !important;
}

/* Footer sitemap columns: equal-width, wrapping — so few columns (anon) spread
   evenly and many (an admin's Admin/HeadAdmin sections) still sit side by side
   instead of one dropping onto a lonely second row. */
.footer-cols {
    display: flex;
    flex-wrap: wrap;
    gap: 1.5rem 2.5rem;
}
.footer-cols .footer-col {
    flex: 1 1 0;
    min-width: 9rem;
}

/* Masked confirm field: a plain text input — so NO browser password manager
   offers or fills the saved login — rendered as discs. Chromium/WebKit mask it
   natively with -webkit-text-security; Firefox lacks that, so a tiny OFL font
   (SIL Open Font License, see assets/fonts/text-security-LICENSE.txt) draws every
   glyph as a disc there. The real typed value still posts. font-display:block so
   there's no flash of the plaintext before the font loads. */
@font-face {
    font-family: 'text-security-disc';
    src: url('/assets/fonts/text-security-disc.woff2') format('woff2');
    font-display: block;
}
.masked-input {
    -webkit-text-security: disc;
    font-family: 'text-security-disc', var(--bs-body-font-family, sans-serif);
}

/* --- Light content area --- */

main {
    display: block;
    background-color: var(--page-bg);
    color: var(--page-text);
    min-height: calc(100vh - 300px);
}

main a {
    color: var(--accent-600);
}

main a:hover {
    color: var(--accent-800);
}

main .card {
    background-color: var(--gray-50);
    border: 1px solid var(--gray-200);
    box-shadow: 0 1px 3px rgba(25, 24, 21, 0.06), 0 1px 2px rgba(25, 24, 21, 0.08);
}

/* The light fill is decoration, and that's allowed.
   I removed it once for measuring 1.13:1 against the page — but the 3:1 floor
   is for elements that CARRY information: a field's border, a meaningful icon.
   A header's background carries none; the rule under it and the weight of the
   title do that work. Judging a decorative fill by a rule written for
   functional ones made the page duller without making it more readable —
   gray-100 in fact gives the title 15.28:1 against gray-200's 11.30:1.
   No accent edge: a third warm tone in the same inch was what turned it muddy. */
main .card-header {
    background-color: var(--gray-100);
    border-bottom: 1px solid var(--gray-300);
    color: var(--page-text);
    font-weight: 600;
}

/* Section colour: an "aha, new topic" marker down a long settings page.
   The whole card takes the hue — a tinted header and a barely-there wash on the
   body — so a card reads as one object in one colour rather than a grey box
   wearing a stripe.
   The colour is REDUNDANT on purpose. The icon and title already say which card
   this is, so the hue reinforces rather than informs — which matters because
   these six differ mainly in hue, not lightness, and hue alone is what a
   colour-blind reader can't rely on. Nobody has to see the colour to use the
   page; it's there so the eye can find its way back to a card it has seen.
   Tints are derived with color-mix rather than hand-picked, so one value per
   section defines all three surfaces and they can't drift apart. Browsers
   without it fall back to the plain grey header, which is what this looked like
   yesterday — degraded, not broken. Contrast holds throughout: title ~15:1,
   description ~7.4:1, body text ~16.8:1. */
main .card-header {
    border-left: 4px solid var(--section, transparent);
}

main .card-header > i:first-child { color: var(--section, inherit); }

@supports (background: color-mix(in srgb, red 10%, white)) {
    /* The class sits on the CARD, so --section inherits down to the header and
       the body alike. Setting it on the header instead was my first attempt and
       silently did nothing: custom properties inherit downwards, and the body
       is a sibling, not a child. */
    main .card[class*="section-"] {
        background-color: color-mix(in srgb, var(--section) 4%, #ffffff);
    }

    main .card[class*="section-"] > .card-header {
        background-color: color-mix(in srgb, var(--section) 12%, #ffffff);
        border-bottom-color: color-mix(in srgb, var(--section) 28%, #ffffff);
    }
}

.section-ai          { --section: #6b4fa8; }
.section-media       { --section: #186b63; }
.section-mail        { --section: #1f5c9e; }
.section-bans        { --section: #a33232; }
.section-rank        { --section: #3d6b28; }
.section-general     { --section: #8e6502; }
.section-connections { --section: #2f6f8f; }

/* Sidebar reordering, revealed by manage mode.
   The grip takes no space until the mode is on: a column that carries handles
   nobody can use is a column that looks half-built to every member reading it. */
.widget-grip { display: none; }

body[data-manage-mode="on"] .widget-grip {
    display: inline-block;
    cursor: grab;
    color: var(--page-text-muted);
    margin-right: 0.15rem;
}

body[data-manage-mode="on"] .widget-grip:active { cursor: grabbing; }

/* Only while managing: the outline says which cards are movable, and saying it
   to a reader would be noise about a thing they cannot do. */
body[data-manage-mode="on"] .widget-card { outline: 1px dashed var(--gray-300); outline-offset: 2px; }
body[data-manage-mode="on"] .widget-card.is-dragging { opacity: 0.45; }

/* The save is a background request after a drop that already looks finished, so
   it needs a sign of its own — and, more importantly, a sign when it fails.
   Silently keeping an order the server rejected is how you come back tomorrow
   to a sidebar that has forgotten what you did. */
[data-widget-sort].is-saving { opacity: 0.75; }

[data-widget-sort].is-failed .widget-card {
    outline-color: #b02a2a;
    outline-style: solid;
}

[data-bs-theme="dark"] body[data-manage-mode="on"] .widget-card { outline-color: rgba(255, 255, 255, 0.18); }
[data-bs-theme="dark"] [data-widget-sort].is-failed .widget-card { outline-color: #ff8a8a; }

/* The rank top-list widget. The position column is fixed-width so the names
   start on the same pixel whether the row is 1. or 10. — a ladder whose left
   edge wobbles reads as a list of unrelated things. */
.rank-widget-pos {
    flex: none;
    width: 1.4rem;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

.rank-widget li > .ms-auto { font-variant-numeric: tabular-nums; }

/* ---------------------------------------------------------------------------
   Connection rows (Site Settings).

   One line per external connection, folded shut. Five stacked cards was the
   previous shape, and it buried the one thing an admin opens this page to find
   out — whether the thing works — under five forms they mostly weren't there to
   edit. So the row answers first and the form waits behind a click.

   Each row carries its own --section inline (set in the template), which paints
   the left edge and the icon. The colour identifies the connection at a glance
   in a list where every row is otherwise the same shape; it says nothing about
   health. That's the lamp's job, and only the lamp's — a red row on a red
   connection would be unreadable.
   --------------------------------------------------------------------------- */
.conn-list { display: flex; flex-direction: column; }
.conn + .conn { border-top: 1px solid var(--gray-200); }

.conn-head {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    padding: 0.5rem 0.85rem;
    background: none;
    border: 0;
    border-left: 4px solid var(--section, transparent);
    text-align: left;
    color: inherit;
    font-size: 0.9rem;
}

.conn-head:hover { background-color: rgba(0, 0, 0, 0.03); }
.conn-head:focus-visible { outline: 2px solid var(--accent-500); outline-offset: -2px; }

.conn-icon { color: var(--section, inherit); flex: none; }
.conn-label { font-weight: 600; flex: none; }

/* The address, the middle of the row and the part allowed to shrink: on a
   narrow screen the name and the status matter more than the hostname. */
.conn-summary {
    color: var(--page-text-muted);
    font-size: 0.82rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
    flex: 1 1 auto;
}

.conn-status {
    font-size: 0.82rem;
    color: var(--page-text-muted);
    text-align: right;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 40%;
    flex: 0 1 auto;
}

.conn-status[data-state="fail"] { color: #b02a2a; font-weight: 500; }
.conn-status[data-state="ok"]   { color: #2f7d32; }

.conn-source {
    flex: none;
    font-size: 0.7rem;
    line-height: 1;
    padding: 0.25rem 0.45rem;
    border-radius: 999px;
    border: 1px solid var(--gray-300);
    color: var(--page-text-muted);
    white-space: nowrap;
}

.conn-source--settings { border-color: #2f7d32; color: #2f7d32; }
.conn-source--none     { border-style: dashed; }

.conn-caret { flex: none; transition: transform 0.15s ease; color: var(--page-text-muted); }
.conn-head:not(.collapsed) .conn-caret { transform: rotate(180deg); }

.conn-body {
    padding: 0.25rem 0.85rem 0.9rem 1.1rem;
    border-left: 4px solid var(--section, transparent);
}

/* The lamp. Three answers, and they have to survive being glanced at: green
   glows, red glows, grey doesn't. Colour alone isn't the signal — the status
   text beside it says the same thing in words, because roughly one in twelve
   men can't tell this green from this red. */
.conn-lamp {
    flex: none;
    width: 0.72rem;
    height: 0.72rem;
    border-radius: 50%;
    background-color: var(--gray-300);
    border: 1px solid var(--gray-400);
}

/* A lit lamp is a light source, not a coloured circle: a bright core, a tight
   ring of its own colour, and a soft halo past the edge. One flat swatch with a
   single shadow read as "a green dot" — technically the right colour, and it
   didn't look like anything was ON.
   The core is a gradient offset up-left, which is where a real indicator's
   highlight sits, and it's what stops the shape from looking printed. */
.conn-lamp[data-state="ok"] {
    background-image: radial-gradient(circle at 35% 32%, #a8f5b4 0%, #35c04a 55%, #1f8c33 100%);
    border-color: #1f8c33;
    box-shadow: 0 0 0.25rem rgba(53, 192, 74, 0.95), 0 0 0.7rem rgba(53, 192, 74, 0.55);
}

.conn-lamp[data-state="fail"] {
    background-image: radial-gradient(circle at 35% 32%, #ffb3b3 0%, #e04141 55%, #a01f1f 100%);
    border-color: #a01f1f;
    box-shadow: 0 0 0.25rem rgba(224, 65, 65, 0.95), 0 0 0.7rem rgba(224, 65, 65, 0.55);
}

/* Configured, but not something this page can verify (RCON, AI switched off).
   Amber says "set, unproven" — lighting it green would be a claim we can't
   make. Dimmer halo than the other two on purpose: it is not a verdict. */
.conn-lamp[data-state="set"] {
    background-image: radial-gradient(circle at 35% 32%, #ffe0a8 0%, #e0a341 55%, #9a6b12 100%);
    border-color: #9a6b12;
    box-shadow: 0 0 0.2rem rgba(224, 163, 65, 0.9), 0 0 0.55rem rgba(224, 163, 65, 0.4);
}

/* Inline in the card header, so the legend reads as the thing it describes. */
.conn-legend {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    margin-right: 0.75rem;
    white-space: nowrap;
}

.conn-lamp[data-state="unset"] { background-color: transparent; }
.conn-lamp[data-state="error"] { background-color: var(--gray-400); }

.conn-lamp[data-state="checking"] { animation: conn-pulse 1.1s ease-in-out infinite; }

@keyframes conn-pulse {
    0%, 100% { opacity: 0.35; }
    50%      { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
    .conn-lamp[data-state="checking"] { animation: none; opacity: 0.6; }
    .conn-caret { transition: none; }
}

/* Narrow screens: the address drops out before the status does. Which host it
   is matters less than whether it answers, and the row keeps one line. */
@media (max-width: 575.98px) {
    .conn-summary { display: none; }
    .conn-status { max-width: none; flex: 1 1 auto; }
}

/* The explanation under the title, not beside it. On one line the two compete:
   the eye can't tell where the name ends and the prose begins, which is what
   made the settings page read as a wall — and that half was the real fix. */
main .card-header .card-note {
    display: block;
    margin-top: 0.15rem;
    font-size: 0.85rem;
    font-weight: 400;
    color: var(--page-text-muted);
}

main .form-control,
main .form-select {
    background-color: #ffffff;
    border-color: var(--gray-300);
    color: var(--page-text);
}

main .form-control:focus,
main .form-select:focus {
    background-color: #ffffff;
    border-color: var(--accent-500);
    box-shadow: 0 0 0 0.2rem rgba(251, 178, 4, 0.25);
    color: var(--page-text);
}

main .form-control::placeholder {
    color: var(--page-text-muted);
    opacity: 0.7;
}

main .form-label,
main .form-text {
    color: var(--page-text);
}

main .form-text {
    opacity: 0.75;
}

main .table {
    --bs-table-color: var(--page-text);
    --bs-table-bg: transparent;
    --bs-table-border-color: var(--gray-200);
}

main .list-group-item {
    background-color: var(--gray-50);
    border-color: var(--gray-200);
    color: var(--page-text);
}

main .breadcrumb {
    background-color: var(--gray-100);
    border-radius: 0.375rem;
}

main .breadcrumb-item.active {
    color: var(--page-text-muted);
}

main .page-link {
    color: var(--accent-600);
    background-color: var(--gray-50);
    border-color: var(--gray-200);
}

main .page-link:hover {
    color: var(--accent-800);
    background-color: var(--gray-100);
    border-color: var(--gray-200);
}

main .page-item.active .page-link {
    background-color: var(--accent-400);
    border-color: var(--accent-400);
    color: var(--gray-900);
}

main .text-muted {
    color: var(--page-text-muted) !important;
}

.btn-primary {
    background-color: var(--accent-400);
    border-color: var(--accent-400);
    color: var(--gray-900);
    font-weight: 600;
}

.btn-primary:hover {
    background-color: var(--accent-500);
    border-color: var(--accent-500);
    color: var(--gray-900);
}

.btn-secondary {
    background-color: var(--gray-600);
    border-color: var(--gray-600);
}

.btn-secondary:hover {
    background-color: var(--gray-700);
    border-color: var(--gray-700);
}

.btn-outline-secondary {
    color: var(--gray-700);
    border-color: var(--gray-300);
}

.btn-outline-secondary:hover {
    background-color: var(--gray-600);
    border-color: var(--gray-600);
    color: #fff;
}

.btn-outline-danger {
    color: var(--os-danger);
    border-color: var(--os-danger);
}

.btn-outline-danger:hover {
    background-color: var(--os-danger);
    border-color: var(--os-danger);
    color: #fff;
}

.thread-indicator,
.badge.bg-primary {
    background-color: var(--accent-400) !important;
    color: var(--gray-900) !important;
}

.badge.bg-secondary {
    background-color: var(--gray-600) !important;
}

/*
 * Subtle inline-edit affordance for admins: an icon-only link, faint until
 * the containing card is hovered/focused, so the page reads the same as it
 * does for a regular visitor until you go looking for the edit control.
 */
.admin-edit-toggle {
    opacity: 0.3;
    color: var(--gray-600);
    transition: opacity 0.15s ease, color 0.15s ease;
    text-decoration: none;
    padding: 0.2rem 0.4rem;
}

.admin-edit-toggle:hover,
.admin-edit-toggle:focus-visible {
    opacity: 1;
    color: var(--accent-600);
}

.admin-edit-host:hover .admin-edit-toggle,
.admin-edit-host:focus-within .admin-edit-toggle {
    opacity: 0.7;
}

/*
 * Server-status cards (/servrar): the current map's Steam Workshop preview
 * sits behind the content as a slowly breathing background, like the old
 * site had. Servers without a cached map image fall back to a designed
 * placeholder (same image for all of them) rather than showing nothing.
 */
.server-card {
    position: relative;
    overflow: hidden;
    border: none;
}

.server-card-bg {
    position: absolute;
    inset: 0;
    background-image: url('/assets/images/map-placeholder.svg');
    background-size: cover;
    background-position: center;
    animation: server-card-zoom 40s ease-in-out infinite alternate;
    z-index: 0;
    /* No `will-change: transform` here. It was tried against the stutter at the
       turn (2026-07-25) on the theory that the layer wasn't being promoted, and
       it stopped the animation from moving visibly instead. The hero variant is
       unaffected either way — it animates `background-size`, not transform — so
       this rule is the only place it could have mattered, and it made things
       worse. If the stutter is worth another go, stagger the cards with
       `animation-delay` so six backgrounds don't turn on the same frame. */
}

@keyframes server-card-zoom {
    from {
        transform: scale(1);
    }

    to {
        transform: scale(1.12);
    }
}

/*
 * /servrar uses the SAME hero cards as the homepage (2026-07-25), after a
 * landscape variant was tried and abandoned. The treatment is drawn for
 * portrait: the frame is a hand-drawn 845x1451 png the card's aspect-ratio
 * matches exactly, and cropping a landscape map screenshot works because it is
 * the SIDES that get cut. Turned on its side, a 555x312 thumbnail in a 4.25:1
 * card has to lose ~60% of its height just to fill the box, and no amount of
 * scaling fixes that — it is the least zoom that still covers.
 *
 * So the page keeps the panels and puts what the front page has no room for —
 * who is playing, and for how long — in a column UNDER each card.
 */
.server-hero-row--list {
    /* Wider columns than the homepage row: these carry a player list, and a
       nick plus a K/A/D and a timer does not fit in 240px. */
    grid-template-columns: repeat(auto-fit, minmax(240px, 300px));
    align-items: start;
}

.server-list-players {
    padding: 0.6rem 0.25rem 0;
    font-size: 0.82rem;
    line-height: 1.5;
}

.server-list-players li {
    margin-bottom: 0.15rem;
}

/* The connect address is reference material, not a call to action — the button
   on the card is that. Keep it available but quiet. */
.server-list-addr {
    font-size: 0.75rem;
    opacity: 0.75;
}

/* The two longest-running animations on the site are also the two most likely
   to make someone motion-sensitive feel ill: a background that never stops
   breathing, and a 20s zoom that starts the moment the page loads. Both stop
   at their resting state — the hero holds at `auto 100%` from its base rule,
   which is where the reveal was heading anyway, so nothing looks half-finished. */
@media (prefers-reduced-motion: reduce) {
    .server-card-bg,
    .server-hero-card .server-card-bg {
        animation: none;
    }
}

.server-card-content {
    position: relative;
    z-index: 1;
    color: #fff;
    background: linear-gradient(180deg, rgba(20, 18, 15, 0.45), rgba(20, 18, 15, 0.75));
}

.server-card-content .text-muted {
    color: rgba(255, 255, 255, 0.7) !important;
}

/* Outline buttons carry the light theme's dark ink (--gray-700), which is set
   explicitly and so wins over the white the card hands down — leaving dark text
   on a darkened map screenshot. Give them the same white treatment the rest of
   the card gets. Covers the hero panels too: they reuse .server-card-content. */
.server-card-content .btn-outline-secondary,
.server-card-content .btn-outline-primary {
    color: #fff;
    border-color: rgba(255, 255, 255, 0.55);
}

.server-card-content .btn-outline-secondary:hover,
.server-card-content .btn-outline-secondary:focus-visible,
.server-card-content .btn-outline-primary:hover,
.server-card-content .btn-outline-primary:focus-visible {
    background-color: #fff;
    border-color: #fff;
    color: var(--gray-900);
}

/* Large angled server panels for the homepage hero row — same background/
   overlay/zoom as .server-card, just bigger and with a cut-corner "tech
   panel" shape instead of a plain rectangle. The corner cut, border and
   glow are all baked into assets/images/serverpanel.png (a hand-designed
   octagon frame, transparent in the middle and around the outside) drawn
   on top via .server-hero-frame — the clip-path below just needs to trim
   the map layer underneath to roughly the same octagon so its square
   corners don't peek out through the frame artwork's cut-away corners.
   The card's aspect-ratio matches the frame image's own (845x1451) so
   background-size: 100% 100% on the frame never distorts it. */
.server-hero-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 240px));
    gap: 1.25rem;
}

.server-hero-card {
    position: relative;
    overflow: hidden;
    aspect-ratio: 845 / 1451;
    clip-path: polygon(
        7% 0, 93% 0, 100% 4%,
        100% 96%, 93% 100%, 7% 100%,
        0 96%, 0 4%
    );
}

.server-hero-frame {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    background-image: url('/assets/images/serverpanel.png');
    background-size: 100% 100%;
    background-repeat: no-repeat;
    /* Shrinking an 845x1451 source down to a card-sized box dims the thin
       amber accent lines/tick marks (a normal downscale-blur artifact) —
       boost brightness/saturation to bring the glow back closer to how it
       looks at native size. */
    filter: brightness(1.3) saturate(1.7) contrast(1.1);
}

.server-hero-card .server-card-bg {
    /* Automatic one-time reveal on page load: starts zoomed in (300% — down
       from an original 500%, which magnified the low-res (555x312) map
       thumbnails enough to look noticeably blurry at the start) and shrinks
       down to auto 100% (fills the card height edge to edge, cropping the
       sides — no letterbox dead space) over 20s, then holds there — not
       hover-triggered. `forwards` keeps it at the final frame instead of
       snapping back once the animation ends. `animation` here (not the
       base rule's shorthand) is what overrides the shared
       `.server-card-bg` rule's own infinite 40s keyframe used by the
       compact /servrar cards — without overriding it, that one would keep
       running underneath this and fight it for the same property. */
    background-size: auto 100%;
    background-repeat: no-repeat;
    background-position: center;
    background-color: var(--gray-900);
    animation: server-hero-reveal 20s linear forwards;
}

@keyframes server-hero-reveal {
    from {
        background-size: auto 300%;
    }

    to {
        background-size: auto 100%;
    }
}

.server-hero-card .server-card-content {
    position: absolute;
    inset: 0;
    z-index: 3;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    padding: 1rem 1.85rem 2rem;
    /* Cancel the base .server-card-content rule's dark gradient (used by
       the compact /servrar cards for text legibility) — it isn't scoped
       away by specificity alone since this rule never touched `background`
       before, so it was silently painting over the frame artwork on top of
       it. Hero cards get legibility from the text-shadow rule below instead. */
    background: none;
}

/* No darkening overlay on the image — legibility comes from a heavy
   multi-directional text-shadow instead (the old site's technique), so the
   map itself stays fully visible/recognizable underneath the text. */
.server-hero-card .server-card-content .small,
.server-hero-card .server-card-content .server-hero-stat-label,
.server-hero-card .server-card-content .server-hero-stat-value,
.server-hero-kicker {
    text-shadow:
        1px 0 0 #000, -1px 0 0 #000,
        0 1px 0 #000, 0 -1px 0 #000,
        1px 1px 0 #000, -1px -1px 0 #000,
        1px -1px 0 #000, -1px 1px 0 #000;
}

.server-hero-kicker {
    position: relative;
    z-index: 3;
    padding: 1.1rem 2rem 0;
    text-align: center;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-size: 0.85rem;
    font-weight: 700;
    color: #fff;
}

/*
 * Shared online/offline status badge — used as-is (plain .badge, no extra
 * shape class) on both the homepage hero cards and /servrar, so the two
 * pages show identical badges. Only "online" shimmers (it's the state
 * worth drawing the eye to); "offline" gets a plain static glow.
 */
.is-online,
.is-offline {
    flex-shrink: 0;
    color: #fff;
}

.is-online {
    --status-glow: rgba(47, 158, 68, 0.75);
    background-color: var(--os-success);
    animation: status-shimmer 2.4s ease-in-out infinite;
}

.is-offline {
    --status-glow: rgba(181, 34, 45, 0.6);
    background-color: var(--os-danger);
    box-shadow: 0 0 4px 0 var(--status-glow);
}

/*
 * Both badge placements sit inside an `overflow: hidden` card (needed for
 * the map background zoom / cut-corner hero shape), which clips a
 * box-shadow's outer glow before it grows large enough to read as a
 * pulse — so the shimmer is carried by `filter: brightness()` on the badge
 * itself (never clipped, since it's painted within the badge's own box).
 * No `transform: scale()` here — it doesn't shift layout, but the
 * sub-pixel size change reads as a distracting "jump" in the tighter
 * homepage hero cards, so brightness/glow alone carries the pulse.
 */
@keyframes status-shimmer {
    0%, 100% {
        box-shadow: 0 0 4px 0 var(--status-glow);
        filter: brightness(1);
    }

    50% {
        box-shadow: 0 0 10px 3px var(--status-glow);
        filter: brightness(1.22);
    }
}

.server-hero-stats {
    display: flex;
    gap: 1.5rem;
    margin-top: 0.5rem;
    padding-top: 0.35rem;
    border-top: 1px solid rgba(251, 178, 4, 0.3);
}

.server-hero-stat-label {
    display: block;
    text-transform: uppercase;
    letter-spacing: 0.1em;
    font-size: 0.65rem;
    color: var(--accent-400);
}

.server-hero-stat-value {
    display: block;
    font-weight: 600;
}

/* Emoji picker (emoji-picker-element) attached to forum/news textareas —
   see partials/emoji-picker.php */
.emoji-picker-widget {
    position: relative;
    display: inline-block;
}

.emoji-picker-widget emoji-picker {
    position: absolute;
    z-index: 20;
    top: 100%;
    left: 0;
    margin-top: 0.25rem;
    box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
    border-radius: var(--bs-border-radius);
}

/* Icon picker: progressively enhances any input[data-icon-picker] into a
   live-preview + searchable-dropdown widget — see public/assets/js/icon-picker.js */
.icon-picker {
    position: relative;
}

.icon-picker-results {
    position: absolute;
    z-index: 20;
    top: 100%;
    left: 0;
    margin-top: 0.25rem;
    width: 280px;
    max-height: 280px;
    overflow-y: auto;
    display: flex;
    flex-wrap: wrap;
    align-content: flex-start;
    gap: 0.25rem;
    padding: 0.5rem;
    background-color: var(--gray-50);
    border: 1px solid var(--gray-200);
    border-radius: var(--bs-border-radius);
    box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}

.icon-picker-item {
    border: none;
    background: none;
    padding: 0.35rem;
    border-radius: var(--bs-border-radius-sm);
    font-size: 1.1rem;
}

.icon-picker-item:hover {
    background-color: var(--gray-200);
}

/* Authored Page/News bodies render Markdown into <article>. Give that prose
   real long-form typography — comfortable line-height, vertical rhythm and a
   clear heading hierarchy — so a long page (e.g. the community history) reads
   as an article, not a wall of text. Heading sizes stay modest so a
   "# heading" in the content still sits under the page's own title. */
article {
    line-height: 1.7;
}

article p {
    margin-bottom: 1rem;
    /* Justified prose reads best on the constrained measure above; hyphenation
       (lang="sv" on <html>) keeps the word spacing even instead of "rivers". */
    text-align: justify;
    -webkit-hyphens: auto;
    hyphens: auto;
    overflow-wrap: break-word;
}
article > *:last-child { margin-bottom: 0; }

article ul,
article ol { margin-bottom: 1rem; padding-left: 1.4rem; }
article li { margin-bottom: 0.3rem; }
/* Markers are content, not decoration — you read "1, 2, 3". accent-500 left
   them at 2.58:1 on the page background, which is what "lite gömda" was. */
article li::marker { color: var(--accent-700); }

/* ---------------------------------------------------------------------------
   A ticket, as one object. The case is the card; messages are a conversation
   inside it. Previously each MESSAGE was a card and the ticket itself was
   nothing — which is backwards, and read as a form dump rather than a case. */
.ticket-toolbar {
    padding: 0.6rem 1rem;
    background-color: var(--gray-100);
    border-bottom: 1px solid var(--gray-200);
}

.ticket-thread {
    display: flex;
    flex-direction: column;
    gap: 0.9rem;
}

.ticket-message {
    display: flex;
    gap: 0.65rem;
    align-items: flex-start;
}

.ticket-message-avatar { flex: 0 0 auto; }

.ticket-message-body {
    flex: 1 1 auto;
    min-width: 0;
    padding: 0.5rem 0.75rem;
    border-radius: 0.5rem;
    /* The requester's side: plain, on the card's own surface. */
    background-color: var(--gray-100);
}

/* Staff replies carry the accent so the two voices separate at a glance,
   without having to read the badge on every message. */
.ticket-message.is-staff .ticket-message-body {
    background-color: transparent;
    border: 1px solid var(--accent-300);
    box-shadow: inset 3px 0 0 var(--accent-500);
}

.ticket-message-body > div:last-child { overflow-wrap: anywhere; }

/* ---------------------------------------------------------------------------
   Markdown toolbar. Wraps on narrow screens rather than scrolling sideways —
   a formatting button you have to scroll to find is one you won't use. */
.md-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.35rem;
    margin-bottom: 0.5rem;
}

.md-table-picker { position: relative; }

.md-table-menu {
    position: absolute;
    z-index: 1050;
    top: calc(100% + 0.25rem);
    left: 0;
    padding: 0.5rem;
    background-color: var(--page-bg, #fff);
    border: 1px solid var(--gray-200);
    border-radius: 0.4rem;
    box-shadow: 0 0.4rem 1rem rgba(0, 0, 0, 0.12);
}

.md-table-grid {
    display: grid;
    grid-template-columns: repeat(6, 1.1rem);
    gap: 2px;
}

.md-table-cell {
    width: 1.1rem;
    height: 1.1rem;
    padding: 0;
    border: 1px solid var(--gray-200);
    border-radius: 2px;
    background-color: transparent;
}

.md-table-cell.active {
    background-color: var(--accent-300);
    border-color: var(--accent-500);
}

.md-table-label { margin-top: 0.4rem; white-space: nowrap; }

/* ---------------------------------------------------------------------------
   The wiki browser: a folding shelf tree in the left column, the page in the
   middle. On narrow screens it falls above the content as an ordinary block,
   because a sidebar on a phone is just a thing in the way.

   The tree used to be sticky with its own max-height, which meant that as soon
   as it grew taller than the window it got a second scrollbar beside the page's
   — two things to scroll, and the shelves at the bottom reachable only by
   noticing the inner one. It was there because a sticky box taller than the
   viewport can't show its own bottom otherwise.

   So neither now. The two columns share one page: whichever is taller sets its
   length, and there is a single scrollbar for the whole thing. The trade is
   that the tree scrolls out of view partway down a long article — accepted
   deliberately, because a tree you scroll back up to beats a tree with a
   scrollbar inside it. */
.wiki-browser {
    font-size: 0.925rem;
    border-right: 1px solid var(--gray-200);
    padding-right: 0.75rem;
}


@media (max-width: 991.98px) {
    .wiki-browser {
        border-right: 0;
        border-bottom: 1px solid var(--gray-200);
        padding-right: 0;
        padding-bottom: 0.75rem;
    }
}

.wiki-browser-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
    margin-bottom: 0.5rem;
}

.wiki-browser-title {
    font-weight: 600;
    text-decoration: none;
    color: var(--page-text);
}

.wiki-browser-title:hover,
.wiki-browser-title.active { color: var(--accent-700); }

/* A shelf header: chevron, name, then the count pushed to the far edge. */
.wiki-shelf-toggle {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    width: 100%;
    padding: 0.35rem 0.4rem;
    border: 0;
    border-radius: 0.35rem;
    background: transparent;
    color: var(--page-text);
    text-align: left;
    font-weight: 600;
}

.wiki-shelf-toggle:hover { background-color: var(--gray-100); }

.wiki-chevron { transition: transform 0.15s ease; transform: rotate(90deg); }
.wiki-shelf-toggle.collapsed .wiki-chevron { transform: rotate(0deg); }

.wiki-shelf-name { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }

.wiki-shelf-badge { font-size: 0.65rem; font-weight: 500; }

.wiki-shelf-count {
    flex: 0 0 auto;
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--page-text-muted);
    font-variant-numeric: tabular-nums;
}

.wiki-shelf-pages {
    list-style: none;
    margin: 0 0 0.35rem 0;
    /* Indent under the chevron, with a guide line so a long shelf reads as one
       group rather than a run of loose links. */
    padding: 0 0 0 0.75rem;
    margin-left: 0.65rem;
    border-left: 1px solid var(--gray-200);
}

.wiki-page-link {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.28rem 0.45rem;
    border-radius: 0.3rem;
    color: var(--page-text);
    text-decoration: none;
}

.wiki-page-link:hover { background-color: var(--gray-100); color: var(--accent-700); }

.wiki-page-link.active {
    background-color: var(--gray-100);
    color: var(--accent-700);
    font-weight: 600;
    /* The active marker sits on the guide line, so the eye follows the line
       straight to where it is in the shelf. */
    box-shadow: inset 2px 0 0 var(--accent-500);
}

.wiki-page-title { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
.wiki-page-lock { flex: 0 0 auto; font-size: 0.7rem; color: var(--page-text-muted); }

/* Markdown pipe tables. Parsedown has emitted these all along; without styling
   they render as naked rows that read as broken text, so nobody used them.
   Wrapped in overflow-x by the sibling rule below — a wide table must scroll
   inside itself rather than push the whole page sideways on a phone. */
article table {
    width: 100%;
    margin-bottom: 1.2rem;
    border-collapse: collapse;
    font-size: 0.95rem;
    /* Prose is justified; table cells must not be. */
    text-align: left;
}

article table th,
article table td {
    padding: 0.5rem 0.7rem;
    border-bottom: 1px solid var(--gray-200);
    text-align: left;
    hyphens: none;
}

article table thead th {
    border-bottom: 2px solid var(--accent-300);
    font-weight: 600;
    white-space: nowrap;
}

article table tbody tr:last-child td { border-bottom: none; }
article table tbody tr:hover { background-color: var(--gray-100); }

/* Markdown::render wraps every table in this, so a wide one scrolls on its own
   instead of widening the page. */
article .table-scroll {
    overflow-x: auto;
    margin-bottom: 1.2rem;
}
article .table-scroll > table { margin-bottom: 0; }

article blockquote {
    margin: 1.1rem 0;
    padding: 0.35rem 0 0.35rem 1rem;
    border-left: 3px solid var(--accent-300);
    color: var(--page-text-muted);
}

/* Heading hierarchy + spacing. Generous top margin separates sections; the
   first block never pushes off the page title. */
article h1,
article h2,
article h3,
article h4,
article h5,
article h6 {
    margin-top: 1.9rem;
    margin-bottom: 0.55rem;
    font-weight: 600;
    line-height: 1.25;
}
article > :first-child { margin-top: 0; }

article h1 { font-size: 1.5rem; }
article h2 {
    font-size: 1.28rem;
    padding-bottom: 0.3rem;
    border-bottom: 2px solid var(--accent-400);  /* bright orange rule — pops the title on both themes */
    color: var(--accent-700);                    /* readable dark-gold on light; lifted brighter in dark below */
}
article h3 { font-size: 1.12rem; color: var(--accent-700); }
article h4,
article h5,
article h6 { font-size: 1rem; color: var(--page-text-muted); }

/* Cap authored page bodies so lines don't sprawl on ultra-wide screens, but
   loose enough to fill the content column at common widths (≤1140px the col is
   ~46rem, so the text reaches the sidebar with no gap; only very wide monitors
   leave a small margin). Scoped to pages, not news. */
#page-content article > div { max-width: 52rem; }

/* Modals mount on <body>, which carries the light chrome text color for
   the dark navbar/footer — give the white modal surface the content-area
   palette so its text is actually readable. */
.modal-content {
    background-color: var(--page-bg);
    color: var(--page-text);
}

/* Access builder: two colour-coded zones with title bars — GREEN for the
   member's active titles/access, RED for the grantable shelf — so which
   is which is unmistakable, and the colours read as on/off. */
.access-zone {
    border: 1px solid var(--gray-200);
    border-radius: 0.5rem;
    overflow: hidden;
}
.access-zone-body { padding: 0.85rem; }
.access-zone-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.75rem;
    padding: 0.5rem 0.85rem;
    font-weight: 600;
    color: #fff;
}
.access-zone--active { border-color: rgba(25, 135, 84, 0.5); background-color: rgba(25, 135, 84, 0.05); }
.access-zone-bar--active { background: linear-gradient(180deg, #22a565, #198754); }
.access-zone--shelf { border-color: rgba(190, 60, 60, 0.45); background-color: rgba(190, 60, 60, 0.045); }
.access-zone-bar--shelf { background: linear-gradient(180deg, #c9525a, #b0434b); }

/* Tournament admin team board */
.border-dashed { border-style: dashed !important; }
[data-player-chip] { cursor: grab; }
[data-player-chip]:active { cursor: grabbing; }

/* Double-elimination playoff bracket: each bracket lays its rounds out as
   columns that scroll horizontally, so a deep bracket never breaks the page. */
/* The wrapper is the horizontal scroller AND the positioning context for the
   SVG connector overlay drawn by bracket-tree.js. */
.playoff-bracket-wrap {
    position: relative;
    overflow-x: auto;
    /* Room so the cards' hover glow isn't clipped by the scroll container (an
       overflow-x scroller also clips vertically), especially on the left edge. */
    padding: 1.25rem;
}
/* Shrink to the bracket's own width so a right-aligned child (the 3rd-place
   match) lands under the final, not at the far edge of a wide page. */
.playoff-bracket { width: max-content; }
.single-elim { width: max-content; }
/* Follow the results by line colour: the winner's path turns green, the loser's
   path (drop to the losers bracket, or into the bronze match) is a faint red
   dash. Undecided matches keep the neutral default stroke. */
.bracket-lines path.won { stroke: var(--os-success, #2f9e44); stroke-width: 2.5; }
.bracket-lines path.lost { stroke: rgba(181, 34, 45, 0.4); stroke-dasharray: 5 3; }
/* Hover a team → its matches + lines GLOW, everything else dims. A traced match
   glows green where the team won and red where it lost, so a loss reads at a
   glance. */
.playoff-bracket-wrap.team-focus .bracket-match { opacity: 0.3; }
.playoff-bracket-wrap.team-focus .bracket-match.hl { opacity: 1; }
.playoff-bracket-wrap.team-focus .bracket-match.hl-win {
    box-shadow: 0 0 0 1.5px var(--os-success, #2f9e44), 0 0 16px 2px rgba(47, 158, 68, 0.55);
}
.playoff-bracket-wrap.team-focus .bracket-match.hl-lose {
    box-shadow: 0 0 0 1.5px rgba(181, 34, 45, 0.8), 0 0 16px 2px rgba(181, 34, 45, 0.45);
}
.playoff-bracket-wrap.team-focus .bracket-lines path { opacity: 0.12; }
/* opacity is driven inline by the play-order trace (bracket-tree.js), so it's
   deliberately not set here — this only handles the line's thickness/glow. */
.bracket-lines path.hl { stroke-width: 3.5; }
.bracket-lines path.hl.won { filter: drop-shadow(0 0 3px rgba(47, 158, 68, 0.9)); }
.bracket-lines path.hl.lost { filter: drop-shadow(0 0 3px rgba(181, 34, 45, 0.85)); }
/* The pulse that sweeps a traced team's run — takes the colour of the line it's
   currently riding (green on a won edge, red on a lost one). */
.bracket-pulse {
    fill: #eafff1;
    filter: drop-shadow(0 0 3px rgba(47, 158, 68, 1)) drop-shadow(0 0 7px rgba(47, 158, 68, 0.75));
    pointer-events: none;
}
.bracket-pulse.pulse-lost {
    fill: #ffecec;
    filter: drop-shadow(0 0 3px rgba(181, 34, 45, 1)) drop-shadow(0 0 7px rgba(181, 34, 45, 0.75));
}

/* While tracing a team, layer it right: the highlighted (focused) cards on top,
   the connector lines in the middle (so a highlighted line like the red drop
   into the losers bracket shows over the dimmed cards), the dimmed cards below. */
.playoff-bracket-wrap.team-focus .bracket-lines { z-index: 2; }
.playoff-bracket-wrap.team-focus .bracket-match.hl { z-index: 3; }

/* A team lit up on hover, everywhere it appears — schedule rows, group-standings
   rows, the teams list, and its own row inside a bracket card — so hovering a
   team anywhere connects the whole page. */
/* A defined ring + a soft glow — the ring gives it shape, the glow the pop. No
   font-weight change here: bolding a non-bold name on hover nudges it sideways.
   Winners/points keep their own weights. */
.team-lit {
    background-color: rgba(47, 158, 68, 0.15) !important;
    border-radius: 5px;
    box-shadow: 0 0 0 1.5px rgba(47, 158, 68, 0.6), 0 0 10px rgba(47, 158, 68, 0.4);
    transition: background-color 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
/* In the schedule, the traced team's chip turns red in a match it LOST. */
.tm-team.tm-lose.team-lit {
    background-color: rgba(181, 34, 45, 0.14) !important;
    color: #b5222d;
    box-shadow: 0 0 0 1.5px rgba(181, 34, 45, 0.6), 0 0 10px rgba(181, 34, 45, 0.4);
}

/* ---- Group schedule: symmetric match rows ---- */
.tourn-round,
.tourn-standings-card {
    border: 1px solid var(--gray-200, #dee2e6);
    border-radius: 0.5rem;
    overflow: hidden;
    background: #fff;
}
.tourn-round-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.5rem 0.85rem;
    background: var(--gray-50, #f8f9fa);
    border-bottom: 1px solid var(--gray-100, #f1f3f5);
    font-size: 0.9rem;
}
.tourn-match {
    position: relative;
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
    gap: 0.85rem;
    /* extra right padding leaves room for the absolutely-placed server chip so
       the matchup itself stays centred in the row. */
    padding: 0.55rem 0.85rem;
}
.tourn-match + .tourn-match { border-top: 1px solid var(--gray-100, #f1f3f5); }
.tourn-match--mine { box-shadow: inset 3px 0 0 var(--os-primary, #0d6efd); background: rgba(13, 110, 253, 0.04); }
/* justify-self shrinks the team cell to the name so a hover highlight hugs the
   text instead of filling the whole 1fr column. The padding is CONSTANT so the
   hover chip reserves its space and the name doesn't shift when the glow appears. */
.tm-team { min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; padding: 0.1rem 0.45rem; }
.tm-home { justify-self: end; text-align: right; }
.tm-away { justify-self: start; text-align: left; }
.tm-win { font-weight: 700; }
.tm-lose { color: var(--gray-500, #868e96); }
.tm-mid { display: flex; align-items: center; justify-content: center; }
.tm-score {
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    background: rgba(47, 158, 68, 0.16);
    color: #2b8a3e;
    border-radius: 0.35rem;
    padding: 0.1rem 0.55rem;
    white-space: nowrap;
}
.tm-score--tbd { background: var(--gray-100, #f1f3f5); color: var(--gray-500, #868e96); font-weight: 600; }
.tm-sep { opacity: 0.45; margin: 0 0.3rem; font-weight: 400; }
.tm-report { display: flex; align-items: center; gap: 0.25rem; }
.tm-report input[type=number] {
    width: 3rem; padding: 0.15rem 0.3rem; text-align: center;
    border: 1px solid var(--gray-300, #dee2e6); border-radius: 0.3rem;
}
/* Server chip floats to the right without shifting the centred matchup — it sits
   in the empty right half of the (left-aligned) away column. */
.tm-meta {
    position: absolute;
    right: 0.85rem;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    align-items: center;
    gap: 0.5rem;
}
.tm-server {
    display: inline-flex; align-items: center; gap: 0.25rem;
    font-size: 0.78rem; color: var(--os-success, #2f9e44); text-decoration: none; white-space: nowrap;
    border: 1px solid rgba(47, 158, 68, 0.4); border-radius: 0.3rem; padding: 0.05rem 0.4rem;
}
.tm-server:hover { background: rgba(47, 158, 68, 0.1); }

/* ---- Group standings ---- */
/* border-collapse: separate (with zero spacing) so a hovered row's green glow
   actually renders — collapsed tables swallow box-shadow on <tr>. */
.tourn-standings { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.88rem; }
/* Don't bold the whole row on hover — that nudges the stat numbers ~1px. The
   name (ts-team) and points (ts-pts) keep their own weights. */
.tourn-standings tr.team-lit { position: relative; z-index: 1; font-weight: normal; }
/* A hovered row that misses the cutoff (eliminated) glows red instead of green. */
.tourn-standings tr.is-out.team-lit {
    background-color: rgba(181, 34, 45, 0.13) !important;
    box-shadow: 0 0 0 1.5px rgba(181, 34, 45, 0.6), 0 0 10px rgba(181, 34, 45, 0.4);
}
.tourn-standings th {
    font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.03em;
    color: var(--gray-500, #868e96); font-weight: 700;
    padding: 0.45rem 0.4rem; text-align: center; border-bottom: 1px solid var(--gray-200, #dee2e6);
}
.tourn-standings td { padding: 0.4rem 0.4rem; text-align: center; border-bottom: 1px solid var(--gray-100, #f1f3f5); }
.tourn-standings tr:last-child td { border-bottom: 0; }
.tourn-standings .ts-rank { width: 1.4rem; color: var(--gray-500, #868e96); }
.tourn-standings th.ts-team,
.tourn-standings td.ts-team { text-align: left; white-space: nowrap; font-weight: 600; }
.tourn-standings .ts-pts { font-weight: 700; }
.tourn-standings tr.is-adv { background: rgba(47, 158, 68, 0.06); }
.tourn-standings tr.is-adv .ts-rank { color: var(--os-success, #2f9e44); font-weight: 700; }
.tourn-standings tr.is-mine { box-shadow: inset 3px 0 0 var(--os-primary, #0d6efd); }
.tourn-standings tr.is-cutoff td { border-bottom: 2px solid var(--os-success, #2f9e44); }
/* Inside the bracket, the traced team's SCORE lights up by that match's result:
   green when it won, red when it lost — so a 0 in a 0–2 glows red. The lit row's
   own tint follows suit (red on a loss, not the generic green). */
.playoff-bracket-wrap.team-focus .bracket-match.hl-lose .team-lit {
    background-color: rgba(181, 34, 45, 0.12) !important;
    /* Light the name up in red rather than leaving it the greyed loser colour,
       and swap the base green ring/glow for a red one. (Weight doesn't change on
       hover — the loser row is bold by default — so nothing nudges sideways.) */
    color: #b5222d;
    box-shadow: 0 0 0 1.5px rgba(181, 34, 45, 0.6), 0 0 10px rgba(181, 34, 45, 0.4);
}
.playoff-bracket-wrap.team-focus .bracket-match.hl-win .team-lit .bm-score {
    background: var(--os-success, #2f9e44) !important;
    color: #fff;
    box-shadow: 0 0 8px rgba(47, 158, 68, 0.5);
}
.playoff-bracket-wrap.team-focus .bracket-match.hl-lose .team-lit .bm-score {
    background: rgba(181, 34, 45, 0.92) !important;
    color: #fff;
    box-shadow: 0 0 8px rgba(181, 34, 45, 0.55);
}
/* Keep the spotlight on the traced team: fade the OPPONENT'S row in each of its
   matches — so focusing a loser greys down the winner that knocked them out. */
.playoff-bracket-wrap.team-focus .bracket-match.hl .bm-team:not(.team-lit) {
    opacity: 0.35;
    transition: opacity 0.2s ease;
}
.bracket-lines {
    position: absolute;
    top: 0;
    left: 0;
    pointer-events: none;
    z-index: 0;
    overflow: visible;
}
.bracket-lines path {
    fill: none;
    stroke: var(--gray-300, #dee2e6);
    stroke-width: 2;
    stroke-linejoin: round;
    stroke-linecap: round;
    /* Ease the hover trace in — lines thicken, glow and fade smoothly. */
    transition: opacity 0.18s cubic-bezier(0.22, 1, 0.36, 1), stroke-width 0.18s cubic-bezier(0.22, 1, 0.36, 1), filter 0.18s ease;
}

.bracket-rounds {
    display: flex;
    gap: 3rem; /* room for the connector branches between rounds */
    align-items: stretch; /* equal-height columns so a later round centres between its feeders */
}
.bracket-round {
    display: flex;
    flex-direction: column;
    justify-content: space-around; /* round R's matches land at the midpoints of round R−1's pairs */
    gap: 0.75rem;
    min-width: 12rem;
}
/* Esport match card: header, one row per team (winner tinted green + bold, loser
   dimmed), the score in its own box, played maps as pills. */
.bracket-match {
    width: 13rem;
    position: relative;
    z-index: 1;
    background: #fff;
    border: 1px solid var(--gray-200, #dee2e6);
    border-radius: 0.5rem;
    overflow: hidden;
    font-size: 0.85rem;
    box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
    /* On the base rule so the hover trace eases in AND out. */
    transition: opacity 0.18s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.18s cubic-bezier(0.22, 1, 0.36, 1);
}
.bm-head {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.5rem;
    padding: 0.25rem 0.5rem;
    font-size: 0.62rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--gray-500, #868e96);
    background: var(--gray-50, #f8f9fa);
    border-bottom: 1px solid var(--gray-100, #f1f3f5);
}
.bm-round { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bm-bo { flex: 0 0 auto; font-weight: 700; }
.bm-team {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.5rem;
    padding: 0.35rem 0.5rem;
}
.bm-team + .bm-team { border-top: 1px solid var(--gray-100, #f1f3f5); }
.bm-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bm-team--win { background: rgba(47, 158, 68, 0.1); font-weight: 700; }
/* Bold by default (like the winner row) so lighting it up on hover changes only
   colour/glow, never the weight — otherwise the name would nudge sideways. */
.bm-team--lose { color: var(--gray-500, #868e96); font-weight: 700; }
.bm-seed {
    font-size: 0.68rem;
    color: var(--gray-600, #666);
    background: var(--gray-100, #f1f3f5);
    border-radius: 0.25rem;
    padding: 0 0.3rem;
    margin-left: 0.1rem;
}
.bm-score {
    flex: 0 0 auto;
    min-width: 1.5rem;
    text-align: center;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    background: var(--gray-100, #f1f3f5);
    border-radius: 0.25rem;
    padding: 0.05rem 0.35rem;
    transition: background-color 0.18s cubic-bezier(0.22, 1, 0.36, 1), color 0.18s ease, box-shadow 0.18s ease;
}
.bm-team--win .bm-score { background: rgba(47, 158, 68, 0.2); color: #2b8a3e; }
.bm-maps {
    display: flex;
    flex-wrap: wrap;
    gap: 0.25rem;
    padding: 0.35rem 0.5rem;
    border-top: 1px dashed var(--gray-200, #dee2e6);
}
.bm-map {
    font-size: 0.66rem;
    color: var(--gray-600, #666);
    background: var(--gray-50, #f8f9fa);
    border: 1px solid var(--gray-200, #dee2e6);
    border-radius: 0.25rem;
    padding: 0 0.3rem;
    white-space: nowrap;
}
.bm-map b { color: var(--gray-900, #212529); }
.bm-map--pending { opacity: 0.55; }

/* Sci-fi geometric numerals on the match scores (bracket series, schedule, and
   per-map) so results read like a scoreboard. */
.bm-score,
.tm-score,
.bm-map b {
    font-family: 'Orbitron', ui-monospace, "SF Mono", "JetBrains Mono", monospace;
    font-variant-numeric: tabular-nums;
    letter-spacing: 0.01em;
}

/* Double-elim, left → right: winners on top, losers below, the grand final on
   the far right where both bracket finals feed in. */
.bracket-lr {
    display: flex;
    align-items: center;
    gap: 3rem;
    width: max-content; /* let it grow so the wrapper scrolls, not this */
}
.bracket-brackets {
    display: flex;
    flex-direction: column;
    gap: 2rem;
}
.bracket-section { display: flex; flex-direction: column; }
.bracket-center {
    display: flex;
    flex-direction: column;
    flex: 0 0 auto;
}
.bracket-flow-label {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: var(--gray-500, #868e96);
    margin-bottom: 0.5rem;
    white-space: nowrap;
}
/* The championship match (grand final, or the single-elim final) is the apex:
   a champion-gold frame and a touch larger — the bronze match never gets this. */
.bracket-match--final {
    width: 15rem;
    border-color: rgba(251, 178, 4, 0.6);
    box-shadow: 0 0 0 0.15rem rgba(251, 178, 4, 0.15);
}
.bracket-match--final .bm-head { font-size: 0.68rem; }
.bracket-match--final .bm-team { padding: 0.5rem 0.6rem; font-size: 0.95rem; }

/* Optional 3rd-place match sits under the final (right-aligned), with a bronze
   frame so it reads as the consolation match, not part of the main tree. */
.bracket-bronze {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    margin-top: 1.5rem;
}
.bracket-bronze .bracket-match { border-color: rgba(205, 127, 50, 0.55); }

/* Captain map-veto grid inside the match modal. */
.veto-maps { display: grid; grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); gap: 0.5rem; }
.veto-map { margin: 0; border: 1px solid var(--gray-200); border-radius: 0.5rem; text-align: center; overflow: hidden; }
.veto-map > button,
.veto-map > span { display: block; width: 100%; padding: 0.5rem; border: 0; background: transparent; font: inherit; color: inherit; }
.veto-map--open { background-color: var(--gray-50); }
.veto-map--clickable > button { cursor: pointer; }
.veto-map--clickable:hover { border-color: var(--os-danger); background-color: rgba(181, 34, 45, 0.08); }
.veto-map--banned { opacity: 0.5; text-decoration: line-through; background-color: var(--gray-100); }
.veto-map--picked { border-color: var(--os-success); background-color: rgba(47, 158, 68, 0.1); font-weight: 600; }

/* The map's own picture behind its tile.
   Same images the server list uses (bin/fetch-map-images.php records them on
   Steam's servers), so this costs no new fetch and no second cache. A map
   nobody has looked up yet has no --map-shot and keeps the flat tile — there is
   no placeholder, because a grey rectangle labelled de_nuke is worse than a
   label alone.

   Picking a map is the one moment in a tournament where people argue about
   what a map LOOKS like, and the grid was five words in boxes. */
.veto-map--shot {
    position: relative;
    background-image: var(--map-shot);
    background-size: cover;
    background-position: center;
    min-height: 4.5rem;
}

/* A scrim, not a tint: the picture is somebody's screenshot with light sky and
   dark corners, and white-on-photo is unreadable over half of them. Darkest at
   the bottom, where the name sits. */
.veto-map--shot::before {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to bottom, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.75) 100%);
}

.veto-map--shot > button,
.veto-map--shot > span {
    position: relative;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    min-height: 4.5rem;
    color: #fff;
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9);
    font-weight: 600;
}

/* Banned reads as struck out. Over a photograph a line through white text is
   easy to miss, so the picture goes grey and dark as well — the tile should be
   obviously out of play from across a room, which is where the captain's
   opponent is standing. */
.veto-map--shot.veto-map--banned { filter: grayscale(1); opacity: 0.45; }
.veto-map--shot.veto-map--banned::before { background: rgba(0, 0, 0, 0.6); }

.veto-map--shot.veto-map--picked::before {
    background: linear-gradient(to bottom, rgba(47, 158, 68, 0.25) 0%, rgba(0, 0, 0, 0.8) 100%);
}

/* Lifting the scrim on hover is the affordance: the tile you are about to
   choose shows you the map you are about to choose. */
.veto-map--shot.veto-map--clickable:hover::before { background: linear-gradient(to bottom, rgba(0, 0, 0, 0.15) 0%, rgba(0, 0, 0, 0.6) 100%); }
.veto-map--shot.veto-map--clickable:hover { background-color: transparent; }

@media (prefers-reduced-motion: no-preference) {
    .veto-map--shot::before { transition: background 0.18s ease; }
}

/* Event builder: lego pieces + drop feedback */
.module-canvas { transition: background-color 0.15s ease; }
.module-canvas.border-primary { background-color: rgba(13, 110, 253, 0.06); }
/* Snap-in when the fragment settles after a drop/change. */
#event-admin.htmx-settling .module-canvas { animation: module-snap 0.35s ease; }
@keyframes module-snap {
    from { transform: scale(0.99); opacity: 0.6; }
    to { transform: none; opacity: 1; }
}

/* The lego chain: bricks connect left to right. Each brick (bar the last)
   has a stud PEG growing out of its right edge — its base merges into the
   brick body (same colour, no seam) so it's clearly part of THAT brick —
   which plugs into a socket indent on the next brick's left edge. A raised
   top highlight + recessed bottom lip give the moulded-plastic look. */
/* Classic chain (event builder): bricks flow left-to-right and wrap, each with
   a connector peg lapping onto the next. */
.lego-chain {
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
    row-gap: 0.75rem;
    padding-right: 12px;
}
/* Plated chain (access builder): a base plate (left) + a wrapping strip of
   bricks (right). The base is its own grid column and stretches to the FULL
   height of the strip, so when bricks wrap to a second/third row those rows
   sit ON the base plate instead of floating loose at the margin. */
.lego-chain-plated {
    display: grid;
    grid-template-columns: max-content minmax(0, 1fr);
    align-items: stretch;
    column-gap: 1px;
}
.lego-base-plate {
    align-self: stretch;      /* spans every wrapped row of the strip */
    align-items: flex-start;  /* label rides the top, not centred on a tall plate */
    cursor: default;
}
/* Studs down the base plate's right edge — one per brick row, so every row
   reads as plugged into the base. Positioned by profile-access.php (a peg per
   detected row) and styled identically to a brick's connector peg, just in the
   plate's dark colour. */
.lego-base-peg {
    position: absolute;
    right: -13px;
    width: 13px;
    height: 14px;
    transform: translateY(-50%);
    border-radius: 2px 5px 5px 2px;
    background-color: inherit; /* the base plate's colour, exactly like a brick peg */
    box-shadow: 1.5px 0 0 rgba(var(--lego-seam), 0.5),
                0 -1.5px 0 rgba(var(--lego-seam), 0.45),
                0 1.5px 0 rgba(var(--lego-seam), 0.45),
                2px 2px 2px rgba(0, 0, 0, 0.22);
    z-index: 3;
    pointer-events: none;
}
.lego-chain-plated .lego-bricks {
    display: flex;
    flex-wrap: wrap;
    align-items: stretch;
    row-gap: 0.75rem;
    padding-right: 12px;
}
.lego-brick {
    position: relative;
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.6rem 1.05rem;
    font-weight: 600;
    font-size: 0.95rem;
    border-radius: 4px;
    background-image: linear-gradient(rgba(255, 255, 255, 0.20), rgba(0, 0, 0, 0.10));
    box-shadow: inset 0 0.12rem 0 rgba(255, 255, 255, 0.18),
                inset 0 -0.24rem 0 rgba(0, 0, 0, 0.22),
                0 2px 3px rgba(0, 0, 0, 0.20);
    cursor: grab;
}
.lego-brick:active { cursor: grabbing; }
a.lego-brick { cursor: pointer; }
/* Bricks sit ~1px apart so you can tell them apart. There's no socket
   hole — the peg simply grows out of this brick's right edge and draws
   OVER the brick to its right (z-index), so it reads as one brick's stud
   lapping onto the next. */
.lego-chain .lego-brick,
.lego-chain-plated .lego-bricks .lego-brick { margin-right: 1px; }
/* Classic chain's base brick (the event, first child) isn't draggable. */
.lego-chain > .lego-brick:first-child { cursor: default; }
.lego-chain .lego-brick::after,
.lego-chain-plated .lego-bricks .lego-brick::after {
    content: "";
    position: absolute;
    right: -13px; /* left edge flush with the owner's right edge — no overlap
                     onto the owner, so it's seamless there */
    top: 50%;
    width: 13px;
    height: 14px;
    transform: translateY(-50%);
    border-radius: 2px 5px 5px 2px;
    background-color: inherit; /* flat, exactly the owner's colour */
    /* Seam rim only on the sides that lap over the NEXT brick (top, right,
       bottom) — none on the owner side, so the peg reads as part of its own
       brick and slotted into a slightly larger hole in the next one. */
    box-shadow: 1.5px 0 0 rgba(var(--lego-seam), 0.5),
                0 -1.5px 0 rgba(var(--lego-seam), 0.45),
                0 1.5px 0 rgba(var(--lego-seam), 0.45),
                2px 2px 2px rgba(0, 0, 0, 0.22);
    z-index: 3;
}
/* A role has no giving KNOB — a user holds exactly ONE role, so nothing
   ever chains off it. Held role: plugged into the base plate (via the base
   peg), its own trailing peg suppressed — the selector stays more specific
   than the chain peg rule above so it actually wins. Shelf role: keeps its
   socket HOLE (reads as "this slots in") but drops the peg. */
.lego-chain-plated .lego-bricks .lego-brick.lego-studless::after,
.lego-loose.lego-studless::after { content: none; }

/* Palette rows: a fixed label column + a wrapping brick column, so a
   second row of bricks stays indented under the first, not at the margin. */
.palette-row {
    display: grid;
    grid-template-columns: 8.6rem 1fr;
    column-gap: 0.4rem;
    row-gap: 0.5rem; /* only matters when a row's bricks wrap */
    align-items: start;
    margin-bottom: 0.35rem;
}
/* Knob-less base brick on the shelf — a base plate the loose pieces are
   listed against (fixed width holds the indentation). No peg, no hole:
   plain .lego-brick outside a chain gets neither. */
.lego-base { width: 100%; cursor: default; white-space: nowrap; justify-content: flex-start; }

/* Loose bricks on the palette shelf: the same plastic brick with a free
   connector peg on its right — a lego piece not plugged into anything,
   ready to snap into the chain when dragged over. */
.lego-loose { margin-right: 8px; padding-left: 1.5rem; }
/* A socket hole cut into the loose brick's LEFT EDGE — the receiving end
   (the free peg on its right is the giving end). It's the seam colour, matching
   the rim a plugged-in peg shows, so you can see the pieces are meant to fit
   together. Flush with the edge, rounded into the brick. */
.lego-loose::before {
    content: "";
    position: absolute;
    left: 0;
    top: 50%;
    width: 11px;
    height: 15px;
    transform: translateY(-50%);
    border-radius: 0 5px 5px 0;
    background-color: rgba(var(--lego-seam), 0.7);
    box-shadow: inset -1px 0 2px rgba(0, 0, 0, 0.28);
}
.lego-loose::after {
    content: "";
    position: absolute;
    right: -10px;
    top: 50%;
    width: 16px;
    height: 13px;
    transform: translateY(-50%);
    border-radius: 2px 5px 5px 2px;
    background-color: inherit; /* flat, exactly the owner's colour — no gradient */
    box-shadow: 1px 1px 1px rgba(0, 0, 0, 0.18);
}

/* Flash alerts animate out when flash-autohide.js dismisses them. */
main .alert-dismissible {
    transition: opacity 0.5s ease, transform 0.5s ease, max-height 0.5s ease 0.2s,
                margin 0.5s ease 0.2s, padding 0.5s ease 0.2s;
    max-height: 8rem;
    overflow: hidden;
}
main .alert-dismissible.flash-hiding {
    opacity: 0;
    transform: translateY(-0.5rem);
    max-height: 0;
    margin-top: 0;
    margin-bottom: 0;
    padding-top: 0;
    padding-bottom: 0;
}

/* Budget Analytics — the /admin/stats bar chart */
.stat-bar {
    background-color: var(--accent-400);
    border-radius: 2px 2px 0 0;
    min-width: 4px;
    transition: background-color .15s ease;
}

.stat-bar:hover {
    background-color: var(--accent-600);
}

/* Facebook-style media embeds (Support\MediaEmbeds) in forum/news bodies */
.media-embed {
    margin: .75rem 0;
    max-width: 640px;
}

.media-embed img {
    max-height: 480px;
    width: auto;
}

/* Link preview card — what a pasted link points at, fetched by the server so
   the reader's browser never contacts the other site. Deliberately no image:
   showing one would put that request back in their browser, which is the whole
   thing this avoids. */
/* The card a page link becomes. The coloured edge and the mark say what KIND
   of place it is before the title says which page — and both come from us: the
   icon is in the font we already serve, the colour is a variable. A favicon or
   an og:image would each be a request to the site being described, and the
   whole reason this card exists is that nobody's browser talks to them until
   they click. */
.link-preview {
    margin: .75rem 0;
    max-width: 640px;
    padding: .6rem .8rem;
    border: 1px solid var(--bs-border-color);
    border-left: 3px solid var(--brand, var(--bs-secondary));
    border-radius: .375rem;
    text-decoration: none;
    background: var(--bs-tertiary-bg);
}

.link-preview-mark {
    color: var(--brand, var(--bs-secondary));
    font-size: 1.25rem;
    line-height: 1.3;
    flex: 0 0 auto;
}

.link-preview-text { min-width: 0; }

/* Reuses the brand palette the profile links already define, so a service has
   one colour on this site rather than one per feature. */
.link-preview--youtube   { --brand: #ff0033; }
.link-preview--twitch    { --brand: #9146ff; }
.link-preview--kick      { --brand: #53fc18; }
.link-preview--discord   { --brand: #5865f2; }
.link-preview--instagram { --brand: #e1306c; }
.link-preview--tiktok    { --brand: #25f4ee; }
.link-preview--x         { --brand: #71767b; }
.link-preview--steam     { --brand: #2a475e; }
.link-preview--facebook  { --brand: #1877f2; }
.link-preview--faceit    { --brand: #ff5500; }
.link-preview--esplay    { --brand: #00b3a4; }

.link-preview:hover {
    background: var(--bs-secondary-bg);
}

.link-preview-site {
    text-transform: lowercase;
}

.link-preview-title {
    font-weight: 500;
    color: var(--bs-body-color);
}

/* A link to a blocked domain: readable, so it can be checked or reported, but
   not one careless click away from being followed. */
.blocked-link {
    color: var(--bs-danger-text-emphasis);
    background: var(--bs-danger-bg-subtle);
    border: 1px solid var(--bs-danger-border-subtle);
    border-radius: .25rem;
    padding: .05rem .35rem;
    word-break: break-all;
}

.blocked-link-url {
    text-decoration: line-through;
}

/* ── Menu builder: the vertical lego stack ───────────────────────────────
   Where the event chain runs left-to-right, a menu stacks top-to-bottom —
   which is how real lego actually plugs together (studs up into the sockets
   on the underside of the brick above). Top-level items are flat black plates
   (the structural pieces); submenu bricks carry the studs and colour-grade by
   access; submenus indent one level under their parent.

   Its own neutral chrome — NOT the access builder's green zone — so the
   coloured bricks are the only colour in play and nothing reads muddy. */
.menu-zone {
    border: 1px solid rgba(0, 0, 0, 0.15);
    border-radius: 8px;
    overflow: hidden;
    background-color: rgba(0, 0, 0, 0.02);
}
.menu-zone-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.5rem 0.85rem;
    color: #fff;
    font-weight: 600;
    background: linear-gradient(180deg, #3a4450, #2c343f); /* neutral default; --active/--shelf override */
}
/* Green = in the menu, red = parked on the shelf — the same green/red the
   access builder uses, so it reads at a glance which way you're dragging. */
.menu-zone--active { border-color: rgba(25, 135, 84, 0.5); background-color: rgba(25, 135, 84, 0.05); }
.menu-zone-bar--active { background: linear-gradient(180deg, #22a565, #198754); }
.menu-zone--shelf { border-color: rgba(190, 60, 60, 0.45); background-color: rgba(190, 60, 60, 0.045); }
.menu-zone-bar--shelf { background: linear-gradient(180deg, #c9525a, #b0434b); }

.lego-menu { display: flex; flex-direction: column; gap: 12px; padding: 0.5rem; }
.menu-node { display: block; }

.menu-brick {
    position: relative;
    display: flex;
    align-items: center;
    gap: 0.5rem;
    width: 100%;
    padding: 0.5rem 0.8rem;
    color: #fff;
    font-weight: 600;
    font-size: 0.92rem;
    border-radius: 4px;
    background-image: linear-gradient(rgba(255, 255, 255, 0.20), rgba(0, 0, 0, 0.10));
    box-shadow: inset 0 0.12rem 0 rgba(255, 255, 255, 0.18),
                inset 0 -0.24rem 0 rgba(0, 0, 0, 0.22),
                0 2px 3px rgba(0, 0, 0, 0.20);
    cursor: grab;
}
.menu-brick:active { cursor: grabbing; }
/* Submenu bricks are flat solid colour — no gradient overlay — so the studs
   (which inherit the raw brick colour) match exactly instead of reading as a
   slightly-off shade. */
.menu-node--child > .menu-brick { background-image: none; }
/* Flat plate: the main-menu piece reads as a smooth lego tile, no studs. */
.menu-brick--plate {
    background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.10));
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 2px 3px rgba(0, 0, 0, 0.25);
}
/* Divider: a dead separator, not a coloured lego piece — a clearly-visible
   medium-grey bar with a dashed rule and an optional section label. No studs. */
.menu-brick--divider {
    background-image: none;
    background-color: #5c636a;
    color: #fff;
    box-shadow: inset 0 -0.18rem 0 rgba(0, 0, 0, 0.18), 0 2px 3px rgba(0, 0, 0, 0.2);
}
.menu-brick--divider .menu-brick-del button,
.menu-brick--divider .menu-brick-grip { color: #fff; }
.menu-divider-rule { flex: 1 1 auto; height: 0; border-top: 2px dashed var(--accent-400); }
.menu-brick--divider-solid .menu-divider-rule { border-top-style: solid; }
.menu-divider-label { flex: 0 0 auto; font-size: 0.78rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; opacity: 0.95; white-space: nowrap; }
/* A divider never grows studs, even nested as a child. */
.menu-node--child > .menu-brick--divider::before,
.menu-node--child > .menu-brick--divider::after { display: none; }
.menu-brick-grip { opacity: 0.55; cursor: grab; }
.menu-brick-label { white-space: nowrap; }
.menu-brick-target { font-weight: 400; font-size: 0.78rem; opacity: 0.82; }
.menu-brick-access { margin-left: auto; background-color: rgba(0, 0, 0, 0.30); color: #fff; font-weight: 600; }
/* On the dark plate, a translucent-black badge would vanish — lift it. */
.menu-brick--plate .menu-brick-access { background-color: rgba(255, 255, 255, 0.16); }
.menu-brick-del { margin: 0; line-height: 1; }
.menu-brick-del button { opacity: 0.7; font-size: 1.15rem; line-height: 1; }
.menu-brick-del button:hover { opacity: 1; }
/* Edit pencil, sits just before the × on a brick. */
.menu-brick-edit { margin: 0 0.15rem 0 0; padding: 0; border: 0; background: none; color: inherit; opacity: 0.6; cursor: pointer; line-height: 1; }
.menu-brick-edit:hover { opacity: 1; }

/* Studs on the TOP of every submenu brick — a few big, well-spaced SQUARE
   (rounded-rect) flat knobs, poking up and lapping onto the brick above so the
   pieces read as plugged together. 60px pitch.

   Two masked layers give the seam rim the other lego builders have: ::before
   is a slightly-larger SEAM-COLOURED knob behind, ::after the brick-coloured
   knob on top — so ~1px of seam peeks out all round. (A filter rim doesn't work here:
   CSS applies mask AFTER filter, so a drop-shadow rim gets clipped away by the
   knob mask.) */
.menu-node--child > .menu-brick::before,
.menu-node--child > .menu-brick::after {
    content: "";
    position: absolute;
    left: 1.1rem;
    right: 1.1rem;
    top: -6px;
    height: 10px;
    -webkit-mask-repeat: repeat-x;
            mask-repeat: repeat-x;
    -webkit-mask-position: left center;
            mask-position: left center;
}
/* Seam rim knob (behind) — only the poking-up part (top + sides), stopping
   before the base so there's no rim where the knob meets its own brick. */
.menu-node--child > .menu-brick::before {
    background-color: rgba(var(--lego-seam), 0.9);
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='60'%20height='10'%3E%3Crect%20x='21'%20y='0'%20width='18'%20height='7'%20rx='3'%20fill='%23000'/%3E%3C/svg%3E");
            mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='60'%20height='10'%3E%3Crect%20x='21'%20y='0'%20width='18'%20height='7'%20rx='3'%20fill='%23000'/%3E%3C/svg%3E");
    -webkit-mask-size: 60px 10px;
            mask-size: 60px 10px;
    z-index: 1;
}
/* Brick-coloured knob (on top) — runs all the way down into the brick, so it
   reads as attached, plugging up. The white behind peeks out as the rim. */
.menu-node--child > .menu-brick::after {
    background-color: inherit; /* flat solid brick colour */
    -webkit-mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='60'%20height='10'%3E%3Crect%20x='22'%20y='1'%20width='16'%20height='9'%20rx='2.5'%20fill='%23000'/%3E%3C/svg%3E");
            mask-image: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='60'%20height='10'%3E%3Crect%20x='22'%20y='1'%20width='16'%20height='9'%20rx='2.5'%20fill='%23000'/%3E%3C/svg%3E");
    -webkit-mask-size: 60px 10px;
            mask-size: 60px 10px;
    z-index: 2;
}

/* Submenus tuck one level in under their parent (a clean 1.4rem step) and
   stack tight, so each brick's studs lap onto the brick above — a connected
   column, no separate socket row needed. */
.menu-children { display: flex; flex-direction: column; gap: 1px; }
.menu-children:not(:empty) { margin-top: 1px; margin-left: 1.4rem; }

.menu-node.dragging { opacity: 0.4; }

/* Where the brick will land when you let go. */
.menu-placeholder {
    height: 2.3rem;
    border: 2px dashed rgba(120, 120, 120, 0.75);
    border-radius: 4px;
    background-color: rgba(0, 0, 0, 0.04);
}

/* The page shelf: full-width bricks (same form as in the menu, so a page
   keeps its shape when dragged up), no studs while parked. */
.lego-menu--shelf .menu-brick { cursor: grab; }
[data-menu-shelf].shelf-hot { outline: 2px dashed #b0434b; outline-offset: -4px; }

/* ── Widgets→pages board: reuses the access/roles lego bricks. Each place is a
   coloured base plate (green Överallt / black section / blue page) with widget
   bricks plugged onto it — inherited-from-Överallt (green) then pinned (slate)
   — over a palette of loose widget bricks. Colour is set inline per place. */
.wl-place { margin-bottom: 0.35rem; }
/* Plate + bricks hug their content (no full-width strip), and the bricks sit on
   the page — no tray background — exactly like the access/role builders, so an
   empty place is just a clean coloured base plate. */
.wl-place .lego-chain-plated { width: fit-content; max-width: 100%; border-radius: 8px; }
.wl-place .lego-bricks {
    min-height: 2.55rem; /* == a brick, so the base plate never stretches taller */
    min-width: 2.5rem;   /* a small landing next to the plate when empty */
    align-content: center;
    align-items: center;
    /* No extra padding — the base rule's padding-right:12px leaves room for the
       trailing peg, and zero left padding keeps the first brick flush against
       the base plate (bridged by the connector stud). */
}
/* Every lego piece here — base plates and widget bricks, in places and in the
   palette — is the same thickness, so the whole board reads as one lego set. */
.wl-base-plate, .wl-widget { min-height: 2.55rem; }
.wl-base-plate { align-items: center; gap: 0.4rem; border-radius: 6px 0 0 6px; }
/* An empty place is just the plate — round it fully (no brick to its right). */
.wl-place--empty .wl-base-plate { border-radius: 6px; }
.wl-count { background-color: rgba(0, 0, 0, 0.28); color: #fff; font-weight: 600; margin-left: 0.2rem; }
.wl-widget .btn-link { color: #fff; opacity: 0.75; text-decoration: none; line-height: 1; display: inline-flex; align-items: center; }
.wl-widget .btn-link:hover { opacity: 1; }

/* Drop feedback wraps the whole compact lego unit (plate + bricks). */
.wl-place.wl-hot .lego-chain-plated { outline: 2px dashed #198754; outline-offset: 3px; }
.wl-place.wl-deny .lego-chain-plated { outline: 2px dashed #c9453d; outline-offset: 3px; }
[data-widget-palette].wl-hot { outline: 2px dashed #b0434b; outline-offset: -4px; }
.wl-palette-body { display: flex; flex-wrap: wrap; gap: 0.75rem 0.4rem; align-items: center; }

/* ── Role builder: one drop zone per role + a shared, inexhaustible palette.
   Reuses the access builder's lego bricks and red shelf; each role is a light
   card whose body is a drop target for ability bricks. */
.role-zone {
    border: 1px solid rgba(0, 0, 0, 0.15);
    border-radius: 8px;
    overflow: hidden;
    margin-bottom: 0.75rem;
}
.role-zone-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 0.5rem;
    padding: 0.45rem 0.75rem;
    background-color: rgba(0, 0, 0, 0.03);
    border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.role-zone-body {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.35rem;
    padding: 0.7rem;
    min-height: 3rem;
}
.role-zone-body.drop-hot {
    background-color: rgba(25, 135, 84, 0.08);
    outline: 2px dashed #198754;
    outline-offset: -4px;
}

/* ── Permission ladder: one zone per access level, action bricks dragged
   between rungs. Zone header colour is graded by level (set inline); bricks
   reuse the shared lego-brick + danger colours. */
.perm-zone {
    border: 1px solid rgba(0, 0, 0, 0.15);
    border-radius: 8px;
    overflow: hidden;
    margin-bottom: 0.6rem;
}
.perm-zone-bar {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0.4rem 0.8rem;
    color: #fff;
    font-weight: 600;
}
.perm-zone-body {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 0.35rem;
    padding: 0.7rem;
    min-height: 2.7rem;
}
.perm-zone-body.drop-hot {
    background-color: rgba(13, 110, 253, 0.06);
    outline: 2px dashed #0d6efd;
    outline-offset: -4px;
}

/* Responsive page width: 1440p and below keep Bootstrap's comfortable defaults;
   very large screens (4K, ultrawide) let the layout grow so it doesn't float in
   a thin column. .app-wide is the full-width variant (brackets, admin tables). */
.app-wide { max-width: 1600px; }
@media (min-width: 2600px) {
    .container { max-width: 1680px; }
    .app-wide { max-width: 2000px; }
}
@media (min-width: 3400px) {
    .container { max-width: 2100px; }
    .app-wide { max-width: 2600px; }
}

/* Tron team-draft modal: the balancer's steps animate here, each team's average
   skill sliding toward the target (centre line) as players are swapped. */
.td-status { display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; font-size: 0.9rem; }
.td-label { font-weight: 600; }
.td-spread { color: #b5222d; font-variant-numeric: tabular-nums; }
.td-spread.ok { color: var(--os-success, #2f9e44); }
.td-final { color: var(--os-success, #2f9e44); font-weight: 700; }
.td-teams { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0.75rem; }
.td-team { border: 1px solid var(--gray-200, #dee2e6); border-radius: 0.5rem; padding: 0.6rem 0.75rem; background: #fff; }
.td-head { display: flex; justify-content: space-between; align-items: baseline; gap: 0.5rem; }
.td-name { font-weight: 700; }
.td-avg { font-family: 'Orbitron', ui-monospace, monospace; font-weight: 700; font-variant-numeric: tabular-nums; }
.td-bar { position: relative; height: 6px; background: var(--gray-100, #f1f3f5); border-radius: 3px; margin: 0.45rem 0 0.55rem; }
.td-mid { position: absolute; left: 50%; top: -2px; bottom: -2px; width: 2px; background: var(--gray-400, #adb5bd); }
.td-dot { position: absolute; top: 50%; width: 10px; height: 10px; border-radius: 50%; background: var(--os-primary, #0d6efd); transform: translate(-50%, -50%); box-shadow: 0 0 6px rgba(13, 110, 253, 0.5); transition: left 0.55s cubic-bezier(0.22, 1, 0.36, 1); }
.td-players { list-style: none; margin: 0; padding: 0; font-size: 0.85rem; }
.td-players li { display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.12rem 0; border-top: 1px solid var(--gray-100, #f1f3f5); }
.td-players li:first-child { border-top: 0; }
.td-sk { color: var(--gray-500, #868e96); font-variant-numeric: tabular-nums; }

/* Escalating-tolerance visuals in the Tron draft modal. */
.td-meta { color: var(--gray-500, #868e96); font-size: 0.85rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.td-label.td-fail { color: #b5222d; font-weight: 600; }
.td-band { position: absolute; top: 0; bottom: 0; background: rgba(47, 158, 68, 0.16); border-radius: 3px; transition: left 0.4s ease, width 0.4s ease; }
.td-dot.in { background: var(--os-success, #2f9e44); box-shadow: 0 0 6px rgba(47, 158, 68, 0.65); }
.td-avg.in { color: #2b8a3e; }

/* Internal-range chip on each team card in the Tron draft modal: benched (6-man)
   teams show their internal skill spread, green when within the derived tolerance. */
.td-range { display: inline-block; font-size: 0.72rem; padding: 0.05rem 0.4rem; border-radius: 10px; margin-bottom: 0.4rem; font-variant-numeric: tabular-nums; }
.td-range.ok { background: rgba(47, 158, 68, 0.15); color: #2b8a3e; }
.td-range.wide { background: rgba(224, 168, 0, 0.18); color: #9a7000; }
.td-range.none { background: var(--gray-100, #f1f3f5); color: var(--gray-500, #868e96); }
.td-team.td-benched { border-color: rgba(47, 158, 68, 0.4); }

/* A small playoff bracket sits beside the side panel (standings/teams); a wide
   one lets the panel wrap below it. */
.playoff-with-side { display: flex; flex-wrap: wrap; gap: 1.5rem; align-items: flex-start; }
.playoff-with-side__bracket { min-width: 0; }
.playoff-with-side__side { flex: 1 1 300px; min-width: 280px; }
.playoff-with-side__side .card:last-child,
.playoff-with-side__side > *:last-child { margin-bottom: 0 !important; }

/* ---- ediTron overlay (shared by the forum reply box and the news editor) ----
   A see-through textarea over a div that redraws the same text with <mark> spans;
   every metric that affects wrapping must match or the marks land beside the
   words. See templates/partials/proofread.php and the forum thread editor. */
.editron-wrap {
    position: relative;
    background-color: #fff;
    border: 1px solid var(--gray-300, #dee2e6);
    border-radius: .375rem;
}
.editron-wrap:focus-within {
    border-color: var(--accent-500, #fbb204);
    box-shadow: 0 0 0 0.2rem rgba(251, 178, 4, 0.25);
}
.editron-wrap .editron-overlay,
.editron-wrap .editron-input {
    font-family: var(--bs-body-font-family);
    font-size: 1rem;
    line-height: 1.5;
    padding: .375rem .75rem;
    border: 1px solid transparent;
    border-radius: .375rem;
    white-space: pre-wrap;
    overflow-wrap: break-word;
}
.editron-overlay {
    position: absolute;
    inset: 0;
    overflow: hidden;
    pointer-events: none;
    color: transparent;
    z-index: 0;
}
.editron-wrap .editron-input,
.editron-wrap .editron-input:focus {
    position: relative;
    z-index: 1;
    background-color: transparent;
    border-color: transparent;
    box-shadow: none;
}
.editron-overlay mark { color: transparent; padding: 0; border-radius: 2px; }
/* Blue = proofread (spelling) — a different job and colour from the judgement marks. */
.editron-overlay mark.ed-blue {
    background: rgba(13, 110, 253, .18);
    border-bottom: 2px dotted #0d6efd;
}

/* Staff rank badge in the game-server player lists. A tinted chip rather than
   bare coloured text: the bright hues that carry a dark dashboard wash out to
   near-invisible on a white card, so each rank keeps its hue but swaps to a
   darker ink on a faint plate of the same colour in light mode. */
.staff-tag {
    display: inline-flex;
    align-items: center;
    gap: .2em;
    padding: .05em .4em;
    margin-left: .35em;
    border-radius: .35rem;
    font-size: .68rem;
    font-weight: 700;
    letter-spacing: .02em;
    line-height: 1.5;
    white-space: nowrap;
    color: var(--staff-ink);
    background: color-mix(in srgb, var(--staff-hue) 16%, transparent);
    border: 1px solid color-mix(in srgb, var(--staff-hue) 40%, transparent);
}
.staff-tag--sa { --staff-hue: #ff5cf4; --staff-ink: #a3007f; }
.staff-tag--ha { --staff-hue: #ffc400; --staff-ink: #7a5600; }
.staff-tag--a  { --staff-hue: #25e0ff; --staff-ink: #00647d; }
.staff-tag--m  { --staff-hue: #8fe388; --staff-ink: #2c6b28; }

/* On dark surfaces the bright hue is the readable one, and a soft glow makes
   ranks findable at a glance from across the room. */
[data-bs-theme="dark"] .staff-tag {
    color: var(--staff-hue);
    text-shadow: 0 0 6px color-mix(in srgb, var(--staff-hue) 55%, transparent);
    background: color-mix(in srgb, var(--staff-hue) 14%, transparent);
    border-color: color-mix(in srgb, var(--staff-hue) 45%, transparent);
}

/* Ban list tables (admin and public). Dates, lengths and status read as single
   units — the reason and the player name are the only things allowed to wrap. */
.ban-table td, .ban-table th { white-space: nowrap; }
.ban-table td.ban-wrap { white-space: normal; }
.ban-table .ban-id { font-size: .7rem; }

/* Skill history chart. The viewBox is stretched to the element so the curve
   fills any width; the labels live outside it (see .skill-axis) precisely so
   they don't stretch with it. */
.skill-chart { width: 100%; height: 250px; display: block; }

/* Axis labels drawn over the chart as real text, never scaled. */
.skill-axis { position: absolute; inset: 0; pointer-events: none; font-size: .7rem; color: var(--bs-secondary-color); }
.skill-axis-y { position: absolute; left: 0; width: 38px; text-align: right; transform: translateY(-50%); }
.skill-axis-x { position: absolute; bottom: -1.15rem; transform: translateX(-50%); white-space: nowrap; }
/* The end labels would otherwise hang off the chart's edges. */
.skill-axis-x:first-of-type { transform: none; }
.skill-axis-x:last-child { transform: translateX(-100%); }

/* Skill chart hover + modal. The wrap is the positioning context for the
   tooltip, which floats over the curve rather than shifting the layout. */
/* Room under the plot for the date row, which is positioned into it. */
.skill-chart-wrap { position: relative; cursor: pointer; padding-bottom: 1.75rem; }
.skill-chart-wrap--large .skill-chart { height: min(60vh, 460px); }
.skill-tip {
    position: absolute;
    z-index: 5;
    pointer-events: none;
    background: var(--bs-body-bg);
    border: 1px solid var(--bs-border-color);
    border-radius: .375rem;
    box-shadow: 0 .5rem 1rem rgba(0, 0, 0, .15);
    padding: .35rem .55rem;
    font-size: .78rem;
    line-height: 1.35;
    white-space: nowrap;
}

/* Decorative silhouette on a profile. Left side on purpose: the right is where
   the live information sits, and nothing should compete with that. It also puts
   the figure's aim pointing into the content rather than off the page.
   Faint enough to read as texture, and pointer-events off so it can never
   swallow a click. */
/* Only shown where it can stand in the margin beside the content. Squeezed in
   behind the cards on a narrower screen it read as a smudge half off the edge,
   and a decoration that looks like dirt is worse than no decoration. */
.profile-head { position: relative; }
.profile-operator {
    display: none;
    position: absolute;
    width: 40%;
    height: 340px;
    background-color: currentColor;
    -webkit-mask-image: var(--operator-src);
    mask-image: var(--operator-src);
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: left top;
    mask-position: left top;
    -webkit-mask-size: contain;
    mask-size: contain;
    opacity: .06;
    pointer-events: none;
    user-select: none;
    z-index: 0;
}
[data-bs-theme="dark"] .profile-operator { opacity: .09; }
/* The profile's own content has to sit above the watermark.
   .modal is excluded deliberately: it's a direct child too, and giving it
   position:relative overrides Bootstrap's position:fixed at equal specificity —
   the dialog then renders in the page flow, looking like a second chart below
   the first while the backdrop dims as normal. */
.profile-page { position: relative; }
.profile-page > *:not(.profile-operator):not(.modal) { position: relative; z-index: 1; }

/* The faceted variant needs more presence than a solid silhouette: its shape
   lives in the white gaps between facets, and those vanish first when faded. */
.profile-operator--poly { opacity: .13; }
[data-bs-theme="dark"] .profile-operator--poly { opacity: .16; }

/* Given room, the figure steps out of the content and stands in the margin
   beside it, where it can be seen properly instead of read as a smudge behind
   the text. The margin only exists on wide screens — below this it stays the
   faint watermark above, which is why both rules exist. */
@media (min-width: 1700px) {
    .profile-operator {
        display: block;
        right: 100%;
        left: auto;
        margin-right: 2rem;
        /* Hung from the name and grown downward, at a fixed size.
           Fitting it to the header block instead made it shrink to nothing on a
           member with a short profile, and anchoring it to the card's baseline
           made it grow upward into the site notification bar. Downward is the
           only direction with nothing to collide with — the margin below is
           empty on every profile — so the figure is the same size on all of
           them. */
        top: 2.5rem;
        bottom: auto;
        /* The figure is taller than it is wide, so the width is what actually
           caps it — both have to grow for it to get bigger. */
        height: 470px;
        width: 300px;
        -webkit-mask-position: left bottom;
        mask-position: left bottom;
        opacity: .22;
    }
    [data-bs-theme="dark"] .profile-operator { opacity: .26; }
    .profile-operator--poly { opacity: .3; }
    [data-bs-theme="dark"] .profile-operator--poly { opacity: .34; }
}

/* A member's own links. The icon carries the brand colour so the service is
   recognisable at a glance, while the row itself reads like every other fact on
   the profile. */
.user-link-mark { color: var(--brand); margin-right: .15rem; }

/* Kept for the link fields in the edit form, which still front each input with
   its service's icon. */
.user-link {
    display: inline-flex;
    align-items: center;
    gap: .4em;
    padding: .2rem .6rem;
    border-radius: 2rem;
    font-size: .82rem;
    font-weight: 500;
    text-decoration: none;
    color: var(--brand-ink, var(--bs-body-color));
    background: color-mix(in srgb, var(--brand, var(--bs-secondary)) 14%, transparent);
    border: 1px solid color-mix(in srgb, var(--brand, var(--bs-secondary)) 35%, transparent);
    transition: background .12s, border-color .12s;
}
a.user-link:hover {
    background: color-mix(in srgb, var(--brand) 26%, transparent);
    border-color: var(--brand);
    color: var(--brand-ink);
}
.user-link i { font-size: 1em; color: var(--brand); }
/* Brand colours, with a darker ink for the label so it stays readable on a
   white card — the brand hue alone fails contrast for several of these. */
.user-link--youtube   { --brand: #ff0033; --brand-ink: #a1001f; }
.user-link--twitch    { --brand: #9146ff; --brand-ink: #6427c4; }
.user-link--kick      { --brand: #53fc18; --brand-ink: #2f7a0f; }
.user-link--discord   { --brand: #5865f2; --brand-ink: #3c47b8; }
.user-link--instagram { --brand: #e1306c; --brand-ink: #ad1e51; }
.user-link--tiktok    { --brand: #25f4ee; --brand-ink: #0b7a76; }
.user-link--x         { --brand: #71767b; --brand-ink: #3d4145; }
/* Steam's own dark navy reads as almost black on a light card, so the row uses
   its brighter blue instead — recognisable without disappearing. */
.user-link--steam     { --brand: #2a475e; --brand-ink: #1b2838; }
.user-link--facebook  { --brand: #1877f2; --brand-ink: #0f5ac2; }
.user-link--faceit    { --brand: #ff5500; --brand-ink: #b83d00; }
.user-link--esplay    { --brand: #00b3a4; --brand-ink: #00726a; }
.user-link--website   { --brand: #6c757d; --brand-ink: #495057; }
[data-bs-theme="dark"] .user-link--steam { --brand: #66c0f4; }
/* On dark the brand hue is the readable one and the plate does the separating. */
[data-bs-theme="dark"] .user-link { color: var(--brand); }
[data-bs-theme="dark"] a.user-link:hover { color: #fff; }
/* The icon that fronts each link field, tinted like its chip. */
.user-link-icon { color: var(--brand); background: color-mix(in srgb, var(--brand) 12%, transparent); }

/* Live preview under an editor. Quiet by design: it's a glimpse of the result,
   not a second document competing with the one being written. */
.preview-pane { background: var(--bs-tertiary-bg); border-style: dashed; }
.preview-body img, .preview-body iframe { max-width: 100%; }

/* The ticket's participant strip: who hears about this case. Sits between the
   toolbar and the thread, because "who else is reading this" is something you
   want to know before you write, not after. */
.ticket-participants {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 1rem;
    border-bottom: 1px solid var(--gray-200);
}

.ticket-participant {
    display: inline-flex;
    align-items: center;
    gap: 0.3rem;
    padding: 0.15rem 0.45rem;
    border: 1px solid var(--gray-200);
    border-radius: 999px;
}

/* @mention autocomplete. Positioned in the body rather than inside the form so
   an overflow:hidden card can't clip it.
   It has to read as a layer floating ABOVE the page: a near-white box on a
   near-white card with a pale border is technically visible and practically
   isn't. Hence a solid surface, a real border, and a shadow with enough depth
   to separate it from whatever it covers. */
.mention-suggest {
    position: absolute;
    z-index: 1080;
    padding: 0.25rem;
    background-color: #fff;
    border: 1px solid var(--gray-400);
    border-radius: 0.5rem;
    box-shadow: 0 0.6rem 1.5rem rgba(0, 0, 0, 0.22);
    overflow: hidden;
}

.mention-suggest-item {
    display: block;
    width: 100%;
    padding: 0.4rem 0.8rem;
    border: 0;
    border-radius: 0.35rem;
    background: transparent;
    text-align: left;
    font-size: 0.92rem;
    color: var(--page-text);
}

/* The highlighted row is the one Enter will take, so it needs to be obvious at
   a glance — gray-100 was a suggestion of a highlight rather than one. */
.mention-suggest-item:hover,
.mention-suggest-item.active {
    background-color: var(--accent-700);
    color: #fff;
    font-weight: 600;
}

/* A lifecycle event in the ticket thread: quieter than a message, because it's
   context around the conversation rather than part of it. */
.ticket-event {
    display: flex;
    align-items: baseline;
    gap: 0.4rem;
    padding: 0 0.25rem 0 2.4rem;
    font-size: 0.85rem;
    color: var(--page-text-muted);
}

.ticket-event i { color: var(--accent-700); }
.ticket-event-time { margin-left: auto; white-space: nowrap; font-size: 0.78rem; }

/* A news byline: a person, not a data row. The avatar and the name lead;
   the timestamp trails as context rather than opening the line. */
.news-byline {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 1rem;
    font-size: 0.92rem;
}

/* A news post as a card. The byline sits tight under the title, and the body
   gets the article typography it already had — the card only supplies edges. */
.news-card .card-body { padding: 1.25rem 1.5rem; }
.news-card h1 { margin-bottom: 0.35rem; }


/* Inline SVG bar charts (partials/bar-chart.php). No charting library — the
   colour comes from the same Bootstrap text utilities everything else uses, so
   the bars follow the theme in light and dark without a second palette. */
.bar-chart {
    overflow: visible;
}

.bar-chart-bar {
    fill: currentColor;
    opacity: .75;
}

.bar-chart-bar:hover {
    opacity: 1;
}

/* The trend line sits over the bars, so it needs to read against both a bar and
   the empty space above one. A dark stroke with a light halo does that in either
   theme without a second colour per palette. */
.bar-chart-trend {
    stroke: var(--bs-body-color);
    stroke-width: 1.5;
    stroke-linejoin: round;
    stroke-linecap: round;
    paint-order: stroke;
    opacity: .85;
}

.bar-chart-trend-key {
    white-space: nowrap;
}

/* Manage mode: the wiki browser as staff see it once they ask for it.
   Everything below is hidden by default, so with the mode off the tree renders
   byte-for-byte what a member gets — a reader can't drag a shelf by accident,
   and staff aren't reading around chrome they didn't want.
   The flag sits on <body> rather than the browser, because the same reveal is
   meant to serve other surfaces later; the wiki is just its first consumer. */
.wiki-grip,
.wiki-shelf-manage { display: none; }

body[data-manage-mode="on"] .wiki-grip {
    display: inline-flex;
    align-items: center;
    cursor: grab;
    color: var(--gray-500);
    padding: 0 0.15rem;
}

body[data-manage-mode="on"] .wiki-grip:active { cursor: grabbing; }

body[data-manage-mode="on"] .wiki-shelf-manage {
    display: flex;
    gap: 0.35rem;
    padding: 0.25rem 0.25rem 0.5rem 1.5rem;
}

/* The grip sits beside the fold toggle, which is a full-width button — so the
   row becomes a flex line only when there's a grip to place. */
.wiki-shelf-row { display: contents; }
body[data-manage-mode="on"] .wiki-shelf-row { display: flex; align-items: center; }
body[data-manage-mode="on"] .wiki-shelf-row .wiki-shelf-toggle { flex: 1 1 auto; min-width: 0; }
body[data-manage-mode="on"] .wiki-shelf-pages li { display: flex; align-items: center; }
body[data-manage-mode="on"] .wiki-shelf-pages li .wiki-page-link { flex: 1 1 auto; min-width: 0; }

/* In manage mode the whole row is a handle, not just the grip — grabbing a
   shelf by its name is the obvious gesture, and hunting for a 10px icon is not.
   The cursor has to say so, or the row looks inert. Clicks still fold the shelf
   and still open the page: a drag needs movement, a click doesn't. */
body[data-manage-mode="on"] .wiki-shelf-toggle,
body[data-manage-mode="on"] .wiki-page-link { cursor: grab; }
body[data-manage-mode="on"] .wiki-shelf-toggle:active,
body[data-manage-mode="on"] .wiki-page-link:active { cursor: grabbing; }

.wiki-manage-toggle.active { background-color: var(--gray-200); }

/* Where a dragged node would land. Same idea as the menu builder's placeholder,
   sized for the narrower tree. */
.sort-placeholder {
    height: 2px;
    margin: 0.15rem 0;
    background-color: var(--bs-primary, #0d6efd);
    border-radius: 2px;
}

[data-sort-node].dragging { opacity: 0.45; }

/* ==========================================================================
   Dark mode. Opt-in: activated by data-bs-theme="dark" on <html>, set by
   theme-toggle.js and the no-flash script in the <head>. The light site is
   completely untouched until a user flips the switch.

   Design keeps the site's identity — dark chrome, but now the CONTENT goes
   near-black too, and the navbar/footer chrome sits one step lighter and
   cooler so it still pops as a band above/below the page.
   ========================================================================== */
[data-bs-theme="dark"] {
    --page-bg: #0a0c0f;                 /* near-black content */
    --page-text: #e6e7e4;
    --page-text-muted: #968f85;
    --chrome-bg: #1b1f26;               /* navbar/footer: lighter + cooler -> pops */

    /* Bootstrap surface vars, so its own components (forms, tables, dropdowns,
       modals) follow the same near-black surfaces. */
    --bs-body-bg: #0a0c0f;
    --bs-body-color: #e6e7e4;
    --bs-secondary-bg: #14171b;
    --bs-tertiary-bg: #14171b;
    --bs-secondary-color: rgba(230, 231, 228, 0.62);
    --bs-tertiary-color: rgba(230, 231, 228, 0.5);
    --bs-border-color: rgba(255, 255, 255, 0.11);
    --bs-emphasis-color: #ffffff;

    /* The lego seam is the surface behind the bricks peeking through, so on the
       near-black page it's a dark gap — a white one reads as bright wiring
       strung between the pieces. */
    --lego-seam: 8, 10, 13;

    color-scheme: dark;
}

/* Gutters/edges stay near-black (the existing body rule points at --chrome-bg,
   which is now the lighter pop colour — override it back to the page here). */
[data-bs-theme="dark"] body { background-color: var(--page-bg); }

/* Cards/panels sit a step above the near-black page so they read as surfaces. */
[data-bs-theme="dark"] main .card {
    background-color: #14171b;
    border-color: rgba(255, 255, 255, 0.08);
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.45), 0 1px 2px rgba(0, 0, 0, 0.5);
}
[data-bs-theme="dark"] main .card-header {
    background-color: #1b1f26;
    border-bottom-color: rgba(255, 255, 255, 0.08);
    color: var(--page-text);
}

/* The theme switch in the navbar — a bare icon button that matches the nav links. */
#theme-toggle {
    border: 0;
    background: none;
    cursor: pointer;
    line-height: 1;
}
#theme-toggle:hover { color: var(--accent-400); }

/* --- Dark-mode coverage ---
   Custom light-content rules hardcode light surfaces or dark text/gold that
   were tuned for the near-white page. On near-black they read wrong, so each
   gets a dark equivalent here. */

/* Links: the light-mode dark-gold (accent-600/800) is unreadable on near-black;
   use the bright end of the accent scale instead. */
/* :not(.btn) is load-bearing — a button is an <a>, and this rule's specificity
   (0,1,2) otherwise beats .btn-primary's (0,1,0), painting the button text gold
   on a gold button. Links get gold; buttons keep their own text colour. */
[data-bs-theme="dark"] main a:not(.btn) { color: var(--accent-300); }
[data-bs-theme="dark"] main a:not(.btn):hover { color: var(--accent-200); }

/* Inputs were hardcoded white. */
[data-bs-theme="dark"] main .form-control,
[data-bs-theme="dark"] main .form-select {
    background-color: #14171b;
    border-color: rgba(255, 255, 255, 0.14);
    color: var(--page-text);
}
[data-bs-theme="dark"] main .form-control:focus,
[data-bs-theme="dark"] main .form-select:focus {
    background-color: #14171b;
    border-color: var(--accent-500);
    color: var(--page-text);
}

/* Surfaces that used light grays. */
[data-bs-theme="dark"] main .list-group-item {
    background-color: #14171b;
    border-color: rgba(255, 255, 255, 0.08);
}
[data-bs-theme="dark"] main .breadcrumb { background-color: #14171b; }
[data-bs-theme="dark"] main .table { --bs-table-border-color: rgba(255, 255, 255, 0.1); }

/* Pagination. */
[data-bs-theme="dark"] main .page-link {
    background-color: #14171b;
    border-color: rgba(255, 255, 255, 0.08);
    color: var(--accent-300);
}
[data-bs-theme="dark"] main .page-link:hover {
    background-color: #1b1f26;
    color: var(--accent-200);
}

/* Outline-secondary button: its dark-gray text/border vanish on the dark page. */
[data-bs-theme="dark"] .btn-outline-secondary {
    color: #cfd2d0;
    border-color: rgba(255, 255, 255, 0.22);
}
[data-bs-theme="dark"] .btn-outline-secondary:hover {
    background-color: rgba(255, 255, 255, 0.12);
    border-color: rgba(255, 255, 255, 0.3);
    color: #fff;
}

/* Dropdown menu: the light-mode warm-brown (gray-800) clashes with the cool
   near-black — match the chrome pop colour instead. */
[data-bs-theme="dark"] .dropdown-menu {
    --bs-dropdown-bg: #1b1f26;
    --bs-dropdown-border-color: rgba(255, 255, 255, 0.1);
    --bs-dropdown-link-color: var(--gray-50);
}

/* Faint inline-edit affordance used a mid-dark gray. */
[data-bs-theme="dark"] .admin-edit-toggle { color: var(--gray-400); }

/* Broad surface remap: the light neutrals (gray-50/100/200) are only ever
   backgrounds/borders inside the content — never text — so remap them to dark
   values SCOPED TO main. This flips every custom panel at once (forum list,
   wiki, veto map, tournament cards, row hovers, table stripes) without touching
   the navbar/footer/dropdown, which reuse the same names for LIGHT text and sit
   outside main. Fixes "light card + now-light text = invisible". */
[data-bs-theme="dark"] main {
    --gray-50: #14171b;
    --gray-100: #1b1f26;
    --gray-200: #2a2f38;
}

/* Prose subheadings: article h3 is a dark gold-gray and h4–h6 the muted body
   colour — both tuned for the light page and too faint on near-black. Lift them
   to readable light, keeping a touch of hierarchy (h3 full, h4–h6 softer). */
[data-bs-theme="dark"] article h2,
[data-bs-theme="dark"] article h3 { color: var(--accent-300); }   /* bright gold on near-black */
[data-bs-theme="dark"] article h4,
[data-bs-theme="dark"] article h5,
[data-bs-theme="dark"] article h6 { color: #c2c6c1; }

/* Components that hardcode #fff instead of a surface variable. */
[data-bs-theme="dark"] .tourn-round,
[data-bs-theme="dark"] .tourn-standings-card,
[data-bs-theme="dark"] .bracket-match,
[data-bs-theme="dark"] .td-team,
[data-bs-theme="dark"] .editron-wrap,
[data-bs-theme="dark"] .mention-suggest {
    background-color: #14171b;
    border-color: rgba(255, 255, 255, 0.1);
}

/* Permission / role / access ladder zones: the drop-zone body has no background
   and the border is translucent BLACK — both vanish on the near-black page, so
   the whole zone reads as just its coloured header bar. Give the body a dark
   card surface and a visible light border. The graded header bars are set inline
   and stay. */
[data-bs-theme="dark"] .perm-zone,
[data-bs-theme="dark"] .role-zone,
[data-bs-theme="dark"] .access-zone { border-color: rgba(255, 255, 255, 0.12); }
[data-bs-theme="dark"] .perm-zone-body,
[data-bs-theme="dark"] .role-zone-body,
[data-bs-theme="dark"] .access-zone-body { background-color: #14171b; }
[data-bs-theme="dark"] .role-zone-bar {
    background-color: rgba(255, 255, 255, 0.04);
    border-bottom-color: rgba(255, 255, 255, 0.1);
}

/* Widget board: no tray by design (bricks sit on the page), but the "black"
   section base plates sink into the near-black page. Give every base plate a
   faint inner ring so the dark ones keep an edge — colours (set inline) stay. */
[data-bs-theme="dark"] .lego-base-plate { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1); }

/* Section-coloured setting cards mix the section hue with WHITE — a near-white
   body and a pale tinted header, which become bright bands with faint text on
   the dark page. Re-mix against the dark card surface so each reads as a subtly
   section-tinted DARK card, title readable, the border-left stripe still the
   full section colour. */
[data-bs-theme="dark"] main .card[class*="section-"] {
    background-color: color-mix(in srgb, var(--section) 10%, #14171b);
}
[data-bs-theme="dark"] main .card[class*="section-"] > .card-header {
    background-color: color-mix(in srgb, var(--section) 22%, #14171b);
    border-bottom-color: color-mix(in srgb, var(--section) 45%, #14171b);
}

/* Connection rows. The row colours are set inline and carry over; what has to
   flip is everything mixed against white — the hover wash, the separators, and
   the two status colours, which at their light-mode values sit too dark to read
   on the near-black surface. The lamps keep their hue: a glowing dot is if
   anything easier to read here than in daylight. */
[data-bs-theme="dark"] .conn + .conn { border-top-color: rgba(255, 255, 255, 0.09); }
[data-bs-theme="dark"] .conn-head:hover { background-color: rgba(255, 255, 255, 0.05); }
[data-bs-theme="dark"] .conn-status[data-state="fail"] { color: #ff8a8a; }
[data-bs-theme="dark"] .conn-status[data-state="ok"]   { color: #7ede8b; }
[data-bs-theme="dark"] .conn-source { border-color: rgba(255, 255, 255, 0.2); }
[data-bs-theme="dark"] .conn-source--settings { border-color: #7ede8b; color: #7ede8b; }
/* ONLY the states that have no colour of their own.
   This was `[data-bs-theme="dark"] .conn-lamp`, which has the same specificity
   as `.conn-lamp[data-state="ok"]` and sits later in the file — so it won every
   tie and painted every lamp the same dim grey in dark mode, whatever the probe
   had answered. The lamps were correct and invisible, which is the worst of
   both: the page looked like it had checked nothing. */
[data-bs-theme="dark"] .conn-lamp[data-state="checking"],
[data-bs-theme="dark"] .conn-lamp[data-state="error"] {
    background-color: rgba(255, 255, 255, 0.2);
    border-color: rgba(255, 255, 255, 0.3);
}

[data-bs-theme="dark"] .conn-lamp[data-state="unset"] {
    background-color: transparent;
    border-color: rgba(255, 255, 255, 0.3);
}

/* Dashboard widget headers keep their colour in the dark.
   
   Each widget sets its own --w-accent, applied by a rule in the page's own
   style block — but `[data-bs-theme="dark"] main .card-header` is the more
   specific selector (attribute + element + class beats class + class), so in
   dark mode every header was repainted the same flat grey and the colour coding
   simply vanished. On a dashboard where colour is how you find a window at a
   glance, that is most of the point of it.
   
   Mixed slightly into the card surface rather than used raw: the palette was
   picked against a white page, and #0d6efd at full strength on near-black glares.
   One number to tune if they should be louder or quieter. */
[data-bs-theme="dark"] main .grid-stack-item-content .card-header {
    background: color-mix(in srgb, var(--w-accent, #1b1f26) 82%, #14171b);
    color: #fff;
}

/* The console widget marks itself with .border-danger, and Bootstrap's full red
   on a near-black page reads as an error rather than as a caution. Keep the
   warning, lose the shouting: same hue, enough of it to notice, not enough to
   pull the eye away from the board. */
[data-bs-theme="dark"] main .grid-stack-item-content .card.border-danger {
    border-color: color-mix(in srgb, var(--os-danger) 55%, transparent) !important;
}

/* Bootstrap 5.3 recomputes its alert colours for dark mode against a lighter
   dark than ours — on a #0a0c0f page they come out muddy, which matters more now
   that the dashboard's notices float as a toast over the content rather than
   sitting in the flow. Re-mix them against the card surface with a visible edge
   and light text, so a confirmation reads at a glance without shouting. */
[data-bs-theme="dark"] .alert-success {
    --bs-alert-color: #d6f5e2;
    --bs-alert-bg: color-mix(in srgb, var(--os-success) 26%, #14171b);
    --bs-alert-border-color: color-mix(in srgb, var(--os-success) 55%, #14171b);
}

[data-bs-theme="dark"] .alert-danger {
    --bs-alert-color: #ffdcdd;
    --bs-alert-bg: color-mix(in srgb, var(--os-danger) 30%, #14171b);
    --bs-alert-border-color: color-mix(in srgb, var(--os-danger) 58%, #14171b);
}

[data-bs-theme="dark"] .alert-warning {
    --bs-alert-color: #ffeaba;
    --bs-alert-bg: color-mix(in srgb, var(--accent-400) 22%, #14171b);
    --bs-alert-border-color: color-mix(in srgb, var(--accent-400) 50%, #14171b);
}

/* The dashboard toast sits on top of everything, so it earns a shadow the inline
   alerts do not need — without it a dark notice over a dark widget has no edge. */
[data-bs-theme="dark"] #dash-flash .alert {
    box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.6);
}

/* The toast container ignores clicks so it never blocks a widget underneath —
   but the alert itself, and its close button, must still be clickable. */
#dash-flash .alert {
    pointer-events: auto;
}

/* Footer column headings pop orange in dark. The footer is always dark chrome,
   so bright accent reads well here; light mode keeps its lighter headings.
   !important to beat Bootstrap's .text-light utility. */
[data-bs-theme="dark"] footer .footer-heading { color: var(--accent-400) !important; }

/* ---------------------------------------------------------------------------
   Träffkarta — the body diagram on a profile's stat tabs.
   Markup: templates/partials/traffkarta.php, driven by traffkarta.js.
   Three columns: weapons | operator + heatmap | zones. Everything is scoped
   under .tk so none of these short class names can leak into the rest of the
   site.
   --------------------------------------------------------------------------- */
.tk-grid {
    display: grid;
    /* Weapons | figure | zones + small arms | numbers.
       The first three are held to what their content needs — the operator art
       carries a wide empty margin of its own, so giving the figure a flexible
       column just spreads blank canvas. The slack goes to the numbers column,
       which is the one that can actually use more room. */
    grid-template-columns: 220px 380px 220px minmax(200px, 1fr) minmax(210px, 1fr);
    gap: 1.5rem;
    align-items: start;
}

@media (max-width: 1599px) {
    .tk-grid { grid-template-columns: 200px 340px 200px minmax(0, 1fr) minmax(0, 1fr); }
}

@media (max-width: 1399px) {
    .tk-grid { grid-template-columns: 200px minmax(0, 1fr) minmax(190px, 1fr) minmax(190px, 1fr); }
}

@media (max-width: 1199px) {
    /* Two columns before one: the figure keeps a partner rather than jumping
       straight to a single stacked column. */
    .tk-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 240px); }
    .tk-fig { order: -1; }
}

@media (max-width: 767px) {
    .tk-grid { grid-template-columns: minmax(0, 1fr); }
}

/* Column headings in accent, the same move the footer and article headings
   make: the card is five columns of grey numbers, and muted headings left the
   eye with nothing to navigate by. Light mode takes the darker accent step —
   accent-400 on white is legible but weak, and these are signposts. */
.tk-h {
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--accent-600);
    margin-bottom: 0.5rem;
}

[data-bs-theme="dark"] .tk-h { color: var(--accent-400); }

/* ---- weapon list ---- */
.tk-grp {
    /* --cat is the category colour, set inline by the JS. The tinted surface is
       what makes "this block is all pistols" readable at a glance. */
    background: color-mix(in srgb, var(--cat) 8%, transparent);
    border-left: 3px solid var(--cat);
    border-radius: 0.35rem;
    padding: 0.4rem 0.5rem;
    margin-bottom: 0.5rem;
}

.tk-grp-h {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--cat);
    margin-bottom: 0.25rem;
}

.tk-dot {
    width: 0.5rem;
    height: 0.5rem;
    border-radius: 50%;
    background: var(--cat);
}

.tk-w {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 3rem 2.5rem;
    align-items: center;
    gap: 0.5rem;
    padding: 0.15rem 0.25rem;
    border-radius: 0.25rem;
    cursor: pointer;
    font-size: 0.85rem;
    line-height: 1.4;
}

.tk-w:hover { background: color-mix(in srgb, var(--page-text) 8%, transparent); }

.tk-w.is-on {
    background: color-mix(in srgb, var(--accent-400) 22%, transparent);
    font-weight: 600;
}

.tk-w--all {
    grid-template-columns: minmax(0, 1fr) 2.5rem;
    margin-bottom: 0.5rem;
    border: 1px solid var(--gray-200);
}

.tk-wn { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.tk-wt {
    height: 0.3rem;
    border-radius: 0.15rem;
    background: color-mix(in srgb, var(--page-text) 12%, transparent);
    overflow: hidden;
}

.tk-wt > i { display: block; height: 100%; }

.tk-wu {
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-size: 0.75rem;
    color: var(--page-text-muted);
}

/* ---- figure: operator + heatmap ---- */
.tk-side {
    display: flex;
    gap: 0.25rem;
    margin-bottom: 0.75rem;
}

.tk-side button {
    flex: 1;
    padding: 0.3rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 600;
    border: 1px solid var(--gray-200);
    border-radius: 0.3rem;
    background: transparent;
    color: var(--page-text-muted);
}

.tk-side button[aria-pressed="true"] {
    background: var(--accent-400);
    border-color: var(--accent-400);
    color: var(--gray-900);
}

.tk-stage {
    position: relative;
    /* The canvas is absolutely positioned over the operator image, so the stage
       takes its height from the image alone. */
    line-height: 0;
}

.tk-stage img { width: 100%; height: auto; }

.tk-stage canvas {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* Drawn plain, NOT with mix-blend-mode. The LUT carries its own alpha ramp
       (transparent at 0, near-opaque at the ceiling), so ordinary compositing
       is what the engine was calibrated against. Screen blend looks fine over
       the body but erases the faint outer bloom against the operator art's
       light background — and that bloom reaching out past the body parts is
       the whole look. */
    pointer-events: none;
}

.tk-empty {
    position: absolute;
    inset: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.5rem;
    padding: 1rem;
    text-align: center;
    line-height: 1.5;
    font-size: 0.85rem;
    color: var(--page-text-muted);
    background: color-mix(in srgb, var(--page-bg) 78%, transparent);
}

.tk-empty .bi { font-size: 1.5rem; }

.tk-skins {
    display: flex;
    gap: 0.35rem;
    justify-content: center;
    margin-top: 0.5rem;
}

.tk-skins img {
    width: 2.5rem;
    height: 2.5rem;
    object-fit: cover;
    object-position: top;
    border-radius: 0.3rem;
    border: 2px solid transparent;
    cursor: pointer;
    opacity: 0.55;
}

.tk-skins img.is-on,
.tk-skins img:hover { opacity: 1; border-color: var(--accent-400); }

.tk-caption,
.tk-note {
    margin: 0.6rem 0 0;
    text-align: center;
    font-size: 0.8rem;
    line-height: 1.5;
    color: var(--page-text-muted);
}

.tk-legend {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-top: 0.5rem;
    font-size: 0.7rem;
    color: var(--page-text-muted);
}

.tk-bar {
    flex: 1;
    height: 0.4rem;
    border-radius: 0.2rem;
    /* Same stops as the engine's LUT — see traffkarta-heatmap.js. If one moves,
       move the other or the legend starts lying about the picture. */
    background: linear-gradient(to right, #000 0%, #1030d0 10%, #10c0d0 22%,
        #10c040 36%, #f0e000 50%, #f08000 62%, #e01010 75%, #fff 100%);
}

/* ---- zone bars + headline numbers ---- */
/* Zone rows sit in normal flow and never move: the JS holds them in a fixed
   anatomical order and only resizes the bars, so there is nothing to position. */
.tk-z {
    display: grid;
    grid-template-columns: 4rem minmax(0, 1fr) 3rem;
    align-items: center;
    gap: 0.5rem;
    padding: 0.2rem 0;
    font-size: 0.8rem;
}

.tk-zt > i,
.tk-duel-bar > i {
    transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1),
                background-color 0.45s ease;
}

/* Shortened, not removed — same reasoning as the heatmap's morph: this is a bar
   growing in place, and watching it move IS the information. Several
   environments report the preference without the user ever asking for it. */
@media (prefers-reduced-motion: reduce) {
    .tk-zt > i,
    .tk-duel-bar > i { transition-duration: 0.18s; }
}

.tk-zt {
    height: 0.45rem;
    border-radius: 0.25rem;
    background: color-mix(in srgb, var(--page-text) 12%, transparent);
    overflow: hidden;
}

.tk-zt > i { display: block; height: 100%; }

.tk-zv {
    text-align: right;
    font-variant-numeric: tabular-nums;
    color: var(--page-text-muted);
}

.tk-kpis {
    display: flex;
    gap: 0.75rem;
    margin-top: 1rem;
}

.tk-kpi {
    flex: 1;
    padding: 0.6rem;
    text-align: center;
    border-radius: 0.4rem;
    background: color-mix(in srgb, var(--page-text) 6%, transparent);
}

.tk-kv {
    font-size: 1.35rem;
    font-weight: 700;
    line-height: 1.2;
    font-variant-numeric: tabular-nums;
}

.tk-kl {
    font-size: 0.7rem;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--page-text-muted);
}

/* Rounded plate behind the operator art. The colour only shows while the image
   is still loading — the art itself is opaque. */
.tk-stage {
    background: color-mix(in srgb, var(--page-text) 6%, transparent);
    border-radius: 0.5rem;
    overflow: hidden;
}

/* Träffkarta, two-column layout: the numbers stack in the narrow column, the
   figure takes what's left. Capped so it doesn't become a billboard on a wide
   screen — the operator art is 1086px, and past that it only gets softer. */
.tk-fig {
    /* Kept compact on purpose: the art has a wide empty margin baked in, so a
       larger figure mostly buys blank pixels. */
    max-width: 400px;
    margin-inline: auto;
}

.tk-h--gap { margin-top: 1.5rem; }

/* The KPI pair closes the numbers block before the weapon list starts. */
.tk-panel .tk-kpis { margin-top: 0.75rem; }

/* The arsenal preview shown when a player has no hit data yet: readable enough
   to say "these are the weapons", quiet enough not to look like real numbers. */
.tk-grp--ghost { opacity: 0.45; }
.tk-w--ghost { cursor: default; }
.tk-w--ghost:hover { background: none; }

/* Profile tabs carry a second line saying what the tab actually shows —
   "Defensiv" alone doesn't tell you it's where you get hit. */
.nav-tabs .nav-link .tab-sub {
    display: block;
    font-size: 0.72rem;
    font-weight: 400;
    line-height: 1.3;
    opacity: 0.7;
}

/* Träffkarta numbers column: label left, value right, one line each. */
.tk-stats { margin: 0; }

.tk-stat {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 0.5rem;
    padding: 0.3rem 0;
    border-bottom: 1px solid color-mix(in srgb, var(--page-text) 10%, transparent);
    font-size: 0.85rem;
}

.tk-stat:last-child { border-bottom: 0; }
.tk-stat dt { font-weight: 400; color: var(--page-text-muted); }

.tk-stat dd {
    margin: 0;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
}

/* The damage breakdown reuses the zone-bar rows but sits in a narrower column,
   so its label track can be tighter. */
.tk-zones--dmg .tk-z { grid-template-columns: 3.6rem minmax(0, 1fr) 2.9rem; }

/* Duel top-list: rank, who, a bar relative to the leader, kills, headshot share.

   The LIST is the grid and each row is `display: contents`, so all five columns
   are shared by every row. With a grid per row they would only line up while
   every value happened to be the same width — the kill count is the one that
   grows without limit, and a long rivalry reaching four digits would have
   knocked one row's columns out of step with the rest. Sharing the grid also
   lets the number column size itself to the widest value instead of being given
   a guessed width that eventually clips. */
.tk-duels {
    display: grid;
    grid-template-columns: 1.5rem minmax(0, 1fr) minmax(1.5rem, 2.5rem) auto 2.6rem;
    align-items: center;
    column-gap: 0.4rem;
    list-style: none;
    margin: 0;
    padding: 0;
}

.tk-duel { display: contents; }

.tk-duel > * {
    padding: 0.3rem 0;
    border-bottom: 1px solid color-mix(in srgb, var(--page-text) 10%, transparent);
    font-size: 0.82rem;
}

.tk-duel:last-child > * { border-bottom: 0; }

.tk-duel-rank {
    font-weight: 700;
    color: var(--page-text-muted);
    font-variant-numeric: tabular-nums;
    text-align: right;
}

.tk-duel-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-weight: 600;
}

/* Someone with no account here: shown, but visibly not one of us. */
.tk-duel-name[data-unknown] {
    font-weight: 400;
    font-variant-numeric: tabular-nums;
    color: var(--page-text-muted);
}

.tk-duel-bar {
    /* A grid item, so it needs its own height rather than inheriting a row's. */
    height: 0.35rem;
    align-self: center;
    border-radius: 0.2rem;
    background: color-mix(in srgb, var(--page-text) 12%, transparent);
    overflow: hidden;
    padding: 0;
    border-bottom: 0;
}

.tk-duel-bar > i {
    display: block;
    height: 100%;
    background: var(--accent-400);
}

.tk-duel-n,
.tk-duel-hs {
    text-align: right;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.tk-duel-n { font-weight: 600; }
.tk-duel-hs { color: var(--page-text-muted); font-size: 0.75rem; }

.tk-duel-none {
    grid-column: 1 / -1;
    padding: 0.4rem 0;
    font-size: 0.8rem;
    color: var(--page-text-muted);
}

/* Duel balance: kills, a diverging bar (losses left of centre, wins right),
   deaths, and the margin. Same shared-grid trick as the top list so the columns
   line up across rows however wide the numbers get. */
.tk-duels--balance {
    grid-template-columns: minmax(0, 1fr) auto minmax(3rem, 4.5rem) auto 2.4rem;
}

.tk-bal { display: contents; }

.tk-bal > * {
    padding: 0.3rem 0;
    border-bottom: 1px solid color-mix(in srgb, var(--page-text) 10%, transparent);
    font-size: 0.82rem;
}

.tk-bal:last-child > * { border-bottom: 0; }

/* A 32-character nick in a ~130px column: clip it and let the title carry the
   rest, rather than letting one long name push the numbers out of the card. */
.tk-bal-name,
.tk-duel-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

.tk-bal-name { font-weight: 600; }
.tk-bal-name[data-unknown] { font-weight: 400; color: var(--page-text-muted); }

.tk-bal-k,
.tk-bal-d,
.tk-bal-diff {
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.tk-bal-k { text-align: right; font-weight: 600; }
.tk-bal-d { text-align: left; color: var(--page-text-muted); }

/* Two fixed halves rather than a centred flex row: with flex the meeting point
   drifts whenever the two sides differ, which is exactly when you're reading it.
   A grid pins the centre line so it always marks where the balance tips. */
.tk-bal-bar {
    display: grid;
    grid-template-columns: 1fr 1fr;
    align-items: center;
    gap: 1px;
    height: 0.35rem;
    align-self: center;
    padding: 0;
    border-bottom: 0;
}

.tk-bal-bar > i {
    display: block;
    height: 100%;
    border-radius: 0.2rem;
    transition: width 0.45s cubic-bezier(0.2, 0.8, 0.2, 1);
}

.tk-bal-lose { justify-self: end; }
.tk-bal-win { justify-self: start; }

.tk-bal-lose { background: var(--os-danger); }
.tk-bal-win { background: var(--os-success); }

.tk-bal-diff { text-align: right; font-weight: 700; }
.tk-bal-diff[data-sign="up"] { color: var(--os-success); }
.tk-bal-diff[data-sign="down"] { color: var(--os-danger); }
.tk-bal-diff[data-sign="even"] { color: var(--page-text-muted); }

/* Operator picker on the profile edit form: small radios on the left drawn as
   the art itself, a large live preview of both current picks on the right. Two
   sizes because they answer different questions — the thumbnails are "which
   ones are there", the preview is "what will my profile look like". */
.operator-pick {
    display: flex;
    flex-wrap: wrap;
    gap: 1.5rem 2.5rem;
    /* Centred rather than top-aligned: the previews are half again as tall as
       the two thumbnail rows, and hanging the picker off the top left it looking
       dropped rather than placed. */
    align-items: center;
}

.operator-cols {
    display: flex;
    flex-direction: column;
    gap: 1rem;
}

.operator-side {
    border: 0;
    padding: 0;
    margin: 0;
}

.operator-side legend {
    float: none;
    width: auto;
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--page-text-muted);
    margin-bottom: 0.4rem;
}

.operator-side > div {
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.operator-opt { display: inline-block; cursor: pointer; }

/* Hidden but still focusable and still the accessible control — the image is
   only its skin, so keyboard and screen readers keep working. */
.operator-opt input {
    position: absolute;
    opacity: 0;
    pointer-events: none;
}

/* `contain`, not `cover`: the four differ by gear and stance, and cropping threw
   away exactly what you choose between. */
.operator-opt img {
    width: 3.6rem;
    height: 4.8rem;
    object-fit: contain;
    background: color-mix(in srgb, var(--page-text) 6%, transparent);
    border: 2px solid transparent;
    border-radius: 0.35rem;
    opacity: 0.5;
    transition: opacity 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}

.operator-opt:hover img { opacity: 0.9; transform: translateY(-2px); }

.operator-opt input:checked + img {
    opacity: 1;
    border-color: var(--accent-400);
}

.operator-preview {
    display: flex;
    gap: 1rem;
}

.operator-preview figure {
    margin: 0;
    text-align: center;
}

/* Sized to the art's own 1086:1448, so the figure fills the box instead of
   floating in letterbox space. Shrinks with the viewport rather than forcing the
   form wide on a narrow screen. */
.operator-preview img {
    width: min(15rem, 36vw);
    aspect-ratio: 1086 / 1448;
    height: auto;
    object-fit: contain;
    background: color-mix(in srgb, var(--page-text) 6%, transparent);
    border-radius: 0.45rem;
}

.operator-preview figcaption {
    margin-top: 0.35rem;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--page-text-muted);
}

.operator-opt input:focus-visible + img {
    outline: 2px solid var(--accent-400);
    outline-offset: 2px;
}

/* Status card, full-width page: facts left, nick history right. */
.profile-facts { flex: 1 1 24rem; min-width: 0; }

.card-body:has(> .profile-aliases) {
    display: flex;
    flex-wrap: wrap;
    gap: 1.5rem 2.5rem;
    align-items: flex-start;
}

.profile-aliases { flex: 0 1 20rem; min-width: 0; }

.profile-aliases ul {
    list-style: none;
    margin: 0;
    padding: 0;
}

.profile-aliases li {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 0.75rem;
    padding: 0.25rem 0;
    border-bottom: 1px solid color-mix(in srgb, var(--page-text) 10%, transparent);
}

.profile-aliases li:last-child { border-bottom: 0; }

/* Nicks are up to 190 chars from the server and are NOT the account name, so
   they get no width guarantees — clip rather than let one push the card wide. */
.profile-alias-name {
    font-weight: 600;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.profile-alias-when {
    flex: none;
    font-size: 0.78rem;
    color: var(--page-text-muted);
}

/* The verdict block: a conclusion, not a list, so it gets a plate of its own
   rather than another row of numbers. */
.tk-verdict {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    margin-bottom: 1rem;
}

.tk-verdict-row {
    padding: 0.5rem 0.6rem;
    border-radius: 0.4rem;
    border-left: 3px solid var(--verdict);
    background: color-mix(in srgb, var(--verdict) 10%, transparent);
}

.tk-verdict-row[data-kind="nemesis"] { --verdict: var(--os-danger); }
.tk-verdict-row[data-kind="prey"] { --verdict: var(--os-success); }

.tk-verdict-label {
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--verdict);
}

.tk-verdict-name {
    font-weight: 700;
    font-size: 1rem;
    line-height: 1.3;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.tk-verdict-line {
    font-size: 0.78rem;
    font-variant-numeric: tabular-nums;
}

.tk-verdict-line span { color: var(--page-text-muted); }

/* Why this person got the title. Each factor is a named bar, so the verdict can
   be argued with rather than just believed. */
.tk-factors {
    list-style: none;
    margin: 0.5rem 0 0;
    padding: 0;
    display: grid;
    gap: 0.2rem;
}

.tk-factors li {
    display: grid;
    grid-template-columns: 5.6rem 2.5rem minmax(0, 1fr);
    align-items: center;
    gap: 0.4rem;
    font-size: 0.72rem;
}

.tk-f-label { color: var(--page-text-muted); }

.tk-f-bar {
    height: 0.3rem;
    border-radius: 0.15rem;
    background: color-mix(in srgb, var(--page-text) 12%, transparent);
    overflow: hidden;
}

.tk-f-bar > i {
    display: block;
    height: 100%;
    background: var(--verdict);
}

.tk-f-text {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Knife/taser lists sit under the figure, which is narrower than the columns
   the duel lists were designed for — tighten the rank and trailing columns so
   the names keep their room. */
.tk-hum .tk-duels {
    grid-template-columns: 1.3rem minmax(0, 1fr) minmax(1.5rem, 2.2rem) auto 2.4rem;
}

/* The period the numbers cover, under the legend. Quiet — it's context, not a
   statistic. */
.tk-coverage {
    margin: 0.4rem 0 0;
    text-align: center;
    font-size: 0.75rem;
    color: var(--page-text-muted);
}

/* Season-vs-earlier trend arrows beside the current figure. */
.tk-form dd { display: flex; align-items: baseline; gap: 0.4rem; justify-content: flex-end; }

.tk-trend {
    font-size: 0.72rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}

.tk-trend[data-dir="up"] { color: var(--os-success); }
.tk-trend[data-dir="down"] { color: var(--os-danger); }
.tk-trend[data-dir="flat"] { color: var(--page-text-muted); }

/* A row that arrived while the mail log was open — fades from a soft highlight
   so a live-appearing entry is noticed rather than silently slotting in at the
   top. Theme-aware: the tint reads on both light and dark. */
@keyframes mail-row-new {
    from { background-color: rgba(224, 122, 63, 0.28); }
    to   { background-color: transparent; }
}
.mail-row-new td {
    animation: mail-row-new 2s ease-out;
}

/* The verification address, styled to be read character by character. The
   worst email typo is one to a real other address: it delivers, never bounces,
   and the only way to catch it is to actually read every character — so this is
   monospace (rn is not m, l is not 1, 0 is not O), spaced out, high-contrast,
   and large. See templates/pages/auth/verify-email.php. */
.verify-address {
    background: var(--bs-tertiary-bg);
    border-color: var(--bs-border-color) !important;
}
.verify-address-text {
    font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
    font-size: 1.35rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    line-height: 1.4;
    color: var(--bs-emphasis-color);
}

/* Mail journey stepper — Queued -> Sent -> Delivered. Driven by
   verify-email-status.js (and reused by any surface that follows a mail's
   progress). All colours come from Bootstrap theme vars, so it tracks light and
   dark without a second ruleset. See templates/pages/auth/verify-email.php. */
.mail-progress {
    gap: 0;
}
.mail-progress-step {
    position: relative;
    flex: 1 1 0;
    min-width: 0;
    text-align: center;
}
/* The connector into this step (from the one before it). The first has none. */
.mail-progress-step::before {
    content: "";
    position: absolute;
    top: 0.5rem;
    left: -50%;
    width: 100%;
    height: 2px;
    background: var(--bs-border-color);
    transition: background-color 0.3s ease;
    z-index: 0;
}
.mail-progress-step:first-child::before {
    display: none;
}
.mail-progress-dot {
    position: relative;
    z-index: 1;
    display: block;
    width: 1rem;
    height: 1rem;
    margin: 0 auto 0.35rem;
    border-radius: 50%;
    background: var(--bs-secondary-bg);
    border: 2px solid var(--bs-border-color);
    transition: background-color 0.3s ease, border-color 0.3s ease;
}
.mail-progress-label {
    font-size: 0.75rem;
    color: var(--bs-secondary-color);
}
/* Reached — the segment leading in has been travelled. */
.mail-progress-step.is-done::before,
.mail-progress-step.is-active::before,
.mail-progress-step.is-fail::before {
    background: var(--bs-success);
}
.mail-progress-step.is-done .mail-progress-dot {
    background: var(--bs-success);
    border-color: var(--bs-success);
}
/* Active — the step in flight, gently pulsing so it reads as "working". */
.mail-progress-step.is-active .mail-progress-dot {
    background: var(--bs-primary);
    border-color: var(--bs-primary);
    animation: mail-progress-pulse 1.4s ease-in-out infinite;
}
.mail-progress-step.is-active .mail-progress-label {
    color: var(--bs-primary);
    font-weight: 600;
}
/* Dead end — the dot goes red where the journey stopped. */
.mail-progress-step.is-fail .mail-progress-dot {
    background: var(--bs-danger);
    border-color: var(--bs-danger);
}
.mail-progress-step.is-fail .mail-progress-label {
    color: var(--bs-danger);
    font-weight: 600;
}
@keyframes mail-progress-pulse {
    0%, 100% { box-shadow: 0 0 0 0 rgba(var(--bs-primary-rgb), 0.5); }
    50%      { box-shadow: 0 0 0 0.35rem rgba(var(--bs-primary-rgb), 0); }
}
@media (prefers-reduced-motion: reduce) {
    .mail-progress-step.is-active .mail-progress-dot {
        animation: none;
    }
}
/* Inline variant — one stepper per row in the bulk hand-off modal, labels beside
   it (in the row) rather than under each dot. Fixed width so the dots line up
   down a long recipient list. Sized up from the first cut: the dots are the
   thing the eye tracks, so they earn the room. */
.mail-progress-inline {
    width: 6rem;
    flex: 0 0 auto;
}
.mail-progress-inline .mail-progress-dot {
    width: 0.95rem;
    height: 0.95rem;
    margin-bottom: 0;
}
.mail-progress-inline .mail-progress-step::before {
    top: 0.4rem;
    height: 3px;
}

/* Contextual table rows, made theme-aware. Bootstrap's .table-danger /
   .table-warning (and friends) are fixed pale colours that stay light under
   data-bs-theme=dark — a pink or yellow row on a near-black page, which is what
   the mail log and the event "Reserves" divider showed. The -subtle /
   -text-emphasis / -border-subtle vars ARE redefined per theme, so pointing the
   table vars at them makes the same row read right in both. Dark is the default
   theme here, so this is the common case, not an edge. State vars follow the
   base so a hover or stripe doesn't fall back to the fixed pale. */
.table-danger {
    --bs-table-color: var(--bs-danger-text-emphasis);
    --bs-table-bg: var(--bs-danger-bg-subtle);
    --bs-table-border-color: var(--bs-danger-border-subtle);
    --bs-table-striped-bg: var(--bs-danger-bg-subtle);
    --bs-table-active-bg: var(--bs-danger-bg-subtle);
    --bs-table-hover-bg: var(--bs-danger-bg-subtle);
}
.table-warning {
    --bs-table-color: var(--bs-warning-text-emphasis);
    --bs-table-bg: var(--bs-warning-bg-subtle);
    --bs-table-border-color: var(--bs-warning-border-subtle);
    --bs-table-striped-bg: var(--bs-warning-bg-subtle);
    --bs-table-active-bg: var(--bs-warning-bg-subtle);
    --bs-table-hover-bg: var(--bs-warning-bg-subtle);
}

/* The map tile's "make it bigger" button.
   Bottom-left, away from the hub label and the clock that some layouts put in
   the opposite corner. Only rendered where the map is small enough to need it. */
.conn-map-expand {
    position: absolute;
    left: .4rem;
    bottom: .4rem;
    z-index: 4;
    display: inline-flex;
    align-items: center;
    gap: .35rem;
    padding: .25rem .55rem;
    font-size: .75rem;
    line-height: 1;
    border: 1px solid rgba(255, 255, 255, .35);
    border-radius: .3rem;
    background: rgba(0, 0, 0, .6);
    color: #fff;
    /* Was .55 with no words on it, which is how a control becomes something you
       find by accident. It says what it does now, and stays legible. */
    opacity: .85;
    transition: opacity .15s ease, background-color .15s ease;
}

.conn-map-expand:hover,
.conn-map-expand:focus-visible { opacity: 1; background: rgba(0, 0, 0, .8); }

/* No point offering "bigger" on the map that is already the whole modal. */
.conn-map--large .conn-map-expand { display: none; }

/* The written-out list under the enlarged map. Capped so a busy evening scrolls
   inside the modal rather than pushing the map off the top of the screen. */
.conn-map-roster {
    max-height: 22vh;
    overflow-y: auto;
}

/* Dot labels: written only on the enlarged map. Fifteen of them across southern
   Sweden at tile size is porridge; at modal size they are the point. */
.conn-map .dot-labels { display: none; }
.conn-map--large .dot-labels { display: block; }

/* The standing, above the two columns it belongs to.
   Sized off the widget rather than fixed: this card is resizable, and a score
   that stayed 1rem tall while the roster grew would stop being the first thing
   the eye lands on. */
.players-score-ct,
.players-score-t {
    font-weight: 700;
    font-size: 1.6rem;
    line-height: 1;
    min-width: 2ch;
    text-align: center;
}

.players-score-ct { color: #4a90d9; }
.players-score-t  { color: #d9a441; }

/* Pan and zoom, offered only where there is room to use them. The grab cursor
   is the whole affordance — nothing else says the map can be dragged. */
.conn-map--large .conn-map-svg { cursor: grab; touch-action: none; }

.conn-map-reset {
    position: absolute;
    right: 1rem;
    bottom: .75rem;
    z-index: 5;
    opacity: .85;
}

.conn-map-reset:hover { opacity: 1; }

/* Server info inside the Players card.
   The block was built to fill a window of its own: its root is h-100 and the
   scoreboard is flex:1 with container-type:size, so the digits scale off the
   height it is given. Dropped into a card that states no height, there is
   nothing to fill — the score collapsed to a sliver and painted over the CT/T
   header below it.
   So the embedded copy gets a definite height. Generous rather than tight: the
   digits are 46cqh of it, and this is the number the room is looking at. */
.players-info { height: 7.5rem; }
.players-info > * { height: 100%; }

/* The map gets the whole line the server's name used to take — its own name is
   the page heading already — and the digits get the height that frees up. */
.players-info .si-score { padding-top: .1rem; }

/* Kills / assists / deaths on a roster row.
   Fixed-width, tabular columns so the three numbers line up down the list —
   ragged digits are what makes a scoreboard read as a jumble. Kills carries the
   weight because it is the one being compared; the other two are context. */
.player-kad {
    display: inline-flex;
    gap: .35rem;
    font-size: .72rem;
    font-variant-numeric: tabular-nums;
    color: var(--bs-secondary-color);
    white-space: nowrap;
}

/* Two digits wide, always. A deaths count that reaches double figures used to
   widen its cell and shove the whole block leftward, so the three columns
   wandered from row to row — the exact thing tabular-nums is meant to prevent,
   undone by a min-width that was too narrow to hold the number. */
.player-kad > * { min-width: 2ch; text-align: right; flex: none; }
.player-kad > b { color: var(--bs-body-color); font-weight: 600; }

/* Change-map feedback, driven by htmx's own request class.
   The line and the spinner appear because a request is in flight — not because
   a listener of ours fired. A press that answers only when the JavaScript
   behaved is a press that sometimes does not answer. */
.map-status { display: none; color: var(--bs-secondary-color); }
.map-spinner { display: none; width: .75rem; height: .75rem; border-width: .15em; margin-right: .25rem; }

[data-map-form].htmx-request .map-status,
[data-map-form].htmx-request .map-spinner { display: inline-block; }
[data-map-form].htmx-request .map-status { display: block; }

/* A disabled button that keeps the primary colour reads as "still clickable".
   Dim it, so the state is visible rather than inferred from a cursor. */
[data-map-form] [data-map-button]:disabled { opacity: .65; }

/* Moderation modal, four panes.

   The colour is the interface. An admin arrives mid-round with somebody
   griefing and needs to land on the right control without reading a word, so
   each pane is its own coloured surface and it is always the same hue in the
   same corner. You learn "red, top left" once; after that you are aiming, not
   reading.

   Which means nothing INSIDE a pane may compete with that. Every button in the
   modal is the same size and the same shape — no fills, no btn-lg, no
   full-width slabs — and the only visual difference between any two of them is
   which pane they are sitting in.

   A hue that identifies, never one that judges the person. Red marks the pane
   you cannot undo by waiting; the rest are ordinary tools. */
.mod-group {
    --section: var(--gray-500);
    --section-wash: rgba(0, 0, 0, .03);
    border: 1px solid var(--section);
    border-radius: .4rem;
    background: var(--section-wash);
    overflow: hidden;
    display: flex;
    flex-direction: column;
}

/* The band, not a label: solid colour edge to edge, so the pane is identified
   before anything in it is read. */
.mod-group-title {
    background: var(--section);
    color: #fff;
    font-size: .8rem;
    font-weight: 600;
    letter-spacing: .02em;
    padding: .35rem .4rem .35rem .7rem;
    margin: 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: .5rem;
}

/* The detail lives behind an "i" rather than under every button.
   What a slay does, or why gagging a shouter accomplishes nothing, is worth
   saying once and reading once — it is not worth saying on the screen an admin
   is using in a hurry, every time. */
.mod-info {
    border: 0;
    background: transparent;
    color: #fff;
    opacity: .75;
    padding: 0 .2rem;
    line-height: 1;
    border-radius: .2rem;
}

.mod-info:hover, .mod-info[aria-expanded="true"] { opacity: 1; }

.mod-help {
    font-size: .75rem;
    line-height: 1.4;
    opacity: .85;
    border-left: 2px solid var(--section);
    padding-left: .5rem;
}

.mod-group > :not(.mod-group-title) { margin-left: .7rem; margin-right: .7rem; }
.mod-group > :not(.mod-group-title):first-of-type { margin-top: .7rem; }
.mod-group > :last-child { margin-bottom: .7rem; }

/* Settings and buttons meet in the middle of the pane.
   They used to be pinned to opposite ends, which lined the four button rows up
   neatly and left a hole in the middle of every pane — and put the thing you
   press a long way from the thing you set. One block, centred, so a pane reads
   as one decision. */
.mod-group > :not(.mod-group-title):not(.mod-help):not(.mod-group-note) {
    margin-top: auto;
    margin-bottom: auto;
}

.mod-actions {
    display: flex;
    flex-wrap: wrap;
    gap: .4rem;
    padding-top: .7rem;
}

/* Length, then reason, then the box for a reason of your own — top to bottom in
   the order you fill them, each the full width of the pane. Side by side they
   were two half-width dropdowns showing half a sentence each. */
.mod-fields {
    display: flex;
    flex-direction: column;
    gap: .4rem;
}

/* Two controls that share a pane but not a decision — mute and gag run for the
   same length and are silenced for different things, so the length is one row
   above and the reason sits on a line with the button it belongs to.
   Stacked, mute over gag: side by side gave each half the width and a dropdown
   reading "Spamming voice cha". */
.mod-split {
    display: flex;
    flex-direction: column;
    /* Wider than the gap inside a block, so the two read as two things. */
    gap: .7rem;
}

/* Reason above its button, the same shape as every other pane — there is room
   for it now, and beside the dropdown the button was competing with it for the
   width. */
.mod-split-row {
    display: flex;
    flex-direction: column;
    gap: .4rem;
    align-items: flex-start;
}

.mod-split-row > select { width: 100%; }

.mod-group--ban    { --section: #a33232; --section-wash: rgba(163, 50, 50, .07); }
.mod-group--warn   { --section: #b08900; --section-wash: rgba(176, 137, 0, .09); }
.mod-group--remove { --section: #6f42c1; --section-wash: rgba(111, 66, 193, .07); }
.mod-group--quiet  { --section: #2b7a9e; --section-wash: rgba(43, 122, 158, .07); }

/* One button, everywhere. It wears its pane's colour and fills on hover, which
   is the whole of its vocabulary. */
.mod-btn {
    border: 1px solid var(--section);
    border-radius: .3rem;
    background: transparent;
    color: var(--section);
    font-size: .8125rem;
    font-weight: 500;
    line-height: 1.4;
    padding: .25rem .7rem;
}

.mod-btn:hover:not(:disabled),
.mod-btn:focus-visible:not(:disabled) {
    background: var(--section);
    color: #fff;
}

/* Disabled has to read as "not for this player", not as "broken". Dimmed, and
   the pane says why in words — see .mod-group-note. */
.mod-btn:disabled { opacity: .35; }
.mod-group .form-select:disabled, .mod-group .form-control:disabled { opacity: .45; }

[data-bs-theme="dark"] .mod-group { --section-wash: rgba(255, 255, 255, .04); }
[data-bs-theme="dark"] .mod-group--ban    { --section-wash: rgba(163, 50, 50, .18); }
[data-bs-theme="dark"] .mod-group--warn   { --section-wash: rgba(176, 137, 0, .18); }
[data-bs-theme="dark"] .mod-group--remove { --section-wash: rgba(111, 66, 193, .18); }
[data-bs-theme="dark"] .mod-group--quiet  { --section-wash: rgba(43, 122, 158, .18); }

/* A pane with nothing it can do to THIS player. Dimmed as a whole rather than
   control by control, so it reads as one thing being unavailable instead of
   several things being broken — and left in place, because a pane that
   disappears takes its explanation with it. */
.mod-group--na { opacity: .5; }

.mod-group-note {
    font-size: .75rem;
    color: var(--section);
    font-weight: 500;
}

/* FACEIT context, above the panes.

   A line, not a box. It was a box, and stacked under the "this is you" alert
   it made three framed things to get past before reaching a single control —
   for what is one sentence of context. Two orange blocks side by side (the tag
   and a level badge) read as one smeared blob on top of that.

   So: one orange chip to say whose numbers these are, everything else plain
   text on one line. The exception earns the box — a FACEIT ban gets the red
   frame, and gets it alone, which is what makes it register. */
.faceit-panel {
    --brand: #ff5500;
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: .4rem;
    font-size: .82rem;
    color: var(--page-text-muted);
}

/* The label, so the numbers after it belong to something. */
.faceit-tag {
    background: var(--brand);
    color: #fff;
    font-size: .65rem;
    font-weight: 700;
    letter-spacing: .06em;
    padding: .1rem .35rem;
    border-radius: .22rem;
    flex: 0 0 auto;
    position: relative;
    top: -.05rem;
}

/* The level is what an admin reads first, so it is the one word carrying
   weight — bold text rather than a second coloured block. */
.faceit-panel b { color: var(--page-text); font-weight: 600; }

.faceit-panel .faceit-sep { opacity: .4; }

/* A ban is the one thing here worth interrupting for, and it is the only thing
   that gets a frame. */
.faceit-panel--banned {
    border: 1px solid #a33232;
    background: rgba(163, 50, 50, .08);
    border-radius: .4rem;
    padding: .45rem .6rem;
}

.faceit-panel--banned .faceit-ban { color: #a33232; font-weight: 600; flex-basis: 100%; }

[data-bs-theme="dark"] .faceit-panel--banned { background: rgba(163, 50, 50, .18); }
[data-bs-theme="dark"] .faceit-panel--banned .faceit-ban { color: #f0a0a0; }

/* The four panes, two by two, filling the modal between them.
   Fixed columns and equal rows rather than flex-grow: a pane that moved or
   resized when another went quiet would cost the admin the muscle memory this
   colour scheme exists to build. */
.mod-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    grid-auto-rows: 1fr;
    gap: .75rem;
    flex: 1 1 auto;
}

/* Roughly square, and all four the same.
   Not aspect-ratio: 1 — on a wide modal that would make each pane as tall as it
   is wide and the grid taller than any screen. A floor instead, sized so the
   panes read as four squares at the width the modal actually opens at. */
.mod-group { min-height: 17rem; }

#moderate-modal .modal-body { display: flex; flex-direction: column; }

@media (max-width: 575.98px) {
    .mod-grid { grid-template-columns: minmax(0, 1fr); }
}

/* Per-field provenance on the connection forms.
   Small and beside the label, because it is read once — when somebody is
   deciding whether a line in .env is still load-bearing — and never again. */
.conn-field-src {
    display: block;
    font-size: .7rem;
    line-height: 1.2;
    margin-bottom: .15rem;
}

/* The archive surface in the forum builder.
   Visibly a different place to put something, without being a different kind
   of thing: same bricks, same drag, just a shelf further down. Dashed because
   an empty one still has to read as a target. */
.lego-menu-heading {
    margin-top: 1rem;
    padding: .35rem .6rem;
    font-size: .8rem;
    font-weight: 600;
    color: var(--page-text-muted);
}

.lego-menu-heading .small { opacity: .75; }

.lego-menu--archive {
    border: 1px dashed var(--gray-400);
    border-radius: .4rem;
    min-height: 3.5rem;
    background: rgba(0, 0, 0, .02);
}

[data-bs-theme="dark"] .lego-menu--archive { background: rgba(255, 255, 255, .03); }
