feat: web dashboard and playground work

The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch:
accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy
reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live
events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved
captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes
they need.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 17:52:10 -05:00
parent 854d02f509
commit e213a7aeff
681 changed files with 88310 additions and 1321 deletions

View File

@@ -0,0 +1,235 @@
.sidebar {
width: 250px;
background-color: var(--bs-tertiary-bg);
}
@media (min-width: 992px) {
nav.sidebar {
position: sticky;
top: 0;
height: 100vh;
overflow-y: auto;
}
}
/* Narrow screens: the sidebar slides out (offcanvas-lg), and wide tables scroll sideways */
@media (max-width: 991.98px) {
nav.sidebar.offcanvas-lg { width: min(280px, 85vw); overflow-y: auto; }
.dt-container .dt-layout-table, .dt-container .dt-layout-row:has(table) { overflow-x: auto; }
}
/* .table-stack: on phones every row becomes a small card, each cell labelled with its column */
@media (max-width: 575.98px) {
.table-stack thead { display: none; }
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% !important; }
.table-stack tr { border: 1px solid var(--bs-border-color); border-radius: .5rem; margin-bottom: .5rem; padding: .25rem 0; }
.table-stack td { border: 0; padding: .15rem .6rem; text-align: left !important; }
.table-stack td[data-label]:not([data-label=""])::before { content: attr(data-label) ": "; color: var(--bs-secondary-color); font-size: .8rem; }
.table-stack td:last-child { padding-top: .4rem; }
h2 { font-size: 1.4rem; }
.card-body { padding: .75rem; }
.table { font-size: .875rem; }
.table .btn-sm { padding: .15rem .4rem; }
}
.list-group-item {
border: none;
background-color: transparent;
}
.dataTables_wrapper .dataTables_paginate .paginate_button.current,
.dataTables_wrapper .dataTables_paginate .paginate_button.current:hover {
background: var(--bs-primary) !important;
border-color: var(--bs-primary) !important;
color: #fff !important;
}
.item-icon {
width: 32px;
height: 32px;
object-fit: contain;
}
pre.log-output {
max-height: 70vh;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 0.8rem;
}
.item-tooltip {
--bs-tooltip-bg: #11141a;
--bs-tooltip-color: #e9ecef;
--bs-tooltip-opacity: 0.97;
}
.item-tooltip .tooltip-inner {
max-width: 320px;
border: 1px solid #3a3f4b;
text-align: left;
}
.rarity-2 { color: #4fc948; }
.rarity-3 { color: #3e8ee8; }
.rarity-4 { color: #b44be1; }
.item-equipped { color: #d16f05; }
/* ---- Reports charts (palette validated against the dark card surface #212529) ---- */
:root {
--viz-surface: #212529;
--viz-up: #3987e5;
--viz-down: #d95926;
--viz-grid: #2c2c2a;
--viz-baseline: #6c757d;
--viz-tick: #898781;
--viz-hover: rgba(255, 255, 255, 0.06);
}
.viz { width: 100%; min-height: 60px; }
.viz svg { display: block; }
.viz-grid { stroke: var(--viz-grid); stroke-width: 1; }
.viz-baseline { stroke: var(--viz-baseline); stroke-width: 1; }
.viz-tick { fill: var(--viz-tick); font-size: 11px; font-variant-numeric: tabular-nums; }
.viz-up { fill: var(--viz-up); background: var(--viz-up); }
.viz-down { fill: var(--viz-down); background: var(--viz-down); }
.viz-hit { fill: transparent; cursor: crosshair; }
.viz-hit.viz-hover { fill: var(--viz-hover); }
.viz-num { font-variant-numeric: tabular-nums; }
.viz-hero { font-size: 1.75rem; font-weight: 600; line-height: 1.2; }
.viz-legend { display: flex; gap: 1rem; font-size: 0.85rem; color: var(--bs-secondary-color); }
.viz-key { display: inline-flex; align-items: center; gap: 0.4rem; }
.viz-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; flex: none; }
.viz-tooltip {
position: fixed;
z-index: 1080;
pointer-events: none;
min-width: 160px;
padding: 0.5rem 0.65rem;
font-size: 0.85rem;
color: var(--bs-body-color);
background: #11141a;
border: 1px solid #3a3f4b;
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
.viz-tip-row { display: flex; align-items: center; gap: 0.4rem; }
.viz-map { position: relative; width: 100%; }
.viz-map canvas { display: block; max-width: 100%; border-radius: 4px; cursor: crosshair; }
.viz-scale { display: inline-flex; align-items: center; gap: 0.5rem; }
.viz-ramp {
display: inline-block;
width: 120px;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, #184f95, #2a78d6, #6da7ec, #cde2fb);
}
.viz-scroll { max-height: 22rem; overflow-y: auto; }
/* In a card that fills the window (.panel-fill) the map takes the room left and reports.js fits the canvas, centred, into it */
@media (min-width: 992px) {
.panel-fill .viz-map { flex: 1 1 auto; min-height: 0; }
.panel-fill .viz-map canvas { position: absolute; inset: 0; margin: auto; }
}
@media (min-width: 1200px) { .panel-stack-xl .viz-scroll { max-height: none; } }
#itemLot { min-width: 18rem; }
#traceId { min-width: 16rem; }
.qr-box { width: 180px; height: 180px; background: #fff; border-radius: 6px; padding: 0; }
.qr-box svg { width: 100%; height: 100%; display: block; }
/* Sidebar groups that fold away; the dot shows while a closed group holds a waiting badge */
.nav-group { margin-top: .25rem; }
.nav-group-toggle {
display: flex; align-items: center; gap: .4rem; width: 100%;
padding: .45rem 1rem; border: 0; background: none;
color: var(--bs-secondary-color); font-size: .75rem; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; text-align: left;
}
.nav-group-toggle:hover { color: var(--bs-body-color); }
.nav-group-toggle::after {
content: ""; margin-left: auto; width: .45rem; height: .45rem;
border-right: 2px solid currentColor; border-bottom: 2px solid currentColor;
transform: rotate(45deg); transition: transform .15s;
}
.nav-group-toggle[aria-expanded="false"]::after { transform: rotate(-45deg); }
.nav-group-dot { display: none; width: .45rem; height: .45rem; border-radius: 50%; background: var(--bs-danger); }
.nav-group:has(.collapse:not(.show) [data-badge]:not(.d-none)) .nav-group-dot { display: inline-block; }
.sidebar .list-group-item { padding: .4rem 1rem; border-radius: .375rem; }
.sidebar .nav-group .list-group-item { padding-left: 1.5rem; }
/* Top bar: the signed-in user's menu sits at the right */
.topbar { z-index: 1020; min-height: 3rem; }
.user-avatar {
display: inline-flex; align-items: center; justify-content: center;
width: 1.9rem; height: 1.9rem; border-radius: 50%;
background: var(--bs-primary); color: #fff; font-weight: 600; font-size: .85rem;
}
/* The page column: clip wide content sideways without becoming a scroll container (that would stop position: sticky) */
.main-column { min-width: 0; overflow-x: clip; }
/* ---- Layout helpers that save page scrolling (dashboard.js keeps --topbar-h and --fill-top current) ---- */
:root { --topbar-h: 3.5rem; }
/* .fill-viewport[-lg|-xl]: reaches from where it starts to the bottom of the window, so the page needn't scroll while you
work in it (a map, a list with its details). The -lg/-xl forms only do so from that width up; below it they size to their content. */
.fill-viewport { height: calc(100dvh - var(--fill-top, 12rem)); min-height: var(--fill-min, 20rem); }
@media (min-width: 992px) { .fill-viewport-lg { height: calc(100dvh - var(--fill-top, 12rem)); min-height: var(--fill-min, 20rem); } }
@media (min-width: 1200px) { .fill-viewport-xl { height: calc(100dvh - var(--fill-top, 12rem)); min-height: var(--fill-min, 20rem); } }
/* A filling .row passes its height on to its columns */
.row.fill-viewport > * { height: 100%; }
@media (min-width: 992px) { .row.fill-viewport-lg > * { height: 100%; } }
@media (min-width: 1200px) { .row.fill-viewport-xl > * { height: 100%; } }
/* .panel-fill: a card whose body takes the height left under its header and scrolls inside (.panel-scroll does the same for any box) */
.panel-fill { display: flex; flex-direction: column; min-height: 0; }
.panel-fill > .card-body, .panel-fill > .list-group, .panel-scroll { flex: 1 1 auto; min-height: 0; overflow: auto; }
.panel-fill > .card-header, .panel-fill > .card-footer { flex: none; }
/* .panel-cap: a .panel-fill card that grows with its content up to most of a window, then scrolls inside (lists that can get long) */
.panel-cap { max-height: min(36rem, 70dvh); }
/* Table headers stay at the top of a scrolling panel (a .table-responsive inside would otherwise become the scroll box) */
.panel-fill > .card-body .table-responsive, .panel-scroll .table-responsive { overflow: visible; }
.panel-fill thead th, .panel-scroll thead th, .sticky-head thead th {
position: sticky; top: 0; z-index: 2;
background: var(--bs-card-bg, var(--bs-body-bg)); box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
/* .panel-stack-xl: a side column whose cards share the height of the row's main content (its tallest neighbour) from xl up,
each .panel-fill card scrolling inside; below xl the cards just stack. Wrap the cards in .panel-stack-xl > .panel-stack-inner. */
@media (min-width: 1200px) {
.panel-stack-xl { position: relative; height: 100%; min-height: 20rem; }
.panel-stack-xl > .panel-stack-inner { position: absolute; inset: 0; display: flex; flex-direction: column; gap: 1rem; }
.panel-stack-xl > .panel-stack-inner > * { margin: 0 !important; }
.panel-stack-xl > .panel-stack-inner > .panel-fill { flex: 1 1 0; }
.panel-stack-xl > .panel-stack-inner > .panel-fit { flex: 0 1 auto; max-height: 50%; }
}
/* .sticky-toolbar: filters and buttons that stay under the top bar while the content below scrolls */
.sticky-toolbar { position: sticky; top: var(--topbar-h); z-index: 5; background: var(--bs-body-bg); padding-top: .5rem; padding-bottom: .5rem; }
/* Long lists in DataTables keep their column headers in view under the top bar (on narrow screens the table scrolls sideways instead) */
@media (min-width: 992px) {
table.dataTable > thead > tr > th {
position: sticky; top: var(--topbar-h); z-index: 2;
background: var(--bs-card-bg, var(--bs-body-bg)); box-shadow: inset 0 -1px 0 var(--bs-border-color);
}
.panel-fill table.dataTable > thead > tr > th, .panel-scroll table.dataTable > thead > tr > th { top: 0; }
.modal table.dataTable > thead > tr > th { position: static; }
}
/* details.about-text: a page's longer explanation, folded to one line until asked for (Ctrl+F still finds and opens it) */
details.about-text > summary {
display: inline-flex; align-items: center; gap: .35rem; cursor: pointer; list-style: none;
color: var(--bs-secondary-color); font-size: .875rem;
}
details.about-text > summary::-webkit-details-marker { display: none; }
details.about-text > summary::before {
content: "?"; display: inline-flex; align-items: center; justify-content: center; width: 1.1rem; height: 1.1rem; border-radius: 50%;
border: 1px solid currentColor; font-size: .7rem; font-weight: 600;
}
details.about-text > summary:hover { color: var(--bs-body-color); }
details.about-text[open] > summary { margin-bottom: .35rem; }
details.about-text > :not(summary) { color: var(--bs-secondary-color); font-size: .875rem; }

View File

@@ -0,0 +1 @@
/* Login page uses Bootstrap 5 dark theme - no custom styles needed */

View File

@@ -0,0 +1,85 @@
/* Settings page. Sizes and colours are variables so the layout can be tuned in one place. */
.settings-page {
--settings-nav-width: 15rem;
--settings-sticky-top: 3.25rem; /* below the top bar */
--setting-input-width: 22rem;
--setting-label-min-width: 14rem;
--setting-padding: .85rem 1rem;
--setting-border: 1px solid var(--bs-border-color);
--setting-changed-accent: var(--bs-primary);
--setting-error-accent: var(--bs-danger);
--setting-locked-bg: var(--bs-tertiary-bg);
--setting-meta-color: var(--bs-secondary-color);
--settings-savebar-bg: var(--bs-body-bg);
}
/* ---- layout: category nav beside the sections ---- */
.settings-layout { display: grid; grid-template-columns: var(--settings-nav-width) minmax(0, 1fr); gap: 1.5rem; align-items: start; }
.settings-nav { position: sticky; top: calc(var(--settings-sticky-top) + 4rem); }
.settings-nav .list-group-item { display: flex; justify-content: space-between; align-items: center; gap: .5rem; cursor: pointer; border-radius: .375rem; }
.settings-nav .count { font-size: .75rem; color: var(--setting-meta-color); }
.settings-nav .list-group-item.active .count { color: inherit; }
@media (max-width: 991.98px) {
.settings-layout { grid-template-columns: minmax(0, 1fr); gap: .75rem; }
/* Categories become one scrolling row */
.settings-nav { position: static; }
.settings-nav .list-group { flex-direction: row; overflow-x: auto; gap: .25rem; scrollbar-width: none; }
.settings-nav .list-group-item { flex: 0 0 auto; width: auto; white-space: nowrap; border: var(--setting-border); }
}
.settings-toolbar { position: sticky; top: var(--settings-sticky-top); z-index: 5; background: var(--bs-body-bg); padding: .5rem 0; }
.settings-toolbar .form-control { max-width: 22rem; }
/* ---- one setting ---- */
/* Label beside the input when the card is wide enough, above it otherwise. Sized by the card (a container query),
not the window, since the sidebars take a varying share of the width. */
.settings-page .card-body { container-type: inline-size; }
.setting { display: grid; grid-template-columns: minmax(var(--setting-label-min-width), 1fr) minmax(0, var(--setting-input-width)); gap: .5rem 1.5rem; padding: var(--setting-padding); border-top: var(--setting-border); border-left: 3px solid transparent; }
@container (max-width: 40rem) { .setting { grid-template-columns: minmax(0, 1fr); } }
.setting:first-child { border-top: 0; }
.setting.is-changed { border-left-color: var(--setting-changed-accent); background: rgba(var(--bs-primary-rgb), .06); }
.setting.has-error { border-left-color: var(--setting-error-accent); }
.setting-title { font-weight: 600; margin: 0; }
.setting-description { font-size: .875rem; color: var(--setting-meta-color); margin: .15rem 0 0; }
.setting-meta { font-size: .8rem; color: var(--setting-meta-color); margin-top: .25rem; }
.setting-meta code { font-size: .95em; }
.setting-tag { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .03em; padding: .1rem .4rem; border-radius: .25rem; margin-left: .35rem; vertical-align: .1em; border: 1px solid currentColor; }
.setting-tag.restart { color: var(--bs-warning-text-emphasis); }
.setting-tag.unused { color: var(--setting-meta-color); }
.setting-locked { background: var(--setting-locked-bg); border-radius: .375rem; padding: .5rem .75rem; font-size: .875rem; }
.setting-locked .value { font-family: var(--bs-font-monospace); word-break: break-all; }
.setting-note { font-size: .8rem; margin-top: .35rem; }
.setting-names { display: flex; flex-wrap: wrap; gap: .25rem; margin-top: .35rem; }
.setting-names .badge { font-weight: 500; }
/* Sections that only matter when another setting is on */
.settings-section-off { border-style: dashed; }
.settings-section-off .card-body { color: var(--setting-meta-color); font-size: .875rem; }
/* Grid layout (e.g. events 1-8) and pairs (help menu title + text) */
.setting-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: .75rem 1rem; padding: var(--setting-padding); }
.setting-grid .setting { display: block; padding: 0; border: 0; }
.setting-pair { padding: var(--setting-padding); border-top: var(--setting-border); }
.setting-pair:first-child { border-top: 0; }
.setting-pair .setting { display: block; padding: 0; border: 0; margin-bottom: .5rem; }
/* ---- save bar ---- */
.settings-savebar { position: sticky; bottom: 0; z-index: 6; background: var(--settings-savebar-bg); border-top: var(--setting-border); padding: .75rem 0; margin-top: 1rem; box-shadow: 0 -.5rem 1rem rgba(0, 0, 0, .15); }
/* Picking list values (zones, reward codes) from the client database */
.pick-chips { display: flex; flex-wrap: wrap; gap: .25rem; margin-bottom: .35rem; }
.pick-chip { display: inline-flex; align-items: center; gap: .35rem; font-weight: 500; }
.pick-chip .btn-close { width: .5em; height: .5em; padding: 0; }
.pick-panel > summary { list-style: none; }
.pick-panel > summary::-webkit-details-marker { display: none; }
.pick-body { margin-top: .35rem; border: var(--setting-border); border-radius: .375rem; padding: .35rem; }
.pick-body [data-pick-list] { max-height: var(--pick-list-height, 18rem); overflow-y: auto; }
.pick-option { display: flex; gap: .5rem; align-items: flex-start; padding: .3rem .4rem; border-radius: .25rem; cursor: pointer; }
.pick-option:hover { background: var(--bs-tertiary-bg); }
.pick-option .form-check-input { margin-top: .2rem; flex-shrink: 0; }
/* A category with edits not saved yet */
.unsaved-dot { display: inline-block; width: .5rem; height: .5rem; border-radius: 50%; background: var(--setting-changed-accent); vertical-align: .1em; margin-left: .25rem; }
.settings-nav .list-group-item.active .unsaved-dot { background: #fff; }

View File

@@ -0,0 +1,108 @@
/* Vanity page (Files & NPCs, Preview). Sizes and colours are variables so the layout can be tuned in one place. */
.vanity-page {
--vanity-side-width: 19rem;
--vanity-sticky-top: 3.5rem; /* below the top bar */
--vanity-border: 1px solid var(--bs-border-color);
--vanity-meta-color: var(--bs-secondary-color);
--vanity-open-accent: var(--bs-primary);
--vanity-chip-bg: var(--bs-secondary-bg);
--vanity-chip-color: var(--bs-body-color);
--vanity-loc-bg: var(--bs-tertiary-bg);
--vanity-savebar-bg: var(--bs-body-bg);
--vanity-num-width: 5.25rem;
--vanity-indent: 1.1rem;
}
/* ---- layout: files and texts beside the editor, stacked on phones ---- */
.vanity-layout { display: grid; grid-template-columns: var(--vanity-side-width) minmax(0, 1fr); gap: 1.5rem; align-items: start; }
.vanity-side { position: sticky; top: calc(var(--vanity-sticky-top) + 1rem); }
@media (max-width: 991.98px) {
.vanity-layout { grid-template-columns: minmax(0, 1fr); gap: 1rem; }
.vanity-side { position: static; }
}
/* ---- the file list ---- */
.vanity-file { display: flex; align-items: center; gap: .5rem; padding: .5rem .75rem .5rem calc(.75rem + var(--depth, 0) * var(--vanity-indent)); position: relative; }
/* A line from each included file back to the file including it */
.vanity-file.is-child::before {
content: ""; position: absolute; left: calc(.75rem + (var(--depth) - 1) * var(--vanity-indent) + .3rem); top: 0; bottom: 50%; width: .6rem;
border-left: var(--vanity-border); border-bottom: var(--vanity-border); border-bottom-left-radius: .25rem; }
.vanity-file.is-repeat .vanity-file-name { opacity: .8; }
.vanity-group-label { font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; color: var(--vanity-meta-color); background: var(--bs-tertiary-bg); padding: .35rem .75rem; }
.vanity-badge-loaded { background: var(--bs-success-bg-subtle); color: var(--bs-success-text-emphasis); border: 1px solid var(--bs-success-border-subtle); }
.vanity-badge-off { background: var(--bs-secondary-bg); color: var(--vanity-meta-color); border: var(--vanity-border); }
/* A scheduled event that uses a file or changes an NPC; stronger while it is on */
.vanity-event-badge { background: var(--bs-info-bg-subtle); color: var(--bs-info-text-emphasis); border: 1px dashed var(--bs-info-border-subtle); text-decoration: none; font-weight: 500; }
.vanity-event-badge.is-on { border-style: solid; background: var(--bs-info); color: var(--bs-white); }
.vanity-event-badge:hover { text-decoration: underline; }
.vanity-head-badge .badge { font-size: .65rem; vertical-align: .25em; font-weight: 500; }
.vanity-file-name { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; color: inherit; text-decoration: none; }
.vanity-file-name:hover .text-truncate { text-decoration: underline; }
.vanity-count { font-size: .75rem; color: var(--vanity-meta-color); }
.vanity-count .badge { font-size: .65rem; vertical-align: .05em; }
.vanity-file.active .vanity-count { color: inherit; opacity: .85; }
.vanity-file .form-switch { flex: 0 0 auto; }
.vanity-file.active .form-check-input:checked { border-color: #fff; }
.vanity-where { font-size: .75rem; color: var(--vanity-meta-color); }
.list-group-item.active .vanity-where { color: inherit; opacity: .85; }
/* ---- the editor ---- */
.vanity-main-head { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; gap: .5rem; margin-bottom: .75rem; }
.vanity-toolbar { position: sticky; top: var(--vanity-sticky-top); z-index: 4; background: var(--bs-body-bg); padding: .6rem 0 .4rem; }
.vanity-toolbar .form-control { max-width: 22rem; flex: 1 1 12rem; }
/* The chosen file's includes */
.vanity-include { display: flex; align-items: center; gap: .75rem; padding: .3rem 0; border-bottom: var(--vanity-border); }
.vanity-include:last-of-type { margin-bottom: .5rem; }
.vanity-include-add { max-width: 26rem; }
/* One NPC */
.npc-card { margin-bottom: .6rem; border-left: 3px solid transparent; }
.npc-card.is-open { border-left-color: var(--vanity-open-accent); }
.npc-head { display: flex; align-items: center; gap: .5rem; padding: 0; }
.npc-toggle { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: .6rem; text-align: left; background: none; border: 0; color: inherit; padding: .6rem .75rem; }
.npc-toggle:focus-visible { outline: 2px solid var(--vanity-open-accent); outline-offset: -2px; }
.npc-caret { flex: 0 0 auto; width: .5rem; height: .5rem; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: rotate(-45deg); transition: transform .15s; opacity: .7; }
.npc-toggle[aria-expanded="true"] .npc-caret { transform: rotate(45deg); }
.npc-heading { min-width: 0; display: flex; flex-direction: column; }
.npc-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.npc-sub { font-size: .8rem; color: var(--vanity-meta-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.npc-badges { flex: 0 0 auto; display: flex; gap: .25rem; padding-right: .75rem; }
@media (max-width: 575.98px) {
.npc-head { flex-wrap: wrap; }
.npc-badges { padding: 0 .75rem .5rem 2rem; }
.npc-sub { white-space: normal; }
}
.npc-row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: .75rem; }
@container (max-width: 34rem) { .npc-row { grid-template-columns: minmax(0, 1fr); } }
.npc-card .card-body { container-type: inline-size; }
.npc-lot input { max-width: 8rem; flex: 0 0 auto; }
.npc-section { margin-top: .9rem; }
.npc-section textarea.font-monospace { font-size: .8rem; }
.npc-chips { display: flex; flex-wrap: wrap; gap: .25rem; margin-bottom: .35rem; }
.npc-chip { display: inline-flex; align-items: center; gap: .4rem; font-weight: 500; background: var(--vanity-chip-bg); color: var(--vanity-chip-color); border: var(--vanity-border); }
.npc-chip .btn-close { width: .5em; height: .5em; padding: 0; }
.npc-equip-add { max-width: 18rem; }
/* Locations: labelled fields that wrap, so a row fits a phone */
.npc-locs { display: flex; flex-direction: column; gap: .4rem; }
.npc-loc { display: flex; flex-wrap: wrap; align-items: flex-end; gap: .4rem .5rem; padding: .5rem; background: var(--vanity-loc-bg); border-radius: .375rem; }
.loc-field { display: flex; flex-direction: column; flex: 1 1 var(--vanity-num-width); min-width: 4.5rem; max-width: 8rem; margin: 0; }
.loc-field > span { font-size: .7rem; color: var(--vanity-meta-color); margin-bottom: .1rem; white-space: nowrap; }
.loc-field.loc-zone { flex: 2 1 13rem; min-width: 11rem; max-width: none; }
.loc-field.loc-facing { flex-basis: 7rem; }
.loc-remove { flex: 0 0 auto; margin-left: auto; min-width: 2.25rem; font-size: 1.1rem; line-height: 1.2; padding: .1rem .5rem; }
.loc-field.loc-rw, .loc-field.loc-rx, .loc-field.loc-ry, .loc-field.loc-rz { flex-basis: 6rem; }
/* Plaque texts */
.vanity-text { font-size: .85rem; min-height: 16rem; }
/* ---- save bar ---- */
.vanity-savebar { position: sticky; bottom: 0; z-index: 6; background: var(--vanity-savebar-bg); border-top: var(--vanity-border); padding: .75rem 0; margin-top: 1rem; box-shadow: 0 -.5rem 1rem rgba(0, 0, 0, .15); }
/* ---- preview ---- */
.vanity-xml { max-height: 32rem; overflow: auto; font-size: .75rem; white-space: pre; }
#vanityPanePreview details > summary { cursor: pointer; }
.vanity-changed > td { --bs-table-bg: var(--bs-info-bg-subtle); }