diff --git a/frontend/src/SchemaEditor.jsx b/frontend/src/SchemaEditor.jsx
index 0f99773..ea42d45 100644
--- a/frontend/src/SchemaEditor.jsx
+++ b/frontend/src/SchemaEditor.jsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
+import { npcInitials } from './components'
// Rebuild an object with one key renamed, preserving order. Returns null on a
// no-op or a collision (so the caller keeps the old object).
@@ -27,13 +28,24 @@ function KeyInput({ value, onCommit, placeholder }) {
)
}
+// Every control in this editor wears the same tiny caption — that consistency
+// is what keeps the mixed row types (stats, flags, milestones, NPCs) reading
+// as one form rather than five.
+function Field({ label, className = '', children }) {
+ return (
+
+ )
+}
+
function Num({ label, value, onChange }) {
return (
-
+
)
}
@@ -65,18 +77,17 @@ function StatEditor({ statKey, def, onRenameKey, onChange, onRemove }) {
onChange(next)
}
return (
-
)
}
-// Form-based editor for a stat_schema. `schema` is the parsed object (or null);
-// `onChange(nextSchema)` fires on every edit. Empty sections are dropped.
+// ---- NPCs ----------------------------------------------------------------
+// NPCs live inside the same stat_schema (`schema.npcs`) but get their own
+// top-level section in the editor: each one is a small character sheet, which
+// doesn't fit the flat stat rows the other sections use.
+
+// Next `npcs` object with a fresh entry appended, and the id it used.
+export function addNpc(npcs) {
+ let i = 1, key = 'npc'
+ while (npcs?.[key]) key = `npc${i++}`
+ return { ...(npcs || {}), [key]: { name: '', keys: '', desc: '', stats: {} } }
+}
+
+function NpcCard({ npcId, npc, onRenameKey, onChange, onRemove }) {
+ const set = (field, val) => onChange({ ...npc, [field]: val })
+ const statCount = Object.keys(npc.stats || {}).length
+ return (
+
+ )
+}
+
+// The NPC roster. Takes/returns just the `npcs` slice of a stat_schema.
+export function NpcEditor({ npcs, onChange }) {
+ const entries = Object.entries(npcs || {})
+ const rename = (o, n) => { const x = withRenamedKey(npcs, o, n); if (x) onChange(x) }
+ const setNpc = (k, val) => onChange({ ...npcs, [k]: val })
+ const remove = (k) => { const x = { ...npcs }; delete x[k]; onChange(x) }
+ if (entries.length === 0) {
+ return (
+
+ No NPCs yet. Each one gets its own stats (trust, health, ferocity — whatever suits them),
+ and a story card is created automatically so they show up in context when mentioned.
+
+ )
+}
+
+// Form-based editor for a stat_schema, minus the NPCs (see `NpcEditor`, which
+// the scenario editor renders as its own page section). `schema` is the parsed
+// object (or null); `onChange(nextSchema)` fires on every edit. Empty sections
+// are dropped.
export default function SchemaEditor({ schema, onChange }) {
const s = schema && typeof schema === 'object' ? schema : {}
const setSection = (key, val) => {
@@ -265,9 +321,10 @@ export default function SchemaEditor({ schema, onChange }) {
}
return (
- setSection('world', v)} />
- setSection('player', v)} />
- setSection('npcs', v)} />
+ setSection('world', v)}
+ hint="Things about the situation, not any one character — time of day, the camp’s alert level." />
+ setSection('player', v)}
+ hint="The player’s own numbers — hp, gold, reputation — plus free-text ones like an outfit." />
setSection('flags', v)} />
setSection('milestones', v)} />
diff --git a/frontend/src/components.jsx b/frontend/src/components.jsx
index c42b769..a16f378 100644
--- a/frontend/src/components.jsx
+++ b/frontend/src/components.jsx
@@ -68,6 +68,18 @@ function monogram(title) {
return letters.join('').toUpperCase()
}
+/** Initials for an NPC's avatar disc — "Bandit Leader" → BL, "gwen" → GW.
+ *
+ * Deliberately not `monogram`: that one drops short words, which is right for
+ * scenario titles and wrong for names, and it has no id to fall back on.
+ */
+export function npcInitials(name, id) {
+ const src = String(name || id || '?').trim() || '?'
+ const words = src.split(/[\s_-]+/).filter(Boolean)
+ const letters = words.length > 1 ? words[0][0] + words[1][0] : src.slice(0, 2)
+ return letters.toUpperCase()
+}
+
/** A scenario's plate: uploaded picture, else emoji, else generated art.
*
* `large` is for the Continue cards, where the plate carries more weight. The
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 63bb0cd..d726e60 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -641,6 +641,37 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
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;
@@ -749,35 +780,47 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
.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 ---- */
+/* ---- 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: 12px 14px;
+ padding: 13px 15px;
border: 1px solid var(--border);
- border-radius: 8px;
+ border-radius: 10px;
background: var(--bg-panel);
}
.se-section-nested {
- margin: 8px 0 0;
- background: transparent;
- border-style: dashed;
+ 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 {
@@ -788,53 +831,145 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
padding: 2px 6px;
font-size: 0.9rem;
line-height: 1;
+ flex-shrink: 0;
}
.se-remove:hover { color: var(--danger, #e5484d); }
-.se-stat, .se-npc {
- padding: 8px 0;
- border-bottom: 1px solid var(--border);
+
+/* 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;
}
-.se-stat:last-child, .se-npc:last-child { border-bottom: none; }
-.se-npc {
+
+/* One entry — a stat, a flag, a milestone. */
+.se-row {
padding: 10px 12px;
margin-bottom: 8px;
border: 1px solid var(--border);
- border-radius: 6px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.018);
+ transition: border-color 0.15s;
}
-.se-row-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
-.se-key {
+.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.85rem;
- padding: 4px 8px;
+ 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: 6px;
+ margin-bottom: 8px;
}
-.se-num { display: flex; flex-direction: column; gap: 2px; }
-.se-num span { font-size: 0.7rem; color: var(--text-dim); }
-.se-num input, .se-num select { width: 72px; padding: 3px 6px; font-size: 0.82rem; border-radius: 6px; }
-.se-kind select { width: 118px; }
-.se-initial-text input { width: 220px; }
+.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; }
-.se-text {
- width: 100%;
- padding: 4px 8px;
- font-size: 0.82rem;
- margin-bottom: 4px;
-}
+/* 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; }
-.se-band-n { width: 64px; padding: 3px 6px; font-size: 0.82rem; }
-.se-band-l { flex: 1; padding: 3px 6px; font-size: 0.82rem; }
+/* 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); }
diff --git a/frontend/src/pages/Play.jsx b/frontend/src/pages/Play.jsx
index 9d7d2ae..4a97608 100644
--- a/frontend/src/pages/Play.jsx
+++ b/frontend/src/pages/Play.jsx
@@ -2,7 +2,7 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'rea
import { createPortal } from 'react-dom'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
-import { AutoTextarea, Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
+import { AutoTextarea, Field, StoryCardRow, downloadJSON, npcInitials, pickJSONFile, useToast } from '../components'
const MODES = ['do', 'say', 'story']
const PLAYER_TYPES = ['do', 'say', 'story']
@@ -703,11 +703,16 @@ function StatRow({ name, def, value, editing, onChange }) {
// `values`/`draft` are both plain {statName: value} maps — `draft` (edit mode)
// is a slice of the drawer's flat path->value map for this group's prefix.
-function StatGroup({ title, defs, values, desc, editing, draft, onEdit }) {
+// `nested` = the caller already drew a heading (an NPC card), so this drops the
+// group's own spacing and says "nothing here" rather than vanishing and leaving
+// that heading dangling over empty space.
+function StatGroup({ title, defs, values, desc, editing, draft, onEdit, nested }) {
const entries = Object.entries(defs || {}).filter(([, d]) => d && typeof d === 'object')
- if (entries.length === 0) return null
+ if (entries.length === 0) {
+ return nested ?
diff --git a/frontend/src/pages/ScenarioEditor.jsx b/frontend/src/pages/ScenarioEditor.jsx
index e8d5894..3c22871 100644
--- a/frontend/src/pages/ScenarioEditor.jsx
+++ b/frontend/src/pages/ScenarioEditor.jsx
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
import ArtPicker from '../ArtPicker'
-import SchemaEditor from '../SchemaEditor'
+import SchemaEditor, { NpcEditor, addNpc } from '../SchemaEditor'
export default function ScenarioEditor() {
const { id } = useParams()
@@ -106,6 +106,15 @@ export default function ScenarioEditor() {
debounceSave('stat_schema', () => saveSchema(cleaned))
}
+ // NPCs live in the same stat_schema, but are edited in their own page
+ // section — so this is just the `npcs` slice of the same save path.
+ const setNpcs = (npcs) => {
+ const next = { ...(parsedSchema || {}) }
+ if (npcs && Object.keys(npcs).length) next.npcs = npcs
+ else delete next.npcs
+ applySchema(next)
+ }
+
const addCard = async () => {
const card = await api.createStoryCard({ scenario_id: Number(id) })
setScenario({ ...scenario, story_cards: [...scenario.story_cards, card] })
@@ -243,10 +252,10 @@ export default function ScenarioEditor() {
- Optional. Define stats (with bands and rules), NPCs, flags, and milestones, and
- the AI will track them each turn — HP, mana, an NPC’s trust, quest objectives.
- Each NPC has its own name, trigger keys, description, and its own stats; a story
- card is created for it automatically. Leave blank for a plain narrative scenario.
+ Optional. Define stats (with bands and rules), flags, and milestones, and the AI
+ will track them each turn — HP, mana, a raised alarm, quest objectives. NPCs are
+ part of this same schema but have their own section below. Leave blank for a plain
+ narrative scenario.
+ Characters the AI tracks by name. Each has its own trigger words, description and
+ stats — a dragon’s ferocity, a ranger’s trust — addressed as
+ npc.<id>.<stat>. Adding one here also creates a story card
+ for it when an adventure starts.
+
+ {schemaView === 'form' ? (
+
+ ) : (
+
+ NPCs are part of the schema JSON above — switch to Editor to edit
+ them as cards.
+