/* public/css/goblinville.css
   Dedicated stylesheet for public Goblinville location pages
   (View/Goblinville/Location.ejs and its _workerAvatar/_workerCard/
   _workerNpc partials). Every selector is scoped under
   .goblinville-location-page, .goblinville-worker, .goblinville-card-modal,
   .goblinville-avatar, or .goblinville-npc so nothing here can affect any
   other page -- same convention as public/css/tavern.css. Reuses the
   site's existing --df-* custom properties (public/css/style.css) instead
   of inventing a new palette, with a warm, forge-flavored accent
   (amber/ember tones layered on top of the existing --df-GoblinGold) to
   read as a fantasy location rather than a plain directory listing --
   restrained, no animation.

   .goblinville-avatar is the identity portrait/medallion, used only inside
   the creator card modal now. .goblinville-npc is the placeholder fantasy
   character shown in the room/location tile -- a separate visual system on
   purpose, see View/Goblinville/_workerNpc.ejs's own header comment. Both
   are DELIBERATELY theme-agnostic -- the per-location accent below only
   ever touches page chrome (tagline color, nav, hover/focus tint), never
   the NPC or the creator-card medallion, so every location still reads as
   "the same Goblinville system," not a costume change. */

.goblinville-location-page {
  --gv-accent: var(--df-GoblinGold--alt, #839713);
  --gv-accent-soft: rgba(205, 236, 30, .1);
  padding-bottom: 2.5rem;
}

/* Per-location accent -- the one modest, data-driven visual difference
   between locations (services/goblinvillePublicLocationService.js's
   `themeClass`). Restrained on purpose: a tagline/nav/hover color swap,
   nothing structural. */
.goblinville-theme-scriptorium {
  --gv-accent: #6c86c4;
  --gv-accent-soft: rgba(108, 134, 196, .14);
}

.goblinville-theme-artificers-guild {
  --gv-accent: #2f8f7f;
  --gv-accent-soft: rgba(47, 143, 127, .14);
}

.goblinville-theme-tavern {
  --gv-accent: #c76b3f;
  --gv-accent-soft: rgba(199, 107, 63, .16);
}

.goblinville-theme-town-hall {
  --gv-accent: #a67c27;
  --gv-accent-soft: rgba(166, 124, 39, .16);
}

.goblinville-location-page .home-intro {
  margin-top: 1.5rem;
}

.goblinville-location__icon {
  display: inline-block;
  margin-right: .35rem;
  font-size: 1.5rem;
  vertical-align: -.1em;
}

.goblinville-location__tagline {
  font-size: .95rem;
  font-weight: 600;
  letter-spacing: .04em;
  text-transform: uppercase;
  color: var(--gv-accent);
  margin: -.25rem 0 .5rem;
}

/* ── Cross-location nav ───────────────────────────────────────────────────
   A small, restrained pill row linking the currently-built public
   locations (services/goblinvillePublicLocationService.js's
   PUBLIC_LOCATION_NAV) -- not a map, not a homepage, just "move between
   the rooms that exist." Wraps on narrow screens rather than scrolling, and
   every link/current-marker keeps generous padding for a comfortable tap
   target. */

.goblinville-location-nav {
  display: flex;
  flex-wrap: wrap;
  gap: .5rem;
  margin: 1.1rem 0 0;
}

.goblinville-location-nav__link {
  display: inline-flex;
  align-items: center;
  padding: .5rem 1rem;
  border-radius: 999px;
  font-size: .85rem;
  font-weight: 700;
  text-decoration: none;
  color: var(--df-text-dark);
  background: rgba(24, 24, 27, .06);
  border: 1px solid rgba(24, 24, 27, .12);
  transition: background-color .15s ease, border-color .15s ease;
}

a.goblinville-location-nav__link:hover,
a.goblinville-location-nav__link:focus-visible {
  background: var(--gv-accent-soft);
  border-color: var(--gv-accent);
}

.goblinville-location-nav__link--current {
  background: var(--gv-accent);
  border-color: var(--gv-accent);
  color: var(--df-surface-alt);
  cursor: default;
}

/* "Return to the town" -- one small, separate control (View/Goblinville/
   _locationNav.ejs), deliberately NOT folded into the pill row above so
   that row stays exactly the 5 destinations, nothing more. Sits directly
   on the page background, plain-text weight, so it doesn't compete with
   the pill row underneath it for attention. */
.goblinville-home-link {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  margin-top: 1.5rem;
  font-size: .85rem;
  font-weight: 600;
  text-decoration: none;
  color: var(--df-text-dark);
  opacity: .75;
}

.goblinville-home-link:hover,
.goblinville-home-link:focus-visible {
  opacity: 1;
  text-decoration: underline;
}

/* ── Worker grid ──────────────────────────────────────────────────────── */

.goblinville-worker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
  gap: 1.25rem;
  margin-top: 1.75rem;
}

/* Character Dialogue Phase 2: every worker/visitor tile's real grid item
   is now this small wrapper (button + optional speech bubble stacked),
   not the <button> directly -- so the grid's own layout/sizing is
   unaffected whether or not that ONE worker happens to be today's
   speaker. See .goblinville-character-speech below. */
