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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ contract.
- The server loads and verifies the 79,370-word production dictionary before
listening, then uses cryptographic randomness for bounded board generation.
- A single 250 ms server lifecycle sweep ends due rounds at their exact
deadline, keeps final results for one server-owned 15-second window, then
deadline, keeps final results for one server-owned 30-second window, then
resets the room to `LOBBY`. Disconnects, reconnects, and controller
transfers do not pause or extend it.
- Browser countdowns use `serverTime` plus a monotonic elapsed clock; only the
Expand All @@ -67,8 +67,8 @@ contract.
beside the complete official board, with a compact timer and accepted-word
counts; it has no QR, word identities, or provisional scores. In
`ROUND_ENDED`, compact dark result cards use authoritative podium levels and
the footer remains. Phones show only their personal score summary without
administration or detailed opponent results.
the footer remains. Phones keep the finished board frozen above their
personal score summary without administration or detailed opponent results.
- Phone puzzle bubbles use semantic labels without a visible puzzle heading.
The compact Tap/Trace control stays centred in the phone header throughout
lobby and active phases; phones do not show provisional scores or accepted-word
Expand Down Expand Up @@ -108,7 +108,7 @@ contract.

Each round stands alone. Words is designed for casual drop-in play rather than
a committed match or campaign. The server destroys detailed results and private
submissions after its 15-second results window, retaining only bounded room
submissions after its 30-second results window, retaining only bounded room
highlights. Players can join, play a round, see that round's result, continue,
or leave. Starting the next round does not restore a previous result.

Expand Down
24 changes: 19 additions & 5 deletions apps/client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2900,7 +2900,7 @@ describe('Stage 4B display and player room routes', () => {
expect(screen.queryByLabelText('Room joining QR code')).toBeNull();
});

