* { margin:0; padding:0; box-sizing:border-box; -webkit-tap-highlight-color:transparent; }

body {
  background:#1a1a2e;
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
  color:#eee; height:100dvh; overflow:hidden;
}

/* ── App shell ──
   No header bar: the tabletop fills the entire viewport, with the title
   and toolbar floating on top of it as separate HUD-style panels (see
   below) rather than pushing it down and eating vertical space. */
#app { position:relative; height:100dvh; overflow:hidden; }

#corner-title {
  position:absolute; top:0; left:0; z-index:20;
  background:#16213e; border-right:1px solid #1e3050; border-bottom:1px solid #1e3050;
  border-radius:0 0 10px 0;
  padding:10px 18px 10px 16px;
}
#app-title { font-size:1.3rem; font-weight:700; color:#80d8ff; letter-spacing:0.04em; }
/* Button first, version text after -- the version sits a bit further right
   than it used to (it was alone on this row before the about button
   existed) to leave room for it. */
#corner-subrow { display:flex; align-items:center; gap:6px; margin-top:4px; }
#app-version { font-size:.65rem; font-weight:400; color:#4a5568; letter-spacing:.02em; }
/* Small and unobtrusive by design (the user's own description) -- a plain
   circled-info glyph rather than a full .add-btn, so it doesn't compete
   with the title for attention. */