.goblinville-worker-slot {
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* A worker "tile" is a real <button> (keyboard/screen-reader friendly,
   opens the matching modal via Bootstrap's own data-bs-toggle wiring --
   see View/Goblinville/Location.ejs) styled to sit directly on the page
   background rather than as a bordered card, so the NPC itself reads as
   the focal point. min-height keeps the whole tappable target comfortably
   above the ~44px touch-target guideline even before the NPC's own
   (much larger) size is added -- the NPC is deliberately sized to feel
   like a character standing in the room, not a small icon. */
.goblinville-worker {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .4rem;
  padding: .75rem .5rem;
  min-height: 200px;
  background: none;
  border: 0;
  border-radius: var(--df-radius);
  cursor: pointer;
  color: inherit;
  transition: background-color .15s ease, transform .15s ease;
}

.goblinville-worker:hover,
.goblinville-worker:focus-visible {
  background: var(--gv-accent-soft);
  transform: translateY(-2px);
}

.goblinville-worker:focus-visible {
  outline: 2px solid var(--gv-accent);
  outline-offset: 2px;
}

.goblinville-worker__name {
  font-size: .85rem;
  font-weight: 700;
  line-height: 1.3;
  text-align: center;
  color: var(--df-text-dark);
  /* Two-line clamp so a long project name never breaks the grid's rhythm. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── Visiting Today (up to 2 daily visitors, per work location) ───────────
   Additive, always AFTER the permanent worker grid/empty-state -- never
   replaces or reorders it (View/Goblinville/Location.ejs renders this
   section unconditionally on its own `visitors.length` check, independent
   of whether the location has any permanent workers). Reuses the exact
   same .goblinville-worker tile + .goblinville-npc figure + creator-card
   modal as permanent workers; the only visual differences are this
   section's own heading/divider, a dashed (vs. permanent workers'
   borderless) tile outline, and the small "Visitor" badge -- the
   character itself is never restyled. */

.goblinville-visiting-today {
  margin-top: 2.5rem;
  padding-top: 1.75rem;
  border-top: 1px solid rgba(24, 24, 27, .12);
}

.goblinville-visiting-today__title {
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--df-text-dark);
  margin: 0 0 .25rem;
}

.goblinville-worker--visitor {
  position: relative;
  border: 1px dashed rgba(24, 24, 27, .22);
  border-radius: var(--df-radius);
}

.goblinville-worker--visitor:hover,
.goblinville-worker--visitor:focus-visible {
  border-color: var(--gv-accent);
}

.goblinville-visitor-badge {
  display: inline-flex;
  align-items: center;
  padding: .15rem .55rem;
  border-radius: 999px;
  font-size: .68rem;
  font-weight: 700;
  letter-spacing: .03em;
  text-transform: uppercase;
  color: var(--df-surface-alt);
  background: var(--gv-accent);
}

/* ── Avatar (placeholder character presentation) ─────────────────────────
   Deliberately simple for this phase: a round "medallion" token, not a
   full character render. .goblinville-avatar__base is the one visual
   layer today; it's the intended attachment point for future body/hair/
   outfit/prop layers, so nothing outside this file has to change when
   that lands. Sized generously (84px) so the tap target this sits inside
   (.goblinville-worker) stays comfortably large on phone. */

.goblinville-avatar {
  display: block;
  width: 84px;
  height: 84px;
}

.goblinville-avatar__base {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: radial-gradient(circle at 35% 30%, var(--df-surface), var(--df-surface-alt));
  border: 2px solid var(--df-GoblinGold);
  box-shadow: var(--df-shadow);
  overflow: hidden;
}

.goblinville-avatar__image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.goblinville-avatar__initial {
  color: var(--df-GoblinGold);
  font-weight: 700;
  font-size: 1.6rem;
  line-height: 1;
}

/* Smaller avatar rendering inside the creator card header. */
.goblinville-card__identity .goblinville-avatar {
  width: 56px;
  height: 56px;
  flex: 0 0 auto;
}

.goblinville-card__identity .goblinville-avatar__initial {
  font-size: 1.1rem;
}

/* Admin Rebuild Phase 1: a small avatar thumbnail for the People list row
   (View/AdminWidgets/GoblinvilleTool.ejs) -- same .goblinville-avatar
   markup/classes as everywhere else, just smaller, so the admin list can
   never visually drift from the real public avatar. */
.goblinville-admin-avatar {
  width: 36px;
  height: 36px;
}

.goblinville-admin-avatar .goblinville-avatar__initial {
  font-size: 1rem;
}

/* ── Placeholder NPC (room/location tile only) ────────────────────────────
   One generic fantasy blacksmith figure, inline SVG (no new image-
   generation dependency, no external asset library, fits the existing
   CSP -- it's just markup). Every worker gets the exact same body this
   phase; --gv-npc-* custom properties below are the one place to retune
   the palette. Sized well above the old 84px medallion so it reads as a
   character standing in the room, not an icon -- see .goblinville-worker's
   own comment for the matching tap-target sizing. */

/* Character Colors phase: --gv-npc-primary/secondary/accent are the three
   admin-configurable colors (services/goblinvilleCharacterConfig.js's
   primaryColor/secondaryColor/accentColor, normalized to a lowercase
   #rrggbb hex string) -- the defaults here match
   DEFAULT_CHARACTER_CONFIG's own palette and only apply when a caller
   doesn't set an inline override (every real one does; see
   View/Goblinville/_workerNpc.ejs's own comment). --gv-npc-skin/
   skin-shade/hammer-handle/metal stay fixed, uncustomizable "materials"
   (skin, wood, metal) -- this phase's own instruction not to recolor
   skin/head and to keep metal/wood fixed "where that makes more visual
   sense." */
.goblinville-npc {
  --gv-npc-skin: #7a9d63;
  --gv-npc-skin-shade: #5c7a49;
  --gv-npc-primary: #6b7a3d;
  --gv-npc-secondary: #3a2d24;
  --gv-npc-accent: #d1a84b;
  --gv-npc-hammer-handle: #6b4226;
  --gv-npc-metal: #9aa0a6;

  position: relative;
  display: block;
  width: 108px;
}

.goblinville-npc__figure {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

.goblinville-npc__head,
.goblinville-npc__arm {
  fill: var(--gv-npc-skin);
}

.goblinville-npc__head {
  stroke: var(--gv-npc-skin-shade);
  stroke-width: 1.5;
}

.goblinville-npc__eye {
  fill: var(--df-text-dark);
}

/* faceStyle variants (services/goblinvilleCharacterConfig.js's
   FACE_STYLES) -- "cheerful"'s eyes are strokes (upward arcs), "stern"'s
   are filled rects (flat/narrow), "calm"'s stay the default filled dot
   above. */
.goblinville-npc__eye--cheerful {
  fill: none;
  stroke: var(--df-text-dark);
  stroke-width: 2.5;
  stroke-linecap: round;
}

.goblinville-npc__eye--stern {
  fill: var(--df-text-dark);
}

.goblinville-npc__brow {
  fill: none;
  stroke: var(--gv-npc-skin-shade);
  stroke-width: 2.5;
  stroke-linecap: round;
}

.goblinville-npc__smile {
  fill: none;
  stroke: var(--gv-npc-skin-shade);
  stroke-width: 2.5;
  stroke-linecap: round;
}

.goblinville-npc__smile--flat {
  stroke-width: 2;
}

/* bodyType variants (services/goblinvilleCharacterConfig.js's BODY_TYPES).
   Character Colors phase: torso is the "main clothing/body garment" this
   phase's own color semantics call PRIMARY -- masculine/neutral's torso
   rect and feminine's dress silhouette both use the exact same variable
   (color is now an axis independent of bodyType, so there's no reason for
   them to differ), which is also why there's no separate
   `.goblinville-npc__torso--feminine` color rule any more. The neutral
   apron (worn OVER the torso) is explicitly a PRIMARY example too
   ("apron/body clothing"), so it shares the same color -- no visible seam
   where apron meets torso. Belt/boots/apron straps are the "supporting
   clothing/detail" examples this phase calls SECONDARY. */

.goblinville-npc__torso {
  fill: var(--gv-npc-primary);
}

.goblinville-npc__belt {
  fill: var(--gv-npc-secondary);
}

.goblinville-npc__apron {
  fill: var(--gv-npc-primary);
}

.goblinville-npc__apron-strap {
  fill: var(--gv-npc-secondary);
}

.goblinville-npc__boot {
  fill: var(--gv-npc-secondary);
}

/* hatStyle variants (services/goblinvilleCharacterConfig.js's HAT_STYLES).
   Character Colors phase: "hat trim" is this phase's own ACCENT example --
   applied here as the whole non-metal hat shape's fill (cap/hood/
   wide_brim), the closest match at this shape's granularity. The helmet
   stays fixed metal (a material, not a garment) with its decorative rim
   stroke as the accent instead. */

.goblinville-npc__hat {
  fill: var(--gv-npc-accent);
}

.goblinville-npc__hat--helmet {
  fill: var(--gv-npc-metal);
  stroke: var(--gv-npc-accent);
  stroke-width: 1.5;
}

/* propStyle variants (services/goblinvilleCharacterConfig.js's
   PROP_STYLES). Character Colors phase: handles stay fixed wood, heads/
   mugs/tool-tips stay fixed metal, the scroll stays fixed parchment
   ("keep metal/wood colors fixed where that makes more visual sense") --
   only the small decorative trim strokes (this phase's own "prop accent"/
   "decorative elements" examples) pick up the accent color. */

.goblinville-npc__hammer-handle,
.goblinville-npc__tool-handle {
  fill: var(--gv-npc-hammer-handle);
}

.goblinville-npc__hammer-head {
  fill: var(--gv-npc-metal);
  stroke: var(--gv-npc-accent);
  stroke-width: 1.5;
}

.goblinville-npc__book {
  fill: var(--gv-npc-hammer-handle);
}

.goblinville-npc__book-spine {
  stroke: var(--gv-npc-accent);
  stroke-width: 2;
}

.goblinville-npc__mug {
  fill: var(--gv-npc-metal);
}

.goblinville-npc__mug-handle {
  fill: none;
  stroke: var(--gv-npc-metal);
  stroke-width: 3;
}

.goblinville-npc__scroll {
  fill: #d8c9a3;
}

.goblinville-npc__scroll-end {
  fill: #b8a578;
}

.goblinville-npc__tool-head {
  fill: none;
  stroke: var(--gv-npc-metal);
  stroke-width: 4;
}

/* Goblinville NPC badge removal phase: the older, separate "identity
   badge" (portrait/initial pinned to the character's chest via
   position:absolute/left:50%/bottom:30%, independent of any prop) that
   used to live here is REMOVED -- .goblinville-npc__prop-badge below
   (Character Composer Phase 3) is now the only place a worker's image/
   initial appears on the NPC figure itself, shown only for the 3 known
   prop_left shield/banner assets. The real portrait remains available
   separately, exclusive to the creator card modal's medallion
   (_workerAvatar.ejs) -- unaffected by this. */

/* Prop-logo badge -- Character Composer Phase 3. The worker's own
   portrait (or initial-monogram fallback, same source data
   _workerAvatar.ejs's medallion uses), overlaid on the FACE of one of the
   3 known prop_left shield/banner assets when worker.propLeftBadgeType
   (services/goblinvilleModularArtRenderer.js#resolvePropLeftBadgeType)
   names one of them. Never baked into the SVG -- an absolutely-positioned
   HTML sibling of the SVG, so it never needs its own image-safety
   handling.

   Position/size are plain CSS percentages of the .goblinville-npc box,
   which always keeps the 120:170 viewBox aspect ratio (width:100%,
   height:auto) -- so `left%`/`width%` = viewBox-x / 120 * 100 and
   `top%`/`height%` = viewBox-y / 170 * 100, independent of the element's
   actual rendered pixel size (no separate mobile-breakpoint overrides
   needed). Each modifier's box is sized a little SMALLER than its prop's
   drawn face shape so a thin ring/border of the prop's own artwork stays
   visible around the logo. */
.goblinville-npc__prop-badge {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--df-surface-alt);
  border: 2px solid var(--df-GoblinGold);
  box-shadow: var(--df-shadow);
  overflow: hidden;
}

.goblinville-npc__prop-badge-image {
  width: 100%;
  height: 100%;
  /* `contain`, not `cover` (unlike the chest badge) -- square/round logos
     read fine on the shields either way, but a wide/rectangular logo on
     the banner must never be cropped; containment keeps every logo shape
     fully visible on every prop. */
  object-fit: contain;
}

.goblinville-npc__prop-badge-initial {
  color: var(--df-GoblinGold);
  font-weight: 700;
  font-size: .6rem;
  line-height: 1;
}

/* Round shield (prop_left_round_shield.svg): face circle cx=20,cy=110,
   r=13 -- badge diameter 24 (viewBox units), 2 units smaller than the
   face so a sliver of its primary-color fill rings the logo. */
.goblinville-npc__prop-badge--round-shield {
  left: 28.25%;
  top: 73.8%;
  width: 50%;
  height: 35%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
}

/* Pointed shield (prop_left_pointed_shield.svg): face path is widest
   (x8-32) around y96-115 before tapering to its point near y143 -- badge
   sits in that upper, widest band (18x22 viewBox units centered on
   20,112) rather than the shape's full vertical center, so it never
   crowds the point. */
.goblinville-npc__prop-badge--pointed-shield {
  left: 24.87%;
  top: 60.75%;
  width: 27%;
  height: 18%;
  transform: translate(-50%, -50%);
  border-radius: 5%;
}

/* Banner (prop_left_banner.svg): cloth's flat rectangular portion (before
   the swallow-tail cut) is roughly x22-66,y6-42 -- badge uses the safe
   interior of that (32x28 viewBox units centered on 44,24). */
.goblinville-npc__prop-badge--banner {
  left: 47.5%;
  top: 13%;
  width: 75;
  height: 16.471%;
  transform: translate(-50%, -50%);
  border-radius: 2%;
}

/* Character Composer's own live preview (View/Goblinville/_npcComposerPreview.ejs,
   the ONLY markup that carries BOTH .goblinville-npc--preview AND
   .goblinville-npc--composer together) renders all 3 prop_left assets
   (and so all 3 badges) into the same SVG at once, same
   .gv-npc-part/.is-active convention this file already uses just above --
   public/js/goblinvilleCharacterComposer.js flips `.is-active` on the one
   badge matching the currently-selected prop_left asset. The public views
   (View/Goblinville/_workerNpc.ejs) print AT MOST ONE `.goblinville-npc__prop-badge`
   element directly (an EJS `<% if %>`, not a class toggle) and never carry
   this "hidden unless active" gate.

   Composite shield-logo fix: `.goblinville-npc--composer` ALONE (no
   `--preview`) is a DIFFERENT, unrelated reuse of this same size-only
   class -- the admin composite Group Preview
   (admin/tools/goblinvilleAdminTool.js / admin/tools/playerPicturesAdminTool.js,
   via services/goblinCompositeCharacterFigureHtmlRenderService.js) prints
   exactly ONE resolved badge per qualifying member, the same "at most one,
   an `<% if %>`, never a toggle" contract _workerNpc.ejs already has -- so
   this gate is scoped to BOTH classes together, or it would incorrectly
   hide that Group Preview's own real badges too. */
.goblinville-npc--composer.goblinville-npc--preview .goblinville-npc__prop-badge {
  display: none;
}

.goblinville-npc--composer.goblinville-npc--preview .goblinville-npc__prop-badge.is-active {
  display: flex;
}

/* ── Admin live preview (View/Goblinville/_npcAdminPreview.ejs) ───────────
   The preview SVG renders every option in every axis at once (so the
   groups exist to toggle, not to draw); only the group matching the
   current selection is shown. public/js/goblinvilleAdminNpcPreview.js
   flips `.is-active` on change -- this file only defines what that class
   does. Never loaded/used on any public Goblinville page. */

.gv-npc-part {
  display: none;
}

.gv-npc-part.is-active {
  display: inline;
}

.goblinville-npc--preview {
  width: 160px;
}

/* ── Character Composer (Character Composer phase) ────────────────────────
   View/AdminWidgets/GoblinvilleCharacterTool.ejs's own large live preview
   -- reuses .goblinville-npc--preview's sizing base, just bigger, plus the
   click-to-select thumbnail grid (public/js/goblinvilleCharacterComposer.js
   toggles `.is-active` the same way it already does for `.gv-npc-part`
   above). Never loaded/used on any public Goblinville page. */

.goblinville-npc--composer {
  width: 220px;
}

.composer-thumb {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 56px;
  padding: 2px;
  background: var(--df-surface-alt);
  border: 2px solid var(--df-border-subtle);
  border-radius: 6px;
  color: var(--df-text-light);
  font-size: .65rem;
  line-height: 1;
  cursor: pointer;
}

.composer-thumb svg {
  display: block;
  max-width: 100%;
  height: auto;
}

.composer-thumb.is-active {
  border-color: var(--df-GoblinGold);
  box-shadow: 0 0 0 1px var(--df-GoblinGold);
}

/* ── Account avatar (My Goblin Phase 2) ────────────────────────────────────
   Two size/crop variants of the SAME .goblinville-npc/.goblinville-npc__figure
   markup every other surface on this page already uses -- same "just
   override width" convention as .goblinville-npc--preview/--composer above,
   nothing new to learn. */

/* Tiny navbar/header avatar -- fixed square box, circular crop. The figure
   itself is NOT resized/repositioned: `.goblinville-npc__figure` still
   renders at its natural width:100%/height:auto (120x170 aspect), which at
   a 40px-wide box comes out taller (~57px) than the 40px-tall container --
   `overflow:hidden` here simply clips that overflow from the bottom, which
   crops off the legs/boots and keeps a natural head-and-shoulders framing.
   No transform, no repositioning -- the saved character's own layers are
   never altered, only how much of the rendered figure is visible. */
.goblinville-npc--nav-avatar {
  width: 40px;
  height: 40px;
  overflow: hidden;
  border-radius: 50%;
  background: var(--df-surface-alt);
  flex-shrink: 0;
}

/* The dynamic prop-logo badge's position/size (above) is calibrated as a
   percentage of the FULL, uncropped .goblinville-npc box -- this variant
   crops that box's rendered height, so the badge would land in the wrong
   spot here. The prop itself (shield/banner) still renders fully, exactly
   as saved; only the small decorative identity-badge overlay is suppressed
   for this one tiny variant. */
.goblinville-npc--nav-avatar .goblinville-npc__prop-badge {
  display: none;
}

/* Account Settings header avatar -- bigger, full figure, no crop. */
.goblinville-npc--account-avatar {
  width: 84px;
}

/* Default Site Character phase: footer branding badge. Same crop/shape
   convention as .goblinville-npc--nav-avatar above, just sized to match
   .site-footer-card__logo's existing 58px (public/css/style.css) so
   switching branding mode never shifts the footer layout. */
.goblinville-npc--footer-avatar {
  width: 58px;
  height: 58px;
  overflow: hidden;
  border-radius: 50%;
  background: var(--df-surface-alt);
  flex-shrink: 0;
}

.goblinville-npc--footer-avatar .goblinville-npc__prop-badge {
  display: none;
}

/* Fantasy Goblin Player Images -- Phase 5 (public rendering). Ranking-row
   player image slot -- same fixed-box/overflow-hidden/no-badge convention
   as .goblinville-npc--nav-avatar above, sized and corner-rounded to match
   the existing 60x60 `rounded-3` headshot <img> it replaces
   (View/RankingsV2/RankingsBoard.ejs), so swapping between Headshot and
   Goblin mode never resizes the row. */
.goblinville-npc--ranking-avatar {
  width: 60px;
  height: 60px;
  overflow: hidden;
  border-radius: .5rem;
  background: var(--df-surface-alt);
  flex-shrink: 0;
}

.goblinville-npc--ranking-avatar .goblinville-npc__prop-badge {
  display: none;
}

/* Player Pictures Composite Composer phase 4A: a composite figure's own
   canvas (220:170, wider-than-tall) doesn't fill a fixed-height avatar box
   the same way a single figure's taller 120:170 one does -- .goblinville-npc__figure's
   own `width:100%; height:auto` (above) preserves its real aspect ratio
   correctly either way, but a wider/shorter composite simply doesn't reach
   the bottom of a fixed-height box the way these crop variants expect (see
   this file's own comment above .goblinville-npc--nav-avatar for why they
   intentionally crop a TALLER figure's own overflow), leaving it pinned to
   the top with empty space below. Center it instead, in every fixed-box
   avatar variant that could show a composite -- purely a positioning fix,
   never a resize/distortion of the figure itself. */
.goblinville-npc--nav-avatar .goblinville-npc__figure--composite,
.goblinville-npc--footer-avatar .goblinville-npc__figure--composite,
.goblinville-npc--ranking-avatar .goblinville-npc__figure--composite {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* Full-body ranking/archive rows -- Dynasty/Redraft/personal rankings
   (View/RankingsV2/RankingsBoard.ejs + controllers/rankingController.js's
   AJAX "add player" row payload) and Personal Archive rows, both the
   private preview and the public/shared page
   (View/Shared/archiveRankingBoard.ejs), which share this SAME markup via
   View/Players/partials/_playerImage.ejs's `figureClassName` param. Two
   OWN classes, never edits to .goblinville-npc--ranking-avatar/
   --nav-avatar above -- those stay exactly as-is so every other surface
   that already uses them (the player-page ranking-context widget, Best
   Available) keeps its existing portrait crop untouched.

   Same fixed footprint box as the portrait class each one stands in for
   (60x60 / 40x40) so swapping in a full-body row never changes row
   height/width. The difference is entirely in how the figure fills that
   box: the portrait classes above set the SVG to width:100%/height:auto
   and clip the overflow at the bottom (a crop); these instead leave
   `overflow` visible and fit the SVG to the box's HEIGHT (height:100%/
   width:auto -- the opposite axis), centered via flexbox. Since the
   figure's 120:170 aspect ratio is taller than it is wide, fitting to
   height keeps the resulting width comfortably inside the same box width
   too, so nothing actually spills past the box -- the whole Goblin just
   appears smaller instead of cropped. */
.goblinville-npc--ranking-avatar-full,
.goblinville-npc--archive-avatar-full {
  overflow: visible;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Dynasty/Redraft ranking rows ONLY -- a lighter --fg-player-surface "stage"
   behind the full-body Goblin instead of the dark --df-surface-alt every
   other avatar box (including .goblinville-npc--archive-avatar-full right
   below, still unchanged/--df-surface-alt) uses. Scoped to this one
   selector, not swept anywhere else yet. */
.goblinville-npc--ranking-avatar-full {
  width: 70px;
  height: 80px;
  border-radius: .5rem;
  background: var(--fg-player-surface);
}

.goblinville-npc--archive-avatar-full {
  background: var(--df-surface-alt);
  width: 40px;
  height: 40px;
  border-radius: 50%;
}

.goblinville-npc--ranking-avatar-full .goblinville-npc__figure,
.goblinville-npc--archive-avatar-full .goblinville-npc__figure {
  width: auto;
  height: 100%;
}

.goblinville-npc--ranking-avatar-full .goblinville-npc__prop-badge,
.goblinville-npc--archive-avatar-full .goblinville-npc__prop-badge {
  display: none;
}

/* Rankings V2 raster-list-image phase (View/Players/partials/_playerImage.ejs's
   own 'goblin-image' branch) -- the WebP <img> equivalent of the full-body
   treatment .goblinville-npc--ranking-avatar-full above gives the inline
   SVG figure. A generated raster goblin is a tall, native ~240x340 image
   dropped into the SAME fixed square headshot box (60x60, via the <img>'s
   own width/height attributes) the plain player photo it replaces uses --
   without this, a browser's default object-fit ('fill') stretches that
   source to fill the square, visibly squashing the goblin. `contain` keeps
   the full body's real proportions (letterboxed within the box) instead. */
.player-goblin-raster-img {
  object-fit: contain;
}

/* ── Background scene (Visual Character-Placement Editor phase) ──────────
   View/Goblinville/_locationScene.ejs. Occupies exactly the vertical slot
   the plain worker-grid + "Visiting Today" section used to (Location.ejs
   only ever renders one or the other), right after the nav/ambient-line/
   pair-interaction content. Every OTHER functional control on the page
   (nav, headings, dialogue/pair-interaction panels) still lives entirely
   outside this element.

   aspect-ratio reserves the box's height purely from its width BEFORE the
   background <img> has loaded -- the single mechanism that avoids layout
   shift here; no JS, no explicit width/height attributes needed. The
   fallback background color is what's visible during that same brief
   window (and for a slow/failed image load), so the box never flashes
   fully transparent against the page background.

   `container-type: inline-size` makes this box itself a container-query
   context for its own inline (width) axis -- added so the mobile-tier
   artwork/prop base sizes below (.goblinville-scene__figures .goblinville-npc,
   .goblinville-scene__prop-image) can be sized in `cqw` (a percentage of
   THIS box's own rendered width) instead of fixed pixels. Confirmed safe
   before adding: no other public-scene descendant declares its own
   `container-type`/`container-name` or consumes any `cq*` unit today (the
   only other user in this file is the admin-only .gv-scene-editor__stage,
   a completely separate, never-public subtree -- see its own header
   comment further down), so nothing gets rebound. It changes nothing about
   this box's own computed size: width is already 100% of its PARENT and
   height is already derived from width via `aspect-ratio`, so containment
   introduces no self-referential sizing loop; the already-present
   `position: relative` already made this box the absolute-positioning
   containing block for `.goblinville-scene__background`/`__overlay`/
   `__figures`, so that contract is unchanged too. */

:root {
  /* Goblinville mobile-scene proportional bases -- shared by the public
     mobile scene (.goblinville-scene__figures .goblinville-npc /
     .goblinville-scene__prop-image, below, resolved against THIS box via
     the container-type added above) and the admin Scene Composer's own
     mobile-viewport preview (.gv-scene-editor[data-viewport="mobile"],
     further down this file, resolved against its own .gv-scene-editor__stage
     container) -- one shared percentage, consumed independently by two
     different containers, so the two can never silently drift apart again.
     `cqw` is a percentage of the NEAREST container-type ancestor's inline
     size, resolved at the point of use, not at declaration -- a `:root`
     custom property is just text substitution, so the same value safely
     serves both trees.

     Calibrated to reproduce the previous fixed-pixel mobile sizes (76px
     character / 84px prop) unchanged at the reference scene width the
     admin editor's own mobile constants were already calibrated against
     (~509.3px -- the scene's rendered width at the top of the mobile tier,
     viewport 767.98px, inside Bootstrap's fixed 540px `sm` container step):
     76 / 509.3 = 14.9212%, 84 / 509.3 = 16.4918%. */
  --gv-mobile-character-base: 14.9212cqw;
  --gv-mobile-prop-base: 16.4918cqw;
}

.goblinville-scene {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  margin-top: 1.75rem;
  overflow: hidden;
  border-radius: var(--df-radius);
  background: var(--df-surface-alt);
  box-shadow: var(--df-shadow);
  container-type: inline-size;
}

.goblinville-scene__background {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* object-position itself is set inline per-location (the admin-chosen
     focal point, View/Goblinville/_locationScene.ejs) -- this is only the
     safe fallback if that inline style is ever absent. */
  object-position: 50% 50%;
}

.goblinville-scene__overlay {
  position: absolute;
  inset: 0;
  background: #000;
  pointer-events: none;
}

.goblinville-scene__figures {
  position: absolute;
  inset: 0;
}

/* ── Individual character placement (Phase 2C) ─────────────────────────────
   Every worker/visitor/user-goblin figure is its own independently
   positioned element -- no more automatic left/center/right grouping or
   "Working Here"/"Visiting Today" area headings. Each figure prints TEN
   CSS custom properties inline (View/Goblinville/_locationScene.ejs's own
   placementStyle()) -- x/y/scale/layer/flip, once for desktop and once for
   mobile -- and this stylesheet is the ONE place that decides which set
   applies. Phase 2F: the scene switches to the mobile set at its OWN
   767.98px breakpoint (matching the third Bootstrap `.container` step,
   see .goblinville-scene__artwork's own responsive-multiplier comment
   below) -- earlier than the 575.98px breakpoint every OTHER Goblinville
   responsive rule in this file still uses, since the scene's artwork
   starts reading oversized well before the page's general phone
   breakpoint. Nothing here calculates one viewport's position FROM the
   other; both are independently supplied values.

   Anchor contract: x%/y% is the character's BOTTOM-CENTER point (see
   View/Goblinville/_locationScene.ejs's own header comment for the full
   reasoning) -- `left`/`top` place that percentage point, and
   `translate(-50%, -100%)` on THIS element shifts its own top-left-
   anchored box so that point lands exactly under the character's feet,
   horizontally centered. This element ONLY ever positions and layers the
   whole placed figure (left/top/z-index/the anchor translate) -- it does
   NOT scale or flip anything itself (Phase 2E moved that onto
   .goblinville-scene__artwork, below, deliberately a separate, DEEPER
   element that wraps only the rendered goblin SVG/composite figure, never
   the name/badge/speech-bubble/button chrome around it -- see this file's
   own comment on that class for why).

   The `, <fallback>` second argument on each var() is what keeps a figure
   safely centered/normal-sized even if scenePlacements was ever entirely
   absent (defense in depth -- services/goblinvilleLocationScenePlacementService.js
   itself never actually omits a resolved value for a real figure, since
   every fallback formula there always returns something). */
.goblinville-scene__placement {
  position: absolute;
  left: var(--gv-slot-x-desktop, 50%);
  top: var(--gv-slot-y-desktop, 90%);
  z-index: var(--gv-slot-layer-desktop, 1);
  transform: translate(-50%, -100%);
}

/* ── Per-viewport visibility (Phase GV3) ───────────────────────────────────
   Independent of x/y/scale/layer/facing -- `visible=false` on a saved
   placement row hides it in ONE viewport only, never deletes/unassigns a
   worker, never affects daily visitor/rotating selection, never modifies a
   saved My Goblin, and has no relation to `locked` (an admin-editing
   safeguard only, with zero public effect -- this file never reads it at
   all). `--desktop-hidden` applies ABOVE the scene's own mobile breakpoint
   (below, its `display: none` is reset back to visible); `--mobile-hidden`
   applies BELOW it -- see the reset pair inside the existing 767.98px
   breakpoint further down this file. A prop instance missing one
   viewport's saved row resolves to this SAME hidden state for that side
   (services/goblinvilleLocationScenePlacementService.js#resolvePropsForLocation's
   own HIDDEN_PROP_SIDE) -- "do not require both viewport rows to render
   the valid one" is satisfied by this one shared mechanism, not a second
   one. */
.goblinville-scene__placement--desktop-hidden {
  display: none;
}

/* Decorative props only -- never intercepts a pointer event meant for a
   worker/visitor/rotating figure positioned behind or near it (a
   transparent PNG's own rectangular box would otherwise happily swallow
   clicks). Workers/visitors/rotating/user-goblin figures are NEVER given
   this class -- their own <button> (View/Goblinville/_characterTile.ejs)
   must stay fully clickable at every scale, exactly as before this
   phase. */
.goblinville-scene__placement--prop {
  pointer-events: none;
}

/* ── Artwork-only scale/flip (Phase 2E) ────────────────────────────────────
   Wraps ONLY the rendered goblin figure (View/Goblinville/_characterTile.ejs's
   own _workerNpc include, and View/Goblinville/_locationScene.ejs's own
   _userGoblinAvatar include for the user-goblin figure) -- never the name,
   the Visitor badge, the pair marker, the speech bubble, or the <button>'s
   own click/focus chrome, all of which stay siblings OUTSIDE this element
   and therefore render at their normal, unscaled, unflipped size no matter
   how large a character's saved scale is. This is what keeps a 500%-scale
   character's NAME/LABEL/SPEECH TEXT readable at normal size, and keeps
   the <button>'s own hit-testable area from balloonING out to match an
   oversized name -- only the actual artwork visually (and therefore
   clickably, since a CSS transform never removes an element from its
   ancestor's hit-testing/event-bubbling chain) grows/shrinks/mirrors.

   The `--gv-slot-*-desktop` custom properties are declared on
   .goblinville-scene__placement (an ANCESTOR of this element, several DOM
   levels up through the <button>) and inherit down to here for free --
   CSS custom properties inherit through any number of intermediate
   elements unless something explicitly stops them, so no element in
   between needs to redeclare or forward them. transform-origin matches
   the SAME bottom-center convention as the anchor above, so scaling this
   element pivots around its own "feet," not its top-left corner or
   center -- it grows in place rather than drifting away from where the
   character is standing. */
.goblinville-scene__artwork {
  display: inline-block;
  transform-origin: 50% 100%;
  transform: scaleX(var(--gv-slot-flip-desktop, 1)) scale(var(--gv-slot-scale-desktop, 1));
}

/* ── Responsive desktop-tier artwork multiplier (Phase 2F) ─────────────────
   The SAVED desktop scale (--gv-slot-scale-desktop) stays the base value at
   every width -- this never touches it, never overwrites it, and never
   reaches the database. What changes per tier is a SECOND, plain literal
   `scale()` appended to the same transform list; multiple scale() calls in
   one `transform` compose multiplicatively regardless of order, so
   `scale(var(--gv-slot-scale-desktop)) scale(0.8)` is exactly "saved scale
   x 0.8" -- e.g. a saved 250% renders at 250% wide, 200% (250 x 0.8) once
   the container narrows past 1200px, then 160% (250 x 0.8 x 0.8 = 64% of
   250) past 992px. facing (scaleX) is unaffected -- it's the same function
   call in every tier, just recombined with whichever scale() pair is
   currently in effect. transform-origin (50% 100%, declared once above)
   is untouched by any of this, so the anchor/pivot point never moves.

   Breakpoints match this project's actual vendored Bootstrap 5.0.2
   `.container` steps (public/vender/bootstrap/bootstrap.min.css) --
   max-width 1140px at >=1200px, 960px at 992-1199px, 720px at 768-991px --
   NOT arbitrary numbers, and NOT a change to those global breakpoints
   themselves (this file only ever reads them to decide when the SCENE's
   own artwork should shrink a little further; `.container`'s own
   min-width rules are never touched). Scoped to .goblinville-scene__artwork
   only -- .goblinville-scene__placement (position/anchor) and every
   text/badge/button element outside the artwork span are never touched by
   either of these two blocks. */
@media (max-width: 1199.98px) {
  .goblinville-scene__artwork {
    transform: scaleX(var(--gv-slot-flip-desktop, 1)) scale(var(--gv-slot-scale-desktop, 1)) scale(.8);
  }
}

@media (max-width: 991.98px) {
  .goblinville-scene__artwork {
    transform: scaleX(var(--gv-slot-flip-desktop, 1)) scale(var(--gv-slot-scale-desktop, 1)) scale(.64);
  }
}

/* .goblinville-worker-slot/.goblinville-worker already supply the real
   tile's own layout (button + name stacked, centered) and behavior
   (hover/focus tint, keyboard focus ring) -- this override only trims the
   grid-specific min-height/padding that made sense in a fixed-column grid
   cell but would waste room inside a compact, individually-placed figure;
   nothing about the button's interactivity changes. Sized via the same
   "just override width" convention as every other .goblinville-npc
   variant -- no distortion, the SVG's own width:100%/height:auto keeps its
   real proportions; scale is applied separately, above, on
   .goblinville-scene__artwork only. */
.goblinville-scene__character-slot .goblinville-worker {
  min-height: 0;
  padding: .3rem .25rem;
}

.goblinville-scene__figures .goblinville-npc {
  width: 92px;
}

/* ── Public prop sizing (Phase GV3) ────────────────────────────────────────
   Public props keep an 84px intrinsic-image cap in the real scene. The
   admin editor derives its own proportional cap from this public 84px value
   divided by the rendered scene width for each responsive tier, then the
   saved scale_percent multiplier is applied identically to every other
   figure's artwork -- by .goblinville-scene__artwork's own transform (the
   SAME scaleX(flip) scale(scale) rule above, including its own responsive
   1199.98px/991.98px compounding tiers). object-fit: contain +
   width/height:auto is what keeps the prop's own INTRINSIC aspect ratio --
   never distorted/stretched into the fixed character proportions
   .goblinville-npc uses. See test/goblinvillePropSizingContract.test.js for
   the automated guarantee that this public cap and the editor's proportional
   caps never drift apart. */
.goblinville-scene__prop-image {
  display: block;
  max-width: 84px;
  max-height: 84px;
  width: auto;
  height: auto;
  object-fit: contain;
}

/* ── Creator card (Bootstrap modal content) ──────────────────────────────
   Restyles Bootstrap's default white modal to match the site's dark card
   surface (same gradient/tokens as .tavern-card in public/css/tavern.css)
   so it reads as part of Goblinville, not a generic browser dialog. */

.goblinville-card {
  background: linear-gradient(180deg, var(--df-surface), var(--df-surface-alt));
  color: var(--df-text-light);
  border: 1px solid var(--df-border-subtle);
  border-radius: var(--df-radius);
}

.goblinville-card__header {
  border-bottom-color: rgba(219, 211, 190, .18);
  align-items: flex-start;
  gap: .75rem;
}

.goblinville-card__identity {
  display: flex;
  align-items: center;
  gap: .85rem;
  min-width: 0;
}

.goblinville-card__name {
  font-size: 1.15rem;
  font-weight: 700;
  line-height: 1.25;
  margin: 0;
  color: inherit;
}

.goblinville-card__creator {
  font-size: .8rem;
  opacity: .7;
  margin-top: .15rem;
}

.goblinville-card .btn-close {
  filter: invert(1) grayscale(1) brightness(1.6);
  flex: 0 0 auto;
}

.goblinville-card__body {
  font-size: .92rem;
  line-height: 1.55;
}

.goblinville-card__what {
  opacity: .85;
  margin: 0 0 .85rem;
}

.goblinville-card__description {
  margin: 0 0 .85rem;
  padding-left: .65rem;
  border-left: 2px solid currentColor;
}

.goblinville-card__qa {
  margin: 0 0 .5rem;
  padding: .65rem .8rem;
  border-radius: 10px;
  background: rgba(127, 127, 127, .14);
  border: 1px solid rgba(127, 127, 127, .18);
}

.goblinville-card__question {
  font-size: .88rem;
  font-style: italic;
  font-weight: 600;
  margin: 0 0 .3rem;
}

.goblinville-card__answer {
  font-size: .88rem;
  opacity: .82;
  margin: 0;
}

.goblinville-card__empty {
  opacity: .7;
  font-style: italic;
  margin: 0;
}

.goblinville-card__footer {
  border-top-color: rgba(219, 211, 190, .18);
}

/* Customizable profile buttons (up to 3, View/Goblinville/_workerCard.ejs).
   Bootstrap's .modal-footer already right-aligns its own direct children
   (justify-content: flex-end), so this single wrapper div sits at the far
   right of the footer as a whole; row-reverse then places button 1 (the
   FIRST child in source/DOM order -- see the presenter's own comment on why
   button order is never reversed in data) at that group's own right edge,
   button 2 immediately to its left, and button 3 furthest left -- ordinary
   flex priority, no hardcoded positions. flex-wrap allows narrow screens to
   wrap to a second row while button 1 stays the last to wrap (it's still
   the first-filled slot at the row-reversed main-start). Each button keeps
   the exact same understated .btn.btn-alt look already used everywhere
   else in Goblinville -- this is spacing/ordering only, not a new button
   style. */
.goblinville-card__buttons {
  display: flex;
  flex-direction: row-reverse;
  flex-wrap: wrap;
  gap: .5rem;
}

/* ── Tavern showcase cards (View/Goblinville/Tavern.ejs / _tavernCard.ejs) ─
   Deliberately richer/more exposed than the standard .goblinville-worker
   tile + modal pair: every field is always visible, no click-to-reveal.
   Reuses .goblinville-card's own dark gradient surface (same tokens as the
   creator card modal) so it still reads as "the same Goblinville system,"
   just presented as a showcase instead of a directory tile. Three cards
   max (services/goblinvilleDailySpotlightSelector.js's SPOTLIGHT_SIZE),
   so auto-fit naturally caps at 3 columns on wide screens without a hard
   3-column rule that would fight smaller viewports. */

.goblinville-tavern-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1.5rem;
  margin-top: 1.75rem;
}

.goblinville-tavern-card {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  padding: 1.75rem 1.5rem 1.5rem;
  background: linear-gradient(180deg, var(--df-surface), var(--df-surface-alt));
  color: var(--df-text-light);
  border: 1px solid var(--df-border-subtle);
  border-radius: var(--df-radius);
  box-shadow: var(--df-shadow);
  text-align: center;
}

/* The NPC reads larger here than on a standard location tile -- a
   spotlight card, not a directory entry -- but it's the exact same
   .goblinville-npc markup/partial, just sized up via CSS. */
.goblinville-tavern-card__npc {
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  gap: .4rem;
}

.goblinville-tavern-card__npc .goblinville-npc {
  width: 150px;
}

.goblinville-tavern-card__body {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
}

.goblinville-tavern-card__heading {
  display: flex;
  align-items: center;
  gap: .75rem;
  margin-bottom: .75rem;
}

.goblinville-tavern-card__logo {
  width: 44px;
  height: 44px;
  object-fit: contain;
  border-radius: 8px;
  background: rgba(255, 255, 255, .08);
  padding: .25rem;
  flex: 0 0 auto;
}

.goblinville-tavern-card__heading-text {
  text-align: left;
}

.goblinville-tavern-card__name {
  font-size: 1.2rem;
  font-weight: 700;
  line-height: 1.25;
  margin: 0;
  color: inherit;
}

.goblinville-tavern-card__creator {
  font-size: .82rem;
  opacity: .7;
  margin-top: .15rem;
}

.goblinville-tavern-card__what {
  font-size: .92rem;
  opacity: .85;
  margin: 0 0 .75rem;
}

.goblinville-tavern-card__description {
  font-size: .92rem;
  line-height: 1.55;
  margin: 0 0 .85rem;
  padding-left: .65rem;
  border-left: 2px solid currentColor;
  text-align: left;
}

.goblinville-tavern-card__qa {
  width: 100%;
  margin: 0 0 1rem;
  padding: .65rem .8rem;
  border-radius: 10px;
  background: rgba(127, 127, 127, .14);
  border: 1px solid rgba(127, 127, 127, .18);
  text-align: left;
}

.goblinville-tavern-card__question {
  font-size: .88rem;
  font-style: italic;
  font-weight: 600;
  margin: 0 0 .3rem;
}

.goblinville-tavern-card__answer {
  font-size: .88rem;
  opacity: .82;
  margin: 0;
}

.goblinville-tavern-card__visit {
  margin-top: auto;
}

/* ── Town Hall guide + waypoints (View/Goblinville/TownHall.ejs) ──────────
   Civic/town-square flavor: a gold accent, one prominent resident NPC with
   a static speech-bubble line, and a clear set of links onward -- no
   click-to-reveal (same "content already exposed" approach as the Tavern),
   no elaborate artwork. The NPC reuses the exact same .goblinville-npc
   markup/partial as every other location, just sized up further still
   (the largest NPC anywhere in Goblinville, since it's the whole page's
   visual focus). */

.goblinville-town-hall-guide {
  display: flex;
  align-items: center;
  gap: 2rem;
  flex-wrap: wrap;
  margin-top: 1.75rem;
  padding: 1.75rem;
  background: linear-gradient(180deg, var(--df-surface), var(--df-surface-alt));
  color: var(--df-text-light);
  border: 1px solid var(--df-border-subtle);
  border-radius: var(--df-radius);
  box-shadow: var(--df-shadow);
}

.goblinville-town-hall-guide__npc {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .85rem;
  flex: 0 0 auto;
}

.goblinville-town-hall-guide__npc .goblinville-npc {
  width: 170px;
}

/* A static line of flavor text, not a dialogue engine -- a simple bubble
   shape (rounded box + a small triangle pointer toward the NPC) built with
   plain CSS, no image asset. */
.goblinville-town-hall-guide__bubble {
  position: relative;
  max-width: 220px;
  padding: .6rem .9rem;
  background: var(--df-text-light);
  color: var(--df-surface-alt);
  border-radius: 12px;
  font-size: .85rem;
  font-weight: 600;
  text-align: center;
}

.goblinville-town-hall-guide__bubble p {
  margin: 0;
}

.goblinville-town-hall-guide__bubble::after {
  content: '';
  position: absolute;
  top: -8px;
  left: 50%;
  transform: translateX(-50%);
  border-left: 8px solid transparent;
  border-right: 8px solid transparent;
  border-bottom: 8px solid var(--df-text-light);
}

.goblinville-town-hall-guide__intro {
  flex: 1 1 320px;
  min-width: 0;
}

.goblinville-town-hall-guide__greeting {
  font-size: 1.3rem;
  font-weight: 700;
  margin: 0 0 .3rem;
  color: inherit;
}

.goblinville-town-hall-guide__name {
  font-size: .82rem;
  font-weight: 600;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--gv-accent);
  margin: 0 0 .85rem;
}

