* { box-sizing: border-box; }

/* ============ Slate & Ember design tokens ============
   The whole UI reads from this one palette. Panels are translucent near-black glass
   (the location backdrop blurs through them — see the chrome block at the end of this
   file), with a single warm ember accent. Change these to retheme every gump at once. */
/* ============ UI scale ============
   How large the interface is drawn, 0.7 to 1 (public/js/uiScale.js owns the value and the
   Options slider sets it). Declared here so a stylesheet loaded without that script — or
   with localStorage unavailable — still renders at full size rather than at `zoom: ` and
   whatever the browser makes of that.

   `zoom` rather than `transform: scale()`, for the same reason the PK combat sprite gives
   further down this file: zoom scales the LAYOUT box, so a shrunken panel genuinely takes up
   less room instead of painting smaller inside a full-size hole. It also leaves fixed
   positioning alone, where a transform would make every panel anchor to its own ancestor
   instead of the viewport — the trap views/game.ejs's header already documents for filters.

   .ui-scaled is stamped by gumpWindow.js onto every window it manages, which is all eight
   main panels and every gump, so nothing here has to name them one by one. The scenes are
   NOT in this list, deliberately — see uiScale.js. */
:root {
  --ui-scale: 1;
  /* Every literal font-size and line-height in this file is written as
     `calc(Npx * var(--ui-font-scale, 1))`, so this one number can hold text at a different
     size from the box around it. One everywhere by default, which computes to exactly the
     value that was there before it existed. */
  --ui-font-scale: 1;
}

.ui-scaled,
.game-header {
  zoom: var(--ui-scale, 1);
  /* ...and here is the only place it is ever anything else. zoom shrinks text along with
     everything else in a panel, which is what made 70% the floor — the 10px rows in Skills
     and Inspect stopped being legible before the panels stopped being useful. Dividing the
     font back out lets text hold its size while the furniture around it shrinks.

     Scoped to the scaled surfaces rather than declared on :root so it CANNOT reach the
     scenes: a gathering glade or a dungeon plan is not zoomed, so boosting its nameplates
     and damage numbers would be a text setting quietly resizing the world. Custom properties
     inherit, so everything inside a panel picks this up and nothing outside one does. */
  --ui-font-scale: var(--ui-text-boost, 1);
}

/* The prompt modals — the wanderer's offer, Bind Offering, Name Change, Bank Withdraw — are
   NOT gump windows. gumpWindow.js never sees them, so they never got the class above, and
   their text sat at 100% however far the Text Size slider was dragged. A player who raised it
   because they could not read comfortably still could not read these.

   They take the TEXT half only, deliberately, and not `zoom` with it: a prompt modal is
   CENTRED by its overlay rather than placed by gumpWindow.js, and zoom on a fixed-position
   box rescales the very viewport box it is being centred against. Text is the half that was
   asked for and the half that carries no layout risk. */
.prompt-modal-overlay {
  --ui-font-scale: var(--ui-text-boost, 1);
}

:root {
  --shade: #10131a;                       /* page ground + darkest wells (old #1c140d) */
  --panel: rgba(15, 17, 22, 0.85);        /* gump/panel glass (old #2b1f14) */
  --panel-deep: #12151c;                  /* gradient partner for --panel (old #241a10) */
  --well: #0b0d12;                        /* inputs, tracks, inset areas (old #16100a) */
  --edge: rgba(var(--edge-rgb), 0.24);       /* panel + control borders (old bronze #8a6d3b) */
  --accent: #ffb578;                      /* headers, highlights, hovers (old gold #c9a227) */
  --accent-strong: #f08a42;               /* primary button gradient top */
  --accent-deep: #d97a35;                 /* primary button gradient bottom */
  --accent-bright: #ffc98f;               /* button hover */
  --ink: #d4d8de;                         /* body text (old cream #e8d9b8) */
  --ink-dim: #c3c8d0;                     /* secondary text */
  --muted: #9aa1ab;                       /* labels, captions */
  --on-accent: #16110b;                   /* dark text on ember fills */
  /* Bare R,G,B triplets (no alpha) mirroring --edge/--accent/--accent-strong above —
     dozens of chrome-pass glows/borders/gradients further down need a *different* alpha
     than each token's own baked-in one, which rgba() can't do off a single-value custom
     property. Every body.theme-<key> block below defines its own matching triplet, so
     `rgba(var(--edge-rgb), 0.4)` etc. retheme correctly instead of staying stuck ember
     orange under a Cold/Forest/Mystic palette. */
  --edge-rgb: 255, 158, 88;
  --accent-rgb: 255, 181, 120;
  --accent-strong-rgb: 240, 138, 66;
  /* Semantic "this is live/on" green, for the handful of controls that report a STATE
     rather than offering an action. Deliberately NOT part of the theme token set above
     and deliberately not overridden per theme: it means the same thing under every
     palette, exactly like .journal-success's #7fd1a8 and .toast-success's #3f9d4a
     already do. Kept dark enough that --on-accent's near-black label still reads on it. */
  --live-strong: #4f9d55;
  --live-deep: #3a7d42;
  --live-bright: #6bbf72;
}

/* Cosmetic theme unlocks (config/themes.js, sockets/themeHandlers.js) — gold-purchased,
   account-permanent, one active at a time. Each block overrides the exact same token set
   the :root block above defines, applied via a `theme-<key>` class the server stamps on
   <body> (views/game.ejs) and public/js/themes.js keeps live afterward on theme:select —
   no other rule in this file needs to know a theme system exists at all. */
body.theme-rustic {
  --shade: #17110c;
  --panel: rgba(30, 21, 14, 0.85);
  --panel-deep: #211710;
  --well: #120c08;
  --edge: rgba(200, 140, 80, 0.28);
  --accent: #d8a55c;
  --accent-strong: #b8823c;
  --accent-deep: #96682e;
  --accent-bright: #e8bd7c;
  --ink: #ecdfcd;
  --ink-dim: #d3c4ae;
  --muted: #ab9a83;
  --on-accent: #1a1310;
  --edge-rgb: 200, 140, 80;
  --accent-rgb: 216, 165, 92;
  --accent-strong-rgb: 184, 130, 60;
}

body.theme-forest {
  --shade: #0e1712;
  --panel: rgba(16, 27, 20, 0.85);
  --panel-deep: #142019;
  --well: #0a100c;
  --edge: rgba(120, 178, 100, 0.26);
  --accent: #8fc76a;
  --accent-strong: #6ba048;
  --accent-deep: #548038;
  --accent-bright: #a8dd82;
  --ink: #dce8d6;
  --ink-dim: #c3d3bc;
  --muted: #93a48c;
  --on-accent: #0e1712;
  --edge-rgb: 120, 178, 100;
  --accent-rgb: 143, 199, 106;
  --accent-strong-rgb: 107, 160, 72;
}

body.theme-mystic {
  --shade: #150f1c;
  --panel: rgba(28, 18, 38, 0.85);
  --panel-deep: #1e1428;
  --well: #100b18;
  --edge: rgba(160, 90, 220, 0.28);
  --accent: #b98af0;
  --accent-strong: #9558d6;
  --accent-deep: #7a3fb8;
  --accent-bright: #d0aef8;
  --ink: #e6dcf2;
  --ink-dim: #cbbdda;
  --muted: #a496b3;
  --on-accent: #170f21;
  --edge-rgb: 160, 90, 220;
  --accent-rgb: 185, 138, 240;
  --accent-strong-rgb: 149, 88, 214;
}

body.theme-cold {
  --shade: #0d151a;
  --panel: rgba(16, 28, 36, 0.85);
  --panel-deep: #131f27;
  --well: #0a1116;
  --edge: rgba(110, 190, 230, 0.28);
  --accent: #8fd8f0;
  --accent-strong: #5cb8d8;
  --accent-deep: #3f96b8;
  --accent-bright: #b0e8fa;
  --ink: #dcecf2;
  --ink-dim: #c0d4dc;
  --muted: #8fa5ae;
  --on-accent: #0d1a1f;
  --edge-rgb: 110, 190, 230;
  --accent-rgb: 143, 216, 240;
  --accent-strong-rgb: 92, 184, 216;
}

body.theme-inferno {
  --shade: #170c0a;
  --panel: rgba(32, 14, 10, 0.85);
  --panel-deep: #24100c;
  --well: #140807;
  --edge: rgba(240, 90, 40, 0.32);
  --accent: #ff8850;
  --accent-strong: #e8582a;
  --accent-deep: #c23e18;
  --accent-bright: #ffa878;
  --ink: #f2ded4;
  --ink-dim: #dcc0b4;
  --muted: #ab8a7d;
  --on-accent: #1c0d08;
  --edge-rgb: 240, 90, 40;
  --accent-rgb: 255, 136, 80;
  --accent-strong-rgb: 232, 88, 42;
}

body.theme-royal {
  --shade: #0c1220;
  --panel: rgba(16, 22, 40, 0.85);
  --panel-deep: #131b30;
  --well: #090e18;
  --edge: rgba(212, 175, 90, 0.28);
  --accent: #e8c878;
  --accent-strong: #c8a24e;
  --accent-deep: #a8823a;
  --accent-bright: #f5dc9c;
  --ink: #e0e2ee;
  --ink-dim: #c4c8d8;
  /* Brightened from #8f95ab, which was the dimmest secondary text of any theme (5.75:1
     on this panel, against ~6.4-7.0 everywhere else) — a blue-grey on navy is the
     hardest pairing in the set, and it was the one theme where small muted text stopped
     being comfortably readable. Same hue, lifted to match the default theme's 7:1. */
  --muted: #9fa5be;
  --on-accent: #140c04;
  --edge-rgb: 212, 175, 90;
  --accent-rgb: 232, 200, 120;
  --accent-strong-rgb: 200, 162, 78;
}

body.theme-abyssal {
  --shade: #08161a;
  --panel: rgba(12, 28, 32, 0.85);
  --panel-deep: #102024;
  --well: #060f12;
  --edge: rgba(70, 190, 180, 0.26);
  --accent: #5cd8c8;
  --accent-strong: #38b0a0;
  --accent-deep: #268878;
  --accent-bright: #82ecdc;
  --ink: #d4ecea;
  --ink-dim: #bcd8d4;
  --muted: #7fa19c;
  --on-accent: #06181a;
  --edge-rgb: 70, 190, 180;
  --accent-rgb: 92, 216, 200;
  --accent-strong-rgb: 56, 176, 160;
}

body {
  margin: 0;
  font-family: system-ui, 'Segoe UI', sans-serif;
  background: var(--shade);
  color: var(--ink);
  cursor: url('/assets/sprites/cursors/default.png') 2 2, auto;
}

/* The main game view's town/dungeon backdrop — a procedurally-generated scene per
   location (see tools/generateBackdrops.js), swapped live on arrival by
   public/js/travel.js. Scoped to .game-page (not the shared `body` rule above, which
   the login/register pages also use) via an inline custom property set server-side in
   views/game.ejs from the character's current location, updated client-side with no
   page reload. More specific than the plain `body` rule above, so it wins even though
   that rule's `background` shorthand implicitly resets background-image too. */
body.game-page {
  background-image: var(--backdrop-url, none);
  background-size: cover;
  background-position: center;
  background-attachment: fixed;
}

/* Block the browser's native text-selection highlight across the whole game UI — a
   stray click-drag anywhere (dragging an item, click-to-move in the Town Square,
   dragging a gump by its header, just an accidental mouse-down-and-move) was
   selecting arbitrary label/panel text instead of doing nothing, which read as a
   rendering glitch. Every scattered per-element `user-select: none` elsewhere in this
   file predates this and is now redundant, but harmless to leave. Explicitly
   re-enabled on real text-entry fields and the Journal/chat log, where selecting
   what you typed or copying a line out is a real, expected use. */
body.game-page {
  user-select: none;
}

body.game-page input,
body.game-page textarea,
body.game-page [contenteditable="true"],
body.game-page .journal-log,
body.game-page .chat-log {
  user-select: text;
}

/* The "open road" scene shown while a trip is in progress, cross-faded over the real
   backdrop above. Toggled by public/js/travel.js on the travelDestination false<->true
   edge, which also sets --transition-duration inline per phase (a slow 7s fade out on
   departure, a snappier fade back in on arrival) — so only the property list lives here.
   Fixed below every gump (gumpWindow.js's z-index counter starts at 950) so panels stay
   fully usable mid-trip. */
.travel-backdrop-overlay {
  position: fixed;
  inset: 0;
  z-index: 5;
  background-image: url('/assets/backdrops/traveling.png');
  background-size: cover;
  background-position: 50% 50%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 3s ease;
}

.travel-backdrop-overlay.visible {
  opacity: 1;
  /* The base rule's pointer-events:none only makes sense while hidden (see its own
     comment) — once the scene is actually covering the screen, it needs to genuinely
     block the panels underneath, or a mouse can still hover/click straight through it
     (reported as tooltips from panels under the fade still showing). */
  pointer-events: auto;
  /* Slow drift so the "traveling" screen doesn't feel like a static freeze-frame during
     longer trips — purely decorative, no fixed duration tie to the trip length itself. */
  animation: travel-backdrop-drift 24s ease-in-out infinite alternate;
}

@keyframes travel-backdrop-drift {
  from { background-position: 50% 50%; }
  to { background-position: 54% 46%; }
}

.travel-backdrop-countdown {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  text-align: center;
  text-shadow: 0 2px 12px rgba(0, 0, 0, 0.85), 0 0 30px rgba(0, 0, 0, 0.5);
  /* The overlay itself stays pointer-events:none so the scene never blocks clicks on
     whatever's behind it while not visible — but that also swallows clicks on the
     Cancel Travel button now living in here, since pointer-events:none cascades to
     children. Re-enabling it just on this block (not the whole overlay) keeps that
     click-through behavior everywhere except the actual countdown/button area. */
  pointer-events: auto;
}

.travel-backdrop-destination {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(22px * var(--ui-font-scale, 1));
  color: var(--ink);
  margin-bottom: 6px;
}

.travel-backdrop-time {
  font-size: calc(40px * var(--ui-font-scale, 1));
  font-weight: bold;
  font-variant-numeric: tabular-nums;
  color: var(--accent);
}

/* The work lined up for the far end of the road (public/js/travelMap.js's right-click
   chain). Sits between the countdown and the Cancel button, and is deliberately quiet: it
   confirms an order already given, so it reads as a note under the clock rather than
   competing with the one number the traveller is actually watching. */
.travel-backdrop-queued {
  margin-top: 8px;
  font-family: Georgia, 'Times New Roman', serif;
  font-style: italic;
  font-size: calc(14px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* Mirrors #travel-cancel-btn's disabled cutoff (public/js/travel.js), just sized to
   read clearly against the full-screen backdrop scene instead of the World Map panel's
   compact 12px version — still a secondary action under the big countdown, not the
   focal point, so it stops short of the primary-CTA treatment (.death-overlay-teleport-btn). */
.travel-backdrop-cancel-btn {
  display: block;
  margin: 18px auto 0;
  padding: 8px 24px;
  background: rgba(16, 19, 26, 0.65);
  border: 1px solid var(--edge);
  border-radius: 5px;
  color: var(--accent);
  font-weight: bold;
  font-size: calc(15px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.travel-backdrop-cancel-btn:hover:not(:disabled) {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.travel-backdrop-cancel-btn:disabled {
  background: rgba(16, 19, 26, 0.65);
  border-color: rgba(255, 255, 255, 0.12);
  color: var(--muted);
  cursor: not-allowed;
}

.travel-backdrop-cancel-window {
  margin-top: 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* Chat stays fully visible and usable while traveling — its normal in-flow spot near
   the bottom of the page is exactly what the full-screen travel-backdrop-overlay
   (z-index: 5) covers. Pinned to a corner above it only while a trip is actually in
   progress (public/js/travel.js toggles this class alongside the overlay's own
   `visible` one) — snaps back to its ordinary stacked position the instant you arrive.
   20% wider than the un-pinned panel's own natural width (400px -> 480px); the
   animation below plays once whenever the class is freshly added (a fixed-position
   element can't have its entrance smoothly interpolated from the un-pinned static-flow
   layout it's leaving — those are two different positioning schemes with nothing in
   common to transition between — so this animates in from just off its own resting
   spot instead, which reads the same as "sliding down to the corner" without needing
   FLIP-style layout math). */
/* :not(.panel-floating) — a chat panel the player has floated (public/js/panelDock.js)
   is ALREADY position:fixed at their chosen spot and above the road scene, so the pin
   mechanic is redundant for it; without this guard the class's width/animation would
   still fire on every trip, snapping the floated panel's width and replaying the
   entrance animation in place. */
.chat-panel.travel-pinned:not(.panel-floating) {
  position: fixed;
  right: 24px;
  bottom: 24px;
  z-index: 6;
  width: 480px;
  margin: 0;
  animation: chat-panel-pin-in 450ms ease-out;
}

@keyframes chat-panel-pin-in {
  from { transform: translate(40px, 60px); opacity: 0; }
  to { transform: translate(0, 0); opacity: 1; }
}

/* The one explicit height in the whole chat panel — everything else (header, tabs,
   input row) is content-sized, so "shorter while pinned" means shrinking this.
   Carries the SAME :not(.panel-floating) guard as the pin rule above and for the same
   reason: a floating chat is not moved to the corner during travel, so resizing its log
   would just be the panel quietly changing shape under the player mid-trip. The two
   rules have to agree — guarding one and not the other is how you get a chat that
   shrinks but never pins. Both are vestigial now that every panel floats, and both are
   kept intact so the mechanic still works for any panel that later does not. */
.chat-panel.travel-pinned:not(.panel-floating) .chat-log {
  height: 132px;
}

/* Dark parchment track, bronze thumb that lights up gold on hover — matches every other
   scrollable panel's palette (journal/chat log, bank/backpack, skills gump) instead of
   leaving the browser's default light-grey bars clashing with the theme. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--edge) var(--well);
}

*::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

*::-webkit-scrollbar-track {
  background: var(--well);
}

*::-webkit-scrollbar-thumb {
  background: var(--edge);
  border-radius: 5px;
  border: 2px solid var(--well);
}

*::-webkit-scrollbar-thumb:hover {
  background: var(--accent);
}

.auth-page {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 40px 16px;
  background:
    radial-gradient(ellipse at 50% 0%, rgba(255, 255, 255, 0.05), transparent 60%),
    var(--shade);
}

.auth-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  width: 100%;
  max-width: 380px;
}

.auth-logo {
  width: 180px;
  height: 180px;
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.6));
}

.dev-banner {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 10px 16px;
  background: rgba(201, 122, 39, 0.15);
  border: 1px solid #c97a27;
  border-radius: 6px;
  color: #e8a765;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-align: center;
  justify-content: center;
  letter-spacing: 0.3px;
}

/* "Join us on Discord" card on the landing page (views/login.ejs). Shares the auth cards'
   geometry below (full width, 8px radius, same drop shadow and inset second border) so it
   sits in the same stack without looking bolted on, but carries Discord's own blurple
   instead of the game's ember accent — it's an external brand link, and colouring it like
   the login box would read as a third thing to fill in. The whole card is the anchor. */
.discord-cta {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  padding: 16px 20px;
  background: linear-gradient(180deg, rgba(88, 101, 242, 0.20), rgba(88, 101, 242, 0.07));
  border: 1px solid #5865f2;
  outline: 1px solid rgba(88, 101, 242, 0.25);
  outline-offset: -5px;
  border-radius: 8px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  color: #dfe1ff;
  text-decoration: none;
  text-align: left;
  transition: background 140ms ease-out, border-color 140ms ease-out, transform 140ms ease-out;
}

.discord-cta:hover {
  background: linear-gradient(180deg, rgba(88, 101, 242, 0.34), rgba(88, 101, 242, 0.16));
  border-color: #7d88f7;
  transform: translateY(-1px);
}

/* Keyboard parity with the hover state — this is a primary CTA, so it must be obviously
   focused when tabbed to, not just outlined by the UA default. */
.discord-cta:focus-visible {
  outline: 2px solid #b9bbfe;
  outline-offset: 2px;
}

.discord-cta-icon {
  width: 34px;
  height: 34px;
  flex-shrink: 0; /* the logo never squashes when the copy wraps on a narrow screen */
  fill: #ffffff;
}

.discord-cta-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1;
}

.discord-cta-title {
  font-size: calc(15px * var(--ui-font-scale, 1));
  letter-spacing: 0.3px;
  color: #ffffff;
}

.discord-cta-sub {
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.35;
  color: #b9bbfe;
}

/* Purely decorative affordance (aria-hidden in the markup) — nudges right on hover to
   read as "this takes you somewhere", the one motion cue the card has. */
.discord-cta-arrow {
  flex-shrink: 0;
  font-size: calc(22px * var(--ui-font-scale, 1));
  color: #b9bbfe;
  transition: transform 140ms ease-out;
}

.discord-cta:hover .discord-cta-arrow { transform: translateX(2px); }

@media (prefers-reduced-motion: reduce) {
  .discord-cta, .discord-cta-arrow { transition: none; }
  .discord-cta:hover { transform: none; }
  .discord-cta:hover .discord-cta-arrow { transform: none; }
}

/* Shared "parchment card" look for both the login box and the Testers Wanted box —
   a gold double-border and subtle inner glow, distinct from the flat game-panel style
   used once you're actually in the game (this is the first thing anyone sees). */
.auth-box, .testers-box {
  width: 100%;
  background: linear-gradient(180deg, var(--panel), var(--panel-deep));
  border: 1px solid var(--edge);
  outline: 1px solid rgba(var(--edge-rgb), 0.25);
  outline-offset: -5px;
  padding: 28px 32px;
  border-radius: 8px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  text-align: center;
}

.auth-box h1, .testers-box h2 {
  margin: 0 0 4px;
  color: var(--accent);
  font-size: calc(22px * var(--ui-font-scale, 1));
  letter-spacing: 0.5px;
}

.testers-box h2 { font-size: calc(18px * var(--ui-font-scale, 1)); }

.auth-subtitle {
  margin: 0 0 20px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.auth-box form, .testers-box form {
  display: flex;
  flex-direction: column;
  gap: 14px;
  text-align: left;
}

.auth-box label, .testers-box label {
  display: flex;
  flex-direction: column;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  gap: 5px;
}

.auth-box input, .auth-box textarea, .testers-box input, .testers-box textarea {
  padding: 9px 10px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.auth-box input:focus, .auth-box textarea:focus, .testers-box input:focus, .testers-box textarea:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 2px rgba(var(--edge-rgb), 0.25);
}

.auth-box textarea, .testers-box textarea {
  resize: vertical;
  min-height: 60px;
}

.auth-box button, .testers-box button {
  margin-top: 6px;
  padding: 10px;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(14px * var(--ui-font-scale, 1));
  letter-spacing: 0.3px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.auth-box button:hover, .testers-box button:hover { background: var(--accent); }

.auth-box p, .testers-box p { font-size: calc(13px * var(--ui-font-scale, 1)); }

.auth-box a { color: var(--accent); }

/* --- The legal documents (/privacy, /terms — views/legal.ejs) ---------------------------
   Rides the .auth-page background so a link followed from the login screen stays in the
   same world, but takes its own wrapper: .auth-wrap caps at 380px, which is right for a
   login form and unreadable for several thousand words of prose. */
.legal-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 20px;
  width: 100%;
  /* ~70 characters a line at this size — the range prose stays comfortable to read in. */
  max-width: 760px;
}

.legal-page {
  /* Long documents start at the top of the page, not vertically centred like a login box. */
  align-items: flex-start;
}

.legal-logo {
  width: 120px;
  height: 120px;
  filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.6));
}

.legal-doc {
  width: 100%;
  padding: 32px 36px;
  background: linear-gradient(180deg, var(--panel), var(--panel-deep));
  border: 1px solid var(--edge);
  outline: 1px solid rgba(var(--edge-rgb), 0.25);
  outline-offset: -5px;
  border-radius: 8px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45);
  /* Deliberately NOT centred, unlike .auth-box. Centred body text is hard work to read. */
  text-align: left;
}

.legal-doc h1 {
  margin: 0 0 4px;
  color: var(--accent);
  font-size: calc(24px * var(--ui-font-scale, 1));
}

.legal-effective {
  margin: 0 0 24px;
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.legal-section { margin-bottom: 22px; }

.legal-section h2 {
  margin: 0 0 8px;
  color: var(--ink);
  font-size: calc(15px * var(--ui-font-scale, 1));
}

.legal-doc p,
.legal-doc li {
  font-size: calc(13px * var(--ui-font-scale, 1));
  line-height: 1.65;
  color: var(--ink-dim);
}

.legal-doc p { margin: 0 0 10px; }
.legal-doc ul { margin: 0 0 10px; padding-left: 20px; }
.legal-doc li { margin-bottom: 5px; }
.legal-doc a { color: var(--accent); overflow-wrap: anywhere; }

/* The quiet pair of links under the login box. */
.legal-footer {
  display: flex;
  justify-content: center;
  gap: 18px;
  margin: 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.legal-footer a { color: var(--muted); }
.legal-footer a:hover { color: var(--accent); }

.legal-crosslink {
  margin-top: 28px;
  padding-top: 16px;
  border-top: 1px solid var(--edge);
  display: flex;
  gap: 16px;
}

/* --- The scroll-to-accept box (views/partials/legalAccept.ejs) -------------------------- */
.legal-accept {
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 12px;
  background: rgba(0, 0, 0, 0.18);
  text-align: left;
}

.legal-accept-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
}

.legal-accept-title {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--ink);
}

.legal-accept-open {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--accent);
}

/* The box that must actually be scrolled. A fixed height in px, not vh: on a short laptop
   window a vh-based box collapses to a couple of lines, and on a tall monitor it grows tall
   enough not to scroll at all — which would hand out the tick for free. */
.legal-accept-scroll {
  height: 180px;
  overflow-y: auto;
  padding: 10px 12px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
  /* Focusable for keyboard scrolling (see the partial); give that focus a visible ring
     rather than the browser default, which the reset above suppresses elsewhere. */
  outline-offset: -2px;
}

.legal-accept-scroll:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.6);
}

.legal-accept-scroll h4 {
  margin: 12px 0 5px;
  color: var(--accent);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.legal-accept-scroll h4:first-child { margin-top: 0; }

.legal-accept-scroll p,
.legal-accept-scroll li {
  margin: 0 0 7px;
  font-size: calc(11.5px * var(--ui-font-scale, 1));
  line-height: 1.55;
  color: var(--ink-dim);
}

.legal-accept-scroll ul { margin: 0 0 7px; padding-left: 18px; }

.legal-accept-end {
  margin-top: 12px;
  padding-top: 8px;
  border-top: 1px solid var(--edge);
  color: var(--muted);
  font-style: italic;
}

.legal-accept-hint {
  margin: 8px 0 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #d8a25e;
}

/* Once unlocked the hint is no longer a warning, so it stops wearing the warning colour. */
.legal-accept-unlocked .legal-accept-hint { color: #7fd1a8; }

/* Overrides .auth-box label's column flex — a checkbox and its sentence belong on one line
   with the box beside the text, not stacked above it. */
.auth-box .legal-accept-check,
.testers-box .legal-accept-check {
  flex-direction: row;
  align-items: flex-start;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.5;
  cursor: pointer;
}

.legal-accept-check input {
  /* width:auto so it escapes the full-width sizing .auth-box input applies to text fields. */
  width: auto;
  margin-top: 2px;
  flex: none;
}

/* A disabled tick should look unavailable, and the whole label greys with it so the reason
   reads as "not yet" rather than "broken". */
.legal-accept-check input:disabled {
  cursor: not-allowed;
}

.legal-accept:not(.legal-accept-unlocked) .legal-accept-check {
  opacity: 0.6;
  cursor: not-allowed;
}

.error { color: #e25c4f; }

.success-message { color: #7fd1a8; font-size: calc(13px * var(--ui-font-scale, 1)); }

.field-feedback {
  margin: -8px 0 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  min-height: 14px;
}

.password-checklist {
  list-style: none;
  margin: -8px 0 0;
  padding: 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.rule-met { color: #7fd1a8; }
.rule-unmet { color: var(--muted); }

.auth-note {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
}

.testers-box {
  border-color: rgba(255, 255, 255, 0.12);
}

.testers-box p.auth-subtitle { margin-bottom: 16px; }

.game-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 6px 20px;
  background:
    radial-gradient(ellipse 260px 100% at 90px 50%, rgba(var(--accent-rgb), 0.14), transparent 70%),
    var(--panel);
  border-bottom: 3px solid var(--edge);
  box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35), 0 1px 0 rgba(var(--accent-rgb), 0.12) inset;
  /* Sticky (was static) since the header crest hangs off this bar (.header-crest near
     the end of this file) — pinning the header keeps the XP bar and attack/armor/
     weight badges it carries always on screen, matching the always-visible guarantee
     they had inside the old position:fixed Status gump. z-index above the scrolling
     panels but below floating gumps (gumpWindow.js starts its shared z counter at 950). */
  position: sticky;
  top: 0;
  z-index: 60;
}

/* The character name/vitals used to live here (nameplate.ejs, inline) — that content is
   now the always-visible, draggable Status gump (statusGump.ejs) instead, so the header
   itself just needs the wordmark in its place. */
.game-header-title {
  display: flex;
  align-items: center;
}

/* The logo lockup: the TIS seal hanging off the bar + the wordmark stack beside it
   (views/game.ejs). The wordmark's colors are still built from the active theme's accent
   tokens (--accent-bright/--accent/--accent-strong/--accent-deep + --accent-rgb) so the
   LETTERING rethemes with the chrome — ember by default, ice under Cold, arcane purple
   under Mystic. The seal is bitmap art and deliberately does not try to retint: stone and
   bronze are its identity, the way the map's own art never rethemes either. */
.game-header-logo {
  display: flex;
  align-items: center;
  gap: 12px;
  /* Room for the absolutely-positioned seal, which is wider than the flow ever was. */
  position: relative;
  padding-left: 94px;
}

/* The seal, hung over the bar's bottom edge like a tavern sign — IN FULL, compass banner
   and all, by design ask (2026-08-15): the first cut cropped it square to the ring and it
   read as clipped. The header is position:sticky at z-index 60 with no overflow clip
   (its own comment explains the stack), so the overhang paints over whatever panel sits
   beneath — and pointer-events:none is what keeps that overhang from ever eating a click
   on it. 80px against a 160px asset, so it stays sharp on a 2x display. */
.logo-crest {
  position: absolute;
  left: 0;
  top: -2px;
  width: 80px;
  height: auto;
  pointer-events: none;
  z-index: 2;
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.18));
}

.game-header-wordstack {
  display: flex;
  flex-direction: column;
  /* Trims Georgia's unused ascender room so the two lines sit as one lockup; the glyphs
     themselves don't clip. The bar rides a little taller than the single-line days and
     that is accepted — the user's own mock chose clarity over the saved pixels. */
  line-height: 0.9;
  gap: 5px;
}

/* One line, one voice — "A Britannia that never sleeps" in letterspaced steel capitals.
   The first cut set two voices here (a gold "IDLE SINCE 2026" plus this line etched
   italic) and the user's own mock of the single line was simply clearer.

   The steel is the CREST's steel (#9db1c7 sits in its silverwork), deliberately not a
   theme token: the seal's stone and silver never retheme, and this line is set in the
   seal's own metal so the lockup reads as one made object. The WORDMARK above stays on
   theme tokens, so the themed lettering sits between two fixed-metal anchors. */
.game-header-tagline {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.3em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #9db1c7;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.9);
}

/* The header wordmark — same small-caps engraving idea as the footer's
   .game-footer-title (views/partials/footer.ejs), bigger and with a stronger glow so
   it still reads as the page's anchor point next to the nav links. Two background
   layers, both clipped to the letterforms: a diagonal white sheen that periodically
   sweeps across (top), over the theme-accent metal gradient (bottom) — the
   bright-to-deep-and-back stops are what sell the engraved-relief look. */
.game-header-wordmark {
  font-family: Georgia, 'Times New Roman', serif;
  font-variant: small-caps;
  font-weight: 700;
  /* 30px — near its old solo 32px. The first stacked cut shrank it to 25px to hold the
     bar's height, and the user's own mock overruled that: the title leads the lockup,
     and the taller bar is the price of it reading like one. */
  font-size: calc(30px * var(--ui-font-scale, 1));
  letter-spacing: 0.08em;
  background:
    linear-gradient(115deg, transparent 42%, rgba(255, 255, 255, 0.7) 50%, transparent 58%) 100% 0 / 300% 100% no-repeat,
    linear-gradient(180deg, var(--accent-bright) 0%, var(--accent) 34%, var(--accent-strong) 56%, var(--accent-deep) 80%, var(--accent) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.9)) drop-shadow(0 0 16px rgba(var(--accent-rgb), 0.45));
  transition: filter 0.25s ease, transform 0.25s ease;
  animation: wordmark-sheen 9s ease-in-out infinite;
}

/* Percentage background-position on an oversized (300%) layer: 100% parks the sheen
   off the left edge, 0% off the right — so holding 100% for most of the cycle, then
   easing to 0%, reads as a light pass sweeping left-to-right every ~9s. The metal
   layer's position pins at 0 0 throughout. */
@keyframes wordmark-sheen {
  0%, 74% { background-position: 100% 0, 0 0; }
  92%, 100% { background-position: 0% 0, 0 0; }
}

.game-header-wordmark:hover {
  filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.9)) drop-shadow(0 0 24px rgba(var(--accent-rgb), 0.8));
  transform: scale(1.03);
}

@media (prefers-reduced-motion: reduce) {
  .game-header-wordmark { animation: none; }
}

.game-header-right {
  display: flex;
  align-items: center;
  gap: 20px;
}

.game-header-nav {
  display: flex;
  align-items: center;
  gap: 16px;
}

.game-header-nav a {
  color: var(--accent);
  text-decoration: none;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.game-header-nav a:hover { text-decoration: underline; }

/* Discord invite — the one nav link that isn't themed to --accent (Discord's own
   "blurple" brand color instead), so it reads as the Discord icon at a glance the same
   way it would anywhere else on the web, regardless of which in-game theme is active. */
.discord-header-link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  color: #b9bbfe !important;
}
.discord-header-link:hover { color: #ffffff !important; }
.discord-header-icon {
  width: 16px;
  height: 16px;
  fill: currentColor;
}

.game-header-nav-button {
  background: none;
  border: none;
  color: var(--accent);
  font-size: calc(14px * var(--ui-font-scale, 1));
  font-family: inherit;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  padding: 0;
}

.game-header-nav-button:hover { text-decoration: underline; }

/* The header's master sound toggle (public/js/sfx.js) — an emoji speaker glyph, so
   no underline-on-hover (that reads oddly on a symbol); a small scale pop instead,
   and the muted state dims it the same way ineligible skill rows dim. */
.sound-toggle-btn {
  font-size: calc(16px * var(--ui-font-scale, 1));
  line-height: 1;
  transition: transform 100ms ease;
}

.sound-toggle-btn:hover {
  text-decoration: none;
  transform: scale(1.2);
}

.sound-toggle-btn.muted {
  opacity: 0.45;
}

/* The header's master layout lock (public/js/panelLock.js) — same emoji-glyph recipe
   as the sound toggle beside it. Locked is a deliberate engaged mode, not an "off"
   state, so it highlights in the accent (matching the old per-gump padlocks' own
   locked styling) rather than dimming the way .muted does. */
.panel-lock-toggle-btn {
  font-size: calc(16px * var(--ui-font-scale, 1));
  line-height: 1;
  transition: transform 100ms ease;
}

.panel-lock-toggle-btn:hover {
  text-decoration: none;
  transform: scale(1.2);
}

.panel-lock-toggle-btn.locked {
  color: var(--accent);
  text-shadow: 0 0 6px rgba(255, 181, 120, 0.5);
}

/* The per-panel show/hide menu (public/js/panelVisibility.js) — the header's second
   layout control, sitting beside the padlock above and styled to match it. */
.panel-visibility-wrap {
  position: relative;
  display: inline-flex;
  align-items: center;
}

.panel-visibility-toggle-btn {
  font-size: calc(16px * var(--ui-font-scale, 1));
  line-height: 1;
  transition: transform 100ms ease;
}

.panel-visibility-toggle-btn:hover {
  text-decoration: none;
  transform: scale(1.2);
}

.panel-visibility-toggle-btn.open {
  color: var(--accent);
  text-shadow: 0 0 6px rgba(255, 181, 120, 0.5);
}

/* Anchored under the button rather than centred on screen: this is a quick checklist you
   flick open and shut, not a gump. right: 0 so it grows leftwards and can never push the
   viewport wider — the button sits near the right edge of the header. */
.panel-visibility-menu {
  display: none;
  position: absolute;
  top: calc(100% + 10px);
  right: 0;
  /* Above the floating panels band (gumpWindow.js parks those from 950 up) so the menu is
     never covered by the very panels it toggles, but below the modal band (3000+). */
  z-index: 2400;
  padding: 8px;
  gap: 4px;
  /* Same two tokens every gump uses (--panel / --edge), so this follows whichever theme
     is active rather than pinning one palette. */
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.6);
  /* --panel is deliberately translucent; without this the panels behind show through the
     menu and the glyph row becomes unreadable against a busy scene. */
  backdrop-filter: blur(6px);
  /* One row of glyphs. grid-auto-flow keeps them on a single line without needing a
     hardcoded column count that would break if a ninth panel is registered. */
  grid-auto-flow: column;
}

.panel-visibility-menu.open { display: grid; }

.panel-visibility-item {
  background: none;
  border: 1px solid transparent;
  border-radius: 6px;
  padding: 6px 8px;
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  transition: background 120ms ease, opacity 120ms ease;
}

.panel-visibility-item:hover { background: rgba(255, 255, 255, 0.07); }

.panel-visibility-item-icon {
  font-size: calc(18px * var(--ui-font-scale, 1));
  line-height: 1;
  display: block;
}

/* Off reads as dimmed-and-struck rather than merely dim — dimming alone is the same
   signal this UI already uses for "you can't afford this", and a player scanning the row
   should be able to tell "switched off" from "unavailable" at a glance. */
.panel-visibility-item.is-off {
  opacity: 0.35;
  border-color: rgba(255, 255, 255, 0.08);
}

.panel-visibility-item.is-off .panel-visibility-item-icon {
  filter: grayscale(1);
}

/* The hide itself. !important because it has to beat every panel's own display rule —
   .journal-panel/.chat-panel and the flex containers each set their own, and this must
   win over all of them without this file knowing which. Applied by panelVisibility.js and
   nothing else; it never touches position, parent or saved layout, which is what lets a
   panel come back exactly where it was. */
.panel-hidden { display: none !important; }

/* The nameplate — character name plus a compact Hits/Mana/Stam bar stack, styled
   after the classic UO overhead status bars. Bar colors match their classic UO
   status-window counterparts (red/blue/green); no combat system moves current
   below max yet, see calculateVitals in services/combatStats.js. */
.nameplate {
  /* relative so spell FX (spellCast.js's player-targeted spawnFx) can anchor over the
     vitals bars rather than the whole floating gump. */
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.nameplate-name {
  font-size: calc(20px * var(--ui-font-scale, 1));
  color: var(--accent);
  font-weight: bold;
}

.nameplate-bars {
  display: flex;
  flex-direction: column;
  gap: 2px;
  width: 190px;
}

.nameplate-bar-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 6px;
}

.nameplate-bar-tag {
  width: 30px;
  flex-shrink: 0;
  font-size: calc(9px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
}

.nameplate-bar {
  flex: 1;
  height: 7px;
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 3px;
  overflow: hidden;
}

.nameplate-bar-fill {
  height: 100%;
  transition: width 0.6s ease-out;
}

.nameplate-bar-fill.nameplate-bar-hits { background: #e25c4f; }
.nameplate-bar-fill.nameplate-bar-mana { background: #5d8ef0; }
.nameplate-bar-fill.nameplate-bar-stam { background: #8fc84f; }

.nameplate-bar-value {
  width: 42px;
  flex-shrink: 0;
  text-align: right;
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

/* The Status gump — same floating-window chrome as every other gump (see the comment
   above .skills-gump), just wrapping the .nameplate content instead of living fixed in
   the header. No close button, so no matching -close rule is needed. */
.status-gump {
  width: 220px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}

.status-gump .nameplate {
  padding: 10px 14px;
}

.game-main {
  display: flex;
  gap: 40px;
  padding: 30px;
  flex-wrap: wrap;
}

/* Every panel now floats (public/js/panelDock.js), so this row exists only to be
   measured: the flex flow above still decides where each panel STARTS, and the moment
   the last one has been read and moved into the floating layer, panelDock stamps this
   class. Without it the row's own 30px padding would sit under the header as a band of
   empty space nothing can ever fill again. */
.game-main.panels-all-floating {
  display: none;
}

.journal-panel {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 16px;
  margin: 0 30px 30px;
}

.journal-panel h2 {
  margin-top: 0;
  color: var(--accent);
  font-size: calc(16px * var(--ui-font-scale, 1));
}

.journal-tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 8px;
}

.journal-tab {
  padding: 4px 12px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.journal-tab.active {
  background: var(--edge);
  color: var(--on-accent);
}

/* The Training Yard's heading inside the Quest Journal (public/js/questLog.js) — the
   rows under it are ordinary .stable-mount-rows, so this is the only new rule the block
   needs: a quiet label saying where the work is, since unlike every other journal line
   these are not turned in where you stand. */
.quest-log-training-header {
  margin: 10px 2px 4px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.05em;
  text-transform: uppercase;
}

/* The Combat Trainer's panel (views/partials/combatTrainerGump.ejs) — one row per
   trainable fighting skill: name, progress against the cap, and either a claim button or
   how far is left. Deliberately a plain stacked list rather than a table: five rows with
   three cells each is a list, and a table would bring borders and column rules to a panel
   whose whole job is to be read at a glance. */
/* Two classes, not one. `.skills-gump` sets width:260px further down this file at equal
   specificity, so a single-class `.combat-trainer-gump { width: 360px }` LOSES to it on
   source order and the panel silently rendered 100px narrower than its own columns were
   sized for — which is what pushed "Kitted out" clean off the right-hand edge. Same fix,
   same reason, as `.skills-gump.spellbook-gump` below. */
.skills-gump.combat-trainer-gump,
.skills-gump.training-chest-gump {
  width: 360px;
}

.combat-trainer-body {
  padding: 12px;
}

.combat-trainer-blurb {
  margin: 0 0 12px;
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.45;
}

.combat-trainer-row {
  display: flex;
  align-items: center;
  /* Every row the same height whether it ends in a button or a line of text, so the list
     reads as a column rather than as five boxes of assorted sizes. Set to the height a row
     with a button comes out at, since that is the taller of the two and the floor has to
     meet it rather than the other way round. */
  min-height: 40px;
  gap: 10px;
  padding: 7px 10px;
  margin-bottom: 6px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 5px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink);
}

/* min-width:0 defeats the flexbox default of min-width:auto, which floors a flex item at
   its content's min-content width. "Swordsmanship" is one unbreakable word, so without
   this the name column refuses to shrink and shoves the columns beside it out of the
   panel instead — the second half of the overflow above. Ellipsis rather than a wrap,
   because a name is allowed to be clipped but the row must not grow a line. */
.combat-trainer-skill {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Progress sits in the middle and is the thing the panel is opened to read, so it holds
   its own column width rather than being pushed around by the button beside it. */
.combat-trainer-level {
  min-width: 74px;
  text-align: right;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.combat-trainer-level-met {
  color: var(--accent);
}

/* Wrestling's "no weapon in the kit" note. It used to live on the button, where the extra
   words wrapped and doubled that row's height; here it sits under the skill name, which
   had spare width going begging. */
.combat-trainer-note {
  display: block;
  /* Its own ellipsis, so if a font scale ever outgrows the column it trims cleanly
     instead of inheriting the parent's clip mid-glyph. */
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-style: italic;
}

.combat-trainer-claim-btn {
  flex: none;
  /* Never wrap. A two-line button was doubling its row's height on its own. */
  white-space: nowrap;
  padding: 4px 10px;
  background: var(--edge);
  border: 1px solid var(--accent);
  border-radius: 3px;
  color: var(--on-accent);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.combat-trainer-claim-btn:hover {
  background: var(--accent);
}

/* The chest gump's item icon (public/js/trainingChest.js) — the 44px item sprite shrunk
   to row height, pixelated like every item icon. */
.training-chest-icon {
  flex: none;
  width: 28px;
  height: 28px;
  image-rendering: pixelated;
}

.combat-trainer-done,
.combat-trainer-todo {
  flex: none;
  min-width: 92px;
  white-space: nowrap;
  text-align: right;
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
}

/* The settings cog at the right end of the tab row (views/partials/journal.ejs, opened by
   public/js/journal.js). margin-left:auto is what puts it there — the row is a flex box of
   tabs, so the cog simply takes the remaining space to its left rather than needing the
   row to become a two-column layout. Styled as a quieter sibling of .journal-tab: same
   box, no bold, muted until hovered, so it reads as chrome rather than a fifth filter. */
.journal-cog {
  margin-left: auto;
  padding: 4px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font-size: calc(13px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.journal-cog:hover,
.journal-cog[aria-expanded="true"] {
  color: var(--accent);
  border-color: var(--accent);
}

/* The popover itself. position: fixed and a body child (see public/js/journal.js) so
   neither the log's overflow nor the panel's own box can clip it.
   z-index 2400, the same band .panel-visibility-menu uses and for the identical reason:
   above the floating panels (gumpWindow.js parks those from 950 up and RAISES them on
   every click, so any fixed value inside that band loses eventually), but below the modal
   band (3000+), which should still cover this. It first shipped at 60 — the header's
   band — which put it underneath every floated panel: reported as the settings panel
   dropping behind the chat window, which is exactly what a 60 against a 950 does. */
.journal-settings-popup {
  position: fixed;
  z-index: 2400;
  min-width: 210px;
  padding: 10px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
}

.journal-settings-title {
  margin-bottom: 8px;
  color: var(--accent);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.journal-settings-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 6px;
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The checkbox row reads label-after-control, so it flips to start-aligned rather than
   pushing the tick to the far edge away from the words it belongs to. */
.journal-settings-check {
  justify-content: flex-start;
}

.journal-settings-row select {
  padding: 2px 4px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.journal-settings-hint {
  margin-top: 2px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.35;
}

.journal-log {
  height: 160px;
  min-height: 80px;
  max-height: 70vh;
  overflow: auto;
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  padding: 8px;
  /* --journal-font-px is set by the cog's Text size setting (public/js/journal.js's
     applyJournalFontSize); 13px is the size the journal has always been. The
     --ui-font-scale multiplication stays HERE rather than in the JS so the global UI
     scale and the per-panel size cannot drift apart. */
  font-size: calc(var(--journal-font-px, 13px) * var(--ui-font-scale, 1));
  display: flex;
  flex-direction: column;
  gap: 2px;
  /* Drag-resize in BOTH directions (public/js/resizableLogs.js persists whatever the
     player leaves it at) — only while the global panel layout is unlocked. Locking
     freezes it at its current size rather than snapping back to the 160px default; see
     body.panels-locked's override further down.
     Widening the log has to widen the panel around it, which needs a nudge now that
     .journal-panel/.chat-panel carry a fixed floating width; see
     .panel-floating.log-custom-width. */
  resize: both;
  /* The floor and ceiling on that drag. Without a min the handle can be dragged down to
     a few unreadable pixels with no way back (the handle goes with it); the max keeps a
     panel from being pulled wider than the window and pushing the layout off-screen. */
  min-width: 240px;
  max-width: 88vw;
}
/* Same lock-gated resize as .journal-log above — the global layout lock
   (public/js/panelLock.js's body.panels-locked) is the single switch every other
   draggable surface already reads, so this piggybacks on it rather than inventing a
   separate "is this log resizable" state. Removes the resize handle only; whatever
   height was last dragged to stays exactly where it was (this doesn't touch height). */
body.panels-locked .journal-log,
body.panels-locked .chat-log {
  resize: none;
}

.journal-entry.journal-fail { color: #e25c4f; }
.journal-entry.journal-success { color: #7fd1a8; }
.journal-entry.journal-skill { color: #6ec6ff; }
.journal-entry.journal-stat { color: #4caf50; }
.journal-entry.journal-info { color: var(--ink); }
.journal-entry.journal-vendor { color: #e0a854; font-style: italic; }
.journal-entry.journal-devtool { color: #d896ff; text-shadow: 0 0 4px rgba(216, 150, 255, 0.6); }
.journal-entry.journal-combat { color: #e07b39; }
/* Routine damage-number lines — colorClass overrides on an otherwise-plain 'combat' line
   (sockets/combatHandlers.js), split by WHO dealt the damage so a busy fight log reads
   at a glance instead of every hit blending into the same shared orange: warm gold for
   anything the player's own side lands on the monster (a swing, a weapon-proc bleed
   tick, thorns, Vampiric Band's drain), vivid red for anything landing on the player
   (a monster's hit, the Fire/Ice Dungeon's own environmental tick, a Dungeon Boss DoT).
   Deliberately NOT applied to the already-emphasized 'crit' lines below (a player's own
   critical hit, a monster's special spell/ability) — those stay the loudest thing in the
   log either way, a bigger moment than the routine my-side/their-side split exists to
   clarify. */
.journal-entry.journal-myDamage { color: #f0d878; }
.journal-entry.journal-creatureDamage { color: #ff6b6b; }
/* A landed critical hit (Archery's own bonus crit roll, or Valor's Binding crit) —
   sockets/combatHandlers.js's colorClass override on an otherwise-plain 'combat' line.
   Brighter than .journal-combat's own orange plus a glow, same "louder" treatment the
   crit's sprite flash (brightness 2.7 vs a normal hit's 2.1) and damage popup already
   get, so a crit reads as distinct in the Journal too, not just on the battlefield. */
.journal-entry.journal-crit { color: #ff9e3a; font-weight: bold; text-shadow: 0 0 4px rgba(255, 158, 58, 0.6); }
/* An Explosion Potion going off (sockets/combatHandlers.js's detonation). Its own colour
   rather than the amber myDamage every other hit uses: a bomb lands five seconds after you
   threw it, in the middle of a scrolling column of swings, and the whole point of a delayed
   effect is being able to find it. Purple is unused by any combat line, and the glow
   matches the crit treatment directly above so the two loud entries read as a set. */
.journal-entry.journal-explosion { color: #b16cff; font-weight: bold; text-shadow: 0 0 4px rgba(177, 108, 255, 0.6); }
.journal-entry.journal-chatter { color: #9fb88a; font-style: italic; }
.journal-entry.journal-loot { color: #f4c430; font-weight: bold; }
/* A skill-gain chance roll (services/skillGain.js's logGainChance) — deliberately
   dimmer/quieter than .journal-skill's own gain-succeeded blue, since this fires on
   EVERY roll (success or not) and would otherwise visually compete with the rarer,
   more noteworthy "it actually gained" line. */
.journal-entry.journal-gainChance { color: #7a92a3; }
/* A GM /broadcast (sockets/adminHandlers.js) — deliberately the loudest line in the
   Journal, gold-on-glow like .journal-loot but bordered so it reads as an announcement
   rather than a drop. */
.journal-entry.journal-broadcast {
  display: block;
  color: var(--accent-bright);
  font-weight: bold;
  text-shadow: 0 0 6px rgba(var(--edge-rgb), 0.6);
  border-left: 2px solid var(--accent);
  padding-left: 6px;
  margin: 2px 0;
}

/* Dungeon Boon start/end (services/dungeonBoons.js) — same "bordered announcement"
   shape as .journal-broadcast above, in the dungeon pins' own purple instead of ember. */
.journal-entry.journal-dungeonBoon {
  display: block;
  color: #d8b6f0;
  font-weight: bold;
  text-shadow: 0 0 6px rgba(168, 107, 214, 0.6);
  border-left: 2px solid #a86bd6;
  padding-left: 6px;
  margin: 2px 0;
}

/* Harvesting Boon start/end (services/harvestingBoons.js) — same "bordered
   announcement" shape as .journal-dungeonBoon above, in a harvest-gold instead of the
   dungeon pins' violet, so the two boon kinds read as distinct news at a glance. */
.journal-entry.journal-pkBoon {
  display: block;
  color: #f08a8a;
  font-weight: bold;
}

.journal-entry.journal-harvestBoon {
  display: block;
  color: #e0c274;
  font-weight: bold;
  text-shadow: 0 0 6px rgba(196, 155, 74, 0.6);
  border-left: 2px solid #c49b4a;
  padding-left: 6px;
  margin: 2px 0;
}

/* Death: no auto-res anymore — the world goes greyscale until "Res" is said in Local
   chat. Scoped to #game-world (header/main/journal/chat), NOT <body> — a CSS filter on
   an ancestor establishes a new containing block for any position:fixed descendant
   (same as transform/perspective), so a body-level filter would make every draggable
   gump (all position:fixed via gumpWindow.js, lock button or not — Status, Bank,
   Stable, Skills, Travel, Guild, DevTools, Combat, GroundLoot, Spellbook) drift with
   page scroll instead of staying pinned to the viewport. The gumps and the death banner
   live outside this wrapper (see views/game.ejs) and stay full-color/stationary. */
.game-world.is-dead { filter: grayscale(1); }

/* --- The pall over the world (views/partials/deathOverlay.ejs) ------------------------
   The rule above only ever reached what was still INSIDE #game-world, and by the time a
   player is looking at a death screen that is very little: public/js/panelDock.js floats
   all eight main panels out to #floating-panels-layer, every gump renders outside it, and
   public/js/options.js reparents the visible scene to <body> when the full-screen setting
   is on. So the world went grey and the things actually on top of it — the paperdoll, the
   backpack, the map, the bank, and the whole scene you were standing in — stayed in full
   colour. This sheet is what makes death look like death: one fixed layer the entire
   screen is seen through, draining colour and light from whatever is painted beneath it,
   regardless of where in the DOM that lives.

   The rule above is KEPT rather than replaced: backdrop-filter is the one property here
   with a real chance of being unavailable, and a browser without it still darkens through
   the background colour below — with #game-world's own filter, the world still greys.

   z-index 9400 is the band this has to sit in: above every gump and floated panel
   (public/js/gumpWindow.js's shared counter runs 950-2300 and renormalises rather than
   climbing past it), below the toast band at 9500 and the death banner's own 9600.
   pointer-events: none throughout — a ghost still walks, clicks and drags through it. */
.death-scrim {
  position: fixed;
  inset: 0;
  z-index: 9400;
  pointer-events: none;
  display: none;
  background: rgba(6, 7, 10, 0.5);
  backdrop-filter: grayscale(1) brightness(0.55);
  -webkit-backdrop-filter: grayscale(1) brightness(0.55);
}

body.is-dead .death-scrim { display: block; }

/* The one window that must survive it. "Res" is said in Local chat, so chat stays in full
   colour and fully legible above the pall — which also means it stops being coverable by
   a gump for as long as you are dead. !important because gumpWindow.js writes its z-index
   inline on every raise, and an inline style beats an ordinary rule. */
body.is-dead .chat-panel { z-index: 9450 !important; }

/* Full-viewport flex column so the footer stays pinned to the bottom. Every panel is
   floated out of the page flow on load (public/js/panelDock.js), so main/journal/chat
   collapse to nothing and without this the footer rode straight up to sit under the
   header. margin-top:auto on .game-footer (below) takes up the slack. */
.game-world {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

.death-overlay {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  /* Above the gump/floated-panel band (950+, gumpWindow.js) and the modal band (3000s)
     — a death banner must never be coverable by a window someone parked at the top of
     the screen. Below only the tooltip band (10000s). */
  z-index: 9600;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 18px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
}

.death-overlay-title { color: #e25c4f; font-weight: bold; font-size: calc(15px * var(--ui-font-scale, 1)); }
.death-overlay-text { color: var(--ink); font-size: calc(13px * var(--ui-font-scale, 1)); }

/* The floating craft-batch pill (views/game.ejs, synced by public/js/activities.js) —
   same top-center fixed placement as .death-overlay above, just lower stakes and lower
   z-index (a death banner appearing mid-batch should win the spot; the batch is being
   cancelled server-side at that moment anyway). */
.craft-status-pill {
  position: fixed;
  top: 12px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 400;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 12px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
}

.craft-status-pill-label {
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--accent);
  font-weight: bold;
}

.craft-status-pill-stop {
  padding: 3px 12px;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 5px;
  color: var(--accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.craft-status-pill-stop:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.death-overlay-teleport-btn {
  padding: 6px 14px;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(13px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.death-overlay-teleport-btn:hover { background: var(--accent); }

.journal-action-link {
  display: inline-block;
  margin-left: 4px;
  padding: 1px 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-family: inherit;
  color: var(--on-accent);
  background: var(--accent);
  border: 1px solid var(--edge);
  border-radius: 10px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  line-height: 1.6;
}

.journal-action-link:hover { background: var(--accent-bright); }

.chat-panel {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 16px;
  margin: 0 30px 30px;
}

.chat-panel h2 {
  margin-top: 0;
  color: var(--accent);
  font-size: calc(16px * var(--ui-font-scale, 1));
}

.chat-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 8px;
}

/* DM tabs (public/js/chat.js's chatOpenDmTab) share every fixed channel tab's base
   look, just tinted violet to match .chat-channel-dm's message color. Double-class
   selector so this beats the later plain ".chat-tab { color: var(--muted) }" rule
   further down this file regardless of source order; .chat-tab.active (also 2 classes,
   but declared after this) still wins the active state on specificity + source order. */
.chat-tab.chat-tab-dm {
  border-color: rgba(217, 168, 240, 0.4);
  color: #d9a8f0;
}

.chat-tab {
  position: relative;
  padding: 4px 12px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.chat-tab.active {
  background: var(--edge);
  color: var(--on-accent);
}

/* Unread indicator (public/js/chat.js) — messages keep arriving for a tab you're not
   looking at (this file's own opening comment), but nothing announced that before: no
   popup, no sound, nothing stealing focus — just a small amber count that pops in once
   per new arrival and clears the instant you switch to that tab. Never shown on the
   active tab (there's nothing "unread" about what's on screen). */
.chat-tab-badge {
  position: absolute;
  top: -6px;
  right: -6px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 8px;
  background: #e0a030;
  color: #1a1310;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(16px * var(--ui-font-scale, 1));
  text-align: center;
  border: 1px solid rgba(0, 0, 0, 0.35);
  pointer-events: none;
}

@keyframes chat-tab-badge-pop {
  0% { transform: scale(0.4); }
  60% { transform: scale(1.25); }
  100% { transform: scale(1); }
}
.chat-tab-badge.chat-tab-badge-pop {
  animation: chat-tab-badge-pop 260ms ease-out;
}

.chat-log {
  height: 120px;
  min-height: 80px;
  max-height: 70vh;
  overflow: auto;
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  padding: 8px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-bottom: 10px;
  /* Same lock-gated two-axis drag-resize as .journal-log, same floor and ceiling — see
     that rule's own comment for why width works at all. */
  resize: both;
  min-width: 240px;
  max-width: 88vw;
}

.chat-entry-name {
  font-weight: bold;
}

/* The Discord-verified mark beside a speaker's name (public/js/chat.js), earned by linking
   an account with "/discord".

   Drawn as a masked shape rather than a text glyph: an inline ✓ would inherit the chat font
   and render at a different weight, width and baseline on every platform, and the one thing
   a verification mark has to be is the same mark every time. The mask paints Discord's own
   blurple through the shape, so it stays crisp at any --ui-font-scale and costs no request.

   ONE path with fill-rule="evenodd", not two paths. A CSS mask reads the ALPHA channel, so
   drawing the tick as a white shape on a black disc would mask nothing — white is every bit
   as opaque as black and the badge would come out a solid blob. evenodd puts the tick inside
   the disc as a genuine hole, which is the only way to knock it out of an alpha mask.

   The custom property is declared on the shared selector rather than :root — it exists purely
   so the mask lines cannot drift apart, and nothing else in the file wants it.

   Sized in em, not px, so it tracks the text beside it if the player scales the UI.

   TWO PLACES WEAR THIS MARK and they share everything except what happens when you press it:
   the chat log (public/js/chat.js), where it is inert and simply reports a fact, and the
   Admin Panel roster (public/js/admin.js), where it is a real button that DMs the player. One
   rule for the shape so the two can never come to mean visibly different things. */
/* The Staff pill (public/js/chat.js) — a filled ember chip before a staff member's name
   in the chat log. Deliberately the one loud thing on a line: the whole point is that an
   official word is distinguishable from a player's at a glance while the log scrolls.
   Filled rather than outlined for that reason, and in the shard's own accent rather than
   a new colour, so it reads as chrome the game already owns. */
.chat-staff-pill {
  display: inline-block;
  margin-right: 0.35em;
  padding: 0 0.4em;
  border-radius: 999px;
  background: var(--accent);
  color: var(--on-accent);
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.06em;
  line-height: 1.5;
  vertical-align: 0.08em;
  /* The tooltip (public/js/tooltip.js) reads data-tooltip off it. */
  pointer-events: auto;
}

.chat-verified-badge,
.admin-verified-badge {
  --chat-verified-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill-rule='evenodd' d='M12 2a10 10 0 1 0 0 20 10 10 0 1 0 0-20zm-1.4 13.9L7 12.3l1.4-1.4 2.2 2.2 5-5L17 9.5z'/%3E%3C/svg%3E");
  display: inline-block;
  width: 1em;
  height: 1em;
  margin-right: 0.25em;
  vertical-align: -0.12em;
  background-color: #5865f2; /* Discord blurple */
  -webkit-mask: var(--chat-verified-mask) no-repeat center / contain;
  mask: var(--chat-verified-mask) no-repeat center / contain;
  /* The tooltip (public/js/tooltip.js) reads data-tooltip off it, so it has to be able to
     take a pointer. */
  pointer-events: auto;
}

/* The admin copy is a <button>, so it has browser chrome to strip and a press to advertise.
   Slightly larger than the chat mark because it is a target rather than a footnote, and it
   sits in a dense roster row where 1em would be easy to miss.

   It FOLLOWS the name (public/js/admin.js appends it to the name cell) where the chat one
   precedes it, so the shared margin-right above is wrong here and becomes a margin-left. */
.admin-verified-badge {
  width: 1.15em;
  height: 1.15em;
  margin-right: 0;
  margin-left: 0.35em;
  padding: 0;
  border: none;
  flex: none;
  background-color: #5865f2;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  transition: background-color 0.12s ease-out, transform 0.12s ease-out;
}

.admin-verified-badge:hover {
  background-color: #7d8cff;
  transform: scale(1.15);
}

.admin-verified-badge:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.7);
  outline-offset: 2px;
}

/* Muted so it never competes with the channel-colored name/message next to it — same
   "quiet metadata" treatment as .journal-entry's own [HH:MM:SS] prefix. */
.chat-entry-time {
  color: var(--muted);
  font-weight: normal;
  font-size: 0.9em;
}

/* Each channel gets its own color so a message's origin is unmistakable at a glance —
   Local reads as plain speech, Global a cool blue, Guild the classic UO guild-chat
   green (a slightly different shade from the Journal's stat-gain green, since they're
   unrelated systems that just happen to share a palette family). */
.chat-entry.chat-channel-local { color: var(--ink); }
.chat-entry.chat-channel-local .chat-entry-name { color: var(--accent); }
.chat-entry.chat-channel-global { color: #5bc8ea; }
.chat-entry.chat-channel-global .chat-entry-name { color: #8fdcf2; }
.chat-entry.chat-channel-guild { color: #6fcf6f; }
.chat-entry.chat-channel-guild .chat-entry-name { color: #9be89b; }
/* Party — a warm amber, deliberately distinct from Guild's green: the two are the
   closest in meaning (both "your group"), so they need to be the easiest to tell apart
   at a glance mid-fight. Clear of the DM violet below and Global's blue too. */
.chat-entry.chat-channel-party { color: #e0a45e; }
.chat-entry.chat-channel-party .chat-entry-name { color: #f2c98f; }
/* DM tabs (public/js/friends.js/chat.js) — a warm violet so a private conversation
   never reads as one of the three fixed public channels. */
.chat-entry.chat-channel-dm { color: #d9a8f0; }
.chat-entry.chat-channel-dm .chat-entry-name { color: #eac6ff; }
.chat-entry-system {
  color: var(--muted);
  font-style: italic;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.chat-form {
  display: flex;
  gap: 8px;
}

.chat-form input {
  flex: 1;
  padding: 6px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  font-family: inherit;
  border-radius: 3px;
}

.chat-form button {
  padding: 6px 14px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Site footer (views/partials/footer.ejs) — the game's one static branding line,
   sitting below Chat at the very bottom of the page flow. Same glass-panel/ember-edge
   language as every other chrome bar (.game-header, .chat-panel) rather than a plain
   unstyled strip, but quieter: no logo, no nav, just the identity line real UO-style
   shards put in a footer. */
.game-footer {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 10px;
  /* margin-top auto absorbs the free space in .game-world's flex column, pinning the
     footer to the viewport bottom, since the in-flow content above it has collapsed to
     nothing (every panel floats). Computes to 0 on any page that still has in-flow
     content taller than the viewport. */
  margin: auto 30px 20px;
  padding: 12px 20px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.3);
}

.game-footer-title {
  font-family: Georgia, 'Times New Roman', serif;
  font-variant: small-caps;
  font-weight: 700;
  font-size: calc(15px * var(--ui-font-scale, 1));
  letter-spacing: 0.08em;
  /* Same theme-accent engraving stops as .game-header-wordmark (no sheen animation
     down here — the footer stays quiet), so both wordmarks recolor together under
     whichever theme is active. */
  background: linear-gradient(180deg, var(--accent-bright) 0%, var(--accent) 34%, var(--accent-strong) 56%, var(--accent-deep) 80%, var(--accent) 100%);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.9)) drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.3));
}

.game-footer-divider {
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
}

.game-footer-tagline {
  font-family: Georgia, 'Times New Roman', serif;
  font-variant: small-caps;
  font-size: calc(12px * var(--ui-font-scale, 1));
  letter-spacing: 0.14em;
  color: var(--muted);
}

.equipped-panel-wrap, .inventory-panel {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 16px;
}

.equipped-panel-wrap h2, .inventory-panel h2 {
  margin-top: 0;
  color: var(--accent);
  font-size: calc(16px * var(--ui-font-scale, 1));
}

.equipped-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
  width: fit-content;
  padding: 8px;
  background: rgba(0, 0, 0, 0.2);
  border: 2px dashed rgba(255, 255, 255, 0.12);
  border-radius: 4px;
}

/* A rough paperdoll silhouette: helm at the top, neck tucked in next to chest
   (shrunk so it doesn't sit too far from the helm), cloak on the other side,
   weapon+shield flanking the legs, and arms/gloves/rings on the bottom row.
   Both rings share the single slot-rings tile — they're the same-sized small
   accessory boxes side by side rather than one full-size gear slot each.
   Placement is by grid-area, not DOM order, so equipped.ejs is free to render
   these slots in whatever order is convenient. */
.equip-paperdoll {
  display: grid;
  grid-template-columns: repeat(3, 44px);
  grid-template-rows: 44px 44px 44px 44px;
  gap: 8px;
  grid-template-areas:
    "slot-mount     slot-head      slot-binding"
    "slot-neck      slot-chest     slot-cloak"
    "slot-weapon    slot-legs      slot-shield"
    "slot-arms      slot-hands     slot-rings";
}

/* A broken piece of equipment — red box, so the paperdoll shows at a glance which slot has
   stopped working instead of needing a hover on all fourteen.

   Keyed off the icon's own data-durability with :has() rather than a class stamped onto the
   box, deliberately. This file's neighbours (views/partials/equipped.ejs, inventory.ejs and
   public/js/dragdrop.js's createItemImg) each build an item icon independently, and their
   own comments record what happens when a flag has to be added in all three: it gets added
   in one and forgotten in the others. data-durability is already stamped by every one of
   them, and serializeItem sends maxDurability for anything undamaged, so "0" means broken
   and nothing else — one selector covers every render path with nothing to keep in sync.

   The pulse is slow and low-contrast on purpose: this needs to be noticeable when you look
   at the paperdoll, not to compete with combat for attention while you fight.

   The weapon and shield wear the SAME red while a spell is in flight (body.spell-casting,
   toggled by public/js/spellCast.js off spell:castStart/castEnd), because mid-cast they are
   mechanically broken in the literal sense — sockets/combatHandlers.js's getCombatProfile
   drops the swing to Wrestling and takes the shield's block and Armor Rating away, using the
   exact code path a 0-durability item goes down. Same rule, same colour: one look at the
   paperdoll says "these are doing nothing right now", whichever reason put them there.

   The weapon slot's own copy of that rule excludes an Inscribed Spellbook (config/
   inscription.js) — getCombatProfile's own comment says a real caster loses nothing when
   casting drops their weapon to Wrestling, since a book's actual value (its cast-damage
   bonus and mana proc) was never its wisp-bolt swing to begin with, and that swing is
   Wrestling-skilled anyway. Painting a caster's own casting tool "broken" red on every
   single cast is exactly backwards — it's the one weapon a cast in flight never disables. */
.equip-slot-box:has(.item-icon[data-durability="0"]),
body.spell-casting .equip-slot-box[data-slot="weapon"]:not(.equip-slot-empty):not(:has(.item-icon[data-is-inscribed-spellbook="true"])),
body.spell-casting .equip-slot-box[data-slot="shield"]:not(.equip-slot-empty) {
  background: rgba(150, 30, 30, 0.42);
  border-color: #ff6b6b;
  box-shadow: 0 0 6px rgba(255, 107, 107, 0.5);
  animation: equip-broken-pulse 2.4s ease-in-out infinite;
}

@keyframes equip-broken-pulse {
  0%, 100% { box-shadow: 0 0 5px rgba(255, 107, 107, 0.35); }
  50% { box-shadow: 0 0 9px rgba(255, 107, 107, 0.7); }
}

/* Respect a reduced-motion preference — the colour alone still carries the message. */
@media (prefers-reduced-motion: reduce) {
  .equip-slot-box:has(.item-icon[data-durability="0"]),
  body.spell-casting .equip-slot-box[data-slot="weapon"]:not(:has(.item-icon[data-is-inscribed-spellbook="true"])),
  body.spell-casting .equip-slot-box[data-slot="shield"] { animation: none; }
}

.equip-slot-box[data-slot="mount"] { grid-area: slot-mount; }
.equip-slot-box[data-slot="head"] { grid-area: slot-head; }
/* The Virtue Binding — blessed and permanently worn (see routes/auth.js), so it never
   shows the empty-slot silhouette/faded-out treatment every other box gets; a soft
   ember glow instead marks it as "always there", matching the header crest XP bar's
   own ember accent (see .xp-bar-fill below) since they track the same progression. */
.equip-slot-box[data-slot="binding"] {
  grid-area: slot-binding;
  box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.35);
}
.equip-slot-box[data-slot="neck"] {
  grid-area: slot-neck;
  width: 30px;
  height: 26px;
  justify-self: center;
}
.equip-slot-box[data-slot="neck"] .item-icon { width: 26px; height: 26px; }
.equip-slot-box[data-slot="chest"] { grid-area: slot-chest; }
.equip-slot-box[data-slot="arms"] { grid-area: slot-arms; }
.equip-slot-box[data-slot="hands"] { grid-area: slot-hands; }
.equip-slot-box[data-slot="legs"] { grid-area: slot-legs; }
.equip-slot-box[data-slot="cloak"] { grid-area: slot-cloak; }
.equip-slot-box[data-slot="weapon"] { grid-area: slot-weapon; }
.equip-slot-box[data-slot="shield"] { grid-area: slot-shield; }
.equip-slot-box[data-slot="ring1"],
.equip-slot-box[data-slot="ring2"] {
  grid-area: slot-rings;
  width: 20px;
  height: 26px;
}
.equip-slot-box[data-slot="ring1"] { justify-self: start; }
.equip-slot-box[data-slot="ring2"] { justify-self: end; }
.equip-slot-box[data-slot="ring1"] .item-icon,
.equip-slot-box[data-slot="ring2"] .item-icon { width: 18px; height: 18px; }
.equip-slot-box[data-slot="ring1"] .equip-slot-placeholder-icon,
.equip-slot-box[data-slot="ring2"] .equip-slot-placeholder-icon { width: 16px; height: 16px; }
.equip-slot-box[data-slot="neck"] .equip-slot-placeholder-icon { width: 22px; height: 22px; }

/* Every empty slot shows a faint silhouette of what belongs there (tools/generateSprites.js)
   instead of a plain text label — the label itself only shows up as a hover tooltip
   (public/js/tooltip.js reads the box's own data-slot-label). Same warm background tint
   for every empty slot, not just Mount, for a consistent "nothing here yet" look. */
.equip-slot-box.equip-slot-empty {
  background: rgba(255, 255, 255, 0.03);
}
.equip-slot-placeholder-icon {
  width: 30px;
  height: 30px;
  opacity: 0.35;
  filter: grayscale(1);
  pointer-events: none;
}

/* Blacked out while a two-handed weapon is worn — there's no free hand for a shield. */
.equip-slot-box.equip-slot-disabled {
  background: #000;
  border-color: rgba(255, 255, 255, 0.05);
  opacity: 0.55;
  pointer-events: none;
}

/* Food + the 3 Potions — always the bottom row, deliberately separate from the
   paperdoll grid above so they never shuffle position as other gear changes. */
.equip-consumables {
  display: grid;
  grid-template-columns: repeat(4, 44px);
  gap: 8px;
}

.item-slot {
  position: relative;
  width: 44px;
  height: 44px;
}

.equip-slot-box {
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 3px;
}

.equip-slot-box.drag-over {
  border-color: var(--accent);
  background: rgba(var(--edge-rgb), 0.2);
}

/* Auto-Drink armed for this Potion slot (sockets/alchemyHandlers.js's potion:setAutoDrink,
   services/autoPotionSweep.js keeps re-drinking it out in a dungeon/mine/forest) — a
   steady accent outline, distinct from .item-icon[data-active="true"]'s pulsing tool halo
   (that one means "in use right now"; this one means "will restock itself later, nothing
   to do"). */
.equip-slot-box.auto-drink-active {
  border-color: rgba(var(--accent-rgb), 0.7);
  box-shadow: 0 0 4px 1px rgba(var(--accent-rgb), 0.35);
}

.equip-badge {
  position: absolute;
  bottom: -2px;
  right: -2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: #3f9d4a;
  color: #f2fff2;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(14px * var(--ui-font-scale, 1));
  text-align: center;
  border: 1px solid #1c4d1c;
  pointer-events: none;
}

/* "A" on the Spellbook while Auto Cast is on (public/js/spellbook.js). Deliberately the
   same size/shape/corner as .equip-badge's "E" above so the two read as one convention,
   in the accent ember rather than that badge's green — it is a mode being on, not a
   permanent property of the item. */
.auto-mage-badge {
  position: absolute;
  bottom: -2px;
  right: -2px;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  background: #d97a35;
  color: #fff6ec;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(14px * var(--ui-font-scale, 1));
  text-align: center;
  border: 1px solid #7a3f14;
  pointer-events: none;
}

.backpack-panel {
  position: relative;
  background-image: url('/assets/sprites/backpack_bg.png');
  background-size: cover;
  border: 1px solid var(--edge);
  border-radius: 4px;
  overflow: hidden;
}

.backpack-panel.drag-over {
  outline: 2px solid var(--accent);
}

.current-location.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.item-icon {
  width: 44px;
  height: 44px;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/grab.png') 16 16, grab;
  /* Touch drag-and-drop bridge (public/js/dragdrop.js) — without these, a touchmove that
     starts on an <img> triggers the mobile browser's own default handling before our
     touchmove listener's preventDefault() ever gets a chance: touch-action:none stops
     the page trying to scroll/pinch-zoom under the gesture, -webkit-touch-callout:none
     stops Safari's long-press "Save Image" popup from intercepting what's meant to be
     the start of a drag. */
  touch-action: none;
  -webkit-touch-callout: none;
}

/* The floating clone that follows a finger during a touch drag (public/js/dragdrop.js)
   — native HTML5 DnD renders its own drag image automatically for a real mouse drag;
   a synthetic touch-driven one has no such thing, so this stands in for it. */
.touch-drag-ghost {
  position: fixed;
  pointer-events: none;
  opacity: 0.85;
  z-index: 10000;
  filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.5));
}

/* The real icon fades out while its ghost tracks the finger — same "the thing you're
   dragging visibly leaves its origin" feedback a real native drag gives for free. */
.item-icon.touch-dragging {
  opacity: 0.35;
}

.backpack-item {
  position: absolute;
  /* Server corrections (a clamped drop, a rolled-back move, another panel's reshuffle)
     glide instead of teleporting — public/js/dragdrop.js patches left/top on persistent
     wrappers rather than rebuilding the panel, which is what makes this transition
     actually fire. Short enough to feel like physicality, not lag. */
  transition: left 140ms ease, top 140ms ease;
}

/* Suppressed for exactly one placement when a wrapper is reparented across panels —
   left/top are relative to the containing panel, so animating through a reparent would
   glide from a meaningless point in the new panel's coordinate space
   (dragdrop.js's placeWrapInPanel). */
.backpack-item.item-slot-no-anim { transition: none; }

/* A genuinely new icon (crafted, looted, withdrawn from somewhere unseen) pops in with a
   small settle instead of just appearing — dragdrop.js removes the class on animationend
   so it can never replay on later patches. */
.item-slot-enter { animation: item-slot-enter 160ms ease; }

@keyframes item-slot-enter {
  from { transform: scale(0.7); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
  .backpack-item { transition: none; }
  .item-slot-enter { animation: none; }
}

.item-icon:active { cursor: url('/assets/sprites/cursors/grabbing.png') 16 16, grabbing; }

.item-icon[data-is-container="true"] { cursor: url('/assets/sprites/cursors/default.png') 2 2, pointer; }

.item-icon.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* In use by a craft batch right now (services/craftBatchState.js's isItemDefLocked) —
   dimmed and un-draggable (public/js/dragdrop.js's createItemImg sets draggable=false
   alongside this), with a cursor that reads as "can't touch this" instead of the usual
   grab hand. */
.item-icon[data-locked="true"] {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Every icon of the same stackable item type, shown while dragging (public/js/
   dragdrop.js's dragstart listener) — a distinct green/glow rather than drag-over's
   static orange outline, since a match can also be the one thing under the cursor at
   the same time and the two need to read as different cues. */
.item-icon.stack-match {
  outline: 2px solid #7fd1a8;
  outline-offset: 1px;
  animation: stack-match-pulse 1s ease-in-out infinite;
}

@keyframes stack-match-pulse {
  0%, 100% { box-shadow: 0 0 4px 1px rgba(127, 209, 168, 0.6); }
  50% { box-shadow: 0 0 8px 3px rgba(127, 209, 168, 0.9); }
}

/* A soft ember glow on whichever tool is currently Set Active (Pickaxe/Hatchet/Fishing
   Pole/Skinning Knife, or a crafting tool — icon.dataset.active only ever exists on a
   tool at all, see dragdrop.js's createItemImg). Applied to .item-icon itself, not the
   .item-slot/.equip-slot-box it sits inside — box-shadow follows the element's own
   rendered box, so the halo always hugs the actual icon (44px in the backpack, as small
   as 18px in a Ring slot) rather than ballooning out to whatever size its containing
   slot happens to be. */
.item-icon[data-active="true"] {
  box-shadow: 0 0 5px 1px rgba(var(--accent-rgb), 0.55);
  animation: active-tool-halo 2s ease-in-out infinite;
}

@keyframes active-tool-halo {
  0%, 100% { box-shadow: 0 0 5px 1px rgba(var(--accent-rgb), 0.55); }
  50% { box-shadow: 0 0 10px 3px rgba(var(--accent-rgb), 0.9); }
}

.stack-badge {
  position: absolute;
  bottom: -2px;
  right: -2px;
  min-width: 14px;
  height: 14px;
  padding: 0 2px;
  border-radius: 3px;
  background: var(--panel);
  color: var(--ink);
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(14px * var(--ui-font-scale, 1));
  text-align: center;
  border: 1px solid var(--edge);
  pointer-events: none;
}

/* Same stack-count look as .stack-badge, just mirrored to the opposite corner (bottom-
   left instead of bottom-right) so it doesn't collide with the equipped-slot "E" badge
   sharing that corner — mainly seen on the Food slot, where auto-eat quietly works
   through the stack and this is the only way to tell how much is left without opening
   the backpack to check. */
.equip-stack-badge {
  position: absolute;
  bottom: -2px;
  left: -2px;
  min-width: 14px;
  height: 14px;
  padding: 0 2px;
  border-radius: 3px;
  background: var(--panel);
  color: var(--ink);
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(14px * var(--ui-font-scale, 1));
  text-align: center;
  border: 1px solid var(--edge);
  pointer-events: none;
}

/* Cooldown sweep on an equipped consumable slot — Food (auto-eat's 2s pacing), either
   Potion slot (a heal potion's 10s gate), and Bandage (its application time plus the short
   post-heal cooldown). Driven by public/js/slotCooldown.js.
   Rendered as a ::after pseudo-element specifically because dragdrop.js's
   renderEquippedSlot wipes the box's real children (innerHTML = '') on every
   character:update; a pseudo-element and the box's own class/dataset/custom property all
   survive that. --slot-busy-pct is percent REMAINING, so the dark region drains downward as
   the timer runs out. */
.equip-slot-box.slot-busy::after {
  content: attr(data-slot-remaining);
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: inherit;
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.62) var(--slot-busy-pct, 0%),
    transparent var(--slot-busy-pct, 0%)
  );
  color: #ffe9c8;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
  pointer-events: none;
}

.activities-panel, .stats-panel {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 16px;
  width: 236px;
}

.stats-panel h2 {
  margin: 0;
  color: var(--accent);
  font-size: calc(16px * var(--ui-font-scale, 1));
}

.stats-icon-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-top: 12px;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.stats-icon-badge {
  display: flex;
  align-items: center;
  gap: 3px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
  white-space: nowrap;
}

.stats-icon-glyph {
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.stats-row {
  display: grid;
  grid-template-columns: 14px 1fr 24px;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.stat-value {
  text-align: right;
}

.stat-arrow {
  padding: 0;
  width: 14px;
  height: 14px;
  line-height: calc(12px * var(--ui-font-scale, 1));
  font-size: calc(8px * var(--ui-font-scale, 1));
  background: var(--shade);
  color: var(--muted);
  border: 1px solid var(--edge);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.stat-arrow-up, .stat-arrow-down {
  background: var(--accent);
  color: var(--on-accent);
  font-weight: bold;
}

.stats-total {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: right;
}

#weight-value.overweight {
  color: #e25c4f;
  font-weight: bold;
}

.activities-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.activities-header h2 {
  margin: 0;
  color: var(--accent);
  font-size: calc(16px * var(--ui-font-scale, 1));
}

.skills-toggle {
  width: 22px;
  height: 22px;
  padding: 0;
  background: var(--shade);
  color: var(--accent);
  border: 1px solid var(--edge);
  border-radius: 3px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.activities-list {
  margin-top: 10px;
  max-height: 320px;
  overflow-y: auto;
}

/* ---- Player-arranged skill groups (public/js/activities.js's renderSkillGroups) ----
   Same collapse idiom as .crafting-section (chevron rotates via .collapsed on the
   parent, body display:none), sized down for the 236px-wide panel. */
.skills-group-header {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 4px 4px 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--accent);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  user-select: none;
}

.skills-group-toggle {
  font-size: calc(9px * var(--ui-font-scale, 1));
  transition: transform 0.15s ease;
}

.skills-group.collapsed .skills-group-toggle {
  transform: rotate(-90deg);
}

.skills-group.collapsed .skills-group-body {
  display: none;
}

.skills-group-label {
  flex: 1;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.skills-group-remove {
  padding: 0 5px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: calc(14px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.skills-group-remove:hover { color: var(--accent); }

/* Keeps an empty custom group a real drop target instead of a 0-height sliver. */
.skills-group-body {
  min-height: 8px;
}

/* Drag-to-arrange affordances: the dragged row inserts above a highlighted row, or
   appends into a highlighted group. */
.activity-row.row-drop-target {
  box-shadow: 0 -2px 0 var(--accent);
}

.skills-group.group-drop-target .skills-group-header {
  color: var(--on-accent);
  background: var(--edge);
}

.activity-row[draggable="true"] { cursor: url('/assets/sprites/cursors/point.png') 4 2, grab; }

/* An activity you can't start from where you're standing (or mid-travel) — dimmed, not
   hidden; the server rejects an ineligible Start with its own clean error either way. */
.activity-row.ineligible {
  opacity: 0.45;
}

.skills-add-group-btn {
  display: block;
  width: 100%;
  margin-top: 8px;
  padding: 4px 0;
  background: none;
  border: 1px dashed var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.skills-add-group-btn:hover {
  color: var(--accent);
  border-color: var(--accent);
}

.activity-row {
  padding: 6px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.activity-row:last-child { border-bottom: none; }

/* The skill-value and button columns are fixed widths deliberately — .activity-main is
   its own independent grid per row, so a max-content button column sizes off that row's
   own label text and ends up at a different X per row, breaking alignment down a
   section. The label column is 1fr (not a fixed px) so it always yields whatever the
   panel's actual width leaves over after the fixed columns + gaps, instead of the old
   fixed-104px label colliding with the button column at the panel's real 236px width
   (104 + 66 + gaps alone left the Start/Stop button almost no room to render in). Long
   labels still truncate via .activity-label's ellipsis, same as always. The value column
   is wide enough for a raw value plus a combined tool+ring .skill-bonus-badge (up to two
   digits, e.g. "75.6 +23") — 38px was sized for the raw value alone and clipped the
   badge the moment a bonus appeared. */
.activity-main {
  display: grid;
  grid-template-columns: 1fr 56px 44px;
  align-items: center;
  gap: 6px;
}

.activity-label {
  font-size: calc(14px * var(--ui-font-scale, 1));
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.activity-skill {
  display: inline-flex;
  align-items: baseline;
  gap: 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
}

/* A Tinkering tool's active bonus (config/tinkering.js) — sectioned off from the raw
   skill value with its own tinted pill rather than sitting inline as plain bracketed
   text, so "this part is a bonus, not the trained value" reads at a glance. Same green
   the rest of the UI already uses for a stat/skill gain (.journal-stat). Shared by the
   persistent Activities panel and the full Skills gump alike. */
.skill-bonus-badge {
  padding: 0 4px;
  border-radius: 3px;
  background: rgba(76, 175, 80, 0.18);
  border: 1px solid rgba(76, 175, 80, 0.4);
  color: #4caf50;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: 1.5;
}

.activity-toggle {
  width: 100%;
  padding: 4px 0;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-align: center;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.activity-toggle.active {
  background: #3f9d4a;
  color: #f2fff2;
}

/* Static crafting skills (Blacksmithing/Carpentry/Tinkering/Alchemy/Cooking) never get
   a Start button — a craft batch only ever begins from its own gump — but sit in the
   exact same 44px grid slot .activity-toggle occupies, always in the "Stop" look since
   there's no other state to show (views/partials/activities.ejs). */
.craft-stop-btn {
  width: 100%;
  padding: 4px 0;
  background: #3f9d4a;
  border: none;
  border-radius: 3px;
  color: #f2fff2;
  font-weight: bold;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-align: center;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.activity-progress {
  height: 3px;
  margin-top: 6px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 2px;
  overflow: hidden;
  visibility: hidden;
}

/* Craft-kind rows (Cooking/Smelting/Carpentry Plank) never get the .active class — that's
   reserved for toggleable tick activities (Start/Stop) — but they do resolve real
   per-attempt waits (craft:attemptStart/attemptResult, see public/js/activities.js), so
   their bar needs to stay visible unconditionally rather than only while .active. */
.activity-row.active .activity-progress,
.activity-row[data-craft-kind] .activity-progress,
.crafting-progress-row .activity-progress,
.crafting-bench-progress-row .activity-progress {
  visibility: visible;
}

.crafting-progress-row {
  margin-bottom: 10px;
}

.activity-progress-fill {
  display: block;
  height: 100%;
  width: 0%;
  background: #3f9d4a;
}

.activity-progress-fill.failed {
  background: #e25c4f;
}

/* Crafting Bench failed-attempt signal (public/js/craftingBench.js's pulseBenchFailure) —
   a red wash over the TRACK that fades on its own, deliberately independent of the
   fill's width so a failure never holds the bar at 100% and then yanks it backward when
   the next attempt's fill takes over. Glow included since the track is only 3px tall. */
.crafting-bench-progress-row .activity-progress.bench-fail-pulse {
  overflow: visible;
  animation: bench-fail-pulse 550ms ease-out;
}

@keyframes bench-fail-pulse {
  0% { background: rgba(226, 92, 79, 0.9); box-shadow: 0 0 8px rgba(226, 92, 79, 0.8); }
  100% { background: rgba(0, 0, 0, 0.3); box-shadow: none; }
}

@media (prefers-reduced-motion: reduce) {
  .crafting-bench-progress-row .activity-progress.bench-fail-pulse { animation: none; }
}

/* Positioning (fixed/left/top/z-index) is now applied inline by initGumpWindow()
   (public/js/gumpWindow.js) so this gump can float and be dragged anywhere, rather than
   sitting behind a full-viewport dimmed modal backdrop. */
.skills-gump {
  width: 260px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}

.skills-gump-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 14px;
  border-bottom: 2px solid rgba(255, 255, 255, 0.12);
  color: var(--accent);
  font-weight: bold;
  /* Grab cursor signals every gump's title bar is draggable (public/js/gumpWindow.js) —
     shared here since every gump reuses this exact header class, same grab/grabbing
     cursor pair .item-icon already uses for item drag-and-drop. */
  cursor: url('/assets/sprites/cursors/grab.png') 16 16, grab;
}

.skills-gump-header:active {
  cursor: url('/assets/sprites/cursors/grabbing.png') 16 16, grabbing;
}

/* Drag handles for the main-screen panels (public/js/panelDock.js) — each
   panel's existing h2/.activities-header bar, plus the new slim title bars added to
   the paperdoll and backpack (which had no header of their own). Same grab/grabbing
   cursor pair as the gump headers; body.panels-locked (below) swaps in the pointer. */
.panel-drag-handle {
  cursor: url('/assets/sprites/cursors/grab.png') 16 16, grab;
  user-select: none;
}

.panel-drag-handle:active {
  cursor: url('/assets/sprites/cursors/grabbing.png') 16 16, grabbing;
}

/* "This moves" hint — a soft ember wash across the bar on hover, only while the
   layout is unlocked (no false promise when it isn't). */
body:not(.panels-locked) .panel-drag-handle:hover {
  background: linear-gradient(90deg, rgba(var(--edge-rgb), 0.16), transparent 70%);
  border-radius: 8px 8px 0 0;
}

/* The bar variant — scoped to the two panels that got a NEW header element rather
   than reusing an existing one, so .activities-header's own layout is untouched. */
.equipped-panel-wrap > .panel-drag-handle,
.inventory-panel > .panel-drag-handle {
  display: flex;
  align-items: center;
  padding: 8px 14px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* The × on every gump header on the shard. It was an 18px glyph with 4px of padding — about
   21x18 of target, which players reported repeatedly missing.
   Bigger in two separate ways, because they solve different halves of the problem:
   the glyph itself grew (a small × is hard to AIM at), and the clickable region grew a great
   deal more than the glyph did (an accurate aim still missed by a pixel or two).

   The region is a pseudo-element rather than padding on purpose. Padding here would push the
   header taller on all forty-odd gumps that share this class, and three of them
   (.bank-gump, .crafting-gump, .binding-gump) already re-tune this button's margin by hand
   and would need re-tuning again. An absolutely-positioned overlay changes no layout at all.
   It bleeds into the header's own 8px/14px padding and stops short of the edge, so it stays
   inside the bar; what it does cover is a sliver of the drag area either side of the ×, which
   is a trade worth making — that is the corner people are aiming at anyway. */
.skills-gump-close {
  position: relative;
  background: none;
  border: none;
  color: var(--accent);
  font-size: calc(22px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  padding: 0 4px;
}

.skills-gump-close::after {
  content: '';
  position: absolute;
  /* ~37x40 of target, from ~21x18. Asymmetric on the left because that side has a neighbour
     — the header's title, and in some gumps a second button — while the right has only the
     bar's own padding to grow into. */
  inset: -9px -10px -9px -6px;
}

/* One global layout lock (public/js/panelLock.js's body.panels-locked, replacing the
   old per-gump padlock buttons) — while locked, every draggable header/handle swaps
   its grab cursor for a plain pointer so each bar visually confirms it won't move
   before you even try. */
body.panels-locked .skills-gump-header,
body.panels-locked .skills-gump-header:active,
body.panels-locked .panel-drag-handle,
body.panels-locked .panel-drag-handle:active {
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Stable/Provisioner/Mage Shop/Healer Shop/Quest Journal/Construction Site/House Storage
   all reuse this plain .skills-gump panel but carry content wider than the Skills panel's
   own fixed 260px (the 360px .stable-roster list, 320px .construction-site-body, 360px
   .bank-panel scatter grid) — auto-size back to content instead of clipping it. */
.stable-gump,
.provisioner-gump,
.mage-shop-gump,
.healer-shop-gump,
.quest-log-gump,
.construction-site-gump,
.house-storage-gump {
  width: auto;
}

.skills-gump-body {
  max-height: 60vh;
  overflow-y: auto;
  padding: 6px 14px;
}

.skills-gump-category {
  margin-top: 10px;
  padding-top: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.skills-gump-category:first-child {
  margin-top: 0;
  padding-top: 0;
  border-top: none;
}

.skills-gump-row {
  display: flex;
  justify-content: space-between;
  padding: 6px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.skills-gump-row:last-child { border-bottom: none; }

/* Value + the optional Craft button, grouped so justify-content keeps the label left and
   this cluster right. */
.skills-gump-right {
  display: flex;
  align-items: center;
  gap: 8px;
}

.skills-gump-value {
  color: var(--muted);
}

/* Inline "Craft" button on the five bench-crafting skill rows (views/partials/skillsGump.ejs)
   — arms a tool and opens the skill's crafting gump (public/js/activities.js). */
.skills-gump-craft-btn {
  padding: 2px 10px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.02em;
  color: var(--panel-deep);
  background: var(--accent);
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
.skills-gump-craft-btn:hover { background: var(--accent-bright); }

.skills-gump-total {
  margin: 0 14px 12px;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--accent);
  text-align: right;
}

/* Closes the Skills panel (views/partials/activities.ejs) — the cap and the running total
   across the full width, in the accent gold, under a divider that separates them from the
   per-skill rows above. Gold rather than muted: this is the panel's headline figure, not a
   footnote to it. */
.skills-panel-footer {
  display: grid;
  /* Equal halves rather than space-between: the two figures are the same KIND of number,
     and a fixed grid keeps them aligned whatever their digit count — 6.0 next to 2865.2
     shouldn't drag the layout around. */
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  margin: 10px 12px 12px;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 255, 255, 0.10);
}

/* Each figure gets its own inset plate, so the pair reads as the panel's summary rather
   than as two more rows of the skill list it sits under. */
.skills-panel-stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 7px 8px;
  border: 1px solid var(--edge);
  border-radius: 6px;
  background: var(--well);
}

/* Small, muted and lettered out — a caption for the number, never competing with it. The
   label stacks ABOVE the value instead of sitting inline with a colon, which is what let
   the old markup wrap mid-phrase in a narrow panel.

   9px with restrained tracking is not a guess: this panel is only ~85px per cell, and at
   10px/0.08em "TOTAL SKILL" measured 62.31px inside a 63px box — it fit here and would have
   clipped on any font fallback or narrower layout. Keep the headroom if you retouch this.

   Deliberately NOT `white-space: nowrap`. A nowrap label sets a min-content floor the 1fr
   columns cannot shrink past, so under any squeeze the whole panel overflowed sideways
   instead of the label giving way — measured, not theorised. Wrapping is the better failure. */
.skills-panel-stat-label {
  font-size: calc(9px * var(--ui-font-scale, 1));
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

.skills-panel-stat-value {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: 1.1;
  /* Tabular figures so the total doesn't jitter sideways as it ticks up mid-session. */
  font-variant-numeric: tabular-nums;
  color: var(--accent);
}

/* See the comment above .skills-gump — positioning now comes from initGumpWindow(). */
.travel-gump {
  width: min(900px, 90vw);
  /* relative so the Active Boons flyout (.travel-boon-panel) can hang off this gump's
     right edge and drag along with it. */
  position: relative;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}

.travel-gump-map {
  position: relative;
  line-height: 0;
}

.travel-gump-map-img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 0 0 4px 4px;
}

/* Pins are invisible hit areas now — the painted map artwork IS every location's
   marker and name, so the pin renders nothing at all: just a transparent click/hover
   target centered over the painted icon. Hovering raises the travel tooltip
   (public/js/travelMap.js), clicking travels. The dot/label elements stay in the
   markup (travelMapGump.ejs) because the tooltip reads the label's text — they just
   never render. Dungeon Boons likewise lost their old pin pulse: the Active Boons
   side panel (.travel-boon-panel below) carries that news instead. */
.travel-map-pin {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 34px;
  height: 34px;
  background: none;
  border: none;
  padding: 0;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.travel-map-pin .travel-map-pin-dot,
.travel-map-pin .travel-map-pin-label {
  display: none;
}

.travel-map-pin.current {
  cursor: default;
}


/* This class is also the house pin's always-visible label (it has no painted name on
   the map to lean on) — only the location pins above hide their copy. */
.travel-map-pin-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #fff;
  text-shadow: 0 0 3px #000, 0 0 3px #000, 0 1px 2px #000;
  white-space: nowrap;
}

/* Active Dungeon Boons flyout (public/js/dungeonBoons.js's renderBoonPanel) — hangs
   off the Travel gump's right edge, only rendered while at least one boon is live.
   Replaces the old pulsing pin dot as the "something special is happening" signal,
   now that pins are invisible. */
.travel-boon-panel {
  position: absolute;
  left: calc(100% + 12px);
  top: 44px;
  width: 235px;
  padding: 12px 14px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55);
}

.travel-boon-panel-title {
  font-family: system-ui, 'Segoe UI', sans-serif;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}

.travel-boon-row {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.travel-boon-row-dungeon {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

/* Same violet identity as .toast-dungeonBoon / the dungeon pins' old purple — boons
   read as their own kind of news across every surface. */
.travel-boon-row-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #d8b6f0;
}

/* Harvesting Boons share this flyout with Dungeon Boons (public/js/dungeonBoons.js's
   renderBoonPanel merges both) — this override on the row's own source class keeps the
   harvest-gold identity (.toast-harvestBoon/.journal-harvestBoon) consistent here too,
   instead of every row reading as the dungeon violet above. */
.travel-boon-row-harvest .travel-boon-row-label {
  color: #e0c274;
}

/* PK Ambush (public/js/pkBoons.js) shares this same flyout, but it's the one entry here that is
   a HAZARD rather than a blessing — blood red so it can't be mistaken at a glance for the two
   good kinds of news beside it. The left rule bar is the extra tell, since colour alone is a
   weak signal on a list this small. */
.travel-boon-row-pk .travel-boon-row-label {
  color: #f08a8a;
}
.travel-boon-row-pk {
  border-left: 3px solid #b33636;
  padding-left: 8px;
}

.travel-boon-row-desc {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.travel-boon-row-time {
  margin-top: 2px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}

/* Random-plot house pin — a hand-drawn CSS house glyph (roof + walls + door) rather
   than a plain dot, so it reads as distinct from every town/dungeon pin at a glance.
   Only ever rendered for the house's own owner (see travelMapGump.ejs). */
.house-map-pin {
  position: absolute;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  background: none;
  border: none;
  padding: 0;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.house-map-pin-icon {
  position: relative;
  width: 14px;
  height: 11px;
  background: #cbb08a;
  border: 1px solid var(--ink);
  border-top: none;
  box-shadow: 0 0 0 1px var(--panel);
}

.house-map-pin-icon::before {
  content: '';
  position: absolute;
  top: -8px;
  left: -1px;
  width: 0;
  height: 0;
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-bottom: 8px solid #8a3a2b;
}

.house-map-pin-icon::after {
  content: '';
  position: absolute;
  bottom: 0;
  left: 5px;
  width: 4px;
  height: 7px;
  background: #4a3420;
}

.house-map-pin:hover .house-map-pin-icon {
  background: #f0c88a;
}

.house-map-pin.current .house-map-pin-icon {
  background: #3f9d4a;
}

/* Armed via the pin's own right-click "Move House" menu (public/js/houseDeed.js) —
   grab cursor plus a dashed accent outline so it's obvious the next drag actually
   relocates the house, not just travels there. */
.house-map-pin.placing {
  cursor: grabbing;
}

.house-map-pin.placing .house-map-pin-icon {
  outline: 2px dashed var(--accent);
  outline-offset: 2px;
}

.treasure-site-map-pin {
  position: absolute;
  transform: translate(-50%, -50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  background: none;
  border: none;
  padding: 0;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.treasure-site-map-pin-icon {
  position: relative;
  width: 14px;
  height: 14px;
}

.treasure-site-map-pin-icon::before,
.treasure-site-map-pin-icon::after {
  content: '';
  position: absolute;
  top: 6px;
  left: 0;
  width: 14px;
  height: 3px;
  background: #d4af37;
  border: 1px solid var(--ink);
}

.treasure-site-map-pin-icon::before {
  transform: rotate(45deg);
}

.treasure-site-map-pin-icon::after {
  transform: rotate(-45deg);
}

.treasure-site-map-pin:hover .treasure-site-map-pin-icon::before,
.treasure-site-map-pin:hover .treasure-site-map-pin-icon::after {
  background: #f5d76e;
}

.treasure-site-map-pin.current .treasure-site-map-pin-icon::before,
.treasure-site-map-pin.current .treasure-site-map-pin-icon::after {
  background: #3f9d4a;
}

.travel-map-tooltip {
  display: none;
  position: fixed;
  /* Joins the shared tooltip band (10000/10001) — it's a hover tooltip, and must
     clear the gump/floated-panel band (950+) and every modal. */
  z-index: 10000;
  background: var(--panel);
  border: 1px solid var(--edge);
  color: var(--ink);
  padding: 4px 8px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  border-radius: 4px;
  /* pre-line, not nowrap: the house pin's tooltip lists the materials a build still
     owes on their own lines (public/js/houseDeed.js's buildHousePinTooltip joins with
     \n). Every other pin sets single-line text with no newlines, which pre-line lays
     out identically to the old nowrap — spaces still collapse, only \n breaks. */
  white-space: pre-line;
  pointer-events: none;
}

/* Every gump now floats (see public/js/gumpWindow.js) rather than sitting behind a
   full-viewport dimmed modal, so the Backpack/Activities/etc. stay visible and
   interactive underneath any of them — not just the Bank, which is where this
   originally started (drag-and-drop deposits need the Backpack visible at the same
   time). Positioning itself comes from initGumpWindow()'s inline styles.

   The Bank gets its own richer "treasure chest" identity rather than the plain
   parchment-book look every other gump shares — a full ornate stone-and-brass gump
   frame (public/assets/sprites/bank_gump_frame.png, a commissioned piece, not
   procedurally drawn) applied as a CSS border-image, its "BANK" title baked right into
   the top slice. border-image-slice's `fill` keyword also paints the frame's own dark
   interior texture across the whole padding/content box as a base layer, so the header
   strip/gold row/weight row/item-scatter panel all sit directly on that same provided
   art with nothing else layered on top of it. */
.bank-gump {
  position: relative;
  border-style: solid;
  border-width: 40px 14px 16px 14px;
  border-image-source: url('/assets/sprites/bank_gump_frame.png');
  border-image-slice: 15% 10% 11% 10% fill;
  border-image-width: 40px 14px 16px 14px;
  border-image-repeat: stretch;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.6);
}

/* The frame's top slice already reads "BANK" in gold — no need for our own header
   text/background duplicating it. This strip exists purely as the drag handle (see
   public/js/gumpWindow.js) and to host the close button, pinned over the border-image's
   title area rather than occupying content-box space of its own. */
.bank-gump .skills-gump-header {
  position: absolute;
  top: -40px;
  left: 0;
  right: 0;
  height: 40px;
  padding: 0;
  border: none;
  background: transparent;
  justify-content: flex-end;
}

.bank-gump .skills-gump-header span {
  display: none;
}

.bank-gump .skills-gump-close {
  margin: 6px 10px 0 0;
}

.bank-panel {
  position: relative;
  background: transparent;
  overflow: hidden;
  border-radius: 6px;
}

/* No more full-viewport modal — combat now floats in the main view (see
   public/js/gumpWindow.js) so Backpack/Activities stay usable mid-fight, matching real
   UO where combat never blocks the rest of the UI. */
.combat-gump {
  width: 320px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  padding-bottom: 14px;
}

/* Flee reuses .skills-gump-close's reset-button shell (no background/border, just the
   header-chip sizing) but shouldn't tint with whichever theme is active like a plain ×
   close button — it's a real, consequential action (drops the fight and eats the
   respawn cooldown same as losing), so it gets the same fixed danger-red the Journal's
   fail lines already use (.journal-fail), regardless of theme. */
#combat-flee-btn {
  color: #e25c4f;
}
#combat-flee-btn:hover {
  color: #ff7a6b;
}

.combat-gump-body {
  padding: 8px 16px 0;
}

.combat-monster-display {
  position: relative;
  text-align: center;
  padding: 16px 0 8px;
}

.combat-monster-sprite {
  width: 132px;
  height: 132px;
  image-rendering: pixelated;
}

/* A PK Ambush attacker (config/pkers.js) in the combat gump. Its src is a PLAYER sheet
   (192x144: six frames across, three facing rows down), not a single monster portrait, so it has
   to be cropped to just the first front-facing frame — otherwise the whole sheet would squash
   into the box. object-fit:none disables scaling so object-position can pick out that one 32x48
   cell, then the box is sized to a whole multiple of it and scaled up crisply. Front row (row 0)
   deliberately, so the ambusher faces the player head-on. */
.combat-monster-sprite.combat-monster-sprite-pk {
  /* Sized to the SOURCE cell, not the display size: object-fit:none draws the image at its
     natural scale and object-position picks which part shows, so the box has to be exactly one
     32x48 cell for the crop to land on the first front-facing frame. */
  width: 32px;
  height: 48px;
  object-fit: none;
  object-position: 0 0;
  /* `zoom` rather than `transform: scale()` on purpose — zoom scales the element's LAYOUT box
     too, so the sprite still occupies a full-height slot in the gump; a transform would scale
     only the paint and collapse the surrounding layout to 32x48. 2.75x lands it at 88x132,
     matching the height of the monster portraits it sits in for. */
  zoom: 2.75;
}

.combat-hp-bar-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
}

/* Floats directly above the sprite (.combat-monster-display's own position: relative is
   what this anchors to) rather than taking up a row in normal flow — same "hovering over
   the creature" read as a classic MMO's own monster status icons. :not(:empty) keeps it
   from reserving space with no debuffs active, same idiom as the player buff bar. */
.combat-monster-debuff-row:not(:empty) {
  position: absolute;
  top: 6px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 4px;
}

/* The player's own debuff row (views/partials/combatGump.ejs) — same icons and tooltip
   machinery as the monster's row above (public/js/combat.js's shared renderer), but
   in-flow under the "You" swing bar rather than floated over a sprite: the player has no
   sprite in this gump to float over. Collapses to nothing while clean, which is almost
   always. */
.combat-player-debuff-row:not(:empty) {
  display: flex;
  gap: 4px;
  margin: 2px 0 4px;
  justify-content: flex-end;
}

.combat-monster-debuff-icon {
  width: 20px;
  height: 20px;
  image-rendering: pixelated;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.combat-hp-bar {
  flex: 1;
}

/* Qualified with .travel-progress-fill (always present together on the same element —
   see combatGump.ejs) rather than left as a bare single class: .travel-progress-fill's
   own background: var(--accent) is declared LATER in this file, and two single-class
   selectors of equal specificity resolve by source order, not which one "sounds more
   specific" — so a bare .combat-hp-fill-monster rule here was silently losing that
   fight and rendering as plain accent-orange the whole time, never actually red. Same
   root cause as the .auto-battle-btn.active fix elsewhere in this file; fixed the same
   way, by out-specificity-ing the later rule instead of relying on source order. */
.travel-progress-fill.combat-hp-fill-monster {
  /* A distinctly hostile red — was rendering as plain accent-orange (see above), which
     didn't read as "the mob's HP" at a glance next to every other orange-accented bar
     in the game. */
  background: #d9271c;
  /* combat.js sets width directly on every combat:tick/combat:start — this transition
     is what turns each of those instant jumps into a smooth drain/refill instead of a
     hard snap, matching the "more fluid, less ticky" bars requested. combat:start's own
     jump to a fresh monster's 100% is a rare exception; a ~250ms fill-in there reads
     fine as "the bar arriving", not wrong. */
  transition: width 250ms ease-out;
}

.combat-hp-value {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  white-space: nowrap;
}

/* Section caption above the two per-combatant rows below — on their own, "You"/
   "Monster" next to a bare bar with no numbers don't read as an attack-speed timer at a
   glance. This names the whole block once. */
.combat-swing-section-label {
  margin-top: 10px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

.combat-swing-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
}

.combat-swing-label {
  width: 46px;
  flex-shrink: 0;
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
}

.combat-swing-bar {
  flex: 1;
}

/* Same .travel-progress-fill-qualified fix as .combat-hp-fill-monster above — these two
   were equally silently losing their own background to .travel-progress-fill's later
   rule, rendering as plain accent-orange instead of blue/amber. */
.travel-progress-fill.combat-swing-fill-player {
  background: #5d8ef0;
}

.travel-progress-fill.combat-swing-fill-monster-swing {
  background: #c97a27;
}

/* Bandage/cast rows mirrored into the Combat gump (views/game.js's bandage.js/
   spellCast.js drive both this copy and the Status gump's own row off the same
   tick) — colored distinctly per action (healing green / arcane violet) rather than
   the shared accent-orange every other bar in the game uses, so a glance tells you
   which one is running without reading the label. */
.combat-action-row {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.combat-action-label-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 6px;
}

.combat-action-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.combat-action-bandage .combat-action-label { color: #7fd1a8; }
.combat-action-cast .combat-action-label { color: #d896ff; }
/* Purple — the monster's own wind-up, matching the cast bar the floorplan pins over the
   casting mob (.dfp-mob-castbar-fill). Was the same salmon red as every damage cue in
   this gump, so "it is casting" read as just more red; a colour reserved for the
   monster's casts alone is legible at a glance without reading the label. */
.combat-action-monstercast .combat-action-label { color: #c99cff; }

/* Same .travel-progress-fill-qualified fix as .combat-hp-fill-monster/.combat-swing-fill-*
   above — a bare single-class background rule here would lose the cascade to
   .travel-progress-fill's own background: var(--accent), declared later in this file. */
.travel-progress-fill.combat-action-fill-bandage {
  background: #7fd1a8;
  transition: width 100ms linear;
}

.travel-progress-fill.combat-action-fill-cast {
  background: #d896ff;
  transition: width 100ms linear;
}

/* The brief "Fizzled!" hold public/js/spellCast.js paints on the player's cast bar when
   a cast breaks (interrupted by a hit, or the skill roll failing at resolution) before
   the bar hides / the next chained cast takes it over. Red replaces the arcane purple so
   the failure reads at a glance even mid-fight. */
.combat-action-cast.spell-cast-fizzled .combat-action-label { color: #ff6b5e; }
.spell-cast-fizzled .travel-progress-fill.combat-action-fill-cast {
  background: #b0453a;
  transition: none;
}

.travel-progress-fill.combat-action-fill-monstercast {
  background: #a855f7;
  transition: width 100ms linear;
}

.combat-action-bandage .travel-cancel-btn {
  margin-top: 8px;
  padding: 3px 10px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.combat-respawn-row {
  text-align: center;
  padding: 28px 0 24px;
}

.combat-respawn-label {
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.combat-respawn-countdown {
  margin-top: 8px;
  font-size: calc(28px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

/* The slain monster's loot, revealed inside the combat gump during the post-kill respawn
   wait (public/js/corpseLoot.js). Rows reuse the ground-loot gump's own .ground-loot-item-row
   styling; only the list wrapper needs its own rule here since the combat-gump-body already
   supplies the horizontal padding the standalone ground-loot list added itself. */
.combat-loot-section {
  padding-bottom: 6px;
}

.combat-loot-item-list {
  max-height: 180px;
  overflow-y: auto;
  margin-top: 4px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

@keyframes damage-popup-float {
  0% { transform: translate(-50%, 0); opacity: 1; }
  100% { transform: translate(-50%, -28px); opacity: 0; }
}

.damage-popup {
  position: absolute;
  top: 0;
  left: 50%;
  font-weight: bold;
  font-size: calc(16px * var(--ui-font-scale, 1));
  pointer-events: none;
  animation: damage-popup-float 800ms ease-out forwards;
}

.damage-popup-monster { color: #ffe08a; text-shadow: 0 0 3px #000; }
.damage-popup-player { color: #e25c4f; text-shadow: 0 0 3px #000; }

/* Outcome words (combat.js's spawnTextPopup) — same float animation as the numbers,
   just quieter (a miss) or louder (a parry/absorb) styling per what happened. */
.damage-popup-miss { color: #9aa1ab; font-style: italic; font-size: calc(13px * var(--ui-font-scale, 1)); text-shadow: 0 0 3px #000; }
.damage-popup-parry { color: #ffd76a; font-size: calc(14px * var(--ui-font-scale, 1)); text-shadow: 0 0 4px #000; }
.damage-popup-absorb { color: #d8b3ff; font-size: calc(14px * var(--ui-font-scale, 1)); text-shadow: 0 0 4px #000; }
/* Inscribed Spellbook mana proc (services/inscription.js). Deliberately the same blue as
   the mana bar itself rather than a new colour, so "+10 Mana" over the bar reads as that
   bar filling rather than as one more unexplained combat number. */
.damage-popup-mana { color: #6fb7ff; font-size: calc(13px * var(--ui-font-scale, 1)); text-shadow: 0 0 4px #000; }

/* A crit's number shouts — bigger, ember-orange, and it slams in oversized before
   settling into the same upward float as everything else. */
@keyframes damage-popup-crit-float {
  0% { transform: translate(-50%, 2px) scale(1.7); opacity: 1; }
  25% { transform: translate(-50%, -4px) scale(1); }
  100% { transform: translate(-50%, -32px) scale(1); opacity: 0; }
}
.damage-popup-crit {
  color: #ff9e3a;
  font-size: calc(22px * var(--ui-font-scale, 1));
  text-shadow: 0 0 6px #000, 0 0 12px rgba(255, 158, 58, 0.5);
  animation: damage-popup-crit-float 900ms ease-out forwards;
}

/* "CALM BROKEN!" (services/barding.js) — borrows the crit float so it lands with the same
   weight as the loudest thing in the gump, but in alarm-red rather than ember-orange:
   a crit is something that went right, this is a bard's song ending early. Smaller than a
   crit's 22px because it's a phrase, not a number, and needs to fit the sprite's width. */
.damage-popup-peacebreak {
  color: #ff6b5e;
  font-size: calc(14px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
  text-shadow: 0 0 6px #000, 0 0 12px rgba(255, 107, 94, 0.55);
  animation: damage-popup-crit-float 900ms ease-out forwards;
}

/* A song LANDING (public/js/combat.js's playBardSongFx) — the peacebreak's two happy
   siblings, same weight and float, in each song's own colour: soothing sky-blue for a
   calm settling, sour violet for a Discordance biting. */
.damage-popup-calmed {
  color: #7ec9ff;
  font-size: calc(14px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
  text-shadow: 0 0 6px #000, 0 0 12px rgba(126, 201, 255, 0.55);
  animation: damage-popup-crit-float 900ms ease-out forwards;
}
.damage-popup-discord {
  color: #c95cff;
  font-size: calc(14px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
  text-shadow: 0 0 6px #000, 0 0 12px rgba(201, 92, 255, 0.55);
  animation: damage-popup-crit-float 900ms ease-out forwards;
}

/* --- Bard song notes (public/js/combat.js's spawnBardNotes) ------------------------------
   One floating musical glyph — rises and sways along its own --sx drift, glowing in the
   song's colour. Spawned in a handful with staggered inline delays so a played song reads
   as a phrase of music, not one stamp. The fizzle variant is a note DYING — a fumble or a
   monster shrugging the song off: it stalls mid-rise, falls, and greys out. */
.bard-note {
  position: absolute;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: 1;
  color: var(--fx-color, #7ec9ff);
  text-shadow: 0 0 4px #000, 0 0 8px var(--fx-color, #7ec9ff);
  animation: bard-note-rise 1.3s ease-out both;
  pointer-events: none;
  z-index: 6;
}
@keyframes bard-note-rise {
  0% { transform: translate(0, 0); opacity: 0; }
  15% { opacity: 1; }
  50% { transform: translate(calc(var(--sx, 0px) * 0.6), -18px); }
  80% { opacity: 1; }
  100% { transform: translate(var(--sx, 0px), -36px); opacity: 0; }
}
.bard-note-fizzle {
  animation: bard-note-fizzle 900ms ease-in both;
  color: #8a8a94;
  text-shadow: 0 0 4px #000;
}
@keyframes bard-note-fizzle {
  0% { transform: translate(0, 0); opacity: 0; }
  20% { transform: translate(calc(var(--sx, 0px) * 0.5), -10px); opacity: 0.9; }
  100% { transform: translate(var(--sx, 0px), 12px); opacity: 0; }
}

/* ============ Combat & spell FX (public/js/combat.js's spawnFx) ============
   All CSS-only transient effects, same append/animationend-remove lifecycle as
   .damage-popup above. The .fx-anchor is a zero-size point pinned over the monster
   sprite's center (132px sprite + 16px top padding => center ~82px); each family's
   .fx-inner centers itself off that point with negative margins so its keyframes can
   use transform purely for motion. Spell tints come from --fx-color (set inline by
   public/js/spellCast.js's SPELL_FX map); weapon effects carry their own colors.
   Weapon/monster swings additionally get a random inline rotate/offset ON THE ANCHOR
   (combat.js's randomSwingTransform) — the anchor itself never animates, so that
   transform freely tilts the coordinate space the .fx-inner keyframes travel through,
   which is what makes every swing arrive from a different direction without a single
   keyframe knowing about it. */
.fx-anchor {
  position: absolute;
  left: 50%;
  top: 82px;
  width: 0;
  height: 0;
  pointer-events: none;
  z-index: 5;
}

/* Player-targeted effects (heal/buff spells, and now the monster's own swing FX —
   combat.js's spawnMonsterSwingFx) anchor over the Status gump's vitals instead of a
   monster — see spellCast.js's MONSTER_TARGETED_EFFECTS split. */
.fx-anchor.spell-fx-on-player,
.fx-anchor.fx-on-player { top: 46px; }

.fx-inner { position: absolute; }

/* --- Monster hit feedback: a landed blow flashes the sprite; macing judders it. --- */
@keyframes sprite-hit-flash {
  0% { filter: brightness(1); }
  30% { filter: brightness(2.1) saturate(0.55); }
  100% { filter: brightness(1); }
}
.sprite-hit-flash { animation: sprite-hit-flash 260ms ease-out; }

@keyframes sprite-hit-shake {
  0%, 100% { transform: translate(0, 0); }
  20% { transform: translate(-4px, 1px); }
  40% { transform: translate(4px, -1px); }
  60% { transform: translate(-3px, 0); }
  80% { transform: translate(2px, 0); }
}
.sprite-hit-shake { animation: sprite-hit-shake 320ms ease-out, sprite-hit-flash 260ms ease-out; }

/* A crit (Valor's Relentless Strikes, config/virtueBinding.js) reads as a whole tier
   above a normal hit: a harder white-out plus a quick swell-and-recoil of the sprite
   itself. */
@keyframes sprite-hit-crit {
  0% { filter: brightness(1); transform: scale(1); }
  25% { filter: brightness(2.7) saturate(0.35); transform: scale(1.12); }
  55% { filter: brightness(1.5); transform: scale(0.95); }
  100% { filter: brightness(1); transform: scale(1); }
}
.sprite-hit-crit { animation: sprite-hit-crit 400ms ease-out; }

/* The monster visibly strikes whenever its own swing timer fires (combat.js's
   lungeMonsterSprite) — a quick snap forward-and-down toward the player's UI, then
   settle back. Interrupted (class stripped) by any landed player hit the same beat. */
@keyframes sprite-attack-lunge {
  0% { transform: translate(0, 0) scale(1); }
  30% { transform: translate(-8px, 9px) scale(1.06); }
  55% { transform: translate(3px, -2px) scale(1.01); }
  100% { transform: translate(0, 0) scale(1); }
}
.sprite-attack-lunge { animation: sprite-attack-lunge 340ms ease-out; }

/* --- Weapon-skill swing effects (combat.js's WEAPON_FX_CLASSES). A miss plays the
   same swing ghosted — the whiff reads as "swung and missed", not "nothing happened". */
.fx-anchor.weapon-fx-miss { opacity: 0.3; }

/* Swordsmanship: a steel streak sweeping across the sprite — the sweep's angle and
   direction come from the anchor's random inline transform (see the block comment
   above), so these keyframes are a plain straight pass in local coordinates. */
@keyframes weapon-fx-slash {
  0% { transform: translateX(-70px) scaleX(0.4); opacity: 0; }
  20% { opacity: 1; }
  100% { transform: translateX(70px) scaleX(1); opacity: 0; }
}
.weapon-fx-slash .fx-inner {
  width: 104px;
  height: 5px;
  margin: -3px 0 0 -52px;
  border-radius: 3px;
  background: linear-gradient(90deg, transparent, #eef2f7, transparent);
  box-shadow: 0 0 8px rgba(238, 242, 247, 0.8);
  animation: weapon-fx-slash 420ms ease-in forwards;
}

/* Fencing: a thin lance stabbing straight in from the left. */
@keyframes weapon-fx-thrust {
  0% { transform: translateX(-85px) scaleX(0.5); opacity: 0; }
  55% { transform: translateX(0) scaleX(1); opacity: 1; }
  100% { transform: translateX(10px) scaleX(0.7); opacity: 0; }
}
.weapon-fx-thrust .fx-inner {
  width: 80px;
  height: 3px;
  margin: -2px 0 0 -40px;
  border-radius: 2px;
  background: linear-gradient(90deg, transparent, #dfe6ee 65%, #ffffff);
  box-shadow: 0 0 6px rgba(223, 230, 238, 0.8);
  animation: weapon-fx-thrust 340ms cubic-bezier(0.5, 0, 0.9, 0.4) forwards;
}

/* Macing: a blunt-impact shockwave ring (paired with the sprite judder above). */
@keyframes weapon-fx-smash {
  0% { transform: scale(0.3); opacity: 1; }
  100% { transform: scale(2.4); opacity: 0; }
}
.weapon-fx-smash .fx-inner {
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border-radius: 50%;
  border: 3px solid #f0d9a8;
  box-shadow: 0 0 10px rgba(240, 217, 168, 0.7), inset 0 0 6px rgba(240, 217, 168, 0.5);
  animation: weapon-fx-smash 380ms ease-out forwards;
}

/* Archery: an arrow flying in from the left, vanishing on impact. */
@keyframes weapon-fx-arrow {
  0% { transform: translateX(-95px); opacity: 0; }
  15% { opacity: 1; }
  85% { transform: translateX(-4px); opacity: 1; }
  100% { transform: translateX(0); opacity: 0; }
}
.weapon-fx-arrow .fx-inner {
  width: 42px;
  height: 3px;
  margin: -2px 0 0 -42px;
  border-radius: 2px;
  background: linear-gradient(90deg, #8a6a42, #c9a86a);
  animation: weapon-fx-arrow 300ms linear forwards;
}
.weapon-fx-arrow .fx-inner::after {
  content: '';
  position: absolute;
  right: -7px;
  top: -3px;
  border-left: 8px solid #dfe6ee;
  border-top: 4px solid transparent;
  border-bottom: 4px solid transparent;
}

/* Wrestling: a compact bare-knuckle burst right on the target. */
@keyframes weapon-fx-punch {
  0% { transform: scale(0.4); opacity: 0.9; }
  100% { transform: scale(1.5); opacity: 0; }
}
.weapon-fx-punch .fx-inner {
  width: 22px;
  height: 22px;
  margin: -11px 0 0 -11px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 255, 255, 0.9), rgba(255, 255, 255, 0));
  animation: weapon-fx-punch 300ms ease-out forwards;
}

/* --- Impact sparks (combat.js's spawnImpactSparks): a radial burst of tiny motes,
   each flying out to its own random --sx/--sy with a slight stagger — layered under
   every landed hit and every parry. Color set inline per weapon/effect. --- */
@keyframes impact-spark {
  0% { transform: translate(0, 0) scale(1); opacity: 1; }
  100% { transform: translate(var(--sx), var(--sy)) scale(0.3); opacity: 0; }
}
.impact-spark {
  position: absolute;
  width: 4px;
  height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
  animation: impact-spark 420ms ease-out forwards;
}

/* --- The monster's own attack, shown over the player's vitals (combat.js's
   spawnMonsterSwingFx): three raked claw streaks sweeping across the nameplate. The
   approach angle comes from the anchor's random inline transform, same as the
   player's weapon FX. --- */
@keyframes monster-fx-swipe {
  0% { transform: translateX(-70px); opacity: 0; }
  25% { opacity: 1; }
  100% { transform: translateX(70px); opacity: 0; }
}
.monster-fx-swipe .fx-inner {
  width: 74px;
  height: 26px;
  margin: -13px 0 0 -37px;
  background:
    linear-gradient(90deg, transparent, rgba(255, 96, 70, 0.95), transparent) 0 2px / 100% 3px no-repeat,
    linear-gradient(90deg, transparent, rgba(255, 96, 70, 0.8), transparent) 0 11px / 100% 3px no-repeat,
    linear-gradient(90deg, transparent, rgba(255, 96, 70, 0.65), transparent) 0 20px / 100% 3px no-repeat;
  filter: drop-shadow(0 0 4px rgba(255, 96, 70, 0.6));
  animation: monster-fx-swipe 380ms ease-in forwards;
}

/* A miss ghosts straight past — same faded-whiff language as .weapon-fx-miss. */
.fx-anchor.monster-fx-whiff { opacity: 0.35; }

/* A parried swipe arrives, stops dead short of the vitals, and recoils back off the
   arc below — the deflection itself, not just a word in the journal. Must sit after
   .monster-fx-swipe .fx-inner in source order: same specificity, and this one only
   overrides the animation while inheriting the streak art. */
@keyframes monster-fx-swipe-deflected {
  0% { transform: translateX(-70px); opacity: 0; }
  30% { opacity: 1; }
  55% { transform: translateX(-12px) rotate(0deg); opacity: 1; }
  100% { transform: translateX(-48px) rotate(16deg); opacity: 0; }
}
.monster-fx-deflected .fx-inner {
  animation: monster-fx-swipe-deflected 450ms ease-out forwards;
}

/* The catch itself: a golden arc segment (shield rim / blade edge) flashing at the
   parry point, rotating slightly as it takes the blow. Pairs with gold impact sparks
   and the "Parried!" popup spawned alongside it. */
@keyframes parry-arc-fx {
  0% { transform: rotate(-25deg) scale(0.5); opacity: 0; }
  30% { opacity: 1; }
  100% { transform: rotate(20deg) scale(1.3); opacity: 0; }
}
.parry-arc-fx .fx-inner {
  width: 46px;
  height: 46px;
  margin: -23px 0 0 -23px;
  border-radius: 50%;
  border: 4px solid transparent;
  border-left-color: #ffd76a;
  border-top-color: #ffd76a;
  box-shadow: 0 0 10px rgba(255, 215, 106, 0.45);
  animation: parry-arc-fx 420ms ease-out forwards;
}

/* With a shield equipped, the parry shows the player's ACTUAL shield art instead of the
   abstract arc (combat.js's 'parried' branch sets this element's backgroundImage to the
   equipped shield variant's sheet): the shield snaps up from the low-left carry into
   the deflected swipe's path, braces through the impact beat, then drops away. Shows
   only the FRONT cell of the 32x144 three-row sheet (background-size/position crop).
   Positioned like a .fx-anchor.fx-on-player point manually — this element carries real
   size, so it can't ride the zero-size anchor rules. transform-origin sits on the
   shield's own boss (the shape lives in the cell's left third) so the rotate/scale
   pivot on the shield, not on empty canvas. Braced (38%) by ~160ms — visibly in place
   before the deflected swipe stops dead at ~250ms of its own 450ms run. */
@keyframes shield-block-fx {
  0% { transform: translate(-18px, 22px) scale(0.7) rotate(-14deg); opacity: 0; }
  38% { transform: translate(0, 0) scale(1.5) rotate(6deg); opacity: 1; }
  70% { transform: translate(2px, 0) scale(1.5) rotate(2deg); opacity: 1; }
  100% { transform: translate(0, 6px) scale(1.3) rotate(0deg); opacity: 0; }
}
.shield-block-fx {
  position: absolute;
  left: 50%;
  top: 46px;
  width: 32px;
  height: 48px;
  margin: -27px 0 0 -8px; /* centers the shield SHAPE (boss ~(7.5,27) in-cell) on the anchor point */
  background-size: 32px 144px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  transform-origin: 23% 56%;
  pointer-events: none;
  z-index: 6; /* over the swipe streak — the blow lands ON the shield */
  filter: drop-shadow(0 0 6px rgba(255, 215, 106, 0.6));
  animation: shield-block-fx 420ms ease-out forwards;
}

/* --- Spell resolution effects (spellCast.js's SPELL_FX) — a small set of keyframe
   families tinted per spell via --fx-color rather than 23 bespoke animations. --- */

/* bolt: Magic Arrow / Energy Bolt — a streak of raw energy. Origin moved to the
   lower-left (the player's vitals side of the layout) so the shot visibly comes FROM
   the caster, tilted along its own flight line, fizzling at the target. */
@keyframes spell-fx-bolt {
  0% { transform: translate(-140px, 60px) rotate(-23deg) scaleX(0.6); opacity: 0; }
  20% { opacity: 1; }
  80% { transform: translate(0, 0) rotate(-23deg) scaleX(1); opacity: 1; }
  100% { transform: translate(6px, -3px) rotate(-23deg) scaleX(0.5); opacity: 0; }
}
.spell-fx-bolt .fx-inner {
  width: 64px;
  height: 4px;
  margin: -2px 0 0 -32px;
  border-radius: 3px;
  background: linear-gradient(90deg, transparent, var(--fx-color));
  box-shadow: 0 0 10px var(--fx-color);
  animation: spell-fx-bolt 380ms ease-in forwards;
}

/* Incoming variant (a MONSTER's cast arriving at the player — spellCast.js's
   spellAttack resolve): same streak, mirrored to fly in from the upper-right where the
   monster display sits. */
@keyframes spell-fx-bolt-incoming {
  0% { transform: translate(140px, -60px) rotate(-23deg) scaleX(0.6); opacity: 0; }
  20% { opacity: 1; }
  80% { transform: translate(0, 0) rotate(-23deg) scaleX(1); opacity: 1; }
  100% { transform: translate(-6px, 3px) rotate(-23deg) scaleX(0.5); opacity: 0; }
}
.spell-fx-bolt.fx-incoming .fx-inner {
  animation: spell-fx-bolt-incoming 380ms ease-in forwards;
}

/* orb: Fireball — stage one of two: PURE FLIGHT from the caster's side, arriving
   opaque at the target; spellCast.js chains the spell-fx-explosion below off this
   animation's end, so the burst is its own dedicated effect instead of the orb
   half-heartedly scaling up. */
@keyframes spell-fx-orb {
  0% { transform: translate(-140px, 70px) scale(0.55); opacity: 0; }
  15% { opacity: 1; }
  100% { transform: translate(0, 0) scale(1); opacity: 1; }
}
.spell-fx-orb .fx-inner {
  width: 20px;
  height: 20px;
  margin: -10px 0 0 -10px;
  border-radius: 50%;
  background: radial-gradient(circle, #fff2d0 15%, var(--fx-color) 55%, transparent 75%);
  box-shadow: 0 0 14px var(--fx-color);
  animation: spell-fx-orb 300ms ease-in forwards;
}
@keyframes spell-fx-orb-incoming {
  0% { transform: translate(140px, -70px) scale(0.55); opacity: 0; }
  15% { opacity: 1; }
  100% { transform: translate(0, 0) scale(1); opacity: 1; }
}
.spell-fx-orb.fx-incoming .fx-inner {
  animation: spell-fx-orb-incoming 300ms ease-in forwards;
}

/* Stage two: the explosion — a hot core flash plus an expanding shockwave ring.
   Chained by spellCast.js at the orb's arrival (and reused under flame_strike's
   pillar); reads as a real detonation rather than a fading sprite. */
@keyframes spell-fx-explosion-core {
  0% { transform: scale(0.4); opacity: 1; }
  100% { transform: scale(2); opacity: 0; }
}
@keyframes spell-fx-explosion-ring {
  0% { transform: scale(0.3); opacity: 0.95; }
  100% { transform: scale(2.6); opacity: 0; }
}
.spell-fx-explosion .fx-inner {
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  background: radial-gradient(circle, #fff4d6 10%, var(--fx-color) 50%, transparent 78%);
  box-shadow: 0 0 18px 4px var(--fx-color);
  animation: spell-fx-explosion-core 420ms ease-out forwards;
}
.spell-fx-explosion .fx-inner::after {
  content: '';
  position: absolute;
  inset: -4px;
  border-radius: 50%;
  border: 3px solid var(--fx-color);
  animation: spell-fx-explosion-ring 420ms ease-out forwards;
}

/* lightning: a jagged bolt striking down from above the sprite, with a flicker. */
@keyframes spell-fx-lightning {
  0% { transform: scaleY(0); opacity: 0; }
  25% { transform: scaleY(1); opacity: 1; }
  40% { opacity: 0.4; }
  55% { opacity: 1; }
  100% { transform: scaleY(1); opacity: 0; }
}
.spell-fx-lightning .fx-inner {
  width: 26px;
  height: 104px;
  margin: -104px 0 0 -13px;
  transform-origin: top;
  background: var(--fx-color);
  clip-path: polygon(52% 0, 68% 0, 44% 38%, 62% 38%, 30% 100%, 46% 55%, 30% 55%);
  filter: drop-shadow(0 0 8px var(--fx-color));
  animation: spell-fx-lightning 450ms ease-out forwards;
}

/* pulse: Harm / Mind Blast — an expanding sphere of force centered on the target. */
@keyframes spell-fx-pulse {
  0% { transform: scale(0.4); opacity: 0.95; }
  100% { transform: scale(2); opacity: 0; }
}
.spell-fx-pulse .fx-inner {
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fx-color) 30%, transparent 70%);
  box-shadow: 0 0 16px var(--fx-color);
  animation: spell-fx-pulse 520ms ease-out forwards;
}

/* pillar: Flame Strike — the UO signature: a roaring column erupting up THROUGH the
   target, not a polite gradient bar. Three stacked layers on the one .fx-inner (the
   element itself is the outer glow; ::before is the white-hot core; ::after is the
   ground flash where it erupts), plus the flame tongues and rising embers spellCast.js
   spawns alongside — the tongues are separate elements because each needs its own
   random offset and flicker phase, which one keyframe can't give. */
@keyframes spell-fx-pillar {
  0% { transform: scaleY(0.05) scaleX(1.5); opacity: 0.95; }
  30% { transform: scaleY(1.04) scaleX(1); opacity: 1; }
  42% { transform: scaleY(0.97) scaleX(1.06); }
  60% { transform: scaleY(1) scaleX(1); opacity: 1; }
  100% { transform: scaleY(1) scaleX(0.85); opacity: 0; }
}
@keyframes spell-fx-pillar-core {
  0% { transform: scaleY(0.05); opacity: 0.9; }
  38% { transform: scaleY(1); opacity: 1; }
  100% { transform: scaleY(1); opacity: 0; }
}
@keyframes spell-fx-pillar-flash {
  0% { transform: scale(0.3); opacity: 0.9; }
  45% { transform: scale(1.15); opacity: 0.6; }
  100% { transform: scale(1.5); opacity: 0; }
}
.spell-fx-pillar .fx-inner {
  width: 52px;
  height: 128px;
  margin: -104px 0 0 -26px;
  transform-origin: bottom;
  border-radius: 46% 46% 10px 10px;
  background: linear-gradient(to top,
    var(--fx-color),
    rgba(255, 176, 64, 0.95) 30%,
    rgba(255, 214, 120, 0.8) 55%,
    rgba(255, 240, 190, 0.4) 78%,
    transparent);
  filter: drop-shadow(0 0 16px var(--fx-color)) drop-shadow(0 0 34px rgba(255, 120, 30, 0.55));
  animation: spell-fx-pillar 820ms ease-out forwards;
  position: relative;
}
.spell-fx-pillar .fx-inner::before {
  /* White-hot core column up the middle. */
  content: '';
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 18px;
  height: 112px;
  margin-left: -9px;
  transform-origin: bottom;
  border-radius: 50% 50% 6px 6px;
  background: linear-gradient(to top, #fff6d8, rgba(255, 246, 216, 0.85) 45%, transparent 85%);
  animation: spell-fx-pillar-core 820ms ease-out forwards;
}
.spell-fx-pillar .fx-inner::after {
  /* Ground flash — the eruption point, flaring wide and dying. */
  content: '';
  position: absolute;
  left: 50%;
  bottom: -7px;
  width: 74px;
  height: 22px;
  margin-left: -37px;
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(255, 240, 190, 0.95), var(--fx-color) 55%, transparent 75%);
  animation: spell-fx-pillar-flash 500ms ease-out forwards;
}

/* Flame tongues — small teardrop licks flickering up the pillar's flanks. Spawned as
   siblings by spellCast.js, each with its own --tx (horizontal seat), --th (height) and
   staggered animation-delay, so no two casts ever flicker alike. */
@keyframes spell-fx-flame-tongue {
  0% { transform: translate(var(--tx), 6px) scaleY(0.2); opacity: 0; }
  25% { opacity: 0.95; }
  55% { transform: translate(calc(var(--tx) * 1.3), calc(var(--th) * -0.6)) scaleY(1); opacity: 0.85; }
  100% { transform: translate(var(--tx), calc(var(--th) * -1)) scaleY(0.5); opacity: 0; }
}
.spell-fx-flame-tongue {
  position: absolute;
  left: 50%;
  bottom: 8px;
  width: 10px;
  height: 22px;
  margin-left: -5px;
  border-radius: 50% 50% 45% 45% / 70% 70% 30% 30%;
  background: linear-gradient(to top, var(--fx-color), rgba(255, 230, 150, 0.9) 60%, transparent);
  filter: drop-shadow(0 0 5px var(--fx-color));
  animation: spell-fx-flame-tongue 640ms ease-out forwards;
  pointer-events: none;
}

/* Rising embers — tiny sparks drifting up and out of the blast. Same --sx/--sy custom
   prop idiom as the melee impact sparks. */
@keyframes spell-fx-ember {
  0% { transform: translate(0, 0) scale(1); opacity: 1; }
  100% { transform: translate(var(--sx), var(--sy)) scale(0.3); opacity: 0; }
}
.spell-fx-ember {
  position: absolute;
  left: 50%;
  bottom: 12px;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: #ffd76a;
  box-shadow: 0 0 6px var(--fx-color);
  animation: spell-fx-ember 900ms ease-out forwards;
  pointer-events: none;
}

/* Poisoned monster tint — toggled by renderMonsterDebuffs (public/js/combat.js) off the
   live debuff list, NOT by a timed animation: the Poison spell's DoT runs for the whole
   fight, so the creature stays visibly sick exactly as long as it is actually poisoned,
   and a mid-fight joiner or reconnect gets the tint back with the next tick. The filter
   chain greens any hue family without flattening the sprite's own shading: sepia
   normalises toward warm grey first so hue-rotate lands every monster in the same sickly
   band, whatever colour it started. */
.sprite-poisoned {
  filter: sepia(0.6) hue-rotate(55deg) saturate(1.9) brightness(0.96);
  transition: filter 400ms ease-in;
}

/* sink: Weaken / Curse / Poison — energy draining down through the target. */
@keyframes spell-fx-sink {
  0% { transform: translateY(-16px) scale(1); opacity: 0.9; }
  100% { transform: translateY(26px) scale(0.65); opacity: 0; }
}
.spell-fx-sink .fx-inner {
  width: 64px;
  height: 26px;
  margin: -13px 0 0 -32px;
  border-radius: 50%;
  border: 3px solid var(--fx-color);
  border-left-color: transparent;
  border-right-color: transparent;
  box-shadow: 0 0 10px var(--fx-color);
  animation: spell-fx-sink 600ms ease-in forwards;
}

/* ring: Paralyze / Protection / Magic Reflection / Invisibility — a rune-circle
   snapping shut around its target. */
@keyframes spell-fx-ring {
  0% { transform: scale(1.9); opacity: 0; }
  35% { transform: scale(1); opacity: 1; }
  100% { transform: scale(1.15); opacity: 0; }
}
.spell-fx-ring .fx-inner {
  width: 52px;
  height: 52px;
  margin: -26px 0 0 -26px;
  border-radius: 50%;
  border: 3px solid var(--fx-color);
  box-shadow: 0 0 12px var(--fx-color), inset 0 0 8px var(--fx-color);
  animation: spell-fx-ring 620ms ease-out forwards;
}

/* rise: Heal / Greater Heal / Cure / Bless — motes of light drifting upward (the
   box-shadow copies fake two extra sparkles off the one element). */
@keyframes spell-fx-rise {
  0% { transform: translateY(14px) rotate(45deg); opacity: 0; }
  25% { opacity: 1; }
  100% { transform: translateY(-34px) rotate(45deg); opacity: 0; }
}
.spell-fx-rise .fx-inner {
  width: 9px;
  height: 9px;
  margin: -5px 0 0 -5px;
  background: var(--fx-color);
  box-shadow:
    -16px 12px 0 -2px var(--fx-color),
    14px 18px 0 -3px var(--fx-color),
    0 0 10px var(--fx-color);
  animation: spell-fx-rise 700ms ease-out forwards;
}

/* Heal sparkle burst — spawned alongside the rise motes for Heal/Greater Heal
   (spawnHealSparkles, public/js/combat.js): a dozen four-pointed stars twinkling into
   life around the healed figure, each seated at its own random --sx/--sy offset with a
   staggered delay, plus one soft glow bloom underneath. The star shape is two crossed
   bars via a clip-path so it reads as a proper sparkle, not a square. */
@keyframes heal-sparkle-twinkle {
  0% { transform: translate(var(--sx), var(--sy)) scale(0) rotate(0deg); opacity: 0; }
  35% { transform: translate(var(--sx), calc(var(--sy) - 5px)) scale(1.15) rotate(20deg); opacity: 1; }
  70% { transform: translate(var(--sx), calc(var(--sy) - 12px)) scale(0.8) rotate(38deg); opacity: 0.85; }
  100% { transform: translate(var(--sx), calc(var(--sy) - 20px)) scale(0) rotate(50deg); opacity: 0; }
}
.heal-sparkle {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 11px;
  height: 11px;
  margin: -5.5px 0 0 -5.5px;
  background: var(--fx-color);
  clip-path: polygon(50% 0%, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0% 50%, 39% 39%);
  filter: drop-shadow(0 0 4px var(--fx-color));
  animation: heal-sparkle-twinkle 750ms ease-out forwards;
  pointer-events: none;
}
@keyframes heal-glow-bloom {
  0% { transform: translate(-50%, -50%) scale(0.4); opacity: 0; }
  40% { opacity: 0.5; }
  100% { transform: translate(-50%, -50%) scale(1.35); opacity: 0; }
}
.heal-glow {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fx-color) 0%, transparent 65%);
  animation: heal-glow-bloom 800ms ease-out forwards;
  pointer-events: none;
}

/* See the comment above .skills-gump — positioning now comes from initGumpWindow(). */
.ground-loot-gump {
  width: 300px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
  padding-bottom: 14px;
}

.ground-loot-item-list {
  max-height: 260px;
  overflow-y: auto;
  padding: 8px 16px 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.ground-loot-item-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.ground-loot-item-name {
  flex: 1;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

/* An open bag's contents, indented under the bag's own row so it reads as "inside this",
   with a rule down the left standing in for the nesting the flat list can't show. */
.ground-loot-bag-contents {
  margin: 0 0 4px 22px;
  padding-left: 8px;
  border-left: 2px solid rgba(var(--edge-rgb), 0.35);
}

.ground-loot-bag-contents .ground-loot-item-row:last-child {
  border-bottom: none;
}

.ground-loot-bag-empty {
  padding: 4px 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
  opacity: 0.6;
}

.ground-loot-take-btn, .ground-loot-take-all-btn, .ground-loot-split-btn,
.ground-loot-open-btn, .ground-loot-take-from-bag-btn {
  padding: 3px 10px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.ground-loot-take-all-btn {
  display: block;
  margin: 12px 16px 0;
}

.bank-panel.drag-over {
  outline: 2px solid var(--accent);
}

.bank-gold-row {
  display: flex;
  justify-content: space-between;
  margin: 10px 14px 0;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.bank-gold-row #bank-gold-value {
  color: #f0d878;
  font-weight: bold;
}

/* The balance and its one control, kept together on the right of the row so the button reads
   as belonging to the number rather than to the panel (views/partials/bankGump.ejs). */
.bank-gold-tail {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* Deliberately quiet — an outline, not the gilded .prompt-modal-confirm. This opens a window
   that asks a question; the accent belongs on the button inside it that actually moves money.
   Same restraint the gump-header buttons show. */
.bank-withdraw-btn {
  padding: 3px 10px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.bank-withdraw-btn:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* --- The Withdraw window (views/partials/bankWithdrawModal.ejs) -------------------------
   The shell is the shared .prompt-modal; these are the three pieces that are its own. */
/* Both classes, deliberately: .prompt-modal sets min-width: 260px and is declared LATER in
   this file than the bank block, so a single-class rule here loses the cascade on equal
   specificity and the window silently comes out 260 wide. Doubling the selector settles it
   by specificity instead of by position. */
.prompt-modal.bank-withdraw-modal { min-width: 300px; }

/* The balance, restated inside the window because the window covers the gump showing it.
   A well rather than a plain row: this is the figure everything else here is measured
   against, so it reads as a field of its own. */
.bank-withdraw-balance {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-top: -4px;
  padding: 7px 10px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.bank-withdraw-balance #bank-withdraw-balance-value {
  color: #f0d878;
  font-size: calc(15px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0;
}

/* Coin or check — a segmented pair, because there are exactly two and each wants a line of
   explanation a dropdown could not carry. */
.bank-withdraw-forms {
  display: flex;
  gap: 8px;
}

.bank-withdraw-form {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 8px 10px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--edge);
  border-radius: 5px;
  color: var(--muted);
  font-family: inherit;
  text-align: left;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.bank-withdraw-form:hover { border-color: rgba(var(--edge-rgb), 0.9); }

.bank-withdraw-form.active {
  background: rgba(var(--accent-rgb), 0.14);
  border-color: var(--accent);
}

.bank-withdraw-form-name {
  color: var(--ink);
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.bank-withdraw-form.active .bank-withdraw-form-name { color: var(--accent); }

.bank-withdraw-form-note {
  font-size: calc(10px * var(--ui-font-scale, 1));
  line-height: 1.3;
}

/* The quick picks. A flat row of equal pills — the amounts a player actually reaches for,
   with All so emptying the account needs no typing. */
.bank-withdraw-quick {
  display: flex;
  gap: 6px;
  margin-top: -6px;
}

.bank-withdraw-quick-btn {
  flex: 1;
  padding: 4px 0;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--muted);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.bank-withdraw-quick-btn:hover:not(:disabled) {
  border-color: var(--accent);
  color: var(--accent);
}

.bank-withdraw-quick-btn:disabled {
  opacity: 0.35;
  cursor: default;
}

/* What the choice costs you to carry. Empty until an amount is entered, so the window does
   not open with a line about nothing. */
.bank-withdraw-weight {
  min-height: 1.3em;
  margin-top: -6px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.3;
  text-align: center;
}

/* Not a refusal — the withdrawal still goes through, and being overweight stops gathering
   rather than voiding a transaction. This is the nudge toward taking a check instead.
   #e25c4f is the same red the weight readouts already turn (#weight-value.overweight, and
   the Bank's own bank-weight-row right above), so "too heavy" reads the same colour wherever
   the game says it. */
.bank-withdraw-weight.too-heavy { color: #e25c4f; }

/* Mage Shop: Arcane Dust balance + the reagent-stall list (views/partials/mageShopGump.ejs,
   public/js/mageShop.js). The dust value takes an arcane violet instead of gold's amber. */
.mage-shop-dust-row #mage-shop-dust-value {
  color: #c79cf0;
  font-weight: bold;
}

.mage-shop-dust-row span:first-child {
  display: flex;
  align-items: center;
  gap: 5px;
}

.mage-shop-dust-icon {
  width: 16px;
  height: 16px;
  image-rendering: pixelated;
}

.mage-reagent-section {
  margin: 10px 14px 12px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  padding-top: 8px;
}

.mage-reagent-header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  margin-bottom: 6px;
}

.mage-reagent-rate {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--accent);
  text-transform: none;
  letter-spacing: 0;
}

/* "Send purchases to my stockpile" — sits between the section heading and the reagent rows
   so it reads as a setting for the whole stall rather than for whichever row is nearest.
   Deliberately quiet (muted, small, no accent): it is a delivery preference you set once,
   not a thing to draw the eye every time the shop opens. */
.mage-reagent-stockpile-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.mage-reagent-stockpile-toggle input {
  cursor: inherit;
}

/* Capped + scrollable so the 8 rows never push the gump off-screen on a short viewport. */
.mage-reagent-list {
  display: flex;
  flex-direction: column;
  gap: 3px;
  max-height: 42vh;
  overflow-y: auto;
}

/* Columns: icon, name, slider, qty, cost, Buy. The last two were 30px/34px when the
   slider topped out at 500 and the price was dust ("50d"). It now runs to 1,000 at 5 gold
   each, so the widest strings became "1,000" and "5,000g" — both grouped, and both wider
   than the boxes they had. The gump is width:auto, so widening the columns widens the
   panel rather than crushing the name beside them. */
.mage-reagent-row {
  display: grid;
  grid-template-columns: 22px 1fr 84px 40px 50px auto;
  align-items: center;
  gap: 7px;
  padding: 3px 4px;
  border-radius: 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.mage-reagent-row:hover {
  background: var(--shade);
}

.mage-reagent-icon {
  width: 22px;
  height: 22px;
  image-rendering: pixelated;
}

.mage-reagent-name {
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.mage-reagent-slider {
  width: 84px;
  accent-color: #a56be0;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.mage-reagent-qty {
  text-align: right;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* var(--accent), the same gold every other price in the game is written in
   (.stable-mount-price above) — it was dust-purple while the stall charged dust. */
.mage-reagent-cost {
  text-align: right;
  color: var(--accent);
  font-weight: bold;
  font-variant-numeric: tabular-nums;
}

/* The Healer's rows carry three more things than the Stable's do (slider, count, total) —
   seven columns needing 476px inside a list built for 360. The earlier attempt to fix this
   put a min-width floor on .stable-mount-info so it stopped being crushed to 50px and
   wrapping onto two lines, but that only moved the problem outward: the row then ran 137px
   past the panel, painting the count, the total and the Buy button on the map outside the
   gump's own background.

   The container was always the wrong size, so size it to the rows instead of squeezing the
   rows into it. max-content rather than a pixel number because the number would be a
   transcription of the row's contents that silently rots the next time a column is added —
   exactly the drift this gump has now hit twice. The panel itself is width:auto (see the
   .stable-gump/.healer-shop-gump rule above), so it follows.

   Width is stable across the whole slider range: .healer-shop-total's 58px floor already
   holds the largest total the sliders can reach (100 x 35g = "3,500g"), so dragging one
   re-prices the row without resizing the window under the cursor.

   Scoped to this gump: the Stable, Provisioner and Mage Shop share .stable-roster, and all
   three fit 360px with room to spare. */
.healer-shop-gump .stable-roster {
  width: max-content;
}

/* 14px is the shared .bank-gold-row size, and next to this gump's 13px names and 11px
   apply-times it read as oversized — a bold amber number louder than the wares it is
   there to price. Dropped to sit level with the row names.

   Scoped rather than changed at source: the Bank, Provisioner, Mage Shop and Stable all
   share that rule, and none of them have this gump's dense rows to be measured against. */
.healer-shop-gump .bank-gold-row {
  font-size: calc(13px * var(--ui-font-scale, 1));
}

/* Healer Shop quantity slider (views/partials/healerShopGump.ejs) — the Mage Shop's
   reagent row arrangement, in gold rather than that shop's arcane violet, since these are
   bought with coin. Its own classes rather than reusing .mage-reagent-*: the two shops sit
   in different palettes and share no markup, and one renamed class would otherwise drag
   the other shop's colours across.

   The row is a fixed-column grid (.stable-mount-row), so the slider and the two numbers
   need explicit widths or a three-digit total shunts the Buy button out of line. */
.healer-shop-slider {
  width: 84px;
  accent-color: var(--accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* tabular-nums on both: without it the count and the total re-flow their neighbours on
   every drag of the slider, which reads as the row twitching. */
.healer-shop-qty {
  min-width: 26px;
  text-align: right;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.healer-shop-total {
  min-width: 58px;
  text-align: right;
  color: var(--accent);
  font-weight: bold;
  font-variant-numeric: tabular-nums;
}

.mage-reagent-buy-btn {
  padding: 2px 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.bank-weight-row {
  display: flex;
  justify-content: space-between;
  margin: 10px 14px 0;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.bank-weight-row .overweight, #bank-weight-value.overweight {
  color: #e25c4f;
  font-weight: bold;
}

.bank-weight-bar {
  height: 8px;
  margin: 6px 14px 0;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 4px;
  overflow: hidden;
}

.bank-weight-bar-fill {
  height: 100%;
  background: var(--edge);
}

.bank-weight-bar-fill.overweight {
  background: #e25c4f;
}

.construction-site-body {
  padding: 4px 0 14px;
  width: 320px;
}

.construction-site-name {
  margin: 0 14px 8px;
  color: var(--accent);
  font-size: calc(15px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.construction-site-drop-zone {
  margin: 16px 14px 0;
  padding: 18px 10px;
  border: 2px dashed rgba(255, 255, 255, 0.12);
  border-radius: 6px;
  text-align: center;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.construction-site-drop-zone.drag-over {
  border-color: var(--accent);
  color: var(--ink);
  background: rgba(var(--edge-rgb), 0.08);
}

.construction-site-status {
  margin: 10px 14px 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #7fd1a8;
  text-align: center;
}

/* ---- House upgrades (views/partials/worldMap.ejs + houseUpgradeGump.ejs) ---- */

/* The row under the floor plan: hammer slot, button, and a one-line state note. */
.house-upgrade-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}

/* The drop target itself. Deliberately reads as an empty socket waiting to be filled —
   dashed while empty, solid and accent-lit once a deed is committed, which is the
   "lights up" the feature was asked for. */
.house-upgrade-slot {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  border: 2px dashed rgba(255, 255, 255, 0.14);
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.18);
  transition: border-color 140ms ease, background 140ms ease, box-shadow 140ms ease;
}

.house-upgrade-slot.armed {
  border-style: solid;
  border-color: var(--accent);
  background: rgba(var(--edge-rgb), 0.12);
  box-shadow: 0 0 10px rgba(var(--edge-rgb), 0.35);
}

/* Matches the drag-over treatment every other drop zone in the game uses. */
.house-upgrade-slot.drag-over {
  border-color: var(--accent);
  background: rgba(var(--edge-rgb), 0.18);
}

.house-upgrade-hammer {
  width: 22px;
  height: 22px;
  opacity: 0.55;
  transition: opacity 140ms ease;
}

.house-upgrade-slot.armed .house-upgrade-hammer { opacity: 1; }

.house-upgrade-hammer-head { fill: var(--muted); }
.house-upgrade-hammer-haft { fill: #8a6a42; }
.house-upgrade-slot.armed .house-upgrade-hammer-head { fill: var(--accent); }

/* You, standing in your own house. Deliberately reuses .dungeon-floorplan-player wholesale
   (public/js/houseDeed.js builds it with that class) — the house floor plan is the same
   32px-cell top-down space, so the facing rows, the left-mirror, the walk cycle and every
   equipment overlay all behave identically without a second copy of any of it.

   This class only settles the layering. The chest and stations carry no z-index of their
   own, so a figure walking "behind" a station would otherwise be painted over it purely by
   DOM order; sitting one above puts the person in the room rather than under the
   furniture. Pointer events are off so a click always reaches the thing being opened, even
   when the figure is standing right in front of it — which, after walking there, it is.

   Both classes in the selector deliberately: .dungeon-floorplan-player is declared much
   later in this file and would otherwise win on source order at equal specificity, pinning
   this back to its z-index: 2. */
.dungeon-floorplan-player.house-view-player {
  z-index: 3;
  pointer-events: none;
}

/* Everyone else under this roof (services/housePresence.js, drawn by public/js/
   houseDeed.js's hvRenderGuests) — the owner, an invited guest, or a Gamemaster on a
   tour. The same paperdoll as your own figure above, one layer behind it so that two
   people standing on the same square leave YOU on top and legible rather than buried. */
.dungeon-floorplan-player.house-view-guest {
  z-index: 2;
}

/* The nameplate over a guest's head. Positioned rather than stacked in a column like the
   town square's .ts-name, because a house figure is an absolutely-positioned 32px cell
   sprite with no room above it — so the plate hangs off the top and centres itself on the
   figure instead of pushing it down the grid. */
/* Counter-flip, because the facing mirror lives on the FIGURE, not on an inner doll.
   .dungeon-floorplan-player.dfp-face-left applies `scale: -1 1` to the whole container, so
   every child mirrors with it — which is right for the helm, shield and weapon and very
   wrong for the name, which came out backwards the moment a guest walked left. The town
   square dodges this by scaling an inner .ts-doll and leaving its label outside the
   transform; the house figure has no such inner element, so the label undoes the flip
   itself. Two negatives, one readable name. */
.house-view-guest.dfp-face-left .house-view-guest-name {
  scale: -1 1;
}

.house-view-guest-name {
  position: absolute;
  /* Centred by a WIDE box with a fixed pixel offset, not by translateX(-50%) and not by
     stretching to the 32px cell. Both of those were tried and both were wrong:

     translateX(-50%) resolves in the parent's coordinate system, and that parent is mirrored
     (scale: -1 1) when the figure walks left — so "shift left by half my width" became
     "shift right by half my width", putting the name a full label-width away from the person
     it belongs to. That was the "nowhere near the player" report.

     Stretching left:0/right:0 across the cell and centring the text fixed the mirror but not
     the centring: a nowrap line wider than its 32px box does not overflow symmetrically, it
     runs off the end side, so every name sat right of its owner by (textWidth - 32) / 2.

     A box far wider than any name, offset by a fixed -120px, centres by layout alone. No
     percentage, no transform, so nothing for the parent's mirror to reinterpret — and the
     counter-flip above now mirrors about the box's own centre, which IS the figure's centre,
     so it cannot move anything. */
  left: 50%;
  width: 240px;
  margin-left: -120px;
  text-align: center;
  bottom: 100%;
  margin-bottom: 2px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  /* The same classic UO innocent-blue as the town square's nameplate — one shard, one
     colour for "another player's name". */
  color: #6f9fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
  pointer-events: none;
  /* Over the figure's own back-slung weapon, so a long blade never crosses the name. */
  z-index: 4;
}

/* Another player working the same place (services/scenePresence.js) — the dungeon floor plan
   (public/js/dungeonFloorplan.js's dfpRenderPeers) and the forest/mine gathering scenes
   (public/js/gatherScene.js's gsRenderPeers) both draw them with this. The house-guest
   treatment above, applied to the two maps everybody walks. Same paperdoll, same nameplate
   rules, and the same reason for sitting one layer behind: two figures on one tile should
   leave YOU on top and legible.

   The z-index below only actually applies on the dungeon plan. A gathering scene depth-sorts
   every sprite by its foot row with an inline z-index (gsApplyDepth), which wins over this —
   correctly, since there a stranger standing behind a tree has to be drawn behind it.

   Slightly translucent, which the house guest is not, and that difference is deliberate:
   a dungeon plan is a crowded surface where the figure that matters is your own — the one
   with a fight timer over it. Ghosting the strangers keeps the eye where combat is without
   hiding that somebody else is working the floor. */
.dungeon-floorplan-player.dfp-peer {
  z-index: 1;
  opacity: 0.85;
}

/* --- A ghost -------------------------------------------------------------------------
   Your own figure while you are dead, and any dead player anyone else can see. .dfp-dead
   is already toggled onto peers by all three renderers (the gather scenes, the dungeon
   plan and the house grounds) and had no rule behind it until now — a dead stranger was
   drawn exactly like a living one.

   The own-figure half is spelled as an exclusion because .dungeon-floorplan-player is the
   shared paperdoll class rather than a "this is me" one: town vendors (.gs-vendor), peers
   (.dfp-peer) and house guests (.house-view-guest) all wear it too, and the figure left
   over is yours. Hung off body.is-dead rather than .game-world.is-dead so it also reaches
   the house view, which lives in a floated panel outside that wrapper.

   IT HAS TO SIT BELOW .dungeon-floorplan-player.dfp-peer, not above it: that rule holds
   peers at 0.85 with the same specificity this one has, so ordered the other way round a
   dead stranger would keep a living stranger's solidity and only your own figure would
   ever fade.

   Brighter than life on purpose. Your own ghost is drawn UNDER the pall
   (.death-scrim), so a figure lit at 1.0 sinks into the gloom with everything else. The
   blue only ever reaches somebody ELSE's screen — on yours the pall greys it out, leaving
   the fade, which is the half that has to read. */
body.is-dead .dungeon-floorplan-player:not(.dfp-peer):not(.gs-vendor):not(.house-view-guest),
.dungeon-floorplan-player.dfp-dead {
  opacity: 0.45;
  filter: grayscale(0.85) brightness(1.5) drop-shadow(0 0 3px rgba(150, 200, 255, 0.65));
}

/* The monster another player is fighting (public/js/dungeonFloorplan.js's dfpSyncPeerMob).
   Ghosted to match the peer it belongs to, so somebody else's fight recedes behind your own
   the way their figure does — and one layer under the real roster mobs, because the one thing
   that must never happen is mistaking a stranger's opponent for a monster you can attack. */
.dungeon-floorplan-monster.dfp-peer-mob {
  opacity: 0.85;
  z-index: 0;
}

/* Counter-flip the label, for exactly the reason .house-view-guest-name documents at length
   above: the facing mirror lives on the FIGURE, so every child mirrors with it, which is
   right for a shield and very wrong for a name. */
.dfp-peer.dfp-face-left .dfp-peer-name {
  scale: -1 1;
}

/* Same wide-box centring trick as the house nameplate — see its own comment for why neither
   translateX(-50%) nor a stretched cell-width box works on a mirrored parent. */
.dfp-peer-name {
  position: absolute;
  left: 50%;
  width: 240px;
  margin-left: -120px;
  text-align: center;
  bottom: 100%;
  margin-bottom: 2px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  /* The classic UO innocent-blue every other player's name on this shard already wears. */
  color: #6f9fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
  pointer-events: none;
  z-index: 4;
}

.house-upgrade-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.house-upgrade-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin-left: auto;
}

/* The material/Build panel, shown only once a deed is committed. Sits inline in the
   House View rather than in a gump — see views/partials/worldMap.ejs for why. */
.house-upgrade-panel {
  margin-top: 8px;
  padding: 10px 12px 12px;
  border: 1px solid var(--edge);
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.16);
}

.house-upgrade-target {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin-bottom: 10px;
  line-height: 1.5;
}

/* A material whose requirement is fully met — the same green the construction site's own
   status line uses, so "done" reads as done at a glance down a column of numbers. */
.house-upgrade-met { color: #7fd1a8; }

/* Tighter than the construction gump's own drop zone: this one sits in a panel, not a
   full-width window, so it inherits the look but not the generous margins. */
.house-upgrade-drop-zone {
  margin: 12px 0 0;
  padding: 14px 8px;
}

.house-upgrade-countdown { margin-top: 12px; }

/* The Build button — the one control first-time construction doesn't have, so it gets
   the accent treatment rather than blending into the panel. */
.house-upgrade-build-btn {
  display: block;
  width: 100%;
  margin: 12px 0 0;
  padding: 9px 0;
  font-size: calc(13px * var(--ui-font-scale, 1));
  border-radius: 5px;
  border: 1px solid var(--accent);
  background: rgba(var(--edge-rgb), 0.2);
  color: var(--ink);
  cursor: pointer;
}

.house-upgrade-build-btn:hover:not(:disabled) { background: rgba(var(--edge-rgb), 0.32); }

.house-upgrade-build-btn:disabled {
  opacity: 0.4;
  border-color: var(--edge);
  cursor: default;
}

/* The Guild gump — reuses the skills-gump-header/close chrome but needs its own
   wider body to fit a member roster with per-row management buttons, or a charter's
   signature list, or the open-charter browser, depending on which of the four views
   currently applies (see public/js/guilds.js). */
/* See the comment above .skills-gump — positioning now comes from initGumpWindow(). */
.guild-gump {
  /* A Guildmaster's member row can show rank badge + name + up to three action buttons
     (Promote/Demote, Make GM, Kick) at once — 380px left barely 350px of usable content
     width after the gump body's own padding, forcing that row to wrap awkwardly. 460px
     matches this game's other button/row-dense gumps (.admin-gump at 440px, .trade-gump/
     .crafting-gump at 420px). Bumped to 520px — the "Start a Guild" row (Guild name + TAG
     inputs + Draft Charter button) still wrapped the button's own label at 460px. */
  width: 520px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}

.guild-gold-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-weight: normal;
  margin-left: 8px;
}

.guild-gump-body {
  max-height: 70vh;
  overflow-y: auto;
  padding: 14px;
}

.guild-gump-body h3 {
  margin: 14px 0 6px;
  color: var(--accent);
  font-size: calc(15px * var(--ui-font-scale, 1));
}

.guild-gump-body h3:first-child { margin-top: 0; }

.guild-hint {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin: 0 0 10px;
}

.guild-invite-banner {
  background: rgba(var(--edge-rgb), 0.12);
  border: 1px solid rgba(var(--edge-rgb), 0.5);
  border-radius: 4px;
  padding: 10px;
  margin-bottom: 14px;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.guild-invite-banner p { margin: 0 0 8px; }

.guild-member-list, .guild-charter-signers, .guild-open-charter-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 10px;
}

.guild-member-row, .guild-open-charter-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.guild-charter-signer-row {
  padding: 5px 8px;
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.guild-member-rank {
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  padding: 2px 6px;
  border-radius: 3px;
  flex-shrink: 0;
}

.guild-rank-guildmaster { background: var(--accent); color: var(--on-accent); }
.guild-rank-officer { background: #6ec6ff; color: var(--on-accent); }
.guild-rank-member { background: rgba(255, 255, 255, 0.08); color: var(--ink); }

.guild-member-name {
  flex: 1;
}

.guild-member-actions {
  display: flex;
  gap: 4px;
}

.guild-open-charter-row span { flex: 1; }

.guild-action-btn {
  padding: 3px 8px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.guild-action-btn.guild-action-danger, .guild-action-danger {
  background: #e25c4f;
  color: #f2e2e2;
}

.guild-sign-btn {
  padding: 4px 10px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.guild-inline-form {
  display: flex;
  gap: 8px;
  margin-bottom: 10px;
}

.guild-inline-form input {
  flex: 1;
  padding: 6px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  font-family: inherit;
  border-radius: 3px;
  min-width: 0; /* lets flex:1 actually shrink below its content width instead of overflowing */
}

/* The charter form's TAG input (views/partials/guildGump.ejs) is capped at a handful of
   characters — sharing an equal flex:1 with the much longer Guild name input left it
   needlessly wide and starved the Draft Charter button of room, forcing its label to
   wrap onto two lines. */
#guild-charter-tag {
  flex: 0 0 70px;
}

.guild-inline-form button {
  padding: 6px 14px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  white-space: nowrap;
  flex-shrink: 0;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.guild-management-buttons {
  display: flex;
  gap: 8px;
}

.guild-management-buttons button {
  padding: 6px 12px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.guild-management-buttons button.guild-action-danger {
  background: #e25c4f;
  color: #f2e2e2;
}

/* The [TAG] prefix on the nameplate — the classic UO guild-chat green, matching
   .chat-entry.chat-channel-guild so a tag reads the same wherever it shows up. */
.nameplate-guild-tag {
  color: #6fcf6f;
  font-weight: bold;
  margin-right: 4px;
}

/* The Party gump (views/partials/partyGump.ejs) reuses every .guild-* chrome class
   above verbatim (they're already generic gump/list/button layout rules, nothing
   guild-specific about them) — only the live HP bar per roster row is genuinely new. */
.party-member-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-bottom: 10px;
}

.party-member-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.party-member-leader-badge {
  color: var(--accent);
  font-size: calc(13px * var(--ui-font-scale, 1));
  flex-shrink: 0;
}

.party-member-name {
  flex: 0 0 auto;
  min-width: 90px;
}

.party-member-hp-bar {
  position: relative;
  flex: 1;
  height: 14px;
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 3px;
  overflow: hidden;
}

.party-member-hp-fill {
  height: 100%;
  background: linear-gradient(180deg, #6fcf6f, #3f9b4a);
  transition: width 0.25s ease-out;
}

.party-member-hp-value {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--ink);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}

.party-member-actions {
  display: flex;
  gap: 4px;
  flex-shrink: 0;
}

.world-map-panel {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 16px;
  /* Wide enough that "Traveling to <longest destination name>… (M:SS)" never teeters
     right on the wrap boundary — at 236px it could flip between 1 and 2 lines as the
     countdown's digit width shifted tick to tick, shoving the Cancel Travel button
     below it up and down every second (read as "flickering"). */
  width: 270px;
}

/* Town Square social hub (public/js/townSquare.js) — the panel opens out wider while
   standing in a town so the bank + crowd around it get real room to breathe. Toggled
   live by townSquare.js on travel; server-rendered on first paint (worldMap.ejs). */
.world-map-panel.town-hub-wide {
  width: 430px;
}

/* The plaza: a tiled pixel-grass lawn (tools/generateSprites.js's town_grass.png,
   seamless 64px tile) under a soft vignette for depth — the bank green, like the
   classic West Britain Bank lawn. */
.town-square {
  margin: 10px 0;
}

.town-square-plaza {
  position: relative;
  /* Tall enough that every slot's full sprite extent (name + doll + bubble headroom)
     fits inside — playtest showed players clipping half off the top edge at 330px. */
  height: 380px;
  /* The ground itself is clickable: click to walk there (public/js/townSquare.js). */
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  border: 1px solid rgba(var(--edge-rgb), 0.6);
  border-radius: 6px;
  overflow: hidden;
  background-color: #3f6b35;
  /* Soft sunlit sweeps layered between the vignette and the tile — broad radial
     washes of light/shade so the lawn's brightness drifts across the plaza instead of
     repeating with the 96px tile (which carries its own smaller shade patches). */
  background-image:
    linear-gradient(180deg, rgba(0, 0, 0, 0.22), transparent 28%, transparent 72%, rgba(0, 0, 0, 0.3)),
    radial-gradient(ellipse 55% 40% at 30% 75%, rgba(255, 244, 190, 0.07), transparent 70%),
    radial-gradient(ellipse 50% 45% at 78% 30%, rgba(255, 244, 190, 0.05), transparent 70%),
    radial-gradient(ellipse 45% 40% at 68% 88%, rgba(10, 30, 8, 0.14), transparent 70%),
    url('/assets/sprites/town_grass.png');
  /* The tile is 96px now (tools/generateSprites.js's drawTownGrassTile) — keep in sync. */
  background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 96px 96px;
  image-rendering: pixelated;
}

/* Fixed plaza dressing (trees/stalls/lamp/crates — worldMap.ejs sets each one's own
   left/top/width/z-index inline, same feet-anchor convention as .ts-player). */
.ts-deco {
  position: absolute;
  transform: translate(-50%, -100%);
  image-rendering: pixelated;
  pointer-events: none;
  filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.35));
}

/* Mirrored placement. NOT the independent `scale` property the doll flip uses — that
   composes BEFORE the transform property, so the anchoring translate(-50%) would run
   in flipped space and shove the tree a full width off its anchor. scaleX at the END
   of the transform chain flips in place first, then translates normally. */
.ts-deco.ts-deco-flip {
  transform: translate(-50%, -100%) scaleX(-1);
}

/* Gentle wind sway for the tree ring — the independent `rotate` property, NOT a
   transform keyframe, so it composes cleanly under BOTH .ts-deco's anchoring translate
   and .ts-deco-flip's scaleX (independent properties resolve before the transform
   value; translation is unaffected by transform-origin). Pivot at the trunk base so
   the canopy leans while the roots stay planted. Each tree sets its own inline
   animation-duration/-delay (worldMap.ejs) so the stand never sways in unison. */
.ts-sway {
  transform-origin: 50% 96%;
  animation: ts-tree-sway 7s ease-in-out infinite;
}

@keyframes ts-tree-sway {
  0%, 100% { rotate: -0.8deg; }
  50% { rotate: 0.8deg; }
}

/* Flat ground decals (the worn path) — centered on their anchor rather than
   feet-anchored, no drop-shadow (a shadow under a flat patch of dirt reads as a
   floating sticker), always under every standing thing (worldMap.ejs gives it z 4). */
.ts-ground-decal {
  position: absolute;
  transform: translate(-50%, -50%);
  image-rendering: pixelated;
  pointer-events: none;
  /* Soften the pixel dirt into the pixel grass rather than sitting on it. */
  opacity: 0.9;
}

/* The plaza fountain — a 2-frame sheet (tools/generateSprites.js's drawTownFountain,
   68px frames side by side) stepped for the water shimmer. Clickable (coin toss —
   public/js/townSquare.js), so unlike the .ts-deco props it keeps pointer events and
   the point cursor. */
.ts-fountain {
  position: absolute;
  transform: translate(-50%, -100%);
  width: 68px;
  height: 60px;
  background-image: url('/assets/sprites/town_fountain.png');
  background-size: 136px 60px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  animation: ts-fountain-water 0.9s steps(2) infinite;
  filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.35));
}

@keyframes ts-fountain-water {
  from { background-position-x: 0; }
  to { background-position-x: -136px; }
}

/* A tossed coin arcing into the basin (spawned per click by townSquare.js), then a
   little expanding splash ring where it lands. */
.ts-coin {
  position: absolute;
  left: 50%;
  top: 34px;
  width: 3px;
  height: 3px;
  background: #ffd75e;
  border-radius: 50%;
  box-shadow: 0 0 3px rgba(255, 215, 94, 0.8);
  pointer-events: none;
  animation: ts-coin-toss 0.6s ease-in forwards;
}

@keyframes ts-coin-toss {
  0% { transform: translate(-16px, -30px); opacity: 1; }
  45% { transform: translate(-5px, -38px); }
  100% { transform: translate(3px, 6px); opacity: 0; }
}

.ts-splash {
  position: absolute;
  left: 50%;
  top: 42px;
  width: 14px;
  height: 5px;
  margin-left: -7px;
  border: 1px solid rgba(191, 228, 242, 0.9);
  border-radius: 50%;
  pointer-events: none;
  animation: ts-splash-ring 0.45s ease-out forwards;
}

@keyframes ts-splash-ring {
  0% { transform: scale(0.3); opacity: 0.9; }
  100% { transform: scale(1.6); opacity: 0; }
}

/* Butterflies — 2-frame flap sheet (12px frames), drawn top-down symmetric so no
   facing flip is needed; each one gets a random hue-rotate inline (townSquare.js) to
   tint the shared orange sprite. They fly ABOVE everything (z 300) — they're airborne,
   nothing on the lawn should occlude them. */
.ts-butterfly {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 12px;
  height: 12px;
  background-image: url('/assets/sprites/town_butterfly.png');
  background-size: 24px 12px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: 300;
  animation: ts-flap 0.4s steps(2) infinite;
}

@keyframes ts-flap {
  from { background-position-x: 0; }
  to { background-position-x: -24px; }
}

/* A falling leaf (or blossom petal — colour set inline per source tree by
   townSquare.js's tsSpawnLeaf): tumbles down ~92px with side-to-side drift, fading
   out. Removed by JS after the animation ends. */
.ts-leaf {
  position: absolute;
  width: 5px;
  height: 7px;
  border-radius: 70% 0 70% 0;
  pointer-events: none;
  animation: ts-leaf-fall 3.9s linear forwards;
}

@keyframes ts-leaf-fall {
  0% { transform: translate(0, 0) rotate(0deg); opacity: 0.95; }
  25% { transform: translate(9px, 23px) rotate(80deg); }
  50% { transform: translate(-7px, 46px) rotate(160deg); }
  75% { transform: translate(8px, 70px) rotate(240deg); opacity: 0.9; }
  100% { transform: translate(-4px, 92px) rotate(320deg); opacity: 0; }
}

/* The town cat — 2-frame sheet (24px frames: standing profile / sitting), feet-anchored
   like the dolls. Flip via the independent `scale` property (same idiom as
   .ts-face-left on dolls — composes fine under the anchoring transform since the box
   is symmetric around its own center). Petting it is encouraged. */
.ts-cat {
  position: absolute;
  transform: translate(-50%, -100%);
  width: 24px;
  height: 20px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The sprite is an inner layer purely so the facing mirror below has somewhere to live
   that ISN'T the root. It used to be the root's own background with `scale: -1 1` applied
   there, which mirrored the cat's entire subtree — and the cat is the one plaza sprite that
   parents overlays (its pet bubble and heart). That painted the bubble's text backwards and
   also threw the bubble a full width off-centre, since `left` is layout (unmirrored) while
   the bubble's own translateX(-50%) centering is paint (mirrored), so the two stopped
   cancelling. Scoping the flip to a child is exactly what .ts-player already does with
   .ts-doll; doing the same here fixes the glyphs, the centring and the clamp direction at
   once rather than counter-transforming each symptom. */
.ts-cat-sprite {
  width: 100%;
  height: 100%;
  background-image: url('/assets/sprites/town_cat.png');
  background-size: 48px 20px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
}

.ts-cat::before {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -1px;
  transform: translateX(-50%);
  width: 18px;
  height: 5px;
  background: radial-gradient(ellipse, rgba(0, 0, 0, 0.35) 0%, rgba(0, 0, 0, 0.14) 55%, transparent 72%);
  pointer-events: none;
}

/* Both keyed off classes still toggled on the ROOT (public/js/townSquare.js), so only the
   sprite layer moves/mirrors — the bubble and heart the root also parents stay upright. */
.ts-cat.ts-cat-sit .ts-cat-sprite { background-position-x: -24px; }
.ts-cat.ts-face-left .ts-cat-sprite { scale: -1 1; }

/* Walk bob — the independent `translate` property so it never fights the anchoring
   transform or the left/top stroll transition. */
.ts-cat.ts-cat-walking {
  animation: ts-cat-bob 0.3s steps(2) infinite;
}

@keyframes ts-cat-bob {
  0%, 100% { translate: 0 0; }
  50% { translate: 0 -1px; }
}

/* A tamed companion trotting at its owner's side (Animal Taming — public/js/
   townSquare.js's tsEnsurePet/tsWalkPet). Same root/inner-sprite split and feet-anchor
   as the town cat above; the 2-frame pet_<key>.png sheet is a walk cycle rather than
   the cat's stand/sit, so walking steps between frames AND bobs. */
.ts-pet {
  position: absolute;
  transform: translate(-50%, -100%);
  width: 24px;
  height: 20px;
  pointer-events: none; /* never intercepts a plaza click — it's an ornament on the owner */
}

.ts-pet-sprite {
  width: 100%;
  height: 100%;
  background-size: 48px 20px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  /* Same level-growth treatment as .dfp-pet — one shared variable, one shared formula
     (public/js/dungeonFloorplan.js's petScaleForLevel), so the same companion is the
     same size in the plaza as on the floorplan. */
  transform-origin: 50% 100%;
  scale: var(--pet-scale, 1);
}

/* Composes the flip with the level scale — see .dfp-pet-left's own comment. */
.ts-pet.ts-face-left .ts-pet-sprite { scale: calc(-1 * var(--pet-scale, 1)) var(--pet-scale, 1); }

/* A bonded (max-level) companion's standout hue — one shared filter across the
   floorplan follower, the plaza follower and the stable roster icon, so it reads as the
   same creature everywhere. A fixed rotation gives each species its own distinct shifted
   coloration (the classic rare-hue treatment) rather than one flat tint. */
.pet-bonded-hue { filter: hue-rotate(165deg) saturate(1.5) brightness(1.1); }

.ts-pet.ts-pet-walking .ts-pet-sprite {
  animation: ts-pet-step 0.35s steps(1) infinite;
}

.ts-pet.ts-pet-walking {
  animation: ts-cat-bob 0.35s steps(2) infinite;
}

@keyframes ts-pet-step {
  0%, 100% { background-position-x: 0; }
  50% { background-position-x: -24px; }
}

/* The petting heart — floats up off the cat and fades. */
.ts-heart {
  position: absolute;
  left: 50%;
  top: -4px;
  color: #ff6f9e;
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
  pointer-events: none;
  animation: ts-heart-float 0.9s ease-out forwards;
}

@keyframes ts-heart-float {
  0% { transform: translate(-50%, 0); opacity: 1; }
  100% { transform: translate(-50%, -16px); opacity: 0; }
}

/* The bank, centered — z-index sits between the back-row players (behind the building)
   and everyone standing in front of it (public/js/townSquare.js's painter's-order
   z-index of 100 + slot y%: back rows land below 135, front rows above). */
.town-square-bank {
  position: absolute;
  left: 50%;
  top: 9%;
  transform: translateX(-50%);
  /* Zoomed out twice now (260 -> 200 -> 180) so the crowd, their chat bubbles, and the
     plaza dressing get real room — the building anchors the scene, it shouldn't fill it. */
  width: 180px;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: 135;
  /* The ONLY shadow the building gets (the sprite bakes none in) — tight to the
     silhouette; the old baked-ellipse + big CSS blur pair read as a bad double shadow. */
  filter: drop-shadow(0 3px 3px rgba(0, 0, 0, 0.45));
}

/* One standing character: name above, doll below, anchored by their feet (translate
   -50%/-100% off the slot's own left/top %). pointer-events ON (they used to be
   none) — right-clicking a character opens the Trade/Inspect menu
   (public/js/townSquare.js); nothing interactive sits underneath a sprite for this
   to block. */
.ts-player {
  position: absolute;
  transform: translate(-50%, -100%);
  display: flex;
  flex-direction: column;
  align-items: center;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.ts-name {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  /* The classic UO innocent-blue nameplate. */
  color: #6f9fff;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
  margin-bottom: 1px;
  /* Above the back-slung weapon (z 0) and the doll (z 1) — a long blade tip must never
     poke through the nameplate. */
  position: relative;
  z-index: 2;
}

.ts-name.ts-self {
  color: #a9c6ff;
}

/* The worn title (config/titles.js), stacked directly above the nameplate. Deliberately
   smaller and warmer-toned than the blue name so the two read as a hierarchy — the name is the
   identity, the title is the decoration — rather than competing for the same glance. Same
   z-index as .ts-name so a long back-slung weapon can't poke through it either. */
.ts-title {
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #e0c274;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
  letter-spacing: 0.2px;
  margin-bottom: 1px;
  position: relative;
  z-index: 2;
}

/* The inspected character's title, under their name in the Inspect gump header. */
.inspect-title {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #e0c274;
  text-align: center;
  margin-bottom: 8px;
}

/* --- Titles gump (views/partials/titlesGump.ejs, public/js/titles.js) --- */
.titles-gump {
  width: 340px;
  max-width: 94vw;
}
.titles-gump-body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 12px;
  max-height: 60vh;
  overflow-y: auto;
}
.titles-current-row {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin-bottom: 6px;
}
#titles-current-label {
  color: #e0c274;
  font-weight: bold;
}
.titles-group-heading {
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--muted);
  margin: 10px 0 4px;
}
/* Each earned title is a real <button> — keyboard-reachable, and it already reads as
   clickable without extra affordance. */
.titles-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  padding: 6px 8px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  cursor: pointer;
  text-align: left;
  font: inherit;
  /* Same trap as .tool-bag-launcher-row: `font: inherit` on a <button> does not carry
     colour, so without this it renders in the browser's default black. Not yet reported
     here only because every child of this row happens to set its own colour — one that
     didn't would have gone invisible too. */
  color: var(--ink);
}
.titles-row:hover {
  background: rgba(255, 255, 255, 0.07);
}
.titles-row-active {
  border-color: #e0c274;
  background: rgba(224, 194, 116, 0.12);
}
.titles-row-label {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #e6e6e6;
}
.titles-row-active .titles-row-label {
  color: #e0c274;
}
.titles-row-source {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  white-space: nowrap;
}
.titles-empty {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  padding: 12px 4px;
  text-align: center;
}

/* The paperdoll at 1.25x the floorplan's 32x48 frame (was 1.5x — zoomed out a notch so
   chat bubbles get headroom inside the plaza's clipping frame). Same sheet layout as
   .dungeon-floorplan-player (6 frames of 32px across, THREE facing rows down — see its
   sheet contract comment), just scaled: 240x180 background covers the whole 192x144
   sheet, so each facing row is 60px tall here. Strolling dolls face the way they walk
   via the same two-axis system as the floorplan (X = stride frame from the animation,
   Y = facing row from .ts-face-side/.ts-face-away below — longhand only, a shorthand
   background-position anywhere in the animated rules would silently reset Y); a doll
   standing still always returns to the front row. Deliberately NO drop-shadow filter
   here: a whole-doll drop-shadow re-casts every child too, so the rotated weapon icon
   got a second ghost silhouette floating offset from the real one ("the shadow doesn't
   match where the weapon is") — grounding comes from the ellipse under the feet below
   instead. */
.ts-doll {
  position: relative;
  width: 40px;
  height: 60px;
  background-size: 240px 180px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  z-index: 1;
}

/* Ground contact shadow at the feet — the classic sprite ellipse, symmetric so the
   .ts-face-left flip changes nothing. */
.ts-doll::before {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -2px;
  transform: translateX(-50%);
  width: 34px;
  height: 8px;
  background: radial-gradient(ellipse, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.18) 55%, transparent 72%);
  pointer-events: none;
}

/* Facing: same independent-scale flip idiom as .dfp-face-left. The class lives on the
   .ts-player container (public/js/townSquare.js) but the flip is applied to the doll —
   scaling the doll mirrors all its children (helm/shield/weapon) with it, while the
   name label and bubble above stay readable, unmirrored. */
.ts-player.ts-face-left .ts-doll {
  scale: -1 1;
}

/* Facing rows while strolling (tsWalkTo sets these from the walk vector, and clears
   them on arrival so standing dolls always face the viewer): the plaza's 1.25x scale
   makes each row 60px. Doll layers (helm/shield/cloak columns) shift in lockstep, and
   the held weapon hides facing away — you can't see a carried blade from behind.
   Only side rows ever combine with the .ts-face-left mirror (dungeon rule: front/back
   never flip, so a slung shield never swaps sides). */
.ts-player.ts-face-side .ts-doll { background-position-y: -60px; }
.ts-player.ts-face-away .ts-doll { background-position-y: -120px; }
.ts-player.ts-face-side .ts-doll .ts-doll-layer { background-position-y: -60px; }
.ts-player.ts-face-away .ts-doll .ts-doll-layer { background-position-y: -120px; }
.ts-player.ts-face-away .ts-weapon { display: none; }

/* Stroll cycle (public/js/townSquare.js's wander loop) — alternates the sheet's two
   stride frames (frames 1/2, at the 1.25x scale's -40/-80px offsets) while a character
   walks between spots; steps(1) keeps it chunky pixel-frame, no tweening. LONGHAND X
   only, same load-bearing split as the floorplan's dfp-run-cycle: a shorthand here
   would reset background-position-y every frame and snap side/back strollers to the
   front row (the facing classes above own Y). */
@keyframes ts-stride {
  0%, 100% { background-position-x: -40px; }
  50% { background-position-x: -80px; }
}

.ts-doll.ts-walking {
  animation: ts-stride 0.45s steps(1) infinite;
}

/* Helm/shield overlays — background divs (public/js/townSquare.js builds them; NOT
   <img>s, which would squash the 32x144 three-row column sheet into the doll box) —
   sized to show only the FRONT cell at the plaza's 1.25x: 40x180 covers the whole
   column, position 0 0 crops to the top (front) cell. */
.ts-doll .ts-doll-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  background-size: 40px 180px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  pointer-events: none;
}

/* The plaza's twin of .dfp-mount-layer — same oversized 48x56 cell and two-frame flap at
   this view's 1.25x scale: sheet 96x168 -> 120x210, cell -> 60x70 overhanging the 40x60
   doll box, one 48px frame -> -60px, facing rows 56px -> -70px. Longhand X only, exactly
   as the stroll cycle above documents, and the mount's own facing overrides below exist
   for the same row-height reason as the floorplan's. */
.ts-doll .ts-mount-layer {
  width: 60px;
  height: 70px;
  left: -10px;
  top: -5px;
  background-size: 240px 210px;
  animation: ts-mount-flap 760ms steps(1) infinite;
}

.ts-player.ts-face-side .ts-doll .ts-mount-layer { background-position-y: -70px; }
.ts-player.ts-face-away .ts-doll .ts-mount-layer { background-position-y: -140px; }

@keyframes ts-mount-flap {
  0%, 100% { background-position-x: 0; }
  50% { background-position-x: -60px; }
}

/* The plaza's walking gate — .ts-walking lives on the DOLL itself (see the stroll cycle
   above), so the child selector rides it. Same walking pair, at this view's 1.25x. */
.ts-doll.ts-walking .ts-mount-layer {
  animation-name: ts-mount-stride;
  animation-duration: 520ms;
}

@keyframes ts-mount-stride {
  0%, 100% { background-position-x: -120px; }
  50% { background-position-x: -180px; }
}

/* The wielded weapon — same grip-lock geometry and near-upright -38deg carry as
   .dungeon-floorplan-weapon (see its comment for the angle's reasoning), every offset
   multiplied by the doll's 1.25x scale (hand at (30.6, 36.25) of the 40x60 frame:
   right = 40 - 30.6 - 0.75*S, bottom = 60 - 36.25 - 0.25*S). No drop-shadow: a filter
   shadow is cast in the element's own pre-rotation space, so under a rotated carry it
   pointed a different direction than every other shadow in the scene. */
.ts-weapon {
  position: absolute;
  right: -20.5px;
  bottom: 13.75px;
  width: 40px;
  height: 40px;
  transform: rotate(-38deg);
  transform-origin: 25% 75%;
  image-rendering: pixelated;
  pointer-events: none;
}

.ts-weapon.is-bow {
  transform: rotate(180deg);
  transform-origin: 38% 50%;
  right: -15px;
  bottom: 11.5px;
  width: 32px;
  height: 32px;
}

.ts-weapon.is-crossbow {
  transform: rotate(90deg);
  transform-origin: 50% 70%;
  right: -7.5px;
  bottom: 13.5px;
  width: 32px;
  height: 32px;
}

/* An Inscribed Spellbook (config/inscription.js) is a weapon, so it comes through this
   overlay — but it's the only one whose icon is a solid upright BLOCK rather than a thin
   diagonal blade. At the melee 40px it filled most of the doll's torso (reported as the
   spellbooks looking too big), because a sword only ever uses its box's diagonal while the
   book's cover occupies 28x34 of the 44px canvas outright. 26px brings the rendered cover
   to roughly 17x20 against a 40x60 doll — a book carried at the hip rather than a shield.
   Held, not swung: the grip is the SPINE (drawn down the icon's left edge at x 8-13, so
   ~24%/50% of the box, not a blade's 25%/75%), and the carry is near-upright with a slight
   tilt for life instead of the melee -38deg swing angle, which laid the book flat across
   the body. Offsets lock that spine to the resting hand at (30.6, 36.25) of the 40x60
   frame by this file's own rule: left = 30.6 - 0.24*26 = 24.36 -> right = 40 - 24.36 - 26;
   top = 36.25 - 0.5*26 = 23.25 -> bottom = 60 - 23.25 - 26. */
.ts-weapon.is-spellbook {
  /* Town Square twin of .dungeon-floorplan-weapon.is-spellbook (see it for the centre-
     anchor reasoning), every value multiplied by this doll's own 1.25x scale: 16 -> 20,
     bottom 11 -> 13.75. */
  transform: rotate(-10deg);
  transform-origin: 50% 50%;
  right: 0;
  bottom: 13.75px;
  width: 20px;
  height: 20px;
}

/* The Inspect gump (views/partials/inspectGump.ejs) — right-click a Town Square
   character -> Inspect. A compact read-only sheet of their gear + trained skills. */
.inspect-gump {
  width: 300px;
}

.inspect-gump-body {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px 14px 14px;
  max-height: 440px;
  overflow-y: auto;
}

.inspect-section-label {
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 6px;
}

.inspect-item-row {
  display: flex;
  align-items: center;
  /* Wraps so the maker's mark below has somewhere to go. Nothing else in the row can wrap —
     icon and slot are `flex: none` and the name is short — so this only ever costs a second
     line when the crafter's name genuinely does not fit beside it. */
  flex-wrap: wrap;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.inspect-item-row img {
  width: 28px;
  height: 28px;
  flex: none;
  image-rendering: pixelated;
}

.inspect-item-slot {
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  min-width: 54px;
  flex: none;
}

/* The maker's mark on an inspected piece (public/js/townSquare.js) — only present on gear
   crafted at GM skill. Pushed to the right so a long item name never collides with it.

   It used to be allowed to shrink past its own text and ellipsise, on the reasoning that the
   item name is the primary fact. That reasoning was backwards: the maker's mark is the whole
   reason a player inspects somebody's gear, and "Crafted by Sandiiiiiiii..." tells you less
   than the name it truncated. It now keeps its full width and drops to the next line when
   there isn't room beside the name — hence flex-wrap on the row above.

   `min-width: 0` is the line that mattered. A flex item's default min-width is its
   min-content, which for `white-space: nowrap` text is the entire string — so the nowrap was
   never the clipper. Overriding that floor to 0 is what let it shrink below its own text and
   handed the ellipsis something to do. */
.inspect-item-crafter {
  margin-left: auto;
  padding-left: 10px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--accent);
  white-space: nowrap;
}

.inspect-gump-skills {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3px 16px;
}

.inspect-skill-row {
  display: flex;
  justify-content: space-between;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
}

/* Item Info — the /info GM command's panel (views/partials/itemInfoGump.ejs,
   public/js/adminItemInfo.js). Wider than .inspect-gump because its right-hand column
   carries raw Mongo ids, which must not wrap mid-string to stay copy-pasteable. */
.item-info-gump {
  width: 340px;
}

.item-info-gump-body {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 14px 14px;
  max-height: 520px;
  overflow-y: auto;
}

.item-info-identity {
  display: flex;
  align-items: center;
  gap: 10px;
}

.item-info-icon {
  width: 40px;
  height: 40px;
  object-fit: contain;
  image-rendering: pixelated;
}

.item-info-name {
  font-weight: bold;
  color: var(--accent);
}

.item-info-key,
.item-info-row-value {
  font-family: 'Cascadia Code', Consolas, monospace;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.item-info-key {
  color: var(--muted);
}

.item-info-description {
  font-style: italic;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin-top: 2px;
}

.item-info-section-label {
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 10px;
  border-bottom: 1px solid var(--edge);
  padding-bottom: 3px;
}

.item-info-rows {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.item-info-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.item-info-row-label {
  color: var(--muted);
  flex: 0 0 auto;
}

.item-info-row-value {
  color: var(--ink);
  text-align: right;
  /* Ids are long and unbroken; wrapping them anywhere but a real boundary would make
     them unreadable, so they get to overflow into the label's slack instead. */
  word-break: break-all;
}

/* The dyed colour's actual tint (config/dyes.js's colors.base), same swatch idiom the
   Dye Tub Tome's own rows use. */
.item-info-swatch {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 2px;
  border: 1px solid rgba(0, 0, 0, 0.4);
  margin-right: 5px;
  vertical-align: middle;
}

.inspect-skill-value {
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.inspect-empty {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
}

/* GM Owyn (worldMap.ejs) — the shard's one deeply unhelpful Game Master, slumped
   asleep against the back-left tree. Same anchor convention as .ts-player, but
   clickable (the only thing on the plaza that is) — public/js/townSquare.js answers
   each click with a random refusal to help. */
.ts-npc {
  position: absolute;
  transform: translate(-50%, -100%);
  display: flex;
  flex-direction: column;
  align-items: center;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Smaller than the crowd (1x — he's a small, sad little man), the player sheet's
   SEATED frame (frame 5 of the FRONT row; the 3-row sheet's -160px 0 shorthand pins
   both), in the True Black dye robe with the hood_owyn overlay riding the same layer
   contract as helms — tipped over against the trunk. The rotate composes fine with
   .ts-doll's own ::before ground ellipse, which slumps with him. hood_owyn.png stays
   a single 32x48 still (an <img>, not a row sheet) aligned to the head, which is
   pixel-identical across the front row's frames, seated included. The ?v= query is
   this url's own cache-buster — it's the one sprite path in CSS (no assetUrl available
   here), and the 48px->144px sheet change would otherwise render a stale cached 1-row
   file squashed into the new background-size math. Bump it whenever the sheet layout
   changes again. */
.ts-npc .ts-doll {
  width: 32px;
  height: 48px;
  background-image: url('/assets/sprites/players/player_dye_true_black.png?v=rows3');
  background-size: 192px 144px;
  background-position: -160px 0;
  transform: rotate(-12deg);
}

/* The coin Man idly flips while refusing to help (public/js/townSquare.js's
   tsScheduleManCoin). Parented to .ts-npc and NOT to .ts-doll on purpose: the doll
   carries a rotate(-12deg) for his slump, and a child inside it would inherit that and
   toss the coin off at an angle instead of straight up.

   The "spin" is scaleX oscillating through 0 — a coin seen edge-on is a line, so
   squashing the width and letting it grow back reads as a full rotation at 5px far more
   cheaply (and more legibly) than an actual 3D transform would. */
.ts-man-coin {
  position: absolute;
  left: 21px;
  bottom: 14px;
  width: 5px;
  height: 5px;
  background: linear-gradient(#ffe89a, #d9a441);
  border-radius: 50%;
  box-shadow: 0 0 3px rgba(255, 215, 94, 0.75);
  pointer-events: none;
  z-index: 2;
  animation: ts-man-coin-flip 1.25s ease-in-out forwards;
}

/* Up, over, and caught. The vertical easing is deliberately asymmetric — quick off the
   thumb, hanging at the apex, quicker back down — which is what makes it read as thrown
   rather than floated. */
@keyframes ts-man-coin-flip {
  0%   { transform: translateY(0) scaleX(1); opacity: 0; }
  8%   { opacity: 1; }
  20%  { transform: translateY(-9px) scaleX(0.15); }
  32%  { transform: translateY(-14px) scaleX(1); }
  44%  { transform: translateY(-16px) scaleX(0.15); }
  56%  { transform: translateY(-14px) scaleX(1); }
  70%  { transform: translateY(-9px) scaleX(0.15); }
  88%  { transform: translateY(-1px) scaleX(1); opacity: 1; }
  100% { transform: translateY(0) scaleX(1); opacity: 0; }
}

/* Anyone who has asked their machine for less motion gets a still plaza — the coin is
   pure idle flavour and is the first thing that should go. */
@media (prefers-reduced-motion: reduce) {
  .ts-man-coin { display: none; }
}

/* Ember nameplate matching his hood trim, distinct from the crowd's innocent blue. */
.ts-gm-name {
  color: #f0a35e;
  font-size: calc(10px * var(--ui-font-scale, 1));
}

/* A dead character haunts the square as a ghost rather than vanishing. */
.ts-ghost .ts-doll {
  filter: grayscale(1) opacity(0.5);
}

.ts-ghost .ts-name {
  color: #9aa3b5;
}

/* Speech bubble — parchment over the crowd, small tail, pops in and fades out
   (public/js/townSquare.js swaps .ts-bubble-fading on at expiry). */
.ts-bubble {
  position: absolute;
  bottom: calc(100% + 5px);
  left: 50%;
  transform: translateX(-50%);
  background: #f2e8cf;
  color: #2b2416;
  border: 1px solid #2b2416;
  border-radius: 8px;
  padding: 4px 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.3;
  /* Wide enough that a max-length Local line wraps to ~3 lines, keeping the bubble
     short enough to fit above even the topmost (y 40%) slots without poking out of
     the plaza frame — the horizontal clamp in tsAttachBubble handles the edges. */
  max-width: 200px;
  width: max-content;
  text-align: center;
  z-index: 400;
  animation: ts-bubble-pop 0.16s ease-out;
  transition: opacity 0.7s ease;
}

.ts-bubble::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: #2b2416;
}

.ts-bubble.ts-bubble-fading {
  opacity: 0;
}

@keyframes ts-bubble-pop {
  from { transform: translateX(-50%) scale(0.7); opacity: 0; }
  to { transform: translateX(-50%) scale(1); opacity: 1; }
}

.server-reboot-overlay {
  position: fixed;
  inset: 0;
  z-index: 5000;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding-top: 22px;
  /* Starts fully clear, not this darkened — public/js/serverReboot.js fades it in as
     the countdown actually runs down (0 at `seconds` remaining, up to this same 0.85 by
     0), so it reads as a growing urgency rather than an immediate wall the instant a
     reboot is announced. Kept here as the floor/fallback value regardless. */
  background-color: rgba(0, 0, 0, 0);
  /* serverReboot.js only sets a new target backgroundColor once a second (same cadence
     as the countdown text) — this is what actually makes that once-a-second step read
     as a continuous glide instead of a jump-cut, matching the 1s update interval so one
     transition finishes right as the next one begins. */
  transition: background-color 1s linear;
  /* The server keeps running a full live countdown (server:rebootWarning's `seconds`,
     sockets/rebootBroadcast.js) before it actually exits — an ongoing fight, its Flee
     button included, is fully intended to keep working the whole time (only a FRESH
     combat:start/combat:autoBattle is blocked mid-countdown, via isRebootInProgress).
     Without this, this full-viewport notice silently ate every click underneath it
     (including Flee) for the entire wait, so anyone already in danger when a reboot was
     announced had no way to react before the fight resolved on its own. Nothing inside
     this overlay is ever interactive (just a title + message), so letting clicks pass
     straight through loses nothing. */
  pointer-events: none;
}

.server-reboot-panel {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  padding: 32px 48px;
  text-align: center;
  box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
}

.server-reboot-title {
  color: var(--accent);
  font-size: calc(22px * var(--ui-font-scale, 1));
  font-weight: bold;
  margin-bottom: 12px;
}

.server-reboot-message {
  color: var(--ink);
  font-size: calc(16px * var(--ui-font-scale, 1));
}

.current-location {
  padding-bottom: 10px;
  margin-bottom: 10px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.current-location-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
}

.current-location-name {
  font-size: calc(16px * var(--ui-font-scale, 1));
  color: var(--accent);
  font-weight: bold;
}

.current-location-desc {
  font-size: calc(12px * var(--ui-font-scale, 1));
  margin-top: 4px;
  color: var(--ink);
}

/* Town vendor tiles (config/vendorTiles.js, views/partials/worldMap.ejs) — replaces the
   old .vendor-stub/.blacksmith-note/.stable-note plain-text hint lines. Modeled on this
   file's own .market-table-grid (flex tile row) and .equip-slot-box (bordered icon box)
   patterns rather than a new convention. */
.vendor-tile-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 6px;
}

.vendor-tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 52px;
  padding: 4px;
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.08);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* A non-openable tile (none currently — every vendor tile opens a gump now, including
   Blacksmith's own gold repair service alongside its right-click-ore-to-smelt tooltip)
   would show the plain default cursor here instead of the pointer above, so a hover
   never invites a click that does nothing. Keyed off data-openable rather than the
   presence of a `title`, since Blacksmith carries both a tooltip and a click target at
   once. */
.vendor-tile[data-openable="false"] {
  cursor: default;
}

.vendor-tile:hover {
  background: rgba(255, 255, 255, 0.08);
}

.vendor-tile-icon {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
}

.vendor-tile-label {
  font-size: calc(10px * var(--ui-font-scale, 1));
  margin-top: 2px;
  color: var(--muted);
  text-align: center;
}

.dungeon-note {
  margin-top: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  font-weight: bold;
  color: #b361e0;
}

/* Regional karma ("Standing") — same row+bar shape as .bank-weight-row/.bank-weight-bar,
   just scoped to the World Map panel's current-location block instead of the Bank gump. */
.karma-row {
  display: flex;
  justify-content: space-between;
  margin-top: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.karma-row .low-standing, #karma-value.low-standing {
  color: #e25c4f;
  font-weight: bold;
}

.karma-bar {
  height: 6px;
  margin-top: 3px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 3px;
  overflow: hidden;
}

.karma-bar-fill {
  height: 100%;
  background: #7fd1a8;
}

.karma-bar-fill.low-standing {
  background: #e25c4f;
}

/* Shared by the Stable, Provisioner, Mage Shop and Healer Shop rosters. The cap is what
   keeps "vendors open centred" honest: the Provisioner's list runs to ~990px, so an
   uncapped roster made a 1057px-tall window that no viewport can centre — it opened
   pinned to the top edge (gumpWindow.js's centerGumpPosition floors top at 20 so the
   header stays grabbable) with its last rows off the bottom of the screen. Scroll the
   list instead, and the window fits. 180px is the gump chrome (header + gold row) plus
   breathing room above and below. */
.stable-roster {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 0 14px 14px;
  width: 360px;
  max-height: calc(100vh - 180px);
  overflow-y: auto;
}

.stable-mount-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.stable-mount-icon {
  width: 44px;
  height: 44px;
  image-rendering: pixelated;
}

.stable-mount-info {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.stable-mount-name {
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.stable-mount-bonus {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* The pack animals' dungeon caveat on the shop row (views/partials/stableGump.ejs) — the
   same soft warning red .tooltip-warning uses for the identical sentence on the animal's
   own tooltip, so the rule reads as one rule wherever a player meets it. display:block so
   it drops under the capacity line rather than running on from it. */
.stable-mount-warning {
  display: block;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #d97b7b;
}

/* "(incl. bank)" beside a town vendor's gold total — Stable Master, Provisioner, Mage
   Shop, Healer Shop and Themes all total carried + bank, because that's what a purchase
   at any of them actually spends (services/gold.js's spendGoldCarriedOrBank). The
   qualifier is what stops the figure reading as a contradiction of the Bank gump's own
   carried-only number. Muted and smaller so the total itself stays the thing you read. */
.vendor-gold-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-weight: normal;
}

.stable-mount-price {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
  white-space: nowrap;
}

.stable-buy-btn {
  padding: 3px 10px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Tamed companions (Animal Taming) — the Stable gump's second, client-built section
   (public/js/stable.js). Own row class per the .theme-mount-row precedent rather than
   loosening .stable-mount-row's documented single-line contract. Hidden entirely when
   the player owns no pets — the section must never tease the secret catalog. */
/* Mounts / Companions tabs (views/partials/stableGump.ejs) — same shape as the
   Spellbook's own tab row, in the Stable's amber rather than that gump's purple. */
.stable-tabs {
  display: flex;
  gap: 6px;
  padding: 4px 14px 8px;
}

.stable-tab-btn {
  flex: 1;
  padding: 5px 6px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.stable-tab-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #16110b;
}

.stable-tab-panel { display: none; }

/* Both tabs share ONE fixed height, so switching between them never resizes the gump —
   and each scrolls its own overflow rather than growing. Sized to the mount catalog,
   which is the taller of the two natural contents; the companion list is unbounded (it
   grows with every pet tamed) and appended below the mounts it used to push the whole
   window off the bottom of the screen. If the catalog ever gains a mount, this is the
   one number to raise — the mounts tab simply scrolls until then. */
.stable-tab-panel.active {
  display: block;
  height: 437px;
  overflow-y: auto;
}

.stable-pets-section {
  margin: 0 14px;
  padding-bottom: 14px;
}

.stable-pets-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  width: 332px; /* .stable-roster's 360px minus the section's own side margins */
}

.stable-pet-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

/* The one currently out with you — same ember edge language as .tool-bag-active. */
.stable-pet-row-active {
  border-left: 2px solid var(--accent);
  padding-left: 8px;
}

/* --- The Companions ladder (public/js/stable.js's stableRenderPets) ------------------
   An undiscovered companion. Deliberately anonymous — the server never sends its name,
   species or bonus — so this row exists only to say "there is one here, and this is the
   Taming it wants". Dimmed so it reads as a gap in the list rather than an entry. */
.stable-pets-progress {
  padding: 6px 0 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.stable-pet-row-undiscovered {
  opacity: 0.55;
}

/* Within reach right now — worth going and looking for, so it keeps full opacity and
   takes the accent, distinguishing "go find this" from "not yet". */
.stable-pet-row-findable {
  opacity: 1;
  border-left: 2px solid var(--muted);
  padding-left: 8px;
}

/* Stands in for the pet sprite at the same footprint, so locked rows line up with owned
   ones instead of the text jumping left. */
.stable-pet-unknown-icon {
  width: 32px;
  height: 32px;
  flex: 0 0 32px;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px dashed var(--muted);
  border-radius: 4px;
  color: var(--muted);
  font-weight: bold;
}

.stable-pet-sub {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* Pet art is a TWO-frame sheet (48x20 — two 24x20 cells), so `contain` was fitting the
   whole sheet into the box and showing both frames side by side, squashed to half width.
   Every other place that draws a pet crops to the first cell: .dfp-pet and .ts-pet-sprite
   both use a 24x20 box with background-size: 48px 20px.

   This is an <img> rather than a background (public/js/stable.js sets .src, and swaps
   .pet-bonded-hue onto the same element), so the crop is done with object-fit instead:
   `cover` scales to the larger ratio — max(36/48, 30/20) = 1.5 — giving a 72x30 image,
   and `left` anchors it so the 36px-wide box shows exactly its left half. 36 of 72 is
   the first cell precisely, at 1.5x. No markup change needed. */
.stable-pet-icon {
  width: 36px;
  height: 30px;
  object-fit: cover;
  object-position: left center;
  image-rendering: pixelated;
}

.stable-pet-info {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.stable-pet-name { font-size: calc(13px * var(--ui-font-scale, 1)); }

.stable-pet-bonded {
  margin-left: 6px;
  padding: 1px 6px;
  border: 1px solid rgba(var(--accent-rgb), 0.6);
  border-radius: 8px;
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--accent);
}

.stable-pet-bonus {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.stable-pet-xp {
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* The stabled companions' own button. Was a flat var(--edge) fill — the same treatment
   .journal-tab.active/.chat-tab.active use to show a tab is ALREADY SELECTED — which on
   a button reads as greyed-out and disabled rather than clickable. It now takes the
   theme's primary-button gradient (the .auth-box button language), so "Bring Out" looks
   like the action it is under every palette. */
.stable-pet-btn {
  padding: 3px 10px;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  white-space: nowrap;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  transition: filter 120ms ease;
}

.stable-pet-btn:hover { filter: brightness(1.12); }

/* The companion currently out with you. Green is a STATE here, not an action — it marks
   which of your pets is live, alongside the row's own ember edge (.stable-pet-row-active).
   Its label reads "Stable" because clicking it puts that companion away. Theme-independent
   on purpose: see --live-strong's note at the top of this file. */
.stable-pet-btn-active {
  background: linear-gradient(180deg, var(--live-strong), var(--live-deep));
  border-color: var(--live-bright);
  /* A faint lit ring, so "this one is out" survives a palette where the theme's own
     accent is ALSO green: under Forest the idle button is #6BA048 against this #4F9D55,
     close enough that hue alone is a weak signal. The glow reads as lit under every
     theme, hue notwithstanding. */
  box-shadow: 0 0 6px rgba(107, 191, 114, 0.45);
}

.quest-log-empty {
  padding: 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
}

/* Friends gump (views/partials/friendsGump.ejs, public/js/friends.js) — same plain
   .skills-gump shell as the Quest Journal, just its own roster/add-form contents. */
.friends-gump { width: 320px; }

/* Tool Bag "Manage Tools" gump (views/partials/toolBagGump.ejs) — same plain
   .skills-gump shell + .stable-roster/.stable-mount-row rows as the Stable/Quest
   Journal gumps; .tool-bag-active marks whichever row is the live source for its
   toolSkill so it doesn't read identically to every other "Activate" button. */
/* min-content, not fit-content — the panel sizes to the GRID and lets everything else
   reflow inside that, rather than the other way round.

   fit-content sizes to the widest thing in the gump, and in two of the three rotations that
   was never the grid. The title ("Golden Leather Tool Bag", 187px on one line) and the
   empty-bag message (one long sentence, 446px unwrapped) each held the panel open far wider
   than the slots needed, and the leftover was flat parchment: measured 178px of dead width
   beside the 2x5 grid, and 352px beside it when the bag was empty — more dead panel than
   panel. A 300px floor on top of that made even the 5x2 rotation 31px wider than its grid.
   Both of those wrap perfectly well; they were only refusing to because nothing made them.
   Under min-content the title wraps to the grid's width and the sentence flows under it,
   and every rotation now sits 11px wider than its grid, which is the grid's own margin. */
.tool-bag-gump { width: min-content; }

/* Manage Tools is a fixed slot per tool type, the same grid language the Gather Bags use
   (.bag-panel.gather-bag) rather than the row list it used to be. Ten tool types lay out
   5x2, which is the same shape a 10-slot Gather Bag takes. */
.tool-bag-grid {
  display: grid;
  /* Same --gb-cols rotation the Gather Bags use (public/js/gatherBag.js's gbAttachRotator),
     so the ten tool types cycle 5x2 / 2x5 / 10x1 off one shared control. */
  grid-template-columns: repeat(var(--gb-cols, 5), 44px);
  gap: 5px;
  padding: 8px;
  margin: 4px 10px 8px;
  /* The same generated hide the Gather Bags wear (tools/generateSprites.js's
     drawLeatherSheet), in its own dye lot — a Tool Bag is a leather bag too, and reading
     as one matters more than matching the parchment gump it happens to live in. cover, not
     100% 100%, so the grain keeps its proportions whichever shape the grid is rotated to. */
  background-image: url('/assets/sprites/gather_bag_bg_tool.png');
  background-size: cover;
  border: 1px solid rgba(0, 0, 0, 0.45);
  border-radius: 6px;
  width: max-content;
}

.tool-bag-slot {
  position: relative;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.34);
  border: 1px solid rgba(0, 0, 0, 0.45);
  border-radius: 4px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07);
}

.tool-bag-slot-empty { background: rgba(0, 0, 0, 0.22); }

.tool-bag-slot-icon {
  width: 34px;
  height: 34px;
  image-rendering: pixelated;
  pointer-events: none;
}

/* A tool type the bag has nothing banked for — same dull treatment a Gather Bag gives a
   resource you have never gathered (.gather-bag-ghost). */
.tool-bag-slot-ghost {
  filter: grayscale(1);
  opacity: 0.3;
}

/* The dustbin sits over its slot's top-right rather than in a row of its own. */
.tool-bag-slot .tool-bag-discard-btn {
  position: absolute;
  top: -4px;
  right: -4px;
  padding: 0;
}

/* Themes gump (views/partials/themesGump.ejs, public/js/themes.js) — same plain
   .skills-gump shell + .stable-roster/.stable-mount-row rows as the Stable/Tool Bag
   gumps above, plus a small color-swatch preview in place of an item icon. */
.theme-gump { width: 380px; }

/* Options gump (views/partials/optionsGump.ejs, public/js/options.js) — same plain
   .skills-gump shell as Themes right above, just a bare checkbox row instead of a
   roster (crafting-bench-checkbox-row's own look, reused rather than duplicated). */
.options-gump { width: 340px; }
.options-gump-body {
  padding: 14px;
  /* CAPPED AND SCROLLABLE, because this panel grows and the screen does not.
     Neither tab is a fixed list: the General tab gains a row every time a setting lands,
     and the Hotkeys tab renders one row per entry in config/hotkeys.js's HOTKEY_ACTIONS —
     thirteen of them now, and that list only ever gets longer. With no cap the gump simply
     ran off the bottom of the screen: measured at 1366x768 it stands 909px tall against a
     768px viewport, so the last four hotkeys were not merely awkward to reach, they were
     unreachable — nothing scrolled, because the gump is a fixed-position element and the
     page behind it does not move. Reported by a player on a smaller display.
     The subtraction covers this gump's own header and tab strip plus the default 90px top
     offset (public/js/options.js) and a margin below, so the WHOLE gump fits rather than
     just its body. Same max-height/overflow idiom as every other long gump body here — the
     Stable's roster right above uses the identical calc. */
  max-height: calc(100vh - 190px);
  overflow-y: auto;
}

/* Options tabs — same shape as .stable-tabs above, which is the gump-tab language this
   codebase already settled on (flat pills, accent fill on the active one). */
.options-tabs {
  display: flex;
  gap: 6px;
  padding: 10px 14px 0;
}

.options-tab {
  flex: 1;
  padding: 5px 6px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.options-tab.active {
  background: var(--accent);
  border-color: var(--accent);
  color: #16110b;
}

/* The Hotkeys tab. The note carries the two rules a player has to know up front (one key
   per action; Enter/Escape are spoken for), so neither has to be discovered by failing. */
.options-hotkey-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  line-height: 1.5;
  margin-bottom: 10px;
}

/* --- The Discord tab (views/partials/optionsGump.ejs, public/js/optionsDiscord.js) ------
   The link state, then what linking buys you. */

/* Discord's own palette, so the tab reads as Discord's corner of the Options window rather
   than as another amber panel. Blurple is their brand colour; the green and red are the two
   they use for connected/failed states, which is exactly what the mark below reports. */
.options-discord-status {
  --discord-blurple: #5865f2;
  --discord-green: #57f287;
  --discord-red: #ed4245;
  /* Discord's own wordless mark, inlined as a data URI and used as a MASK so it takes
     whatever colour the button paints it — no extra request, nothing for the strict CSP
     to block, and no sprite added to the pipeline for one 15px glyph. */
  --discord-glyph: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwLjMxNyA0LjM3YTE5Ljc5IDE5Ljc5IDAgMCAwLTQuODg1LTEuNTE1LjA3LjA3IDAgMCAwLS4wNzkuMDM3Yy0uMjEuMzc1LS40NDQuODY0LS42MDggMS4yNWExOC4yNyAxOC4yNyAwIDAgMC01LjQ4NyAwIDEyLjY0IDEyLjY0IDAgMCAwLS42MTctMS4yNS4wNzcuMDc3IDAgMCAwLS4wNzktLjAzN0ExOS43MzYgMTkuNzM2IDAgMCAwIDMuNjc3IDQuMzdhLjA3LjA3IDAgMCAwLS4wMzIuMDI3Qy41MzMgOS4wNDYtLjMyIDEzLjU4LjA5OSAxOC4wNTdhLjA4Mi4wODIgMCAwIDAgLjAzMS4wNTcgMTkuOSAxOS45IDAgMCAwIDUuOTkzIDMuMDMuMDc4LjA3OCAwIDAgMCAuMDg0LS4wMjggMTQuMDkgMTQuMDkgMCAwIDAgMS4yMjYtMS45OTQuMDc2LjA3NiAwIDAgMC0uMDQxLS4xMDYgMTMuMTA3IDEzLjEwNyAwIDAgMS0xLjg3Mi0uODkyLjA3Ny4wNzcgMCAwIDEtLjAwOC0uMTI4IDEwLjIgMTAuMiAwIDAgMCAuMzcyLS4yOTIuMDc0LjA3NCAwIDAgMSAuMDc3LS4wMWMzLjkyOCAxLjc5MyA4LjE4IDEuNzkzIDEyLjA2MiAwYS4wNzQuMDc0IDAgMCAxIC4wNzguMDFjLjEyLjA5OC4yNDYuMTk4LjM3My4yOTJhLjA3Ny4wNzcgMCAwIDEtLjAwNi4xMjcgMTIuMjk5IDEyLjI5OSAwIDAgMS0xLjg3My44OTIuMDc3LjA3NyAwIDAgMC0uMDQxLjEwN2MuMzYuNjk4Ljc3MiAxLjM2MiAxLjIyNSAxLjk5M2EuMDc2LjA3NiAwIDAgMCAuMDg0LjAyOCAxOS44MzkgMTkuODM5IDAgMCAwIDYuMDAyLTMuMDMuMDc3LjA3NyAwIDAgMCAuMDMyLS4wNTRjLjUtNS4xNzctLjgzOC05LjY3NC0zLjU0OS0xMy42NmEuMDYuMDYgMCAwIDAtLjAzMS0uMDN6TTguMDIgMTUuMzNjLTEuMTgzIDAtMi4xNTctMS4wODUtMi4xNTctMi40MTkgMC0xLjMzMy45NTYtMi40MTkgMi4xNTctMi40MTkgMS4yMSAwIDIuMTc2IDEuMDk2IDIuMTU3IDIuNDIgMCAxLjMzMy0uOTU2IDIuNDE4LTIuMTU3IDIuNDE4em03Ljk3NSAwYy0xLjE4MyAwLTIuMTU3LTEuMDg1LTIuMTU3LTIuNDE5IDAtMS4zMzMuOTU1LTIuNDE5IDIuMTU3LTIuNDE5IDEuMjEgMCAyLjE3NiAxLjA5NiAyLjE1NyAyLjQyIDAgMS4zMzMtLjk0NiAyLjQxOC0yLjE1NyAyLjQxOHoiLz48L3N2Zz4=');
  /* COLUMN, not row. The button sat beside the text and the text collapsed to a one-word
     column with the button hanging out of the panel — see the button's own note. Stacked,
     each gets the full width and neither can squeeze the other. */
  display: flex;
  flex-direction: column;
  gap: 9px;
  margin-bottom: 10px;
  padding: 10px 11px;
  background: var(--well);
  border: 1px solid var(--edge);
  /* A blurple edge down the left — the same "this block belongs to something" device
     Discord's own embeds use. */
  border-left: 3px solid var(--discord-blurple);
  border-radius: 5px;
}

.options-discord-status-line {
  display: flex;
  align-items: flex-start;
  gap: 9px;
}

/* The tick or the cross. Fixed square so the text beside it starts at the same place in
   either state — a mark that changes width would shuffle the whole line as it flips. */
.options-discord-mark {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: 1;
  /* Neutral while the first broadcast is still on its way — neither answer has arrived yet,
     and guessing one would flicker. */
  background: rgba(255, 255, 255, 0.06);
  color: var(--muted);
}

.options-discord-mark.linked {
  background: rgba(87, 242, 135, 0.15);
  color: var(--discord-green);
}

.options-discord-mark.unlinked {
  background: rgba(237, 66, 69, 0.15);
  color: var(--discord-red);
}

.options-discord-state {
  flex: 1;
  min-width: 0;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.4;
}

.options-discord-state.linked { color: var(--discord-green); }

/* Its own button rather than a borrowed .craft-station-deposit-all-btn, which is a
   full-width STACKED button (width: 100%) declared later in this file — so it won the
   cascade over every override and burst out of the panel. In Discord's blurple, because a
   button that opens Discord should look like one. */
.options-discord-verify-btn {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 14px;
  background: var(--discord-blurple);
  border: 1px solid var(--discord-blurple);
  border-radius: 4px;
  color: #fff;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.options-discord-verify-btn:hover {
  background: #4752c4;
  border-color: #4752c4;
}

/* Discord's own mark, inline as a data URI — a CSP-safe, no-extra-request way to put the
   logo on the button without adding a sprite to the pipeline for one 14px glyph. */
.options-discord-btn-mark {
  width: 15px;
  height: 15px;
  background: #fff;
  /* The wordless Discord glyph, as a mask so it takes the button's own text colour. */
  -webkit-mask: var(--discord-glyph) center / contain no-repeat;
  mask: var(--discord-glyph) center / contain no-repeat;
}

.options-discord-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* Dimmed as a whole rather than each row, so an unlinked player reads it as one blocked
   thing with one fix above it, not as five separately broken settings. Still readable —
   deciding whether to link means reading what it would buy. */
.options-discord-list.locked {
  opacity: 0.45;
  /* The checkboxes carry their own disabled attribute (the real gate, alongside the
     server's); this stops a click landing on the label's text and toggling through it. */
  pointer-events: none;
}

.options-discord-row {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  padding: 8px;
  border-radius: 4px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.options-discord-list:not(.locked) .options-discord-row:hover {
  background: rgba(255, 255, 255, 0.03);
}

.options-discord-row input[type="checkbox"] {
  margin-top: 2px;
  accent-color: var(--accent);
  cursor: inherit;
}

.options-discord-labels {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.options-discord-name {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.options-discord-blurb {
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  line-height: 1.4;
}

.options-hotkey-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  border-bottom: 1px solid var(--edge);
}

.options-hotkey-row:last-child { border-bottom: none; }

.options-hotkey-labels {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.options-hotkey-name {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--text);
}

.options-hotkey-hint {
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* Fixed width so the row never reflows as the label changes between a short key ("2"),
   a long one ("F11") and the capture prompt. */
.options-hotkey-key {
  width: 104px;
  padding: 5px 6px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--text);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.options-hotkey-key:hover { border-color: var(--accent); }

/* Listening for the next keypress — the accent border and italic prompt are the whole
   feedback that the click registered, since nothing else on screen changes. */
.options-hotkey-key.capturing {
  border-color: var(--accent);
  color: var(--accent);
  font-style: italic;
}

.options-hotkey-clear {
  width: 22px;
  padding: 4px 0;
  background: transparent;
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--muted);
  font-size: calc(13px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.options-hotkey-clear:hover {
  color: #ff6b5e;
  border-color: #ff6b5e;
}
/* Town Music's currently-playing track (public/js/music.js) — empty (and takes no
   visible space) until a track has actually loaded, i.e. the character is standing in
   a town. Muted/italic, same "quiet metadata" treatment as a tooltip's own flavor text. */
.options-now-playing {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
  min-height: 14px;
}

/* Inscription's Auto-Convert Scrolls checkbox row (views/partials/optionsGump.ejs) —
   reintroduces the checkbox-row look the old Britain Bank ambience setting used, before
   Town Music replaced it. */
.options-checkbox-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #cfcfcf;
  cursor: pointer;
  line-height: 1.4;
}

/* Groups the Options panel into headed sections (Audio, and whatever follows) — same
   small-caps accent treatment the gump headers themselves use, just quieter since this
   sits inside a panel rather than titling one. First-child rule keeps the top section
   flush with the body padding instead of double-spacing it. */
.options-section-heading {
  margin: 14px 0 8px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--edge);
  color: var(--accent);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.options-section-heading:first-child {
  margin-top: 0;
}

/* An Options setting whose control is a button rather than a slider or a checkbox —
   currently just Display's "Reset Window Layout" (wired in public/js/panelDock.js). */
.options-action-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 8px 0 14px;
}

.options-action-btn {
  padding: 6px 14px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.options-action-btn:hover {
  border-color: var(--accent);
  color: var(--accent);
}

.options-action-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* The cadence, said on the checkbox line itself rather than only in the hint below it —
   "is this thing even running" is answered by a glance at the row, and the hint is where
   somebody goes once they already suspect something is wrong. Quiet enough not to compete
   with the label it trails. */
.options-backpack-skin-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #cfcfcf;
}

.options-backpack-skin-select {
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: var(--well, #0b0d12);
  color: var(--ink);
  border: 1px solid var(--edge);
  border-radius: 4px;
  padding: 4px 6px;
}

.options-checkbox-cadence {
  margin-left: 2px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--muted);
  opacity: 0.85;
  white-space: nowrap;
}

.options-slider-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 10px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #cfcfcf;
}

/* A line of explanation under a slider whose effect isn't self-evident from its label —
   UI Scale, which needs to say what it does and doesn't resize. Indented past where the
   labels start so it reads as belonging to the row above rather than as a new setting. */
.options-slider-note {
  margin: -4px 0 12px 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.4;
  color: var(--muted);
}

.options-slider {
  flex: 1;
  min-width: 0; /* lets it actually shrink inside the flex row rather than overflowing */
  accent-color: var(--accent); /* themes the native track/thumb with the active palette */
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Tabular + fixed width so the row doesn't twitch as the number changes while dragging. */
.options-slider-value {
  min-width: 38px;
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}

.theme-swatch {
  display: flex;
  gap: 3px;
  width: 44px;
  flex: none;
}

.theme-swatch-dot {
  width: 13px;
  height: 24px;
  border-radius: 2px;
  border: 1px solid rgba(0, 0, 0, 0.35);
}

/* Selected theme's row — same italic-muted shape as .spellbook-locked-label's "Owned"
   state, but in the accent color so the currently-active row reads distinctly from a
   merely-owned-but-inactive one (which still shows a "Select" button). */
.theme-active-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--accent);
  font-style: italic;
  font-weight: bold;
  white-space: nowrap;
}

.tool-bag-empty {
  padding: 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
}

/* Donation Store (views/partials/donationGump.ejs) and the Theme Token picker that
   shares its row styling. Gold-leaning rather than ember, so the one surface in the game
   that touches real money never gets clicked by muscle memory meant for another panel. */
/* Widened from 420px. The panel had grown tall enough to scroll: an intro paragraph, a
   balance, four buy rows, a slider and four item rows each carrying a full sentence of
   description. Extra width is what buys height back — every item description is a
   wrapping paragraph, so widening the column shortens each of them by a line or two, and
   the two-column intro below does the same for the preamble.
   Capped with a viewport-relative max so it can never exceed a small laptop screen. */
/* max() floors the clamp: a bare calc(100vw - 40px) collapses the panel to nothing if
   100vw ever resolves to 0 (a not-yet-laid-out or non-compositing viewport), and a store
   panel that can render 2px wide is worse than one that overflows a narrow screen. */
/* Storefront layout (2026-08-15): seal top-left in the header, a rail of category tabs
   down the left, one page at a time on the right. The gump is a fixed-height shop window
   and the MAIN PANE scrolls, never the whole panel — the rail stays put like furniture. */
/* A FIXED height, not a max-height. With max-height the panel shrank to whatever the
   current tab happened to contain, so every click on the rail resized the whole window —
   Featured is long, a two-item shelf is short, and the shop jumped between them. Pinning
   the height makes the frame a constant and moves all the variation inside .donation-main,
   which already scrolls. Generous, because the room is what stops the scrolling from being
   the first thing you notice.

   min() keeps it honest on a short screen: 720px normally, less if the viewport cannot
   spare it, so the panel never grows taller than the window it sits in. */
.donation-gump {
  width: 760px;
  max-width: max(360px, calc(100vw - 40px));
  display: flex;
  flex-direction: column;
  height: min(720px, calc(100vh - 90px));
}

.donation-header { gap: 10px; }

.donation-title {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1 1 auto;
}

/* The TIS seal, small — the same art the game header hangs (tis-crest-header.png), here
   as a shopkeeper's plaque. It pokes a little above and below the title bar the way the
   header's own seal does; overflow is visible on the gump, so nothing clips it. */
.donation-crest {
  width: 42px;
  height: auto;
  margin: -6px 0;
  filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.6));
}

.donation-shell {
  display: flex;
  flex: 1 1 auto;
  min-height: 0; /* lets .donation-main actually scroll inside the flex column */
}

/* The rail. Tabs styled as the store's own gold-edged plates. */
.donation-nav {
  flex: 0 0 148px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 12px 10px;
  border-right: 1px solid var(--edge);
  background: rgba(0, 0, 0, 0.18);
}

.donation-tab {
  padding: 8px 10px;
  text-align: left;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #f0cf72;
  background: rgba(184, 145, 47, 0.08);
  border: 1px solid rgba(184, 145, 47, 0.4);
  border-radius: 4px;
  cursor: pointer;
}

.donation-tab:hover { background: rgba(184, 145, 47, 0.18); }

.donation-tab.active {
  background: rgba(184, 145, 47, 0.26);
  border-color: #b8912f;
  color: #ffe9a8;
  box-shadow: inset 0 0 8px rgba(184, 145, 47, 0.25);
}

#donation-nav-sections {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

/* Offers is a sale, not a shelf, and is tinted to say so — the same warm red the offer
   badge, the sale price and the on-offer card wash already use, so the tab and what it
   opens onto read as one thing. Hidden entirely when nothing is on sale
   (public/js/donation.js). */
.donation-tab-offers {
  color: #e0876f;
  background: rgba(184, 68, 47, 0.12);
  border-color: rgba(212, 103, 79, 0.45);
}

.donation-tab-offers:hover { background: rgba(184, 68, 47, 0.22); }

.donation-tab-offers.active {
  background: rgba(184, 68, 47, 0.3);
  border-color: #d4674f;
  color: #ffd8c9;
  box-shadow: inset 0 0 8px rgba(184, 68, 47, 0.3);
}

/* Buy BritCoin sits apart at the rail's foot — it is the one tab that leads out of the
   game to a payment page, and it should not read as just another shelf. */
.donation-tab-coins { margin-top: auto; }

.donation-nav-foot {
  padding: 6px 2px 0;
  font-size: calc(10px * var(--ui-font-scale, 1));
  letter-spacing: 0.06em;
  text-align: center;
  color: var(--muted);
}

.donation-main {
  flex: 1 1 auto;
  min-width: 0;
  overflow-y: auto;
  padding: 12px 14px 14px;
}

/* --- The cards -------------------------------------------------------------------------
   A listing as a framed card rather than a table row. The hero is the Featured page's
   headline offer: full width, bigger art, its badge worn on the frame's top edge. */
.donation-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px 12px;
  background: linear-gradient(180deg, rgba(184, 145, 47, 0.07), rgba(0, 0, 0, 0.18));
  border: 1px solid rgba(184, 145, 47, 0.4);
  border-radius: 6px;
}

.donation-card-hero {
  margin: 4px 0 10px;
  padding: 14px 16px 12px;
  border-color: #b8912f;
  box-shadow: 0 0 14px rgba(184, 145, 47, 0.18), inset 0 0 22px rgba(0, 0, 0, 0.3);
}

.donation-card-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.donation-card-body {
  display: flex;
  gap: 12px;
  align-items: flex-start;
}

/* Native 44px pixel art in the same inset-slot treatment an inventory cell has; the hero
   doubles it, still integer-scaled so the art stays crisp. */
.donation-card-icon {
  width: 44px;
  height: 44px;
  flex: 0 0 auto;
  image-rendering: pixelated;
  border: 1px solid rgba(184, 145, 47, 0.35);
  border-radius: 4px;
  background: rgba(0, 0, 0, 0.25);
}

.donation-card-hero .donation-card-icon { width: 88px; height: 88px; }

.donation-card-icon-empty { opacity: 0.25; }

.donation-card-info {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

.donation-card-name {
  font-weight: bold;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.donation-card-hero .donation-card-name { font-size: calc(16px * var(--ui-font-scale, 1)); }

.donation-card-desc {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  line-height: 1.4;
}

.donation-card-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  padding-top: 6px;
  border-top: 1px solid rgba(184, 145, 47, 0.2);
}

.donation-card-price {
  font-weight: bold;
  color: #f0cf72;
}

.donation-card-owned { opacity: 0.6; }

/* An owned card stays fully legible — its whole point now is the Wear button. Only cards
   that are owned AND have nothing left to offer (no skin to wear) keep the fade above. */
.donation-card-owned:has(.donation-wear-btn) { opacity: 1; }

/* Press-to-preview strip: the satchel flanked by its panniers, scaled to the card. The
   height cap is what scales all three together; flex keeps them on one baseline. */
.donation-card-preview {
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: 2px;
  padding: 8px 0 4px;
}

.donation-card-preview img {
  height: 96px;
  width: auto;
  image-rendering: auto;
  filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.5));
}

.donation-card-hero .donation-card-preview img { height: 150px; }

.donation-preview-btn {
  background: none;
  border: 1px solid rgba(184, 145, 47, 0.5);
  color: #f0cf72;
  margin-right: auto; /* Preview left, price+Buy/Wear right */
}

.donation-preview-btn:hover { background: rgba(184, 145, 47, 0.15); }

.donation-wear-btn:disabled {
  /* Worn: not greyed like an exhausted Buy — it is the active state, said in gold. */
  background: rgba(184, 145, 47, 0.25);
  border: 1px solid #b8912f;
  color: #ffe9a8;
  cursor: default;
}

/* The hero's badge rides the frame's top edge, banner-style. */
.donation-card-hero > .donation-offer-badge {
  position: absolute;
  top: -10px;
  left: 50%;
  transform: translateX(-50%);
  margin: 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  padding: 3px 14px;
}

.theme-token-gump { width: 380px; }

/* The header button, tinted the same gold as the panel it opens. */
.donation-header-btn {
  border-color: #b8912f;
  color: #f0cf72;
}

.donation-header-btn:hover { background: rgba(184, 145, 47, 0.18); }

/* The "we don't sell power" statement above the balance. Given its own quiet panel with
   a gold left rule rather than being styled like a store row — it is the shop's promise,
   not another thing to buy, and it should read as someone talking rather than as UI. */
.donation-intro {
  margin: 0 0 12px;
  padding: 10px 12px;
  background: rgba(255, 255, 255, 0.03);
  border-left: 2px solid rgba(184, 145, 47, 0.65);
  border-radius: 0 4px 4px 0;
  font-size: calc(11.5px * var(--ui-font-scale, 1));
  line-height: 1.5;
  color: var(--muted);
}

.donation-intro p { margin: 0 0 7px; }
.donation-intro p:last-child { margin-bottom: 0; }
.donation-intro strong { color: #f0cf72; }
.donation-intro em { color: var(--text); font-style: italic; }

/* The two load-bearing promises (no power sold; coins survive the wipe). Previously
   these were plain <strong> runs inside body paragraphs, which left the second one
   wrapping mid-sentence into its own explanation and reading as emphasis rather than as
   a commitment. Each is now its own line at the head of what explains it. */
.donation-promise {
  color: #f0cf72;
  font-weight: bold;
  font-size: calc(12.5px * var(--ui-font-scale, 1));
  line-height: 1.35;
}

/* Only the two SUPPORTING paragraphs share columns now, not the whole block. They are
   parallel detail of the same length, so a gutter suits them — where column-spanning the
   promises out of a 4-item column flow (the previous approach) meant the reader crossed
   the gutter twice and the sign-off could drift up beside the last paragraph. Keeping
   the two-column shape here is what stops the panel needing to scroll again. */
.donation-intro-cols {
  column-count: 2;
  column-gap: 18px;
  margin-bottom: 9px;
}

.donation-intro-cols p { break-inside: avoid; }
.donation-intro-cols p:last-child { margin-bottom: 0; }

/* The wipe promise gets its own inset panel: it is the one paragraph a player might act
   on financially (by hoarding coins instead of spending them), so it should not read as
   another block of preamble prose. */
.donation-wipe-note {
  padding: 8px 10px;
  background: rgba(184, 145, 47, 0.09);
  border: 1px solid rgba(184, 145, 47, 0.3);
  border-radius: 4px;
}

.donation-wipe-note p:last-child { margin-bottom: 0; }

/* Single column under ~520px of gump: at that width a gutter leaves ~4 words a line. */
@media (max-width: 560px) {
  .donation-intro-cols { column-count: 1; }
}

.donation-intro-signoff {
  padding-top: 6px;
  border-top: 1px solid rgba(184, 145, 47, 0.25);
  font-style: italic;
}

/* Lives in the title bar now, a compact pill beside the close button — the balance is
   the one number every page of the shop prices against, so it stays on screen whichever
   tab is open. */
.donation-balance {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 4px 10px;
  background: rgba(184, 145, 47, 0.1);
  border: 1px solid rgba(184, 145, 47, 0.45);
  border-radius: 4px;
}

.donation-balance-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--muted);
}

.donation-balance-value {
  font-size: calc(15px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #f0cf72;
}

/* Test mode / not-configured notices — impossible to confuse with a live store, which is
   the entire point of showing them. */
.donation-testmode,
.donation-unconfigured {
  margin: 0 0 8px;
  padding: 5px 8px;
  border-radius: 3px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-align: center;
}

.donation-testmode {
  background: rgba(90, 140, 200, 0.15);
  border: 1px solid rgba(90, 140, 200, 0.5);
  color: #9ec4f0;
}

.donation-unconfigured {
  background: rgba(200, 90, 70, 0.15);
  border: 1px solid rgba(200, 90, 70, 0.5);
  color: #e09a8a;
}

.donation-section-label {
  margin: 0 0 8px;
  padding-bottom: 3px;
  border-bottom: 1px solid var(--edge);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}

.theme-token-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 0 14px;
  max-height: 34vh;
  overflow-y: auto;
}

/* The Buy BritCoin rows — inside .donation-main, which scrolls for them. */
.donation-bundles {
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.donation-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

/* A shelf heading in the store (public/js/donation.js, config/donationStore.js's
   STORE_SECTIONS). Deliberately NOT a .donation-row: it is a label ON the list rather than
   an entry in it, so it carries no panel, no border and no hover — the rule underneath is
   the only thing dividing one shelf from the next. The first heading loses its top margin
   so the list does not open with a gap the others do not have. */
.donation-section-head {
  display: flex;
  flex-direction: column;
  gap: 1px;
  margin: 12px 2px 2px;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--edge);
}
.donation-section-head:first-child { margin-top: 2px; }

.donation-section-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--accent);
}

.donation-section-blurb {
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.4;
  color: var(--muted);
}

.donation-row-name { font-weight: bold; }

/* Pushes the price right on the preset rows. */
.donation-row > .donation-row-name { flex: 1 1 auto; }

/* The slider's own rules (.donation-amount-slider, .donation-row-amount and the
   .donation-row-custom overrides) lived here and are gone with it — see .donation-custom
   further down for what replaced it. Dead selectors for a control that no longer exists are
   worse than none: the next person to style this page reads them as the current design. */

.donation-row-price {
  flex: 0 0 auto;
  font-weight: bold;
  color: #f0cf72;
}


/* --- Store offers (config/donationStore.js's `offer:` field) ------------------------
   A listing on sale. Deliberately the only warm-red accent on a page that is otherwise
   entirely the store's gold: a discount has to be spottable in one glance down the list,
   and a gold badge on gold rows is not. Everything here is presentational — the sale
   price, the saving and the deadline are all decided and sent by the server. */
.donation-offer-badge {
  display: inline-block;
  margin-left: 8px;
  padding: 1px 7px;
  border-radius: 9px;
  background: linear-gradient(180deg, #b8442f, #8d2f1f);
  border: 1px solid #d4674f;
  color: #ffe4d8;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  vertical-align: middle;
  /* Never shrink to fit a long item name — the badge is the point of the row. */
  white-space: nowrap;
}

.donation-offer-ends {
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #e0876f;
  letter-spacing: 0.03em;
}

/* Price cell while on offer: the old price struck through, the new one beside it. Column
   on narrow rows, so a two-price cell never squeezes the Buy button off the end. */
.donation-row-price-sale {
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
  white-space: nowrap;
}

.donation-price-was {
  color: var(--muted);
  font-weight: 500;
  font-size: calc(11px * var(--ui-font-scale, 1));
  /* The strike is the <s> element's own default; this only keeps it from reading as a
     live price at a glance. */
  opacity: 0.75;
}

.donation-price-now { color: #ffb38a; }

/* A faint warm wash across an on-offer card, so the sale reads at shelf level rather
   than only where the badge sits. Kept very low-alpha: it must not fight the frame. */
.donation-card-offer {
  background: linear-gradient(135deg, rgba(184, 68, 47, 0.14), rgba(0, 0, 0, 0.18) 55%);
  border-color: rgba(212, 103, 79, 0.55);
}

.donation-buy-btn {
  flex: 0 0 auto;
  padding: 3px 12px;
  background: rgba(184, 145, 47, 0.18);
  border: 1px solid #b8912f;
  border-radius: 3px;
  color: #f0cf72;
  font: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}

.donation-buy-btn:hover:not(:disabled) { background: rgba(184, 145, 47, 0.35); }

.donation-buy-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

.donation-footnote {
  padding: 10px 14px 12px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  text-align: center;
  color: var(--muted);
}

/* "Thank you." on its own line, in the accent — the one warm note in a panel that is
   otherwise about money. display:block rather than a <br>, so the line break belongs to the
   styling of the phrase rather than being punctuation smuggled into the markup. */
.donation-footnote-thanks {
  display: block;
  margin-top: 5px;
  color: var(--accent);
  font-style: normal;
  font-weight: bold;
  letter-spacing: 0.3px;
}

/* --- The custom donation box (public/js/donation.js) -------------------------------------
   Sits under the one-click presets on the Buy BritCoin page and is deliberately the most
   substantial thing there: it is now the way to give any amount, not an afterthought below
   the "real" options. Framed and gold-edged like the shop's own cards rather than styled as
   another .donation-row, because it is a form and not a listing. */
.donation-custom {
  margin-top: 10px;
  padding: 12px 14px 13px;
  background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.07), rgba(0, 0, 0, 0.22));
  border: 1px solid rgba(var(--accent-rgb), 0.4);
  border-radius: 7px;
}

.donation-custom-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 9px;
}

.donation-custom-title {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

.donation-custom-range {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.donation-custom-field {
  display: flex;
  align-items: center;
  gap: 8px;
}

.donation-custom-currency {
  font-size: calc(20px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  line-height: 1;
}

/* Wide enough for three digits at this size and no wider — a field sized for an essay
   invites one, and the ceiling is three digits. */
.donation-custom-input {
  width: 5.5em;
  padding: 7px 10px;
  font-family: inherit;
  font-size: calc(20px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-align: center;
  color: var(--ink);
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
}

.donation-custom-input:focus {
  outline: none;
  border-color: rgba(var(--accent-rgb), 0.75);
  box-shadow: 0 0 0 3px rgba(var(--accent-rgb), 0.14);
}

/* The live conversion, which is the whole reason the box can be trusted: it restates what
   the typed number actually buys, from the same maths the server will charge. */
.donation-custom-coins {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.donation-custom-note {
  margin: 8px 0 0;
  min-height: 1em; /* reserved, so a message appearing never nudges the button downward */
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* A refusal has to look like one. The field goes red rather than the box silently
   correcting the number — see the note in donation.js on why nothing here clamps. */
.donation-custom-invalid .donation-custom-input {
  border-color: rgba(201, 60, 60, 0.75);
}

.donation-custom-invalid .donation-custom-note {
  color: #e08a8a;
}

.donation-custom-btn {
  display: block;
  width: 100%;
  margin-top: 10px;
}

/* The Lore Book (views/partials/animalLoreGump.ejs) — every character's blessed dungeon
   bestiary. Deliberately NOT the dark theme every other gump wears: the shell is a
   russet leather cover (matching the lore_book item sprite) around a parchment page
   written in ink, so it reads as a physical book sitting on top of the game rather than
   another panel of it. One dungeon per page; rows expand in place — an unlored creature
   is a single collapsed line with a blacked-out silhouette, a lored one grows its stat
   grid and drop list. Nothing toggles: a row's height IS whether you know it. */
.lore-book-gump {
  width: 430px;
  background: linear-gradient(160deg, #5a3a20, #462c16 55%, #33200f);
  border: 1px solid #1f1207;
}

.lore-book-page {
  margin: 4px 10px 6px;
  padding: 10px 12px 8px;
  background: linear-gradient(165deg, #ecdcb6, #ddc795 55%, #cfb67d);
  border: 1px solid #8a6f44;
  border-radius: 2px;
  box-shadow: inset 0 0 18px rgba(90, 62, 24, 0.35);
  color: #3a2a16;
}

.lore-book-dungeon {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(17px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
  text-align: center;
  color: #2e1f0d;
}

/* A short ink rule under the dungeon name, thick at the middle and vanishing at the
   ends — a hand-drawn divider rather than a CSS border stripe. */
.lore-book-rule {
  height: 2px;
  margin: 4px 18% 6px;
  background: radial-gradient(closest-side, rgba(58, 42, 22, 0.75), rgba(58, 42, 22, 0));
}

.lore-book-subhead {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  color: #6b5638;
}

/* A FIXED height, not max-height: a book is a physical object, and the old max-height
   let it resize every time you turned a page or inked a creature in (measured: 423px to
   570px across the twelve pages). 430px holds the largest roster — 5 creatures, the
   4-monster dungeons plus their boss — with room to spare, so no page is ever cramped.
   A fully-lored page's stat blocks and drop lists run to ~880px, which cannot fit any
   normal viewport, so that scrolls INSIDE the fixed frame rather than growing it.
   The vh cap keeps the whole gump on screen for short windows; it varies per viewport,
   never per page, which is the thing that actually mattered. */
.animal-lore-list {
  display: flex;
  flex-direction: column;
  gap: 5px;
  height: 430px;
  max-height: 52vh;
  overflow-y: auto;
  padding-right: 2px;
}

.animal-lore-row {
  padding: 6px 8px;
  background: rgba(255, 248, 226, 0.35);
  border: 1px solid rgba(110, 84, 46, 0.5);
  border-radius: 3px;
}

/* The creature the book was opened AT (the combat gump's Lore button — public/js/
   animalLore.js's openLoreBookToMonster). A brief ink-bloom on the parchment rather than
   a persistent state: it exists to answer "which one did I just read?" the instant the
   book appears, then get out of the way of reading it. */
@keyframes animal-lore-focus-flash {
  0% { background: rgba(255, 214, 120, 0.95); box-shadow: 0 0 0 3px rgba(212, 160, 60, 0.55); }
  100% { background: rgba(255, 248, 226, 0.35); box-shadow: 0 0 0 0 rgba(212, 160, 60, 0); }
}
.animal-lore-row-focus {
  animation: animal-lore-focus-flash 1400ms ease-out;
}

/* Only rows that can actually be studied from here invite a click — every other page's
   rows are plain reading matter (public/js/animalLore.js only offers attempts on the
   current dungeon's page). */
.animal-lore-row-studyable { cursor: pointer; }
.animal-lore-row-studyable:hover { border-color: #8a5a1a; background: rgba(255, 248, 226, 0.6); }

/* A creature already inked into the bestiary — a gold edge, the parchment take on the
   accent edge the old panel used. */
.animal-lore-row-known { border-left: 3px solid #a4761f; }

.animal-lore-row-head {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

/* The framed plate a creature's portrait sits in — a darker parchment inset with a
   double rule, like an illustration mounted on the page. */
.lore-book-plate {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 40px;
  height: 40px;
  flex: 0 0 auto;
  background: linear-gradient(160deg, #d8c390, #c4ab72);
  border: 1px solid #6e542e;
  outline: 1px solid rgba(110, 84, 46, 0.35);
  outline-offset: 2px;
  border-radius: 2px;
}

.animal-lore-sprite {
  width: 34px;
  height: 34px;
  object-fit: contain;
  image-rendering: pixelated;
}

/* An unstudied creature is present only as its shadow — the sprite blacked out to a
   silhouette until it's lored, which is most of what makes filling the book feel like
   filling a book. */
.animal-lore-row:not(.animal-lore-row-known) .animal-lore-sprite {
  filter: brightness(0) opacity(0.55);
}

.animal-lore-name {
  font-family: Georgia, 'Times New Roman', serif;
  font-weight: bold;
  color: #2e1f0d;
}

/* A wax-seal red, since the parchment page has no room for the dark theme's tag chrome. */
.animal-lore-boss-tag {
  padding: 1px 5px;
  border: 1px solid #8c2f22;
  border-radius: 3px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: #8c2f22;
}

/* Where this creature lives — rendered ONLY in the search view (public/js/animalLore.js),
   where the list has left the page metaphor behind and "which page is this on" is the one
   thing a row can no longer say by sitting on it. Deliberately quieter than the Boss tag:
   it's a footnote on the row, not a badge. */
.animal-lore-row-where {
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-style: italic;
  letter-spacing: 0.02em;
  color: #7a6340;
}

/* The index box at the top of the page. Sunk into the parchment rather than raised off it
   — an ink-ruled slot, the same russet palette the rest of the book wears, so it reads as
   part of the page instead of a browser control dropped onto it. */
.lore-book-search-row {
  position: relative;
  margin-bottom: 6px;
}

.lore-book-search {
  width: 100%;
  padding: 4px 22px 4px 8px;
  background: rgba(255, 248, 226, 0.5);
  border: 1px solid rgba(110, 84, 46, 0.55);
  border-radius: 3px;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #2e1f0d;
}

.lore-book-search::placeholder {
  font-style: italic;
  color: #8a7350;
}

.lore-book-search:focus {
  outline: none;
  border-color: #8a5a1a;
  background: rgba(255, 248, 226, 0.75);
}

/* Sits inside the box's right edge, and is hidden entirely while the box is empty — a
   permanently visible cross on an empty field is a control that does nothing. */
.lore-book-search-clear {
  position: absolute;
  top: 50%;
  right: 4px;
  transform: translateY(-50%);
  padding: 0 3px;
  background: none;
  border: none;
  font-size: calc(14px * var(--ui-font-scale, 1));
  line-height: 1;
  color: #6b5638;
  cursor: pointer;
}

.lore-book-search-clear:hover {
  color: #8c2f22;
}

/* Pushed hard right so every unlored row's ??? lines up down the column. */
.animal-lore-state {
  margin-left: auto;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #6b5638;
}

.animal-lore-stats {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 1px 12px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid rgba(110, 84, 46, 0.45);
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.animal-lore-stats > div { display: flex; justify-content: space-between; gap: 8px; }
.animal-lore-stats span:first-child { color: #6b5638; }

.animal-lore-drops { margin-top: 6px; font-size: calc(11px * var(--ui-font-scale, 1)); }

.animal-lore-drops-label {
  color: #6b5638;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-size: calc(10px * var(--ui-font-scale, 1));
  margin-bottom: 2px;
}

.animal-lore-drop { display: flex; justify-content: space-between; gap: 8px; }
.animal-lore-drop-chance { color: #6b5638; }

/* The Companions page (public/js/animalLore.js's renderCompanionPage) — one row per
   companion the character has ever tamed, showing every level 1-10 at once rather than
   only its current one, so "is this worth levelling to Bonded" never needs a trip to the
   Stable. Rows reuse .animal-lore-row-known's gold "already inked" edge (they can only
   ever be here already-discovered), and .stable-pet-icon for the sprite crop — pet art is
   a two-frame sheet, same reason the Stable needs that class rather than the plain
   .animal-lore-sprite monsters use. */
.animal-lore-companion-levels {
  display: flex;
  flex-direction: column;
  gap: 1px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid rgba(110, 84, 46, 0.45);
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.animal-lore-companion-level-row { display: flex; justify-content: space-between; gap: 8px; }
.animal-lore-companion-level-row span:first-child { color: #6b5638; }

/* The level this companion is actually at right now, picked out of its own 1-10 ladder. */
.animal-lore-companion-level-current { font-weight: bold; color: #2e1f0d; }

/* The companion currently out with the player — theme-independent green, same convention
   game.css's .stable-pet-btn-active/.stable-pet-row-active use (see --live-strong's note
   at the top of this file), so "this one is live" reads identically in the Stable and
   here. Two classes so it always outranks .animal-lore-row-known's gold edge regardless
   of source order. */
.animal-lore-row.animal-lore-row-companion-active {
  border-left: 3px solid var(--live-bright);
  background: rgba(79, 157, 85, 0.14);
}

.animal-lore-companion-active-tag {
  margin-left: auto;
  padding: 1px 5px;
  border: 1px solid var(--live-deep);
  border-radius: 3px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--live-strong);
  background: rgba(79, 157, 85, 0.16);
}

/* The Loop row and the away/locked hint swap places page to page, and they don't
   naturally agree on height: a checkbox is taller than a line of text, and a long
   dungeon name wraps the hint onto a second line. Left alone that moved the whole book
   by up to 11px as you turned pages. A FIXED two-line box, centred, makes the footer the
   same height whichever one is showing and however the text falls. */
.animal-lore-loop-row,
.lore-book-away-hint {
  box-sizing: border-box;
  height: 46px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.animal-lore-loop-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid rgba(110, 84, 46, 0.45);
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}

/* A page whose dungeon outranks your skill (config/animalLore.js's tier gate) — the same
   footer line as the travel hint, in the wax-seal red the boss tag uses, since "you
   aren't good enough yet" is a harder no than "you're standing in the wrong place". */
.lore-book-away-hint.lore-book-locked {
  color: #8c2f22;
  font-weight: bold;
}

/* Reading a page for a dungeon you're not standing in — the study controls swap for
   this one italic line saying where to go. */
.lore-book-away-hint {
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid rgba(110, 84, 46, 0.45);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  text-align: center;
  color: #6b5638;
}

/* Page navigation lives on the leather cover below the page, not on the parchment —
   it's part of the book, not of what's written in it. */
.lore-book-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 2px 12px 10px;
}

.lore-book-nav-btn {
  padding: 1px 10px;
  background: rgba(236, 220, 182, 0.12);
  border: 1px solid rgba(236, 220, 182, 0.35);
  border-radius: 3px;
  color: #ecdcb6;
  font: inherit;
  font-size: calc(14px * var(--ui-font-scale, 1));
  cursor: pointer;
}

.lore-book-nav-btn:hover:not(:disabled) { background: rgba(236, 220, 182, 0.25); }
.lore-book-nav-btn:disabled { opacity: 0.35; cursor: default; }

.lore-book-pageno {
  min-width: 52px;
  text-align: center;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #ecdcb6;
}

/* The Slayer Logbook (views/partials/slayerLogGump.ejs, public/js/slayerLog.js) — the
   Donation Store's reading book. STRUCTURALLY it still borrows the Lore Book's skeleton
   (.lore-book-page/.lore-book-nav/.lore-book-plate carry the layout), but every colour is
   overridden under this scope: the first cut reused the russet-and-parchment look outright
   and the two books were indistinguishable at a glance, which is wrong for what this one
   is. A bestiary describes; this book counts kills. So it dresses like its own sprite —
   black leather, a blood-red spine, bone-ash paper gone cold, headings in dried-blood red
   under a skull — and the Lore Book keeps warm russet to itself. A creature never killed
   still sits as a silhouette: filling the book has to look like filling a book. */
.slayer-log-gump {
  /* Wider than the Lore Book's 430px: a row here carries a name, a trophy chip and a
     five-figure tally on one line, where a Lore Book row carries a name and "???". */
  width: 468px;
  /* The sprite's own cover: near-black leather, and the blood spine down the left edge. */
  background: linear-gradient(160deg, #33302f, #211d1c 55%, #100c0b);
  border: 1px solid #060403;
  border-left: 4px solid #5e1712;
}

/* Header on the black cover: the title in dried-blood red, flanked by a skull — the mark
   the whole restyle hangs on. The skull is a pseudo-element so the partial stays plain. */
.slayer-log-gump .skills-gump-header {
  color: #b5433a;
  border-bottom-color: rgba(140, 47, 34, 0.4);
}
.slayer-log-gump .skills-gump-header > span:first-child::before {
  content: '\2620\00a0\00a0'; /* skull and crossbones */
  color: #8c2f22;
}
.slayer-log-gump .skills-gump-close { color: #b5433a; }

/* The paper: the Lore Book's warm tan drained cold — bone and ash, paper that has spent
   its life underground. Ink goes charcoal to match. */
.slayer-log-gump .lore-book-page {
  background: linear-gradient(165deg, #d9d4c4, #c3beac 55%, #aaa590);
  border-color: #55503f;
  box-shadow: inset 0 0 18px rgba(20, 16, 12, 0.45);
  color: #2a251d;
}

/* The page name in dried blood, with small skulls closing each end of the line —
   ✠-style flourishes would say chapel; skulls say charnel house. */
.slayer-log-gump .lore-book-dungeon {
  color: #7a1410;
}
.slayer-log-gump .lore-book-dungeon::before { content: '\2620\00a0\00a0'; font-size: 0.8em; color: rgba(122, 20, 16, 0.65); }
.slayer-log-gump .lore-book-dungeon::after { content: '\00a0\00a0\2620'; font-size: 0.8em; color: rgba(122, 20, 16, 0.65); }

/* The hand-drawn rule under the title, re-inked in blood rather than brown. */
.slayer-log-gump .lore-book-rule {
  background: radial-gradient(closest-side, rgba(122, 20, 16, 0.7), rgba(122, 20, 16, 0));
}

.slayer-log-gump .lore-book-subhead { color: #5c5647; }

/* The portrait plate: a colder, darker inset than the Lore Book's — a rubbing taken from
   a tomb rather than an illustration mounted on a page. */
.slayer-log-gump .lore-book-plate {
  background: linear-gradient(160deg, #b8b29c, #9c967f);
  border-color: #453f2f;
  outline-color: rgba(69, 63, 47, 0.4);
}

/* Nav on the black cover: bone text, blood borders. */
.slayer-log-gump .lore-book-nav-btn {
  background: rgba(217, 212, 196, 0.08);
  border-color: rgba(140, 47, 34, 0.55);
  color: #d9d4c4;
}
.slayer-log-gump .lore-book-nav-btn:hover:not(:disabled) { background: rgba(140, 47, 34, 0.3); }
.slayer-log-gump .lore-book-pageno { color: #d9d4c4; }

/* A FIXED height for the same reason .animal-lore-list has one — a book is a physical
   object, and a max-height would resize the whole window on every page turn (The Wilds
   holds 38 creatures, Ice Dungeon 4). The vh cap keeps it on screen for short viewports;
   it varies per viewport, never per page, which is the part that actually mattered. */
.slayer-log-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  height: 430px;
  max-height: 52vh;
  overflow-y: auto;
  padding-right: 2px;
}

.slayer-log-row {
  padding: 5px 8px;
  background: rgba(236, 232, 218, 0.3);
  border: 1px solid rgba(70, 64, 50, 0.5);
  border-radius: 3px;
}

/* A creature actually killed at least once — the gold edge the Lore Book gives a creature
   already inked in, meaning the same thing here: this one is on the record. */
.slayer-log-row-slain { border-left: 3px solid #a4761f; }

/* A boss's wax-seal red edge, ranked above the gold one (two classes, so it wins
   regardless of source order) — a boss row should read as a boss row before it reads as a
   slain one. */
.slayer-log-row.slayer-log-row-boss { border-left: 3px solid #8c2f22; }

.slayer-log-row-head {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.slayer-log-sprite {
  width: 34px;
  height: 34px;
  object-fit: contain;
  image-rendering: pixelated;
}

/* Never killed, never drawn — present only as its shadow, exactly as an unlored creature
   is in the Lore Book. */
.slayer-log-row:not(.slayer-log-row-slain) .slayer-log-sprite {
  filter: brightness(0) opacity(0.55);
}

.slayer-log-name {
  font-family: Georgia, 'Times New Roman', serif;
  font-weight: bold;
  color: #241f18;
}

/* The tally, pushed hard right so every row's number lines up down the column — the same
   arrangement .animal-lore-state uses for its ???. Tabular figures so the digits sit in
   the same places from row to row rather than shuffling with their widths. */
.slayer-log-count {
  margin-left: auto;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-variant-numeric: tabular-nums;
  color: #3a352a;
}

.slayer-log-detail {
  margin-top: 2px;
  padding-left: 48px; /* clears the 40px plate + its 8px gap, so the line hangs under the name */
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-style: italic;
  color: #5c5647;
}

/* What a creature has taken back. Deliberately the same wax-seal red the Boss tag uses —
   on a parchment page there is one colour for danger, and a creature that has killed you
   is exactly that. */
.slayer-log-nemesis { color: #8c2f22; }

/* The trophies. Struck metal on bone paper: a tinted plate with a darker rule around it,
   in four metals that stay legible against the page's #d9d4c4 — a literal silver
   (#b9c2c9) reads as a smudge on paper this pale, so each is the metal darkened to ink
   weight instead. Cosmetic in the strictest sense: nothing outside this book reads a
   trophy, which is what keeps a Donation Store item the right side of
   config/donationStore.js's own no-pay-to-win rule. */
.slayer-log-trophy {
  padding: 1px 6px;
  border-radius: 3px;
  border: 1px solid currentColor;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.04em;
}

.slayer-log-trophy-bronze { color: #8c5a2b; background: rgba(140, 90, 43, 0.13); }
.slayer-log-trophy-silver { color: #667581; background: rgba(102, 117, 129, 0.13); }
.slayer-log-trophy-gold { color: #9a7714; background: rgba(154, 119, 20, 0.15); }

/* Platinum outranks gold, and on a parchment page it cannot say so the way it would on a
   dark one — a true platinum is a near-white metal, which is the least legible thing there
   is against #ecdcb6. So it takes a cold blue-steel ink (nothing else in the book is cool
   blue; silver is deliberately the warmer grey) and is the ONLY chip with a sheen across
   it. A polished streak where every other rung is flat is what reads as "above gold" at
   this size, where one more colour would only read as "a fourth colour". */
.slayer-log-trophy-platinum {
  color: #3f5f78;
  background: linear-gradient(115deg, rgba(63, 95, 120, 0.10), rgba(168, 198, 219, 0.45) 45%, rgba(63, 95, 120, 0.10));
}

/* The counted form — page subhead and book footer. Chips sit tight together there because
   they are a score line, not labels on a row. */
.slayer-log-trophy-tally {
  display: inline-flex;
  gap: 4px;
}

/* The subhead is pinned to one height for THIS book only (the Lore Book's own is left
   alone), because a trophy chip's 1px border makes it a pixel taller than the bare italic
   line — and a page with trophies earned was measurably taller than one without, moving
   the whole window as you turned through. The same page-turn resize the fixed list height
   above exists to prevent, just small enough to be missed. Centred rather than
   baseline-aligned, since a chip and a sentence have no shared baseline worth keeping. */
.slayer-log-gump .lore-book-subhead {
  height: 16px;
  align-items: center;
}

/* The book's last line, ruled off from the page above it. A fixed height so turning to a
   page with no trophies earned cannot shorten the window — the same lesson the fixed list
   height above records. */
.slayer-log-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  height: 26px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid rgba(69, 63, 47, 0.45);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  color: #5c5647;
}

/* The Harvest Logbook (views/partials/harvestLogGump.ejs, public/js/harvestLog.js) — the
   Slayer Logbook's green-bound twin. It wears the slayer book's whole skeleton AND its
   row classes (.slayer-log-list/-row/-count/-detail, the trophy chips): the two ledgers
   are deliberately one visual language, told apart the way their sprites are — by the
   cover. This scope re-dresses only that: forest-green leather over a bark spine, the
   ash paper warmed back toward growing things, headings in moss rather than dried blood,
   and the crossed-tools mark where the slayer wears its skull. */
.harvest-log-gump {
  background: linear-gradient(160deg, #2f4a2a, #22371e 55%, #122010);
  border: 1px solid #060a04;
  border-left: 4px solid #5a4020;
}

.harvest-log-gump .skills-gump-header {
  color: #8fbf6a;
  border-bottom-color: rgba(90, 112, 60, 0.5);
}
.harvest-log-gump .skills-gump-header > span:first-child::before {
  content: '\2692\00a0\00a0'; /* hammer and pick */
  color: #b8963c;
}
.harvest-log-gump .skills-gump-close { color: #8fbf6a; }

/* Paper with the life back in it — the slayer's cold ash warmed toward hay and moss. */
.harvest-log-gump .lore-book-page {
  background: linear-gradient(165deg, #ddd8bd, #c9c2a0 55%, #b0a983);
  border-color: #4f4c33;
  box-shadow: inset 0 0 18px rgba(24, 26, 14, 0.4);
  color: #292515;
}

.harvest-log-gump .lore-book-dungeon { color: #3d5222; }
.harvest-log-gump .lore-book-dungeon::before { content: '\2692\00a0\00a0'; font-size: 0.8em; color: rgba(122, 96, 32, 0.75); }
.harvest-log-gump .lore-book-dungeon::after { content: '\00a0\00a0\2692'; font-size: 0.8em; color: rgba(122, 96, 32, 0.75); }

.harvest-log-gump .lore-book-rule {
  background: radial-gradient(closest-side, rgba(61, 82, 34, 0.7), rgba(61, 82, 34, 0));
}

.harvest-log-gump .lore-book-plate {
  background: linear-gradient(160deg, #beb794, #a29b77);
  border-color: #4a4426;
  outline-color: rgba(74, 68, 38, 0.4);
}

.harvest-log-gump .lore-book-nav-btn {
  background: rgba(221, 216, 189, 0.08);
  border-color: rgba(122, 96, 32, 0.6);
  color: #ddd8bd;
}
.harvest-log-gump .lore-book-nav-btn:hover:not(:disabled) { background: rgba(90, 112, 60, 0.35); }
.harvest-log-gump .lore-book-pageno { color: #ddd8bd; }

/* A material actually gathered keeps the gold "on the record" edge; the never-gathered
   silhouette treatment comes with the row classes for free. */

/* ===================== The Wayfarer's Atlas ==========================================
   The third Logbook (views/partials/wayfarerAtlasGump.ejs), palette-only over the same
   .lore-book-*/.slayer-log-* skeleton its two siblings share — sea-blue leather and a
   weathered tan spine where the slayer wears black-on-blood and the harvest green-on-bark,
   and a compass rose for a glyph. Nothing here restates a layout rule; if a row or a
   trophy chip changes, all three books change together, which is the point. */
.wayfarer-atlas-gump {
  background: linear-gradient(160deg, #28496b, #1b3450 55%, #0d1c2e);
  border: 1px solid #050a10;
  border-left: 4px solid #9a7c4e;
}

.wayfarer-atlas-gump .skills-gump-header {
  color: #8fb8dd;
  border-bottom-color: rgba(60, 92, 128, 0.5);
}
.wayfarer-atlas-gump .skills-gump-header > span:first-child::before {
  content: '\1F9ED\00a0\00a0'; /* compass */
  color: #d8b976;
}
.wayfarer-atlas-gump .skills-gump-close { color: #8fb8dd; }

/* Chart paper — the harvest book's hay warmed the slayer's ash; this cools it toward the
   bleached vellum of something that has been at sea. */
.wayfarer-atlas-gump .lore-book-page {
  background: linear-gradient(165deg, #dcdcc9, #c6c8b2 55%, #aab098);
  border-color: #3f4a52;
  box-shadow: inset 0 0 18px rgba(16, 24, 32, 0.4);
  color: #1f2a30;
}

.wayfarer-atlas-gump .lore-book-dungeon { color: #23445f; }
.wayfarer-atlas-gump .lore-book-dungeon::before { content: '\2740\00a0\00a0'; font-size: 0.8em; color: rgba(122, 96, 32, 0.75); }
.wayfarer-atlas-gump .lore-book-dungeon::after { content: '\00a0\00a0\2740'; font-size: 0.8em; color: rgba(122, 96, 32, 0.75); }

.wayfarer-atlas-gump .lore-book-rule {
  background: radial-gradient(closest-side, rgba(35, 68, 95, 0.7), rgba(35, 68, 95, 0));
}

.wayfarer-atlas-gump .lore-book-plate {
  background: linear-gradient(160deg, #b6bda8, #9aa28c);
  border-color: #3f4a52;
  outline-color: rgba(63, 74, 82, 0.4);
}

.wayfarer-atlas-gump .lore-book-nav-btn {
  background: rgba(220, 220, 201, 0.08);
  border-color: rgba(154, 124, 78, 0.6);
  color: #dcdcc9;
}
.wayfarer-atlas-gump .lore-book-nav-btn:hover:not(:disabled) { background: rgba(60, 92, 128, 0.35); }
.wayfarer-atlas-gump .lore-book-pageno { color: #dcdcc9; }

/* The page's one-line description of what it collects. Sits under the rule, above the
   rows — the other two books have no equivalent because "Mining" needs no explaining,
   while "Waypoints" does. */
.wayfarer-atlas-blurb {
  margin: 4px 0 2px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  line-height: 1.35;
  color: rgba(31, 42, 48, 0.72);
}

/* A row's plate holds a cropped BACKDROP rather than a 44px item sprite, so the plate
   clips instead of centring a small icon. object-fit: cover keeps each place recognisable
   at thumbnail size rather than squashing a wide painting into a square. */
.wayfarer-atlas-plate {
  overflow: hidden;
  padding: 0;
}

.wayfarer-atlas-thumb {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  image-rendering: auto; /* a painted backdrop, not pixel art — let it scale smoothly */
}

/* Somewhere never visited is drawn as a place on a map you have not reached: greyed and
   dimmed rather than hidden, since an atlas is also a list of where there is still to go.
   Keyed off the absence of the shared "on the record" class its siblings already set. */
.wayfarer-atlas-gump .slayer-log-row:not(.slayer-log-row-slain) .wayfarer-atlas-thumb {
  filter: grayscale(0.85) brightness(0.75);
  opacity: 0.55;
}

/* ===================== The Craftsman's Ledger ========================================
   The fourth Logbook (views/partials/craftLogGump.ejs), palette-only over the same
   .lore-book-*/.slayer-log-* skeleton the other three share — oxblood leather and a dark
   steel spine, with an anvil for a glyph. Nothing here restates a layout rule. */
.craft-log-gump {
  background: linear-gradient(160deg, #6b2f28, #4d201b 55%, #2a100d);
  border: 1px solid #140705;
  border-left: 4px solid #7d8590;
}

.craft-log-gump .skills-gump-header {
  color: #e0a08f;
  border-bottom-color: rgba(125, 133, 144, 0.5);
}
.craft-log-gump .skills-gump-header > span:first-child::before {
  content: '\2692\00a0\00a0'; /* hammer and pick — the working mark */
  color: #cdd4dc;
}
.craft-log-gump .skills-gump-close { color: #e0a08f; }

/* Forge-warmed paper: the slayer's ash pushed toward scorched parchment. */
.craft-log-gump .lore-book-page {
  background: linear-gradient(165deg, #e0d4c0, #cbbda6 55%, #b0a189);
  border-color: #4a3a2e;
  box-shadow: inset 0 0 18px rgba(32, 20, 14, 0.4);
  color: #2c1f17;
}

.craft-log-gump .lore-book-dungeon { color: #6b2f28; }
.craft-log-gump .lore-book-dungeon::before { content: '\2692\00a0\00a0'; font-size: 0.8em; color: rgba(90, 96, 104, 0.8); }
.craft-log-gump .lore-book-dungeon::after { content: '\00a0\00a0\2692'; font-size: 0.8em; color: rgba(90, 96, 104, 0.8); }

.craft-log-gump .lore-book-rule {
  background: radial-gradient(closest-side, rgba(107, 47, 40, 0.7), rgba(107, 47, 40, 0));
}

.craft-log-gump .lore-book-plate {
  background: linear-gradient(160deg, #c4b6a0, #a89a84);
  border-color: #4a3a2e;
  outline-color: rgba(74, 58, 46, 0.4);
}

.craft-log-gump .lore-book-nav-btn {
  background: rgba(224, 212, 192, 0.08);
  border-color: rgba(125, 133, 144, 0.6);
  color: #e0d4c0;
}
.craft-log-gump .lore-book-nav-btn:hover:not(:disabled) { background: rgba(107, 47, 40, 0.35); }
.craft-log-gump .lore-book-pageno { color: #e0d4c0; }

/* The harvest strike — the same seal structure re-dressed: green leather, the mark in
   struck gold rather than wax-seal red. The metal chips and stamp/fade animations come
   with the slayer classes unchanged. */
.harvest-trophy-fx .slayer-trophy-seal {
  background: linear-gradient(160deg, #2f4a2a, #1b2a17 55%, #0c150a);
}
.harvest-trophy-fx .harvest-trophy-mark {
  color: #d9a93f;
  text-shadow: 0 0 10px rgba(217, 169, 63, 0.55);
}

/* The trophy strike (public/js/slayerLog.js's slayerLog:trophy) — the ceremony a rung
   crossing earns, for the earner alone. A wax-seal card stamped onto the middle of the
   screen: the creature's portrait under the skull, the rank named in its metal, all of it
   over a brief dark vignette so the moment owns the screen without hiding it. Stamps in
   with overshoot, holds, fades — the whole thing is gone in ~4s and pointer-events: none
   throughout, so nothing about a fight is ever blocked. z-index 10050: above the gump
   tier (10000 — the seal should land on top of an open Logbook), below the death screen
   and admin broadcast, which outrank any celebration. */
.slayer-trophy-fx {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 10050;
  background: radial-gradient(closest-side at 50% 46%, rgba(10, 6, 4, 0.55), rgba(10, 6, 4, 0) 70%);
  animation: slayer-trophy-fade 4s ease forwards;
}

/* The metal each rank stamps in — the fx root sets one variable and everything below
   (ring, rank text, skull glow) reads it, so a future rung is one class here. Brighter
   than the book's in-page chips on purpose: these sit on a dark vignette, not on bone
   paper. */
.slayer-trophy-fx-bronze { --slayer-trophy-metal: #c98a4b; --slayer-trophy-glow: rgba(201, 138, 75, 0.5); }
.slayer-trophy-fx-silver { --slayer-trophy-metal: #ccd6de; --slayer-trophy-glow: rgba(204, 214, 222, 0.5); }
.slayer-trophy-fx-gold { --slayer-trophy-metal: #e9c34a; --slayer-trophy-glow: rgba(233, 195, 74, 0.5); }
.slayer-trophy-fx-platinum { --slayer-trophy-metal: #d7e9f7; --slayer-trophy-glow: rgba(215, 233, 247, 0.55); }

.slayer-trophy-seal {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 18px 34px 16px;
  background: linear-gradient(160deg, #33302f, #1a1615 55%, #0d0a09);
  border: 2px solid var(--slayer-trophy-metal);
  border-radius: 8px;
  outline: 1px solid rgba(0, 0, 0, 0.8);
  outline-offset: 3px;
  box-shadow: 0 0 34px rgba(0, 0, 0, 0.75), 0 0 18px var(--slayer-trophy-glow);
  animation: slayer-trophy-stamp 420ms cubic-bezier(0.2, 1.6, 0.35, 1) both;
}

.slayer-trophy-skull {
  font-size: calc(20px * var(--ui-font-scale, 1));
  line-height: 1;
  color: #8c2f22;
  text-shadow: 0 0 10px rgba(200, 50, 32, 0.6);
}

.slayer-trophy-portrait {
  width: 56px;
  height: 56px;
  object-fit: contain;
  image-rendering: pixelated;
  margin: 4px 0 2px;
  filter: drop-shadow(0 0 8px var(--slayer-trophy-glow));
}

.slayer-trophy-rank {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(16px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--slayer-trophy-metal);
  text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
}

.slayer-trophy-creature {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
  color: #d9d4c4;
}

@keyframes slayer-trophy-stamp {
  0% { transform: scale(2.1) rotate(-7deg); opacity: 0; }
  62% { transform: scale(0.94) rotate(0.6deg); opacity: 1; }
  100% { transform: scale(1) rotate(0deg); opacity: 1; }
}

/* The fade owns the OUTER node (vignette and seal together), timed so the seal reads at
   full strength for ~3s and is gone by 4 — matching the JS removal timer's 4.2s. */
@keyframes slayer-trophy-fade {
  0%, 72% { opacity: 1; }
  100% { opacity: 0; }
}

/* The quest completion seal (public/js/questLog.js's quest:complete) — the trophy strike's
   sibling, and deliberately built ON its keyframes: same stamp-with-overshoot, same
   vignette, same 4s life, so the two ceremonies read as one family. What tells them apart
   at a glance is temperature — the Logbook stamps in a slain creature's cold metal, this
   stamps in gilt and candlelight, because one is a tally of the dead and the other is work
   brought home. Same z-index tier and pointer-events: none, for the same reasons. */
.quest-complete-fx {
  --quest-complete-metal: #ffd76a;
  --quest-complete-glow: rgba(255, 207, 92, 0.55);
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
  z-index: 10050;
  background: radial-gradient(closest-side at 50% 46%, rgba(12, 8, 3, 0.55), rgba(12, 8, 3, 0) 70%);
  animation: slayer-trophy-fade 4s ease forwards;
}

.quest-complete-seal {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 18px 36px 16px;
  background: linear-gradient(160deg, #3a3226, #1d1712 55%, #0f0b06);
  border: 2px solid var(--quest-complete-metal);
  border-radius: 8px;
  outline: 1px solid rgba(0, 0, 0, 0.8);
  outline-offset: 3px;
  box-shadow: 0 0 34px rgba(0, 0, 0, 0.75), 0 0 22px var(--quest-complete-glow);
  animation: slayer-trophy-stamp 420ms cubic-bezier(0.2, 1.6, 0.35, 1) both;
}

.quest-complete-star {
  font-size: calc(20px * var(--ui-font-scale, 1));
  line-height: 1;
  color: var(--quest-complete-metal);
  text-shadow: 0 0 12px var(--quest-complete-glow);
}

.quest-complete-emblem {
  width: 56px;
  height: 56px;
  object-fit: contain;
  image-rendering: pixelated;
  margin: 4px 0 2px;
  filter: drop-shadow(0 0 8px var(--quest-complete-glow));
}

.quest-complete-heading {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(16px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--quest-complete-metal);
  text-shadow: 0 1px 0 rgba(0, 0, 0, 0.8);
}

.quest-complete-name {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-style: italic;
  color: #e8dfc8;
}

/* The half that keeps this honest — nothing was handed in, and the card itself says so. */
.quest-complete-hint {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(11px * var(--ui-font-scale, 1));
  letter-spacing: 0.04em;
  color: #b8a877;
  margin-top: 3px;
}

/* Animal Lore's Grandmaster insta-lore button, at the LEFT of the combat header
   (views/partials/combatGump.ejs) — opposite Flee, so a panicked Flee click can't hit it.
   Deliberately quieter than Flee: it's a convenience, not an escape. */
/* The Combat gump's dismiss x (views/partials/combatGump.ejs). Deliberately quieter than
   Flee beside it — closing the window is not a combat action, and the two must never be
   mistaken for each other mid-fight. */
.combat-gump-dismiss {
  margin-left: 8px;
  padding: 0 4px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: calc(17px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: pointer;
}

.combat-gump-dismiss:hover { color: var(--text); }

/* Collapse-to-icon, sitting just before the × and styled as its quieter twin — the two are
   both "put this away", so they read as a pair rather than as an action and a control. */
.combat-gump-minimize {
  margin-left: 8px;
  padding: 0 4px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: calc(17px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: pointer;
}

.combat-gump-minimize:hover { color: var(--text); }

/* The collapsed state (public/js/combatPanel.js). !important on purpose, and it is the
   entire trick: combat.js, bandage.js, spellCast.js and corpseLoot.js all drive this
   overlay's INLINE display for their own reasons, and a stylesheet !important beats a
   non-important inline style — so those modules keep full control of whether the panel
   wants to be up, and this one class decides whether the player sees it. Nothing has to
   intercept, mirror or agree with any of them. */
#combat-gump-overlay.combat-panel-collapsed {
  display: none !important;
}

/* What the panel collapses INTO: one small round button, bottom-right, that only exists
   while there is actually something behind it (a live fight, a respawn wait, a haul being
   revealed). Clicking it opens the panel again. Fixed to the viewport and appended to
   <body> by its own module — see the containing-block note there. */
.combat-panel-icon {
  position: fixed;
  right: 18px;
  bottom: 24px;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: var(--panel);
  border: 1px solid var(--edge);
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
  color: var(--text);
  font-size: calc(21px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: pointer;
  z-index: 10000; /* the gump tier — it stands in for a gump */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  transition: transform 120ms ease, border-color 200ms ease, box-shadow 200ms ease;
}

.combat-panel-icon:hover {
  transform: scale(1.08);
  border-color: var(--accent);
}

.combat-panel-icon-glyph {
  pointer-events: none;
}

/* A kill dropped something. The icon goes gold and pulses rather than merely showing a
   count, because the whole point of collapsing the panel is that the player isn't watching
   this corner — a number alone would be missed. */
.combat-panel-icon.has-loot {
  border-color: #ffd27a;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 12px rgba(255, 200, 110, 0.55);
  animation: combat-panel-loot-pulse 1600ms ease-in-out infinite;
}

@keyframes combat-panel-loot-pulse {
  0%, 100% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 8px rgba(255, 200, 110, 0.35); }
  50% { box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 18px rgba(255, 200, 110, 0.8); }
}

@media (prefers-reduced-motion: reduce) {
  .combat-panel-icon.has-loot { animation: none; }
  .combat-panel-icon:hover { transform: none; }
}

/* How many rows are waiting behind the icon. */
.combat-panel-icon-badge {
  position: absolute;
  top: -4px;
  right: -4px;
  min-width: 17px;
  height: 17px;
  padding: 0 4px;
  border-radius: 9px;
  background: #ffd27a;
  color: #241a08;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(17px * var(--ui-font-scale, 1));
  text-align: center;
  pointer-events: none;
}

/* The explanatory line under a checkbox in Options, indented past the box itself so it
   reads as belonging to the setting above it rather than as a new one. */
.options-checkbox-hint {
  margin: 2px 0 0 22px;
}

.combat-lore-btn {
  margin-right: 8px;
  padding: 2px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: pointer;
}

.combat-lore-btn:hover { border-color: var(--accent); color: var(--text); }

/* Barding row (views/partials/combatGump.ejs, public/js/barding.js) — the two song
   buttons. Sized to fill the row evenly rather than sitting left-aligned, since there are
   only ever exactly two and a bard picks between them constantly mid-fight; a small
   left-hugging pair would be a needlessly fiddly target. Shares the .combat-lore-btn
   surface treatment above so it reads as part of the same gump. */
.combat-barding-buttons {
  display: flex;
  gap: 6px;
}

.combat-barding-btn {
  flex: 1;
  padding: 5px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}

.combat-barding-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--text); }

/* Disabled covers both the shared cooldown and the in-flight window between clicking and
   the server's reply, so it needs to read as "wait", not as "broken". */
.combat-barding-btn:disabled { opacity: 0.45; cursor: default; }

/* "Keep playing" — the continuous-barding tickbox under the two song buttons. Sits on
   its own line and stays muted: it's a set-and-forget switch, not a third action
   competing with the buttons above it. */
.combat-barding-loop {
  display: flex;
  align-items: center;
  gap: 6px;
  margin-top: 6px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: pointer;
  user-select: none;
}

.combat-barding-loop input { cursor: pointer; }
.combat-barding-loop:hover { color: var(--text); }

/* Tool Bag "Harvest / Craft" launcher (views/partials/toolBagLauncherGump.ejs) — the
   same .skills-gump shell as Manage Tools, but full-width clickable rows under two
   headings rather than a roster with per-row buttons: the whole row IS the action here,
   so there's nothing to aim at inside it. */
.tool-bag-launcher { width: 380px; }

.tool-bag-launcher-body {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 6px 14px 12px;
  max-height: 60vh;
  overflow-y: auto;
}

.tool-bag-launcher-heading {
  margin-top: 8px;
  padding: 4px 2px 3px;
  border-bottom: 1px solid var(--edge);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.tool-bag-launcher-heading:first-child { margin-top: 0; }

.tool-bag-launcher-row {
  display: grid;
  grid-template-columns: 1fr auto auto;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 6px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  font: inherit;
  /* Load-bearing, and easy to lose: this row is a real <button>, and the `font` shorthand
     above does NOT carry colour. Without this the button falls back to the browser's own
     default — black — so on every dark theme the skill name rendered near-invisible. The
     tool name and charge count only escaped it because they each set their own colour. */
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-align: left;
  cursor: pointer;
}

.tool-bag-launcher-row:hover {
  border-color: var(--accent);
  background: rgba(255, 255, 255, 0.06);
}

/* The tool this skill is already armed with — a left edge rather than a full-row tint,
   so it reads as a status marker and not as a disabled/selected state (every row here
   stays clickable regardless). */
.tool-bag-launcher-row-active { border-left: 3px solid var(--accent); }

.tool-bag-launcher-skill { font-weight: bold; }

.tool-bag-launcher-tool {
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tool-bag-launcher-charges {
  min-width: 46px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: right;
}

/* An unowned theme row (public/js/themes.js) adds a Preview button alongside Buy — that's
   a third element (on top of the swatch + name/description already there) squeezed into
   the same 360px .stable-roster width the Stable/Tool Bag rows get away with at two. Wraps
   the whole price+Preview+Buy group as one unit (never one button orphaned on its own line)
   rather than loosening .stable-mount-row itself, which the Stable Master/Tool Bag rows
   still rely on staying a plain single-line flex row. */
.theme-mount-row {
  flex-wrap: wrap;
}

.theme-buy-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
}

/* The active preview's own button, mid-countdown — same shape as :disabled elsewhere but
   left readable (not the usual greyed-out .stable-buy-btn:disabled look) since the text
   itself ("Previewing… 12s") is the whole point of still being visible. */
.theme-preview-btn.theme-preview-active {
  background: rgba(var(--accent-rgb), 0.35);
  color: var(--accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, default;
}

/* Rides alongside the Select button on an OWNED theme's row for the rare moment a preview
   outlives its own row's Preview button — e.g. the previewed theme gets bought mid-preview,
   which flips that row from the unowned (Preview+Buy) layout to the owned (Select) one. The
   temporary look is still live and still fading out on its own timer either way; this just
   keeps that fact visible instead of the countdown silently vanishing from the row. */
.theme-preview-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--accent);
  font-style: italic;
  white-space: nowrap;
}

/* A brief full-screen dim that masks the instant CSS-custom-property swap a theme change
   really is (public/js/themes.js's fadeToThemeClass) as a soft fade instead of a jarring
   hard cut — cheaper and far lower-risk than adding a `transition` to every themed
   panel/button/border in the game (which reads its colors from these same custom
   properties). Sits above every gump/popup/modal (highest z-index in the file is the
   stack-split popup's 10002) so the mask genuinely covers the whole screen, not just the
   Themes gump itself. */
.theme-preview-fade-overlay {
  position: fixed;
  inset: 0;
  z-index: 10100;
  background: #000;
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms ease;
}

.tool-bag-activate-btn.tool-bag-active {
  background: var(--accent);
  color: var(--on-accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Manage Tools dustbin — deliberately quiet (dimmed, no button chrome) next to the
   Activate button, so a destructive control never competes for the eye with the one
   you actually press every session. */
.tool-bag-discard-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 24px;
  height: 24px;
  padding: 0;
  background: none;
  border: none;
  border-radius: 3px;
  opacity: 0.45;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  transition: opacity 0.12s ease, background 0.12s ease;
}

.tool-bag-discard-btn img {
  width: 18px;
  height: 18px;
  image-rendering: pixelated;
  pointer-events: none;
}

.tool-bag-discard-btn:hover { opacity: 1; }

/* Armed (one click in, awaiting the confirming second) — flashes red so it's obvious
   the next press actually bins the charges. */
.tool-bag-discard-btn.tool-bag-discard-armed {
  opacity: 1;
  background: rgba(192, 57, 43, 0.85);
  animation: tool-bag-discard-pulse 0.8s ease-in-out infinite;
}

@keyframes tool-bag-discard-pulse {
  0%, 100% { background: rgba(192, 57, 43, 0.85); }
  50% { background: rgba(192, 57, 43, 0.35); }
}

.friends-add-form {
  display: flex;
  gap: 8px;
  padding: 0 14px 10px;
}

.friends-add-form input {
  flex: 1;
  padding: 6px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  font-family: inherit;
  border-radius: 3px;
}

.friends-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 0 14px 14px;
  max-height: 360px;
  overflow-y: auto;
}

.friends-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 4px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.friends-status-dot {
  width: 9px;
  height: 9px;
  border-radius: 50%;
  flex-shrink: 0;
}

.friends-status-dot.online { background: #6fcf6f; box-shadow: 0 0 5px rgba(111, 207, 111, 0.7); }
.friends-status-dot.amber { background: #e0a030; box-shadow: 0 0 5px rgba(224, 160, 48, 0.7); }
.friends-status-dot.offline { background: var(--muted); }

.friends-row-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.friends-row-name { font-size: calc(13px * var(--ui-font-scale, 1)); }
.friends-row-detail {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Color/hover come from the shared .stable-buy-btn ember-chip theme (also applied as a
   second class in public/js/friends.js) — this rule only supplies the sizing that class
   doesn't already set. Was solid var(--edge) (a washed-out translucent fill with no
   hover state), which read as disabled rather than as the gump's one real action button. */
.friends-message-btn {
  padding: 3px 10px;
  font-weight: bold;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.friends-empty {
  padding: 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
}

/* --- Trash Barrel (views/partials/trashBarrelGump.ejs, public/js/trashBarrel.js) ------
   A queue, not a container: every row is something already condemned, and the draining
   bar is the point of the whole gump — it is the five minutes you have left to change
   your mind. Reads off the same Slate & Ember tokens as everything else; the one literal
   colour is the warning red the bar drains toward. */
.trash-barrel-gump { width: 340px; }

/* No padding of its own any more — .fixture-gump-body wraps this whole panel now, and the
   hint being inset while the list below it was not is exactly the piecemeal drift that
   wrapper exists to end. */
.trash-barrel-hint {
  margin: 0 0 8px;
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.45;
}

.trash-barrel-list { max-height: 46vh; overflow-y: auto; }

.trash-barrel-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.trash-barrel-row-icon {
  width: 28px;
  height: 28px;
  image-rendering: pixelated;
  flex: 0 0 auto;
}

.trash-barrel-row-main { flex: 1 1 auto; min-width: 0; }

.trash-barrel-row-name {
  color: var(--ink);
  font-size: calc(13px * var(--ui-font-scale, 1));
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Drains left to right as the wait runs out. Red rather than the ember accent because
   this bar filling up is not progress toward anything you want. */
.trash-barrel-row-bar {
  margin-top: 3px;
  height: 3px;
  background: var(--well);
  border-radius: 2px;
  overflow: hidden;
}
.trash-barrel-row-bar span {
  display: block;
  height: 100%;
  background: linear-gradient(90deg, #c4484a, #e07a5f);
}

.trash-barrel-row-time {
  flex: 0 0 auto;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-variant-numeric: tabular-nums;
  min-width: 56px;
  text-align: right;
}

.trash-barrel-take-btn {
  flex: 0 0 auto;
  padding: 3px 8px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: pointer;
}
.trash-barrel-take-btn:hover { background: var(--panel); color: var(--accent); }

.trash-barrel-empty {
  padding: 10px;
  color: var(--ink);
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-style: italic;
  text-align: center;
}

/* Dragging something over the open gump — the second way to bin a thing, alongside
   dropping straight onto the barrel on the floorplan. */
.trash-barrel-gump-overlay.trash-barrel-drag-over .trash-barrel-gump,
#trash-barrel-gump-overlay.trash-barrel-drag-over .trash-barrel-gump {
  outline: 2px dashed var(--accent);
  outline-offset: -4px;
}

/* --- Virtue Shop section headings (config/virtueShop.js's getVirtueShopSections) ------
   The shop used to be one flat grid. Splitting relics from account upgrades needed a
   divider that reads as a shelf label rather than another card. */
.virtue-shop-section-heading {
  margin: 14px 4px 8px;
  padding-bottom: 5px;
  border-bottom: 1px solid var(--edge);
  color: var(--accent);
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.virtue-shop-section-heading:first-child { margin-top: 4px; }

/* --- Alpha Event leaderboard (views/partials/alphaEventGump.ejs) ---------------------
   Row layout copies .admin-player-row's CSS-grid idiom rather than a flex row, so rank,
   name and total line up in columns however long a character name is. Everything is
   theme-token driven, so the panel recolours with the player's selected theme like every
   other gump. The gold accent is the event's own colour, matching the token sprite. */
.alpha-event-gump { width: 320px; }

.alpha-event-header-btn {
  color: #f0cb6d;
  border-color: rgba(240, 203, 109, 0.45);
}

.alpha-event-status {
  padding: 8px 12px 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
}

/* Live events get the accent treatment; a finished one stays muted, so the two states are
   distinguishable at a glance without reading the sentence. */
.alpha-event-status-live {
  color: #f0cb6d;
  font-weight: bold;
}

.alpha-event-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 320px;
  overflow-y: auto;
  padding: 0 8px;
}

.alpha-event-row {
  display: grid;
  grid-template-columns: 32px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 5px 6px;
  border-radius: 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: var(--shade);
}

/* The viewer's own row, wherever it appears — in the ranked list or pinned below it. */
.alpha-event-row-self {
  background: rgba(240, 203, 109, 0.14);
  border: 1px solid rgba(240, 203, 109, 0.4);
}

.alpha-event-rank {
  text-align: center;
  color: var(--muted);
  font-weight: bold;
}

.alpha-event-name {
  color: var(--accent);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Renders an em dash for every player but the viewer — the server never sends anyone
   else's number, so there is nothing here to reveal. */
.alpha-event-tokens {
  text-align: right;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.alpha-event-you { padding: 6px 8px 0; }

.alpha-event-empty,
.alpha-event-hint {
  padding: 10px 14px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
  line-height: 1.5;
}

/* The Spellbook used to borrow the Bank/Trade ornate commissioned-frame treatment
   ("SPELLBOOK" carved into its own violet-tinted spellbook_gump_frame.png) — now the same
   plain .skills-gump panel every other gump uses, with just an arcane-violet accent (top
   border glow, tinted header, a small star flanking the title) for a bit of mythical
   character instead of a dedicated frame asset. Matches the violet already used for
   circle headings/hover rows below (see the grouped button rules near the end of this
   file, which deliberately exclude .spellbook-cast-btn). */
/* .skills-gump.spellbook-gump (not just .spellbook-gump) — the shared "ember hairline"
   rule further down (.skills-gump's own box-shadow/border-top-color, applied to every
   major panel) is also a single-class selector, so this needs the extra specificity to
   actually win the violet accent instead of the shared ember one. */
.skills-gump.spellbook-gump {
  width: 400px;
  border-top-color: rgba(183, 156, 232, 0.55);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(183, 156, 232, 0.12);
}

.spellbook-gump .skills-gump-header {
  color: #b79ce8;
}

.spellbook-gump .skills-gump-header span::before {
  content: '✦ ';
  opacity: 0.7;
}

.spellbook-roster {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 10px 14px 14px;
  max-height: 420px;
  overflow-y: auto;
}

.spellbook-circle-heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 8px;
  margin-top: 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: #b79ce8;
  border-bottom: 1px solid rgba(160, 120, 230, 0.22);
  padding-bottom: 2px;
}

/* The circle's fixed mana cost, shown once on the heading as a column header for the
   rows below (every spell in the circle costs the same — config/spells.js). */
.spellbook-circle-mana {
  font-size: calc(10px * var(--ui-font-scale, 1));
  letter-spacing: 0.04em;
  color: #8aa6c9;
  white-space: nowrap;
}

/* A small arcane star leads each circle heading — text-based, no sprite needed. On the
   label span (not the heading) so the flex row's space-between keeps it hugging the
   circle name instead of drifting to the far left. */
.spellbook-circle-heading > span:first-child::before {
  content: '✦ ';
  color: rgba(183, 156, 232, 0.6);
}

.spellbook-circle-heading:first-child {
  margin-top: 0;
}

.spellbook-spell-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 6px;
  margin: 0 -6px;
  border-radius: 4px;
}

/* The row's spell icon tile (tools/generateSpellIcons.js) — doubles as the drag
   handle for tearing a castable macro icon off onto the screen
   (public/js/spellIcons.js). Unlearned rows grey it out and drag is refused. */
.spellbook-spell-icon {
  width: 24px;
  height: 24px;
  flex: none;
  image-rendering: pixelated;
  border-radius: 3px;
  cursor: grab;
  touch-action: none; /* pointer-drag owns the gesture — without this, touch scroll steals it */
}

.spellbook-spell-row[data-known="false"] .spellbook-spell-icon {
  filter: grayscale(1) opacity(0.45);
  cursor: default;
}

/* --- Floating spell macro icons (public/js/spellIcons.js): UO-style tear-off cast
   buttons. Left-click casts, right-click dismisses, drag moves (snapped neighbours
   move as a group). z 2500 — ABOVE the whole panel/gump band (gumpWindow.js's shared
   counter, 950 + one per bring-to-front, includes every main-screen panel, so
   anything under it can end up buried beneath the base UI) but below the modal band
   (3000+): a cast button that hides under a window isn't a cast button. --- */
.spell-macro-icon {
  position: fixed;
  width: 36px;
  height: 36px;
  z-index: 2500;
  image-rendering: pixelated;
  border-radius: 4px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  touch-action: none;
  user-select: none;
}

.spell-macro-icon:hover {
  filter: brightness(1.18);
}

.spell-macro-icon.spell-macro-dragging {
  filter: brightness(1.1);
  box-shadow: 0 6px 14px rgba(0, 0, 0, 0.6);
  cursor: grabbing;
}

/* Click feedback — a quick press-and-glow pulse confirming the cast was sent (the
   cast bar / fizzle / mana toast is the real outcome channel). */
@keyframes spell-macro-cast-pulse {
  0% { transform: scale(0.86); filter: brightness(1.7); }
  100% { transform: scale(1); filter: brightness(1); }
}
.spell-macro-icon.spell-macro-cast {
  animation: spell-macro-cast-pulse 260ms ease-out;
}

/* The ghost that follows the pointer while tearing an icon out of the book. */
.spell-macro-icon.spell-macro-ghost {
  opacity: 0.75;
  pointer-events: none;
  z-index: 3500; /* above the spellbook gump while mid-tear */
}

/* Hidden during the full-screen travel backdrop (public/js/travel.js toggles this body
   class) — parked icons sit fixed at z 2500, high enough to otherwise float right on
   top of the road scene, which reads as clutter over a moment that isn't interactive
   anyway (nothing to cast at mid-trip). Positions/state aren't touched, just display —
   they reappear exactly where they were left the instant the trip ends. */
body.is-traveling .spell-macro-icon {
  display: none;
}

/* The rare-gathering-prize opening bar (public/js/gatherPrizeOpen.js — Geode/Lost
   Satchel/Message in a Bottle's 6s reveal, chained once per unit for Open All). Not
   tied to any gump since the click that starts it can happen anywhere an icon is
   right-clickable, so it's its own small floating pill, top-center — just clear of the
   sticky .game-header (which tops out around 60px) so it never overlaps the XP bar/
   crest that header carries. z above the panel/gump band, below the modal band, same
   as the spell macro icons. */
.gather-prize-open-bar {
  position: fixed;
  left: 50%;
  top: 70px;
  transform: translateX(-50%);
  z-index: 2500;
  min-width: 220px;
  padding: 8px 14px;
  background: rgba(12, 12, 16, 0.88);
  border: 1px solid rgba(224, 168, 48, 0.4);
  border-radius: 8px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5);
  pointer-events: none;
}

.gather-prize-open-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #ffd98a;
  text-align: center;
  margin-bottom: 6px;
}

.gather-prize-open-track {
  height: 6px;
}

.gather-prize-open-fill {
  background: #e0a030;
  transition: width 100ms linear;
}

.spellbook-spell-row:hover {
  background: rgba(139, 99, 235, 0.08);
}

/* Unlearned spells sit dimmed on the page — readable as "in the book, not yet yours"
   at a glance, before the Not-learned label is even read. */
.spellbook-spell-row[data-known="false"] {
  opacity: 0.55;
}

.spellbook-spell-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.spellbook-spell-name {
  font-size: calc(13px * var(--ui-font-scale, 1));
}

/* The power words are the mystical part — set in the frame's arcane violet rather
   than the generic muted grey. */
.spellbook-spell-words {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #9d86cc;
  font-style: italic;
}

.spellbook-spell-mana {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #8aa6c9;
  white-space: nowrap;
}

.spellbook-cast-btn {
  padding: 3px 12px;
  background: rgba(139, 99, 235, 0.12);
  border: 1px solid rgba(168, 130, 255, 0.45);
  border-radius: 5px;
  color: #cdb8f5;
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.spellbook-cast-btn:hover:not(:disabled) {
  background: #8b63eb;
  border-color: #a882ff;
  color: #16110b;
  box-shadow: 0 0 10px rgba(139, 99, 235, 0.55);
}

.spellbook-cast-btn:disabled {
  background: rgba(255, 255, 255, 0.05);
  border-color: rgba(255, 255, 255, 0.1);
  color: #6d7480;
}

.spellbook-locked-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #6d7480;
  font-style: italic;
  white-space: nowrap;
}

/* Master Auto Magery on/off — sits above the tabs since it gates both (services/
   autoMagery.js's evaluators both check it before anything else). */
.magery-master-toggle-row {
  padding: 10px 14px 0;
}

.magery-master-toggle-btn {
  width: 100%;
  padding: 6px 10px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(160, 120, 230, 0.3);
  border-radius: 5px;
  color: #9d86cc;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.magery-master-toggle-btn[data-enabled="true"] {
  background: #8b63eb;
  border-color: #a882ff;
  color: #16110b;
}

/* Cast / Auto tabs — same toggle-active-class shape as .session-gains-tabs, recolored
   into the Spellbook's own arcane violet instead of the generic ember accent. */
.spellbook-tabs {
  display: flex;
  gap: 6px;
  padding: 10px 14px 0;
}

.spellbook-tab-btn {
  flex: 1;
  padding: 5px 6px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid rgba(160, 120, 230, 0.22);
  border-radius: 4px;
  color: #9d86cc;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.spellbook-tab-btn.active {
  background: #8b63eb;
  border-color: #a882ff;
  color: #16110b;
}

.spellbook-tab-panel {
  display: none;
}

.spellbook-tab-panel.active {
  display: block;
}

.magery-rules-empty {
  padding: 16px 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #6d7480;
  font-style: italic;
  text-align: center;
}

.magery-rules-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 10px 14px 0;
  max-height: 350px;
  overflow-y: auto;
}

/* Column headers for the Auto rule rows. Same 8 grid tracks + gap as .magery-rule-row, and
   the same 21px left/right inset (14px list padding + a row's own 6px padding + 1px border)
   so every label sits directly over its column. Shown/hidden with the list's empty state
   (public/js/autoMagery.js). */
.magery-rules-header {
  display: grid;
  grid-template-columns: 22px 178px 1fr 55px 68px 26px 26px 26px;
  gap: 5px;
  padding: 8px 21px 2px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: #9d86c9;
}

.magery-rules-header span {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* 8 columns. Condition is a fixed width sized to fit its longest label ("Buff Missing
   (Out of Combat)") without clipping; spell takes whatever's left via 1fr so it grows
   as the gump widens instead of a guessed fr-ratio split that clips long spell labels
   ("Magic Reflection — needs 70 Magery") at the old narrower width. */
.magery-rule-row {
  display: grid;
  grid-template-columns: 22px 178px 1fr 55px 68px 26px 26px 26px;
  align-items: center;
  gap: 5px;
  padding: 6px;
  background: rgba(139, 99, 235, 0.06);
  border: 1px solid rgba(160, 120, 230, 0.18);
  border-radius: 5px;
}

.magery-rule-enabled-checkbox {
  width: 15px;
  height: 15px;
  margin: 0;
  accent-color: #8b63eb;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.magery-rule-condition-select, .magery-rule-spell-select {
  width: 100%;
  padding: 4px 5px;
  background: var(--well);
  border: 1px solid rgba(160, 120, 230, 0.3);
  border-radius: 3px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  min-width: 0;
}

.magery-rule-percent-input, .magery-rule-min-mana-input {
  width: 100%;
  padding: 4px 5px;
  background: var(--well);
  border: 1px solid rgba(160, 120, 230, 0.3);
  border-radius: 3px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.magery-rule-move-btn, .magery-rule-remove-btn {
  width: 100%;
  padding: 3px 0;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(160, 120, 230, 0.25);
  border-radius: 3px;
  color: #cdb8f5;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1;
  text-align: center;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.magery-rule-move-btn:hover, .magery-rule-remove-btn:hover {
  background: rgba(139, 99, 235, 0.25);
}

.magery-rule-remove-btn {
  color: #e0605a;
  font-weight: bold;
}

.magery-rules-actions {
  display: flex;
  gap: 8px;
  padding: 10px 14px 14px;
}

.magery-add-rule-btn, .magery-save-rules-btn {
  flex: 1;
  padding: 7px 10px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  border-radius: 5px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.magery-add-rule-btn {
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(160, 120, 230, 0.3);
  color: #cdb8f5;
}

.magery-add-rule-btn:hover {
  background: rgba(139, 99, 235, 0.2);
}

.magery-save-rules-btn {
  background: #8b63eb;
  border: 1px solid #a882ff;
  color: #16110b;
}

.magery-save-rules-btn:hover {
  background: #a075ff;
  box-shadow: 0 0 10px rgba(139, 99, 235, 0.55);
}

/* Generic crafting gump (Blacksmithing today, Tailoring/Tinkering later) — see
   views/partials/craftingGump.ejs/public/js/crafting.js. */
.crafting-gump {
  width: 420px;
}

.crafting-gump-body {
  padding: 10px 14px 14px;
  overflow-y: auto;
  min-height: 0; /* lets this flex child actually shrink/scroll instead of forcing .crafting-gump to grow to fit it */
}

.crafting-material-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
}

.crafting-material-row label {
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.crafting-material-select {
  flex: 1;
  background: var(--well);
  border: 1px solid var(--edge);
  color: var(--ink);
  border-radius: 4px;
  padding: 4px 6px;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

/* Floats at the top of .crafting-gump-body's own scroll area (position: sticky is
   relative to that ancestor, the nearest scrolling container) instead of scrolling away
   with the recipe list below it — so the preview stays visible the whole time you're
   browsing a long Weapons/Armor list. Needs a near-opaque background (matching --panel,
   not the previous much-more-transparent rgba(0,0,0,0.25)) so scrolled-under recipe rows
   don't show through once it's actually stuck in place. */
.crafting-preview-panel {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 10px;
  min-height: 60px;
  padding: 8px;
  margin-bottom: 10px;
  background: var(--panel);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  position: sticky;
  top: 0;
  z-index: 2;
}

/* The Unselect cross, pinned to the preview panel's top-right corner. Absolute rather
   than a flex child: the panel is a wrapping flex row whose contents vary by recipe
   (icon, stats, and a gain line that spans the full width), and a flow-positioned button
   would be pushed somewhere different for each one. The panel is `position: sticky`,
   which is a positioned ancestor, so this anchors to it without needing a `relative`
   added. Muted until hovered, like the journal cog — it is an undo, not an action to
   draw the eye. */
.crafting-preview-clear {
  position: absolute;
  top: 2px;
  right: 4px;
  padding: 0 4px;
  background: none;
  border: none;
  color: var(--muted);
  font-size: calc(16px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.crafting-preview-clear:hover {
  color: var(--accent);
}

/* The stats sit beside a 44px icon, and the cross overlaps the top-right corner where a
   long name would otherwise run underneath it. A little right padding on the panel keeps
   the two apart without moving anything when nothing is selected. */
.crafting-preview-panel {
  padding-right: 22px;
}

.crafting-preview-icon {
  width: 44px;
  height: 44px;
  image-rendering: pixelated;
  flex-shrink: 0;
}

.crafting-preview-placeholder {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
}

.crafting-preview-stats {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  line-height: 1.5;
}

.crafting-preview-name {
  font-weight: bold;
  color: var(--accent);
}

/* Always its own full-width row below the icon+stats above (flex-wrap on
   .crafting-preview-panel), and empty (no height) until a craft:previewResult actually
   fills it in — same per-line-kind coloring as the Crafting Bench's own
   .crafting-bench-preview-line* (see that block's own comment for the reasoning), just
   under this gump's own class prefix. */
.crafting-preview-gain {
  flex: 1 0 100%;
}
.crafting-preview-gain:empty {
  display: none;
}
.crafting-preview-line {
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.5;
}
.crafting-preview-line-headline {
  color: var(--ink);
}
.crafting-preview-line-threshold {
  color: var(--muted);
  font-style: italic;
}
.crafting-preview-line-warning {
  color: #e25c4f;
  font-weight: bold;
}

/* Collapsible sections — no prior precedent in this codebase; the header's arrow
   flips via .collapsed on the parent, body collapses via display:none. */
.crafting-section-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 6px 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--accent);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.crafting-section-toggle {
  transition: transform 0.15s ease;
}

.crafting-section.collapsed .crafting-section-toggle {
  transform: rotate(-90deg);
}

.crafting-section.collapsed .crafting-section-body {
  display: none;
}

.crafting-recipe-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 5px 4px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.crafting-recipe-row.selected {
  background: rgba(var(--edge-rgb), 0.12);
}

.crafting-recipe-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.crafting-recipe-name {
  font-size: calc(13px * var(--ui-font-scale, 1));
}

/* A recipe/material the character's skill hasn't unlocked yet — red instead of a
   "(needs X)" suffix; the requirement itself lives on hover (data-tooltip for recipe
   rows, the native title for <select> options, which the custom tooltip can't reach).
   Same red as the journal's fail lines. */
/* Crafting menu gain-state colours (services/craftingColor.js, kept live by
   public/js/craftSkillColor.js) — applied to both recipe-name spans and material/recipe
   <option>s. Red = can't craft (skill too low); yellow = still trains you well; green =
   only the 2% gain floor left; grey (the default name/option colour) = maxed, no gain. */
.crafting-recipe-name.recipe-locked,
option.recipe-locked,
option.recipe-locked-option {
  color: #e25c4f;
}

.crafting-recipe-name.recipe-gain-good,
option.recipe-gain-good {
  color: #e0c34a;
}

.crafting-recipe-name.recipe-gain-floor,
option.recipe-gain-floor {
  color: #5ec46a;
}

.crafting-recipe-cost {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.crafting-qty-input {
  width: 44px;
}

.crafting-craft-btn {
  padding: 3px 8px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(11px * var(--ui-font-scale, 1));
  white-space: nowrap;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The trade window used to borrow .bank-gump's ornate commissioned-frame treatment
   ("TRADE" baked into its own trade_gump_frame.png) — now the same plain .skills-gump
   panel every other gump uses, just sized for its two-column offer layout. */
.trade-gump {
  width: 420px;
}

.trade-pending {
  padding: 16px 14px;
  text-align: center;
}

.trade-pending-message {
  margin: 0 0 12px;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

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

.trade-pending-actions .trade-accept-btn,
.trade-pending-actions .trade-cancel-btn {
  width: auto;
  margin: 0;
}

.trade-columns {
  display: flex;
  gap: 10px;
  padding: 10px 14px 0;
}

.trade-column {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.trade-column-header {
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 6px;
}

.trade-item-list {
  flex: 1;
  min-height: 140px;
  max-height: 220px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 4px;
  overflow-y: auto;
}

.trade-item-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 3px 4px;
  border-radius: 3px;
}

.trade-item-list-mine .trade-item-row {
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.trade-item-list-mine .trade-item-row:hover {
  background: rgba(var(--edge-rgb), 0.15);
}

.trade-item-list-mine.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

.trade-item-row .item-icon {
  width: 28px;
  height: 28px;
}

.trade-item-name {
  font-size: calc(12px * var(--ui-font-scale, 1));
  flex: 1;
}

.trade-their-status {
  margin-top: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #7fd1a8;
  min-height: 16px;
}

.trade-accept-btn, .trade-cancel-btn {
  display: block;
  width: calc(100% - 28px);
  margin: 10px 14px 14px;
  padding: 6px 10px;
  border: none;
  border-radius: 3px;
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.trade-column .trade-accept-btn {
  width: 100%;
  margin: 8px 0 0;
}

.trade-accept-btn {
  background: var(--edge);
  color: var(--on-accent);
}

.trade-accept-btn:disabled {
  background: #4a3d28;
  color: #8a7a5a;
  cursor: default;
}

.trade-cancel-btn {
  background: rgba(226, 92, 79, 0.18);
  color: var(--ink);
}

/* The panel is resizable (public/js/admin.js passes resizable: true), which means
   gumpWindow.js writes an explicit pixel width/height onto the OVERLAY. These bounds are
   therefore set here rather than on .admin-gump, because the browser's own resize handle
   honours the min/max of the element it is resizing.

   min-width is 640 rather than something smaller on purpose: that is the width a roster
   row needs to keep its five action buttons on one line, and letting it be dragged
   narrower would silently reintroduce the wrap this width was chosen to fix. Height is
   the axis worth dragging anyway — it is the lists that grow. */
#admin-gump-overlay {
  min-width: 640px;
  min-height: 300px;
  max-width: calc(100vw - 40px);
  max-height: calc(100vh - 40px);
}

.admin-gump {
  /* Fills whatever size the overlay has been dragged to. Was a fixed 640px wide with a
     viewport-derived max-height, which is what a NON-resizable panel needs — left as-is
     it would simply ignore the handle. The 640 lives on the overlay's min-width above
     now, so the reason for that number is not lost. */
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

/* Title bar stays put as the drag handle; everything under it scrolls as one. */
.admin-gump > .skills-gump-header {
  flex: 0 0 auto;
}

.admin-gump-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* A flex column so the two lists can take the height gained by dragging the panel
     taller. Without this they keep their own fixed caps and a resized panel just grows
     its empty space, which makes the handle feel broken. */
  display: flex;
  flex-direction: column;
}

/* Everything in the body keeps its natural height... */
.admin-gump-body > * {
  flex: 0 0 auto;
}

/* ...except the two rosters, which share whatever is left over. They still scroll
   internally once their share runs out, and the min-heights stop the taller Manage
   Player console from squeezing either of them down to nothing. */
.admin-gump-body > .admin-player-list {
  flex: 1 1 auto;
  min-height: 120px;
  max-height: none;
}

.admin-gump-body > .admin-offline-list {
  flex: 1 1 auto;
  min-height: 96px;
  max-height: none;
}

.admin-broadcast-row {
  display: flex;
  gap: 8px;
  padding: 10px 14px 0;
}

.admin-broadcast-input {
  flex: 1;
  padding: 6px 8px;
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  border-radius: 3px;
}

.admin-broadcast-send-btn {
  padding: 6px 14px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: var(--accent);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.admin-broadcast-send-btn:hover { background: var(--accent-bright); }

.admin-player-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 14px;
  max-height: 260px;
  overflow-y: auto;
}

.admin-player-row {
  display: grid;
  /* The actions column used to be a bare `auto` track, which a CSS Grid can still
     squeeze below its 5-button content width once the row's total exceeds the gump's
     own width — the buttons don't wrap text, so the row visibly split onto two lines
     instead. minmax(...) floors it at its real content width so that can't happen. */
  grid-template-columns: 1.2fr 1fr 0.5fr 0.6fr minmax(230px, auto);
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.admin-player-name {
  font-weight: bold;
  color: var(--accent);
}

.admin-player-location, .admin-player-status, .admin-player-age {
  color: var(--muted);
}

/* Age reads as a short "3d"/"5h"/"12m" duration — right-aligned so it doesn't look like
   a stray word wedged between Location and Status. */
.admin-player-age {
  text-align: right;
}

.admin-player-actions {
  display: flex;
  flex-wrap: nowrap;
  gap: 3px;
  justify-content: flex-end;
}

.admin-player-action-btn {
  flex-shrink: 0;
  padding: 2px 5px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  background: var(--edge);
  color: var(--on-accent);
  border: none;
  border-radius: 3px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.admin-player-action-btn:hover {
  background: var(--accent);
}

/* --- All-Seeing Eye spectate chrome (public/js/adminSpectate.js) --- */

/* Full-viewport transparent shield that swallows every click/drag/scroll into the game UI
   while an admin is watching a player, so they can't fiddle with their own hidden
   character behind the spectated view. z 3400 — above the whole panel/gump/modal band but
   below the banner (3500) whose Stop button must stay clickable. This is UX only; the real
   read-only guarantee is server-side (the admin's socket.characterId never changes, so no
   action can ever affect the target). */
.spectate-input-shield {
  position: fixed;
  inset: 0;
  z-index: 3400;
  background: transparent;
  cursor: not-allowed;
  /* A quiet amber inset frame — a constant peripheral reminder you're in someone else's
     session. On the shield (guaranteed on top) rather than body, so it always shows. */
  box-shadow: inset 0 0 0 3px rgba(224, 160, 48, 0.55);
}

.spectate-banner {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 3500;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding: 7px 14px;
  background: linear-gradient(180deg, #3a2a12, #241a0c);
  border-bottom: 2px solid #e0a030;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
  color: #ffd98a;
  font-weight: bold;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.spectate-banner-label {
  letter-spacing: 0.03em;
}

.spectate-banner-stop {
  padding: 3px 12px;
  background: #e0a030;
  color: #1a1310;
  border: none;
  border-radius: 3px;
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.spectate-banner-stop:hover {
  background: #f2b944;
}

.admin-section-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
  padding: 0 14px;
}

/* Alpha Event lifecycle controls (views/partials/adminGump.ejs) — wraps, because the
   tie-break case renders one Award button per tied winner and there can be several. */
.admin-alpha-event-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 6px 14px;
}

.admin-alpha-event-result {
  display: flex;
  flex-direction: column;
  gap: 6px;
  padding: 0 14px 8px;
}

.admin-alpha-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.admin-offline-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 6px 14px 10px;
  max-height: 160px;
  overflow-y: auto;
}

.admin-offline-row {
  display: grid;
  grid-template-columns: 1.2fr 1fr 1fr 0.7fr;
  align-items: center;
  gap: 6px;
  padding: 4px 6px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.admin-offline-name {
  font-weight: bold;
  color: #e0a030;
}

.admin-offline-location, .admin-offline-activity, .admin-offline-elapsed {
  color: var(--muted);
}

.admin-offline-elapsed {
  text-align: right;
}

.admin-offline-empty {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  padding: 0 6px;
}

/* Click a row for what that player is actually owed on next login (public/js/admin.js's
   buildOfflineDetail). The list is short and the panel is cramped, so the detail expands
   inline underneath rather than opening a second window. */
.admin-offline-row-clickable {
  cursor: pointer;
}

.admin-offline-row-clickable:hover {
  border-color: #e0a030;
}

.admin-offline-detail {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin: -2px 0 4px;
  padding: 6px 10px;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid var(--edge);
  border-radius: 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.admin-offline-detail-line {
  display: grid;
  grid-template-columns: 110px 1fr;
  gap: 8px;
}

.admin-offline-detail-key {
  color: var(--muted);
}

.admin-offline-detail-value {
  color: var(--ink);
}

/* The two that an admin is scanning for: nothing is coming, or something is capping it
   well short of the time they have been away. */
.admin-offline-detail-blocked .admin-offline-detail-value {
  color: #d06060;
  font-weight: bold;
}

.admin-offline-detail-warn .admin-offline-detail-value {
  color: #e0a030;
}

.admin-offline-detail-total .admin-offline-detail-value {
  font-weight: bold;
}

.admin-inspect {
  margin-top: 8px;
  padding: 10px 14px 14px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.admin-inspect-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 6px;
  font-weight: bold;
  color: var(--accent);
}

.admin-inspect-close {
  background: none;
  border: none;
  color: var(--muted);
  font-size: calc(16px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.admin-inspect-gold {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #f0d878;
  margin-bottom: 8px;
}

.admin-inspect-section-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: var(--muted);
  margin-top: 8px;
}

.admin-inspect-items {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 6px 0;
}

.admin-inspect-row {
  display: flex;
  align-items: center;
  gap: 4px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  padding: 3px 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.admin-inspect-row .item-icon {
  width: 24px;
  height: 24px;
}

.admin-inspect-empty {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #6d7480;
  font-style: italic;
}

/* Manage Player console (views/partials/adminGump.ejs's #admin-manage, public/js/
   adminManage.js) — layered on top of the existing .admin-inspect look (margin/padding/
   border-top already come from that shared class); everything below is new surface the
   old read-only Inspect panel never needed: a rename+kick row, gold/stat editors, and a
   scrollable full skill list, each row reusing one shared input/button look. */
.admin-manage-badge {
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  padding: 1px 6px;
  border-radius: 8px;
  margin-left: 6px;
  font-weight: bold;
}

.admin-manage-badge.online {
  background: rgba(120, 200, 120, 0.18);
  color: #7fd88f;
}

.admin-manage-badge.offline {
  background: rgba(160, 160, 170, 0.15);
  color: var(--muted);
}

.admin-manage-row {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  margin-bottom: 6px;
}

.admin-manage-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.admin-manage-input {
  flex: 1;
  min-width: 60px;
  padding: 5px 7px;
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  border-radius: 3px;
}

.admin-manage-input-narrow {
  flex: 0 0 64px;
  min-width: 0;
}

.admin-manage-btn {
  padding: 5px 12px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  background: var(--accent);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.admin-manage-btn:hover { background: var(--accent-bright); }

.admin-manage-btn:disabled {
  background: var(--edge);
  color: var(--muted);
  cursor: not-allowed;
}

.admin-manage-btn-danger {
  background: #b8453f;
  color: #fff;
}

.admin-manage-btn-danger:hover { background: #d1544d; }

.admin-manage-stat-group {
  display: flex;
  align-items: center;
  gap: 4px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  padding: 4px 6px;
}

.admin-manage-skills {
  display: flex;
  flex-direction: column;
  gap: 3px;
  max-height: 200px;
  overflow-y: auto;
  padding: 4px 0;
}

.admin-manage-skill-row {
  display: grid;
  grid-template-columns: 1.4fr 0.6fr 0.6fr auto;
  align-items: center;
  gap: 6px;
  padding: 3px 6px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.admin-manage-skill-name {
  color: var(--ink-dim);
}

.admin-manage-skill-current {
  color: var(--muted);
  text-align: right;
}

/* Recent Deaths (views/partials/adminGump.ejs's #admin-manage-deaths, public/js/
   adminManage.js's renderDeaths) — same scrollable-list-of-rows shape as
   .admin-manage-skills/.admin-manage-skill-row just above, adapted to a single info
   string plus a Restore button per row instead of a value/input/select trio. */
.admin-manage-deaths {
  display: flex;
  flex-direction: column;
  gap: 3px;
  max-height: 160px;
  overflow-y: auto;
  padding: 4px 0;
}

.admin-death-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 4px 6px;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.admin-death-info {
  color: var(--ink-dim);
  flex: 1;
  min-width: 0;
}

.dungeon-roster {
  margin-top: 8px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.dungeon-monster-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.dungeon-monster-icon {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
}

/* Difficulty sits ABOVE the name, so the column reads danger-first — which is what the
   roster is for now that nothing in it starts a fight. */
.dungeon-monster-text {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}

.dungeon-monster-name {
  font-size: calc(13px * var(--ui-font-scale, 1));
}

/* Uppercase and small, the same "label above the thing it describes" treatment
   .combat-swing-section-label already uses. Colour comes from the tier rule below. */
.dungeon-monster-difficulty {
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--muted);
}

/* One colour per DIFFICULTY_TIERS index (config/monsters.js) — green through to a hot
   red, so a glance down the roster sorts the floor by threat without reading a word.
   Indexed rather than named so a tier added to that array is a one-line addition here
   and cannot silently fall back to grey unnoticed. */
.dungeon-monster-difficulty-0 { color: #7fb069; }
.dungeon-monster-difficulty-1 { color: #9cc069; }
.dungeon-monster-difficulty-2 { color: #c9c264; }
.dungeon-monster-difficulty-3 { color: #d8a952; }
.dungeon-monster-difficulty-4 { color: #dd8f46; }
.dungeon-monster-difficulty-5 { color: #e0743e; }
.dungeon-monster-difficulty-6 { color: #dd5540; }
.dungeon-monster-difficulty-7 { color: #d33b3b; }
.dungeon-monster-difficulty-8 { color: #c9308f; }

/* The post-kill respawn wait, moved under whichever roster row was last fought
   (public/js/combat.js, Element.after()). Effectively dormant now: a manual crawl
   schedules no respawn wait at all (sockets/combatHandlers.js's manualCrawl branch) and
   Auto Battle shows its own countdown in the combat gump instead, so nothing reaches the
   branch that raises this. Kept rather than deleted because the element and its timer are
   self-contained and harmless, and the wait itself still exists server-side for auto. */
.dungeon-roster-cooldown {
  text-align: center;
  padding: 6px 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

/* Dungeon Boss (config/bosses.js) — a weekly summon bar right below the monster rows,
   same karma-bar visual idiom (.karma-bar/.karma-bar-fill above) for the gold-progress
   fill. Wraps to a second line for the bar/label/status so the row never crowds the
   fixed roster width. */
.dungeon-boss-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  padding: 8px 0 4px;
  margin-top: 4px;
  border-top: 1px solid rgba(255, 255, 255, 0.15);
}

.dungeon-boss-icon {
  width: 36px;
  height: 36px;
  image-rendering: pixelated;
}

.dungeon-boss-name {
  font-weight: bold;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--accent);
}

.dungeon-boss-bar {
  flex-basis: 100%;
  height: 8px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 4px;
  overflow: hidden;
}

/* Same ember meter as the floorplan overlay's own fill — this row and that overlay show
   the identical number and now finally look like it. Was a hardcoded #d4af37 left over
   from the pre-Slate-&-Ember gold palette. */
.dungeon-boss-bar-fill {
  height: 100%;
  background: linear-gradient(90deg, var(--accent-deep), var(--accent));
  box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.45);
  transition: width 0.3s ease;
}

.dungeon-boss-bar-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--text-dim, #b0b0b8);
  flex: 1;
}

/* var(--edge) is a low-alpha (0.24) border tint, not a fill — used as a background it
   read as a barely-visible grey box that looked disabled even when the button was live
   (reported bug). The fix for that was a hardcoded green, which solved "looks disabled"
   but made this the only non-ember control in the game: it fought the gold progress bar
   sitting directly beside it, and every alternate palette (body.theme-*) retints
   --accent while knowing nothing about that green. Now the same filled-ember treatment
   as the game's canonical confirm button (.prompt-modal-confirm) — just as obviously
   live, and it rethemes with everything else. */
.dungeon-boss-summon-btn {
  padding: 3px 10px;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.dungeon-boss-summon-btn:hover:not(:disabled) {
  background: var(--accent-bright);
}

.dungeon-boss-summon-btn:disabled {
  background: var(--edge);
  border-color: transparent;
  color: var(--on-accent);
  opacity: 0.4;
  cursor: not-allowed;
}

.dungeon-boss-status {
  flex-basis: 100%;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--accent);
}

.auto-battle-btn {
  display: block;
  width: 100%;
  margin-top: 8px;
  padding: 5px 10px;
  background: var(--edge);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.auto-battle-btn.active {
  background: var(--accent);
  border-color: #e8c14a;
  box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.5);
  /* Without this, .active only wins the background — color falls through to the later
     "flips solid on hover" chip rule below (.auto-battle-btn { color: var(--accent) }),
     same value as this rule's own background, so the label goes invisible the instant
     Auto Battle turns on (fixed only once you actually hover it, since :hover's own
     color: var(--on-accent) happens to out-cascade that rule). */
  color: var(--on-accent);
}

.ground-loot-list {
  margin-top: 8px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.ground-loot-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.ground-loot-label {
  flex: 1;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
}

.ground-loot-view-btn {
  padding: 3px 10px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.my-house-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 0;
  margin-top: 4px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.my-house-label {
  flex: 1;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
}

.travel-progress {
  margin-top: 10px;
}

.travel-progress-label {
  font-size: calc(13px * var(--ui-font-scale, 1));
  margin-bottom: 6px;
}

.travel-countdown {
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.travel-progress-bar {
  height: 6px;
  background: rgba(0, 0, 0, 0.3);
  border-radius: 3px;
  overflow: hidden;
}

/* public/js/buffBar.js rebuilds this row's children on every character:update rather
   than toggling display — :not(:empty) keeps the divider/spacing collapsed to nothing
   when no buff is active instead of leaving a bare empty gap in the Status gump. */
/* ONE row, always, however many buffs are up — it scrolls sideways instead of wrapping.
   Wrapping made the row's height a function of the buff count, and this panel is a fixed
   500px (see .equipped-panel-wrap further down, whose own comment records the last time
   this exact row overflowed it). A second line of icons pushed the paperdoll and the slot
   grid down and shifted everything the player was looking at. With fish bonuses, potions,
   spells and pet bonuses all landing here at once there is no buff count worth tuning a
   height against, so the height simply stops depending on it.

   The scrollbar is hidden rather than thinned: even a 4px one would add height to a 24px
   row, which is the whole thing being avoided. .is-scrollable (set by public/js/buffBar.js
   only when the icons genuinely overflow) fades the right edge instead, and the wheel
   handler there scrolls it without needing shift. */
.buff-bar-row {
  display: flex;
  gap: 6px;
  flex-wrap: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none;
  /* width:0 with min-width:100% is doing real work and is not interchangeable with either
     half alone. This whole panel is CONTENT-sized — .equipped-panel-wrap takes its width
     from the widest thing inside it — so a nowrap row of icons simply became the widest
     thing and dragged the panel from 254px out to 632px, overflow-x never engaging because
     the row always got exactly the width it asked for. min-width:0 on the row or on the
     wrap does nothing here: nothing is being squeezed, the panel is being stretched.
     width:0 takes the row out of that intrinsic-width calculation entirely, and
     min-width:100% then refills it to whatever the panel settled on. Measured: the panel
     holds 254px at 1, 7 and 20 buffs, and at 20 the row reports 594px of content in a
     216px viewport — i.e. it finally scrolls. */
  width: 0;
  min-width: 100%;
}

.buff-bar-row::-webkit-scrollbar {
  display: none;
}

/* Only while there is actually something off the right edge — applied unconditionally it
   would dim the last icon of a two-buff row for no reason. */
.buff-bar-row.is-scrollable {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent 100%);
  mask-image: linear-gradient(to right, #000 calc(100% - 20px), transparent 100%);
}

.buff-bar-row:not(:empty) {
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.buff-bar-icon {
  /* Never squashed to make the row fit — the row scrolls instead (see above). Without this
     a long buff list would shrink every icon toward illegibility rather than overflow. */
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.travel-progress-fill {
  height: 100%;
  width: 0%;
  background: var(--accent);
}

.travel-cancel-btn, .house-travel-cancel-btn {
  display: block;
  margin: 8px 0 0;
  padding: 5px 12px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Wraps, because this row gained a third occupant. Title + floor strip + the "Travel from
   the map to leave" hint do not fit across a panel this narrow, and the hint is nowrap by
   design — so without flex-wrap the title broke onto two lines and the strip pushed out
   past the panel's edge. Wrapping lets the hint drop to its own line instead, which is the
   right thing to lose first: it is the least urgent of the three. */
.house-view-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 4px 8px;
  margin-top: 8px;
  margin-bottom: 8px;
}

/* min-width: 0 so a long facade name ("Large Marble Workshop") can shrink rather than
   forcing the row wider than the panel — the default min-width:auto on a flex item is what
   lets text overflow its container instead of wrapping. */
.house-view-title {
  font-size: calc(14px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  flex: 1 1 auto;
  min-width: 0;
}

/* The floor switcher (views/partials/worldMap.ejs, filled by public/js/houseDeed.js's
   renderHouseFloorStrip). A house is multi-storey from the Two-Story tier up
   (config/housing.js's `floors`) and each floor has its own layout and its own storage
   chest; these buttons are how you climb.

   Sits between the title and the "Travel from the map to leave" hint in the header's
   existing space-between flow, so it centres itself without needing its own rule — and is
   display:none on a single-storey house, leaving a Small House's header pixel-identical to
   what it has always been. */
.house-view-floors {
  display: flex;
  gap: 3px;
  align-items: center;
  /* Holds its natural width — the header wraps around it (see .house-view-header), so at
     every real panel width even a Castle's four buttons stay on one line.

     flex-wrap here is purely the last-resort fallback below about 150px, which the House
     View's own panel never reaches. It exists because the alternative failure mode is the
     buttons hanging off the panel's edge, and a strip that wraps is merely ugly where one
     that overhangs is broken. */
  flex: 0 1 auto;
  flex-wrap: wrap;
}

.house-floor-btn {
  padding: 2px 6px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.5;
  cursor: pointer;
}

.house-floor-btn:hover {
  color: var(--ink);
  border-color: var(--accent);
}

/* The storey you're standing on. Filled rather than merely outlined — at 11px an outline
   alone is too quiet to answer "which floor am I on?" at a glance, which is the only
   question this control exists to answer. */
.house-floor-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
  font-weight: bold;
  cursor: default;
}

/* Replaced the Leave button (views/partials/worldMap.ejs) — a house is a real location
   now, so the House View's header says how to leave rather than offering a free exit. */
.house-view-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
  white-space: nowrap;
  /* The first thing to drop to its own line when the row runs out of room (see
     .house-view-header's flex-wrap) — and pushed right when it lands there, so it stays
     visually attached to the header rather than reading as a new paragraph. */
  flex: 0 0 auto;
  margin-left: auto;
}

.house-view-leave-btn {
  padding: 3px 10px;
  background: var(--edge);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The cutaway room (views/partials/worldMap.ejs) — the floorplan grid inside stone
   walls on a plank floor, viewed UO-style: the tall far wall stands up across the top,
   the near wall is knee-high so you look over it INTO the room, thin edge-on side
   walls, two glassless window slits, and an open doorway breaking the near wall. All of
   it is decorative padding AROUND the grid — the 32px coordinate space and every placed
   item's position are untouched, and every wall ignores the pointer.

   .house-room-fit scales the whole room to the panel's width (houseDeed.js's
   fitHouseRoom sets the transform and reserves the scaled height), which is what
   replaced the old scrollbar — the JS derives the natural size from these same wall
   thicknesses (top 44, sides 14, bottom 14), so change one here and change it there. */
/* Holds the scaled room. A transform reserves no layout space, so the height is set by
   public/js/houseDeed.js's fitHouseRoom; overflow:hidden then trims the sub-pixel edge the
   scale can leave behind. */
.house-room-fit {
  width: 100%;
  overflow: hidden;
}

/* Scaled as one unit to fit the panel, by a factor houseDeed.js snaps so a 32px tile lands
   on a whole number of pixels — transform-origin must stay top-left for that to hold, since
   it is what keeps every scaled child position a plain multiple of the tile size. */
.house-room {
  position: relative;
  width: max-content;
  padding: 44px 14px 14px;
  transform-origin: top left;
  background: #14100a; /* only ever peeks out at the corners behind the wall strips */
  border-radius: 3px;
}

.house-room .house-wall-top,
.house-room .house-wall-left,
.house-room .house-wall-right,
.house-room .house-wall-bottom,
.house-room .house-window,
.house-room .house-door {
  position: absolute;
  pointer-events: none;
  image-rendering: pixelated;
}

.house-wall-top {
  top: 0;
  left: 0;
  right: 0;
  height: 44px;
  background: url('/assets/sprites/floorplan/house_wall_face.png') repeat-x bottom left;
}

.house-window {
  bottom: 0;
  width: 32px;
  height: 44px;
  background: url('/assets/sprites/floorplan/house_wall_window.png') no-repeat;
}

.house-wall-left,
.house-wall-right {
  top: 44px;
  bottom: 14px;
  width: 14px;
  background: url('/assets/sprites/floorplan/house_wall_side.png') repeat-y;
}

.house-wall-left { left: 0; }
/* Mirrored so its lit outer lip faces outward on the right too. */
.house-wall-right { right: 0; transform: scaleX(-1); }

.house-wall-bottom {
  bottom: 0;
  left: 0;
  right: 0;
  height: 14px;
  background: url('/assets/sprites/floorplan/house_wall_bottom.png') repeat-x;
}

/* Sits AFTER the floorplan in the DOM so its open threshold paints over the floor's
   bottom edge — 20px tall against the near wall's 14px, and that 6px of overlap up
   into the room is the cutaway depth read. */
.house-door {
  bottom: 0;
  left: 50%;
  width: 64px;
  height: 20px;
  margin-left: -32px;
  background: url('/assets/sprites/floorplan/house_door.png') no-repeat;
}


/* --- House facade materials (config/housing.js's facade `material`) --------------------
   One override block per material: the five wall/floor sprites swap as a set off a single
   house-mat-* class on .house-room (server-rendered first paint in worldMap.ejs, kept
   live by houseDeed.js's renderHouseView). 'stone' is the default set above and has no
   block — no class, original sprites, every pre-materials house unchanged.
   The floorplan override restates the full 3-layer background stack because
   background-image is one property: the two seam-line gradients must ride along or the
   placement grid vanishes with the wood. */

.house-mat-brick .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_brick.png');
}
.house-mat-brick .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_brick.png');
}
.house-mat-brick .house-wall-left,
.house-mat-brick .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_brick.png');
}
.house-mat-brick .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_brick.png');
}
.house-mat-brick .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_brick.png');
}

.house-mat-sandstone .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_sandstone.png');
}
.house-mat-sandstone .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_sandstone.png');
}
.house-mat-sandstone .house-wall-left,
.house-mat-sandstone .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_sandstone.png');
}
.house-mat-sandstone .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_sandstone.png');
}
.house-mat-sandstone .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_sandstone.png');
}

.house-mat-log .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_log.png');
}
.house-mat-log .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_log.png');
}
.house-mat-log .house-wall-left,
.house-mat-log .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_log.png');
}
.house-mat-log .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_log.png');
}
.house-mat-log .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_log.png');
}

.house-mat-timber .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_timber.png');
}
.house-mat-timber .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_timber.png');
}
.house-mat-timber .house-wall-left,
.house-mat-timber .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_timber.png');
}
.house-mat-timber .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_timber.png');
}
.house-mat-timber .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_timber.png');
}

.house-mat-stucco .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_stucco.png');
}
.house-mat-stucco .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_stucco.png');
}
.house-mat-stucco .house-wall-left,
.house-mat-stucco .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_stucco.png');
}
.house-mat-stucco .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_stucco.png');
}
.house-mat-stucco .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_stucco.png');
}

.house-mat-fieldstone .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_fieldstone.png');
}
.house-mat-fieldstone .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_fieldstone.png');
}
.house-mat-fieldstone .house-wall-left,
.house-mat-fieldstone .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_fieldstone.png');
}
.house-mat-fieldstone .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_fieldstone.png');
}
.house-mat-fieldstone .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_fieldstone.png');
}

.house-mat-marble .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_marble.png');
}
.house-mat-marble .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_marble.png');
}
.house-mat-marble .house-wall-left,
.house-mat-marble .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_marble.png');
}
.house-mat-marble .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_marble.png');
}
.house-mat-marble .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_marble.png');
}

.house-mat-castle .house-wall-top {
  background-image: url('/assets/sprites/floorplan/house_wall_face_castle.png');
}
.house-mat-castle .house-window {
  background-image: url('/assets/sprites/floorplan/house_wall_window_castle.png');
}
.house-mat-castle .house-wall-left,
.house-mat-castle .house-wall-right {
  background-image: url('/assets/sprites/floorplan/house_wall_side_castle.png');
}
.house-mat-castle .house-wall-bottom {
  background-image: url('/assets/sprites/floorplan/house_wall_bottom_castle.png');
}
.house-mat-castle .house-view-floorplan {
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_castle.png');
}

/* --- Town buildings (public/js/gatherScene.js's gsSpawnTownBuildings) ---
   The player house's cutaway, reassembled per plot from the same floorplan sprites — one
   visual language for every roof(less) line in the game. The shell is pointer-events:none
   except the FAR WALL: interior cells are ordinary walkable ground and the vendor inside
   is his own clickable element, so a shell that swallowed clicks would swallow the
   click-to-walk for every cell it covers. The far wall alone takes events — it carries
   the trade tooltip, its cells are blocked ground where a click was always a no-op, and
   an unhandled click there still bubbles to the board as before. */
.gs-bldg {
  position: absolute;
  pointer-events: none;
}
.gs-bldg > div,
.gs-bldg > span {
  position: absolute;
  pointer-events: none;
  image-rendering: pixelated;
}
.gs-bldg-floor {
  background: url('/assets/sprites/floorplan/house_floor_wood.png');
  /* Inset shading so the room reads sunk between its own walls, same trick the panel
     floorplan uses. */
  box-shadow: inset 0 6px 10px rgba(0, 0, 0, 0.35), inset 0 -3px 6px rgba(0, 0, 0, 0.2);
}
.gs-bldg-wall-top {
  top: 0;
  left: 14px;
  right: 14px;
  height: 44px;
  background: url('/assets/sprites/floorplan/house_wall_face.png') repeat-x bottom left;
  pointer-events: auto;
}
.gs-bldg-window {
  position: absolute;
  bottom: 0;
  width: 32px;
  height: 44px;
  background: url('/assets/sprites/floorplan/house_wall_window.png') no-repeat;
  image-rendering: pixelated;
}
.gs-bldg-wall-left,
.gs-bldg-wall-right {
  top: 44px;
  bottom: 14px;
  width: 14px;
  background: url('/assets/sprites/floorplan/house_wall_side.png') repeat-y;
}
.gs-bldg-wall-left { left: 0; }
.gs-bldg-wall-right { right: 0; transform: scaleX(-1); }
.gs-bldg-wall-bottom {
  bottom: 0;
  left: 14px;
  right: 14px;
  height: 14px;
  background: url('/assets/sprites/floorplan/house_wall_bottom.png') repeat-x;
}
.gs-bldg-door {
  bottom: 0;
  width: 64px;
  height: 20px;
  background: url('/assets/sprites/floorplan/house_door.png') no-repeat;
}
/* An E/W doorway has no leaf sprite — a worn stone threshold marks the opening in the
   side wall instead, two cells tall to match the gap the plan left in the ring. */
.gs-bldg-threshold-side {
  width: 14px;
  height: 64px;
  background: linear-gradient(90deg, rgba(120, 112, 100, 0.85), rgba(70, 64, 56, 0.85));
  box-shadow: inset 0 2px 3px rgba(0, 0, 0, 0.4), inset 0 -2px 3px rgba(0, 0, 0, 0.4);
}
/* What a vendor holds (config/townGlades.js's VENDOR_ROLE_APPEARANCE) — same size and
   hang as the wanderer's tool (.wnd-npc-tool), so an NPC carrying something looks the
   same wherever you meet one. */
.gs-vendor-held {
  position: absolute;
  right: -8px;
  bottom: 12px;
  width: 18px;
  height: 18px;
  image-rendering: pixelated;
  pointer-events: none;
}

/* Interior furnishings (tools/generateTownInteriors.js) — scenery, never collision. */
.gs-bldg-piece {
  background-repeat: no-repeat;
  background-size: 100% 100%;
}
/* The paddock's water trough (tools/generateTownInteriors.js) — outside furniture, so it
   stands on grass rather than floorboards. */
.gs-paddock-trough {
  position: absolute;
  width: 48px;
  height: 24px;
  background-image: url('/assets/sprites/town/int_trough.png');
  image-rendering: pixelated;
  pointer-events: none;
}

/* The paddock horse: a 96x48 two-frame sheet (grazing / head up), hard-cut at a slow
   graze rhythm by steps(1) — the fountain shimmer's idiom. Walks flip to the head-up
   frame and glide on the same left/top transitions every figure uses. */
.gs-horse {
  position: absolute;
  width: 48px;
  height: 48px;
  background-image: url('/assets/sprites/town/town_horse.png');
  background-size: 200% 100%;
  image-rendering: pixelated;
  pointer-events: none;
  animation: gs-horse-graze 7s steps(1) infinite;
  transition: left 4s linear, top 4s linear;
}
@keyframes gs-horse-graze {
  0%, 70% { background-position: 0 0; }      /* head down, grazing */
  70.01%, 100% { background-position: -48px 0; }  /* head up, listening */
}
.gs-horse-walking {
  animation: none;
  background-position: -48px 0;  /* a walking horse carries its head up */
}
.gs-horse-flip {
  transform: scaleX(-1);
}

.house-view-floorplan {
  position: relative;
  /* Grid lines drawn in floor-seam brown OVER the plank tile — the alignment guide
     players place against, kept but made part of the floor rather than chalk on it. */
  background-image:
    linear-gradient(rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    linear-gradient(90deg, rgba(20, 12, 4, 0.22) 1px, transparent 1px),
    url('/assets/sprites/floorplan/house_floor_wood.png');
  background-size: 32px 32px, 32px 32px, 32px 32px;
  image-rendering: pixelated;
  /* Depth: the far wall and both side walls each throw shadow onto the floor. */
  box-shadow:
    inset 0 10px 14px rgba(0, 0, 0, 0.45),
    inset 8px 0 10px rgba(0, 0, 0, 0.25),
    inset -8px 0 10px rgba(0, 0, 0, 0.25);
}

.house-view-chest {
  position: absolute;
  width: 32px;
  height: 32px;
  background: url('/assets/sprites/bank_chest.png') center / contain no-repeat;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.house-view-chest.dragging {
  opacity: 0.6;
}

/* Same "unlocked = dashed outline" treatment as .house-view-station:not(.locked) below —
   the chest now joins the exact same lock/release/targeting system a Craft Station uses
   (sockets/craftStationHandlers.js's handleChestTarget). */
.house-view-chest:not(.locked) {
  outline: 1px dashed rgba(255, 255, 255, 0.4);
  outline-offset: 2px;
}

/* The floorplan has no border and a plank-tile background now, so the old
   border-color/background tint had nothing visible to change — an inset dashed outline
   reads as "drop anywhere on the floor" without covering the boards. */
.house-view-floorplan.drag-over {
  outline: 2px dashed var(--accent);
  outline-offset: -2px;
}

.house-view-station {
  position: absolute;
  width: 32px;
  height: 32px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.house-view-station:not(.locked) {
  outline: 1px dashed rgba(255, 255, 255, 0.4);
  outline-offset: 2px;
}

.house-view-station.running {
  box-shadow: 0 0 6px 2px rgba(110, 255, 110, 0.6);
}

.house-view-station.dragging {
  opacity: 0.6;
}

/* Targeting cursor for the Lock Down/Release chat-phrase flow (public/js/
   craftStation.js's beginTargeting/endTargeting) — a UO-style crosshair over the whole
   page (not just the floorplan) since a stray click anywhere cancels targeting, same as
   the cursor itself should reflect "you're targeting" everywhere, not just when hovering
   a valid target. !important overrides every other cursor rule in this file (the point-
   cursor on stations/chest/buttons, etc.) for the duration. */
body.targeting-station,
body.targeting-station * {
  cursor: crosshair !important;
}

/* Highlights whichever floorplan icons are actually legal targets for the currently
   armed action (public/js/craftStation.js's markTargetableIcons) — layered on top of
   whatever outline a station/chest already has (locked, unlocked, running). */
.targetable {
  outline: 2px solid var(--accent) !important;
  outline-offset: 2px;
}

/* The /info GM command's own targeting cursor (public/js/adminItemInfo.js) — same
   whole-page crosshair treatment as the station targeting above it, for the same reason:
   a stray click anywhere cancels, so the cursor should say "you're targeting" everywhere.
   Every .item-icon on screen is a legal target, and they all take the shared .targetable
   outline above while it's armed. */
body.targeting-info,
body.targeting-info * {
  cursor: crosshair !important;
}

/* Recall and Mark's rune cursor (public/js/runeTargeting.js) — the third user of the same
   whole-page crosshair treatment, for the same reason as the two above. Unlike /info, only
   SOME icons are legal targets here (Mark takes any Recall Rune, Recall only a marked
   one), so the shared .targetable outline lands on a subset rather than on everything. */
body.targeting-rune,
body.targeting-rune * {
  cursor: crosshair !important;
}

/* Recycle's own item cursor (public/js/itemRecycle.js) — the fourth user of the same
   whole-page crosshair treatment, for the same reason as the three above. Like the rune
   cursor, only SOME icons are legal targets (gear the arming tool's own skill made, sitting
   in the backpack), so the shared .targetable outline lands on a subset. */
body.targeting-recycle,
body.targeting-recycle * {
  cursor: crosshair !important;
}

/* Craft Station gump (Saw Pit/Ore Smelter/Iron Stove) — shares the exact same
   theme-adaptive glass chrome as .crafting-gump (var(--panel)/var(--edge), not a
   hardcoded palette: the Blacksmithing/Carpentry/Tinkering gumps have no per-skill
   color scheme of their own either, they just inherit whichever theme is active). The
   per-station theme-wood/theme-forge/theme-hearth classes only tint the header's own
   border — a light touch rather than fighting the player's chosen theme. */
.craft-station-gump {
  display: flex;
  flex-direction: column;
  width: 320px;
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  background: var(--panel);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

.craft-station-gump .skills-gump-header {
  position: static;
  flex-shrink: 0;
  height: auto;
  padding: 10px 14px;
  border-bottom: 2px solid rgba(255, 255, 255, 0.12);
  background: transparent;
  justify-content: space-between;
}

.craft-station-gump.theme-wood .skills-gump-header { border-bottom-color: rgba(160, 110, 60, 0.5); }
.craft-station-gump.theme-forge .skills-gump-header { border-bottom-color: rgba(220, 100, 40, 0.5); }
.craft-station-gump.theme-hearth .skills-gump-header { border-bottom-color: rgba(200, 70, 50, 0.5); }

.craft-station-gump-body {
  padding: 12px 14px 14px;
}

.craft-station-columns {
  display: flex;
  gap: 12px;
}

.craft-station-column {
  flex: 1;
  min-width: 0;
}

/* --ink, not --muted — the same legibility pass .fixture-gump-body's own note describes.
   These caption the wells and the rack a player is dragging onto, so they are wayfinding
   rather than fine print. */
.craft-station-column-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: 4px;
}

.craft-station-slot-list {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 4px;
  min-height: 52px;
  padding: 6px;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
}

.craft-station-input-list.drag-over {
  border-color: var(--accent);
  background: rgba(var(--edge-rgb), 0.08);
}

.craft-station-slot {
  position: relative;
  width: 40px;
  height: 40px;
}

.craft-station-slot .item-icon {
  width: 40px;
  height: 40px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* --- The Craft Station shelf (config/craftStations.js's shelfTiers) ------------------
   A fixed slot per material tier for parking raw material the station cannot process yet.
   The grid's own language is deliberately the Gather Bag's (.gather-bag-slot and friends
   above) since it is the same idea in another place; what differs is the wooden rack
   BEHIND it, which is the Cooking Stove spice rack's language instead. */

.craft-station-shelf {
  margin-top: 12px;
}

.craft-station-shelf-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: 4px;
}

/* The rack art is the drop surface, not just the cells — matching how the spice rack
   works (public/js/dragdrop.js routes the whole element). */
.craft-station-shelf-rack {
  border: 1px solid rgba(0, 0, 0, 0.5);
  border-radius: 4px;
  overflow: hidden;
}

.craft-station-shelf-rack.drag-over {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px rgba(var(--edge-rgb), 0.35);
}

/* grid-auto-rows MUST equal both the sprite's own height and tools/generateSprites.js's
   STATION_SHELF_ROW_H — the background is one row's rack tiled vertically, so the boards
   only sit under the slots while these three agree. 5 columns puts the 9 tiers on two
   rows inside the 320px gump. */
.craft-station-shelf-grid {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  grid-auto-rows: 58px;
  align-items: center;
  justify-items: center;
  background-image: url('/assets/sprites/craft_station_shelf.png');
  background-size: 100% 58px;
  background-repeat: repeat-y;
  image-rendering: pixelated;
}

/* Sits ON the board rather than centred in its cell — the 11px board plus its lip is the
   bottom of each row, so the slot is nudged up out of it. */
.craft-station-shelf-slot {
  position: relative;
  width: 40px;
  height: 40px;
  margin-bottom: 12px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
}

.craft-station-shelf-slot .item-icon {
  width: 40px;
  height: 40px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* A tier whose minSkill the station's owner has not reached. A note, never a refusal —
   storing it is exactly what the shelf is for (sockets/craftStationHandlers.js's
   station:depositShelf has no skill gate) — so this only marks the cell, and a LOCKED
   slot holding real material still shows that material at full strength. */
.craft-station-shelf-slot-locked.craft-station-shelf-slot-empty {
  border: 1px dashed rgba(255, 255, 255, 0.16);
}

/* The dull placeholder, same treatment as .gather-bag-ghost. */
.craft-station-shelf-ghost {
  width: 30px;
  height: 30px;
  image-rendering: pixelated;
  filter: grayscale(1);
  opacity: 0.28;
  pointer-events: none;
}

.craft-station-actions {
  display: flex;
  gap: 8px;
  margin-top: 10px;
}

.craft-station-action-btn,
.craft-station-stop-btn {
  flex: 1;
  padding: 7px 10px;
  border-radius: 4px;
  font-family: inherit;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.craft-station-action-btn {
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  color: var(--on-accent);
}

.craft-station-action-btn:hover:not(:disabled) {
  background: var(--accent-bright);
}

.craft-station-stop-btn {
  background: var(--edge);
  border: 1px solid var(--edge);
  color: var(--ink);
}

.craft-station-stop-btn:hover:not(:disabled) {
  background: rgba(var(--edge-rgb), 0.15);
  color: var(--accent);
}

.craft-station-action-btn:disabled,
.craft-station-stop-btn:disabled {
  opacity: 0.45;
  cursor: default;
}

/* "All to Stockpile", under the Output column (views/partials/craftStationGump.ejs).
   Deliberately the quietest control in the gump — an outline chip rather than the action
   button's filled gradient. Smelting is what you came here to do; shifting the results onto
   a shelf is housekeeping, and a second solid slab beside Smelt/Stop would compete with it
   for the eye. Full width of its own column so it reads as belonging to the output list it
   sits under rather than to the action row below.

   "Deposit All" is its opposite number under the Raw Material column, filling the station
   from the pack. Identical treatment on purpose: the two are the same KIND of act — bulk
   material in, bulk material out — and giving one of them more visual weight than the other
   would suggest a difference that isn't there. */
.craft-station-to-stockpile-btn,
.craft-station-deposit-all-btn {
  margin-top: 6px;
  width: 100%;
  padding: 5px 8px;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 4px;
  color: var(--accent);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.craft-station-to-stockpile-btn:hover:not(:disabled),
.craft-station-deposit-all-btn:hover:not(:disabled) {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.craft-station-to-stockpile-btn:disabled,
.craft-station-deposit-all-btn:disabled {
  opacity: 0.45;
  cursor: default;
}

.craft-station-hint {
  margin-top: 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink);
  text-align: center;
}

/* Centered column — the site is a single focal moment (fight, then chest, then loot),
   not a stacked settings panel, so everything centers instead of stretching full
   width. */
/* The dig's controls, and ONLY its controls: Resume, the pick bar, the loot the chest gave
   up, Abandon. The title, the guardian count and the map itself moved out to the full-screen
   scene (.treasure-site-scene-view), so this is a centred stack again, exactly as it was
   before the glade existed. */
.treasure-site-view {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  text-align: center;
}

.treasure-site-title {
  font-size: calc(14px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

/* Lives in the scene's header row now (views/game.ejs) rather than on a line of its own in
   the panel, so it carries no bottom margin and must not wrap — that header is pinned and
   centred over the map when the scene goes full-screen. */
.treasure-site-monsters {
  font-size: calc(13px * var(--ui-font-scale, 1));
  white-space: nowrap;
}

.treasure-site-attack-btn {
  display: block;
  margin: 6px auto 10px;
  padding: 8px 36px;
  font-weight: bold;
  font-size: calc(14px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.treasure-site-abandon-btn {
  display: block;
  margin: 12px auto 0;
  padding: 5px 14px;
  font-weight: bold;
  font-size: calc(11px * var(--ui-font-scale, 1));
  opacity: 0.85;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The chest centerpiece — closed sprite is a click target for starting the pick
   (public/js/treasureMap.js), swapped to the open sprite once the lock springs. */
.treasure-site-chest {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 4px 0 2px;
}

.treasure-site-chest-img {
  width: 88px;
  height: 88px;
  image-rendering: pixelated;
}

.treasure-site-chest-img.pickable {
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.treasure-site-chest-img.pickable:hover {
  filter: brightness(1.25);
}

.treasure-site-chest-hint {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin-top: 2px;
}

/* One icon per uncollected chestLoot entry — click to take it. Same 44px item-icon
   scale as the backpack grid. */
.treasure-site-loot-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 6px;
  margin-top: 8px;
  max-width: 260px;
}

.treasure-site-loot-item {
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid rgba(240, 216, 120, 0.35);
  border-radius: 4px;
  padding: 0;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.treasure-site-loot-item:hover {
  border-color: var(--accent);
  background: rgba(240, 216, 120, 0.12);
}

.treasure-site-loot-item img {
  width: 40px;
  height: 40px;
  image-rendering: pixelated;
  pointer-events: none;
}

.treasure-site-pick-progress {
  margin-top: 10px;
  width: 100%;
}

.treasure-site-cancel-pick-btn {
  display: block;
  margin: 8px 0 0;
  padding: 5px 12px;
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.travel-cancel-btn:hover:not(:disabled), .house-travel-cancel-btn:hover:not(:disabled) { background: var(--accent); }

.travel-cancel-btn:disabled, .house-travel-cancel-btn:disabled {
  background: #4a3e28;
  color: #8a7a5c;
  cursor: not-allowed;
}

/* #open-bags-area intentionally has no layout rules of its own — each .bag-panel-wrap
   child is position:fixed (public/js/gumpWindow.js) and self-positions via inline
   left/top, so this element is purely a DOM append target now, not a visual container. */

.bag-panel-wrap {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 10px;
  height: fit-content;
}

.bag-panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
  color: var(--accent);
  font-size: calc(14px * var(--ui-font-scale, 1));
}

/* A bag's own × — the same too-small target as .skills-gump-close above, grown the same two
   ways. Only the left bleed differs: .gather-bag-rotate sits directly beside this one, so the
   overlay stops inside that button's own right padding rather than reaching its glyph. */
.bag-panel-close {
  position: relative;
  background: none;
  border: none;
  color: var(--accent);
  font-size: calc(20px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  padding: 0 4px;
}

.bag-panel-close::after {
  content: '';
  position: absolute;
  inset: -9px -8px -9px -2px;
}

.bag-panel {
  position: relative;
  background-image: url('/assets/sprites/backpack_bg.png');
  background-size: cover;
  border: 1px solid var(--edge);
  border-radius: 4px;
  overflow: hidden;
}

.bag-panel.drag-over {
  outline: 2px solid var(--accent);
}

/* ============ Gather Bags ============
   config/gatherBags.js — one organiser per harvest line. Deliberately the SAME
   .bag-panel-wrap shell as every other bag (so the glass chrome, saved position,
   close-on-travel and every drop route come for free); only the interior differs, from
   free-scattered icons to a fixed grid with one slot per resource in the line.

   The leather is real generated art, not a CSS tint: tools/generateSprites.js's
   drawLeatherSheet emits one re-dyed sheet per line from the exact routine that draws the
   Backpack's own background, stitched border and strap loops included. That is what makes
   four bags read as four dye lots of one hide rather than four unrelated panels. */
.bag-panel.gather-bag {
  display: grid;
  /* The three rotations, cycled by the header's ⟳ button. --gb-cols is set per bag by
     public/js/gatherBag.js off config/gatherBags.js's derived `cols`, rather than being
     hardcoded per layout here — that is what lets the 8-slot Reg Bag lay out 4x2 / 2x4 /
     8x1 while the 10-slot harvest bags stay 5x2 / 2x5 / 10x1, with no per-bag rule. */
  grid-template-columns: repeat(var(--gb-cols, 5), 44px);
  gap: 5px;
  padding: 8px;
  /* Overrides the inline 360x260 the generic shell stamps on every .bag-panel — a grid
     sizes to its own slots, and has to be free to reshape when the layout rotates. */
  width: max-content;
  height: max-content;
  /* background-size stays .bag-panel's `cover`, deliberately not `100% 100%`: the sheets
     are 360x260 but a grid panel is whatever its layout makes it (258x111 wide, 111x258
     tall, 501x60 as a strip). Stretching to fit squashed the grain and the stitched border
     out of proportion; cover crops instead, so the leather always reads at its true
     scale. */
}

/* The wrap sizes to the GRID, same fix and same reason as .tool-bag-gump.
   .bag-panel-wrap is otherwise auto-width inside a shrink-to-fit gump window, so it took
   the width of the widest thing in it — and in the 2-column rotation that was the TITLE,
   not the grid. Measured: "Lumberjack's Log Roll" left 75px of bare panel beside its
   slots and "Miner's Ore Satchel" 61px, while "Reg Bag" was short enough to look fine,
   which is why this only ever read as a per-bag oddity. The 5-column and strip rotations
   were always correct, because there the grid is the widest thing on its own.

   Scoped to gather bags with :has() rather than applied to every .bag-panel-wrap: a plain
   scatter bag's panel carries an inline 360x260 from buildBagPanelShell, so it has no
   layout question to answer and no reason to be dragged into this one.

   Titles wrap to two or three lines in that rotation as a result. A bag whose longest
   single WORD is wider than the grid keeps a little slack (Lumberjack's, 21px) — breaking
   a word mid-way to reclaim ten pixels would look worse than the ten pixels. */
.bag-panel-wrap:has(.bag-panel.gather-bag) { width: min-content; }

.bag-panel.gather-bag-theme-ore { background-image: url('/assets/sprites/gather_bag_bg_ore.png'); }
.bag-panel.gather-bag-theme-wood { background-image: url('/assets/sprites/gather_bag_bg_wood.png'); }
.bag-panel.gather-bag-theme-fish { background-image: url('/assets/sprites/gather_bag_bg_fish.png'); }
.bag-panel.gather-bag-theme-leather { background-image: url('/assets/sprites/gather_bag_bg_leather.png'); }
.bag-panel.gather-bag-theme-reg { background-image: url('/assets/sprites/gather_bag_bg_reg.png'); }

/* A slot is a well sunk into the hide. Same 44px module as .item-slot, so a real item
   icon drops straight in at its natural size. */
.gather-bag-slot {
  position: relative;
  width: 44px;
  height: 44px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.34);
  border: 1px solid rgba(0, 0, 0, 0.45);
  border-radius: 4px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.07);
}

.gather-bag-slot-empty {
  background: rgba(0, 0, 0, 0.22);
}

/* A tier still above your skill. Dashed rather than dimmer, borrowing
   .display-case-slot.empty's "this is a place, not a thing" language — it says the ladder
   continues, without implying you failed to find something you could have. */
.gather-bag-slot-locked {
  border-style: dashed;
  border-color: rgba(255, 255, 255, 0.16);
}

/* The dull placeholder: the resource's own icon, greyed out. Same treatment
   .equipment-locker-loadout-icon.missing uses for a piece of gear you no longer own. */
.gather-bag-ghost {
  width: 34px;
  height: 34px;
  image-rendering: pixelated;
  filter: grayscale(1);
  opacity: 0.3;
  pointer-events: none;
}

/* Sits left of the × in the bag header. Matches .bag-panel-close rather than inventing a
   second button style. */
.gather-bag-rotate {
  background: none;
  border: none;
  color: var(--accent);
  font-size: calc(15px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  padding: 0 6px 0 4px;
  margin-left: auto;
}

.gather-bag-rotate:hover {
  color: var(--accent-bright);
}

/* Items with no slot of their own — only ever a Reg Bag or Leather Sling filled before
   these bags had a whitelist, when they would hold anything you dragged in. Spans the full
   grid so it reads as a footnote to the bag rather than another row of it. */
.gather-bag-overflow {
  grid-column: 1 / -1;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px dashed rgba(255, 255, 255, 0.18);
}

.gather-bag-overflow-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  margin-bottom: 5px;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
}

.gather-bag-overflow-row {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}

/* Amber rather than the ember accent: this is "this is out of place", not "this is
   selected", and it should not read as an error either — nothing is broken, the item is
   simply somewhere it can no longer be put back. */
.gather-bag-slot-stray {
  border-color: rgba(214, 158, 74, 0.55);
}

.item-tooltip {
  display: none;
  position: fixed;
  /* Must always beat any gump's z-index, including a gump that's been dragged/clicked
     to the front repeatedly — gumpWindow.js's shared z-index counter starts at 950 and
     climbs indefinitely with every bringToFront(), so a fixed 1000 here eventually gets
     buried (most noticeably on whichever gump — often the Bank — gets clicked most).
     10000 is comfortably out of that counter's realistic reach for a single session. */
  z-index: 10000;
  background: linear-gradient(158deg, var(--panel), var(--panel-deep));
  border: 1px solid var(--edge);
  color: var(--ink);
  padding: 7px 11px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  line-height: 1.5;
  border-radius: 6px;
  white-space: nowrap;
  pointer-events: none;
  /* Lift the panel off the game board: a soft drop shadow for depth, a faint warm inner
     highlight along the top edge, and a frosted-glass blur through the translucent
     --panel so it reads as a floating pane rather than a flat sticker. */
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(var(--accent-rgb), 0.14);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  animation: tooltip-appear 120ms ease-out;
}

/* A quick fade-and-rise as the tooltip pops in on hover (display none -> block restarts
   it each time). Held back for readers who prefer reduced motion. */
@keyframes tooltip-appear {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .item-tooltip { animation: none; }
}

/* The item name, styled as the tooltip's title. The divider only appears when there are
   stat lines beneath it (:not(:only-child)), so a plain resource's name-only tooltip
   doesn't get a dangling underline. */
.tooltip-name {
  color: var(--accent);
  font-weight: 700;
  letter-spacing: 0.01em;
}
.tooltip-name:not(:only-child) {
  padding-bottom: 4px;
  margin-bottom: 4px;
  border-bottom: 1px solid rgba(var(--edge-rgb), 0.22);
}
.tooltip-weight {
  color: var(--muted);
  font-weight: 400;
  font-size: 0.88em;
}

.tooltip-heal {
  color: #4caf50;
  font-weight: bold;
}

.tooltip-ar {
  color: #7fb3d5;
  font-weight: bold;
}

.tooltip-mount {
  color: #d4af7a;
  font-weight: bold;
}

/* Leather armour's caster stats (config/leatherArmor.js) — the arcane purple already used
   for the player's own cast bar (.combat-action-cast), so "this is magic" reads the same
   wherever it appears, and clearly apart from the blue AR line above. */
.tooltip-spell {
  color: #d896ff;
  font-weight: bold;
}

/* Cloak of the Hourglass time cuts — a cool cyan for "haste/time", distinct from the
   warm gold mount-speed line above it. */
.tooltip-haste {
  color: #5fc9d6;
  font-weight: bold;
}

.tooltip-blessed {
  color: var(--accent);
  font-weight: bold;
}

/* Owner-bound items (models/ItemDef.js's untradeable) — the same bracketed-tag shape as
   [Blessed] above, in the donation gold, since the only bound items are real-money
   purchases. */
.tooltip-bound {
  color: #f0cf72;
  font-weight: bold;
}

.tooltip-ring-bonus {
  color: #4caf50;
  font-weight: bold;
}

.tooltip-active {
  color: #4caf50;
  font-weight: bold;
}

/* The one tooltip line that's a real sentence rather than a single stat value — the
   parent .item-tooltip is white-space:nowrap (every other line is short enough to
   never need wrapping), so this overrides back to normal wrapping within a capped
   width, or it'd render as one line running off the edge of the screen. */
.tooltip-binding {
  display: block;
  margin-top: 2px;
  max-width: 220px;
  white-space: normal;
  color: var(--muted);
  font-style: italic;
}

.tooltip-gold {
  color: #e0c14c;
  font-weight: bold;
}

.tooltip-magic {
  color: #a06bd6;
  font-weight: bold;
}

.tooltip-magic-conditional {
  color: #a06bd6;
  font-style: italic;
  font-size: 0.9em;
}

/* Hand-authored flavor text for items with no stat field of their own (config/
   itemFlavorText.js's ITEM_FALLBACK_DESCRIPTIONS) — a real sentence, same wrapping
   treatment as .tooltip-binding above for the same reason (the parent tooltip is
   nowrap by default). */
.tooltip-flavor {
  display: block;
  margin-top: 2px;
  max-width: 220px;
  white-space: normal;
  color: var(--muted);
  font-style: italic;
}

/* The "you can lose this" caveat under a flavor line (config/itemFlavorText.js's
   ITEM_WARNING_TEXT) — same block/wrapping treatment as .tooltip-flavor above it, but in
   the soft warning red .tooltip-locked already uses for its own can't-miss line, so the
   two read as the same kind of alert rather than two different reds. */
.tooltip-warning {
  display: block;
  margin-top: 3px;
  max-width: 220px;
  white-space: normal;
  color: #d97b7b;
  font-weight: bold;
}

/* A broken item (public/js/tooltip.js) — durability at 0. Deliberately louder than
   .tooltip-warning above: that one flags a stat you should think about, this one flags a
   piece of gear that has silently stopped working while still sitting in its slot looking
   normal. The stat lines printed above it are what the item WOULD give once repaired. */
.tooltip-broken {
  color: #ff6b6b;
  font-weight: bold;
}

.tooltip-broken-note {
  display: block;
  margin-top: 2px;
  color: #ff6b6b;
  font-weight: bold;
  letter-spacing: 0.03em;
}

/* A Reg Bag's reagent breakdown (public/js/tooltip.js) — a compact two-column list, name
   left / amount right, set off from the weight/name header above it by a hairline
   divider so it reads as a distinct "contents" section rather than another stat line.
   The parent .item-tooltip is white-space:nowrap; each row overrides back to normal so
   the name/amount pair can space apart via justify-content instead of running together. */
/* A bag's contents block, revealed after holding the cursor over it (tooltip.js's
   BAG_CONTENTS_HOLD_MS). Was Reg-Bag-only; now any container gets one. */
.tooltip-bag-contents {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 5px;
  padding-top: 5px;
  border-top: 1px solid rgba(var(--edge-rgb), 0.2);
}

.tooltip-bag-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 16px;
  font-size: 0.92em;
  white-space: normal;
}

.tooltip-bag-name {
  color: var(--ink-dim);
}

.tooltip-bag-amount {
  color: var(--accent);
  font-weight: 700;
  font-variant-numeric: tabular-nums;
}

/* The overflow line when a bag holds more stacks than BAG_CONTENTS_MAX_ROWS. */
.tooltip-bag-more {
  font-size: 0.88em;
  color: var(--muted);
  font-style: italic;
  margin-top: 2px;
}

/* Shown in place of the contents until the hold elapses — quiet, so it reads as an
   affordance rather than another stat line. */
.tooltip-bag-hint {
  color: var(--muted);
  font-size: 0.88em;
  font-style: italic;
}

.tooltip-dye {
  color: #e07fc4;
  font-weight: bold;
}

/* A Dye Tub's rarity band badge (public/js/tooltip.js) — classic RPG rarity colors,
   with a soft glow that intensifies up the ladder so the holy-grail tubs read as an
   event on hover. */
.tooltip-dye-rarity {
  font-weight: bold;
  letter-spacing: 0.04em;
}
.tooltip-dye-rarity-common { color: #b7bec8; }
.tooltip-dye-rarity-rare {
  color: #4d9fff;
  text-shadow: 0 0 6px rgba(77, 159, 255, 0.45);
}
.tooltip-dye-rarity-veryRare {
  color: #c07bff;
  text-shadow: 0 0 7px rgba(192, 123, 255, 0.5);
}
.tooltip-dye-rarity-ultraRare {
  color: #ffb020;
  text-shadow: 0 0 8px rgba(255, 176, 32, 0.6);
}

.tooltip-crafter {
  color: #7fa8d9;
  font-style: italic;
}

.tooltip-locked {
  color: #d97b7b;
  font-weight: bold;
}

/* The Shardbinder's Ring (the pre-launch testers' reward) — a founder's item, marked
   out from ordinary loot with a permanent violet aura wherever its icon appears
   (backpack, bank, equipped paperdoll). Keyed off data-item-key, which
   public/js/dragdrop.js's createItemImg stamps on every item icon. */
.item-icon[data-item-key="shardbinders_ring"] {
  filter: drop-shadow(0 0 4px rgba(184, 146, 232, 0.9)) drop-shadow(0 0 9px rgba(138, 95, 201, 0.55));
}

/* Its tooltip lines, in the same violet, so the item reads as one piece. */
.tooltip-founder {
  display: block;
  margin-top: 2px;
  max-width: 220px;
  white-space: normal;
  color: #c9a8ff;
  font-weight: bold;
}

.tooltip-gather-prize {
  color: #e0a030;
  font-style: italic;
}

.bind-offering-modal { width: 340px; }

.bind-offering-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 220px;
  overflow-y: auto;
  padding-right: 2px;
}

.bind-offering-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.bind-offering-row img {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  flex-shrink: 0;
}

.bind-offering-row-name { flex: 1; color: var(--ink); }
.bind-offering-row-points { color: var(--accent); font-weight: bold; white-space: nowrap; }

.bind-offering-total {
  text-align: center;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.tooltip-decay {
  color: #9aa1ab;
  font-style: italic;
}

.toast {
  position: fixed;
  top: 20px;
  left: 50%;
  transform: translateX(-50%) translateY(-10px);
  /* A transient notice must beat the gump/floated-panel band (950+) and the modal
     band (3000s); below the death banner (9600) and tooltips (10000s). */
  z-index: 9500;
  background: var(--panel);
  border: 1px solid var(--edge);
  color: var(--ink);
  padding: 8px 16px;
  border-radius: 6px;
  font-size: calc(14px * var(--ui-font-scale, 1));
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s, transform 0.2s;
}

.toast.visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

.toast-success { border-color: #3f9d4a; }
.toast-error { border-color: #e25c4f; }
/* GM /broadcast (public/js/adminBroadcast.js) — wider and brighter than a normal toast
   so it can't be mistaken for routine craft-success feedback. */
.toast-broadcast {
  border-color: var(--accent);
  border-width: 2px;
  color: var(--accent-bright);
  font-weight: bold;
  box-shadow: 0 0 16px rgba(var(--edge-rgb), 0.35);
}

/* Virtue Binding level-up (public/js/bindingLevelUp.js) — same server-wide reach as a
   GM /broadcast above, but a slow pulsing glow (rather than a flat one) keeps a player
   milestone from reading as a staff announcement. */
.toast-levelup {
  border-color: var(--accent);
  border-width: 2px;
  color: var(--accent-bright);
  font-weight: bold;
  background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.12), var(--panel));
  animation: levelup-toast-pulse 1.4s ease-in-out infinite;
}

@keyframes levelup-toast-pulse {
  0%, 100% { box-shadow: 0 0 14px rgba(var(--edge-rgb), 0.4); }
  50% { box-shadow: 0 0 26px rgba(var(--edge-rgb), 0.8); }
}

/* Dungeon Boon start/end (public/js/dungeonBoons.js) — the dungeon pins' own purple
   (game.css's .travel-map-pin.dungeon dot) rather than the ember tones every other
   toast here uses, so a Dungeon Boon reads as its own distinct kind of news rather
   than blurring into a Binding milestone or a GM broadcast. */
.toast-dungeonBoon {
  border-color: #a86bd6;
  border-width: 2px;
  color: #d8b6f0;
  font-weight: bold;
  box-shadow: 0 0 16px rgba(107, 45, 143, 0.5);
}

/* Harvesting Boon start/end (public/js/harvestingBoons.js) — same distinct-news
   treatment as .toast-dungeonBoon above, in the matching harvest-gold from
   .journal-harvestBoon instead of dungeon violet. */
.toast-harvestBoon {
  border-color: #c49b4a;
  border-width: 2px;
  color: #e0c274;
  font-weight: bold;
  box-shadow: 0 0 16px rgba(196, 155, 74, 0.5);
}

/* PK Ambush start/end (public/js/pkBoons.js) — same distinct-news treatment as the two boon
   toasts above, in blood red. This is the only one of the three that's a warning, so it gets the
   most alarming palette in the set. */
.toast-pkBoon {
  border-color: #b33636;
  border-width: 2px;
  color: #f08a8a;
  font-weight: bold;
  box-shadow: 0 0 16px rgba(179, 54, 54, 0.55);
}

/* Shared right-click menu (public/js/contextMenu.js) — a Local speaker's name in chat
   (Trade), and every non-weapon/armor item icon (Split Stack, Smelt/Cook/Carve, etc).
   Same fixed, above-every-gump z-index reasoning as .item-tooltip above. */
.context-menu {
  display: none;
  position: fixed;
  z-index: 10000;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 4px;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
  min-width: 130px;
  padding: 4px 0;
}

.context-menu-item {
  display: block;
  width: 100%;
  padding: 7px 14px;
  background: none;
  border: none;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(13px * var(--ui-font-scale, 1));
  text-align: left;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.context-menu-item:hover {
  background: rgba(var(--edge-rgb), 0.15);
  color: var(--accent);
}

/* Themed "how many?" dialog (public/js/contextMenu.js's promptItemAmount) — replaces
   the browser's native window.prompt() behind every batch/split action (Split Stack,
   Smelt/Cook/Carve x, Sell/Buy how many). A dim full-screen backdrop centers one small
   glass panel, same look language as every other gump. Higher z-index than the context
   menu (10000) since a quantity prompt is often triggered *from* a context menu item. */
.prompt-modal-overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 10001;
  background: rgba(0, 0, 0, 0.55);
  align-items: center;
  justify-content: center;
}

.prompt-modal {
  display: flex;
  flex-direction: column;
  gap: 14px;
  min-width: 260px;
  padding: 20px 22px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

.prompt-modal-label {
  color: var(--ink);
  font-size: calc(14px * var(--ui-font-scale, 1));
  text-align: center;
  /* Honours the newlines callers put in their message — Recycle All itemises what it is
     about to melt (public/js/itemRecycle.js) and promptNotice separates its title from its
     body the same way. Every other message is a single line, and pre-line collapses
     ordinary whitespace exactly as normal does, so nothing else changes. */
  white-space: pre-line;
}

.prompt-modal-input {
  padding: 8px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(16px * var(--ui-font-scale, 1));
  text-align: center;
}

.prompt-modal-input:focus {
  outline: none;
  border-color: var(--accent);
}

/* 'amount'-mode only (public/js/contextMenu.js's promptItemAmount) — the number input
   above stays the actual value; this is just a faster way to set it. */
.prompt-modal-slider {
  width: 100%;
  margin: -6px 0 0;
  accent-color: var(--accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The Name Change Deed prompt's rules hint (views/partials/nameChangeModal.ejs) — a
   quiet caption under the input, sitting closer to it than the modal's default 14px gap. */
.name-change-hint {
  margin-top: -6px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-align: center;
}

.prompt-modal-actions {
  display: flex;
  gap: 8px;
  justify-content: center;
}

.prompt-modal-btn {
  padding: 7px 20px;
  border-radius: 4px;
  font-family: inherit;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.prompt-modal-confirm {
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  color: var(--on-accent);
}

.prompt-modal-confirm:hover {
  background: var(--accent-bright);
}

.prompt-modal-cancel {
  background: var(--edge);
  border: 1px solid var(--edge);
  color: var(--ink);
}

.prompt-modal-cancel:hover {
  background: rgba(var(--edge-rgb), 0.15);
  color: var(--accent);
}

/* The shift-drag stack-split popup (public/js/stackSplit.js). A small cursor-anchored panel
   rather than the centred .prompt-modal — it appears mid-drag, so it stays near where you
   grabbed the stack. Borrows the same panel chrome as .prompt-modal for a consistent look. */
.stack-split-popup {
  position: fixed;
  z-index: 10002;
  display: none;
  flex-direction: column;
  gap: 10px;
  width: 200px;
  padding: 12px 14px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

.stack-split-label {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-align: center;
}

.stack-split-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.stack-split-slider {
  flex: 1;
  min-width: 0;
  accent-color: var(--accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.stack-split-input {
  width: 52px;
  padding: 5px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(13px * var(--ui-font-scale, 1));
  text-align: center;
}

.stack-split-input:focus {
  outline: none;
  border-color: var(--accent);
}

.stack-split-actions {
  display: flex;
  gap: 8px;
  justify-content: center;
}

.stack-split-btn {
  flex: 1;
  padding: 5px 0;
  border-radius: 4px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.stack-split-confirm {
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  color: var(--on-accent);
}

.stack-split-confirm:hover {
  background: var(--accent-bright);
}

.stack-split-cancel {
  background: var(--edge);
  border: 1px solid var(--edge);
  color: var(--ink);
}

.stack-split-cancel:hover {
  background: rgba(var(--edge-rgb), 0.15);
  color: var(--accent);
}

/* The Auto-eat/Auto-bandage threshold popup (public/js/thresholdPopup.js) — right-click
   the Food/Bandage equip slot to reach it, instead of a slider sitting permanently under
   the paperdoll. Same cursor/menu-anchored panel chrome as .stack-split-popup. */
.threshold-popup {
  position: fixed;
  z-index: 10002;
  display: none;
  flex-direction: column;
  gap: 10px;
  width: 220px;
  padding: 12px 14px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

.threshold-popup-label {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-align: center;
}

.threshold-popup-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}

.threshold-popup-slider {
  flex: 1;
  min-width: 0;
  accent-color: var(--accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.threshold-popup-value {
  min-width: 24px;
  text-align: right;
  color: var(--ink);
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.threshold-popup-unit {
  color: var(--muted);
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.threshold-popup-done {
  padding: 5px 0;
  border-radius: 4px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  color: var(--on-accent);
}

.threshold-popup-done:hover {
  background: var(--accent-bright);
}

.wiki-nav {
  display: flex;
  gap: 16px;
  padding: 10px 20px;
  background: var(--panel);
  border-bottom: 3px solid var(--edge);
}

.wiki-nav a {
  color: var(--accent);
  text-decoration: none;
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.wiki-nav a:hover { text-decoration: underline; }

.wiki-main {
  max-width: 900px;
  margin: 0 auto;
  padding: 30px;
}

.wiki-main h1 {
  color: var(--accent);
  margin-top: 0;
}

.wiki-list {
  line-height: 2;
}

.wiki-list a { color: var(--accent); }

.wiki-section {
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 6px;
  padding: 16px 20px;
  margin-bottom: 24px;
}

.wiki-section h2 {
  color: var(--accent);
  margin-top: 0;
}

.wiki-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 12px;
}

.wiki-table th, .wiki-table td {
  text-align: left;
  padding: 6px 10px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  font-size: calc(14px * var(--ui-font-scale, 1));
}

.wiki-table th {
  color: var(--muted);
  text-transform: uppercase;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.wiki-icon {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
}

.wiki-heal {
  color: #4caf50;
  font-weight: bold;
}

/* Testers Ring dev tool — deliberately more "extra" than every other gump (shimmering
   gradient frame, glowing title text) since this is meant to be the reusable base look
   for future dev/test tooling rather than a one-off. */
.devtools-gump-overlay {
  position: fixed;
  inset: 0;
  /* Modal band: above the gump/floated-panel band (950+, which grows per focus —
     capped well short of 3000 by gumpWindow.js's bringToFront guard). */
  z-index: 3050;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(10, 4, 20, 0.65);
}

.devtools-gump-frame {
  padding: 3px;
  border-radius: 14px;
  background: linear-gradient(135deg, #ff8a8a, #ffe08a, #8ad4ff, #c88aff, #ff8a8a);
  background-size: 300% 300%;
  animation: devtools-shimmer 6s ease infinite;
  box-shadow: 0 0 32px rgba(200, 140, 255, 0.55), 0 8px 24px rgba(0, 0, 0, 0.6);
}

@keyframes devtools-shimmer {
  0% { background-position: 0% 50%; }
  50% { background-position: 100% 50%; }
  100% { background-position: 0% 50%; }
}

.devtools-gump {
  width: 320px;
  max-height: 80vh;
  overflow-y: auto;
  background: #1a1024;
  border-radius: 11px;
}

.devtools-gump-header {
  position: relative;
  padding: 14px 16px;
  text-align: center;
  border-bottom: 1px solid rgba(216, 150, 255, 0.35);
}

.devtools-gump-title {
  font-size: calc(17px * var(--ui-font-scale, 1));
  font-weight: bold;
  background: linear-gradient(90deg, #ff8a8a, #ffe08a, #8ad4ff, #c88aff, #ff8a8a);
  background-size: 300% 100%;
  animation: devtools-shimmer 6s ease infinite;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  text-shadow: 0 0 14px rgba(200, 140, 255, 0.5);
}

.devtools-gump-close {
  position: absolute;
  top: 8px;
  right: 12px;
  background: none;
  border: none;
  color: #d896ff;
  font-size: calc(20px * var(--ui-font-scale, 1));
  line-height: 1;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  padding: 0 4px;
}

.devtools-gump-body {
  padding: 8px 16px 16px;
}

.devtools-gump-section-label {
  margin-top: 10px;
  padding-top: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  color: #a888c9;
  border-top: 1px solid rgba(216, 150, 255, 0.25);
}

.devtools-gump-section-label:first-child {
  margin-top: 0;
  padding-top: 0;
  border-top: none;
}

.devtools-gump-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 0;
}

.devtools-gump-row label {
  flex: 1;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: #e8d9ff;
}

.devtools-gump-input {
  width: 64px;
  background: #2a1f38;
  border: 1px solid #6b4a8f;
  color: #f0e8ff;
  border-radius: 4px;
  padding: 3px 6px;
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.devtools-gump-set {
  background: linear-gradient(135deg, #8a5fc9, #c88aff);
  border: none;
  color: #1a1024;
  font-weight: bold;
  border-radius: 4px;
  padding: 4px 10px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.devtools-gump-set:hover {
  filter: brightness(1.15);
}

.devtools-contact-row {
  padding: 8px 0;
  border-bottom: 1px solid rgba(216, 150, 255, 0.2);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.devtools-contact-row:last-child { border-bottom: none; }

.devtools-contact-name {
  color: #e8d9ff;
  font-weight: bold;
}

.devtools-contact-email {
  color: #a888c9;
}

.devtools-contact-message {
  margin-top: 3px;
  color: #c9b8e8;
  white-space: pre-wrap;
  word-break: break-word;
}

.devtools-contact-empty {
  color: #a888c9;
  font-size: calc(12px * var(--ui-font-scale, 1));
  padding: 6px 0;
}

/* ============ Slate & Ember chrome pass ============
   Everything below deliberately sits at the END of the file so it wins the cascade over
   the per-panel rules above at equal specificity — one grouped elevation/typography
   treatment instead of re-editing every gump rule individually. */

/* Panel glass: blur the location backdrop through every floating surface, one soft
   elevation shadow, and an ember hairline across the top edge (border-top-color only —
   the border itself is already 1px var(--edge)). */
.activities-panel, .stats-panel, .world-map-panel, .journal-panel, .chat-panel,
.equipped-panel-wrap, .inventory-panel, .bag-panel-wrap, .status-gump, .skills-gump,
.combat-gump, .guild-gump, .party-gump, .ground-loot-gump, .travel-gump, .context-menu,
.server-reboot-panel, .death-overlay, .travel-map-tooltip, .toast {
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  border-radius: 8px;
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
  border-top-color: rgba(var(--edge-rgb), 0.45);
}

/* Data panels get the modern uppercase micro-header; the serif stays reserved for
   identity (names and places) below. */
.journal-panel h2, .chat-panel h2, .equipped-panel-wrap h2, .inventory-panel h2,
.stats-panel h2, .activities-header h2, .skills-gump-header, .skills-gump-header span,
.panel-drag-handle span {
  font-family: system-ui, 'Segoe UI', sans-serif;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}

/* Serif is an identity marker now, not the default voice: character names, place
   names, and the wordmark keep it. */
.game-header-title, .nameplate-name, .current-location-name,
.auth-box h1, .testers-box h2, .server-reboot-title {
  font-family: Georgia, 'Times New Roman', serif;
  letter-spacing: 0.02em;
  text-transform: none;
}

/* Vitals read at a glance: each bar fill casts its own colored glow. */
.nameplate-bar-fill.nameplate-bar-hits { box-shadow: 0 0 8px rgba(226, 92, 79, 0.55); }
.nameplate-bar-fill.nameplate-bar-mana { box-shadow: 0 0 8px rgba(93, 142, 240, 0.55); }
.nameplate-bar-fill.nameplate-bar-stam { box-shadow: 0 0 8px rgba(143, 200, 79, 0.5); }
.nameplate-bar { background: rgba(255, 255, 255, 0.07); border-color: transparent; }

/* Active tabs flip to a solid ember chip — the tokenized var(--edge) background the
   bulk pass left here is translucent, which reads as disabled rather than selected. */
.journal-tab.active, .chat-tab.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.journal-tab, .chat-tab { color: var(--muted); }

/* Primary CTA buttons carry a soft ember bloom so they read as the one warm thing on
   an otherwise cool panel. */
.death-overlay-teleport-btn, .auth-box button, .testers-box button {
  box-shadow: 0 4px 14px rgba(var(--accent-strong-rgb), 0.3);
  border-radius: 6px;
}

/* Secondary action buttons — the game's workhorse control (Attack, Craft, Cast, Buy,
   Take, Cancel Travel…). Formerly solid bronze with dark text; now a quiet ember
   outline chip that flips solid on hover. Grouped here once rather than re-editing
   each rule above. */
/* .spellbook-cast-btn deliberately absent — the Spellbook's controls carry their own
   arcane-violet identity (see the .spellbook-* block) matching its mystical frame art,
   not the shared ember chip. */
.chat-form button, .activity-toggle, .ground-loot-take-btn, .ground-loot-take-all-btn,
.ground-loot-split-btn, .ground-loot-open-btn, .ground-loot-take-from-bag-btn,
.guild-action-btn, .guild-sign-btn, .guild-inline-form button,
.guild-management-buttons button, .stable-buy-btn,
.crafting-craft-btn, .admin-player-action-btn, .auto-battle-btn,
.ground-loot-view-btn, .travel-cancel-btn, .house-travel-cancel-btn, .house-view-leave-btn,
.trade-accept-btn, .quick-action-btn,
.treasure-site-attack-btn, .treasure-site-open-chest-btn, .treasure-site-abandon-btn,
.treasure-site-cancel-pick-btn, .stockpile-add-btn, .stockpile-add-all-btn,
.repair-bench-row-btn, .repair-bench-all-btn, .blacksmith-shop-row-btn, .blacksmith-shop-repair-all-btn,
.quest-log-contract-btn {
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 5px;
  color: var(--accent);
}

.chat-form button:hover, .activity-toggle:hover, .ground-loot-take-btn:hover,
.ground-loot-take-all-btn:hover, .ground-loot-split-btn:hover,
.ground-loot-open-btn:hover, .ground-loot-take-from-bag-btn:hover,
.guild-action-btn:hover, .guild-sign-btn:hover,
.guild-inline-form button:hover, .guild-management-buttons button:hover,
.stable-buy-btn:hover:not(:disabled),
.crafting-craft-btn:hover:not(:disabled), .admin-player-action-btn:hover,
.auto-battle-btn:hover, .ground-loot-view-btn:hover,
.travel-cancel-btn:hover:not(:disabled), .house-travel-cancel-btn:hover:not(:disabled),
.house-view-leave-btn:hover, .trade-accept-btn:hover:not(:disabled), .quick-action-btn:hover,
.treasure-site-attack-btn:hover, .treasure-site-open-chest-btn:hover,
.treasure-site-abandon-btn:hover, .treasure-site-cancel-pick-btn:hover,
.stockpile-add-btn:hover, .stockpile-add-all-btn:hover,
.repair-bench-row-btn:hover, .repair-bench-all-btn:hover, .blacksmith-shop-row-btn:hover,
.blacksmith-shop-repair-all-btn:hover:not(:disabled),
.quest-log-contract-btn:hover:not(:disabled) {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

/* Disabled state must still beat the grouped chip style above (same specificity,
   declared later). */
.stable-buy-btn:disabled, .crafting-craft-btn:disabled,
.travel-cancel-btn:disabled, .house-travel-cancel-btn:disabled, .trade-accept-btn:disabled {
  background: rgba(255, 255, 255, 0.05);
  border-color: rgba(255, 255, 255, 0.1);
  color: #6d7480;
}

/* The bank weight meter fill was tokenized to the translucent edge color — give it a
   real ember fill instead. */
.bank-weight-bar-fill { background: var(--accent-strong); }

/* The header Log Out button never had a rule of its own — it was rendering as a raw
   browser-default button. Styled as a quiet outline chip matching the secondary
   buttons above. */
.game-header form button {
  padding: 5px 12px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 5px;
  color: var(--muted);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.game-header form button:hover {
  border-color: rgba(var(--edge-rgb), 0.5);
  color: var(--accent);
}

/* Crafting gumps (Blacksmithing/Carpentry/Tinkering) share .skills-gump's plain panel
   but flex-column themselves so a long recipe list can scroll internally (see
   .crafting-gump-body) without the whole window growing unbounded. */
.crafting-gump {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-image: none;
  background: var(--panel);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
  /* No longer user-resizable (see crafting.js) — capped instead of growing unbounded.
     The scroll lives on .crafting-gump-body below, not here, so the header (title,
     drag handle, close button) stays put instead of scrolling out of reach along with
     a long recipe list. */
  max-height: 80vh;
}

/* .crafting-gump is a flex column (see above) — pin the header as a fixed-height flex
   item so a long/scrolling recipe list below can't squeeze it. */
.crafting-gump .skills-gump-header {
  position: static;
  flex-shrink: 0;
  height: auto;
  padding: 10px 14px;
  border-bottom: 2px solid rgba(255, 255, 255, 0.12);
  background: transparent;
  justify-content: space-between;
}

.crafting-gump .skills-gump-header span {
  display: block;
}

.crafting-gump .skills-gump-close {
  margin: 0;
}

/* The qty input borrows .devtools-gump-input's purple identity in markup — keep the
   sharing, override the skin to match every other slate input. */
.crafting-qty-input {
  background: var(--well);
  border: 1px solid var(--edge);
  color: var(--ink);
  font-family: inherit;
}

/* ============ Backpack-as-bag ============
   The main Backpack sheds the glass panel entirely — it renders as the bag itself
   (backpack_gump.png, drawn by tools/generateSprites.js's drawBackpackGump). The
   padding here MUST match that file's BAG_PAD_* constants (56/34/30/34): it positions
   the item grid exactly over the art's open interior. Sub-bags and the Bank keep
   their existing looks. */
.inventory-panel {
  background: none;
  border: none;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  padding: 0;
}

.inventory-panel h2 { margin: 0 0 6px 12px; }

.backpack-bag {
  position: relative;
  background: url('/assets/sprites/backpack_gump.png') top left / 100% 100% no-repeat;
  padding: 56px 34px 30px;
  width: fit-content;
  /* drop-shadow follows the bag's silhouette (flap wings and all), where box-shadow
     would draw a rectangle around the transparent canvas. */
  filter: drop-shadow(0 14px 24px rgba(0, 0, 0, 0.55));
}

/* "Established <date>" stitched into the flap. tools/generateSprites.js's
   drawBackpackGump traces the flap as two quadratic curves (top rim, bottom scalloped
   seam) that both bow toward the center, so the leather band between them is NOT a
   straight strip — it's narrowest near the sides (~y32-54 around x90/x338) and widest
   dead center (~y26-57 at x214). top/left/right/height below sit inside the tightest
   part of that band across its whole horizontal run, so the text never pokes above the
   rim or below the seam at any point along it. Colour is the same seam's own stitch
   thread hue (240,216,120) darkened down to a worn dark-gold, so it reads as stitched by
   the same hand rather than a bright UI label glued on top.

   That thread is a VARIABLE now, because the flap it is stitched into changes material
   (config/backpackSkins.js). --pack-ink defaults to the original leather's dark gold and
   is overridden per skin beside that skin's art rules further up this file; because the
   override lives on .backpack-bag[data-pack-skin], the same attribute that swaps the art
   recolours the thread, on first paint and on a live swap alike, with no JS involved.

   --pack-emboss flips with it, and that is the half worth spelling out: dark-below /
   light-above reads as stitched INTO a dark hide, and on Winterpelt's pale fur it read as
   grime instead — a light lift below the letters is what makes the same words look pressed
   into a light material. */
.backpack-established {
  position: absolute;
  top: 33px;
  height: 18px;
  left: 90px;
  right: 90px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  pointer-events: none;
  user-select: none;
  white-space: nowrap;
  overflow: hidden;
  font-family: Georgia, 'Times New Roman', serif;
  font-style: italic;
  font-variant: small-caps;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1;
  letter-spacing: 1px;
  color: var(--pack-ink, rgba(210, 170, 90, 0.8));
  text-shadow: var(--pack-emboss, 0 1px 0 rgba(0, 0, 0, 0.5), 0 -1px 0 rgba(255, 230, 160, 0.1));
}

.backpack-established::before,
.backpack-established::after {
  content: '';
  flex: 1 1 16px;
  max-width: 28px;
  height: 0;
  /* The flourish rules follow the thread rather than carrying their own copy of it. */
  border-top: 1px dashed color-mix(in srgb, var(--pack-ink, rgba(210, 170, 90, 0.8)) 65%, transparent);
}

.backpack-bag .backpack-panel {
  background-image: none;
  border: none;
  border-radius: 10px;
  overflow: visible;
  /* Above the Side Panel flaps below, so item icons filed into a panel (negative /
     oversized left offsets overflowing this element) paint ON the flap art rather
     than under it. */
  position: relative;
  z-index: 1;
}

/* Backpack Side Panels (the 'side_panels' premium unlock, config/donationStore.js) —
   two pannier flaps flanking the bag art. Positioned children of .backpack-bag, so
   they ride its drag/dock moves and share its silhouette drop-shadow (the parent's
   filter includes them). The art dimensions and the drop zone's interior offsets are
   tools/generateSprites.js's paintSidePanelFlap geometry (146x346 canvas, interior
   110x260 at inner-pad 14 / outer-pad 22 / top 56) — change them together or not at
   all, same contract as .backpack-bag's own padding above. Only the recessed interior
   takes the mouse: the leather margins are pointer-transparent so a drop on them falls
   through to nothing rather than pretending the rim is storage. */
.backpack-side-flap {
  position: absolute;
  top: 0;
  width: 146px;
  height: 346px;
  pointer-events: none;
}

.backpack-side-flap.left {
  left: -146px;
  background: url('/assets/sprites/backpack_side_left.png') top left / 100% 100% no-repeat;
}

.backpack-side-flap.right {
  right: -146px;
  background: url('/assets/sprites/backpack_side_right.png') top left / 100% 100% no-repeat;
}

/* The Donation Store's backpack skins (config/backpackSkins.js) — one data attribute
   on .backpack-bag dresses satchel and both flaps at once, since the flaps are its
   children, AND carries the --pack-ink/--pack-emboss the "Established" thread reads
   (see .backpack-established below). Those two are chosen for LEGIBILITY on each flap
   rather than copied from the palette: Verdant's own stitch hue disappears into its
   canvas at 11px, and Winterpelt needs the emboss inverted, not merely a darker thread. Painted by tools/generateSprites.js's drawBackpackSkins; server-rendered
   on first paint (views/partials/inventory.ejs) and kept live by
   public/js/premiumUnlocks.js. */
.backpack-bag[data-pack-skin="sovereign"] {
  background-image: url('/assets/sprites/backpack_gump_skin_sovereign.png');
  --pack-ink: rgba(255, 211, 92, 0.85);
}
.backpack-bag[data-pack-skin="sovereign"] .backpack-side-flap.left {
  background-image: url('/assets/sprites/backpack_side_left_skin_sovereign.png');
}
.backpack-bag[data-pack-skin="sovereign"] .backpack-side-flap.right {
  background-image: url('/assets/sprites/backpack_side_right_skin_sovereign.png');
}
.backpack-bag[data-pack-skin="verdant"] {
  background-image: url('/assets/sprites/backpack_gump_skin_verdant.png');
  --pack-ink: rgba(232, 236, 196, 0.82);
}
.backpack-bag[data-pack-skin="verdant"] .backpack-side-flap.left {
  background-image: url('/assets/sprites/backpack_side_left_skin_verdant.png');
}
.backpack-bag[data-pack-skin="verdant"] .backpack-side-flap.right {
  background-image: url('/assets/sprites/backpack_side_right_skin_verdant.png');
}
.backpack-bag[data-pack-skin="corsair"] {
  background-image: url('/assets/sprites/backpack_gump_skin_corsair.png');
  --pack-ink: rgba(238, 246, 250, 0.85);
}
.backpack-bag[data-pack-skin="corsair"] .backpack-side-flap.left {
  background-image: url('/assets/sprites/backpack_side_left_skin_corsair.png');
}
.backpack-bag[data-pack-skin="corsair"] .backpack-side-flap.right {
  background-image: url('/assets/sprites/backpack_side_right_skin_corsair.png');
}
.backpack-bag[data-pack-skin="shadow"] {
  background-image: url('/assets/sprites/backpack_gump_skin_shadow.png');
  --pack-ink: rgba(208, 198, 238, 0.82);
}
.backpack-bag[data-pack-skin="shadow"] .backpack-side-flap.left {
  background-image: url('/assets/sprites/backpack_side_left_skin_shadow.png');
}
.backpack-bag[data-pack-skin="shadow"] .backpack-side-flap.right {
  background-image: url('/assets/sprites/backpack_side_right_skin_shadow.png');
}
.backpack-bag[data-pack-skin="winter"] {
  background-image: url('/assets/sprites/backpack_gump_skin_winter.png');
  --pack-ink: rgba(44, 38, 32, 0.9);
  --pack-emboss: 0 1px 0 rgba(255, 255, 255, 0.4), 0 -1px 0 rgba(0, 0, 0, 0.18);
}
.backpack-bag[data-pack-skin="winter"] .backpack-side-flap.left {
  background-image: url('/assets/sprites/backpack_side_left_skin_winter.png');
}
.backpack-bag[data-pack-skin="winter"] .backpack-side-flap.right {
  background-image: url('/assets/sprites/backpack_side_right_skin_winter.png');
}

.backpack-side-drop {
  position: absolute;
  top: 56px;
  width: 110px;
  height: 260px;
  border-radius: 9px;
  pointer-events: auto;
}

.backpack-side-flap.left .backpack-side-drop { left: 22px; }
.backpack-side-flap.right .backpack-side-drop { left: 14px; }

/* Same accent outline every other scatter surface shows mid-drag. */
.backpack-side-drop.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* Quick-action row: Bank/Quests/Friends, centered under the bag art itself (inside
   .inventory-panel, sibling to .backpack-bag — not part of the bag's own drop-shadowed
   footprint, so its own shadow doesn't stack with the bag's). */
.quick-actions-row {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 10px;
}

/* Bank/Quests/Friends sit over the location backdrop art (not on a panel), so the
   shared translucent edge-chip look dissolved into bright scenes ("lost in the
   background", per playtest). Overridden here — declared after the shared group, same
   specificity, so these props win — with an OPAQUE panel-grounded base: theme accent
   tint over var(--panel), accent border and text, and a real shadow to lift them off
   the art. Every color is a theme token, so they recolor with the active palette same
   as .travel-map-open-btn's own solid chip. The shared group's :hover (higher
   specificity) still applies — full accent fill on hover, unchanged. */
.quick-action-btn {
  padding: 6px 16px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.3px;
  background:
    linear-gradient(180deg, rgba(var(--accent-strong-rgb), 0.28), rgba(var(--accent-strong-rgb), 0.08) 55%, rgba(0, 0, 0, 0.25)),
    var(--panel);
  border: 1px solid rgba(var(--accent-strong-rgb), 0.7);
  border-radius: 5px;
  color: var(--accent-bright);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.7);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The World Map header button was a 22px icon squint — now a solid ember chip, the
   one always-visible call-to-action on the main screen. */
.travel-map-open-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 5px 12px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 5px;
  color: var(--on-accent);
  box-shadow: 0 3px 10px rgba(var(--accent-strong-rgb), 0.3);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.travel-map-open-btn:hover { background: var(--accent-bright); }

/* Folded-parchment glyph, drawn rather than an emoji so it's pixel-identical on every
   OS/browser instead of falling back to whichever emoji font the client has installed.
   Panels theme off --on-accent (same token the button's own text uses, so contrast
   against the ember gradient is guaranteed on every theme); the route dot uses
   --accent-bright for a small "special" pop that still tracks the active theme. */
.map-btn-icon {
  width: 17px;
  height: 16px;
  overflow: visible;
  flex-shrink: 0;
}

.map-btn-icon .map-icon-panel { fill: var(--on-accent); opacity: 0.78; }
.map-btn-icon .map-icon-panel-mid { opacity: 1; }

.map-btn-icon .map-icon-fold {
  stroke: var(--accent-deep);
  stroke-width: 1;
  fill: none;
  opacity: 0.7;
}

.map-btn-icon .map-icon-route {
  stroke: var(--on-accent);
  stroke-width: 1.1;
  stroke-linecap: round;
  stroke-dasharray: 1.6 2;
  fill: none;
  opacity: 0.6;
}

.map-btn-icon .map-icon-pin {
  fill: var(--accent-bright);
  stroke: var(--on-accent);
  stroke-width: 0.6;
}

/* ============ Status gump ============
   Now just the always-visible essentials: the character's name in the title bar
   (serif override near the end of this file), vitals bars, transient progress rows,
   and the buff bar — stat training moved to the Skills panel, the badge strip to the
   header crest. */
.status-gump { width: 248px; }

/* Ember hairline divider between the gump's sections, brightest in the middle. */
.status-divider {
  height: 1px;
  margin: 12px 4px 10px;
  background: linear-gradient(90deg, transparent, rgba(var(--edge-rgb), 0.55) 50%, transparent);
}

.status-stats .stats-row { padding: 5px 0; }
.status-stats .stats-row + .stats-row { border-top: 1px solid rgba(255, 255, 255, 0.05); }

.status-stats .stats-total {
  border-top: none;
  padding-top: 4px;
  font-variant-numeric: tabular-nums;
}

/* Thin meter under the Total line showing how close to the stat cap you are. */
.stats-total-bar {
  height: 4px;
  margin-top: 5px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.07);
  overflow: hidden;
}

.stats-total-fill {
  height: 100%;
  border-radius: 2px;
  background: linear-gradient(90deg, var(--accent-deep), var(--accent));
  box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.45);
}

/* Attack / Armor / Weight as three equal columns split by vertical dividers. */
.status-gump .stats-icon-row {
  justify-content: stretch;
  gap: 0;
  margin-top: 0;
  padding-top: 2px;
  border-top: none;
}

.status-gump .stats-icon-badge {
  flex: 1;
  justify-content: center;
  font-variant-numeric: tabular-nums;
}

.status-gump .stats-icon-badge + .stats-icon-badge {
  border-left: 1px solid rgba(255, 255, 255, 0.1);
}

/* ============ "On the Ground" panel ============
   Same glass chrome as the other game-main panels; also a drop target — dragdrop.js
   treats a drop anywhere on it as item:drop. */
.ground-panel {
  width: 248px;
  align-self: flex-start;
  padding: 16px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

.ground-panel h2 {
  margin: 0 0 8px;
  font-family: system-ui, 'Segoe UI', sans-serif;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}

.ground-panel-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
  margin-bottom: 8px;
}

.ground-panel.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* ============ Session Gains panel ============
   Sits right after "On the Ground" in game-main's flow (views/game.ejs), same width
   (248px) so the two read as a matched pair; height locked to 500px to match
   .equipped-panel-wrap/.activities-panel below. Two real tabs (Skill/Gains) share this
   one fixed-height shell — only the active .session-gains-tab-panel is display:flex
   (the other display:none), so it alone stretches to fill the remaining height below
   the header/tab row, with its own internal list scrolling and its footer/Reset button
   pinned below that. Themed scrollbar comes free from the global
   *::-webkit-scrollbar rules (top of this file) the moment a list actually overflows. */
.session-gains-panel {
  width: 248px;
  height: 500px;
  display: flex;
  flex-direction: column;
  padding: 16px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
}

.session-gains-panel h2 {
  margin: 0 0 8px;
  font-family: system-ui, 'Segoe UI', sans-serif;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--accent);
}

.session-gains-tabs {
  display: flex;
  gap: 6px;
  margin-bottom: 10px;
  flex-shrink: 0;
}

.session-gains-tab-btn {
  flex: 1;
  padding: 4px 6px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.session-gains-tab-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.session-gains-tab-panel {
  display: none;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}

.session-gains-tab-panel.active {
  display: flex;
}

.session-gains-list {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.session-gains-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 4px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  flex-shrink: 0;
}

.session-gains-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.session-gains-value {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* The "| 62.3" current-absolute-level suffix on a Skill tab row — deliberately muted
   and not bold, so the session's own "+0.3" gain (the actual point of the tab) stays
   the visually louder of the two numbers. */
.session-gains-current-level {
  font-weight: normal;
  color: var(--muted);
}

.session-gains-empty {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
  text-align: center;
  margin-top: 12px;
}

/* Total Skills line — sits between the Skill tab's list and its Reset button. */
.session-gains-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-shrink: 0;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.session-gains-footer-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}

.session-gains-footer-value {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

/* Virtue XP total/per-hour + Monsters Slain — the Gains tab's fixed stat block above
   its scrolling Item Gains list. */
.session-gains-stat-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 3px 0;
  flex-shrink: 0;
}

.session-gains-stat-label {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.session-gains-stat-value {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
}

.session-gains-divider {
  flex-shrink: 0;
  height: 1px;
  margin: 6px 0 8px;
  background: rgba(255, 255, 255, 0.08);
}

.session-gains-item-header {
  flex-shrink: 0;
  margin-bottom: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}

.session-gains-reset-btn {
  flex-shrink: 0;
  margin-top: 10px;
  padding: 6px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--ink-dim);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.session-gains-reset-btn:hover {
  background: var(--edge);
  color: var(--ink);
}

/* Collapsible Stats section inside the Status gump — same chevron/.collapsed pattern
   as .crafting-section-header, scoped to this gump. Replaces the divider that used to
   sit between the vitals and the stat rows (its border-bottom plays that role now). */
.status-stats-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 4px 2px;
  margin-top: 10px;
  font-family: system-ui, 'Segoe UI', sans-serif;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--accent);
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.status-stats-toggle {
  transition: transform 0.15s ease;
  font-size: calc(9px * var(--ui-font-scale, 1));
}

.status-stats-section.collapsed .status-stats-toggle {
  transform: rotate(-90deg);
}

.status-stats-section.collapsed .status-stats {
  display: none;
}

/* Total line, sans the old meter — tightened so it doesn't eat vertical space. */
.status-stats .stats-total {
  margin-top: 2px;
  padding-top: 2px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

/* ============ Market Place ============
   The market gump (views/partials/marketGump.ejs, public/js/market.js) reuses the
   crafting gumps' glass chrome (.crafting-gump) for its shell. Two interior modes,
   swapped by public/js/market.js: the DIRECTORY (a searchable, shuffle-ordered list of
   table names — never owner names) and the TABLE VIEW (one table's physical surface,
   the backpack's own free-scatter idiom at double size, config/market.js's
   TABLE_SURFACE_WIDTH/HEIGHT). Plus the fixed-position modals (sell settings /
   open-a-table / one-time welcome). */
.market-gump {
  width: 548px;
}

.market-search-input {
  width: 100%;
  box-sizing: border-box;
  padding: 7px 10px;
  margin-bottom: 10px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--edge);
  border-radius: 5px;
  color: var(--ink);
}
.market-search-input:focus { outline: none; border-color: var(--accent); }

.market-directory-list {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 480px;
  overflow-y: auto;
}

/* The four awning section headers the directory groups under (public/js/market.js's
   renderDirectoryList) — the chip carries the stall's accent colour straight off
   config/market.js's MARKET_STALLS, the same hue its scene sprite's awning wears, so
   the section you read is visibly the stall you'd walk to. */
.market-stall-header {
  display: flex;
  align-items: center;
  gap: 7px;
  margin: 10px 2px 2px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}
.market-stall-header:first-child { margin-top: 2px; }
.market-stall-chip {
  width: 10px;
  height: 10px;
  border-radius: 2px;
  border: 1px solid rgba(0, 0, 0, 0.35);
  flex-shrink: 0;
}

.market-directory-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 8px 12px;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 5px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.market-directory-row:hover { border-color: var(--accent); color: var(--accent); }
.market-directory-row.mine { border-color: rgba(var(--edge-rgb), 0.6); }
.market-directory-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.market-directory-count { font-size: calc(11px * var(--ui-font-scale, 1)); color: var(--ink-dim); white-space: nowrap; }

.market-directory-empty {
  padding: 18px 10px;
  text-align: center;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--ink-dim);
}

/* ---- Table view: the physical surface ---- */
.market-table-view-bar {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.market-back-btn {
  padding: 4px 10px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink-dim);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.market-back-btn:hover { border-color: rgba(var(--edge-rgb), 0.5); color: var(--accent); }

.market-table-view-name {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-weight: bold;
  color: var(--accent);
}

.market-table-view-info { color: var(--ink-dim); font-size: calc(11px * var(--ui-font-scale, 1)); white-space: nowrap; }

/* The surface itself — hand-supplied tabletop art (assets/sprites/market/
   table_surface.png, stored at 2x the display size for high-DPI), stretched to the
   exact pixel size from config/market.js's TABLE_SURFACE_WIDTH/HEIGHT so server-side
   clamping and the client agree. border-radius clips the art's own baked-in white
   corners (it ships RGB, no alpha). A drop target (dragdrop.js's
   #market-table-surface branch). */
.market-table-surface {
  position: relative;
  width: 504px;
  height: 364px;
  border: 1px solid var(--edge);
  border-radius: 14px;
  overflow: hidden;
  background: url('/assets/sprites/market/table_surface.png') center / 100% 100% no-repeat;
}
.market-table-surface.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* A listed item sitting on the surface — deliberately NOT .item-icon (the global
   drag/context-menu/tooltip systems must never treat another player's goods as
   draggable inventory; hover info rides tooltip.js's generic [data-tooltip] path). */
.market-surface-icon {
  position: absolute;
  width: 44px;
  height: 44px;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.market-surface-icon.pending { opacity: 0.55; }

.market-surface-price {
  position: absolute;
  transform: translateX(-50%);
  padding: 1px 5px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  background: rgba(0, 0, 0, 0.68);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 3px;
  color: var(--gold, #d4af37);
  white-space: nowrap;
  pointer-events: none;
}
.market-surface-price.pending { color: var(--ink-dim); font-style: italic; }

/* Stack count on a listed pile — the backpack's .stack-badge look, re-anchored:
   surface icons are bare positioned imgs, so the badge positions against the surface
   itself (public/js/market.js sets left/top at the icon's bottom-right corner;
   translateX right-aligns it to that corner whatever width the number needs). */
.market-surface-stack {
  position: absolute;
  min-width: 14px;
  height: 14px;
  padding: 0 2px;
  border-radius: 3px;
  background: var(--panel);
  color: var(--ink);
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: calc(14px * var(--ui-font-scale, 1));
  text-align: center;
  border: 1px solid var(--edge);
  pointer-events: none;
  transform: translateX(-100%);
}

.market-owner-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 8px;
}

.market-owner-till {
  flex: 1;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
}

.market-rename-btn {
  padding: 4px 10px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink-dim);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.market-rename-btn:hover { border-color: rgba(var(--edge-rgb), 0.5); color: var(--accent); }

.market-my-table-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 6px 10px;
  margin-bottom: 10px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
  background: rgba(var(--edge-rgb), 0.07);
  border: 1px solid rgba(var(--edge-rgb), 0.35);
  border-radius: 5px;
}

.market-abandon-btn {
  padding: 4px 10px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(201, 60, 60, 0.6);
  border-radius: 4px;
  color: #e08a8a;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.market-abandon-btn:hover { background: rgba(201, 60, 60, 0.15); }

/* World Map panel's market note/button — same quiet-note styling family as
   .stable-note/.blacksmith-note (which inherit generic styles), plus a real button. */
.market-note {
  margin-top: 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
  font-style: italic;
}

.market-open-btn {
  margin-top: 6px;
  width: 100%;
  padding: 7px 10px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 5px;
  color: var(--on-accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Fixed-position modal shell shared by the sell-settings and welcome popups — the
   game's first true modal (a centered dialog over a dimmed backdrop), distinct from
   the draggable gumps: setting a price is a focused decision, not a window to park. */
.market-modal-overlay {
  position: fixed;
  inset: 0;
  /* Modal band (3000s): must clear the gump/floated-panel band (950+, grows per
     focus — see gumpWindow.js's bringToFront guard). */
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
}

.market-modal {
  width: 360px;
  background: var(--panel);
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-radius: 8px;
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.6);
}

.market-modal .skills-gump-header { cursor: default; }

.market-modal-body {
  padding: 12px 14px 14px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.market-modal-body p { margin: 0 0 10px; line-height: 1.5; }
.market-modal-body a { color: var(--accent); }

.market-sell-item-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 10px;
}

.market-sell-item-icon {
  width: 36px;
  height: 36px;
  image-rendering: pixelated;
}

.market-sell-item-name { font-weight: bold; color: var(--ink); }

.market-sell-price-label {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  margin-bottom: 10px;
}

#market-sell-price-input { width: 120px; padding: 5px 8px; }

/* Open-a-Table popup: full-width fields — the shared .crafting-qty-input base width
   is sized for tiny quantity boxes, far too narrow for typing out a shop name. */
#market-open-name-input,
#market-open-fund-input {
  width: 100%;
  box-sizing: border-box;
  padding: 6px 8px;
}

.market-sell-mode-row {
  display: flex;
  gap: 14px;
  margin-bottom: 12px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.market-sell-mode-row label {
  display: flex;
  align-items: center;
  gap: 5px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.market-modal-actions {
  display: flex;
  gap: 8px;
  justify-content: flex-end;
}

.market-confirm-btn {
  padding: 7px 14px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 5px;
  color: var(--on-accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.market-cancel-btn {
  padding: 7px 14px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge);
  border-radius: 5px;
  color: var(--ink-dim);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* "Welcome back" Offline Progress summary (views/partials/offlineSummaryModal.ejs) —
   its own richer chrome (not the plain .market-modal idiom every other one-shot popup
   reuses) since this is the one moment the game actively celebrates time the player
   wasn't even watching — a gold-lit hero banner, per-category accent colors, real item
   icons, and an entrance animation earn their keep here specifically. */
.offline-summary-modal-overlay {
  position: fixed;
  inset: 0;
  /* Modal band (3000s): must clear the gump/floated-panel band (950+, grows per
     focus — see gumpWindow.js's bringToFront guard). */
  z-index: 3000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: radial-gradient(ellipse at center, rgba(20, 14, 6, 0.55) 0%, rgba(0, 0, 0, 0.7) 100%);
}

.offline-summary-modal {
  width: 420px;
  max-height: 82vh;
  display: flex;
  flex-direction: column;
  background: var(--panel);
  border: 1px solid rgba(255, 190, 110, 0.4);
  border-radius: 10px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.65), 0 0 40px rgba(224, 160, 48, 0.15);
  overflow: hidden;
  opacity: 0;
  transform: translateY(14px) scale(0.94);
}

.offline-summary-modal.offline-summary-modal-enter {
  animation: offlineSummaryEnter 0.5s cubic-bezier(0.2, 0.8, 0.3, 1) forwards;
}

@keyframes offlineSummaryEnter {
  to { opacity: 1; transform: translateY(0) scale(1); }
}

.offline-summary-hero {
  position: relative;
  padding: 22px 20px 18px;
  text-align: center;
  background: linear-gradient(180deg, rgba(224, 160, 48, 0.16) 0%, rgba(224, 160, 48, 0) 70%), var(--panel-deep);
  border-bottom: 1px solid rgba(255, 190, 110, 0.25);
  overflow: hidden;
}

.offline-summary-hero-burst {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 260px;
  height: 260px;
  transform: translate(-50%, -50%);
  background: radial-gradient(circle, rgba(255, 197, 120, 0.35) 0%, rgba(255, 197, 120, 0) 65%);
  animation: offlineSummaryPulse 3.5s ease-in-out infinite;
  pointer-events: none;
}

@keyframes offlineSummaryPulse {
  0%, 100% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.92); }
  50% { opacity: 1; transform: translate(-50%, -50%) scale(1.05); }
}

.offline-summary-hero-title {
  position: relative;
  display: block;
  font-size: calc(22px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  background: linear-gradient(100deg, #ffb578 0%, #fff4d6 30%, #ffb578 55%, #f0a850 100%);
  background-size: 220% 100%;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  text-shadow: 0 0 24px rgba(255, 197, 120, 0.35);
  animation: offlineSummaryShimmer 4s linear infinite;
}

@keyframes offlineSummaryShimmer {
  0% { background-position: 0% 0; }
  100% { background-position: -220% 0; }
}

.offline-summary-hero-subtitle {
  position: relative;
  display: inline-block;
  margin-top: 8px;
  padding: 3px 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--on-accent);
  background: linear-gradient(180deg, var(--accent-bright), var(--accent-strong));
  border-radius: 999px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
}

.offline-summary-modal-body {
  padding: 16px 18px 6px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  overflow-y: auto;
}

.offline-summary-section {
  margin-bottom: 14px;
  padding-left: 12px;
  border-left: 3px solid var(--section-accent, #e0a030);
}

.offline-summary-section-gathering { --section-accent: #8fbf6f; }
.offline-summary-section-crafting { --section-accent: #d99a5b; }
.offline-summary-section-combat { --section-accent: #e0605a; }

.offline-summary-section-title {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--section-accent, #e0a030);
  margin-bottom: 6px;
}

.offline-summary-section-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.06);
  filter: drop-shadow(0 0 4px var(--section-accent, transparent));
}

.offline-summary-list {
  display: flex;
  flex-direction: column;
  gap: 5px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.offline-summary-line {
  display: flex;
  align-items: center;
  gap: 8px;
  line-height: 1.4;
}

.offline-summary-item-icon {
  width: 22px;
  height: 22px;
  flex-shrink: 0;
  image-rendering: pixelated;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.05);
}

.offline-summary-item-icon-blank { visibility: hidden; }

.offline-summary-item-name { color: var(--ink); }

.offline-summary-item-amount {
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.offline-summary-line-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  flex-shrink: 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.05);
}

.offline-summary-line-skill { color: var(--ink); }
.offline-summary-line-skill .offline-summary-line-badge { color: var(--section-accent, #e0a030); }
.offline-summary-line-skill strong { color: var(--section-accent, #e0a030); }

.offline-summary-line-gold {
  color: #f0d878;
  font-weight: bold;
}
.offline-summary-line-gold .offline-summary-line-badge { color: #f0d878; }

.offline-summary-line-kills .offline-summary-line-badge { filter: grayscale(1) brightness(1.4); }

.offline-summary-line-fail { color: var(--muted); font-size: calc(12px * var(--ui-font-scale, 1)); }

/* Why a window stopped before its time ran out (pack full / no tools left / no material
   left) — public/js/offlineSummary.js's buildStoppedEarlyNote. Amber rather than the
   death line's red: this is a "your night was cut short, here's what to fix" notice, not
   a loss. Sits inside its own section, since gathering and crafting can each stop for a
   different reason in the same window. */
.offline-summary-line-stopped {
  margin-top: 4px;
  padding: 6px 9px;
  color: #f0c070;
  background: rgba(224, 160, 48, 0.1);
  border: 1px solid rgba(224, 160, 48, 0.28);
  border-radius: 5px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}
.offline-summary-line-stopped .offline-summary-line-badge { color: #f0c070; }

.offline-summary-line-death {
  margin-top: 4px;
  padding: 8px 10px;
  color: #ff9d94;
  background: rgba(224, 96, 90, 0.12);
  border: 1px solid rgba(224, 96, 90, 0.3);
  border-radius: 5px;
}

.offline-summary-line-cheat {
  margin-top: 4px;
  padding: 8px 10px;
  color: #ffe6ad;
  background: rgba(255, 197, 120, 0.12);
  border: 1px solid rgba(255, 197, 120, 0.35);
  border-radius: 5px;
}

.offline-summary-cap-note {
  margin: 6px 0 12px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
}

.offline-summary-modal-actions {
  padding: 6px 18px 20px;
  display: flex;
  justify-content: center;
}

.offline-summary-confirm-btn {
  padding: 10px 28px;
  font-family: inherit;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  color: var(--on-accent);
  background: linear-gradient(180deg, var(--accent-bright), var(--accent-strong));
  border: 1px solid var(--accent);
  border-radius: 999px;
  box-shadow: 0 4px 16px rgba(var(--accent-strong-rgb), 0.35);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.offline-summary-confirm-btn:hover {
  transform: translateY(-1px);
  box-shadow: 0 6px 20px rgba(var(--accent-strong-rgb), 0.5);
}

/* ============ Header crest ============
   A small banner hanging from the game header's center (views/partials/headerCrest.ejs)
   carrying the XP bar and the attack/armor/weight badges — moved out of the Status
   gump, which now stays tight around the vitals. The ::before/::after flare wings are
   quarter-circle concave fills that curve the crest's shoulders out into the header
   rail, so it reads as one continuous forged piece rather than a box floating under a
   bar. */
.header-crest {
  position: absolute;
  top: calc(100% + 3px); /* below the header's 3px ember rail, hanging from it */
  left: 50%;
  /* --crest-offset-x is a horizontal-only drag offset from center, set by
     public/js/headerCrest.js and persisted per character — 0px (dead center) until
     the player drags it. Only the X term ever changes; top/vertical position stays
     fixed to the header rail regardless. */
  transform: translateX(calc(-50% + var(--crest-offset-x, 0px)));
  z-index: 5;
  width: 250px;
  padding: 8px 16px 10px;
  background: linear-gradient(180deg, #171b23 0%, rgba(15, 17, 22, 0.9) 100%);
  border: 1px solid rgba(var(--edge-rgb), 0.35);
  border-top: none;
  border-radius: 0 0 20px 20px;
  box-shadow:
    0 10px 24px rgba(0, 0, 0, 0.5),
    0 0 14px rgba(var(--edge-rgb), 0.08),
    inset 0 -1px 0 rgba(var(--accent-rgb), 0.1);
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  cursor: url('/assets/sprites/cursors/grab.png') 16 16, grab;
}

.header-crest.crest-dragging {
  cursor: url('/assets/sprites/cursors/grabbing.png') 16 16, grabbing;
  user-select: none;
}

/* Flare wings. radial-gradient paints everything OUTSIDE a quarter-circle centered on
   the wing's bottom-inner corner, leaving a smooth concave curve from the header edge
   down onto the crest's side. Solid fill (not the translucent panel glass) — a
   translucent wing over the scrolling backdrop would read as a seam, not a curve. */
.header-crest::before,
.header-crest::after {
  content: '';
  position: absolute;
  top: 0;
  width: 20px;
  height: 20px;
  pointer-events: none;
}

.header-crest::before {
  left: -20px;
  background: radial-gradient(circle 20px at 0 100%, transparent 19px, #171b23 20px);
}

.header-crest::after {
  right: -20px;
  background: radial-gradient(circle 20px at 100% 100%, transparent 19px, #171b23 20px);
}

/* The XP bar — the reserved "thin glowing ember meter" design, now tracking the
   Virtue Binding live (public/js/bindingXpBar.js drives #xp-bar-fill's width off
   character:update; the slow breathing glow keeps it feeling alive even sitting at a
   fixed percentage between ticks). */
.xp-bar {
  height: 6px;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
  overflow: hidden;
}

.xp-bar-fill {
  height: 100%;
  border-radius: 3px;
  background: linear-gradient(90deg, var(--accent-deep), var(--accent));
  box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.45);
  animation: xp-ember-breathe 3s ease-in-out infinite;
  transition: width 0.4s ease;
}

@keyframes xp-ember-breathe {
  0%, 100% { box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.45); filter: brightness(1); }
  50% { box-shadow: 0 0 12px rgba(var(--edge-rgb), 0.8); filter: brightness(1.15); }
}

/* The level-up flourish (public/js/bindingXpBar.js) — the bar's own container gets a
   brighter halo around it for the moment the fill sits pinned at 100% before draining
   down to the new level's progress, so the "you just leveled" beat reads clearly even
   on the thin header crest bar. */
.xp-bar.xp-bar-levelup {
  border-color: var(--accent);
  box-shadow: 0 0 14px rgba(var(--edge-rgb), 0.85);
}

/* The active companion's level bar, hanging directly under the Binding bar (Animal
   Taming — views/partials/headerCrest.ejs, public/js/petXpBar.js). Deliberately NOT a
   second ember meter: 4px against the Binding bar's 6px, violet instead of accent, and
   no breathing animation. A player glancing at the crest must never mistake their pet's
   progress for their own Virtue XP, and violet is already this feature's colour (the
   pet buff icons and the paw share it). Hidden entirely when no pet is out — an empty
   bar would imply something to fill. */
.pet-xp-row {
  display: flex;
  align-items: center;
  gap: 5px;
  margin-top: 4px;
}

.pet-xp-paw {
  width: 10px;
  height: 10px;
  flex: none;
  image-rendering: pixelated;
  opacity: 0.9;
}

.pet-xp-bar {
  flex: 1;
  height: 4px;
  border-radius: 2px;
  background: rgba(0, 0, 0, 0.5);
  border: 1px solid rgba(255, 255, 255, 0.08);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.6);
  overflow: hidden;
}

.pet-xp-fill {
  height: 100%;
  border-radius: 2px;
  background: linear-gradient(90deg, #7a3fb8, #b98af0);
  box-shadow: 0 0 5px rgba(185, 138, 240, 0.5);
  transition: width 0.4s ease;
}

.pet-xp-level {
  flex: none;
  font-size: calc(9px * var(--ui-font-scale, 1));
  color: #b98af0;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
}

/* Badge strip inside the crest — .stats-icon-row's base rule was written for its old
   Status-gump home (top divider + wide margin); tighter here under the XP bar. */
.header-crest .stats-icon-row {
  margin-top: 7px;
  padding-top: 0;
  border-top: none;
}

/* ============ Stats section in the Skills panel ============
   The stat-training rows (moved from the Status gump — views/partials/activities.ejs)
   restyled to sit flush with the panel's collapsible skill groups: chevron-first
   header like .skills-group-header, not the old space-between gump layout. The
   .collapsed behavior itself comes from the existing .status-stats-section rules. */
/* The section sits UNDER the Skills header now (the header is the panel's drag handle and
   belongs on its top edge — views/partials/activities.ejs), so the spacing swapped ends:
   the gap it used to hold below itself is now above it, and .activities-list's own 10px
   margin-top supplies the one below. */
.skills-stats-section {
  margin-bottom: 0;
}

.skills-stats-section .status-stats-header {
  justify-content: flex-start;
  gap: 6px;
  margin-top: 8px;
  padding: 6px 4px 4px 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  letter-spacing: 0.05em;
}

/* ============ Skills panel: fixed height + animated collapses ============
   The Skills panel and the Equipped panel read as a mismatched pair whenever one's
   content made it taller than the other (expanding the Stats section, wrapping to
   separate flex lines at narrow widths) — both now share one fixed height, with the
   skill list flexing to absorb whatever the Stats section above it takes. 500px is the
   Equipped panel's natural content height with a buff active (vitals block + one line
   of buff-bar-row icons + slot grid + food slider) — was tuned to 460px against the
   no-buff case only, which left just 2px of slack; the buff-bar-row's own margin/
   padding/icon (public/js/buffBar.js, ~29px) blew straight through that the moment any
   buff/potion was active, spilling the paperdoll grid out past the panel's bottom edge.
   If this panel's contents ever change shape again, retune both numbers together
   against the WORST case (something active), not the empty one. */
.equipped-panel-wrap,
.activities-panel {
  height: 500px;
}

.activities-panel {
  display: flex;
  flex-direction: column;
}

.activities-panel .activities-list {
  flex: 1 1 auto;
  min-height: 0; /* lets the flex item actually shrink below its content height */
  max-height: none; /* the fixed panel height governs now, not the old 320px cap */
}

/* Slide-and-fade collapse for the skill groups and the Stats section — replaces the
   old instant display:none. The grid-template-rows 1fr->0fr transition animates the
   body's height without needing a measured max-height; the body keeps display:block
   while collapsed (overridden from the earlier rules) so there's something to animate,
   with overflow:hidden clipping it and opacity fading it on the way. */
.skills-group,
.status-stats-section {
  display: grid;
  grid-template-rows: auto 1fr;
  transition: grid-template-rows 0.28s ease;
}

.skills-group.collapsed,
.status-stats-section.collapsed {
  grid-template-rows: auto 0fr;
}

.skills-group-body,
.status-stats-section .status-stats {
  overflow: hidden;
  opacity: 1;
  transition: opacity 0.22s ease;
}

.skills-group.collapsed .skills-group-body,
.status-stats-section.collapsed .status-stats {
  display: block;
  opacity: 0;
  /* Overrides .skills-group-body's 8px empty-drop-target min-height — a collapsed
     group must actually reach 0 or the 0fr row gets propped open. */
  min-height: 0;
}



/* ============ Vitals inside the Equipped panel ============
   The Status gump's chrome is fully stripped — the vitals block (bars + progress rows
   + buff bar) is part of the Equipped Items panel now, sitting above the paperdoll
   grid (views/partials/equipped.ejs); an ember hairline below it hands off to the item
   slots. The character's name lives in the header crest. */
.status-gump-overlay {
  position: static;
}

.status-gump-overlay .status-gump {
  width: auto;
  background: none;
  border: none;
  border-radius: 0;
  box-shadow: none;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
  margin-bottom: 10px;
  padding-bottom: 8px;
  border-bottom: 1px solid rgba(var(--edge-rgb), 0.18);
}

.status-gump-overlay .nameplate {
  padding: 2px 2px 0;
}

/* The character's name in the header crest — serif identity font over the XP bar. */
.header-crest-name {
  position: relative; /* anchors .damage-popup-bindingxp's absolute top:0/left:50% here */
  text-align: center;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(15px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.02em;
  color: var(--accent);
  text-shadow: 0 1px 6px rgba(var(--accent-rgb), 0.35), 0 2px 3px rgba(0, 0, 0, 0.7);
  margin-bottom: 6px;
}

/* A quiet purple "+N XP" next to the Lv badge whenever Virtue Binding gains XP —
   public/js/bindingXpBar.js diffs character:update's cumulative binding.xp and spawns
   this via combat.js's shared spawnTextPopup helper (loads first, see views/game.ejs).
   Deliberately gentler than the combat damage-popup float this borrows its base
   position/pointer-events from: a quick fade in, then one continuous linear drift+fade
   out — no held plateau followed by a rushed finish (an earlier version had a long slow
   crawl in the middle before hurrying to fade at the end, which read as the popup
   stalling/lagging rather than gliding). */
@keyframes binding-xp-gain-fade {
  0% { opacity: 0; transform: translate(-50%, 4px); }
  20% { opacity: 1; transform: translate(-50%, 0); }
  100% { opacity: 0; transform: translate(-50%, -16px); }
}

.damage-popup-bindingxp {
  top: -14px;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #c9a0ff;
  text-shadow: 0 0 6px rgba(201, 160, 255, 0.65), 0 1px 3px rgba(0, 0, 0, 0.8);
  animation: binding-xp-gain-fade 1500ms linear forwards;
}

/* The Virtue Binding's level, sitting right after the name atop its own XP bar —
   smaller and unshadowed so it reads as a badge, not a second name. */
.header-crest-level {
  font-family: 'Segoe UI', sans-serif;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: normal;
  letter-spacing: normal;
  color: var(--accent-bright);
  text-shadow: none;
  opacity: 0.85;
}

/* The header crest hangs ~42px into the content area over the centered bag — this
   drops the bag below it with a small breathing gap (crest bottom sits at ~133px from
   the viewport top with game-main's 30px padding putting the bag at 91px). */
.inventory-panel {
  margin-top: 52px;
}

/* Inspect Binding gump (views/partials/bindingGump.ejs, public/js/binding.js) — same
   "standard glass chrome" shell the Crafting gump uses (see .crafting-gump above),
   just its own width for an 8-virtue grid rather than a scrolling recipe list. */
.binding-gump {
  display: flex;
  flex-direction: column;
  width: 700px;
  border: 1px solid var(--edge);
  border-top-color: rgba(var(--edge-rgb), 0.45);
  border-image: none;
  background: var(--panel);
  border-radius: 8px;
  backdrop-filter: blur(7px);
  -webkit-backdrop-filter: blur(7px);
  box-shadow: 0 16px 38px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.04);
  max-height: 85vh;
}

.binding-gump .skills-gump-header {
  position: static;
  flex-shrink: 0;
  height: auto;
  padding: 10px 14px;
  border-bottom: 2px solid rgba(255, 255, 255, 0.12);
  background: transparent;
  justify-content: space-between;
}

.binding-gump .skills-gump-header span { display: block; }
.binding-gump .skills-gump-close { margin: 0; }

.binding-gump-body {
  padding: 18px;
  overflow-y: auto;
  background: radial-gradient(ellipse at 50% -10%, rgba(var(--edge-rgb), 0.07), transparent 55%);
}

/* Shrine banner — ankh level-medallion beside the XP bar and the two point chips. */
.binding-summary {
  display: flex;
  align-items: center;
  gap: 20px;
  margin-bottom: 12px;
  padding-bottom: 16px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}

.binding-medallion {
  width: 84px;
  height: 84px;
  flex-shrink: 0;
  border-radius: 50%;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  background: radial-gradient(circle at 50% 38%, rgba(var(--edge-rgb), 0.18), rgba(0, 0, 0, 0.5) 70%);
  border: 2px solid var(--edge);
  box-shadow: 0 0 24px -4px rgba(var(--edge-rgb), 0.45), inset 0 0 14px rgba(0, 0, 0, 0.6);
}

/* A second, fainter orbit ring just outside the medallion's own border. */
.binding-medallion::before {
  content: '';
  position: absolute;
  inset: -7px;
  border-radius: 50%;
  border: 1px solid rgba(var(--edge-rgb), 0.25);
}

.binding-medallion-ankh {
  font-size: calc(26px * var(--ui-font-scale, 1));
  color: var(--accent);
  text-shadow: 0 0 12px rgba(var(--edge-rgb), 0.8);
  line-height: 1;
}

.binding-medallion-level {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent-bright);
  margin-top: 2px;
}

.binding-summary-info {
  flex: 1;
  min-width: 0;
}

/* A wider restatement of the header crest's own ember bar (.xp-bar/.xp-bar-fill in
   game.css) — same design, just promoted to a size worth reading progress off of. */
.binding-summary-xp-bar {
  height: 8px;
  border-radius: 4px;
  margin: 8px 0 3px;
}

.binding-summary-xp-text {
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  text-align: right;
  font-variant-numeric: tabular-nums;
}

.binding-summary-row {
  display: flex;
  gap: 12px;
  margin-top: 10px;
}

.binding-summary-chip {
  flex: 1;
  text-align: center;
  padding: 6px 0;
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid var(--edge);
  border-radius: 5px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
}

.binding-summary-chip strong {
  display: block;
  font-size: calc(15px * var(--ui-font-scale, 1));
  color: var(--accent-bright);
  font-family: Georgia, 'Times New Roman', serif;
}

.binding-summary-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  font-style: italic;
  line-height: 1.45;
}

.binding-summary-hint-candles {
  margin-bottom: 26px;
}

/* The eight alcoves. The generous row gap is load-bearing: each alcove's sigil roundel
   floats above its arch (translate(-50%, -40%)), so rows need air between them or a
   roundel crowds the tier list of the alcove above. */
.binding-virtues {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 34px 16px;
  padding-top: 16px;
}

/* One virtue's shrine alcove — arched top, tinted throughout by its own --vh hue
   (config/virtueBinding.js's per-virtue color, inlined by bindingGump.ejs). */
.binding-virtue {
  position: relative;
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--vh, var(--accent)) 10%, transparent), transparent 45%),
    rgba(0, 0, 0, 0.28);
  border: 1px solid color-mix(in srgb, var(--vh, var(--accent)) 35%, transparent);
  border-radius: 60px 60px 8px 8px;
  padding: 34px 11px 14px;
}

/* The virtue's sigil in a glowing roundel, breaking the top of the arch. */
.binding-virtue-roundel {
  position: absolute;
  top: -1px;
  left: 50%;
  transform: translate(-50%, -40%);
  width: 40px;
  height: 40px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: radial-gradient(circle at 50% 35%, #1a1e26, #0b0d12);
  border: 1.5px solid var(--vh, var(--accent));
  box-shadow: 0 0 16px -2px var(--vh, var(--accent)), inset 0 0 8px rgba(0, 0, 0, 0.7);
  font-size: 17px;
  color: var(--vh, var(--accent));
  text-shadow: 0 0 8px var(--vh, var(--accent));
  pointer-events: none;
}

.binding-virtue-header {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  position: relative;
}

.binding-virtue-label {
  font-weight: bold;
  color: color-mix(in srgb, var(--vh, var(--accent)) 55%, #fff);
  font-size: calc(13px * var(--ui-font-scale, 1));
}

.binding-virtue-theme {
  font-size: calc(9.5px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  margin: 2px 0 10px;
  min-height: 24px;
}

/* Suppressed via the on-panel candle or the legacy right-click (public/js/binding.js's
   binding:toggleVirtue) — points spent stay spent, but the whole alcove desaturates so
   it reads as a cold shrine at a glance; the candle below (is-off) already carries its
   own "Stilled" label, so no separate badge is needed. */
.binding-virtue-off {
  filter: saturate(0.22) brightness(0.85);
}

/* The votive candle (views/partials/bindingGump.ejs) — a flame that burns while the
   virtue is active and is snuffed to a smoke wisp while suppressed, so on/off reads at
   a glance across all 8 virtues without reading text. Disabled (no tier unlocked yet,
   or mid-cooldown) just dims it same as every other disabled control in this gump. */
.binding-virtue-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  margin-bottom: 13px;
  background: none;
  border: none;
  padding: 0;
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.binding-virtue-toggle:disabled {
  cursor: default;
  opacity: 0.55;
}

.binding-votive-candle {
  position: relative;
  width: 8px;
  height: 12px;
  flex-shrink: 0;
  background: linear-gradient(180deg, #cbb896, #7d6f52);
  border-radius: 2px 2px 1px 1px;
}

.binding-votive-flame {
  position: absolute;
  left: 50%;
  top: -8px;
  transform: translateX(-50%);
  width: 6px;
  height: 9px;
  background: radial-gradient(circle at 50% 80%, #ffe8b0, #ffb040 55%, rgba(255, 110, 30, 0.35));
  border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%;
  box-shadow: 0 0 10px 2px rgba(255, 170, 60, 0.55);
  animation: binding-votive-flicker 1.7s ease-in-out infinite;
}

@keyframes binding-votive-flicker {
  0%, 100% { transform: translateX(-50%) scale(1); opacity: 1; }
  30% { transform: translateX(-56%) scale(0.92, 1.06); opacity: 0.92; }
  60% { transform: translateX(-45%) scale(1.05, 0.95); opacity: 0.97; }
}

.binding-virtue-toggle.is-on .binding-virtue-toggle-text {
  color: #ffcf8e;
}

/* Snuffed — no flame, just a thin wisp of smoke rising off the wick. */
.binding-virtue-toggle.is-off .binding-votive-flame {
  display: none;
}

.binding-virtue-toggle.is-off .binding-votive-candle::after {
  content: '';
  position: absolute;
  left: 50%;
  top: -10px;
  transform: translateX(-50%);
  width: 2px;
  height: 8px;
  background: linear-gradient(180deg, transparent, rgba(160, 160, 170, 0.5));
  border-radius: 2px;
}

.binding-virtue-toggle.is-off .binding-virtue-toggle-text {
  color: #98a0ab;
}

/* The tier chain — a faint devotional thread runs behind the stones. */
.binding-virtue-tiers {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.binding-virtue-tiers::before {
  content: '';
  position: absolute;
  left: 8px;
  top: 10px;
  bottom: 10px;
  width: 1px;
  background: rgba(255, 255, 255, 0.09);
}

.binding-tier-node {
  position: relative;
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  background: none;
  border: none;
  padding: 2px 0;
  color: var(--ink-dim);
  font-size: calc(10.5px * var(--ui-font-scale, 1));
  text-align: left;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.binding-tier-node:disabled {
  cursor: default;
}

/* A rune-stone on the chain rather than a status dot. z-index lifts it over the
   thread behind it. */
.binding-tier-dot {
  width: 17px;
  height: 17px;
  border-radius: 50%;
  flex-shrink: 0;
  background: radial-gradient(circle at 40% 35%, #2a2f3a, #12151b);
  border: 1px solid rgba(255, 255, 255, 0.14);
  position: relative;
  z-index: 1;
}

/* Already spent — the stone fills with the virtue's own light. */
.binding-tier-unlocked {
  color: var(--ink);
}
.binding-tier-unlocked .binding-tier-dot {
  background: radial-gradient(
    circle at 40% 35%,
    color-mix(in srgb, var(--vh, var(--accent)) 80%, #fff),
    color-mix(in srgb, var(--vh, var(--accent)) 55%, #000)
  );
  border-color: transparent;
  box-shadow: 0 0 9px var(--vh, var(--accent));
}

/* The one node a talent point can actually buy right now — a slow beckoning pulse
   invites the click; still resolves to :disabled (see the EJS) when no points are
   left to spend. */
.binding-tier-next:not(:disabled) {
  color: color-mix(in srgb, var(--vh, var(--accent)) 70%, #fff);
}
.binding-tier-next:not(:disabled) .binding-tier-dot {
  border-color: var(--vh, var(--accent));
  animation: binding-stone-beckon 2.2s ease-in-out infinite;
}
.binding-tier-next:not(:disabled):hover {
  color: #fff;
}

@keyframes binding-stone-beckon {
  0%, 100% { box-shadow: 0 0 4px color-mix(in srgb, var(--vh, var(--accent)) 40%, transparent); }
  50% { box-shadow: 0 0 11px color-mix(in srgb, var(--vh, var(--accent)) 80%, transparent); }
}

/* Locked behind an earlier tier in the same virtue not being spent yet, or the next
   tier with no point to spend — both arrive here via :disabled. */
.binding-tier-locked,
.binding-tier-node:disabled:not(.binding-tier-unlocked) {
  opacity: 0.4;
}

/* Hover hosts for the custom tooltips (public/js/binding.js) — a :disabled button
   fires no mouse events at all, so the buttons go pointer-events:none and these
   wrappers catch the hover in their place. Interactivity is unharmed: enabled buttons'
   clicks/contextmenus bubble up through the wrapper as ever. */
.binding-tier-slot,
.binding-votive-slot {
  display: block;
}

.binding-tier-node:disabled,
.binding-virtue-toggle:disabled {
  pointer-events: none;
}

/* The shrine tooltip painted into tooltip.js's shared .item-tooltip element — that
   parent is white-space:nowrap for its usual one-stat-per-line items, so this resets
   to wrapping prose. Hue accents ride the same --vh custom property the alcoves use,
   copied onto the wrapper inline by binding.js. */
.binding-tier-tooltip {
  display: block;
  max-width: 250px;
  white-space: normal;
}

.binding-tooltip-virtue {
  display: block;
  font-size: 0.85em;
  color: color-mix(in srgb, var(--vh, var(--accent)) 75%, #fff);
  opacity: 0.85;
}

.binding-tooltip-title {
  display: block;
  font-weight: bold;
  font-size: 1.05em;
  color: color-mix(in srgb, var(--vh, var(--accent)) 55%, #fff);
  margin: 1px 0 4px;
}

.binding-tooltip-desc {
  display: block;
  color: var(--ink);
  margin-bottom: 5px;
}

.binding-tooltip-bound {
  display: block;
  font-weight: bold;
  color: color-mix(in srgb, var(--vh, var(--accent)) 70%, #fff);
}

.binding-tooltip-stilled {
  display: block;
  font-weight: bold;
  color: #98a0ab;
}

.binding-tooltip-spend {
  display: block;
  font-weight: bold;
  color: #ffcf8e;
}

.binding-tooltip-hint {
  display: block;
  font-style: italic;
  font-size: 0.9em;
  color: var(--ink-dim);
  margin-top: 2px;
}

/* Matches the soft warning red every other "can't right now" tooltip line uses. */
.binding-tooltip-cooldown {
  display: block;
  color: #d97b7b;
  margin-top: 3px;
}

/* Stockpile — the bulk-counter house fixture (public/js/stockpile.js, views/partials/
   stockpileGump.ejs). Deliberately its own class prefix throughout, not shared with
   .craft-station-* — that gump assumes an input/output/action/stop shape Stockpile
   doesn't have. */
.stockpile-gump {
  /* 340px fitted 6 tabs, 400px fitted 7. config/stockpile.js is now up to EIGHT
     (Bottles and Pristine), which clipped Parchment/Leather/Cooked Food/Pristine to
     "Parchm", "Leath"... — measured in the browser, not guessed.
     At 480px every label fits on one row with the content-sized tabs below; 520 leaves
     room for a ninth tab or a longer label before anything has to move again. */
  width: 520px;
}
.stockpile-gump-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
}
.stockpile-tab-strip {
  display: flex;
  /* Wrapping is the RELIEF VALVE, not the normal case: at the width above all eight tabs
     sit on one row. It exists so a ninth tab, a longer label or a zoomed-in browser
     drops to a second row instead of truncating every label at once, which is the
     failure this pair of rules was changed to fix. */
  flex-wrap: wrap;
  gap: 4px;
}
.stockpile-tab-btn {
  /* Content-sized (basis auto), NOT the equal-share `flex: 1 1 0` this used to have.
     Equal shares divide the row by the number of tabs, so every tab is only ever as wide
     as the NARROWEST one can be — at eight tabs that was 44px, which is narrower than
     "Parchment" and clipped four of the eight labels. Sizing each tab to its own label
     needs less total width (427px of text versus 8 x 81px of forced-equal columns) and
     keeps "Cooked Food" on one line instead of two. */
  flex: 0 1 auto;
  min-width: 0;
  padding: 6px 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 4px;
  color: var(--muted);
  cursor: pointer;
}
/* Ember chip active state — same treatment .journal-tab.active/.chat-tab.active already
   use, so the selected tab reads as "selected", not "disabled" (the old translucent-
   white fill read as barely different from the resting state). */
.stockpile-tab-btn.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.stockpile-contents-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-height: 210px; /* 120px + 75% */
  max-height: 455px; /* 260px + 75% */
  overflow-y: auto;
  padding: 6px;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
}
.stockpile-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 4px 6px;
  border-radius: 4px;
  cursor: pointer;
}
.stockpile-row:hover {
  background: rgba(255, 255, 255, 0.08);
}
.stockpile-row .item-icon {
  width: 28px;
  height: 28px;
  pointer-events: none;
}
.stockpile-row-label {
  flex: 1 1 auto;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #d9d9d9;
}
.stockpile-row-count {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #f0c869;
}
.stockpile-actions {
  display: flex;
  gap: 6px;
}
/* `flex: 1 1 0` (a zero basis), not `1 1 auto` — with an auto basis each button is sized
   from its own text first, so the longer label ("Add Cooked Food") came out visibly wider
   than "Add All". A zero basis splits the row evenly regardless of label length, which
   matters more here than usual because the left button's text changes per tab. */
.stockpile-add-btn, .stockpile-add-all-btn {
  flex: 1 1 0;
  min-width: 0;
  padding: 8px;
  cursor: pointer;
  text-align: center;
  white-space: nowrap;
}

/* Sits under the "Add All" button it modifies (see stockpileGump.ejs). */
.stockpile-exclude-regs {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 5px;
  margin-top: 5px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  cursor: pointer;
  user-select: none;
}

.stockpile-exclude-regs input {
  margin: 0;
  cursor: pointer;
  accent-color: var(--accent);
}

.stockpile-exclude-regs:hover {
  color: var(--accent);
}
.stockpile-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
}

/* Repair Bench / Blacksmith Shop — both share this exact row shape (icon, name +
   durability, cost, a single action button), just against different socket events and
   currencies, so one set of classes covers both gumps' rows. */
.repair-bench-gump, .blacksmith-shop-gump {
  width: 360px;
}
.repair-bench-gump-body, .blacksmith-shop-gump-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 10px;
}
.repair-bench-contents-list, .blacksmith-shop-contents-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-height: 120px;
  max-height: 360px;
  overflow-y: auto;
  padding: 6px;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
}
.repair-bench-empty, .blacksmith-shop-empty {
  padding: 16px 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
}
.repair-bench-row, .blacksmith-shop-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.04);
}
.repair-bench-row .item-icon, .blacksmith-shop-row .item-icon {
  width: 32px;
  height: 32px;
  pointer-events: none;
  flex-shrink: 0;
}
.repair-bench-row-info, .blacksmith-shop-row-info {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.repair-bench-row-name, .blacksmith-shop-row-name {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #d9d9d9;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.repair-bench-row-durability, .blacksmith-shop-row-durability {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
}
.repair-bench-row-cost, .blacksmith-shop-row-cost {
  flex-shrink: 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #f0c869;
  white-space: nowrap;
}
.repair-bench-row-btn, .blacksmith-shop-row-btn {
  flex-shrink: 0;
  padding: 6px 10px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}
.repair-bench-hint, .blacksmith-shop-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
}
.blacksmith-shop-repair-all-btn {
  padding: 8px 10px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 600;
  cursor: pointer;
}
.blacksmith-shop-repair-all-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

/* Crafting Bench — a flat select-driven layout (not the rich per-row cards
   craftingGump.ejs's regular per-skill gumps use), since the whole interaction here is
   "pick one recipe, toggle some checkboxes, click Start" rather than a batch-craft-many
   menu. Reuses .stockpile-row-list's dark scroll-panel look for its own two storage
   columns. */
.crafting-bench-gump {
  /* 380px until the Inscription tab arrived — six tab chips at 11px plus their gaps
     genuinely need the room, and the wider storage columns breathe better for it. */
  width: 460px;
}
.crafting-bench-gump-body {
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: 10px;
}
.crafting-bench-skill-tabs {
  display: flex;
  gap: 4px;
  /* Six tabs now (Inscription joined) — allow a second row rather than ever squeezing a
     label into ellipsis if a theme's font runs wide. */
  flex-wrap: wrap;
}
.crafting-bench-skill-tab {
  flex: 1 1 auto;
  padding: 6px 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  background: rgba(0, 0, 0, 0.3);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 4px;
  color: var(--muted);
  cursor: pointer;
}
/* Ember chip active state — same treatment .journal-tab.active/.chat-tab.active and the
   Stockpile's own tab strip use, instead of the old translucent-white fill. */
.crafting-bench-skill-tab.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.crafting-bench-skill-tab:disabled {
  opacity: 0.5;
  cursor: default;
}
/* No rule existed for the panel wrapper itself before this — the Item/Material rows
   were plain sibling divs with only their own (unset) margins between them, reading
   as cramped in practice. */
.crafting-bench-skill-panel {
  display: flex;
  flex-direction: column;
  gap: 5px;
}
.crafting-bench-select-row {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #cfcfcf;
}
.crafting-bench-select-row select {
  flex: 1 1 auto;
  background: var(--well);
  border: 1px solid var(--edge);
  color: var(--ink);
  border-radius: 4px;
  padding: 4px 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}
.crafting-bench-preview {
  padding: 8px;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
  min-height: 48px;
}
.crafting-bench-preview-placeholder {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
}
/* One color per line kind (public/js/craftingBench.js's renderPreview) instead of a
   single flat .crafting-bench-preview-line for all of them — the item/gain-chance
   headline stays the near-white body-text color, the cost line uses the game's own
   gold/ember accent (same var every other highlighted number already reads in),
   the source line fades to --muted since it's incidental, and the "no active tool"
   line reuses the exact red .journal-fail already uses elsewhere so a blocking
   warning reads the same way everywhere in the game. */
.crafting-bench-preview-line {
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.5;
}
.crafting-bench-preview-line-headline {
  color: var(--ink);
}
.crafting-bench-preview-line-cost {
  color: var(--accent);
}
.crafting-bench-preview-line-source {
  color: var(--muted);
}
.crafting-bench-preview-line-threshold {
  color: var(--muted);
  font-style: italic;
}
.crafting-bench-preview-line-warning {
  color: #e25c4f;
  font-weight: bold;
}
.crafting-bench-options {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #cfcfcf;
}
.crafting-bench-checkbox-row {
  display: flex;
  align-items: center;
  gap: 6px;
  cursor: pointer;
}
.crafting-bench-stop-row {
  display: flex;
  align-items: center;
  gap: 8px;
}
.crafting-bench-stop-row input {
  width: 70px;
  background: var(--well);
  border: 1px solid var(--edge);
  color: var(--ink);
  border-radius: 4px;
  padding: 4px 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}
.crafting-bench-counter {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
  margin-top: 4px;
}
.crafting-bench-start-btn {
  padding: 8px;
  font-weight: bold;
  cursor: pointer;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 5px;
  color: var(--accent);
}
.crafting-bench-start-btn:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.crafting-bench-start-btn-active {
  background: #e25c4f;
  border-color: #e25c4f;
  color: #fff;
}
.crafting-bench-start-btn-active:hover {
  background: #ff7a6b;
  border-color: #ff7a6b;
}
.crafting-bench-storage {
  display: flex;
  gap: 8px;
}
.crafting-bench-storage-col {
  flex: 1 1 50%;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.crafting-bench-storage-title {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #999;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.crafting-bench-storage-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-height: 90px;
  max-height: 160px;
  overflow-y: auto;
  padding: 6px;
  background: rgba(0, 0, 0, 0.25);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 4px;
}
.crafting-bench-storage-row {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: #d9d9d9;
  padding: 2px 4px;
}

/* The Bench's own storage rows only (not the adjacent-Stockpile preview beside them) —
   right-click to withdraw (public/js/craftingBench.js). */
.crafting-bench-storage-row-interactive {
  border-radius: 4px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.crafting-bench-storage-row-interactive:hover {
  background: rgba(255, 255, 255, 0.08);
}
.crafting-bench-storage-empty {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
  padding: 8px 0;
}
.crafting-bench-storage-actions {
  display: flex;
  gap: 4px;
}
.crafting-bench-deposit-tab-select {
  flex: 1 1 auto;
  background: var(--well);
  border: 1px solid var(--edge);
  color: var(--ink);
  border-radius: 4px;
  padding: 4px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  min-width: 0;
}
.crafting-bench-deposit-btn, .crafting-bench-deposit-all-btn {
  padding: 4px 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: pointer;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 4px;
  color: var(--accent);
  white-space: nowrap;
}
.crafting-bench-deposit-btn:hover, .crafting-bench-deposit-all-btn:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.crafting-bench-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
}

/* Crafting Storage Cap bar — the same .bank-weight-row/.bank-weight-bar shape every other
   capacity readout in the game uses (Bank, house storage, construction), minus that pair's
   own 14px side margins: those exist for gumps whose body has no padding of its own, and
   inside the Bench's body they inset the bar from the storage columns sitting right above it. */
.crafting-bench-cap-row {
  margin: 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  color: #999;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.crafting-bench-cap-bar {
  margin: 4px 0 0;
}
.crafting-bench-cap-value {
  color: #d9d9d9;
  letter-spacing: normal;
  font-variant-numeric: tabular-nums;
}
.crafting-bench-cap-value.crafting-bench-cap-full {
  color: #e25c4f;
}

/* --- Equipment Locker gump (views/partials/equipmentLockerGump.ejs, public/js/
   equipmentLocker.js) — stored-gear grid + saved loadout list. Leans on the shared
   .skills-gump chrome; everything below is the two-list body. Width has to go on THIS
   outer class, not just -gump-body — .skills-gump's own base rule (260px) otherwise wins
   the cascade (same specificity, declared earlier in the file), which left the body's
   520px overflowing a still-260px-wide card instead of actually widening it. Same fix
   .crafting-bench-gump/.stockpile-gump/.virtue-shop-gump already use. */
.equipment-locker-gump {
  width: 600px;
  max-width: 90vw;
}
.equipment-locker-gump-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px;
}

.equipment-locker-status {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
}

.equipment-locker-status.adjacent { color: #7fd88f; font-style: normal; }

.equipment-locker-section-label {
  display: flex;
  align-items: baseline;
  gap: 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--ink-dim);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-top: 4px;
}

.equipment-locker-slot-count {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: normal;
  text-transform: none;
  letter-spacing: normal;
  color: var(--muted);
}

.equipment-locker-loadout-actions {
  display: flex;
  gap: 6px;
}

.equipment-locker-loadout-actions .equipment-locker-btn { flex: 1; }

/* A disabled button here used to be genuinely INVISIBLE, on every theme: --shade is
   almost exactly the panel colour, and opacity 0.5 then faded both it and its near-black
   --on-accent label into that same ground. Measured: 1.01:1 text and 1.00:1 button-
   against-panel — not "dim", but indistinguishable from empty space. The "Save current
   gear as loadout" button is disabled whenever your slots are full, so a player at 1/1
   slots simply saw a gap where a button should be.
   Now it keeps full opacity and states its unavailability through muted text and a
   dashed edge, which reads as disabled while staying clearly present. */
.equipment-locker-btn:disabled {
  opacity: 1;
  cursor: default;
  background: var(--shade);
  color: var(--muted);
  /* --muted, not --edge: the disabled fill is nearly the panel colour, so this dashed
     outline IS the button's silhouette. At --edge it only reached 1.40:1 on Mystic;
     matching the label's own colour keeps the shape unambiguous on every theme, and the
     dashed style still reads as "not available" rather than as a live control. */
  border: 1px dashed var(--muted);
}
.equipment-locker-btn:disabled:hover { background: var(--shade); color: var(--muted); }

.equipment-locker-loadouts {
  display: flex;
  flex-direction: column;
  gap: 6px;
  max-height: 200px;
  overflow-y: auto;
}

.equipment-locker-loadout {
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 4px;
  padding: 6px 8px;
}

.equipment-locker-loadout-head {
  display: flex;
  align-items: center;
  gap: 6px;
}

.equipment-locker-loadout-name {
  flex: 1;
  min-width: 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.equipment-locker-loadout-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  margin-top: 5px;
}

.equipment-locker-loadout-icon {
  width: 22px;
  height: 22px;
  image-rendering: pixelated;
  background: rgba(0, 0, 0, 0.2);
  border-radius: 2px;
}

.equipment-locker-loadout-icon.missing { opacity: 0.3; filter: grayscale(1); }

.equipment-locker-reg-editor {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 10px;
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--edge);
}

.equipment-locker-reg-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
}

.equipment-locker-reg-icon { width: 30px; height: 30px; image-rendering: pixelated; }

.equipment-locker-reg-input {
  width: 56px;
  padding: 3px 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-align: center;
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  border-radius: 3px;
}

.equipment-locker-gear-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}

.equipment-locker-gear-tab {
  padding: 4px 10px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: 600;
  background: var(--shade);
  border: 1px solid var(--edge);
  border-radius: 3px;
  color: var(--ink-dim);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.equipment-locker-gear-tab:hover { color: var(--ink); }

.equipment-locker-gear-tab.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

.equipment-locker-gear-search {
  padding: 5px 8px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: var(--shade);
  border: 1px solid var(--edge);
  color: var(--ink);
  border-radius: 3px;
}

.equipment-locker-gear-search:focus { border-color: var(--accent); outline: none; }

.equipment-locker-gear {
  display: flex;
  flex-wrap: wrap;
  align-content: flex-start;
  gap: 6px;
  min-height: 60px;
  max-height: 260px;
  overflow-y: auto;
  padding: 8px;
  background: rgba(0, 0, 0, 0.15);
  border: 1px solid var(--edge);
  border-radius: 4px;
}

.equipment-locker-gear-cell .item-icon { width: 40px; height: 40px; }

.equipment-locker-empty {
  font-size: calc(11px * var(--ui-font-scale, 1));
  /* var(--muted), not a hardcoded grey: #6d7480 was tuned against the default theme's
     panel and ignored every other palette — on Royal it measured 3.75:1, the least
     readable text in the gump. */
  color: var(--muted);
  font-style: italic;
  padding: 4px;
}

.equipment-locker-btn {
  padding: 6px 12px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  background: var(--accent);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.equipment-locker-btn:hover { background: var(--accent-bright); }

/* Must come AFTER the base .equipment-locker-btn rule above — it sits at equal
   specificity, so the original copy of this rule (declared ~160 lines EARLIER in the
   file) was silently overridden and never rendered at all. The unlock button has been
   painting as a plain solid accent button, identical to the free "Deposit backpack gear"
   one directly below it, despite being a 1,000,000 gold purchase.
   Restoring that intended distinction as an OUTLINE rather than the author's original
   --accent-deep fill: --on-accent (a near-black chosen for the BRIGHT accent) on the DARK
   accent measures 2.87:1 on Mystic — a WCAG failure — and no single text colour rescues
   it, since the deep accents span bright orange to dark purple. Accent-on-panel instead
   scores 6.70:1 at worst and ~10:1 on most themes. */
.equipment-locker-btn-unlock {
  background: transparent;
  border: 1px solid var(--accent);
  color: var(--accent);
}

.equipment-locker-btn-unlock:hover {
  background: var(--accent);
  color: var(--on-accent);
}

.equipment-locker-btn-small { padding: 3px 8px; font-size: calc(11px * var(--ui-font-scale, 1)); }

.equipment-locker-btn-danger { background: #b8453f; color: #fff; }

.equipment-locker-btn-danger:hover { background: #d1544d; }

.equipment-locker-hint {
  font-size: calc(11px * var(--ui-font-scale, 1));
  /* Themed for the same reason as .equipment-locker-empty above — a flat #999 grey sat
     outside the palette entirely, reading as washed-out on the tinted themes. */
  color: var(--muted);
  text-align: center;
}

/* =========================================================================
   Virtue Shop (views/partials/virtueShopGump.ejs, public/js/virtueShop.js) —
   an end-game shrine, deliberately heavy on presentation: an animated
   backdrop, a glowing statue, drifting motes of light, and locked
   "Coming Soon" reward cards with a slow shine sweep. Every color below
   reads off the shared theme tokens (:root / body.theme-* at the top of this
   file) rather than a one-off palette, so the shrine reskins along with
   every other gump instead of staying a fixed violet regardless of which
   theme the player has active.
   ========================================================================= */
.virtue-shop-gump {
  position: relative;
  width: 460px;
  max-width: 94vw;
  overflow: hidden;
  border: 1px solid var(--edge);
  box-shadow: 0 0 24px rgba(var(--accent-rgb), 0.3), inset 0 0 40px rgba(var(--accent-strong-rgb), 0.12);
  /* A slow-drifting aurora behind the whole panel, in the active theme's own accent. */
  background:
    radial-gradient(120% 80% at 20% 0%, rgba(var(--accent-rgb), 0.18), transparent 60%),
    radial-gradient(120% 80% at 90% 20%, rgba(var(--accent-strong-rgb), 0.14), transparent 55%),
    linear-gradient(160deg, var(--panel-deep) 0%, var(--shade) 60%, var(--well) 100%);
  background-size: 200% 200%, 200% 200%, 100% 100%;
  animation: virtue-aurora 18s ease-in-out infinite;
}

@keyframes virtue-aurora {
  0%, 100% { background-position: 0% 0%, 100% 0%, 0 0; }
  50% { background-position: 40% 60%, 60% 80%, 0 0; }
}

.virtue-shop-header {
  position: relative;
  z-index: 2;
  background: linear-gradient(90deg, rgba(var(--accent-strong-rgb), 0.4), rgba(var(--accent-strong-rgb), 0.2));
  border-bottom: 1px solid var(--edge);
  text-shadow: 0 0 8px rgba(var(--accent-rgb), 0.6);
  letter-spacing: 0.5px;
}

.virtue-shop-body {
  position: relative;
  z-index: 2;
  padding: 14px;
  max-height: 70vh;
  overflow-y: auto;
}

/* Drifting motes — a scatter of soft dots rising slowly through the panel. */
.virtue-shop-motes {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  overflow: hidden;
}

.virtue-mote {
  position: absolute;
  bottom: -8px;
  width: 4px;
  height: 4px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(var(--accent-rgb), 0.9), rgba(var(--accent-rgb), 0));
  animation: virtue-mote-rise linear infinite;
}

@keyframes virtue-mote-rise {
  0% { transform: translateY(0) scale(0.6); opacity: 0; }
  15% { opacity: 0.9; }
  85% { opacity: 0.7; }
  100% { transform: translateY(-520px) scale(1.1); opacity: 0; }
}

/* Spread the 14 motes across the width with staggered timing (no per-element JS). */
.virtue-mote:nth-child(1)  { left: 6%;  animation-duration: 11s; animation-delay: 0s; }
.virtue-mote:nth-child(2)  { left: 14%; animation-duration: 14s; animation-delay: 2s; }
.virtue-mote:nth-child(3)  { left: 22%; animation-duration: 9s;  animation-delay: 4s; }
.virtue-mote:nth-child(4)  { left: 30%; animation-duration: 13s; animation-delay: 1s; }
.virtue-mote:nth-child(5)  { left: 38%; animation-duration: 16s; animation-delay: 6s; }
.virtue-mote:nth-child(6)  { left: 46%; animation-duration: 10s; animation-delay: 3s; }
.virtue-mote:nth-child(7)  { left: 54%; animation-duration: 15s; animation-delay: 5s; }
.virtue-mote:nth-child(8)  { left: 62%; animation-duration: 12s; animation-delay: 0.5s; }
.virtue-mote:nth-child(9)  { left: 70%; animation-duration: 9.5s; animation-delay: 7s; }
.virtue-mote:nth-child(10) { left: 78%; animation-duration: 14.5s; animation-delay: 2.5s; }
.virtue-mote:nth-child(11) { left: 86%; animation-duration: 11.5s; animation-delay: 4.5s; }
.virtue-mote:nth-child(12) { left: 92%; animation-duration: 13.5s; animation-delay: 1.5s; }
.virtue-mote:nth-child(13) { left: 50%; animation-duration: 17s; animation-delay: 8s; }
.virtue-mote:nth-child(14) { left: 34%; animation-duration: 10.5s; animation-delay: 6.5s; }

/* The shrine banner. */
.virtue-shop-shrine {
  position: relative;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 12px;
  margin-bottom: 14px;
  border-radius: 8px;
  border: 1px solid var(--edge);
  background: linear-gradient(180deg, rgba(var(--accent-strong-rgb), 0.16), rgba(var(--accent-strong-rgb), 0.06));
}

.virtue-shop-statue {
  position: relative;
  z-index: 2;
  width: 64px;
  height: 64px;
  image-rendering: pixelated;
  animation: virtue-statue-bob 4.5s ease-in-out infinite;
  filter: drop-shadow(0 0 8px rgba(var(--accent-rgb), 0.55));
}

@keyframes virtue-statue-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-4px); }
}

/* A pulsing halo behind the statue, in the active theme's accent. */
.virtue-shop-statue-halo {
  position: absolute;
  left: 12px;
  top: 50%;
  width: 80px;
  height: 80px;
  transform: translateY(-50%);
  border-radius: 50%;
  background: radial-gradient(circle, rgba(var(--accent-rgb), 0.4), rgba(var(--accent-rgb), 0));
  animation: virtue-halo-pulse 3.6s ease-in-out infinite;
  z-index: 1;
}

@keyframes virtue-halo-pulse {
  0%, 100% { opacity: 0.45; transform: translateY(-50%) scale(0.9); }
  50% { opacity: 0.8; transform: translateY(-50%) scale(1.12); }
}

.virtue-shop-shrine-info {
  flex: 1;
  min-width: 0;
  position: relative;
  z-index: 2;
}

.virtue-shop-balance {
  display: flex;
  align-items: baseline;
  gap: 6px;
}

.virtue-shop-balance-value {
  font-size: calc(26px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent-bright);
  text-shadow: 0 0 10px rgba(var(--accent-rgb), 0.7);
  animation: virtue-balance-glow 3s ease-in-out infinite;
}

@keyframes virtue-balance-glow {
  0%, 100% { text-shadow: 0 0 8px rgba(var(--accent-rgb), 0.5); }
  50% { text-shadow: 0 0 16px rgba(var(--accent-rgb), 0.9); }
}

.virtue-shop-balance-label {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 1px;
}

/* Same primary-button chrome every other CTA in the game uses (e.g. the World Map's
   own .travel-map-open-btn) — a solid gradient in the active theme's accent, not a
   one-off gold fixed to this shop alone. */
.virtue-shop-offer-btn {
  margin-top: 8px;
  padding: 8px 16px;
  font-family: inherit;
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--on-accent);
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 5px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  box-shadow: 0 0 12px rgba(var(--accent-strong-rgb), 0.35);
  transition: box-shadow 0.2s, transform 0.1s;
}

.virtue-shop-offer-btn:hover {
  background: var(--accent-bright);
  box-shadow: 0 0 20px rgba(var(--accent-strong-rgb), 0.7);
  transform: translateY(-1px);
}

.virtue-shop-shrine-hint {
  margin-top: 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  line-height: 1.4;
}

/* Reward card grid. */
.virtue-shop-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 10px;
}

.virtue-shop-card {
  position: relative;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding: 12px 10px 10px;
  border-radius: 7px;
  border: 1px solid var(--edge);
  background: linear-gradient(180deg, var(--panel), var(--panel-deep));
  transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
}

.virtue-shop-card:hover {
  transform: translateY(-2px);
  border-color: rgba(var(--accent-rgb), 0.6);
  box-shadow: 0 4px 18px rgba(var(--accent-strong-rgb), 0.4);
}

/* Locked (Coming Soon) cards read dim, with the reward sealed away. */
.virtue-shop-card.is-locked .virtue-shop-card-icon { opacity: 0.5; filter: grayscale(0.2) brightness(0.9); }
.virtue-shop-card.is-locked .virtue-shop-card-name,
.virtue-shop-card.is-locked .virtue-shop-card-desc { opacity: 0.75; }

/* A slow diagonal shine sweeping across each card. */
.virtue-shop-card-shine {
  position: absolute;
  top: -60%;
  left: -80%;
  width: 60%;
  height: 220%;
  transform: rotate(20deg);
  background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.14), transparent);
  animation: virtue-card-shine 6s ease-in-out infinite;
  pointer-events: none;
}

.virtue-shop-card:nth-child(even) .virtue-shop-card-shine { animation-delay: 3s; }

@keyframes virtue-card-shine {
  0% { left: -80%; }
  55%, 100% { left: 160%; }
}

.virtue-shop-ribbon {
  position: absolute;
  top: 10px;
  right: -30px;
  width: 110px;
  transform: rotate(38deg);
  text-align: center;
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.5px;
  text-transform: uppercase;
  color: var(--on-accent);
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4);
  z-index: 3;
}

.virtue-shop-card-icon {
  width: 44px;
  height: 44px;
  image-rendering: pixelated;
  margin-bottom: 6px;
  filter: drop-shadow(0 0 6px rgba(var(--accent-rgb), 0.6));
}

.virtue-shop-card-name {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--ink);
  margin-bottom: 4px;
}

.virtue-shop-card-desc {
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  line-height: 1.35;
  flex: 1;
}

.virtue-shop-card-cost {
  margin-top: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent-bright);
  font-weight: bold;
}

.virtue-shop-card-cost-value { text-shadow: 0 0 6px rgba(var(--accent-rgb), 0.5); }

/* Only rendered on a reward with real mechanics behind it (config/virtueShop.js's
   itemDefKey) — every still-locked card shows the Coming Soon ribbon instead, so these
   two never appear on the same card. */
.virtue-shop-buy-btn {
  margin-top: 10px;
  width: 100%;
  padding: 6px 10px;
  border: 1px solid rgba(var(--accent-rgb), 0.55);
  border-radius: 6px;
  background: rgba(var(--accent-rgb), 0.16);
  color: var(--accent-bright);
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.4px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  transition: background 120ms ease, border-color 120ms ease;
}

.virtue-shop-buy-btn:hover:not(:disabled) {
  background: rgba(var(--accent-rgb), 0.3);
  border-color: rgba(var(--accent-rgb), 0.85);
}

.virtue-shop-buy-btn:disabled {
  opacity: 0.5;
  cursor: default;
}

@media (max-width: 480px) {
  .virtue-shop-grid { grid-template-columns: 1fr; }
}

/* --- Dungeon Floorplan (views/partials/worldMap.ejs's #dungeon-floorplan-view,
   public/js/dungeonFloorplan.js) — the Auto-Battle wandering view. Same 32px-cell
   absolute-positioning technique as .house-view-floorplan above; --floor/--wall come
   from the dungeon's own theme (config/dungeonFloorplans.js), set inline by the JS. */
.dungeon-floorplan-view {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.dungeon-floorplan-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  /* Clears the World Map panel's own .activities-header, which sits directly above with
     no margin of its own — without this the Stop Auto Battle button is flush against the
     Map button, reading as one stuck-together block. 8px matches .house-view-header, the
     sibling view that replaces this same panel's contents when you are at home; the
     treasure-site view is the third of the set. Kept on the header rather than on
     .dungeon-floorplan-view so it mirrors how the house view already does it. */
  margin-top: 8px;
}

.dungeon-floorplan-title {
  font-weight: bold;
  color: var(--ink);
}

.dungeon-floorplan-stop-btn {
  padding: 4px 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: pointer;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 4px;
  color: var(--accent);
  white-space: nowrap;
}

.dungeon-floorplan-stop-btn:hover {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

/* The fixed camera viewport: the world layer inside slides underneath while the
   player sprite stays pinned dead-center (public/js/dungeonFloorplan.js's
   dfpUpdateCamera). overflow hidden + no listeners means the map genuinely cannot be
   dragged or scrolled by hand — it only ever moves with the character. The dark
   background is the void beyond the map's border walls. */
.dungeon-floorplan {
  position: relative;
  width: 100%;
  height: 460px;
  overflow: hidden;
  background: #0b0b0e;
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 3px;
  user-select: none;
}

/* Dungeon Boss progress, pinned in the top-left corner of the map, just under the
   header row (public/js/dungeonFloorplan.js's dfpRenderBoss). Positioned against
   .dungeon-floorplan-view (not .dungeon-floorplan itself — see that element's own
   comment in worldMap.ejs for why it can't live inside the board), so the top offset
   clears .dungeon-floorplan-header's own row height + the view's flex gap rather than
   sitting flush in the view's own top-left corner. pointer-events off for the panel so
   it never blocks the map beneath it; only the Summon button re-enables them. */
.dfp-boss-overlay {
  position: absolute;
  /* bottom is measured from .dungeon-floorplan-view's own bottom edge, not the map
     board's — that view is a flex column of [header, map, #dungeon-floorplan-status],
     and this overlay (an absolutely-positioned sibling, so it's out of that flow) used
     to sit low enough to cover the status line's text. Cleared by the status row's own
     8px flex gap + 14px min-height, keeping the original 10px breathing room above the
     map itself. */
  bottom: calc(10px + 8px + 14px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 5;
  display: flex;
  align-items: center;
  /* Wraps because it has to: the World Map panel is a fixed 270px (see
     .world-map-panel), leaving this overlay a 216px cap, while its own min-content —
     32px icon + 130px body + a ~73px Summon button + gaps/padding — is 277px. With
     nowrap and nothing able to shrink (both flex: none, body min-width: 130px), the
     line overflowed its own border box and the button rendered ~50px outside the
     frame. Wrapping drops the button to its own row at this width and keeps it inline
     wherever the panel is wider (a floated panel). */
  flex-wrap: wrap;
  gap: 9px;
  /* A definite width, not shrink-to-fit. Being absolutely positioned, an auto width
     sizes to content — which is what let the old nowrap line overflow its own border
     box (the Summon button rendered ~50px outside the frame), and what collapses this
     into a narrow column once wrapping is allowed. It also made the overlay resize and
     re-centre every time the gold counter gained a digit. The host panel is a fixed
     270px (.world-map-panel — its 430px town-hub-wide variant is town-only, so a
     dungeon never sees it), so there is no width here worth deriving from content. */
  width: calc(100% - 20px);
  padding: 7px 12px;
  background: rgba(12, 12, 16, 0.86);
  border: 1px solid rgba(var(--edge-rgb), 0.18);
  border-radius: 8px;
  backdrop-filter: blur(2px);
  pointer-events: none;
}

/* An ember edge that breathes the instant the bar fills, so "ready" reads even in
   peripheral vision while you're watching the fight, not the corner. The pulse does the
   attention-grabbing that a hardcoded green used to do by hue alone — motion carries
   further than colour out of the corner of your eye, and it keeps the whole overlay
   inside the palette (and rethemes, via --accent-rgb). */
.dfp-boss-overlay-ready {
  border-color: rgba(var(--accent-rgb), 0.75);
  animation: dfp-boss-ready-pulse 2.4s ease-in-out infinite;
}

@keyframes dfp-boss-ready-pulse {
  0%, 100% { box-shadow: 0 0 8px rgba(var(--accent-strong-rgb), 0.25); }
  50% { box-shadow: 0 0 18px rgba(var(--accent-strong-rgb), 0.5); }
}

/* Anyone who'd rather not have a looping glow in their peripheral vision keeps the
   static ember edge — the border alone still carries the state. */
@media (prefers-reduced-motion: reduce) {
  .dfp-boss-overlay-ready {
    animation: none;
    box-shadow: 0 0 10px rgba(var(--accent-strong-rgb), 0.35);
  }
}

/* Framed in its own dark well rather than floating loose on the panel — most boss
   sprites are near-black silhouettes, which read as a smudge against a translucent dark
   overlay until they have an edge to sit against. */
.dfp-boss-overlay-icon {
  width: 32px;
  height: 32px;
  flex: none;
  padding: 1px;
  background: rgba(0, 0, 0, 0.4);
  border: 1px solid rgba(var(--edge-rgb), 0.22);
  border-radius: 4px;
  image-rendering: pixelated;
}

/* 130px is the preferred width, not a floor — as a hard min-width it was half of why
   the button used to overflow. min-width: 0 lets the column shrink past its longest
   word if it ever has to (the name already ellipsizes), so the flex line can always
   fit inside the frame rather than spilling out of it. */
.dfp-boss-overlay-body {
  display: flex;
  flex-direction: column;
  gap: 3px;
  flex: 1 1 130px;
  min-width: 0;
}

.dfp-boss-overlay-name {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--ink);
  text-align: center;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.dfp-boss-overlay-bar {
  height: 7px;
  background: rgba(255, 255, 255, 0.12);
  border-radius: 4px;
  overflow: hidden;
}

/* The glowing ember meter from the Status gump the user picked out and named the "XP
   Bar" — same gradient/shadow recipe, at this bar's own 7px height. Replaces a gradient
   that ran into a hardcoded pale gold (#f0d878), the one part of this bar that never
   followed an alternate palette. */
.dfp-boss-overlay-fill {
  height: 100%;
  width: 0%;
  background: linear-gradient(90deg, var(--accent-deep), var(--accent));
  box-shadow: 0 0 6px rgba(var(--edge-rgb), 0.45);
  border-radius: 4px;
  transition: width 0.3s ease;
}

.dfp-boss-overlay-label {
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--muted);
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* Set apart from the "12,400 / 20,000 gold" counter it replaces by letterform, not just
   colour — small caps + tracking reads as a state line rather than another number. */
.dfp-boss-overlay-ready .dfp-boss-overlay-label {
  color: var(--accent);
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: calc(9px * var(--ui-font-scale, 1));
}

/* margin-left: auto keeps it hard right on whichever row it lands on — tucked against
   the frame's right edge when it wraps to its own line, rather than sitting orphaned
   under the portrait. */
.dfp-boss-overlay-summon {
  pointer-events: auto;
  flex: none;
  margin-left: auto;
}

/* The sliding world — sized to the full map (up to ~200 cells x 32px). Its transform
   transitions at the exact same duration/curve as the player's own left/top step, so
   the two motions cancel and the sprite appears pinned mid-step. pointer-events off
   for the whole layer: nothing inside is interactive, and it kills native image drag. */
.dungeon-floorplan-world {
  position: absolute;
  left: 0;
  top: 0;
  will-change: transform;
  transition: transform 0.35s linear;
  pointer-events: none;
}

/* The baked static world (floor, walls, themed props, decor) — one canvas drawn once
   at half resolution and CSS-upscaled 2x, crisp under pixelated rendering. Everything
   live (player, mobs) is positioned above it. */
.dungeon-floorplan-bake {
  position: absolute;
  left: 0;
  top: 0;
  image-rendering: pixelated;
  z-index: 0;
}

/* The layered paperdoll (tools/generatePlayerSprites.js): a 192x144 sheet of SIX
   32x48 frames x THREE facing rows. Columns (X): frame 0 (0) stands, frames 1/2
   (-32px/-64px) are the two striding legs the run cycle flips between, frame 3 (-96px)
   thrusts the weapon arm forward (melee swing), frame 4 (-128px) is the bow draw,
   frame 5 (-160px) is the seated fishing pose (.gs-pose-sit). Rows (Y): 0 = front
   view (walking south / idle), -48px = side profile drawn facing right (west is the
   .dfp-face-left mirror), -96px = back view (walking north).

   CRITICAL: X (pose/animation) and Y (facing) are selected INDEPENDENTLY —
   every pose/walk rule below uses the background-position-x LONGHAND and every facing
   class uses background-position-y. A `background-position` SHORTHAND anywhere in
   these rules (or the run-cycle keyframes) would reset the other axis and silently
   snap side/back walkers onto the wrong row — .gs-pose-sit is the ONE deliberate
   exception, using the shorthand to pin the fishing dock to the front row.

   Head/torso are pixel-identical across every frame WITHIN a row, so the helm/shield
   overlays (one 32x48 cell per row, stacked in a 32x144 column — see .dfp-doll-layer)
   stay aligned whatever frame that row shows. 48px is taller than a tile so the feet
   anchor to the cell and the head overlaps the tile above (classic UO proportion);
   the -16px pull-up does that anchoring. This class is shared by the gather-scene
   player element too (.gs-player) — a change here affects both views. */
.dungeon-floorplan-player {
  position: absolute;
  width: 32px;
  height: 48px;
  margin-top: -16px;
  background-size: 192px 144px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  /* One grid step per transition — public/js/dungeonFloorplan.js chains steps on
     transitionend. Purely cosmetic pacing; combat timing is server-driven. */
  transition: left 0.35s linear, top 0.35s linear;
  z-index: 2;
}

/* Bless (config/spells.js) — a blue sparkle hanging over the figure while the spell runs.
   Built and toggled by public/js/dungeonFloorplan.js off the 'bless' buff type; only that
   file creates the element, so the house-view player (which reuses
   .dungeon-floorplan-player wholesale) never grows one.

   The diamond is a plain square turned 45deg. Because the ROOT carries that rotation, the
   breathe animation below is deliberately opacity/box-shadow only — animating transform
   here would fight the rotate and flatten the shape mid-cycle. The sparkles ride a
   pseudo-element, which has its own transform and can safely scale.

   Positioned over the upper body rather than centred on the 48px sprite: light settling on
   someone reads from the head and shoulders, and centring put it around the knees. */
/* Bless's CAST FLOURISH (public/js/blessAura.js). Invisible by default and for the whole
   duration of the buff — it fires once, as the spell lands, and then gets out of the way.
   It began as a persistent breathing aura, which is a long time to have something pulsing
   on your character for a spell the buff bar already reports.

   The element lives permanently on the paperdoll in all three views that draw the player
   (dungeon floorplan, gather scene, house view); only .is-casting makes it visible. */
.dfp-bless-aura {
  position: absolute;
  left: 50%;
  top: 4px;
  width: 26px;
  height: 26px;
  margin-left: -13px;
  transform: rotate(45deg);
  opacity: 0;
  pointer-events: none;
  z-index: 3;
}

/* One pass, forwards, then public/js/blessAura.js strips the class on animationend. The
   diamond blooms outward and fades: bright and obvious at the instant of the cast, gone
   inside a second. */
.dfp-bless-aura.is-casting {
  animation: dfp-bless-cast 900ms ease-out forwards;
}

/* Four sparkles, one at each point of the diamond, from a single element: the dot itself
   plus three box-shadow copies. Four separate nodes would need four pseudo-elements and
   there are only two. */
.dfp-bless-aura.is-casting::before {
  content: '';
  position: absolute;
  left: -1.5px;
  top: -1.5px;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: #eaf6ff;
  box-shadow:
    26px 0 0 0 #eaf6ff,
    0 26px 0 0 #eaf6ff,
    26px 26px 0 0 #eaf6ff,
    0 0 4px 1px rgba(140, 200, 255, 0.9);
  animation: dfp-bless-spark 900ms ease-out forwards;
}

/* A second sparkle riding the centre of the diamond, offset in phase so the flourish does
   not blink in unison. */
.dfp-bless-aura.is-casting::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 4px;
  height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
  background: rgba(233, 246, 255, 0.9);
  box-shadow: 0 0 6px 2px rgba(120, 190, 255, 0.8);
  animation: dfp-bless-spark 900ms ease-out 120ms forwards;
}

/* Scale is composed WITH the base rotate(45deg) — a transform here replaces the whole
   property, so dropping the rotation would snap the diamond square for the animation. */
@keyframes dfp-bless-cast {
  0% {
    opacity: 0;
    transform: rotate(45deg) scale(0.4);
    border: 1px solid rgba(150, 210, 255, 0);
    box-shadow: 0 0 0 rgba(120, 190, 255, 0);
  }
  35% {
    opacity: 1;
    transform: rotate(45deg) scale(1.15);
    border: 1px solid rgba(196, 232, 255, 1);
    box-shadow: 0 0 14px rgba(140, 205, 255, 0.95), inset 0 0 12px rgba(200, 235, 255, 0.8);
  }
  100% {
    opacity: 0;
    transform: rotate(45deg) scale(1.5);
    border: 1px solid rgba(150, 210, 255, 0);
    box-shadow: 0 0 4px rgba(120, 190, 255, 0);
  }
}

@keyframes dfp-bless-spark {
  0% { transform: scale(0.3); opacity: 0; }
  40% { transform: scale(1.3); opacity: 1; }
  100% { transform: scale(0.6); opacity: 0; }
}

/* Reduced motion still gets to SEE the spell land — it just does not travel. A brief
   fade in and out, no scaling. */
@media (prefers-reduced-motion: reduce) {
  .dfp-bless-aura.is-casting,
  .dfp-bless-aura.is-casting::before,
  .dfp-bless-aura.is-casting::after {
    animation: dfp-bless-cast-still 700ms ease-out forwards;
  }
}

@keyframes dfp-bless-cast-still {
  0%, 100% { opacity: 0; }
  40% { opacity: 1; }
}

/* The tamed companion trailing one grid step behind (public/js/dungeonFloorplan.js's
   dfpWalkPetTo). Same 2-frame pet_<key>.png sheet as the Town Square follower; a
   single element (no overlay layers), so the leftward mirror can flip the root
   directly. Centred in its 32px cell; z-index 1 keeps it just behind the player. */
.dfp-pet {
  position: absolute;
  width: 24px;
  height: 20px;
  margin: 8px 0 0 4px;
  background-size: 48px 20px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  transition: left 0.35s linear, top 0.35s linear;
  z-index: 1;
  pointer-events: none;
  /* --pet-scale (public/js/dungeonFloorplan.js's petScaleForLevel): a pet grows +3% per
     level, up 27% at max. The scale property rather than width/height because the
     2-frame sheet steps by background-position and a resized frame box would break the
     stepping; bottom-centre origin so it grows up from its feet, not off the floor. */
  transform-origin: 50% 100%;
  scale: var(--pet-scale, 1);
}

/* The left-facing flip must COMPOSE with the level scale — a bare -1 1 here would snap a
   grown pet back to its newborn size the moment it turned around. */
.dfp-pet.dfp-pet-left { scale: calc(-1 * var(--pet-scale, 1)) var(--pet-scale, 1); }

/* A named companion's nameplate (config/donationStore.js's Pet Rename Deed) — drawn by
   public/js/gatherScene.js and public/js/dungeonFloorplan.js over their own follower AND
   over every other player's, so a name you paid for is a name strangers read.

   Only a NAMED pet gets one: no plate is built when customName is null, which is why an
   unnamed companion is not merely blank here but absent, and a glade full of wolves does
   not become a wall of labels.

   Geometry is .dfp-peer-name's exactly — the same wide-box centring (a translateX(-50%)
   would be mirrored by the parent's flip and shove the plate sideways), the same offset
   above the sprite, the same shadow. Two differences, both deliberate: it is a size
   smaller than a person's name, and it is warm parchment rather than the innocent-blue
   this shard reserves for PLAYERS — a companion must never read as one at a glance.

   Inherits --pet-scale from the parent, so a level-10 pet's name grows with it. */
.dfp-pet-name {
  position: absolute;
  left: 50%;
  width: 240px;
  margin-left: -120px;
  text-align: center;
  bottom: 100%;
  margin-bottom: 1px;
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #e0c98a;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
  pointer-events: none;
  z-index: 4;
}

/* The parent mirrors itself westward with .dfp-pet-left's negative scale, which would
   otherwise print the name backwards — the same counter-flip .popup-flip-inner applies to
   the fetch-return popup hosted inside this very element. */
.dfp-pet.dfp-pet-left .dfp-pet-name { scale: -1 1; }

/* The sprite itself, one layer in — created by public/js/petDyeApply.js's
   petSpriteLayer so a dye's filter can tint the creature without tinting the nameplate
   and popup the outer element also hosts. Inherits the outer box (inset: 0), carries the
   sheet, and the walk-step animates IT now — the outer element paints nothing. */
.dfp-pet-sprite {
  position: absolute;
  inset: 0;
  background-size: 48px 20px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
}

.dfp-pet.dfp-pet-walking .dfp-pet-sprite {
  animation: ts-pet-step 0.35s steps(1) infinite;
}

/* A walking DYED pet needs both animations at once — `animation` is one property, so the
   walk rule above (three classes deep) would otherwise silently replace the coat's
   shimmer for exactly as long as the pet moves. The two animate different properties
   (background-position-x vs filter), so a comma-list carries both. One rule per kind,
   because each keeps its own timing. */
.dfp-pet.dfp-pet-walking .dfp-pet-sprite.pet-dye-anim-shimmer {
  animation: ts-pet-step 0.35s steps(1) infinite, pet-dye-shimmer 2.8s ease-in-out infinite;
}
.dfp-pet.dfp-pet-walking .dfp-pet-sprite.pet-dye-anim-spectral {
  animation: ts-pet-step 0.35s steps(1) infinite, pet-dye-spectral 3.6s ease-in-out infinite;
}
.dfp-pet.dfp-pet-walking .dfp-pet-sprite.pet-dye-anim-molten {
  animation: ts-pet-step 0.35s steps(1) infinite, pet-dye-molten 1.9s ease-in-out infinite;
}
.dfp-pet.dfp-pet-walking .dfp-pet-sprite.pet-dye-anim-storm {
  animation: ts-pet-step 0.35s steps(1) infinite, pet-dye-storm 1.3s steps(2, jump-none) infinite;
}

/* Facing rows (public/js/dungeonFloorplan.js's dfpSetDirection): no class = front row;
   side = the profile row (always paired with the mirror below for westward);
   away = the back row. Applied to the body AND both doll layers so all three sheets
   switch rows in lockstep; a raised weapon can't be seen from behind, so the weapon
   icon hides while facing away. */
.dungeon-floorplan-player.dfp-face-side { background-position-y: -48px; }
.dungeon-floorplan-player.dfp-face-away { background-position-y: -96px; }
.dfp-face-side .dfp-doll-layer { background-position-y: -48px; }
.dfp-face-away .dfp-doll-layer { background-position-y: -96px; }
.dfp-face-away .dungeon-floorplan-weapon { display: none; }

/* ============ Split armour layers: legs and chest (.dfp-body-layer) ============
   A separately-dyed leg and chest piece over the cloth base, so a turquoise breastplate
   no longer paints the trousers turquoise as well (tools/generatePlayerSprites.js).

   THE DIFFERENCE FROM .dfp-doll-layer, and why this class has to exist at all: a helm or a
   shield is one 32x48 cell per facing row, because the head and torso are pixel-identical
   across every frame of a row, so one cell aligns over all six. Legs are exactly what is NOT
   identical — they stride on frames 1 and 2 — so these carry the body's whole 192x144 sheet
   and must land on the SAME FRAME the body is showing, every frame, or the greaves walk out
   of step with the boots inside them.

   Which is what every rule below does: each one mirrors a rule the body element already has,
   selected off the same class on the same parent. Both axes are driven by longhands only —
   background-position-x for the frame, -y for the facing — because a shorthand in either
   would reset the other and snap a striding side-view back to the standing front frame. Same
   load-bearing split as dfp-run-cycle's own note. */
.dfp-body-layer {
  position: absolute;
  left: 0;
  top: 0;
  width: 32px;
  height: 48px;
  background-size: 192px 144px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  pointer-events: none;
}

/* MOUNTED: no body layer paints below the waist.
   Hiding the legs sheet is not enough on its own. A leather chest piece is a ROBE whose
   skirt runs to row 43 of the 48-row frame (chest_leather_*.png; a plate chest stops at
   32 and is untouched by this), and every body layer is drawn OVER the beast — so a rider
   in leather wore the horse: the skirt hung down the barrel to the hooves and the mount
   was a pair of legs sticking out from under a coat.
   Clipped rather than hidden, because the torso half of that same sheet must stay. The cut
   keeps 33 of 48 rows, which is not a guess: row 33 is where legs_*.png begins, where
   chest_iron ends, and where the ridden-mount barrels top out (see the ceiling note in
   tools/generatePlayerSprites.js) — one line that every layer already agrees on.
   A PERCENTAGE, not pixels: the treasure site draws this same doll at 1.25x, and a pixel
   inset would cut it in the wrong place there.
   Deliberately .dfp-body-layer only. The cloak is a .dfp-doll-layer and its hem is MEANT
   to fall over the beast's rump — that drape is why the mount is the bottom child. */
.dfp-mounted .dfp-body-layer { clip-path: inset(0 0 31.25% 0); }

/* Facing — the body's own -48/-96 row shifts, applied to the overlays. */
.dfp-face-side .dfp-body-layer { background-position-y: -48px; }
.dfp-face-away .dfp-body-layer { background-position-y: -96px; }

/* Frame — the attack poses the body pins statically… */
.dfp-pose-swing .dfp-body-layer { background-position-x: -96px; }
.dfp-pose-draw .dfp-body-layer { background-position-x: -128px; }

/* …and the walk cycle it animates. The SAME keyframes and the same duration as the body's,
   started by the same class toggle in the same tick, so the two stay in phase for as long as
   they run — rather than a second animation of its own that could drift a frame apart. */
.dfp-walking .dfp-body-layer {
  animation: dfp-run-cycle 0.4s steps(1, end) infinite;
}

/* Seated fishing (frame 5, front row) — the shorthand here for the same reason the body's own
   sit rule uses it: sitting always shows the front row whatever facing class was left behind.
   No margin-top to cancel; unlike the body this layer never had the -16px pull-up. */
.gs-pose-sit .dfp-body-layer { background-position: -160px 0; }

/* Mirror: the side row is drawn facing right; stepping (or fighting) leftward mirrors
   the whole paperdoll — body, overlays and held weapon together. Only ever applied
   WITH .dfp-face-side (front/back rows never mirror, so the weapon never swaps hands).
   Done with the INDEPENDENT `scale` property (not a `transform`), which composes
   underneath the transform-based jab/lunge/shake animations instead of being clobbered
   by them — the flip holds even mid-hit. The jab's own inline translate runs in this
   mirrored space, so dfpAttackJab negates its x when the sprite faces left. */
.dungeon-floorplan-player.dfp-face-left {
  scale: -1 1;
}

/* Attack arm poses (public/js/dungeonFloorplan.js flashes one for the duration of each
   weapon-swing animation): frame 3 = melee arm thrust forward, frame 4 = bow draw.
   Longhand X only — the facing class owns Y (see the sheet comment above). Never
   active while walking (the run-cycle animation owns background-position-x then, and
   a fighter never walks mid-fight). */
.dungeon-floorplan-player.dfp-pose-swing {
  background-position-x: -96px;
}
.dungeon-floorplan-player.dfp-pose-draw {
  background-position-x: -128px;
}
/* Seated fishing pose (sheet frame 5) — held for the whole dock scene rather than
   flashed per-swing like the two above (public/js/gatherScene.js). Deliberately the
   SHORTHAND: sitting always shows the front row, whatever facing class was left on. */
.dungeon-floorplan-player.gs-pose-sit {
  background-position: -160px 0;
  /* Cancels the base rule's margin-top: -16px. That pull-up exists so a 32x48 sprite
     overlaps the cell ABOVE its 32px grid square (dungeon/forest/mine), but the fishing
     dock isn't a grid — public/js/gatherScene.js's gsRenderFishingWorld computes exact
     pixel coordinates for the chair, the sitter and the rod against each other (seat top
     at chairTop+29, hip row at local y33, rod grip at local y41.4 meeting the hand at
     local y24.5). The inherited margin silently lifted the sprite 16px out of that math
     while the chair/rod — which carry no margin — stayed put, so the angler hovered above
     the seat and the rod hung below his fist. Zeroing it here makes the documented
     geometry actually hold. */
  margin-top: 0;
}

/* Helm/shield overlay layers — background divs (NOT <img>s) over the same 32x48 grid
   as the body, showing one cell of their 32x144 three-row column sheet; the facing
   classes above move background-position-y in lockstep with the body's own row. */
.dfp-doll-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  background-size: 32px 144px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  pointer-events: none;
}

/* Ridden-mount layer (tools/generatePlayerSprites.js's mount_* sheets) — TWO wing-flap
   frames per facing row, and a CELL BIGGER THAN THE PLAYER'S: 48x56 against the body's
   32x48, so the beast overhangs the rider box 8px each side and 4px above/below and
   stays legible as a creature rather than a saddle-shaped blob. Centered horizontally on
   purpose: the west mirror is a scale(-1,1) on the player element, and a symmetric child
   flips cleanly.
   The flap steps background-position-X between the 48px columns; LONGHAND X only, same
   load-bearing split as .dfp-run-cycle above — a shorthand would reset Y every frame and
   snap a side/back-facing beast to its front row. And because the shared facing shifts
   (-48/-96px above) assume a 48px row, the mount carries its OWN facing overrides in its
   56px rows below. steps(1) keeps it chunky pixel-frame, no tweening. */
.dfp-mount-layer {
  width: 48px;
  height: 56px;
  left: -8px;
  top: -4px;
  background-size: 192px 168px;
  animation: dfp-mount-flap 760ms steps(1) infinite;
}

.dfp-face-side .dfp-mount-layer { background-position-y: -56px; }
.dfp-face-away .dfp-mount-layer { background-position-y: -112px; }

@keyframes dfp-mount-flap {
  0%, 100% { background-position-x: 0; }
  50% { background-position-x: -48px; }
}

/* While the rider is actually moving (.dfp-walking, the same class that strides the
   body's own run cycle), the mount switches to the sheet's WALKING pair — columns 3-4:
   legs swapping fore/aft and fire streaming from the jaws. Faster beat than the idle
   flap, matching the urgency of the run cycle it rides alongside. */
.dfp-walking .dfp-mount-layer {
  animation-name: dfp-mount-stride;
  animation-duration: 520ms;
}

@keyframes dfp-mount-stride {
  0%, 100% { background-position-x: -96px; }
  50% { background-position-x: -144px; }
}

/* The Nightmare and the Unicorn STAND STILL while still, and stride when they move.
   Their idle pair is fire and spark shimmer rather than a wingbeat, and alternating it at
   760ms under a motionless rider read as the sprite FLASHING (reported once per beast) —
   so at rest they simply stand. Moving is the opposite case: the walk pair swaps the legs
   fore and aft, and a mount sliding along frozen was reported just as quickly. Pinning
   every frame fixed the first complaint by causing the second.
   Matched on the inline background-image every doll builder sets (houseDeed, gatherScene,
   dungeonFloorplan, treasureSite — all four) rather than a per-key class, because the
   layer element is REUSED across mount swaps and one missed class removal would leave an
   ordinary horse frozen; the style attribute is already the single source of which beast
   is drawn.
   NO !important anywhere, and that is load-bearing twice over. The walking rules below
   already outrank this block on specificity (.dfp-walking adds a class), so they need no
   help; and an !important background-position-x would beat the stride KEYFRAMES
   themselves, freezing the very animation it is meant to permit. Facing rows ride
   background-position-Y and are untouched throughout.
   The drakes are deliberately not in these selectors: wings genuinely beat, fire merely
   flickers, so their idle flap stays. The stable's plain horses (every mount_bay_horse_*
   coat — the substring match covers all three), the War Horse and the Black Direwolf
   join the two flagships here for exactly their reason: their idle pairs are a breath
   and a mane-drift, and a motionless rider on a flashing beast was the original
   complaint. */
.dfp-mount-layer[style*="mount_nightmare"],
.dfp-mount-layer[style*="mount_unicorn"],
.dfp-mount-layer[style*="mount_bay_horse"],
.dfp-mount-layer[style*="mount_war_horse"],
.dfp-mount-layer[style*="mount_black_direwolf"],
.ts-mount-layer[style*="mount_nightmare"],
.ts-mount-layer[style*="mount_unicorn"],
.ts-mount-layer[style*="mount_bay_horse"],
.ts-mount-layer[style*="mount_war_horse"],
.ts-mount-layer[style*="mount_black_direwolf"] {
  animation: none;
  background-position-x: 0;
}

/* ...but the moment the rider is actually walking, the sheet's walking pair runs. Same
   keyframes and beat the drakes use — only the idle flap was ever the problem. */
.dfp-walking .dfp-mount-layer[style*="mount_nightmare"],
.dfp-walking .dfp-mount-layer[style*="mount_unicorn"],
.dfp-walking .dfp-mount-layer[style*="mount_bay_horse"],
.dfp-walking .dfp-mount-layer[style*="mount_war_horse"],
.dfp-walking .dfp-mount-layer[style*="mount_black_direwolf"] {
  animation: dfp-mount-stride 520ms steps(1) infinite;
}

.ts-doll.ts-walking .ts-mount-layer[style*="mount_nightmare"],
.ts-doll.ts-walking .ts-mount-layer[style*="mount_unicorn"],
.ts-doll.ts-walking .ts-mount-layer[style*="mount_bay_horse"],
.ts-doll.ts-walking .ts-mount-layer[style*="mount_war_horse"],
.ts-doll.ts-walking .ts-mount-layer[style*="mount_black_direwolf"] {
  animation: ts-mount-stride 520ms steps(1) infinite;
}

/* The equipped weapon's own item icon, wielded at the player's right hand. Sized big
   and heroic against the 48px doll — nearly the height of the figure, overhanging its
   shoulder — with a drop shadow so it reads as a real object held out, not a decal.
   The offsets and transform-origin lock the icon's GRIP pixel to the sprite's resting
   gauntlet (the hand at ~(24.5, 29) of the 32x48 frame): weapon icons in this set are
   drawn vertically with the handle band at ~47% across / 75% down the canvas, so that
   exact point is both pinned to the hand and used as the swing pivot — the blade
   rotates around the fist, not around empty air. Tipped slightly right at rest, then
   swings on each attack (see .dfp-weapon-swinging). */
.dungeon-floorplan-weapon {
  position: absolute;
  right: -16px;
  bottom: 11px;
  /* 32px (not the old 40) — proportionate to the 48px doll: blade rides over the
     shoulder instead of crossing the face. Offsets re-derived to keep the icon's grip
     (25%/75% of the box) locked to the resting gauntlet at (24, 29) of the 32x48
     frame: left = 24 - 0.25*32 = 16 -> right -16; top = 29 - 0.75*32 = 5 -> bottom 11. */
  width: 32px;
  height: 32px;
  /* Icons are drawn in the heroic diagonal pose (grip lower-left, tip 45deg
     upper-right — tools/generateSprites.js's wpnHeroic), so the grip sits at ~25%/75%
     of the canvas. Rest rotation -38deg nets a NEAR-UPRIGHT carry: blade 7deg past
     vertical, tip leaning slightly outward away from the face (-45 would be dead
     vertical). The old -67deg laid the blade half-horizontal across the hip, which
     read as the weapon floating detached at the waist — reported from the Town Square
     (which shares this geometry) but equally wrong here. */
  transform: rotate(-38deg);
  transform-origin: 25% 75%; /* the diagonal icon's grip — pivot exactly at the fist */
  image-rendering: pixelated;
  pointer-events: none;
  filter: drop-shadow(-1px 2px 1px rgba(0, 0, 0, 0.55));
  z-index: 3; /* over the body/helm layers — a raised weapon is in front */
}

/* While the swing pose flashes (frame 3 — arm thrust forward), the weapon rides the
   extended gauntlet at ~(29.5, 24) instead of the resting one, so blade and fist move
   as one piece through the whole animation. */
.dfp-pose-swing .dungeon-floorplan-weapon {
  /* Extended gauntlet at (29.5, 24), same grip-lock derivation as the resting rule. */
  right: -21.5px;
  bottom: 16px;
}

/* Bow only (see .is-crossbow below for crossbow/heavy crossbow — a differently-shaped
   icon that needs its own geometry, not this one). Bow icons put the stave's grip at
   ~37%/50% of the canvas — that's the origin, pinned to the resting hand low at the
   side (carried), and rotation about the grip keeps it there through the flip. */
.dungeon-floorplan-weapon.is-bow {
  /* Bows are drawn UPRIGHT in the icon (tools/generateSprites.js's drawBowFamily, angle:0
     — the limb's own vertical C-curve is the whole composition), so they're carried
     upright too: a bow is held VERTICAL, limbs up/down, unlike the crossbow's level rifle
     carry (.is-crossbow above). The old -35deg "forward cant" just tipped that upright
     stave over backwards across the shoulder, which read as the bow being slung/rotated
     wrong. Held upright, flipped a full 180 so the stave's belly curves FORWARD and the
     string faces the body the way a drawn bow does. Grip wrap sits at ~38%/50% of the
     icon; offsets lock it to the resting hand at (24.6, 29): left = 24.6 - 0.38*26 ~= 15
     -> right -9; top = 29 - 13 = 16 -> bottom 6. Rotation pivots about that same grip
     origin, so the 180 flip keeps the wood grip pinned in the hand. */
  transform: rotate(180deg);
  transform-origin: 38% 50%;
  /* Nudged a few px up/right off the raw grip-lock (was right -9 / bottom 6) so the flipped
     stave sits in the hand cleanly rather than dipping below/behind it. */
  right: -12px;
  bottom: 9px;
  width: 26px;
  height: 26px;
}

/* During the draw pose (frame 4) the bow lifts to the extended lead hand at
   ~(29.5, 18) — held out level to loose the arrow. */
.dfp-pose-draw .dungeon-floorplan-weapon.is-bow {
  /* Extended lead hand at (29.5, 18), same grip-lock derivation as the carry rule. */
  right: -14px;
  bottom: 17px;
}

/* Crossbow/Heavy Crossbow (public/js/dungeonFloorplan.js's dfpWeaponIsCrossbow) — a
   plain upright "T": the prod/bar spans the top, the stock hangs straight down from its
   center, NOT the bow's diagonal recurve art, so reusing .is-bow's -35deg cant held it
   sideways-on like a dropped letter T instead of aimed forward (the bug this class
   fixes). Rotating the upright T a full 90deg swings the prod (originally "up") around
   to point forward/right and the stock (originally "down", the shoulder/butt end)
   around to point back toward the body — exactly how a crossbow is actually carried.
   Origin at ~50%/70% of the icon sits where the stock meets the trigger, just below the
   prod; offsets lock that point to the resting hand at (24.6, 29), same target as the
   bow's own carry rule: left = 24.6 - 0.5*26 = 11.6 -> right -6; top = 29 - 0.7*26 =
   10.8 -> bottom 11. */
.dungeon-floorplan-weapon.is-crossbow {
  transform: rotate(90deg);
  transform-origin: 50% 70%;
  right: -6px;
  bottom: 11px;
  width: 26px;
  height: 26px;
}

/* During the draw pose (frame 4) the crossbow lifts to the extended lead hand at
   ~(29.5, 18), same target as the bow's own draw-pose rule. */
.dfp-pose-draw .dungeon-floorplan-weapon.is-crossbow {
  right: -11px;
  bottom: 22px;
}

/* An Inscribed Spellbook (config/inscription.js) — same reasoning as .ts-weapon.is-spellbook
   in the Town Square block above: it's the one weapon icon that's a solid upright block
   rather than a thin diagonal blade, so at the melee 32px it read as oversized against the
   32x48 doll. 22px, carried near-upright by its SPINE (the icon's left edge, ~24%/50% of the
   box) instead of swung at the melee -38deg. Offsets lock that spine to the resting gauntlet
   at (24, 29) of the frame by this rule's own arithmetic: left = 24 - 0.24*22 = 18.72 ->
   right = 32 - 18.72 - 22; top = 29 - 0.5*22 = 18 -> bottom = 48 - 18 - 22. It keeps the
   ordinary melee swing animation (it is a melee weapon mechanically — a wisp-bolt basic
   attack, not a bow), which pivots about this same grip origin. */
.dungeon-floorplan-weapon.is-spellbook {
  transform: rotate(-10deg);
  /* Anchored by its CENTRE on the resting gauntlet (24, 29) of the 32x48 frame, not by
     its spine as before. A blade hangs FROM its grip, so a spine anchor is right for one;
     a book is CARRIED, so its middle sits in the hand. Anchoring the spine pushed the
     whole block out past the body's right edge, which is what read as floating beside the
     character rather than held: left = 24 - 16/2 = 16 -> right = 32 - 16 - 16 = 0;
     top = 29 - 16/2 = 21 -> bottom = 48 - 21 - 16 = 11.
     16px (was 22): a spellbook is a solid block, so it carries far more visual weight per
     pixel than a thin diagonal blade at the same size. */
  transform-origin: 50% 50%;
  right: 0;
  bottom: 11px;
  width: 16px;
  height: 16px;
}

/* Melee swing: wind the blade back over the shoulder, then chop down and through
   toward the enemy, and settle back to the resting angle — pivoting at the grip.
   Played on each landed-or-missed player swing (public/js/dungeonFloorplan.js). */
/* Same swing arc as ever, every keyframe shifted +29deg alongside the resting carry's
   own -67 -> -38 change (see the .dungeon-floorplan-weapon comment above) — the arc's
   size and character are unchanged, it just starts and ends at the new upright rest. */
@keyframes dfp-weapon-swing {
  0% { transform: rotate(-38deg); }
  22% { transform: rotate(-84deg); }
  55% { transform: rotate(36deg); }
  78% { transform: rotate(14deg); }
  100% { transform: rotate(-38deg); }
}
.dungeon-floorplan-weapon.dfp-weapon-swinging {
  animation: dfp-weapon-swing 300ms ease-out;
}

/* Parry: the shield physically snaps up and out to catch the blow — a raise/push jolt
   with a brightness flash at the moment of contact (public/js/dungeonFloorplan.js's
   dfpPlayParry, on monsterSwing.outcome === 'parried'). The +x push points toward the
   sheet's facing direction; the layer being a child of the (possibly scale-mirrored)
   player element is what aims it at the mob with no direction math. .dfp-doll-layer
   has no transform/filter of its own, so nothing fights this animation (the hit-shake
   lives on the parent). */
@keyframes dfp-shield-block {
  0% { transform: translate(0, 0) rotate(0deg); filter: brightness(1); }
  30% { transform: translate(7px, -4px) rotate(14deg); filter: brightness(1.9); }
  60% { transform: translate(5px, -2px) rotate(10deg); filter: brightness(1.3); }
  100% { transform: translate(0, 0) rotate(0deg); filter: brightness(1); }
}
.dfp-doll-layer.dfp-shield-parry {
  animation: dfp-shield-block 320ms ease-out;
}

/* The two-handed version — no shield showing, so the weapon itself jolts up to turn
   the blow aside: a quick counter-rotation about the same grip origin the swing uses.
   The rotate values are absolute (this animation owns transform for its duration, same
   as dfp-weapon-swing above), starting/ending at the resting -38deg carry. */
@keyframes dfp-weapon-parry-jolt {
  0% { transform: rotate(-38deg); }
  35% { transform: rotate(-70deg) translateY(-3px); }
  100% { transform: rotate(-38deg); }
}
.dungeon-floorplan-weapon.dfp-weapon-parry {
  animation: dfp-weapon-parry-jolt 300ms ease-out;
}

/* Bow shot: a quick draw-back-and-release nock, keeping the upright, 180-flipped carry
   orientation (.is-bow above). */
@keyframes dfp-bow-shoot {
  0% { transform: rotate(180deg) translateX(0); }
  35% { transform: rotate(180deg) translateX(3px); }
  50% { transform: rotate(180deg) translateX(-2px); }
  100% { transform: rotate(180deg) translateX(0); }
}
.dungeon-floorplan-weapon.is-bow.dfp-bow-shooting {
  animation: dfp-bow-shoot 300ms ease-out;
}

/* Crossbow shot: same nock-and-release beat as the bow's own dfp-bow-shoot, just
   keeping the 90deg carry orientation (.is-crossbow above) instead of the bow's -35deg.
   public/js/dungeonFloorplan.js toggles the same dfp-bow-shooting class for both — only
   the resting angle differs, so this is a separate keyframes rather than a shared one. */
@keyframes dfp-crossbow-shoot {
  0% { transform: rotate(90deg) translateX(0); }
  35% { transform: rotate(90deg) translateX(3px); }
  50% { transform: rotate(90deg) translateX(-2px); }
  100% { transform: rotate(90deg) translateX(0); }
}
.dungeon-floorplan-weapon.is-crossbow.dfp-bow-shooting {
  animation: dfp-crossbow-shoot 300ms ease-out;
}

/* Channeling glow while a spell cast is in flight (spell:castStart -> castEnd). */
@keyframes dfp-casting-pulse {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(140, 190, 255, 0.55)); }
  50% { filter: drop-shadow(0 0 8px rgba(140, 190, 255, 0.95)); }
}
.dungeon-floorplan-player.dfp-casting {
  animation: dfp-casting-pulse 900ms ease-in-out infinite;
}

/* A resolved spell's impact on the map: an expanding tinted ring + soft flash over
   whoever it landed on, colored per spell via --fx-color (the same tints
   public/js/spellCast.js's own SPELL_FX map gives the combat gump's effects).
   Keyframe named -ring (not plain dfp-spell-burst): the monster-spell-hit fx block
   further down had its own same-named @keyframes that silently shadowed this one in
   the cascade — both renamed apart. */
@keyframes dfp-spell-burst-ring {
  0% { transform: translate(-50%, -50%) scale(0.25); opacity: 0.95; }
  70% { opacity: 0.55; }
  100% { transform: translate(-50%, -50%) scale(1.6); opacity: 0; }
}
.dfp-spell-burst {
  position: absolute;
  left: 50%;
  top: 55%;
  width: 30px;
  height: 30px;
  border: 2px solid var(--fx-color, #d8dbe6);
  border-radius: 50%;
  box-shadow: 0 0 12px 2px var(--fx-color, #d8dbe6), inset 0 0 8px 1px var(--fx-color, #d8dbe6);
  pointer-events: none;
  animation: dfp-spell-burst-ring 550ms ease-out forwards;
}

/* Bigger, hotter variant for the heavy hitters (fireball/flame strike arrivals —
   public/js/dungeonFloorplan.js passes big:true): a wider ring plus a radial core
   flash, reading as a real explosion rather than a spark. */
.dfp-spell-burst.dfp-spell-burst-big {
  width: 44px;
  height: 44px;
  border-width: 3px;
}
.dfp-spell-burst.dfp-spell-burst-big::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 244, 214, 0.95), var(--fx-color, #ff8c3a) 45%, transparent 75%);
  animation: dfp-flash-core 380ms ease-out forwards;
}

/* Flame Strike on the floorplan — an in-place eruption on the mob's own tile (it does
   NOT fly as a projectile: the whole identity of the spell is a column bursting up out
   of the ground under the target). Scaled-down twin of .spell-fx-pillar's layering: the
   element is the outer column, ::before the white core, ::after the ground flash.
   Shares the gump's own keyframes, which are pure transforms and size-agnostic. */
.dfp-flame-pillar {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 26px;
  height: 60px;
  margin-left: -13px;
  transform-origin: bottom;
  border-radius: 46% 46% 5px 5px;
  background: linear-gradient(to top,
    var(--fx-color, #ff5a26),
    rgba(255, 176, 64, 0.95) 30%,
    rgba(255, 214, 120, 0.8) 55%,
    rgba(255, 240, 190, 0.4) 78%,
    transparent);
  filter: drop-shadow(0 0 9px var(--fx-color, #ff5a26));
  animation: spell-fx-pillar 820ms ease-out forwards;
  pointer-events: none;
  z-index: 3;
}
.dfp-flame-pillar::before {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 9px;
  height: 52px;
  margin-left: -4.5px;
  transform-origin: bottom;
  border-radius: 50% 50% 3px 3px;
  background: linear-gradient(to top, #fff6d8, rgba(255, 246, 216, 0.85) 45%, transparent 85%);
  animation: spell-fx-pillar-core 820ms ease-out forwards;
}
.dfp-flame-pillar::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -4px;
  width: 38px;
  height: 12px;
  margin-left: -19px;
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(255, 240, 190, 0.95), var(--fx-color, #ff5a26) 55%, transparent 75%);
  animation: spell-fx-pillar-flash 500ms ease-out forwards;
}

/* Lightning on the map — the combat gump's .spell-fx-lightning scaled down onto a 44px
   sprite, for both full-screen scenes (public/js/dungeonFloorplan.js's dfpStrikeLightning,
   which public/js/gatherScene.js calls too for a harvest ambush). Like Flame Strike above
   it does NOT fly across the room: the bolt comes down out of the dark onto the target's
   own tile, which is the entire reason it reads as lightning rather than as one more
   energy bolt.

   The root is a bare, unclipped box. The bolt is ::before rather than the root itself
   because clip-path clips an element's pseudo-elements along with it — drawn on the root,
   the jagged mask would have taken the ground flash with it. Same three-part layering as
   .dfp-flame-pillar, one layer lighter. */
.dfp-lightning {
  position: absolute;
  left: 50%;
  /* Measured from the sprite's own box, so the tip lands on the upper body of a
     bottom-anchored 44px mob rather than stopping short above its head. */
  bottom: 38%;
  width: 20px;
  height: 58px;
  margin-left: -10px;
  pointer-events: none;
  z-index: 4;
}

.dfp-lightning::before {
  content: '';
  position: absolute;
  inset: 0;
  transform-origin: top;
  background: var(--fx-color, #eaf6ff);
  /* The gump's own jag, verbatim — percentages, so it scales to this smaller box on its
     own and the two views can never draw a differently-shaped bolt. */
  clip-path: polygon(52% 0, 68% 0, 44% 38%, 62% 38%, 30% 100%, 46% 55%, 30% 55%);
  filter: drop-shadow(0 0 6px var(--fx-color, #eaf6ff));
  /* Shared with the gump for the same reason .dfp-flame-pillar shares the pillar's:
     scaleY + opacity only, so it is size-agnostic. This is also the LONGEST-running
     animation on the element, which is what dfpStrikeLightning's removal listener leans
     on — retiming it below the ground flash would strand the bolt in the DOM. */
  animation: spell-fx-lightning 450ms ease-out forwards;
}

/* The ground flash under the strike, delayed to the beat the bolt actually reaches full
   length (25% into its run) so the two read as one event: strike, then flare. */
.dfp-lightning::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -3px;
  width: 30px;
  height: 11px;
  margin-left: -15px;
  border-radius: 50%;
  background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.95), var(--fx-color, #eaf6ff) 50%, transparent 75%);
  animation: dfp-flash-core 340ms ease-out 90ms forwards;
  opacity: 0;
}

/* Sea-serpent size (public/js/gatherScene.js's fishing ambush): that sprite is 88px, twice
   a dungeon mob, and the dungeon-scale bolt read as a spark glancing off it. */
.dfp-lightning.dfp-lightning-lg {
  bottom: 42%;
  width: 34px;
  height: 96px;
  margin-left: -17px;
}

/* Roaming mobs (and the engaged one mid-fight). Scaled up to 44px — bigger than the
   32px cell and bottom-anchored (feet on the tile, bulk rising above), so they loom
   over the ground and read as real threats beside the tall player figure. The left/top
   transitions give the idle shuffle and the gap-closing approach the same fluid glide
   as the player's own steps; a drop shadow grounds them. */
.dungeon-floorplan-monster {
  position: absolute;
  width: 44px;
  height: 44px;
  margin-left: -6px; /* centre the 44px sprite on the 32px cell */
  margin-top: -12px; /* bottom-anchor: feet at the cell's lower edge */
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center bottom;
  image-rendering: pixelated;
  z-index: 1;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5));
  transition: left 0.35s linear, top 0.35s linear, opacity 0.4s ease-out;
}

.dungeon-floorplan-monster.defeated {
  opacity: 0;
}

/* A caster's own telegraphed cast bar (config/monsterSpellAttacks.js) — appended over
   the CASTING mob's sprite while its spell/breath winds up, so the incoming damage is
   visibly "coming" rather than landing with no warning. Removed the instant the cast
   resolves (public/js/dungeonFloorplan.js's dfpClearMobCastBar) or the mob itself is torn
   down. .dungeon-floorplan-monster is already position: absolute, which is enough to
   anchor these absolutely-positioned children without any extra positioning change. */
.dfp-mob-castbar {
  position: absolute;
  left: 50%;
  top: -14px;
  /* Mirror-safe centring and a counter-flip, exactly as .dfp-mob-hpbar above — same trap,
     same fix. This bar's fill drained from the wrong side on a left-facing caster too. */
  margin-left: -17px;
  width: 34px;
  height: 4px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 2px;
  overflow: hidden;
  z-index: 7;
}
/* Purple, and purple everywhere a monster's wind-up is drawn (the Combat gump's own
   monster cast row wears the same two values). It used to be the same salmon red as
   every damage cue on screen, which made "something is being cast at you" just another
   red thing among red things; a colour used for nothing else in a fight means the bar
   needs no reading to be understood. */
.dfp-mob-castbar-fill {
  height: 100%;
  width: 0%;
  background: #a855f7;
}

/* The cast bar's counter-flip. Its LABEL needs no rule of its own despite being readable
   text on a mirrored parent: the label is a child of the bar (dfpStartMobCastBar appends
   it there), so this flip un-mirrors it along with the fill, and its own
   translateX(-50%) centring resolves correctly again inside the restored coordinate
   space. Two negatives, one forward-reading spell name. */
.dfp-face-left > .dfp-mob-castbar {
  scale: -1 1;
}
.dfp-mob-castbar-label {
  position: absolute;
  left: 50%;
  top: -23px;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: calc(9px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #c99cff;
  text-shadow: 0 0 3px #000, 0 0 3px #000;
  pointer-events: none;
}

/* The engaged mob's health, pinned under its feet (public/js/dungeonFloorplan.js's
   dfpSetMobHpBar). Same hostile red as the Combat gump's own monster bar
   (.combat-hp-fill-monster) so the two surfaces read as one number, and the same 34px
   width as the cast bar above so a caster mid-wind-up shows two tidily stacked bars
   rather than two different-sized ones. Only the mob actually being fought ever gets
   one. */
.dfp-mob-hpbar {
  position: absolute;
  left: 50%;
  top: 46px; /* just clear of the 44px sprite box, i.e. under the feet */
  /* margin-left, NOT transform: translateX(-50%) — see .house-view-guest-name's own long
     comment for why: a percentage translate resolves in the PARENT's coordinate system,
     and that parent is mirrored (scale: -1 1) the moment the mob faces left, so "shift
     left by half my width" silently becomes "shift right by half my width". A fixed
     margin is layout, not transform, so the mirror has nothing to reinterpret — and it
     leaves the bar centred on the mob's own centre, which is what makes the counter-flip
     below safe (mirroring about a box's centre when that centre IS the sprite's centre
     cannot move it). Half of the 34px width. */
  margin-left: -17px;
  width: 34px;
  height: 4px;
  background: rgba(0, 0, 0, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 2px;
  overflow: hidden;
  z-index: 7;
  pointer-events: none;
}

/* Counter-flip, for the same reason the nameplates and damage popups carry one: the
   facing mirror lives on the MOB, so every child mirrors with it. That is right for the
   sprite and wrong for a bar, whose fill is anchored to its left edge and so drained from
   the right-hand side — backwards from the Combat gump's bar for the same monster — every
   time the creature happened to be facing left. Deliberately keyed off `.dfp-face-left`
   alone rather than `.dungeon-floorplan-monster.dfp-face-left`, so a PK sprite
   (.dungeon-floorplan-pk, which mirrors with the same class) or any future bar host is
   covered without a second rule that someone has to remember to add. */
.dfp-face-left > .dfp-mob-hpbar {
  scale: -1 1;
}
.dfp-mob-hpbar-fill {
  height: 100%;
  width: 100%;
  background: #d9271c;
  transition: width 180ms linear;
}

/* The timer under the WORKER's own feet (public/js/gatherScene.js's gsSyncActionBar) — how
   long until the thing they are doing next resolves, drawn where the player is already
   looking. Two things drive it, and it looks identical for both by design: a gather tick's
   cadence, and one timed craft attempt (cooking, smelting, plank-carving, any crafting-gump
   skill, the Crafting Bench). The Activities panel's row carries the same countdown, but a
   scene is full-screen over the top of it, so mid-shift that row is not on screen at all.

   Yellow, and yellow is used for nothing else down here: red is a monster's health, purple
   an incoming cast, green a poison. A colour shared with none of them means the meter needs
   no reading to be understood.

   NO radius and no inset, so the fill runs flush into all four inner edges and the bar is
   one unbroken sweep rather than something with a gap at each end. And no transition here
   either: the fill's own is written in JS as `width <remaining>ms linear`, aimed at the real
   moment the tick is due, and a standing rule would fight it. */
.gs-action-bar {
  position: absolute;
  left: 50%;
  top: 50px; /* just clear of the 48px sprite box, i.e. under the feet */
  /* margin-left, NOT transform: translateX(-50%) — the figure is mirrored (scale: -1 1) the
     moment it faces left, and a percentage translate resolves in the PARENT's coordinate
     system, so "shift left by half my width" silently becomes "shift right by half my
     width". A fixed margin is layout, not transform, so the mirror has nothing to
     reinterpret. Exactly the trap .dfp-mob-hpbar above documents. Half of the width below. */
  margin-left: -18px;
  width: 36px;
  height: 5px;
  background: rgba(6, 7, 10, 0.8);
  border: 1px solid rgba(0, 0, 0, 0.8);
  overflow: hidden;
  z-index: 7;
  pointer-events: none;
}

/* The facing mirror lives on the FIGURE, so every child mirrors with it — right for the
   sprite, wrong for a meter whose fill is anchored to its left edge and would otherwise
   drain from the right every time the worker happened to be swinging westward. Same
   counter-flip the mob bars carry, safe for the same reason: the box is centred on the
   sprite's own centre, and mirroring a box about its own centre cannot move it. */
.dfp-face-left > .gs-action-bar {
  scale: -1 1;
}

.gs-action-bar-fill {
  height: 100%;
  width: 0%;
  background: #f2c230;
}

/* A craft attempt that failed, held red for a beat on its way down (gsEndCraftBar). The
   same red the Activities panel's own failed fill uses, so one signal means one thing in
   both places. Only ever set on a craft: a gather tick that pays nothing is an ordinary
   outcome the journal reports, not a failure the meter should shout about. */
.gs-action-bar-fill-failed {
  background: #e25c4f;
}

/* Poisoned monsters LOOK poisoned down here too (public/js/dungeonFloorplan.js's
   dfpTintEngagedMob). Two things this deliberately does NOT do, each for a reason:

   It is not the Combat gump's .sprite-poisoned. That rule tints with `filter`, and
   .dungeon-floorplan-monster above declares its own `filter: drop-shadow(...)` at equal
   specificity and later in this file — so it won the cascade every time and a poisoned
   mob on the map never changed colour at all, however sick the gump's sprite looked.

   And it tints through a pseudo-element rather than a filter on the mob itself, because
   a filter applies to the whole rendered subtree: the HP and cast bars are children of
   this element now, and would have gone green with the creature — a green HP bar being
   exactly the wrong signal to send about a monster that is losing health.

   The overlay repaints the sprite over itself (background-image and its sizing all come
   straight off the element via `inherit`, so nothing has to be told which monster this
   is) with the gump's own poison filter chain applied, and only where the sprite has
   pixels — a transparent background stays transparent, so the tile itself never greens. */
/* A POISONED PLAYER (config/poison.js) — green on every figure that draws one, your own
   and everybody else's, on the dungeon floor plan and in the gathering scenes alike (they
   share the .dungeon-floorplan-player paperdoll).

   The same overlay trick the poisoned monster below uses, and for the same reason, but
   applied per LAYER rather than once to the figure: a player is not one sprite, it is a
   body background plus a stack of gear layers (.dfp-body-layer legs/chest/arms/gloves,
   .dfp-doll-layer helm/shield/cloak, and the weapon <img>). A plain `filter` on the figure
   would tint all of that in one go — but it would also tint the NAMEPLATE, the damage
   numbers and any speech bubble hanging off the same element, because a filter applies to
   the whole rendered subtree and a child cannot opt out. Overlaying each sprite layer with
   a tinted copy of its own background instead greens exactly the pixels that are the
   character and leaves the text alone.

   background-image: inherit is what makes one rule serve every layer — each ::after picks
   up whichever sprite its own parent is wearing. Transparent pixels stay transparent, so
   the tile underneath never greens. */
.dungeon-floorplan-player.dfp-figure-poisoned::after,
.dfp-figure-poisoned > .dfp-body-layer::after,
.dfp-figure-poisoned > .dfp-doll-layer::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: inherit;
  background-size: inherit;
  background-repeat: inherit;
  background-position: inherit;
  filter: sepia(0.6) hue-rotate(55deg) saturate(1.9) brightness(0.96);
  pointer-events: none;
}

/* The FIGURE's own overlay has to paint UNDER the gear, not over it.
   Its background is the bare body (player_none.png), and an ::after defaults to the top of
   the parent's stacking order — so without this a poisoned character was repainted as an
   undressed body covering their own armour, helm, shield, cloak and mount. z-index -1 puts
   the tinted copy above the parent's own background (which is all it needs to cover) and
   below every child layer. The per-LAYER overlays above need no such rule: each sits inside
   its own layer and tints only that layer's sprite. */
.dungeon-floorplan-player.dfp-figure-poisoned::after {
  z-index: -1;
}

/* The weapon is an <img>, not a background div, so it takes the filter directly — there is
   no background to overlay a copy of, and an image element has no text children to protect. */
.dfp-figure-poisoned > .dungeon-floorplan-weapon {
  filter: sepia(0.6) hue-rotate(55deg) saturate(1.9) brightness(0.96);
}

.dungeon-floorplan-monster.dfp-mob-poisoned::after {
  content: '';
  position: absolute;
  inset: 0;
  background-image: inherit;
  background-size: inherit;
  background-repeat: inherit;
  background-position: inherit;
  filter: sepia(0.6) hue-rotate(55deg) saturate(1.9) brightness(0.96);
  z-index: 2; /* over the sprite, under the bars at 7 */
  pointer-events: none;
}

/* A discovered Dungeon Chest on the wander map (public/js/dungeonFloorplan.js's
   chest:spawn handler, sockets/combatHandlers.js's spawnDungeonChest) — sized/anchored
   like a small mob, reusing the treasure-map chest art. .dfp-chest-open swaps to the
   open-lid frame once the lock gives; removal reuses .defeated's opacity fade (the
   transition below carries it), same lifecycle as a mob corpse. */
.dfp-chest {
  position: absolute;
  width: 30px;
  height: 30px;
  margin-left: 1px; /* centre the 30px sprite on the 32px cell */
  margin-top: -2px; /* settle it on the cell floor */
  background-image: url('/assets/sprites/items/treasure_chest_closed.png');
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center bottom;
  image-rendering: pixelated;
  z-index: 1;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5));
  transition: opacity 0.4s ease-out;
}
.dfp-chest.dfp-chest-open {
  background-image: url('/assets/sprites/items/treasure_chest_open.png');
}
.dfp-chest.defeated {
  opacity: 0;
}

/* The Orc Fort's staircase (config/dungeonFloorplans.js's levels, public/js/
   dungeonFloorplan.js's dfpSpawnStair) — the one cell on a floor that is a way off it.
   Drawn rather than baked into the world canvas because it is a thing you interact with by
   standing on, and the baked props are scenery: a player has to be able to see where it is
   from across a room and walk to it deliberately.

   No sprite of its own — steps read perfectly well as three stacked bars of stone with a
   dark mouth above them, and inventing a new art file for one cell in one dungeon would be
   a sprite that nothing else in the game ever uses. */
.dfp-stair {
  position: absolute;
  width: 32px;
  height: 32px;
  z-index: 1;
  pointer-events: none;
  background:
    linear-gradient(to bottom,
      rgba(10, 8, 6, 0.95) 0 34%,
      #6b6355 34% 50%,
      #4e4a3f 50% 62%,
      #7a7263 62% 76%,
      #57523f 76% 88%,
      #857c68 88% 100%);
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.55);
  /* A faint warm glow so the way up is findable across a dark floor without a label. */
  filter: drop-shadow(0 0 5px rgba(240, 205, 130, 0.35));
}

/* Mobs mirror to face whoever they're squaring up against (the player, mid-fight) or
   the direction of a roaming step — same independent-scale trick as the player's own
   .dfp-face-left, composing under the transform-based jab/flash animations. Monster art
   is drawn facing right (or symmetric, where the flip is invisible). */
.dungeon-floorplan-monster.dfp-face-left {
  scale: -1 1;
}

/* Player run cycle: flip between the two striding-leg frames of the sheet (frame 1 at
   -32px, frame 2 at -64px) while moving, so the legs actually stride rather than the
   figure gliding or bobbing in place. Each frame is held for half the cycle with a hard
   jump between them (no interpolation — the value is constant within each range), giving
   a crisp two-beat run. background-position-x LONGHAND only — the facing classes own
   background-position-y (which row strides: front, side, or back), and the shorthand
   here would snap every walker back to the front row on each frame (see the sheet
   contract comment above .dungeon-floorplan-player). Never touches the element's
   transform, so it can't fight the combat jab's inline transform. Applied only while
   actually moving (public/js/dungeonFloorplan.js toggles .dfp-walking); at rest the
   base rule's frame 0 shows both feet planted. */
@keyframes dfp-run-cycle {
  0%, 49.9% { background-position-x: -32px; }
  50%, 100% { background-position-x: -64px; }
}
.dungeon-floorplan-player.dfp-walking {
  animation: dfp-run-cycle 0.4s steps(1, end) infinite;
}

/* Shared caster->target projectile (public/js/dungeonFloorplan.js's
   dfpLaunchProjectile): flies the --dx/--dy pixel gap rotated to --ang over --fly-ms,
   then self-removes and fires the launcher's onArrive. The visual subclasses below
   (arrow / orb / bolt) only style the moving object — position/flight always live
   here. */
@keyframes dfp-arrow-fly {
  from { transform: translate(0, 0) rotate(var(--ang)); opacity: 1; }
  to { transform: translate(var(--dx), var(--dy)) rotate(var(--ang)); opacity: 0.85; }
}
.dfp-projectile {
  position: absolute;
  z-index: 4;
  pointer-events: none;
  animation: dfp-arrow-fly var(--fly-ms, 150ms) linear forwards;
}

/* An arrow loosed from a bow — a slim fletched streak. */
.dfp-projectile.dfp-arrow {
  width: 16px;
  height: 3px;
  margin: -1.5px 0 0 -8px;
  background: linear-gradient(90deg, rgba(216, 195, 138, 0) 0%, #b89a5a 35%, #f0e6c0 100%);
  border-radius: 1px;
  box-shadow: 0 0 3px rgba(240, 230, 192, 0.7);
}

/* A PK Ambush attacker (config/pkers.js, public/js/dungeonFloorplan.js's dfpBuildPkEl) — drawn
   as another PLAYER, not a monster, so it deliberately duplicates .dungeon-floorplan-player's
   sheet geometry (32x48 cell off a 192x144 three-row sheet) rather than the 44px
   background-size:contain that every real mob uses. Its own class instead of reusing the
   player's so nothing that reasons about "the player figure" can ever pick up a PK by accident.
   The facing rules below mirror the player's exactly, which is what lets the shared
   `.dfp-face-side .dfp-doll-layer` descendant rules already in this file drive its helm/shield/
   cloak layers with no extra CSS. */
.dungeon-floorplan-pk {
  position: absolute;
  width: 32px;
  height: 48px;
  margin-top: -16px;
  background-size: 192px 144px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  /* Above ordinary mobs (z-index 1) but level with the player, so an ambusher reads as a real
     participant in the fight rather than scenery. */
  z-index: 2;
  transition: left 0.35s linear, top 0.35s linear, opacity 0.4s ease-out;
  /* A faint red rim — the one visual tell that this "player" is hostile, since the gear itself is
     randomised from the same art the player's own paperdoll uses and would otherwise be
     indistinguishable from a friendly character. */
  filter: drop-shadow(0 0 3px rgba(200, 40, 40, 0.85)) drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5));
}
.dungeon-floorplan-pk.dfp-face-side { background-position-y: -48px; }
.dungeon-floorplan-pk.dfp-face-away { background-position-y: -96px; }
.dungeon-floorplan-pk.dfp-face-left { scale: -1 1; }
.dungeon-floorplan-pk.defeated { opacity: 0; }

/* A hurled boulder (config/monsterRockThrows.js) — a chunk of rock tumbling end over end
   across the gap. Unlike every other projectile here it deliberately does NOT hold the
   flight angle: a thrown rock spins, so the inner element owns its own continuous spin
   while the outer .dfp-projectile carries it along the --dx/--dy flight line. The irregular
   border-radius is what keeps it reading as a rough rock rather than a ball, and the spin
   makes that irregularity visible. */
.dfp-projectile.dfp-proj-boulder {
  width: 15px;
  height: 15px;
  margin: -7.5px 0 0 -7.5px;
  border-radius: 42% 58% 47% 53% / 55% 44% 56% 45%;
  background:
    radial-gradient(circle at 34% 30%, rgba(255, 255, 255, 0.42) 0%, transparent 42%),
    linear-gradient(150deg, #cdc2b4 0%, var(--fx-color, #b9ada0) 46%, #6f665c 100%);
  border: 1px solid rgba(52, 46, 40, 0.85);
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.55), inset -2px -2px 3px rgba(0, 0, 0, 0.35);
  animation:
    dfp-arrow-fly var(--fly-ms, 380ms) linear forwards,
    dfp-boulder-tumble 420ms linear infinite;
}
@keyframes dfp-boulder-tumble {
  from { rotate: 0deg; }
  to { rotate: 360deg; }
}

/* A flying fireball (or any orb-family spell): a glowing molten core with a trailing
   streak swept back along the flight line (the element rotates into --ang space, so
   "left" is always backwards). */
.dfp-projectile.dfp-proj-orb {
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  background: radial-gradient(circle, #fff2d0 10%, var(--fx-color, #ff8c3a) 55%, transparent 80%);
  box-shadow: 0 0 10px 3px var(--fx-color, #ff8c3a);
}
.dfp-projectile.dfp-proj-orb::after {
  content: '';
  position: absolute;
  right: 9px;
  top: 50%;
  width: 20px;
  height: 6px;
  margin-top: -3px;
  border-radius: 3px;
  background: linear-gradient(90deg, transparent 0%, var(--fx-color, #ff8c3a) 100%);
  opacity: 0.7;
}

/* A bolt-family spell (Magic Arrow / Energy Bolt / Lightning etc. in flight): a slim
   tinted energy streak, the arrow's geometry recolored by --fx-color. */
.dfp-projectile.dfp-proj-bolt {
  width: 16px;
  height: 3px;
  margin: -1.5px 0 0 -8px;
  border-radius: 2px;
  background: linear-gradient(90deg, transparent 0%, var(--fx-color, #6fe3ff) 45%, #ffffff 100%);
  box-shadow: 0 0 5px 1px var(--fx-color, #6fe3ff);
}

/* Dragon breath: a gradient cone anchored at the MOB's mouth, rotated toward the
   player (same --ang space as the projectiles) and scaling out over its run — the
   telegraphed blast washing across the gap rather than a discrete missile.
   transform-origin at the left edge keeps the apex pinned at the dragon. */
@keyframes dfp-breath-cone-sweep {
  0% { transform: rotate(var(--ang)) scaleX(0.15); opacity: 0.4; }
  45% { opacity: 0.95; }
  100% { transform: rotate(var(--ang)) scaleX(1); opacity: 0; }
}
.dfp-breath-cone {
  position: absolute;
  width: var(--cone-len, 48px);
  height: 26px;
  margin-top: -13px;
  transform-origin: 0 50%;
  clip-path: polygon(0 42%, 100% 0, 100% 100%, 0 58%);
  background: linear-gradient(90deg, rgba(255, 255, 255, 0.9) 0%, var(--fx-color, #ff7828) 45%, transparent 95%);
  filter: blur(0.5px);
  z-index: 4;
  pointer-events: none;
  animation: dfp-breath-cone-sweep 350ms ease-out forwards;
}

.dungeon-floorplan-status {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #999;
  text-align: center;
  min-height: 14px;
}

/* ...and pinned when the scene IS the screen. In full-screen mode the view is a block with a
   100vh board, so anything after that board sits below the fold — this line was rendering a
   whole viewport height down the page, invisible. Harmless for the dungeon floor plan, whose
   status only ever carried optional crawl hints; NOT harmless for the treasure site, where it
   is the only place "the chest is here, click it" was ever said, so a finished dig looked
   like a dead end.
   DIRECTLY UNDER THE HEADER, not down at the foot of the screen. It was pinned to the bottom
   first, on the reasoning that the header already owned the top — which put the running
   commentary ("You stumble into something hostile!") as far from the thing it describes as
   the screen allows, with the whole map between them. It belongs with the scene's own title:
   one block that says where you are and what is happening, read in one glance.
   The offset is the header's own measured top plus its height (4px padding + ~20px line +
   4px padding + 1px border, rounded to 30) plus 8px of air. Same variable, so the pair moves
   together whenever the crest changes shape. */
body.floorplan-backdrop .dungeon-floorplan-status,
body.floorplan-backdrop .gather-scene-status {
  position: fixed;
  top: calc(var(--fs-scene-header-top, 147px) + 38px);
  left: 50%;
  transform: translateX(-50%);
  z-index: 3;
  max-width: 80vw;
  padding: 4px 12px;
  border-radius: 6px;
  background: rgba(11, 13, 18, 0.72);
  border: 1px solid var(--edge);
  color: #cfc7b4;
  pointer-events: none;
}

/* An empty status must not leave an empty plaque floating over the glade. :empty is exact
   here because every writer sets textContent — there is no stray whitespace node to defeat
   it. */
body.floorplan-backdrop .dungeon-floorplan-status:empty,
body.floorplan-backdrop .gather-scene-status:empty {
  display: none;
}

/* The chest, the instant the last guardian falls (public/js/treasureSite.js's tsSyncChest).
   A short rise-and-settle: it is being uncovered, not dropped in. */
@keyframes ts-chest-appear {
  0% { opacity: 0; transform: translateY(6px) scale(0.8); }
  60% { opacity: 1; transform: translateY(-2px) scale(1.08); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
.dfp-chest.ts-chest-appear {
  animation: ts-chest-appear 520ms ease-out;
}

/* ...and a slow glow for as long as it is waiting to be picked. The camera follows the
   player, so the one thing left to do has to be findable from across the clearing — this is
   what stops a finished dig reading as nothing having happened. Dropped the moment the lock
   springs (tsSyncChest again), because an opened chest is no longer asking. */
@keyframes ts-chest-ready {
  0%, 100% { filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 3px rgba(255, 200, 90, 0.55)); }
  50% { filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5)) drop-shadow(0 0 9px rgba(255, 210, 110, 0.95)); }
}
.dfp-chest.ts-chest-ready {
  animation: ts-chest-ready 1600ms ease-in-out infinite;
}
.dfp-chest.ts-chest-appear.ts-chest-ready {
  /* The reveal plays first, then the glow takes over — one shorthand so the two don't fight
     over the same property. */
  animation: ts-chest-appear 520ms ease-out, ts-chest-ready 1600ms ease-in-out 520ms infinite;
}

/* ============ Main-screen panels / floating layer (public/js/panelDock.js) ============ */

/* In-flow margins would offset a position:fixed panel from its dragged left/top
   (journal/chat carry "margin: 0 30px 30px", the backpack a margin-top) — the id
   selector outguns every standalone class rule regardless of file order. */
#floating-panels-layer > * { margin: 0; }

/* Floated widths for the two panels that are full-width blocks while in flow —
   fixed-position elements shrink-to-fit, so without these their floated size would be
   content-determined and unpredictable. panelDock.js deliberately skips its inline
   width freeze for these two (cssFloatWidth). */
/* ...unless the player has actually dragged that log to a width of their own
   (public/js/resizableLogs.js stamps .log-custom-width once it applies a saved or
   dragged width). The fixed widths above are a sensible DEFAULT for a floated panel, not
   a cap: left in force they would clip a log deliberately dragged wider, since a
   fixed-position panel does not grow to fit an oversized child the way an in-flow
   flex-item version does. Shrink-to-fit hands the decision back to the log. */
.journal-panel.panel-floating.log-custom-width,
.chat-panel.panel-floating.log-custom-width {
  width: fit-content;
}

.journal-panel.panel-floating { width: 520px; }
.chat-panel.panel-floating { width: 420px; }

/* The lift — any gump or floated panel visibly rises while it's being dragged
   (class managed by gumpWindow.js's mousedown/mouseup). */
.gump-dragging {
  box-shadow: 0 22px 52px rgba(0, 0, 0, 0.65), inset 0 1px 0 rgba(255, 255, 255, 0.04);
  user-select: none;
}


/* ==========================================================================================
   GATHER SCENES — the forest/mine/fishing-dock visible-aid views (public/js/gatherScene.js,
   views/partials/worldMap.ejs's #gather-scene-view). The player and ambush-mob elements
   deliberately reuse .dungeon-floorplan-player/.dungeon-floorplan-monster and the dfp-*
   pose/facing/swing classes — one paperdoll system serves both views, so a change to
   those shared rules affects the dungeon AND gathering scenes. Everything below is
   gather-scene-only and gs-/gather- prefixed.
   ========================================================================================== */

.gather-scene-view {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* The treasure-site glade (views/game.ejs, drawn by public/js/treasureSite.js) — the third
   full-screen scene, structurally identical to its two siblings: a flex column of header,
   board and status line, whose board reads its own clientWidth/Height. That sameness is the
   whole reason the two body.floorplan-backdrop rules further down cover all three by simply
   naming them, and why the renderer needed nothing to become full-screen. */
.treasure-site-scene-view {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.treasure-site-scene-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.gather-scene-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}

.gather-scene-title {
  font-weight: 700;
  font-size: calc(15px * var(--ui-font-scale, 1));
  color: var(--accent);
}

/* The scene's Stop button — a proper ember primary (the accent-strong/deep gradient
   recipe every filled button in the game uses), with a soft ember halo so it reads as
   THE control on the dark scene header. All tokens are theme vars, so it re-tints
   under Cold/Mystic/etc. like everything else. */
.gather-scene-stop-btn {
  padding: 5px 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.4px;
  background: linear-gradient(180deg, var(--accent-strong), var(--accent-deep));
  border: 1px solid var(--accent);
  border-radius: 4px;
  color: var(--on-accent);
  white-space: nowrap;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  box-shadow: 0 0 10px rgba(var(--accent-strong-rgb), 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.25);
  text-shadow: 0 1px 0 rgba(255, 255, 255, 0.18);
  transition: box-shadow 0.15s ease-out, background 0.15s ease-out, border-color 0.15s ease-out;
}

.gather-scene-stop-btn:hover {
  background: var(--accent-bright);
  border-color: var(--accent-bright);
  box-shadow: 0 0 16px rgba(var(--accent-strong-rgb), 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.3);
}

.gather-scene-stop-btn:active {
  transform: translateY(1px);
  box-shadow: 0 0 6px rgba(var(--accent-strong-rgb), 0.3);
}

/* --- The arrival prompt (views/partials/gatherPrompt.ejs, driven by public/js/
   gatherScene.js's gsRefreshPrompt) ----------------------------------------------------
   The inverse of the scene above: shown centred on arriving at a mine or the forest while
   that location's own gathering activity ISN'T running.

   Fixed against the VIEWPORT rather than living in the World Map panel the scene sits in —
   that panel is 270px, freely movable, and can be hidden outright, none of which should decide
   whether an arrival prompt appears. The z-index puts it above the page (.game-main is 1)
   and below both floating panels (950+) and modals (3000+): a dragged-out panel or an open
   dialog still covers it, which is the right precedence for a passive offer.

   pointer-events none/auto so the centred box never swallows a click meant for whatever
   sits behind it — only the button itself is a target. */
.gather-prompt {
  position: fixed;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  z-index: 900;
  display: none; /* gsRefreshPrompt sets 'flex' — never '', which would land back here */
  align-items: center;
  justify-content: center;
  pointer-events: none;
}

/* The whole panel is the button (see the partial). Dark glass with an ember edge rather
   than the solid accent-gradient fill .gather-scene-stop-btn uses: this sits over the
   scenery at full screen width, where a solid ember slab reads as a modal demanding an
   answer instead of an offer you can ignore. */
.gather-prompt-btn {
  pointer-events: auto;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 14px 26px 14px 20px;
  background: linear-gradient(180deg, rgba(18, 21, 28, 0.94), rgba(12, 14, 19, 0.94));
  border: 1px solid rgba(var(--accent-rgb), 0.55);
  border-radius: 10px;
  backdrop-filter: blur(3px);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  font: inherit;
  text-align: left;
  /* Breathes so it catches the eye on arrival without the hard flash a colour change
     would give — same reasoning (and reduced-motion opt-out) as .dfp-boss-overlay-ready. */
  animation: gather-prompt-breathe 2.8s ease-in-out infinite;
  transition: border-color 0.15s ease-out, transform 0.1s ease-out;
}

/* Hover kills the loop outright rather than layering on top of it: the keyframes animate
   box-shadow every frame, so a hover box-shadow would simply be overwritten mid-cycle. */
.gather-prompt-btn:hover {
  animation: none;
  border-color: var(--accent-bright);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 26px rgba(var(--accent-strong-rgb), 0.45);
}

.gather-prompt-btn:active {
  transform: translateY(1px);
}

@keyframes gather-prompt-breathe {
  0%, 100% { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 14px rgba(var(--accent-strong-rgb), 0.16); }
  50% { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 24px rgba(var(--accent-strong-rgb), 0.38); }
}

@media (prefers-reduced-motion: reduce) {
  .gather-prompt-btn {
    animation: none;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55), 0 0 18px rgba(var(--accent-strong-rgb), 0.28);
  }
}

.gather-prompt-icon {
  width: 40px;
  height: 40px;
  flex: none;
  image-rendering: pixelated;
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.6));
}

.gather-prompt-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.gather-prompt-label {
  font-size: calc(19px * var(--ui-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.4px;
  color: var(--accent);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}

.gather-prompt-where {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

/* The fixed camera viewport — same recipe as .dungeon-floorplan. For forest/mine a
   world layer slides beneath it; for fishing it hosts the static dock composition. */
.gather-scene {
  position: relative;
  width: 100%;
  height: 460px;
  overflow: hidden;
  background: #0b0b0e;
  border: 1px solid rgba(255, 255, 255, 0.07);
  border-radius: 6px;
  user-select: none;
}

.gather-scene-status {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  min-height: 16px;
}

/* The sliding world layer (forest/mine). Its transform transition MUST match
   .gs-player's left/top duration below — the pinned-camera invariant (see
   dungeonFloorplan.js's own comment on the same pairing). */
.gather-scene-world {
  transition: transform 0.28s linear;
}

/* ================= The ambience pass (public/js/townAmbience.js) =================
   Sky, weather, wildlife and heraldry over the outdoor grid worlds. Two homes:
   world-space children of .gather-scene-world (clouds, fireflies, critters, banners,
   landmarks — they hold their ground as the camera pans; the world's transform makes
   it a stacking context, so their z-indexes stay inside it), and viewport-space
   children of .gather-scene itself (the sky tint, rain, lightning — weather covers
   the fringe too, and never moves with the camera). */

.ta-world-layer {
  position: absolute;
  left: 0;
  top: 0;
  overflow: hidden;
  pointer-events: none;
  z-index: 8000; /* cloud shade falls on props and players alike — above the crowd, below UI */
}

/* One cloud's shadow: a soft dark ellipse the size of a district, drifting on its own
   slow diagonal forever. Blur sells "cast from high up" — a hard edge reads as a stain. */
.ta-cloud {
  position: absolute;
  background: radial-gradient(closest-side, rgba(8, 12, 8, 0.22), rgba(8, 12, 8, 0.12) 55%, transparent 75%);
  filter: blur(6px);
  animation: ta-cloud-drift linear infinite alternate;
}

@keyframes ta-cloud-drift {
  from { transform: translate(-260px, -140px); }
  to { transform: translate(260px, 140px); }
}

/* The sky's colour cast, over everything in the viewport. Long transitions, so dusk
   ARRIVES rather than switching on. Day is deliberately a no-tint baseline — the bake's
   own colours are the daylight version of the world. */
.ta-sky {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 600;
  background: transparent;
  transition: background 8s ease;
}
.ta-sky-dawn { background: linear-gradient(180deg, rgba(255, 190, 130, 0.14), rgba(200, 120, 90, 0.08)); }
.ta-sky-dusk { background: linear-gradient(180deg, rgba(255, 140, 60, 0.12), rgba(110, 40, 90, 0.18)); }
.ta-sky-night { background: rgba(10, 16, 46, 0.36); }

/* Rain: two interleaved streak fields falling at slightly different angles and speeds,
   over a cold veil that does the darkening. Hidden by opacity so a shower can fade in
   like weather instead of toggling like a lamp. */
/* Rain as short DASHES, not lines. A repeating-linear-gradient draws every streak as one
   unbroken diagonal from the top of the screen to the bottom, and a field of those reads
   as scratches on the lens ("too harsh" — the exact live report). Each layer is now one
   short slanted dash inside its own small tile, so the drops have heads, tails and gaps
   in every direction; two tile sizes at two opacities give the depth the long lines
   never had. Softer alphas throughout — weather, not interference. */
.ta-rain {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 610;
  opacity: 0;
  transition: opacity 4s ease;
  background:
    linear-gradient(104deg, transparent 0 47%, rgba(190, 210, 240, 0.10) 48% 51.5%, transparent 52.5% 100%),
    linear-gradient(101deg, transparent 0 48%, rgba(170, 195, 230, 0.06) 49% 51.5%, transparent 52.5% 100%),
    rgba(18, 26, 44, 0.12);
  /* One dash per TILE, so tile area is drop density. Third calibration off live
     screenshots: the first cut filled every 17px column, the second was ~7x sparser and
     still read busy, this one is ~2.5x sparser again with fainter ink and a lighter
     veil — rain you notice rather than rain you watch through. */
  background-size: 72px 140px, 100px 180px, 100% 100%;
  animation: ta-rain-fall 0.9s linear infinite;
}
.ta-rain-on { opacity: 1; }

/* Offsets are whole multiples of each layer's tile height, so the loop is seamless. */
@keyframes ta-rain-fall {
  from { background-position: 0 0, 0 0, 0 0; }
  to { background-position: 0 280px, 0 360px, 0 0; }
}

/* Lightning — one frame of white, gone before the eye finishes flinching. */
.ta-flash {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 620;
  background: rgba(240, 245, 255, 0.55);
  opacity: 0;
  transition: opacity 0.3s ease-out;
}
.ta-flash-on {
  opacity: 1;
  transition: opacity 0.04s ease-in;
}

/* A firefly: a mote of green-gold that blinks as it wanders. The wander vector is the
   spawner's per-fly CSS vars, so no two trace the same loop. */
.ta-firefly {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: #d8f0a0;
  box-shadow: 0 0 6px 2px rgba(200, 240, 140, 0.7);
  pointer-events: none;
  z-index: 8100;
  animation: ta-firefly-wander ease-in-out infinite alternate;
}

@keyframes ta-firefly-wander {
  0% { transform: translate(0, 0); opacity: 0; }
  25% { opacity: 1; }
  45% { opacity: 0.2; }
  65% { opacity: 1; }
  100% { transform: translate(var(--ta-wx, 40px), var(--ta-wy, -20px)); opacity: 0; }
}

/* A leaf off the treeline, falling in lazy swings. */
.ta-leaf {
  position: absolute;
  width: 5px;
  height: 3px;
  border-radius: 60% 40% 60% 40%;
  pointer-events: none;
  z-index: 8050;
  animation: ta-leaf-fall linear forwards;
}

@keyframes ta-leaf-fall {
  0% { transform: translate(0, 0) rotate(0deg); opacity: 0; }
  10% { opacity: 0.9; }
  30% { transform: translate(14px, 34px) rotate(140deg); }
  60% { transform: translate(-10px, 72px) rotate(260deg); }
  90% { opacity: 0.8; }
  100% { transform: translate(8px, 110px) rotate(400deg); opacity: 0; }
}

/* Wildlife. Hops are left/top transitions, so a critter GLIDES its cell-to-cell moves
   the same way the walking figures do rather than teleporting. */
.ta-critter {
  position: absolute;
  width: 28px;
  image-rendering: pixelated;
  pointer-events: none;
  transition: left 1.1s ease, top 1.1s ease;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.35));
}
.ta-critter-fly { animation: ta-critter-fly 1.2s ease-in forwards; }
.ta-critter-bolt { animation: ta-critter-bolt 1s ease-in forwards; }

@keyframes ta-critter-fly {
  to { transform: translate(90px, -160px) scale(0.6); opacity: 0; }
}
@keyframes ta-critter-bolt {
  to { transform: translate(130px, 30px) scaleX(-1); opacity: 0; }
}

/* Gate heraldry: a pole with the town's pennant, rippling. The cloth pivots at the
   hoist so the fly end swings — skew keyframes read as wind at this size. */
.ta-banner {
  position: absolute;
  transform: translate(-50%, -100%);
  pointer-events: none;
  width: 4px;
  height: 68px;
}
.ta-banner-pole {
  position: absolute;
  left: 0;
  top: 0;
  width: 3px;
  height: 68px;
  background: linear-gradient(90deg, #4a3a26, #2a2016);
  border-radius: 1px;
}
.ta-banner-cloth {
  position: absolute;
  left: 3px;
  top: 3px;
  width: 27px;
  height: 16px;
  clip-path: polygon(0 0, 100% 0, 82% 50%, 100% 100%, 0 100%);
  transform-origin: left center;
  animation: ta-banner-wave 2.6s ease-in-out infinite;
  box-shadow: inset 0 -2px 3px rgba(0, 0, 0, 0.25);
}

@keyframes ta-banner-wave {
  0%, 100% { transform: skewY(-3deg) scaleX(1); }
  50% { transform: skewY(3.5deg) scaleX(0.94); }
}

/* Thrown light — the gate braziers and the landmark's moon-halo. Dark-hours only
   (.ta-glow-lit, toggled by the phase tick); braziers breathe on top of it. */
.ta-brazier-glow,
.ta-landmark-glow {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 96px;
  height: 72px;
  pointer-events: none;
  border-radius: 50%;
  opacity: 0;
  transition: opacity 4s ease;
}
.ta-brazier-glow { background: radial-gradient(closest-side, rgba(255, 170, 70, 0.34), rgba(255, 130, 40, 0.12) 60%, transparent); }
.ta-landmark-glow {
  width: 120px;
  height: 90px;
  background: radial-gradient(closest-side, rgba(200, 215, 255, 0.16), transparent 70%);
}
.ta-brazier-glow.ta-glow-lit {
  opacity: 1;
  animation: ta-brazier-breathe 2.8s ease-in-out infinite;
}
.ta-landmark-glow.ta-glow-lit { opacity: 1; }

@keyframes ta-brazier-breathe {
  0%, 100% { transform: translate(-50%, -50%) scale(1); }
  50% { transform: translate(-50%, -50%) scale(1.12); }
}

/* The monuments. Feet-anchored like every standing thing in the world. */
.ta-landmark {
  position: absolute;
  transform: translate(-50%, -100%);
  image-rendering: pixelated;
  pointer-events: none;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.4));
}
.ta-landmark-statue { width: 80px; }
.ta-landmark-obelisk { width: 56px; }
.ta-landmark-great_oak { width: 144px; }
.ta-landmark-well { width: 80px; }
.ta-landmark-anchor { width: 72px; }

/* A courtyard bench — a SEAT, so unlike nearly everything else the ambience mounts it
   takes the pointer: click it and you walk over and sit (public/js/townAmbience.js's
   bench handler + gatherScene's taSeatAt case). */
.ta-bench {
  position: absolute;
  transform: translate(-50%, -100%);
  width: 34px;
  image-rendering: pixelated;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.35));
}

/* The gatehouse — a wrapper clipping the portcullis's travel, feet-anchored past the wall
   courses so painter's order has a player in the passage rendering BEHIND it: walking
   under the iron. The arch masonry sits OVER the portcullis inside, so the piers mask the
   gate's edges as it moves. */
.ta-gatehouse {
  position: absolute;
  transform: translate(-50%, -100%);
  width: 96px;
  height: 66px;
  overflow: hidden;
  pointer-events: none;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.45));
}
.ta-arch-img {
  position: absolute;
  left: 0;
  top: 0;
  width: 96px;
  image-rendering: pixelated;
}
/* The iron keeps hours: raised through the day (teeth just showing under the crown),
   cranked to the ground for the night. Sixteen seconds either way — slow enough to watch
   happen if you are standing at the gate when the light turns. .ta-gate-snap suppresses
   the transition for the first beat after mount, so a page opened at midnight shows a
   gate that has been down for hours rather than one hurriedly descending. */
.ta-portcullis {
  position: absolute;
  left: 14px;
  top: 0;
  width: 68px;
  image-rendering: pixelated;
  transform: translateY(-24px);
  transition: transform 16s ease-in-out;
}
.ta-gate-down .ta-portcullis { transform: translateY(12px); }
.ta-gate-snap .ta-portcullis { transition: none; }

/* Wall torches: an iron sconce with a live flame, its glow (a smaller sibling of the
   braziers') thrown only after dusk. */
.ta-torch {
  position: absolute;
  transform: translate(-50%, -100%);
  width: 4px;
  height: 12px;
  background: linear-gradient(180deg, #3a3128, #201a12);
  border-radius: 1px;
  pointer-events: none;
}
.ta-torch-flame {
  position: absolute;
  left: -2px;
  top: -6px;
  width: 8px;
  height: 8px;
  border-radius: 50% 50% 40% 40%;
  background: radial-gradient(circle at 50% 70%, #ffdf8a, #f0a04a 55%, #c9502a 90%);
  animation: ta-torch-flicker 0.4s ease-in-out infinite alternate;
}
.ta-torch-glow {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 60px;
  height: 46px;
  pointer-events: none;
  border-radius: 50%;
  opacity: 0;
  transition: opacity 4s ease;
  background: radial-gradient(closest-side, rgba(255, 170, 70, 0.26), transparent 70%);
}
.ta-torch-glow.ta-glow-lit { opacity: 1; }

@keyframes ta-torch-flicker {
  from { transform: scale(1) translateY(0); opacity: 0.92; }
  to { transform: scale(1.15, 0.9) translateY(-1px); opacity: 1; }
}

/* A raindrop landing: a ring that grows and dies inside half a second. */
.ta-splash {
  position: absolute;
  width: 6px;
  height: 4px;
  border: 1px solid rgba(200, 220, 245, 0.55);
  border-radius: 50%;
  pointer-events: none;
  z-index: 8060;
  animation: ta-splash-ring 0.5s ease-out forwards;
}

@keyframes ta-splash-ring {
  from { transform: scale(0.3); opacity: 0.9; }
  to { transform: scale(1.6); opacity: 0; }
}

/* A storm leans on everything: denser, more slanted rain, a darker veil, and the gate
   pennants whipping at twice their easy ripple. */
.ta-rain-heavy {
  background:
    linear-gradient(107deg, transparent 0 45%, rgba(190, 210, 240, 0.15) 46% 52%, transparent 53% 100%),
    linear-gradient(103deg, transparent 0 47%, rgba(170, 195, 230, 0.10) 48% 51.5%, transparent 52.5% 100%),
    rgba(14, 20, 36, 0.19);
  background-size: 50px 112px, 70px 148px, 100% 100%;
  animation-name: ta-rain-fall-heavy;
  animation-duration: 0.65s;
}

@keyframes ta-rain-fall-heavy {
  from { background-position: 0 0, 0 0, 0 0; }
  to { background-position: 0 224px, 0 296px, 0 0; }
}
.ta-storm .ta-banner-cloth { animation-duration: 1.1s; }

/* --- The fringe (gatherScene.js's gsBuildFringe) — fake map past the playable edge.

   The far field is pinned to the VIEWPORT, not the world: it fills the board at any
   size, and gsUpdateCamera scrolls its repeating tile by the same offset the world
   translates, so it reads as continuous ground while costing one viewport-sized layer.
   Its transition matches .gather-scene-world's transform above — the two must glide
   together on a walk step or the ground would visibly slide against itself. */
.gs-fringe-far {
  position: absolute;
  inset: 0;
  background-repeat: repeat;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: 0;
  transition: background-position 0.28s linear;
}

/* The dungeon's own fringe (dungeonFloorplan.js's dfpBuildFringe) — same viewport-pinned,
   background-scrolled arrangement as .gs-fringe-far above, but one layer instead of two:
   every dungeon floorplan's border is solid wall, so the fringe just continues that rock
   and there is no treeline to blend.

   The 0.35s here is only the untrained default: the dungeon's real step time is
   Tracking-scaled (dfpEffectiveStepMs, 350ms down to 233ms at GM) and set INLINE on the
   player, the world and this element together on every step — see dfpStepAlongPath's
   three-way note. A fixed duration here alone would slide the rock against the floor for
   anyone who has trained Tracking at all. */
.dfp-fringe-far {
  position: absolute;
  inset: 0;
  background-repeat: repeat;
  image-rendering: pixelated;
  pointer-events: none;
  transition: background-position 0.35s linear;
}

/* The near ring IS a child of the world (it must sit in exact cell register with the
   real ground). Negative z-index because the bake canvas inserts itself as firstChild
   whenever its own async build lands — DOM order between the two is a race, so depth
   must not depend on it. The world's transform makes it a stacking context, so negative
   z still paints above the far field below, just under every z:0+ sibling. */
.gs-fringe-ring {
  position: absolute;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: -1;
}

/* Gathering walks are a work amble, a touch brisker than the dungeon prowl — and this
   duration pairs with .gather-scene-world's transform above. */
.dungeon-floorplan-player.gs-player {
  transition: left 0.28s linear, top 0.28s linear;
}

/* --- Harvest nodes --------------------------------------------------------------------- */

/* An ore vein: 32px, bottom-anchored on its cell. */
.gs-node {
  position: absolute;
  width: 32px;
  height: 32px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center bottom;
  image-rendering: pixelated;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.45));
  z-index: 1;
}

/* A harvest tree is TALL (32x48 sprite) — feet on the cell, canopy overlapping the
   tile above, same proportion trick as the player sprite. */
.gs-node-tree {
  height: 48px;
  margin-top: -16px;
}

/* Ore veins are TALL too now (tools/generateFloorplanProps.js's drawOreNode moved to
   the 32x48 canvas) — same overlap treatment as the trees, so a vein looms over the
   floor as a proper outcrop instead of a knee-high pebble. This class was applied by
   gsSpawnNodes from day one but had no rule until the sheets grew. */
.gs-node-ore {
  height: 48px;
  margin-top: -16px;
}

/* Top-tier veins twinkle (gatherScene.js's gsApplySparkle — golden and up, mines
   only): two tiny star points in the tier color blinking on alternating beats over the
   crystal cluster. Pseudo-element so no extra DOM per node; cleared with the class
   while the node sits as rubble. */
@keyframes gs-node-sparkle {
  0%, 100% { opacity: 0.2; transform: scale(0.7); }
  50% { opacity: 1; transform: scale(1.15); }
}
.gs-node-sparkle::after {
  content: '';
  position: absolute;
  left: 15px;
  top: 15px;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: var(--gs-tier-color, #f0d878);
  box-shadow:
    0 0 4px 1px var(--gs-tier-color, #f0d878),
    -8px 12px 0 -1px var(--gs-tier-color, #f0d878),
    -8px 12px 4px 0 var(--gs-tier-color, #f0d878);
  animation: gs-node-sparkle 1.9s ease-in-out infinite;
  pointer-events: none;
}

/* Rattled by each chop/mine swing (gatherScene.js's gsSwingAtNode). */
@keyframes gs-node-shake {
  0% { transform: translateX(0) rotate(0deg); }
  25% { transform: translateX(-2px) rotate(-2deg); }
  55% { transform: translateX(2px) rotate(1.5deg); }
  100% { transform: translateX(0) rotate(0deg); }
}
.gs-node.gs-node-shake {
  animation: gs-node-shake 260ms ease-out;
}

/* The final swing's payoff — the node glows in its own tier color for a beat. */
@keyframes gs-node-glint {
  0% { filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.45)); }
  40% { filter: drop-shadow(0 0 9px var(--gs-tier-color, #d8dbe6)); }
  100% { filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.45)); }
}
.gs-node.gs-node-glint {
  animation: gs-node-glint 700ms ease-out;
}

/* A felled harvest tree topples off to the side and fades, leaving its stump behind
   (gatherScene.js's gsDepleteNode). --gs-fall-dir flips which way it drops. */
@keyframes gs-tree-fall {
  0% { transform: rotate(0deg) translateY(0); opacity: 1; }
  70% { opacity: 1; }
  100% { transform: rotate(calc(var(--gs-fall-dir, 1) * 82deg)) translateY(7px); opacity: 0; }
}
.gs-tree-fall {
  transform-origin: 50% 100%; /* pivot at the trunk base */
  animation: gs-tree-fall 850ms cubic-bezier(0.45, 0, 0.85, 0.5) forwards;
  z-index: 2;
  pointer-events: none;
}

/* A mined-out vein bursts a few tier-colored shards (gsDepleteNode) — each flies to its
   own (--sx,--sy) then keeps falling under gravity as it shrinks and fades. */
@keyframes gs-ore-shard {
  0% { transform: translate(0, 0) scale(1); opacity: 1; }
  30% { opacity: 1; }
  100% { transform: translate(var(--sx, 0), calc(var(--sy, 0) + 26px)) scale(0.5); opacity: 0; }
}
.gs-ore-shard {
  position: absolute;
  width: 4px;
  height: 4px;
  margin: 18px 0 0 14px; /* start near the vein's own center within the cell */
  border-radius: 1px;
  image-rendering: pixelated;
  box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
  animation: gs-ore-shard 720ms ease-out forwards;
  z-index: 2;
  pointer-events: none;
}

/* A spent node growing back after its regrow delay — a quick spring up from the base. */
@keyframes gs-node-regrow {
  0% { transform: scaleY(0.14) scaleX(0.66); opacity: 0.55; }
  60% { transform: scaleY(1.08) scaleX(0.97); opacity: 1; }
  100% { transform: scaleY(1) scaleX(1); opacity: 1; }
}
.gs-node.gs-node-regrow {
  transform-origin: 50% 100%;
  animation: gs-node-regrow 480ms cubic-bezier(0.3, 1.3, 0.5, 1);
}

/* The floating yield line ("+2 Goldwood") — tinted per tier via --gs-tier-color. */
@keyframes gs-popup-float {
  0% { opacity: 0; transform: translate(-50%, 0); }
  18% { opacity: 1; }
  100% { opacity: 0; transform: translate(-50%, -30px); }
}
.gs-gather-popup {
  position: absolute;
  left: 50%;
  top: -6px;
  transform: translate(-50%, 0);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 700;
  white-space: nowrap;
  color: var(--gs-tier-color, #d8dbe6);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 6px rgba(0, 0, 0, 0.6);
  pointer-events: none;
  animation: gs-popup-float 1200ms ease-out forwards;
  z-index: 5;
}

/* A sprite facing left is mirrored with `scale: -1 1` (.dfp-face-left) — and any popup
   spawned INSIDE it (damage numbers, "Miss", the gather yields above) inherits the
   mirror and reads backwards. The counter-flip lives on the popup's INNER text span
   (spawnTextPopup/gsSpawnGatherPopup both build one), which flips about its own
   center — glyphs read upright again while the popup box's position and float
   animation are untouched. (Counter-scaling the popup element itself instead would
   mirror its translate(-50%) centering and shove it off to the side.) Covers the
   gather scenes AND the dungeon floorplan, whose player/mob elements share the
   facing class. */
.popup-flip-inner {
  display: inline-block;
}
.dfp-face-left .popup-flip-inner {
  scale: -1 1;
}
/* Pets mirror with their OWN class (.dfp-pet-left composes the flip with --pet-scale,
   see .dfp-pet) — a harvesting pet's fetch-return popup is hosted inside the pet
   element, so it needs the same counter-flip or "+2 Iron Ore" reads backwards on
   every westward homecoming. */
.dfp-pet-left .popup-flip-inner {
  scale: -1 1;
}

/* ============ Weapon-special strike FX on the floorplan/gather-scene mobs ============
   Driven by public/js/dungeonFloorplan.js's dfpSpecialFx on a combat:tick's specialProc
   (config/weaponProcs.js). Self-contained centering on the small (~32px) map sprite —
   NOT combat.js's .fx-anchor, which is pinned to the big combat-gump sprite's ~82px
   center. The outer is a zero-size point at the sprite center; the inner's box centers on
   it (translate baked into each keyframe so the animation is free to scale/rotate). */
.dfp-special-fx {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 0;
  height: 0;
  pointer-events: none;
  z-index: 6;
}
.dfp-special-fx-inner {
  position: absolute;
  left: 0;
  top: 0;
}

/* Execute — a bright expanding shockwave ring with a hot core flash: the killing blow. */
.dfp-fx-execute .dfp-special-fx-inner {
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 3px solid #ff7a3a;
  box-shadow: 0 0 12px 4px rgba(255, 120, 60, 0.8), inset 0 0 10px rgba(255, 200, 120, 0.6);
  animation: dfp-execute-burst 500ms ease-out forwards;
}
.dfp-fx-execute .dfp-special-fx-inner::before {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 30px;
  height: 30px;
  margin: -15px 0 0 -15px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 240, 200, 0.95), rgba(255, 120, 60, 0) 70%);
  animation: dfp-flash-core 380ms ease-out forwards;
}
@keyframes dfp-execute-burst {
  0% { transform: translate(-50%, -50%) scale(0.2); opacity: 1; }
  60% { opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(1.7); opacity: 0; }
}
@keyframes dfp-flash-core {
  0% { transform: scale(0.6); opacity: 1; }
  100% { transform: scale(1.5); opacity: 0; }
}

/* Bleed — a wet crimson splatter blooming out from the wound. */
.dfp-fx-bleed .dfp-special-fx-inner {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(192, 57, 43, 0.9) 0%, rgba(125, 33, 26, 0.5) 45%, rgba(125, 33, 26, 0) 70%);
  animation: dfp-bleed-splat 520ms ease-out forwards;
}
@keyframes dfp-bleed-splat {
  0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0.95; }
  100% { transform: translate(-50%, -50%) scale(1.5); opacity: 0; }
}

/* Peace broken (services/barding.js's rollPeaceBreak) — a calm SHATTERING rather than an
   effect landing, so it reads as a ring flying apart outward: no soft bloom, a hard
   two-tone edge snapping open fast. Alarm-red to sit apart from the yellow stun ring and
   the crimson bleed, both of which are things you did TO the monster. */
/* The Explosion Potion's blast (public/js/dungeonFloorplan.js). Bigger and slower than the
   peace-shatter ring below it — a bomb the player armed five seconds ago and then watched
   count down has earned a louder payoff than an incidental proc. */
.dfp-fx-bomb .dfp-special-fx-inner {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  border: 4px solid var(--fx-color, #b16cff);
  box-shadow: 0 0 18px 6px rgba(177, 108, 255, 0.7), inset 0 0 14px rgba(255, 240, 200, 0.65);
  animation: dfp-bomb-blast 520ms cubic-bezier(0.1, 0.75, 0.25, 1) forwards;
}
@keyframes dfp-bomb-blast {
  0% { transform: translate(-50%, -50%) scale(0.2); opacity: 1; border-width: 7px; }
  55% { opacity: 0.9; }
  100% { transform: translate(-50%, -50%) scale(1.9); opacity: 0; border-width: 1px; }
}

/* The thrown potion lying on the floor at the monster's feet while its fuse burns down,
   with the remaining seconds floating above it (public/js/dungeonFloorplan.js).

   A column: count on top, flask beneath. Anchored at the mob's cell centre and pulled DOWN
   past it, so the flask sits at the feet rather than over the sprite's middle — a bomb
   hovering at chest height reads as a status icon, not as an object on the ground. */
.dfp-bomb-fuse {
  position: absolute;
  transform: translate(-50%, 2px);
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1px;
  pointer-events: none;
}

/* The real potion sprite, not a drawn glyph — the same object the player threw. Sits in a
   pulsing violet glow so it stays findable against a dark dungeon floor and reads as armed
   rather than as dropped loot. */
.dfp-bomb-flask {
  width: 16px;
  height: 16px;
  image-rendering: pixelated;
  filter: drop-shadow(0 0 4px rgba(177, 108, 255, 0.95));
  animation: dfp-bomb-pulse 1s ease-in-out infinite;
}

/* The number IS the mechanic — a player reads it to decide whether to keep swinging or
   back off — so it gets a solid plate behind it rather than sitting loose over floor art. */
.dfp-bomb-count {
  min-width: 14px;
  padding: 0 4px;
  border-radius: 7px;
  background: rgba(18, 10, 28, 0.9);
  border: 1px solid #b16cff;
  color: #e6d2ff;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  line-height: 1.4;
  text-align: center;
  text-shadow: 0 0 4px rgba(177, 108, 255, 0.8);
}

@keyframes dfp-bomb-pulse {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(177, 108, 255, 0.6)); }
  50% { filter: drop-shadow(0 0 8px rgba(177, 108, 255, 1)); }
}

.dfp-fx-peacebreak .dfp-special-fx-inner {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border: 3px solid #ff6b5e;
  box-shadow: 0 0 12px 3px rgba(255, 107, 94, 0.65), inset 0 0 8px rgba(255, 107, 94, 0.5);
  animation: dfp-peace-shatter 460ms cubic-bezier(0.15, 0.8, 0.3, 1) forwards;
}
@keyframes dfp-peace-shatter {
  0% { transform: translate(-50%, -50%) scale(0.25); opacity: 1; border-width: 5px; }
  60% { opacity: 0.85; }
  100% { transform: translate(-50%, -50%) scale(1.9); opacity: 0; border-width: 1px; }
}

/* Paralyze — a jolting dashed ring that spins out, reads as a dizzy stun. */
.dfp-fx-paralyze .dfp-special-fx-inner {
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 3px dashed #ffe06a;
  box-shadow: 0 0 10px 2px rgba(255, 224, 106, 0.7);
  animation: dfp-stun-ring 600ms ease-out forwards;
}
@keyframes dfp-stun-ring {
  0% { transform: translate(-50%, -50%) scale(0.4) rotate(0deg); opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(1.4) rotate(200deg); opacity: 0; }
}

/* Monster spell attack — instant cast (config/monsterSpellAttacks.js): a compact arcane
   burst over the PLAYER sprite, the one fx kind that targets the player rather than the
   mob, since this damage flows the other direction. Tinted per spell via --fx-color now
   that the payload carries the spellKey; falls back to the original arcane purple.
   Keyframe renamed -pop: this block's original same-named @keyframes silently shadowed
   the player-cast burst ring's own further up the file. */
.dfp-fx-spell .dfp-special-fx-inner {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fx-color, #a855f7) 0%, transparent 70%);
  box-shadow: 0 0 12px 3px var(--fx-color, #a855f7);
  animation: dfp-fx-spell-pop 480ms ease-out forwards;
}
@keyframes dfp-fx-spell-pop {
  0% { transform: translate(-50%, -50%) scale(0.3); opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(1.6); opacity: 0; }
}

/* Dragon breath (Fire or Frost) — a bigger, hotter blast for the telegraphed hit landing,
   also over the player sprite. Tinted per element via --fx-color (Fire Breath orange /
   Frost Breath ice-blue — public/js/dungeonFloorplan.js passes it from the payload's
   element field). */
.dfp-fx-breath .dfp-special-fx-inner {
  width: 46px;
  height: 46px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fx-color, #ff7828) 0%, transparent 70%);
  box-shadow: 0 0 18px 6px var(--fx-color, #ff7828);
  animation: dfp-breath-burst 650ms ease-out forwards;
}
@keyframes dfp-breath-burst {
  0% { transform: translate(-50%, -50%) scale(0.2); opacity: 1; }
  60% { opacity: 1; }
  100% { transform: translate(-50%, -50%) scale(2); opacity: 0; }
}

/* A hurled boulder landing on the player (config/monsterRockThrows.js) — a dusty stone
   impact rather than a glowing magical one: no bloom/box-shadow halo like the spell and
   breath bursts above, just a hard grey shock that punches out and fades, so a physical
   rock never reads as a spell effect. */
.dfp-fx-rock .dfp-special-fx-inner {
  width: 42px;
  height: 42px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--fx-color, #b9ada0) 0%, rgba(120, 110, 98, 0.45) 45%, transparent 72%);
  animation: dfp-fx-rock-impact 520ms ease-out forwards;
}
@keyframes dfp-fx-rock-impact {
  0% { transform: translate(-50%, -50%) scale(0.25); opacity: 1; }
  45% { opacity: 0.95; }
  100% { transform: translate(-50%, -50%) scale(1.75); opacity: 0; }
}

/* The lingering "blood debuff marker above their heads" — the blood-drop sprite bobbing
   over a bleeding mob for the wound's duration (dfpAddBloodMarker). */
.dfp-blood-marker {
  position: absolute;
  left: 50%;
  top: -12px;
  width: 14px;
  height: 14px;
  margin-left: -7px;
  background: url('/assets/sprites/items/debuff_bleed.png') center / contain no-repeat;
  pointer-events: none;
  z-index: 6;
  filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.85));
  animation: dfp-blood-bob 1.2s ease-in-out infinite;
}
@keyframes dfp-blood-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-2px); }
}

/* Stun marker (Mace paralyze) — the blood marker's twin, but wobbling side to side
   rather than bobbing, so the two afflictions stay tellable apart at 14px even before
   the icons themselves resolve. */
.dfp-stun-marker {
  position: absolute;
  left: 50%;
  top: -13px;
  width: 16px;
  height: 16px;
  margin-left: -8px;
  background: url('/assets/sprites/items/debuff_stunned.png') center / contain no-repeat;
  pointer-events: none;
  z-index: 6;
  filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.85));
  animation: dfp-stun-wobble 0.9s ease-in-out infinite;
}
@keyframes dfp-stun-wobble {
  0%, 100% { transform: rotate(-9deg); }
  50% { transform: rotate(9deg); }
}

/* --- Blood pools (public/js/corpseGround.js's cgSpawnPools — shared, every screen) ------
   The pool a kill leaves on the floor. z-index 0: the same plane as the baked floor
   canvas but a later DOM sibling, so it paints over the tiles and under mobs (z1) and
   the player (z2). Size/position/sprite/flip are set inline per pool — this class owns
   only the shared plumbing and the splat-in entrance. image-rendering matches the
   board's own pixel-art scaling so the sprite stays crisp at 48px. */
.dfp-blood-pool {
  position: absolute;
  background-size: 100% 100%;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: 0;
  /* `backwards`, NOT `both`: the base style already matches the final frame (opacity 1,
     scale 1), and a `both` fill would keep the keyframe's opacity applied forever —
     animations outrank class declarations in the cascade, so the fade class below
     would never win. */
  animation: dfp-blood-pool-in 0.45s cubic-bezier(0.2, 1.6, 0.4, 1) backwards;
  transition: opacity 4s ease; /* corpseGround.js's CG_FADE_MS — the slow soak-away */
}
/* Lands fast and slightly overshoots — liquid hitting stone, not a decal switching on. */
@keyframes dfp-blood-pool-in {
  0% { opacity: 0; scale: 0.15; }
  60% { opacity: 1; }
  100% { opacity: 1; scale: 1; }
}
.dfp-blood-pool-fade {
  opacity: 0;
}

/* The spray itself — one droplet of the burst, flung along its own --bx/--by vector
   (the spawnImpactSparks custom-prop idiom). Falls slightly at the end of its arc and
   vanishes; the pools are what persist, the spray is pure moment. z4 = the projectile
   layer, over the corpse it's spraying off. */
.dfp-blood-droplet {
  position: absolute;
  background: #a01818;
  border-radius: 1px;
  pointer-events: none;
  z-index: 4;
  animation: dfp-blood-droplet-fly 0.55s cubic-bezier(0.2, 0.7, 0.6, 1) both;
}
@keyframes dfp-blood-droplet-fly {
  0% { transform: translate(0, 0); opacity: 1; }
  70% { opacity: 1; }
  100% { transform: translate(var(--bx), calc(var(--by) + 14px)); opacity: 0; }
}

/* --- The kill gore (public/js/corpseGround.js's cgGoreBurst — shared, everyone sees it) ---
   The heavy droplet of a death spray: same custom-prop flight as .dfp-blood-droplet but
   longer-lived and dragged much harder by gravity at the end of its arc, so a kill reads
   as a gout, not a spark burst. Size and shade arrive inline per droplet. */
.cg-gore-drop {
  position: absolute;
  background: #a01818;
  border-radius: 1px;
  pointer-events: none;
  z-index: 4;
  animation: cg-gore-drop-fly 0.75s cubic-bezier(0.25, 0.6, 0.6, 1) both;
}
@keyframes cg-gore-drop-fly {
  0% { transform: translate(0, 0); opacity: 1; }
  60% { transform: translate(var(--bx), var(--by)); opacity: 1; }
  100% { transform: translate(var(--bx), calc(var(--by) + 26px)); opacity: 0; }
}

/* The red mist of the moment itself — a brief crimson bloom swallowing the falling body. */
.cg-gore-flash {
  position: absolute;
  width: 44px;
  height: 44px;
  background: radial-gradient(circle at center, rgba(190, 24, 24, 0.85) 0%, transparent 65%);
  animation: cg-gore-flash-pop 450ms ease-out forwards;
  pointer-events: none;
  z-index: 4;
}
@keyframes cg-gore-flash-pop {
  0% { transform: scale(0.4); opacity: 0.9; }
  100% { transform: scale(1.6); opacity: 0; }
}

/* A dark shockwave of blood rushing outward across the stone at the corpse's feet —
   the ground-level counterpart of the mist above, under everything alive (z0 like the
   pools it precedes). */
.cg-gore-ring {
  position: absolute;
  width: 26px;
  height: 12px; /* squashed — spreading ON the floor, not hooping through the air */
  border: 2px solid #7c1010;
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: cg-gore-ring-pop 600ms ease-out forwards;
  pointer-events: none;
  z-index: 0;
}
@keyframes cg-gore-ring-pop {
  0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0.85; }
  100% { transform: translate(-50%, -50%) scale(2.3); opacity: 0; }
}

/* One drip off a bleeding mob (the Fencing DoT ticking) — falls straight down and
   soaks away, quieter than any of the kill gore above: a wound, not a death. */
.cg-blood-drip {
  position: absolute;
  width: 2px;
  height: 4px;
  background: #a01818;
  border-radius: 0 0 1px 1px;
  animation: cg-blood-drip-fall 700ms ease-in both;
  pointer-events: none;
  z-index: 4;
}
@keyframes cg-blood-drip-fall {
  0% { transform: translateY(0); opacity: 0.95; }
  100% { transform: translateY(14px); opacity: 0; }
}

/* --- Corpses on the floor (public/js/corpseGround.js, services/corpses.js) ---------------
   A body lies where it fell for five minutes, becomes a skeleton for ten, then fades.
   Shared by the dungeon floor plan and the gathering scenes, which is why these are
   cg- rather than dfp- prefixed.

   z-index 0 puts them on the same plane as the blood pools above — over the baked floor
   canvas, under every living thing (dungeon mobs z1 and player z2; the gathering scenes
   start their own row-ordered stack at GS_Z_BASE 10). Within that plane the corpse wins on
   DOM order, which is the right way round and the order it actually happens in: the pool is
   laid down by the killing blow, the body arrives from the server a beat later. */
.cg-corpse {
  position: absolute;
  background-size: 100% 100%;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  pointer-events: none;
  z-index: 0;
  transform-origin: center center;
  /* The fade-away at the end of the fifteen minutes. Same 4s soak as .dfp-blood-pool's, so
     a skeleton and the stain under it disappear as one thing rather than two. */
  transition: opacity 4s ease;
  /* Settles in rather than popping: the mob sprite is still fading out of the same tile
     when this lands (dungeonFloorplan.js holds the dying mob 650ms), and a hard cut between
     the two reads as the creature being replaced instead of dropping. */
  animation: cg-corpse-settle 0.5s ease-out backwards;
}
@keyframes cg-corpse-settle {
  0% { opacity: 0; }
  100% { opacity: 1; }
}

/* The body itself — the creature's own sprite (or the player's paperdoll) tipped onto its
   side by an inline rotate. Drained rather than recoloured: brightness alone reads as
   shadow, and it is dropping the saturation that makes the thing look dead.

   These two numbers were measured against the real floor tint (#26262a) across four
   creatures, not picked by eye. Darker — 0.5 was the first try — and a Lich or a Troll
   simply vanishes into the stone, which defeats the entire point of leaving a body behind;
   lighter than about 0.85 and it is not visibly deader than the living one two tiles away. */
.cg-corpse-body {
  filter: brightness(0.75) saturate(0.65);
}

/* Picked clean. No filter — the sprite is already bone white and dimming it would lose the
   one thing that makes a skeleton legible against a dark dungeon floor. */
.cg-corpse-bones {
  opacity: 0.92;
}

.cg-corpse-gone {
  opacity: 0;
}

/* One session per account (public/js/sessionKick.js). A full-screen takeover rather than a
   toast: everything behind it is stale, the socket is gone for good, and a dismissible
   message over a frozen game invites clicking a screen that cannot answer. Above the modal
   band (3000) and the panel-visibility menu (2400) — this is the one message that must never
   be covered, since nothing underneath it works any more. */
.session-replaced-overlay {
  position: fixed;
  inset: 0;
  z-index: 30000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(8, 9, 13, 0.88);
  backdrop-filter: blur(3px);
}
.session-replaced-box {
  max-width: 420px;
  padding: 26px 30px;
  text-align: center;
  background: linear-gradient(180deg, rgba(20, 23, 30, 0.98), rgba(13, 15, 20, 0.98));
  border: 1px solid rgba(var(--accent-rgb), 0.5);
  border-radius: 10px;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
.session-replaced-title {
  font-size: calc(17px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.session-replaced-message { margin-top: 12px; font-size: calc(13px * var(--ui-font-scale, 1)); line-height: 1.5; }
.session-replaced-hint { margin-top: 10px; font-size: calc(11px * var(--ui-font-scale, 1)); color: var(--muted); line-height: 1.45; }
.session-replaced-btn {
  margin-top: 18px;
  padding: 9px 26px;
  font: inherit;
  font-weight: bold;
  color: #16181d;
  background: linear-gradient(180deg, var(--accent), rgba(var(--accent-rgb), 0.82));
  border: none;
  border-radius: 6px;
  cursor: pointer;
}
.session-replaced-btn:hover { filter: brightness(1.1); }

/* Nodes take their pointer events back from the world layer.

   The gather world shares .dungeon-floorplan-world, whose own comment says why it is
   pointer-events: none — "nothing inside is interactive" — and that was true right up until
   manual mode made the veins clickable. Nobody told this rule: hit-testing skipped every node,
   a click on one resolved to the board underneath, the delegated handler's closest('.gs-node')
   found nothing, and the fall-through walk targeted the node's own unwalkable cell — a silent
   no-op. Floor clicks worked the whole time, because they pass through the dead layer to the
   board, which is exactly what made this so hard to see: movement proved the listener worked,
   and dispatching a synthetic click on a node "proved" delegation worked, because dispatch
   bypasses hit-testing. Only a real hit-test in a real browser showed the node was never the
   target. The wanderer needed this same opt-in (.wnd-npc declares pointer-events: auto) for
   the same reason.

   The dungeon floor plan then made the SAME mistake, in the same file, against the same dead
   layer: manual crawling made monsters clickable and nobody told this rule either. The
   symptom was subtler because proximity auto-engage was quietly covering for it — walking
   near a monster started the fight, so combat "worked" — while an actual click on a monster
   resolved to the board and was handled as a floor click. Which meant clicking the creature
   you were already fighting read as "walk somewhere else", i.e. Flee. See .dfp-mob below. */
.gs-node {
  pointer-events: auto;
}

/* Monsters take their pointer events back too, for exactly the reason .gs-node does above.
   Covers all three sprites that carry .dfp-mob — roamers, the server-fallback fight mob, and
   a PK — so hit-testing lands on the creature and the delegated board listener's
   closest('.dfp-mob') can actually find it. Without this the sword cursor never shows (the
   hovered element is the board, not the mob) and no monster is clickable. */
.dfp-mob {
  pointer-events: auto;
}

/* And the third: other players standing in a town, a forest or a mine, so right-clicking one
   can offer Trade and Inspect (public/js/gatherScene.js). Same dead world layer, same opt-in.

   Scoped to .gather-scene-world rather than declared on .dfp-peer outright, because the
   dungeon plan draws party members with that same class and has no menu to offer them — a
   blanket rule would only make their bodies swallow the hit-test and turn "click the floor
   behind Bob" into a click on Bob.

   Left-clicking someone here still walks you to them, and deliberately so: the move handler
   lives on the BOARD and computes its cell from the pointer's position rather than from what
   was hit (see gsBoard's own listener), so the figure taking the hit-test changes nothing
   about where the click lands. */
.gather-scene-world .dfp-peer {
  pointer-events: auto;
}

/* ============ Town glades: shop fronts and the traders in front of them ============
   config/townGlades.js places both; public/js/gatherScene.js draws them.

   A shop front is 64x64 — TWO cells wide and two tall against the 32px grid — positioned by
   its own top-left and given a row-ordered z-index off the row its BASE stands on, exactly as
   every walking figure is. That is what lets a player cross in front of the bank and be hidden
   behind its roof walking the other way, which baking the building into the floor canvas could
   never do. */
.gs-shop {
  position: absolute;
  width: 64px;
  height: 64px;
  background-size: 64px 64px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  /* Bottom-anchored the same way the 48px figures are: the base sits on its own cell and the
     roof overhangs the row above, so the building stands ON the ground rather than beside it. */
  margin-top: -32px;
  pointer-events: none;
}

/* The trader. A plain paperdoll body — the same sheets players and wandering tradesmen use —
   with pointer events taken back, since the world layer it sits in is pointer-events: none and
   this is one of the two things in a glade you are meant to click (the veins are the other). */
/* The square's landmark — a fountain, a pit head. Two 64x64 frames side by side in one sheet;
   the animation swaps between them and nothing else moves. steps(2) keeps it a hard cut rather
   than a cross-fade, which is what makes water read as water at this size. */
.gs-landmark {
  position: absolute;
  width: 64px;
  height: 64px;
  background-size: 128px 64px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  margin-top: -32px;
  pointer-events: none;
  animation: gs-landmark-shimmer 1.1s steps(1) infinite;
}
@keyframes gs-landmark-shimmer {
  0%, 49.9% { background-position-x: 0; }
  50%, 100% { background-position-x: -64px; }
}

/* Britain's fountain outgrew the shared landmark geometry (tools/generateTownProps.js) —
   96x96 and FOUR frames, so the jet reads as water rising and falling rather than as a
   flicker. Its own class beside .gs-landmark rather than an edit to it, because the mine
   head still wears the 64px two-frame shape and must go on doing so. margin-top returns to
   0: the fountain is placed by its computed centre (public/js/gatherScene.js), not by the
   cell-plus-nudge the small landmarks use. */
/* The Training Yard's practice post (tools/generateTownProps.js's training_post sheet,
   stood beside the Combat Trainer by public/js/gatherScene.js). Same 64px two-frame
   geometry and clickable-prop treatment as a market stall — it is a place you walk up to
   and use. The slow beat is the straw head still rocking from the last blow. */
/* The hall's makeshift chest — same clickable-prop treatment as the post beside it. */
.gs-training-chest {
  position: absolute;
  width: 32px;
  height: 32px;
  background-size: 32px 32px;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  pointer-events: auto;
}

.gs-training-post {
  position: absolute;
  width: 64px;
  height: 64px;
  background-size: 128px 64px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  animation: gs-landmark-shimmer 2.1s steps(1) infinite;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  pointer-events: auto;
}

/* The practice-hit flash (public/js/gatherScene.js's dummy combat:tick theater). Has to
   restate BOTH animations: .sprite-hit-flash alone loses the `animation` property to the
   always-on shimmer above — equal specificity, later in this file — so on this element
   the flash must ride the shimmer's own declaration or it never renders at all. */
.gs-training-post.sprite-hit-flash {
  animation: gs-landmark-shimmer 2.1s steps(1) infinite, sprite-hit-flash 260ms ease-out;
}

/* A Market Square stall (tools/generateTownProps.js's stall_<key> sheets, spawned for
   the hasMarketSquare town by public/js/gatherScene.js) — the landmark's own 64px
   two-frame geometry, but clickable: unlike every .gs-landmark it takes the pointer,
   because a stall IS a door — walking up and browsing is the whole point. The slower
   beat than the landmark shimmer is the awning hem lifting in a breeze, not water
   playing; canvas cloth does not flutter at fountain speed. */
.gs-market-stall {
  position: absolute;
  width: 64px;
  height: 64px;
  background-size: 128px 64px;
  background-repeat: no-repeat;
  background-position: 0 0;
  image-rendering: pixelated;
  animation: gs-landmark-shimmer 1.7s steps(1) infinite;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  /* The scene layer is pointer-events: none with per-element opt-ins (see the wanderer's
     own comment near .wnd-npc) — without this line a click sailed straight through the
     stall to the floor and read as a walk order. Reported as "the stalls aren't
     clickable": they rendered, took a cursor, and received nothing. */
  pointer-events: auto;
}

/* Flower patches on the market court's apron (tools/generateTownProps.js's flowers
   sheet) — ground decoration: non-blocking, non-interactive, under the feet of anyone
   walking their cell. Two variants in the one sheet, same idiom as the hedge's bloom. */
.gs-town-flowers {
  position: absolute;
  width: 32px;
  height: 24px;
  background-image: url('/assets/sprites/town/flowers.png');
  background-size: 64px 24px;
  background-position: 0 0;
  background-repeat: no-repeat;
  image-rendering: pixelated;
  pointer-events: none;
}
.gs-flowers-showy { background-position-x: -32px; }
/* The sampled goods on the counter (market:stallDressing) — real listed-item sprites
   scaled to sit on the 64px tabletop. pointer-events off so a click on a ham or an
   ingot is a click on the stall under it. */
.gs-stall-goods {
  position: absolute;
  width: 18px;
  height: 18px;
  image-rendering: pixelated;
  pointer-events: none;
}

.gs-landmark-fountain {
  width: 96px;
  height: 96px;
  background-size: 384px 96px;
  margin-top: 0;
  animation: gs-fountain-play 0.72s steps(1) infinite;
}
@keyframes gs-fountain-play {
  0%, 24.9% { background-position-x: 0; }
  25%, 49.9% { background-position-x: -96px; }
  50%, 74.9% { background-position-x: -192px; }
  75%, 100% { background-position-x: -288px; }
}

/* The clipped hedges ringing the fountain square (public/js/gatherScene.js lays the ring
   off the plan's street set, so the gaps fall where streets enter). Two variants share one
   sheet; the bloom modifier picks the flowering half. A head taller than its cell,
   base-anchored, the same taller-than-the-tile trick every prop out here uses. */
.gs-town-hedge {
  position: absolute;
  width: 32px;
  height: 40px;
  background: url('/assets/sprites/town/hedge.png') no-repeat;
  background-size: 64px 40px;
  image-rendering: pixelated;
  pointer-events: none;
}

.gs-town-hedge.gs-hedge-bloom {
  background-position-x: -32px;
}

@media (prefers-reduced-motion: reduce) {
  .gs-landmark, .gs-landmark-fountain, .gs-market-stall, .gs-training-post { animation: none; }
}

/* A landmark you can actually use — Britain's fountain, which is its Virtue Shrine. The
   base class is scenery and takes no clicks; this one opts back in and says so with the
   same pointer cursor every vendor in the square uses. */
.gs-landmark-usable {
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.gs-landmark-usable:hover {
  filter: brightness(1.15) drop-shadow(0 0 6px rgba(140, 200, 255, 0.55));
}

/* The braziers flanking a house's front door and the moongate in its yard
   (public/js/gatherScene.js's gsSpawnYardProps) — two-frame sheets flipped by steps(1),
   exactly the .gs-landmark idiom, at their own sizes. */
/* The Keep/Castle corner bastion (public/js/gatherScene.js's gsSpawnYardProps) — an <img>
   prop standing over the room element's wall art, which is the whole reason it is not
   baked into the ground canvas. Sized by its own sprite (44x72); scenery, never a click
   target. */
.gs-turret {
  position: absolute;
  image-rendering: pixelated;
  pointer-events: none;
}

.gs-brazier {
  position: absolute;
  width: 32px;
  height: 48px;
  background: url('/assets/sprites/town/brazier.png') no-repeat;
  background-size: 64px 48px;
  image-rendering: pixelated;
  pointer-events: none;
  /* The warmth is the point of them: a soft firelight the flame art alone cannot throw. */
  filter: drop-shadow(0 0 7px rgba(244, 150, 40, 0.4));
  animation: gs-brazier-flicker 0.55s steps(1) infinite;
}
@keyframes gs-brazier-flicker {
  0%, 49.9% { background-position-x: 0; }
  50%, 100% { background-position-x: -32px; }
}

/* The pond's fish: seen from above, a fish is a dark shape sliding under the surface, so
   that is what this is — no sprite, just a body and the suggestion of a tail. Each glides its
   own distance over its own duration and turns at the ends, which is why there are three
   variants rather than one class with a shared clock. */
.gs-pond-fish {
  position: absolute;
  width: 11px;
  height: 5px;
  border-radius: 60% 40% 45% 55%;
  background: rgba(9, 26, 38, 0.62);
  box-shadow: 6px 1px 0 -1px rgba(9, 26, 38, 0.45); /* the tail, trailing */
  pointer-events: none;
}

.gs-pond-fish-0 { animation: gs-fish-a 11s ease-in-out infinite; }
.gs-pond-fish-1 { animation: gs-fish-b 15s ease-in-out infinite 2.5s; }
.gs-pond-fish-2 { animation: gs-fish-c 9s ease-in-out infinite 1.2s; }

@keyframes gs-fish-a {
  0%   { transform: translate(0, 0) scaleX(1); }
  45%  { transform: translate(34px, 9px) scaleX(1); }
  50%  { transform: translate(34px, 9px) scaleX(-1); }
  95%  { transform: translate(0, 0) scaleX(-1); }
  100% { transform: translate(0, 0) scaleX(1); }
}
@keyframes gs-fish-b {
  0%   { transform: translate(0, 0) scaleX(-1); }
  45%  { transform: translate(-42px, -13px) scaleX(-1); }
  50%  { transform: translate(-42px, -13px) scaleX(1); }
  95%  { transform: translate(0, 0) scaleX(1); }
  100% { transform: translate(0, 0) scaleX(-1); }
}
@keyframes gs-fish-c {
  0%   { transform: translate(0, 0) scaleX(1); }
  45%  { transform: translate(23px, -17px) scaleX(1); }
  50%  { transform: translate(23px, -17px) scaleX(-1); }
  95%  { transform: translate(0, 0) scaleX(-1); }
  100% { transform: translate(0, 0) scaleX(1); }
}

/* Bubbles: rise, widen a touch, and fade out. Staggered so the pond never pulses in unison. */
.gs-pond-bubble {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: rgba(190, 230, 245, 0.75);
  pointer-events: none;
  opacity: 0;
  animation: gs-bubble 4.5s ease-out infinite;
}

.gs-pond-bubble-1 { animation-duration: 6s; animation-delay: 1.4s; }
.gs-pond-bubble-2 { animation-duration: 5.2s; animation-delay: 2.9s; }
.gs-pond-bubble-3 { animation-duration: 7s; animation-delay: 0.6s; }

@keyframes gs-bubble {
  0%   { opacity: 0; transform: translate(0, 6px) scale(0.5); }
  15%  { opacity: 0.8; }
  70%  { opacity: 0.5; }
  100% { opacity: 0; transform: translate(2px, -13px) scale(1.15); }
}

/* The line and float of a cast at the house pond (public/js/gatherScene.js's
   gsPlayPondCast). The dock's own line is part of its painted scene; this one has to be
   struck between two arbitrary points in a camera-translated world, so it is one thin
   element rotated about its left end — a taut line at any angle without a canvas. */
.gs-pond-line {
  position: absolute;
  height: 1px;
  background: rgba(240, 240, 225, 0.55);
  transform-origin: 0 50%;
  pointer-events: none;
}

.gs-pond-bobber {
  position: absolute;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: #d94b3a;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), inset 0 -2px 0 rgba(255, 255, 255, 0.35);
  pointer-events: none;
  animation: gs-pond-bob 1.6s ease-in-out infinite;
}

@keyframes gs-pond-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(2px); }
}

@media (prefers-reduced-motion: reduce) {
  .gs-pond-bobber { animation: none; }
}

/* Sat in the chair (public/js/gatherScene.js's gsUpdateSeated).

   There is no seated sprite, so the figure is CUT OFF at seat height instead: settled four
   pixels down, then clipped so nothing below the seat surface draws. A standing figure
   nudged down — which is all this did at first — still has its legs and its feet on the
   grass, and reads as somebody standing in front of a chair, not in it.

   The arithmetic, because it is the whole trick: the sprite box is 48px tall with a -16px
   pull-up, so it spans the cell from -16 to +32. Four down puts the feet at +36, and taking
   16 off the bottom leaves the figure ending at +20 — which is exactly where the chair
   sprite's seat surface sits. Change the chair art and this number moves with it. */
.dungeon-floorplan-player.gs-seated {
  transform: translateY(4px);
  clip-path: inset(0 0 16px 0);
}

/* The throw (public/js/gatherScene.js's gsPondCast). The sprite sheet's swing column alone
   was not enough to see: one changed column for a third of a second, once every fourteen, on
   a figure that is small, seated and clipped at the waist. This is the motion the eye
   actually catches — wind back, flick forward, settle.

   Pivots about the hips rather than the centre, because a seated figure throwing from the
   middle of its own body reads as the whole chair rocking. The translateY is repeated in
   every frame on purpose: an animation on `transform` replaces the property outright, so
   leaving it out of a keyframe would drop the figure back out of its seat mid-throw. */
.dungeon-floorplan-player.gs-seated.gs-casting {
  transform-origin: 50% 78%;
  animation: gs-pond-cast 460ms ease-out;
}

@keyframes gs-pond-cast {
  0%   { transform: translateY(4px) rotate(0deg); }
  28%  { transform: translateY(2px) rotate(-11deg); }
  55%  { transform: translateY(3px) rotate(8deg); }
  78%  { transform: translateY(4px) rotate(-2deg); }
  100% { transform: translateY(4px) rotate(0deg); }
}

/* The same throw, standing up. A cast from the bank (public/js/gatherScene.js's
   gsPondContextMenu) is the same motion made on your feet, so it rocks about the hips the
   same way and for the same 460ms.

   Its OWN keyframes rather than the seated ones above, and that is the whole reason this
   rule exists: every frame up there repeats translateY(4px) to hold the figure down in the
   chair, and an animation on `transform` replaces the property outright — so borrowing them
   would drop somebody standing on the grass 4px into it for the length of the throw. Pure
   rotation here; there is no seat to stay in.

   Safe against the facing flip for the reason .dfp-face-left's own note gives: that uses the
   independent `scale` property, which composes underneath a transform animation instead of
   being clobbered by it. */
.dungeon-floorplan-player.gs-casting:not(.gs-seated) {
  transform-origin: 50% 78%;
  animation: gs-pond-cast-standing 460ms ease-out;
}

@keyframes gs-pond-cast-standing {
  0%   { transform: rotate(0deg); }
  28%  { transform: rotate(-11deg); }
  55%  { transform: rotate(8deg); }
  78%  { transform: rotate(-2deg); }
  100% { transform: rotate(0deg); }
}

@media (prefers-reduced-motion: reduce) {
  .dungeon-floorplan-player.gs-seated.gs-casting { animation: none; }
  .dungeon-floorplan-player.gs-casting:not(.gs-seated) { animation: none; }
}

@media (prefers-reduced-motion: reduce) {
  .gs-pond-fish, .gs-pond-bubble { animation: none; }
  .gs-pond-bubble { opacity: 0.55; }
}

/* The cool box beside the fishing chair (public/js/gatherScene.js's gsSpawnYardProps). */
.gs-cool-box {
  position: absolute;
  width: 32px;
  height: 28px;
  background: url('/assets/sprites/town/cool_box.png') no-repeat;
  background-size: 32px 28px;
  image-rendering: pixelated;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.gs-cool-box:hover {
  filter: brightness(1.2) drop-shadow(0 0 5px rgba(168, 216, 230, 0.7));
}

/* The way out of the yard's own mine (public/js/gatherScene.js's gsSpawnMineExit) — a
   timber-framed ladder standing in a shaft of daylight, two cells tall and bottom-anchored
   on the exit square. Drawn here rather than as a sprite file because it is four flat
   shapes and a gradient; a PNG would be one more asset to keep in step with the cave
   palette for no gain.

   The bake underneath already lights this square. What this adds is the part a player can
   READ: something built, at human scale, that says "climb". A glow alone is just a bright
   patch of floor. */
.gs-mine-exit {
  position: absolute;
  width: 32px;
  height: 64px;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  /* Daylight falling down the shaft, brightest at the top where the opening is. */
  background:
    linear-gradient(180deg, rgba(247, 233, 186, 0.5) 0%, rgba(247, 233, 186, 0.16) 45%, rgba(247, 233, 186, 0) 78%);
  image-rendering: pixelated;
}

/* The ladder itself: two rails and five rungs, all one element via repeating-linear-
   gradient so the rungs stay crisp at any height without five more divs. */
.gs-mine-exit::before {
  content: '';
  position: absolute;
  left: 9px;
  bottom: 0;
  width: 14px;
  height: 52px;
  background:
    /* rungs */
    repeating-linear-gradient(180deg, #8a6a3a 0 3px, rgba(0, 0, 0, 0) 3px 11px),
    /* rails */
    linear-gradient(90deg, #6b4f2a 0 3px, rgba(0, 0, 0, 0) 3px 11px, #6b4f2a 11px 14px);
  box-shadow: 0 0 0 1px rgba(20, 12, 6, 0.85);
}

/* The frame around the opening at the top — the lintel that says somebody cut this. */
.gs-mine-exit::after {
  content: '';
  position: absolute;
  left: 2px;
  top: 2px;
  width: 28px;
  height: 6px;
  background: #553f24;
  box-shadow:
    0 0 0 1px rgba(20, 12, 6, 0.9),
    inset 0 1px 0 rgba(255, 226, 160, 0.45);
}

.gs-mine-exit:hover {
  filter: brightness(1.25) drop-shadow(0 0 6px rgba(247, 233, 186, 0.75));
}

/* Its gump (public/js/coolBox.js). Built on .skills-gump like every other container in the
   game, so it floats, drags by its title bar and leaves the world visible behind it — the
   rack inside reuses the craft-station shelf classes verbatim rather than imitating them,
   because it IS the stove's Fish Store, just standing at the water. */
.cool-box-gump {
  width: 268px;
}

.cool-box-body {
  padding: 10px 12px 12px;
}

.cool-box-hint,
.moongate-hint {
  margin-bottom: 9px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  opacity: 0.65;
}

/* Three across rather than the stove's five: this gump is narrower than a station's, and
   five 40px slots in 268px would leave no shelf showing between them. */
.cool-box-grid {
  grid-template-columns: repeat(3, 1fr);
}

.cool-box-icon {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* The arrow that sends a stack to the stove — tucked into the slot's corner so it reads as
   an action ON that fish rather than a second control competing with it. */
.cool-box-stove-btn {
  position: absolute;
  top: -2px;
  right: -2px;
  width: 15px;
  height: 15px;
  padding: 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: calc(13px * var(--ui-font-scale, 1));
  color: #ffc48a;
  background: rgba(20, 16, 11, 0.9);
  border: 1px solid var(--edge, #444);
  border-radius: 3px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.cool-box-stove-btn:hover {
  color: var(--accent-bright, #ffb066);
  border-color: var(--accent, #c8752a);
}

.cool-box-actions,
.moongate-actions {
  display: flex;
  justify-content: center;
  margin-top: 10px;
}

.cool-box-note {
  margin-top: 8px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  text-align: center;
  opacity: 0.55;
}

/* The moongate's own gump (public/js/moongate.js) — same frame, same reasoning. */
.moongate-gump {
  width: 244px;
}

.moongate-body {
  padding: 10px 12px 12px;
}

/* The gate's two destination tabs. Same shape as the Session Gains panel's own pair — a
   segmented strip where the chosen half is filled, rather than two buttons that both look
   pressable, so which list you are looking at is never a guess. */
.moongate-tabs {
  display: flex;
  gap: 4px;
  margin-bottom: 8px;
}

.moongate-tab {
  flex: 1;
  padding: 5px 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 700;
  color: var(--ink, #ddd);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge, #444);
  border-radius: 4px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.moongate-tab:hover {
  border-color: var(--accent, #c8752a);
}

.moongate-tab.selected {
  color: var(--on-accent, #1a1208);
  background: var(--accent, #c8752a);
  border-color: var(--accent, #c8752a);
}

.moongate-town-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  max-height: 260px;
  overflow-y: auto;
}

.moongate-town {
  padding: 6px 9px;
  text-align: left;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink, #ddd);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge, #444);
  border-radius: 4px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.moongate-town:hover {
  border-color: var(--accent, #c8752a);
}

.moongate-town.selected {
  border-color: var(--accent, #c8752a);
  background: rgba(200, 117, 42, 0.14);
  color: var(--accent-bright, #ffb066);
}

/* A notice with the world left visible behind it (public/js/contextMenu.js's promptNotice).
   The shared modal dims everything to make a question unignorable; a notice is not a
   question, and blacking out the garden to say "not yet" about one gate is out of
   proportion — the player wants to keep looking at the thing they were refused at. */
.prompt-modal-overlay.prompt-modal-overlay-clear {
  background: transparent;
}

/* The chair on the pond's west bank (public/js/gatherScene.js's gsSpawnYardProps). Clickable
   like the moongate, and for the same reason: it is a thing you use, not a thing you walk at. */
.gs-fish-chair {
  position: absolute;
  width: 32px;
  height: 32px;
  background: url('/assets/sprites/town/fish_chair.png') no-repeat;
  background-size: 32px 32px;
  image-rendering: pixelated;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.gs-fish-chair:hover {
  filter: brightness(1.2) drop-shadow(0 0 5px rgba(232, 178, 96, 0.6));
}

.gs-moongate {
  position: absolute;
  width: 40px;
  height: 56px;
  background: url('/assets/sprites/town/moongate.png') no-repeat;
  background-size: 80px 56px;
  image-rendering: pixelated;
  /* Clickable, unlike every other prop out here — it opens the travel gump. */
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  filter: drop-shadow(0 0 10px rgba(96, 128, 255, 0.5));
  animation: gs-moongate-breathe 1.4s steps(1) infinite;
}
.gs-moongate:hover {
  filter: drop-shadow(0 0 14px rgba(128, 156, 255, 0.75));
}
@keyframes gs-moongate-breathe {
  0%, 49.9% { background-position-x: 0; }
  50%, 100% { background-position-x: -40px; }
}
@media (prefers-reduced-motion: reduce) {
  .gs-brazier, .gs-moongate { animation: none; }
}

/* --- The ring of standing stones (config/moongateStones.js, public/js/gatherScene.js) ---
   The menhirs themselves are BAKED into the world canvas (gsDrawStandingStone) and stay
   that way — the bake is the art. Each of these is a transparent overlay sitting on top of
   one: the hit target, the numeral, and the glow. */
.gs-moon-stone {
  position: absolute;
  /* Sized to the stone, NOT to its 32px cell. The ring stands on ground the yard clears so
     it can be walked between, so a full-cell target would eat the walk-here click on seven
     walkable squares and make the grass beside a stone press it. */
  width: 22px;
  height: 28px;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  display: flex;
  align-items: flex-end;
  justify-content: center;
}

/* The numeral, cut into the face of the stone. Carried here rather than only in the
   tooltip because a rite is written in stone numbers — and on touch there is no tooltip to
   read, so the one piece of information the whole mechanic depends on would be invisible. */
.gs-moon-stone-face {
  font-size: 9px;
  line-height: 1;
  padding-bottom: 7px;
  font-weight: bold;
  letter-spacing: -0.5px;
  color: #26241f;
  text-shadow: 0 1px 0 rgba(190, 184, 170, 0.55);
  pointer-events: none;
  user-select: none;
}

.gs-moon-stone:hover .gs-moon-stone-face { color: #100f0c; }

/* Pressed and holding. The glow is a background+shadow pair rather than a filter, so the
   numeral above keeps its own colour instead of being tinted with everything else. */
.gs-moon-stone.is-lit {
  background: radial-gradient(ellipse at 50% 82%, rgba(150, 205, 255, 0.5), transparent 70%);
  filter: drop-shadow(0 0 7px rgba(140, 200, 255, 0.85));
  animation: gs-moon-stone-breathe 1.8s ease-in-out infinite;
}

.gs-moon-stone.is-lit .gs-moon-stone-face {
  color: #eaf4ff;
  text-shadow: 0 0 5px rgba(150, 205, 255, 0.95);
}

@keyframes gs-moon-stone-breathe {
  0%, 100% { filter: drop-shadow(0 0 6px rgba(140, 200, 255, 0.7)); }
  50% { filter: drop-shadow(0 0 11px rgba(170, 220, 255, 0.95)); }
}

/* The ring's answer. Green blooms round the circle on a per-stone stagger set from JS, so
   it reads as the ring waking in order; red is one hard flash on everything at once,
   because a failure is a single event rather than a sequence. */
.gs-moon-stone.is-success {
  animation: gs-moon-success 1.1s ease-out var(--gs-moon-stagger, 0ms) both;
}

.gs-moon-stone.is-failure {
  animation: gs-moon-failure 0.9s ease-out both;
}

@keyframes gs-moon-success {
  0% { background: radial-gradient(ellipse at 50% 82%, rgba(120, 240, 150, 0), transparent 70%); }
  25% {
    background: radial-gradient(ellipse at 50% 82%, rgba(120, 240, 150, 0.85), transparent 72%);
    filter: drop-shadow(0 0 14px rgba(110, 245, 150, 1));
  }
  100% { background: radial-gradient(ellipse at 50% 82%, rgba(120, 240, 150, 0), transparent 70%); }
}

@keyframes gs-moon-failure {
  0%, 100% { background: radial-gradient(ellipse at 50% 82%, rgba(230, 80, 74, 0), transparent 70%); }
  18% {
    background: radial-gradient(ellipse at 50% 82%, rgba(230, 80, 74, 0.8), transparent 72%);
    filter: drop-shadow(0 0 10px rgba(235, 90, 80, 0.95));
  }
}

/* The broken slab standing on the grass west of the ring — an upright menhir snapped
   across the diagonal, its lower face deeply engraved (tools/generateYardProps.js's
   drawMoonSlab).

   A SPRITE, not CSS shapes. The first pass drew it with a gradient and a clip-path, which
   can make a jagged silhouette but cannot make a carved one — the engravings, the lip of
   fresh stone under the break and the weathered flank all need pixels placed by hand, and
   every other prop out here (brazier, moongate, cool box) is already drawn that way. */
.gs-moon-slab {
  position: absolute;
  width: 28px;
  height: 38px;
  background: url('/assets/sprites/town/moon_slab.png') no-repeat;
  background-size: 28px 38px;
  image-rendering: pixelated;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.55));
}

.gs-moon-slab:hover {
  filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.6)) drop-shadow(0 0 7px rgba(var(--edge-rgb), 0.8));
}

/* The gate a completed rite opens. The same art as the travel gate and the same blue as the
   lit stones around it, sitting one row above the travel gate so it wins the click for its
   minute (public/js/gatherScene.js tests it first for the same reason).

   BLUE, not green. The green belongs to the ring's success flash — the moment the seventh
   stone lands — and a gate that then stood there glowing green for a minute read as the
   flash never having finished. This is a moongate, and every moongate in the game is blue;
   what marks this one out is that it burns brighter and wider than the travel gate beside
   it, not that it is a different colour. */
.gs-moon-boon-gate {
  position: absolute;
  width: 40px;
  height: 56px;
  background: url('/assets/sprites/town/moongate.png') no-repeat;
  background-size: 80px 56px;
  image-rendering: pixelated;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  filter: drop-shadow(0 0 16px rgba(130, 195, 255, 0.9));
  animation: gs-moongate-breathe 1.4s steps(1) infinite;
}

.gs-moon-boon-gate:hover {
  filter: drop-shadow(0 0 22px rgba(175, 220, 255, 1));
}

@media (prefers-reduced-motion: reduce) {
  .gs-moon-stone.is-lit,
  .gs-moon-stone.is-success,
  .gs-moon-stone.is-failure,
  .gs-moon-boon-gate { animation: none; }
}

/* The broken slab's engravings (public/js/moongateSlab.js) — the ten rites, and the order
   each one wants. Styled as cut stone rather than as a list: this is a thing being read off
   a slab, and the numerals have to be legible against the ones on the stones themselves. */
.moongate-slab-gump {
  width: 340px;
}

.moongate-slab-body {
  padding: 12px 14px 14px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.moongate-slab-intro,
.moongate-slab-note {
  margin: 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.45;
  color: var(--muted);
}

.moongate-slab-note {
  font-style: italic;
  opacity: 0.85;
}

.moongate-slab-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 320px;
  overflow-y: auto;
}

.moongate-slab-rite {
  padding: 6px 8px;
  border-left: 2px solid var(--accent);
  background: rgba(255, 255, 255, 0.03);
  border-radius: 0 4px 4px 0;
}

/* A rite this slab has not given up yet. Quiet rather than absent — the point is that
   there is more still to be uncovered here. */
.moongate-slab-rite.is-worn {
  border-left-color: var(--edge);
  opacity: 0.55;
}

.moongate-slab-rite-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.moongate-slab-rite-name {
  color: var(--ink);
  font-weight: bold;
}

.moongate-slab-rite-bonus {
  color: var(--accent);
  font-size: calc(11px * var(--ui-font-scale, 1));
  white-space: nowrap;
}

.moongate-slab-order {
  display: flex;
  gap: 4px;
  margin-top: 5px;
}

/* One cut numeral per press, in reading order. Tabular-ish fixed boxes so the seven line up
   into a row you can follow with a finger while pressing stones. */
.moongate-slab-numeral {
  min-width: 22px;
  padding: 2px 0;
  text-align: center;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #e6dcc6;
  background: linear-gradient(180deg, #4a453c, #35312a);
  border: 1px solid rgba(0, 0, 0, 0.5);
  box-shadow: inset 0 1px 0 rgba(190, 184, 170, 0.25);
  border-radius: 2px;
}

.moongate-slab-numeral.is-blank {
  color: var(--muted);
  background: none;
  border-color: var(--edge);
  box-shadow: none;
}

.moongate-slab-rite-blurb {
  margin-top: 4px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
}

/* The Rite Binder's gump (public/js/riteBinder.js) — one row per Cryptic Text, showing
   how many of that kind you hold against the seven a binding wants. Sits beside the slab
   above because the two are the same feature at its two ends: this is where a rite is
   assembled, that is where it is read back. */
.rite-binder-gump {
  width: 380px;
}

.rite-binder-body {
  padding: 10px 12px 12px;
  max-height: 60vh;
  overflow-y: auto;
}

.rite-binder-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.45;
  opacity: 0.72;
  margin-bottom: 8px;
}

.rite-binder-warn {
  color: #d8a25e;
  opacity: 0.95;
}

.rite-binder-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 4px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.07);
}

.rite-binder-row:last-child {
  border-bottom: none;
}

.rite-binder-icon {
  width: 26px;
  height: 26px;
  flex: none;
  image-rendering: pixelated;
}

.rite-binder-name {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.rite-binder-rite {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: 700;
  color: var(--ink, #ddd);
}

/* The page's own name under the rite it teaches — the thing you actually shop for. */
.rite-binder-text {
  font-size: calc(10px * var(--ui-font-scale, 1));
  opacity: 0.6;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.rite-binder-count {
  font-variant-numeric: tabular-nums;
  font-size: calc(12px * var(--ui-font-scale, 1));
  flex: none;
}

.rite-binder-count.met { color: #9fd58a; }
.rite-binder-count.short { color: #d8a25e; }

/* width/margin-top are UNDOING .craft-station-deposit-all-btn, not styling — that class is
   a full-width STACKED button (width: 100%, margin-top: 6px, declared ~8,000 lines above)
   and every other borrower of it sits alone on its own line, where that is exactly right.
   This is the only one inside a flex ROW, and `flex: none` (= 0 0 auto) told the button
   never to shrink BELOW that 100%: it took the whole 336px row, crushed the name span to
   zero width so "Rite of Warding" wrapped a word per line under the count, and pushed the
   body 58px wider than itself — the horizontal scrollbar under the list. The Yard gump's
   own borrowers escape it with `flex: 1 1 0; min-width: 0`, which is the same escape by a
   different route. Anything else that borrows this class into a row needs one of the two. */
.rite-binder-bind {
  flex: none;
  width: auto;
  margin-top: 0;
  padding: 4px 10px;
}

/* A row that can actually be bound right now earns the accent — with seven text kinds
   listed, the eye needs somewhere to land. */
.rite-binder-row.ready .rite-binder-rite {
  color: var(--accent-bright, #ffb066);
}

/* The offering sign beside the stone ring (public/js/gatherScene.js's gsSpawnYardProps) —
   stands only between "seventh stone placed" and "gate open", wearing the house sign's own
   board (tools/generateHouseSign.js). Clickable: it opens the Yard gump, where the Arcane
   Dust is actually poured. */
.gs-yard-sign {
  position: absolute;
  width: 20px;
  height: 32px;
  background: url('/assets/sprites/floorplan/house_sign.png') no-repeat center / contain;
  image-rendering: pixelated;
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  filter: drop-shadow(0 0 6px rgba(184, 146, 232, 0.55));
}
.gs-yard-sign:hover {
  filter: brightness(1.2) drop-shadow(0 0 9px rgba(184, 146, 232, 0.8));
}

/* The Yard gump (public/js/yardGump.js) — the house sign's own window: one section per
   thing the yard can grow, the moongate's stones-dust-words road laid out in the first. */
.yard-gump {
  width: 340px;
}

.yard-body {
  padding: 10px 12px 4px;
}

.yard-section {
  padding: 8px 0 10px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}

.yard-section:last-child {
  border-bottom: none;
}

.yard-section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 4px;
}

.yard-section-title {
  font-weight: 700;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink, #ddd);
}

.yard-status {
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: 9px;
  border: 1px solid var(--edge, #444);
  opacity: 0.85;
}

.yard-status-open {
  color: #9fd58a;
  border-color: rgba(159, 213, 138, 0.5);
  background: rgba(112, 168, 90, 0.12);
}

.yard-status-locked {
  color: #b9b9b9;
  background: rgba(255, 255, 255, 0.04);
}

.yard-section-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.45;
  opacity: 0.7;
}

/* The seven stone hollows. A filled one is a standing menhir; an empty one is the greyed
   placeholder the yard's own bake mirrors as a ghost stone; `ready` (a Large Rock in the
   backpack) lights the hollow up as the thing to click. */
.yard-stone-row {
  display: flex;
  justify-content: center;
  gap: 6px;
  margin: 6px 0 8px;
}

.yard-stone-slot {
  width: 26px;
  height: 34px;
  border: 1px dashed var(--edge, #444);
  border-radius: 5px 5px 3px 3px;
  background:
    linear-gradient(180deg, rgba(131, 125, 114, 0.16) 0%, rgba(61, 58, 51, 0.22) 100%);
  opacity: 0.45;
  cursor: default;
  padding: 0;
}

.yard-stone-slot.filled {
  opacity: 1;
  border-style: solid;
  border-color: #6a655c;
  background:
    linear-gradient(180deg, #837d72 0%, #6a655c 45%, #3d3a33 100%);
  box-shadow: inset 1px 0 0 rgba(255, 255, 255, 0.18), 0 2px 3px rgba(0, 0, 0, 0.4);
}

.yard-stone-slot.ready {
  opacity: 0.9;
  border-style: solid;
  border-color: var(--accent, #c8752a);
  box-shadow: 0 0 7px rgba(200, 117, 42, 0.45);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.yard-stone-slot.ready:hover {
  box-shadow: 0 0 10px rgba(200, 117, 42, 0.75);
}

.yard-dust-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 6px;
}

/* VAS REL POR — the one button in the game that casts something, dressed a shade arcane. */
.yard-cast-btn {
  color: #e9d8ff;
  border-color: rgba(184, 146, 232, 0.7);
  background: rgba(138, 95, 201, 0.22);
  text-shadow: 0 0 6px rgba(184, 146, 232, 0.6);
}

.yard-cast-btn:hover {
  border-color: #b892e8;
  background: rgba(138, 95, 201, 0.34);
}

.yard-actions {
  display: flex;
  justify-content: center;
  padding: 8px 12px 12px;
}

/* The house's own menu row, at the TOP of the Yard gump: Rename House and Access Rights,
   the two things you open the gump to DO rather than read. Sitting above the status
   sections rather than below them keeps them in the same place whether the yard has four
   rows or one — the old single Rename button at the foot moved with the list above it. */
.yard-actions-top {
  gap: 6px;
  padding: 10px 12px 4px;
}

.yard-actions-top > button {
  flex: 1 1 0;
  min-width: 0;
}

/* The bill for a yard plot bought with materials (config/yardUpgrades.js), drawn inside
   its own section of the Yard gump with each line's icon taken from the ItemDef. */
.yard-upgrade-costs {
  display: flex;
  flex-direction: column;
  gap: 3px;
  margin-bottom: 7px;
}

.yard-upgrade-cost-line {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

/* The item's own icon, at inventory scale — the 44px sprite shown small, so a bill quotes
   the same picture the backpack does. */
.yard-upgrade-cost-icon {
  width: 22px;
  height: 22px;
  image-rendering: pixelated;
  flex: none;
}

.yard-upgrade-cost-label {
  flex: 1;
  opacity: 0.85;
}

.yard-upgrade-cost-value {
  font-variant-numeric: tabular-nums;
}

.yard-upgrade-cost-line.met .yard-upgrade-cost-value {
  color: #9fd58a;
}

.yard-upgrade-cost-line.short .yard-upgrade-cost-value {
  color: #d8a25e;
}

.yard-upgrade-buy {
  width: 100%;
}

/* The moongate's own little travel gump (public/js/moongate.js) — the prompt modal's frame
   with a town list in it. */
.moongate-gump {
  width: 300px;
}

.moongate-town-list {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 10px 0 4px;
  max-height: 260px;
  overflow-y: auto;
}

.moongate-town {
  padding: 7px 10px;
  text-align: left;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: var(--ink, #ddd);
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid var(--edge, #444);
  border-radius: 4px;
  cursor: pointer;
}

.moongate-town:hover {
  border-color: var(--accent, #c8752a);
}

.moongate-town.selected {
  border-color: var(--accent, #c8752a);
  background: rgba(200, 117, 42, 0.14);
  color: var(--accent-bright, #ffb066);
}

.gs-vendor {
  pointer-events: auto;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* No breathing glow any more — a vendor standing INSIDE their own premises, behind their
   own counter with their trade over their head, does not need a halo to say they are
   staff; the plate below does that legibly now, and the pulse read as an enchantment
   rather than an invitation. (The WANDERER keeps his glow: he stands loose in a crowd of
   players, which is the exact situation the halo was for.) */

/* Job first, person second — you are looking for a banker; which banker is a detail you
   pick up on the way. Same nameplate geometry and counter-flip every other figure here
   uses — but on a BACKPLATE now: the plate used to be bare text over whatever the shop
   floor happened to be (herringbone brick, hay, sandstone), and against the busy ones it
   went soft. A dark plate under bold near-white type reads at a glance on every floor in
   the game, which is the whole job of a sign. */
/* Centered by gsSpawnTownVendors after measuring, onto a whole-pixel inline `left` — the
   translate below is only the pre-measure fallback (board hidden during a rebuild). At a
   fractional/odd width the translate composites the whole plate onto a half-pixel and
   blurs it, which is exactly the "one vendor's name is fuzzy" bug. */
.gs-vendor-name {
  position: absolute;
  left: 50%;
  bottom: 100%;
  margin-bottom: 2px;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: calc(10px * var(--ui-font-scale, 1));
  line-height: 1.25;
  pointer-events: none;
}
.gs-vendor.dfp-face-left .gs-vendor-name > .popup-flip-inner { scale: -1 1; }
/* Trade over name, on two lines. The inner span stacks them so the counter-flip still applies
   to the pair as one block — flipping each line on its own would mirror their centring apart.

   `align-items: center` here is the PRE-MEASURE FALLBACK only, same as the translate above.
   It centres the narrower line at a fractional offset whenever the two lines' text metrics
   differ by an odd amount, which is the "this vendor's name is fuzzy and the next one's
   isn't" bug — so gsSpawnTownVendors switches this to flex-start and sets a rounded
   margin-left per line once it can measure them. */
.gs-vendor-name > .popup-flip-inner {
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 2px 6px 3px;
  background: rgba(12, 10, 8, 0.72);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 3px;
}
.gs-vendor-tag {
  color: var(--accent);
  font-weight: bold;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.9);
}
.gs-vendor-who {
  color: #f4efe4;
  font-weight: bold;
  text-shadow: 0 1px 1px rgba(0, 0, 0, 0.9);
}

/* --- Rich veins, and working a place by hand (gatherScene.js's manual mode) ---------------

   EVERY cue below is gated on the board's data-mode being "manual". In auto mode the
   character picks its own workspots and the player never clicks anything, so a third of the
   forest glowing would be advertising an interaction that is not on offer — decoration
   promising a verb. The nodes are still marked .gs-node-rich in the DOM either way (the
   marking is by index and must stay stable across a mode switch); the mode simply decides
   whether that marking is shown at all.

   Only these can be struck manually, so they have to be findable across a cavern at a glance —
   which is why the cue is an outline and a glow rather than a recolour: the tier colour is
   already doing a job on these sprites (which ore it is), and overwriting it to say "clickable"
   would cost the information players actually navigate by. */
.gather-scene[data-mode="manual"] .gs-node-rich {
  cursor: pointer;
  /* The seam of brighter rock around it. drop-shadow rather than a border or outline because
     the sprites are irregular silhouettes on transparency — a box would frame empty corners. */
  filter: drop-shadow(0 0 3px rgba(255, 214, 140, 0.95)) drop-shadow(0 0 7px rgba(255, 170, 60, 0.55));
}

/* Ready to be struck: a slow breath, so a floor of them reads as alive without flickering. */
.gather-scene[data-mode="manual"] .gs-node-rich-ready {
  animation: gs-node-rich-pulse 2.2s ease-in-out infinite;
}
@keyframes gs-node-rich-pulse {
  0%, 100% { filter: drop-shadow(0 0 3px rgba(255, 214, 140, 0.75)) drop-shadow(0 0 6px rgba(255, 170, 60, 0.40)); }
  50%      { filter: drop-shadow(0 0 5px rgba(255, 232, 180, 1)) drop-shadow(0 0 12px rgba(255, 180, 70, 0.75)); }
}

/* The tool as the cursor over a strikeable vein — a pickaxe in the mines, a hatchet in the
   glade — so what a click will DO is announced before it is committed. The scene stamps
   data-kind on the board (gatherScene.js's gsRefresh); the icons are the game's own 44px item
   sprites, hotspot centred, with `pointer` as the fallback for anything that refuses the
   image. Spent veins keep the plain pointer from .gs-node-rich: they can still be clicked,
   but a click on rubble is a walk, not a swing, and the cursor should not promise one. */
.gather-scene[data-mode="manual"][data-kind="mine"] .gs-node-rich-ready { cursor: url('/assets/sprites/items/iron_pickaxe.png') 22 22, pointer; }
.gather-scene[data-mode="manual"][data-kind="forest"] .gs-node-rich-ready { cursor: url('/assets/sprites/items/iron_hatchet.png') 22 22, pointer; }

/* At HOME the tool cursor is the whole interface, not a flourish on top of one. The Skills
   panel refuses to start Lumberjacking or Mining while you are at your house (a yard has
   two plots and the panel cannot tell which one you are standing in), so the only way to
   begin is to click a tree in your field or a vein in your mine — and this is what says
   so before the click. Every node, not just a rich one, and in either gather mode: at home
   they are all workable, and none of them is a manual strike. */
.gather-scene[data-kind="house"] .gs-node { cursor: url('/assets/sprites/items/iron_hatchet.png') 22 22, pointer; }
.gather-scene[data-house-mine="true"] .gs-node { cursor: url('/assets/sprites/items/iron_pickaxe.png') 22 22, pointer; }

/* A felled stump keeps the tool cursor on purpose, unlike a spent vein on the public maps:
   there a click on rubble is a walk and nothing more, but here it still starts (or keeps)
   the job at the next tree along, so the cursor is telling the truth. */

/* A strike is on its way: further clicks are ignored until it lands (gatherScene.js's
   gsBindManualClicks), so the tool cursor gives way rather than inviting clicks that go
   nowhere. Progress, not refusal — the swing is coming. */
.gather-scene[data-mode="manual"][data-striking="1"] .gs-node-rich-ready { cursor: progress; }

/* On cooldown (or mined out). The glow goes and the sprite dims — the floor itself carries the
   ten seconds, so there is no separate timer anywhere for the player to watch instead of the
   scene. Deliberately still clickable: the click walks you to it, which is what you want to be
   doing with those ten seconds anyway. */
.gather-scene[data-mode="manual"] .gs-node-rich-spent {
  animation: none;
  filter: grayscale(0.45) brightness(0.72);
}

/* The two ways to work a place, offered side by side on arrival (gatherPrompt.ejs). Column on
   the narrowest screens, where two full-width buttons side by side would each be too small to
   read the sub-label under. */
.gather-prompt-choices {
  display: flex;
  gap: 12px;
  align-items: stretch;
  pointer-events: auto;
}
@media (max-width: 640px) {
  .gather-prompt-choices { flex-direction: column; }
}

/* Manual is the deliberate choice, not the default one — a cooler edge so the pair reads as
   two options rather than one recommendation and an afterthought. */
.gather-prompt-btn-manual {
  border-color: rgba(150, 190, 235, 0.55);
}
.gather-prompt-btn-manual:hover {
  border-color: rgba(180, 215, 250, 0.85);
}

/* --- Wandering tradesmen (public/js/wanderer.js, services/wanderers.js) --------------------
   A Wandering Miner or Lumberjack walking a gathering scene. Positioned in the same world
   layer, in the same 32px cells, as everything else on that ground; z-index is set per step
   from his row so he passes correctly in front of and behind the veins.

   The left/top transition is what turns the server's discrete steps into a walk. Its
   duration matches WANDERER_STEP_MS in config/wanderers.js — he arrives exactly as the next
   step is sent, so the movement is continuous rather than a step-and-wait stutter. */
.wnd-npc {
  position: absolute;
  /* A player BODY sprite is a 192x144 SHEET — six 32x48 frames across, three rows deep
     (front / side / away), exactly like .dungeon-floorplan-pk which draws its ambusher from
     the same art. These five properties are what pick ONE frame out of it.

     This was originally 44x44 with background-size: 100% 100%, copied from the monster
     sprites — and monsters genuinely are a single 44x44 image, so that was right for them and
     very wrong here: it crushed all eighteen frames into one cell, which rendered as a tiny
     grid of figures under the nameplate rather than a person. */
  width: 32px;
  height: 48px;
  margin-top: -16px; /* bottom-anchor: feet at the cell's lower edge, as the PK does */
  background-size: 192px 144px;
  background-repeat: no-repeat;
  background-position: 0 0; /* frame 0 of the front row — standing */
  image-rendering: pixelated;
  cursor: pointer;
  /* Explicit rather than inherited. The full-screen scene sets pointer-events: none on the
     whole view and re-enables it on the board; this is the one element in the world layer
     that has to be clickable, so it says so itself rather than relying on that chain. */
  pointer-events: auto;
  transition: left 900ms linear, top 900ms linear;
}

/* A slow breathing glow, so a stranger you can actually talk to is distinguishable from the
   other harvesters at a glance. Deliberately gentle — this is an invitation, not an alarm. */
.wnd-npc::after {
  content: '';
  position: absolute;
  inset: -4px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 200, 120, 0.30) 0%, rgba(255, 200, 120, 0) 68%);
  pointer-events: none;
  animation: wnd-npc-glow 2.4s ease-in-out infinite;
  z-index: -1;
}
@keyframes wnd-npc-glow {
  0%, 100% { opacity: 0.45; }
  50% { opacity: 1; }
}

/* Which ROW of the sheet shows — front, profile, or back — exactly the contract
   .dungeon-floorplan-player and .dungeon-floorplan-pk already follow, and set from the
   direction of travel by wndPlace. He used to walk every route face-on, sliding sideways
   and backwards without ever turning. */
.wnd-npc.wnd-face-side { background-position-y: -48px; }
.wnd-npc.wnd-face-away { background-position-y: -96px; }
/* Mirrored for westward travel — the profile art only exists facing one way. */
.wnd-npc.wnd-face-left { transform: scaleX(-1); }
/* ...which mirrors every child too, so the nameplate flips back about its own centre. The
   same popup-flip-inner arrangement damage numbers and gather yields use, and for the same
   reason: counter-scaling the plate itself would mirror its translate(-50%) centring and
   shove the name off his head. */
.wnd-npc.wnd-face-left .wnd-npc-name > .popup-flip-inner { transform: scaleX(-1); }

/* The two-beat stride, frames 1 and 2 of whichever row is showing.
   background-position-x LONGHAND only — the facing rules above own -y, and a shorthand here
   would reset it every frame and snap a walker back to the front row mid-step. That is the
   load-bearing detail dfp-run-cycle and ts-stride both call out, and this is the third place
   it matters. steps(1) keeps the frames chunky rather than tweening between them.
   0.45s reads as an amble against his 900ms step — he is browsing for someone to hire, not
   marching. Applied only while actually moving; at rest the base rule's frame 0 stands. */
@keyframes wnd-stride {
  0%, 49.9% { background-position-x: -32px; }
  50%, 100% { background-position-x: -64px; }
}
.wnd-npc.wnd-walking { animation: wnd-stride 0.45s steps(1) infinite; }

.wnd-npc-tool {
  position: absolute;
  right: -8px;
  bottom: 12px;
  width: 18px;
  height: 18px;
  image-rendering: pixelated;
  pointer-events: none;
}

.wnd-npc-name {
  position: absolute;
  left: 50%;
  top: -12px;
  transform: translateX(-50%);
  white-space: nowrap;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #ffce8a;
  text-shadow: 0 0 3px #000, 0 1px 2px #000;
  pointer-events: none;
}

/* His ten minutes are up. Fades as he goes rather than vanishing mid-stride. */
.wnd-npc-leaving {
  opacity: 0;
  transition: opacity 1.2s ease, left 900ms linear, top 900ms linear;
}

/* The offer window's body. The shell is the shared .prompt-modal the Bind Offering and Name
   Change prompts wear; only the lines inside it are this feature's own. */
.wnd-offer-body {
  display: flex;
  flex-direction: column;
  gap: 6px;
  margin: 10px 0 4px;
  text-align: center;
}
.wnd-offer-trade {
  font-size: calc(12px * var(--ui-font-scale, 1));
  letter-spacing: 0.08em;
  color: var(--muted);
  text-transform: uppercase;
  margin-top: -4px;
}
/* His backstory — one line, and the only place in the game there is room for it. */
.wnd-offer-blurb { font-size: calc(12px * var(--ui-font-scale, 1)); color: var(--muted); line-height: 1.45; }
.wnd-offer-greeting { font-style: italic; color: var(--muted); font-size: calc(13px * var(--ui-font-scale, 1)); }
.wnd-offer-tier {
  font-size: calc(12px * var(--ui-font-scale, 1));
  letter-spacing: 0.14em;
  color: var(--accent);
  font-weight: bold;
}
.wnd-offer-ask { font-size: calc(16px * var(--ui-font-scale, 1)); font-weight: bold; }
.wnd-offer-pay { font-size: calc(13px * var(--ui-font-scale, 1)); color: #9fd6a0; }
/* The bounty hunter's "might also throw in" line — the Dye Tub and the Slayer, which are
   genuinely chances rather than promises (services/dungeonBounties.js rolls them at collection).
   Deliberately a cooler, dimmer green than .wnd-offer-pay above: it sits directly beneath the
   guaranteed pay, and the two must not read as one list. */
.wnd-offer-chance { font-size: calc(12px * var(--ui-font-scale, 1)); color: #7fae9c; font-style: italic; }

/* ============ Local chat over the speaker's head (public/js/speechBubble.js) ============
   A CHILD of the figure element, which is what makes it follow along for nothing: the scene
   already positions and transitions that figure cell to cell, so the bubble rides it with no
   position tracking of its own.

   bottom: 100% puts it above the sprite's own box. The figures here are 32x48 with a -16px
   pull-up (see .dungeon-floorplan-player), so that lands it just over the head rather than
   over the chest, and it grows UPWARD as it wraps — away from the floor, so a long line never
   covers the ground the speaker is standing on.

   pointer-events: none throughout: this sits directly over a dungeon's mobs and a mine's
   veins, both of which are clicked, and a bubble that ate those clicks would make talking to
   somebody a way to break their farming. */
.speech-bubble {
  position: absolute;
  left: 50%;
  bottom: 100%;
  margin-bottom: 6px;
  transform: translateX(-50%);
  /* Wide enough for a short sentence, narrow enough that two people standing together do not
     overlap. Longer lines wrap and the bubble grows up, never sideways past this. */
  width: max-content;
  max-width: 190px;
  padding: 4px 8px;
  background: rgba(12, 14, 19, 0.92);
  border: 1px solid rgba(var(--accent-rgb), 0.45);
  border-radius: 8px;
  color: var(--ink);
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.35;
  text-align: center;
  white-space: pre-wrap;
  overflow-wrap: anywhere; /* an unbroken 140-character "word" must still wrap */
  pointer-events: none;
  /* Above the sprites in this figure's own stacking context — the nameplate sits at the same
     level, and the two never coincide (one is over the head, the other under the feet). */
  z-index: 5;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
  animation: speech-bubble-in 140ms ease-out;
  transition: opacity 220ms ease-in;
}

/* The tail, pointing down at whoever said it. A rotated square rather than a border triangle
   so it can carry the same 1px edge as the bubble on its two visible sides. */
.speech-bubble::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -4px;
  width: 7px;
  height: 7px;
  margin-left: -4px;
  background: rgba(12, 14, 19, 0.92);
  border-right: 1px solid rgba(var(--accent-rgb), 0.45);
  border-bottom: 1px solid rgba(var(--accent-rgb), 0.45);
  transform: rotate(45deg);
}

@keyframes speech-bubble-in {
  from { opacity: 0; transform: translateX(-50%) translateY(3px); }
  to { opacity: 1; transform: translateX(-50%) translateY(0); }
}

/* Faded out by speechBubble.js when its time is up, then removed on transitionend. */
.speech-bubble-leaving { opacity: 0; }

/* Anyone who has asked their machine for less motion gets the bubble without the entrance —
   it still appears, and still fades, just without the rise. Same treatment the rest of the
   client's motion already gets. */
@media (prefers-reduced-motion: reduce) {
  .speech-bubble { animation: none; }
}
.wnd-offer-clock { font-size: calc(12px * var(--ui-font-scale, 1)); color: var(--muted); }
.wnd-offer-busy { font-size: calc(12px * var(--ui-font-scale, 1)); color: #e08a5a; margin-top: 4px; }

/* The order, as it appears at the top of the Quest Journal (public/js/questLog.js). Same
   .stable-mount-row shape the starter lines use, plus a left edge marking it as the one
   entry in that list that is on a clock and can be failed. */
.quest-log-contract {
  border-left: 3px solid var(--accent);
  align-items: flex-start;
}
.quest-log-contract-info { flex: 1; min-width: 0; }
.quest-log-contract-title { font-weight: bold; color: var(--accent); font-size: calc(12px * var(--ui-font-scale, 1)); }
.quest-log-contract-need { font-size: calc(12px * var(--ui-font-scale, 1)); margin-top: 2px; }
.quest-log-contract-pay { font-size: calc(11px * var(--ui-font-scale, 1)); color: #9fd6a0; margin-top: 2px; }
.quest-log-contract-clock { font-size: calc(11px * var(--ui-font-scale, 1)); color: var(--muted); margin-top: 2px; }

/* Deliver / Collect / Abandon.
   These carried .stable-activate-btn for a class that has never existed in any stylesheet,
   so both rows rendered a bare browser button — grey, square, and visibly not part of the
   game. Colour and hover now come from the shared ember-chip group (see the big grouped
   rule near .stable-buy-btn); this supplies only the sizing and the stacking that class
   does not, matching .friends-message-btn's own note about the same split. */
.quest-log-contract-btn {
  padding: 4px 12px;
  font-weight: bold;
  font-size: calc(11px * var(--ui-font-scale, 1));
  white-space: nowrap;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}
.quest-log-contract-btn:disabled { opacity: 0.45; cursor: default; }

/* Filled rather than outlined the moment it can actually be pressed. A contract row spends
   most of its life with a greyed button on it, so the transition to "this is done, come and
   get it" should be visible from across the panel rather than being a subtle opacity change
   on a chip that looked disabled anyway. Same idea as .dfp-boss-overlay-ready. */
.quest-log-contract-btn.quest-log-contract-ready {
  background: rgba(var(--accent-rgb), 0.22);
  border-color: var(--accent);
  box-shadow: 0 0 8px rgba(var(--accent-rgb), 0.35);
}

/* Abandon sits beside Collect and throws away real progress, so it must not wear the same
   ember chip as the button next to it — a hurried click should never land on the wrong one.
   Muted until hovered, then the warning red the rest of the game uses for a destructive
   action (.wnd-offer-busy's ember-orange is a caution, this is a step past it). */
.quest-log-contract-abandon-btn {
  background: transparent;
  border-color: rgba(var(--edge-rgb), 0.35);
  color: var(--muted);
}
.quest-log-contract-abandon-btn:hover:not(:disabled) {
  background: rgba(179, 54, 54, 0.85);
  border-color: #b33636;
  color: #fff;
}

/* The two buttons stack rather than sitting side by side: the row is already narrow with an
   icon, three lines of text and a clock in it, and two chips in a line squeezed the wording
   ("Collect" wrapping mid-word was the first thing that broke). */
.quest-log-contract-actions {
  display: flex;
  flex-direction: column;
  gap: 4px;
  align-self: center;
}

/* Floating special labels (dfpSpecialFx + bleed-tick numbers) — reuse the .damage-popup
   float; execute slams in oversized like a crit, bleed drips dark red, stun flashes gold. */
.damage-popup-execute {
  color: #ff8a3a;
  font-size: calc(20px * var(--ui-font-scale, 1));
  font-weight: 900;
  text-shadow: 0 0 6px #000, 0 0 12px rgba(255, 120, 40, 0.7);
  animation: damage-popup-crit-float 900ms ease-out forwards;
}
.damage-popup-bleed { color: #e0564a; font-size: calc(14px * var(--ui-font-scale, 1)); text-shadow: 0 0 3px #000; }
/* The "HURL BOULDER!" shout over the player as a thrown rock lands (config/
   monsterRockThrows.js) — stone grey, matching the projectile and its impact burst. */
.damage-popup-rock { color: #d6cbbd; font-weight: bold; font-size: calc(15px * var(--ui-font-scale, 1)); text-shadow: 0 0 4px #000; }
.damage-popup-paralyze { color: #ffe06a; font-weight: bold; font-size: calc(15px * var(--ui-font-scale, 1)); text-shadow: 0 0 4px #000; }

/* The ambusher's entrance — rises out of the ground/undergrowth. */
@keyframes gs-mob-spawn {
  0% { opacity: 0; transform: translateY(10px) scale(0.6); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}
.gs-mob-spawning {
  animation: gs-mob-spawn 450ms ease-out;
}

/* --- The fishing dock -------------------------------------------------------------------- */

.gs-fishing-board {
  background: #0b0b0e;
}

.gs-sky {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  background: linear-gradient(to bottom, var(--gs-sky-top, #27364a), var(--gs-sky-bot, #6d87a0));
}

.gs-sea {
  position: absolute;
  left: 0;
  width: 100%;
  background: linear-gradient(to bottom, var(--gs-sea, #2e5670), var(--gs-sea-deep, #16303f));
}

/* One drifting wave band. The stripe pattern repeats every 64px and the drift keyframe
   travels exactly 64px, so the loop is seamless; three bands at different heights,
   speeds and opacities read as rolling swell, with a slow vertical bob on top. */
@keyframes gs-wave-drift {
  from { background-position-x: 0; }
  to { background-position-x: 64px; }
}
@keyframes gs-wave-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(2px); }
}
.gs-wave {
  position: absolute;
  left: 0;
  width: 100%;
  background-image: repeating-linear-gradient(
    90deg,
    transparent 0 26px,
    rgba(235, 235, 225, 0.55) 26px 42px,
    transparent 42px 64px
  );
  border-radius: 40%;
  animation: gs-wave-drift 10s linear infinite, gs-wave-bob 3.6s ease-in-out infinite;
  pointer-events: none;
}

/* The pier deck: repeating plank stripes with a lit top edge and a shadowed lip. */
.gs-dock {
  position: absolute;
  left: 0;
  height: 22px;
  background: repeating-linear-gradient(
    90deg,
    var(--gs-dock, #6b4f30) 0 22px,
    var(--gs-dock-dark, #4a3620) 22px 24px
  );
  border-top: 2px solid var(--gs-dock-light, #8a6a42);
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.45);
  z-index: 3;
}

.gs-dock-post {
  position: absolute;
  width: 6px;
  background: #3e2d1b;
  box-shadow: 1px 0 0 rgba(0, 0, 0, 0.4);
  z-index: 2;
}

/* The seated angler sits above the dock line. */
.gs-fishing-board .gs-player {
  position: absolute;
  z-index: 4;
}

/* The dock chair (tools/generateFloorplanProps.js's dock_chair, 32x48) — behind the
   sitter, back post to the left, facing the sea. */
.gs-chair {
  position: absolute;
  width: 32px;
  height: 48px;
  image-rendering: pixelated;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.45));
  pointer-events: none;
  z-index: 3;
}

/* The rod: the ACTUAL equipped tool's icon (exact tier/type — golden pole shows gold)
   held in the seated hand, pivoting at the grip end (gatherScene.js lines the icon's
   grip up with the sprite's fist). Resting tilt points the tip out over the water; the
   cast flick whips it back then forward. */
.gs-rod {
  position: absolute;
  width: 46px;
  height: 46px;
  /* The pole icon is drawn diagonally already (butt lower-left) — grip at ~17.5%/90%
     of the box; a gentle +14deg rests the tip out over the water. */
  transform: rotate(14deg);
  transform-origin: 17.5% 90%;
  image-rendering: pixelated;
  filter: drop-shadow(-1px 2px 1px rgba(0, 0, 0, 0.55));
  pointer-events: none;
  z-index: 5;
}

@keyframes gs-rod-cast {
  0% { transform: rotate(14deg); }
  30% { transform: rotate(-30deg); }
  60% { transform: rotate(38deg); }
  100% { transform: rotate(14deg); }
}
.gs-rod.gs-rod-casting {
  animation: gs-rod-cast 600ms ease-out;
}

/* The line: a 1px thread stretched/rotated from rod tip to bobber by JS. */
.gs-line {
  position: absolute;
  height: 1px;
  background: rgba(235, 235, 225, 0.4);
  transform-origin: top left;
  transition: opacity 0.2s ease-out;
  pointer-events: none;
  z-index: 4;
}

@keyframes gs-bobber-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(3px); }
}
.gs-bobber {
  position: absolute;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: #c9402e;
  box-shadow: inset 0 -2px 0 rgba(0, 0, 0, 0.35), 0 0 3px rgba(0, 0, 0, 0.5);
  animation: gs-bobber-bob 2.4s ease-in-out infinite;
  transition: opacity 0.2s ease-out;
  pointer-events: none;
  z-index: 4;
}

/* The splash where the bobber (or the catch) breaks the surface. */
@keyframes gs-splash-ring {
  0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0.9; }
  100% { transform: translate(-50%, -50%) scale(1.8); opacity: 0; }
}
.gs-splash {
  position: absolute;
  width: 22px;
  height: 10px;
  border: 2px solid rgba(235, 240, 245, 0.75);
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: gs-splash-ring 520ms ease-out forwards;
  pointer-events: none;
  z-index: 4;
}

/* The catch arcing from the water back to the dock — endpoints via --gs-dx/--gs-dy,
   lifted at the midpoint, spinning as it flies. */
@keyframes gs-fish-arc {
  0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
  50% { transform: translate(calc(var(--gs-dx) * 0.5), calc(var(--gs-dy) * 0.5 - 46px)) rotate(160deg); }
  100% { transform: translate(var(--gs-dx), var(--gs-dy)) rotate(320deg); opacity: 0.9; }
}
.gs-fish-arc {
  position: absolute;
  width: 22px;
  height: 22px;
  image-rendering: pixelated;
  animation: gs-fish-arc 800ms ease-in forwards;
  filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.5));
  pointer-events: none;
  z-index: 5;
}

/* --- Master's Touch (premium unlock — gatherScene.js's gsMastersTouch helpers) -----------
   The paid epic-harvest garnish. Every class here is spawned only when the unlock is
   owned; positions/colors/z arrive inline per element (tier color via --gs-tier-color,
   flight vectors via the same --sx/--sy idiom the ore shards use). */

/* Expanding ground shockwave at the base of a felled tree / collapsed vein. */
@keyframes gs-mt-ring-pop {
  0% { transform: translate(-50%, -50%) scale(0.2); opacity: 0.9; }
  100% { transform: translate(-50%, -50%) scale(2.1); opacity: 0; }
}
.gs-mt-ring {
  position: absolute;
  width: 26px;
  height: 12px; /* squashed ellipse — a ripple ON the ground, not a hoop in the air */
  border: 2px solid var(--gs-tier-color, #d8dbe6);
  border-radius: 50%;
  transform: translate(-50%, -50%);
  animation: gs-mt-ring-pop 700ms ease-out forwards;
  pointer-events: none;
}

/* One canopy leaf fluttering down — drift via --sx, drop via --sy, spinning as it
   falls; duration/delay are randomized inline per leaf. */
@keyframes gs-mt-leaf-fall {
  0% { transform: translate(0, 0) rotate(0deg); opacity: 1; }
  70% { opacity: 1; }
  100% { transform: translate(var(--sx, 0), var(--sy, 30px)) rotate(300deg); opacity: 0; }
}
.gs-mt-leaf {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 1px 3px 1px 3px; /* pinched corners — reads leaf-shaped at 3px */
  animation-name: gs-mt-leaf-fall;
  animation-timing-function: ease-in;
  animation-fill-mode: both;
  pointer-events: none;
}

/* The tier-colored detonation flash swallowing a collapsing vein. */
@keyframes gs-mt-flash-pop {
  0% { transform: scale(0.4); opacity: 0.85; }
  100% { transform: scale(1.5); opacity: 0; }
}
.gs-mt-flash {
  position: absolute;
  width: 48px;
  height: 60px;
  background: radial-gradient(ellipse at center, var(--gs-tier-color, #ffffff) 0%, transparent 65%);
  animation: gs-mt-flash-pop 480ms ease-out forwards;
  pointer-events: none;
}

/* One sparkle mote — a diamond twinkling in and out; the fishing trail strings several
   along the catch's leap with staggered delays, the grid glint scatters them. */
@keyframes gs-mt-twinkle-pop {
  0% { transform: rotate(45deg) scale(0); opacity: 0; }
  40% { transform: rotate(45deg) scale(1.25); opacity: 1; }
  100% { transform: rotate(45deg) scale(0); opacity: 0; }
}
.gs-mt-twinkle {
  position: absolute;
  width: 5px;
  height: 5px;
  background: var(--gs-tier-color, #ffe9a8);
  box-shadow: 0 0 4px var(--gs-tier-color, #ffe9a8);
  animation: gs-mt-twinkle-pop 700ms ease-in-out both;
  pointer-events: none;
  z-index: 5;
}

/* Water droplet off a Master's Touch catch — up and out, then gravity. */
@keyframes gs-mt-droplet-fly {
  0% { transform: translate(0, 0); opacity: 0.95; }
  100% { transform: translate(var(--sx, 0), calc(var(--sy, -14px) + 30px)); opacity: 0; }
}
.gs-mt-droplet {
  position: absolute;
  width: 3px;
  height: 3px;
  border-radius: 50%;
  background: #9fd4ef;
  animation: gs-mt-droplet-fly 600ms ease-out forwards;
  pointer-events: none;
  z-index: 4;
}

/* A column of tier-colored light standing up out of the payoff for a breath — the
   "the ground itself is impressed" centerpiece under the leaf storm / ore eruption.
   Grows from its base (transform-origin bottom) so it reads as erupting, not appearing. */
@keyframes gs-mt-pillar-pop {
  0% { transform: translateX(-50%) scaleY(0); opacity: 0.9; }
  35% { transform: translateX(-50%) scaleY(1); opacity: 0.8; }
  100% { transform: translateX(-50%) scaleY(1.15); opacity: 0; }
}
.gs-mt-pillar {
  position: absolute;
  width: 12px;
  height: 58px;
  background: linear-gradient(to top, var(--gs-tier-color, #ffe9a8), transparent 85%);
  transform-origin: center bottom;
  animation: gs-mt-pillar-pop 750ms ease-out forwards;
  pointer-events: none;
  opacity: 0.9;
}

/* One ember — flung up-and-out along --sx/--sy, then dragged 22px back down by
   gravity as it dies. The glowing counterpart of an ore shard: light, not stone. */
@keyframes gs-mt-ember-fly {
  0% { transform: translate(0, 0); opacity: 1; }
  55% { transform: translate(var(--sx, 0), var(--sy, -20px)); opacity: 1; }
  100% { transform: translate(var(--sx, 0), calc(var(--sy, -20px) + 22px)); opacity: 0; }
}
.gs-mt-ember {
  position: absolute;
  width: 2px;
  height: 2px;
  background: var(--gs-tier-color, #ffe9a8);
  box-shadow: 0 0 3px var(--gs-tier-color, #ffe9a8);
  animation: gs-mt-ember-fly 900ms ease-out both;
  pointer-events: none;
}

/* The big brother of a twinkle mote — one bright starburst at the moment of payoff,
   a 9px diamond with a hot core that pops past full size and collapses. */
@keyframes gs-mt-star-pop {
  0% { transform: rotate(45deg) scale(0); opacity: 0; }
  35% { transform: rotate(45deg) scale(1.4); opacity: 1; }
  100% { transform: rotate(45deg) scale(0.1); opacity: 0; }
}
.gs-mt-star {
  position: absolute;
  width: 9px;
  height: 9px;
  background: radial-gradient(circle at center, #ffffff 0%, var(--gs-tier-color, #ffe9a8) 55%);
  box-shadow: 0 0 8px 2px var(--gs-tier-color, #ffe9a8);
  animation: gs-mt-star-pop 550ms ease-out both;
  pointer-events: none;
  z-index: 5;
}

/* --- The sea serpent (fishing ambush) --------------------------------------------------- */

/* Overflow-hidden window over the open water — the serpent climbs into view inside it,
   so the rise reads as breaching the surface rather than fading in. */
.gs-serpent-mask {
  position: absolute;
  overflow: hidden;
  pointer-events: none;
  z-index: 3;
}

@keyframes gs-serpent-rise {
  from { translate: 0 105%; }
  to { translate: 0 0; }
}
/* Independent rotate property so the sway composes under the hit-flash filter and any
   transform-based jab without clobbering either. */
@keyframes gs-serpent-sway {
  0%, 100% { rotate: -3deg; }
  50% { rotate: 3deg; }
}
@keyframes gs-serpent-sink {
  from { translate: 0 0; opacity: 1; }
  to { translate: 0 108%; opacity: 0.4; }
}
.gs-serpent {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 88px;
  height: 88px;
  margin-left: -44px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center bottom;
  image-rendering: pixelated;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.55));
}
.gs-serpent.gs-serpent-rise {
  animation: gs-serpent-rise 700ms ease-out forwards, gs-serpent-sway 2.2s ease-in-out 700ms infinite;
}
.gs-serpent.gs-serpent-sink {
  animation: gs-serpent-sink 900ms ease-in forwards;
}

/* --- Tome gump (views/partials/tomeGump.ejs, public/js/tome.js) ---
   --tome-accent is set per-open from the tome type's own colour (config/tomes.js), so
   one rule set serves all three and the hue is what tells them apart. */
.tome-gump {
  width: 460px;
  max-width: 92vw;
  --tome-accent: #c9a227;
}
.tome-gump-header {
  border-bottom: 2px solid var(--tome-accent);
}
.tome-gump-body {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px;
}
.tome-gump-actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
}
.tome-gump-btn {
  background: rgba(0, 0, 0, 0.3);
  color: var(--ink, #e8e2d6);
  border: 1px solid var(--tome-accent);
  border-radius: 6px;
  padding: 7px 14px;
  font: inherit;
  cursor: pointer;
}
.tome-gump-btn:hover { background: rgba(255, 255, 255, 0.07); }
.tome-gump-count { font-size: calc(12px * var(--ui-font-scale, 1)); opacity: 0.7; }
/* Filter box (tomeGump.ejs, driven by tome.js's tomeRenderList). Full width of the body
   so it reads as filtering the list beneath it rather than as another action button. */
.tome-gump-filter {
  background: rgba(0, 0, 0, 0.35);
  color: var(--ink, #e8e2d6);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  padding: 6px 8px;
  font: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  width: 100%;
  box-sizing: border-box;
}
.tome-gump-filter:focus {
  outline: none;
  border-color: var(--tome-accent);
}

/* Exactly twelve rows, then scroll. The cap is expressed in ROWS, not a viewport
   fraction, so a full tome reads the same on every screen — the old 52vh showed six rows
   on a laptop and twenty on a tall monitor.
   For "12 rows" to mean twelve rows, a row's height has to be knowable, so .tome-row
   below fixes its own height rather than letting its tallest child (the icon, or the
   Take button on a row that has one) decide it. Keep the two in step: --tome-row-h must
   equal .tome-row's height, which is border-box and so already includes its
   border-bottom — adding the border on top of it here would show 12.3 rows, not 12. */
.tome-gump-list {
  display: flex;
  flex-direction: column;
  --tome-row-h: 40px; /* == .tome-row height, border-box (border-bottom included) */
  max-height: calc(12 * var(--tome-row-h));
  overflow-y: auto;
}
.tome-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 4px;
  height: 40px;
  box-sizing: border-box;
  flex: none; /* the list is a flex column — without this, rows squash instead of scrolling */
  border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.tome-row-icon { width: 28px; height: 28px; image-rendering: pixelated; flex: none; }
/* Dye Tub Tome only — the dye's actual colour beside its name. */
.tome-row-swatch {
  width: 16px;
  height: 16px;
  flex: none;
  border-radius: 3px;
  border: 1px solid rgba(0, 0, 0, 0.6);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
}
.tome-row-name { flex: 1; font-size: calc(13px * var(--ui-font-scale, 1)); }
/* --- Rune book gump (views/partials/runebookGump.ejs, public/js/runebook.js) ---
   Reuses the whole .tome-gump / .tome-row rule set above, including --tome-accent, since
   the two are the same shape of thing: a book that lists what is filed inside it. Only
   the parts a tome has no concept of are here. */
.runebook-charges {
  font-size: calc(12px * var(--ui-font-scale, 1));
  margin-left: auto;
  color: var(--tome-accent);
  font-weight: 600;
}
/* Drag-to-reorder (public/js/runebook.js). The handle is the one draggable element on a
   row — see the client's own note on why the row itself must not be. The drop markers are
   an inset accent bar on whichever edge the drag will land, the standard sortable read,
   in the same --tome-accent the rest of the gump is tinted with. */
.runebook-row-handle {
  cursor: grab;
  color: var(--muted);
  font-size: calc(13px * var(--ui-font-scale, 1));
  padding: 0 4px 0 1px;
  user-select: none;
  flex-shrink: 0;
}
.runebook-row-handle:hover { color: var(--tome-accent, #b8a06a); }
.runebook-row-dragging { opacity: 0.45; }
.runebook-row-drop-before { box-shadow: inset 0 2px 0 var(--tome-accent, #b8a06a); }
.runebook-row-drop-after { box-shadow: inset 0 -2px 0 var(--tome-accent, #b8a06a); }

/* The pager — Prev / "Page 2 / 3" / Next, shown only past ten runes. */
.runebook-pager {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 8px;
}
.runebook-pager-label {
  color: var(--muted);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.runebook-carry-note {
  font-size: calc(12px * var(--ui-font-scale, 1));
  opacity: 0.7;
  font-style: italic;
  padding: 2px 4px 6px;
}
/* A rune filed before it was ever marked. It still belongs in the book — it is just not a
   destination yet, and its row carries no Recall button to explain the difference. */
.runebook-row-blank { opacity: 0.55; font-style: italic; }
.runebook-charge-btn { padding: 7px 10px; }
.runebook-recall-btn:disabled,
.runebook-charge-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.tome-row-amount { font-size: calc(12px * var(--ui-font-scale, 1)); opacity: 0.75; min-width: 38px; text-align: right; }
.tome-row-controls { display: flex; align-items: center; gap: 5px; flex: none; }
.tome-row-qty {
  width: 62px;
  background: rgba(0, 0, 0, 0.35);
  color: var(--ink, #e8e2d6);
  border: 1px solid rgba(255, 255, 255, 0.18);
  border-radius: 4px;
  padding: 4px 5px;
  font: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
}
.tome-row-take {
  background: rgba(0, 0, 0, 0.3);
  color: var(--ink, #e8e2d6);
  border: 1px solid var(--tome-accent);
  border-radius: 4px;
  padding: 4px 10px;
  font: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}
.tome-row-take:hover { background: rgba(255, 255, 255, 0.08); }
.tome-gump-empty { font-size: calc(13px * var(--ui-font-scale, 1)); opacity: 0.6; padding: 14px 4px; text-align: center; }

/* --- GM Fight Panel (views/partials/adminFightGump.ejs, public/js/adminFight.js) ------
   Staff testing tool. Reuses .skills-gump's chrome so it inherits the drag/position
   behaviour every other gump has; only the list body below is bespoke. */
.admin-fight-gump {
  width: 560px;
  max-width: 94vw;
  display: flex;
  flex-direction: column;
  max-height: 80vh;
}

.admin-fight-note {
  padding: 10px 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.5;
  color: var(--text-dim, #b3a58a);
  border-bottom: 1px solid var(--edge, #4a3b28);
}

.admin-fight-controls {
  display: flex;
  gap: 8px;
  padding: 10px 14px 6px;
}

.admin-fight-search { flex: 1 1 auto; min-width: 0; }
.admin-fight-search,
.admin-fight-select {
  padding: 6px 9px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--text, #e8dcc0);
  background: var(--panel-deep, #241a10);
  border: 1px solid var(--edge, #4a3b28);
  border-radius: 4px;
}
.admin-fight-select { flex: 0 0 auto; }
.admin-fight-search:focus,
.admin-fight-select:focus { outline: none; border-color: var(--accent, #d8a55c); }

.admin-fight-paragon {
  display: flex;
  align-items: center;
  gap: 7px;
  padding: 2px 14px 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}
.admin-fight-paragon em { color: var(--text-dim, #b3a58a); font-style: normal; }

.admin-fight-count {
  padding: 0 14px 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--text-dim, #b3a58a);
}

.admin-fight-list {
  overflow-y: auto;
  padding: 0 14px 14px;
  /* The panel is capped at 80vh and the header/controls are fixed, so this is the only
     thing that scrolls — a 99-row list has to live somewhere. */
  flex: 1 1 auto;
}

.admin-fight-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid rgba(74, 59, 40, 0.5);
}

.admin-fight-info { flex: 1 1 auto; min-width: 0; }
.admin-fight-name { font-size: calc(13px * var(--ui-font-scale, 1)); display: flex; align-items: center; gap: 8px; }
.admin-fight-stats,
.admin-fight-found { font-size: calc(11px * var(--ui-font-scale, 1)); color: var(--text-dim, #b3a58a); margin-top: 2px; }
.admin-fight-found-none { color: #c98b6b; }

.admin-fight-tier-badge {
  font-size: calc(9.5px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.6px;
  padding: 1px 6px;
  border-radius: 999px;
  border: 1px solid var(--edge, #4a3b28);
  color: var(--text-dim, #b3a58a);
}
/* Difficulty reads at a glance without needing the label — green through red across the
   nine tiers in config/monsters.js. Theme-independent on purpose: these are severity
   colours, not brand colours, and must not change meaning with the palette. */
.admin-fight-tier-0, .admin-fight-tier-1 { color: #7fd1a8; border-color: #3c6b55; }
.admin-fight-tier-2, .admin-fight-tier-3 { color: #d8c26a; border-color: #6b6033; }
.admin-fight-tier-4, .admin-fight-tier-5 { color: #e0a854; border-color: #7a5a2c; }
.admin-fight-tier-6, .admin-fight-tier-7 { color: #e0793f; border-color: #7a4220; }
.admin-fight-tier-8 { color: #e05f5f; border-color: #7a2f2f; }

.admin-fight-btn {
  flex: 0 0 auto;
  padding: 6px 14px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
  color: var(--on-accent, #241a10);
  background: var(--accent, #d8a55c);
  border: 1px solid var(--accent, #d8a55c);
  border-radius: 4px;
}
.admin-fight-btn:hover { filter: brightness(1.1); }

.admin-fight-empty {
  padding: 18px 0;
  text-align: center;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--text-dim, #b3a58a);
}

/* --- Words of power (public/js/spellWords.js) -----------------------------------------
   The spell's incantation, spoken above your head as you cast — "Kal Ort Por" for a
   Recall. Attached as a child of whichever player sprite is on screen, so it follows the
   character as they walk rather than needing its own position tracking. */

/* THE FONT NEEDS ONE FILE DROPPED IN, and until then this is a plain serif.

   The look asked for is blackletter. This project bundles no font files, and measuring the
   candidates in a real browser showed the uncomfortable truth: NONE of the usual
   blackletter faces are reliably installed — not UnifrakturMaguntia, not Old English Text
   MT, not Blackadder ITC, not Luminari. A width fingerprint against a deliberately
   nonexistent family confirmed every one of them falls through, landing on Georgia.
   (document.fonts.check() is no help here — it returns true for anything usable via
   fallback, so it reports fonts you do not have as present.)

   So the @font-face below is the actual fix, and it is already wired: drop
   UnifrakturMaguntia.woff2 into public/assets/fonts/ and every player gets true
   blackletter with no other change. It is SIL OFL licensed and free to redistribute. A
   missing file fails silently and falls through the stack, which is why this can ship
   before the file exists rather than 404-ing noisily.

   The named faces after it are kept as a free upgrade for whoever happens to have one
   installed; Georgia is the floor everyone actually hits today. */
@font-face {
  font-family: 'TIS Blackletter';
  src: url('/assets/fonts/UnifrakturMaguntia.woff2') format('woff2');
  font-display: swap;
}
.spell-words {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-bottom: 6px;
  font-family: 'TIS Blackletter', 'UnifrakturMaguntia', 'Old English Text MT', 'Blackadder ITC', 'Luminari', Georgia, serif;
  font-size: calc(15px * var(--ui-font-scale, 1));
  line-height: 1.1;
  white-space: nowrap;
  /* Words are short and the scene behind them is busy, so legibility comes from a dark
     outline rather than a panel — a background plate would sit on the character's head. */
  color: #f4e6c4;
  text-shadow:
    0 0 3px rgba(0, 0, 0, 0.95),
    1px 1px 0 rgba(0, 0, 0, 0.9),
    -1px 1px 0 rgba(0, 0, 0, 0.9),
    1px -1px 0 rgba(0, 0, 0, 0.9),
    -1px -1px 0 rgba(0, 0, 0, 0.9);
  /* Never intercepts a click meant for the character or the tile under it. */
  pointer-events: none;
  z-index: 20;
  animation: spell-words-rise 2600ms ease-out forwards;
}

/* Rises and fades — spoken, then gone. The hold in the middle is what makes three words
   readable; a straight fade from the first frame is gone before the eye reaches them. */
@keyframes spell-words-rise {
  0%   { opacity: 0; transform: translateX(-50%) translateY(6px); }
  12%  { opacity: 1; transform: translateX(-50%) translateY(0); }
  70%  { opacity: 1; transform: translateX(-50%) translateY(-6px); }
  100% { opacity: 0; transform: translateX(-50%) translateY(-16px); }
}

@media (prefers-reduced-motion: reduce) {
  .spell-words { animation: spell-words-fade 2600ms ease-out forwards; }
  @keyframes spell-words-fade {
    0%, 70% { opacity: 1; }
    100% { opacity: 0; }
  }
}

/* --- Display Case (config/displayCase.js) --------------------------------------------
   A house placeable that holds two carried craft-buff charms and keeps their bonus running
   while you craft at home. Two visual halves: the composited floorplan sprite, and the
   two-slot gump you load it from. */

/* The charms' OWN icons, drawn over the case sprite on the floorplan
   (public/js/houseDeed.js). Half-cell each so two sit side by side inside the same 32px
   station box, which is what lets a case holding a hammer read differently from one
   holding a thimble with no bespoke art for either. pointer-events off so the case itself
   still takes the double-click. */
.house-view-case-item {
  position: absolute;
  bottom: 2px;
  width: 14px;
  height: 14px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  image-rendering: pixelated;
  pointer-events: none;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7));
}
.house-view-case-item.slot-0 { left: 2px; }
.house-view-case-item.slot-1 { right: 2px; }

/* A Hitching Post's (config/hitchingPost.js) held mounts — the same at-a-glance read as
   .house-view-case-item above, and now display-only for the same reason: unhitching moved
   into the post's gump (public/js/hitchingPost.js) because right-clicking an icon this size
   was a coin flip between "unhitch" and the post's own "Reclaim".

   Bigger than a case's charms at 18px, and anchored 2px PROUD of the 32px cell on each side
   rather than 2px inside it, so the pair spans the full width with no overlap and a mount
   actually reads as a horse. Nothing is aiming at them any more, so overhanging the sprite
   costs nothing — and pointer-events:none means the post underneath keeps every click. */
.house-view-hitch-item {
  position: absolute;
  bottom: 2px;
  width: 18px;
  height: 18px;
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  image-rendering: pixelated;
  pointer-events: none;
  filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.7));
}
.house-view-hitch-item.slot-0 { left: -2px; }
.house-view-hitch-item.slot-1 { right: -2px; }

/* --- Display Case and Hitching Post gumps ---------------------------------------------
   Two fixtures, one set of rules. The Hitching Post's gump (views/partials/
   hitchingPostGump.ejs) is the Display Case's interaction exactly — a pair of wells you
   drag into and click to empty — so its selectors join these rather than copying them, and
   the two can't drift apart visually. The ONLY thing that differs is what a filled well
   says underneath the name: a buff label for a charm, frozen time left for a mount. */
/* --- Shared body for the house-fixture gumps ------------------------------------------
   .skills-gump itself carries NO padding — every gump pads its own content, and the
   fixtures that hold things (Display Case, Hitching Post, Trash Barrel, Power Potion
   Shelf) were each missing it, so their wells and lists ran flush to the panel edge while
   the Craft Station and Cooking Stove beside them sat comfortably inset.

   One padded wrapper around the whole body rather than a padding rule per row, which is
   the same call .cooking-stove-body's own note records making — patching each element
   individually is how the Trash Barrel ended up with a hint that was inset and a list that
   was not. Matches .craft-station-gump-body's 12px 14px 14px exactly so a fixture panel
   is a fixture panel whichever one you opened.

   The header stays OUTSIDE this wrapper on purpose: its own padding and full-width
   bottom border are what make the title bar read as a bar. */
.fixture-gump-body {
  padding: 12px 14px 14px;
}

.display-case-gump,
.hitching-post-gump { width: 340px; }

/* Hints and labels across these panels read at --ink (the body-text colour) rather than
   --muted. Muted grey is right for a caption sitting on a dark panel, but half of these
   labels sit on the fixtures' own WOOD art — the shelf's flavour names, the Craft
   Station's shelf caption — where a low-contrast grey on mid-brown was genuinely hard to
   read. Using the theme's ink token rather than a literal #fff keeps every palette
   (Cold, Mystic, the wood/forge/hearth station skins) recolouring these with everything
   else instead of one hardcoded white fighting them. */
.display-case-hint,
.hitching-post-hint {
  margin: 0 0 10px;
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.45;
}

.display-case-slots,
.hitching-post-slots {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
}

.display-case-slot,
.hitching-post-slot {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  min-height: 96px;
  padding: 10px 6px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 6px;
  text-align: center;
}

/* An empty slot is a dashed well — the same "this is a drop target, not a thing" language
   the unlocked-station outline above uses. */
.display-case-slot.empty,
.hitching-post-slot.empty {
  border-style: dashed;
  justify-content: center;
  color: var(--ink);
}

.display-case-slot.filled,
.hitching-post-slot.filled {
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.display-case-slot.filled:hover,
.hitching-post-slot.filled:hover {
  border-color: var(--accent);
}

.display-case-slot img,
.hitching-post-slot img {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
}

.display-case-slot-name,
.hitching-post-slot-name {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.3;
}

.display-case-slot-buff,
.hitching-post-slot-time {
  color: var(--accent);
  font-size: calc(11px * var(--ui-font-scale, 1));
}

/* --- Power Potion Shelf gump (views/partials/powerPotionShelfGump.ejs) ----------------
   Three rows of five wells, laid over a tiled board sprite the same way the Cooking Stove's
   spice rack lays its one slot over cooking_stove_rack.png. The grid is NOT hardcoded to
   3x5 here: rows come from the server (config/powerPotionShelf.js derives them from
   config/alchemy.js's tiers x flavors) and the columns use auto-fit, so a sixth flavor
   widens the row without a CSS edit. */
.power-potion-shelf-gump {
  width: 460px;
}

.power-potion-shelf-hint {
  margin: 0 0 10px;
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.45;
}

.power-potion-shelf-rows {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.pps-row {
  display: flex;
  align-items: flex-end;
  gap: 8px;
}

/* The tier name, rotated out of the way of the shelf itself — "Lesser/Regular/Greater"
   reads down the left edge like the spine labels on a real rack, and keeps all five wells
   at full width rather than surrendering a column to text. */
.pps-row-label {
  flex: 0 0 auto;
  width: 54px;
  padding-bottom: 16px;
  color: var(--ink);
  font-size: calc(11px * var(--ui-font-scale, 1));
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-align: right;
}

/* height MUST equal tools/generateSprites.js's PPS_ROW_H — the board art is a fixed-height
   repeating strip, and a mismatch slides the wells off the plank they are meant to stand
   on. background-repeat: repeat-x so a wider gump tiles more board rather than stretching
   and blurring the grain. */
.pps-row-board {
  flex: 1 1 auto;
  height: 62px;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(46px, 1fr));
  align-items: end;
  gap: 2px;
  padding: 0 10px 13px;
  background-image: url('/assets/sprites/power_potion_shelf_board.png');
  background-repeat: repeat-x;
  background-size: auto 100%;
  image-rendering: pixelated;
}

/* Drop-target highlight — the same outline every other drop zone uses
   (.bag-panel.drag-over, .craft-station-input-list.drag-over, .cooking-stove-rack.drag-over). */
.power-potion-shelf-rows.drag-over .pps-row-board {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.pps-well {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  height: 44px;
}

.pps-well img {
  width: 30px;
  height: 30px;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/grab.png') 16 16, grab;
}

.pps-well.filled:hover img {
  filter: brightness(1.25);
}

/* An empty place still shows a faint dashed well AND names the flavor that belongs there —
   the same "this is a place, not a thing" language .display-case-slot.empty uses, but the
   label is what makes the shelf read as a collection with gaps rather than a blank grid. */
.pps-well.empty::before {
  content: '';
  width: 24px;
  height: 28px;
  border: 1px dashed rgba(255, 255, 255, 0.22);
  border-radius: 3px;
}

/* These sit ON the board art, not on the panel, so they carry a shadow rather than relying
   on panel contrast. Filled and empty read the SAME colour — the dashed well above an
   empty one is already the state cue, and dimming the label as well made the flavour names
   on a mostly-empty shelf (which is every new shelf) the hardest text in the panel to
   read. */
.pps-well-flavor {
  margin-top: 2px;
  font-size: calc(9px * var(--ui-font-scale, 1));
  line-height: 1;
  letter-spacing: 0.02em;
  color: var(--ink);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
}

/* Stack count, bottom-right of the bottle — same placement and weight as the backpack's
   own stack badge, so a number on a shelf means what it means everywhere else. */
.pps-well-count {
  position: absolute;
  right: 2px;
  bottom: 12px;
  padding: 0 3px;
  border-radius: 3px;
  background: rgba(0, 0, 0, 0.75);
  color: #fff;
  font-size: calc(10px * var(--ui-font-scale, 1));
  line-height: 1.4;
  pointer-events: none;
}

.power-potion-shelf-total {
  margin-top: 10px;
  padding-top: 8px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

/* --- Cooking Stove gump (views/partials/cookingStoveGump.ejs) -------------------------
   Four fixed recipe rows, each an icon, its effect and cost, and a Cook button. Rows are
   built by public/js/cookingStove.js from the server payload, so this only styles them. */
.cooking-stove-gump {
  min-width: 400px;
  max-width: 480px;
}

/* Everything below the header sat with zero horizontal padding of its own, each row's
   text and icons running flush to the panel's edges — reported as "a bit tight". One
   padded wrapper around the whole body fixes every row at once, rather than patching
   margins onto each of hint/stock/list individually. */
.cooking-stove-body {
  padding: 12px 18px 16px;
}

.cooking-stove-hint {
  margin: 0 0 10px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
  line-height: 1.5;
}

/* Shown when the player is under 100 Cooking — the gump still opens so they can read what
   the recipes want, and this is what says why nothing is cookable. */
.cooking-stove-hint-warn {
  color: var(--warn, #d9a441);
}

/* The spice rack (tools/generateSprites.js's drawSpiceRack) — a single drop slot for
   Common Spices, stocked ahead of a session instead of dug out of the pack every cook.
   aspect-ratio locks it to the art's own 320x60 proportions at whatever width the gump's
   padded body actually gives it, so the shelf and its brackets never stretch out of shape
   the way a bare 100%/100% background-size would if the two widths ever drifted apart. */
.cooking-stove-rack {
  position: relative;
  width: 100%;
  aspect-ratio: 320 / 60;
  margin-bottom: 12px;
  background-image: url('/assets/sprites/cooking_stove_rack.png');
  background-size: 100% 100%;
  image-rendering: pixelated;
}

/* Drop-target highlight — same outline every other drop zone in the game uses
   (.bag-panel.drag-over, .craft-station-input-list.drag-over). */
.cooking-stove-rack.drag-over {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* Purely the icon/badge host — centered on the shelf's own surface band, which sits
   slightly above the art's vertical middle (the brackets and shadow live below it). */
.cooking-stove-rack-slot {
  position: absolute;
  top: 44%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.cooking-stove-rack-spice {
  width: 34px;
  height: 34px;
  image-rendering: pixelated;
  cursor: url('/assets/sprites/cursors/grab.png') 16 16, grab;
}

/* An empty rack still shows a faint outline where a stack would land — the same
   "this is a drop target, not a thing" dashed-well language .display-case-slot.empty
   and a locked Gather Bag slot both already use. */
.cooking-stove-rack-slot-empty {
  width: 36px;
  height: 36px;
  border: 1px dashed rgba(255, 255, 255, 0.28);
  border-radius: 4px;
}

.cooking-stove-stock {
  margin-bottom: 10px;
  padding-bottom: 8px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.08);
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.cooking-stove-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.cooking-stove-row:last-child {
  border-bottom: none;
}

.cooking-stove-icon {
  width: 32px;
  height: 32px;
  image-rendering: pixelated;
  flex: 0 0 auto;
}

/* min-width:0 so a long effect line wraps inside the row instead of widening the gump —
   the same intrinsic-sizing trap the buff bar hit. */
.cooking-stove-text {
  flex: 1 1 auto;
  min-width: 0;
}

.cooking-stove-name {
  font-size: calc(13px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

.cooking-stove-effect {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.cooking-stove-cost {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.cooking-stove-cook-btn {
  flex: 0 0 auto;
  padding: 5px 14px;
  background: var(--accent);
  border: none;
  border-radius: 3px;
  color: var(--on-accent);
  font-family: inherit;
  font-weight: bold;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
}

.cooking-stove-cook-btn:disabled {
  background: var(--edge);
  color: var(--muted);
  cursor: default;
}

/* An ingredient line the player is short of. Marked per line rather than reddening the
   whole row: a Fish Feast wants two different fish, and knowing WHICH one is missing is
   the entire point of listing them separately. */
.cooking-stove-short {
  color: var(--danger, #d9534f);
}

/* --- Exchange trade counters ----------------------------------------------------------
   Shared by every shop that takes a stack of one thing and hands back another: the Foreign
   Exchange's two counters (views/partials/foreignExchangeGump.ejs) and the Merchant and
   Dungeoneer counters (views/partials/tokenExchangeGump.ejs).

   Laid out like the Mage Shop's reagent stall (.mage-reagent-row further up this file): a
   heading stating the rate, then ONE grid row per counter carrying icon, holding, slider,
   quote and button.

   A grid for the same reason that stall is one — those five things have to line up ACROSS
   counters, which a shared column template guarantees and stacked flex blocks cannot. All
   three counters WERE stacked blocks under their own .foreign-exchange-* rules: paragraph,
   holding line, full-width slider, centred readout, full-width button, italic note, six
   elements to say one thing. That read fine with a single counter per panel and made the
   Foreign Exchange taller than the map the moment it grew a second. Those rules are gone —
   nothing referenced them once both panels moved here. */
.exchange-counter-gump {
  width: 420px;
}

.exchange-counter-body {
  padding: 12px 14px 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* The dividing rule belongs to the SECOND counter rather than to a separator element
   between them, so it can never be left painting a line under a counter that has nothing
   following it. */
.exchange-counter + .exchange-counter {
  border-top: 1px solid var(--edge);
  padding-top: 12px;
}

.exchange-counter-header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 6px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

/* The price, said once per counter rather than once per control. */
.exchange-counter-rate {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--accent);
  text-transform: none;
  letter-spacing: 0;
  white-space: nowrap;
}

/* Sized for the widest thing each column ever holds: a hundred-scroll quote
   ("100 -> +100.0") in the readout, "Carrying 100 scrolls" in the 1fr. The label is the
   column that gives — it ellipses at a large --ui-font-scale rather than shoving the
   button off the panel, exactly as the reagent row's name column does. */
.exchange-counter-row {
  display: grid;
  grid-template-columns: 22px 1fr 78px 86px auto;
  align-items: center;
  gap: 7px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.exchange-counter-icon {
  width: 22px;
  height: 22px;
  image-rendering: pixelated;
}

/* The token counters fill their icon in from the payload, so the element exists before it
   has anything to show. visibility, NOT display or [hidden]: this is a grid item, and
   removing it from the grid would slide every cell after it one column to the left. */
.exchange-counter-icon:not([src]) {
  visibility: hidden;
}

/* The vendor's own line, above the heading. The Foreign Exchange's counters have none —
   their heading and rate say everything there is to say — but one panel serves both the
   Merchant and the Dungeoneer, and this sentence is the only thing on it that tells them
   apart once it is open. */
.exchange-counter-blurb {
  margin: 0 0 10px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.45;
  color: var(--muted);
}

.exchange-counter-carried {
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.exchange-counter-slider {
  width: 78px;
  accent-color: var(--accent);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.exchange-counter-slider:disabled {
  opacity: 0.45;
  cursor: default;
}

/* The trade, stated in one span: what goes in, what comes out. Right-aligned and tabular
   so the arrow holds still instead of shuffling sideways as the slider moves. */
.exchange-counter-readout {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 5px;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}

.exchange-counter-arrow {
  color: var(--muted);
}

.exchange-counter-gain {
  color: var(--accent);
  font-weight: bold;
}

.exchange-counter-btn {
  padding: 3px 10px;
  background: var(--accent);
  border: none;
  border-radius: 4px;
  color: var(--on-accent);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: pointer;
}

.exchange-counter-btn:disabled {
  background: var(--edge);
  color: var(--muted);
  cursor: default;
}

.exchange-counter-note {
  margin: 6px 0 0;
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.45;
  font-style: italic;
  color: var(--muted);
  opacity: 0.85;
}

/* --- Repair Bench totals + Repair All (views/partials/repairBenchGump.ejs) ------------
   Sits under the list, above the hint. The button reuses the shared row-button styling
   further up this file rather than restating it; only layout lives here. */
.repair-bench-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 10px 14px 0;
  padding-top: 10px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.repair-bench-totals {
  display: flex;
  flex-direction: column;
  gap: 2px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  min-width: 0;
}

/* One line per material — cost on the left, what you are carrying on the right. */
.repair-bench-total {
  display: flex;
  gap: 8px;
  align-items: baseline;
  color: var(--accent);
}

.repair-bench-total-carried {
  color: var(--muted);
}

/* Short of this material: the whole line goes warning-red, so the shortfall is visible
   without reading the numbers against each other. */
.repair-bench-total.short {
  color: var(--danger, #d9534f);
}

.repair-bench-total.short .repair-bench-total-carried {
  color: var(--danger, #d9534f);
  opacity: 0.85;
}

.repair-bench-all-btn {
  flex: 0 0 auto;
  padding: 6px 12px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: pointer;
}

/* --- House Access gump (views/partials/houseAccessGump.ejs) ---------------------------
   The owner's controls for who may visit and what they may do (services/houseAccess.js). */
.house-access-gump {
  width: min(420px, 92vw);
  background: var(--panel);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
}

.house-access-gump-body {
  padding: 12px 14px 14px;
  max-height: min(520px, 70vh);
  overflow-y: auto;
}

.house-access-section + .house-access-section {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
}

.house-access-section-title {
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 6px;
}

/* The two blocks of explanatory prose. Deliberately present rather than trimmed: co-owner
   scope and the mutual-friend rule are the two things about this feature a player cannot
   infer from the controls, and the second is the one most likely to be reported as a bug. */
.house-access-note {
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.5;
  color: var(--muted);
  margin-bottom: 10px;
}

.house-access-empty {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
  padding: 4px 0 8px;
}

.house-access-coowner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 5px 8px;
  margin-bottom: 4px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
}

.house-access-coowner-name {
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
}

.house-access-add {
  display: flex;
  gap: 6px;
  margin-top: 8px;
}

.house-access-add-input {
  flex: 1 1 auto;
  min-width: 0;
  padding: 5px 8px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--ink);
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
}

.house-access-add-input:focus {
  outline: none;
  border-color: rgba(var(--accent-rgb), 0.6);
}

/* Add, beside that input — it had no rule at all and was rendering as a raw browser
   button. Padding and radius are the input's own so the pair reads as one control: they
   sit in a flex row and any difference in height shows immediately. `flex: 0 0 auto`
   because the input is `1 1 auto` and would otherwise squeeze the word. */
.house-access-add-btn {
  flex: 0 0 auto;
  padding: 5px 12px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
  white-space: nowrap;
  color: var(--accent);
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.house-access-add-btn:hover {
  color: var(--accent-bright);
  border-color: rgba(var(--accent-rgb), 0.55);
  background: rgba(var(--accent-rgb), 0.1);
}

/* Matching the input's own focus ring rather than the browser's, which the input beside it
   has already suppressed — one of the two showing an outline would look like a bug. */
.house-access-add-btn:focus-visible {
  outline: none;
  border-color: rgba(var(--accent-rgb), 0.6);
}

/* Remove, on a co-owner row. Same omission as Add, and handing back the keys deserves the
   destructive treatment the rest of the game uses for it (.market-abandon-btn) rather than
   the accent, which would read as an invitation. */
.house-access-remove-btn {
  flex: 0 0 auto;
  padding: 3px 9px;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  white-space: nowrap;
  color: #e08a8a;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(201, 60, 60, 0.6);
  border-radius: 4px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.house-access-remove-btn:hover {
  color: #f0a5a5;
  background: rgba(201, 60, 60, 0.15);
}

/* One block per storey. Each floor already has its own plan and its own chest, which is
   what makes per-floor rules meaningful rather than fiddly. */
.house-access-floor {
  padding: 8px 10px;
  margin-bottom: 8px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 6px;
}

.house-access-floor-title {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
  margin-bottom: 6px;
}

/* Two columns: five toggles in one column would run the gump too tall to see a whole
   floor's rules at once, which is exactly the comparison an owner is making. */
.house-access-toggles {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 4px 10px;
}

.house-access-toggle {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--ink-dim);
  cursor: pointer;
}

.house-access-toggle input {
  cursor: pointer;
  accent-color: var(--accent-strong);
}

/* --- House View header additions (views/partials/worldMap.ejs) ----------------------- */
/* Shown only to a guest — whose house this is and what this floor allows them. Hidden in
   your own house, which must look exactly as it always has. */
.house-view-guest-banner {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--muted);
  font-style: italic;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}

/* Visit, on a friends row (public/js/friends.js). Disabled — not hidden — for a one-way
   friendship, because "I added them, why can't I visit" is the most predictable question
   this feature raises and a greyed button with a reason on hover answers it far better
   than an absent one. */
.friends-visit-btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* --- Full-screen floor plan backdrop (models/Character.js's floorplanBackdrop) ---------
   Opt-in display mode: run the dungeon floor plan behind the whole interface instead of
   inside its panel. Nothing about the floor plan changes — same element, same renderer,
   same camera. It is only given a different box to live in.

   That works because the camera is already size-agnostic: public/js/dungeonFloorplan.js
   centres on `dfpBoard.clientWidth / 2`, so handing it the viewport instead of a 460px
   panel needs no JS at all. Had it hardcoded its dimensions this would have been a
   rewrite rather than a stylesheet.

   Only ever visible where a floor plan exists — 12 dungeons against 34 backdrops — so
   everywhere else this mode is set and simply has nothing to show, and the painterly
   backdrop renders exactly as it always has. */
body.floorplan-backdrop .dungeon-floorplan-view,
body.floorplan-backdrop .gather-scene-view,
body.floorplan-backdrop .treasure-site-scene-view {
  position: fixed;
  inset: 0;
  /* NO z-index, deliberately. A z-index here creates a stacking context, and a stacking
     context traps every descendant inside it — the header below carried z-index 2 and still
     painted underneath the interface at z-index 1, because its 2 was only ever compared
     against its own siblings. Left as auto, this still sits under .game-main (which IS
     positioned, at z-index 1) while the header can be compared against .game-main directly
     and win. */
  gap: 0;
  /* BLOCK, not the flex column it is in panel mode. As a flex item the board was being
     shrunk to fit its container's content rather than honouring its own 100vh — measured
     678px in a 982px viewport, a third of the screen missing. The header is absolutely
     positioned in this mode anyway, so there is nothing left for a flex column to arrange. */
  display: block;
  /* Out of the panel's flow entirely, so the World Map panel closes up around the gap
     rather than reserving 460px of nothing. */
  pointer-events: none;
}

body.floorplan-backdrop .dungeon-floorplan,
body.floorplan-backdrop .gather-scene {
  width: 100vw;
  height: 100vh;
  border: none;
  border-radius: 0;
  /* The board paints its own near-black ground, which is what actually replaces the
     backdrop image — no need to blank --backdrop-url and risk a flash of nothing while
     the mode is toggled off again. */
  pointer-events: auto;
}

/* The header and the boss bar are the two things that must NOT stretch with the board:
   fixed to the viewport they would otherwise sit marooned in opposite corners of the
   screen. Pinned to the top instead, centred, and floated over the plan. */
body.floorplan-backdrop .dungeon-floorplan-header,
body.floorplan-backdrop .gather-scene-header,
body.floorplan-backdrop .treasure-site-scene-header {
  position: fixed;
  /* TOP, hanging just under the header crest — was bottom: 64px, which sat right on top of
     the boss overlay (.dfp-boss-overlay, pinned 32px off the same edge) and covered the one
     line you actually want to read mid-fight.
     It has to clear .header-crest specifically, not just the header bar: the crest paints
     inside .game-header's z-index 60 and so beats this element's 3 outright, meaning "too
     high" here is INVISIBLE rather than merely overlapping. The variable is that crest's own
     measured bottom + 8px (public/js/options.js's fsSyncSceneHeaderTop), because its height
     is not a constant — the pet XP bar comes and goes with the active companion, and a guild
     tag plus a long name wraps its title row. The 147px fallback is the measured worst
     ordinary case (51px header + an 88px crest with a pet bar showing, + the 8px gap); with
     the variable set it lands at 131px on a crest with no pet out.
     Everything ELSE up here is a .game-main panel at z-index 1, which this wins against; the
     old "Stop Auto Battle is behind the pane" was the stacking-context trap since fixed by
     lifting the header to <body> (see options.js's fsPlace), not the top row being unusable. */
  top: var(--fs-scene-header-top, 147px);
  bottom: auto;
  /* .dungeon-floorplan-header's own margin, which only exists to clear the World Map panel's
     header in panel mode, would otherwise push this 8px off the number above. */
  margin-top: 0;
  left: 50%;
  transform: translateX(-50%);
  /* Above .game-main (z-index 1). Only reachable because the view no longer creates a
     stacking context to trap it — see the note there. */
  z-index: 3;
  gap: 12px;
  padding: 4px 12px;
  border-radius: 6px;
  background: rgba(11, 13, 18, 0.72);
  border: 1px solid var(--edge);
  pointer-events: auto;
}

/* Everything the player actually operates sits above the plan. One rule on the layout
   wrapper rather than a z-index on each panel — gumps and overlays already carry their
   own much higher values and are unaffected. */
body.floorplan-backdrop .game-main {
  position: relative;
  z-index: 1;
  /* The scene runs BEHIND this container (the view above is position: fixed with no z-index
     of its own, deliberately), so .game-main's stacking context sits over the whole floor
     plan. That is right for the panels, which must stay on top of it — and was wrong for
     every click, which landed on this container's empty space and never reached the ground.
     Reported as being unable to click a vein or walk anywhere in manual mining, and it is the
     same reason a wandering tradesman could not be clicked either.

     So the container itself is transparent to the pointer and its panels take it back,
     one level down. Exactly the pair .gather-scene-view / .gather-scene already uses for the
     same reason. A click in the gaps between panels falls through to the scene; a click on a
     panel still hits the panel. */
  pointer-events: none;
}
/* Every panel inside it, back to normal. Direct children only: the gaps this is opening up
   are between the panels, not inside them, and anything nested keeps inheriting `auto` from
   whichever panel it belongs to. */
body.floorplan-backdrop .game-main > * {
  pointer-events: auto;
}

/* Travel — the header's own button, and the way to the map now that the World Map panel
   is gone from the screen. Tinted with the accent rather than left as plain nav text
   because it is the only one of these that MOVES you somewhere; Options and Guild open a
   window you can close again. */
.travel-nav-button {
  color: var(--accent);
  border-color: rgba(var(--edge-rgb), 0.45);
}

.travel-nav-button:hover {
  color: var(--accent-bright);
  border-color: var(--accent);
}

/* The house room, standing in its own glade (public/js/gatherScene.js's gsMountHouseRoom).
   Reparented out of the Location panel into the scene's world and placed by hand at the
   squares the yard reserved for it, so it stops being a panel's content and becomes part of
   the map. Life size — the fit-to-panel scaling is off while this holds, because a house
   square and a glade square are both 32px and anything else would slide the door out of
   line with the gap in the wall it is drawn over. */
.house-room-fit.house-room-in-glade {
  position: absolute;
  width: max-content;
  height: auto;
  margin: 0;
  /* The base class clips (overflow: hidden) because in the PANEL the room is scaled to fit
     and anything past the box is spill. Out here nothing is scaled and the box is not a
     frame — it is a building standing in a field, and the signpost is planted in the grass
     in FRONT of it. Clipped, that post simply did not exist: it measured and reported a
     perfectly good rectangle, which is why looking at its geometry never found this. */
  overflow: visible;
  /* The room is scenery you walk into, not a control surface — but its floorplan very much
     is (items, stations, the chest, every drop target), so the opt-in goes back on there.
     Same idiom the rest of the scene's world layers use. */
  pointer-events: none;
}

.house-room-fit.house-room-in-glade #house-view-floorplan {
  pointer-events: auto;
}

/* Your own figure inside the room stands down while the room is in the glade. There is ONE
   of you out here and the scene already draws it: the same walker crosses the grass, steps
   through the door and carries on across the floorboards, because to the scene those are
   all just walkable cells. Two figures would be the same player drawn twice.

   Scoped off :not(.house-view-guest) deliberately — everyone ELSE in the house still draws
   from the floorplan's own roster, and because the room is now positioned in the world at
   life size, their house-grid coordinates already land them in exactly the right place with
   nothing to convert. */
body.house-in-glade .house-view-player:not(.house-view-guest) {
  display: none;
}

/* The Location panel with nothing to say.

   It was the World Map and stood there permanently; travel now lives on the header's own
   Travel button and what is left — your house, a treasure site, the location notes — only
   exists when you are actually standing in one of those. Rather than an empty box holding a
   header, it goes away, and public/js/houseDeed.js brings it back the moment there is
   something in it.

   A separate class from .panel-hidden on purpose: that one is the player's own choice from
   the Panels menu and is persisted per character, and this is the game deciding. Writing
   theirs from here would quietly overwrite it on every broadcast. */
.world-map-panel.location-panel-idle {
  display: none;
}

/* The signpost outside the front door (public/js/houseSign.js, markup in worldMap.ejs
   inside .house-room so it travels with the room into the glade).

   A real post, planted in the grass IN FRONT of the house rather than a plaque nailed to the
   wall — which is what it was first, and it read as a piece of interface stuck to the
   brickwork, the brightest thing on a screen of trees. Now it is scenery: the same pixel art
   as everything else in the scene (tools/generateHouseSign.js), standing on the ground a
   little to the door's right where a signpost goes.

   Positioned from the room's bottom edge, which sits 14px INTO the wall row — so bottom:-46px
   puts the foot of the post a clear cell out on the grass, past the wall and past the door
   step, with nothing overlapping the house itself.

   The board is deliberately blank. The name is read by hovering (that is what the tooltip is
   for, and how it was asked for); painted on, it is unreadable at 32px cells and turns the
   post back into a label. */
.house-sign {
  position: absolute;
  bottom: -50px;
  left: 50%;
  margin-left: 44px; /* clear of the 64px door, which is centred with -32px */
  width: 20px;
  height: 32px;
  background: url('/assets/sprites/floorplan/house_sign.png') no-repeat;
  background-size: 20px 32px;
  image-rendering: pixelated;
  /* Beds it into the grass. Pixel art this small loses its edge against a busy green field,
     and a drop shadow reads as the post casting one rather than as a UI glow. */
  filter: drop-shadow(1px 2px 1px rgba(0, 0, 0, 0.55));
  /* The board is BLANK — the name is the tooltip's job. The text stays in the DOM for screen
     readers, so it has to be made invisible rather than removed, and all three of these are
     load-bearing: without them the house's name spills down the post in plain white, which is
     exactly what it did when this rule was rewritten and they were dropped. */
  font-size: 0;
  line-height: 0;
  color: transparent;
  overflow: hidden;
  /* The room is scenery in the glade (pointer-events: none on .house-room-in-glade); the
     post is the one part of it you interact with, so it opts back in. */
  pointer-events: auto;
  z-index: 3;
}

/* Only the owner gets an affordance, because only the owner can act on it — anyone else
   double-clicking learns nothing. A soft warm lift rather than an outline: the post is pixel
   art with its own hard edge, and a border would fight it. */
.house-sign-owner {
  cursor: pointer;
}

.house-sign-owner:hover {
  filter: brightness(1.18) drop-shadow(0 0 3px rgba(232, 178, 96, 0.55));
}

/* The Location panel is a PANEL again while a scene is full-screen.

   There used to be a rule here stripping its width, padding, background, border and blur
   whenever `fs-scene-active` held, collapsing it to a bare header row. It was right when it
   was written: the panel was on screen permanently, and lifting the scene out of it left it
   holding nothing but a header over a stack of hidden views — a tall empty box taking a
   quarter of the screen.

   That premise is gone twice over. The scenes were lifted out of this panel entirely
   (views/game.ejs), and public/js/locationPanel.js now hides the panel outright the moment
   it has nothing to say — which handles the empty case properly rather than shrinking it.

   What the rule DID still catch was the one state nobody had when it was written: standing
   in your own house yard, where a scene is full-screen AND the panel has real contents (the
   house's name, Access, Upgrade, the capacity bar). It stripped the background out from
   under all of that and left the text floating over the grass. So it is gone: a panel with
   something in it should look like every other panel, whatever is behind it. */

/* The Backpack panel's drag-handle label, hidden once panels are locked.
   
   The handle exists only to be dragged (public/js/panelDock.js), so with dragging off it is
   a word floating over the bag art doing nothing. Scoped to .inventory-panel rather than
   every .panel-drag-handle on purpose: the World Map panel uses that same class for a
   header that also carries the Map button, and a blanket rule would hide the way out of the
   panel along with its title. */
body.panels-locked .inventory-panel > .panel-drag-handle {
  display: none;
}

/* --- GM Decayed Items panel (public/js/adminDecay.js, views/partials/adminDecayGump.ejs) --
   Cloned from the .admin-fight-* block above, which is the house shape for a staff
   list-with-search gump. Wider (620 vs 560) because every row carries a provenance line —
   "Lost by <name> at <place> · <date>" — that wraps badly at the Fight Panel's width. */
.admin-decay-gump {
  width: 620px;
  max-width: 94vw;
  display: flex;
  flex-direction: column;
  max-height: 80vh;
}

.admin-decay-note {
  padding: 10px 14px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  line-height: 1.5;
  color: var(--text-dim, #b3a58a);
  border-bottom: 1px solid var(--edge, #4a3b28);
}

.admin-decay-controls {
  display: flex;
  gap: 8px;
  padding: 10px 14px 6px;
}

.admin-decay-search { flex: 1 1 auto; min-width: 0; }
.admin-decay-search,
.admin-decay-select {
  padding: 6px 9px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--text, #e8dcc0);
  background: var(--panel-deep, #241a10);
  border: 1px solid var(--edge, #4a3b28);
  border-radius: 4px;
}
.admin-decay-select { flex: 0 0 auto; }
.admin-decay-search:focus,
.admin-decay-select:focus { outline: none; border-color: var(--accent, #d8a55c); }

.admin-decay-count {
  padding: 0 14px 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--text-dim, #b3a58a);
}

.admin-decay-list {
  overflow-y: auto;
  padding: 0 14px 8px;
  /* The panel is capped at 80vh with a fixed header, controls and pager, so this is the
     only thing that scrolls — a 50-row page has to live somewhere. */
  flex: 1 1 auto;
}

.admin-decay-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 8px 0;
  border-bottom: 1px solid rgba(74, 59, 40, 0.5);
}

.admin-decay-info { flex: 1 1 auto; min-width: 0; }
.admin-decay-name { font-size: calc(13px * var(--ui-font-scale, 1)); }
/* The per-instance properties (magic property / durability / crafter) are the whole
   reason the pool stores a full snapshot, so they get the accent rather than the dim
   treatment the provenance line gets — they are what makes one row worth more than the
   next. */
.admin-decay-props {
  font-size: calc(11px * var(--ui-font-scale, 1));
  margin-top: 2px;
  color: var(--accent, #d8a55c);
}
.admin-decay-meta {
  font-size: calc(11px * var(--ui-font-scale, 1));
  margin-top: 2px;
  color: var(--text-dim, #b3a58a);
}

.admin-decay-cause-badge {
  flex: 0 0 auto;
  margin-top: 2px;
  font-size: calc(9.5px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.6px;
  padding: 1px 6px;
  border-radius: 999px;
  border: 1px solid var(--edge, #4a3b28);
  color: var(--text-dim, #b3a58a);
  white-space: nowrap;
}

.admin-decay-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  padding: 8px 14px 12px;
  border-top: 1px solid var(--edge, #4a3b28);
}

.admin-decay-page-btn {
  padding: 4px 12px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  cursor: pointer;
  color: var(--text, #e8dcc0);
  background: var(--panel-deep, #241a10);
  border: 1px solid var(--edge, #4a3b28);
  border-radius: 4px;
}
.admin-decay-page-btn:hover:not(:disabled) { border-color: var(--accent, #d8a55c); }
.admin-decay-page-btn:disabled { opacity: 0.4; cursor: default; }

.admin-decay-page-indicator {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: var(--text-dim, #b3a58a);
}

.admin-decay-empty {
  padding: 18px 0;
  text-align: center;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--text-dim, #b3a58a);
}

/* ---- Per-skill up/down/lock arrows (models/Character.js's skillLocks) --------------
   The skill-panel twin of .stat-arrow above: same three states, same glyphs, same
   "accent fill means this skill is doing something, muted means it's frozen" reading.
   Appended at the end of the file rather than edited into the .activity-main block
   further up so the width override below lands after the later .activities-panel rules
   (~line 12760) and actually wins. */
/* 22px square, up from 16px. Sixteen was a fiddly target for a control you click three times
   in a row to cycle up -> down -> locked, and missing it costs a click on the skill row
   underneath. The glyph grew with the box (8px -> 11px) so the arrow still reads as an arrow
   at the larger size rather than a small mark in a big button.

   Unlike the gump × above, this one grows for real rather than through an overlay: it sits in
   a grid column of its own, so making it bigger is a matter of widening that column and the
   panel by the same amount — both immediately below — and nothing else shifts. An overlay
   here would reach across the row and swallow clicks meant for the skill name. */
.skill-arrow {
  padding: 0;
  width: 22px;
  height: 22px;
  line-height: calc(20px * var(--ui-font-scale, 1));
  font-size: calc(11px * var(--ui-font-scale, 1));
  background: var(--shade);
  color: var(--muted);
  border: 1px solid var(--edge);
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* 'locked' is the odd one out and deliberately stays muted/unfilled — an accent-filled
   dash would read as "active" when it means the exact opposite. Matches .stat-arrow-up/
   .stat-arrow-down's own decision to fill only the two moving states. */
.skill-arrow-up, .skill-arrow-down {
  background: var(--accent);
  color: var(--on-accent);
  font-weight: bold;
}

/* One more fixed column ahead of the label for the arrow. The label column stays 1fr so
   it still absorbs whatever the panel's width leaves over, exactly as the .activity-main
   comment further up describes — nothing about the value/button columns changes. */
.activity-main {
  grid-template-columns: 22px 1fr 56px 44px;
}

/* +28px on the panel: the 22px arrow column plus its 6px grid gap, so the label column
   keeps precisely the width it had before the arrows arrived and no skill name starts
   truncating that didn't before. This number and the two above move together — widening the
   arrow without widening the panel would take the difference out of the label column, which
   is the one thing the arrows were added on the condition of not doing. Only the skills panel
   grows — .stats-panel shares the 236px rule up at ~line 1934 and has no arrows of its own to
   make room for. */
.activities-panel {
  width: 264px;
}

/* ---- The Skills panel is drag-resizable (public/js/panelDock.js passes resizable: true) --
   which means gumpWindow.js writes an explicit pixel width/height onto the panel itself, and
   the bounds on that drag belong here: the browser's own resize handle honours the min/max of
   the element it is resizing — the same division .admin-gump and #leaderboard-gump-overlay
   already use. Scoped to .panel-floating because that is the only state the handle exists in;
   an in-flow layout keeps precisely the fixed 264x500 box it always had.

   min-width is the 264px directly above and for the same reason: .activity-main's other three
   columns are fixed, so every pixel taken off the panel comes out of the skill-name column and
   starts truncating labels. min-height keeps the header (the drag handle), a row or two, and
   the Skill Cap / Total footer on screen together — drag shorter than that and the handle
   you'd need to undo it goes with them. The maxima simply keep a dragged panel inside the
   window. */
.activities-panel.panel-floating {
  min-width: 264px;
  min-height: 260px;
  max-width: calc(100vw - 40px);
  max-height: calc(100vh - 40px);
}

/* ---- Manual dungeon crawling cursors (public/js/dungeonFloorplan.js) ---------------
   The class is toggled on the board by dfpSyncManualClass, so both rules switch off
   together the moment Auto Battle takes over — a sword cursor over a monster the player
   cannot actually click would be the map offering something it will not honour.

   Hotspots matter here: the sword's is its blade TIP (2,1 — see
   tools/generateSwordCursor.js), so the thing being pointed at is the thing under the
   point, not under the middle of the hilt. */
.dfp-manual {
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

/* Higher specificity than the board rule above so a monster wins over the floor beneath
   it. `.dfp-mob` covers every creature on the plan — the roamers, the one the server
   spawned for a fallback fight, and a peer's opponent — because all three carry it. */
.dfp-manual .dfp-mob {
  cursor: url('/assets/sprites/cursors/sword.png') 2 1, crosshair;
}

/* ---- Monster nameplates on the floor plan (public/js/dungeonFloorplan.js) ----------
   The hostile twin of .dfp-peer-name, and deliberately identical in geometry: same
   wide-box centring, same offset above the sprite, same shadow. Only the colour differs —
   the red every other hostile cue in this game already uses — so a floor of players and
   creatures reads as one labelling system in two colours rather than two systems.

   A child of the mob element, so it rides along with the sprite as it roams and is
   removed with it. That is also why it needs the counter-flip below. */
.dfp-mob-name {
  position: absolute;
  left: 50%;
  width: 240px;
  margin-left: -120px;
  text-align: center;
  bottom: 100%;
  margin-bottom: 2px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: #e0553f;
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9), 0 0 4px rgba(0, 0, 0, 0.6);
  white-space: nowrap;
  pointer-events: none;
  z-index: 4;
}

/* Counter-flip, for the third time in this file and for the same reason each time (see
   .house-view-guest-name's long note): the facing mirror lives on the CREATURE, so every
   child mirrors with it — right for the sprite, very wrong for its name. Mirroring about
   the wide box's own centre, which IS the sprite's centre, cannot move it. */
.dungeon-floorplan-monster.dfp-face-left .dfp-mob-name {
  scale: -1 1;
}

/* --- The seasonal Leaderboards (views/partials/leaderboardGump.ejs) ----------------------
   Ten boards behind a rail of tabs, the same shape the Donation Store's storefront uses and
   for the same reason: ten boards in one column is a very long scroll for a panel whose
   whole job is answering "where do I stand". */
/* The panel is resizable (public/js/leaderboard.js passes resizable: true), which means
   gumpWindow.js writes an explicit pixel width/height onto the OVERLAY. The bounds therefore
   belong here rather than on .leaderboard-gump, because the browser's own resize handle
   honours the min/max of the element it is resizing — the same division .admin-gump uses.

   min-width 420 is what the rail plus a board row needs before the rank, name and total start
   colliding; min-height 320 keeps at least a few rows visible above the footer. */
#leaderboard-gump-overlay {
  min-width: 420px;
  min-height: 320px;
  max-width: calc(100vw - 40px);
  max-height: calc(100vh - 40px);
}

/* FILLS the overlay rather than setting its own size. It used to carry a fixed
   height: min(620px, …), which a non-resizable panel wants and a resizable one must not
   have: dragged larger, the gump stayed 620 tall and the footer was left stranded outside
   the frame, floating over the world. Now the overlay owns the size and everything inside
   follows it. */
.leaderboard-gump {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}

.leaderboard-title {
  font-weight: bold;
  color: var(--accent);
}

.leaderboard-season {
  margin-left: auto;
  margin-right: 10px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--muted);
}

.leaderboard-shell {
  display: flex;
  flex: 1 1 auto;
  min-height: 0; /* lets .leaderboard-main actually scroll inside the flex column */
}

.leaderboard-nav {
  flex: 0 0 152px;
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 10px 8px;
  overflow-y: auto;
  border-right: 1px solid var(--edge);
}

.leaderboard-tab {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  padding: 6px 9px;
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-align: left;
  color: var(--ink-dim);
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.leaderboard-tab:hover { color: var(--ink); border-color: rgba(var(--accent-rgb), 0.45); }

.leaderboard-tab.active {
  color: var(--accent);
  font-weight: bold;
  border-color: rgba(var(--accent-rgb), 0.7);
  background: rgba(var(--accent-rgb), 0.1);
}

/* The viewer's own rank, on the tab — so they can find the boards they are on without
   opening all ten. */
.leaderboard-tab-you {
  flex: none;
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--accent);
  opacity: 0.85;
}

.leaderboard-main {
  flex: 1 1 auto;
  min-width: 0;
  overflow-y: auto;
  padding: 12px 14px;
}

.leaderboard-board-head { margin-bottom: 10px; }

.leaderboard-board-name {
  display: block;
  font-size: calc(16px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--accent);
}

.leaderboard-board-blurb {
  display: block;
  margin-top: 2px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
}

.leaderboard-rows { display: flex; flex-direction: column; gap: 2px; }

.leaderboard-row {
  display: grid;
  /* Rank column fixed so the names line up however many digits the rank has. */
  grid-template-columns: 28px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 4px 8px;
  font-size: calc(12px * var(--ui-font-scale, 1));
  background: var(--shade);
  border: 1px solid transparent;
  border-radius: 4px;
}

.leaderboard-rank {
  font-weight: bold;
  text-align: right;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.leaderboard-name { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.leaderboard-total {
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}

/* The three that take a trophy, wearing their medal — the board should show at a glance
   where the cut is. */
.leaderboard-row-gold   { border-color: rgba(212, 161, 42, 0.55); background: rgba(212, 161, 42, 0.08); }
.leaderboard-row-silver { border-color: rgba(185, 192, 200, 0.5); background: rgba(185, 192, 200, 0.07); }
.leaderboard-row-bronze { border-color: rgba(176, 113, 47, 0.5);  background: rgba(176, 113, 47, 0.07); }
.leaderboard-row-gold .leaderboard-rank   { color: #e8c05a; }
.leaderboard-row-silver .leaderboard-rank { color: #d3d9e0; }
.leaderboard-row-bronze .leaderboard-rank { color: #d0904a; }

/* The viewer's own row, marked however high or low it sits. */
.leaderboard-row-you {
  outline: 1px solid rgba(var(--accent-rgb), 0.65);
  outline-offset: -1px;
}

.leaderboard-you-line {
  margin: 10px 0 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  color: var(--accent);
}

.leaderboard-empty {
  margin: 12px 0;
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-style: italic;
  color: var(--muted);
}

.leaderboard-past {
  margin-top: 14px;
  padding-top: 10px;
  border-top: 1px solid var(--edge);
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.leaderboard-past-head {
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  color: var(--muted);
  margin-bottom: 3px;
}

.leaderboard-past-row {
  display: grid;
  grid-template-columns: 28px 1fr auto;
  align-items: center;
  gap: 8px;
  padding: 3px 8px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  border: 1px solid transparent;
  border-radius: 4px;
  opacity: 0.8;
}

/* A three-segment status bar, not a sentence. flex: none so it can never be squeezed out of
   the frame when the panel is dragged short — it is the one strip that must always be
   readable, and it was previously the first thing to go. */
.leaderboard-foot {
  flex: none;
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: 1px;
  padding: 0;
  border-top: 1px solid rgba(var(--accent-rgb), 0.35);
  background: linear-gradient(180deg, rgba(var(--accent-rgb), 0.07), rgba(0, 0, 0, 0.25));
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.leaderboard-foot-seg {
  /* 150px basis: below that the label clips, so wrapping to a second row is the better
     failure. Above it they share the bar three-across as intended. */
  flex: 1 1 150px;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  padding: 8px 10px;
  min-width: 0;
}

/* Hairline dividers between segments rather than borders on each, so the ends stay clean. */
.leaderboard-foot-seg + .leaderboard-foot-seg {
  box-shadow: -1px 0 0 var(--edge);
}

.leaderboard-foot-icon {
  font-size: calc(14px * var(--ui-font-scale, 1));
  line-height: 1;
  opacity: 0.9;
}

.leaderboard-foot-text {
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.leaderboard-foot-label {
  font-size: calc(9.5px * var(--ui-font-scale, 1));
  letter-spacing: 0.5px;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.leaderboard-foot-value {
  font-weight: bold;
  color: var(--ink);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* The countdown is the segment that changes what a player does today, so it is the one
   wearing the accent. */
.leaderboard-foot-clock .leaderboard-foot-value { color: var(--accent); }
.leaderboard-foot-prize .leaderboard-foot-value { color: #e8c05a; }

/* Wrapping is driven by the PANEL's width, not the viewport's. A media query would have been
   wrong here and was the first attempt: this is a gump the player drags to any size, so a
   470px-wide panel on a 1920px screen never triggers one. A flex-basis instead means the
   segments wrap when THEY run out of room, which is the actual condition. */

/* ============ The haul, one piece at a time (public/js/lootReveal.js) ============
   A treasure chest springing open used to fill a grid in one go, which is a lot of loot
   arriving as a wall of small icons — the moment a whole dig has been building toward,
   reported as an anticlimax. This shows each piece large and lit with its name underneath,
   then the next.

   Built as a general component, not a treasure-site detail: the seasonal leaderboard
   trophies (services/leaderboardAwards.js) currently arrive as a line of Herald chat and a
   thing in your bank, and are the obvious second caller.

   Purely a flourish over loot the player ALREADY owns — the items sit in the chest waiting
   to be collected by hand either way — which is why any click dismisses it outright. */
.loot-reveal-overlay {
  position: fixed;
  inset: 0;
  /* Above every gump (those top out around 1000) but below nothing that matters — this is
     modal for as long as it runs, and it runs for a couple of seconds. */
  z-index: 2000;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(6, 7, 10, 0.66);
  cursor: pointer;
  /* Its own fade-in, so the chest's own open animation is not stepped on. */
  animation: loot-reveal-fade 220ms ease-out;
}

@keyframes loot-reveal-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

.loot-reveal-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  min-width: 220px;
  padding: 20px 28px 14px;
  border-radius: 8px;
  background: linear-gradient(180deg, rgba(30, 26, 18, 0.96), rgba(18, 16, 12, 0.96));
  border: 1px solid #6b5620;
  box-shadow: 0 0 34px rgba(0, 0, 0, 0.7), inset 0 0 26px rgba(120, 90, 30, 0.22);
}

/* The pop each piece arrives with. Re-triggered per item by removing and re-adding the
   class (public/js/lootReveal.js), so the second item lands as visibly as the first. */
@keyframes loot-reveal-pop {
  0% { opacity: 0; transform: scale(0.86) translateY(8px); }
  55% { opacity: 1; transform: scale(1.04) translateY(-2px); }
  100% { opacity: 1; transform: scale(1) translateY(0); }
}
.loot-reveal-card.loot-reveal-pop {
  animation: loot-reveal-pop 280ms ease-out;
}

.loot-reveal-title {
  font-family: Georgia, 'Times New Roman', serif;
  font-style: italic;
  font-size: calc(13px * var(--ui-font-scale, 1));
  color: #b9a373;
}

/* A fixed box so a tall sprite and a short one do not make the card jump between pieces. */
.loot-reveal-stage {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 96px;
  height: 96px;
}

.loot-reveal-icon {
  max-width: 96px;
  max-height: 96px;
  image-rendering: pixelated;
  filter: drop-shadow(0 0 12px rgba(255, 210, 110, 0.5));
}

.loot-reveal-name {
  font-family: Georgia, 'Times New Roman', serif;
  font-weight: bold;
  font-size: calc(15px * var(--ui-font-scale, 1));
  color: #f0e2bd;
  text-align: center;
  max-width: 260px;
}

.loot-reveal-count {
  font-size: calc(11px * var(--ui-font-scale, 1));
  color: #8d7c5c;
}

.loot-reveal-hint {
  margin-top: 4px;
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: #6a5f4a;
}

/* ============================================================================
   Auto Cast Loadouts (views/partials/spellbookGump.ejs, public/js/autoMagery.js)
   ----------------------------------------------------------------------------
   The strip above the rule list: one tab per owned loadout, a rename field, and
   the arm button. Hidden entirely at one slot (the client sets display:none), so
   a player who has bought nothing sees the panel exactly as it was.

   Two states a tab can be in, and they are deliberately different marks because
   they mean different things: EDITING is which list is on screen, ARMED is which
   one Auto Cast is actually running. One tab can be both, and usually is.
   ========================================================================= */

.magery-loadout-bar {
  display: flex;
  flex-direction: column;
  gap: 7px;
  padding: 10px 14px 8px;
  border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}

.magery-loadout-tabs {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}

.magery-loadout-tab {
  /* Sized to its own NAME, not stretched to fill the row. With flex-grow the tabs split
     the panel between them, so two loadouts meant two half-width slabs and the strip read
     as a pair of buttons rather than as tabs. Capped as well as shrink-wrapped, so a long
     name ellipses instead of shoving the rest of the row off the edge. */
  flex: 0 1 auto;
  min-width: 0;
  max-width: 132px;
  padding: 5px 12px;
  border: 1px solid #6b5a3e;
  border-radius: 4px;
  background: linear-gradient(180deg, #3a3226, #2b241b);
  color: #cbbc9a;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  cursor: pointer;
  transition: border-color 120ms ease-out, color 120ms ease-out, background 120ms ease-out;
}

.magery-loadout-tab:hover {
  border-color: #9a834f;
  color: #eadfc0;
}

/* OPEN — the list you are looking at. A lifted face, because this tab owns the
   rows immediately below it and should read as continuous with them. */
.magery-loadout-tab.is-editing {
  background: linear-gradient(180deg, #4a4030, #383022);
  border-color: #a98f52;
  color: #f3e6c4;
}

/* ARMED — the one Auto Cast is running. A gold rule along the bottom rather than
   another background, so it can be worn at the same time as is-editing without
   the two fighting: they are independent facts about one tab. */
.magery-loadout-tab.is-active {
  box-shadow: inset 0 -2px 0 #d4af37;
}

.magery-loadout-controls {
  display: flex;
  gap: 6px;
  align-items: center;
}

.magery-loadout-name-input {
  flex: 1;
  min-width: 0;
  padding: 5px 8px;
  border: 1px solid #5c4e39;
  border-radius: 4px;
  background: #241e17;
  color: #e2d5b4;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
}

.magery-loadout-name-input:focus {
  outline: none;
  border-color: #a98f52;
}

.magery-loadout-active-btn {
  flex-shrink: 0;
  padding: 5px 12px;
  border: 1px solid #6b5a3e;
  border-radius: 4px;
  background: linear-gradient(180deg, #4a4030, #383022);
  color: #f3e6c4;
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  cursor: pointer;
}

.magery-loadout-active-btn:hover:not(:disabled) {
  border-color: #d4af37;
  color: #ffe9a8;
}

/* Disabled means "this one is already armed", which is a statement rather than a
   refusal — so it reads as settled (gold, quiet) instead of broken (greyed). */
.magery-loadout-active-btn:disabled {
  border-color: #6b5a3e;
  background: none;
  color: #9c8c68;
  cursor: default;
}

/* --- The Town Collector (views/partials/townRepGump.ejs, public/js/townRepGump.js) -------
   Your standing with a town, and everything you are carrying that it wants. Reuses the
   shared .skills-gump shell; only the body below is its own. */
.townrep-gump {
  width: 440px;
}

.townrep-body {
  padding: 12px 14px 14px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

/* The standing block — rank, points, the bar, and the shard's own ledger line. A well, so
   it reads as the figure the rows below are measured against rather than as another row. */
.townrep-standing {
  padding: 10px 12px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 5px;
}

.townrep-standing-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}

.townrep-rank {
  color: var(--accent);
  font-size: calc(15px * var(--ui-font-scale, 1));
  font-weight: bold;
}

/* Below the first rung there is no rank to be proud of, so it stops wearing the accent. */
.townrep-rank-none {
  color: var(--muted);
  font-weight: normal;
  font-style: italic;
}

.townrep-points {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.townrep-bar {
  margin-top: 8px;
  height: 7px;
  background: rgba(0, 0, 0, 0.35);
  border: 1px solid var(--edge);
  border-radius: 4px;
  overflow: hidden;
}

.townrep-bar-fill {
  height: 100%;
  width: 0;
  background: linear-gradient(90deg, var(--accent-deep), var(--accent));
  transition: width 220ms ease-out;
}

.townrep-next {
  margin-top: 6px;
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
}

/* The shard's total. Quieter than everything above it — it is the town's business, not
   yours, and it is here for context rather than as a target. */
.townrep-ledger {
  margin-top: 6px;
  padding-top: 6px;
  border-top: 1px solid var(--edge);
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-style: italic;
}

/* CAPPED AND SCROLLABLE. This list is one row per carried donatable and can run to dozens
   after a long gathering session, so it scrolls inside the panel rather than growing the
   gump off the bottom of the screen — the same idiom every other long gump body here uses,
   and the fix the Options panel needed for exactly this reason. */
.townrep-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: calc(100vh - 320px);
  overflow-y: auto;
}

.townrep-category {
  margin-top: 6px;
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.07em;
}

.townrep-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 6px;
  border-radius: 4px;
}

.townrep-row:hover {
  background: rgba(255, 255, 255, 0.03);
}

.townrep-row-icon {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  image-rendering: pixelated;
}

.townrep-row-labels {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.townrep-row-name {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.townrep-row-rate {
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
}

.townrep-row-amount {
  flex: 0 0 auto;
  width: 64px;
  padding: 4px 6px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--ink);
  font-family: inherit;
  font-size: calc(12px * var(--ui-font-scale, 1));
  text-align: center;
}

.townrep-row-amount:focus {
  outline: none;
  border-color: var(--accent);
}

/* What this row would pay, updated as the number is typed. Fixed width so a changing figure
   does not shuffle the button beside it left and right under the cursor. */
.townrep-row-gain {
  flex: 0 0 auto;
  min-width: 48px;
  color: var(--accent);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-align: right;
}

.townrep-row-btn {
  flex: 0 0 auto;
  padding: 5px 12px;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 4px;
  color: var(--accent);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

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

.townrep-row-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

.townrep-empty {
  color: var(--muted);
  font-size: calc(11px * var(--ui-font-scale, 1));
  line-height: 1.5;
  text-align: center;
}

/* Town Reputation's line in the Location panel (views/partials/worldMap.ejs), styled to sit
   with the Karma row above it rather than as a second kind of thing — they are both
   "how this town sees you" readings, however differently they behave underneath. */
.townrep-row-panel {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-top: 4px;
  font-size: calc(12px * var(--ui-font-scale, 1));
}

.townrep-row-panel #townrep-panel-value {
  color: var(--accent);
  font-weight: bold;
}

/* --- The Town Collector's two faces (Phase 2) -------------------------------------------
   Donate and Wares, plus the oath that governs both. Tab chrome borrows .options-tab's
   language (flat pills, accent fill on the active one) rather than inventing a third. */
.townrep-tabs {
  display: flex;
  gap: 6px;
  padding: 10px 14px 0;
}

.townrep-tab {
  flex: 1;
  padding: 5px 6px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--edge);
  border-radius: 4px;
  color: var(--muted);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

.townrep-tab.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

/* The oath. A framed strip rather than another row, because it is a statement about you
   rather than a thing to buy — and it sits above both tabs for the same reason. */
.townrep-oath {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--edge);
  border-left: 3px solid var(--accent);
  border-radius: 4px;
}

.townrep-oath-note {
  flex: 1;
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  line-height: 1.4;
}

/* Sworn is a standing, not a note — it wears the accent and the full width. */
.townrep-oath-sworn {
  color: var(--accent);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.townrep-oath-btn {
  flex: 0 0 auto;
  padding: 5px 12px;
  background: rgba(var(--edge-rgb), 0.10);
  border: 1px solid rgba(var(--edge-rgb), 0.4);
  border-radius: 4px;
  color: var(--accent);
  font-family: inherit;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
}

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

.townrep-oath-btn:disabled {
  opacity: 0.4;
  cursor: default;
}

/* A locked ware stays READABLE — dimmed, never hidden. Deciding whether a town is worth
   swearing to means being able to read what swearing would buy. */
.townrep-ware.locked {
  opacity: 0.5;
}

.townrep-ware-cost {
  flex: 0 0 auto;
  min-width: 62px;
  color: #f0d878;
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
  text-align: right;
}

/* The Mayor's plaque at the courtyard gate (config/townRep.js, drawn by
   public/js/gatherScene.js's gsSpawnMayorPlaque). Unlike .gs-town-hedge above it KEEPS its
   pointer events: the name is in the tooltip, so a plaque you cannot hover is a plaque that
   says nothing. It is still not in gsPropBlockSet — decoration, never collision. */
.gs-mayor-plaque {
  position: absolute;
  width: 32px;
  height: 34px;
  background: url('/assets/sprites/town/mayor_plaque.png') no-repeat;
  background-size: 32px 34px;
  image-rendering: pixelated;
}

/* --- What a town's standing looks like (config/townRep.js's TOWN_APPEARANCE_TIERS) --------
   Drawn by public/js/gatherScene.js's gsSpawnTownStanding, entirely from one number. All of
   it is DECORATION: none of these cells block, so a town growing grander can never narrow a
   street the square was generated around. */

/* The watch tower, standing ON a wall cell beside the gate. Taller than a cell on purpose —
   the point of it is that it rises above the wall line and is seen from inside the square. */
.gs-guard-tower {
  position: absolute;
  width: 48px;
  height: 64px;
  background: url('/assets/sprites/town/guard_tower.png') no-repeat;
  background-size: 48px 64px;
  image-rendering: pixelated;
}

/* Wall banners. Two on one sheet, plain and deviced, flipped by background-position exactly
   as .gs-town-hedge picks its flowering half. */
.gs-town-banner {
  position: absolute;
  width: 32px;
  height: 36px;
  background: url('/assets/sprites/town/banner.png') no-repeat;
  background-size: 64px 36px;
  image-rendering: pixelated;
  pointer-events: none;
}

.gs-town-banner.gs-banner-device { background-position-x: -32px; }

/* Planters at the courtyard corners — the existing flower sheet, which is exactly the object
   this wants and already ships in two variants. */
.gs-town-planter {
  position: absolute;
  width: 32px;
  height: 24px;
  background: url('/assets/sprites/town/flowers.png') no-repeat;
  background-size: 64px 24px;
  image-rendering: pixelated;
  pointer-events: none;
}

.gs-town-planter.gs-planter-bloom { background-position-x: -32px; }

/* The guards themselves ride the player-figure sprite pipeline (.gs-player sizes them), so
   this only has to say they are hoverable — the armour they wear IS the town's rung, and a
   guard you cannot hover cannot tell you so. */
/* The second plate tier (public/js/gatherScene.js's watch): raised a full plate-height so
   two figures in adjacent columns read as two names, not one mangled box. The horizontal
   centering rides along in the same transform — translateX alone is the baseline rule. */
.gs-vendor-name.gs-plate-high {
  transform: translate(-50%, calc(-100% - 6px));
}

.gs-town-guard {
  cursor: url('/assets/sprites/cursors/point.png') 4 2, pointer;
  /* The patrol walk. One second per cell with a beat between steps (gsStartGuardPatrol),
     so a guard AMBLES rather than glides — the pause is what reads as looking about. */
  transition: left 1s linear, top 1s linear;
}

/* The Towns tab — the whole shard's ladder, one card per town. A stacked card rather than a
   row because each town has four things to say (rung, what that means, progress, who speaks
   for it) and a single line would truncate three of them. */
.townrep-town-row {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 8px 9px;
  border: 1px solid var(--edge);
  border-radius: 4px;
  margin-bottom: 6px;
}

/* The one you have sworn to wears the accent edge — you should be able to find it without
   reading fourteen names. */
.townrep-town-row.sworn { border-left: 3px solid var(--accent); }

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

.townrep-town-name {
  color: var(--ink);
  font-size: calc(12px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.townrep-town-oath {
  color: var(--accent);
  font-size: calc(9px * var(--ui-font-scale, 1));
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.townrep-town-tier {
  color: var(--accent);
  font-size: calc(11px * var(--ui-font-scale, 1));
  font-weight: bold;
}

.townrep-town-note {
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  line-height: 1.35;
}

.townrep-town-foot {
  display: flex;
  justify-content: space-between;
  gap: 8px;
  color: var(--muted);
  font-size: calc(10px * var(--ui-font-scale, 1));
  opacity: 0.85;
}

/* --- The 2026-08 Donation Store shelf (config/donationStore.js) ---------------------------
   Five features, one block: the Pet Dye Brush's hues, Illuminated Speech's parchment,
   Master's Tools' gilding, the Shardfire Rockets' burst and the Coloured Footprints.
   Everything here is theatre over mechanics that never read any of it. */

/* Pet Dye Brush (config/petDyes.js) — fifty finishes, ZERO per-colour rules. The config
   ships each dye's complete filter and glow; public/js/petDyeApply.js writes them onto
   the sprite as --pd-filter/--pd-glow, and these few rules are the whole system: one
   static class, one animation per KIND. The first version had a class per colour and a
   comment warning that a config colour without its class 'applies invisibly' — at fifty
   colours that trap retired the design that carried it.

   Every animation is a whole-filter keyframe built on var(--pd-filter), because filter
   functions appended after a var() chain compose left-to-right — so one keyframe block
   serves every colour, whatever its base tint. drop-shadow follows the sprite's own
   alpha, which is what makes a glow hug the creature rather than its box. */
.pet-dyed { filter: var(--pd-filter); }

/* Shimmering — the coat breathes: a slow swell of light and a CLOSE glow, 4-5px at its
   fullest by design ask — the first cut bloomed to 7-9px and read as a lamp, not a coat. */
.pet-dye-anim-shimmer { animation: pet-dye-shimmer 2.8s ease-in-out infinite; }
@keyframes pet-dye-shimmer {
  0%, 100% { filter: var(--pd-filter) brightness(1) drop-shadow(0 0 1px var(--pd-glow)); }
  50% { filter: var(--pd-filter) brightness(1.22) drop-shadow(0 0 4px var(--pd-glow)); }
}


/* Spectral — a ghost that remembers being solid. The base filter already carries
   opacity(0.75); the trough fades further and the glow holds, so it reads as a haunting
   rather than a render bug. */
.pet-dye-anim-spectral { animation: pet-dye-spectral 3.6s ease-in-out infinite; }
@keyframes pet-dye-spectral {
  0%, 100% { filter: var(--pd-filter) opacity(1) drop-shadow(0 0 2px var(--pd-glow)); }
  50% { filter: var(--pd-filter) opacity(0.55) drop-shadow(0 0 5px var(--pd-glow)); }
}

/* Molten — heat wobble: the hue rocks a few degrees either side of red while the glow
   pulses, the way embers never hold one colour. */
.pet-dye-anim-molten { animation: pet-dye-molten 1.9s ease-in-out infinite; }
@keyframes pet-dye-molten {
  0%, 100% { filter: var(--pd-filter) hue-rotate(-8deg) brightness(1) drop-shadow(0 0 2px var(--pd-glow)); }
  50% { filter: var(--pd-filter) hue-rotate(10deg) brightness(1.18) drop-shadow(0 0 5px var(--pd-glow)); }
}

/* Stormcharged — electricity does not breathe, it ARCS: steps(), not ease, so the flicker
   snaps between states like a spark gap. */
.pet-dye-anim-storm { animation: pet-dye-storm 1.3s steps(2, jump-none) infinite; }
@keyframes pet-dye-storm {
  0%, 100% { filter: var(--pd-filter) brightness(1) drop-shadow(0 0 1px var(--pd-glow)); }
  45% { filter: var(--pd-filter) brightness(1.35) drop-shadow(0 0 5px var(--pd-glow)); }
  55% { filter: var(--pd-filter) brightness(0.95) drop-shadow(0 0 2px var(--pd-glow)); }
}

/* The dye picker's swatch row (views/partials/petDyeModal.ejs). Its own classes rather
   than borrowing a shop card's — the bank modal and the collector's Donate button both
   learned what borrowing a later-declared class costs. */
/* Fifty swatches need a ceiling and sections — the scroll wheel rule the Options gump
   already learned for long settings lists. */
.pet-dye-modal { width: 440px; max-width: 92vw; }
.pet-dye-scroll {
  max-height: 46vh;
  overflow-y: auto;
  overflow-x: hidden;
  margin: 10px 0 4px;
  padding-right: 4px;
}
.pet-dye-group-label {
  margin: 10px 2px 6px;
  font-size: calc(11px * var(--ui-font-scale, 1));
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent);
}
.pet-dye-group-label:first-child { margin-top: 2px; }

/* Which brush a premium group takes, said on the shelf itself — and a swatch the opening
   brush cannot pay for goes quiet rather than vanishing: the player should SEE what the
   dearer brush buys, greyed, which is half of why it sells. */
.pet-dye-group-brush {
  margin-left: 8px;
  letter-spacing: normal;
  text-transform: none;
  font-size: calc(10px * var(--ui-font-scale, 1));
  color: var(--muted);
}
.pet-dye-swatch-locked {
  opacity: 0.35;
  cursor: default;
}
.pet-dye-swatch-locked::before { animation: none; }
.pet-dye-swatches {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  justify-content: flex-start;
}
.pet-dye-swatch {
  width: 78px;
  padding: 6px 4px 5px;
  background: var(--well);
  border: 1px solid var(--edge);
  border-radius: 6px;
  cursor: pointer;
  color: var(--ink-dim);
  font-size: calc(11px * var(--ui-font-scale, 1));
}
.pet-dye-swatch::before {
  content: '';
  display: block;
  width: 26px;
  height: 26px;
  margin: 0 auto 4px;
  border-radius: 50%;
  background: var(--pet-dye-swatch);
  box-shadow: inset 0 -3px 5px rgba(0, 0, 0, 0.35), 0 0 5px rgba(0, 0, 0, 0.4);
}
.pet-dye-swatch:hover { border-color: rgba(var(--accent-rgb), 0.5); color: var(--ink); }
.pet-dye-swatch.selected {
  border-color: var(--accent);
  color: var(--accent);
  box-shadow: 0 0 6px rgba(var(--accent-rgb), 0.4);
}

/* The animated finishes preview ON the shelf — what is being sold is the movement, and a
   flat circle under-sells it. Live swatches pulse their glow in the colour they carry.
   They ride the swatch's ::before disc. */
.pet-dye-swatch-live::before {
  animation: pet-dye-swatch-pulse 2.2s ease-in-out infinite;
}
@keyframes pet-dye-swatch-pulse {
  0%, 100% { box-shadow: inset 0 -3px 5px rgba(0, 0, 0, 0.35), 0 0 2px var(--pd-glow, #fff); }
  50% { box-shadow: inset 0 -3px 5px rgba(0, 0, 0, 0.35), 0 0 5px var(--pd-glow, #fff); }
}


/* Illuminated Speech — the parchment-and-drop-cap dressing on a speaker's bubbles
   (public/js/speechBubble.js adds the class off the payload's own flag). ONE accent by
   design: Local chat must stay readable when six owners talk at once, so the paper and
   the capital are the whole of it — same size, same wrap, same lifetime. The inner span
   goes block-level because ::first-letter only exists on block containers. */
.speech-bubble-illuminated {
  background: linear-gradient(#f4e8cc, #e9d9b4);
  border-color: rgba(180, 140, 60, 0.75);
  color: #3a2f1c;
}
.speech-bubble-illuminated::after {
  background: #ecdcb8;
  border-right-color: rgba(180, 140, 60, 0.75);
  border-bottom-color: rgba(180, 140, 60, 0.75);
}
.speech-bubble-illuminated .popup-flip-inner { display: block; }
.speech-bubble-illuminated .popup-flip-inner::first-letter {
  color: #a67615;
  font-weight: bold;
  font-size: 1.35em;
  font-family: Georgia, 'Times New Roman', serif;
}

/* Master's Tools — redesigned by ask after a day live: the tool KEEPS ITS OWN COLOURS
   (the gold recolour filter is gone), and what the purchase buys is the AIR moving — a
   clear arc through the stroke and a few translucent wisps drifting off it
   (gsSpawnToolTrail). Clear means clear: white at low alpha, no tint. */
.gs-tool-trail {
  position: absolute;
  width: 26px;
  height: 26px;
  margin-left: -13px;
  border-radius: 50%;
  border: 2px solid transparent;
  border-top-color: rgba(255, 255, 255, 0.55);
  border-right-color: rgba(255, 255, 255, 0.28);
  pointer-events: none;
  animation: gs-tool-trail 340ms ease-out forwards;
}

/* One wisp: a soft translucent puff that rises and thins, drifting on its own --wisp-dx.
   blur(1px) is what makes it read as disturbed air rather than a white dot. */
.gs-tool-wisp {
  position: absolute;
  width: 7px;
  height: 7px;
  margin: -3px 0 0 -3px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.4);
  filter: blur(1px);
  pointer-events: none;
  animation: gs-tool-wisp 900ms ease-out forwards;
}
@keyframes gs-tool-wisp {
  from { transform: translate(0, 0) scale(1); opacity: 0.6; }
  to { transform: translate(var(--wisp-dx, 0), -18px) scale(1.7); opacity: 0; }
}
.gs-tool-trail-left { animation-name: gs-tool-trail-left; }
@keyframes gs-tool-trail {
  from { transform: rotate(-70deg); opacity: 0.9; }
  to { transform: rotate(150deg); opacity: 0; }
}
@keyframes gs-tool-trail-left {
  from { transform: scaleX(-1) rotate(-70deg); opacity: 0.9; }
  to { transform: scaleX(-1) rotate(150deg); opacity: 0; }
}

/* Shardfire Rockets — the shell's climb and the burst (gsLaunchFirework and the dungeon's
   twin). Sparks carry their own trajectory as custom properties, so one keyframe block
   serves every direction. */
.gs-firework-shell {
  position: absolute;
  width: 4px;
  height: 12px;
  margin-left: -2px;
  border-radius: 2px;
  background: linear-gradient(#ffffff, #f0a24a);
  box-shadow: 0 6px 8px -2px rgba(240, 162, 74, 0.7);
  pointer-events: none;
  animation: gs-firework-ascend 700ms ease-out forwards;
}
@keyframes gs-firework-ascend {
  /* --fw-sx/--fw-sy: where THIS shell is headed — a display's five shells fan out to
     server-rolled points, and the lone launch keeps the straight-up default. */
  from { transform: translate(0, 0); opacity: 1; }
  to { transform: translate(var(--fw-sx, 0px), var(--fw-sy, -96px)); opacity: 0.9; }
}
.gs-firework-flash {
  position: absolute;
  width: 10px;
  height: 10px;
  margin: -5px 0 0 -5px;
  border-radius: 50%;
  background: #fff8e0;
  box-shadow: 0 0 24px 14px rgba(255, 240, 190, 0.85);
  pointer-events: none;
  animation: gs-firework-flash 380ms ease-out forwards;
}
@keyframes gs-firework-flash {
  from { opacity: 1; transform: scale(1); }
  to { opacity: 0; transform: scale(2.4); }
}
.gs-firework-spark {
  position: absolute;
  width: 4px;
  height: 4px;
  margin: -2px 0 0 -2px;
  border-radius: 50%;
  background: var(--fw-colour, #ffd873);
  box-shadow: 0 0 6px var(--fw-colour, #ffd873);
  pointer-events: none;
  animation: gs-firework-spark 1100ms ease-out forwards;
}
@keyframes gs-firework-spark {
  from { transform: translate(0, 0); opacity: 1; }
  70% { opacity: 0.9; }
  to { transform: translate(var(--fw-dx, 0), var(--fw-dy, 0)); opacity: 0; }
}

/* Coloured Footprints (config/footprints.js) — two small marks per print, near the CELL's
   bottom edge because the 32x48 figures are pulled up 16px and that is where the feet
   actually land (the first cut sat them mid-cell, floating at knee height) — drawn as the
   element's own pseudo-pair so a print costs one node. Colour keys mirror the config by
   hand — a colour added there needs its class here or its trail is invisible. The fade
   runs on the print's own clock; the per-walker queue (gsSpawnFootprint) caps how many
   stand at once. */
.gs-footprint {
  position: absolute;
  width: 32px;
  height: 32px;
  pointer-events: none;
  animation: gs-footprint-fade 1600ms ease-in forwards; /* was 2600 — lingered too long live */
}
.gs-footprint::before,
.gs-footprint::after {
  content: '';
  position: absolute;
  width: 5px;
  height: 8px;
  border-radius: 50% 50% 45% 45%;
  background: var(--gs-footprint-colour, #f08a42);
  box-shadow: 0 0 4px var(--gs-footprint-colour, #f08a42);
}
.gs-footprint::before { left: 8px; top: 21px; transform: rotate(-10deg); }
.gs-footprint::after { left: 21px; top: 25px; transform: rotate(10deg); }
@keyframes gs-footprint-fade {
  0% { opacity: 0.85; }
  60% { opacity: 0.5; }
  100% { opacity: 0; }
}
.gs-footprint-ember { --gs-footprint-colour: #f08a42; }
.gs-footprint-frost { --gs-footprint-colour: #9fd4ef; }
.gs-footprint-verdant { --gs-footprint-colour: #57944a; }

@media (prefers-reduced-motion: reduce) {
  .gs-tool-trail, .gs-tool-wisp, .gs-firework-shell, .gs-firework-spark, .gs-firework-flash { animation-duration: 1ms; }
  .gs-footprint { animation-duration: 1600ms; } /* a fade is not motion — it stays */
  /* An animated coat settles to its still tint — the colour was bought, the motion yields. */
  .pet-dye-anim-shimmer, .pet-dye-anim-spectral,
  .pet-dye-anim-molten, .pet-dye-anim-storm { animation: none; }
  .pet-dye-swatch-live::before { animation: none; }
}
