From 2421274098e83e202157178cb0b80292f7f21300 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 15:55:53 -0500 Subject: [PATCH] feat(system-builder): the SETUP page (4a3b) components/SetupPage.tsx, from the approved mockup builder-setup (2026-10-06): five questions on one page, every change through the builder's edit (autosaved). 1 name (RENAME with its refusal kept in place; the builder's copy updated so the next save keeps it), description, author, license; 2 the health model cards, each model's settings (list editors within the server's limits, new entries given their own ids, numbers clamped), a live starter-sheet preview and what the token shows, and a warning when characters already play it; 3 advancement; 4 dice (toggles, a typed die, eight at most); 5 distance. Switching models and back keeps what was set. BuilderScreen holds the definition the pages edit (edit = show + autosave.edit) and shows its name in the top bar. Tests now reach what 4a2b couldn't: unsaved status, SAVE, the browser tab asking, EXIT saving first, EXIT.EXE's STAY / TRY AGAIN / EXIT WITHOUT SAVING, PUBLISH stopping on a failed save. --- CHANGELOG.md | 8 + frontend/src/components/BuilderScreen.tsx | 18 +- frontend/src/components/SetupPage.tsx | 333 ++++++++++++++++++ .../__tests__/BuilderScreen.test.tsx | 113 +++++- .../components/__tests__/SetupPage.test.tsx | 219 ++++++++++++ 5 files changed, 685 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/SetupPage.tsx create mode 100644 frontend/src/components/__tests__/SetupPage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e0217b..86f95e74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **The builder's SETUP page.** The page a new system opens on asks five things, each saved as + you answer: what the system is (its name, refused if another system has it, plus description, + author and license); how characters get hurt, with each health model's own settings and a live + preview of the HEALTH section every character sheet will start with; how characters grow; the + dice it rolls most; and how distance is measured. When characters already play the system, it + says that changing their health changes their sheets once you publish, keeping their numbers. + Leaving the builder now warns you only if your latest changes couldn't be saved. + - **What the builder's SETUP page will ask and show.** The core questions read from a system and written back: its description, author and license; how characters get hurt, with each health model's usual starting shape; how they grow; the dice it rolls most (up to eight); and how diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 80dc8332..062bac5a 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -6,6 +6,7 @@ import { } from '../sheets/builderSession'; import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary'; import { MySystemsPage } from './MySystemsPage'; +import { SetupPage } from './SetupPage'; // The system builder (4a2b): it takes over the whole window, with no map. A sidebar down the left // holds the system's name, its pages, SAVE and PUBLISH, and EXIT TO MAP; the open page fills the @@ -137,6 +138,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o const [railOpen, setRailOpen] = useState(false); const [, redraw] = useReducer((n: number) => n + 1, 0); const autosave = useRef | null>(null); + /** The draft as the pages edit it; the server's copy catches up as autosave saves it. */ + const [definition, setDefinition] = useState(null); const load = useCallback(async () => { if (!systemId) return null; @@ -158,11 +161,17 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o onChange: redraw, initialProblems: sys.problems, }); - redraw(); + setDefinition(sys.draft); }); return () => { live = false; autosave.current?.dispose(); }; }, [api, load, systemId]); + /** A page changed the system: shown at once, saved once editing pauses (builderSession). */ + const edit = (next: Definition) => { + setDefinition(next); + autosave.current?.edit(next); + }; + // Closing or reloading the tab asks first while anything is unsaved. useEffect(() => { const ask = (e: BeforeUnloadEvent) => { @@ -174,7 +183,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o const state = autosave.current?.state ?? null; const problems = autosave.current?.problems ?? system?.problems ?? []; - const name = system?.draft?.name || system?.name || ''; + const name = definition?.name || system?.name || ''; /** Save, then leave; if saving fails, say so and let the GM choose. */ const leave = async (then: () => void) => { @@ -304,7 +313,10 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o {problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} ))} - {system && page !== 'problems' && page !== 'systems' && ( + {systemId && system && definition && page === 'setup' && ( + setStatus({ text, bad })} /> + )} + {system && !['problems', 'systems', 'setup'].includes(page) && (

    {current.label}

    {current.what} This page arrives in a coming update.

    diff --git a/frontend/src/components/SetupPage.tsx b/frontend/src/components/SetupPage.tsx new file mode 100644 index 00000000..ab781baf --- /dev/null +++ b/frontend/src/components/SetupPage.tsx @@ -0,0 +1,333 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { systemsApi, Definition } from '../sheets/systemsApi'; +import { renamedMessage, SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary'; +import { + HEALTH_MODELS, ADVANCEMENT, DISTANCE, COMMON_DICE, MAX_DICE, LIST_LIMITS, defaultHealth, idFor, setupOf, withCover, withCore, + toggleAdvancement, toggleDie, addDie, healthLayout, tokenNote, type Health, type HealthModel, type Named, type HarmLevel, +} from '../sheets/setup'; + +// The builder's SETUP page (4a3b): the core rules questions a new system opens on, five on one page, +// each saved as it is answered (approved mockup docs/mockups/builder-setup.html, 2026-10-06). The +// name can be changed here as well as from MY SYSTEMS, refused the same way when another system +// has it. A wizard instead of one page only if users find this too much (the user, same day). +// What it reads and writes is sheets/setup.ts; every change goes through the builder's `edit`, +// which autosaves it. + +interface Props { + api: ReturnType; + systemId: string; + definition: Definition; + /** A changed definition: shown at once, saved once editing pauses. */ + edit: (next: Definition) => void; + /** A line for the builder's status bar. */ + say: (text: string, bad?: boolean) => void; +} + +const small: React.CSSProperties = { fontSize: 10, letterSpacing: 2, opacity: 0.75 }; +const why: React.CSSProperties = { fontSize: 12, lineHeight: 1.45, opacity: 0.85 }; +const btn: React.CSSProperties = { fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' }; +const input: React.CSSProperties = { + background: 'var(--black)', color: 'var(--green)', border: '1px solid var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '6px 8px', minWidth: 0, +}; +const pick = (on: boolean): React.CSSProperties => ({ + border: `1px solid ${on ? 'var(--green)' : 'var(--dark-green)'}`, background: on ? 'color-mix(in srgb, var(--green) 14%, transparent)' : 'none', + color: 'var(--green)', fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '6px 10px', cursor: 'pointer', textAlign: 'left', +}); + +function Question({ n, title, ask, children }: { n: number; title: string; ask: string; children: React.ReactNode }) { + return ( +
    + {n} +

    {title}

    +

    {ask}

    +
    {children}
    +
    + ); +} + +/** An editable list of named entries (tracks, damage types, harm levels, locations). */ +function NamedList({ what, items, limits, onChange, make, extra }: { + what: string; items: T[]; limits: readonly [number, number]; onChange: (items: T[]) => void; + make: (label: string, id: string) => T; extra?: (item: T, set: (item: T) => void) => React.ReactNode; +}) { + const [min, max] = limits; + const set = (i: number, item: T) => onChange(items.map((x, j) => (j === i ? item : x))); + return ( +
    + {items.map((item, i) => ( +
    + set(i, { ...item, label: e.target.value })} /> + {extra?.(item, (next) => set(i, next))} + {min !== max && ( + + )} +
    + ))} + {min !== max && ( +
    + + {min} to {max}. +
    + )} +
    + ); +} + +/** The chosen model's own settings. */ +function HealthSettings({ health, onChange }: { health: Health; onChange: (h: Health) => void }) { + const labelField = (value: string | undefined, fallback: string, title: string) => ( + + ); + switch (health.model) { + case 'pool': return labelField(health.label, 'HP', 'WHAT IT\'S CALLED'); + case 'tracks': { + const [a, b] = health.tracks; + const track = (i: 0 | 1, t: Named, title: string) => ( + + ); + return <> +
    {track(0, a, 'FIRST TRACK (ON THE TOKEN)')}{track(1, b, 'SECOND TRACK')}
    + + ; + } + case 'typed': return <> + {labelField(health.label, 'HEALTH', 'THE TRACK')} + DAMAGE TYPES, LIGHTEST FIRST + ({ id, label })} + onChange={(types) => onChange({ ...health, types })} /> + ; + case 'harm': return <> + LEVELS, LEAST FIRST + what="level" items={health.levels} limits={LIST_LIMITS.levels} make={(label, id) => ({ id, label, slots: 1 })} + onChange={(levels) => onChange({ ...health, levels })} + extra={(level, set) => <> + + { + const { penalty: _old, ...rest } = level; + set(e.target.value ? { ...rest, penalty: e.target.value } : rest); + }} /> + } /> + ; + case 'wounds': return ( +
    + + +
    + ); + case 'locations': return <> + {labelField(health.label, 'HP', 'THE POOL')} + LOCATIONS + ({ id, label })} + onChange={(locations) => onChange({ ...health, locations })} /> + ; + default: return

    Nothing to set.

    ; + } +} + +/** The starter sheet's HEALTH section for the chosen model, and what the token shows. */ +function Preview({ health }: { health: Health }) { + const { sections } = healthLayout(health); + return ( +
    + THE STARTER SHEET GETS + {sections.map((s) => ( +
    +
    {s.label}
    +
    + {s.fields.map((f) => ( +
    + {f.label} + + {f.hint && {f.hint}} +
    + ))} +
    +
    + ))} +

    {tokenNote(health)}

    +
    + ); +} + +export function SetupPage({ api, systemId, definition, edit, say }: Props) { + const answers = setupOf(definition); + const [nameText, setNameText] = useState(definition.name); + const [nameError, setNameError] = useState(null); + const [renaming, setRenaming] = useState(false); + const [dieText, setDieText] = useState(''); + const [dieError, setDieError] = useState(null); + const [characters, setCharacters] = useState(0); + // Each model's settings as last left, so trying another model and coming back loses nothing. + const kept = useRef>>({ [answers.health.model]: answers.health }); + const nameRef = useRef(null); + + // How many characters play it, for the warning under the health question. + useEffect(() => { + let live = true; + api.list().then((r) => { if (live && r.ok) setCharacters(r.value.find((s) => s.id === systemId)?.characterCount ?? 0); }); + return () => { live = false; }; + }, [api, systemId]); + + const core = (patch: Record) => edit(withCore(definition, patch)); + const setHealth = (h: Health) => { kept.current[h.model] = h; core({ health: h }); }; + + const rename = async () => { + const wanted = nameText.trim(); + if (!wanted || wanted === definition.name || renaming) return; + setRenaming(true); + const r = await api.rename(systemId, wanted); + setRenaming(false); + if (!r.ok) { setNameError(r.error); nameRef.current?.focus(); return; } + // The draft on the server has it now; the builder's copy must too, or the next save undoes it. + edit({ ...definition, name: r.value.name }); + setNameText(r.value.name); + say(renamedMessage(r.value.name)); + window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); + }; + + const addTyped = () => { + const r = addDie(answers.dice, dieText); + if ('error' in r) { setDieError(r.error); return; } + core({ dice: r.dice }); + setDieText(''); + }; + + const model = HEALTH_MODELS.find((m) => m.id === answers.health.model)!; + + return ( +
    + + + {nameError && {nameError}} +