.goblinville-town-hall-guide__copy {
  font-size: .95rem;
  line-height: 1.6;
  opacity: .9;
  margin: 0 0 .75rem;
}

.goblinville-town-hall-guide__copy:last-child {
  margin-bottom: 0;
}

.goblinville-town-hall-waypoints {
  margin-top: 2.25rem;
}

.goblinville-town-hall-waypoints__title {
  font-size: 1.05rem;
  font-weight: 700;
  color: var(--df-text-dark);
  margin: 0 0 1rem;
}

.goblinville-town-hall-waypoint-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 1rem;
}

/* Real links, not buttons that merely look like ones -- comfortably
   tappable (generous padding, no reliance on hover for the label/blurb
   both being visible). */
.goblinville-town-hall-waypoint {
  display: flex;
  flex-direction: column;
  gap: .35rem;
  padding: 1rem 1.1rem;
  border-radius: var(--df-radius);
  text-decoration: none;
  color: var(--df-text-dark);
  background: rgba(24, 24, 27, .04);
  border: 1px solid rgba(24, 24, 27, .12);
  transition: background-color .15s ease, border-color .15s ease, transform .15s ease;
}

.goblinville-town-hall-waypoint:hover,
.goblinville-town-hall-waypoint:focus-visible {
  background: var(--gv-accent-soft);
  border-color: var(--gv-accent);
  transform: translateY(-2px);
}

