/* Canvas + layout helpers, and the theme tokens everything else reads.
 *
 * The chrome used to be a Twitch/Discord skin — violet #9147ff, Inter, 8-10px
 * rounded cards, blurred translucency — bolted onto a game whose world-facing
 * HUD (zone banner, loot window, tooltips, minimap) was already gold-on-dark
 * with Georgia serif. Two visual languages, and the modern one owned every
 * surface a new player met first.
 *
 * So the gold idiom was promoted into :root and the chrome brought to it.
 * The palette is forged rather than printed: aged iron grounds, oiled wood
 * panels, tarnished brass edges, gold leaf for anything the player owns,
 * oxblood for anything that can hurt them. Edges are hard — 1px brass over an
 * inner bevel, with corner brackets standing in for the border-radius that got
 * removed. Nothing here is round; a 2-3px radius is the most any panel gets,
 * and only so the brass does not read as a pixel-stair.
 */

/* Geist variable fonts (nameplates). Weight range spans the variable axis. */
@font-face {
    font-family: 'Geist';
    src: url('assets/fonts/GeistVF.woff') format('woff');
    font-weight: 100 900;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: 'Geist Mono';
    src: url('assets/fonts/GeistMonoVF.woff') format('woff');
    font-weight: 100 900;
    font-style: normal;
    font-display: swap;
}

:root {
    --bottom-nav-height: 40px;
    /* Shared bottom-stack rhythm. Hotbar and left-hand docks share the band
       just above the nav; horizontal --hotbar-gutter keeps them from colliding.
       One gap token from nav to that band so the strips cannot drift. */
    --bottom-nav-inset: 0.5rem;
    --bottom-stack-gap: 0.5rem;
    --hotbar-slot-size: 46px;
    /* Fallback: 6×46px slots + 5×6px gaps. ability_bar.js publishes the
       measured width so a future slot-count change cannot desync the docks. */
    --hotbar-width: 306px;
    /* Horizontal air between left-hand docks and the centred hotbar. */
    --hotbar-gutter: 1rem;
    /* THE CARD BAND, and the direction of the dependency is the whole design.
     *
     * It used to run cards-first: the gap was a constant 236px, and the dock
     * column below shrank to clear whatever the cards took (--card-island-max).
     * That is backwards. The cards are two nameplates; the column holds Chat,
     * the Bag and the Character sheet, and it is the thing that actually needs
     * the room. So the column takes its width first and the CARDS yield.
     *
     * The gap is therefore derived, not authored. The player card's left edge
     * must never cross --hud-card-left-limit (the column's right edge), which
     * fixes the maximum half-gap at 50vw - cardWidth - limit; double it and you
     * have the gap. Clamped at both ends:
     *
     *   wide    the clamp's MAX wins, the pair sits at a fixed 236px channel
     *           either side of the hotbar and never drifts further out
     *   narrow  the derived middle wins and shrinks, so the two cards close on
     *           the centre together rather than sliding over the column
     *   least   the MIN wins and they meet at a seam
     *
     * The cast bar used to live in this channel, which is why it could not
     * shrink; it is above the band now (see #castBar) precisely so this can
     * close. */
    --hud-card-gap-max: 236px;
    --hud-card-gap-min: 8px;
    --hud-card-gap: clamp(
        var(--hud-card-gap-min),
        calc(100vw - (var(--hud-card-width) * 2) - (var(--hud-card-left-limit) * 2)),
        var(--hud-card-gap-max)
    );
    /* Where the card band sits and how tall it is. Tokens because three other
       things are positioned off it: the cast bar in the channel between the
       cards, the dock column when it has to clear them, and the cards
       themselves. */
    --hud-card-band-bottom: calc(
        var(--bottom-nav-height)
        + var(--bottom-nav-inset)
        + var(--bottom-stack-gap)
        + var(--hotbar-slot-size)
        + 10px
    );
    /* 15 health + 10 resource + 8 XP, two 3px gaps and 4px of padding either
       side. Was 76px when the card carried a 44px portrait and two text rows
       above its bars. Three other things are positioned off this, so it is a
       token: the dock column when it lifts, the map dock, and the cast bar. */
    --hud-card-height: 47px;
    /* Fluid rather than fixed, because this number is subtracted from the
       dock column's budget twice over: a card that stays 260px wide on a
       1280px screen eats the column that has to fit a chat pane and the
       Character/Bag pair beside it. Clamped so it is always a nameplate —
       196px still holds a name, a level and three bars. */
    --hud-card-width: clamp(196px, 14vw, 240px);
    /* The floor the card stops shrinking at. Below this the bars stop being
       readable, so the card holds its size and takes the overlap with the dock
       column instead — which only happens on viewports too narrow to seat both
       at any size, and where the panes are mutually exclusive anyway. Without
       a floor the width expression goes negative and the card silently
       disappears, which is a much worse failure than an overlap. */
    --hud-card-width-min: 148px;
    /* How wide anything in the bottom-left column may be before its right edge
       runs under the centred hotbar. Named rather than inlined because it is
       the number that decides where the column ends, and it is worth being
       able to find: at 1600x900 it is 623px against a navbar that wants 663,
       which is the whole reason the bar overhangs the chat dock above it. */
    --left-island-max: calc(
        50vw
        - (var(--hotbar-width, 306px) * 0.5)
        - var(--hotbar-gutter)
        - var(--bottom-nav-inset)
    );
    /* Floor for the column: narrower than this cannot hold the Character/Bag
       pair at any useful size (the bag falls to two slot columns). The
       viewport cap is applied OUTSIDE this floor, so a genuinely narrow window
       still wins over it. */
    --dock-column-min: 340px;
    /* ONE width for the whole bottom-left column, hoisted out of .dock-row so
       the card band can be positioned against its right edge. It tracks the
       navbar below it (--nav-width, published by navbar.js) and yields to the
       centred hotbar; it no longer yields to the cards, because the cards now
       yield to it. */
    --dock-column-width: min(
        calc(100vw - (var(--bottom-nav-inset) * 2)),
        max(
            var(--dock-column-min),
            min(
                max(var(--nav-width, 340px), 320px),
                var(--left-island-max)
            )
        )
    );
    /* THE TWO LAYOUT BREAKPOINTS, and both are DERIVED — see the block at
       "Layout regimes" further down for the arithmetic and the measurements.
       They are written here as comments because a media query cannot read a
       custom property; navbar.js carries the same two numbers and
       hud_layout.test.mjs holds the three copies together.
     *
         1207  cards-clear limit. Below it the player card cannot stay right
               of the menu bar at a readable width, so the bar moves under the
               action bar and the panes go to the screen edges.
         1023  sides limit. Below it a left pane, the hotbar and the map stop
               fitting side by side, so panes open above the action bar and
               only one at a time. */

    /* The line the player card's left edge may not cross: the right edge of
       the bottom-left stack, plus a gutter. Everything in that column (Chat,
       Bag, Character) therefore keeps its full width with no card over it,
       which is the entire point of the clamp.
     *
     * It is the WIDER of the navbar and the dock column, and that matters —
     * they are not the same number. The column yields to the centred hotbar
     * (--left-island-max) while the navbar does not, so between roughly 1030
     * and 1330 the bar is wider than the docks above it and overhangs them.
     * Measured at 1100: navbar 428, column 373. Clamping to the column alone
     * cleared the chat pane and still put the health bar 39px over the menu
     * bar, which is the line this was supposed to respect. */
    --hud-card-left-limit: calc(
        var(--bottom-nav-inset)
        + max(var(--dock-column-width), var(--nav-width, 340px))
        + var(--hotbar-gutter)
    );

    /* Grounds and panels — warm blacks, never blue-greys. */
    --ink: #14110d;
    --panel: #1e1a14;
    --panel-raised: #272118;
    --panel-sunk: #100d09;

    /* Edges. --edge is the workhorse rule; --edge-lit is the top/left bevel
       that makes a panel look stamped rather than drawn. */
    --edge: #6b5836;
    --edge-dim: rgba(107, 88, 54, 0.45);
    --edge-lit: rgba(212, 175, 95, 0.16);

    /* Gold leaf. Reserved for what belongs to the player: their own frame,
       their coin, their headings. Spending it on everything spends it. */
    --gold: #d4af5f;
    --gold-bright: #f0d68a;
    --gold-dim: #8f7434;

    --oxblood: #7d2b24;
    --oxblood-bright: #a8392f;

    /* Type. --parchment is body text by candlelight, --muted is everything
       the eye should skip. Both were raised once the UI was judged on a real
       monitor rather than a screenshot: #a3906c on the #1e1a14 panel is about
       5:1, which is legible for a label and tiring for anything you actually
       read, and --muted-dim at #6f6248 was under 3:1 — below the floor for
       text of any size. The ordering (parchment > muted > dim) is what
       carries the hierarchy; the whole ramp just sits higher now. */
    --parchment: #f2e9d2;
    --muted: #c2ae86;
    --muted-dim: #96866a;

    --font-display: 'Cinzel', Georgia, 'Times New Roman', serif;
    --font-body: Georgia, 'Times New Roman', serif;
    --font-num: 'Geist Mono', ui-monospace, monospace;
}

html {
    height: 100%;
    -webkit-text-size-adjust: 100%;
}

/* The page ground moved out of Tailwind's slate utilities: bg-slate-950 is a
   BLUE black, and against it every warm panel above read as slightly dirty. */
body {
    height: 100%;
    background: var(--ink);
    color: var(--parchment);
    font-family: var(--font-body);
}

/* Shared chassis for every framed surface — auth card, docks, nav, windows.
   The three layers are what sell "stamped metal" over "div with a border":
   a warm panel, a 1px brass rule, and an inner top highlight so the edge
   catches light from above like a real bevel would. */
.forged {
    position: relative;
    background:
        linear-gradient(180deg, rgba(255, 240, 200, 0.045), transparent 55%),
        var(--panel);
    border: 1px solid var(--edge);
    border-radius: 2px;
    box-shadow:
        inset 0 1px 0 var(--edge-lit),
        inset 0 0 24px rgba(0, 0, 0, 0.55),
        0 6px 22px rgba(0, 0, 0, 0.55);
}

/* Corner brackets. These do the job the old 8-10px border-radius was doing —
   telling you where a panel stops — without the softness that read as modern.
   All four corners come off ONE pseudo-element: eight 1px gradient bars, two
   per corner, each pinned to a background-position. Drawing them as borders on
   ::before and ::after only ever gets two corners, since a single box can only
   offer one top-left and one bottom-right. */
.forged-corners::before {
    content: '';
    position: absolute;
    inset: 3px;
    pointer-events: none;
    opacity: 0.7;
    background-repeat: no-repeat;
    background-image:
        linear-gradient(var(--gold), var(--gold)), linear-gradient(var(--gold), var(--gold)),
        linear-gradient(var(--gold), var(--gold)), linear-gradient(var(--gold), var(--gold)),
        linear-gradient(var(--gold), var(--gold)), linear-gradient(var(--gold), var(--gold)),
        linear-gradient(var(--gold), var(--gold)), linear-gradient(var(--gold), var(--gold));
    background-size: 11px 1px, 1px 11px, 11px 1px, 1px 11px,
        11px 1px, 1px 11px, 11px 1px, 1px 11px;
    background-position:
        left top, left top,
        right top, right top,
        left bottom, left bottom,
        right bottom, right bottom;
}

#gameCanvas.game-stage {
    contain: layout paint;
    box-shadow: inset 0 0 40px rgba(0, 0, 0, 0.45);
    overflow: hidden;
}

#gameCanvas canvas {
    display: block;
    width: 100% !important;
    height: 100% !important;
    touch-action: none;
}

/* Turning the camera. Both the container and the canvas, because the canvas
   fills the container and would otherwise paint its own cursor over it. */
#gameCanvas.camera-grabbing,
#gameCanvas.camera-grabbing canvas {
    cursor: var(--cur-camera);
}

/* What the pointer is over IN THE WORLD, set by game.js from the same
   raycast that decides what a click would do — so the cursor and the click
   can never disagree about which of the three this is. `canvas` again for
   the reason above. */
#gameCanvas.cursor-attack,
#gameCanvas.cursor-attack canvas {
    cursor: var(--cur-attack);
}

#gameCanvas.cursor-talk,
#gameCanvas.cursor-talk canvas {
    cursor: var(--cur-talk);
}

#gameCanvas.cursor-loot,
#gameCanvas.cursor-loot canvas {
    cursor: var(--cur-loot);
}

/* Aiming a ground-target ability: the pointer is placing a point on the
   terrain, not picking a thing, so it gets the centred crosshair rather than
   the arrow-and-badge. Set by game.js for the whole of targeting mode. */
#gameCanvas.cursor-ground,
#gameCanvas.cursor-ground canvas {
    cursor: var(--cur-ground);
}

#gameCanvas canvas[data-render='2d'] {
    width: 100%;
    height: auto;
    aspect-ratio: 4 / 3;
}

/* This one is a SCRIM, not a backdrop: the lobby scene is live behind it and
   showing your own character, so it must stay see-through. It gains a vignette
   rather than the flat 45% black it had — the same value in the middle, deeper
   at the corners, which frames the sign-in card without dimming the figure. */
.game-lobby-hint {
    position: absolute;
    inset: 0;
    z-index: 5;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: radial-gradient(ellipse 82% 74% at 50% 46%,
        rgba(6, 5, 4, 0.42) 0%,
        rgba(5, 4, 4, 0.58) 58%,
        rgba(3, 2, 2, 0.76) 100%);
    pointer-events: none;
}

.game-lobby-title {
    font: 600 20px var(--font-display);
    letter-spacing: 0.1em;
    color: var(--gold);
    margin: 0 0 6px;
    text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8), 0 0 22px rgba(212, 175, 95, 0.25);
}

.game-lobby-sub {
    font: italic 12px var(--font-body);
    color: rgba(220, 210, 190, 0.85);
    margin: 0;
}

/* The ornament is width: 78% of its container; in the full-viewport lobby
   that would run edge to edge. */
.rule-ornament--lobby {
    width: 240px;
    margin-bottom: 10px;
}

.game-lobby-progress {
    width: 180px;
    height: 4px;
    margin-top: 10px;
    border-radius: 2px;
    background: rgba(255, 255, 255, 0.12);
    overflow: hidden;
}

.game-lobby-progress-bar {
    height: 100%;
    width: 0;
    border-radius: 2px;
    background: #c9a84c;
    transition: width 0.15s ease-out;
}

/* Once assets are ready the bar has nothing left to show. */
.game-lobby-hint.assets-ready .game-lobby-progress {
    display: none;
}

/* Character select: the preview figure stands where this text and its
   vignette would print (auth.js setModalScrim). */
.game-lobby-hint.is-character-select {
    display: none;
}

/* The navbar is game chrome — Chat, Character, Inventory, Map, and a status
   readout about a world you are not in — so none of it belongs behind the
   sign-in card. Hiding only the tabs left a status pill floating over the
   lobby; with Sign Out now in the character panel, that bar has nothing a
   signed-out player can use. */
.bottom-nav[data-mode="lobby"] {
    display: none;
}

/* Sign out, beside the account name in the character panel. Understated on
   purpose: it is the rarer of the two exits and must not compete with Enter
   World for the eye. */
.character-panel-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
}

.sign-out-link {
    background: none;
    border: 0;
    padding: 0;
    cursor: var(--cur-point);
    font-size: 12px;
    letter-spacing: 0.04em;
    color: rgba(232, 220, 192, 0.55);
    text-decoration: underline;
    text-underline-offset: 3px;
}

.sign-out-link:hover {
    color: #d4af5f;
}

/* --dial is not an animatable type until it is registered as an angle; without
   this the transition below is ignored and the sweep jumps between values. */
@property --dial {
    syntax: '<angle>';
    inherits: false;
    initial-value: 0deg;
}

/* The Enter World veil. Opaque on purpose: its whole job is that the world is
   never seen assembling, and a translucent one would show precisely that.

   The backdrop is a FLUX-generated painting of the aqueduct at dawn
   (assets/ui/loading_backdrop.jpg — the recipe left this repo with the FLUX
   pipeline and is in ../web-game-flux, the 1080p master in the raw-asset
   store). Shipped as a 308KB q88 JPEG: the scene is photographic and opaque,
   so PNG paid 10x for nothing, and the auto-traced SVG once committed beside
   it cost 1.8MB to render the same raster worse. It is composed around this
   element's own layout — the dial and label sit dead centre, so the art keeps
   its horizon low and its one bright passage off to the right of it.

   The scrim over the top is what guarantees the label stays legible no matter
   how the art is later retouched, and `cover` plus a matching flat colour
   underneath means an ultra-wide or very tall viewport crops rather than
   letterboxes. */
.world-loading {
    position: absolute;
    inset: 0;
    z-index: 40;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 18px;
    background:
        radial-gradient(circle at 50% 46%, rgba(8, 7, 6, 0.24) 0%, rgba(6, 5, 5, 0.66) 74%),
        url('assets/ui/loading_backdrop.jpg') center center / cover no-repeat,
        #0a0908;
}

.world-loading[hidden] {
    display: none;
}

/* One conic-gradient sweep driven by a single custom property, so advancing the
   dial costs a paint and never a layout. The inner disc masks the centre, which
   is what turns a filled pie into a ring. */
.world-loading-dial {
    position: relative;
    width: 116px;
    height: 116px;
    border-radius: 50%;
    display: grid;
    place-items: center;
    background: conic-gradient(#c9a84c var(--dial, 0deg), rgba(201, 168, 76, 0.14) var(--dial, 0deg));
    transition: --dial 0.25s ease-out;
}

.world-loading-dial::after {
    content: '';
    position: absolute;
    inset: 9px;
    border-radius: 50%;
    background: #0f0c0a;
    border: 1px solid rgba(201, 168, 76, 0.25);
}

.world-loading-pct {
    position: relative;
    z-index: 1;
    font-size: 20px;
    font-weight: 600;
    letter-spacing: 0.02em;
    color: #e8dcc0;
    font-variant-numeric: tabular-nums;
}

.world-loading-label {
    color: rgba(232, 220, 192, 0.72);
    font-size: 14px;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.game-overlay {
    position: absolute;
    inset: 0;
    z-index: 6;
    pointer-events: none;
}

/* ── The HUD is a control surface, not a document ──────────────────────────
 *
 * A browser treats every label in it as text, and that default is at odds
 * with using it: a double-click on an ability slot highlights its keycap
 * digit, a click-drag across the bar paints a blue selection over the
 * interface instead of doing what the drag was for, and a drag begun on a
 * label is a SELECTION drag — which is the browser refusing the gesture the
 * element was built for.
 *
 * The chat log opts back in, because there the text really is text: reading
 * a name out of it and copying it is a thing players do. Inheritance is what
 * makes that work — a descendant's own declaration always beats the value it
 * would otherwise inherit, whatever the specificity of the rule that set it.
 */
/* `#gameCanvas` and `body.in-world` close the gap the first three left: the
 * nameplates, examine markers and damage ticks are hosted in #gameCanvas, not
 * #gameOverlay, so a press on a nameplate dragged a highlight across every
 * name on screen. In the world NOTHING is a document except the opt-ins
 * below. The -webkit- line is Safari's, which still wants the prefix. */
#gameOverlay,
#gameCanvas,
body.in-world,
.dock-row,
.bottom-nav {
    -webkit-user-select: none;
    user-select: none;
}

.chat-panel,
#chatBox,
#combatLog,
input,
textarea {
    -webkit-user-select: text;
    user-select: text;
}

/* Controls reference, top right of the stage.
 *
 * Sits UNDER the loot window's z-index on purpose: both want the top-right
 * corner, the loot window is transient and urgent, and this is a reference
 * you consult twice and then collapse to a chip for good. */
.help-panel {
    position: absolute;
    top: 10px;
    right: 10px;
    z-index: 15;
    width: 168px;
    pointer-events: auto;
    border: 1px solid var(--edge);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.04), transparent 40%),
        rgba(19, 15, 11, 0.9);
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 4px 16px rgba(0, 0, 0, 0.5);
    overflow: hidden;
}

.help-toggle {
    display: flex;
    align-items: center;
    gap: 7px;
    width: 100%;
    padding: 6px 9px;
    font: 600 10px var(--font-display);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--gold);
    background: linear-gradient(180deg, #302819 0%, #201a12 100%);
    border: none;
    border-bottom: 1px solid var(--edge);
    box-shadow: inset 0 1px 0 var(--edge-lit);
    cursor: var(--cur-point);
}

.help-toggle-glyph {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 15px;
    height: 15px;
    border: 1px solid var(--edge);
    border-radius: 50%;
    font: 700 9px var(--font-body);
    color: var(--parchment);
    background: var(--panel-sunk);
}

.help-body {
    margin: 0;
    padding: 7px 9px 8px;
}

.help-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
    margin: 0 0 3px;
}

.help-row:last-child {
    margin-bottom: 0;
}

.help-row dt {
    font: 600 9px var(--font-num);
    color: var(--gold-dim);
    white-space: nowrap;
}

.help-row dd {
    margin: 0;
    font: 10px var(--font-body);
    color: var(--muted);
}

/* Rows that belong to ONE control scheme. The panel carries the active scheme
 * in data-scheme (game.js writes it from the toggle) and the other scheme's
 * rows fold away; an untagged row is on both. */
.help-panel[data-scheme="mouse"] .help-row[data-scheme="trackpad"],
.help-panel[data-scheme="trackpad"] .help-row[data-scheme="mouse"] {
    display: none;
}

/* The scheme toggle under the list, set in the list's own type. A native
 * checkbox reads as foreign in this palette; accent-color is the one property
 * that seats it without rebuilding the control. */
.help-scheme {
    display: flex;
    align-items: center;
    gap: 6px;
    margin: 0;
    padding: 6px 9px 7px;
    border-top: 1px solid var(--edge);
    font: 10px var(--font-body);
    color: var(--muted);
    cursor: var(--cur-point);
}

.help-scheme input {
    flex: none;
    width: 11px;
    height: 11px;
    margin: 0;
    accent-color: var(--gold);
    cursor: var(--cur-point);
}

