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

- **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
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 @@ -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
Expand Down Expand Up @@ -316,7 +317,8 @@ export function BuilderScreen({ token, systemId, startPage = 'setup', running, o
{systemId && system && definition && page === 'setup' && (
<SetupPage api={api} systemId={systemId} definition={definition} edit={edit} say={(text, bad) => setStatus({ text, bad })} />
)}
{system && !['problems', 'systems', 'setup'].includes(page) && (
{system && definition && page === 'words' && <WordsPage definition={definition} edit={edit} />}
{system && !['problems', 'systems', 'setup', 'words'].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
85 changes: 85 additions & 0 deletions frontend/src/components/WordsPage.tsx
Original file line number Diff line number Diff line change
@@ -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<WordForm, string> = { 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 (
<div style={{ maxWidth: 940, display: 'flex', flexDirection: 'column', gap: 12 }}>
<p style={{ margin: 0, fontSize: 12, lineHeight: 1.45, opacity: 0.85, maxWidth: '72ch' }}>
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.
</p>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr>
{['TERM', ...FORMS.map((f) => FORM_LABEL[f]), 'READS AS', ''].map((h, i) => (
<th key={i} scope="col" style={{ textAlign: 'left', fontSize: 10, letterSpacing: 2, opacity: 0.7, fontWeight: 'normal', padding: '0 8px 6px 0' }}>{h}</th>
))}
</tr>
</thead>
{TERM_GROUPS.map((group) => (
<tbody key={group.label}>
<tr><th colSpan={6} scope="colgroup" style={{ textAlign: 'left', paddingTop: 14, fontSize: 10, letterSpacing: 2, color: 'var(--green)', opacity: 0.8, fontWeight: 'normal' }}>{group.label}</th></tr>
{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 (
<tr key={row.id} data-testid={`term-${row.id}`} style={{ opacity: off ? 0.45 : 1 }}>
<td style={{ ...cell, width: 160 }}>
<b style={{ color: 'var(--green)', letterSpacing: 1 }}>{name}</b>
<span style={{ display: 'block', fontSize: 11, opacity: 0.65 }}>{part ? `${part.label} is off in FEATURES` : row.what}</span>
</td>
{FORMS.map((form) => (
<td key={form} style={{ ...cell, ...(form === 'short' ? { width: 90 } : {}) }}>
{row.app[form] === undefined
? <span aria-hidden style={{ opacity: 0.4 }}>·</span>
: (
<input
type="text"
maxLength={WORD_LIMIT}
aria-label={`${name} ${FORM_LABEL[form].toLowerCase()}`}
value={wordOf(definition, row.id, form)}
placeholder={row.app[form]}
disabled={off}
onChange={(e) => 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)'}`,
}}
/>
)}
</td>
))}
<td style={{ ...cell, fontSize: 11, opacity: 0.8, whiteSpace: 'nowrap' }}>{readsAs(definition, row)}</td>
<td style={cell}>
<button type="button" className="utility-btn" style={{ fontFamily: 'monospace', fontSize: 11, letterSpacing: 1, padding: '4px 9px' }}
disabled={!changed || off} aria-label={`Back to the app's words for ${name}`}
onClick={() => edit(withoutWord(definition, row.id))}>RESET</button>
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
);
}
11 changes: 10 additions & 1 deletion frontend/src/components/__tests__/BuilderScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/components/__tests__/WordsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <WordsPage definition={def} edit={(next) => { edits.push(next); setDef(next); }} />;
};
render(<Harness />);
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');
});
});
Loading