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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

### Under the hood

- **One place for your systems: the builder.** The GAME tab's SYSTEMS.EXE button is now SYSTEM
BUILDER, and opens the builder on MY SYSTEMS, with the system the game runs already open when
it's one of yours. Everything SYSTEMS.EXE did happens there, so the SYSTEMS.EXE window is gone.
With no system open yet, MY SYSTEMS is the only page, and SAVE and PUBLISH wait until you open
or make one.

- **The builder's new MY SYSTEMS page, ready to be put in.** Every system as one line: its name and
version, its description, who made it, when it changed, where it came from and how many
characters are played in it, then its badges. Clicking a line opens it out with OPEN, RENAME,
Expand Down
31 changes: 12 additions & 19 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ import { useVehicleRoster } from './hooks/useVehicleRoster';
import { useEnemyVehicles } from './hooks/useEnemyVehicles';
import { hasVehicles } from './sheets/vehicleSystems';
import { NpcLibrary } from './components/NpcLibrary';
import { SystemsWindow } from './components/SystemsWindow';
import { BuilderScreen } from './components/BuilderScreen';
import { isCustomSystem } from './sheets/customTemplates';
import type { BuilderPage } from './sheets/builderSession';
import { NpcSheetWindow } from './components/NpcSheetWindow';
import { TvPortrait } from './components/TvPortrait';
Expand Down Expand Up @@ -301,10 +301,11 @@ function App() {

const [isNpcLibraryOpen, setIsNpcLibraryOpen] = useState(false);
const [npcLibraryPos, setNpcLibraryPos] = useState(() => ({ x: window.innerWidth / 2 - 150, y: window.innerHeight / 2 - 200 }));
const [isSystemsOpen, setIsSystemsOpen] = useState(false);
/** The system the builder is open on, taking over the window (4a2b), and the page it opened at. */
const [builder, setBuilder] = useState<{ id: string; page: BuilderPage } | null>(null);
const [systemsPos, setSystemsPos] = useState(() => ({ x: window.innerWidth / 2 - 310, y: window.innerHeight / 2 - 260 }));
/**
* 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.
*/
const [builder, setBuilder] = useState<{ id: string | null; page: BuilderPage | 'systems' } | null>(null);
const [openNpcSheet, setOpenNpcSheet] = useState<{ id: number; npc_label: string; token_shape?: string; locationId?: number } | null>(null);
// NPC sheet linked to the currently selected token (admin) - drives
// GENERATE_SHEET vs OPEN_SHEET on the token menu
Expand Down Expand Up @@ -2045,7 +2046,10 @@ function App() {
onGrantAccess={handleGrantAccess}
onRevokeAccess={handleRevokeAccess}
onOpenNpcLibrary={() => setIsNpcLibraryOpen(true)}
onOpenSystems={isPrimaryAdmin ? () => setIsSystemsOpen(true) : undefined}
onOpenSystems={isPrimaryAdmin
// On MY SYSTEMS, with the running system open when it is one of the GM's own.
? () => setBuilder({ id: isCustomSystem(gameSystem) ? gameSystem : null, page: 'systems' })
: undefined}
/>
)}
{adminBankPlayer && (
Expand Down Expand Up @@ -2327,26 +2331,15 @@ function App() {
attachLocationId={selectedLocation && ['enemy_rhombus', 'friendly_rhombus'].includes(selectedLocation.shape) ? selectedLocation.id : null}
/>
)}
{isSystemsOpen && token && isPrimaryAdmin && (
<SystemsWindow
token={token}
running={gameSystem}
pos={systemsPos}
setPos={setSystemsPos}
onClose={() => setIsSystemsOpen(false)}
onOpenBuilder={(id, page) => { setIsSystemsOpen(false); setBuilder({ id, page }); }}
/>
)}
{builder && token && isPrimaryAdmin && (
<BuilderScreen
key={builder.id}
key={builder.id ?? 'none'}
token={token}
systemId={builder.id}
startPage={builder.page}
running={gameSystem}
onExit={() => setBuilder(null)}
onOpenSystem={(id) => setBuilder({ id, page: 'setup' })}
onManageSystems={() => { setBuilder(null); setIsSystemsOpen(true); }}
onOpenSystem={(id, page) => setBuilder({ id, page })}
/>
)}
{openPlayerSheetUser && token && (
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/AdminPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2300,7 +2300,7 @@ const SR6_HOUSE_RULES: HouseRuleDef[] = [

function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers }: {
token: string; onOpenNpcLibrary?: () => void; activeUsers?: any[];
/** Opens SYSTEMS.EXE; the main admin's alone, so absent for a granted editor. */
/** Opens the system builder on MY SYSTEMS; the main admin's alone, so absent for a granted editor. */
onOpenSystems?: () => void;
}) {
const [open, setOpen] = useState(false);
Expand All @@ -2320,7 +2320,7 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers
};

useEffect(() => { if (open) refresh(); }, [open]);
// SYSTEMS.EXE renamed, copied or deleted one: the picker shows it at once.
// The builder renamed, copied, installed or deleted one: the picker shows it at once.
useEffect(() => {
const again = () => refresh();
window.addEventListener(SYSTEMS_CHANGED_EVENT, again);
Expand Down Expand Up @@ -2351,9 +2351,9 @@ function TTRPGSystemPanel({ token, onOpenNpcLibrary, onOpenSystems, activeUsers
Each system keeps its own characters, banks and token health; switching back restores them.
</p>
{onOpenSystems && <>
<button className="utility-btn" style={{ fontSize: '0.65rem' }} onClick={onOpenSystems}>SYSTEMS.EXE</button>
<button className="utility-btn" style={{ fontSize: '0.65rem' }} onClick={onOpenSystems}>SYSTEM BUILDER</button>
<p style={{ fontSize: '0.6rem', opacity: 0.6, margin: 0 }}>
Switching the game stays here, in the picker. SYSTEMS.EXE is for making and managing systems.
Switching the game stays here, in the picker. The builder is for making and managing systems.
</p>
</>}
<HouseRulesPanel token={token} note={<ShortfallNote system={system} />} defs={[
Expand Down
107 changes: 31 additions & 76 deletions frontend/src/components/BuilderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
createAutosave, saveStatus, exitWarning, leaveNeedsAsking, publishBlocked, publishedMessage,
BUILDER_PAGES, type Autosave, type BuilderPage,
} from '../sheets/builderSession';
import { SYSTEMS_CHANGED_EVENT, badgesFor, versionLabel, type Badge, type LibrarySystem } from '../sheets/systemsLibrary';
import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary';
import { MySystemsPage } from './MySystemsPage';

// 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
Expand All @@ -18,23 +19,23 @@ import { SYSTEMS_CHANGED_EVENT, badgesFor, versionLabel, type Badge, type Librar

interface Props {
token: string;
systemId: string;
/** The page it opens on: SETUP for a system just made. */
startPage?: BuilderPage;
/** The system open in it; none when the GM has yet to pick one, and MY SYSTEMS is all there is. */
systemId: string | null;
/** The page it opens on: SETUP for a system just made, MY SYSTEMS from the GAME tab. */
startPage?: BuilderPage | 'systems';
/** The system the game runs, so PUBLISH can say whether the game now runs the new version. */
running: string | null;
/** Back to the map. */
onExit: () => void;
/** Another system, opened here in its place (MY SYSTEMS). */
onOpenSystem: (id: string) => void;
/** Back to SYSTEMS.EXE on the map, to rename, copy, share or install. */
onManageSystems: () => 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;
fetcher?: typeof fetch;
}

/**
* MY SYSTEMS is the builder's own list of systems, not a way out of it: picking one opens it here
* (the user asked, 2026-10-06, after it first went back to SYSTEMS.EXE on the map).
* 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,
* which retired the SYSTEMS.EXE window).
*/
const MY_SYSTEMS = { id: 'systems' as const, label: 'MY SYSTEMS', what: 'Every system you have made or installed. Pick one to work on it.' };
type Page = BuilderPage | 'systems';
Expand Down Expand Up @@ -62,10 +63,6 @@ const ExitIcon = () => (
/** How wide the rail grows to show the names. */
const RAIL_OPEN = 220;

const BADGE_BORDER: Record<Badge['tone'], string> = {
run: 'var(--cyan)', plain: 'var(--dark-green)', warn: 'var(--warning)', bad: 'var(--danger)',
};

const badge = (border: string, color?: string): React.CSSProperties => ({
fontSize: 10, letterSpacing: 1, padding: '1px 5px', border: `1px solid ${border}`, ...(color ? { color } : {}),
});
Expand Down Expand Up @@ -128,12 +125,12 @@ function RailButton({ icon: glyph, label, onClick, title, active, disabled, coun
);
}

export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, onManageSystems, fetcher }: Props) {
export function BuilderScreen({ token, systemId, startPage = 'setup', running, onExit, onOpenSystem, 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 [page, setPage] = useState<Page>(startPage);
const [library, setLibrary] = useState<{ systems: LibrarySystem[] | null; error: string | null }>({ systems: null, error: null });
// With no system open, MY SYSTEMS is the only page there is.
const [page, setPage] = useState<Page>(systemId ? startPage : 'systems');
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 All @@ -142,6 +139,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
const autosave = useRef<Autosave<Definition> | null>(null);

const load = useCallback(async () => {
if (!systemId) return null;
const r = await api.get(systemId);
if (!r.ok) { setLoadError(r.error); return null; }
setSystem(r.value);
Expand All @@ -151,7 +149,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
useEffect(() => {
let live = true;
load().then((sys) => {
if (!live || !sys) return;
if (!live || !sys || !systemId) return;
autosave.current = createAutosave<Definition>({
save: async (definition) => {
const r = await api.saveDraft(systemId, definition);
Expand Down Expand Up @@ -200,7 +198,7 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
if (!saved) { setBusy(false); return; }
const blocked = publishBlocked(autosave.current.problems);
if (blocked) { setBusy(false); setPage('problems'); setStatus({ text: blocked, bad: true }); return; }
const r = await api.publish(systemId);
const r = await api.publish(systemId!);
setBusy(false);
if (!r.ok) {
if (r.problems) setPage('problems');
Expand All @@ -212,14 +210,6 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
window.dispatchEvent(new Event(SYSTEMS_CHANGED_EVENT));
};

// MY SYSTEMS reads the list each time it opens, so it shows what was published or renamed since.
useEffect(() => {
if (page !== 'systems') return;
let live = true;
api.list().then((r) => { if (live) setLibrary(r.ok ? { systems: r.value, error: null } : { systems: null, error: r.error }); });
return () => { live = false; };
}, [api, page]);

const current = page === 'systems' ? MY_SYSTEMS : BUILDER_PAGES.find((p) => p.id === page)!;
const saveLine = state ? saveStatus(state) : null;

Expand All @@ -246,7 +236,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('setup')} />
<RailButton icon={Icons.builder} label="SYSTEM_BUILDER" big onClick={() => setPage(systemId ? '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 @@ -259,7 +249,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
key={p.id}
icon={Icons[p.id]}
label={p.label}
title={p.what}
title={systemId ? p.what : 'Open a system first, in MY SYSTEMS.'}
disabled={!systemId}
active={p.id === page}
count={p.id === 'problems' && problems.length > 0 ? problems.length : undefined}
onClick={() => { setPage(p.id); setStatus(null); }}
Expand All @@ -281,7 +272,7 @@ 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 }}>{name.toUpperCase() || '…'}</b>
<b style={{ color: 'var(--green)', letterSpacing: 1 }}>{systemId ? (name.toUpperCase() || '…') : 'NO SYSTEM OPEN'}</b>
{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 @@ -296,59 +287,23 @@ 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>}
{!system && !loadError && <p style={{ opacity: 0.7 }}>LOADING…</p>}
{systemId && !system && !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 })}
/>
)}
{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>
))}
{system && page === 'systems' && (
<div style={{ maxWidth: '70ch', display: 'flex', flexDirection: 'column', gap: 12 }}>
{library.error && <p role="alert" style={{ margin: 0, color: 'var(--danger)' }}>{library.error}</p>}
{!library.systems && !library.error && <p style={{ margin: 0, opacity: 0.7 }}>LOADING…</p>}
{library.systems && (
<div role="group" aria-label="Your systems" style={{ display: 'flex', flexDirection: 'column', border: '1px solid var(--dark-green)' }}>
{library.systems.map((s) => {
const here = s.id === systemId;
return (
<button
key={s.id}
type="button"
aria-current={here ? 'true' : undefined}
disabled={here || busy}
title={here ? 'Open now' : `Save this one and open ${s.name}`}
onClick={() => leave(() => onOpenSystem(s.id))}
style={{
...mono, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto', gap: '4px 12px', padding: '9px 12px',
border: 0, borderBottom: '1px solid var(--dark-green)', textAlign: 'left', fontSize: 12, color: 'var(--green)',
cursor: here ? 'default' : 'pointer',
background: here ? 'color-mix(in srgb, var(--green) 14%, transparent)' : 'none',
boxShadow: here ? 'inset 3px 0 0 var(--green)' : 'none',
}}
>
<span style={{ fontWeight: 700, letterSpacing: 1, overflowWrap: 'anywhere' }}>{s.name.toUpperCase()}</span>
<span style={{ opacity: 0.7 }}>{here ? 'OPEN NOW' : versionLabel(s)}</span>
<span style={{ gridColumn: '1 / -1', display: 'flex', gap: 5, flexWrap: 'wrap' }}>
{badgesFor(s, running).map((b) => (
<span key={b.text} style={badge(BADGE_BORDER[b.tone], b.tone === 'plain' ? undefined : BADGE_BORDER[b.tone])}>{b.text}</span>
))}
</span>
</button>
);
})}
</div>
)}
<p style={{ margin: 0, opacity: 0.75, fontSize: 12 }}>
To rename, copy, share or install a system, use SYSTEMS.EXE on the map.
</p>
<div>
<button type="button" className="utility-btn" style={{ ...mono, fontSize: 11, letterSpacing: 1, padding: '5px 10px' }}
onClick={() => leave(onManageSystems)}>OPEN SYSTEMS.EXE</button>
</div>
</div>
)}
{system && page !== 'problems' && page !== 'systems' && (
<div style={{ maxWidth: '60ch', border: '1px dashed color-mix(in srgb, var(--green) 45%, transparent)', padding: '16px 18px' }}>
<p style={{ margin: '0 0 6px', color: 'var(--green)', letterSpacing: 1 }}>{current.label}</p>
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/SystemsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import {
insideBadges, installPlan, installedMessage, type Badge, type InstallPreview, type InstallAction,
} from '../sheets/systemsLibrary';

// The parts of managing a GM's systems that SYSTEMS.EXE and the builder's MY SYSTEMS page share
// (4a2c2a): NEW, INSTALL, the badges, and their look. What they say is sheets/systemsLibrary.ts.
// The builder MY SYSTEMS page's parts (4a2c2a; first built for the SYSTEMS.EXE window, retired in
// 4a2c2b): NEW, INSTALL, the badges, and their look. What they say is sheets/systemsLibrary.ts.

/** Larger than any system file can be (the server's limit is about half a megabyte). */
export const MAX_FILE_BYTES = 1024 * 1024;
Expand Down
Loading
Loading