.goblinville-town-hall-waypoint__label {
  font-weight: 700;
  font-size: 1rem;
}

.goblinville-town-hall-waypoint__blurb {
  font-size: .85rem;
  opacity: .75;
  line-height: 1.45;
}

/* ── Town Square (View/Goblinville/TownSquare.ejs) -- the town homepage ───
   A CSS grid "town square," not a real map: two rows on desktop (Town Hall
   + Tavern sharing the top row, the three work locations sharing the
   second) so the eye reads "these are places in one town" without literal
   geographic layout. Tavern gets 4/6 columns (double any other panel) for
   the "extra visual weight" this phase asks for; Town Hall gets 2/6 as the
   compact "start here" panel. Collapses to a single column well before the
   grid gets cramped (768px, not this file's usual 575.98px phone
   breakpoint) -- "vertical village list," per this phase's mobile
   requirement, not a shrunk desktop map.

   Each panel carries its OWN goblinville-theme-* class (unlike every other
   Goblinville page, which applies exactly one theme class to <main>) since
   five different locations' accents are all present on this one page at
   once -- --gv-accent/--gv-accent-soft still cascade correctly to a
   panel's own children either way, CSS custom properties don't care which
   element defines them. */

.goblinville-town-square-grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: 1.5rem;
  margin-top: 1.75rem;
}

