From 4ada0d5b2aaaeb02a1f4cb4d62a93a3cabb817c6 Mon Sep 17 00:00:00 2001 From: Developer Date: Tue, 6 Oct 2026 17:50:47 -0500 Subject: [PATCH] feat(system-builder): the WORDS page (4b1b) components/WordsPage.tsx, from the approved mockup builder-words-features (2026-10-06): the app's terms in their groups, one / many / short boxes with the app's word as placeholder (blank = the app's, nothing stored), READS AS, RESET per term, and terms whose part is off greyed with where to turn it on. Every change goes through the builder's edit (autosaved). BuilderScreen shows it on WORDS. --- CHANGELOG.md | 6 ++ frontend/src/components/BuilderScreen.tsx | 4 +- frontend/src/components/WordsPage.tsx | 85 +++++++++++++++++++ .../__tests__/BuilderScreen.test.tsx | 11 ++- .../components/__tests__/WordsPage.test.tsx | 73 ++++++++++++++++ 5 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/WordsPage.tsx create mode 100644 frontend/src/components/__tests__/WordsPage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd2eda3..a21a4bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,6 +75,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Under the hood +- **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 + players will see it, and RESET puts a term back. A term whose part is off in FEATURES is greyed. + Every change autosaves like the rest of the builder. + - **What the builder's WORDS and FEATURES pages will change.** A system's own words for the app's terms; which parts of the app it uses; its names for building types and shop catalogues, and the currency each catalogue sells in; its currencies, counted in whole numbers, decimals or diff --git a/frontend/src/components/BuilderScreen.tsx b/frontend/src/components/BuilderScreen.tsx index 062bac5a..460d249f 100644 --- a/frontend/src/components/BuilderScreen.tsx +++ b/frontend/src/components/BuilderScreen.tsx @@ -7,6 +7,7 @@ import { import { SYSTEMS_CHANGED_EVENT } from '../sheets/systemsLibrary'; import { MySystemsPage } from './MySystemsPage'; import { SetupPage } from './SetupPage'; +import { WordsPage } from './WordsPage'; // 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 @@ -316,7 +317,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o {systemId && system && definition && page === 'setup' && ( setStatus({ text, bad })} /> )} - {system && !['problems', 'systems', 'setup'].includes(page) && ( + {system && definition && page === 'words' && } + {system && !['problems', 'systems', 'setup', 'words'].includes(page) && (

{current.label}

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

diff --git a/frontend/src/components/WordsPage.tsx b/frontend/src/components/WordsPage.tsx new file mode 100644 index 00000000..c4225ee0 --- /dev/null +++ b/frontend/src/components/WordsPage.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import type { Definition } from '../sheets/systemsApi'; +import type { WordForm } from '../sheets/words'; +import { TERM_GROUPS, PART_ROWS, WORD_LIMIT, wordOf, withWord, withoutWord, readsAs, termPartOff } from '../sheets/wordsFeatures'; + +// The builder's WORDS page (4b1b): what the game calls the app's terms, one table, each term's one, +// many and short forms with the app's own word as the placeholder (approved mockup +// docs/mockups/builder-words-features.html, 2026-10-06). A box left blank is the app's word and +// nothing is stored for it (the user, same day). A term whose part is off in FEATURES is greyed: it +// has nowhere to show. 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 FORMS: WordForm[] = ['singular', 'plural', 'short']; +const FORM_LABEL: Record = { singular: 'ONE', plural: 'MANY', short: 'SHORT' }; +const cell: React.CSSProperties = { padding: '4px 8px 4px 0', verticalAlign: 'middle', borderTop: '1px solid var(--dark-green)' }; + +export function WordsPage({ definition, edit }: Props) { + return ( +
+

+ Leave a box empty to keep the app's own word, shown in it. The last column shows each term as players will read it. +

+ + + + {['TERM', ...FORMS.map((f) => FORM_LABEL[f]), 'READS AS', ''].map((h, i) => ( + + ))} + + + {TERM_GROUPS.map((group) => ( + + + {group.terms.map((row) => { + const off = termPartOff(definition, row); + const changed = FORMS.some((f) => wordOf(definition, row.id, f)); + const part = off ? PART_ROWS.find((p) => p.id === row.part) : undefined; + const name = row.app.singular!; + return ( + + + {FORMS.map((form) => ( + + ))} + + + + ); + })} + + ))} +
{h}
{group.label}
+ {name} + {part ? `${part.label} is off in FEATURES` : row.what} + + {row.app[form] === undefined + ? · + : ( + edit(withWord(definition, row.id, form, e.target.value))} + style={{ + width: '100%', boxSizing: 'border-box', background: 'var(--black)', color: 'var(--green)', fontFamily: 'monospace', fontSize: 12, + padding: '5px 7px', border: `1px solid ${wordOf(definition, row.id, form) ? 'var(--cyan)' : 'var(--green)'}`, + }} + /> + )} + {readsAs(definition, row)} + +
+
+ ); +} diff --git a/frontend/src/components/__tests__/BuilderScreen.test.tsx b/frontend/src/components/__tests__/BuilderScreen.test.tsx index ecaaabbe..66d6d0f7 100644 --- a/frontend/src/components/__tests__/BuilderScreen.test.tsx +++ b/frontend/src/components/__tests__/BuilderScreen.test.tsx @@ -94,11 +94,20 @@ describe('the builder', () => { }); it('shows what a page not built yet will hold', async () => { - open({ startPage: 'words' }); + open({ startPage: 'rules' }); await ready(); expect(screen.getByText(/This page arrives in a coming update\./)).toBeTruthy(); }); + it('WORDS edits the system\'s terms, saved like any other change', async () => { + open({ startPage: 'words' }); + await ready(); + await userEvent.type(await screen.findByLabelText('HP one'), 'wound'); + expect(screen.getByTestId('save-status').textContent).toBe('DRAFT · UNSAVED CHANGES'); + await userEvent.click(within(sidebar()).getByLabelText('SAVE')); + await waitFor(() => expect(drafts.at(-1)).toEqual({ format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND' } } })); + }); + it('lists every page, each saying what it is for, and opens the one picked', async () => { open({ startPage: 'problems' }); await ready(); diff --git a/frontend/src/components/__tests__/WordsPage.test.tsx b/frontend/src/components/__tests__/WordsPage.test.tsx new file mode 100644 index 00000000..acd91fec --- /dev/null +++ b/frontend/src/components/__tests__/WordsPage.test.tsx @@ -0,0 +1,73 @@ +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 { WordsPage } from '../WordsPage'; +import type { Definition } from '../../sheets/systemsApi'; + +/** + * The builder's WORDS page (4b1b). Approved mockup builder-words-features (2026-10-06): one table of + * the app's terms; a blank box is the app's word and nothing is stored; a term whose part is off is + * greyed. + */ + +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 row = (term: string) => screen.getByTestId(`term-${term}`); + +describe('WORDS', () => { + it('lists every term in its group, the app\'s word in each box', () => { + open(); + const groups = screen.getAllByRole('columnheader').filter((h) => h.getAttribute('scope') === 'colgroup').map((h) => h.textContent); + expect(groups).toEqual(['CHARACTERS', 'MONEY', 'PLAY', 'VEHICLES']); + expect(screen.getAllByTestId(/^term-/)).toHaveLength(13); + const hp = row('hp'); + expect((within(hp).getByLabelText('HP one') as HTMLInputElement).placeholder).toBe('HP'); + expect((within(hp).getByLabelText('HP one') as HTMLInputElement).value).toBe(''); + expect(hp.textContent).toContain('1 HP · 3 HP · HP'); + // A term with no short form has no box for one. + expect(within(row('round')).queryByLabelText('ROUND short')).toBeNull(); + }); + + it('renames a term as typed, in capitals, and reads it back', async () => { + const { last } = open(); + await userEvent.type(screen.getByLabelText('HP one'), 'wound'); + await userEvent.type(screen.getByLabelText('HP many'), 'wounds'); + expect(last().words).toEqual({ hp: { singular: 'WOUND', plural: 'WOUNDS' } }); + expect((screen.getByLabelText('HP one') as HTMLInputElement).value).toBe('WOUND'); + expect(row('hp').textContent).toContain('1 WOUND · 3 WOUNDS · HP'); + }); + + it('clearing a box goes back to the app\'s word, storing nothing', async () => { + const { last } = open({ format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND' } } }); + await userEvent.clear(screen.getByLabelText('HP one')); + expect(last()).toEqual({ format: 1, name: 'Hearth' }); + }); + + it('RESET puts every form of one term back, and is off for a term left alone', async () => { + const { last } = open({ format: 1, name: 'Hearth', words: { hp: { singular: 'WOUND', short: 'WND' }, xp: { short: 'EXP' } } }); + expect((screen.getByLabelText('Back to the app\'s words for LEVEL') as HTMLButtonElement).disabled).toBe(true); + await userEvent.click(screen.getByLabelText('Back to the app\'s words for HP')); + expect(last().words).toEqual({ xp: { short: 'EXP' } }); + }); + + it('greys a term whose part is off, saying where to turn it on', () => { + open({ format: 1, name: 'Hearth', parts: { vehicles: { on: false } }, words: { vehicle: { singular: 'MOUNT' } } }); + const vehicle = row('vehicle'); + expect(vehicle.textContent).toContain('VEHICLES is off in FEATURES'); + expect((within(vehicle).getByLabelText('VEHICLE one') as HTMLInputElement).disabled).toBe(true); + expect((within(vehicle).getByLabelText('Back to the app\'s words for VEHICLE') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByLabelText('HP one') as HTMLInputElement).disabled).toBe(false); + expect(row('hp').textContent).toContain('Their health'); + }); +});