.about-btn {
  background:none; border:none; padding:0; margin:0; line-height:1;
  color:#4a5568; font-size:.85rem; cursor:pointer;
}
.about-btn:hover { color:#80d8ff; }
/* Pinned to the right of the subrow (the corner-title panel's own bottom
   row) via margin-left:auto -- moved out of #button-rail entirely
   (2026-09-10, user-requested) since that bar is routinely left minimized
   on a PC, which otherwise left no way to see or reach GM Mode at all.
   A small always-visible corner badge, not gated by the rail's own
   collapse state. Reuses .gm-mode-btn.active's existing amber highlight
   (below) as the on/off indicator rather than changing its own label text
   -- the badge just reads "GM" always, same as the rail's other buttons
   read their own action rather than their current state. */
.gm-mode-corner-btn {
  margin-left:auto;
  background:#0f1730; border:1.5px solid #33507d; border-radius:5px;
  color:#80d8ff; padding:2px 8px; font-size:.68rem; font-family:inherit;
  letter-spacing:.03em; cursor:pointer;
}
.gm-mode-corner-btn:hover { border-color:#80d8ff; }

/* Top-right column: the current room's name, then the current scene's name
   right below it (mirrors #corner-title on the left -- see .corner-name-btn
   below). #button-rail is also a child here in the DOM, but no longer
   participates in this column's own flow at all -- it's always
   position:fixed to the bottom of the viewport now (see its own rule
   below), so in practice this column only ever holds the two badges. */
#top-right-stack {
  position:absolute; top:0; right:0; z-index:20;
  display:flex; flex-direction:column; align-items:flex-end; gap:0;
  /* Room and Scene sit flush against each other (see #room-name-btn's own
     override below) -- the gap between them read as not worth anything
     (user-requested 2026-09-10: "doesn't have a lot of value, let's abut
     the scene selector to the room selector"). */
  /* This column's own box is as wide as its widest row -- if the room and
     scene names differ in length, the shorter one still has real empty
     space to its left, right-aligned as it is. pointer-events is
     inherited, so without this, that empty space still belongs to this
     (invisible, background-less) container and swallows clicks meant for
     whatever's on the tabletop underneath (confirmed live 2026-09-10, back
     when #button-rail was also still part of this same flow on desktop:
     cards under that empty margin were unselectable regardless of vertical
     position -- the same risk remains for the two badges alone). `none`
     here + `auto` on every actual clickable/visible piece below restores
     click-through everywhere else. */
  pointer-events:none;
}
#top-right-stack button { pointer-events:auto; }
/* Mirrors #corner-title's own panel treatment (background, border, rounded
   corner) but on the opposite side -- shared by the room-name badge and,
   right below it, the scene-name badge (user-requested 2026-09-10: "the
   scene name / button should be right below the room name / button", the
   same compact always-visible-but-tiny footprint for both, rather than the
   old permanently-open #scene-panel). Neither has a keyboard shortcut of
   its own, so neither belongs in the alphabetized shortcut rail below (see
   CLAUDE.md's Room/Scene Selector section) -- click either to open its own
   picker overlay (openRoomSelector/openSceneSelector in app.js, sharing
   one implementation -- see that section for why). */
.corner-name-btn {
  background:#16213e; border-left:1px solid #1e3050; border-bottom:1px solid #1e3050;
  border-radius:0 0 0 10px; border-top:none; border-right:none;
  padding:10px 16px 10px 18px; max-width:280px;
  color:#80d8ff; font-size:1rem; font-weight:600; letter-spacing:0.01em;
  cursor:pointer; text-align:right; font-family:inherit;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}
.corner-name-btn:hover { color:#a8e6ff; }
/* The pair now abuts directly (see #top-right-stack's own gap:0) -- the
   rounded outer corner belongs to whichever one is visually last in the
   merged pair (.corner-name-btn's own base already has it, correct for
   #scene-name-btn unchanged), not the room badge sitting above it, which
   would otherwise show a stray rounded notch mid-panel. Its own
   border-bottom is kept as-is, reading as a thin divider between the two
   still-independently-clickable rows rather than removed for a fully
   seamless blend. */
#room-name-btn { border-radius:0; }

/* A bottom-pinned *contextual* bar (not a header, not a floating vertical
   rail -- an earlier version switched between the two at a 700px breakpoint;
   user-requested 2026-09-10 to eliminate that split entirely: "the floating
   set of buttons shouldn't ever appear, so the lower menu is the right way
   to work this" -- so this one bottom-bar treatment is now universal at
   every viewport width, desktop included). Holds one button per keyboard
   shortcut (see CLAUDE.md's Button Rail section) -- shows only whatever's
   currently actionable (a button that would be a no-op is hidden outright
   below, not just dimmed), growing/shrinking as the selection changes,
   rather than a fixed list of every shortcut all the time. Reuses
   updateShortcutButtonStates' own disabled/.group-empty flags -- there's no
   separate CSS-only state to keep in sync, only a visual treatment of the
   exact same facts JS already tracks. */
#button-rail {
  position:fixed; left:0; right:0; bottom:0; top:auto; z-index:25;
  display:flex; flex-direction:row; flex-wrap:wrap; justify-content:center; align-items:center;
  background:#16213e; border-top:1.5px solid #33507d; border-radius:0;
  padding:8px 10px calc(8px + env(safe-area-inset-bottom, 0px)); gap:6px;
  /* A real, opaque background covering its whole footprint -- unlike the
     old floating vertical rail (see #top-right-stack's own pointer-events
     note above, which still applies to the two name badges that remain in
     #top-right-stack's own flow), this bar should behave like any other
     visible panel and not let taps in its own empty space fall through to
     the tabletop underneath. */
  pointer-events:auto;
  /* The bar's own height varies a lot with however many buttons the
     current selection surfaces (confirmed live 2026-09-10: a single
     selected token alone already fills 5 wrapped rows -- Bigger/Smaller,
     Number, Color, and two more) -- capped and internally scrollable
     rather than left to grow indefinitely and eat the screen. */
  max-height:42vh; overflow-y:auto;
}
/* User-collapsible (2026-09-10, requested alongside the 700px removal
   above) -- the bar can surface quite a few rows at once (see its own
   comment), and not every moment wants that much of the screen's bottom
   edge covered. #rail-toggle is deliberately NOT one of #button-rail's own
   children -- a first attempt gave it a full-width row inside the bar
   (like .multi-select-badge/.sync-error-badge below), but that cost a
   whole row just to show a toggle (user-reported 2026-09-10: "we're
   spending a lot of screen real estate on minimize/expand... just a small
   'v'/'^' button in the corner"). It's a sibling of #button-rail instead --
   position:fixed to the same bottom-right corner regardless of the bar's
   own height, so it floats *over* whatever row of buttons happens to be
   there rather than reserving space of its own. Since the whole bar is
   hidden outright while minimized (see .minimized below), there's nothing
   left to overlap at that point -- it just reads as a small standalone
   button in the corner. */
.rail-toggle {
  position:fixed; right:14px; bottom:14px; z-index:30;
  width:36px; height:36px; border-radius:50%; padding:0;
  display:flex; align-items:center; justify-content:center;
  background:#16213e; border:1.5px solid #33507d;
  color:#80d8ff; font-size:1.1rem; font-family:inherit; cursor:pointer;
}
.rail-toggle:hover { border-color:#80d8ff; }
/* The whole bar disappears outright (not visibility:hidden -- the point is
   reclaiming the screen space it'd otherwise reserve). #rail-toggle is
   unaffected since it's a sibling, not a child, of #button-rail. */
#button-rail.minimized { display:none; }
.add-btn {
  background:#16213e; border:1.5px solid #33507d; border-radius:8px;
  color:#80d8ff; padding:6px 14px; cursor:pointer; font-size:.85rem; font-family:inherit;
  transition:border-color .15s, opacity .15s;
}
.add-btn:hover { border-color:#80d8ff; }
/* A disabled shortcut/add button is hidden outright, not dimmed -- see
   #button-rail's own comment above: a greyed-out button someone might
   still tap just adds clutter, and this bar only ever shows what's
   currently actionable. */
#button-rail .add-btn:disabled { display:none; }
/* Amber rather than the toolbar's usual blue -- matches .hidden-item's own
   badge color, so "amber = secret/GM-only" reads as one consistent accent
   across the toggle and the things it controls. */
.gm-mode-btn.active { border-color:#f2a854; color:#f2a854; }

/* Shortcut-rail buttons -- deliberately more compact than .add-btn (smaller
   padding/font): there are ~17 of these now (one per keyboard shortcut, see
   CLAUDE.md), so the rail stays a reasonable height only if each row is
   tighter than the original toolbar's few, larger buttons. The key-hint
   span is what actually shows the shortcut -- the button's own word is
   chosen to start with that same letter wherever the shortcut is a plain
   keypress (e.g. "Hide" / H), so the hint is often redundant with the
   label's own first letter by design; where the label can't start with the
   key (the OS-standard Ctrl+C/V/X/Z/Y combos, and Backspace/+/-), the hint
   is what actually spells out the real shortcut. */
.shortcut-btn {
  background:#16213e; border:1.5px solid #33507d; border-radius:7px;
  color:#80d8ff; padding:8px 14px; cursor:pointer; font-size:.85rem; font-family:inherit;
  transition:border-color .15s, opacity .15s;
  display:flex; align-items:center; gap:7px; white-space:nowrap;
}
.shortcut-btn:hover { border-color:#80d8ff; }
/* Hidden outright rather than dimmed -- see #button-rail's own comment. */
#button-rail .shortcut-btn:disabled { display:none; }
/* No persistent keyboard reference to show this against now that the bar
   is universal (a touch session has no keyboard at all; a desktop session
   might, but the bar no longer distinguishes the two -- see #button-rail's
   own comment) -- the plain action word (already chosen, where possible,
   to start with the key it maps to) is the whole story. */
.key-hint { display:none; }

/* Number (plain 0-9) and Color (Shift+1..4/0) are each ~5-10 keys at once --
   individually placed buttons the same as every other shortcut, just built
   here rather than by hand in index.html (see the loop below). display:contents
   unwraps #number-buttons/#color-buttons out of the box tree entirely, so
   their button children become direct flex items of #button-rail itself and
   wrap exactly like Cut/Copy/Paste/etc. already do -- breaking wherever the
   real available width runs out, not capped at a fixed count per row (a
   narrow window no longer runs numbers off the edge unreachable, and a wide
   one doesn't force all 5 colors onto their own line either). */
.number-row, .color-row { display:contents; }
/* Real touch targets -- there's no separate mouse-only sizing now that one
   bar serves both (see #button-rail's own comment). */
.number-btn {
  background:#0f1730; border:1.5px solid #33507d; border-radius:5px;
  color:#80d8ff; width:34px; height:34px; padding:0; cursor:pointer;
  font-size:.72rem; font-family:inherit; line-height:1;
}
.number-btn:hover { border-color:#80d8ff; }
.number-btn:disabled { color:#4a5568; cursor:default; opacity:.35; border-color:#33507d; }
.color-swatch {
  width:34px; height:34px; padding:0; border-radius:50%; cursor:pointer;
  border:1.5px solid rgba(255,255,255,.25);
}
.color-swatch:hover { border-color:#fff; }
.color-swatch:disabled { cursor:default; opacity:.35; }
/* Clearing a token's color falls back to the default purple fill
   (.token-circle's own background, #7c5cd6) -- the swatch shows that same
   color rather than a generic "reset" glyph, so it reads as "the color
   this button actually produces" like every other swatch here. */
.color-swatch.clear {
  background:#7c5cd6;
}
/* Number/Color have no single button of their own to hang a :disabled off
   of -- .group-empty (toggled alongside their own buttons' disabled state
   in updateShortcutButtonStates) plays the same role for the whole group.
   Set directly on #number-buttons/#color-buttons (not a wrapper) -- display:none
   here overrides the display:contents above for exactly this state, hiding
   every button inside without needing a separate wrapper element to hang it
   off of. */
.number-row.group-empty, .color-row.group-empty { display:none; }

/* Touch long-press multi-select (see app.js's updateMultiSelectIndicator)
   has no other visible trace once only one item is selected -- this badge
   is the only sign the mode is active, and doubles as the way to leave it
   (click = deselectAll(), same as tapping empty canvas). Blue accent, same
   as the selection ring itself, since this *is* a selection-related mode --
   not the amber "secret/GM-only" meaning .hidden-item/.gm-mode-btn.active
   use, and not the red "error" meaning .sync-error-badge uses just below.
   Same "always in the DOM, display:none toggled, own full-width row while
   visible" shape as .sync-error-badge, and for the same reason: appearing/
   disappearing must never shift whatever else has wrapped onto the same
   row of the bar. */
.multi-select-badge {
  display:none;
  background:rgba(128,216,255,.15); border:1.5px solid #80d8ff; border-radius:8px;
  color:#80d8ff; padding:6px 14px; font-size:.85rem; text-align:center;
  cursor:pointer; font-family:inherit;
}
.multi-select-badge:hover { background:rgba(128,216,255,.25); }
.multi-select-badge.visible { display:block; flex-basis:100%; }

/* Always in the DOM (display:none toggled, rather than removed) so
   appearing/disappearing never shifts whatever else has wrapped onto the
   same row of the bar. Red danger accent (see .picker-delete-btn) is
   deliberate here: this really is an error state. flex-basis:100% while
   visible gives it its own full-width row rather than wrapping awkwardly
   alongside ordinary buttons. */
.sync-error-badge {
  display:none;
  background:rgba(233,69,96,.15); border:1.5px solid #e94560; border-radius:8px;
  color:#e94560; padding:6px 14px; font-size:.85rem; text-align:center;
}
.sync-error-badge.visible { display:block; flex-basis:100%; }

/* ── Tabletop ── */
#tabletop {
  position:absolute; inset:0; overflow:hidden; touch-action:none;
  background-color:#14142a;
  background-image:radial-gradient(#4a6088 1.5px, transparent 1.5px);
  background-size:48px 48px;
  cursor:grab;
}
#tabletop.panning { cursor:grabbing; }
#tabletop.inserting { cursor:crosshair; }

#world { position:absolute; left:0; top:0; width:0; height:0; transform-origin:0 0; }

/* Live rubber-band preview while drawing a new card's rectangle (insert
   mode) — lives in #selection-layer just like the ring, purely for a
   top-stacked place to render, not because it's selection-related. */
.insert-preview {
  position:absolute; border:2px dashed #80d8ff; background:rgba(128,216,255,.12);
  border-radius:6px; pointer-events:none;
}

/* Marquee (rubber-band) multi-select rectangle -- ctrl/shift-drag from
   empty canvas, see CLAUDE.md's Multi-select section. Animated "marching
   ants" (four independently-scrolling linear-gradient edges, the standard
   pure-CSS technique for this -- a real dashed border can't animate its
   own dash offset) rather than a static dashed border, so it reads
   clearly as "a selection in progress," distinct from insert-mode's
   .insert-preview box above. No fill (unlike .insert-preview) so the two
   don't read as the same kind of rectangle at a glance. */
.marquee-preview {
  position:absolute; pointer-events:none;
  background-image:
    linear-gradient(90deg, #80d8ff 50%, transparent 50%),
    linear-gradient(90deg, #80d8ff 50%, transparent 50%),
    linear-gradient(0deg, #80d8ff 50%, transparent 50%),
    linear-gradient(0deg, #80d8ff 50%, transparent 50%);
  background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
  background-size: 8px 2px, 8px 2px, 2px 8px, 2px 8px;
  background-position: 0 0, 0 100%, 0 0, 100% 0;
  animation: marching-ants .5s linear infinite;
}
@keyframes marching-ants {
  to { background-position: 8px 0, -8px 100%, 0 -8px, 100% 8px; }
}

#connections-svg { position:absolute; left:0; top:0; width:1px; height:1px; overflow:visible; pointer-events:none; }
.connection-line { stroke:#3a4a6b; stroke-width:2; }

/* ── Card ── */
.card {
  /* width/height/margin set inline per card (resizable); padding too (a
     flat CARD_PADDING, see app.js) — kept out of this rule so it stays a
     single source of truth alongside the other size-dependent inline styles. */
  position:absolute;
  background:#16213e; border:1px solid #33507d; border-radius:10px;
  display:flex; align-items:center; justify-content:center; text-align:center;
  color:#eee; font-size:1rem; line-height:1.3;
  user-select:none; cursor:grab; word-break:break-word;
  /* Belt-and-suspenders alongside #tabletop's own touch-action:none --
     explicit here too so a touch-and-hold (see the long-press multi-select
     note in app.js) can't get reinterpreted as a pan/zoom gesture by the
     browser partway through, on any browser that scopes touch-action less
     generously to an ancestor than the spec calls for. */
  touch-action:none;
  /* .card-body-text's z-index:1 (see below) is meant to stay scoped to
     "above this card's own icon watermark" -- without a stacking context
     of its own, .card is z-index:auto and doesn't provide one, so that
     z-index leaks all the way up to the document root and outranks
     *everything* else at the default level, including #selection-layer's
     resize handles. For a nested card, the parent's card-body-text sits
     underneath the child at the same screen point and, with that leaked
     z-index, was winning hit-testing over the child's own resize handle --
     silently un-resizable, only while nested (a non-nested card has no
     other card's text overlapping it, so the bug never showed). isolation
     boxes the z-index back in without otherwise affecting layout. */
  isolation:isolate;
}
.card.selected { border-color:#80d8ff; }
.card.dragging { cursor:grabbing; }

/* Hidden cards/tokens (GM Mode / Hidden Cards & Tokens, see CLAUDE.md) --
   only ever have DOM at all on a device with GM mode on (render() excludes
   them entirely everywhere else), so this only needs to make "this one's
   secret" obvious on the one screen that can see it: dimmed, plus a small
   eye-slash badge so it doesn't read as some other, unrelated dimmed state.
   Amber rather than the selection ring's blue or the delete accent's red --
   its own distinct meaning, not a variant of either. */
.hidden-item { opacity:.5; }
.hidden-item::after {
  content:''; position:absolute; top:-6px; right:-6px; z-index:3;
  width:18px; height:18px; border-radius:50%; background:#f2a854;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3 12c2.5-4.5 6-7 9-7s6.5 2.5 9 7c-2.5 4.5-6 7-9 7s-6.5-2.5-9-7z'/%3E%3Cline x1='4' y1='20' x2='20' y2='4'/%3E%3C/svg%3E");
  background-size:65%; background-position:center; background-repeat:no-repeat;
  pointer-events:none;
}

/* Auto-matched icon (see CLAUDE.md "Auto-matched card/token icons"), a faint
   watermark filling the card or token behind its text. Shared between both
   (.card > .icon-watermark and .token > .icon-watermark below). z-index
   makes the paint order explicit rather than relying on default stacking
   rules, which would otherwise put this position:absolute layer ABOVE the
   in-flow text layer, not behind it. pointer-events:none so it never
   intercepts drag/click. */
.icon-watermark {
  position:absolute; inset:0; z-index:0;
  display:flex; align-items:center; justify-content:center;
  pointer-events:none; overflow:hidden;
}
.icon-watermark-svg { width:70%; height:70%; fill:#eee; opacity:.16; }
/* "f" (flipSelectedIcon in app.js) toggles this class per-item -- a plain
   horizontal mirror around the icon's own center, independent of size/
   position, so it composes cleanly with every other transform-free rule
   here rather than needing its own width/height recalculation. */
.icon-watermark-svg.flipped { transform:scaleX(-1); }
/* A user-imported image (see CLAUDE.md's "Custom (imported) icons"),
   rendered in the same slot .icon-watermark-svg occupies but noticeably
   more opaque -- unlike the curated line-art set, which is deliberately a
   faint background watermark behind the text, a photo or picked-image icon
   is the whole point of importing it, so it needs to actually read as an
   image, not fade into the card. object-fit:contain keeps an
   arbitrary-aspect-ratio source from stretching/distorting the way a fixed
   width/height alone would. */
.icon-watermark-img { width:70%; height:70%; object-fit:contain; opacity:.55; }
/* Tokens are much smaller (43px) than cards, so the same opacity reads as
   nearly invisible -- boosted well past the card's here, scoped to just the
   token case, to actually read at that size. Also, on .icon-watermark
   below, shifted up (not centered) and top-anchored via padding rather than
   sized to fill the whole circle -- still clearly visible at this size, and
   it frees room below it, inside the same circle, for the label (see
   .token-content) instead of it having to sit outside the circle entirely.
   3px top padding (not flush at 0) so it doesn't read as crowded against
   the circle's own top edge -- unaffected by the width/height below, since
   align-items:flex-start anchors the *top*, so growing the icon only
   extends its bottom edge, not its top. 70.5% -- halfway back down from a
   78% pass that read as a hair too large, from an initial 60% then 63%:
   most curated icons' own artwork doesn't fill their full 512x512
   viewBox edge-to-edge (some baked-in internal margin), so the *visual* gap
   above the label was bigger than the icon's own bounding-box math alone
   would suggest -- sized empirically against the rendered result, not
   computed, to use that up without the icon's bounding box actually
   reaching into .token-content's own top offset (see below). */
.token .icon-watermark-svg { opacity:.7; width:70.5%; height:70.5%; }
.token .icon-watermark-img { opacity:.85; width:70.5%; height:70.5%; }
/* align-items:flex-start here, padding-top set inline per token
   (applyTokenGeometry in app.js, proportional to the token's own variable
   size -- see CLAUDE.md's Tokens section) -- 3px was the tuned value at the
   default size (TOKEN_SIZE), scaled from there. */
.token .icon-watermark { align-items:flex-start; }

.card-body-text {
  /* align-items:flex-start (not center) so plain text sits at the same top
     position a <textarea>'s content always starts at — a textarea has no
     way to vertically center its own text, so centering here would make
     the text visibly jump every time editing starts/stops. white-space:
     pre-wrap for the same reason: a <textarea> always preserves newlines
     (Shift+Enter), but plain textContent collapses them by default unless
     told not to — without this a newline would silently vanish on commit
     even though it's still in the data (and reappear next time you edit). */
  width:100%; height:100%; display:flex; align-items:flex-start; justify-content:center;
  overflow:hidden; cursor:text; white-space:pre-wrap;
  position:relative; z-index:1; /* paint above .icon-watermark */
}
/* Text mode: a narrower highlight around just the text area, distinct from
   the whole-card ring (.selection-ring) that card mode shows instead. */
.card-body-text.text-mode { outline:2px solid #80d8ff; outline-offset:-4px; border-radius:6px; }

/* Selection ring lives in its own layer above #cards-layer (see
   #selection-layer) rather than on the card itself, so an unselected card
   stacked on top can never paint over and hide it. */
#selection-layer { position:absolute; left:0; top:0; pointer-events:none; }
.selection-ring {
  position:absolute; border:2px solid #80d8ff; border-radius:14px;
  box-shadow:0 0 10px rgba(128,216,255,.4); pointer-events:none;
}

/* Resize handles live in #selection-layer (see updateSelectionOverlay in
   app.js), not on the card itself — only the selected card has them, and
   living in that top-stacked layer means an unselected card rendered on top
   of the selected one can never sit in front of a handle and steal its
   clicks. No visible marker; the cursor change is the only affordance. */
.resize-handle { position:absolute; pointer-events:auto; }
.resize-handle.corner-tl, .resize-handle.corner-br { cursor:nwse-resize; }
.resize-handle.corner-tr, .resize-handle.corner-bl { cursor:nesw-resize; }
.resize-handle.edge-top, .resize-handle.edge-bottom { cursor:ns-resize; }
.resize-handle.edge-left, .resize-handle.edge-right { cursor:ew-resize; }

/* ── Token ── */
.token {
  /* width/height/margin set inline per token (variable size, see
     CLAUDE.md's Tokens section and app.js's applyTokenGeometry) -- kept out
     of this rule so it stays a single source of truth, same reasoning as
     .card's own inline sizing. */
  position:absolute;
  border-radius:50%; cursor:grab; user-select:none; z-index:2;
  touch-action:none; /* see .card's own comment on this */
  /* No background/overflow here anymore -- .token-circle (below) owns the
     round clip, scoped to just the background + icon, so .token-content can
     sit outside it (see below) unclipped. border-radius stays, purely so
     .selected's box-shadow ring (below) still traces a circle. */
}
/* Background + icon watermark only -- the part that should stay a clean
   circle. Purple rather than the app's red danger/delete accent (see
   .picker-delete-btn) -- reusing that color read as an error state, not a
   token. Per-token color choice is a planned future feature, see TODO.md. */
.token-circle {
  position:absolute; inset:0; border-radius:50%; overflow:hidden;
  background:#7c5cd6;
}
.token-content {
  /* top set inline per token by applyTokenGeometry (app.js), proportional
     to the token's own (variable) size -- see CLAUDE.md's Tokens section.
     Still, conceptually: back inside the circle (a sibling of
     .token-circle, which owns the only clip left -- see above, and above
     that for the icon's own upward shift that makes room here) rather than
     below it, starting just under where the icon actually ends -- not
     `top:100%`/auto-height like the "below the circle" attempt before this
     one. A short single-word label (the common case, e.g. "dog") fits
     entirely within the circle this way, reading as sitting *inside* the
     token just below the icon, the way a plain shrink-to-fit label used to
     -- but without needing to shrink the font or the icon into illegibility
     to get there.
     left:50% + width:max-content + translateX(-50%), not a fixed px width
     with text-align:center -- text-align turned out not to reliably center
     *overflowing* `white-space:pre` content (confirmed: the box itself
     measured dead-center on the circle, but the actual rendered glyphs
     sat well to the right of it once the text was wider than that fixed
     box, e.g. any token at size 1.3). max-content sizes this element to
     exactly however wide its own text is -- there's nothing left to
     "overflow" out of, so translateX(-50%) centers it exactly regardless
     of length, the same guarantee a fixed box only gave for text that
     happened to fit inside it. Overridden for the live-editing case, see
     .text-mode below -- a <textarea> can't organically shrink-to-fit its
     own value the way a plain text node can. */
  position:absolute; left:50%; width:max-content; transform:translateX(-50%);
  color:#eee;
  text-align:center; line-height:1.05;
  box-sizing:border-box;
  /* white-space:pre (not pre-wrap) -- still honors an explicit typed
     newline (Shift+Enter), but no longer auto-wraps at spaces or mid-word:
     wrapping was almost always what made the icon watermark behind the
     text hard to see. */
  white-space:pre;
  z-index:1;
}
/* Live editing: a <textarea> needs a real, explicit width (it can't sizes
   itself to its own value the way a plain text node can), so this reverts
   to a fixed left/width set inline by applyTokenGeometry instead of the
   shrink-to-fit centering above -- the full grid-cell footprint the token
   occupies (see CLAUDE.md), same as it always was. A label too wide even
   for that still simply extends past it sideways rather than wrapping or
   being clipped, only while actively being edited -- the instant it
   commits, the plain-label rule above takes back over and centers it
   exactly regardless of length. */
.token-content.text-mode { left:var(--token-edit-left, 0); width:var(--token-edit-width, auto); transform:none; }
/* The number badge (0-9, "0" == blank -- see setSelectedTokenNumber in
   app.js) is its own element, a sibling of .token-content and
   .token-circle (not nested in either) -- centered at the very top of the
   circle, independent of the name below it, so neither one's length or
   position affects the other. top set inline per token
   (applyTokenGeometry, TOKEN_NUMBER_TOP_RATIO -- same anchor point as the
   icon's own top clearance) proportional to the token's own size, same as
   everything else here; font-size likewise set inline (fitTokenText) to
   match the name's own computed size exactly, since it has no shared
   parent to inherit that from anymore. z-index:2 -- above both
   .icon-watermark (0) and .token-content (1), so it stays legible sitting
   over the icon artwork rather than being painted under it. The small
   dark backdrop (vs. no background at all, the name's own treatment) is
   what actually makes it read clearly regardless of what color/icon is
   directly behind it at that exact spot -- unlike the name, which has a
   plain unobstructed part of the circle to itself, the badge deliberately
   overlaps the icon watermark. :empty (the "0"/unset case) collapses it
   to nothing rather than showing a bare dot of backdrop with no digit in it. */
.token-number {
  position:absolute; left:50%; transform:translateX(-50%); z-index:2;
  min-width:1.5em; height:1.5em; line-height:1.5em; text-align:center;
  padding:0 .15em;
  border-radius:50%; background:rgba(10,10,20,.72); color:#fff;
  font-weight:600; white-space:pre; pointer-events:none;
}
.token-number:empty { display:none; }
/* Dead center of the token (top:50%/left:50%/translate(-50%,-50%), not
   anchored to the top like .token-number or below the icon like
   .token-content) -- an important, at-a-glance status effect deserves the
   most visually prominent spot on the token, not a corner badge. z-index:3,
   above .icon-watermark(0)/.token-content(1)/.token-number(2), so it reads
   clearly regardless of what's drawn underneath it. Plain black italic
   text with no backdrop, per spec -- unlike .token-number, this doesn't
   need one to stay legible: it's meant to be the single most visible thing
   on the token when active, not a small detail sharing space with the
   icon. pointer-events:none same as .token-number, so it never steals a
   drag/click meant for the token underneath. */
.token-dazed {
  position:absolute; top:50%; left:50%; transform:translate(-50%, -50%); z-index:3;
  color:#000; font-style:italic; white-space:nowrap; pointer-events:none;
}
.token-dazed:empty { display:none; }
/* This element has no fixed height (unlike the old overlapping-the-circle
   layout, which filled the token's own fixed-size box) -- it's however
   tall its content naturally is, so a plain label and a live <textarea>
   both just grow top-down from the same starting point with nothing to
   keep in sync, unlike the harder centering/bottom-alignment case (see
   CLAUDE.md's Tokens section) where a textarea's height would need to be
   pixel-matched to a plain node's to avoid a jump. height:auto overrides
   the shared .text-editor rule's height:100% (meaningless here anyway,
   with no definite parent height to be 100% of); JS (fitTokenText) sets an
   explicit pixel height matching scrollHeight so it actually grows past
   its default single-row size as typed content wraps or gains lines. */
.token-content .text-editor { height:auto; overflow:hidden; }
/* Same blue as a card's own selection border and a token's own text-mode
   ring (line below) -- "blue = selected/focused" reads consistently
   everywhere else in this app; the previous translucent red here was an
   inconsistency (red is this app's danger/delete accent -- see
   .picker-delete-btn), not just a visibility problem. Solid, thicker, and
   with a soft glow plus a modest scale-up on top of the ring -- a lone
   colored outline is easy to lose among many small, already-colorful
   tokens the way it wouldn't be on a card's own much larger border, so
   this also has to work as a size/shape cue at a glance, not just a color
   one. Doesn't move the token's own logical x/y -- transform is purely
   visual, applied after layout -- so a selected token slightly overlaps
   its neighbors rather than shoving them aside. */
.token.selected { box-shadow:0 0 0 4px #80d8ff, 0 0 10px 3px rgba(128,216,255,.7); transform:scale(1.12); z-index:4; }
.token.dragging { cursor:grabbing; z-index:3; }
/* Text mode for a token — same blue accent as a card's text mode, so "blue
   ring = editing text" reads consistently across both. */
.token-content.text-mode { box-shadow:0 0 0 2px #80d8ff; }

/* ── Icon picker (manual override, "i" shortcut on a selected card/token) ── */
#icon-picker-overlay {
  position:fixed; inset:0; background:rgba(10,10,20,.6);
  display:flex; align-items:center; justify-content:center; z-index:100;
}
.icon-picker-panel {
  background:#16213e; border:1px solid #33507d; border-radius:12px;
  width:min(480px, 92vw); height:min(560px, 85vh);
  display:flex; flex-direction:column; padding:16px; gap:10px;
}
/* Wrapper exists purely to inset the magnifying-glass glyph inside the
   input's own left edge without costing any extra layout width -- the
   glyph is absolutely positioned within it, and the input's own left
   padding (below) just makes room for it. */
.icon-picker-search-wrap { position:relative; }
.icon-picker-search-icon {
  position:absolute; left:10px; top:50%; transform:translateY(-50%);
  font-size:.85rem; opacity:.55; pointer-events:none; line-height:1;
}
.icon-picker-search {
  width:100%; background:#0f1730; border:1px solid #33507d; border-radius:8px;
  color:#eee; padding:8px 12px 8px 30px; font:inherit; font-size:.9rem;
}
.icon-picker-search:focus { outline:none; border-color:#80d8ff; }
/* Swapping search/actions/grid for the import sub-panel (openIconPicker's
   showImportPanel/hideImportPanel) toggles the `hidden` attribute on each
   -- but .icon-picker-actions/.icon-picker-grid/.icon-import-panel below
   all set their own explicit `display` from this same author stylesheet,
   which outranks the browser's default `[hidden]{display:none}` UA rule
   at equal specificity (author styles always win over UA styles). Without
   this, all four ended up visible and overlapping at once. */
.icon-picker-search-wrap[hidden], .icon-picker-actions[hidden], .icon-picker-grid[hidden], .icon-import-panel[hidden], .icon-import-preview[hidden] { display:none; }
.icon-picker-actions { display:flex; gap:8px; }
.icon-picker-actions button {
  flex:1; background:none; border:1.5px solid #33507d; border-radius:8px;
  color:#80d8ff; padding:6px; cursor:pointer; font-size:.85rem; font-family:inherit;
}
.icon-picker-actions button:hover { border-color:#80d8ff; }
.icon-picker-grid {
  flex:1; overflow-y:auto; display:grid;
  grid-template-columns:repeat(auto-fill, minmax(52px, 1fr)); gap:6px;
}
.icon-picker-item {
  aspect-ratio:1; background:#0f1730; border:1px solid #33507d; border-radius:8px;
  display:flex; align-items:center; justify-content:center; cursor:pointer; padding:8px;
}
.icon-picker-item:hover { border-color:#80d8ff; }
.icon-picker-item svg { width:100%; height:100%; fill:#eee; }

/* ── Import sub-panel (the icon picker's third button, "Import" -- see
   openIconPicker/buildImportPanel in app.js) -- occupies the same flex
   slot search+actions+grid do (swapped via [hidden], not a second overlay)
   so the picker's own fixed-size box never resizes/reflows when this
   opens. flex:1 + overflow-y:auto for the same reason .icon-picker-grid
   has it -- three input methods plus a preview can run taller than the
   panel on a short viewport. */
.icon-import-panel {
  flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:10px;
}
.icon-import-back-btn {
  align-self:flex-start; background:none; border:none; color:#80d8ff;
  cursor:pointer; font-size:.85rem; font-family:inherit; padding:2px 0;
}
.icon-import-file-btn {
  display:block; text-align:center; background:none; border:1.5px solid #33507d;
  border-radius:8px; color:#80d8ff; padding:8px; cursor:pointer; font-size:.85rem;
}
.icon-import-file-btn:hover { border-color:#80d8ff; }
.icon-import-paste-zone {
  border:1.5px dashed #33507d; border-radius:8px; padding:14px 8px;
  text-align:center; color:#9fb0d0; font-size:.82rem; cursor:text;
}
.icon-import-paste-zone:focus { outline:none; border-color:#80d8ff; color:#eee; }
.icon-import-url-row { display:flex; gap:8px; }
.icon-import-url-row input {
  flex:1; min-width:0; background:#0f1730; border:1px solid #33507d; border-radius:8px;
  color:#eee; padding:8px 10px; font:inherit; font-size:.85rem;
}
.icon-import-url-row input:focus { outline:none; border-color:#80d8ff; }
.icon-import-url-row button, .icon-import-remove-btn {
  background:none; border:1.5px solid #33507d; border-radius:8px;
  color:#80d8ff; padding:6px 12px; cursor:pointer; font-size:.85rem; font-family:inherit;
}
.icon-import-url-row button:hover, .icon-import-remove-btn:hover { border-color:#80d8ff; }
.icon-import-remove-btn { align-self:flex-start; color:#ff8a80; }
.icon-import-status { min-height:1.1em; color:#9fb0d0; font-size:.82rem; }
.icon-import-preview {
  display:flex; flex-direction:column; align-items:center; gap:10px;
  background:#0f1730; border:1px solid #33507d; border-radius:8px; padding:12px;
}
.icon-import-preview img { width:96px; height:96px; object-fit:contain; }
.icon-import-apply-row { display:flex; flex-direction:column; gap:6px; width:100%; }
.icon-import-apply-row button {
  background:none; border:1.5px solid #33507d; border-radius:8px;
  color:#80d8ff; padding:8px; cursor:pointer; font-size:.85rem; font-family:inherit;
}
.icon-import-apply-row button:hover:not(:disabled) { border-color:#80d8ff; }
.icon-import-apply-row button:disabled { color:#5a6a8a; cursor:default; }

/* ── Picker overlay (Room Selector, Scene Selector, "Room not found") --
   see CLAUDE.md's Room/Scene Selector section, and openPickerOverlay/
   buildPickerRow/buildConfirmButton in app.js, for why these three share
   one set of classes instead of three near-duplicate style blocks: a room
   row and a scene row (and a plain "here's what happened, pick a room"
   notice) are the same shape of small centered modal, differing only in
   their own content, not their look. Same overlay/panel pattern as the
   icon picker/about panel above. All three share z-index:100 (About/
   icon-picker's own level) since at most one is ever open at once (the
   Room Selector force-closes itself before "Room not found" opens, and
   vice versa -- see closeRoomSelector/closeRoomNotFound in app.js). */
.picker-overlay {
  position:fixed; inset:0; background:rgba(10,10,20,.6);
  display:flex; align-items:center; justify-content:center; z-index:100;
}
.picker-panel {
  background:#16213e; border:1px solid #33507d; border-radius:12px;
  width:min(420px, 92vw); max-height:85vh; overflow-y:auto;
  padding:20px 22px; position:relative;
  display:flex; flex-direction:column; gap:12px;
}
.picker-panel h2 { color:#80d8ff; font-size:1.15rem; }
.picker-hint { font-size:.85rem; line-height:1.5; color:#c5cbd8; }
.picker-create-row { display:flex; gap:8px; }
.picker-name-input {
  flex:1; min-width:0; background:#0f1730; border:1px solid #33507d; border-radius:8px;
  color:#eee; padding:8px 10px; font:inherit; font-size:.85rem;
}
.picker-name-input:focus { outline:none; border-color:#80d8ff; }
.picker-create-row button, .picker-primary-btn {
  background:none; border:1.5px solid #33507d; border-radius:8px;
  color:#80d8ff; padding:8px 14px; cursor:pointer; font-size:.85rem; font-family:inherit;
}
.picker-create-row button:hover, .picker-primary-btn:hover { border-color:#80d8ff; }
.room-name-regen-btn { padding:8px 10px; flex:none; }
.picker-primary-btn { align-self:flex-start; }
.picker-status { min-height:1.1em; color:#9fb0d0; font-size:.82rem; }
.picker-list { display:flex; flex-direction:column; gap:6px; overflow-y:auto; }
.picker-list-empty { color:#4a5568; font-size:.85rem; padding:8px 0; }
.picker-row {
  display:flex; align-items:center; justify-content:space-between; gap:8px;
  background:#0f1730; border:1px solid #33507d; border-radius:8px; padding:8px 10px;
}
.picker-row.active { border-color:#80d8ff; }
.picker-row-name {
  flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
  font-size:.9rem; color:#eee; cursor:pointer;
}
/* Still clickable, unlike a plain "current selection" state elsewhere --
   this is the one row whose name can be renamed in place
   (startRenamingPickerRow in app.js). */
.picker-row.active .picker-row-name { color:#80d8ff; cursor:text; }
/* The small "×" trailing action (Disconnect/Delete) -- deliberately the
   compact icon-button look the scene picker's own delete button always
   had, not the old room picker's wide "Disconnect" pill (user-requested
   2026-09-10: adopt the scene list's small × into the room list, not the
   other way around). Shared by both via buildConfirmButton. */
.picker-delete-btn {
  flex-shrink:0; background:none; border:none; color:#4a5568; cursor:pointer;
  font-size:.9rem; line-height:1; padding:2px 6px; border-radius:4px; font-family:inherit;
}
.picker-delete-btn:hover:not(:disabled) { color:#e94560; background:rgba(233,69,96,.15); }
/* First click arms it (see buildConfirmButton's own confirm-timeout); a
   second click within the window actually confirms. */
.picker-delete-btn.confirming { color:#e94560; font-size:.62rem; font-weight:600; }
.picker-delete-btn:disabled { color:#3a4a6b; cursor:default; }

/* ── About (small "ⓘ" button under the title) -- same overlay/panel
   pattern as the icon picker above, reused rather than inventing a second
   modal look. */
#about-overlay {
  position:fixed; inset:0; background:rgba(10,10,20,.6);
  display:flex; align-items:center; justify-content:center; z-index:100;
}
.about-panel {
  background:#16213e; border:1px solid #33507d; border-radius:12px;
  width:min(420px, 92vw); max-height:85vh; overflow-y:auto;
  padding:20px 22px; position:relative;
}
.about-panel h2 { color:#80d8ff; font-size:1.15rem; margin-bottom:2px; }
.about-panel .about-version { color:#4a5568; font-size:.75rem; margin-bottom:16px; }
.about-panel h3 {
  color:#eee; font-size:.8rem; font-weight:600; text-transform:uppercase;
  letter-spacing:.04em; margin:16px 0 6px;
}
.about-panel p, .about-panel li { font-size:.85rem; line-height:1.5; color:#c5cbd8; }
.about-panel a { color:#80d8ff; }
.about-panel ul { list-style:none; }
.about-close-btn {
  position:absolute; top:14px; right:14px; background:none; border:none;
  color:#4a5568; font-size:1.1rem; line-height:1; cursor:pointer; padding:2px 4px;
}
.about-close-btn:hover { color:#80d8ff; }

/* ── Live text editor (a card/token selected for editing) ── */
.text-editor {
  width:100%; height:100%; border:none; outline:none; resize:none;
  background:transparent; color:inherit; font:inherit; text-align:inherit;
  padding:0; margin:0; overflow:auto; cursor:text;
}