/* Admin-Created Locations phase: the standard-location panels are no
   longer fixed-count siblings of Town Hall/Tavern inside the 6-column
   grid above (that grid's own column math only ever worked for exactly
   3) -- they get their OWN grid, auto-fitting any number of panels
   (1, 3, 7...) into as many equal-width columns as comfortably fit,
   never a fixed count. Preserves the same visual hierarchy: Town Hall/
   Tavern stay the prominent top row, standard locations are their own
   row below. */
.goblinville-town-square-work-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 1.5rem;
  margin-top: 1.5rem;
}

.goblinville-town-square-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1rem;
  padding: 1.5rem 1.35rem;
  background: linear-gradient(180deg, var(--df-surface), var(--df-surface-alt));
  color: var(--df-text-light);
  border: 1px solid var(--df-border-subtle);
  border-radius: var(--df-radius);
  box-shadow: var(--df-shadow);
  text-align: center;
}

.goblinville-town-square-panel--town-hall {
  grid-column: span 2;
}

.goblinville-town-square-panel--tavern {
  grid-column: span 4;
}

/* No fixed span -- .goblinville-town-square-work-grid's own auto-fit
   columns size every work panel identically, however many there are. */
.goblinville-town-square-panel--work {
  grid-column: auto;
}

.goblinville-town-square-panel__npc .goblinville-npc {
  width: 110px;
}

