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

### Under the hood

- **The builder's FEATURES page: parts and shops.** Every part of the app as a switch with a line
saying what it does, all on unless turned off. XP awards, death saves, luck and PDF import show
as off and greyed, since a custom system can't use them yet. SHOPS opens out to the app's shop
buildings, its catalogues and its other buildings, each renamed (blank keeps the app's name) or
turned off, and each catalogue priced in any of the system's currencies. A catalogue whose part
is off is greyed. The bank's settings come next.

- **The builder's WORDS page.** One table of the app's terms, grouped (characters, money, play,
vehicles), with a box for each one's singular, plural and short form. The app's own word shows
faintly in every empty box and is what a blank box means. The last column reads each term as
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/components/BuilderScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary';
import { MySystemsPage } from './MySystemsPage';
import { SetupPage } from './SetupPage';
import { WordsPage } from './WordsPage';
import { FeaturesPage } from './FeaturesPage';

// 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 Down Expand Up @@ -318,7 +319,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
<SetupPage api={api} systemId={systemId} definition={definition} edit={edit} say={(text, bad) => setStatus({ text, bad })} />
)}
{system && definition && page === 'words' && <WordsPage definition={definition} edit={edit} />}
{system && !['problems', 'systems', 'setup', 'words'].includes(page) && (
{system && definition && page === 'features' && <FeaturesPage definition={definition} edit={edit} />}
{system && !['problems', 'systems', 'setup', 'words', 'features'].includes(page) && (
<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>
<p style={{ margin: 0, opacity: 0.8 }}>{current.what} This page arrives in a coming update.</p>
Expand Down
146 changes: 146 additions & 0 deletions frontend/src/components/FeaturesPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import React, { useState } from 'react';
import type { Definition } from '../sheets/systemsApi';
import {
PART_ROWS, partIsOn, withPart, SHOP_TYPES, OTHER_TYPES, CATALOGUE_ROWS, BUILDING_NAME_LIMIT, buildingSetting, withBuilding,
cataloguePartOff, currencyList, type BuildingKind,
} from '../sheets/wordsFeatures';

// The builder's FEATURES page (4b1c): which parts of the app the game uses, one switch each, the
// parts with settings opening out beneath (approved mockup docs/mockups/builder-words-features.html,
// 2026-10-06). SHOPS holds the app's building types and catalogues: renamed, turned off, and each
// catalogue's currency. Renaming buildings lives here, beside their switches, not on WORDS; a blank
// name is the app's (the user, same day). XP awards, death saves, luck and PDF import have no rules
// a custom system can use yet, so they show off and greyed (same day). What it reads and writes is
// sheets/wordsFeatures.ts; every change goes through the builder's `edit`, which autosaves it.

interface Props {
definition: Definition;
edit: (next: Definition) => 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 field: React.CSSProperties = {
width: '100%', boxSizing: 'border-box', background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, padding: '5px 7px',
};
const td: React.CSSProperties = { padding: '3px 8px 3px 0', verticalAlign: 'middle' };

/** An on/off switch, drawn as the mockup's, read as a switch by assistive tech. */
function Switch({ on, label, disabled, onChange }: { on: boolean; label: string; disabled?: boolean; onChange: (on: boolean) => void }) {
return (
<button type="button" role="switch" aria-checked={on} aria-label={label} disabled={disabled} onClick={() => onChange(!on)}
style={{
width: 44, height: 22, padding: 0, position: 'relative', cursor: disabled ? 'not-allowed' : 'pointer', flexShrink: 0,
border: '1px solid var(--green)', background: on ? 'color-mix(in srgb, var(--green) 20%, transparent)' : 'var(--black)', opacity: disabled ? 0.45 : 1,
}}>
<span aria-hidden style={{ position: 'absolute', top: 2, left: on ? 24 : 2, width: 16, height: 16, background: on ? 'var(--green)' : 'var(--dark-green)', transition: 'left 0.15s' }} />
</button>
);
}

/** One table of building types or catalogues: the app's name, the system's, (a currency), on. */
function BuildingTable({ definition, edit, kind, rows, priced }: {
definition: Definition; edit: (next: Definition) => void; kind: BuildingKind; rows: { id: string; label: string }[]; priced?: boolean;
}) {
const currencies = currencyList(definition);
const heads = ['THE APP\'S', 'CALLED', ...(priced ? ['PRICED IN'] : []), 'ON'];
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead><tr>{heads.map((h) => <th key={h} scope="col" style={{ ...td, ...small, textAlign: 'left', fontWeight: 'normal' }}>{h}</th>)}</tr></thead>
<tbody>
{rows.map((row) => {
const s = buildingSetting(definition, kind, row.id);
const partOff = kind === 'catalogues' && cataloguePartOff(definition, row.id);
return (
<tr key={row.id} data-testid={`${kind}-${row.id}`} style={{ opacity: partOff ? 0.45 : 1 }}>
<td style={{ ...td, width: 150 }}>
{row.label}
{partOff && <span style={{ display: 'block', fontSize: 10, opacity: 0.8 }}>its part is off</span>}
</td>
<td style={td}>
<input type="text" maxLength={BUILDING_NAME_LIMIT} aria-label={`${row.label} called`} value={s.name ?? ''} placeholder={row.label}
disabled={partOff} onChange={(e) => edit(withBuilding(definition, kind, row.id, { name: e.target.value }))}
style={{ ...field, border: `1px solid ${s.name ? 'var(--cyan)' : 'var(--green)'}` }} />
</td>
{priced && (
<td style={{ ...td, width: 150 }}>
<select aria-label={`${row.label} priced in`} disabled={partOff} value={s.currency ?? currencies[0]?.id}
onChange={(e) => edit(withBuilding(definition, kind, row.id, { currency: e.target.value }))} style={{ ...field, border: '1px solid var(--green)' }}>
{currencies.map((c, i) => <option key={c.id} value={c.id}>{c.name}{i === 0 ? ' (main)' : ''}</option>)}
</select>
</td>
)}
<td style={{ ...td, width: 60 }}>
<Switch on={s.on !== false} label={`${row.label} on`} disabled={partOff}
onChange={(on) => edit(withBuilding(definition, kind, row.id, { on }))} />
</td>
</tr>
);
})}
</tbody>
</table>
);
}

function ShopsSettings({ definition, edit }: Props) {
const priced = currencyList(definition).length > 0;
const box: React.CSSProperties = { border: '1px solid var(--dark-green)', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: 8 };
return <>
<section aria-label="Shop buildings" style={box}>
<h3 style={{ ...small, margin: 0, color: 'var(--green)', opacity: 1 }}>SHOP BUILDINGS</h3>
<span style={why}>Rename the app's shop types, or turn one off. A building keeps its type and stock whatever it's called.</span>
<BuildingTable definition={definition} edit={edit} kind="types" rows={SHOP_TYPES} />
</section>
<section aria-label="Catalogues" style={box}>
<h3 style={{ ...small, margin: 0, color: 'var(--green)', opacity: 1 }}>CATALOGUES</h3>
<span style={why}>
{priced ? 'What the shops sell, each priced in the main currency unless you pick another.' : 'What the shops sell, priced in the app\'s money until the bank has currencies of its own.'}
</span>
<BuildingTable definition={definition} edit={edit} kind="catalogues" rows={CATALOGUE_ROWS} priced={priced} />
</section>
<section aria-label="Other buildings" style={box}>
<h3 style={{ ...small, margin: 0, color: 'var(--green)', opacity: 1 }}>OTHER BUILDINGS</h3>
<span style={why}>The app's buildings that don't sell anything.</span>
<BuildingTable definition={definition} edit={edit} kind="types" rows={OTHER_TYPES} />
</section>
</>;
}

export function FeaturesPage({ definition, edit }: Props) {
const [open, setOpen] = useState<Record<string, boolean>>({});
// A part's settings, where it has any. The bank's come with 4b1c2.
const settings: Partial<Record<string, (p: Props) => React.ReactElement>> = { shops: ShopsSettings };

return (
<div style={{ maxWidth: 920, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ ...why, margin: 0, maxWidth: '72ch' }}>
Everything is on unless you turn it off. Turning a part off only hides it: turn it back on and everything comes back.
</p>
<div style={{ border: '1px solid var(--dark-green)', display: 'flex', flexDirection: 'column' }}>
{PART_ROWS.map((p) => {
const on = !p.unused && partIsOn(definition, p.id);
const Settings = settings[p.id];
const isOpen = !!Settings && on && !!open[p.id];
return (
<div key={p.id} data-testid={`part-${p.id}`} style={{ borderBottom: '1px solid var(--dark-green)' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr) auto', gap: '4px 14px', alignItems: 'center', padding: '10px 14px' }}>
<Switch on={on} label={p.label} disabled={!!p.unused} onChange={(next) => edit(withPart(definition, p.id, next))} />
<b style={{ letterSpacing: 1, color: 'var(--green)', opacity: on ? 1 : 0.5 }}>{p.label}</b>
{Settings && on
? <button type="button" aria-expanded={isOpen} onClick={() => setOpen({ ...open, [p.id]: !isOpen })}
style={{ background: 'none', border: 0, color: 'var(--green)', fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, cursor: 'pointer' }}>
{isOpen ? '▾' : '▸'} SETTINGS
</button>
: <span />}
<span style={{ ...why, gridColumn: 2, opacity: on ? 0.8 : 0.5 }}>
{p.unused ? `${p.what} Not used by custom systems yet.` : p.what}
</span>
</div>
{isOpen && <div style={{ padding: '0 14px 14px 72px', display: 'flex', flexDirection: 'column', gap: 10 }}><Settings definition={definition} edit={edit} /></div>}
</div>
);
})}
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions frontend/src/components/__tests__/BuilderScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ describe('the builder', () => {
expect(screen.getByText(/This page arrives in a coming update\./)).toBeTruthy();
});

it('FEATURES turns parts on and off, saved like any other change', async () => {
open({ startPage: 'features' });
await ready();
await userEvent.click(await screen.findByRole('switch', { name: 'VEHICLES' }));
await userEvent.click(within(sidebar()).getByLabelText('SAVE'));
await waitFor(() => expect(drafts.at(-1)).toEqual({ format: 1, name: 'Hearth', parts: { vehicles: { on: false } } }));
});

it('WORDS edits the system\'s terms, saved like any other change', async () => {
open({ startPage: 'words' });
await ready();
Expand Down
129 changes: 129 additions & 0 deletions frontend/src/components/__tests__/FeaturesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useState } from 'react';
import { describe, it, expect, afterEach } from 'vitest';
import { render, screen, cleanup, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { FeaturesPage } from '../FeaturesPage';
import type { Definition } from '../../sheets/systemsApi';

/**
* The builder's FEATURES page, parts and shops (4b1c1). Approved mockup builder-words-features
* (2026-10-06): a switch per part; SHOPS opens out to building types and catalogues; renames blank =
* the app's; XP awards, death saves, luck and PDF import off and greyed for custom systems.
*/

afterEach(() => cleanup());

const open = (start: Definition = { format: 1, name: 'Hearth' }) => {
const edits: Definition[] = [];
const Harness = () => {
const [def, setDef] = useState(start);
return <FeaturesPage definition={def} edit={(next) => { edits.push(next); setDef(next); }} />;
};
render(<Harness />);
return { edits, last: () => edits[edits.length - 1] };
};
const part = (id: string) => screen.getByTestId(`part-${id}`);
const sw = (label: string) => screen.getByRole('switch', { name: label }) as HTMLButtonElement;
const openShops = () => userEvent.click(within(part('shops')).getByText(/SETTINGS/));

describe('the parts', () => {
it('lists every part with what it does, all on to start', () => {
open();
expect(screen.getAllByTestId(/^part-/)).toHaveLength(12);
expect(part('vehicles').textContent).toContain('VEHICLES.EXE and vehicle sheets.');
for (const p of ['BANK', 'SHOPS', 'VEHICLES', 'CYBERWARE', 'INITIATIVE', 'COMBAT', 'TOKEN HEALTH', 'NPC TIERS']) {
expect(sw(p).getAttribute('aria-checked'), p).toBe('true');
}
});

it('turns one off, and on again stores nothing', async () => {
const { last } = open();
await userEvent.click(sw('VEHICLES'));
expect(last().parts).toEqual({ vehicles: { on: false } });
expect(sw('VEHICLES').getAttribute('aria-checked')).toBe('false');
await userEvent.click(sw('VEHICLES'));
expect(last()).toEqual({ format: 1, name: 'Hearth' });
});

it('shows the four a custom system can\'t use yet as off and greyed', () => {
open({ format: 1, name: 'Hearth' });
for (const p of ['XP AWARDS', 'DEATH SAVES', 'LUCK', 'PDF IMPORT']) {
expect(sw(p).getAttribute('aria-checked'), p).toBe('false');
expect(sw(p).disabled, p).toBe(true);
}
expect(part('luck').textContent).toContain('Not used by custom systems yet.');
});

it('opens SHOPS\' settings only while shops are on', async () => {
open();
expect(within(part('bank')).queryByText(/SETTINGS/)).toBeNull();
await openShops();
expect(screen.getByRole('region', { name: 'Shop buildings' })).toBeTruthy();
await userEvent.click(within(part('shops')).getByText(/SETTINGS/));
expect(screen.queryByRole('region', { name: 'Shop buildings' })).toBeNull();
await openShops();
await userEvent.click(sw('SHOPS'));
expect(within(part('shops')).queryByText(/SETTINGS/)).toBeNull();
expect(screen.queryByRole('region', { name: 'Shop buildings' })).toBeNull();
});
});

describe('SHOPS', () => {
it('renames a building type, blank going back to the app\'s', async () => {
const { last } = open();
await openShops();
const ripperdoc = screen.getByLabelText('Ripperdoc called') as HTMLInputElement;
expect(ripperdoc.placeholder).toBe('Ripperdoc');
await userEvent.type(ripperdoc, 'Temple');
expect(last().buildings).toEqual({ types: { ripperdoc: { name: 'Temple' } } });
await userEvent.clear(ripperdoc);
expect(last()).toEqual({ format: 1, name: 'Hearth' });
});

it('turns off a shop, a catalogue and another building', async () => {
const { last } = open();
await openShops();
await userEvent.click(sw('Garage on'));
await userEvent.click(sw('Weapons on'));
await userEvent.click(sw('Corporate on'));
expect(last().buildings).toEqual({ types: { garage: { on: false }, corp: { on: false } }, catalogues: { weapons: { on: false } } });
expect(within(screen.getByRole('region', { name: 'Other buildings' })).getByTestId('types-bar')).toBeTruthy();
});

it('shows one already off as off, and turning it on stores nothing', async () => {
const { last } = open({ format: 1, name: 'Hearth', buildings: { types: { clinic: { on: false } } } });
await openShops();
expect(sw('Clinic on').getAttribute('aria-checked')).toBe('false');
expect(sw('Gun Shop on').getAttribute('aria-checked')).toBe('true');
await userEvent.click(sw('Clinic on'));
expect(last()).toEqual({ format: 1, name: 'Hearth' });
});

it('greys a catalogue whose part is off', async () => {
open({ format: 1, name: 'Hearth', parts: { cyberware: { on: false } } });
await openShops();
const relics = screen.getByTestId('catalogues-cyberware');
expect(relics.textContent).toContain('its part is off');
expect((within(relics).getByLabelText('Cyberware called') as HTMLInputElement).disabled).toBe(true);
expect(sw('Cyberware on').disabled).toBe(true);
expect((screen.getByLabelText('Weapons called') as HTMLInputElement).disabled).toBe(false);
});

it('prices catalogues in the app\'s money until the system has currencies, then in any of them', async () => {
open();
await openShops();
expect(screen.queryByLabelText('Weapons priced in')).toBeNull();
expect(screen.getByText(/priced in the app's money/)).toBeTruthy();
cleanup();
const { last } = open({ format: 1, name: 'Hearth', currencies: [{ id: 'gold', name: 'Gold' }, { id: 'favor', name: 'Favor' }] });
await openShops();
const select = screen.getByLabelText('Weapons priced in') as HTMLSelectElement;
expect(select.value).toBe('gold');
expect([...select.options].map((o) => o.textContent)).toEqual(['Gold (main)', 'Favor']);
await userEvent.selectOptions(select, 'favor');
expect(last().buildings).toEqual({ catalogues: { weapons: { currency: 'favor' } } });
await userEvent.selectOptions(select, 'gold');
expect('buildings' in last()).toBe(false);
});
});
Loading