/* Collapsed: the chip is the toggle alone, shrunk to its glyph. */
.help-panel.is-collapsed {
    width: auto;
}

.help-panel.is-collapsed .help-body,
.help-panel.is-collapsed .help-scheme,
.help-panel.is-collapsed .help-toggle-label {
    display: none;
}

.help-panel.is-collapsed .help-toggle {
    padding: 5px;
    border-bottom: none;
}

/* Strip hosting the player card and (when a target is selected) the target
 * card. Bottom-centre, directly above the action bar, so the combat readout
 * and the buttons that act on it share one glance. The strip itself is a
 * zero-height line the cards hang from: each card is bottom-anchored and
 * pinned a fixed distance off the viewport centre, so the pair is symmetric
 * about the hotbar whether or not a target is selected — a flex row would
 * re-centre the lone player card the moment the target died.
 *
 * --hud-card-gap is the clear channel between the two cards. The cast bar
 * (220px, centred, same band) lives inside it, which is why it must not
 * shrink below the cast bar's width plus breathing room on wide viewports. */
.hud-cards {
    position: absolute;
    left: 0;
    right: 0;
    bottom: var(--hud-card-band-bottom);
    height: 0;
    /* Above the docks (35): where a narrow viewport forces the chat pane and
       the card band to share ground, your health bar wins the paint order —
       a hidden HP bar mid-fight is worse than a covered chat corner. */
    z-index: 36;
    pointer-events: none;
}

/* Bars only, so the card is as tall as its bars and no taller. Both cards
   share the footprint so the pair reads as a matched set — the target card's
   missing XP strip is absorbed by the min-height rather than shown as a
   shorter card.
 *
 * The width has a SECOND cap beyond the token: once the gap has closed to its
 * minimum there is nothing left to give, so the card itself starts shrinking
 * rather than crossing --hud-card-left-limit. Without that last term a narrow
 * enough viewport puts the health bar back over the chat pane, which is the
 * exact thing this layout exists to prevent. */
.player-card {
    position: absolute;
    bottom: 0;
    right: calc(50% + var(--hud-card-gap) / 2);
    width: max(
        var(--hud-card-width-min),
        min(
            var(--hud-card-width),
            calc(50vw - (var(--hud-card-gap) / 2) - var(--hud-card-left-limit))
        )
    );
    min-height: var(--hud-card-height);
    display: flex;
    align-items: stretch;
    padding: 4px;
    border: 1px solid var(--edge);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.04), transparent 40%),
        rgba(19, 15, 11, 0.9);
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 4px 16px rgba(0, 0, 0, 0.5);
}

/* ── Transactional windows: loot, merchant, conversation ─────────────────
 *
 * One chassis, three uses. They used to be 190/220/268px wide with 11px rows,
 * which is a phone-sized column on a desktop screen: item names were cut to
 * "Rusted Iron Longsword of ...", the merchant and the conversation pane were
 * pinned to the SAME corner and drew over each other, and the loot window sat
 * on the controls reference.
 *
 * Sizes are in the viewport's own units: width follows the stage (clamped so
 * it neither shrinks to a sliver nor eats the world), lists are capped by
 * height and scroll INSIDE the window, and the window itself is a flex column
 * capped to the stage so its buttons can never be pushed off the bottom.
 * Rows are 40px tall with a 26px icon: a hit target you can land at speed and
 * an icon you can tell apart. The base is opaque enough that whatever is
 * behind (the controls card, the other window) no longer ghosts through.
 */
.loot-window {
    --win-width: clamp(280px, 24vw, 360px);
    position: relative;
    flex: none;
    display: flex;
    flex-direction: column;
    width: var(--win-width);
    max-width: 100%;
    max-height: calc(100vh - 20px);
    padding: 8px 8px 10px;
    border: 1px solid var(--edge);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.04), transparent 40%),
        rgba(19, 15, 11, 0.985);
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 8px 26px rgba(0, 0, 0, 0.6);
    pointer-events: auto;
}

.loot-window[hidden] {
    display: none;
}

.loot-window-head {
    display: flex;
    flex: none;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 6px;
    padding: 0 0 6px 2px;
    border-bottom: 1px solid rgba(212, 175, 95, 0.3);
}

.loot-window-title {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-family: var(--font-display);
    font-size: 15px;
    font-weight: 600;
    letter-spacing: 0.03em;
    color: var(--gold);
}

.loot-window-count {
    flex: none;
    margin-left: auto;
    font-family: var(--font-num);
    font-size: 11px;
    color: var(--muted-dim);
}

.loot-window-close {
    flex: none;
    width: 32px;
    height: 32px;
    padding: 0;
    font-size: 22px;
    line-height: 1;
    color: var(--muted);
    background: none;
    border: 1px solid transparent;
    border-radius: 2px;
    cursor: var(--cur-point);
}

.loot-window-close:hover,
.loot-window-close:focus-visible {
    color: var(--gold-bright);
    border-color: var(--edge);
    outline: none;
}

.loot-window-list {
    display: flex;
    flex-direction: column;
    gap: 3px;
    min-height: 0;
    max-height: min(44vh, 320px);
    overflow-y: auto;
    overscroll-behavior: contain;
}

.loot-row {
    display: flex;
    flex: none;
    align-items: center;
    gap: 9px;
    min-height: 40px;
    padding: 5px 8px;
    font-size: 13px;
    font-weight: 500;
    text-align: left;
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid transparent;
    border-radius: 3px;
    cursor: var(--cur-point);
}

.loot-row-icon {
    flex: none;
    width: 26px;
    height: 26px;
}

.loot-row-name {
    flex: 1;
    min-width: 0;
    line-height: 1.25;
    overflow-wrap: anywhere;
}

.loot-row:hover,
.loot-row:focus-visible {
    background: rgba(255, 255, 255, 0.13);
    border-color: currentColor;
    outline: none;
}

.loot-window-empty {
    padding: 10px 6px;
    font-size: 13px;
    font-style: italic;
    color: var(--muted-dim);
}

.loot-window-all {
    flex: none;
    width: 100%;
    min-height: 38px;
    margin-top: 8px;
    padding: 6px 8px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 0.02em;
    color: #0f0f13;
    background: #d4af5f;
    border: none;
    border-radius: 3px;
    cursor: var(--cur-point);
}

.loot-window-all:hover:not(:disabled),
.loot-window-all:focus-visible {
    background: var(--gold-bright);
    outline: none;
}

.loot-window-all:disabled {
    opacity: 0.4;
    cursor: var(--cur-default);
}

/* Merchant shop: left of the stage, beside the conversation pane rather than
   under it. The two can be open together (you can be shopping and talking to
   the same NPC), and when they are the shop slides right by the
   conversation's width instead of sitting on it. */
.vendor-window {
    --win-width: clamp(320px, 28vw, 430px);
}

.vendor-section {
    display: flex;
    flex: none;
    align-items: baseline;
    justify-content: space-between;
    margin: 8px 0 4px 2px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
}

/* The purse: a VALUE, not a heading. Written and coloured like the prices it
   is going to be compared against and aligned to their column, so the eye can
   drop straight down from what you have to what things cost. */
.vendor-balance {
    flex: none;
    margin: 2px 0 2px;
    padding-right: 9px;
    font-family: var(--font-num);
    font-size: 15px;
    font-weight: 700;
    text-align: right;
    color: var(--gold);
}

.vendor-window .loot-window-list {
    flex: 0 1 auto;
    max-height: min(26vh, 230px);
}

.vendor-row {
    justify-content: space-between;
}

.vendor-row-price {
    flex: none;
    font-family: var(--font-num);
    font-size: 13px;
    font-weight: 700;
    white-space: nowrap;
    color: var(--gold);
}

/* Can't afford it: the price goes red and the row dims, but stays clickable so
   the server's refusal still reaches the log; the client never decides. */
.vendor-row.is-unaffordable {
    opacity: 0.66;
}

.vendor-row.is-unaffordable .vendor-row-price {
    color: #e8695a;
}

.vendor-row-tag {
    flex: none;
    padding: 1px 5px;
    font-size: 10px;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--muted);
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
}

/* A stack's count beside its name in the merchant's lists (play-test #11). */
.vendor-row-count {
    flex: none;
    font-family: var(--font-num);
    font-size: 12px;
    font-weight: 700;
    color: var(--muted);
}

/* The quantity picker (play-test #11): a small popover against the row that
   was clicked, below it or above it (vendor.js placePopover), on <body> so the
   shop's scrolling lists and the bag panel cannot clip or cover it. */
.vendor-qty {
    position: fixed;
    z-index: 60;
    display: flex;
    flex-direction: column;
    gap: 6px;
    width: max-content;
    max-width: calc(100vw - 16px);
    padding: 6px;
    color: #f2e6c8;
    background: #15140f;
    border: 1px solid #8a7440;
    border-radius: 4px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.7);
}

.vendor-qty-row {
    display: flex;
    align-items: center;
    gap: 4px;
}

.vendor-qty-step,
.vendor-qty-max {
    min-width: 30px;
    height: 30px;
    padding: 0 6px;
    font-size: 15px;
    font-weight: 700;
    line-height: 1;
    color: #f2e6c8;
    background: rgba(255, 255, 255, 0.07);
    border: 1px solid rgba(126, 114, 90, 0.6);
    border-radius: 3px;
    cursor: var(--cur-point);
}

.vendor-qty-max {
    font-size: 11px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.vendor-qty-num {
    width: 3.2em;
    height: 30px;
    padding: 0 4px;
    font-family: var(--font-num);
    font-size: 14px;
    text-align: center;
    color: #f2e6c8;
    background: rgba(0, 0, 0, 0.5);
    border: 1px solid rgba(126, 114, 90, 0.6);
    border-radius: 3px;
    -moz-appearance: textfield;
}

.vendor-qty-num::-webkit-inner-spin-button,
.vendor-qty-num::-webkit-outer-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

.vendor-qty-total {
    flex: 1;
    font-family: var(--font-num);
    font-size: 13px;
    font-weight: 700;
    color: var(--gold);
}

.vendor-qty-ok {
    height: 30px;
    padding: 0 12px;
    font-size: 12px;
    font-weight: 700;
    color: #0f0f13;
    background: #d4af5f;
    border: none;
    border-radius: 3px;
    cursor: var(--cur-point);
}

.vendor-row.is-picking {
    border-color: #d4af5f;
}

/* Small viewports (phone landscape ~812x375, anything under ~900 wide or
   ~500 tall): one compact row, [- n + Max] [total] [Buy], 28px targets.
   Verdict 2026-10-11: "way too big on the smaller viewports". */
@media (max-width: 900px), (max-height: 500px) {
    .vendor-qty {
        flex-direction: row;
        align-items: center;
        gap: 6px;
        padding: 3px 4px;
    }

    .vendor-qty-row {
        gap: 2px;
    }

    .vendor-qty-step,
    .vendor-qty-max,
    .vendor-qty-num,
    .vendor-qty-ok {
        height: 28px;
        font-size: 12px;
    }

    .vendor-qty-step {
        min-width: 28px;
        padding: 0;
    }

    .vendor-qty-max {
        min-width: 0;
        padding: 0 5px;
        font-size: 10px;
    }

    .vendor-qty-num {
        width: 2.4em;
    }

    .vendor-qty-total {
        flex: none;
        font-size: 12px;
        padding: 0 2px;
    }

    .vendor-qty-ok {
        padding: 0 8px;
    }
}

/* Quest conversation: wide enough that a paragraph of dialogue reads as prose,
   and scrolls inside itself when objectives and rewards run long. */
.quest-window {
    --win-width: clamp(320px, 28vw, 420px);
    overflow-y: auto;
    overscroll-behavior: contain;
}

.quest-speaker {
    margin: 0 0 3px 2px;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
}

.quest-dialogue {
    margin: 0 2px 8px;
    font-family: var(--font-body);
    font-size: 15px;
    font-style: italic;
    line-height: 1.5;
    color: var(--parchment);
}

.examine-marker {
    width: 26px;
    height: 26px;
    line-height: 26px;
    text-align: center;
    font-size: 18px;
    color: #cfc4ad;
    cursor: var(--cur-examine);
    text-shadow: 0 2px 3px rgba(0, 0, 0, 0.95);
    will-change: transform;
    user-select: none;
}

.examine-marker[data-state="far"] {
    opacity: 0.55;
}

.examine-marker[data-state="near"] {
    color: #e8dcc0;
}

.examine-marker[data-state="quest"] {
    color: #f0c14b;
}

.quest-choices {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin-top: 8px;
}

.quest-choices .quest-choice {
    width: 100%;
    text-align: left;
}

.quest-objectives {
    display: flex;
    flex-direction: column;
    gap: 3px;
}

.quest-objective {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
    margin: 0;
    font-size: 13px;
    color: var(--muted);
}

.quest-objective-label {
    min-width: 0;
    overflow-wrap: anywhere;
}

.quest-objective-count {
    flex: none;
    font-family: var(--font-num);
    font-weight: 600;
    color: var(--gold-dim);
}

/* A finished objective is struck through and goes gold — the state change
   has to be readable at a glance mid-fight, not on inspection. */
.quest-objective.is-done .quest-objective-label {
    text-decoration: line-through;
    opacity: 0.7;
}

.quest-objective.is-done .quest-objective-count {
    color: var(--gold-bright);
}

.quest-rewards {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 4px 8px;
}

.quest-reward {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 13px;
    font-weight: 600;
    color: var(--muted);
}

.quest-reward-coins,
.quest-reward-xp {
    font-family: var(--font-num);
    color: var(--gold);
}

/* Honor reads in the parchment, not the gold: it is the currency the
 * quartermaster's sets are priced in, and a chip the colour of coins would
 * say "money" about a thing you cannot buy with money. */
.quest-reward-honor {
    font-family: var(--font-num);
    color: var(--parchment);
}

.quest-tag {
    margin-top: 6px;
    font-size: 12px;
    font-style: italic;
    color: var(--gold-dim);
}

/* Shop, on a giver who also keeps one. Secondary to the turn-in: handing a
   quest in is what you came for, trading is the other thing you can do — two
   solid gold buttons stacked read as two primary actions. */
.quest-shop {
    color: var(--gold-dim);
    background: transparent;
    border: 1px solid var(--gold-dim);
}

.quest-shop:hover {
    color: #0f0f13;
    background: var(--gold-dim);
}

/* Journal dock: one card per active quest. */
.quest-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.quest-card {
    padding: 7px 8px;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: rgba(255, 255, 255, 0.03);
}

.quest-card.is-complete {
    border-color: rgba(212, 175, 95, 0.55);
    background: rgba(212, 175, 95, 0.07);
}

.quest-card-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
}

.quest-card-title {
    margin: 0;
    font-family: var(--font-display);
    font-size: 12px;
    font-weight: 600;
    color: var(--gold);
}

/* Abandon stays quiet until hovered: it is destructive and it sits inside a
   card the player reads constantly. */
.quest-card-drop {
    flex: none;
    padding: 0 2px;
    font-size: 10px;
    color: var(--muted-dim);
    background: none;
    border: none;
    cursor: var(--cur-point);
}

.quest-card-drop:hover {
    color: var(--oxblood);
}

.quest-card-giver {
    margin: 0 0 4px;
    font-size: 10px;
    color: var(--muted-dim);
}

.quest-card-ready {
    margin-top: 4px;
    font-size: 10px;
    font-style: italic;
    color: var(--gold-bright);
}

.quest-card .quest-rewards {
    margin-top: 5px;
    padding-top: 5px;
    border-top: 1px solid rgba(212, 175, 95, 0.18);
}

/* ── The objective tracker (quest.js renderTracker) ──
 *
 * The right rail, under the controls card: quest.js publishes the card's live
 * height as --qt-help-h, so the tracker follows it open or collapsed. 180px
 * because .window-dock stops 190px short of the right edge — the loot window
 * at the end of that row must not land on top of it. Only the header takes
 * the pointer: the body is read, and the world under it is still the camera's
 * to drag. It lives in #gameCanvas, not #gameOverlay, so that it can sit ABOVE
 * the nameplate layer (z 10) — a plate drifting over it hid the very count the
 * fight was moving — and under the transactional windows (z 40). Shown only in
 * the world. */
.quest-tracker {
    --qt-objective: #e2874b;
    position: absolute;
    top: calc(10px + var(--qt-help-h, 26px) + 6px);
    right: 10px;
    z-index: 12;
    width: min(180px, 46vw);
    max-height: 46vh;
    overflow: hidden;
    pointer-events: none;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(19, 15, 11, 0.82), rgba(19, 15, 11, 0.6));
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 4px 14px rgba(0, 0, 0, 0.35);
}

.quest-tracker[hidden],
body:not(.in-world) .quest-tracker {
    display: none;
}

.quest-tracker-head {
    display: flex;
    align-items: center;
    gap: 6px;
    width: 100%;
    padding: 4px 8px;
    pointer-events: auto;
    font: 600 10px var(--font-display);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--gold);
    background: linear-gradient(180deg, #302819 0%, #201a12 100%);
    border: none;
    border-bottom: 1px solid var(--edge-dim);
    cursor: var(--cur-point);
}

.quest-tracker-head-label {
    flex: 1;
    text-align: left;
}

.quest-tracker-count {
    font: 600 10px var(--font-num);
    letter-spacing: 0;
    color: var(--muted-dim);
}

.quest-tracker-chevron {
    width: 6px;
    height: 6px;
    margin-top: -3px;
    border-right: 1.5px solid currentColor;
    border-bottom: 1.5px solid currentColor;
    transform: rotate(45deg);
    transition: transform 120ms ease-out;
}

.quest-tracker.is-collapsed .quest-tracker-chevron {
    margin-top: 2px;
    transform: rotate(-135deg);
}

.quest-tracker.is-collapsed .quest-tracker-head {
    border-bottom: none;
}

.quest-tracker.is-collapsed .quest-tracker-body {
    display: none;
}

.quest-tracker-body {
    display: flex;
    flex-direction: column;
    gap: 6px;
    padding: 6px 8px 7px;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
}

.quest-tracker-title {
    margin: 0 0 2px;
    font: 600 11px var(--font-display);
    line-height: 1.25;
    color: var(--gold);
    overflow-wrap: anywhere;
}

.quest-tracker-quest.is-complete .quest-tracker-title {
    color: var(--gold-bright);
}

.quest-tracker-row {
    display: flex;
    align-items: baseline;
    gap: 5px;
    margin: 0;
    font: 11px/1.3 var(--font-body);
    color: var(--parchment);
}

/* The ember diamond is the same mark the maps and the nameplate badge use for
   "your quest wants this" — one colour, one idea, on all four surfaces. */
.quest-tracker-mark {
    flex: none;
    width: 6px;
    height: 6px;
    margin: 0 1px 1px;
    background: var(--qt-objective);
    transform: rotate(45deg);
    box-shadow: 0 0 0 1px rgba(15, 11, 6, 0.8);
}

.quest-tracker-row.is-done .quest-tracker-mark {
    background: var(--gold-dim);
}

.quest-tracker-row.is-done .quest-tracker-label {
    text-decoration: line-through;
    opacity: 0.6;
}

.quest-tracker-row.is-done .quest-tracker-num {
    color: var(--gold-bright);
}

.quest-tracker-row.is-turn-in {
    font-style: italic;
    color: var(--gold-bright);
}

.quest-tracker-row.is-turn-in .quest-tracker-mark {
    width: auto;
    height: auto;
    margin: 0 1px;
    background: none;
    box-shadow: none;
    transform: none;
    font: 700 12px var(--font-body);
    font-style: normal;
    line-height: 1;
    color: #f0c14b;
}

.quest-tracker-label {
    flex: 1;
    min-width: 0;
    overflow-wrap: anywhere;
}

.quest-tracker-num {
    flex: none;
    font: 600 10px var(--font-num);
    color: var(--muted);
}

.quest-tracker-row.is-bumped .quest-tracker-num {
    animation: quest-tracker-bump 900ms ease-out;
}