.goblinville-town-square-panel__body {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 100%;
}

.goblinville-town-square-panel__icon {
  font-size: 1.6rem;
  margin-bottom: .3rem;
}

.goblinville-town-square-panel__title {
  font-size: 1.1rem;
  font-weight: 700;
  margin: 0 0 .2rem;
  color: inherit;
}

.goblinville-town-square-panel__tagline {
  font-size: .8rem;
  font-weight: 600;
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--gv-accent);
  margin: 0 0 .85rem;
}

.goblinville-town-square-panel__prompt {
  font-size: .95rem;
  font-style: italic;
  opacity: .85;
  margin: 0 0 1rem;
}

.goblinville-town-square-panel__empty {
  font-size: .85rem;
  opacity: .75;
  font-style: italic;
  margin: 0 0 1rem;
}

.goblinville-town-square-panel__more {
  font-size: .78rem;
  opacity: .65;
  margin: -.35rem 0 .85rem;
}

.goblinville-town-square-panel__enter {
  margin-top: auto;
}

/* Preview rows -- a small taste of who's there, never the full card/tile
   set (services/goblinvilleTownSquareService.js caps these at 3). */

.goblinville-town-square-tavern-preview,
.goblinville-town-square-worker-preview {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 1rem;
  margin-bottom: 1rem;
}

.goblinville-town-square-preview-person {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .3rem;
  max-width: 90px;
}

.goblinville-town-square-preview-person .goblinville-npc {
  width: 72px;
}

.goblinville-town-square-preview-person--compact .goblinville-npc {
  width: 60px;
}

.goblinville-town-square-preview-person__name {
  font-size: .75rem;
  font-weight: 700;
  line-height: 1.25;
}

.goblinville-town-square-preview-person__creator {
  font-size: .7rem;
  opacity: .7;
}

/* ── Ambient line ─────────────────────────────────────────────────────── */
/* One short, authored room-atmosphere line per populated location per day
   (services/goblinvilleAmbientDialogue.js). Deliberately NOT attached to
   any one NPC -- sits above the worker/visitor grid as a small room-rumor
   panel, not a speech bubble hanging off a character. Room-specific accent
   via the same --gv-accent/--gv-accent-soft custom properties every other
   themed section already uses. */

.goblinville-ambient-line {
  display: flex;
  align-items: flex-start;
  gap: .6rem;
  max-width: 34rem;
  margin: 0 auto 1.5rem;
  padding: .65rem .9rem;
  background: var(--gv-accent-soft);
  border: 1px solid var(--gv-accent);
  border-radius: .5rem;
}

.goblinville-ambient-line__icon {
  font-size: 1.1rem;
  line-height: 1.4;
}

.goblinville-ambient-line__text {
  margin: 0;
  font-size: .9rem;
  font-style: italic;
  line-height: 1.4;
}

.goblinville-ambient-line__label {
  font-style: normal;
  font-weight: 700;
  margin-right: .3rem;
}

/* ── Character speech (Character Dialogue Phase 2) ───────────────────────
   Exactly ONE authored, ATTRIBUTED speech bubble per populated location
   per day (services/goblinvilleCharacterSpeechService.js), attached right
   under the one selected character's own tile/card
   (View/Goblinville/_characterSpeech.ejs). Deliberately styled to read
   differently from .goblinville-ambient-line above: a bubble shape with a
   pointer (Town-Hall-guide-bubble convention, not a room-wide panel),
   non-italic, and carries the speaker's own name -- ambient rumor is
   unattributed atmosphere, this is one specific NPC's authored line. */

.goblinville-character-speech {
  position: relative;
  max-width: 150px;
  margin-top: .55rem;
  padding: .45rem .65rem;
  background: var(--df-text-light);
  color: var(--df-surface-alt);
  border-radius: 10px;
  font-size: .74rem;
  line-height: 1.3;
  text-align: center;
}

.goblinville-character-speech::after {
  content: '';
  position: absolute;
  top: -7px;
  left: 50%;
  transform: translateX(-50%);
  border-left: 7px solid transparent;
  border-right: 7px solid transparent;
  border-bottom: 7px solid var(--df-text-light);
}

.goblinville-character-speech__name {
  display: block;
  font-weight: 700;
  font-size: .68rem;
  text-transform: uppercase;
  letter-spacing: .03em;
  margin-bottom: .15rem;
  opacity: .8;
}

.goblinville-character-speech__text {
  margin: 0;
  font-weight: 600;
}

