diff --git a/frontend/src/index.css b/frontend/src/index.css
index 1324cc5..f83fac1 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1,2865 +1,28 @@
-:root {
- --bg: #0a0a0f;
- --bg-panel: #131320;
- --bg-panel-glass: rgba(19, 19, 32, 0.82);
- --bg-input: #1a1a2a;
- --border: #2b2b3d;
- --border-bright: #3d3d55;
- --text: #e2ddd0;
- --text-dim: #918c7d;
- --accent: #d4a94e;
- --accent-bright: #e8c476;
- --accent-dim: #96773a;
- --accent-glow: rgba(212, 169, 78, 0.25);
- --danger: #d06565;
- --player: #9fc7d1;
- /* Chart hues (analytics dashboard). Deeper and more saturated than --accent
- and --player, which are tuned for text and borders and turn muddy once
- they are a 10px bar: these are checked against --bg-panel for lightness,
- chroma, contrast and colour-blind separation as a set. */
- --chart-1: #b58a30;
- --chart-2: #3d8ac4;
- --chart-3: #a8608f;
- --font-display: 'Cinzel', Georgia, serif;
- --font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
- --font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
- font-family: var(--font-ui);
- color-scheme: dark;
-}
-
-* { box-sizing: border-box; }
-
-body {
- margin: 0;
- background:
- radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%),
- radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%),
- var(--bg);
- background-attachment: fixed;
- color: var(--text);
- min-height: 100vh;
-}
-
-#root { min-height: 100vh; display: flex; flex-direction: column; }
-
-h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; }
-a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
-a:hover { color: var(--accent-bright); }
-
-::selection { background: var(--accent-dim); color: #0f0d08; }
-
-/* ---------- Scrollbars ---------- */
-
-::-webkit-scrollbar { width: 10px; height: 10px; }
-::-webkit-scrollbar-track { background: transparent; }
-::-webkit-scrollbar-thumb {
- background: var(--border-bright);
- border-radius: 5px;
- border: 2px solid var(--bg);
-}
-::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
-
-/* ---------- Top navigation ---------- */
-
-.topnav {
- display: flex;
- align-items: center;
- gap: 26px;
- padding: 0 28px;
- height: 56px;
- background: var(--bg-panel-glass);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border-bottom: 1px solid var(--border);
- box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
- position: sticky;
- top: 0;
- z-index: 10;
-}
-.topnav .brand {
- font-family: var(--font-display);
- font-size: 1.2rem;
- font-weight: 700;
- color: var(--accent);
- letter-spacing: 0.14em;
- text-shadow: 0 0 18px var(--accent-glow);
- margin-right: 6px;
-}
-.topnav a.navlink {
- color: var(--text-dim);
- padding: 17px 2px 15px;
- font-size: 0.9rem;
- font-weight: 500;
- letter-spacing: 0.04em;
- border-bottom: 2px solid transparent;
- transition: color 0.15s, border-color 0.2s;
-}
-.topnav a.navlink.active {
- color: var(--accent-bright);
- border-bottom-color: var(--accent);
- text-shadow: 0 0 14px var(--accent-glow);
-}
-.topnav a.navlink:hover { color: var(--text); }
-
-/* On desktop the wrapper is transparent — its children (nav links + account)
- participate directly in the .topnav flex row, exactly as before. The mobile
- query below turns it into a dropdown. */
-.nav-links { display: contents; }
-.nav-hamburger {
- display: none;
- background: none;
- border: none;
- color: var(--text);
- font-size: 1.2rem;
- padding: 6px 8px;
- margin-left: auto;
- line-height: 1;
-}
-
-/* Phase 8: account area (hosted multi-user mode only) */
-.nav-account {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: 10px;
- min-width: 0;
-}
-.nav-account button { padding: 5px 12px; font-size: 0.82rem; }
-.guest-nudge {
- color: var(--text-dim);
- font-size: 0.78rem;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-.account-email {
- color: var(--text-dim);
- font-size: 0.82rem;
- max-width: 220px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-button.linklike {
- background: none;
- border: none;
- padding: 0;
- color: var(--accent);
- font-size: 0.82rem;
- cursor: pointer;
-}
-button.linklike:hover { text-decoration: underline; }
-@media (max-width: 900px) {
- .guest-nudge { display: none; }
-}
-
-/* Phase 8: demo-key banner (Settings) */
-.demo-banner {
- border: 1px solid var(--border-bright);
- border-left: 3px solid var(--accent);
- border-radius: 8px;
- padding: 12px 16px;
- margin-bottom: 18px;
- color: var(--text-dim);
- font-size: 0.88rem;
- line-height: 1.5;
-}
-.demo-banner strong { color: var(--accent-bright); }
-
-.page {
- flex: 1;
- width: 100%;
- max-width: 960px;
- margin: 0 auto;
- padding: 28px 24px;
- animation: page-in 0.35s ease both;
-}
-@keyframes page-in {
- from { opacity: 0; transform: translateY(8px); }
- to { opacity: 1; transform: none; }
-}
-
-/* ---------- Forms ---------- */
-
-label.field { display: block; margin-bottom: 14px; }
-label.field .label {
- display: block;
- font-size: 0.72rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.12em;
- color: var(--text-dim);
- margin-bottom: 5px;
-}
-input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select {
- width: 100%;
- background: var(--bg-input);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 9px 12px;
- font-size: 0.95rem;
- font-family: inherit;
- transition: border-color 0.15s, box-shadow 0.15s;
-}
-textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
-input:focus, textarea:focus, select:focus {
- outline: none;
- border-color: var(--accent-dim);
- box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12);
-}
-input::placeholder, textarea::placeholder { color: #5c584d; }
-
-button {
- background: var(--bg-input);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 7px 16px;
- font-size: 0.88rem;
- font-weight: 500;
- cursor: pointer;
- font-family: inherit;
- transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s;
-}
-button:hover {
- border-color: var(--accent-dim);
- box-shadow: 0 0 12px rgba(212, 169, 78, 0.1);
-}
-button:active { transform: translateY(1px); }
-button.primary {
- background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e);
- border-color: var(--accent);
- color: #191307;
- font-weight: 600;
- letter-spacing: 0.02em;
- box-shadow: 0 2px 10px var(--accent-glow);
-}
-button.primary:hover {
- box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45);
- filter: brightness(1.08);
-}
-button.danger { color: var(--danger); }
-button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); }
-button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
-
-/* ---------- Cards & lists ---------- */
-
-.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
-.card {
- position: relative;
- background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 18px;
- cursor: pointer;
- transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
- overflow: hidden;
-}
-.card::before {
- content: '';
- position: absolute;
- inset: 0 0 auto 0;
- height: 2px;
- background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
- opacity: 0;
- transition: opacity 0.25s;
-}
-.card:hover {
- border-color: var(--border-bright);
- transform: translateY(-3px);
- box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06);
-}
-.card:hover::before { opacity: 1; }
-.card h3 {
- margin: 0 0 6px;
- font-family: var(--font-display);
- font-size: 1.02rem;
- color: var(--accent);
-}
-.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; }
-.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); }
-
-.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
-.page-header h1 {
- margin: 0;
- font-family: var(--font-display);
- font-size: 1.55rem;
- color: var(--accent-bright);
- text-shadow: 0 0 24px var(--accent-glow);
-}
-
-.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; }
-
-/* ---------- Story card editor ---------- */
-
-.storycard {
- background: var(--bg-input);
- border: 1px solid var(--border);
- border-left: 3px solid var(--accent-dim);
- border-radius: 8px;
- padding: 12px;
- margin-bottom: 10px;
- transition: border-color 0.15s;
-}
-.storycard:focus-within { border-color: var(--accent-dim); }
-.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; }
-.storycard .row > * { flex: 1; }
-
-/* ---------- Play page ---------- */
-
-.play-page {
- display: flex;
- flex-direction: column;
- min-height: calc(100dvh - 56px);
- /* Room to scroll the last beat clear of a composer lifted by the keyboard
- (see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink
- the layout viewport themselves. Margin, not padding — .page already sets
- padding and a longhand here would drop its bottom gutter. */
- margin-bottom: var(--kb-inset, 0px);
-}
-
-/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story
- scrolls — pin the header just below the 56px top nav. */
-.play-page .page-header {
- position: sticky;
- top: 56px;
- z-index: 5;
- background: var(--bg);
- margin-bottom: 12px;
- padding: 12px 0;
- border-bottom: 1px solid var(--border);
-}
-
-.story {
- flex: 1;
- font-family: var(--font-story);
- font-size: 1.14rem;
- line-height: 1.7;
- white-space: pre-wrap;
- padding-bottom: 16px;
-}
-/* The head of a windowed transcript: how much story is still above, and the
- way back to it. Deliberately quiet — it is a seam in the page, not a
- feature — and it sits inside .story so it inherits the story face. */
-.story-earlier {
- text-align: center;
- margin: 4px 0 22px;
- font-size: 0.9rem;
- /* No action-in animation here: this appears above content the reader is
- already looking at, and a fade would read as the story moving. */
-}
-.story-earlier .dim { color: var(--text-dim); font-style: italic; }
-.story-earlier button {
- background: none;
- border: none;
- border-bottom: 1px solid rgba(159, 199, 209, 0.3);
- color: var(--text-dim);
- font: inherit;
- font-style: italic;
- cursor: pointer;
- padding: 2px 4px;
-}
-.story-earlier button:hover {
- color: var(--accent-bright);
- border-bottom-color: var(--accent-bright);
-}
-.story .action {
- position: relative;
- margin-bottom: 15px;
- padding-right: 56px;
- animation: action-in 0.4s ease both;
-}
-@keyframes action-in {
- from { opacity: 0; transform: translateY(6px); }
- to { opacity: 1; transform: none; }
-}
-.story .action.player {
- color: var(--player);
- border-left: 2px solid rgba(159, 199, 209, 0.35);
- padding-left: 14px;
- font-style: italic;
-}
-.story .action.streaming { color: var(--text); }
-.story .cursor {
- color: var(--accent-bright);
- text-shadow: 0 0 8px var(--accent-glow);
- animation: blink 1s steps(1) infinite;
-}
-@keyframes blink { 50% { opacity: 0; } }
-
-/* Reasoning-model thinking, collapsed above the AI's response */
-.story .reasoning {
- margin: 0 0 8px;
- font-size: 0.82rem;
- line-height: 1.5;
- color: var(--text-dim);
- font-style: normal;
-}
-.story .reasoning summary {
- cursor: pointer;
- user-select: none;
- color: var(--text-dim);
- opacity: 0.8;
-}
-.story .reasoning summary:hover { opacity: 1; }
-.story .reasoning .reasoning-text {
- margin-top: 6px;
- padding: 8px 12px;
- border-left: 2px solid var(--border);
- opacity: 0.85;
- max-height: 260px;
- overflow-y: auto;
-}
-
-.story .action-tools {
- position: absolute;
- top: 0;
- right: 0;
- display: inline-flex;
- gap: 4px;
- opacity: 0;
- pointer-events: none;
- transition: opacity 0.15s;
-}
-.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
-.story .action-tools button {
- padding: 1px 8px;
- font-size: 0.72rem;
- font-family: var(--font-ui);
- color: var(--text-dim);
- background: var(--bg-panel-glass);
- backdrop-filter: blur(6px);
-}
-.story .action-tools button:hover { color: var(--text); }
-
-/* The takes of one turn: ‹ 2/4 ›. Stays quiet until hovered — it's a footnote
- on the message, not part of the prose.
-
- Small and unemphatic on purpose. Stepping through takes reads them and does
- nothing else, so it should not carry the visual weight of a decision; the
- decision is made by writing, which happens in the composer below. */
-.take-pager {
- display: flex;
- align-items: center;
- gap: 2px;
- margin-top: 6px;
- font-family: var(--font-ui);
- opacity: 0.45;
- transition: opacity 0.15s;
-}
-.story .action:hover .take-pager,
-.take-pager:focus-within { opacity: 1; }
-.take-pager button {
- width: 20px;
- height: 20px;
- padding: 0;
- font-size: 0.9rem;
- line-height: 1;
- color: var(--text-dim);
- background: transparent;
- border: 1px solid transparent;
- border-radius: 4px;
-}
-.take-pager button:hover:not(:disabled) {
- color: var(--text);
- border-color: var(--border-bright);
-}
-.take-pager button:disabled { opacity: 0.3; cursor: default; }
-.take-count {
- min-width: 30px;
- text-align: center;
- font-size: 0.72rem;
- font-variant-numeric: tabular-nums;
- color: var(--text-dim);
-}
-/* Only while a take that isn't the live one is on screen: it says what the
- next thing typed will do, because that is the click that forks. */
-.take-note {
- margin-left: 6px;
- font-size: 0.72rem;
- font-style: italic;
- color: var(--accent-dim);
-}
-
-/* ---------- Branches panel (Phase 14, SP7) ---------- */
-
-.panel-empty {
- padding: 18px 0;
- color: var(--text-dim);
- font-size: 0.85rem;
-}
-.branch-panel { display: flex; flex-direction: column; gap: 14px; }
-.branch-intro {
- margin: 0;
- color: var(--text-dim);
- font-size: 0.82rem;
- line-height: 1.55;
-}
-.branch-list { display: flex; flex-direction: column; gap: 8px; }
-.branch-row {
- display: flex;
- flex-direction: column;
- gap: 5px;
- padding: 9px 11px;
- border: 1px solid var(--border);
- border-radius: 5px;
- background: var(--bg-input);
-}
-.branch-row.here {
- border-color: var(--accent-dim);
- background: rgba(212, 169, 78, 0.07);
-}
-.branch-head { display: flex; align-items: center; gap: 7px; }
-.branch-glyph { color: var(--border-bright); font-size: 0.85rem; }
-.branch-row.here .branch-glyph { color: var(--accent); }
-.branch-name {
- font-family: var(--font-story);
- font-size: 0.98rem;
- color: var(--text);
-}
-.branch-row.here .branch-name { color: var(--accent-bright); }
-.branch-here {
- margin-left: auto;
- font-size: 0.64rem;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--accent-dim);
-}
-.branch-rename {
- flex: 1;
- min-width: 0;
- padding: 3px 7px;
- font-family: var(--font-story);
- font-size: 0.95rem;
-}
-.branch-meta {
- font-size: 0.72rem;
- color: var(--text-dim);
- font-variant-numeric: tabular-nums;
-}
-.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
-.branch-tools button, .branch-confirm button {
- padding: 3px 9px;
- font-size: 0.72rem;
- color: var(--text-dim);
- background: transparent;
- border: 1px solid var(--border);
-}
-.branch-tools button:hover:not(:disabled),
-.branch-confirm button:hover:not(:disabled) {
- color: var(--accent-bright);
- border-color: var(--border-bright);
-}
-.branch-tools button.danger:hover:not(:disabled),
-.branch-confirm button.danger:hover:not(:disabled) {
- color: var(--danger);
- border-color: var(--danger);
-}
-.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; }
-.branch-confirm span {
- font-size: 0.74rem;
- color: var(--danger);
- flex-basis: 100%;
-}
-
-.action-edit { margin-bottom: 14px; }
-/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
- several paragraphs, and the old fixed 110px turned that into a keyhole.
- min-height keeps a short player action from collapsing; max-height stops a
- very long beat from pushing Save/Cancel off the screen (it scrolls instead).
- resize stays on, so the height is a starting point, not a cage. */
-.action-edit textarea {
- min-height: 110px;
- max-height: 65vh;
- overflow-y: auto;
- font-family: inherit;
- font-size: inherit;
-}
-.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
-
-.play-controls {
- position: sticky;
- /* Sits on the keyboard's top edge when one is open, on the viewport bottom
- otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */
- bottom: var(--kb-inset, 0px);
- background: linear-gradient(to top, var(--bg) 75%, transparent);
- padding: 18px 0 16px;
- border-top: 1px solid var(--border);
-}
-.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
-.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
-.turn-buttons button:hover:not(:disabled) { color: var(--text); }
-
-.input-bar { display: flex; gap: 8px; align-items: flex-end; }
-.input-bar input { flex: 1; }
-/* Auto-growing action box: starts at one line, grows to ~4, then scrolls. */
-.input-bar textarea {
- flex: 1;
- resize: none;
- min-height: 0;
- max-height: calc(6em + 20px); /* ~4 lines (1.5em each) + padding/border */
- overflow-y: auto;
- line-height: 1.5;
-}
-
-.mode-select { display: flex; }
-.mode-select button {
- border-radius: 0;
- border-right-width: 0;
- padding: 7px 14px;
- color: var(--text-dim);
- text-transform: capitalize;
-}
-.mode-select button:first-child { border-radius: 8px 0 0 8px; }
-.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
-.mode-select button.active {
- color: var(--accent-bright);
- background: var(--bg-panel);
- border-color: var(--accent-dim);
- border-right-width: 1px;
- box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
-}
-.mode-select button.active + button { border-left-width: 0; }
-
-/* ---------- Side panel (Plot / Insights) ---------- */
-
-.play-layout { flex: 1; display: flex; min-height: calc(100dvh - 56px); }
-.play-layout .play-page { max-width: 960px; }
-.play-layout.with-panel .play-page { max-width: none; }
-
-.panel-toggles { display: flex; gap: 8px; }
-.panel-toggles button.active {
- color: var(--accent-bright);
- border-color: var(--accent-dim);
- box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
-}
-
-.side-panel {
- width: 420px;
- flex-shrink: 0;
- border-left: 1px solid var(--border);
- background: var(--bg-panel);
- padding: 20px;
- overflow-y: auto;
- height: calc(100dvh - 56px);
- position: sticky;
- top: 56px;
- animation: panel-in 0.25s ease both;
-}
-
-/* ---------- In-play script viewer ---------- */
-
-.adv-script {
- border-bottom: 1px solid var(--border);
- padding: 10px 0;
-}
-.adv-script-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
-.adv-script-desc { font-size: 0.82rem; margin-top: 4px; }
-.linklike {
- border: none;
- background: none;
- padding: 0;
- color: var(--accent-bright);
- font-size: 0.8rem;
- cursor: pointer;
- flex-shrink: 0;
-}
-.linklike:hover { text-decoration: underline; }
-.adv-script-code { margin-top: 8px; }
-.adv-script-hook {
- font-size: 0.72rem;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- color: var(--text-dim);
- margin: 8px 0 2px;
-}
-.adv-script-code pre {
- margin: 0;
- padding: 10px;
- background: var(--bg-input);
- border: 1px solid var(--border);
- border-radius: 6px;
- overflow-x: auto;
- font-family: var(--font-mono, monospace);
- font-size: 0.76rem;
- line-height: 1.5;
- white-space: pre;
-}
-
-/* ---------- Left status drawer (script state) ---------- */
-
-.status-drawer {
- flex-shrink: 0;
- position: sticky;
- top: 56px;
- height: calc(100dvh - 56px);
- display: flex;
- align-items: flex-start;
- border-right: 1px solid var(--border);
- background: var(--bg-panel);
-}
-.status-drawer.open { width: 300px; }
-.status-toggle {
- flex-shrink: 0;
- height: 100%;
- width: 30px;
- padding: 12px 0;
- border: none;
- border-radius: 0;
- background: transparent;
- color: var(--text-dim);
- cursor: pointer;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 8px;
-}
-.status-toggle:hover { color: var(--text); background: var(--bg-input); }
-.status-toggle-label {
- writing-mode: vertical-rl;
- text-orientation: mixed;
- letter-spacing: 0.12em;
- font-size: 0.72rem;
- text-transform: uppercase;
-}
-.status-body {
- flex: 1;
- min-width: 0;
- height: 100%;
- overflow-y: auto;
- padding: 20px 16px;
- animation: panel-in 0.2s ease both;
-}
-.status-vars { list-style: none; margin: 0; padding: 0; }
-.status-vars li {
- display: flex;
- flex-direction: column;
- gap: 2px;
- padding: 8px 0;
- border-bottom: 1px solid var(--border);
-}
-.status-key {
- font-family: var(--font-mono, monospace);
- font-size: 0.82rem;
- color: var(--accent-bright);
- word-break: break-word;
-}
-.status-val {
- font-family: var(--font-mono, monospace);
- font-size: 0.82rem;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
-}
-/* Recursive script-state value tree */
-.jv-string { color: var(--text); }
-.jv-number { color: var(--player); }
-.jv-bool { color: var(--accent-bright); }
-.jv-null { color: var(--text-dim); font-style: italic; }
-.jv-empty { color: var(--text-dim); }
-.jv-tree { display: inline-block; vertical-align: top; max-width: 100%; }
-.jv-toggle {
- background: none; border: none; padding: 0; cursor: pointer;
- font-family: inherit; font-size: 0.72rem; color: var(--text-dim);
-}
-.jv-toggle:hover { color: var(--accent-bright); }
-.jv-children {
- list-style: none; margin: 2px 0 2px 0; padding-left: 12px;
- border-left: 1px solid var(--border);
-}
-.jv-children li { padding: 2px 0; word-break: break-word; }
-.jv-key { color: var(--accent); margin-right: 6px; }
-.jv-key::after { content: ':'; color: var(--text-dim); }
-@keyframes panel-in {
- from { opacity: 0; transform: translateX(16px); }
- to { opacity: 1; transform: none; }
-}
-
-/* ---------- World State drawer (Phase 12 RPG) ---------- */
-.ws-group { margin-bottom: 18px; }
-.ws-group-title {
- font-size: 0.72rem;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--text-dim);
- margin: 0 0 8px;
-}
-.ws-stat { padding: 5px 0; }
-.ws-stat-head {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 8px;
- font-size: 0.85rem;
-}
-.ws-stat-name { color: var(--text); text-transform: capitalize; }
-.ws-stat-val {
- color: var(--text-dim);
- font-variant-numeric: tabular-nums;
- font-size: 0.8rem;
- text-align: right;
-}
-.ws-band { color: var(--accent-bright); }
-.ws-bar {
- margin-top: 4px;
- height: 5px;
- border-radius: 3px;
- background: var(--bg-input);
- overflow: hidden;
-}
-.ws-bar-fill {
- height: 100%;
- border-radius: 3px;
- background: var(--accent);
- transition: width 0.35s ease;
-}
-/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of
- five doesn't read as five more unrelated sections in the rail. */
-.ws-npc {
- padding: 7px 9px;
- margin-bottom: 7px;
- border: 1px solid var(--border);
- border-left: 2px solid var(--accent-dim);
- border-radius: 7px;
- background: rgba(255, 255, 255, 0.022);
-}
-.ws-npc:last-child { margin-bottom: 0; }
-.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
-.ws-npc-avatar {
- flex-shrink: 0;
- width: 22px; height: 22px;
- border-radius: 50%;
- display: grid; place-items: center;
- font-family: var(--font-display);
- font-size: 0.6rem;
- color: #14100a;
- background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
-}
-.ws-npc-name {
- font-family: var(--font-display);
- font-size: 0.85rem;
- color: var(--text);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; }
-.ws-milestones { list-style: none; margin: 0; padding: 0; }
-.ws-milestones li {
- display: flex;
- gap: 7px;
- align-items: flex-start;
- padding: 4px 0;
- font-size: 0.85rem;
- color: var(--text);
-}
-.ws-milestones li.done { color: var(--text-dim); }
-.ws-check { flex-shrink: 0; color: var(--accent-bright); }
-.ws-milestones li.done .ws-check { color: var(--player); }
-.ws-flags { list-style: none; margin: 0; padding: 0; }
-.ws-flags li {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 4px 0;
- font-size: 0.85rem;
- color: var(--text);
-}
-.ws-flag-dot {
- width: 9px; height: 9px; border-radius: 50%;
- flex-shrink: 0;
- background: var(--bg-input);
- border: 1px solid var(--border);
-}
-.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); }
-.ws-flag-name { flex: 1; }
-.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
-.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; }
-
-.ws-edit-actions { display: flex; gap: 6px; }
-.ws-edit-input {
- width: 100%;
- margin-top: 3px;
- padding: 4px 7px;
- font-size: 0.82rem;
- border-radius: 6px;
-}
-
-.schema-editor {
- width: 100%;
- font-family: var(--font-mono, monospace);
- font-size: 0.82rem;
- line-height: 1.5;
- tab-size: 2;
- white-space: pre;
- overflow-x: auto;
-}
-.schema-error {
- margin-top: 6px;
- color: var(--danger, #e5484d);
- font-size: 0.82rem;
-}
-.schema-json-actions {
- display: flex;
- align-items: center;
- gap: 12px;
- margin-top: 8px;
-}
-.schema-json-actions .schema-error { margin-top: 0; }
-.schema-preview {
- margin-top: 14px;
- padding: 14px 16px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: var(--bg-panel);
-}
-.schema-preview-title {
- font-size: 0.72rem;
- text-transform: uppercase;
- letter-spacing: 0.12em;
- color: var(--text-dim);
- margin-bottom: 10px;
-}
-.schema-group { margin-bottom: 16px; }
-.schema-group:last-child { margin-bottom: 0; }
-.schema-group-title {
- font-family: Georgia, serif;
- font-size: 0.98rem;
- color: var(--accent-bright);
- margin-bottom: 6px;
-}
-.schema-npc-id {
- font-family: var(--font-mono, monospace);
- font-size: 0.72rem;
- color: var(--text-dim);
- margin-left: 6px;
-}
-.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); }
-.schema-stat:last-child { border-bottom: none; }
-.schema-stat-head {
- display: flex;
- justify-content: space-between;
- align-items: baseline;
- gap: 12px;
-}
-.schema-stat-name { color: var(--text); text-transform: capitalize; }
-.schema-stat-meta {
- color: var(--text-dim);
- font-size: 0.8rem;
- font-variant-numeric: tabular-nums;
- text-align: right;
-}
-.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
-.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
-
-/* ---- Form-based schema editor ----
- One shape for everything: a section is a panel, every entry inside it is a
- `.se-row` tile, and every control wears a `.se-field` caption. Adding a new
- kind of entry means reusing those three, not inventing a fourth box. */
-.se-section {
- margin-bottom: 16px;
- padding: 13px 15px;
- border: 1px solid var(--border);
- border-radius: 10px;
- background: var(--bg-panel);
-}
-.se-section-nested {
- margin: 10px 0 0;
- padding: 11px 12px;
- background: rgba(0, 0, 0, 0.22);
- border-radius: 8px;
-}
-.se-section-head {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- margin-bottom: 8px;
-}
-.se-section-title {
- font-size: 0.74rem;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--accent);
-}
-.se-hint {
- margin: -2px 0 10px;
- font-size: 0.78rem;
- line-height: 1.45;
- color: var(--text-dim);
-}
-.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
-.se-add, .se-add-sm {
- font-size: 0.78rem;
- padding: 3px 10px;
- white-space: nowrap;
-}
-.se-add-sm { margin-top: 6px; }
-.se-remove {
- background: transparent;
- border: none;
- color: var(--text-dim);
- cursor: pointer;
- padding: 2px 6px;
- font-size: 0.9rem;
- line-height: 1;
- flex-shrink: 0;
-}
-.se-remove:hover { color: var(--danger, #e5484d); }
-
-/* Every control in the editor, one look. The global field rule keys off
- `input[type="…"]`, so the plain ``s here (keys, descriptions) never
- matched it and fell back to browser defaults — that mismatch is most of why
- this panel used to read as a pile of unrelated boxes. Everything below is
- qualified with `.se` so it outranks that global rule (0,1,1) too. */
-/* `:where()` so the exclusion costs no specificity — a plain
- `:not([type="checkbox"])` would rank this above the `.se .se-*` overrides
- below and swallow every one of them. */
-.se input:not(:where([type="checkbox"])), .se textarea, .se select {
- width: 100%;
- background: var(--bg-input);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: 6px;
- padding: 5px 8px;
- font-size: 0.84rem;
- font-family: inherit;
- transition: border-color 0.15s, box-shadow 0.15s;
-}
-
-/* One entry — a stat, a flag, a milestone. */
-.se-row {
- padding: 10px 12px;
- margin-bottom: 8px;
- border: 1px solid var(--border);
- border-radius: 8px;
- background: rgba(255, 255, 255, 0.018);
- transition: border-color 0.15s;
-}
-.se-row:last-child { margin-bottom: 0; }
-.se-row:focus-within { border-color: var(--border-bright); }
-/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and
- an input's default `min-width: auto` would push the ✕ off-screen instead. */
-.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }
-.se .se-key {
- font-family: var(--font-mono, monospace);
- font-size: 0.82rem;
- min-width: 0;
- max-width: 220px;
-}
-/* Only in the row context — `.se-field` is a *column* flex, where a basis of
- 140px would set the input's height instead of its width. */
-.se .se-row-top .se-key { flex: 1 1 140px; }
-.se-field { display: flex; flex-direction: column; gap: 3px; }
-.se-field > span {
- font-size: 0.66rem;
- text-transform: uppercase;
- letter-spacing: 0.09em;
- color: var(--text-dim);
-}
-.se-fields {
- display: flex;
- flex-wrap: wrap;
- gap: 8px 12px;
- align-items: flex-end;
- margin-bottom: 8px;
-}
-.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; }
-.se .se-kind select { width: 132px; }
-.se .se-initial-text input { width: 220px; max-width: 100%; }
-.se-check {
- display: flex; align-items: center; gap: 5px;
- font-size: 0.8rem; color: var(--text-dim);
- white-space: nowrap;
-}
-.se-check input { width: auto; }
-/* The global textarea rule also forces min-height:70px, which a 2-row inline
- field doesn't want. */
-.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; }
-.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
-.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
-/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) —
- as a bare class it lost, and the band bounds rendered full-width. */
-.se .se-band-n { width: 64px; padding: 4px 6px; }
-.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; }
-.se-bands { margin-top: 8px; }
-
-/* ---- NPC roster (its own page section) ---- */
-.se-npc-grid {
- display: grid;
- /* min() so a 320px phone gets one column instead of a 340px overflow */
- grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
- gap: 14px;
- align-items: start;
-}
-.se-npc {
- border: 1px solid var(--border);
- border-radius: 10px;
- background: var(--bg-panel);
- overflow: hidden;
-}
-.se-npc:focus-within { border-color: var(--border-bright); }
-.se-npc-head {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 10px 12px;
- border-bottom: 1px solid var(--border);
- background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent);
-}
-.se-avatar {
- flex-shrink: 0;
- width: 34px; height: 34px;
- border-radius: 50%;
- display: grid; place-items: center;
- font-family: var(--font-display);
- font-size: 0.8rem;
- letter-spacing: 0.02em;
- color: #14100a;
- background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
-}
-.se-npc-ident { flex: 1; min-width: 0; }
-.se .se-npc-name {
- padding: 2px 6px;
- font-family: var(--font-display);
- font-size: 0.95rem;
- background: transparent;
- border-color: transparent;
-}
-.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); }
-.se-npc-addr {
- display: block;
- margin: 1px 0 0 6px;
- font-family: var(--font-mono, monospace);
- font-size: 0.7rem;
- color: var(--text-dim);
-}
-.se-npc-count {
- flex-shrink: 0;
- font-size: 0.7rem;
- color: var(--text-dim);
- white-space: nowrap;
-}
-.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
-.se-npc-idfield .se-key { max-width: 100%; }
-.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
-.ws-report li { padding: 2px 0; color: var(--text); }
-
-/* Inline per-turn change chips under an AI message */
-.turn-changes {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- margin-top: 8px;
-}
-.chg {
- font-size: 0.74rem;
- padding: 2px 8px;
- border-radius: 10px;
- border: 1px solid var(--border);
- background: var(--bg-input);
- color: var(--text-dim);
- font-variant-numeric: tabular-nums;
- /* A chip must never be wider than the column it sits in: an NPC stat like
- "bandit leader aggression +10" overruns a phone, and the chip row is
- inside the story text, which has nothing to scroll. The label may break
- mid-word as a last resort; the value is glued to it by its own nowrap so
- the sign and number never end up on separate lines. */
- max-width: 100%;
- overflow-wrap: anywhere;
-}
-.chg-val { white-space: nowrap; }
-.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); }
-.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); }
-.chg-flag { color: var(--accent-bright); }
-.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
-/* A refused change, and a clamp that left the value where it started. The
- dashed border separates it from the changes that took effect. It is not
- styled as an error, because the rules refusing a change is them working. */
-.chg-refused {
- border-style: dashed;
- color: var(--text-dim);
- border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
-}
-/* A clamp that reduced a change but still moved the value. */
-.chg-limited { opacity: 0.75; font-style: italic; }
-.ws-report code { color: var(--accent-bright); }
-.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
-.ws-flag { color: var(--text-dim); font-style: italic; }
-.ws-rejected { color: var(--text-dim); }
-.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
-.side-panel-header h2 {
- margin: 0;
- font-family: var(--font-display);
- font-size: 1.05rem;
- color: var(--accent);
-}
-
-/* ---------- Insights ---------- */
-
-.insights-meta { margin-bottom: 16px; font-size: 0.85rem; }
-.insights-mode { color: var(--text-dim); margin-bottom: 8px; }
-.insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
-.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
-.token-total.over { color: var(--danger); }
-/* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in
- Play.jsx so the bar, its legend and each section header stay in sync. */
-.token-split {
- display: flex;
- height: 14px;
- background: var(--bg-input);
- border-radius: 4px;
- overflow: hidden;
- margin-bottom: 8px;
-}
-.token-split.over { box-shadow: 0 0 0 1px var(--danger); }
-.token-seg {
- min-width: 2px; /* a 0.2% section is still worth seeing */
- border-right: 1px solid var(--bg-panel);
- transition: opacity 0.15s ease;
-}
-.token-seg:last-child { border-right: none; }
-.token-seg-free { background: transparent; min-width: 0; border-right: none; }
-.token-seg.faded, .token-legend-item.faded { opacity: 0.25; }
-
-.token-legend {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
- gap: 1px 12px;
- margin-bottom: 10px;
-}
-.token-legend-item {
- display: flex;
- align-items: center;
- gap: 7px;
- background: none;
- border: none;
- border-radius: 4px;
- padding: 2px 5px;
- font-size: 0.78rem;
- color: var(--text-dim);
- text-align: left;
- transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
-}
-.token-legend-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
-.token-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
-.token-legend-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
-.token-legend-pct { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
-.token-legend-tok {
- width: 4.5em;
- text-align: right;
- opacity: 0.65;
- font-variant-numeric: tabular-nums;
-}
-.token-legend-more {
- justify-self: start;
- background: none;
- border: none;
- padding: 2px 5px;
- font-size: 0.75rem;
- color: var(--text-dim);
-}
-.token-legend-more:hover { color: var(--accent); background: none; }
-.insights-history { color: var(--text-dim); margin-bottom: 8px; }
-.insights-cards div { margin-bottom: 3px; }
-.insights-cards .dropped { color: var(--danger); }
-
-.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; }
-.ctx-section .ctx-header {
- display: flex;
- justify-content: space-between;
- font-size: 0.7rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.1em;
- color: var(--text-dim);
- padding: 5px 10px 3px;
-}
-.ctx-section pre {
- margin: 0;
- padding: 6px 10px 8px;
- white-space: pre-wrap;
- word-break: break-word;
- font-family: Consolas, monospace;
- font-size: 0.8rem;
- line-height: 1.5;
- color: var(--text);
- max-height: 260px;
- overflow-y: auto;
-}
-/* Per-section border/header colours are applied inline from SECTION_COLORS
- (Play.jsx) — one palette drives the bar, the legend and these headers. */
-
-/* ---------- Scripts ---------- */
-
-.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; }
-.slot-tabs button {
- border-radius: 0;
- border-right-width: 0;
- color: var(--text-dim);
-}
-.slot-tabs button:first-child { border-radius: 8px 0 0 8px; }
-.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
-.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); }
-.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; }
-
-.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; }
-.cm-editor.cm-focused {
- outline: none;
- border-color: var(--accent-dim);
- box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1);
-}
-
-.test-run .row { display: flex; gap: 12px; }
-.test-result {
- margin-top: 12px;
- background: var(--bg-input);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 10px 12px;
- font-size: 0.85rem;
-}
-.test-result .label {
- text-transform: uppercase;
- font-size: 0.7rem;
- letter-spacing: 0.08em;
- color: var(--text-dim);
- margin-right: 6px;
-}
-.test-result pre {
- display: inline-block;
- margin: 2px 0;
- white-space: pre-wrap;
- word-break: break-word;
- font-family: Consolas, monospace;
- vertical-align: top;
-}
-.test-error { color: var(--danger); white-space: pre-wrap; }
-
-.script-attach {
- display: flex;
- align-items: baseline;
- gap: 8px;
- padding: 9px 12px;
- background: var(--bg-input);
- border: 1px solid var(--border);
- border-radius: 8px;
- margin-bottom: 6px;
- cursor: pointer;
- transition: border-color 0.15s;
-}
-.script-attach:hover { border-color: var(--border-bright); }
-.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; }
-
-/* ---------- Memory bank (Phase 6) ---------- */
-
-.memory-row {
- background: var(--bg-input);
- border: 1px solid var(--border);
- border-radius: 8px;
- padding: 9px 12px;
- margin-bottom: 6px;
-}
-.memory-row.forgotten { opacity: 0.55; }
-.memory-text { font-size: 0.9rem; white-space: pre-wrap; }
-.memory-meta {
- display: flex;
- align-items: baseline;
- flex-wrap: wrap;
- gap: 10px;
- margin-top: 5px;
- font-size: 0.78rem;
-}
-.memory-meta .dim { color: var(--text-dim); }
-.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; }
-.memory-meta .action-tools button {
- padding: 1px 8px;
- font-size: 0.72rem;
- color: var(--text-dim);
-}
-.memory-meta .action-tools button:hover { color: var(--text); }
-.memory-badge {
- color: var(--accent);
- border: 1px solid var(--border);
- border-radius: 999px;
- padding: 0 8px;
-}
-
-.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
-.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
-.script-logs {
- background: var(--bg-input);
- border-radius: 8px;
- padding: 8px 10px;
- font-family: Consolas, monospace;
- font-size: 0.8rem;
- white-space: pre-wrap;
- word-break: break-word;
- margin: 6px 0;
-}
-
-/* ---------- Filter bar & tags ---------- */
-
-.filter-bar { margin-bottom: 18px; }
-.search-input { max-width: 340px; }
-.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
-.tag {
- padding: 2px 12px;
- font-size: 0.76rem;
- border-radius: 999px;
- color: var(--text-dim);
-}
-.tag.active {
- color: var(--accent-bright);
- border-color: var(--accent-dim);
- box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1);
-}
-.tag.small {
- display: inline-block;
- background: var(--bg-input);
- border: 1px solid var(--border);
- padding: 1px 9px;
- margin-right: 4px;
- cursor: default;
-}
-
-/* ---------- Modal ---------- */
-
-.modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(5, 5, 9, 0.7);
- backdrop-filter: blur(4px);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 50;
- animation: page-in 0.2s ease both;
-}
-.modal {
- width: min(480px, calc(100vw - 40px));
- max-height: 80vh;
- overflow-y: auto;
- background: var(--bg-panel);
- border: 1px solid var(--border-bright);
- border-radius: 14px;
- padding: 26px 28px;
- box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
-}
-.modal h2 {
- margin: 0 0 4px;
- font-family: var(--font-display);
- font-size: 1.2rem;
- color: var(--accent-bright);
-}
-.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
-.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
-
-/* ---------- Auth (log in / sign up) ---------- */
-
-.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; }
-.auth-crest {
- font-size: 1.1rem;
- color: var(--accent-dim);
- letter-spacing: 0.3em;
- margin-bottom: 6px;
-}
-.auth-modal h2 { font-size: 1.35rem; }
-.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; }
-/* Fields go back to left-aligned — only the header is centred. */
-.auth-modal label.field { text-align: left; }
-
-.auth-tabs {
- display: flex;
- gap: 4px;
- padding: 4px;
- margin-bottom: 18px;
- background: var(--bg-input);
- border: 1px solid var(--border);
- border-radius: 10px;
-}
-.auth-tab {
- flex: 1;
- padding: 7px 0;
- border: 1px solid transparent;
- border-radius: 7px;
- background: transparent;
- color: var(--text-dim);
- font-family: var(--font-display);
- font-size: 0.78rem;
- letter-spacing: 0.1em;
- text-transform: uppercase;
-}
-.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); }
-.auth-tab.active {
- background: var(--bg-panel);
- border-color: var(--accent-dim);
- color: var(--accent-bright);
-}
-
-.auth-password { display: flex; gap: 8px; }
-.auth-password input { flex: 1; min-width: 0; }
-.auth-reveal {
- flex: none;
- padding: 0 12px;
- font-size: 0.78rem;
- color: var(--text-dim);
-}
-.auth-reveal:hover { color: var(--accent-bright); }
-
-.auth-help {
- display: block;
- margin-top: 6px;
- font-size: 0.76rem;
- color: var(--text-dim);
-}
-.auth-error {
- margin-top: 4px;
- padding: 9px 12px;
- border: 1px solid rgba(208, 101, 101, 0.45);
- border-radius: 8px;
- background: rgba(208, 101, 101, 0.09);
- color: var(--danger);
- font-size: 0.85rem;
- text-align: left;
- white-space: pre-wrap;
-}
-
-/* "Update from scenario" — a wider modal, since it lists a diff. */
-.refresh-modal { width: min(620px, calc(100vw - 40px)); }
-.refresh-changes {
- border: 1px solid var(--border);
- border-radius: 10px;
- padding: 12px 14px;
- font-size: 0.86rem;
-}
-.refresh-change + .refresh-change {
- margin-top: 10px;
- padding-top: 10px;
- border-top: 1px solid var(--border);
-}
-.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; }
-.refresh-old { text-decoration: line-through; }
-.refresh-new { color: var(--accent-bright); }
-/* The button sits above the plot fields it would overwrite. */
-.plot-source {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
- flex-wrap: wrap;
- margin-bottom: 16px;
- padding-bottom: 12px;
- border-bottom: 1px solid var(--border);
- font-size: 0.82rem;
-}
-.plot-source .linklike { flex-shrink: 0; }
-
-/* ---------- Debug log (Settings) ---------- */
-
-.debug-entry {
- border: 1px solid var(--border);
- border-radius: 8px;
- margin-bottom: 8px;
- overflow: hidden;
-}
-.debug-entry-head {
- display: flex;
- align-items: baseline;
- gap: 12px;
- padding: 9px 12px;
- cursor: pointer;
- font-size: 0.86rem;
- background: var(--bg-input);
-}
-.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
-.debug-status {
- font-size: 0.68rem;
- font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.08em;
- padding: 1px 8px;
- border-radius: 999px;
- border: 1px solid var(--border-bright);
- color: var(--text-dim);
-}
-.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
-.debug-status.error { color: var(--danger); border-color: var(--danger); }
-.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
-.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
-.debug-entry-body pre {
- margin: 2px 0 8px;
- padding: 8px 10px;
- background: var(--bg-input);
- border-radius: 6px;
- white-space: pre-wrap;
- word-break: break-word;
- font-family: Consolas, monospace;
- font-size: 0.78rem;
- max-height: 300px;
- overflow-y: auto;
-}
-
-/* ---------- Play screen error/success banner ----------
- Persistent (no auto-dismiss) and can carry a Retry action, so it stays
- separate from the transient global toasts further down this file. */
-
-.play-toast {
- position: fixed;
- bottom: 24px;
- left: 50%;
- transform: translateX(-50%);
- background: var(--bg-panel-glass);
- backdrop-filter: blur(10px);
- border: 1px solid var(--danger);
- color: var(--text);
- padding: 11px 20px;
- border-radius: 10px;
- z-index: 100;
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
- animation: toast-in 0.25s ease both;
-}
-.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
-@keyframes toast-in {
- from { opacity: 0; transform: translate(-50%, 12px); }
- to { opacity: 1; transform: translate(-50%, 0); }
-}
-
-/* ============================================================
- Illuminated tome — ambient layer, ornament, art plates, cards
- Everything below styles the home/library surfaces. It comes after
- the base .card rules on purpose: several selectors here tie with
- earlier ones on specificity and rely on source order to win.
- ============================================================ */
-
-/* ---------- Ambient drifting motes ---------- */
-
-/* Behind every page: fixed, unclickable, and out of the stacking flow. The
- canvas is sized in JS (see Embers.jsx); z-index 0 with the app's own
- surfaces at 1+ keeps it under content without needing a negative index
- (which would drop it behind the body background). */
-.embers {
- position: fixed;
- inset: 0;
- z-index: 0;
- pointer-events: none;
- opacity: 0.85;
-}
-/* .topnav already carries z-index 10 — deliberately not touched here, so it
- keeps sitting above page content as well as above the canvas. */
-.page { position: relative; z-index: 1; }
-
-/* A third gradient pool warming the middle of the ground. `background` is a
- shorthand, so background-attachment has to be restated or it resets to
- scroll and the gradients start sliding with the page. */
-body {
- background:
- radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.07), transparent 60%),
- radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.1), transparent 55%),
- radial-gradient(760px 520px at 50% 42%, rgba(150, 119, 58, 0.05), transparent 62%),
- var(--bg);
- background-attachment: fixed;
-}
-
-/* ---------- Home banner ---------- */
-
-.hall {
- display: flex;
- flex-direction: column;
- gap: 5px;
- padding: 6px 0 26px;
- margin-bottom: 4px;
- border-bottom: 1px solid var(--border);
- position: relative;
-}
-/* Gold hairline that fades out toward both ends, sat on the border above. */
-.hall::after {
- content: '';
- position: absolute;
- bottom: -1px;
- left: 0;
- right: 0;
- height: 1px;
- background: linear-gradient(90deg, transparent, var(--accent-dim) 22%, var(--accent-dim) 78%, transparent);
- opacity: 0.55;
-}
-.hall-eyebrow {
- margin: 0;
- font-size: 0.68rem;
- letter-spacing: 0.24em;
- text-transform: uppercase;
- color: var(--accent-dim);
- font-weight: 600;
-}
-.hall-title {
- margin: 0;
- font-family: var(--font-display);
- font-size: clamp(1.7rem, 4.6vw, 2.3rem);
- font-weight: 600;
- letter-spacing: 0.05em;
- color: var(--accent-bright);
- text-shadow: 0 0 30px var(--accent-glow);
- text-wrap: balance;
-}
-.hall-sub {
- margin: 0;
- color: var(--text-dim);
- font-family: var(--font-story);
- font-size: 1.02rem;
-}
-
-/* ---------- Ornamental section rules ---------- */
-
-.home-section { margin-top: 34px; }
-
-.rule-head {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-bottom: 16px;
-}
-.rule {
- flex: 1;
- display: flex;
- align-items: center;
- gap: 14px;
- margin: 0;
- font-family: var(--font-display);
- font-size: 0.82rem;
- font-weight: 600;
- letter-spacing: 0.26em;
- text-transform: uppercase;
- color: var(--accent-bright);
- white-space: nowrap;
-}
-/* Rules grow to fill whatever the label leaves — the label stays optically
- centred in its own space without needing a fixed width. */
-.rule::before, .rule::after {
- content: '';
- height: 1px;
- flex: 1;
- background: linear-gradient(90deg, transparent, var(--accent-dim) 45%, var(--accent-dim) 55%, transparent);
-}
-.rule::before { flex: 0 0 26px; }
-.rule > span { flex: none; }
-
-button.linklike.ornate {
- font-family: var(--font-display);
- font-size: 0.74rem;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--accent-dim);
- white-space: nowrap;
- transition: color 0.15s;
-}
-button.linklike.ornate:hover { color: var(--accent-bright); text-decoration: none; }
-
-/* ---------- Art plates ---------- */
-
-.art {
- width: 38px;
- height: 38px;
- flex: none;
- border-radius: 3px;
- border: 1px solid var(--accent-dim);
- box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
- overflow: hidden;
- display: grid;
- place-items: center;
- line-height: 1;
-}
-.art-lg { width: 46px; height: 46px; }
-.art-photo img {
- width: 100%;
- height: 100%;
- object-fit: cover;
- display: block;
-}
-.art-icon { font-size: 1.15rem; }
-.art-lg.art-icon { font-size: 1.4rem; }
-.art-mono {
- font-family: var(--font-display);
- font-size: 0.82rem;
- font-weight: 700;
- letter-spacing: 0.06em;
- color: rgba(232, 196, 118, 0.92);
- text-shadow: 0 1px 4px rgba(0, 0, 0, 0.65);
-}
-.art-lg.art-mono { font-size: 0.95rem; }
-
-/* ---------- Tome cards ---------- */
-
-.card-grid.wide { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
-
-.card.tome {
- border-radius: 3px;
- padding: 16px 17px 14px;
- background:
- linear-gradient(168deg, rgba(212, 169, 78, 0.05), transparent 55%),
- var(--bg-panel);
- /* Inner dark gutter + a gold keyline: the double-rule border of a plate in
- an old book, done with shadows so it costs no extra element. */
- box-shadow:
- inset 0 0 0 1px rgba(10, 10, 15, 0.9),
- inset 0 0 0 2px rgba(150, 119, 58, 0.22);
-}
-
-/* Filigree corner brackets. These override the base .card::before gradient
- bar (equal specificity, later in the file wins). */
-.card.tome::before,
-.card.tome::after {
- content: '';
- position: absolute;
- width: 12px;
- height: 12px;
- border: 1px solid var(--accent-dim);
- opacity: 0.5;
- transition: opacity 0.2s;
- background: none;
- inset: auto;
-}
-.card.tome::before {
- top: 6px;
- left: 6px;
- border-right: 0;
- border-bottom: 0;
-}
-.card.tome::after {
- bottom: 6px;
- right: 6px;
- border-left: 0;
- border-top: 0;
-}
-.card.tome:hover::before,
-.card.tome:hover::after { opacity: 1; }
-.card.tome:hover {
- border-color: var(--accent-dim);
- box-shadow:
- inset 0 0 0 1px rgba(10, 10, 15, 0.9),
- inset 0 0 0 2px rgba(212, 169, 78, 0.4),
- 0 12px 30px rgba(0, 0, 0, 0.5),
- 0 0 26px rgba(212, 169, 78, 0.08);
-}
-
-.card-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
-/* Keep headings clear of the wax seal, which is absolutely positioned over
- the card's top-right corner. Only cards that actually carry one pay the
- indent, so scenario cards keep their full width. */
-.card.tome:has(> .seal) .card-head { padding-right: 26px; }
-.card-headings { min-width: 0; }
-.card.tome h3 {
- margin: 0;
- font-family: var(--font-display);
- font-size: 1rem;
- font-weight: 600;
- letter-spacing: 0.045em;
- color: var(--accent);
- overflow-wrap: anywhere;
-}
-.card-from {
- margin: 3px 0 0;
- font-size: 0.75rem;
- color: var(--text-dim);
- overflow-wrap: anywhere;
-}
-
-/* Wax seal marking a story in progress. */
-.seal {
- position: absolute;
- top: 12px;
- right: 12px;
- width: 24px;
- height: 24px;
- border-radius: 50%;
- background: radial-gradient(circle at 34% 30%, #b8553f, #7d2f24 72%);
- box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 3px rgba(0, 0, 0, 0.45);
- display: grid;
- place-items: center;
- font-size: 0.6rem;
- color: #f0d5b0;
- z-index: 1;
-}
-
-.snippet {
- margin: 0;
- font-family: var(--font-story);
- font-size: 0.98rem;
- line-height: 1.5;
- color: #b3ac9c;
- /* Clamp so a long beat can't stretch one card taller than its row. */
- display: -webkit-box;
- -webkit-line-clamp: 3;
- -webkit-box-orient: vertical;
- overflow: hidden;
-}
-.snippet.muted { color: var(--text-dim); font-style: italic; }
-.snippet.dropcap::first-letter {
- float: left;
- font-family: var(--font-display);
- font-size: 2.3rem;
- line-height: 0.85;
- font-weight: 700;
- color: var(--accent);
- padding: 3px 7px 0 0;
-}
-
-.card-foot {
- display: flex;
- align-items: center;
- gap: 8px;
- margin-top: 13px;
- padding-top: 11px;
- border-top: 1px solid var(--border);
- font-size: 0.74rem;
- color: var(--text-dim);
- flex-wrap: wrap;
-}
-.card-foot .turns { font-variant-numeric: tabular-nums; }
-.card-foot .dot { opacity: 0.5; }
-.card-foot .resume {
- margin-left: auto;
- color: var(--accent-dim);
- font-family: var(--font-ui);
- letter-spacing: 0.1em;
- text-transform: uppercase;
- font-size: 0.68rem;
- font-weight: 600;
- transition: color 0.15s;
-}
-.card.tome:hover .resume { color: var(--accent-bright); }
-.tag-cluster { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; }
-.card-actions { margin-left: auto; display: inline-flex; gap: 5px; }
-
-button.tiny { padding: 2px 9px; font-size: 0.72rem; }
-button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: auto; }
-
-/* Staggered card entrance. The per-card delay is set inline. */
-.enter { animation: card-in 0.4s ease both; }
-@keyframes card-in {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: none; }
-}
-
-/* ---------- Loading skeletons ---------- */
-
-.skeleton-card {
- cursor: default;
- pointer-events: none;
- animation: card-in 0.3s ease both;
-}
-.skeleton-card::before, .skeleton-card::after { content: none; }
-.skeleton-card:hover { transform: none; border-color: var(--border); box-shadow: none; }
-.sk {
- border-radius: 3px;
- background: linear-gradient(90deg, #1c1c2b 25%, #262639 50%, #1c1c2b 75%);
- background-size: 220% 100%;
- animation: shimmer 1.4s ease-in-out infinite;
-}
-.sk-plate { width: 38px; height: 38px; margin-bottom: 12px; }
-.sk-title { height: 13px; width: 58%; margin-bottom: 12px; }
-.sk-line { height: 9px; margin-bottom: 8px; }
-@keyframes shimmer {
- from { background-position: 120% 0; }
- to { background-position: -120% 0; }
-}
-
-/* ---------- Global toasts ---------- */
-
-.toast-host {
- position: fixed;
- bottom: 22px;
- left: 50%;
- transform: translateX(-50%);
- z-index: 120;
- display: flex;
- flex-direction: column-reverse;
- gap: 8px;
- align-items: center;
- width: max-content;
- max-width: min(92vw, 520px);
- pointer-events: none;
-}
-.toast {
- pointer-events: auto;
- display: flex;
- align-items: flex-start;
- gap: 9px;
- text-align: left;
- font-family: var(--font-ui);
- font-size: 0.86rem;
- line-height: 1.45;
- padding: 10px 16px;
- border-radius: 8px;
- background: var(--bg-panel-glass);
- backdrop-filter: blur(12px);
- -webkit-backdrop-filter: blur(12px);
- border: 1px solid var(--border-bright);
- border-left: 3px solid var(--accent);
- color: var(--text);
- box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
- animation: rise 0.25s ease both;
-}
-.toast:hover { border-color: var(--accent-dim); }
-.toast-error { border-left-color: var(--danger); }
-.toast-mark { color: var(--accent); flex: none; line-height: 1.5; }
-.toast-error .toast-mark { color: var(--danger); }
-@keyframes rise {
- from { opacity: 0; transform: translateY(10px); }
- to { opacity: 1; transform: none; }
-}
-
-/* ---------- Story text polish ---------- */
-
-/* Drop cap opening the story's first narrated beat. */
-.story .action.opening::first-letter {
- float: left;
- font-family: var(--font-display);
- font-size: 3.1rem;
- line-height: 0.82;
- font-weight: 700;
- color: var(--accent);
- padding: 5px 10px 0 2px;
- text-shadow: 0 0 22px var(--accent-glow);
-}
-
-/* Ornamental separator between turns — a diamond flanked by fading rules.
- Marks the beat change without the heaviness of a full
. */
-.scene-break {
- display: flex;
- align-items: center;
- gap: 12px;
- margin: 4px 0 16px;
- color: var(--accent-dim);
- font-size: 0.6rem;
- user-select: none;
-}
-.scene-break::before, .scene-break::after {
- content: '';
- height: 1px;
- flex: 1;
- background: linear-gradient(90deg, transparent, rgba(150, 119, 58, 0.4), transparent);
-}
-
-/* Thinking indicator — three motes pulsing in sequence. */
-.thinking {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- font-family: var(--font-ui);
- font-size: 0.78rem;
- letter-spacing: 0.14em;
- text-transform: uppercase;
- color: var(--accent-dim);
-}
-.thinking i {
- width: 5px;
- height: 5px;
- border-radius: 50%;
- background: var(--accent);
- box-shadow: 0 0 8px var(--accent-glow);
- animation: mote 1.25s ease-in-out infinite;
-}
-.thinking i:nth-child(2) { animation-delay: 0.18s; }
-.thinking i:nth-child(3) { animation-delay: 0.36s; }
-@keyframes mote {
- 0%, 100% { opacity: 0.25; transform: scale(0.8); }
- 45% { opacity: 1; transform: scale(1.15); }
-}
-
-/* ---------- Scenario art picker (editor) ---------- */
-
-.art-picker { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
-.art-preview {
- width: 76px;
- height: 76px;
- flex: none;
- border-radius: 3px;
- border: 1px solid var(--accent-dim);
- box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
- overflow: hidden;
- display: grid;
- place-items: center;
-}
-.art-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
-.art-preview .glyph { font-size: 2rem; line-height: 1; }
-.art-preview .mono {
- font-family: var(--font-display);
- font-size: 1.4rem;
- font-weight: 700;
- color: rgba(232, 196, 118, 0.92);
-}
-.art-controls { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 9px; }
-.art-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
-.icon-row { display: flex; gap: 5px; flex-wrap: wrap; }
-.icon-row button {
- padding: 3px 8px;
- font-size: 1rem;
- line-height: 1.2;
- min-width: 34px;
-}
-.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
-.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; }
-
-/* ---------- AI Chat (power users) ----------
- A plain chat scratchpad, styled as a transcript rather than as the story
- page: UI font, role labels, and a sticky composer at the bottom. */
-
-.chat-page {
- max-width: 820px;
- display: flex;
- flex-direction: column;
- margin-bottom: var(--kb-inset, 0px); /* see .play-page */
-}
-.chat-meta {
- font-size: 0.78rem;
- margin: -10px 0 16px;
- word-break: break-word;
-}
-.chat-options {
- border: 1px solid var(--border);
- border-radius: 10px;
- padding: 14px 16px 4px;
- margin-bottom: 18px;
- background: var(--bg-panel);
-}
-
-.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; }
-
-.chat-msg {
- border: 1px solid var(--border);
- border-radius: 10px;
- padding: 10px 14px 12px;
- background: var(--bg-panel);
-}
-.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); }
-.chat-msg.assistant { border-left: 3px solid var(--accent-dim); }
-.chat-msg-head {
- display: flex;
- align-items: center;
- gap: 10px;
- margin-bottom: 6px;
- font-size: 0.72rem;
- letter-spacing: 0.14em;
- text-transform: uppercase;
-}
-.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; }
-.chat-msg.user .chat-role { color: var(--player); }
-.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; }
-.chat-msg-actions {
- margin-left: auto;
- display: flex;
- gap: 12px;
- opacity: 0;
- transition: opacity 0.15s;
-}
-.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; }
-.chat-msg-body {
- white-space: pre-wrap;
- overflow-wrap: anywhere;
- line-height: 1.6;
- font-size: 0.95rem;
-}
-.chat-msg .cursor {
- color: var(--accent-bright);
- text-shadow: 0 0 8px var(--accent-glow);
- animation: blink 1s steps(1) infinite;
-}
-
-/* Same collapsed thinking block as the story page, re-scoped for the transcript. */
-.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); }
-.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; }
-.chat-msg .reasoning summary:hover { opacity: 1; }
-.chat-msg .reasoning .reasoning-text {
- margin-top: 6px;
- padding: 8px 12px;
- border-left: 2px solid var(--border);
- white-space: pre-wrap;
- opacity: 0.85;
- max-height: 260px;
- overflow-y: auto;
-}
-
-.chat-composer {
- position: sticky;
- bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */
- display: flex;
- gap: 10px;
- align-items: flex-end;
- padding: 14px 0;
- background: linear-gradient(to top, var(--bg) 55%, transparent);
-}
-.chat-composer textarea {
- flex: 1;
- min-height: 44px;
- max-height: 220px;
- resize: none;
- overflow-y: auto;
-}
-.chat-composer button { flex: none; height: 40px; }
-
-/* ---------- The story tree, drawn (branch map overlay) ---------- */
-
-.branch-map-open {
- align-self: flex-start;
- padding: 5px 12px;
- font-size: 0.74rem;
- letter-spacing: 0.08em;
- color: var(--accent);
- background: transparent;
- border: 1px solid var(--border-bright);
-}
-.branch-map-open:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
-
-/* The overlay is deliberately wider and shorter than .modal: a tree is read
- across, and the number of branches decides the height. */
-.branch-map-overlay { z-index: 60; }
-.branch-map {
- display: flex;
- flex-direction: column;
- width: min(1080px, calc(100vw - 40px));
- max-height: min(760px, calc(100vh - 60px));
- background: var(--bg-panel);
- border: 1px solid var(--border-bright);
- border-radius: 14px;
- box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
-}
-.branch-map-header {
- display: flex;
- align-items: baseline;
- gap: 12px;
- padding: 18px 22px 12px;
- border-bottom: 1px solid var(--border);
-}
-.branch-map-header h2 {
- margin: 0;
- font-family: var(--font-display);
- font-size: 1.15rem;
- color: var(--accent-bright);
-}
-.branch-map-scale {
- font-size: 0.72rem;
- color: var(--text-dim);
- font-variant-numeric: tabular-nums;
-}
-.branch-map-close {
- margin-left: auto;
- align-self: center;
- padding: 2px 8px;
- color: var(--text-dim);
- background: transparent;
- border: 1px solid transparent;
-}
-.branch-map-close:hover { color: var(--accent-bright); border-color: var(--border); }
-
-.branch-map-canvas {
- flex: 1;
- min-height: 120px;
- /* Height comes from the branch count, so a deep tree scrolls rather than the
- diagram shrinking until nobody can read it. Width never scrolls — the map
- is scaled to fit, which is the whole point of drawing it to one clock. */
- overflow-y: auto;
- overflow-x: hidden;
- padding: 4px 12px 8px;
-}
-.branch-map-svg { display: block; }
-
-.bm-tick line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 6; }
-.bm-tick text {
- fill: var(--text-dim);
- font-family: var(--font-ui);
- font-size: 10px;
- font-variant-numeric: tabular-nums;
-}
-
-.bm-lane { cursor: pointer; }
-.bm-hit { fill: transparent; }
-.bm-lane:hover .bm-hit { fill: rgba(212, 169, 78, 0.035); }
-.bm-lane.picked .bm-hit { fill: rgba(212, 169, 78, 0.07); }
-.bm-lane:focus { outline: none; }
-.bm-lane:focus-visible .bm-hit { fill: rgba(212, 169, 78, 0.12); }
-
-.bm-line {
- stroke: var(--border-bright);
- stroke-width: 3;
- stroke-linecap: round;
-}
-.bm-fork {
- fill: none;
- stroke: var(--border-bright);
- stroke-width: 2;
-}
-.bm-fork-dot, .bm-tip { fill: var(--border-bright); }
-
-.bm-lane:hover .bm-line, .bm-lane.picked .bm-line { stroke: var(--accent-dim); }
-.bm-lane:hover .bm-fork, .bm-lane.picked .bm-fork { stroke: var(--accent-dim); }
-.bm-lane:hover .bm-tip, .bm-lane.picked .bm-tip { fill: var(--accent); }
-
-/* The line being read: brighter, and its tip carries the glow so where you
- are standing is findable before a word is read. */
-.bm-lane.here .bm-line { stroke: var(--accent); }
-.bm-lane.here .bm-fork { stroke: var(--accent-dim); }
-.bm-lane.here .bm-tip {
- fill: var(--accent-bright);
- filter: drop-shadow(0 0 6px var(--accent-glow));
-}
-.bm-lane.here .bm-name { fill: var(--accent-bright); }
-
-.bm-name {
- fill: var(--text);
- font-family: var(--font-story);
- font-size: 15px;
-}
-.bm-meta {
- fill: var(--text-dim);
- font-family: var(--font-ui);
- font-size: 10.5px;
- font-variant-numeric: tabular-nums;
-}
-
-.branch-map-hint {
- margin: 0;
- padding: 0 22px 10px;
- color: var(--text-dim);
- font-size: 0.82rem;
- line-height: 1.55;
-}
-
-.branch-map-detail {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding: 14px 22px 18px;
- border-top: 1px solid var(--border);
-}
-.branch-map-detail.here { background: rgba(212, 169, 78, 0.05); }
-.bmd-head { display: flex; align-items: center; gap: 8px; }
-.branch-map-detail .branch-name { font-size: 1.05rem; }
-.branch-map-detail.here .branch-name { color: var(--accent-bright); }
-.branch-map-detail .branch-tools button, .branch-map-detail .branch-confirm button {
- padding: 4px 11px;
- font-size: 0.74rem;
- color: var(--text-dim);
- background: transparent;
- border: 1px solid var(--border);
-}
-.branch-map-detail .branch-tools button:hover:not(:disabled),
-.branch-map-detail .branch-confirm button:hover:not(:disabled) {
- color: var(--accent-bright);
- border-color: var(--border-bright);
-}
-
-@media (prefers-reduced-motion: reduce) {
- .enter, .skeleton-card, .sk, .toast, .thinking i,
- .story .action, .page {
- animation: none !important;
- }
- .card.tome:hover { transform: none; }
-}
-
-/* ---------- Visit analytics (owner dashboard) ---------- */
-
-/* Wider than the reading pages: this one is a grid of small figures, not
- prose, and 960px puts two charts and eight tiles into a column. */
-.an-page { max-width: 1180px; }
-.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; }
-
-/* Four across, not auto-fit: there are eight tiles, and letting them flow
- leaves a single orphan on the second row at most widths. */
-.an-tiles {
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 12px;
- margin-bottom: 18px;
-}
-@media (max-width: 900px) {
- .an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
-}
-.an-tile {
- background: var(--bg-panel);
- border: 1px solid var(--border);
- border-radius: 10px;
- padding: 14px 16px;
-}
-.an-tile-value {
- font-family: var(--font-display);
- font-size: 1.7rem;
- line-height: 1.1;
- color: var(--accent-bright);
-}
-.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
-.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
-
-.an-grid {
- display: grid;
- grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
- gap: 16px;
- margin-bottom: 16px;
-}
-.an-card {
- position: relative;
- background: var(--bg-panel);
- border: 1px solid var(--border);
- border-radius: 12px;
- padding: 16px 18px 14px;
- margin-bottom: 16px;
-}
-.an-grid .an-card { margin-bottom: 0; }
-.an-card-head {
- display: flex;
- align-items: baseline;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 14px;
-}
-.an-card-head h2 {
- margin: 0;
- font-family: var(--font-display);
- font-size: 0.95rem;
- letter-spacing: 0.06em;
- color: var(--accent);
-}
-.an-note { font-size: 0.72rem; color: var(--text-dim); }
-.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
-
-.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
-.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
-.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
-
-/* ----- Day columns ----- */
-
-.an-plot { position: relative; height: 170px; margin-top: 12px; }
-.an-gridline {
- position: absolute;
- left: 0;
- right: 0;
- border-top: 1px dashed var(--border);
- pointer-events: none;
-}
-.an-gridline span {
- position: absolute;
- right: 0;
- top: -0.62em;
- font-size: 0.68rem;
- color: var(--text-dim);
- background: var(--bg-panel);
- padding: 0 4px;
- /* The dashed rule belongs behind the bars; its value does not. Columns are
- positioned, so without this the tallest bar swallows the label. */
- z-index: 3;
-}
-.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
-.an-column {
- position: relative;
- flex: 1 1 0;
- min-width: 0;
- height: 100%;
- display: flex;
- align-items: flex-end;
- border-radius: 4px 4px 0 0;
- outline: none;
-}
-.an-column.hot { background: rgba(212, 169, 78, 0.07); }
-.an-stack {
- width: 100%;
- /* Capped so a 7-day range draws bars and not slabs; the column around it
- stays full width, so the hover target does not shrink with the mark. */
- max-width: 44px;
- margin: 0 auto;
- height: 100%;
- display: flex;
- flex-direction: column;
- justify-content: flex-end;
- /* A 2px gap of surface between stacked segments: the boundary reads as a
- boundary without a border, which would eat a thin bar entirely. */
- gap: 2px;
-}
-.an-bar { width: 100%; transition: filter 0.15s; }
-.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
-.an-column.hot .an-bar { filter: brightness(1.2); }
-.an-tip {
- position: absolute;
- bottom: calc(100% + 8px);
- left: 50%;
- transform: translateX(-50%);
- display: flex;
- flex-direction: column;
- gap: 3px;
- padding: 8px 10px;
- background: var(--bg-panel);
- border: 1px solid var(--border-bright);
- border-radius: 8px;
- box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
- font-size: 0.74rem;
- white-space: nowrap;
- pointer-events: none;
- z-index: 4;
-}
-.an-tip strong { font-weight: 600; color: var(--accent-bright); }
-.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
-
-.an-axis { display: flex; gap: 2px; margin-top: 7px; }
-.an-axis span {
- flex: 1 1 0;
- min-width: 0;
- font-size: 0.68rem;
- color: var(--text-dim);
- text-align: center;
- white-space: nowrap;
-}
-.an-axis span:first-child { text-align: left; }
-.an-axis span:last-child { text-align: right; }
-
-.an-overlay-empty {
- position: absolute;
- inset: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- color: var(--text-dim);
- font-style: italic;
- font-size: 0.85rem;
- pointer-events: none;
-}
-
-/* ----- Funnel ----- */
-
-.an-funnel { display: flex; flex-direction: column; gap: 10px; }
-.an-funnel-row {
- display: grid;
- grid-template-columns: 168px 1fr 96px;
- align-items: center;
- gap: 12px;
-}
-.an-funnel-label { font-size: 0.84rem; }
-.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
-.an-funnel-bar {
- height: 100%;
- min-width: 2px;
- background: var(--chart-1);
- border-radius: 7px;
- transition: width 0.4s ease;
-}
-.an-funnel-value {
- text-align: right;
- font-size: 0.86rem;
- display: flex;
- justify-content: flex-end;
- align-items: baseline;
- gap: 8px;
-}
-.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
-
-/* ----- Ranked lists ----- */
-
-.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
-.an-list li {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 7px 10px;
- border-radius: 6px;
- font-size: 0.84rem;
-}
-/* The bar sits behind the row rather than beside it, so a long label keeps the
- full width and the magnitude is still visible at a glance. */
-.an-list-fill {
- position: absolute;
- top: 0;
- bottom: 0;
- left: 0;
- background: rgba(181, 138, 48, 0.2);
- border-radius: 6px;
-}
-.an-list-label {
- position: relative;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.an-list-value { position: relative; color: var(--text-dim); flex: none; }
-
-/* ----- Tabs, and the access log table ----- */
-
-.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
-.an-tabs button {
- background: none;
- border: none;
- border-bottom: 2px solid transparent;
- border-radius: 0;
- padding: 8px 14px;
- color: var(--text-dim);
- font-family: var(--font-display);
- font-size: 0.82rem;
- letter-spacing: 0.06em;
- cursor: pointer;
-}
-.an-tabs button:hover { color: var(--text); }
-.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
-
-.an-log-head { flex-wrap: wrap; row-gap: 10px; }
-.an-log-search { max-width: 260px; }
-
-/* The table is the one thing here with a minimum width — six columns of real
- data don't compress. It scrolls inside its own box so the page never does. */
-.an-table-scroll { overflow-x: auto; }
-.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
-.an-table th {
- text-align: left;
- font-weight: 500;
- font-size: 0.72rem;
- letter-spacing: 0.08em;
- text-transform: uppercase;
- color: var(--text-dim);
- padding: 0 10px 8px;
- border-bottom: 1px solid var(--border);
- white-space: nowrap;
-}
-.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
-.an-table tr:last-child td { border-bottom: none; }
-.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
-/* A failed attempt is the row you are scanning for; it gets the only colour in
- the table, on a border rather than the text, which stays readable. */
-.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
-.an-table tr.failed td:nth-child(3) { color: var(--danger); }
-.an-cell-dim { color: var(--text-dim); }
-.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
-.an-tag {
- margin-left: 8px;
- padding: 1px 6px;
- border: 1px solid var(--border-bright);
- border-radius: 10px;
- font-size: 0.66rem;
- color: var(--text-dim);
-}
-.an-more { display: flex; justify-content: center; padding-top: 14px; }
-
-.an-footnote {
- color: var(--text-dim);
- font-size: 0.74rem;
- line-height: 1.6;
- margin: 22px 0 0;
- max-width: 70ch;
-}
-
-/* ============================================================
- Mobile / narrow screens (≤ 720px)
- The desktop Play screen is a horizontal row of up to four columns
- (two left rails + story + right side-panel). Phones can't fit that,
- so here we collapse to a single story column and turn the rails and
- side-panel into full-height overlays. Everything above this query is
- the untouched desktop design.
- ============================================================ */
-@media (max-width: 720px) {
-
- /* ---------- Top nav → hamburger dropdown ---------- */
- .topnav { gap: 12px; padding: 0 16px; }
- .nav-hamburger { display: block; }
- .nav-links {
- display: none;
- position: absolute;
- top: 56px;
- left: 0;
- right: 0;
- flex-direction: column;
- align-items: stretch;
- background: var(--bg-panel);
- border-bottom: 1px solid var(--border);
- box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
- padding: 4px 0;
- }
- .nav-links.open { display: flex; }
- .topnav a.navlink {
- padding: 13px 20px;
- border-bottom: none;
- border-left: 3px solid transparent;
- }
- .topnav a.navlink.active {
- border-bottom-color: transparent;
- border-left-color: var(--accent);
- }
- .nav-account {
- margin-left: 0;
- padding: 12px 20px;
- border-top: 1px solid var(--border);
- flex-wrap: wrap;
- }
- .account-email { max-width: 100%; }
-
- /* ---------- Global page padding / typography ---------- */
- .page { padding: 18px 14px; }
- /* 16px inputs stop iOS Safari auto-zooming when a field is focused. */
- input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select {
- font-size: 16px;
- }
-
- /* ---------- Play screen → single column ---------- */
- .play-layout { flex-direction: column; }
- .play-layout .play-page,
- .play-layout.with-panel .play-page { max-width: 100%; }
- .play-page .page-header h1 { font-size: 1.25rem; }
- .panel-toggles { flex-wrap: wrap; }
- .story { font-size: 1.06rem; }
- /* Reserve a left gutter so the fixed World/State edge-tabs never sit on
- top of the story text as it scrolls past them. */
- .story, .play-page .empty { padding-left: 26px; }
- .story .action { padding-right: 0; }
- /* No hover on touch — keep per-action edit/delete tools visible. */
- .story .action-tools { opacity: 1; pointer-events: auto; }
-
- /* ---------- Left rails (World State / Script State) → edge tabs + overlay ---------- */
- /* Closed: a compact tab pinned to the left edge instead of a full-height
- column that steals the story's width. World State sits above Script State.
- (World State only renders for RPG adventures, so it may be absent.) */
- .status-drawer {
- position: fixed;
- left: 0;
- z-index: 40;
- }
- .status-drawer:not(.open) {
- top: 170px;
- height: auto;
- background: var(--bg-panel);
- border: 1px solid var(--border);
- border-left: none;
- border-radius: 0 10px 10px 0;
- box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
- }
- .ws-drawer:not(.open) { top: 170px; }
- .status-drawer:not(.ws-drawer):not(.open) { top: 260px; }
- .status-drawer:not(.open) .status-toggle { height: auto; padding: 14px 4px; }
-
- /* Open: slide-in overlay covering most of the screen. */
- .status-drawer.open {
- top: 56px;
- height: calc(100dvh - 56px);
- width: min(88vw, 320px);
- box-shadow: 0 0 50px rgba(0, 0, 0, 0.65);
- border-right: 1px solid var(--border-bright);
- }
-
- /* ---------- Right side-panel (Plot/Memory/Scripts/Insights) → full overlay ---------- */
- .side-panel {
- position: fixed;
- inset: 56px 0 0 0;
- width: auto;
- height: auto;
- z-index: 45;
- border-left: none;
- padding: 16px;
- }
-
- /* ---------- Home / library surfaces ---------- */
-
- /* Both grids go single-column: the wide variant's 320px minimum would
- otherwise overflow a 360px phone once card padding is counted. */
- .card-grid, .card-grid.wide { grid-template-columns: 1fr; }
-
- .hall { padding: 2px 0 20px; }
- .hall-sub { font-size: 0.95rem; }
- .home-section { margin-top: 26px; }
-
- /* The label eats most of a narrow line, so drop the short leading rule and
- let the trailing one take whatever space is left. */
- .rule { letter-spacing: 0.16em; font-size: 0.76rem; gap: 10px; }
- .rule::before { display: none; }
- .rule-head { gap: 10px; }
-
- /* Ornament scales down rather than crowding the corners on a small card. */
- .card.tome { padding: 14px 15px 12px; }
- .card.tome::before, .card.tome::after { width: 9px; height: 9px; }
- /* Always-on filigree: there's no hover on touch. */
- .card.tome::before, .card.tome::after { opacity: 0.8; }
- .seal { width: 21px; height: 21px; top: 10px; right: 10px; font-size: 0.54rem; }
- .card.tome:has(> .seal) .card-head { padding-right: 22px; }
- .snippet { font-size: 0.94rem; -webkit-line-clamp: 4; }
- .snippet.dropcap::first-letter { font-size: 2rem; padding-right: 6px; }
-
- /* Motes are pure decoration — thin them out where the GPU is weakest and
- the effect is least visible. (Embers.jsx already scales count to the
- viewport; this drops the opacity too.) */
- .embers { opacity: 0.55; }
-
- /* Full-width toasts, clear of the sticky play controls. */
- .toast-host {
- left: 12px;
- right: 12px;
- bottom: 14px;
- transform: none;
- width: auto;
- max-width: none;
- align-items: stretch;
- }
- .toast { font-size: 0.82rem; }
-
- /* Art picker stacks; the emoji grid stays tappable at 34px minimum. */
- .art-picker { gap: 12px; }
- .art-preview { width: 64px; height: 64px; }
- .art-controls { min-width: 0; }
-
- /* The drop cap on the opening beat competes with the edge tabs at this
- width, so shrink it and keep it inside the 26px story inset. */
- .story .action.opening::first-letter {
- font-size: 2.5rem;
- padding: 4px 8px 0 0;
- }
-
- /* ---------- Composers: give the typing area the full width ----------
- Both composers are one flex row on desktop, which on a phone left the
- textarea with a sliver: the Play bar spends ~180px on Do/Say/Story plus
- ~75px on Send, and the chat bar ~90px on Send. Letting the row wrap and
- moving the buttons onto their own line hands that width back to the
- textarea, which is the part you actually type into.
-
- Play: Do/Say/Story and Send share the top row (Send pushed to the right
- edge), and `order` drops the textarea onto its own full-width row beneath
- them. Two rows, and the typing area goes from ~138px to the full width —
- measured at a 390px composer. */
- .input-bar { flex-wrap: wrap; }
- .mode-select button { flex: 1; padding: 7px 10px; }
- .input-bar > button { margin-left: auto; }
- /* min-width:0 lets a flex item shrink below its content width. */
- .input-bar textarea { order: 1; flex: 1 0 100%; margin-top: 8px; min-width: 0; }
-
- /* ---------- AI Chat ---------- */
- /* No hover on touch, so the per-message copy/delete links stay visible. */
- .chat-msg-actions { opacity: 1; }
- .chat-msg { padding: 9px 11px 11px; }
- .chat-composer { padding: 10px 0 14px; flex-wrap: wrap; }
- /* Full-width textarea; Send wraps beneath it and sits at the right edge. */
- .chat-composer textarea { flex: 1 1 100%; max-height: 140px; }
- .chat-composer button { margin-left: auto; }
-
- /* ---------- The branch map ---------- */
- /* A phone has no room for a dialog inset from the edges, and the tree is the
- one thing on this screen that wants every pixel across. */
- .branch-map {
- width: 100vw;
- max-width: none;
- max-height: 100dvh;
- height: 100dvh;
- border: none;
- border-radius: 0;
- }
- .branch-map-header { padding: 14px 16px 10px; flex-wrap: wrap; }
- .branch-map-canvas { padding: 4px 6px 8px; }
- .branch-map-detail { padding: 12px 16px 16px; }
- .branch-map-hint { padding: 0 16px 10px; }
-
- /* ---------- Analytics dashboard ---------- */
- .an-page .page-header { flex-direction: column; align-items: flex-start; gap: 10px; }
- .an-tiles { gap: 8px; }
- .an-tile { padding: 10px 12px; }
- .an-tile-value { font-size: 1.35rem; }
- .an-grid { grid-template-columns: 1fr; }
- .an-plot { height: 140px; }
- .an-tabs button { padding: 8px 10px; font-size: 0.78rem; }
- .an-log-search { max-width: none; width: 100%; }
- /* Three columns don't fit; the label goes above its own bar instead. */
- .an-funnel-row { grid-template-columns: 1fr auto; gap: 4px 10px; }
- .an-funnel-track { grid-column: 1; }
- .an-funnel-label { grid-column: 1 / -1; }
- .an-funnel-value { grid-column: 2; }
-}
+/* The stylesheet, split into sections and imported in order.
+
+ The order is load-bearing and must not change. Several selectors in
+ `tome.css` tie with earlier ones on specificity and win only because they
+ come later, and `responsive.css` overrides the whole desktop design at
+ narrow widths. Both are noted where the rules are.
+
+ Add a new section by adding a file and an `@import` for it. Put the import
+ where the rules belong in the cascade, not at the end by habit.
+*/
+@import './styles/tokens.css'; /* custom properties: colors, fonts, spacing */
+@import './styles/base.css'; /* scrollbars */
+@import './styles/nav.css'; /* the top navigation bar */
+@import './styles/forms.css'; /* inputs, labels, and buttons */
+@import './styles/cards.css'; /* cards, lists, and the story-card editor */
+@import './styles/play.css'; /* the Play screen and the branches panel */
+@import './styles/panels.css'; /* the side panel and the in-play script viewer */
+@import './styles/drawers.css'; /* the status drawer and the world-state drawer */
+@import './styles/schema-editor.css'; /* the stat-schema form and the NPC roster */
+@import './styles/insights.css'; /* insights, scripts, and the memory bank */
+@import './styles/modals.css'; /* the filter bar, tags, and modals */
+@import './styles/auth.css'; /* log in, sign up, and the settings debug log */
+@import './styles/banners.css'; /* the Play screen's persistent banner */
+@import './styles/tome.css'; /* the illuminated-tome surfaces */
+@import './styles/chat.css'; /* the AI Chat scratchpad */
+@import './styles/tree-map.css'; /* the branch map overlay */
+@import './styles/analytics.css'; /* the visitor dashboard */
+@import './styles/responsive.css'; /* narrow screens, 720px and below */
diff --git a/frontend/src/styles/analytics.css b/frontend/src/styles/analytics.css
new file mode 100644
index 0000000..b56cdbf
--- /dev/null
+++ b/frontend/src/styles/analytics.css
@@ -0,0 +1,287 @@
+/* ---------- Visit analytics (owner dashboard) ---------- */
+
+/* Wider than the reading pages: this one is a grid of small figures, not
+ prose, and 960px puts two charts and eight tiles into a column. */
+.an-page { max-width: 1180px; }
+.an-ranges { display: flex; flex-wrap: wrap; gap: 6px; }
+
+/* Four across, not auto-fit: there are eight tiles, and letting them flow
+ leaves a single orphan on the second row at most widths. */
+.an-tiles {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 12px;
+ margin-bottom: 18px;
+}
+@media (max-width: 900px) {
+ .an-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+.an-tile {
+ background: var(--bg-panel);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 14px 16px;
+}
+.an-tile-value {
+ font-family: var(--font-display);
+ font-size: 1.7rem;
+ line-height: 1.1;
+ color: var(--accent-bright);
+}
+.an-tile-label { font-size: 0.82rem; color: var(--text); margin-top: 4px; }
+.an-tile-hint { font-size: 0.72rem; color: var(--text-dim); margin-top: 2px; }
+
+.an-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
+ gap: 16px;
+ margin-bottom: 16px;
+}
+.an-card {
+ position: relative;
+ background: var(--bg-panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 16px 18px 14px;
+ margin-bottom: 16px;
+}
+.an-grid .an-card { margin-bottom: 0; }
+.an-card-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+.an-card-head h2 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ letter-spacing: 0.06em;
+ color: var(--accent);
+}
+.an-note { font-size: 0.72rem; color: var(--text-dim); }
+.an-empty { color: var(--text-dim); font-style: italic; padding: 18px 0; text-align: center; }
+
+.an-legend { display: flex; gap: 12px; font-size: 0.72rem; color: var(--text-dim); }
+.an-legend-item { display: inline-flex; align-items: center; gap: 5px; }
+.an-swatch { width: 9px; height: 9px; border-radius: 2px; display: inline-block; flex: none; }
+
+/* ----- Day columns ----- */
+
+.an-plot { position: relative; height: 170px; margin-top: 12px; }
+.an-gridline {
+ position: absolute;
+ left: 0;
+ right: 0;
+ border-top: 1px dashed var(--border);
+ pointer-events: none;
+}
+.an-gridline span {
+ position: absolute;
+ right: 0;
+ top: -0.62em;
+ font-size: 0.68rem;
+ color: var(--text-dim);
+ background: var(--bg-panel);
+ padding: 0 4px;
+ /* The dashed rule belongs behind the bars; its value does not. Columns are
+ positioned, so without this the tallest bar swallows the label. */
+ z-index: 3;
+}
+.an-columns { display: flex; align-items: flex-end; gap: 2px; height: 100%; }
+.an-column {
+ position: relative;
+ flex: 1 1 0;
+ min-width: 0;
+ height: 100%;
+ display: flex;
+ align-items: flex-end;
+ border-radius: 4px 4px 0 0;
+ outline: none;
+}
+.an-column.hot { background: rgba(212, 169, 78, 0.07); }
+.an-stack {
+ width: 100%;
+ /* Capped so a 7-day range draws bars and not slabs; the column around it
+ stays full width, so the hover target does not shrink with the mark. */
+ max-width: 44px;
+ margin: 0 auto;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ /* A 2px gap of surface between stacked segments: the boundary reads as a
+ boundary without a border, which would eat a thin bar entirely. */
+ gap: 2px;
+}
+.an-bar { width: 100%; transition: filter 0.15s; }
+.an-stack > .an-bar:first-child { border-radius: 4px 4px 0 0; }
+.an-column.hot .an-bar { filter: brightness(1.2); }
+.an-tip {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ padding: 8px 10px;
+ background: var(--bg-panel);
+ border: 1px solid var(--border-bright);
+ border-radius: 8px;
+ box-shadow: 0 10px 26px rgba(0, 0, 0, 0.55);
+ font-size: 0.74rem;
+ white-space: nowrap;
+ pointer-events: none;
+ z-index: 4;
+}
+.an-tip strong { font-weight: 600; color: var(--accent-bright); }
+.an-tip span { display: inline-flex; align-items: center; gap: 6px; color: var(--text-dim); }
+
+.an-axis { display: flex; gap: 2px; margin-top: 7px; }
+.an-axis span {
+ flex: 1 1 0;
+ min-width: 0;
+ font-size: 0.68rem;
+ color: var(--text-dim);
+ text-align: center;
+ white-space: nowrap;
+}
+.an-axis span:first-child { text-align: left; }
+.an-axis span:last-child { text-align: right; }
+
+.an-overlay-empty {
+ position: absolute;
+ inset: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--text-dim);
+ font-style: italic;
+ font-size: 0.85rem;
+ pointer-events: none;
+}
+
+/* ----- Funnel ----- */
+
+.an-funnel { display: flex; flex-direction: column; gap: 10px; }
+.an-funnel-row {
+ display: grid;
+ grid-template-columns: 168px 1fr 96px;
+ align-items: center;
+ gap: 12px;
+}
+.an-funnel-label { font-size: 0.84rem; }
+.an-funnel-track { height: 14px; background: var(--bg-input); border-radius: 7px; overflow: hidden; }
+.an-funnel-bar {
+ height: 100%;
+ min-width: 2px;
+ background: var(--chart-1);
+ border-radius: 7px;
+ transition: width 0.4s ease;
+}
+.an-funnel-value {
+ text-align: right;
+ font-size: 0.86rem;
+ display: flex;
+ justify-content: flex-end;
+ align-items: baseline;
+ gap: 8px;
+}
+.an-funnel-share { font-size: 0.72rem; color: var(--text-dim); }
+
+/* ----- Ranked lists ----- */
+
+.an-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
+.an-list li {
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 7px 10px;
+ border-radius: 6px;
+ font-size: 0.84rem;
+}
+/* The bar sits behind the row rather than beside it, so a long label keeps the
+ full width and the magnitude is still visible at a glance. */
+.an-list-fill {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ background: rgba(181, 138, 48, 0.2);
+ border-radius: 6px;
+}
+.an-list-label {
+ position: relative;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.an-list-value { position: relative; color: var(--text-dim); flex: none; }
+
+/* ----- Tabs, and the access log table ----- */
+
+.an-tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); }
+.an-tabs button {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ padding: 8px 14px;
+ color: var(--text-dim);
+ font-family: var(--font-display);
+ font-size: 0.82rem;
+ letter-spacing: 0.06em;
+ cursor: pointer;
+}
+.an-tabs button:hover { color: var(--text); }
+.an-tabs button.active { color: var(--accent-bright); border-bottom-color: var(--accent); }
+
+.an-log-head { flex-wrap: wrap; row-gap: 10px; }
+.an-log-search { max-width: 260px; }
+
+/* The table is the one thing here with a minimum width — six columns of real
+ data don't compress. It scrolls inside its own box so the page never does. */
+.an-table-scroll { overflow-x: auto; }
+.an-table { width: 100%; min-width: 620px; border-collapse: collapse; font-size: 0.82rem; }
+.an-table th {
+ text-align: left;
+ font-weight: 500;
+ font-size: 0.72rem;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ padding: 0 10px 8px;
+ border-bottom: 1px solid var(--border);
+ white-space: nowrap;
+}
+.an-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
+.an-table tr:last-child td { border-bottom: none; }
+.an-table tbody tr:hover { background: rgba(212, 169, 78, 0.05); }
+/* A failed attempt is the row you are scanning for; it gets the only colour in
+ the table, on a border rather than the text, which stays readable. */
+.an-table tr.failed td:first-child { box-shadow: inset 2px 0 var(--danger); }
+.an-table tr.failed td:nth-child(3) { color: var(--danger); }
+.an-cell-dim { color: var(--text-dim); }
+.an-cell-mono { font-family: ui-monospace, 'Cascadia Code', Consolas, monospace; }
+.an-tag {
+ margin-left: 8px;
+ padding: 1px 6px;
+ border: 1px solid var(--border-bright);
+ border-radius: 10px;
+ font-size: 0.66rem;
+ color: var(--text-dim);
+}
+.an-more { display: flex; justify-content: center; padding-top: 14px; }
+
+.an-footnote {
+ color: var(--text-dim);
+ font-size: 0.74rem;
+ line-height: 1.6;
+ margin: 22px 0 0;
+ max-width: 70ch;
+}
+
diff --git a/frontend/src/styles/auth.css b/frontend/src/styles/auth.css
new file mode 100644
index 0000000..8e7b017
--- /dev/null
+++ b/frontend/src/styles/auth.css
@@ -0,0 +1,145 @@
+/* ---------- Auth (log in / sign up) ---------- */
+
+.auth-modal { width: min(400px, calc(100vw - 40px)); text-align: center; padding-top: 22px; }
+.auth-crest {
+ font-size: 1.1rem;
+ color: var(--accent-dim);
+ letter-spacing: 0.3em;
+ margin-bottom: 6px;
+}
+.auth-modal h2 { font-size: 1.35rem; }
+.auth-modal .auth-hint { margin-bottom: 16px; line-height: 1.45; }
+/* Fields go back to left-aligned — only the header is centred. */
+.auth-modal label.field { text-align: left; }
+
+.auth-tabs {
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ margin-bottom: 18px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+}
+.auth-tab {
+ flex: 1;
+ padding: 7px 0;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--text-dim);
+ font-family: var(--font-display);
+ font-size: 0.78rem;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+}
+.auth-tab:hover { color: var(--text); background: rgba(212, 169, 78, 0.06); }
+.auth-tab.active {
+ background: var(--bg-panel);
+ border-color: var(--accent-dim);
+ color: var(--accent-bright);
+}
+
+.auth-password { display: flex; gap: 8px; }
+.auth-password input { flex: 1; min-width: 0; }
+.auth-reveal {
+ flex: none;
+ padding: 0 12px;
+ font-size: 0.78rem;
+ color: var(--text-dim);
+}
+.auth-reveal:hover { color: var(--accent-bright); }
+
+.auth-help {
+ display: block;
+ margin-top: 6px;
+ font-size: 0.76rem;
+ color: var(--text-dim);
+}
+.auth-error {
+ margin-top: 4px;
+ padding: 9px 12px;
+ border: 1px solid rgba(208, 101, 101, 0.45);
+ border-radius: 8px;
+ background: rgba(208, 101, 101, 0.09);
+ color: var(--danger);
+ font-size: 0.85rem;
+ text-align: left;
+ white-space: pre-wrap;
+}
+
+/* "Update from scenario" — a wider modal, since it lists a diff. */
+.refresh-modal { width: min(620px, calc(100vw - 40px)); }
+.refresh-changes {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 12px 14px;
+ font-size: 0.86rem;
+}
+.refresh-change + .refresh-change {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--border);
+}
+.refresh-old, .refresh-new { margin-top: 3px; font-family: var(--font-mono, monospace); font-size: 0.8rem; }
+.refresh-old { text-decoration: line-through; }
+.refresh-new { color: var(--accent-bright); }
+/* The button sits above the plot fields it would overwrite. */
+.plot-source {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+ padding-bottom: 12px;
+ border-bottom: 1px solid var(--border);
+ font-size: 0.82rem;
+}
+.plot-source .linklike { flex-shrink: 0; }
+
+/* ---------- Debug log (Settings) ---------- */
+
+.debug-entry {
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 8px;
+ overflow: hidden;
+}
+.debug-entry-head {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ padding: 9px 12px;
+ cursor: pointer;
+ font-size: 0.86rem;
+ background: var(--bg-input);
+}
+.debug-entry-head .dim, .debug-entry-body .dim { color: var(--text-dim); font-size: 0.8rem; }
+.debug-status {
+ font-size: 0.68rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ padding: 1px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--border-bright);
+ color: var(--text-dim);
+}
+.debug-status.ok { color: #7dc9a2; border-color: #7dc9a244; }
+.debug-status.error { color: var(--danger); border-color: var(--danger); }
+.debug-status.pending { color: var(--accent); border-color: var(--accent-dim); }
+.debug-entry-body { padding: 10px 12px; font-size: 0.85rem; }
+.debug-entry-body pre {
+ margin: 2px 0 8px;
+ padding: 8px 10px;
+ background: var(--bg-input);
+ border-radius: 6px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: Consolas, monospace;
+ font-size: 0.78rem;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
diff --git a/frontend/src/styles/banners.css b/frontend/src/styles/banners.css
new file mode 100644
index 0000000..07658ea
--- /dev/null
+++ b/frontend/src/styles/banners.css
@@ -0,0 +1,25 @@
+/* ---------- Play screen error/success banner ----------
+ Persistent (no auto-dismiss) and can carry a Retry action, so it stays
+ separate from the transient global toasts further down this file. */
+
+.play-toast {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--bg-panel-glass);
+ backdrop-filter: blur(10px);
+ border: 1px solid var(--danger);
+ color: var(--text);
+ padding: 11px 20px;
+ border-radius: 10px;
+ z-index: 100;
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
+ animation: toast-in 0.25s ease both;
+}
+.play-toast.ok { border-color: var(--accent); box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 20px var(--accent-glow); }
+@keyframes toast-in {
+ from { opacity: 0; transform: translate(-50%, 12px); }
+ to { opacity: 1; transform: translate(-50%, 0); }
+}
+
diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css
new file mode 100644
index 0000000..293e9c6
--- /dev/null
+++ b/frontend/src/styles/base.css
@@ -0,0 +1,11 @@
+/* ---------- Scrollbars ---------- */
+
+::-webkit-scrollbar { width: 10px; height: 10px; }
+::-webkit-scrollbar-track { background: transparent; }
+::-webkit-scrollbar-thumb {
+ background: var(--border-bright);
+ border-radius: 5px;
+ border: 2px solid var(--bg);
+}
+::-webkit-scrollbar-thumb:hover { background: var(--accent-dim); }
+
diff --git a/frontend/src/styles/cards.css b/frontend/src/styles/cards.css
new file mode 100644
index 0000000..01d96a8
--- /dev/null
+++ b/frontend/src/styles/cards.css
@@ -0,0 +1,63 @@
+/* ---------- Cards & lists ---------- */
+
+.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
+.card {
+ position: relative;
+ background: linear-gradient(170deg, #17172400, #17172400 60%, rgba(212, 169, 78, 0.03)), var(--bg-panel);
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ padding: 18px;
+ cursor: pointer;
+ transition: border-color 0.2s, transform 0.2s, box-shadow 0.2s;
+ overflow: hidden;
+}
+.card::before {
+ content: '';
+ position: absolute;
+ inset: 0 0 auto 0;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
+ opacity: 0;
+ transition: opacity 0.25s;
+}
+.card:hover {
+ border-color: var(--border-bright);
+ transform: translateY(-3px);
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45), 0 0 20px rgba(212, 169, 78, 0.06);
+}
+.card:hover::before { opacity: 1; }
+.card h3 {
+ margin: 0 0 6px;
+ font-family: var(--font-display);
+ font-size: 1.02rem;
+ color: var(--accent);
+}
+.card p { margin: 0; color: var(--text-dim); font-size: 0.88rem; line-height: 1.5; }
+.card .meta { margin-top: 12px; font-size: 0.75rem; color: var(--text-dim); }
+
+.page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
+.page-header h1 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1.55rem;
+ color: var(--accent-bright);
+ text-shadow: 0 0 24px var(--accent-glow);
+}
+
+.empty { color: var(--text-dim); text-align: center; padding: 60px 0; font-style: italic; font-family: var(--font-story); font-size: 1.05rem; }
+
+/* ---------- Story card editor ---------- */
+
+.storycard {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent-dim);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 10px;
+ transition: border-color 0.15s;
+}
+.storycard:focus-within { border-color: var(--accent-dim); }
+.storycard .row { display: flex; gap: 10px; margin-bottom: 8px; }
+.storycard .row > * { flex: 1; }
+
diff --git a/frontend/src/styles/chat.css b/frontend/src/styles/chat.css
new file mode 100644
index 0000000..3cd4332
--- /dev/null
+++ b/frontend/src/styles/chat.css
@@ -0,0 +1,97 @@
+/* ---------- AI Chat (power users) ----------
+ A plain chat scratchpad, styled as a transcript rather than as the story
+ page: UI font, role labels, and a sticky composer at the bottom. */
+
+.chat-page {
+ max-width: 820px;
+ display: flex;
+ flex-direction: column;
+ margin-bottom: var(--kb-inset, 0px); /* see .play-page */
+}
+.chat-meta {
+ font-size: 0.78rem;
+ margin: -10px 0 16px;
+ word-break: break-word;
+}
+.chat-options {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 14px 16px 4px;
+ margin-bottom: 18px;
+ background: var(--bg-panel);
+}
+
+.chat-transcript { flex: 1; display: flex; flex-direction: column; gap: 16px; padding-bottom: 20px; }
+
+.chat-msg {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 10px 14px 12px;
+ background: var(--bg-panel);
+}
+.chat-msg.user { border-left: 3px solid var(--player); background: var(--bg-input); }
+.chat-msg.assistant { border-left: 3px solid var(--accent-dim); }
+.chat-msg-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 6px;
+ font-size: 0.72rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+.chat-msg .chat-role { color: var(--accent-dim); font-weight: 600; }
+.chat-msg.user .chat-role { color: var(--player); }
+.chat-model-tag { text-transform: none; letter-spacing: 0; font-size: 0.74rem; }
+.chat-msg-actions {
+ margin-left: auto;
+ display: flex;
+ gap: 12px;
+ opacity: 0;
+ transition: opacity 0.15s;
+}
+.chat-msg:hover .chat-msg-actions, .chat-msg:focus-within .chat-msg-actions { opacity: 1; }
+.chat-msg-body {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+ line-height: 1.6;
+ font-size: 0.95rem;
+}
+.chat-msg .cursor {
+ color: var(--accent-bright);
+ text-shadow: 0 0 8px var(--accent-glow);
+ animation: blink 1s steps(1) infinite;
+}
+
+/* Same collapsed thinking block as the story page, re-scoped for the transcript. */
+.chat-msg .reasoning { margin: 0 0 8px; font-size: 0.82rem; line-height: 1.5; color: var(--text-dim); }
+.chat-msg .reasoning summary { cursor: pointer; user-select: none; opacity: 0.8; }
+.chat-msg .reasoning summary:hover { opacity: 1; }
+.chat-msg .reasoning .reasoning-text {
+ margin-top: 6px;
+ padding: 8px 12px;
+ border-left: 2px solid var(--border);
+ white-space: pre-wrap;
+ opacity: 0.85;
+ max-height: 260px;
+ overflow-y: auto;
+}
+
+.chat-composer {
+ position: sticky;
+ bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */
+ display: flex;
+ gap: 10px;
+ align-items: flex-end;
+ padding: 14px 0;
+ background: linear-gradient(to top, var(--bg) 55%, transparent);
+}
+.chat-composer textarea {
+ flex: 1;
+ min-height: 44px;
+ max-height: 220px;
+ resize: none;
+ overflow-y: auto;
+}
+.chat-composer button { flex: none; height: 40px; }
+
diff --git a/frontend/src/styles/drawers.css b/frontend/src/styles/drawers.css
new file mode 100644
index 0000000..86d387a
--- /dev/null
+++ b/frontend/src/styles/drawers.css
@@ -0,0 +1,266 @@
+/* ---------- Left status drawer (script state) ---------- */
+
+.status-drawer {
+ flex-shrink: 0;
+ position: sticky;
+ top: 56px;
+ height: calc(100dvh - 56px);
+ display: flex;
+ align-items: flex-start;
+ border-right: 1px solid var(--border);
+ background: var(--bg-panel);
+}
+.status-drawer.open { width: 300px; }
+.status-toggle {
+ flex-shrink: 0;
+ height: 100%;
+ width: 30px;
+ padding: 12px 0;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--text-dim);
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+}
+.status-toggle:hover { color: var(--text); background: var(--bg-input); }
+.status-toggle-label {
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+ letter-spacing: 0.12em;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+}
+.status-body {
+ flex: 1;
+ min-width: 0;
+ height: 100%;
+ overflow-y: auto;
+ padding: 20px 16px;
+ animation: panel-in 0.2s ease both;
+}
+.status-vars { list-style: none; margin: 0; padding: 0; }
+.status-vars li {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ padding: 8px 0;
+ border-bottom: 1px solid var(--border);
+}
+.status-key {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.82rem;
+ color: var(--accent-bright);
+ word-break: break-word;
+}
+.status-val {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.82rem;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+/* Recursive script-state value tree */
+.jv-string { color: var(--text); }
+.jv-number { color: var(--player); }
+.jv-bool { color: var(--accent-bright); }
+.jv-null { color: var(--text-dim); font-style: italic; }
+.jv-empty { color: var(--text-dim); }
+.jv-tree { display: inline-block; vertical-align: top; max-width: 100%; }
+.jv-toggle {
+ background: none; border: none; padding: 0; cursor: pointer;
+ font-family: inherit; font-size: 0.72rem; color: var(--text-dim);
+}
+.jv-toggle:hover { color: var(--accent-bright); }
+.jv-children {
+ list-style: none; margin: 2px 0 2px 0; padding-left: 12px;
+ border-left: 1px solid var(--border);
+}
+.jv-children li { padding: 2px 0; word-break: break-word; }
+.jv-key { color: var(--accent); margin-right: 6px; }
+.jv-key::after { content: ':'; color: var(--text-dim); }
+@keyframes panel-in {
+ from { opacity: 0; transform: translateX(16px); }
+ to { opacity: 1; transform: none; }
+}
+
+/* ---------- World State drawer (Phase 12 RPG) ---------- */
+.ws-group { margin-bottom: 18px; }
+.ws-group-title {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-dim);
+ margin: 0 0 8px;
+}
+.ws-stat { padding: 5px 0; }
+.ws-stat-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 0.85rem;
+}
+.ws-stat-name { color: var(--text); text-transform: capitalize; }
+.ws-stat-val {
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+ font-size: 0.8rem;
+ text-align: right;
+}
+.ws-band { color: var(--accent-bright); }
+.ws-bar {
+ margin-top: 4px;
+ height: 5px;
+ border-radius: 3px;
+ background: var(--bg-input);
+ overflow: hidden;
+}
+.ws-bar-fill {
+ height: 100%;
+ border-radius: 3px;
+ background: var(--accent);
+ transition: width 0.35s ease;
+}
+/* Cast — each NPC is a small plate inside the one "Cast" group, so a roster of
+ five doesn't read as five more unrelated sections in the rail. */
+.ws-npc {
+ padding: 7px 9px;
+ margin-bottom: 7px;
+ border: 1px solid var(--border);
+ border-left: 2px solid var(--accent-dim);
+ border-radius: 7px;
+ background: rgba(255, 255, 255, 0.022);
+}
+.ws-npc:last-child { margin-bottom: 0; }
+.ws-npc-head { display: flex; align-items: center; gap: 7px; margin-bottom: 3px; }
+.ws-npc-avatar {
+ flex-shrink: 0;
+ width: 22px; height: 22px;
+ border-radius: 50%;
+ display: grid; place-items: center;
+ font-family: var(--font-display);
+ font-size: 0.6rem;
+ color: #14100a;
+ background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
+}
+.ws-npc-name {
+ font-family: var(--font-display);
+ font-size: 0.85rem;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.ws-none { font-size: 0.78rem; color: var(--text-dim); font-style: italic; }
+.ws-milestones { list-style: none; margin: 0; padding: 0; }
+.ws-milestones li {
+ display: flex;
+ gap: 7px;
+ align-items: flex-start;
+ padding: 4px 0;
+ font-size: 0.85rem;
+ color: var(--text);
+}
+.ws-milestones li.done { color: var(--text-dim); }
+.ws-check { flex-shrink: 0; color: var(--accent-bright); }
+.ws-milestones li.done .ws-check { color: var(--player); }
+.ws-flags { list-style: none; margin: 0; padding: 0; }
+.ws-flags li {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
+ font-size: 0.85rem;
+ color: var(--text);
+}
+.ws-flag-dot {
+ width: 9px; height: 9px; border-radius: 50%;
+ flex-shrink: 0;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+}
+.ws-flag-dot.on { background: var(--accent); border-color: var(--accent); }
+.ws-flag-name { flex: 1; }
+.ws-flag-val { color: var(--text-dim); font-size: 0.8rem; }
+.ws-flag-edit { display: flex; align-items: center; gap: 8px; cursor: pointer; width: 100%; }
+
+.ws-edit-actions { display: flex; gap: 6px; }
+.ws-edit-input {
+ width: 100%;
+ margin-top: 3px;
+ padding: 4px 7px;
+ font-size: 0.82rem;
+ border-radius: 6px;
+}
+
+.schema-editor {
+ width: 100%;
+ font-family: var(--font-mono, monospace);
+ font-size: 0.82rem;
+ line-height: 1.5;
+ tab-size: 2;
+ white-space: pre;
+ overflow-x: auto;
+}
+.schema-error {
+ margin-top: 6px;
+ color: var(--danger, #e5484d);
+ font-size: 0.82rem;
+}
+.schema-json-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 8px;
+}
+.schema-json-actions .schema-error { margin-top: 0; }
+.schema-preview {
+ margin-top: 14px;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: var(--bg-panel);
+}
+.schema-preview-title {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--text-dim);
+ margin-bottom: 10px;
+}
+.schema-group { margin-bottom: 16px; }
+.schema-group:last-child { margin-bottom: 0; }
+.schema-group-title {
+ font-family: Georgia, serif;
+ font-size: 0.98rem;
+ color: var(--accent-bright);
+ margin-bottom: 6px;
+}
+.schema-npc-id {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ margin-left: 6px;
+}
+.schema-stat { padding: 4px 0; border-bottom: 1px solid var(--border); }
+.schema-stat:last-child { border-bottom: none; }
+.schema-stat-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 12px;
+}
+.schema-stat-name { color: var(--text); text-transform: capitalize; }
+.schema-stat-meta {
+ color: var(--text-dim);
+ font-size: 0.8rem;
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+.schema-desc { color: var(--text-dim); font-size: 0.8rem; margin-top: 2px; }
+.schema-bands { color: var(--text-dim); font-size: 0.76rem; margin-top: 2px; font-style: italic; }
+
diff --git a/frontend/src/styles/forms.css b/frontend/src/styles/forms.css
new file mode 100644
index 0000000..afe0f52
--- /dev/null
+++ b/frontend/src/styles/forms.css
@@ -0,0 +1,64 @@
+/* ---------- Forms ---------- */
+
+label.field { display: block; margin-bottom: 14px; }
+label.field .label {
+ display: block;
+ font-size: 0.72rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.12em;
+ color: var(--text-dim);
+ margin-bottom: 5px;
+}
+input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select {
+ width: 100%;
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 9px 12px;
+ font-size: 0.95rem;
+ font-family: inherit;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+textarea { resize: vertical; min-height: 70px; line-height: 1.5; }
+input:focus, textarea:focus, select:focus {
+ outline: none;
+ border-color: var(--accent-dim);
+ box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.12);
+}
+input::placeholder, textarea::placeholder { color: #5c584d; }
+
+button {
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 7px 16px;
+ font-size: 0.88rem;
+ font-weight: 500;
+ cursor: pointer;
+ font-family: inherit;
+ transition: border-color 0.15s, background 0.15s, box-shadow 0.15s, transform 0.1s;
+}
+button:hover {
+ border-color: var(--accent-dim);
+ box-shadow: 0 0 12px rgba(212, 169, 78, 0.1);
+}
+button:active { transform: translateY(1px); }
+button.primary {
+ background: linear-gradient(160deg, var(--accent-bright), var(--accent) 55%, #b78d3e);
+ border-color: var(--accent);
+ color: #191307;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ box-shadow: 0 2px 10px var(--accent-glow);
+}
+button.primary:hover {
+ box-shadow: 0 2px 18px rgba(212, 169, 78, 0.45);
+ filter: brightness(1.08);
+}
+button.danger { color: var(--danger); }
+button.danger:hover { border-color: var(--danger); box-shadow: 0 0 12px rgba(208, 101, 101, 0.15); }
+button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: none; }
+
diff --git a/frontend/src/styles/insights.css b/frontend/src/styles/insights.css
new file mode 100644
index 0000000..2998733
--- /dev/null
+++ b/frontend/src/styles/insights.css
@@ -0,0 +1,203 @@
+/* ---------- Insights ---------- */
+
+.insights-meta { margin-bottom: 16px; font-size: 0.85rem; }
+.insights-mode { color: var(--text-dim); margin-bottom: 8px; }
+.insights-mode button { padding: 2px 8px; font-size: 0.75rem; }
+.token-total { font-size: 1rem; color: var(--text); margin-bottom: 4px; }
+.token-total.over { color: var(--danger); }
+/* Stacked share-of-context bar. Segment colours come from SECTION_COLORS in
+ Play.jsx so the bar, its legend and each section header stay in sync. */
+.token-split {
+ display: flex;
+ height: 14px;
+ background: var(--bg-input);
+ border-radius: 4px;
+ overflow: hidden;
+ margin-bottom: 8px;
+}
+.token-split.over { box-shadow: 0 0 0 1px var(--danger); }
+.token-seg {
+ min-width: 2px; /* a 0.2% section is still worth seeing */
+ border-right: 1px solid var(--bg-panel);
+ transition: opacity 0.15s ease;
+}
+.token-seg:last-child { border-right: none; }
+.token-seg-free { background: transparent; min-width: 0; border-right: none; }
+.token-seg.faded, .token-legend-item.faded { opacity: 0.25; }
+
+.token-legend {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ gap: 1px 12px;
+ margin-bottom: 10px;
+}
+.token-legend-item {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ background: none;
+ border: none;
+ border-radius: 4px;
+ padding: 2px 5px;
+ font-size: 0.78rem;
+ color: var(--text-dim);
+ text-align: left;
+ transition: opacity 0.15s ease, background 0.15s ease, color 0.15s ease;
+}
+.token-legend-item:hover { background: rgba(255, 255, 255, 0.05); color: var(--text); }
+.token-swatch { width: 9px; height: 9px; border-radius: 2px; flex: none; }
+.token-legend-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.token-legend-pct { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
+.token-legend-tok {
+ width: 4.5em;
+ text-align: right;
+ opacity: 0.65;
+ font-variant-numeric: tabular-nums;
+}
+.token-legend-more {
+ justify-self: start;
+ background: none;
+ border: none;
+ padding: 2px 5px;
+ font-size: 0.75rem;
+ color: var(--text-dim);
+}
+.token-legend-more:hover { color: var(--accent); background: none; }
+.insights-history { color: var(--text-dim); margin-bottom: 8px; }
+.insights-cards div { margin-bottom: 3px; }
+.insights-cards .dropped { color: var(--danger); }
+
+.ctx-section { margin-bottom: 12px; border-left: 3px solid var(--border); background: rgba(255, 255, 255, 0.015); border-radius: 0 6px 6px 0; }
+.ctx-section .ctx-header {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--text-dim);
+ padding: 5px 10px 3px;
+}
+.ctx-section pre {
+ margin: 0;
+ padding: 6px 10px 8px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: Consolas, monospace;
+ font-size: 0.8rem;
+ line-height: 1.5;
+ color: var(--text);
+ max-height: 260px;
+ overflow-y: auto;
+}
+/* Per-section border/header colours are applied inline from SECTION_COLORS
+ (Play.jsx) — one palette drives the bar, the legend and these headers. */
+
+/* ---------- Scripts ---------- */
+
+.slot-tabs { display: flex; gap: 0; margin-bottom: 4px; }
+.slot-tabs button {
+ border-radius: 0;
+ border-right-width: 0;
+ color: var(--text-dim);
+}
+.slot-tabs button:first-child { border-radius: 8px 0 0 8px; }
+.slot-tabs button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
+.slot-tabs button.active { color: var(--accent-bright); background: var(--bg-panel); }
+.slot-hint { color: var(--text-dim); font-size: 0.8rem; margin-bottom: 8px; }
+
+.cm-editor { border: 1px solid var(--border); border-radius: 8px; font-size: 0.88rem; }
+.cm-editor.cm-focused {
+ outline: none;
+ border-color: var(--accent-dim);
+ box-shadow: 0 0 0 3px rgba(212, 169, 78, 0.1);
+}
+
+.test-run .row { display: flex; gap: 12px; }
+.test-result {
+ margin-top: 12px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-size: 0.85rem;
+}
+.test-result .label {
+ text-transform: uppercase;
+ font-size: 0.7rem;
+ letter-spacing: 0.08em;
+ color: var(--text-dim);
+ margin-right: 6px;
+}
+.test-result pre {
+ display: inline-block;
+ margin: 2px 0;
+ white-space: pre-wrap;
+ word-break: break-word;
+ font-family: Consolas, monospace;
+ vertical-align: top;
+}
+.test-error { color: var(--danger); white-space: pre-wrap; }
+
+.script-attach {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ padding: 9px 12px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ transition: border-color 0.15s;
+}
+.script-attach:hover { border-color: var(--border-bright); }
+.script-attach .dim { color: var(--text-dim); font-size: 0.82rem; }
+
+/* ---------- Memory bank (Phase 6) ---------- */
+
+.memory-row {
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 9px 12px;
+ margin-bottom: 6px;
+}
+.memory-row.forgotten { opacity: 0.55; }
+.memory-text { font-size: 0.9rem; white-space: pre-wrap; }
+.memory-meta {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 5px;
+ font-size: 0.78rem;
+}
+.memory-meta .dim { color: var(--text-dim); }
+.memory-meta .action-tools { display: inline-flex; gap: 4px; margin-left: auto; }
+.memory-meta .action-tools button {
+ padding: 1px 8px;
+ font-size: 0.72rem;
+ color: var(--text-dim);
+}
+.memory-meta .action-tools button:hover { color: var(--text); }
+.memory-badge {
+ color: var(--accent);
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ padding: 0 8px;
+}
+
+.script-report { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 8px; }
+.script-error { color: var(--danger); font-size: 0.85rem; margin-bottom: 4px; white-space: pre-wrap; }
+.script-logs {
+ background: var(--bg-input);
+ border-radius: 8px;
+ padding: 8px 10px;
+ font-family: Consolas, monospace;
+ font-size: 0.8rem;
+ white-space: pre-wrap;
+ word-break: break-word;
+ margin: 6px 0;
+}
+
diff --git a/frontend/src/styles/modals.css b/frontend/src/styles/modals.css
new file mode 100644
index 0000000..41b9cb7
--- /dev/null
+++ b/frontend/src/styles/modals.css
@@ -0,0 +1,57 @@
+/* ---------- Filter bar & tags ---------- */
+
+.filter-bar { margin-bottom: 18px; }
+.search-input { max-width: 340px; }
+.tag-row { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.tag {
+ padding: 2px 12px;
+ font-size: 0.76rem;
+ border-radius: 999px;
+ color: var(--text-dim);
+}
+.tag.active {
+ color: var(--accent-bright);
+ border-color: var(--accent-dim);
+ box-shadow: inset 0 0 10px rgba(212, 169, 78, 0.1);
+}
+.tag.small {
+ display: inline-block;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ padding: 1px 9px;
+ margin-right: 4px;
+ cursor: default;
+}
+
+/* ---------- Modal ---------- */
+
+.modal-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(5, 5, 9, 0.7);
+ backdrop-filter: blur(4px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 50;
+ animation: page-in 0.2s ease both;
+}
+.modal {
+ width: min(480px, calc(100vw - 40px));
+ max-height: 80vh;
+ overflow-y: auto;
+ background: var(--bg-panel);
+ border: 1px solid var(--border-bright);
+ border-radius: 14px;
+ padding: 26px 28px;
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
+}
+.modal h2 {
+ margin: 0 0 4px;
+ font-family: var(--font-display);
+ font-size: 1.2rem;
+ color: var(--accent-bright);
+}
+.modal .modal-hint { margin: 0 0 18px; color: var(--text-dim); font-size: 0.88rem; }
+.modal .modal-buttons { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px; }
+
diff --git a/frontend/src/styles/nav.css b/frontend/src/styles/nav.css
new file mode 100644
index 0000000..67a271a
--- /dev/null
+++ b/frontend/src/styles/nav.css
@@ -0,0 +1,120 @@
+/* ---------- Top navigation ---------- */
+
+.topnav {
+ display: flex;
+ align-items: center;
+ gap: 26px;
+ padding: 0 28px;
+ height: 56px;
+ background: var(--bg-panel-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid var(--border);
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+.topnav .brand {
+ font-family: var(--font-display);
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: var(--accent);
+ letter-spacing: 0.14em;
+ text-shadow: 0 0 18px var(--accent-glow);
+ margin-right: 6px;
+}
+.topnav a.navlink {
+ color: var(--text-dim);
+ padding: 17px 2px 15px;
+ font-size: 0.9rem;
+ font-weight: 500;
+ letter-spacing: 0.04em;
+ border-bottom: 2px solid transparent;
+ transition: color 0.15s, border-color 0.2s;
+}
+.topnav a.navlink.active {
+ color: var(--accent-bright);
+ border-bottom-color: var(--accent);
+ text-shadow: 0 0 14px var(--accent-glow);
+}
+.topnav a.navlink:hover { color: var(--text); }
+
+/* On desktop the wrapper is transparent — its children (nav links + account)
+ participate directly in the .topnav flex row, exactly as before. The mobile
+ query below turns it into a dropdown. */
+.nav-links { display: contents; }
+.nav-hamburger {
+ display: none;
+ background: none;
+ border: none;
+ color: var(--text);
+ font-size: 1.2rem;
+ padding: 6px 8px;
+ margin-left: auto;
+ line-height: 1;
+}
+
+/* Phase 8: account area (hosted multi-user mode only) */
+.nav-account {
+ margin-left: auto;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+}
+.nav-account button { padding: 5px 12px; font-size: 0.82rem; }
+.guest-nudge {
+ color: var(--text-dim);
+ font-size: 0.78rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.account-email {
+ color: var(--text-dim);
+ font-size: 0.82rem;
+ max-width: 220px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+button.linklike {
+ background: none;
+ border: none;
+ padding: 0;
+ color: var(--accent);
+ font-size: 0.82rem;
+ cursor: pointer;
+}
+button.linklike:hover { text-decoration: underline; }
+@media (max-width: 900px) {
+ .guest-nudge { display: none; }
+}
+
+/* Phase 8: demo-key banner (Settings) */
+.demo-banner {
+ border: 1px solid var(--border-bright);
+ border-left: 3px solid var(--accent);
+ border-radius: 8px;
+ padding: 12px 16px;
+ margin-bottom: 18px;
+ color: var(--text-dim);
+ font-size: 0.88rem;
+ line-height: 1.5;
+}
+.demo-banner strong { color: var(--accent-bright); }
+
+.page {
+ flex: 1;
+ width: 100%;
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 28px 24px;
+ animation: page-in 0.35s ease both;
+}
+@keyframes page-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: none; }
+}
+
diff --git a/frontend/src/styles/panels.css b/frontend/src/styles/panels.css
new file mode 100644
index 0000000..45c3a3f
--- /dev/null
+++ b/frontend/src/styles/panels.css
@@ -0,0 +1,65 @@
+/* ---------- Side panel (Plot / Insights) ---------- */
+
+.play-layout { flex: 1; display: flex; min-height: calc(100dvh - 56px); }
+.play-layout .play-page { max-width: 960px; }
+.play-layout.with-panel .play-page { max-width: none; }
+
+.panel-toggles { display: flex; gap: 8px; }
+.panel-toggles button.active {
+ color: var(--accent-bright);
+ border-color: var(--accent-dim);
+ box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
+}
+
+.side-panel {
+ width: 420px;
+ flex-shrink: 0;
+ border-left: 1px solid var(--border);
+ background: var(--bg-panel);
+ padding: 20px;
+ overflow-y: auto;
+ height: calc(100dvh - 56px);
+ position: sticky;
+ top: 56px;
+ animation: panel-in 0.25s ease both;
+}
+
+/* ---------- In-play script viewer ---------- */
+
+.adv-script {
+ border-bottom: 1px solid var(--border);
+ padding: 10px 0;
+}
+.adv-script-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
+.adv-script-desc { font-size: 0.82rem; margin-top: 4px; }
+.linklike {
+ border: none;
+ background: none;
+ padding: 0;
+ color: var(--accent-bright);
+ font-size: 0.8rem;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.linklike:hover { text-decoration: underline; }
+.adv-script-code { margin-top: 8px; }
+.adv-script-hook {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--text-dim);
+ margin: 8px 0 2px;
+}
+.adv-script-code pre {
+ margin: 0;
+ padding: 10px;
+ background: var(--bg-input);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow-x: auto;
+ font-family: var(--font-mono, monospace);
+ font-size: 0.76rem;
+ line-height: 1.5;
+ white-space: pre;
+}
+
diff --git a/frontend/src/styles/play.css b/frontend/src/styles/play.css
new file mode 100644
index 0000000..117c668
--- /dev/null
+++ b/frontend/src/styles/play.css
@@ -0,0 +1,316 @@
+/* ---------- Play page ---------- */
+
+.play-page {
+ display: flex;
+ flex-direction: column;
+ min-height: calc(100dvh - 56px);
+ /* Room to scroll the last beat clear of a composer lifted by the keyboard
+ (see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink
+ the layout viewport themselves. Margin, not padding — .page already sets
+ padding and a longhand here would drop its bottom gutter. */
+ margin-bottom: var(--kb-inset, 0px);
+}
+
+/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story
+ scrolls — pin the header just below the 56px top nav. */
+.play-page .page-header {
+ position: sticky;
+ top: 56px;
+ z-index: 5;
+ background: var(--bg);
+ margin-bottom: 12px;
+ padding: 12px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.story {
+ flex: 1;
+ font-family: var(--font-story);
+ font-size: 1.14rem;
+ line-height: 1.7;
+ white-space: pre-wrap;
+ padding-bottom: 16px;
+}
+/* The head of a windowed transcript: how much story is still above, and the
+ way back to it. Deliberately quiet — it is a seam in the page, not a
+ feature — and it sits inside .story so it inherits the story face. */
+.story-earlier {
+ text-align: center;
+ margin: 4px 0 22px;
+ font-size: 0.9rem;
+ /* No action-in animation here: this appears above content the reader is
+ already looking at, and a fade would read as the story moving. */
+}
+.story-earlier .dim { color: var(--text-dim); font-style: italic; }
+.story-earlier button {
+ background: none;
+ border: none;
+ border-bottom: 1px solid rgba(159, 199, 209, 0.3);
+ color: var(--text-dim);
+ font: inherit;
+ font-style: italic;
+ cursor: pointer;
+ padding: 2px 4px;
+}
+.story-earlier button:hover {
+ color: var(--accent-bright);
+ border-bottom-color: var(--accent-bright);
+}
+.story .action {
+ position: relative;
+ margin-bottom: 15px;
+ padding-right: 56px;
+ animation: action-in 0.4s ease both;
+}
+@keyframes action-in {
+ from { opacity: 0; transform: translateY(6px); }
+ to { opacity: 1; transform: none; }
+}
+.story .action.player {
+ color: var(--player);
+ border-left: 2px solid rgba(159, 199, 209, 0.35);
+ padding-left: 14px;
+ font-style: italic;
+}
+.story .action.streaming { color: var(--text); }
+.story .cursor {
+ color: var(--accent-bright);
+ text-shadow: 0 0 8px var(--accent-glow);
+ animation: blink 1s steps(1) infinite;
+}
+@keyframes blink { 50% { opacity: 0; } }
+
+/* Reasoning-model thinking, collapsed above the AI's response */
+.story .reasoning {
+ margin: 0 0 8px;
+ font-size: 0.82rem;
+ line-height: 1.5;
+ color: var(--text-dim);
+ font-style: normal;
+}
+.story .reasoning summary {
+ cursor: pointer;
+ user-select: none;
+ color: var(--text-dim);
+ opacity: 0.8;
+}
+.story .reasoning summary:hover { opacity: 1; }
+.story .reasoning .reasoning-text {
+ margin-top: 6px;
+ padding: 8px 12px;
+ border-left: 2px solid var(--border);
+ opacity: 0.85;
+ max-height: 260px;
+ overflow-y: auto;
+}
+
+.story .action-tools {
+ position: absolute;
+ top: 0;
+ right: 0;
+ display: inline-flex;
+ gap: 4px;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.15s;
+}
+.story .action:hover .action-tools { opacity: 1; pointer-events: auto; }
+.story .action-tools button {
+ padding: 1px 8px;
+ font-size: 0.72rem;
+ font-family: var(--font-ui);
+ color: var(--text-dim);
+ background: var(--bg-panel-glass);
+ backdrop-filter: blur(6px);
+}
+.story .action-tools button:hover { color: var(--text); }
+
+/* The takes of one turn: ‹ 2/4 ›. Stays quiet until hovered — it's a footnote
+ on the message, not part of the prose.
+
+ Small and unemphatic on purpose. Stepping through takes reads them and does
+ nothing else, so it should not carry the visual weight of a decision; the
+ decision is made by writing, which happens in the composer below. */
+.take-pager {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ margin-top: 6px;
+ font-family: var(--font-ui);
+ opacity: 0.45;
+ transition: opacity 0.15s;
+}
+.story .action:hover .take-pager,
+.take-pager:focus-within { opacity: 1; }
+.take-pager button {
+ width: 20px;
+ height: 20px;
+ padding: 0;
+ font-size: 0.9rem;
+ line-height: 1;
+ color: var(--text-dim);
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 4px;
+}
+.take-pager button:hover:not(:disabled) {
+ color: var(--text);
+ border-color: var(--border-bright);
+}
+.take-pager button:disabled { opacity: 0.3; cursor: default; }
+.take-count {
+ min-width: 30px;
+ text-align: center;
+ font-size: 0.72rem;
+ font-variant-numeric: tabular-nums;
+ color: var(--text-dim);
+}
+/* Only while a take that isn't the live one is on screen: it says what the
+ next thing typed will do, because that is the click that forks. */
+.take-note {
+ margin-left: 6px;
+ font-size: 0.72rem;
+ font-style: italic;
+ color: var(--accent-dim);
+}
+
+/* ---------- Branches panel (Phase 14, SP7) ---------- */
+
+.panel-empty {
+ padding: 18px 0;
+ color: var(--text-dim);
+ font-size: 0.85rem;
+}
+.branch-panel { display: flex; flex-direction: column; gap: 14px; }
+.branch-intro {
+ margin: 0;
+ color: var(--text-dim);
+ font-size: 0.82rem;
+ line-height: 1.55;
+}
+.branch-list { display: flex; flex-direction: column; gap: 8px; }
+.branch-row {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 9px 11px;
+ border: 1px solid var(--border);
+ border-radius: 5px;
+ background: var(--bg-input);
+}
+.branch-row.here {
+ border-color: var(--accent-dim);
+ background: rgba(212, 169, 78, 0.07);
+}
+.branch-head { display: flex; align-items: center; gap: 7px; }
+.branch-glyph { color: var(--border-bright); font-size: 0.85rem; }
+.branch-row.here .branch-glyph { color: var(--accent); }
+.branch-name {
+ font-family: var(--font-story);
+ font-size: 0.98rem;
+ color: var(--text);
+}
+.branch-row.here .branch-name { color: var(--accent-bright); }
+.branch-here {
+ margin-left: auto;
+ font-size: 0.64rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent-dim);
+}
+.branch-rename {
+ flex: 1;
+ min-width: 0;
+ padding: 3px 7px;
+ font-family: var(--font-story);
+ font-size: 0.95rem;
+}
+.branch-meta {
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+}
+.branch-tools, .branch-confirm { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
+.branch-tools button, .branch-confirm button {
+ padding: 3px 9px;
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ background: transparent;
+ border: 1px solid var(--border);
+}
+.branch-tools button:hover:not(:disabled),
+.branch-confirm button:hover:not(:disabled) {
+ color: var(--accent-bright);
+ border-color: var(--border-bright);
+}
+.branch-tools button.danger:hover:not(:disabled),
+.branch-confirm button.danger:hover:not(:disabled) {
+ color: var(--danger);
+ border-color: var(--danger);
+}
+.branch-tools button:disabled, .branch-confirm button:disabled { opacity: 0.4; cursor: default; }
+.branch-confirm span {
+ font-size: 0.74rem;
+ color: var(--danger);
+ flex-basis: 100%;
+}
+
+.action-edit { margin-bottom: 14px; }
+/* Sized by AutoTextarea to fit the text being edited — an AI beat is usually
+ several paragraphs, and the old fixed 110px turned that into a keyhole.
+ min-height keeps a short player action from collapsing; max-height stops a
+ very long beat from pushing Save/Cancel off the screen (it scrolls instead).
+ resize stays on, so the height is a starting point, not a cage. */
+.action-edit textarea {
+ min-height: 110px;
+ max-height: 65vh;
+ overflow-y: auto;
+ font-family: inherit;
+ font-size: inherit;
+}
+.action-edit .edit-buttons { display: flex; gap: 8px; margin-top: 6px; }
+
+.play-controls {
+ position: sticky;
+ /* Sits on the keyboard's top edge when one is open, on the viewport bottom
+ otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */
+ bottom: var(--kb-inset, 0px);
+ background: linear-gradient(to top, var(--bg) 75%, transparent);
+ padding: 18px 0 16px;
+ border-top: 1px solid var(--border);
+}
+.turn-buttons { display: flex; gap: 8px; margin-bottom: 10px; }
+.turn-buttons button { font-size: 0.78rem; padding: 4px 12px; color: var(--text-dim); }
+.turn-buttons button:hover:not(:disabled) { color: var(--text); }
+
+.input-bar { display: flex; gap: 8px; align-items: flex-end; }
+.input-bar input { flex: 1; }
+/* Auto-growing action box: starts at one line, grows to ~4, then scrolls. */
+.input-bar textarea {
+ flex: 1;
+ resize: none;
+ min-height: 0;
+ max-height: calc(6em + 20px); /* ~4 lines (1.5em each) + padding/border */
+ overflow-y: auto;
+ line-height: 1.5;
+}
+
+.mode-select { display: flex; }
+.mode-select button {
+ border-radius: 0;
+ border-right-width: 0;
+ padding: 7px 14px;
+ color: var(--text-dim);
+ text-transform: capitalize;
+}
+.mode-select button:first-child { border-radius: 8px 0 0 8px; }
+.mode-select button:last-child { border-radius: 0 8px 8px 0; border-right-width: 1px; }
+.mode-select button.active {
+ color: var(--accent-bright);
+ background: var(--bg-panel);
+ border-color: var(--accent-dim);
+ border-right-width: 1px;
+ box-shadow: inset 0 0 12px rgba(212, 169, 78, 0.08);
+}
+.mode-select button.active + button { border-left-width: 0; }
+
diff --git a/frontend/src/styles/responsive.css b/frontend/src/styles/responsive.css
new file mode 100644
index 0000000..61e2a68
--- /dev/null
+++ b/frontend/src/styles/responsive.css
@@ -0,0 +1,219 @@
+/* ============================================================
+ Mobile / narrow screens (≤ 720px)
+ The desktop Play screen is a horizontal row of up to four columns
+ (two left rails + story + right side-panel). Phones can't fit that,
+ so here we collapse to a single story column and turn the rails and
+ side-panel into full-height overlays. Everything above this query is
+ the untouched desktop design.
+ ============================================================ */
+@media (max-width: 720px) {
+
+ /* ---------- Top nav → hamburger dropdown ---------- */
+ .topnav { gap: 12px; padding: 0 16px; }
+ .nav-hamburger { display: block; }
+ .nav-links {
+ display: none;
+ position: absolute;
+ top: 56px;
+ left: 0;
+ right: 0;
+ flex-direction: column;
+ align-items: stretch;
+ background: var(--bg-panel);
+ border-bottom: 1px solid var(--border);
+ box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
+ padding: 4px 0;
+ }
+ .nav-links.open { display: flex; }
+ .topnav a.navlink {
+ padding: 13px 20px;
+ border-bottom: none;
+ border-left: 3px solid transparent;
+ }
+ .topnav a.navlink.active {
+ border-bottom-color: transparent;
+ border-left-color: var(--accent);
+ }
+ .nav-account {
+ margin-left: 0;
+ padding: 12px 20px;
+ border-top: 1px solid var(--border);
+ flex-wrap: wrap;
+ }
+ .account-email { max-width: 100%; }
+
+ /* ---------- Global page padding / typography ---------- */
+ .page { padding: 18px 14px; }
+ /* 16px inputs stop iOS Safari auto-zooming when a field is focused. */
+ input[type="text"], input[type="email"], input[type="password"], input[type="number"], textarea, select {
+ font-size: 16px;
+ }
+
+ /* ---------- Play screen → single column ---------- */
+ .play-layout { flex-direction: column; }
+ .play-layout .play-page,
+ .play-layout.with-panel .play-page { max-width: 100%; }
+ .play-page .page-header h1 { font-size: 1.25rem; }
+ .panel-toggles { flex-wrap: wrap; }
+ .story { font-size: 1.06rem; }
+ /* Reserve a left gutter so the fixed World/State edge-tabs never sit on
+ top of the story text as it scrolls past them. */
+ .story, .play-page .empty { padding-left: 26px; }
+ .story .action { padding-right: 0; }
+ /* No hover on touch — keep per-action edit/delete tools visible. */
+ .story .action-tools { opacity: 1; pointer-events: auto; }
+
+ /* ---------- Left rails (World State / Script State) → edge tabs + overlay ---------- */
+ /* Closed: a compact tab pinned to the left edge instead of a full-height
+ column that steals the story's width. World State sits above Script State.
+ (World State only renders for RPG adventures, so it may be absent.) */
+ .status-drawer {
+ position: fixed;
+ left: 0;
+ z-index: 40;
+ }
+ .status-drawer:not(.open) {
+ top: 170px;
+ height: auto;
+ background: var(--bg-panel);
+ border: 1px solid var(--border);
+ border-left: none;
+ border-radius: 0 10px 10px 0;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
+ }
+ .ws-drawer:not(.open) { top: 170px; }
+ .status-drawer:not(.ws-drawer):not(.open) { top: 260px; }
+ .status-drawer:not(.open) .status-toggle { height: auto; padding: 14px 4px; }
+
+ /* Open: slide-in overlay covering most of the screen. */
+ .status-drawer.open {
+ top: 56px;
+ height: calc(100dvh - 56px);
+ width: min(88vw, 320px);
+ box-shadow: 0 0 50px rgba(0, 0, 0, 0.65);
+ border-right: 1px solid var(--border-bright);
+ }
+
+ /* ---------- Right side-panel (Plot/Memory/Scripts/Insights) → full overlay ---------- */
+ .side-panel {
+ position: fixed;
+ inset: 56px 0 0 0;
+ width: auto;
+ height: auto;
+ z-index: 45;
+ border-left: none;
+ padding: 16px;
+ }
+
+ /* ---------- Home / library surfaces ---------- */
+
+ /* Both grids go single-column: the wide variant's 320px minimum would
+ otherwise overflow a 360px phone once card padding is counted. */
+ .card-grid, .card-grid.wide { grid-template-columns: 1fr; }
+
+ .hall { padding: 2px 0 20px; }
+ .hall-sub { font-size: 0.95rem; }
+ .home-section { margin-top: 26px; }
+
+ /* The label eats most of a narrow line, so drop the short leading rule and
+ let the trailing one take whatever space is left. */
+ .rule { letter-spacing: 0.16em; font-size: 0.76rem; gap: 10px; }
+ .rule::before { display: none; }
+ .rule-head { gap: 10px; }
+
+ /* Ornament scales down rather than crowding the corners on a small card. */
+ .card.tome { padding: 14px 15px 12px; }
+ .card.tome::before, .card.tome::after { width: 9px; height: 9px; }
+ /* Always-on filigree: there's no hover on touch. */
+ .card.tome::before, .card.tome::after { opacity: 0.8; }
+ .seal { width: 21px; height: 21px; top: 10px; right: 10px; font-size: 0.54rem; }
+ .card.tome:has(> .seal) .card-head { padding-right: 22px; }
+ .snippet { font-size: 0.94rem; -webkit-line-clamp: 4; }
+ .snippet.dropcap::first-letter { font-size: 2rem; padding-right: 6px; }
+
+ /* Motes are pure decoration — thin them out where the GPU is weakest and
+ the effect is least visible. (Embers.jsx already scales count to the
+ viewport; this drops the opacity too.) */
+ .embers { opacity: 0.55; }
+
+ /* Full-width toasts, clear of the sticky play controls. */
+ .toast-host {
+ left: 12px;
+ right: 12px;
+ bottom: 14px;
+ transform: none;
+ width: auto;
+ max-width: none;
+ align-items: stretch;
+ }
+ .toast { font-size: 0.82rem; }
+
+ /* Art picker stacks; the emoji grid stays tappable at 34px minimum. */
+ .art-picker { gap: 12px; }
+ .art-preview { width: 64px; height: 64px; }
+ .art-controls { min-width: 0; }
+
+ /* The drop cap on the opening beat competes with the edge tabs at this
+ width, so shrink it and keep it inside the 26px story inset. */
+ .story .action.opening::first-letter {
+ font-size: 2.5rem;
+ padding: 4px 8px 0 0;
+ }
+
+ /* ---------- Composers: give the typing area the full width ----------
+ Both composers are one flex row on desktop, which on a phone left the
+ textarea with a sliver: the Play bar spends ~180px on Do/Say/Story plus
+ ~75px on Send, and the chat bar ~90px on Send. Letting the row wrap and
+ moving the buttons onto their own line hands that width back to the
+ textarea, which is the part you actually type into.
+
+ Play: Do/Say/Story and Send share the top row (Send pushed to the right
+ edge), and `order` drops the textarea onto its own full-width row beneath
+ them. Two rows, and the typing area goes from ~138px to the full width —
+ measured at a 390px composer. */
+ .input-bar { flex-wrap: wrap; }
+ .mode-select button { flex: 1; padding: 7px 10px; }
+ .input-bar > button { margin-left: auto; }
+ /* min-width:0 lets a flex item shrink below its content width. */
+ .input-bar textarea { order: 1; flex: 1 0 100%; margin-top: 8px; min-width: 0; }
+
+ /* ---------- AI Chat ---------- */
+ /* No hover on touch, so the per-message copy/delete links stay visible. */
+ .chat-msg-actions { opacity: 1; }
+ .chat-msg { padding: 9px 11px 11px; }
+ .chat-composer { padding: 10px 0 14px; flex-wrap: wrap; }
+ /* Full-width textarea; Send wraps beneath it and sits at the right edge. */
+ .chat-composer textarea { flex: 1 1 100%; max-height: 140px; }
+ .chat-composer button { margin-left: auto; }
+
+ /* ---------- The branch map ---------- */
+ /* A phone has no room for a dialog inset from the edges, and the tree is the
+ one thing on this screen that wants every pixel across. */
+ .branch-map {
+ width: 100vw;
+ max-width: none;
+ max-height: 100dvh;
+ height: 100dvh;
+ border: none;
+ border-radius: 0;
+ }
+ .branch-map-header { padding: 14px 16px 10px; flex-wrap: wrap; }
+ .branch-map-canvas { padding: 4px 6px 8px; }
+ .branch-map-detail { padding: 12px 16px 16px; }
+ .branch-map-hint { padding: 0 16px 10px; }
+
+ /* ---------- Analytics dashboard ---------- */
+ .an-page .page-header { flex-direction: column; align-items: flex-start; gap: 10px; }
+ .an-tiles { gap: 8px; }
+ .an-tile { padding: 10px 12px; }
+ .an-tile-value { font-size: 1.35rem; }
+ .an-grid { grid-template-columns: 1fr; }
+ .an-plot { height: 140px; }
+ .an-tabs button { padding: 8px 10px; font-size: 0.78rem; }
+ .an-log-search { max-width: none; width: 100%; }
+ /* Three columns don't fit; the label goes above its own bar instead. */
+ .an-funnel-row { grid-template-columns: 1fr auto; gap: 4px 10px; }
+ .an-funnel-track { grid-column: 1; }
+ .an-funnel-label { grid-column: 1 / -1; }
+ .an-funnel-value { grid-column: 2; }
+}
diff --git a/frontend/src/styles/schema-editor.css b/frontend/src/styles/schema-editor.css
new file mode 100644
index 0000000..dacc093
--- /dev/null
+++ b/frontend/src/styles/schema-editor.css
@@ -0,0 +1,243 @@
+/* ---- Form-based schema editor ----
+ One shape for everything: a section is a panel, every entry inside it is a
+ `.se-row` tile, and every control wears a `.se-field` caption. Adding a new
+ kind of entry means reusing those three, not inventing a fourth box. */
+.se-section {
+ margin-bottom: 16px;
+ padding: 13px 15px;
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-panel);
+}
+.se-section-nested {
+ margin: 10px 0 0;
+ padding: 11px 12px;
+ background: rgba(0, 0, 0, 0.22);
+ border-radius: 8px;
+}
+.se-section-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ margin-bottom: 8px;
+}
+.se-section-title {
+ font-size: 0.74rem;
+ text-transform: uppercase;
+ letter-spacing: 0.1em;
+ color: var(--accent);
+}
+.se-hint {
+ margin: -2px 0 10px;
+ font-size: 0.78rem;
+ line-height: 1.45;
+ color: var(--text-dim);
+}
+.se-empty { color: var(--text-dim); font-size: 0.82rem; padding: 4px 0; }
+.se-add, .se-add-sm {
+ font-size: 0.78rem;
+ padding: 3px 10px;
+ white-space: nowrap;
+}
+.se-add-sm { margin-top: 6px; }
+.se-remove {
+ background: transparent;
+ border: none;
+ color: var(--text-dim);
+ cursor: pointer;
+ padding: 2px 6px;
+ font-size: 0.9rem;
+ line-height: 1;
+ flex-shrink: 0;
+}
+.se-remove:hover { color: var(--danger, #e5484d); }
+
+/* Every control in the editor, one look. The global field rule keys off
+ `input[type="…"]`, so the plain ``s here (keys, descriptions) never
+ matched it and fell back to browser defaults — that mismatch is most of why
+ this panel used to read as a pile of unrelated boxes. Everything below is
+ qualified with `.se` so it outranks that global rule (0,1,1) too. */
+/* `:where()` so the exclusion costs no specificity — a plain
+ `:not([type="checkbox"])` would rank this above the `.se .se-*` overrides
+ below and swallow every one of them. */
+.se input:not(:where([type="checkbox"])), .se textarea, .se select {
+ width: 100%;
+ background: var(--bg-input);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 5px 8px;
+ font-size: 0.84rem;
+ font-family: inherit;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+/* One entry — a stat, a flag, a milestone. */
+.se-row {
+ padding: 10px 12px;
+ margin-bottom: 8px;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.018);
+ transition: border-color 0.15s;
+}
+.se-row:last-child { margin-bottom: 0; }
+.se-row:focus-within { border-color: var(--border-bright); }
+/* Wraps because a phone-width row can't fit key + toggle + ✕ side by side, and
+ an input's default `min-width: auto` would push the ✕ off-screen instead. */
+.se-row-top { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 8px; }
+.se .se-key {
+ font-family: var(--font-mono, monospace);
+ font-size: 0.82rem;
+ min-width: 0;
+ max-width: 220px;
+}
+/* Only in the row context — `.se-field` is a *column* flex, where a basis of
+ 140px would set the input's height instead of its width. */
+.se .se-row-top .se-key { flex: 1 1 140px; }
+.se-field { display: flex; flex-direction: column; gap: 3px; }
+.se-field > span {
+ font-size: 0.66rem;
+ text-transform: uppercase;
+ letter-spacing: 0.09em;
+ color: var(--text-dim);
+}
+.se-fields {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 12px;
+ align-items: flex-end;
+ margin-bottom: 8px;
+}
+.se .se-num input, .se .se-num select { width: 72px; padding: 4px 6px; }
+.se .se-kind select { width: 132px; }
+.se .se-initial-text input { width: 220px; max-width: 100%; }
+.se-check {
+ display: flex; align-items: center; gap: 5px;
+ font-size: 0.8rem; color: var(--text-dim);
+ white-space: nowrap;
+}
+.se-check input { width: auto; }
+/* The global textarea rule also forces min-height:70px, which a 2-row inline
+ field doesn't want. */
+.se .se-area { min-height: 0; line-height: 1.45; resize: vertical; }
+.se-sub-head { font-size: 0.7rem; color: var(--text-dim); margin: 4px 0 4px; }
+.se-band { display: flex; gap: 6px; align-items: center; margin-bottom: 4px; }
+/* Qualified so it outweighs the global input[type="number"] rule (0,1,1) —
+ as a bare class it lost, and the band bounds rendered full-width. */
+.se .se-band-n { width: 64px; padding: 4px 6px; }
+.se .se-band-l { flex: 1 1 60px; width: auto; min-width: 0; }
+.se-bands { margin-top: 8px; }
+
+/* ---- NPC roster (its own page section) ---- */
+.se-npc-grid {
+ display: grid;
+ /* min() so a 320px phone gets one column instead of a 340px overflow */
+ grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
+ gap: 14px;
+ align-items: start;
+}
+.se-npc {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--bg-panel);
+ overflow: hidden;
+}
+.se-npc:focus-within { border-color: var(--border-bright); }
+.se-npc-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 12px;
+ border-bottom: 1px solid var(--border);
+ background: linear-gradient(180deg, rgba(212, 169, 78, 0.07), transparent);
+}
+.se-avatar {
+ flex-shrink: 0;
+ width: 34px; height: 34px;
+ border-radius: 50%;
+ display: grid; place-items: center;
+ font-family: var(--font-display);
+ font-size: 0.8rem;
+ letter-spacing: 0.02em;
+ color: #14100a;
+ background: linear-gradient(150deg, var(--accent-bright), var(--accent-dim));
+}
+.se-npc-ident { flex: 1; min-width: 0; }
+.se .se-npc-name {
+ padding: 2px 6px;
+ font-family: var(--font-display);
+ font-size: 0.95rem;
+ background: transparent;
+ border-color: transparent;
+}
+.se .se-npc-name:focus { background: var(--bg-input); border-color: var(--border-bright); }
+.se-npc-addr {
+ display: block;
+ margin: 1px 0 0 6px;
+ font-family: var(--font-mono, monospace);
+ font-size: 0.7rem;
+ color: var(--text-dim);
+}
+.se-npc-count {
+ flex-shrink: 0;
+ font-size: 0.7rem;
+ color: var(--text-dim);
+ white-space: nowrap;
+}
+.se-npc-body { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
+.se-npc-idfield .se-key { max-width: 100%; }
+.ws-report { list-style: none; margin: 4px 0 0; padding: 0; font-size: 0.82rem; }
+.ws-report li { padding: 2px 0; color: var(--text); }
+
+/* Inline per-turn change chips under an AI message */
+.turn-changes {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-top: 8px;
+}
+.chg {
+ font-size: 0.74rem;
+ padding: 2px 8px;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--bg-input);
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+ /* A chip must never be wider than the column it sits in: an NPC stat like
+ "bandit leader aggression +10" overruns a phone, and the chip row is
+ inside the story text, which has nothing to scroll. The label may break
+ mid-word as a last resort; the value is glued to it by its own nowrap so
+ the sign and number never end up on separate lines. */
+ max-width: 100%;
+ overflow-wrap: anywhere;
+}
+.chg-val { white-space: nowrap; }
+.chg-up { color: var(--player, #4caf82); border-color: color-mix(in srgb, var(--player, #4caf82) 40%, var(--border)); }
+.chg-down { color: var(--danger, #e5484d); border-color: color-mix(in srgb, var(--danger, #e5484d) 40%, var(--border)); }
+.chg-flag { color: var(--accent-bright); }
+.chg-ms { color: var(--accent-bright); border-color: color-mix(in srgb, var(--accent-bright) 40%, var(--border)); }
+/* A refused change, and a clamp that left the value where it started. The
+ dashed border separates it from the changes that took effect. It is not
+ styled as an error, because the rules refusing a change is them working. */
+.chg-refused {
+ border-style: dashed;
+ color: var(--text-dim);
+ border-color: color-mix(in srgb, var(--danger) 30%, var(--border));
+}
+/* A clamp that reduced a change but still moved the value. */
+.chg-limited { opacity: 0.75; font-style: italic; }
+.ws-report code { color: var(--accent-bright); }
+.ws-delta { color: var(--player); font-variant-numeric: tabular-nums; }
+.ws-flag { color: var(--text-dim); font-style: italic; }
+.ws-rejected { color: var(--text-dim); }
+.side-panel-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
+.side-panel-header h2 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1.05rem;
+ color: var(--accent);
+}
+
diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css
new file mode 100644
index 0000000..17e380a
--- /dev/null
+++ b/frontend/src/styles/tokens.css
@@ -0,0 +1,50 @@
+:root {
+ --bg: #0a0a0f;
+ --bg-panel: #131320;
+ --bg-panel-glass: rgba(19, 19, 32, 0.82);
+ --bg-input: #1a1a2a;
+ --border: #2b2b3d;
+ --border-bright: #3d3d55;
+ --text: #e2ddd0;
+ --text-dim: #918c7d;
+ --accent: #d4a94e;
+ --accent-bright: #e8c476;
+ --accent-dim: #96773a;
+ --accent-glow: rgba(212, 169, 78, 0.25);
+ --danger: #d06565;
+ --player: #9fc7d1;
+ /* Chart hues (analytics dashboard). Deeper and more saturated than --accent
+ and --player, which are tuned for text and borders and turn muddy once
+ they are a 10px bar: these are checked against --bg-panel for lightness,
+ chroma, contrast and colour-blind separation as a set. */
+ --chart-1: #b58a30;
+ --chart-2: #3d8ac4;
+ --chart-3: #a8608f;
+ --font-display: 'Cinzel', Georgia, serif;
+ --font-story: 'Crimson Pro', Georgia, 'Times New Roman', serif;
+ --font-ui: 'Inter', 'Segoe UI', system-ui, sans-serif;
+ font-family: var(--font-ui);
+ color-scheme: dark;
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background:
+ radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.06), transparent 60%),
+ radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.08), transparent 55%),
+ var(--bg);
+ background-attachment: fixed;
+ color: var(--text);
+ min-height: 100vh;
+}
+
+#root { min-height: 100vh; display: flex; flex-direction: column; }
+
+h1, h2, h3 { font-weight: 600; letter-spacing: 0.03em; }
+a { color: var(--accent); text-decoration: none; transition: color 0.15s; }
+a:hover { color: var(--accent-bright); }
+
+::selection { background: var(--accent-dim); color: #0f0d08; }
+
diff --git a/frontend/src/styles/tome.css b/frontend/src/styles/tome.css
new file mode 100644
index 0000000..c592564
--- /dev/null
+++ b/frontend/src/styles/tome.css
@@ -0,0 +1,475 @@
+/* ============================================================
+ Illuminated tome — ambient layer, ornament, art plates, cards
+ Everything below styles the home/library surfaces. It comes after
+ the base .card rules on purpose: several selectors here tie with
+ earlier ones on specificity and rely on source order to win.
+ ============================================================ */
+
+/* ---------- Ambient drifting motes ---------- */
+
+/* Behind every page: fixed, unclickable, and out of the stacking flow. The
+ canvas is sized in JS (see Embers.jsx); z-index 0 with the app's own
+ surfaces at 1+ keeps it under content without needing a negative index
+ (which would drop it behind the body background). */
+.embers {
+ position: fixed;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ opacity: 0.85;
+}
+/* .topnav already carries z-index 10 — deliberately not touched here, so it
+ keeps sitting above page content as well as above the canvas. */
+.page { position: relative; z-index: 1; }
+
+/* A third gradient pool warming the middle of the ground. `background` is a
+ shorthand, so background-attachment has to be restated or it resets to
+ scroll and the gradients start sliding with the page. */
+body {
+ background:
+ radial-gradient(1200px 700px at 15% -10%, rgba(212, 169, 78, 0.07), transparent 60%),
+ radial-gradient(1000px 600px at 90% 110%, rgba(88, 76, 140, 0.1), transparent 55%),
+ radial-gradient(760px 520px at 50% 42%, rgba(150, 119, 58, 0.05), transparent 62%),
+ var(--bg);
+ background-attachment: fixed;
+}
+
+/* ---------- Home banner ---------- */
+
+.hall {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 6px 0 26px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid var(--border);
+ position: relative;
+}
+/* Gold hairline that fades out toward both ends, sat on the border above. */
+.hall::after {
+ content: '';
+ position: absolute;
+ bottom: -1px;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--accent-dim) 22%, var(--accent-dim) 78%, transparent);
+ opacity: 0.55;
+}
+.hall-eyebrow {
+ margin: 0;
+ font-size: 0.68rem;
+ letter-spacing: 0.24em;
+ text-transform: uppercase;
+ color: var(--accent-dim);
+ font-weight: 600;
+}
+.hall-title {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: clamp(1.7rem, 4.6vw, 2.3rem);
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ color: var(--accent-bright);
+ text-shadow: 0 0 30px var(--accent-glow);
+ text-wrap: balance;
+}
+.hall-sub {
+ margin: 0;
+ color: var(--text-dim);
+ font-family: var(--font-story);
+ font-size: 1.02rem;
+}
+
+/* ---------- Ornamental section rules ---------- */
+
+.home-section { margin-top: 34px; }
+
+.rule-head {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-bottom: 16px;
+}
+.rule {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 0.82rem;
+ font-weight: 600;
+ letter-spacing: 0.26em;
+ text-transform: uppercase;
+ color: var(--accent-bright);
+ white-space: nowrap;
+}
+/* Rules grow to fill whatever the label leaves — the label stays optically
+ centred in its own space without needing a fixed width. */
+.rule::before, .rule::after {
+ content: '';
+ height: 1px;
+ flex: 1;
+ background: linear-gradient(90deg, transparent, var(--accent-dim) 45%, var(--accent-dim) 55%, transparent);
+}
+.rule::before { flex: 0 0 26px; }
+.rule > span { flex: none; }
+
+button.linklike.ornate {
+ font-family: var(--font-display);
+ font-size: 0.74rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent-dim);
+ white-space: nowrap;
+ transition: color 0.15s;
+}
+button.linklike.ornate:hover { color: var(--accent-bright); text-decoration: none; }
+
+/* ---------- Art plates ---------- */
+
+.art {
+ width: 38px;
+ height: 38px;
+ flex: none;
+ border-radius: 3px;
+ border: 1px solid var(--accent-dim);
+ box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
+ overflow: hidden;
+ display: grid;
+ place-items: center;
+ line-height: 1;
+}
+.art-lg { width: 46px; height: 46px; }
+.art-photo img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.art-icon { font-size: 1.15rem; }
+.art-lg.art-icon { font-size: 1.4rem; }
+.art-mono {
+ font-family: var(--font-display);
+ font-size: 0.82rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ color: rgba(232, 196, 118, 0.92);
+ text-shadow: 0 1px 4px rgba(0, 0, 0, 0.65);
+}
+.art-lg.art-mono { font-size: 0.95rem; }
+
+/* ---------- Tome cards ---------- */
+
+.card-grid.wide { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); }
+
+.card.tome {
+ border-radius: 3px;
+ padding: 16px 17px 14px;
+ background:
+ linear-gradient(168deg, rgba(212, 169, 78, 0.05), transparent 55%),
+ var(--bg-panel);
+ /* Inner dark gutter + a gold keyline: the double-rule border of a plate in
+ an old book, done with shadows so it costs no extra element. */
+ box-shadow:
+ inset 0 0 0 1px rgba(10, 10, 15, 0.9),
+ inset 0 0 0 2px rgba(150, 119, 58, 0.22);
+}
+
+/* Filigree corner brackets. These override the base .card::before gradient
+ bar (equal specificity, later in the file wins). */
+.card.tome::before,
+.card.tome::after {
+ content: '';
+ position: absolute;
+ width: 12px;
+ height: 12px;
+ border: 1px solid var(--accent-dim);
+ opacity: 0.5;
+ transition: opacity 0.2s;
+ background: none;
+ inset: auto;
+}
+.card.tome::before {
+ top: 6px;
+ left: 6px;
+ border-right: 0;
+ border-bottom: 0;
+}
+.card.tome::after {
+ bottom: 6px;
+ right: 6px;
+ border-left: 0;
+ border-top: 0;
+}
+.card.tome:hover::before,
+.card.tome:hover::after { opacity: 1; }
+.card.tome:hover {
+ border-color: var(--accent-dim);
+ box-shadow:
+ inset 0 0 0 1px rgba(10, 10, 15, 0.9),
+ inset 0 0 0 2px rgba(212, 169, 78, 0.4),
+ 0 12px 30px rgba(0, 0, 0, 0.5),
+ 0 0 26px rgba(212, 169, 78, 0.08);
+}
+
+.card-head { display: flex; gap: 12px; align-items: center; margin-bottom: 10px; }
+/* Keep headings clear of the wax seal, which is absolutely positioned over
+ the card's top-right corner. Only cards that actually carry one pay the
+ indent, so scenario cards keep their full width. */
+.card.tome:has(> .seal) .card-head { padding-right: 26px; }
+.card-headings { min-width: 0; }
+.card.tome h3 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1rem;
+ font-weight: 600;
+ letter-spacing: 0.045em;
+ color: var(--accent);
+ overflow-wrap: anywhere;
+}
+.card-from {
+ margin: 3px 0 0;
+ font-size: 0.75rem;
+ color: var(--text-dim);
+ overflow-wrap: anywhere;
+}
+
+/* Wax seal marking a story in progress. */
+.seal {
+ position: absolute;
+ top: 12px;
+ right: 12px;
+ width: 24px;
+ height: 24px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 34% 30%, #b8553f, #7d2f24 72%);
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6), inset 0 -1px 3px rgba(0, 0, 0, 0.45);
+ display: grid;
+ place-items: center;
+ font-size: 0.6rem;
+ color: #f0d5b0;
+ z-index: 1;
+}
+
+.snippet {
+ margin: 0;
+ font-family: var(--font-story);
+ font-size: 0.98rem;
+ line-height: 1.5;
+ color: #b3ac9c;
+ /* Clamp so a long beat can't stretch one card taller than its row. */
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.snippet.muted { color: var(--text-dim); font-style: italic; }
+.snippet.dropcap::first-letter {
+ float: left;
+ font-family: var(--font-display);
+ font-size: 2.3rem;
+ line-height: 0.85;
+ font-weight: 700;
+ color: var(--accent);
+ padding: 3px 7px 0 0;
+}
+
+.card-foot {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 13px;
+ padding-top: 11px;
+ border-top: 1px solid var(--border);
+ font-size: 0.74rem;
+ color: var(--text-dim);
+ flex-wrap: wrap;
+}
+.card-foot .turns { font-variant-numeric: tabular-nums; }
+.card-foot .dot { opacity: 0.5; }
+.card-foot .resume {
+ margin-left: auto;
+ color: var(--accent-dim);
+ font-family: var(--font-ui);
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ font-size: 0.68rem;
+ font-weight: 600;
+ transition: color 0.15s;
+}
+.card.tome:hover .resume { color: var(--accent-bright); }
+.tag-cluster { display: flex; gap: 5px; flex-wrap: wrap; min-width: 0; }
+.card-actions { margin-left: auto; display: inline-flex; gap: 5px; }
+
+button.tiny { padding: 2px 9px; font-size: 0.72rem; }
+button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: auto; }
+
+/* Staggered card entrance. The per-card delay is set inline. */
+.enter { animation: card-in 0.4s ease both; }
+@keyframes card-in {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: none; }
+}
+
+/* ---------- Loading skeletons ---------- */
+
+.skeleton-card {
+ cursor: default;
+ pointer-events: none;
+ animation: card-in 0.3s ease both;
+}
+.skeleton-card::before, .skeleton-card::after { content: none; }
+.skeleton-card:hover { transform: none; border-color: var(--border); box-shadow: none; }
+.sk {
+ border-radius: 3px;
+ background: linear-gradient(90deg, #1c1c2b 25%, #262639 50%, #1c1c2b 75%);
+ background-size: 220% 100%;
+ animation: shimmer 1.4s ease-in-out infinite;
+}
+.sk-plate { width: 38px; height: 38px; margin-bottom: 12px; }
+.sk-title { height: 13px; width: 58%; margin-bottom: 12px; }
+.sk-line { height: 9px; margin-bottom: 8px; }
+@keyframes shimmer {
+ from { background-position: 120% 0; }
+ to { background-position: -120% 0; }
+}
+
+/* ---------- Global toasts ---------- */
+
+.toast-host {
+ position: fixed;
+ bottom: 22px;
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 120;
+ display: flex;
+ flex-direction: column-reverse;
+ gap: 8px;
+ align-items: center;
+ width: max-content;
+ max-width: min(92vw, 520px);
+ pointer-events: none;
+}
+.toast {
+ pointer-events: auto;
+ display: flex;
+ align-items: flex-start;
+ gap: 9px;
+ text-align: left;
+ font-family: var(--font-ui);
+ font-size: 0.86rem;
+ line-height: 1.45;
+ padding: 10px 16px;
+ border-radius: 8px;
+ background: var(--bg-panel-glass);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border: 1px solid var(--border-bright);
+ border-left: 3px solid var(--accent);
+ color: var(--text);
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
+ animation: rise 0.25s ease both;
+}
+.toast:hover { border-color: var(--accent-dim); }
+.toast-error { border-left-color: var(--danger); }
+.toast-mark { color: var(--accent); flex: none; line-height: 1.5; }
+.toast-error .toast-mark { color: var(--danger); }
+@keyframes rise {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: none; }
+}
+
+/* ---------- Story text polish ---------- */
+
+/* Drop cap opening the story's first narrated beat. */
+.story .action.opening::first-letter {
+ float: left;
+ font-family: var(--font-display);
+ font-size: 3.1rem;
+ line-height: 0.82;
+ font-weight: 700;
+ color: var(--accent);
+ padding: 5px 10px 0 2px;
+ text-shadow: 0 0 22px var(--accent-glow);
+}
+
+/* Ornamental separator between turns — a diamond flanked by fading rules.
+ Marks the beat change without the heaviness of a full
. */
+.scene-break {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 4px 0 16px;
+ color: var(--accent-dim);
+ font-size: 0.6rem;
+ user-select: none;
+}
+.scene-break::before, .scene-break::after {
+ content: '';
+ height: 1px;
+ flex: 1;
+ background: linear-gradient(90deg, transparent, rgba(150, 119, 58, 0.4), transparent);
+}
+
+/* Thinking indicator — three motes pulsing in sequence. */
+.thinking {
+ display: inline-flex;
+ align-items: center;
+ gap: 10px;
+ font-family: var(--font-ui);
+ font-size: 0.78rem;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+ color: var(--accent-dim);
+}
+.thinking i {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 8px var(--accent-glow);
+ animation: mote 1.25s ease-in-out infinite;
+}
+.thinking i:nth-child(2) { animation-delay: 0.18s; }
+.thinking i:nth-child(3) { animation-delay: 0.36s; }
+@keyframes mote {
+ 0%, 100% { opacity: 0.25; transform: scale(0.8); }
+ 45% { opacity: 1; transform: scale(1.15); }
+}
+
+/* ---------- Scenario art picker (editor) ---------- */
+
+.art-picker { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
+.art-preview {
+ width: 76px;
+ height: 76px;
+ flex: none;
+ border-radius: 3px;
+ border: 1px solid var(--accent-dim);
+ box-shadow: inset 0 0 0 1px rgba(10, 10, 15, 0.7);
+ overflow: hidden;
+ display: grid;
+ place-items: center;
+}
+.art-preview img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.art-preview .glyph { font-size: 2rem; line-height: 1; }
+.art-preview .mono {
+ font-family: var(--font-display);
+ font-size: 1.4rem;
+ font-weight: 700;
+ color: rgba(232, 196, 118, 0.92);
+}
+.art-controls { flex: 1; min-width: 220px; display: flex; flex-direction: column; gap: 9px; }
+.art-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
+.icon-row { display: flex; gap: 5px; flex-wrap: wrap; }
+.icon-row button {
+ padding: 3px 8px;
+ font-size: 1rem;
+ line-height: 1.2;
+ min-width: 34px;
+}
+.icon-row button.active { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
+.art-hint { font-size: 0.76rem; color: var(--text-dim); line-height: 1.45; margin: 0; }
+
diff --git a/frontend/src/styles/tree-map.css b/frontend/src/styles/tree-map.css
new file mode 100644
index 0000000..6e7533e
--- /dev/null
+++ b/frontend/src/styles/tree-map.css
@@ -0,0 +1,159 @@
+/* ---------- The story tree, drawn (branch map overlay) ---------- */
+
+.branch-map-open {
+ align-self: flex-start;
+ padding: 5px 12px;
+ font-size: 0.74rem;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+ background: transparent;
+ border: 1px solid var(--border-bright);
+}
+.branch-map-open:hover { color: var(--accent-bright); border-color: var(--accent-dim); }
+
+/* The overlay is deliberately wider and shorter than .modal: a tree is read
+ across, and the number of branches decides the height. */
+.branch-map-overlay { z-index: 60; }
+.branch-map {
+ display: flex;
+ flex-direction: column;
+ width: min(1080px, calc(100vw - 40px));
+ max-height: min(760px, calc(100vh - 60px));
+ background: var(--bg-panel);
+ border: 1px solid var(--border-bright);
+ border-radius: 14px;
+ box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(212, 169, 78, 0.06);
+}
+.branch-map-header {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+ padding: 18px 22px 12px;
+ border-bottom: 1px solid var(--border);
+}
+.branch-map-header h2 {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 1.15rem;
+ color: var(--accent-bright);
+}
+.branch-map-scale {
+ font-size: 0.72rem;
+ color: var(--text-dim);
+ font-variant-numeric: tabular-nums;
+}
+.branch-map-close {
+ margin-left: auto;
+ align-self: center;
+ padding: 2px 8px;
+ color: var(--text-dim);
+ background: transparent;
+ border: 1px solid transparent;
+}
+.branch-map-close:hover { color: var(--accent-bright); border-color: var(--border); }
+
+.branch-map-canvas {
+ flex: 1;
+ min-height: 120px;
+ /* Height comes from the branch count, so a deep tree scrolls rather than the
+ diagram shrinking until nobody can read it. Width never scrolls — the map
+ is scaled to fit, which is the whole point of drawing it to one clock. */
+ overflow-y: auto;
+ overflow-x: hidden;
+ padding: 4px 12px 8px;
+}
+.branch-map-svg { display: block; }
+
+.bm-tick line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 6; }
+.bm-tick text {
+ fill: var(--text-dim);
+ font-family: var(--font-ui);
+ font-size: 10px;
+ font-variant-numeric: tabular-nums;
+}
+
+.bm-lane { cursor: pointer; }
+.bm-hit { fill: transparent; }
+.bm-lane:hover .bm-hit { fill: rgba(212, 169, 78, 0.035); }
+.bm-lane.picked .bm-hit { fill: rgba(212, 169, 78, 0.07); }
+.bm-lane:focus { outline: none; }
+.bm-lane:focus-visible .bm-hit { fill: rgba(212, 169, 78, 0.12); }
+
+.bm-line {
+ stroke: var(--border-bright);
+ stroke-width: 3;
+ stroke-linecap: round;
+}
+.bm-fork {
+ fill: none;
+ stroke: var(--border-bright);
+ stroke-width: 2;
+}
+.bm-fork-dot, .bm-tip { fill: var(--border-bright); }
+
+.bm-lane:hover .bm-line, .bm-lane.picked .bm-line { stroke: var(--accent-dim); }
+.bm-lane:hover .bm-fork, .bm-lane.picked .bm-fork { stroke: var(--accent-dim); }
+.bm-lane:hover .bm-tip, .bm-lane.picked .bm-tip { fill: var(--accent); }
+
+/* The line being read: brighter, and its tip carries the glow so where you
+ are standing is findable before a word is read. */
+.bm-lane.here .bm-line { stroke: var(--accent); }
+.bm-lane.here .bm-fork { stroke: var(--accent-dim); }
+.bm-lane.here .bm-tip {
+ fill: var(--accent-bright);
+ filter: drop-shadow(0 0 6px var(--accent-glow));
+}
+.bm-lane.here .bm-name { fill: var(--accent-bright); }
+
+.bm-name {
+ fill: var(--text);
+ font-family: var(--font-story);
+ font-size: 15px;
+}
+.bm-meta {
+ fill: var(--text-dim);
+ font-family: var(--font-ui);
+ font-size: 10.5px;
+ font-variant-numeric: tabular-nums;
+}
+
+.branch-map-hint {
+ margin: 0;
+ padding: 0 22px 10px;
+ color: var(--text-dim);
+ font-size: 0.82rem;
+ line-height: 1.55;
+}
+
+.branch-map-detail {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ padding: 14px 22px 18px;
+ border-top: 1px solid var(--border);
+}
+.branch-map-detail.here { background: rgba(212, 169, 78, 0.05); }
+.bmd-head { display: flex; align-items: center; gap: 8px; }
+.branch-map-detail .branch-name { font-size: 1.05rem; }
+.branch-map-detail.here .branch-name { color: var(--accent-bright); }
+.branch-map-detail .branch-tools button, .branch-map-detail .branch-confirm button {
+ padding: 4px 11px;
+ font-size: 0.74rem;
+ color: var(--text-dim);
+ background: transparent;
+ border: 1px solid var(--border);
+}
+.branch-map-detail .branch-tools button:hover:not(:disabled),
+.branch-map-detail .branch-confirm button:hover:not(:disabled) {
+ color: var(--accent-bright);
+ border-color: var(--border-bright);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .enter, .skeleton-card, .sk, .toast, .thinking i,
+ .story .action, .page {
+ animation: none !important;
+ }
+ .card.tome:hover { transform: none; }
+}
+