@keyframes quest-tracker-bump {
    0% { color: #fff3c4; text-shadow: 0 0 8px rgba(240, 214, 138, 0.9); }
    100% { color: var(--muted); }
}

.quest-tracker-more {
    margin: 0;
    font: italic 10px var(--font-body);
    color: var(--muted-dim);
}

/* Touch: under the top-right navbar, at the HUD's touch scale, where the
   graphics panel also opens (that panel is transient and sits above). The
   width is the PRE-scale width, so it is the same 180px of layout. */
body.scheme-touch.in-world .quest-tracker {
    top: calc(var(--touch-top-band) + var(--touch-safe-t));
    right: calc(4px + var(--touch-safe-r));
    width: 180px;
    max-height: calc((100dvh - var(--touch-top-band) - var(--touch-bar-band) - 96px) / var(--touch-ui-scale));
    transform: scale(var(--touch-ui-scale));
    transform-origin: 100% 0;
}

body.scheme-touch.in-world .quest-tracker-head {
    touch-action: none;
}

/* The nameplate's objective badge (nameplates.js syncHuntMarker): a small
   ember diamond hung off the bar's left end. `drop` is the same diamond
   hollowed, so "kill it" and "it carries what you need" read apart once you
   look, and as one family before you do. */
.np-hunt {
    left: -13px;
    top: 3px;
    width: 10px;
    height: 10px;
    background: #e2874b;
    border: 1px solid rgba(15, 11, 6, 0.95);
    transform: rotate(45deg);
    box-shadow: 0 0 4px rgba(226, 135, 75, 0.55), 0 1px 2px rgba(0, 0, 0, 0.9);
}

.np-hunt[data-kind="drop"] {
    background: radial-gradient(circle, #e2874b 0 28%, #1a120a 32% 100%);
    border-color: #e2874b;
}

.quest-completed-tally {
    font-size: 10px;
    font-style: italic;
    color: var(--muted-dim);
}

/* Nameplate damage tick: rises off the plate and fades. --np-dx carries the
   per-tick horizontal jitter (includes the -50% self-centering). */
.np-damage {
    pointer-events: none;
    animation: npDamageFloat 0.9s ease-out forwards;
}

@keyframes npDamageFloat {
    from {
        opacity: 1;
        transform: translate(var(--np-dx, -50%), 0);
    }
    to {
        opacity: 0;
        transform: translate(var(--np-dx, -50%), -22px);
    }
}

/* Target card: same chassis, hostile-red accent, and MIRRORED across the
   centre line — its bar text runs the other way so the pair is symmetric
   about the hotbar rather than two copies of the same left-aligned card. */
.target-card {
    border-color: rgba(200, 70, 60, 0.55);
    right: auto;
    left: calc(50% + var(--hud-card-gap) / 2);
}

/* A corpse. The whole frame greys and drops back rather than the word "(Dead)"
   being appended to a class label — which is what this used to do, in the same
   colour as a living target, on the row that no longer exists. */
.target-card.is-dead {
    border-color: rgba(120, 110, 96, 0.45);
    filter: grayscale(0.85);
    opacity: 0.7;
}

.player-card-bars {
    display: flex;
    flex-direction: column;
    gap: 3px;
    width: 100%;
    min-width: 0;
}

/* Name and level ride the health bar. The level is a separate span so it can
   stay put while the name ellipses — a long name must not push the level off
   the bar, because on a target the level is the more urgent of the two. */
.player-card-unit-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    min-width: 0;
}

.player-card-unit-level {
    flex: 0 0 auto;
    color: #d9c48f;
    font-variant-numeric: tabular-nums;
    opacity: 0.85;
}

.player-card-unit-level::before {
    content: 'L';
    opacity: 0.6;
}

/* Three heights, in order of how often you look at them: health is the tall
   one and carries the name, the resource is shorter, XP is a hairline. That
   hierarchy is the whole reason these read at a glance without labels.

   THE TRACK IS THE FLEX ROW BOTH LABELS SHARE, and that is load-bearing rather
   than a styling choice. They used to be two independent absolutely-positioned
   spans, one pinned left and one pinned right, which cannot see each other:
   a long name and a four-digit health value overlapped into an unreadable
   smear ("Warden Petronas" under "1000/1000") with no width at which either
   yielded. Laying them out in one row makes the name's ellipsis fire at
   exactly the point the number would have been reached, at every card width,
   with no breakpoint to maintain. justify-content keeps the lone number on a
   label-less track (XP) at the far end. */
.player-card-bar-track {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 6px;
    padding: 0 6px;
    width: 100%;
    height: 15px;
    border-radius: 2px;
    background: rgba(0, 0, 0, 0.55);
    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.5);
    overflow: hidden;
}

.player-card-bar-track--resource {
    height: 10px;
}

.player-card-bar-fill {
    position: absolute;
    left: 0;
    top: 0;
    bottom: 0;
    width: 100%;
    border-radius: inherit;
    transition: width 0.2s ease;
    z-index: 0;
}

/* Relative, not static: the fill is absolute at z-index 0, and an unpositioned
   element cannot take a z-index to sit above it. */
.player-card-bar-text {
    position: relative;
    display: flex;
    align-items: center;
    gap: 4px;
    min-width: 0;
    font: 600 9px var(--font-body);
    letter-spacing: 0.02em;
    color: #fff;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
    z-index: 1;
    pointer-events: none;
    line-height: 1;
}

/* The name side is the one that gives. It takes the leftover width and
   shrinks below its content when there is none, which is what lets the
   ellipsis engage. */
.player-card-bar-text--left {
    flex: 1 1 auto;
}

/* The number never truncates. Health is the reason the frame exists, and half
   a health value is worse than no name. */
.player-card-bar-text--right {
    flex: 0 0 auto;
    font-variant-numeric: tabular-nums;
}

/* The health bar carries the unit's name, so it gets the readable size; every
   other bar is a number and stays small. */
.player-card-bar-track--hp .player-card-bar-text {
    font-size: 10px;
}

/* MIRRORED. The target's text runs from its outer edge inward, so the two
   frames are reflections about the centre line rather than two identical
   left-aligned cards — the name and the health value swap sides, and the
   level trails the name on the way back.

   Reversing the ROW does all of that in one declaration, and keeps the
   truncation symmetric for free: physical left/right overrides would have had
   to be repeated on every label, and an auto margin reverses meaning under
   row-reverse. */
.target-card .player-card-bar-track {
    flex-direction: row-reverse;
}

.target-card .player-card-bar-text--left {
    flex-direction: row-reverse;
}

/* XP: the thinnest bar on the card, and the only one with no label — it is
   progress, not a combat stat. Kept tall enough to hold its own number rather
   than floating the text above a hairline, which clips at the first descender
   and collides with the resource bar above it. */
.player-card-bar-track--xp {
    height: 8px;
}

.player-card-bar-track--xp .player-card-bar-text {
    font-size: 7px;
    opacity: 0.8;
}

.player-card-bar-fill--xp {
    background: linear-gradient(90deg, #6b5836, var(--gold));
}

.player-card-bar-fill--hp {
    background: linear-gradient(90deg, #6f9e4a, #47702e);
}

.player-card-bar-fill--hp.is-low {
    background: linear-gradient(90deg, var(--oxblood-bright), var(--oxblood));
}

.player-card-bar-fill--resource[data-resource="mana"] {
    background: linear-gradient(90deg, #4a6f9e, #2f4a72);
}

.player-card-bar-fill--resource[data-resource="energy"] {
    background: linear-gradient(90deg, #c9a227, #96741a);
}

.player-card-bar-fill--resource[data-resource="rage"] {
    background: linear-gradient(90deg, #ef4444, #b91c1c);
}

.player-card-bar-fill--resource.is-low {
    filter: brightness(0.85);
}

/* Breath. A pale sea-blue that belongs to none of the three class resources, so
 * a glance never confuses it for mana — they would be the only two blue bars on
 * the card, and the one meaning "you are about to drown" has to win that read.
 * The whole row is `hidden` unless the server sent a breath value at all. */
.player-card-bar-fill--breath {
    background: linear-gradient(90deg, #5fd0e6, #2b8ba3);
}

/* Running out IS the point of this bar, so is-low is loud here rather than the
 * quiet dimming the resource bar uses: by then the player needs to be looking
 * for the surface, not at the HUD. */
.player-card-bar-fill--breath.is-low {
    background: linear-gradient(90deg, #ef4444, #b91c1c);
    animation: player-card-breath-warn 0.9s ease-in-out infinite;
}

@keyframes player-card-breath-warn {
    0%, 100% { filter: brightness(1); }
    50% { filter: brightness(1.55); }
}

/* The motion is a warning rather than decoration — but an unrequested pulse is
 * exactly what this preference exists to stop, so the colour carries it alone. */
@media (prefers-reduced-motion: reduce) {
    .player-card-bar-fill--breath.is-low {
        animation: none;
    }
}

/* Bottom-right HUD column: zone banner stacked above the minimap. */
/* ---- The map ----
 *
 * One frame around a square map. It was three — a "Map" header bar, a bordered
 * banner box naming the region, and a bordered well around the canvas — and on
 * a 320px panel that spent more pixels on chrome than on the map inside it.
 *
 * Square because the map is centred on the player now (minimap.js), and a
 * square is the shape that shows the same distance in every direction. The
 * frame's width is the map's size and `aspect-ratio` makes it that tall.
 *
 * The frame is a thin gilt edge — a near-black 1px border with a gold hairline
 * just inside it — rather than the heavy panel chrome the docks use. The map is
 * the content; the frame's job is to separate it from the world behind it and
 * then stay out of the way. */
.dock.dock-map {
    --map-size: 248px;
    width: var(--map-size);
    max-height: none;
    border: none;
    border-radius: 3px;
    background: none;
    box-shadow: none;
    overflow: visible;
}

.map-frame {
    position: relative;
    width: 100%;
    aspect-ratio: 1;
    border: 1px solid rgba(8, 6, 4, 0.95);
    border-radius: 3px;
    overflow: hidden;
    background: rgba(8, 8, 12, 0.92);
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.55);
}

/* The gilt hairline has to sit OVER the canvas or the map paints across it, so
   it is a pseudo-element above the map and below the controls. The inner
   shadow darkens the rim a touch, which is what makes the edge read as a
   bevel rather than a line. */
.map-frame::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    border-radius: 2px;
    box-shadow:
        inset 0 0 0 1px rgba(212, 175, 95, 0.5),
        inset 0 0 16px rgba(0, 0, 0, 0.5);
    pointer-events: none;
}

#minimapCanvas {
    display: block;
    width: 100%;
    height: 100%;
}

/* The region name rides the top edge of the map, and that strip is the handle
   the map is moved by. A gradient rather than a bar, so the terrain under it is
   dimmed rather than cut off: the top of a player-centred map is the far side
   of the view, and it is still worth seeing. */
.map-titlebar {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    z-index: 3;
    min-height: 30px;
    padding: 5px 30px 13px;
    background: linear-gradient(180deg,
        rgba(10, 8, 6, 0.86) 0%, rgba(10, 8, 6, 0.5) 62%, transparent 100%);
    text-align: center;
    user-select: none;
    touch-action: none;
}

.dock-map.is-movable .map-titlebar {
    cursor: var(--cur-grab);
}

.dock-map.is-dragging .map-titlebar {
    cursor: var(--cur-drag);
}

/* The banner's old box is gone. game.js still paints its border in the region's
   accent, so the border is neutralised here and the accent survives where it
   reads — in the name itself. */
.zone-banner {
    margin: 0;
    padding: 0;
    border: none !important;
    background: none;
}

.zone-banner-title {
    margin: 0;
    overflow: hidden;
    font: 600 14px/1.2 Cinzel, Georgia, serif;
    letter-spacing: 0.07em;
    text-overflow: ellipsis;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--gold);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95);
}

/* The subtitle is a sentence, and on a 248px map it cannot share the strip with
   the name, so it only appears once the player has made the map big enough to
   hold it (map_frame.js sets .is-large). */
.zone-banner-sub {
    display: none;
    margin: 1px 0 0;
    overflow: hidden;
    font: italic 10px/1.25 Georgia, serif;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: rgba(236, 224, 198, 0.82);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95);
}

.dock-map.is-large .zone-banner-sub {
    display: block;
}

/* Zoom: two small buttons down the right edge, under the title's gradient.
   Dark glass rather than panel chrome, so they read as controls ON the map
   instead of a toolbar bolted to it. */
.map-zoom {
    position: absolute;
    top: 35px;
    right: 7px;
    z-index: 3;
    display: flex;
    flex-direction: column;
    gap: 3px;
}

.map-zoom-btn {
    display: grid;
    place-items: center;
    width: 22px;
    height: 22px;
    padding: 0;
    border: 1px solid rgba(212, 175, 95, 0.45);
    border-radius: 3px;
    background: rgba(12, 10, 8, 0.72);
    color: var(--gold);
    transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}

.map-zoom-btn svg {
    width: 10px;
    height: 10px;
    fill: currentColor;
}

.map-zoom-btn:hover,
.map-zoom-btn:focus-visible {
    border-color: var(--gold);
    background: rgba(40, 32, 20, 0.9);
    color: var(--gold-bright);
    outline: none;
}

/* The resize grip: diagonal ticks in whichever corner of the map faces the
   middle of the screen (map_frame.js sets data-grip), because that is the
   direction there is room to grow into. Quiet until the map is hovered. */
.map-resize {
    position: absolute;
    z-index: 4;
    display: none;
    width: 16px;
    height: 16px;
    opacity: 0;
    transition: opacity 140ms ease;
    touch-action: none;
    background: linear-gradient(135deg,
        transparent 0 44%, rgba(212, 175, 95, 0.95) 44% 52%,
        transparent 52% 66%, rgba(212, 175, 95, 0.95) 66% 74%, transparent 74%);
}

.dock-map.is-movable .map-resize {
    display: block;
}

.dock-map.is-movable:hover .map-resize,
.dock-map.is-resizing .map-resize {
    opacity: 1;
}

.map-frame[data-grip="br"] .map-resize {
    right: 1px;
    bottom: 1px;
    cursor: var(--cur-resize-nwse);
}

.map-frame[data-grip="tl"] .map-resize {
    left: 1px;
    top: 1px;
    cursor: var(--cur-resize-nwse);
    transform: rotate(180deg);
}

.map-frame[data-grip="tr"] .map-resize {
    right: 1px;
    top: 1px;
    cursor: var(--cur-resize-nesw);
    transform: rotate(-90deg);
}

.map-frame[data-grip="bl"] .map-resize {
    left: 1px;
    bottom: 1px;
    cursor: var(--cur-resize-nesw);
    transform: rotate(90deg);
}

/* While the map is being moved or resized, nothing under the pointer should
   select. */
body.is-arranging-map,
body.is-arranging-map * {
    user-select: none !important;
}

/* Equipment paper doll (character dock).
 *
 * Two columns flanking the character's own class art, with the weapons on a
 * rail underneath. The grid template IS the layout — inventory.js only names
 * which area each slot claims — so moving a slot is a one-line change here
 * rather than a row/col arithmetic puzzle spread across two files.
 *
 * Left column descends the body head to foot. Right column is everything worn
 * rather than armoured: neck, back, the rings, the trinkets. It runs one row
 * shorter than the left, which is deliberate — the gap sits at the bottom
 * beside the boots, where it reads as margin instead of as a missing slot.
 */
.equipment-panel {
    padding: 10px 8px 8px;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.03), transparent 40%),
        rgba(12, 10, 8, 0.6);
    box-shadow: inset 0 1px 0 var(--edge-lit);
}

.equipment-panel-title {
    font: 600 10px var(--font-display);
    color: var(--gold);
    margin: 0 0 10px;
    text-align: center;
    letter-spacing: 0.16em;
    text-transform: uppercase;
}

/* The middle column floors at 120px, not the figure art's own 150: the pane
   is a fraction of the dock column now, and 120 is where the 2:3 art still
   reads as a portrait rather than a cropped strip. Below that the pane
   switches to the light layout and drops the figure entirely. */
.equipment-grid {
    display: grid;
    grid-template-columns: 38px minmax(120px, 1fr) 38px;
    grid-template-rows: repeat(7, 38px) 10px 38px;
    grid-template-areas:
        "head   art   neck"
        "shldr  art   back"
        "chest  art   ring1"
        "hands  art   ring2"
        "waist  art   trk1"
        "legs   art   trk2"
        "feet   art   ."
        ".      .     ."
        "main   rail  off";
    gap: 5px;
    justify-content: center;
    max-width: 100%;
}

/* The figure between the columns: a framed panel icon. The art is a 2:3
   mosaic rendering with its own gold tessera ground, so the cell crops it
   like a frame crops a panel — object-fit: cover, centre anchored, and only
   the gold margins fall away. */
.equipment-figure {
    grid-area: art;
    min-width: 0;
    overflow: hidden;
    border: 1px solid var(--edge);
    border-radius: 2px;
    box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.45), inset 0 1px 0 var(--edge-lit);
    background: #8a6d2f;
}

.equipment-figure img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
}

/* The weapon rail's own rule, so the two weapons read as racked below the
   body rather than as two more body slots. */
.equipment-grid::after {
    content: '';
    grid-area: rail;
    align-self: center;
    height: 1px;
    background: linear-gradient(90deg, transparent, var(--edge), transparent);
}

.equip-slot {
    width: 38px;
    height: 38px;
    padding: 0;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: radial-gradient(120% 110% at 50% 4%, #221c14 0%, #14100b 52%, #090705 100%);
    box-shadow: inset 0 1px 0 rgba(212, 175, 95, 0.1), inset 0 -3px 7px rgba(0, 0, 0, 0.6);
    cursor: var(--cur-point);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: border-color 120ms ease, filter 120ms ease;
}

.equip-slot.equipped {
    background: linear-gradient(180deg, #2c251a, #17130e);
}

.equip-slot:hover,
.inventory-slot:hover {
    filter: brightness(1.25);
    border-color: var(--gold-dim);
}

.equip-slot-icon,
.inventory-slot-icon {
    font: 700 11px var(--font-body);
    line-height: 1;
    width: 100%;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
}

/* Drag to equip (inventory.js). While gear is carried the slots it cannot go
   in fade back and the ones it can are ringed; the slot under the pointer is
   lit gold, or red where a drop would be refused. An OUTLINE, not the border:
   an equipped cell's border is its rarity colour, set inline. */
.equipment-grid.is-gear-drag .equip-slot:not(.is-drop-valid) {
    filter: brightness(0.55) grayscale(0.6);
}

.equip-slot.is-drop-valid {
    outline: 1px solid var(--gold-dim);
    outline-offset: 1px;
}

.equip-slot.is-drop-target {
    outline: 2px solid var(--gold);
    box-shadow: inset 0 0 0 1px var(--gold), 0 0 10px rgba(212, 175, 95, 0.55);
    filter: brightness(1.3);
}

.equip-slot.is-drop-refused,
.equip-slot.is-drop-refused-flash {
    outline: 2px solid #b5452f;
    outline-offset: 1px;
}

.equip-slot.is-drop-refused-flash {
    animation: equip-refuse 0.4s ease;
}

@keyframes equip-refuse {
    0%, 100% { transform: translateX(0); }
    25% { transform: translateX(-3px); }
    75% { transform: translateX(3px); }
}

/* An equipped piece dragged back over the bag: the bag takes it. */
#inventorySection.is-drop-target {
    outline: 1px dashed var(--gold);
    outline-offset: 2px;
}

/* An empty slot shows what belongs in it, ghosted back far enough that a full
   doll and an empty one are still distinguishable at a glance. */
.equip-slot-empty {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    opacity: 0.2;
    filter: grayscale(1);
}

/* Bag dock inventory. auto-fill, not a fixed 6: the bag is a share of the
   dock column now rather than a 250px panel, so the slot count has to follow
   the width it is given — six columns in a wide pair, four in a narrow one,
   at a slot size that stays square either way. */
.inventory-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(34px, 1fr));
    gap: 4px;
}

.inventory-slot {
    position: relative;
    overflow: hidden;
    aspect-ratio: 1;
    min-height: 36px;
    padding: 0;
    border: 1px solid rgba(126, 114, 90, 0.6);
    border-radius: 3px;
    /* Sunk, and darkest where the art's own contact shadow falls. A flat
       rgba(14,14,16,.95) put pale metal on a mid-dark card with nothing
       between them. */
    background: radial-gradient(120% 110% at 50% 8%, #16161a 0%, #0d0d10 55%, #060608 100%);
    box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.6);
    cursor: var(--cur-point);
    display: flex;
    align-items: center;
    justify-content: center;
}

/* A stack's count, bottom-right over the art (play-test #14). Above the
   cooldown wipe so a potion's number stays readable while it is on cooldown. */
.inventory-slot-count {
    position: absolute;
    right: 2px;
    bottom: 1px;
    z-index: 3;
    font: 700 11px/1 var(--font-num);
    color: #f2e6c8;
    text-shadow: 0 0 2px #000, 0 1px 2px #000;
    pointer-events: none;
}

.inventory-empty {
    grid-column: 1 / -1;
    font-size: 11px;
    font-style: italic;
    color: var(--muted-dim);
    margin: 0;
}

/* Gold row pinned under the bag grid. */
.bag-coins {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 8px 0 0;
    padding-top: 6px;
    border-top: 1px solid rgba(212, 175, 95, 0.25);
}

.bag-coins-label {
    font: 600 10px var(--font-body);
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--muted);
}

.bag-coins-value {
    font: 600 12px var(--font-num);
    font-variant-numeric: tabular-nums;
    color: #d4af5f;
}

/* Abilities list, above the ledger: the class's whole spellbook, each row a
   drag handle for the action bar. Same panel chassis and the same <details>
   summary/chevron rules as the ledger — those selectors are shared rather
   than copied, because two dropdowns side by side that disagree about their
   own chevron look like a bug in one of them.

   The list SCROLLS inside a cap instead of running the pane long: opening a
   dropdown pins the pane's height, so an unbounded list would simply be
   clipped, and a clipped list gives no sign that there is more of it. */
.ability-book {
    margin-top: 10px;
    padding: 8px 10px 9px;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.03), transparent 40%),
        rgba(12, 10, 8, 0.6);
    box-shadow: inset 0 1px 0 var(--edge-lit);
}

/* Empty until `welcome` lands. An empty bordered panel reads as a broken one,
   so the chrome only exists once there is a list inside it. */
.ability-book:empty {
    display: none;
}

.ability-book-rows {
    display: flex;
    flex-direction: column;
    gap: 2px;
    max-height: 208px;
    overflow-y: auto;
    padding-right: 2px;
}

.ability-book-row {
    display: flex;
    align-items: center;
    gap: 7px;
    padding: 3px 4px;
    border: 1px solid transparent;
    border-radius: 2px;
    /* The row is a drag handle, and a half-selected label is what a drag on
       text looks like when the browser reads it as a selection instead.
       (Its cursor comes from `.ui-draggable` in cursors.css — see the bag
       cells below for why that lives there and not here.) */
    user-select: none;
}

.ability-book-row:hover,
.ability-book-row:focus-visible {
    border-color: var(--edge-lit);
    background: rgba(212, 175, 95, 0.09);
    outline: none;
}

/* Faded while it is being carried; the copy under the pointer is the ghost.
   No cursor here — `html[data-uidrag]` paints the whole document, because the
   pointer spends most of the trip to the action bar over the world. */
.ability-book-row.is-dragging {
    opacity: 0.45;
}

.ability-book-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 auto;
    width: 26px;
    height: 26px;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: linear-gradient(180deg, #2c2519, #15110c);
    font: 600 9px var(--font-display);
    color: var(--gold-dim);
    overflow: hidden;
}

.ability-book-icon img,
.ability-book-icon svg {
    width: 100%;
    height: 100%;
    object-fit: contain;
}

.ability-book-text {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 6px;
    min-width: 0;
    flex: 1 1 auto;
}

.ability-book-name {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font: 600 11px var(--font-body);
    color: #cfc6ad;
}