it('replaces the completed phone board with an authoritative summary', async () => {
it('keeps the completed phone board above an authoritative summary', async () => {
const activeRoom = createRoundRoom();
const endedRoom: RoomState = {
...activeRoom,
Expand Down Expand Up @@ -2947,12 +2947,19 @@ describe('Stage 4B display and player room routes', () => {
);

expect(
await screen.findByRole('region', { name: 'Round summary' }),
await screen.findByRole('region', { name: 'Previous round puzzle' }),
).toBeInTheDocument();
expect(
screen.getByRole('grid', {
name: '4 by 4 previous round letter grid',
}),
).toBeVisible();
expect(
screen.getByRole('region', { name: 'Look at the TV!' }),
).toBeVisible();
expect(screen.getByText('Look at the TV!')).toBeInTheDocument();
expect(screen.getByText('Your Score')).toBeInTheDocument();
expect(screen.getByText('No scoring winner')).toBeInTheDocument();
expect(screen.queryByRole('grid')).toBeNull();
expect(screen.queryByRole('timer')).toBeNull();
expect(
screen.getByRole('group', { name: 'Word entry mode' }),
Expand Down Expand Up @@ -3018,7 +3025,10 @@ describe('Stage 4B display and player room routes', () => {
);

expect(
await screen.findByRole('region', { name: 'Round summary' }),
await screen.findByRole('region', { name: 'Previous round puzzle' }),
).toBeVisible();
expect(
screen.getByRole('region', { name: 'Look at the TV!' }),
).toBeVisible();
expect(
screen.queryByText('Round complete — results are on the TV.'),
Expand All @@ -3029,7 +3039,11 @@ describe('Stage 4B display and player room routes', () => {
expect(screen.queryByText('<Bright Fox> (You)')).toBeNull();
expect(screen.queryByRole('button', { name: 'Start Round' })).toBeNull();
expect(screen.getByText('Look at the TV!')).toBeVisible();
expect(screen.queryByRole('grid')).toBeNull();
expect(
screen.getByRole('grid', {
name: '4 by 4 previous round letter grid',
}),
).toBeVisible();
expect(screen.queryByRole('timer')).toBeNull();
expect(screen.queryByRole('button', { name: 'Make Game Host' })).toBeNull();
expect(screen.queryByRole('region', { name: 'Game settings' })).toBeNull();
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/components/PhoneRoundSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export function PhoneRoundSummary({

return (
<section
className="phone-round-summary"
className="panel phone-round-summary"
aria-labelledby="round-summary-title"
>
<p className="eyebrow">ROUND OVER</p>
Expand Down
86 changes: 66 additions & 20 deletions apps/client/src/components/RoomLobby.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,7 @@ describe('RoomLobby word entry', () => {
).toBeVisible();
});

it('keeps the selected mode through active, ended, and next lobby phases', async () => {
it('keeps the selected mode through ended results and the next lobby', async () => {
const user = userEvent.setup();
const view = renderLobby();
const mode = screen.getByRole('group', { name: 'Word entry mode' });
Expand Down Expand Up @@ -402,7 +402,7 @@ describe('RoomLobby word entry', () => {
expect(onSubmitWord).not.toHaveBeenCalled();
});

it('shows one primary Return to Lobby action below the phone results only for the connected controller', async () => {
it('shows one primary Return to Lobby action between the board and summary only for the connected controller', async () => {
const user = userEvent.setup();
const onReturnToLobby = vi.fn(async () => null);
render(
Expand All @@ -416,16 +416,13 @@ describe('RoomLobby word entry', () => {

const button = screen.getByRole('button', { name: 'Return to Lobby' });
expect(button).toHaveClass('button', 'button--primary');
expect(
screen
.getByRole('region', { name: 'Round summary' })
.closest('.board-panel'),
).toHaveClass('panel', 'board-panel');
expect(
screen
.getByRole('region', { name: 'Round summary' })
.closest('.board-panel')?.nextElementSibling,
).toContainElement(button);
const board = screen.getByRole('region', {
name: 'Previous round puzzle',
});
const summary = screen.getByRole('region', { name: 'Look at the TV!' });
expect(board).toHaveClass('panel', 'board-panel');
expect(board.nextElementSibling).toContainElement(button);
expect(button.closest('.round-action')?.nextElementSibling).toBe(summary);
await user.click(button);
expect(onReturnToLobby).toHaveBeenCalledTimes(1);
});
Expand Down Expand Up @@ -725,9 +722,18 @@ describe('RoomLobby word entry', () => {
renderLobby(undefined, {
room: createRoom({ phase: 'ROUND_ENDED' }),
});
expect(screen.getByRole('region', { name: 'Round summary' })).toBeVisible();
expect(
screen.getByRole('region', { name: 'Previous round puzzle' }),
).toBeVisible();
expect(
screen.getByRole('region', { name: 'Look at the TV!' }),
).toBeVisible();
expect(screen.getByText('Look at the TV!')).toBeVisible();
expect(screen.queryByRole('grid')).toBeNull();
expect(
screen.getByRole('grid', {
name: '4 by 4 previous round letter grid',
}),
).toBeVisible();
expect(
screen.getByRole('group', { name: 'Word entry mode' }),
).toBeVisible();
Expand Down Expand Up @@ -802,7 +808,7 @@ describe('RoomLobby word entry', () => {
).toBeNull();
expect(
screen.getByRole('region', {
name: phase === 'ROUND_ENDED' ? 'Round summary' : 'Puzzle',
name: phase === 'ROUND_ENDED' ? 'Previous round puzzle' : 'Puzzle',
}),
).toHaveClass('panel', 'board-panel');
expect(
Expand All @@ -824,7 +830,7 @@ describe('RoomLobby word entry', () => {
expect(screen.queryByRole('region', { name: 'Game settings' })).toBeNull();
});

it('replaces an ended phone puzzle with a round summary and restores the lobby cleanly', () => {
it('shows the exact frozen ended board above the phone summary and restores the lobby cleanly', () => {
const ordinaryPlayerId = '00000000-0000-4000-8000-000000000002';
const room = createRoom({
phase: 'ROUND_ENDED',
Expand All @@ -845,9 +851,43 @@ describe('RoomLobby word entry', () => {
currentPlayerId: ordinaryPlayerId,
});

expect(screen.getByRole('region', { name: 'Round summary' })).toBeVisible();
const board = screen.getByRole('region', {
name: 'Previous round puzzle',
});
const grid = screen.getByRole('grid', {
name: '4 by 4 previous round letter grid',
});
const summary = screen.getByRole('region', { name: 'Look at the TV!' });
expect(board).toContainElement(grid);
expect(
within(grid)
.getAllByRole('gridcell')
.map((cell) => cell.textContent),
).toEqual([
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'QU',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
]);
expect(within(grid).queryByRole('button')).toBeNull();
expect(board.nextElementSibling).toBe(summary);
expect(
screen.queryByRole('button', { name: 'Return to Lobby' }),
).toBeNull();
expect(summary).toHaveClass('panel', 'phone-round-summary');
expect(screen.getByText('Look at the TV!')).toBeVisible();
expect(screen.queryByRole('grid')).toBeNull();
expect(screen.queryByRole('timer')).toBeNull();
expect(screen.queryByRole('button', { name: 'Submit' })).toBeNull();
expect(
Expand Down Expand Up @@ -1354,8 +1394,14 @@ describe('RoomLobby word entry', () => {
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole('grid')).toBeNull();
expect(screen.getByRole('region', { name: 'Round summary' })).toBeVisible();
expect(
screen
.getByRole('grid', { name: '4 by 4 previous round letter grid' })
.querySelectorAll('.letter-tile--accepted'),
).toHaveLength(0);
expect(
screen.getByRole('region', { name: 'Look at the TV!' }),
).toBeVisible();
firstView.unmount();

const response = deferred<SubmitWordResponse>();
Expand Down
49 changes: 32 additions & 17 deletions apps/client/src/components/RoomLobby.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -536,15 +536,24 @@ export function RoomLobby({
className="panel board-panel"
aria-labelledby={isDisplay ? 'board-title' : undefined}
aria-label={
isDisplay ? undefined : roundIsEnded ? 'Round summary' : 'Puzzle'
isDisplay
? undefined
: roundIsEnded
? 'Previous round puzzle'
: 'Puzzle'
}
data-round-id={room.round?.id}
data-round-deadline-at={room.round?.deadlineAt}
>
{roundIsEnded ? (
<PhoneRoundSummary
results={room.round?.results ?? null}
currentPlayerId={currentPlayerId}
{roundIsEnded && room.round ? (
<LetterGrid
letters={[...room.round.board.tiles]}
size={room.round.board.size}
label={`${room.round.board.size} by ${room.round.board.size} previous round letter grid`}
selectedIndices={[]}
acceptedIndices={[]}
interactive={false}
disabled
/>
) : (
<>
Expand Down Expand Up @@ -623,6 +632,24 @@ export function RoomLobby({
</>
)}
</section>
{isConnectedController && roundIsEnded && (
<div className="round-action">
<button
className="button button--primary"
type="button"
disabled={actionPending}
onClick={() => void runReturnToLobby()}
>
{actionPending ? 'Working…' : 'Return to Lobby'}
</button>
</div>
)}
{roundIsEnded && (
<PhoneRoundSummary
results={room.round?.results ?? null}
currentPlayerId={currentPlayerId}
/>
)}
{isConnectedController && room.phase === 'LOBBY' && (
<div className="round-action">
{
Expand All @@ -637,18 +664,6 @@ export function RoomLobby({
}
</div>
)}
{isConnectedController && roundIsEnded && (
<div className="round-action">
<button
className="button button--primary"
type="button"
disabled={actionPending}
onClick={() => void runReturnToLobby()}
>
{actionPending ? 'Working…' : 'Return to Lobby'}
</button>
</div>
)}
{showControllerAdministration && (
<GameSettings
settings={room.settings}
Expand Down
5 changes: 4 additions & 1 deletion apps/client/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ button:disabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
justify-content: flex-start;
gap: 1rem;
padding-top: clamp(0.75rem, 1.5vh, 1.25rem);
padding-bottom: 1rem;
padding-inline: 2vw;
}
.display-results > h1,
Expand Down Expand Up @@ -2121,6 +2123,7 @@ button:disabled {

@media (min-width: 62rem) {
.room-dashboard--phone .board-panel,
.room-dashboard--phone .phone-round-summary,
.room-dashboard--phone .round-action,
.room-dashboard--phone .settings-panel,
.room-dashboard--phone .controller-panel {
Expand Down
8 changes: 7 additions & 1 deletion apps/client/src/styles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ describe('tile typography', () => {
/\.room-dashboard--phone \.letter-grid\s*\{[^}]*width: min\(100%, 26rem\);/s,
);
expect(styles).toMatch(
/@media \(min-width: 62rem\)[\s\S]*?\.room-dashboard--phone \.board-panel,\s*\.room-dashboard--phone \.round-action,\s*\.room-dashboard--phone \.settings-panel,\s*\.room-dashboard--phone \.controller-panel\s*\{[^}]*width: min\(100%, 27\.5rem\);[^}]*justify-self: center;/s,
/@media \(min-width: 62rem\)[\s\S]*?\.room-dashboard--phone \.board-panel,\s*\.room-dashboard--phone \.phone-round-summary,\s*\.room-dashboard--phone \.round-action,\s*\.room-dashboard--phone \.settings-panel,\s*\.room-dashboard--phone \.controller-panel\s*\{[^}]*width: min\(100%, 27\.5rem\);[^}]*justify-self: center;/s,
);
const basePlayerStyles = styles.slice(
styles.indexOf('.room-dashboard--phone {'),
Expand Down Expand Up @@ -167,6 +167,12 @@ describe('tile typography', () => {
expect(styles).toMatch(
/\.display-player-list__count\s*\{[^}]*justify-self: end;[^}]*min-width: 3ch;[^}]*color: var\(--mint-strong\);[^}]*font-size: clamp\(1rem, 1\.5vw, 1\.25rem\);[^}]*font-variant-numeric: tabular-nums;[^}]*font-weight: 900;[^}]*text-align: right;/s,
);
expect(styles).toMatch(
/\.display-results\s*\{[^}]*justify-content: flex-start;[^}]*padding-top: clamp\(0\.75rem, 1\.5vh, 1\.25rem\);[^}]*padding-bottom: 1rem;/s,
);
expect(styles).not.toMatch(
/\.display-results\s*\{[^}]*justify-content: center;/s,
);
expect(styles).toMatch(
/\.display-results__cards\s*\{[^}]*width: fit-content;[^}]*max-width: 96vw;[^}]*grid-template-columns: repeat\(2, minmax\(16rem, max-content\)\);/s,
);
Expand Down
3 changes: 2 additions & 1 deletion apps/server/tests/room-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2533,8 +2533,9 @@ describe('RoomStore authoritative settings and rounds', () => {
const ended = store.getRoomState('ABC234');
expect(ended?.phase).toBe('ROUND_ENDED');
const version = ended?.stateVersion;
now += productConfig.resultsDisplaySeconds * 1_000 - 1;
now += 29_999;
expect(store.advanceDueRounds()).toEqual([]);
expect(store.getRoomState('ABC234')?.phase).toBe('ROUND_ENDED');
expect(store.getRoomState('ABC234')?.round).not.toBeNull();
now += 1;
expect(store.advanceDueRounds()).toEqual(['ABC234']);
Expand Down
7 changes: 6 additions & 1 deletion apps/server/tests/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1759,7 +1759,12 @@ describe('Words Stage 4B server', () => {
display,
(room) => room.phase === 'LOBBY' && room.round === null,
);
now += 20_000;
now += 29_999;
lifecycleSweep?.();
await new Promise<void>((resolve) => setImmediate(resolve));
expect(observedPhases).toHaveLength(broadcastCountBeforeReset);

now += 1;
lifecycleSweep?.();
const lobby = await lobbyState;
await new Promise<void>((resolve) => setImmediate(resolve));
Expand Down
7 changes: 7 additions & 0 deletions docs/DEVELOPMENT_LOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Development log

## 2026-08-14 — Post-round player board review (draft)

- Player phones now retain the exact authoritative finished board as a frozen,
noninteractive grid above the existing round summary during `ROUND_ENDED`.
- The server-authoritative results window is extended to 30 seconds; the
connected controller can still return the room to the lobby early.

## 2026-08-14 — Active-round display join QR (draft)

- The shared TV now keeps an approximately 7rem direct-join QR in its own
Expand Down
Loading