Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
8 changes: 5 additions & 3 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -2340,13 +2340,15 @@ function App() {
)}
{builder && token && isPrimaryAdmin && (
<BuilderScreen
key={builder.id ?? 'none'}
key={builder.id ?? (builder.example ? `example:${builder.example}` : 'none')}
token={token}
systemId={builder.id}
startPage={builder.page}
example={builder.example ?? null}
running={gameSystem}
onExit={() => setBuilder(null)}
onOpenSystem={(id, page) => setBuilder({ id, page })}
onLookAt={(example) => setBuilder({ id: null, page: 'rules', example })}
/>
)}
{openPlayerSheetUser && token && (
Expand Down
134 changes: 111 additions & 23 deletions frontend/src/components/BuilderScreen.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<HTMLDivElement>(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 <div ref={ref} className={on ? 'builder-locked' : undefined} data-testid={on ? 'locked-page' : undefined}>{children}</div>;
}

/**
* 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,
Expand Down Expand Up @@ -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<SystemCopies | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const looking = !systemId && !!example;
// With no system open, MY SYSTEMS is the only page there is.
const [page, setPage] = useState<Page>(systemId ? startPage : 'systems');
const [page, setPage] = useState<Page>(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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 (
<div
Expand All @@ -251,7 +306,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
}}
>
<div className="rail-top" style={{ alignItems: 'stretch' }}>
<RailButton icon={Icons.builder} label="SYSTEM_BUILDER" big onClick={() => setPage(systemId ? 'setup' : 'systems')} />
<RailButton icon={Icons.builder} label="SYSTEM_BUILDER" big onClick={() => setPage(systemId || looking ? 'setup' : 'systems')} />
</div>
<div style={{ padding: '10px 0' }}>
<RailButton icon={<ExitIcon />} label="EXIT TO MAP" aria="Exit the builder and go back to the map" onClick={() => leave(onExit)} />
Expand All @@ -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); }}
Expand All @@ -287,7 +342,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
minWidth: 0,
}}>
<span data-testid="builder-system" style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<b style={{ color: 'var(--green)', letterSpacing: 1 }}>{systemId ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'}</b>
<b style={{ color: 'var(--green)', letterSpacing: 1 }}>{systemId || looking ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'}</b>
{looking && <span style={badge('var(--cyan)', 'var(--cyan)')}>EXAMPLE · READ ONLY</span>}
{system && (system.published
? <span style={badge('color-mix(in srgb, var(--green) 50%, transparent)')}>PUBLISHED v{system.version}</span>
: <span style={badge('var(--warning)', 'var(--warning)')}>NEVER PUBLISHED</span>)}
Expand All @@ -302,32 +358,64 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o

<section aria-label={current.label} className="cyber-scroll" style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: 24, fontSize: 13, lineHeight: 1.5 }}>
{loadError && <p role="alert" style={{ color: 'var(--danger)' }}>{loadError}</p>}
{systemId && !system && !loadError && <p style={{ opacity: 0.7 }}>LOADING…</p>}
{(systemId ? !system : looking && !definition) && !loadError && <p style={{ opacity: 0.7 }}>LOADING…</p>}
{page === 'systems' && (systemId === null || system) && (
<MySystemsPage
api={api}
openId={systemId}
running={running}
onOpen={(id, at) => 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
? <p style={{ color: 'var(--green)' }}>No problems. It can be published.</p>
: (
<ul style={{ margin: 0, paddingLeft: '1.2em', color: 'var(--warning)', display: 'flex', flexDirection: 'column', gap: 4 }}>
{problems.map((p, i) => <li key={i}><b>{p.where}</b>: {p.message}</li>)}
</ul>
))}
{systemId && system && definition && page === 'setup' && (
<SetupPage api={api} systemId={systemId} definition={definition} edit={edit} say={(text, bad) => setStatus({ text, bad })} />
{looking && ready && page !== 'systems' && (
<div role="region" aria-label="Example" style={{ border: '1px solid var(--cyan)', padding: '10px 12px', marginBottom: 16, display: 'flex', flexDirection: 'column', gap: 8, maxWidth: 900 }}>
<span style={{ color: 'var(--cyan)', fontSize: 10, letterSpacing: 2 }}>A BUILT-IN EXAMPLE</span>
<span style={{ fontSize: 12, lineHeight: 1.45 }}>
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.
</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<input
type="text" aria-label="Name for the copy" maxLength={80} autoComplete="off" value={copy.name}
placeholder={suggestedName(definition!.name)}
aria-invalid={copy.error ? true : undefined}
onChange={(e) => 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)'}`,
}}
/>
<button type="button" className="utility-btn active" style={{ ...mono, fontSize: 11, letterSpacing: 1, padding: '5px 9px' }} disabled={copy.busy} onClick={copyExample}>
COPY TO CHANGE IT
</button>
<button type="button" className="utility-btn" style={{ ...mono, fontSize: 11, letterSpacing: 1, padding: '5px 9px' }} onClick={() => { setPage('systems'); setStatus(null); }}>
BACK TO + NEW
</button>
</div>
{copy.error && <span role="alert" style={{ color: 'var(--danger)', fontSize: 12 }}>{copy.error}</span>}
</div>
)}
{system && definition && page === 'words' && <WordsPage definition={definition} edit={edit} />}
{system && definition && page === 'features' && <FeaturesPage definition={definition} edit={edit} api={api} />}
{system && definition && page === 'rules' && <StatsRulesPage definition={definition} edit={edit} api={api} />}
{system && definition && page === 'sheet' && <SheetPage definition={definition} edit={edit} api={api} />}
{system && definition && page === 'npcs' && <NpcsPage definition={definition} edit={edit} api={api} />}
{system && definition && page === 'try' && <TryItPage definition={definition} edit={edit} api={api} />}
<Locked key={page} on={looking && page !== 'try'}>
{ready && page === 'problems' && (problems.length === 0
? <p style={{ color: 'var(--green)' }}>No problems. It can be published.</p>
: (
<ul style={{ margin: 0, paddingLeft: '1.2em', color: 'var(--warning)', display: 'flex', flexDirection: 'column', gap: 4 }}>
{problems.map((p, i) => <li key={i}><b>{p.where}</b>: {p.message}</li>)}
</ul>
))}
{ready && page === 'setup' && (
<SetupPage api={api} systemId={systemId ?? ''} definition={definition!} edit={edit} say={(text, bad) => setStatus({ text, bad })} />
)}
{ready && page === 'words' && <WordsPage definition={definition!} edit={edit} />}
{ready && page === 'features' && <FeaturesPage definition={definition!} edit={edit} api={api} />}
{ready && page === 'rules' && <StatsRulesPage definition={definition!} edit={edit} api={api} />}
{ready && page === 'sheet' && <SheetPage definition={definition!} edit={edit} api={api} />}
{ready && page === 'npcs' && <NpcsPage definition={definition!} edit={edit} api={api} />}
{ready && page === 'try' && <TryItPage definition={definition!} edit={looking ? undefined : edit} api={api} />}
</Locked>
</section>

<footer style={{
Expand Down
10 changes: 7 additions & 3 deletions frontend/src/components/MySystemsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ interface Props {
onOpen: (id: string, page: BuilderPage) => void;
/** A line for the builder's status bar. */
say: (text: string, bad?: boolean) => void;
/** Open a built-in example in the builder to look at first (4d1b). */
onLook?: (exampleId: string) => void;
/** The tab it opens on: + NEW when coming back from looking at an example. */
startTab?: Tab;
}

const tabStyle = (on: boolean): React.CSSProperties => ({
Expand All @@ -34,8 +38,8 @@ const tabStyle = (on: boolean): React.CSSProperties => ({
color: on ? 'var(--green)' : 'color-mix(in srgb, var(--green) 55%, transparent)', fontFamily: 'monospace', fontSize: 11, letterSpacing: 1,
});

export function MySystemsPage({ api, openId, running, onOpen, say }: Props) {
const [tab, setTab] = useState<Tab>('list');
export function MySystemsPage({ api, openId, running, onOpen, say, onLook, startTab = 'list' }: Props) {
const [tab, setTab] = useState<Tab>(startTab);
const [systems, setSystems] = useState<LibrarySystem[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [picked, setPicked] = useState<string | null>(openId);
Expand Down Expand Up @@ -237,7 +241,7 @@ export function MySystemsPage({ api, openId, running, onOpen, say }: Props) {
)}
</>}

{tab === 'new' && <NewPanel api={api} onMade={(id) => { window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); onOpen(id, 'setup'); }} />}
{tab === 'new' && <NewPanel api={api} onLook={onLook} onMade={(id) => { window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT)); onOpen(id, 'setup'); }} />}

{tab === 'install' && <>
{installed && (
Expand Down
Loading
Loading