/* Why a row is greyed, or where it already sits. Everything else about the
   ability is in the tooltip, live against the current snapshot. */
.ability-book-note {
    flex: 0 0 auto;
    font: 600 9px var(--font-body);
    letter-spacing: 0.04em;
    color: var(--muted-dim);
}

/* Locked reads as NOT YET rather than as unavailable: still legible, still
   draggable, because the pane exists partly to plan a bar you have not
   earned. The icon carries the dimming so the name stays readable. */
.ability-book-row.is-locked .ability-book-icon {
    opacity: 0.4;
}

.ability-book-row.is-locked .ability-book-name {
    color: var(--muted-dim);
}

.ability-book-row.is-locked .ability-book-note {
    color: #8a6740;
}

.ability-book-row.is-bound .ability-book-note {
    color: var(--gold-dim);
}

.ability-book-hint {
    margin: 6px 0 0;
    font: 400 9px var(--font-body);
    font-style: italic;
    color: var(--muted-dim);
    text-align: center;
}

/* Attribute ledger under the paperdoll: the same panel chassis as the
   equipment block, rows ruled like a merchant's book. */
.player-stat-summary {
    margin-top: 10px;
    padding: 8px 10px 9px;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.03), transparent 40%),
        rgba(12, 10, 8, 0.6);
    box-shadow: inset 0 1px 0 var(--edge-lit);
}

.stat-rows {
    display: flex;
    flex-direction: column;
    gap: 3px;
}

.stat-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
}

.stat-row-label {
    font: 10px var(--font-body);
    color: var(--muted);
    letter-spacing: 0.02em;
}

.stat-row-value {
    font: 600 10px var(--font-num);
    font-variant-numeric: tabular-nums;
    color: var(--parchment);
}

/* Bonuses conferred by gear: sage, the "this is a blessing" colour the combat
   log already uses for the same idea. */
.stat-row--bonus .stat-row-value {
    color: #9bbf6a;
}

.stat-rule {
    height: 1px;
    margin: 6px 0;
    background: linear-gradient(90deg, transparent, var(--edge), transparent);
}

/* The ledger's <details> chassis. The summary IS the panel title and it is a
   working dropdown at every pane width — the attributes are reference material
   you consult, and unfolded under the paperdoll they made the pane taller than
   a laptop viewport could show. It was briefly a dropdown only in the narrow
   pane, which meant the same panel was a control in one layout and an inert
   heading in another. */
.stat-details > summary,
.ability-book-details > summary {
    list-style: none;
}

.stat-details > summary::-webkit-details-marker,
.ability-book-details > summary::-webkit-details-marker {
    display: none;
}

.stat-details-summary,
.ability-book-summary {
    cursor: var(--cur-point);
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
}

/* Chevron, pointing down when shut and up when open — the only cue that the
   title is a control. */
.stat-details-summary::after,
.ability-book-summary::after {
    content: '';
    width: 0;
    height: 0;
    border-left: 4px solid transparent;
    border-right: 4px solid transparent;
    border-top: 5px solid var(--gold-dim);
    transition: transform 120ms ease;
}

.stat-details[open] .stat-details-summary::after,
.ability-book-details[open] .ability-book-summary::after {
    transform: rotate(180deg);
}

.stat-details-summary:hover,
.ability-book-summary:hover {
    color: var(--gold-bright);
}

/* Shut, the summary is the whole panel — its title margin would dangle. */
.stat-details:not([open]) .stat-details-summary,
.ability-book-details:not([open]) .ability-book-summary {
    margin-bottom: 0;
}

.character-gear-placeholder {
    margin: 0.5rem 0 0;
    padding: 0.75rem 0.5rem;
    text-align: center;
    font-style: italic;
}

/* Item tooltip — and the ability tooltip, which is the same element with the
 * same classes. ONE type scale, declared here as tokens, because the two are
 * painted by two different modules (inventory.js and ability_bar.js) and drift
 * between them is invisible to whoever is editing one of them: the ability
 * flavour line spent a while at 11px directly under a 10px effects list, and
 * the ability tooltip had no header row at all where an item had a gold
 * inscriptional label and a rule.
 *
 * Three sizes, and everything belongs to one of them:
 *   --tip-title  the name, and nothing else
 *   --tip-read   the rows you actually read — the header labels and the stats
 *   --tip-fine   fine print — provenance, effects prose, flavour
 *
 * Three faces, chosen by what the row IS rather than by where it sits:
 *   --font-display  the gold caps label, like every other heading in the HUD
 *   --font-num      lines that are mostly figures (rarity · iLvl)
 *   --font-body     everything you read as prose
 */
.item-tooltip {
    --tip-title: 14px;
    --tip-read: 11px;
    --tip-fine: 10px;

    /* The panel's own type, so anything that lands in either tooltip without
       a class of its own inherits the scale instead of the page's 16px
       default — which is what the header row was quietly doing. */
    font: var(--tip-read) var(--font-body);

    position: fixed;
    z-index: 100;
    width: min(260px, calc(100vw - 16px));
    padding: 8px 10px;
    border: 1px solid var(--edge);
    border-radius: 2px;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.04), transparent 40%),
        rgba(18, 14, 10, 0.97);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.55);
    pointer-events: none;
}

.item-tooltip-name {
    font: 600 var(--tip-title) var(--font-body);
    margin: 0 0 3px;
}

/* Slot on the left, item type on the right — the two facts you compare when
   deciding whether a drop replaces what you are wearing. The slot was missing
   entirely, so a tooltip could tell you it was Mail without telling you
   whether it went on your chest or your legs. */
.item-tooltip-slotline {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 10px;
    margin: 0 0 5px;
    padding-bottom: 4px;
    border-bottom: 1px solid var(--edge-dim);
}

.item-tooltip-slot {
    font: 600 var(--tip-read) var(--font-display);
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--gold);
}

.item-tooltip-type {
    font: var(--tip-read) var(--font-body);
    color: var(--muted);
}

.item-tooltip-meta {
    font: var(--tip-fine) var(--font-num);
    color: var(--muted);
    margin: 0 0 6px;
}

.item-tooltip-stats {
    list-style: none;
    margin: 0 0 4px;
    padding: 0;
    font: var(--tip-read) var(--font-body);
    color: #b6d98a;
}

.item-tooltip-stats li {
    margin: 1px 0;
}

/* The two prose blocks — an item's "Use:" line and an ability's flavour — are
   the same kind of row and share one declaration, which is what stops them
   drifting apart again. Colour is the only thing that separates them: an
   effect is something the thing DOES, flavour is only atmosphere. */
.item-tooltip-effects,
.ability-tooltip-flavor {
    font: italic var(--tip-fine) var(--font-body);
}

.item-tooltip-effects {
    list-style: none;
    margin: 4px 0 0;
    padding: 0;
    color: #ffd968;
}

.item-tooltip-effects li {
    margin: 2px 0;
}

.chat-panel > div {
    animation: chatFadeIn 0.25s ease-out;
    overflow-wrap: anywhere;
}

/* ---- Bottom navbar ---- */