/* Tavern cards are wider/richer than a standard worker tile -- let the
   bubble breathe a little more there rather than staying pinned to the
   compact 150px default. */
.goblinville-tavern-card .goblinville-character-speech {
  max-width: 220px;
}

/* ── Pair Interactions (Phase 2, public) ──────────────────────────────────
   A THIRD, deliberately separate text system from the two above: an
   ambient rumor is unattributed room atmosphere, character speech is
   attributed to ONE NPC, a pair interaction involves and names TWO
   specific NPCs (services/goblinvillePairInteractionService.js). At most
   one per location per day. The panel itself sits once per page, right
   below the ambient-line panel (View/Goblinville/_pairInteraction.ejs);
   `.goblinville-pair-marker`/`--paired` below are the restrained per-tile
   pointer back to it, applied to whichever two worker/visitor/Tavern-card
   tiles are actually involved. */

.goblinville-pair-interaction {
  display: flex;
  flex-direction: column;
  gap: .3rem;
  max-width: 34rem;
  margin: 0 auto 1.5rem;
  padding: .65rem .9rem;
  background: var(--df-surface);
  color: var(--df-text-light);
  border: 1px solid var(--df-GoblinGold);
  border-radius: .5rem;
}

.goblinville-pair-interaction__names {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-wrap: wrap;
  gap: .3rem;
  font-size: .78rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: .02em;
  color: var(--df-GoblinGold);
}

.goblinville-pair-interaction__marker {
  font-size: .85rem;
}

.goblinville-pair-interaction__connector {
  opacity: .7;
  font-weight: 400;
}

.goblinville-pair-interaction__text {
  margin: 0;
  font-size: .9rem;
  line-height: 1.4;
  text-align: center;
}

/* The small badge shown on each of the two involved NPC tiles -- same
   restrained treatment everywhere it's used (a standard worker tile, a
   visitor tile, a Tavern showcase card), never a second avatar/marker
   system. */
.goblinville-pair-marker {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: .85rem;
  line-height: 1;
}

/* The shared "ring" that visually connects the two involved tiles --
   applied identically to both, regardless of whether they're permanent
   workers, visitors, or (for Tavern) showcase cards, so the same color/
   shape always means "part of today's story moment." Deliberately a
   box-shadow (not outline/border) so it layers cleanly on top of
   .goblinville-worker--visitor's own dashed border and
   .goblinville-worker's own :hover/:focus-visible treatments without
   fighting either. */
.goblinville-worker--paired {
  box-shadow: 0 0 0 2px var(--df-GoblinGold);
  border-radius: var(--df-radius);
}

.goblinville-tavern-card--paired {
  box-shadow: var(--df-shadow), 0 0 0 2px var(--df-GoblinGold);
}

/* ── Empty state ──────────────────────────────────────────────────────── */

.goblinville-empty {
  max-width: 34rem;
  margin: 2.5rem auto 0;
  padding: 2rem 1.5rem;
  text-align: center;
  border-radius: var(--df-radius);
  border: 1px dashed rgba(24, 24, 27, .18);
}

.goblinville-empty__icon {
  font-size: 2rem;
  margin-bottom: .5rem;
}

.goblinville-empty__title {
  font-size: 1.2rem;
  font-weight: 700;
  color: var(--df-text-dark);
  margin: 0 0 .4rem;
}

.goblinville-empty__copy {
  color: rgba(24, 24, 27, .7);
  margin: 0;
}

/* ── Responsive ───────────────────────────────────────────────────────── */

/* The town square grid gets cramped (Town Hall alongside a 4-column-wide
   Tavern) well before this file's usual 575.98px phone breakpoint --
   collapsing at 768px (Bootstrap's own md breakpoint, already used
   elsewhere in this app) turns both grids into a single vertical column
   ("a village list of places") on tablets and phones alike, with no
   horizontal scrolling and no attempt to preserve the desktop arrangement
   at a size it doesn't fit. */
@media (max-width: 767.98px) {
  .goblinville-town-square-grid {
    grid-template-columns: 1fr;
  }

  .goblinville-town-square-panel--town-hall,
  .goblinville-town-square-panel--tavern {
    grid-column: span 1;
  }

  .goblinville-town-square-work-grid {
    grid-template-columns: 1fr;
  }

  /* ── Scene mobile transition (Phase 2F) ───────────────────────────────
     Moved here from the 575.98px breakpoint below -- this is the SAME
     breakpoint used above for Town Square, and matches this project's
     actual vendored Bootstrap `.container` step down to a 720px max-width
     (public/vender/bootstrap/bootstrap.min.css), the third of the three
     container-width jumps the scene's own artwork needs to react to. Below
     this width the scene switches WHOLESALE to the saved MOBILE
     coordinate/scale/layer/facing set and the taller mobile aspect ratio --
     never the compounded desktop responsive multiplier
     (.goblinville-scene__artwork's own base/1199.98px/991.98px rules,
     above) at the same time; only one of the two systems is ever active
     for a given viewport. Every OTHER (non-scene) Goblinville responsive
     rule still changes at 575.98px, unchanged below -- this scene-only
     transition is deliberately earlier because a 16:9 box's own artwork
     starts reading oversized well before the page's general phone
     breakpoint. */
  .goblinville-scene {
    aspect-ratio: 3 / 4;
  }

  .goblinville-scene__placement {
    left: var(--gv-slot-x-mobile, 50%);
    top: var(--gv-slot-y-mobile, 90%);
    z-index: var(--gv-slot-layer-mobile, 1);
  }

  .goblinville-scene__artwork {
    transform: scaleX(var(--gv-slot-flip-mobile, 1)) scale(var(--gv-slot-scale-mobile, 1));
  }

  /* Differential-scaling fix: below this breakpoint the scene's own
     rendered width is only fixed-pixel for part of the mobile range
     (Bootstrap's `.container` caps at 540px from 767.98px down to 576px,
     then goes fully fluid below that) while these two base sizes used to
     stay flat fixed pixels (76px / 84px) the whole way down to the
     smallest phone -- the scene kept shrinking past 576px while the
     artwork did not, so the bartender/prop kept growing relative to the
     scene the narrower the viewport got, with no breakpoint involved (see
     test/goblinvilleMobileSceneProportionalScaling.test.js for the
     measured drift this replaces). `cqw` ties both bases to
     `.goblinville-scene`'s own current width (via `container-type:
     inline-size` on that rule, above) instead of the viewport, so they
     track the scene continuously at every width, not just at the two
     Bootstrap container steps. `--gv-mobile-character-base`/
     `--gv-mobile-prop-base` (declared once, :root, above) are calibrated
     to reproduce the exact previous 76px/84px appearance at the ~509.3px
     reference scene width (viewport 767.98px) -- nothing changes there.
     The saved `--gv-slot-scale-mobile` multiplier (applied by
     .goblinville-scene__artwork's own `transform: scale()`, above) is
     unaffected -- final rendered size is still exactly
     "mobile base x stored scale", only the base itself is now
     scene-relative instead of a flat constant. */
  .goblinville-scene__figures .goblinville-npc {
    width: var(--gv-mobile-character-base);
  }

  .goblinville-scene__prop-image {
    max-width: var(--gv-mobile-prop-base);
    max-height: var(--gv-mobile-prop-base);
  }

  /* Phase GV3: the mirror image of --desktop-hidden above -- below this
     breakpoint, a desktop-only hide no longer applies (reset back to
     visible) and a mobile-only hide takes over instead. Declared in THIS
     order (reset, then hide) so an item hidden in BOTH viewports still
     resolves to display:none here too (same specificity, later rule
     wins). */
  .goblinville-scene__placement--desktop-hidden {
    display: block;
  }

  .goblinville-scene__placement--mobile-hidden {
    display: none;
  }
}

@media (max-width: 575.98px) {
  .goblinville-location-nav {
    gap: .4rem;
  }

  .goblinville-location-nav__link {
    padding: .45rem .85rem;
    font-size: .8rem;
  }

  .goblinville-worker-grid {
    grid-template-columns: repeat(auto-fill, minmax(104px, 1fr));
    gap: .85rem;
  }

  .goblinville-worker {
    min-height: 168px;
  }

  .goblinville-npc {
    width: 88px;
  }

  /* My Goblin Phase 2 mobile-framing fix: without this, the rule just
     above (.goblinville-npc { width: 88px; }) wins the cascade tie against
     .goblinville-npc--nav-avatar's own width:40px (equal specificity,
     earlier in the file, so this later same-breakpoint rule was winning) --
     the nav avatar's height stayed fixed at 40px while its width silently
     grew to 88px, turning the circular crop oval and pushing most of the
     visible window into empty space above the head. Reasserting width
     here keeps the container square again at the one breakpoint where it
     broke; height/overflow/border-radius are untouched, and the desktop
     nav avatar (never affected by this media query) is untouched too.
     The small translateY nudge below just shifts the already-rendered
     figure up a bit within that same fixed crop, trimming some of the
     blank headroom above the head so the face sits closer to center --
     the SVG/layers/saved character_config are never altered, only how
     much of the existing render is visible. */
  .goblinville-npc--nav-avatar {
    width: 40px;
  }

  .goblinville-npc--nav-avatar .goblinville-npc__figure {
    transform: translateY(-8px);
  }

  .goblinville-avatar {
    width: 72px;
    height: 72px;
  }

  /* Bootstrap's modal-dialog-centered already keeps the dialog usable on
     small screens; this just trims the outer margin so the card gets more
     width to work with on a phone. */
  .goblinville-card-modal .modal-dialog {
    margin-left: .75rem;
    margin-right: .75rem;
  }

  /* .goblinville-tavern-grid already stacks to one column below ~280px
     tiles via its own auto-fit minmax -- no column-count override needed
     here, just trimming card padding/NPC size to match every other
     component's mobile scale-down. */
  .goblinville-tavern-card {
    padding: 1.25rem 1.1rem 1.1rem;
    gap: .75rem;
  }

  .goblinville-tavern-card__npc .goblinville-npc {
    width: 120px;
  }

  /* .goblinville-town-hall-guide's flex-wrap already stacks the NPC above
     the intro text on narrow screens (the intro's 320px flex-basis won't
     fit beside a 170px NPC on a phone) -- this just centers everything
     once stacked and trims padding/NPC size to match. */
  .goblinville-town-hall-guide {
    justify-content: center;
    text-align: center;
    padding: 1.25rem;
  }

  .goblinville-town-hall-guide__npc .goblinville-npc {
    width: 130px;
  }

  .goblinville-town-hall-waypoint-grid {
    grid-template-columns: 1fr;
  }

  .goblinville-town-square-panel {
    padding: 1.25rem 1.1rem;
  }

  .goblinville-town-square-panel__npc .goblinville-npc {
    width: 92px;
  }

  .goblinville-town-square-preview-person .goblinville-npc {
    width: 60px;
  }

  .goblinville-town-square-preview-person--compact .goblinville-npc {
    width: 52px;
  }
}

