From 3a5da9a9cf989053df8e406a89717313fa3e41b2 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 20:55:24 -0500 Subject: [PATCH] feat(system-builder): the FEATURES page, parts and shops (4b1c1) components/FeaturesPage.tsx, from the approved mockup builder-words-features (2026-10-06): a switch per part with what it does; XP awards, death saves, luck and PDF import off and greyed as not used by custom systems yet; SHOPS opens out (while on) to shop buildings, catalogues and other buildings: rename (blank = the app's), on/off, and a catalogue's currency when the system has any; catalogues whose part is off greyed. BuilderScreen shows it on FEATURES; the bank's settings follow in 4b1c2. --- CHANGELOG.md | 7 + frontend/src/components/BuilderScreen.tsx | 4 +- frontend/src/components/FeaturesPage.tsx | 146 ++++++++++++++++++ .../__tests__/BuilderScreen.test.tsx | 8 + .../__tests__/FeaturesPage.test.tsx | 129 ++++++++++++++++ 5 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/FeaturesPage.tsx create mode 100644 frontend/src/components/__tests__/FeaturesPage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index a21a4bc..8ee374e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 460d249..dc45c4e 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -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 @@ -318,7 +319,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o setStatus({ text, bad })} /> )} {system && definition && page === 'words' && } - {system && !['problems', 'systems', 'setup', 'words'].includes(page) && ( + {system && definition && page === 'features' && } + {system && !['problems', 'systems', 'setup', 'words', 'features'].includes(page) && (

{current.label}

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

diff --git a/frontend/src/components/FeaturesPage.tsx b/frontend/src/components/FeaturesPage.tsx new file mode 100644 index 0000000..0d2e65c --- /dev/null +++ b/frontend/src/components/FeaturesPage.tsx @@ -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 ( + + ); +} + +/** 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 ( + + {heads.map((h) => )} + + {rows.map((row) => { + const s = buildingSetting(definition, kind, row.id); + const partOff = kind === 'catalogues' && cataloguePartOff(definition, row.id); + return ( + + + + {priced && ( + + )} + + + ); + })} + +
{h}
+ {row.label} + {partOff && its part is off} + + edit(withBuilding(definition, kind, row.id, { name: e.target.value }))} + style={{ ...field, border: `1px solid ${s.name ? 'var(--cyan)' : 'var(--green)'}` }} /> + + + + edit(withBuilding(definition, kind, row.id, { on }))} /> +
+ ); +} + +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 <> +
+

SHOP BUILDINGS

+ Rename the app's shop types, or turn one off. A building keeps its type and stock whatever it's called. + +
+
+

CATALOGUES

+ + {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.'} + + +
+
+

OTHER BUILDINGS

+ The app's buildings that don't sell anything. + +
+ ; +} + +export function FeaturesPage({ definition, edit }: Props) { + const [open, setOpen] = useState>({}); + // A part's settings, where it has any. The bank's come with 4b1c2. + const settings: Partial React.ReactElement>> = { shops: ShopsSettings }; + + return ( +
+

+ Everything is on unless you turn it off. Turning a part off only hides it: turn it back on and everything comes back. +

+
+ {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 ( +
+
+ edit(withPart(definition, p.id, next))} /> + {p.label} + {Settings && on + ? + : } + + {p.unused ? `${p.what} Not used by custom systems yet.` : p.what} + +
+ {isOpen &&
} +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/__tests__/BuilderScreen.test.tsx b/frontend/src/components/__tests__/BuilderScreen.test.tsx index 66d6d0f..43746e2 100644 --- a/frontend/src/components/__tests__/BuilderScreen.test.tsx +++ b/frontend/src/components/__tests__/BuilderScreen.test.tsx @@ -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(); diff --git a/frontend/src/components/__tests__/FeaturesPage.test.tsx b/frontend/src/components/__tests__/FeaturesPage.test.tsx new file mode 100644 index 0000000..e141fbe --- /dev/null +++ b/frontend/src/components/__tests__/FeaturesPage.test.tsx @@ -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 { edits.push(next); setDef(next); }} />; + }; + render(); + 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); + }); +});