.bottom-nav {
    position: absolute;
    left: var(--bottom-nav-inset);
    bottom: var(--bottom-nav-inset);
    z-index: 40;
    display: flex;
    align-items: center;
    gap: 2px;
    height: var(--bottom-nav-height);
    /* The viewport floor. The column edge is the tighter cap and is applied
       above 1200px only — see the bottom-left column block further down, which
       is where the reasoning and both breakpoints live. */
    max-width: calc(100vw - (var(--bottom-nav-inset) * 2));
    padding: 0 7px;
    /* NB: `position: absolute` above is load-bearing and must not be replaced
       with `relative` to host the ornament pseudo-elements — an absolutely
       positioned box is already a containing block for them, and overriding it
       drops the bar out of its corner and back into normal flow at the top of
       the page. */
    border: 1px solid var(--edge);
    border-radius: 2px;
    /* One decoration per corner. The brass end-cap seams that used to live
       here sat 4px in from each end — two pixels from the corner ticks that
       ::after draws at inset 2px — so every corner carried two near-parallel
       gold marks doing the same job. The ticks are the better of the two
       (they read as a bracket rather than a stray line), so the seams go. */
    background: linear-gradient(180deg, #2a231a 0%, #15110c 100%);
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 4px 16px rgba(0, 0, 0, 0.6);
}

/* A hairline gold rule along the top edge, brightest at the middle — the same
   fade the .rule-ornament uses, so the island belongs to the same set as the
   panels above it. */
.bottom-nav::before {
    content: '';
    position: absolute;
    left: 8px;
    right: 8px;
    top: -1px;
    height: 1px;
    pointer-events: none;
    background: linear-gradient(90deg, transparent, var(--gold-dim) 30%, var(--gold) 50%, var(--gold-dim) 70%, transparent);
    opacity: 0.75;
}

/* Corner ticks, matching .forged-corners on the auth card. */
.bottom-nav::after {
    content: '';
    position: absolute;
    inset: 2px;
    pointer-events: none;
    opacity: 0.5;
    background-repeat: no-repeat;
    background-image:
        linear-gradient(var(--gold), var(--gold)), linear-gradient(var(--gold), var(--gold)),
        linear-gradient(var(--gold), var(--gold)), linear-gradient(var(--gold), var(--gold));
    background-size: 7px 1px, 1px 7px, 7px 1px, 1px 7px;
    background-position: left top, left top, right bottom, right bottom;
}

.nav-tab {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    height: calc(var(--bottom-nav-height) - 12px);
    padding: 0 8px;
    border: 1px solid transparent;
    border-radius: 2px;
    background: transparent;
    color: var(--muted);
    font: 600 10px var(--font-display);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    cursor: var(--cur-point);
    transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.nav-tab:hover {
    color: var(--parchment);
    background: rgba(212, 175, 95, 0.08);
}

/* The open dock's tab is lit brass with a gold underline, so which panel is
   open reads off the rail without having to look up at the panel. */
.nav-tab.nav-tab-active {
    color: var(--gold-bright);
    background: linear-gradient(180deg, rgba(212, 175, 95, 0.2), rgba(212, 175, 95, 0.05));
    border-color: var(--edge);
    border-bottom-color: var(--gold);
    box-shadow: inset 0 1px 0 var(--edge-lit);
}

.nav-tab-icon {
    position: relative;
    display: inline-flex;
    width: 16px;
    height: 16px;
}

.nav-tab-icon svg {
    width: 100%;
    height: 100%;
}

.nav-divider {
    width: 1px;
    /* 55% of the bar, stated as a LENGTH rather than a percentage. A
       percentage height resolves against the parent's *specified* height, and
       only .bottom-nav has one — a divider nested in any of its flex children
       (.nav-status-group, say) silently collapsed to 1x0 and vanished with no
       warning from anywhere. */
    height: calc(var(--bottom-nav-height) * 0.55);
    margin: 0 4px;
    /* Fades at both ends rather than a hard bar — a full-height rule between
       every tab read as a table border. */
    background: linear-gradient(180deg, transparent, var(--edge) 25%, var(--edge) 75%, transparent);
}

/* The status items carry their own spacing. The bar's own `gap` is 1px, which
   is what the tab buttons want and nowhere near enough for four unrelated
   readouts — without this the dot, the perf figures and the log-out button
   run together into one unreadable clump. */
.nav-status-group {
    display: flex;
    align-items: center;
    gap: 10px;
    min-width: 0;
}

/* Class emblem, in place of the character name and class. Sized to match the
   tab icons so the bar reads as one row of glyphs. */
.nav-status-icon {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 22px;
    height: 22px;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: var(--panel-sunk);
    box-shadow: inset 0 1px 0 var(--edge-lit);
}

.nav-status-icon[hidden] {
    display: none;
}

.nav-status-icon img {
    width: 18px;
    height: 18px;
    display: block;
}

/* Out of world it carries words ("Sign in to play"); in world it is emptied
   and the emblem beside it does the talking, so it must not hold width. */
.nav-status:empty {
    display: none;
}

/* The rule inside the status group drops its own margin: the group already
   spaces its children by 10px, and the 4px the tab dividers carry (they sit in
   .bottom-nav, whose gap is only 2px) would stack on top of that and give this
   one noticeably more air than the four to its left. */
.nav-status-group .nav-divider {
    margin: 0;
}

.nav-status {
    max-width: 132px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 11px;
    color: var(--muted);
}

.nav-perf {
    white-space: nowrap;
    font: 600 10px var(--font-num);
    font-variant-numeric: tabular-nums;
    color: var(--muted-dim);
}

/* Three digits' worth of box for each figure, whatever it currently reads.
   The navbar is sized by its content and the chat dock is sized to MATCH it
   (--nav-width, published by navbar.js), so an fps that ticked 59 → 100 → 60
   resized the bar, every tab on it and the dock underneath, once a second.
   Monospace by --font-num, so 3ch is exactly three digits.

   WHICH EDGE the figure sits against is the whole difference between a
   reserved box you notice and one you do not. The spare column has to fall
   where there is already a break in the line, never between two things that
   read as one:

     fps  — trails, so it stays against the "fps" it labels. Its slack lands at
            the head of the readout, beside the connection pill.
     ms   — leads, so it stays against the "·" that introduces it and grows
            rightward into its own slack. Trailing it put a whole blank digit
            between "fps ·" and the number, which read as a gap in the middle
            of the bar rather than as alignment. */
.nav-perf-value {
    display: inline-block;
    min-width: 3ch;
    text-align: left;
}

.nav-perf-value--trail {
    text-align: right;
}

.nav-perf-sep {
    margin: 0 4px;
    font-size: 15px;
    line-height: 0;
    vertical-align: middle;
    color: var(--gold-dim);
}

/* Units are back, just quieter than the figures they label — dropping them
   entirely made two bare numbers that only meant something if you already
   knew the order. */
.nav-perf-unit {
    margin-left: 2px;
    font-size: 8px;
    letter-spacing: 0.04em;
    color: var(--muted-dim);
}

/* Icon-sized session buttons. These were two full .ui-btn plates — 
   uppercase Cinzel, 14px padding — for actions taken once a session. */
.nav-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 21px;
    height: 21px;
    padding: 0;
    font-size: 11px;
    line-height: 1;
    color: var(--muted);
    background: linear-gradient(180deg, #3a3228 0%, #241e17 100%);
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    cursor: var(--cur-point);
    transition: color 120ms ease, border-color 120ms ease;
}

.nav-icon-btn:hover {
    color: var(--gold);
    border-color: var(--edge);
}

.nav-conn {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    min-width: 0;
    font-size: 11px;
    color: var(--muted);
}

.nav-conn-label {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    max-width: 180px;
}

.nav-conn-dot {
    flex: none;
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: var(--muted-dim);
}

.nav-conn[data-state='connected'] .nav-conn-dot {
    background: #34d399;
    box-shadow: 0 0 6px rgba(52, 211, 153, 0.7);
    animation: navConnPulse 2s ease-in-out infinite;
}

/* The label only earns its space when the news is bad — connected is the
   state you assume, and the dot plus its title attribute cover it. */
.nav-conn[data-state='connected'] .nav-conn-label {
    display: none;
}

.nav-conn[data-state='disconnected'] .nav-conn-dot {
    background: #ef4444;
    box-shadow: 0 0 6px rgba(239, 68, 68, 0.6);
}

.nav-conn[data-state='disconnected'] .nav-conn-label {
    color: #f87171;
}

@keyframes navConnPulse {
    0%, 100% { transform: scale(1); opacity: 1; }
    50% { transform: scale(1.25); opacity: 0.75; }
}

.nav-session {
    display: flex;
    align-items: center;
    gap: 4px;
    /* No margin: this sits FIRST in .nav-status-group now, whose own gap
       spaces it from the name. */
    /* The confirm bubble anchors to this, not to the viewport. */
    position: relative;
}

/* The icon buttons carry glyphs at 11px; an SVG needs its own size, and
   currentColor is what makes it inherit the hover transition above. */
.nav-icon-btn svg {
    width: 14px;
    height: 14px;
}

/* Leave-world confirmation. Anchored above the button that opened it, because
   the action is one click away and reversible — it wants a check, not a
   full-screen modal that stops the world to ask. */
.confirm-bubble {
    position: absolute;
    bottom: calc(100% + 10px);
    right: 0;
    z-index: 60;
    min-width: 190px;
    padding: 12px;
    background: linear-gradient(180deg, #2a231b 0%, #1a1610 100%);
    border: 1px solid var(--edge-dim);
    border-radius: 4px;
    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.55);
}

.confirm-bubble[hidden] {
    display: none;
}

/* The pointer down to the button. */
.confirm-bubble::after {
    content: '';
    position: absolute;
    top: 100%;
    right: 14px;
    border: 6px solid transparent;
    border-top-color: #1a1610;
}

.confirm-bubble-text {
    margin: 0 0 10px;
    font-size: 12px;
    color: var(--parchment, #e8dcc0);
}

.confirm-bubble-actions {
    display: flex;
    gap: 6px;
    justify-content: flex-end;
}

.confirm-bubble-btn {
    padding: 4px 12px;
    font-size: 11px;
}

/* "A new version is available" (update_notice.js): the confirm bubble's look,
 * pinned top-centre over both the lobby and the world. Non-blocking: only the
 * card itself takes pointer events. */
.update-notice {
    position: fixed;
    top: 14px;
    /* Centred by margins, not left:50% + translate: that shrink-wraps the
     * card to half the viewport, which on a phone wrapped the line to four. */
    left: 0;
    right: 0;
    margin: 0 auto;
    width: max-content;
    z-index: 1000;
    display: flex;
    align-items: center;
    gap: 10px;
    max-width: calc(100vw - 32px);
    box-sizing: border-box;
    padding: 5px 6px 5px 12px;
    background: linear-gradient(180deg, #2a231b 0%, #1a1610 100%);
    border: 1px solid var(--gold-dim);
    border-radius: 4px;
    box-shadow: 0 6px 16px rgba(0, 0, 0, 0.5);
}

.update-notice[hidden] {
    display: none;
}

.update-notice-text {
    margin: 0;
    font-size: 12px;
    line-height: 1.2;
    white-space: nowrap;
    color: var(--parchment, #e8dcc0);
}

.update-notice-short {
    display: none;
}

/* One slim line on a phone: the short text, the pill, the cross. */
@media (max-width: 520px) {
    .update-notice-long {
        display: none;
    }

    .update-notice-short {
        display: inline;
    }
}

.update-notice-reload {
    flex-shrink: 0;
    padding: 3px 10px;
    font: inherit;
    font-size: 11px;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: #1a1610;
    background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 100%);
    border: 0;
    border-radius: 999px;
    cursor: var(--cur-point);
}

.update-notice-reload:hover {
    filter: brightness(1.08);
}

.update-notice-dismiss {
    flex-shrink: 0;
    width: 22px;
    height: 22px;
    padding: 0;
    font-size: 16px;
    line-height: 1;
    color: var(--gold-dim);
    background: none;
    border: 0;
    cursor: var(--cur-point);
}

.update-notice-dismiss:hover {
    color: var(--gold-bright);
}

/* The bottom-left column, held to one width.
 *
 * The chat dock sizes itself from the bar (--nav-width) so the two islands read
 * as one column, and yields before its right edge reaches the centred hotbar,
 * which shares its band. The bar had no such limit, so as it grew — a fifth nav
 * tab, then three digits' worth of reserved room in the perf readout — it went
 * past the line the dock stops at and overhung it by 40px at 1600x900.
 *
 * Capping the bar alone does not work: it wants 663px with its tab labels, and
 * MEASURED, at 1440 it has nothing left to give (the status text is already
 * ellipsised to zero) and its tabs spill outside their own border. So the
 * labels come off first. Icon-only the bar wants 426px, which fits the column
 * from 1206px of viewport upward.
 *
 * Hiding the label is why every .nav-tab now carries `title` AND `aria-label`.
 * `display: none` takes the span out of the accessibility tree as well as off
 * the screen, so without them these become five icon buttons with no
 * accessible name at all — and no tooltip either, which is the difference
 * between an icon you can learn and one you have to guess at.
 *
 * Two breakpoints, both derived rather than chosen:
 *   1680 — where the column is finally wide enough for the labelled bar
 *          (663 + 177 of hotbar clearance, doubled).
 *   1200 — below which even the icon-only bar cannot fit the column, so the
 *          cap comes off and the bar goes back to overhanging. A bar that
 *          overflows is worse than one that overhangs.
 * Between them the .nav-status ellipsis absorbs the difference, which is what
 * lets a long character name pass through both regimes without overflowing. */
@media (max-width: 1679px) {
    .nav-tab-label {
        display: none;
    }
}

@media (min-width: 1200px) {
    .bottom-nav {
        max-width: min(
            calc(100vw - (var(--bottom-nav-inset) * 2)),
            var(--left-island-max)
        );
    }
}

@media (max-width: 640px) {
    /* No --hud-card-gap override here any more. The gap is derived from the
       viewport and the dock column now, so it has already closed to its
       minimum long before phone width — this block used to force it to 10px
       and that is exactly what the clamp does on its own. */

    /* .nav-tab-label is already hidden by the rule above — the phone-width
       block only has to deal with what is left. */
    .nav-status {
        max-width: 110px;
    }

    .nav-perf {
        display: none;
    }

    .nav-conn-label {
        max-width: 80px;
    }
}

/* ---- Death card ---- */

/* Above the HUD but under the sign-in modal (z-index 50): being killed while
   the socket drops must not put a Rebirth button over the character select.
   pointer-events back on, because .game-overlay turns them off for the whole
   HUD layer and this is the one thing in it you have to be able to click. */
.death-screen {
    position: absolute;
    inset: 0;
    z-index: 45;
    display: flex;
    align-items: center;
    justify-content: center;
    pointer-events: auto;
    /* Oxblood at the centre rather than a neutral scrim. The dark ring still
       does the work of pulling the eye in; the warmth is what stops it reading
       as a loading screen. */
    background:
        radial-gradient(ellipse at center, rgba(70, 14, 11, 0.42) 0%, rgba(6, 4, 4, 0.9) 72%);
    animation: deathFadeIn 0.45s ease-out;
}

.death-screen[hidden] {
    display: none;
}

/* Slow on purpose. A card that snaps in reads as an error dialog; this is
   supposed to feel like the lights going down. */
@keyframes deathFadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

.death-card {
    width: min(380px, calc(100% - 2rem));
    padding: 26px 28px 22px;
    text-align: center;
}

.death-title {
    margin: 0;
    font: 600 24px var(--font-display);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--oxblood-bright);
    /* Struck into the panel, like .auth-title, but glowing oxblood rather than
       gold — gold is the player's own colour and this is the one moment that
       is not theirs. */
    text-shadow: 0 1px 0 rgba(0, 0, 0, 0.85), 0 0 22px rgba(168, 57, 47, 0.35);
}

/* The killing blow. Italic and quiet: it is the caption under the headline,
   and it has to hold a name plus an ability without wrapping into the button. */
.death-blow {
    margin: 12px 0 0;
    font: 13px var(--font-body);
    font-style: italic;
    line-height: 1.45;
    color: var(--muted);
}

.death-rebirth {
    margin-top: 20px;
    width: 100%;
}

/* ---- Pop-up docks ---- */

/* The dock row.
 *
 * `wrap-reverse` rather than a hard breakpoint: docks are individually
 * toggleable, so the worst case is however many the player happens to have
 * open, and a fixed row simply overflowed once that total passed the
 * viewport. Wrapping in reverse keeps the first dock nearest the navbar and
 * grows the stack UPWARD, which is the direction there is room in. */
.dock-row {
    position: absolute;
    left: var(--bottom-nav-inset);
    /* One gap above the navbar — same band the centred hotbar uses. Chat
       used to clear the hotbar VERTICALLY (slot + gap), which left an empty
       strip under the left-hand docks equal to the whole action bar. The
       hotbar is centre-anchored; the column already yields horizontally via
       --left-island-max, so it shares the vertical band instead of stacking
       a dead lane between chat and nav. */
    bottom: calc(
        var(--bottom-nav-height)
        + var(--bottom-nav-inset)
        + var(--bottom-stack-gap)
    );
    z-index: 35;
    display: flex;
    /* COLUMN-REVERSE: the docks are one bottom-left column now — chat at the
       bottom (first in the DOM, so first from the bottom), the Character/Bag
       pair above it, the journal above that. Reverse order is what anchors
       the stack to the navbar and grows it upward, without the wrap-reverse
       cross-axis flip that broke the old row layout. */
    flex-direction: column-reverse;
    align-items: stretch;
    gap: 0.5rem;
    /* ONE width for every dock in the column, and it is the only width any of
       them gets. Chat, the Character/Bag pair and the journal all stretch to
       it, so the pair's combined width IS the chat pane's width — not by
       arithmetic that has to be kept in step, but because there is a single
       number.
     *
     * That single number is what stops the panes SNAPPING. Each dock used to
     * carry its own width, and the column widened when both pair docks were
     * open, so opening the Bag resized the Character pane under the cursor
     * and closing it resized it back.
     *
     * The number itself is --dock-column-width, defined on :root rather than
     * here because the HUD card band is positioned against this column's right
     * edge and needs to read it. It used to also yield to the cards
     * (--card-island-max, now gone); that dependency runs the other way now —
     * the column takes its width and the cards stop short of it. */
    width: var(--dock-column-width);
    max-height: calc(
        100vh
        - var(--bottom-nav-height)
        - var(--bottom-nav-inset)
        - var(--bottom-stack-gap)
        - 1rem
    );
    pointer-events: none;
}

/* Character + Bag share the column's width, hanging above the chat pane.
 *
 * A GRID with two explicit tracks, not a flex row, and that is the whole
 * anti-snap mechanism: an explicit track exists whether or not anything is
 * placed in it, so the Character pane is exactly 1.4fr of the column with the
 * Bag open and 1.4fr of it with the Bag shut. Under flex the open pane
 * absorbed the closed one's space and every toggle resized its neighbour.
 *
 * 1.4fr : 1fr rather than even halves — the paperdoll's middle column is art
 * and wants the room, while the bag grid reflows its own slot columns to
 * whatever it gets. The wrapper hides when both docks are shut, or the
 * column's gap would leave a 16px dead band in the middle of the stack. */
.dock-pair {
    display: grid;
    grid-template-columns: 1.4fr 1fr;
    align-items: end;
    gap: 0.5rem;
    /* The one column item allowed to give ground: chat and the journal keep
       their heights, and when the column hits its ceiling the pair absorbs
       the shortfall — its docks' bodies scroll, theirs would clip. */
    flex: 0 1 auto;
    min-height: 0;
    pointer-events: none;
}

.dock-pair:not(:has(> .dock:not([hidden]))) {
    display: none;
}

/* ---- Layout regimes ----
 *
 * Three, and both boundaries are derived from geometry rather than picked.
 *
 * WIDE (>= 1208). The menu bar and the docks keep the bottom-left corner, the
 * map keeps the bottom-right, and the two HUD cards flank the hotbar with the
 * player card clamped so its left edge never crosses the bar. Nothing
 * overlaps: the cards yield, the column does not.
 *
 * SIDES (1024 .. 1207). Below 1208 that clamp stops being satisfiable. The
 * limit wants 452px (a 428px menu bar plus insets and a gutter) while the
 * centre is at 50vw, so a card that stops shrinking at 148px no longer fits
 * between them — measured at 1100, there is 102px of room for it. Rather than
 * pin two stunted cards against the bar, the BAR moves: it goes under the
 * action bar, the docks take the corner it vacated and run to the bottom, and
 * the cards are free to close on the centre.
 *
 *   1207 = the last width at which 50vw - 4 - (8 + 428 + 16) >= 148.
 *   The 428 is the icon-only menu bar; its labels are hidden below 1680
 *   (see .nav-tab-label), so the bar is a step function and this holds
 *   across the whole band.
 *
 * STACKED (<= 1023). A left pane, the hotbar and the map stop fitting side by
 * side: 340 + 306 + 320 and four gutters is 1030. So the panes give up the
 * edges, open above the action bar, and go one-at-a-time — see the small
 * viewport block below and SMALL_VIEWPORT in navbar.js.
 *
 * The old lift at 1355 is gone with all of this. It existed because the
 * column and the cards fought over the same band; they no longer do. */
@media (max-width: 1207px) {
    :root {
        /* The clamp STAYS here and only loses the MENU BAR from it. The bar
           is centred under the action bar from this width down, so it is no
           longer something the cards can run into; the docks still hold the
           left edge, so the limit falls back to the column alone. That is
           also what buys the cards their room — at 1100 the limit drops from
           452 to 397 and the pair closes on the centre instead of stalling.
         *
           Releasing the clamp ENTIRELY was the first attempt and it is wrong:
           with no limit the derived gap jumps straight to its 236px maximum,
           which parks the cards 118px either side of centre. Measured at 1100
           that put the player card at 236..432 against a chat pane ending at
           381 — 145px of health bar laid over the conversation. The cards
           keep yielding; it is only the bar that left. */
        --hud-card-left-limit: calc(
            var(--bottom-nav-inset)
            + var(--dock-column-width)
            + var(--hotbar-gutter)
        );
    }

    /* The bar joins the centre stack: hotbar over menu bar, both centred.
       Left-anchored it read as a stray toolbar once the cards took the
       middle, and it is the thing the cards could not get past. */
    .bottom-nav {
        left: 50%;
        transform: translateX(-50%);
    }

    /* The docks do NOT take the corner the bar left, though this used to say
       they should. The bar is centred now, but it is 424px wide, so it still
       reaches back into the column's lane until the viewport is wide enough to
       clear it: the column's right edge (inset + 340px) plus a gutter has to be
       left of 50vw - 212px, which needs ~1136px.
     *
       Below that the two shared the bottom band. Measured at 1024x768: chat
       x 8..348 and the bar x 300..724, both in the bottom 40px — 48px of the
       chat pane under the menu bar, at the default layout, with nothing open
       but chat and the map. A clamp keyed on the bar's width would fix exactly
       that and break the day a tab is added, so the column keeps the same
       floor it has on WIDE instead: above the bar, always. It costs the pane
       56px of height between 1136 and 1207, where it would have fitted. */
}

.dock-pair > .dock-character {
    grid-column: 1;
    min-width: 0;
    /* Stretch, not end: when the column runs out of height the pair box
       shrinks, and cross-axis stretch is what actually passes the shrunk
       height down to the dock — a percentage max-height does not re-resolve
       after flexing (measured: the doll dock kept its natural 691px and
       overflowed straight through the journal above it). Stretch never makes
       the dock TALLER than its content wants, because the pair's own height
       comes from this dock's bounded natural height in the first place. */
    align-self: stretch;
}

.dock-pair > .dock-inventory {
    grid-column: 2;
    min-width: 0;
    /* Shorter ceiling than the generic dock's 66vh. The bag is end-aligned,
       so a bag tall enough to exceed the column's budget would overflow the
       pair box UPWARD into the journal instead of shrinking with it.
     *
       The 46vh cap alone did not stop that, and the comment used to say it
       did. It bounds the bag against the VIEWPORT, but the column shrinks the
       pair against its own budget — chat and the journal keep their heights
       and the pair absorbs the rest — so on a short window the pair can end up
       well under 46vh. Measured at 1180x640 with every dock open: the pair
       squeezed to 195px, the character dock stretched down to it, and the bag
       kept its natural 274px and climbed 71px over the journal.
     *
       So the bag is also bounded by the PAIR (100% of its grid area), and its
       body scrolls instead, exactly as the character dock's does. On a tall
       window this changes nothing — measured at 1920x1080, 157px with or
       without it — because there the grid area is already taller than the bag. */
    max-height: min(46vh, 380px, 100%);
}


.dock {
    display: flex;
    flex-direction: column;
    min-height: 0;
    max-height: min(66vh, 540px);
    border: 1px solid var(--edge);
    border-radius: 2px;
    overflow: hidden;
    background: linear-gradient(180deg, rgba(255, 240, 200, 0.04), transparent 30%),
        rgba(21, 17, 12, 0.96);
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 8px 24px rgba(0, 0, 0, 0.6);
    pointer-events: auto;
    animation: dockIn 0.16s ease-out;
}

.dock[hidden] {
    display: none;
}

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

/* NO WIDTH HERE, deliberately. Chat takes the column's width like every other
   dock — that is what makes the Character/Bag pair above it measure the same.
   Chat briefly carried its own narrower formula (it shares its vertical band
   with the HUD cards, which the pair does not), and the panes stopped lining
   up; the card clearance moved into the column's own width instead, so the
   whole stack yields together. Height is a starting point, not a rule:
   --chat-height is rewritten by the drag handle and restored from
   localStorage. */
.dock-chat {
    flex: none;
    height: var(--chat-height, 260px);
    min-height: 140px;
    max-height: calc(
        100vh
        - var(--bottom-nav-height)
        - var(--bottom-nav-inset)
        - var(--bottom-stack-gap)
        - 1.5rem
    );
}

/* Grab strip along the top edge — the right affordance for a panel pinned to
   the bottom of the screen, where the browser's own `resize` corner would sit
   next to the send button and grow the box the wrong way. */
.dock-resize {
    flex: none;
    height: 7px;
    cursor: var(--cur-resize-ns);
    background:
        linear-gradient(90deg, transparent, var(--edge) 25%, var(--edge) 75%, transparent) center / 100% 1px no-repeat,
        linear-gradient(180deg, #2a2318, #1a150f);
    border-bottom: 1px solid var(--edge-dim);
}

.dock-resize:hover,
.dock-resize:focus-visible,
.dock-resize.is-dragging {
    background:
        linear-gradient(90deg, transparent, var(--gold) 25%, var(--gold) 75%, transparent) center / 100% 1px no-repeat,
        linear-gradient(180deg, #3a3020, #241d13);
    outline: none;
}

/* While dragging, the pointer leaves the 7px strip constantly; killing
   selection and pointer events on everything else keeps the drag alive and
   stops the chat log being highlighted blue on the way past. */
body.is-resizing-dock {
    user-select: none;
    cursor: var(--cur-resize-ns);
}

/* The doll needs 38px columns either side of the figure, and the column is
   tall: seven slot rows plus the weapon rail plus the attribute ledger. The
   generic dock ceiling would clip the ledger mid-row, so this dock gets a
   taller one; on short viewports the body still scrolls. */
/* Wide enough that the figure between the slot columns is ART, not a sliver —
   the 150x290 class figures set the width and the dock accommodates them,
   not the other way around. */
.dock-character {
    /* The dock's width comes from the pair (or the small-viewport column),
       so its INTERNALS adapt to whatever they get: container queries below
       pick the full doll, the side-by-side ledger, or the lightweight
       horizontal layout by the dock's own measured width — the same layout
       at 1400px of viewport and 340px of pane. */
    container-type: inline-size;
    container-name: chardock;
    /* 76vh: on an 800px viewport that is exactly the full doll + ledger with
       the dock's top edge kissing the chat dock below. Taller viewports hit
       the 640px cap; shorter ones scroll the body. */
    max-height: min(76vh, 640px);
}

.dock-inventory {
    min-width: 170px;
}

/* Wider than the bag: quest cards carry sentences, not item names. In the
   column it stretches like chat does. */
.dock-quest {
    flex: none;
}

/* There is no ledger-beside-the-doll tier any more. It existed because a
   ledger unfolded UNDER the doll made the pane taller than a maximised
   browser, and putting it in a second column was the only way to buy that
   height back — at the cost of a pane wide enough to hold both, which the
   dock column no longer hands out. Folding the ledger into a dropdown solves
   the same problem without spending any width, so every pane width now gets
   the same stacked layout. */

/* ---- Character pane modes ----
 *
 * Which layout the pane gets is decided ONCE, as --char-pane on the gear
 * section, and everything else keys off that value:
 *
 *   full  — the paperdoll, figure between the slot columns
 *   light — no figure, slots in wrapped rows
 *
 * The Attributes ledger is a collapsed dropdown in BOTH, so it is not part of
 * this decision any more and inventory.js no longer reads this property.
 *
 * Two rules can set it to `light`, and they are asking different questions.
 * The container query asks whether the PANE is wide enough for the figure to
 * be art rather than a cropped vertical strip: 280px leaves the middle column
 * ~156px after the two 38px slot rails, their gaps and 36px of panel and body
 * padding. The threshold sits well clear of what the pair actually hands the
 * pane at common viewports (249px at 1600) ON PURPOSE — a breakpoint one pixel
 * from the usual case is a breakpoint that flickers between layouts on a
 * window drag. The media query asks whether the VIEWPORT can afford the doll's
 * height at all — in the STACKED regime (1023px and below) a dock is capped at
 * 46vh, and the doll alone is ~400px. No container query can ask that: an
 * inline-size container has no height to query. */
#characterGearSection {
    --char-pane: full;
}

@container chardock (max-width: 279px) {
    #characterGearSection {
        --char-pane: light;
    }
}

@media (max-width: 1023px) {
    #characterGearSection {
        --char-pane: light;
    }
}

/* One copy of the light layout, selected by the value rather than by either
   condition that produced it. */
@container style(--char-pane: light) {
    .equipment-figure {
        display: none;
    }

    .equipment-grid {
        display: flex;
        flex-wrap: wrap;
        justify-content: center;
        gap: 5px;
    }

    /* The weapon rail's rule is a grid-area pseudo; in the flex fallback it
       would render as a stray bar between slots. */
    .equipment-grid::after {
        display: none;
    }
}

.dock-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    flex: none;
    padding: 7px 10px;
    border-bottom: 1px solid var(--edge);
    background: linear-gradient(180deg, #302819 0%, #201a12 100%);
    box-shadow: inset 0 1px 0 var(--edge-lit);
    font: 600 10px var(--font-display);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--gold);
}

/* The keyboard hint reads as a stamped key, not a browser chip. */
.dock-header-hint {
    font: 9px var(--font-num);
    color: var(--muted);
    background: var(--panel-sunk);
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    padding: 1px 5px;
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
}

.dock-body {
    min-height: 0;
    flex: 1;
    overflow-y: auto;
    padding: 8px 10px;
    overscroll-behavior: contain;
}

/* ---- Chat dock: Chat / Log / Players ---- */

.chat-tabs {
    display: flex;
    flex: none;
    gap: 0;
    padding: 0 6px;
    border-bottom: 1px solid var(--edge);
    background: linear-gradient(180deg, #302819 0%, #201a12 100%);
    box-shadow: inset 0 1px 0 var(--edge-lit);
}

.chat-tab {
    position: relative;
    padding: 7px 10px 6px;
    font: 600 10px var(--font-display);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--muted-dim);
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    cursor: var(--cur-point);
    transition: color 120ms ease, border-color 120ms ease;
}

.chat-tab:hover {
    color: var(--muted);
}

.chat-tab.is-active {
    color: var(--gold);
    border-bottom-color: var(--gold);
}

/* The roster's size is the thing you actually want off this tab, so it rides
   on the tab itself rather than needing the panel opened to be read. */
.chat-tab-count {
    margin-left: 5px;
    padding: 1px 4px;
    border-radius: 2px;
    font: 600 9px var(--font-num);
    color: var(--parchment);
    background: rgba(0, 0, 0, 0.4);
    border: 1px solid var(--edge-dim);
}

/* Unread mark. Combat now lands on a tab you may not be looking at, so the
   panel has to be able to say something arrived. */
.chat-tab.has-unread::after {
    content: '';
    position: absolute;
    top: 5px;
    right: 4px;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--gold);
    box-shadow: 0 0 5px rgba(212, 175, 95, 0.8);
}

.chat-panel {
    min-height: 0;
    flex: 1;
    overflow-y: auto;
    padding: 8px 10px;
    overscroll-behavior: contain;
    scrollbar-width: thin;
    scrollbar-color: var(--edge) transparent;
}

.chat-panel[hidden] {
    display: none;
}

#chatControls {
    display: flex;
    flex: none;
    gap: 6px;
    padding: 7px 8px;
    border-top: 1px solid var(--edge);
    background: linear-gradient(180deg, #241e15 0%, #17130d 100%);
}

#chatControls .field-input {
    min-width: 0;
    flex: 1;
    padding: 6px 9px;
    font-size: 12px;
}

#chatControls .ui-btn {
    flex: none;
    padding: 6px 12px;
    font-size: 10px;
}

/* ---- Chat dock: Report tab (bug_report.js) ---- */

/* The message row belongs to the Chat tab; under a form it is a second,
   confusing text box. */
#chatDock:has(> .report-panel:not([hidden])) #chatControls {
    display: none;
}

.report-form {
    display: flex;
    flex-direction: column;
    gap: 7px;
    min-height: 100%;
}

.report-kind {
    display: flex;
    gap: 0;
    align-self: flex-start;
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    background: var(--panel-sunk);
}

.report-kind-opt {
    position: relative;
    cursor: var(--cur-point);
}

.report-kind-opt input {
    position: absolute;
    opacity: 0;
    pointer-events: none;
}

.report-kind-opt span {
    display: block;
    padding: 4px 11px;
    font: 600 10px var(--font-display);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--muted-dim);
    transition: color 120ms ease, background 120ms ease;
}

.report-kind-opt + .report-kind-opt span {
    border-left: 1px solid var(--edge-dim);
}

.report-kind-opt:hover span {
    color: var(--muted);
}

.report-kind-opt input:checked + span {
    color: #1a1409;
    background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 60%, #a5813a 100%);
}

.report-kind-opt input:focus-visible + span {
    outline: 1px solid var(--gold);
    outline-offset: 1px;
}

.report-text {
    flex: 1;
    min-height: 64px;
    padding: 6px 9px;
    font-size: 12px;
    line-height: 1.4;
    resize: none;
}

.report-row {
    display: flex;
    align-items: center;
    gap: 8px;
    min-height: 26px;
}

.report-row-end {
    justify-content: flex-end;
}

.report-form .ui-btn {
    flex: none;
    padding: 5px 11px;
    font-size: 10px;
}

.report-shot {
    position: relative;
    display: inline-flex;
    flex: none;
}

.report-shot[hidden] {
    display: none;
}

.report-shot img {
    display: block;
    height: 34px;
    width: auto;
    max-width: 72px;
    object-fit: cover;
    border: 1px solid var(--edge);
    border-radius: 2px;
    box-shadow: 0 1px 4px rgba(0, 0, 0, 0.6);
}

.report-shot-remove {
    position: absolute;
    top: -6px;
    right: -6px;
    width: 16px;
    height: 16px;
    padding: 0;
    font: 600 12px/14px var(--font-body);
    color: var(--parchment);
    background: var(--oxblood);
    border: 1px solid var(--oxblood-bright);
    border-radius: 50%;
    cursor: var(--cur-point);
}

.report-shot-remove:hover {
    background: var(--oxblood-bright);
}

.report-count {
    margin-left: auto;
    font: 10px var(--font-num);
    color: var(--muted-dim);
}

.report-count.is-near {
    color: var(--gold);
}

.report-status {
    flex: 1;
    min-width: 0;
    font: italic 11px var(--font-body);
    color: var(--muted);
    overflow-wrap: anywhere;
}

.report-status.is-ok {
    color: var(--gold-bright);
}

.report-status.is-error {
    color: #e08a7f;
}


/* ---- Small viewports ----
 *
 * Below this the four docks cannot sit side by side at any useful size, so
 * they stop trying: the row becomes a single full-width column and the map
 * joins it instead of floating over the top of it. navbar.js opens NOTHING by
 * default at this size (see SMALL_VIEWPORT there) — on a phone-width screen a
 * dock that defaults open is just a wall between the player and the game.
 */
