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'); + }); +});