/* ── Admin Visual Scene Layout editor (Phase 2C) ───────────────────────────
   View/AdminWidgets/GoblinvilleLocationsTool.ejs +
   public/js/goblinvilleLocationSceneEditor.js -- NEVER loaded/used on any
   public Goblinville page, same convention as .gv-npc-part/
   .goblinville-npc--composer above. The stage mirrors the PUBLIC scene's
   own aspect-ratio/background-position exactly (16:9 desktop, 3:4 mobile)
   so a saved position previews accurately; figures are plain draggable
   placeholder chips (not full rendered character art -- see this file's
   own view-side comment for why), positioned with the SAME bottom-center-
   anchor percentage contract the public page uses (left/top % + translate
   -50%/-100%), so admin and public agree on what a given x/y means. */

.gv-scene-editor__stage {
  position: relative;
  width: 100%;
  max-width: 480px;
  container-type: inline-size;
  overflow: hidden;
  border-radius: var(--df-radius, 8px);
  border: 1px solid rgba(255, 255, 255, .15);
  background-color: #222;
}

/* Editor artwork sizes are stage-proportional: each cqw value mirrors the
   corresponding public artwork/image-width / public scene-width ratio for
   that responsive tier. Goblin art and props use separate bases because
   their public base sizes are not the same. */
.gv-scene-editor {
  --gv-editor-artwork-base: 7.9861cqw;
  --gv-editor-prop-base: 7.2917cqw;
}

/* Mobile-viewport tier only: reads the SAME --gv-mobile-character-base/
   --gv-mobile-prop-base custom properties (declared once, :root, near
   .goblinville-scene above) the public mobile scene now sizes its own
   artwork/props with, instead of repeating the literal 14.9212cqw/
   16.4918cqw numbers here -- one shared source of truth, so the admin
   preview and the public page can no longer drift apart at this tier
   even if one is retuned later. Each still resolves against its OWN
   local container-type ancestor (.gv-scene-editor__stage here vs
   .goblinville-scene publicly) -- see the :root declaration's own
   comment. The desktop-tier bases immediately above and the two
   breakpoint overrides below are untouched -- this fix is scoped to the
   mobile viewport only. */
.gv-scene-editor[data-viewport="mobile"] {
  --gv-editor-artwork-base: var(--gv-mobile-character-base);
  --gv-editor-prop-base: var(--gv-mobile-prop-base);
}

@media (max-width: 1199.98px) {
  .gv-scene-editor[data-viewport="desktop"] {
    --gv-editor-artwork-base: 8.0608cqw;
    --gv-editor-prop-base: 9.2002cqw;
  }
}

@media (max-width: 991.98px) {
  .gv-scene-editor[data-viewport="desktop"] {
    --gv-editor-artwork-base: 8.6541cqw;
    --gv-editor-prop-base: 12.3461cqw;
  }
}

.gv-scene-editor__figure {
  position: absolute;
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  transform: translate(-50%, -100%);
  cursor: grab;
  touch-action: none; /* required so a pointer drag is never hijacked by the browser's own touch-scroll gesture */
  user-select: none;
}

.gv-scene-editor__figure:active {
  cursor: grabbing;
}

.gv-scene-editor__artwork {
  display: inline-block;
  transform-origin: 50% 100%;
}

.gv-scene-editor__figure.is-selected .gv-scene-editor__figure-chip {
  outline: 2px solid var(--df-GoblinGold, #cdec1e);
  outline-offset: 2px;
}

.gv-scene-editor__figure-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: .3rem .55rem;
  border-radius: 999px;
  font-size: .68rem;
  font-weight: 700;
  white-space: nowrap;
  color: #fff;
  box-shadow: 0 1px 4px rgba(0, 0, 0, .5);
}

.gv-scene-editor__figure-chip--worker {
  background: #839713;
}

.gv-scene-editor__figure-chip--visitor {
  background: #c76b3f;
}

.gv-scene-editor__figure-chip--user {
  background: #6c86c4;
}

/* Phase 3B: the Tavern's three rotating spotlight slots -- its own color,
   distinct from --visitor (a standard location's Visitor Slot 1/2), since
   the two placement types never coexist on the same location's editor but
   share the same "reusable slot, not a fixed identity" visual language. */
.gv-scene-editor__figure-chip--rotating {
  background: #a67c27;
}

.gv-scene-editor__panel {
  padding: .75rem;
  border: 1px solid rgba(255, 255, 255, .12);
  border-radius: var(--df-radius, 8px);
  background: rgba(255, 255, 255, .04);
}

.gv-scene-editor-tabs .btn.active {
  background: var(--df-GoblinGold, #cdec1e);
  color: #18181b;
  border-color: var(--df-GoblinGold, #cdec1e);
}

/* ── Phase GV2: visual editor additions ──────────────────────────────────
   Dirty-state indicator, lock/hidden states (never color-only -- each
   pairs a badge icon + accessible title with its own outline/opacity
   change), real worker preview art, the neutral slot/user placeholder
   silhouette, prop images, and the Scene Asset Library add/upload row. */

.gv-scene-editor__dirty-indicator {
  font-size: .78rem;
  font-weight: 600;
  padding: .2rem .55rem;
  border-radius: 999px;
  background: rgba(255, 255, 255, .08);
  color: rgba(255, 255, 255, .75);
}

.gv-scene-editor__dirty-indicator[data-dirty="1"] {
  background: rgba(205, 236, 30, .18);
  color: var(--df-GoblinGold, #cdec1e);
}

/* Locked: outline + a small badge icon (never relying on cursor style
   alone, which a screen reader or a static screenshot can't convey). */
.gv-scene-editor__figure.is-locked {
  cursor: not-allowed;
}

.gv-scene-editor__figure.is-locked .gv-scene-editor__figure-chip {
  outline: 2px dashed rgba(255, 255, 255, .5);
  outline-offset: 2px;
}

/* Hidden-in-this-viewport: dimmed, never fully removed from the canvas
   (still selectable/editable), plus its own badge. */
.gv-scene-editor__figure.is-hidden {
  opacity: .35;
}

.gv-scene-editor__figure-badge {
  display: none;
  position: absolute;
  top: -6px;
  font-size: .7rem;
  line-height: 1;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .8));
}

.gv-scene-editor__figure-badge--locked {
  left: -6px;
}

.gv-scene-editor__figure-badge--hidden {
  right: -6px;
}

.gv-scene-editor__figure.is-locked .gv-scene-editor__figure-badge--locked,
.gv-scene-editor__figure.is-hidden .gv-scene-editor__figure-badge--hidden {
  display: inline;
}

/* Real worker preview art (View/Goblinville/_characterFigureLayers.ejs,
   included directly -- see this view's own comment on why: it avoids that
   partial's normal fixed-size wrapper CSS, so THIS editor's own viewport
   base plus scale_percent-driven multiplier is the only thing controlling
   size here). */
.gv-scene-editor__worker-art svg {
  width: var(--gv-editor-artwork-base, 92px);
  height: auto;
  display: block;
}

/* Neutral, goblin-proportioned placeholder silhouette -- visitor/rotating
   slots and the logged-in-user slot (never a real character's or user's
   actual appearance; see the view's own header comment). A flat, muted
   fill deliberately distinct from every real NPC/prop color already used
   on this page. */
.gv-scene-editor__placeholder-figure {
  width: var(--gv-editor-artwork-base, 92px);
  height: auto;
  display: block;
  fill: #5a5a68;
  stroke: rgba(255, 255, 255, .25);
  stroke-width: 1;
}

.gv-scene-editor__placeholder-boot {
  fill: #3c3c46;
}

.gv-scene-editor__prop-image {
  max-width: var(--gv-editor-prop-base, 84px);
  max-height: var(--gv-editor-prop-base, 84px);
  width: auto;
  height: auto;
  display: block;
  object-fit: contain;
}

.gv-scene-editor__figure-chip--prop {
  background: #3f7a6b;
}

.gv-scene-editor__panel-asset-name {
  font-weight: 400;
  font-size: .85rem;
  color: rgba(255, 255, 255, .65);
  margin-left: .35rem;
}

.gv-scene-editor__prop-library {
  padding: .65rem .75rem;
  border: 1px solid rgba(255, 255, 255, .12);
  border-radius: var(--df-radius, 8px);
  background: rgba(255, 255, 255, .03);
}

.gv-scene-editor__prop-library-row {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

/* Reduced motion is already fully satisfied without any rule of its own
   here: this whole section stays free of any CSS-timed motion effect --
   every visual update (drag, lock, layer reorder) is an immediate style
   write driven by pointer/JS -- so there is nothing to disable for a
   reduced-motion preference. See this codebase's own scope-guard test for
   this section (test/goblinvilleScenePlacementEditorScopeGuard.test.js),
   extended in GV2 to cover this same guarantee here too. */