@media (max-width: 1023px) {
    .dock-row {
        /* The column stops sizing from the navbar (which may be wider than
           the viewport wants) and lets each dock keep its own width — the
           stretch-to-column behaviour is what made the bag and map GROW as
           the viewport shrank. No scrolling: navbar.js runs the docks
           one-at-a-time at this width, so the column is never taller than
           one pane plus chat. */
        width: auto;
        max-width: min(460px, calc(100vw - 1rem));
        align-items: flex-start;
        max-height: none;
        /* ABOVE THE CARD BAND. This regime's own description says the panes
           "open above the action bar", and nothing made them: the column sat
           on the navbar, in the same band as the hotbar and the unit cards.
           Measured at 390x844 on the dev branch before this: the open map
           dock covered the navbar (238x40), the hotbar (226x46) and the
           player card (148x48) at once. Centred bars and a left column are
           horizontally apart only on a wide screen, and at this width even a
           single chat pane is 460px, so the lift is unconditional here.
         *
           Derived from the tokens the band is built from, not measured:
           --hud-card-band-bottom already stacks nav, inset, gap and hotbar. */
        bottom: calc(
            var(--hud-card-band-bottom)
            + var(--hud-card-height)
            + var(--bottom-stack-gap)
        );
    }

    /* The pair wrapper dissolves; its two docks stack in the column like
       everything else. */
    .dock-pair {
        display: contents;
    }

    /* Each dock keeps its wide-viewport size, capped by the viewport —
       shrinking is fine, growing is not. */
    .dock {
        max-height: 46vh;
        max-width: min(460px, calc(100vw - 1rem));
    }

    .dock-chat {
        width: min(460px, calc(100vw - 1rem));
        height: var(--chat-height, 220px);
        max-height: 46vh;
    }

    /* Sized, not stretched, and not grid items any more — the wrapper is
       `display: contents` here, so these are direct children of the vertical
       column. The pane is in light mode at this size regardless of its width
       (the --char-pane media rule above), so 300px buys wrapped slot rows
       rather than a doll it has no height for. */
    .dock-pair > .dock-character {
        width: min(300px, calc(100vw - 1rem));
        max-height: 46vh;
    }

    .dock-pair > .dock-inventory {
        width: 250px;
        max-height: 46vh;
    }

    .dock-quest {
        width: 280px;
    }

    /* Square now, so the height is the width: bounded by the viewport height
       as well, or on a short landscape phone the map alone is the whole
       column. Not movable at this size — map_frame.js turns the handles off
       and it keeps this anchor. */
    .dock.dock-map {
        --map-size: min(260px, calc(100vw - 1rem), 42vh);
    }

    /* The controls card is reference material and the stage is small; the chip
       stays, the open panel would cover a third of the view. */
    .help-panel {
        width: auto;
    }

    .help-panel .help-body,
    .help-panel .help-scheme,
    .help-panel .help-toggle-label {
        display: none;
    }

    .help-panel .help-toggle {
        padding: 5px;
        border-bottom: none;
    }

    /* The island loses its ornament here: at this width the ticks and the top
       rule land on top of the buttons rather than around them. Nothing else
       needs undoing — the background is the same plain gradient at every
       width now that the end-cap seams are gone. */
    .bottom-nav::before,
    .bottom-nav::after {
        display: none;
    }

    /* The nav is already centred from 1207 down (see the layout regimes
       block); nothing to do here but keep its ornaments off. */
}

/* ---- Wide and SIDES: the map keeps the right edge ----
 * It anchors to the right-hand corner in both regimes and only joins the
 * column when the panes give up the edges entirely (STACKED). */
@media (min-width: 1024px) {
    /* FIXED, not absolute: #mapDock's parent is .dock-row, which is itself
       positioned, so `absolute` pinned the map to the ROW's right edge — which
       is the edge held back to make room for the map. It ended up 352px inside
       the viewport, sitting on the character dock with a 320px strip of dead
       space beside it. Fixed resolves against the viewport, which is what the
       corner placement actually means.
     *
       This is the DEFAULT corner. From this width up the player can move and
       resize the map (map_frame.js writes left/top and --map-size inline); these
       declarations are first paint, before that script has run, and what a
       reset returns to. */
    #mapDock {
        position: fixed;
        right: 10px;
        bottom: 10px;
    }
}

/* ---- Auth / character-select modal ---- */

/* This is the first thing anyone sees, so it carries the theme hardest: a
   vignette rather than a flat scrim, so the world behind stays visible at the
   edges and the card sits in a pool of candlelight. */
.auth-modal {
    position: absolute;
    inset: 0;
    z-index: 50;
    /* Three rows: two equal spacers either side of the card, so the card sits
       at the true centre of the screen whether or not the news panel is
       showing, and the panel hangs from the top of the lower spacer. Where
       the panel is taller than that half of the screen, the modal scrolls
       rather than pushing the card up. */
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr) auto minmax(0, 1fr);
    justify-items: center;
    overflow-y: auto;
    padding: 1rem;
    background:
        radial-gradient(ellipse at center, rgba(20, 17, 13, 0.5) 0%, rgba(8, 6, 4, 0.88) 70%);
}

/* Once you are signed in, the scrim comes off.
   It earns its keep on the sign-in panel, which is a stranger's first sight of
   the game and wants the eye pulled to one card. By character select the player
   has already looked at it, twice a session forever after, and the only thing
   the vignette is still doing is hiding the scene they are about to walk into.
   The card carries its own forged panel and border, so it holds the foreground
   on its own. */
.auth-modal--clear {
    background: none;
}

/* Character select: the card steps to the right so the character preview
   (lobby_scene.js) can stand in the middle of the screen. A narrow screen has
   no room for both; the card stays centred there and the preview gives way. */
.auth-modal--clear > .auth-card {
    justify-self: end;
    margin-inline-end: clamp(0px, 6vw, 140px);
}

@media (max-width: 760px) {
    .auth-modal--clear > .auth-card {
        justify-self: center;
        margin-inline-end: 0;
    }
}

.auth-modal[hidden] {
    display: none;
}

.auth-card {
    grid-row: 2;
    width: min(360px, 100%);
    max-height: calc(100dvh - 2rem);
    overflow-y: auto;
    padding: 20px 22px 18px;
}

.auth-head {
    text-align: center;
    margin-bottom: 16px;
}

.auth-title {
    margin: 0;
    font: 600 32px var(--font-display);
    line-height: 1.15;
    letter-spacing: 0.09em;
    color: var(--gold);
    /* Struck into the panel (the dark 1px drop) and lit from within (the two
       gold halos), so the name carries the same glow as the flourish below. */
    text-shadow:
        0 1px 0 rgba(0, 0, 0, 0.85),
        0 0 12px rgba(240, 214, 138, 0.3),
        0 0 30px rgba(212, 175, 95, 0.22);
}

/* The sign-in card's divider (see index.html). The glow is a CSS drop-shadow
   over the whole drawing, so the rules, curls and star all bloom together; the
   halo circle behind the star breathes on its own, slowly. */
.auth-flourish {
    display: block;
    width: min(280px, 90%);
    height: auto;
    margin: 8px auto 0;
    overflow: visible;
    filter:
        drop-shadow(0 0 2px rgba(240, 214, 138, 0.55))
        drop-shadow(0 0 7px rgba(212, 175, 95, 0.35));
}

.auth-flourish-halo {
    fill: #f0d68a;
    opacity: 0.18;
    filter: blur(4px);
    transform-box: fill-box;
    transform-origin: center;
    animation: auth-flourish-breathe 4.5s ease-in-out infinite;
}

@keyframes auth-flourish-breathe {
    0%, 100% { opacity: 0.14; transform: scale(0.9); }
    50%      { opacity: 0.32; transform: scale(1.15); }
}

@media (prefers-reduced-motion: reduce) {
    .auth-flourish-halo {
        animation: none;
    }
}

.auth-subtitle {
    margin: 8px 0 0;
    font: 12px var(--font-body);
    font-style: italic;
    color: var(--muted);
}

/* Emptied rather than removed — the same element carries "Select a character"
   on the next panel — so it has to stop holding a line box and its top margin
   when there is nothing in it. Without this the signed-out card keeps a 20px
   gap where the sentence used to be. */
.auth-subtitle:empty {
    display: none;
}

/* A rule with a lozenge at its middle — the cheapest possible piece of
   manuscript furniture, and it does more for the period read than any amount
   of border tuning. Reused under dock headers. */
.rule-ornament {
    position: relative;
    height: 1px;
    margin: 10px auto 0;
    width: 78%;
    background: linear-gradient(90deg, transparent, var(--edge), transparent);
}

.rule-ornament::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 5px;
    height: 5px;
    background: var(--gold);
    transform: translate(-50%, -50%) rotate(45deg);
}

.auth-tabs {
    display: flex;
    gap: 0;
    margin-bottom: 14px;
    border-bottom: 1px solid var(--edge-dim);
}

/* Tabs read as tabs, not as pills: no fill, no radius, a gold underline on the
   active one. `.auth-tab` is queried by auth.js and the active state is now a
   MODIFIER (`is-active`) rather than a whole replacement class string, so
   switchAuthTab toggles one class instead of overwriting className. */
.auth-tab {
    flex: 1;
    padding: 8px 4px 7px;
    font: 600 11px var(--font-display);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted-dim);
    background: none;
    border: none;
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    cursor: var(--cur-point);
    transition: color 120ms ease, border-color 120ms ease;
}

.auth-tab:hover {
    color: var(--muted);
}

.auth-tab.is-active {
    color: var(--gold);
    border-bottom-color: var(--gold);
}

.auth-form {
    display: flex;
    flex-direction: column;
    gap: 9px;
}

/* Community links at the foot of the card. Kept quiet — muted gold icons under
   a hairline, lit on hover — so they never compete with the Log in button. */
.auth-social {
    margin-top: 16px;
    padding-top: 12px;
    border-top: 1px solid var(--edge-dim);
    text-align: center;
}

.auth-social-handle {
    margin: 0 0 8px;
    font: 600 10px var(--font-display);
    letter-spacing: 0.1em;
    color: var(--muted-dim);
}

.auth-social-list {
    display: flex;
    justify-content: center;
    flex-wrap: wrap;
    gap: 6px;
    margin: 0;
    padding: 0;
    list-style: none;
}

.auth-social-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 32px;
    height: 32px;
    border-radius: 50%;
    color: var(--muted);
    cursor: var(--cur-point);
    transition: color 120ms ease, background-color 120ms ease;
}

.auth-social-link svg {
    width: 17px;
    height: 17px;
    fill: currentColor;
}

a.auth-social-link:hover,
a.auth-social-link:focus-visible {
    color: var(--gold-bright);
    background-color: var(--edge-lit);
}

a.auth-social-link:focus-visible {
    outline: 1px solid var(--gold);
    outline-offset: 1px;
}

/* The news panel under the sign-in card: the same forged panel at the card's
   width, so the two read as one column. At most three posts (auth.js), so it
   never needs a scroller of its own. */
.news-panel {
    grid-row: 3;
    align-self: start;
    display: flex;
    flex-direction: column;
    width: min(360px, 100%);
    margin-top: 18px;
    padding: 16px 18px 12px;
}

.news-panel[hidden] {
    display: none;
}

.news-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--edge-dim);
}

.news-title {
    margin: 0;
    font: 600 14px var(--font-display);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--gold);
    text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8), 0 0 12px rgba(240, 214, 138, 0.22);
}

.news-follow {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    font: 600 10px var(--font-display);
    letter-spacing: 0.06em;
    color: var(--muted-dim);
    text-decoration: none;
    cursor: var(--cur-point);
    transition: color 120ms ease;
}

.news-follow svg {
    width: 10px;
    height: 10px;
    fill: currentColor;
}

.news-follow:hover,
.news-follow:focus-visible {
    color: var(--gold-bright);
}

.news-list {
    min-height: 0;
    margin: 0;
    padding: 0;
    list-style: none;
    overflow-y: auto;
}

.news-post {
    display: block;
    padding: 11px 6px;
    border-bottom: 1px solid var(--edge-dim);
    color: var(--parchment);
    text-decoration: none;
    cursor: var(--cur-point);
    transition: background-color 120ms ease;
}

.news-list li:last-child .news-post {
    border-bottom: 0;
}

.news-post:hover,
.news-post:focus-visible {
    background-color: var(--edge-lit);
}

.news-date {
    display: block;
    margin-bottom: 4px;
    font: 600 10px var(--font-display);
    letter-spacing: 0.08em;
    color: var(--muted-dim);
}

/* Five lines of a post, then the ellipsis: the whole post is one click away. */
.news-text {
    display: -webkit-box;
    margin: 0;
    overflow: hidden;
    font: 13px/1.45 var(--font-body);
    color: var(--parchment);
    white-space: pre-line;
    overflow-wrap: anywhere;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 5;
}

/* ---- Coming-soon mode (the public page before launch) ----
   The public edge stamps data-wg-mode="coming-soon" on <html> and serves
   client/partials/coming_soon.html in place of the sign-in and character
   panels (docker/nginx.public.conf). The card is the sign-in card's frame —
   the same title, flourish, social footer and news panel — with this body in
   it, so it reads as the same surface rather than a separate splash page. */
.coming-soon {
    text-align: center;
}

.coming-soon-kicker {
    margin: 0;
    font: 600 11px var(--font-display);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--muted);
}

.coming-soon-title {
    margin: 10px 0 12px;
    font: 600 22px var(--font-display);
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--gold-bright);
    text-shadow:
        0 1px 0 rgba(0, 0, 0, 0.85),
        0 0 14px rgba(240, 214, 138, 0.28);
}

.coming-soon-text {
    margin: 0 0 10px;
    font: 14px/1.55 var(--font-body);
    color: var(--parchment);
}

.coming-soon-text:last-child {
    margin-bottom: 0;
    color: var(--muted);
    font-style: italic;
}

/* The play-test countdown on the card (auth.js startPlaytestCountdown). */
.playtest-countdown {
    margin: 0 0 16px;
    padding: 12px 10px;
    background: var(--panel-sunk);
    border: 1px solid var(--edge-dim);
    border-radius: 4px;
}

.playtest-countdown[hidden] {
    display: none;
}

.playtest-countdown-lead,
.playtest-countdown-note {
    margin: 0;
    font: 13px/1.5 var(--font-body);
    color: var(--parchment);
}

.playtest-countdown-lead strong {
    color: var(--gold-bright);
    font-weight: 600;
}

.playtest-countdown-note {
    margin-top: 10px;
    font-size: 12px;
    color: var(--muted);
}

.playtest-countdown-clock {
    display: flex;
    justify-content: center;
    gap: 14px;
    margin-top: 10px;
}

.playtest-unit {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-width: 54px;
}

.playtest-unit b {
    font: 600 28px/1.1 var(--font-display);
    font-variant-numeric: tabular-nums;
    color: var(--gold-bright);
    text-shadow: 0 1px 0 rgba(0, 0, 0, 0.85);
}

.playtest-unit small {
    font: 600 10px var(--font-display);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--muted);
}

/* The thank-you card's numbers (auth.js renderPlaytestStats). */
.playtest-stats {
    margin: 4px 0 14px;
    padding: 12px 10px 10px;
    background: var(--panel-sunk);
    border: 1px solid var(--edge-dim);
    border-radius: 4px;
}

.playtest-stats[hidden] {
    display: none;
}

.playtest-stats-title {
    margin: 0 0 10px;
    font: 600 11px var(--font-display);
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--muted);
}

.playtest-stats-grid {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 10px 8px;
    margin: 0;
}

.playtest-stat {
    display: flex;
    flex-direction: column-reverse;
    align-items: center;
    min-width: 0;
}

.playtest-stat dt {
    font: 600 10px/1.3 var(--font-display);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--muted);
}

.playtest-stat dd {
    margin: 0;
    font: 600 20px/1.15 var(--font-display);
    font-variant-numeric: tabular-nums;
    color: var(--gold-bright);
    text-shadow: 0 1px 0 rgba(0, 0, 0, 0.85);
    overflow-wrap: anywhere;
}

.playtest-stat dd small {
    display: block;
    font: 11px var(--font-body);
    color: var(--muted);
    text-shadow: none;
}

.playtest-stats-line {
    margin: 10px 0 0;
    font: 13px/1.5 var(--font-body);
    color: var(--parchment);
}

.playtest-stats-line span {
    display: inline-block;
    margin: 0 6px;
}

.playtest-stats-line b {
    color: var(--gold-bright);
    font-weight: 600;
}

/* A little more measure than the sign-in form wanted, for prose; the news
   panel keeps the card's width so the two still read as one column. */
[data-wg-mode="coming-soon"] .auth-card,
[data-wg-mode="coming-soon"] .news-panel {
    width: min(400px, 100%);
}

/* The stage's painted backdrop: what every visitor sees before the lobby
   scene draws over it, and ALL a phone or a data-saver visitor sees (game.js
   comingSoonScene never boots the 3D there). The aqueduct already ships for
   the Enter World veil, so it costs no new asset. */
[data-wg-mode="coming-soon"] #gameCanvas.game-stage {
    background:
        url('assets/ui/loading_backdrop.jpg') center center / cover no-repeat,
        #0a0908;
}

/* The lobby hint is for someone waiting to sign in ("Loading assets…" and a
   progress bar); here nobody is. Its vignette goes too: stacked under the
   modal's own scrim it took the painted backdrop a phone gets nearly to
   black. The modal's scrim still pulls the eye to the card. */
[data-wg-mode="coming-soon"] #lobbyHint {
    display: none;
}

/* Fields are SUNK — darker than the panel, with the bevel inverted so the
   highlight sits at the bottom. An input should look like a groove cut into
   the wood, which is also what makes the raised buttons read as raised. */
.field-input {
    width: 100%;
    padding: 9px 11px;
    font: 13px var(--font-body);
    color: var(--parchment);
    background: var(--panel-sunk);
    border: 1px solid var(--edge-dim);
    border-radius: 2px;
    box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.6), inset 0 -1px 0 var(--edge-lit);
    outline: none;
    transition: border-color 120ms ease, box-shadow 120ms ease;
}

.field-input::placeholder {
    color: var(--muted-dim);
    font-style: italic;
}

.field-input:focus {
    border-color: var(--gold-dim);
    box-shadow:
        inset 0 2px 5px rgba(0, 0, 0, 0.6),
        0 0 0 1px rgba(212, 175, 95, 0.35);
}

select.field-input {
    appearance: none;
    /* Chevron drawn in the border colour so no icon font is needed. */
    background-image:
        linear-gradient(45deg, transparent 50%, var(--muted) 50%),
        linear-gradient(135deg, var(--muted) 50%, transparent 50%);
    background-position: calc(100% - 16px) 50%, calc(100% - 11px) 50%;
    background-size: 5px 5px, 5px 5px;
    background-repeat: no-repeat;
}

.form-error {
    min-height: 1em;
    margin: 0 0 8px;
    font: 11px var(--font-body);
    color: var(--oxblood-bright);
}

.logged-in-user {
    margin: 0 0 10px;
    font: 11px var(--font-body);
    font-style: italic;
    color: var(--muted-dim);
    text-align: center;
}

.character-list {
    display: flex;
    flex-direction: column;
    gap: 4px;
    max-height: 12rem;
    overflow-y: auto;
    margin-bottom: 10px;
}

/* A muster roll rather than a list of radio buttons: the selected row gets a
   gold left rule, the way a marked entry in a ledger would. */
.character-option {
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 7px 9px;
    font: 12px var(--font-body);
    color: var(--muted);
    background: rgba(0, 0, 0, 0.28);
    border: 1px solid transparent;
    border-left: 3px solid var(--edge-dim);
    border-radius: 2px;
    cursor: var(--cur-point);
    transition: background 120ms ease, color 120ms ease, border-color 120ms ease;
}

.character-option:hover {
    background: rgba(0, 0, 0, 0.45);
    color: var(--parchment);
}

.character-option.is-selected {
    color: var(--parchment);
    background: linear-gradient(90deg, rgba(212, 175, 95, 0.13), transparent 70%);
    border-left-color: var(--gold);
}

/* The radio is kept (it is what makes the row a real form control and what
   keyboard selection drives) but restyled to a brass stud — the default
   white circle was the last piece of stock browser chrome on the panel. */
.character-option input {
    appearance: none;
    flex: none;
    width: 11px;
    height: 11px;
    margin: 0;
    border: 1px solid var(--edge);
    border-radius: 50%;
    background: var(--panel-sunk);
    box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.7);
}

.character-option input:checked {
    background: radial-gradient(circle, var(--gold-bright) 0 40%, var(--gold-dim) 55%, var(--panel-sunk) 70%);
    border-color: var(--gold-dim);
}

