diff --git a/CHANGELOG.md b/CHANGELOG.md index c97ba20b..ff90bc4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,14 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **Start a system from Cities Without Number or Shadowrun.** In the builder's + NEW, A BUILT-IN + EXAMPLE now works: a card for each says what it holds (its health, how characters advance, its + stats and formulas), and COPY makes it a new system of your own under the name you type, opened + on SETUP, with no link back. LOOK FIRST opens the example in the builder to read: every page is + there with its boxes and buttons locked, tabs and settings still open, SAVE and PUBLISH off, and + TRY IT works as on your own systems. COPY TO CHANGE IT makes the copy from there. The built-in + games themselves don't change. + - **Cities Without Number and Shadowrun as examples to copy.** Both are now whole systems in the builder's format, ready to publish as they stand: their stats, the names players see, a sample character, and their setup answers (one pool in meters for CWN; physical and stun with overflow, diff --git a/frontend/src/App.css b/frontend/src/App.css index 023ce010..d68a1768 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -650,6 +650,13 @@ header { --rail-top-padding: clamp(6px, 1.5vh, 15px); } +/* A built-in example's page in the builder (4d1b): its controls are locked, so they look it. + A filter rather than colors, since the pages color their controls inline from the theme. */ +.builder-locked :is(input, select, textarea, button):disabled { + cursor: not-allowed; + filter: saturate(0.4) brightness(0.75); +} + .icon-rail { width: var(--rail-width); height: 100%; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c0716601..2a255702 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -305,9 +305,9 @@ function App() { const [npcLibraryPos, setNpcLibraryPos] = useState(() => ({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 200 })); /** * The builder, taking over the window while open (4a2b): the system open in it (none until the - * GM picks one in MY SYSTEMS) and the page it opened at. + * GM picks one in MY SYSTEMS) and the page it opened at, or a built-in example looked at (4d1b). */ - const [builder, setBuilder] = useState<{ id: string | null; page: BuilderPage | 'systems' } | null>(null); + const [builder, setBuilder] = useState<{ id: string | null; page: BuilderPage | 'systems'; example?: string } | null>(null); const [openNpcSheet, setOpenNpcSheet] = useState<{ id: number; npc_label: string; token_shape?: string; locationId?: number } | null>(null); // A friendly NPC the GM gave this player, its sheet open read-only (4b5b4). const [controlledNpc, setControlledNpc] = useState<{ locationId: number; name: string } | null>(null); @@ -2340,13 +2340,15 @@ function App() { )} {builder && token && isPrimaryAdmin && ( setBuilder(null)} onOpenSystem={(id, page) => setBuilder({ id, page })} + onLookAt={(example) => setBuilder({ id: null, page: 'rules', example })} /> )} {openPlayerSheetUser && token && ( diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 96ef254e..ba6d4665 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react'; import { systemsApi, type Definition, type SystemCopies } from '../sheets/systemsApi'; +import { lockControls, suggestedName, LOCKED_MESSAGE } from '../sheets/examples'; import { createAutosave, saveStatus, exitWarning, leaveNeedsAsking, publishBlocked, publishedMessage, BUILDER_PAGES, type Autosave, type BuilderPage, @@ -36,9 +37,34 @@ interface Props { onExit: () => void; /** Another system, opened here in its place at a page (MY SYSTEMS), once the open one is saved. */ onOpenSystem: (id: string, page: BuilderPage) => void; + /** + * A built-in example open to look at (4d1b) instead of a system: every page shown, everything + * locked but TRY IT, nothing saved, and COPY TO CHANGE IT to make it the GM's own. + */ + example?: string | null; + /** Look at a built-in example here, from + NEW's LOOK FIRST. */ + onLookAt?: (exampleId: string) => void; fetcher?: typeof fetch; } +/** + * A page of an example, locked: every control greyed but those that only move around it (tabs, + * things opening out), so it can be read but not changed (sheets/examples.ts lockControls). + */ +function Locked({ on, children }: { on: boolean; children: React.ReactNode }) { + const ref = useRef(null); + useLayoutEffect(() => { + const root = ref.current; + if (!on || !root) return undefined; + lockControls(root); + // Pages draw more as they load and open out, so whatever appears is locked too. + const watch = new MutationObserver(() => lockControls(root)); + watch.observe(root, { subtree: true, childList: true, attributes: true, attributeFilter: ['disabled'] }); + return () => watch.disconnect(); + }, [on]); + return
{children}
; +} + /** * MY SYSTEMS is the builder's own page for every system: picking, making, copying, sharing and * installing them without leaving (MySystemsPage; approved mockup builder-my-systems, 2026-10-06, @@ -132,12 +158,14 @@ function RailButton({ icon: glyph, label, onClick, title, active, disabled, coun ); } -export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, fetcher }: Props) { +export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, example = null, onLookAt, fetcher }: Props) { const api = useMemo(() => systemsApi(token, fetcher), [token, fetcher]); const [system, setSystem] = useState(null); const [loadError, setLoadError] = useState(null); + const looking = !systemId && !!example; // With no system open, MY SYSTEMS is the only page there is. - const [page, setPage] = useState(systemId ? startPage : 'systems'); + const [page, setPage] = useState(systemId || looking ? startPage : 'systems'); + const [copy, setCopy] = useState<{ name: string; error: string | null; busy: boolean }>({ name: '', error: null, busy: false }); const [status, setStatus] = useState<{ text: string; bad?: boolean } | null>(null); const [exitError, setExitError] = useState<{ error: string; then: () => void } | null>(null); const [busy, setBusy] = useState(false); @@ -172,12 +200,37 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o return () => { live = false; autosave.current?.dispose(); }; }, [api, load, systemId]); + // An example to look at: its definition, with nothing to save it to. + useEffect(() => { + if (!looking || !example) return undefined; + let live = true; + api.example(example).then((r) => { + if (!live) return; + if (r.ok) setDefinition(r.value.definition); else setLoadError(r.error); + }); + return () => { live = false; }; + }, [api, looking, example]); + /** A page changed the system: shown at once, saved once editing pauses (builderSession). */ const edit = (next: Definition) => { + // Nothing on a locked page can call this, but an example never changes whatever does. + if (looking) { setStatus({ text: LOCKED_MESSAGE, bad: true }); return; } setDefinition(next); autosave.current?.edit(next); }; + /** COPY TO CHANGE IT: the example as a new system of the GM's own, opened on SETUP. */ + const copyExample = async () => { + const wanted = copy.name.trim(); + if (!example || copy.busy) return; + if (!wanted) { setCopy({ ...copy, error: 'Give the copy a name.' }); return; } + setCopy({ ...copy, busy: true, error: null }); + const r = await api.createFromExample(wanted, example); + if (!r.ok) { setCopy({ ...copy, busy: false, error: r.error }); return; } + window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); + onOpenSystem(r.value.id, 'setup'); + }; + // Closing or reloading the tab asks first while anything is unsaved. useEffect(() => { const ask = (e: BeforeUnloadEvent) => { @@ -227,6 +280,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o const current = page === 'systems' ? MY_SYSTEMS : BUILDER_PAGES.find((p) => p.id === page)!; const saveLine = state ? saveStatus(state) : null; + /** Whether the pages have something to show: an open system's draft, or an example. */ + const ready = !!definition && (looking || !!system); return (
- setPage(systemId ? 'setup' : 'systems')} /> + setPage(systemId || looking ? 'setup' : 'systems')} />
} label="EXIT TO MAP" aria="Exit the builder and go back to the map" onClick={() => leave(onExit)} /> @@ -264,8 +319,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o key={p.id} icon={Icons[p.id]} label={p.label} - title={systemId ? p.what : 'Open a system first, in MY SYSTEMS.'} - disabled={!systemId} + title={systemId || looking ? p.what : 'Open a system first, in MY SYSTEMS.'} + disabled={!systemId && !looking} active={p.id === page} count={p.id === 'problems' && problems.length > 0 ? problems.length : undefined} onClick={() => { setPage(p.id); setStatus(null); }} @@ -287,7 +342,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o minWidth: 0, }}> - {systemId ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'} + {systemId || looking ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'} + {looking && EXAMPLE · READ ONLY} {system && (system.published ? PUBLISHED v{system.version} : NEVER PUBLISHED)} @@ -302,7 +358,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
{loadError &&

{loadError}

} - {systemId && !system && !loadError &&

LOADING…

} + {(systemId ? !system : looking && !definition) && !loadError &&

LOADING…

} {page === 'systems' && (systemId === null || system) && ( leave(() => onOpenSystem(id, at))} say={(text, bad) => setStatus({ text, bad })} + onLook={onLookAt ? (id) => leave(() => onLookAt(id)) : undefined} + startTab={looking ? 'new' : undefined} /> )} - {system && page === 'problems' && (problems.length === 0 - ?

No problems. It can be published.

- : ( -
    - {problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} -
- ))} - {systemId && system && definition && page === 'setup' && ( - setStatus({ text, bad })} /> + {looking && ready && page !== 'systems' && ( +
+ A BUILT-IN EXAMPLE + + Look around and try it. Nothing here can be changed. To make it your own, copy it under a name of yours: the copy opens on SETUP. + +
+ setCopy({ ...copy, name: e.target.value, error: null })} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); copyExample(); } }} + style={{ + ...mono, flex: 1, minWidth: 0, maxWidth: 320, background: 'var(--black)', color: 'var(--green)', fontSize: 12, padding: '5px 7px', + border: `1px solid ${copy.error ? 'var(--danger)' : 'var(--green)'}`, + }} + /> + + +
+ {copy.error && {copy.error}} +
)} - {system && definition && page === 'words' && } - {system && definition && page === 'features' && } - {system && definition && page === 'rules' && } - {system && definition && page === 'sheet' && } - {system && definition && page === 'npcs' && } - {system && definition && page === 'try' && } + + {ready && page === 'problems' && (problems.length === 0 + ?

No problems. It can be published.

+ : ( +
    + {problems.map((p, i) =>
  • {p.where}: {p.message}
  • )} +
+ ))} + {ready && page === 'setup' && ( + setStatus({ text, bad })} /> + )} + {ready && page === 'words' && } + {ready && page === 'features' && } + {ready && page === 'rules' && } + {ready && page === 'sheet' && } + {ready && page === 'npcs' && } + {ready && page === 'try' && } +