.character-option-text {
    min-width: 0;
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.character-empty {
    font: 11px var(--font-body);
    font-style: italic;
    color: var(--muted-dim);
}

/* .ui-btn is inline-flex, so a button that is not inside .auth-form's column
   shrinks to its text. This one sits on its own. */
.btn-enter {
    width: 100%;
    margin-bottom: 14px;
}

.create-character {
    border-top: 1px solid var(--edge-dim);
    padding-top: 10px;
}

.create-character-summary {
    list-style: none;
    cursor: var(--cur-point);
    font: 11px var(--font-body);
    font-style: italic;
    color: var(--muted-dim);
    transition: color 120ms ease;
}

.create-character-summary::-webkit-details-marker {
    display: none;
}

.create-character-summary::before {
    content: '\271A\00a0\00a0';
    color: var(--gold-dim);
    font-style: normal;
}

.create-character-summary:hover {
    color: var(--gold);
}

.create-character-body {
    margin-top: 10px;
}

/* ---- character appearance controls ----
   Built at runtime from the server's /appearance/options payload, so this
   styles SHAPES (a labelled row, a swatch strip, a slider) rather than named
   axes — a new hair colour or a new face must not need a rule here. */
.appearance-controls {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin: 8px 0;
}

.appearance-row {
    display: flex;
    align-items: center;
    gap: 8px;
}

.appearance-label {
    flex: 0 0 84px;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: #adadb8;
}

/* An axis that is stored but not drawn yet. Dimmed rather than hidden: the
   choice IS saved, and hiding it would make the feature look absent. */
.appearance-label.is-pending {
    color: #7a7a85;
    font-style: italic;
}

.appearance-select {
    flex: 1 1 auto;
    padding: 4px 6px;
}

.appearance-swatches {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
}

.appearance-swatch {
    width: 18px;
    height: 18px;
    border: 1px solid #26262c;
    border-radius: 3px;
    padding: 0;
    cursor: var(--cur-point);
}

.appearance-swatch.is-selected {
    border-color: #c9a227;
    box-shadow: 0 0 0 1px #c9a227;
}

.appearance-slider {
    flex: 1 1 auto;
}

/* ---- UI icon art ----
   One rule for every icon site. The <img> fills the span the old text glyph
   filled, so nothing above it had to move; `contain` keeps a 48x48 viewBox
   square inside a non-square cell, and the padding stops the art butting into
   the slot border. Cooldown sweeps are siblings with a z-index, so they still
   cover this. */
.ui-icon-img {
    display: block;
    width: 100%;
    height: 100%;
    padding: 5px;
    box-sizing: border-box;
    object-fit: contain;
    pointer-events: none;
    user-select: none;
}

/* The 22px effect cells and the 18px loot rows have no room for 5px of it. */
.effect-icon-glyph .ui-icon-img,
.loot-row-icon .ui-icon-img {
    padding: 2px;
}

/* ---- Consumable cooldown feedback ---- */

/* Radial wipe: the dark wedge covers the remaining fraction (--cd, 1 -> 0)
   and unwinds clockwise as the cooldown runs out. */
.cooldown-overlay {
    position: absolute;
    inset: 0;
    z-index: 1;
    border-radius: inherit;
    background: conic-gradient(rgba(5, 5, 9, 0.78) calc(var(--cd, 0) * 1turn), transparent 0);
    pointer-events: none;
}

.cooldown-text {
    position: absolute;
    inset: 0;
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: center;
    font: 700 11px var(--font-num);
    color: #ffd100;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
    pointer-events: none;
}

.nav-cooldown {
    position: absolute;
    inset: -2px;
    border-radius: 50%;
    background: conic-gradient(rgba(255, 209, 0, 0.55) calc(var(--cd, 0) * 1turn), transparent 0);
    pointer-events: none;
}

/* Buttons are struck brass plates. The bevel is the whole trick: a light inner
   top edge and a dark inner bottom edge read as a raised face, which is what
   the old flat purple gradient could not do at any colour. Press moves the
   highlight to the bottom instead of scaling the element, because a button
   nailed to a panel does not shrink. */
.ui-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    padding: 9px 14px;
    font: 600 12px var(--font-display);
    letter-spacing: 0.1em;
    text-transform: uppercase;
    line-height: 1.25;
    border-radius: 2px;
    cursor: var(--cur-point);
    transition: filter 0.12s ease, box-shadow 0.12s ease;
}

.ui-btn:hover {
    filter: brightness(1.14);
}

.ui-btn:active {
    box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6);
}

.ui-btn:disabled {
    opacity: 0.4;
    cursor: var(--cur-default);
    filter: none;
}

.btn-primary {
    color: #1a1409;
    background: linear-gradient(180deg, var(--gold-bright) 0%, var(--gold) 45%, #a5813a 100%);
    border: 1px solid #5b4720;
    box-shadow: inset 0 1px 0 rgba(255, 248, 225, 0.5),
        inset 0 -2px 4px rgba(0, 0, 0, 0.3), 0 2px 5px rgba(0, 0, 0, 0.5);
    text-shadow: 0 1px 0 rgba(255, 240, 200, 0.35);
}

/* Iron, not brass — for anything secondary, so gold keeps meaning "yes". */
.btn-secondary {
    color: var(--parchment);
    background: linear-gradient(180deg, #3a3228 0%, #241e17 100%);
    border: 1px solid var(--edge);
    box-shadow: inset 0 1px 0 var(--edge-lit), 0 2px 5px rgba(0, 0, 0, 0.45);
}

/* Kept because loot/vendor markup still asks for it; retired to the same iron
   as .btn-secondary so no emerald survives in the chrome. */
.btn-success {
    color: var(--parchment);
    background: linear-gradient(180deg, #3d4a2e 0%, #232b1a 100%);
    border: 1px solid #5c6b41;
    box-shadow: inset 0 1px 0 rgba(220, 240, 190, 0.14), 0 2px 5px rgba(0, 0, 0, 0.45);
}


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

@keyframes lowHpPulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.65; }
}

.player-entry.low-hp {
    animation: lowHpPulse 1s ease-in-out infinite;
}

/* Chat rail. These carried Tailwind colour utilities set from game.js, so the
   log and the roster stayed indigo-and-slate inside docks that had already
   been rebuilt in brass. Naming the variants keeps the palette in one file. */
.chat-line {
    margin-bottom: 4px;
    font-size: 13px;
    color: var(--parchment);
}

/* Narration (the Kestrel intro's arrival line): no sender, set apart. */
.chat-line--narration {
    font-style: italic;
    color: #e8d9b0;
    border-left: 2px solid rgba(232, 217, 176, 0.45);
    padding-left: 6px;
}

.combat-log {
    margin-bottom: 4px;
    font-size: 13px;
    font-weight: 600;
    color: #e0a33f;
}

/* A blessing on you reads gold; something eating you reads venom; an expiry
   is just bookkeeping and steps back. */
.combat-log--buff {
    color: var(--gold-bright);
}

.combat-log--dot {
    color: #8fae5a;
}

.combat-log--fade {
    color: var(--muted-dim);
}

/* Quest progress shares the buff gold: both are "something went right". */
.combat-log--quest {
    color: var(--gold);
}

.player-entry {
    margin-bottom: 6px;
    font-size: 13px;
    color: var(--muted);
    transition: color 0.12s ease;
}

.player-entry.self {
    font-weight: 600;
    color: var(--gold-bright);
}

.player-entry.low-hp {
    color: var(--oxblood-bright);
}


/* ---- Ability bar ---- */

/* Bottom-centre, above the chat rail. The cooldown wipe is shared with the
   consumable slots above (.cooldown-overlay / .cooldown-text) rather than
   reimplemented, so both read identically. */
#abilityBar {
    position: absolute;
    left: 50%;
    /* CLEAR THE NAVBAR. Derived from the same stack tokens as .dock-row so
       the three strips (nav / hotbar / docks) share one rhythm and cannot
       drift — a fixed offset here used to put the slots' lower half behind
       the nav, and because the nav wins the hit test, clicking a slot did
       nothing while the number key still worked. */
    bottom: calc(
        var(--bottom-nav-height)
        + var(--bottom-nav-inset)
        + var(--bottom-stack-gap)
    );
    transform: translateX(-50%);
    display: flex;
    gap: 6px;
    /* Above the nav, so an overlapping slot is still clickable. */
    z-index: 41;
    pointer-events: auto;
}

#abilityBar.is-empty {
    display: none;
}

/* The cast bar sits ABOVE the card band, not in the channel between the two
   cards.
 *
 * It used to render in that channel, and being 220px wide it set the channel's
 * floor: --hud-card-gap could not drop below ~232px, which is precisely what
 * stopped the cards closing on the centre and forced them outward over the
 * dock column on narrow viewports. Moving it up removes that coupling, and the
 * gap became free to shrink.
 *
 * It cannot collide with the lifted dock column on the way up: the column
 * yields to the centred hotbar (--left-island-max), so its right edge stays
 * clear of a 220px bar centred on that same axis.
 *
 * Purely informational, so pointer-events stay off — it must never eat a click
 * meant for the slot underneath it, which is the mistake #abilityBar itself
 * had to be moved to fix. */
#castBar {
    position: absolute;
    left: 50%;
    bottom: calc(
        var(--hud-card-band-bottom)
        + var(--hud-card-height)
        + 10px
    );
    transform: translateX(-50%);
    width: 220px;
    height: 16px;
    z-index: 41;
    pointer-events: none;
}

.cast-bar-fill {
    /* No CSS transition on the width: the bar is stepped every frame from the
       render loop, and a transition would add its own easing on top and lag
       the true cast progress. */
    background: linear-gradient(90deg, #7a6224, var(--gold-bright));
}

/* The cast bar reuses the card's track and label, but has one label rather
   than two, so it takes the whole row and centres inside it. `text-align`
   cannot do that any more — the label is itself a flex container. */
.cast-bar-text {
    flex: 1 1 auto;
    justify-content: center;
}

/* An interrupted cast flashes red rather than vanishing, so "it was stopped"
   is distinguishable from "it went off". */
#castBar.is-interrupted .cast-bar-fill {
    background: #b3423a;
}

.ability-slot {
    position: relative;
    width: var(--hotbar-slot-size);
    height: var(--hotbar-slot-size);
    padding: 0;
    /* A SPELL slot: warm, and standing PROUD of the bar — brass-lit top bevel,
       the face falling away to near black at the bottom, and a real drop
       shadow under the whole button. The deep face is not decoration: the art
       is mostly light metal and pale cloth, and it was sitting on a #2c2519
       plate that shared its mid-tones, so the icons went soft. Contrast here
       buys legibility on every icon at once.

       Consumables use the cool inset treatment below. Warm-raised vs
       cool-sunken is the whole distinction, and it survives at a glance and at
       46px, which two nearly identical browns did not. */
    border: 1px solid #7d6640;
    border-radius: 2px;
    background:
        radial-gradient(125% 105% at 50% -10%, #3a2e1c 0%, #241d13 42%, #120e09 100%);
    box-shadow:
        inset 0 1px 0 rgba(226, 192, 120, 0.26),
        inset 0 -7px 11px rgba(0, 0, 0, 0.55),
        0 2px 7px rgba(0, 0, 0, 0.62);
    color: var(--parchment);
    cursor: var(--cur-point);
    transition: border-color 120ms ease, filter 120ms ease;
}

.ability-slot:hover {
    border-color: var(--gold);
    filter: brightness(1.15);
}

.ability-slot:active {
    box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.65);
}

.ability-slot.is-cooling {
    border-color: var(--edge-dim);
}

/* Not yet unlocked. Desaturated AND dimmed rather than one or the other:
   opacity alone left a locked slot looking merely out of focus, and that was
   true of the two-letter glyphs this bar used to draw and is more true of the
   art it draws now. `default` not `not-allowed` — the slot is a preview of
   something coming, not a rejected input. */
.ability-slot.is-locked {
    border-color: var(--edge-dim);
    opacity: 0.45;
    filter: grayscale(1);
    /* No cursor of its own. It used to say `default` — chosen when the only
       question was whether it could be CLICKED, and correct then. It can be
       dragged, though: rearranging a bar you are planning ahead is half the
       point of showing a locked ability at all. So it inherits the grab
       cursor from .ui-draggable like every other bound slot. */
}

.ability-slot.is-locked:hover {
    /* Still hoverable: the tooltip is where "unlocks at level 3" lives, and it
       is the whole reason a locked slot is drawn at all. */
    border-color: var(--edge);
    opacity: 0.6;
}

.ability-icon {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font: 700 15px var(--font-body);
    letter-spacing: 0.5px;
}

/* ---- Hotbar arrangement ----
   Empty slots are drawn, not skipped: a hole has to be a drop target or there
   is nowhere to move things to. */
.ability-slot--empty {
    /* A hole, not a button: no top bevel, no drop shadow, and an inner
       shadow so it reads as sunk into the bar. With both filled variants now
       carrying real relief, an empty slot that keeps any of it looks broken. */
    background: linear-gradient(180deg, rgba(16, 13, 9, 0.72), rgba(6, 5, 4, 0.78));
    border-style: dashed;
    border-color: rgba(107, 88, 54, 0.5);
    box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.6);
    cursor: var(--cur-default);
}

.ability-slot.is-dragging {
    opacity: 0.4;
}

.ability-slot.is-drop-target {
    border-color: var(--gold);
    box-shadow: inset 0 0 0 1px var(--gold), 0 0 10px rgba(212, 175, 95, 0.45);
}

/* The ground ability being AIMED: its reticle is on the terrain and the slot
   stays lit until the click lands it or Esc puts it away, so the bar says
   which key the world is waiting on. Warm, because the first one is fire. */
.ability-slot.is-targeting {
    border-color: #ff8c2a;
    box-shadow: inset 0 0 0 1px #ff8c2a, 0 0 12px rgba(255, 140, 42, 0.55);
}

/* Bound, but the bag has none left. Kept rather than cleared — running out of
   potions should not un-bind the key you reach for. */
.ability-slot.is-unavailable {
    opacity: 0.4;
    filter: grayscale(0.8);
    /* Same as is-locked above: out of potions is a reason not to press it,
       not a reason it cannot be moved. */
}

/* The bag's own cells are drag sources for the bar. The CURSOR for that is
   not stated here: `.ui-draggable` in cursors.css covers every drag source in
   the HUD at once — UIDrag.bindSource stamps that class, so the mark and the
   behaviour cannot come apart — and it has to be there rather than here
   because it must outrank tw_shim's `button { cursor: pointer }`, which these
   cells are. One rule, one place, three sources. */

/* Stack count on a hotbar consumable — bottom-LEFT, opposite the keycap,
   so the two numbers on one slot never collide. */
.ability-stack {
    position: absolute;
    left: 3px;
    bottom: 2px;
    font: 600 9px var(--font-num);
    color: var(--parchment);
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95);
    pointer-events: none;
}

/* Consumables read as a different KIND of button from a spell: cool steel,
   and SUNKEN where a spell slot is raised.

   This rule already claimed to do that and did not — it was #2a2a20 against
   the spell slot's #2c2519, two browns four points apart, which is no
   difference at all on a 46px button. The split is now warm/cool AND
   raised/inset, which are the two cues that survive peripheral vision: you
   find the potion without reading the art. */
.ability-slot--item {
    background: linear-gradient(180deg, #0d1114 0%, #182026 46%, #080b0d 100%);
    border-color: #566670;
    box-shadow:
        inset 0 2px 6px rgba(0, 0, 0, 0.72),
        inset 0 -1px 0 rgba(176, 205, 226, 0.12),
        0 1px 3px rgba(0, 0, 0, 0.5);
}

.ability-slot--item:hover {
    border-color: #9fc0d4;
}

.ability-key {
    position: absolute;
    right: 3px;
    bottom: 1px;
    z-index: 3;
    font: 600 10px var(--font-num);
    color: rgba(203, 213, 225, 0.75);
    pointer-events: none;
}

/* ---- Status effect icons (buffs / debuffs) ----
   A row under each card's bars. Deliberately smaller than an ability slot:
   these are things that are true, not things you press, and at full slot size
   five debuffs would outweigh the health bar they sit under. */

.effect-row {
    display: flex;
    flex-wrap: wrap;
    gap: 3px;
    margin-top: 4px;
}

.effect-icon {
    position: relative;
    width: 22px;
    height: 22px;
    border: 1px solid rgba(148, 163, 184, 0.4);
    border-radius: 4px;
    background: linear-gradient(180deg, rgba(30, 32, 44, 0.92), rgba(14, 15, 22, 0.92));
    overflow: hidden;
}

/* Border colour is the ONLY thing `kind` changes: a buff and a debuff are the
   same kind of fact, and which one it is comes off the tooltip. Sky for good,
   green for a poison, amber for everything else that is being done TO you. */
.effect-icon--buff {
    border-color: rgba(56, 189, 248, 0.7);
}

.effect-icon--debuff {
    border-color: rgba(251, 191, 36, 0.7);
}

.effect-icon--dot {
    border-color: rgba(52, 211, 153, 0.7);
}

.effect-icon-glyph {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 100%;
    font: 700 10px var(--font-body);
    letter-spacing: 0.3px;
    color: #e8e8ea;
}

/* The sweep runs the OPPOSITE way from a cooldown: --cd is the fraction SPENT,
   so the wedge grows as the effect runs out. Same element and same gradient,
   because "time is passing on this square" should look the same everywhere. */
.effect-icon-sweep {
    z-index: 1;
}

/* The absorb badge. Corner-anchored like .ability-key, and ABOVE the sweep,
   because a shield's remaining strength has to stay readable while the wedge
   grows over it. The icon is 22px and overflow:hidden, so this sits flush in
   the bottom-right and the number is shortened (1.4k) rather than clipped.
   Text-shadow rather than a plate: a solid backing at this size covers more of
   the art than the digits do. */
.effect-icon-count {
    position: absolute;
    right: 1px;
    bottom: 0;
    z-index: 2;
    font: 700 9px 'Geist Mono', ui-monospace, monospace;
    line-height: 1;
    /* Cool against the warm theme, matching the absorb damage number: the two
       are the same fact seen twice (what the shield has left, what it just
       ate), so they should not be two different colours. */
    color: #8fb8d4;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.95), 0 0 3px rgba(0, 0, 0, 0.8);
    pointer-events: none;
}

/* Ability tooltip: the item tooltip's element, classes and type scale, plus
   the two pieces an ability has that an item does not. Nothing here may set a
   font-size or a family — the scale lives with .item-tooltip, and these rules
   carry COLOUR only, which is the whole reason the two tooltips read as one
   thing now. */

/* An item's name is coloured by its rarity, which is data. An ability has no
   rarity, so it takes the theme's own gold rather than the #ffd100 it used to
   carry — that yellow belongs to the cooldown digits and matched nothing else
   in the panel it was sitting in. */
.ability-tooltip-name {
    color: var(--gold-bright);
}

.ability-tooltip-dim {
    color: var(--muted);
}

.ability-tooltip-flavor {
    margin: 6px 0 0;
    color: var(--muted);
}

/* The world map (M) — world_map.js builds its own DOM; only the look lives
 * here. The parchment frame quotes the zone banner's brass-and-shadow. */
.world-map-overlay {
    position: fixed;
    inset: 0;
    z-index: 60;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    background: rgba(8, 6, 4, 0.72);
}

.world-map-canvas {
    border: 2px solid #d4af5f;
    border-radius: 4px;
    background: #14110c;
    box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
}

.world-map-hint {
    margin: 0;
    font: 11px Georgia, serif;
    color: rgba(220, 210, 190, 0.75);
}

/* ---- fatal screen: unsupported browser, lost GPU, failed load ---- */
.fatal-screen {
    position: absolute;
    inset: 0;
    z-index: 60;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 24px;
    background: rgba(6, 5, 5, 0.92);
}

.fatal-screen[hidden] {
    display: none;
}

.fatal-card {
    max-width: 440px;
    padding: 28px 32px;
    border: 1px solid rgba(201, 168, 76, 0.35);
    background: #0f0c0a;
    color: #e8dcc4;
    font-family: Georgia, serif;
    text-align: center;
}

.fatal-title {
    margin: 0 0 10px;
    font-size: 20px;
    color: #c9a84c;
}

.fatal-message {
    margin: 0 0 18px;
    font-size: 14px;
    line-height: 1.5;
    color: rgba(232, 220, 196, 0.85);
}

.fatal-actions {
    display: flex;
    gap: 10px;
    justify-content: center;
}

.fatal-btn {
    padding: 8px 18px;
    border: 1px solid #c9a84c;
    background: #1a1410;
    color: #e8dcc4;
    font: 14px Georgia, serif;
}

.fatal-btn--ghost {
    border-color: rgba(201, 168, 76, 0.4);
    color: rgba(232, 220, 196, 0.7);
}

/* Sell-column hint in the merchant window: quiet, on the section's right. */
.vendor-section-hint {
    font-size: 10px;
    font-weight: 500;
    letter-spacing: 0.02em;
    text-transform: none;
    color: var(--muted-dim);
}

.vendor-row-name {
    flex: 1;
    min-width: 0;
    text-align: left;
    line-height: 1.25;
    overflow-wrap: anywhere;
}

/* The dock: one lane for all three windows. Conversation and merchant fill from
   the left, loot sits at the right end of the row, and the dock stops short of
   the right-hand column (controls card above, minimap below) so nothing lands on
   either. The row WRAPS rather than overlapping when the stage is too narrow for
   all three, and below 1000px it becomes a single scrolling column: the same
   stacking a phone gets, at tablet width. */
.window-dock {
    position: absolute;
    top: 10px;
    left: 10px;
    right: 190px;
    z-index: 40;
    display: flex;
    flex-flow: row wrap;
    align-items: flex-start;
    gap: 10px;
    max-height: calc(100% - 20px);
    overflow: hidden;
    pointer-events: none;
}

.window-dock > * {
    pointer-events: auto;
}

/* Loot rides the right end of the row. */
.window-dock > #lootWindow {
    margin-left: auto;
}

@media (max-height: 620px) {
    /* Short window: the loot list gives back the room the minimap needs. */
    .loot-window-list {
        max-height: max(90px, calc(100vh - 400px));
    }
}

@media (max-width: 1000px) {
    .window-dock {
        /* Below the controls chip, which collapses to a 26px square up here. */
        top: 44px;
        /* ...and above the hotbar and nav strip along the bottom, which a longer
           stack scrolls under rather than covers. */
        max-height: calc(100% - 44px - 168px);
        right: 10px;
        flex-flow: column nowrap;
        overflow-y: auto;
        overscroll-behavior: contain;
    }

    .window-dock > #lootWindow {
        margin-left: 0;
    }
}

@media (max-width: 720px) {
    .loot-window,
    .vendor-window,
    .quest-window {
        --win-width: 100%;
    }

    .vendor-window .loot-window-list,
    .loot-window-list {
        max-height: min(30vh, 260px);
    }
}

/* While the world loads, input is held: the pointer says so. */
.world-loading {
    cursor: var(--cur-busy);
}

/* The chat dock's grab strip is 7px tall, which is a hard target to land at speed.
   The pseudo-element widens the HIT area 5px each way without changing what is
   drawn. */
.dock-resize {
    position: relative;
}

.dock-resize::before {
    content: '';
    position: absolute;
    inset: -5px 0;
}

/* Row order is fixed regardless of which window opened first: conversation,
   then merchant, then loot at the right end. */
.window-dock > .quest-window { order: 1; }
.window-dock > .vendor-window { order: 2; }
.window-dock > #lootWindow { order: 3; }

/* Scrollbars inside these windows: thin and in the HUD's metal, not the
   browser's white track. */
.window-dock,
.loot-window,
.loot-window-list {
    scrollbar-width: thin;
    scrollbar-color: var(--edge) transparent;
}

/* ── Graphics settings (gfx_settings.js) ────────────────────────────────────
   A floating panel over the navbar's right end, at #app level so it stands
   above the docks (35) and the navbar (40). */
.gfx-settings {
    position: fixed;
    right: calc(var(--bottom-nav-inset) + env(safe-area-inset-right, 0px));
    bottom: calc(var(--bottom-nav-height) + var(--bottom-nav-inset) * 2 + env(safe-area-inset-bottom, 0px));
    z-index: 56;
    width: min(340px, calc(100vw - 16px));
    max-height: calc(100dvh - var(--bottom-nav-height) - 32px);
    overflow-y: auto;
    padding: 12px 14px;
    background: linear-gradient(180deg, #2a231b 0%, #1a1610 100%);
    border: 1px solid var(--edge-dim);
    border-radius: 4px;
    box-shadow: 0 12px 28px rgba(0, 0, 0, 0.6);
    color: var(--parchment);
    font-size: 12px;
    scrollbar-width: thin;
    scrollbar-color: var(--edge) transparent;
}

.gfx-settings[hidden] {
    display: none;
}

.gfx-settings-head {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 10px;
}

.gfx-settings-title {
    margin: 0;
    font-family: var(--font-display);
    font-size: 14px;
    letter-spacing: 0.06em;
    color: var(--gold);
}

.gfx-settings-preset {
    flex: 1;
    font-size: 11px;
    color: var(--muted);
}

.gfx-settings-presets {
    display: grid;
    grid-template-columns: repeat(4, 1fr);
    gap: 6px;
    margin-bottom: 10px;
}

.gfx-preset-btn {
    padding: 6px 4px;
    font-size: 11px;
}

.gfx-preset-btn.is-active {
    color: var(--gold-bright);
    border-color: var(--gold);
}

.gfx-settings-list {
    display: grid;
    gap: 6px;
    margin: 0;
}

.gfx-settings-row {
    display: grid;
    grid-template-columns: 1fr 150px;
    align-items: center;
    gap: 8px;
}

.gfx-settings-row dt {
    display: flex;
    align-items: center;
    gap: 6px;
    color: var(--muted);
}

.gfx-settings-row dd {
    margin: 0;
}

.gfx-settings-tag {
    font-size: 9px;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--muted-dim);
}

.gfx-settings-select {
    width: 100%;
    padding: 3px 6px;
    font-size: 12px;
}

.gfx-settings-reload {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-top: 10px;
    color: var(--gold-bright);
}

.gfx-settings-reload[hidden] {
    display: none;
}

.gfx-settings-reload-btn {
    padding: 4px 10px;
    font-size: 11px;
}

.gfx-settings-foot {
    margin: 10px 0 0;
    font-size: 10px;
    color: var(--muted-dim);
}

/* ── The touch control scheme: sticks, buttons and a landscape HUD ──────────
 *
 * Everything below is keyed on body.scheme-touch (game.js setControlScheme),
 * so the mouse and trackpad schemes are untouched by construction. Designed
 * for a landscape phone, ~850x390 CSS px: the two bottom corners belong to
 * the thumbsticks, the action bar takes the bottom-centre between them, and
 * everything else moves to the top edge — unit cards top-left, the navbar
 * top-right, docks and windows in the centre column between the sticks.
 * env(safe-area-inset-*) keeps all of it off a notch and the home indicator
 * (index.html's viewport-fit=cover is what makes those insets non-zero).
 *
 * THE HUD IS SCALED, not re-authored. The desktop HUD's islands are sized in
 * hundreds of px literals, and the first cut kept them — on a real phone
 * every one of them read as too big. Each island takes `transform: scale()`
 * from its own anchored corner (--touch-ui-scale): a transform moves no
 * layout box, so every offset below is the island's ANCHOR, and
 * getBoundingClientRect, hit-testing and elementFromPoint all report the
 * scaled box, which is what the tooltip placement and the drag code read.
 * `zoom` was the obvious lever and is not used: it scales an element's own
 * offsets and its rect reporting differs between engines.
 */
body.scheme-touch {
    --touch-ui-scale: 0.72;
    --touch-safe-l: env(safe-area-inset-left, 0px);
    --touch-safe-r: env(safe-area-inset-right, 0px);
    --touch-safe-t: env(safe-area-inset-top, 0px);
    --touch-safe-b: env(safe-area-inset-bottom, 0px);
    /* The drawn sticks' home columns, and the corner regions a thumb can land
       anywhere in (the stick is FLOATING: it centres where the thumb lands). */
    --touch-stick-col: 128px;
    /* The stick's CAPTURE zone: a corner region larger than the drawn base,
       so a near miss still grabs the stick and never reaches the camera. */
    --touch-zone-w: min(30vw, 220px);
    --touch-zone-h: min(58dvh, 220px);
    --touch-top-band: 40px;
    --touch-bar-band: 48px;
    /* No double-tap zoom, no long-press callout or selection on game UI. */
    touch-action: manipulation;
    -webkit-touch-callout: none;
    -webkit-tap-highlight-color: transparent;
    -webkit-user-select: none;
    user-select: none;
}

body.scheme-touch #gameCanvas,
.touch-stick,
.touch-btn {
    touch-action: none;
}

/* NOTHING MAY SCROLL THE PAGE in the world. A drag that starts on a HUD
   button (the action bar under a fast thumb) can rubber-band the document,
   and on iOS a downward overscroll at the top is PULL-TO-REFRESH — a reload
   that reads exactly like "rapid taps on an ability reloaded the page". The
   body is pinned, overscroll is off, and the HUD controls take no browser
   gesture at all. */
html:has(body.scheme-touch),
body.scheme-touch {
    overscroll-behavior: none;
}

body.scheme-touch.in-world {
    position: fixed;
    inset: 0;
    overflow: hidden;
}

body.scheme-touch.in-world #abilityBar,
body.scheme-touch.in-world .bottom-nav,
body.scheme-touch.in-world .hud-cards,
body.scheme-touch.in-world .help-panel {
    touch-action: none;
}
body.scheme-touch img {
    -webkit-user-drag: none;
    -webkit-touch-callout: none;
}

/* iOS zooms the page onto any focused input under 16px and does not zoom
   back. 16px under touch, and game.js holds maximum-scale=1 on the viewport
   while the scheme is on. */
body.scheme-touch input,
body.scheme-touch textarea,
body.scheme-touch select {
    /* !important: the field classes set their own sizes at higher specificity,
       and anything under 16px is the bug. */
    font-size: 16px !important;
    -webkit-user-select: text;
    user-select: text;
}

.touch-controls {
    position: fixed;
    inset: 0;
    /* Under the docks (35) and the navbar (40): an open pane is read, not
       steered through. Above the stage, which paints at z auto. */
    z-index: 34;
    display: none;
    pointer-events: none;
}

body.scheme-touch.in-world .touch-controls {
    display: block;
}

/* The zone is the stick's capture region, a generous corner larger than the
   drawn base. The base is FIXED at its home in the corner and never moves;
   input is measured from its centre (touch_controls.js). */
.touch-stick {
    position: absolute;
    bottom: 0;
    width: var(--touch-zone-w);
    height: var(--touch-zone-h);
    pointer-events: auto;
    user-select: none;
}

.touch-stick--left {
    left: 0;
}

.touch-stick-base {
    position: absolute;
    bottom: calc(14px + var(--touch-safe-b));
    width: 92px;
    height: 92px;
    border-radius: 50%;
    border: 1px solid rgba(212, 175, 95, 0.35);
    background: radial-gradient(circle, rgba(20, 17, 13, 0.15) 0%, rgba(20, 17, 13, 0.45) 100%);
    box-shadow: inset 0 0 14px rgba(0, 0, 0, 0.4);
}

.touch-stick--left .touch-stick-base {
    left: calc(16px + var(--touch-safe-l));
}

.touch-stick.is-held .touch-stick-base {
    border-color: var(--gold);
}

.touch-stick-knob {
    position: absolute;
    left: 50%;
    top: 50%;
    width: 38px;
    height: 38px;
    margin: -19px 0 0 -19px;
    border-radius: 50%;
    border: 1px solid var(--gold-dim);
    background: radial-gradient(circle at 40% 35%, #4a3d28 0%, #1e1810 100%);
    box-shadow: inset 0 1px 0 rgba(226, 192, 120, 0.3), 0 2px 6px rgba(0, 0, 0, 0.6);
}

/* The buttons, bottom-right where the right thumb is. The rest of the right
   side is the world, which one finger drags to turn the camera. */
.touch-buttons {
    position: absolute;
    right: calc(12px + var(--touch-safe-r));
    bottom: calc(14px + var(--touch-safe-b));
    display: flex;
    align-items: flex-end;
    gap: 6px;
    pointer-events: auto;
}

.touch-btn[hidden] {
    display: none;
}

.touch-btn {
    width: 36px;
    height: 36px;
    padding: 0;
    border-radius: 50%;
    border: 1px solid #7d6640;
    background: radial-gradient(125% 105% at 50% -10%, #3a2e1c 0%, #241d13 42%, #120e09 100%);
    box-shadow: inset 0 1px 0 rgba(226, 192, 120, 0.26), 0 2px 6px rgba(0, 0, 0, 0.62);
    color: var(--parchment);
    font-size: 15px;
    line-height: 1;
    user-select: none;
}

.touch-btn:active {
    border-color: var(--gold);
    filter: brightness(1.2);
}

.touch-btn--attack {
    width: 44px;
    height: 44px;
    border-color: var(--oxblood-bright);
    font-size: 18px;
}

/* Portrait: ask for landscape. In the world only — the sign-in card and the
   character select read fine either way. */
.touch-rotate {
    position: fixed;
    inset: 0;
    z-index: 70;
    display: none;
    align-items: center;
    justify-content: center;
    background: rgba(12, 10, 8, 0.95);
    color: var(--parchment);
    text-align: center;
}

.touch-rotate-glyph {
    font-size: 40px;
    color: var(--gold);
}

.touch-rotate-card p {
    margin: 10px 24px 0;
    font-family: var(--font-display);
    font-size: 14px;
    letter-spacing: 0.06em;
}

@media (orientation: portrait) {
    body.scheme-touch.in-world .touch-rotate {
        display: flex;
    }
}

/* ── The HUD, scaled and rearranged around the sticks ── */

/* Action bar: bottom-centre, between the two stick columns. */
body.scheme-touch.in-world #abilityBar {
    bottom: calc(4px + var(--touch-safe-b));
    transform: translateX(-50%) scale(var(--touch-ui-scale));
    transform-origin: 50% 100%;
}

body.scheme-touch.in-world #castBar {
    bottom: calc(var(--touch-bar-band) + 6px + var(--touch-safe-b));
    transform: translateX(-50%) scale(var(--touch-ui-scale));
    transform-origin: 50% 100%;
}

/* Unit cards: top-left, side by side. 176px wide before the scale. */
body.scheme-touch.in-world .hud-cards {
    top: calc(4px + var(--touch-safe-t));
    bottom: auto;
}

body.scheme-touch.in-world .player-card {
    top: 0;
    bottom: auto;
    right: auto;
    left: calc(4px + var(--touch-safe-l));
    width: 176px;
    transform: scale(var(--touch-ui-scale));
    transform-origin: 0 0;
}

body.scheme-touch.in-world .target-card {
    left: calc(4px + 176px * var(--touch-ui-scale) + 6px + var(--touch-safe-l));
}

/* Navbar: top-right, icons only. */
body.scheme-touch.in-world .bottom-nav {
    top: calc(4px + var(--touch-safe-t));
    bottom: auto;
    left: auto;
    right: calc(4px + var(--touch-safe-r));
    max-width: none;
    transform: scale(var(--touch-ui-scale));
    transform-origin: 100% 0;
}

body.scheme-touch.in-world .nav-tab-label,
body.scheme-touch.in-world .nav-status,
body.scheme-touch.in-world .nav-divider {
    display: none;
}

/* Docks and the transactional windows: the centre column, between the top
   band and the action bar, clear of both drawn sticks. The column is laid
   out at 1/scale of the room it has and scaled into it from the bottom
   centre, so a dock fills the column on screen rather than a 72% patch of
   it. */
body.scheme-touch.in-world .dock-row {
    --touch-col-w: calc(100vw - 2 * var(--touch-stick-col) - var(--touch-safe-l) - var(--touch-safe-r));
    --touch-col-h: calc(100dvh - var(--touch-top-band) - var(--touch-bar-band) - var(--touch-safe-t) - var(--touch-safe-b));
    top: auto;
    bottom: calc(var(--touch-bar-band) + var(--touch-safe-b));
    left: calc(50% - var(--touch-col-w) / (2 * var(--touch-ui-scale)));
    right: auto;
    width: calc(var(--touch-col-w) / var(--touch-ui-scale));
    max-width: none;
    height: calc(var(--touch-col-h) / var(--touch-ui-scale));
    max-height: none;
    align-items: center;
    transform: scale(var(--touch-ui-scale));
    transform-origin: 50% 100%;
}

body.scheme-touch.in-world .dock,
body.scheme-touch.in-world .dock-chat,
body.scheme-touch.in-world .dock-pair > .dock-character,
body.scheme-touch.in-world .dock-pair > .dock-inventory {
    max-width: 100%;
    max-height: 100%;
}

body.scheme-touch.in-world .dock.dock-map {
    --map-size: min(300px, calc(var(--touch-col-h) / var(--touch-ui-scale) - 16px));
}

body.scheme-touch.in-world .window-dock {
    top: calc(var(--touch-top-band) + var(--touch-safe-t));
    left: calc(var(--touch-stick-col) + var(--touch-safe-l));
    right: calc(var(--touch-stick-col) + var(--touch-safe-r));
    max-height: none;
    transform: scale(var(--touch-ui-scale));
    transform-origin: 50% 0;
}

/* The controls card: under the unit cards, top-left. */
body.scheme-touch.in-world .help-panel {
    top: calc(var(--touch-top-band) + var(--touch-safe-t));
    right: auto;
    left: calc(4px + var(--touch-safe-l));
    transform: scale(var(--touch-ui-scale));
    transform-origin: 0 0;
}

/* The graphics panel: under the navbar, top-right, clear of the sticks. */
body.scheme-touch .gfx-settings {
    top: calc(var(--touch-top-band) + var(--touch-safe-t));
    bottom: auto;
    max-height: calc((100dvh - var(--touch-top-band) - 8px) / var(--touch-ui-scale));
    transform: scale(var(--touch-ui-scale));
    transform-origin: 100% 0;
}

/* The item/ability tooltip is positioned by inventory.js from rects, so it is
   made smaller rather than scaled. */
body.scheme-touch .item-tooltip {
    --tip-title: 12px;
    --tip-read: 10px;
    --tip-fine: 9px;
    width: min(200px, calc(100vw - 16px));
    padding: 6px 8px;
}

/* Three schemes' rows: touch rows only under touch, and neither of the
   keyboard schemes' rows under it. The mouse/trackpad pair is folded by the
   rule further up. */
.help-panel[data-scheme="mouse"] .help-row[data-scheme="touch"],
.help-panel[data-scheme="trackpad"] .help-row[data-scheme="touch"],
.help-panel[data-scheme="touch"] .help-row:not([data-scheme="touch"]) {
    display: none;
}

/* Under touch the navbar sits at the TOP, so a menu anchored to it (the
   leave-world confirmation) opens DOWNWARD, its pointer flipped to match. */
body.scheme-touch.in-world .bottom-nav .confirm-bubble {
    top: calc(100% + 10px);
    bottom: auto;
}

body.scheme-touch.in-world .bottom-nav .confirm-bubble::after {
    top: auto;
    bottom: 100%;
    border-top-color: transparent;
    border-bottom-color: #2a231b;
}

/* ui_drag.js drags are pointer events, and a finger on a source inside a
   scrolling dock was claimed by the browser as a PAN: pointercancel, and the
   drag never started. So a drag source takes no browser gesture under touch
   (the pane still scrolls from anywhere that is not a source). */
body.scheme-touch .ui-draggable {
    touch-action: none;
}

/* ── The map: drag to pan, and a way back ── */
#minimapCanvas {
    touch-action: none;
}

.dock-map:not([data-panned]) [data-minimap-recenter] {
    display: none;
}
/* ── The lobby on a landscape phone (touch scheme) ──
 *
 * The sign-in and character cards were sized for a monitor. On the phone the
 * lobby beacon measured (874x338 CSS px landscape) the first cut of this was
 * still 507 x 320 — 58% of the width and the whole height — with the
 * controls at desktop size. Here the card is NARROW and its controls small:
 * ~40% of the width, 11px type, tight rows, the appearance rows in two
 * columns, the header shrunk and the social row dropped, and it scrolls
 * inside itself (contained, so a fling never drags the page). The figure
 * gets the rest of the screen.
 *
 * Text fields go to 16px while FOCUSED: under 16px iOS zooms the page onto
 * a focused field (game.js also holds maximum-scale=1 under touch). Keyed on
 * a SHORT viewport, so a tablet in portrait keeps the ordinary card.
 */
@media (max-height: 560px) {
    body.scheme-touch .auth-modal {
        padding: 4px calc(6px + env(safe-area-inset-right, 0px)) 4px calc(6px + env(safe-area-inset-left, 0px));
    }

    body.scheme-touch .auth-card {
        width: min(340px, 40vw);
        max-height: calc(100dvh - 8px);
        padding: 8px 10px;
        font-size: 11px;
        overscroll-behavior: contain;
        -webkit-overflow-scrolling: touch;
        touch-action: pan-y;
    }

    body.scheme-touch .auth-modal--clear > .auth-card {
        margin-inline-end: 0;
    }

    body.scheme-touch .auth-head {
        margin-bottom: 4px;
    }

    body.scheme-touch .auth-title {
        font-size: 15px;
    }

    body.scheme-touch .auth-flourish {
        width: min(110px, 50%);
        margin-top: 0;
    }

    body.scheme-touch .auth-subtitle {
        margin-top: 1px;
        font-size: 10px;
    }

    body.scheme-touch .auth-social {
        display: none;
    }

    body.scheme-touch .auth-tabs {
        margin-bottom: 6px;
    }

    body.scheme-touch .auth-tab {
        padding-top: 3px;
        padding-bottom: 3px;
        font-size: 10px;
    }

    body.scheme-touch .auth-form {
        gap: 4px;
    }

    body.scheme-touch .auth-card .field-input {
        min-height: 0;
        padding: 3px 6px;
        font-size: 12px !important;
        line-height: 1.3;
    }

    /* 16px only while a text field has focus: the size iOS judges a zoom by. */
    body.scheme-touch .auth-card input[type="text"]:focus,
    body.scheme-touch .auth-card input[type="password"]:focus {
        font-size: 16px !important;
    }

    body.scheme-touch .auth-card .ui-btn {
        min-height: 0;
        padding: 5px 10px;
        font-size: 10px;
    }

    body.scheme-touch .character-panel-head {
        font-size: 10px;
    }

    body.scheme-touch .character-list {
        max-height: 5.5rem;
        gap: 2px;
        margin-bottom: 6px;
    }

    body.scheme-touch .character-option {
        padding: 3px 6px;
        font-size: 11px;
    }

    body.scheme-touch .btn-enter {
        margin-bottom: 6px;
    }

    body.scheme-touch .create-character {
        padding-top: 6px;
    }

    body.scheme-touch .create-character-summary {
        font-size: 10px;
    }

    body.scheme-touch .create-character-body {
        margin-top: 4px;
    }

    /* Name and class side by side; the appearance grid and the button span. */
    body.scheme-touch #createCharacterForm {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 4px 8px;
    }

    body.scheme-touch #createCharacterForm > .appearance-controls,
    body.scheme-touch #createCharacterForm > button {
        grid-column: 1 / -1;
    }

    /* Two columns of appearance rows: half the height, and every swatch
       strip on screen at once. */
    body.scheme-touch .appearance-controls {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 3px 8px;
        margin: 2px 0;
    }

    body.scheme-touch .appearance-row {
        min-width: 0;
        gap: 4px;
    }

    body.scheme-touch .appearance-label {
        flex-basis: 40px;
        font-size: 8px;
        line-height: 1.1;
    }

    body.scheme-touch .appearance-select {
        min-width: 0;
        padding: 1px 4px;
    }

    body.scheme-touch .appearance-swatches {
        gap: 3px;
    }

    body.scheme-touch .appearance-swatch {
        width: 14px;
        height: 14px;
    }

    body.scheme-touch .appearance-slider {
        min-width: 0;
        height: 14px;
    }
}

/* ---- Sound on/off (audio.js, [data-mute-toggle]) ----
   One speaker glyph; the waves show when sound is on, a cross when muted. */
.mute-toggle .mute-off { display: none; }
.mute-toggle.is-muted .mute-on { display: none; }
.mute-toggle.is-muted .mute-off { display: inline; }

/* The homepage's button: top right, above the modal and its scrim (50).
   Gone once the navbar leaves lobby mode — #navMuteBtn is the in-world one. */
.lobby-mute-btn {
    position: fixed;
    top: max(12px, env(safe-area-inset-top));
    right: max(12px, env(safe-area-inset-right));
    z-index: 55;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 38px;
    height: 38px;
    padding: 0;
    color: var(--parchment);
    background: linear-gradient(180deg, rgba(58, 50, 40, 0.92) 0%, rgba(36, 30, 23, 0.92) 100%);
    border: 1px solid var(--edge-dim);
    border-radius: 3px;
    cursor: var(--cur-point);
    transition: color 120ms ease, border-color 120ms ease;
}

.lobby-mute-btn:hover,
.lobby-mute-btn:focus-visible {
    color: var(--gold-bright);
    border-color: var(--edge);
}

.lobby-mute-btn svg {
    width: 20px;
    height: 20px;
}

.bottom-nav:not([data-mode="lobby"]) ~ .lobby-mute-btn {
    display: none;
}
